View Javadoc
1   /*
2    * #%L
3    * wcm.io
4    * %%
5    * Copyright (C) 2015 wcm.io
6    * %%
7    * Licensed under the Apache License, Version 2.0 (the "License");
8    * you may not use this file except in compliance with the License.
9    * You may obtain a copy of the License at
10   *
11   *      http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the License is distributed on an "AS IS" BASIS,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the License for the specific language governing permissions and
17   * limitations under the License.
18   * #L%
19   */
20  package io.wcm.caravan.maven.plugins.haldocs;
21  
22  import io.wcm.caravan.hal.docs.annotations.LinkRelationDoc;
23  import io.wcm.caravan.hal.docs.annotations.ServiceDoc;
24  import io.wcm.caravan.hal.docs.impl.JsonSchemaBundleTracker;
25  import io.wcm.caravan.hal.docs.impl.model.LinkRelation;
26  import io.wcm.caravan.hal.docs.impl.model.Service;
27  import io.wcm.caravan.hal.docs.impl.reader.ServiceJson;
28  import io.wcm.caravan.hal.docs.impl.reader.ServiceModelReader;
29  import io.wcm.caravan.jaxrs.publisher.ApplicationPath;
30  
31  import java.io.File;
32  import java.io.FileOutputStream;
33  import java.io.IOException;
34  import java.io.InputStream;
35  import java.io.OutputStream;
36  import java.lang.annotation.Annotation;
37  import java.lang.reflect.Field;
38  import java.net.URLClassLoader;
39  import java.util.Arrays;
40  import java.util.jar.Manifest;
41  import java.util.zip.ZipEntry;
42  import java.util.zip.ZipFile;
43  
44  import org.apache.commons.lang3.StringUtils;
45  import org.apache.maven.model.Plugin;
46  import org.apache.maven.plugin.MojoExecutionException;
47  import org.apache.maven.plugin.MojoFailureException;
48  import org.apache.maven.plugins.annotations.LifecyclePhase;
49  import org.apache.maven.plugins.annotations.Mojo;
50  import org.apache.maven.plugins.annotations.Parameter;
51  import org.apache.maven.plugins.annotations.ResolutionScope;
52  import org.codehaus.plexus.util.xml.Xpp3Dom;
53  import org.jsoup.Jsoup;
54  
55  import com.thoughtworks.qdox.JavaProjectBuilder;
56  import com.thoughtworks.qdox.model.JavaAnnotatedElement;
57  import com.thoughtworks.qdox.model.JavaClass;
58  import com.thoughtworks.qdox.model.JavaField;
59  
60  /**
61   * Generates HAL documentation JSON files for service.
62   */
63  @Mojo(name = "generate-hal-docs-json", defaultPhase = LifecyclePhase.PROCESS_CLASSES,
64  requiresProject = true, threadSafe = true, requiresDependencyResolution = ResolutionScope.COMPILE)
65  public class GenerateHalDocsJsonMojo extends AbstractBaseMojo {
66  
67    /**
68     * Paths containing the java source files.
69     */
70    @Parameter(defaultValue = "${basedir}/src/main/java")
71    private String source;
72  
73    /**
74     * Service ID. If not given it is tried to detect it automatically from <code>maven-bundle-plugin</code>
75     * configuration, instruction <code>Caravan-JaxRs-ApplicationPath</code>. If no Servce ID is found no documentation
76     * files are generated.
77     */
78    @Parameter
79    private String serviceId;
80  
81    /**
82     * Relative target path for the generated resources.
83     */
84    @Parameter(defaultValue = ServiceModelReader.DOCS_CLASSPATH_PREFIX)
85    private String target;
86  
87    @Parameter(defaultValue = "generated-hal-docs-resources")
88    private String generatedResourcesDirectory;
89  
90    private static final String MAVEN_BUNDLE_PLUGIN_ID = Plugin.constructKey("org.apache.felix", "maven-bundle-plugin");
91  
92    @Override
93    public void execute() throws MojoExecutionException, MojoFailureException {
94  
95      // if nor service id given try to detect from maven-bundle-plugin config
96      if (StringUtils.isEmpty(serviceId)) {
97        serviceId = getServiceIdFromMavenBundlePlugin();
98      }
99  
100     // if nor service id detected skip further processing
101     if (StringUtils.isEmpty(serviceId)) {
102       getLog().info("No service ID detected, skip HAL documentation file generation.");
103       return;
104     }
105     else {
106       getLog().info("Generate HAL documentation files for service: " + serviceId);
107     }
108 
109     try {
110       // get classloader for all "compile" dependencies
111       ClassLoader compileClassLoader = URLClassLoader.newInstance(getCompileClasspathElementURLs(), getClass().getClassLoader());
112 
113       // generate HTML documentation for service
114       Service service = getServiceInfos(compileClassLoader);
115 
116       // generate JSON for service info
117       File jsonFile = new File(getGeneratedResourcesDirectory(), ServiceModelReader.SERVICE_DOC_FILE);
118       getLog().info("Write " + jsonFile.getCanonicalPath());
119       try (OutputStream os = new FileOutputStream(jsonFile)) {
120         new ServiceJson().write(service, os);
121       }
122 
123       // add as resources to classpath
124       addResource(getGeneratedResourcesDirectory().getPath(), target);
125     }
126     catch (Throwable ex) {
127       throw new MojoExecutionException("Generating HAL documentation failed: " + ex.getMessage(), ex);
128     }
129   }
130 
131   /**
132    * Tries to detect the service id automatically from maven bundle plugin definition in same POM.
133    * @return Service id or null
134    */
135   private String getServiceIdFromMavenBundlePlugin() {
136     Plugin bundlePlugin = project.getBuildPlugins().stream()
137         .filter(plugin -> StringUtils.equals(plugin.getKey(), MAVEN_BUNDLE_PLUGIN_ID))
138         .findFirst().orElse(null);
139     if (bundlePlugin != null) {
140       Xpp3Dom configuration = (Xpp3Dom)bundlePlugin.getConfiguration();
141       if (configuration != null) {
142         Xpp3Dom instructions = configuration.getChild("instructions");
143         if (instructions != null) {
144           Xpp3Dom applicationPath = instructions.getChild(ApplicationPath.HEADER_APPLICATON_PATH);
145           if (applicationPath != null) {
146             return applicationPath.getValue();
147           }
148         }
149       }
150     }
151     return null;
152   }
153 
154   /**
155    * Get service infos from current maven project.
156    * @return Service
157    */
158   private Service getServiceInfos(ClassLoader compileClassLoader) {
159     Service service = new Service();
160 
161     // get some service properties from pom
162     service.setServiceId(serviceId);
163     service.setName(project.getName());
164 
165     // find @ServiceDoc annotated class in source folder
166     JavaProjectBuilder builder = new JavaProjectBuilder();
167     builder.addSourceTree(new File(source));
168     JavaClass serviceInfo = builder.getSources().stream()
169         .flatMap(javaSource -> javaSource.getClasses().stream())
170         .filter(javaClass -> hasAnnotation(javaClass, ServiceDoc.class))
171         .findFirst().orElse(null);
172 
173     // populate further service information from @ServiceDoc class and @LinkRelationDoc fields
174     if (serviceInfo != null) {
175       service.setDescriptionMarkup(serviceInfo.getComment());
176 
177       serviceInfo.getFields().stream()
178       .filter(field -> hasAnnotation(field, LinkRelationDoc.class))
179       .map(field -> toLinkRelation(serviceInfo, field, compileClassLoader))
180       .forEach(service::addLinkRelation);
181     }
182 
183     // resolve link relations
184     service.resolve();
185 
186     return service;
187   }
188 
189   /**
190    * Builds a {@link LinkRelation} from a field definition with {@link LinkRelationDoc} annotation.
191    * @param javaClazz QDox class
192    * @param javaField QDox field
193    * @param compileClassLoader Classloader for compile dependencies
194    * @return Link relation
195    */
196   private LinkRelation toLinkRelation(JavaClass javaClazz, JavaField javaField, ClassLoader compileClassLoader) {
197     LinkRelation rel = new LinkRelation();
198 
199     rel.setShortDescription(buildShortDescription(javaField.getComment()));
200     rel.setDescriptionMarkup(javaField.getComment());
201 
202     rel.setRel(getStaticFieldValue(javaClazz, javaField, compileClassLoader, String.class));
203 
204     LinkRelationDoc relDoc = getAnnotation(javaClazz, javaField, compileClassLoader, LinkRelationDoc.class);
205     rel.setJsonSchemaRef(buildJsonSchemaRefModel(relDoc.jsonSchema(), relDoc.model()));
206 
207     Arrays.stream(relDoc.embedded()).forEach(embedded -> rel.addResourceRef(embedded.value(), embedded.description(),
208         buildJsonSchemaRefModel(embedded.jsonSchema(), embedded.model())));
209 
210     Arrays.stream(relDoc.links()).forEach(link -> rel.addLinkRelationRef(link.value(), link.description()));
211 
212     return rel;
213   }
214 
215   /**
216    * Get short description anlogous to javadoc method tile: Strip out all HTML tags and use only
217    * the first sentence from the description.
218    * @param descriptionMarkup Description markup.
219    * @return Title or null if none defined
220    */
221   private static String buildShortDescription(String descriptionMarkup) {
222     if (StringUtils.isBlank(descriptionMarkup)) {
223       return null;
224     }
225     String text = Jsoup.parse(descriptionMarkup).text();
226     if (StringUtils.isBlank(text)) {
227       return null;
228     }
229     if (StringUtils.contains(text, ".")) {
230       return StringUtils.substringBefore(text, ".") + ".";
231     }
232     else {
233       return StringUtils.trim(text);
234     }
235   }
236 
237   /**
238    * If a json schema ref is given, this is returned unchanged.
239    * Otherwise: Scans all project's dependencies for one with manifest with Caravan-HalDocs-DomainPath bundle header.
240    * If the dependencies JAR files contains a matching JSON schema file generated by this plugin, build a
241    * URL to reference this schema.
242    * @param modelClass Model class
243    * @return JSON schema reference URL, or null if none found
244    */
245   private String buildJsonSchemaRefModel(String jsonSchemaRef, Class<?> modelClass) {
246     if (StringUtils.isNotEmpty(jsonSchemaRef)) {
247       return jsonSchemaRef;
248     }
249     if (modelClass != void.class) {
250       return project.getDependencyArtifacts().stream()
251           .filter(artifact -> StringUtils.equals("jar", artifact.getType()))
252           .map(artifact -> buildJsonSchemaRefForModel(modelClass, artifact.getFile()))
253           .filter(StringUtils::isNotEmpty)
254           .findFirst()
255           .orElse(null);
256     }
257     return null;
258   }
259 
260   /**
261    * Check if JAR file has a doc domain path and corresponding schema file and then builds a documenation path for it.
262    * @param modelClass Model calss
263    * @param artifactFile JAR artifact's file
264    * @return JSON schema path or null
265    */
266   private String buildJsonSchemaRefForModel(Class<?> modelClass, File artifactFile) {
267     try {
268       ZipFile zipFile = new ZipFile(artifactFile);
269       String halDocsDomainPath = getHalDocsDomainPath(zipFile);
270       if (halDocsDomainPath != null && hasJsonSchemaFile(zipFile, modelClass)) {
271         return JsonSchemaBundleTracker.SCHEMA_URI_PREFIX + halDocsDomainPath
272             + "/" + modelClass.getName() + ".json";
273       }
274       return null;
275     }
276     catch (IOException ex) {
277       throw new RuntimeException("Unable to read artifact file: " + artifactFile.getAbsolutePath() + "\n" + ex.getMessage(), ex);
278     }
279   }
280 
281   /**
282    * Gets bundle header/mainfest entry Caravan-HalDocs-DomainPath from given JAR file.
283    * @param jarFile JAR file
284    * @return Header value or null
285    * @throws IOException
286    */
287   private String getHalDocsDomainPath(ZipFile jarFile) throws IOException {
288     ZipEntry manifestEntry = jarFile.getEntry("META-INF/MANIFEST.MF");
289     if (manifestEntry != null) {
290       try (InputStream is = jarFile.getInputStream(manifestEntry)) {
291         Manifest manifest = new Manifest(is);
292         return manifest.getMainAttributes().getValue(JsonSchemaBundleTracker.HEADER_DOMAIN_PATH);
293       }
294     }
295     return null;
296   }
297 
298   /**
299    * Gets bundle header/mainfest entry Caravan-HalDocs-DomainPath from given JAR file.
300    * @param jarFile JAR file
301    * @return Header value or null
302    * @throws IOException
303    */
304   private boolean hasJsonSchemaFile(ZipFile jarFile, Class<?> modelClass) throws IOException {
305     String path = JsonSchemaBundleTracker.SCHEMA_CLASSPATH_PREFIX + "/" + modelClass.getName() + ".json";
306     return jarFile.getEntry(path) != null;
307   }
308 
309   /**
310    * Checks if the given element has an annotation set.
311    * @param clazz QDox class
312    * @param annotationClazz Annotation class
313    * @return true if annotation is present
314    */
315   private boolean hasAnnotation(JavaAnnotatedElement clazz, Class<? extends Annotation> annotationClazz) {
316     return clazz.getAnnotations().stream()
317         .filter(item -> item.getType().isA(annotationClazz.getName()))
318         .count() > 0;
319   }
320 
321   /**
322    * Get constant field value.
323    * @param javaClazz QDox class
324    * @param javaField QDox field
325    * @param compileClassLoader Classloader for compile dependencies
326    * @param fieldType Field type
327    * @return Value
328    */
329   @SuppressWarnings("unchecked")
330   private <T> T getStaticFieldValue(JavaClass javaClazz, JavaField javaField, ClassLoader compileClassLoader, Class<T> fieldType) {
331     try {
332       Class<?> clazz = compileClassLoader.loadClass(javaClazz.getFullyQualifiedName());
333       Field field = clazz.getField(javaField.getName());
334       return (T)field.get(fieldType);
335     }
336     catch (ClassNotFoundException | NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException ex) {
337       throw new RuntimeException("Unable to get contanst value of field '" + javaClazz.getName() + "#" + javaField.getName() + ":\n" + ex.getMessage(), ex);
338     }
339   }
340 
341   /**
342    * Get annotation for field.
343    * @param javaClazz QDox class
344    * @param javaField QDox field
345    * @param compileClassLoader Classloader for compile dependencies
346    * @param annotationType Annotation type
347    * @return Annotation of null if not present
348    */
349   private <T extends Annotation> T getAnnotation(JavaClass javaClazz, JavaField javaField, ClassLoader compileClassLoader, Class<T> annotationType) {
350     try {
351       Class<?> clazz = compileClassLoader.loadClass(javaClazz.getFullyQualifiedName());
352       Field field = clazz.getField(javaField.getName());
353       return field.getAnnotation(annotationType);
354     }
355     catch (ClassNotFoundException | NoSuchFieldException | SecurityException | IllegalArgumentException ex) {
356       throw new RuntimeException("Unable to get contanst value of field '" + javaClazz.getName() + "#" + javaField.getName() + ":\n" + ex.getMessage(), ex);
357     }
358   }
359 
360   @Override
361   protected String getGeneratedResourcesDirectoryPath() {
362     return generatedResourcesDirectory;
363   }
364 
365 }