/* * Copyright 2015 herd contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.finra.herd.swaggergen; import java.io.IOException; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.xml.bind.annotation.XmlType; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.models.Operation; import io.swagger.models.Path; import io.swagger.models.RefModel; import io.swagger.models.Response; import io.swagger.models.Swagger; import io.swagger.models.Tag; import io.swagger.models.parameters.BodyParameter; import io.swagger.models.parameters.PathParameter; import io.swagger.models.parameters.QueryParameter; import io.swagger.models.parameters.SerializableParameter; import io.swagger.models.properties.RefProperty; import org.apache.commons.lang3.BooleanUtils; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.logging.Log; import org.jboss.forge.roaster.Roaster; import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.Javadoc; import org.jboss.forge.roaster._shade.org.eclipse.jdt.core.dom.TagElement; import org.jboss.forge.roaster.model.JavaDocTag; import org.jboss.forge.roaster.model.source.AnnotationSource; import org.jboss.forge.roaster.model.source.JavaClassSource; import org.jboss.forge.roaster.model.source.JavaDocSource; import org.jboss.forge.roaster.model.source.MethodSource; import org.jboss.forge.roaster.model.source.ParameterSource; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.core.type.classreading.CachingMetadataReaderFactory; import org.springframework.core.type.classreading.MetadataReader; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.SystemPropertyUtils; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** * Finds and process REST controllers. */ public class RestControllerProcessor { // The log to use for logging purposes. @SuppressWarnings("PMD.ProperLogger") // Logger is passed into this method from Mojo base class. private Log log; // The Swagger metadata. private Swagger swagger; // The REST Java package. private String restJavaPackage; // The tag pattern to determine REST controller names. private Pattern tagPattern; // The map of Java class names to their respective source class information. private Map<String, JavaClassSource> sourceMap = new HashMap<>(); // The classes that we will create examples for. private Set<String> exampleClassNames = new HashSet<>(); // The model error class. private Class<?> modelErrorClass; // A set of operation Id's to keep track of so we don't create duplicates. private Set<String> operationIds = new HashSet<>(); /** * Instantiates a REST controller process which finds and processes REST controllers. * * @param log the log * @param swagger the Swagger metadata * @param restJavaPackage the REST Java package. * @param tagPatternTemplate the tag pattern template. * @param modelErrorClass the model error class. * * @throws MojoExecutionException if any problems were encountered. */ public RestControllerProcessor(Log log, Swagger swagger, String restJavaPackage, String tagPatternTemplate, Class<?> modelErrorClass) throws MojoExecutionException { this.log = log; this.swagger = swagger; this.restJavaPackage = restJavaPackage; this.modelErrorClass = modelErrorClass; // Create the tag pattern based on the parameter. tagPattern = Pattern.compile(tagPatternTemplate); findAndProcessRestControllers(); } /** * Finds all the REST controllers within the configured REST Java package and process the REST methods within each one. * * @throws MojoExecutionException if any errors were encountered. */ private void findAndProcessRestControllers() throws MojoExecutionException { try { log.debug("Finding and processing REST controllers."); // Loop through each resources and process each one. for (Resource resource : ResourceUtils .getResources(ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + restJavaPackage.replace('.', '/') + "/**/*.java")) { if (resource.isReadable()) { JavaClassSource javaClassSource = Roaster.parse(JavaClassSource.class, resource.getInputStream()); sourceMap.put(javaClassSource.getName(), javaClassSource); log.debug("Found Java source class \"" + javaClassSource.getName() + "\"."); } } // Loop through each controller resources and process each one. for (Resource resource : ResourceUtils.getResources(ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + ClassUtils.convertClassNameToResourcePath(SystemPropertyUtils.resolvePlaceholders(restJavaPackage)) + "/**/*.class")) { if (resource.isReadable()) { // Create a resource resolver to fetch resources. MetadataReader metadataReader = new CachingMetadataReaderFactory(new PathMatchingResourcePatternResolver()).getMetadataReader(resource); Class<?> clazz = Class.forName(metadataReader.getClassMetadata().getClassName()); processRestControllerClass(clazz); } } } catch (ClassNotFoundException | IOException e) { throw new MojoExecutionException("Error processing REST classes. Reason: " + e.getMessage(), e); } } /** * Processes a Spring MVC REST controller class that is annotated with RestController. Also collects any required model objects based on parameters and * return types of each endpoint into the specified model classes set. * * @param clazz the class to process * * @throws MojoExecutionException if any errors were encountered. */ private void processRestControllerClass(Class<?> clazz) throws MojoExecutionException { // Get the Java class source information. JavaClassSource javaClassSource = sourceMap.get(clazz.getSimpleName()); if (javaClassSource == null) { throw new MojoExecutionException("No source resource found for class \"" + clazz.getName() + "\"."); } Api api = clazz.getAnnotation(Api.class); boolean hidden = api != null && api.hidden(); if ((clazz.getAnnotation(RestController.class) != null) && (!hidden)) { log.debug("Processing RestController class \"" + clazz.getName() + "\"."); // Default the tag name to the simple class name. String tagName = clazz.getSimpleName(); // See if the "Api" annotation exists. if (api != null && api.tags().length > 0) { // The "Api" annotation was found so use it's configured tag. tagName = api.tags()[0]; } else { // No "Api" annotation so try to get the tag name from the class name. If not, we will stick with the default simple class name. Matcher matcher = tagPattern.matcher(clazz.getSimpleName()); if (matcher.find()) { // If our class has the form tagName = matcher.group("tag"); } } log.debug("Using tag name \"" + tagName + "\"."); // Add the tag and process each method. swagger.addTag(new Tag().name(tagName)); for (Method method : clazz.getDeclaredMethods()) { // Get the method source information. List<Class<?>> methodParamClasses = new ArrayList<>(); for (Parameter parameter : method.getParameters()) { methodParamClasses.add(parameter.getType()); } MethodSource<JavaClassSource> methodSource = javaClassSource.getMethod(method.getName(), methodParamClasses.toArray(new Class<?>[methodParamClasses.size()])); if (methodSource == null) { throw new MojoExecutionException( "No method source found for class \"" + clazz.getName() + "\" and method name \"" + method.getName() + "\"."); } // Process the REST controller method along with its source information. processRestControllerMethod(method, clazz.getAnnotation(RequestMapping.class), tagName, methodSource); } } else { log.debug("Skipping class \"" + clazz.getName() + "\" because it is either not a RestController or it is hidden."); } } /** * Processes a method in a REST controller which represents an endpoint, that is, it is annotated with RequestMapping. * * @param method the method. * @param classRequestMapping the parent. * @param tagName the tag name. * @param methodSource the method source information. * * @throws MojoExecutionException if any errors were encountered. */ @SuppressWarnings("unchecked") // CollectionUtils doesn't work with generics. private void processRestControllerMethod(Method method, RequestMapping classRequestMapping, String tagName, MethodSource<JavaClassSource> methodSource) throws MojoExecutionException { log.debug("Processing method \"" + method.getName() + "\"."); // Build a map of each parameter name to its description from the method Javadoc (i.e. all @param and @return values). Map<String, String> methodParamDescriptions = new HashMap<>(); JavaDocSource<MethodSource<JavaClassSource>> javaDocSource = methodSource.getJavaDoc(); List<JavaDocTag> tags = javaDocSource.getTags(); for (JavaDocTag javaDocTag : tags) { processJavaDocTag(javaDocTag, methodParamDescriptions); } List<String> produces = Collections.emptyList(); List<String> consumes = Collections.emptyList(); List<RequestMethod> requestMethods = Collections.emptyList(); List<String> uris = Collections.emptyList(); // If a class request mapping exists, use it as the default. if (classRequestMapping != null) { produces = CollectionUtils.arrayToList(classRequestMapping.produces()); consumes = CollectionUtils.arrayToList(classRequestMapping.consumes()); requestMethods = CollectionUtils.arrayToList(classRequestMapping.method()); uris = CollectionUtils.arrayToList(classRequestMapping.value()); } // Get the API Operation and see if this endpoint is hidden. ApiOperation apiOperation = method.getAnnotation(ApiOperation.class); boolean hidden = apiOperation != null && apiOperation.hidden(); // Only process methods that have a RequestMapping annotation. RequestMapping methodRequestMapping = method.getAnnotation(RequestMapping.class); if ((methodRequestMapping != null) && (!hidden)) { log.debug("Method \"" + method.getName() + "\" is a RequestMapping."); // Override values with method level ones if present. requestMethods = getClassOrMethodValue(requestMethods, CollectionUtils.arrayToList(methodRequestMapping.method())); uris = getClassOrMethodValue(uris, CollectionUtils.arrayToList(methodRequestMapping.value())); produces = getClassOrMethodValue(produces, CollectionUtils.arrayToList(methodRequestMapping.produces())); consumes = getClassOrMethodValue(consumes, CollectionUtils.arrayToList(methodRequestMapping.consumes())); // Perform validation. if (requestMethods.isEmpty()) { log.warn("No request method defined for method \"" + method.getName() + "\". Skipping..."); return; } if (uris.isEmpty()) { log.warn("No URI defined for method \"" + method.getName() + "\". Skipping..."); return; } if (uris.size() > 1) { log.warn(uris.size() + " URI's found for method \"" + method.getName() + "\". Only processing the first one."); } if (requestMethods.size() > 1) { log.warn(uris.size() + " request methods found for method \"" + method.getName() + "\". Only processing the first one."); } String uri = uris.get(0).trim(); Path path = swagger.getPath(uri); if (path == null) { path = new Path(); swagger.path(uri, path); } // Get the method summary from the ApiOperation annotation or use the method name if the annotation doesn't exist. String methodSummary = method.getName(); if (apiOperation != null) { methodSummary = apiOperation.value(); } Operation operation = new Operation(); operation.tag(tagName); operation.summary(methodSummary); if (javaDocSource.getText() != null) { // Process the method description. Javadoc javadoc = (Javadoc) javaDocSource.getInternal(); List<TagElement> tagList = javadoc.tags(); StringBuilder stringBuilder = new StringBuilder(); for (TagElement tagElement : tagList) { // Tags that have a null tag name are related to the overall method description (as opposed to the individual parameters, etc.). // In most cases, there should be only 1, but perhaps that are other cases that could have more. This general logic comes from // JavaDocImpl.getText(). Although that implementation also filters out on TextElements, we'll grab them all in case there's something // else available (e.g. @link, etc.). if (tagElement.getTagName() == null) { processFragments(tagElement.fragments(), stringBuilder); } } // The string builder has the final method text to use. operation.description(stringBuilder.toString()); setOperationId(tagName, method, operation); } if (!produces.isEmpty()) { operation.setProduces(produces); } if (!consumes.isEmpty()) { operation.setConsumes(consumes); } path.set(requestMethods.get(0).name().toLowerCase(), operation); // HTTP method MUST be lower cased // Process each method parameter. // We are using the parameter source here instead of the reflection method's parameters so we can match it to it's Javadoc descriptions. // The reflection approach only uses auto-generated parameter names (e.g. arg0, arg1, etc.) which we can't match to Javadoc parameter // names. for (ParameterSource<JavaClassSource> parameterSource : methodSource.getParameters()) { processRestMethodParameter(parameterSource, operation, methodParamDescriptions); } // Process the return value. processRestMethodReturnValue(method.getReturnType(), operation, methodParamDescriptions.get("@return")); } else { log.debug("Skipping method \"" + method.getName() + "\" because it is either not a RequestMapping or it is hidden."); } } /** * Processes the Java doc tag (i.e. the parameters and return value). * * @param javaDocTag the Java doc tag * @param methodParamDescriptions the map of method parameters to update. */ private void processJavaDocTag(JavaDocTag javaDocTag, Map<String, String> methodParamDescriptions) { // Get the list of fragments which are the parts of an individual Javadoc parameter or return value. TagElement tagElement = (TagElement) javaDocTag.getInternal(); List fragments = tagElement.fragments(); // We need to populate the parameter name and get the list of fragments that contain the actual text. String paramName = ""; List subFragments = new ArrayList<>(); if (javaDocTag.getName().equals("@param")) { // In the case of @param, the first fragment is the name and the rest make up the description. paramName = String.valueOf(fragments.get(0)); subFragments = fragments.subList(1, fragments.size()); } else if (javaDocTag.getName().equals("@return")) { // In the case of @return, we'll use "@return" itself for the name and all the fragments make up the description. paramName = "@return"; subFragments = fragments; } // Process all fragments and place the results in the map. StringBuilder stringBuilder = new StringBuilder(); processFragments(subFragments, stringBuilder); methodParamDescriptions.put(paramName, stringBuilder.toString()); } /** * Processes all the fragments that make up the description. This needs to be done manually as opposed to using the higher level roaster text retrieval * methods since those eat carriage return characters and don't replace them with a space. The result is the the last word of one line and the first word of * the next line are placed together with no separating space. This method builds it manually to fix this issue. * <p> * This method updates a passed in stringBuilder so callers can process multiple list of fragments and use the same stringBuilder to hold the processed * contents of all of them. * * @param fragments the list of fragments. * @param stringBuilder the string builder to update. */ private void processFragments(List fragments, StringBuilder stringBuilder) { // Loop through the fragments. for (Object fragment : fragments) { // Get and trim this fragment. String fragmentString = String.valueOf(fragment).trim(); // If we have already processed a fragment, add a space. if (stringBuilder.length() > 0) { stringBuilder.append(' '); } // Append this fragment to the string builder. stringBuilder.append(fragmentString); } } /** * Sets an operation Id on the operation based on the specified method. The operation Id takes on the format of * "~tagNameWithoutSpaces~.~methodName~[~counter~]". * * @param tagName the tag name for the class. * @param method the method for the operation. * @param operation the operation to set the Id on. */ private void setOperationId(String tagName, Method method, Operation operation) { // Initialize the counter and the "base" operation Id (i.e. the one without the counter) and default the operation Id we're going to use to the base // one. int count = 0; String baseOperationId = tagName.replaceAll(" ", "") + "." + method.getName(); String operationId = baseOperationId; // As long as the operation Id is a duplicate with one used before, add a counter to the end of it until we find one that hasn't been used before. while (operationIds.contains(operationId)) { count++; operationId = baseOperationId + count; } // Add our new operation Id to the set so we don't use it again and set the operation Id on the operation itself. operationIds.add(operationId); operation.setOperationId(operationId); } /** * Process a REST method parameter. * * @param parameterSource the parameter source information. * @param operation the Swagger operation. * @param methodParamDescriptions the method parameter Javadoc descriptions. * * @throws MojoExecutionException if any problems were encountered. */ private void processRestMethodParameter(ParameterSource<JavaClassSource> parameterSource, Operation operation, Map<String, String> methodParamDescriptions) throws MojoExecutionException { log.debug("Processing parameter \"" + parameterSource.getName() + "\"."); try { AnnotationSource<JavaClassSource> requestParamAnnotationSource = parameterSource.getAnnotation(RequestParam.class); AnnotationSource<JavaClassSource> requestBodyAnnotationSource = parameterSource.getAnnotation(RequestBody.class); AnnotationSource<JavaClassSource> pathVariableAnnotationSource = parameterSource.getAnnotation(PathVariable.class); if (requestParamAnnotationSource != null) { log.debug("Parameter \"" + parameterSource.getName() + "\" is a RequestParam."); QueryParameter queryParameter = new QueryParameter(); queryParameter.name(requestParamAnnotationSource.getStringValue("value").trim()); queryParameter.setRequired(BooleanUtils.toBoolean(requestParamAnnotationSource.getStringValue("required"))); setParameterType(parameterSource, queryParameter); operation.parameter(queryParameter); setParamDescription(parameterSource, methodParamDescriptions, queryParameter); } else if (requestBodyAnnotationSource != null) { log.debug("Parameter \"" + parameterSource.getName() + "\" is a RequestBody."); // Add the class name to the list of classes which we will create an example for. exampleClassNames.add(parameterSource.getType().getSimpleName()); BodyParameter bodyParameter = new BodyParameter(); XmlType xmlType = getXmlType(Class.forName(parameterSource.getType().getQualifiedName())); String name = xmlType.name().trim(); bodyParameter.name(name); bodyParameter.setRequired(true); bodyParameter.setSchema(new RefModel(name)); operation.parameter(bodyParameter); setParamDescription(parameterSource, methodParamDescriptions, bodyParameter); } else if (pathVariableAnnotationSource != null) { log.debug("Parameter \"" + parameterSource.getName() + "\" is a PathVariable."); PathParameter pathParameter = new PathParameter(); pathParameter.name(pathVariableAnnotationSource.getStringValue("value").trim()); setParameterType(parameterSource, pathParameter); operation.parameter(pathParameter); setParamDescription(parameterSource, methodParamDescriptions, pathParameter); } } catch (ClassNotFoundException e) { throw new MojoExecutionException("Unable to instantiate class \"" + parameterSource.getType().getQualifiedName() + "\". Reason: " + e.getMessage(), e); } } /** * Converts the given Java parameter type into a Swagger param type and sets it into the given Swagger param. * * @param parameterSource the parameter source. * @param swaggerParam the Swagger parameter. */ private void setParameterType(ParameterSource<JavaClassSource> parameterSource, SerializableParameter swaggerParam) throws MojoExecutionException { try { String typeName = parameterSource.getType().getQualifiedName(); if (String.class.getName().equals(typeName)) { swaggerParam.setType("string"); } else if (Integer.class.getName().equals(typeName) || Long.class.getName().equals(typeName)) { swaggerParam.setType("integer"); } else if (Boolean.class.getName().equals(typeName)) { swaggerParam.setType("boolean"); } else { // See if the type is an enum. Enum<?>[] enumValues = (Enum<?>[]) Class.forName(parameterSource.getType().getQualifiedName()).getEnumConstants(); if (enumValues != null) { swaggerParam.setType("string"); swaggerParam.setEnum(new ArrayList<>()); for (Enum<?> enumEntry : enumValues) { swaggerParam.getEnum().add(enumEntry.name()); } } else { // Assume "string" for all other types since everything is ultimately a string. swaggerParam.setType("string"); } } log.debug("Parameter \"" + parameterSource.getName() + "\" is a type \"" + swaggerParam.getType() + "\"."); } catch (ClassNotFoundException e) { throw new MojoExecutionException("Unable to instantiate class \"" + parameterSource.getType().getQualifiedName() + "\". Reason: " + e.getMessage(), e); } } /** * Sets a Swagger parameter description. * * @param parameterSource the parameter source information. * @param methodParamDescriptions the map of parameter names to their descriptions. * @param swaggerParam the Swagger parameter metadata to update. */ private void setParamDescription(ParameterSource<JavaClassSource> parameterSource, Map<String, String> methodParamDescriptions, io.swagger.models.parameters.Parameter swaggerParam) { // Set the parameter description if one was found. String parameterDescription = methodParamDescriptions.get(parameterSource.getName()); log.debug("Parameter \"" + parameterSource.getName() + "\" has description\"" + parameterDescription + "\"."); if (parameterDescription != null) { swaggerParam.setDescription(parameterDescription); } } /** * Processes the return value of a RequestMapping annotated method. * * @param returnType the return type. * @param operation the operation. * @param returnDescription the description of the return value. * * @throws MojoExecutionException if the return type isn't an XmlType. */ private void processRestMethodReturnValue(Class<?> returnType, Operation operation, String returnDescription) throws MojoExecutionException { log.debug("Processing REST method return value \"" + returnType.getName() + "\"."); // Add the class name to the list of classes which we will create an example for. exampleClassNames.add(returnType.getSimpleName()); // Add the success response operation.response(200, new Response().description(returnDescription == null ? "Success" : returnDescription) .schema(new RefProperty(getXmlType(returnType).name().trim()))); // If we have an error class, add that as the default response. if (modelErrorClass != null) { operation.defaultResponse(new Response().description("General Error").schema(new RefProperty(getXmlType(modelErrorClass).name().trim()))); } } /** * Gets the method level object if not empty or uses the class level if the method level is empty. * * @param classLevel the class level object. * @param methodLevel the method level object. * @param <T> the type of the object. * * @return the class or method level object. */ private <T extends List<?>> T getClassOrMethodValue(T classLevel, T methodLevel) { return (methodLevel == null || methodLevel.isEmpty()) ? classLevel : methodLevel; } /** * Gets the XmlType annotation from the specified class. If the XmlType doesn't exist, an exception will be thrown. * * @param clazz the class with the XmlType annotation. * * @return the XmlType. * @throws MojoExecutionException if the class isn't an XmlType. */ private XmlType getXmlType(Class<?> clazz) throws MojoExecutionException { XmlType xmlType = clazz.getAnnotation(XmlType.class); if (xmlType == null) { throw new MojoExecutionException("Class \"" + clazz.getName() + "\" is not of XmlType."); } return xmlType; } /** * Gets the example class names. * * @return the example class names. */ public Set<String> getExampleClassNames() { return exampleClassNames; } }