aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/foo/zaaarf/routecartographer
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/foo/zaaarf/routecartographer')
-rw-r--r--src/main/java/foo/zaaarf/routecartographer/Route.java143
-rw-r--r--src/main/java/foo/zaaarf/routecartographer/RouteCartographer.java396
2 files changed, 539 insertions, 0 deletions
diff --git a/src/main/java/foo/zaaarf/routecartographer/Route.java b/src/main/java/foo/zaaarf/routecartographer/Route.java
new file mode 100644
index 0000000..650cc17
--- /dev/null
+++ b/src/main/java/foo/zaaarf/routecartographer/Route.java
@@ -0,0 +1,143 @@
+package foo.zaaarf.routecartographer;
+
+import org.springframework.web.bind.annotation.RequestMethod;
+
+import java.util.Arrays;
+
+/**
+ * Representation of a REST route.
+ */
+public class Route {
+ /**
+ * The paths of the endpoint.
+ */
+ public final String[] paths;
+
+ /**
+ * The supported {@link RequestMethod}s, as strings.
+ */
+ public final String[] methods;
+
+ /**
+ * The media types produced by the endpoint.
+ * May be null if not specified.
+ */
+ public final String[] produces;
+
+ /**
+ * The media types consumed by the endpoint.
+ * May be null if not specified.
+ */
+ public final String[] consumes;
+
+ /**
+ * Whether the endpoint is deprecated.
+ */
+ public final boolean deprecated;
+
+ /**
+ * A {@link DTO} representing the response body.
+ */
+ public final DTO returnType;
+
+ /**
+ * A {@link DTO} representing the request body.
+ */
+ public final DTO inputType;
+
+ /**
+ * An array of {@link Param}s, representing parameters accepted by the endpoint.
+ */
+ public final Param[] params;
+
+ /**
+ * The one and only constructor.
+ * @param paths the paths of the endpoint
+ * @param methods the {@link RequestMethod}s accepted by the endpoint
+ * @param consumes the media types consumed by the endpoint, may be null
+ * @param produces the media types produced by the endpoint, may be null
+ * @param deprecated whether the endpoint is deprecated
+ * @param returnType the DTO for the response type, may be null
+ * @param inputType the DTO for the request type, may be null
+ * @param params {@link Param}s of the endpoint, may be null
+ */
+ public Route(String[] paths, RequestMethod[] methods, String[] consumes, String[] produces,
+ boolean deprecated, DTO returnType, DTO inputType, Param... params) {
+ this.paths = paths;
+ this.methods = Arrays.stream(methods).map(Enum::name).toArray(String[]::new);
+
+ if(produces != null) this.produces = produces;
+ else this.produces = new String[0];
+
+ if(consumes != null) this.consumes = consumes;
+ else this.consumes = new String[0];
+
+ this.deprecated = deprecated;
+
+ this.returnType = returnType;
+ this.inputType = inputType;
+
+ if(params != null) this.params = params;
+ else this.params = new Param[0]; //just in case
+ }
+
+ /**
+ * Representation of a parameter of a REST route.
+ */
+ public static class Param {
+ /**
+ * The fully-qualified name of the expected type of the parameter.
+ */
+ public final String typeFQN;
+
+ /**
+ * The name of the parameter.
+ */
+ public final String name;
+
+ /**
+ * The default value of the parameter.
+ * May be null, in which case the parameter is required.
+ */
+ public final String defaultValue;
+
+ /**
+ * The one and only constructor.
+ * @param typeFQN the FQN of the expected type of the parameter
+ * @param name the name of the parameter
+ * @param defaultValue the default value of the parameter, may be null if the parameter is required
+ */
+ public Param(String typeFQN, String name, String defaultValue) {
+ this.typeFQN = typeFQN;
+ this.name = name;
+ this.defaultValue = defaultValue;
+ }
+ }
+
+ /**
+ * Representation of a DTO type.
+ */
+ public static class DTO {
+
+ /**
+ * Fully-qualified name of the type.
+ */
+ public final String FQN;
+
+ /**
+ * An array of {@link Param} representing the type's fields.
+ */
+ public final Route.Param[] fields;
+
+ /**
+ * The one and only constructor.
+ * @param FQN the fully-qualified name
+ * @param fields the {@link Param}s representing the fields
+ */
+ public DTO(String FQN, Route.Param ... fields) {
+ this.FQN = FQN;
+ if(fields == null) this.fields = new Route.Param[0];
+ else this.fields = fields;
+ }
+ }
+}
diff --git a/src/main/java/foo/zaaarf/routecartographer/RouteCartographer.java b/src/main/java/foo/zaaarf/routecartographer/RouteCartographer.java
new file mode 100644
index 0000000..2b872be
--- /dev/null
+++ b/src/main/java/foo/zaaarf/routecartographer/RouteCartographer.java
@@ -0,0 +1,396 @@
+package foo.zaaarf.routecartographer;
+
+import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedSourceVersion;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.*;
+import javax.lang.model.type.TypeKind;
+import javax.lang.model.type.TypeMirror;
+import javax.tools.Diagnostic;
+import javax.tools.FileObject;
+import javax.tools.StandardLocation;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.lang.annotation.Annotation;
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/**
+ * The main processor class.
+ */
+@SupportedSourceVersion(SourceVersion.RELEASE_8)
+public class RouteCartographer extends AbstractProcessor {
+
+ /**
+ * The filename of the output.
+ */
+ private static final String ROUTE_MAP_FILENAME = "route_map";
+
+ /**
+ * A {@link Map} tying each component class to the routes it contains.
+ */
+ private final Map<String, List<Route>> foundRoutes = new HashMap<>();
+
+ /**
+ * A {@link Set} containing all the supported annotation classes.
+ */
+ private final Set<Class<? extends Annotation>> annotationClasses = new HashSet<>();
+
+ /**
+ * Default constructor, it only initialises {@link #annotationClasses}.
+ */
+ public RouteCartographer() {
+ this.annotationClasses.add(RequestMapping.class);
+ this.annotationClasses.add(GetMapping.class);
+ this.annotationClasses.add(PostMapping.class);
+ this.annotationClasses.add(PutMapping.class);
+ this.annotationClasses.add(DeleteMapping.class);
+ this.annotationClasses.add(PatchMapping.class);
+ }
+
+ /**
+ * Processes Spring's annotations, NOT claiming them for itself.
+ * It builds a {@link Route} object for each route and adds it to {@link #foundRoutes},
+ * then proceeds to print it to a file.
+ * @param annotations the annotation types requested to be processed
+ * @param env environment for information about the current and prior round
+ * @return false, letting other processor process the annotations again
+ */
+ @Override
+ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {
+ for(TypeElement annotationType : annotations) {
+ env.getElementsAnnotatedWith(annotationType)
+ .stream()
+ .filter(elem -> elem instanceof ExecutableElement)
+ .map(elem -> (ExecutableElement) elem)
+ .forEach(elem -> {
+ String classFQN = elem.getEnclosingElement().asType().toString();
+ List<Route> routesInClass = foundRoutes.computeIfAbsent(classFQN, k -> new ArrayList<>());
+ routesInClass.add(new Route(
+ this.getFullRoute(annotationType, elem),
+ this.getRequestMethods(annotationType, elem),
+ this.getConsumedType(annotationType, elem),
+ this.getProducedType(annotationType, elem),
+ this.isDeprecated(elem),
+ this.getDTO(this.processingEnv.getTypeUtils().asElement(elem.getReturnType())),
+ this.getDTO(elem.getParameters().stream()
+ .filter(e -> e.getAnnotation(RequestBody.class) != null)
+ .findFirst().orElse(null)),
+ this.getQueryParams(elem.getParameters())
+ ));
+ });
+ }
+
+ try {
+ CharSequence startingContents;
+ try {
+ FileObject existingRouteMap = this.processingEnv.getFiler().getResource(
+ StandardLocation.SOURCE_OUTPUT, "", ROUTE_MAP_FILENAME
+ );
+ startingContents = existingRouteMap.getCharContent(true);
+ existingRouteMap.delete();
+ } catch (IOException ex) {
+ startingContents = "";
+ }
+
+ FileObject routeMap = this.processingEnv.getFiler().createResource(
+ StandardLocation.SOURCE_OUTPUT, "", ROUTE_MAP_FILENAME
+ );
+
+ PrintWriter out = new PrintWriter(routeMap.openWriter());
+ out.println(startingContents); //print with an extra newline
+ for(String componentClass : this.foundRoutes.keySet()) {
+ out.println(componentClass + ":");
+
+ List<Route> routesInClass = this.foundRoutes.get(componentClass);
+ for(Route r : routesInClass) {
+ out.print("\t- ");
+ if(r.deprecated) out.print("[DEPRECATED] ");
+ out.print("[" + String.join("|", r.methods) + "] ["
+ + String.join("|", r.paths) + "]");
+ if(r.consumes != null && r.consumes.length > 0)
+ out.print(" (expects: " + String.join("|", r.consumes) + ")");
+ if(r.produces != null && r.produces.length > 0)
+ out.print(" (returns: " + String.join("|", r.produces) + ")");
+ out.println();
+
+ BiConsumer<String, Route.Param[]> printParam = (name, params) -> {
+ if(name != null) out.println("\t\t" + name);
+ for(Route.Param p : params) {
+ out.print(name != null ? "\t\t\t" : "\t\t");
+ out.print("- " + p.typeFQN + " " + p.name);
+ if(p.defaultValue != null)
+ out.print(" (default: " + p.defaultValue + ")");
+ out.println();
+ }
+ };
+
+ printParam.accept(null, r.params);
+
+ if(r.inputType != null)
+ printParam.accept("input: " + r.inputType.FQN, r.inputType.fields);
+
+ if(r.returnType != null)
+ printParam.accept("output: " + r.returnType.FQN, r.returnType.fields);
+ }
+ }
+
+ out.close();
+ } catch(IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ return false; //don't claim them, let spring do its job
+ }
+
+ /**
+ * Extracts the route of an element.
+ * @param annotationType the {@link TypeElement} with the annotation we are processing
+ * @param element the {@link Element} currently being examined
+ * @return the full route of the endpoint
+ */
+ private String[] getFullRoute(TypeElement annotationType, Element element) {
+ try {
+ String[] routes = this.getAnnotationFieldsValue(
+ annotationType,
+ element,
+ (arr) -> Arrays.deepEquals(arr, new String[] {}),
+ "path", "value");
+ return this.getParentOrFallback(element, routes, (a, e) -> {
+ //assume parent doesn't have multiple routes
+ String parent = this.getFullRoute(a, e)[0];
+ for(int i = 0; i < routes.length; i++) {
+ StringBuilder sb = new StringBuilder(parent);
+ if(!parent.endsWith("/") && !routes[i].startsWith("/"))
+ sb.append("/");
+ if(parent.endsWith("/") && routes[i].startsWith("/"))
+ sb.deleteCharAt(sb.length() - 1);
+ sb.append(routes[i]);
+ routes[i] = sb.toString();
+ }
+ return routes;
+ });
+ } catch (ReflectiveOperationException ex) {
+ throw new RuntimeException(ex); //if it fails something went very wrong
+ }
+ }
+
+ /**
+ * Finds the request methods supported by the endpoint.
+ * @param annotationType the {@link TypeElement} with the annotation we are processing
+ * @param element the {@link Element} currently being examined
+ * @return the {@link RequestMethod}s supported by the endpoint
+ */
+ private RequestMethod[] getRequestMethods(TypeElement annotationType, Element element) {
+ RequestMethod[] methods = annotationType.getQualifiedName().contentEquals(RequestMapping.class.getName())
+ ? element.getAnnotation(RequestMapping.class).method()
+ : annotationType.getAnnotation(RequestMapping.class).method();
+ return methods.length == 0
+ ? this.getParentOrFallback(element, methods, this::getRequestMethods)
+ : methods;
+ }
+
+ /**
+ * Finds the media type consumed by an endpoint.
+ * @param annotationType the {@link TypeElement} with the annotation we are processing
+ * @param element the {@link Element} currently being examined
+ * @return the media type consumed by the endpoint
+ */
+ private String[] getConsumedType(TypeElement annotationType, Element element) {
+ try {
+ String[] res = this.getAnnotationFieldsValue(
+ annotationType,
+ element,
+ (arr) -> Arrays.deepEquals(arr, new String[] {}),
+ "consumes");
+ return res == null
+ ? this.getParentOrFallback(element, res, this::getConsumedType)
+ : res;
+ } catch(ReflectiveOperationException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ /**
+ * Finds the media type consumed by an endpoint.
+ * @param annotationType the {@link TypeElement} with the annotation we are processing
+ * @param element the {@link Element} currently being examined
+ * @return the media type consumed by the endpoint
+ */
+ private String[] getProducedType(TypeElement annotationType, Element element) {
+ try {
+ String[] res = this.getAnnotationFieldsValue(
+ annotationType,
+ element,
+ (arr) -> Arrays.deepEquals(arr, new String[] {}),
+ "produces");
+ return res == null
+ ? this.getParentOrFallback(element, res, this::getProducedType)
+ : res;
+ } catch(ReflectiveOperationException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ /**
+ * Checks whether the endpoint or its parent are deprecated
+ * @param element the {@link Element} currently being examined
+ * @return whether the given endpoint is deprecated
+ */
+ private boolean isDeprecated(Element element) {
+ return element.getAnnotation(Deprecated.class) != null
+ || element.getEnclosingElement().getAnnotation(Deprecated.class) != null;
+ }
+
+ /**
+ * Gets the parameters accepted by a request.
+ * @param params the {@link VariableElement}s representing the parameters of a request
+ * @return an array of {@link Route.Param} representing the parameters of the request.
+ */
+ private Route.Param[] getQueryParams(List<? extends VariableElement> params) {
+ return params.stream()
+ .map(p -> {
+ RequestParam ann = p.getAnnotation(RequestParam.class);
+ if(ann == null) return null;
+
+ String name = ann.name(); //first try annotation.name()
+ name = name.isEmpty()
+ ? ann.value() //then annotation.value()
+ : name;
+ name = name.isEmpty()
+ ? p.getSimpleName().toString() //fall back on parameter name
+ : name;
+
+ String defaultValue = ann.defaultValue();
+ if(defaultValue.equals(ValueConstants.DEFAULT_NONE))
+ defaultValue = null;
+
+ return new Route.Param(name, p.asType().toString(), defaultValue);
+ }).filter(Objects::nonNull).toArray(Route.Param[]::new);
+ }
+
+ /**
+ * Gets a representation of a DTO type.
+ * @param type the {@link TypeElement} to examine
+ * @return a {@link Route.DTO} representing the given type
+ */
+ private Route.DTO getDTO(Element type) {
+ if(!(type instanceof TypeElement)) //doubles as null check
+ return null;
+
+ TypeElement typeElement = (TypeElement) type;
+
+ //parameter checks
+ Name base = typeElement.getQualifiedName();
+ if(base.contentEquals(ResponseEntity.class.getCanonicalName())
+ || base.contentEquals(RequestEntity.class.getCanonicalName())) {
+ typeElement = (TypeElement) typeElement.getTypeParameters()
+ .stream()
+ .findFirst()
+ .map(TypeParameterElement::getBounds)
+ .map(l -> {
+ List<TypeMirror> lst = new ArrayList<>(l); //mutable
+ lst.removeIf(b -> b.toString().equals("java.lang.Object"));
+ return lst;
+ }).flatMap(l -> l.stream().findFirst())
+ .map(m -> this.processingEnv.getTypeUtils().asElement(m))
+ .filter(m -> m instanceof TypeElement)
+ .orElse(null );
+
+ if(typeElement == null)
+ return new Route.DTO(base.toString());
+ }
+
+ List<VariableElement> fieldElements = new ArrayList<>();
+ do {
+ fieldElements.addAll(typeElement
+ .getEnclosedElements()
+ .stream().filter(e -> e instanceof VariableElement)
+ .map(e -> (VariableElement) e)
+ .collect(Collectors.toList()));
+ TypeMirror superclass = typeElement.getSuperclass();
+ if(superclass.getKind() == TypeKind.DECLARED)
+ typeElement = (TypeElement) this.processingEnv.getTypeUtils().asElement(superclass);
+ else typeElement = null;
+ } while(typeElement != null);
+
+ return new Route.DTO(type.asType().toString(), fieldElements.stream()
+ .map(e -> new Route.Param(e.asType().toString(), e.getSimpleName().toString(), null))
+ .toArray(Route.Param[]::new));
+ }
+
+ /**
+ * An annotation value.
+ * @param annotationType the {@link TypeElement} with the annotation we are processing
+ * @param element the {@link Element} currently being examined
+ * @param unsetPredicate lambda that returns true if the value is the default one (thus unset)
+ * @param fieldNames the field name(s) to look for; they are tried in order, and the first found is returned
+ * @return the field value, cast to the expected type
+ * @param <T> the expected type of the field
+ * @throws ReflectiveOperationException when given non-existing or inaccessible field names (hopefully never)
+ */
+ @SuppressWarnings({"OptionalGetWithoutIsPresent", "unchecked"})
+ private <T> T getAnnotationFieldsValue(TypeElement annotationType, Element element,
+ Predicate<T> unsetPredicate, String ... fieldNames)
+ throws ReflectiveOperationException {
+
+ Class<? extends Annotation> annClass = this.annotationClasses.stream()
+ .filter(c -> annotationType.getQualifiedName().contentEquals(c.getCanonicalName()))
+ .findFirst()
+ .get(); //should never fail
+
+ T result = null;
+ for(String fieldName : fieldNames) {
+ result = (T) annClass.getMethod(fieldName).invoke(element.getAnnotation(annClass));
+ if(result != null && !unsetPredicate.test(result))
+ return result;
+ }
+
+ return result;
+ }
+
+ /**
+ * Finds whether the parent of the given element has any supported annotation, then applies the given
+ * function to both parent and found annotation.
+ * @param element the {@link Element} currently being examined
+ * @param fallback the value to return if the parent didn't have any supported annotations
+ * @param fun the {@link BiFunction} to apply
+ * @return the output or the function, or the fallback value if the parent didn't have any supported annotation
+ * @param <T> the type of the expected result
+ */
+ private <T> T getParentOrFallback(Element element, T fallback, BiFunction<TypeElement, Element, T> fun) {
+ List<Class<? extends Annotation>> found = this.annotationClasses.stream()
+ .filter(annClass -> element.getEnclosingElement().getAnnotation(annClass) != null)
+ .collect(Collectors.toList());
+
+ if(found.isEmpty()) return fallback;
+
+ if(found.size() > 1) this.processingEnv.getMessager().printMessage(
+ Diagnostic.Kind.WARNING,
+ "Found multiple mapping annotations on "
+ + element.getSimpleName().toString()
+ + ", only one of them will be considered!"
+ );
+
+ return fun.apply(
+ this.processingEnv.getElementUtils().getTypeElement(found.get(0).getCanonicalName()),
+ element.getEnclosingElement()
+ );
+ }
+
+ /**
+ * @return the types of annotations supported by this processor
+ */
+ @Override
+ public Set<String> getSupportedAnnotationTypes() {
+ return annotationClasses.stream().map(Class::getCanonicalName).collect(Collectors.toSet());
+ }
+}