All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.jooby.apt.JoobyProcessor Maven / Gradle / Ivy

There is a newer version: 3.5.3
Show newest version
/*
 * Jooby https://jooby.io
 * Apache License Version 2.0 https://jooby.io/LICENSE.txt
 * Copyright 2014 Edgar Espina
 */
package io.jooby.apt;

import static io.jooby.apt.JoobyProcessor.Options.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Optional.ofNullable;
import static javax.tools.StandardLocation.SOURCE_OUTPUT;

import java.io.*;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.DeclaredType;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardLocation;

import io.jooby.internal.apt.*;

@SupportedOptions({
  HANDLER,
  DEBUG,
  INCREMENTAL,
  SERVICES,
  MVC_METHOD,
  RETURN_TYPE,
  SKIP_ATTRIBUTE_ANNOTATIONS
})
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class JoobyProcessor extends AbstractProcessor {
  public interface Options {
    String HANDLER = "jooby.handler";
    String DEBUG = "jooby.debug";
    String ROUTER_PREFIX = "jooby.routerPrefix";
    String ROUTER_SUFFIX = "jooby.routerSuffix";
    String INCREMENTAL = "jooby.incremental";
    String RETURN_TYPE = "jooby.returnType";
    String MVC_METHOD = "jooby.mvcMethod";
    String SERVICES = "jooby.services";
    String SKIP_ATTRIBUTE_ANNOTATIONS = "jooby.skipAttributeAnnotations";

    static boolean boolOpt(ProcessingEnvironment environment, String option, boolean defaultValue) {
      return Boolean.parseBoolean(
          environment.getOptions().getOrDefault(option, String.valueOf(defaultValue)));
    }

    static List stringListOpt(ProcessingEnvironment environment, String option) {
      String value = string(environment, option, null);
      return value == null || value.isEmpty()
          ? List.of()
          : Stream.of(value.split(",")).filter(it -> !it.isBlank()).map(String::trim).toList();
    }

    static String string(ProcessingEnvironment environment, String option, String defaultValue) {
      String value = environment.getOptions().getOrDefault(option, defaultValue);
      return value == null || value.isEmpty() ? defaultValue : value;
    }
  }

  protected MvcContext context;
  private BiConsumer output;
  private final Set processed = new HashSet<>();

  public JoobyProcessor(BiConsumer output) {
    this.output = output;
  }

  public JoobyProcessor() {}

  @Override
  public synchronized void init(ProcessingEnvironment processingEnvironment) {
    this.context =
        new MvcContext(
            processingEnvironment,
            ofNullable(output)
                .orElseGet(
                    () ->
                        (kind, message) ->
                            processingEnvironment.getMessager().printMessage(kind, message)));
    super.init(processingEnvironment);
  }

  @Override
  public boolean process(Set annotations, RoundEnvironment roundEnv) {
    try {
      if (roundEnv.processingOver()) {
        context.debug("Output:");
        context.getRouters().forEach(it -> context.debug("  %s.java", it.getGeneratedType()));
        if (context.generateServices()) {
          doServices(context.getProcessingEnvironment().getFiler(), context.getRouters());
        }
        return false;
      } else {
        var routeMap = buildRouteRegistry(annotations, roundEnv);
        for (var router : routeMap.values()) {
          try {
            var sourceCode = router.toSourceCode(null);
            var sourceLocation = router.getGeneratedFilename();
            onGeneratedSource(toJavaFileObject(sourceLocation, sourceCode));
            context.debug("router %s: %s", router.getTargetType(), router.getGeneratedType());
            router.getRoutes().forEach(it -> context.debug("   %s", it));
            writeSource(router, sourceLocation, sourceCode);
            context.add(router);
          } catch (IOException cause) {
            throw new RuntimeException("Unable to generate: " + router.getTargetType(), cause);
          }
        }
        return true;
      }
    } catch (Exception cause) {
      context.error(
          Optional.ofNullable(cause.getMessage()).orElse("Unable to generate routes"), cause);
      throw sneakyThrow0(cause);
    }
  }

  private void writeSource(MvcRouter router, String sourceLocation, String sourceCode)
      throws IOException {
    var environment = context.getProcessingEnvironment();
    var filer = environment.getFiler();
    if (router.isKt()) {
      var kapt = environment.getOptions().get("kapt.kotlin.generated");
      if (kapt != null) {
        var output = Paths.get(kapt, sourceLocation);
        Files.createDirectories(output.getParent());
        Files.writeString(output, sourceCode);
      } else {
        var ktFile =
            filer.createResource(SOURCE_OUTPUT, "", sourceLocation, router.getTargetType());
        try (var writer = ktFile.openWriter()) {
          writer.write(sourceCode);
        }
      }
    } else {
      var javaFIle = filer.createSourceFile(router.getGeneratedType(), router.getTargetType());
      try (var writer = javaFIle.openWriter()) {
        writer.write(sourceCode);
      }
    }
  }

  private static JavaFileObject toJavaFileObject(String filename, String source) {
    var uri = URI.create(filename);
    return new SimpleJavaFileObject(uri, JavaFileObject.Kind.SOURCE) {
      private final long lastModified = System.currentTimeMillis();

      @Override
      public String getCharContent(boolean ignoreEncodingErrors) {
        return source;
      }

      @Override
      public InputStream openInputStream() {
        return new ByteArrayInputStream(getCharContent(true).getBytes(UTF_8));
      }

      @Override
      public long getLastModified() {
        return lastModified;
      }

      @Override
      public String toString() {
        return getCharContent(false);
      }
    };
  }

  protected void onGeneratedSource(JavaFileObject source) {}

  private void doServices(Filer filer, List routers) {
    try {
      var location = "META-INF/services/io.jooby.MvcFactory";
      context.debug(location);

      var resource = filer.createResource(StandardLocation.CLASS_OUTPUT, "", location);
      var content = new StringBuilder();
      for (var router : routers) {
        var classname = router.getGeneratedType();
        context.debug("  %s", classname);
        content.append(classname).append(System.lineSeparator());
      }
      try (var writer = new PrintWriter(resource.openOutputStream())) {
        writer.println(content);
      }
    } catch (IOException cause) {
      throw propagate(cause);
    }
  }

  private Map buildRouteRegistry(
      Set annotations, RoundEnvironment roundEnv) {
    Map registry = new LinkedHashMap<>();

    for (var annotation : annotations) {
      context.debug("found annotation: %s", annotation);
      var elements = roundEnv.getElementsAnnotatedWith(annotation);
      // Element could be Class or Method, bc @Path can be applied to both of them
      // Also we need to expand lookup to external jars see #2486
      for (var element : elements) {
        context.debug("  %s", element);
        if (element instanceof TypeElement typeElement) {
          buildRouteRegistry(registry, typeElement);
        } else if (element instanceof ExecutableElement method) {
          buildRouteRegistry(registry, (TypeElement) method.getEnclosingElement());
        }
      }
    }
    // Remove all abstract router
    var abstractTypes =
        registry.entrySet().stream()
            .filter(it -> it.getValue().isAbstract())
            .map(Map.Entry::getKey)
            .collect(Collectors.toSet());
    abstractTypes.forEach(registry::remove);

    // Generate unique method name by router
    for (var router : registry.values()) {
      // Initialize with supports/create method from MvcFactory (avoid name collision)
      var names = new HashSet<>();
      for (var route : router.getRoutes()) {
        if (!names.add(route.getMethodName())) {
          var paramsString =
              route.getRawParameterTypes(true).stream()
                  .map(it -> it.substring(Math.max(0, it.lastIndexOf(".") + 1)))
                  .map(it -> Character.toUpperCase(it.charAt(0)) + it.substring(1))
                  .collect(Collectors.joining());
          route.setGeneratedName(route.getMethodName() + paramsString);
        } else {
          route.setGeneratedName(route.getMethodName());
        }
      }
    }
    return registry;
  }

  /**
   * Scan routes from basType and any super class of it. It saves all route method found in current
   * type or super (inherited). Routes method from super types are also saved.
   *
   * 

Abstract route method are ignored. * * @param registry Route registry. * @param currentType Base type. */ private void buildRouteRegistry(Map registry, TypeElement currentType) { for (TypeElement superType : context.superTypes(currentType)) { if (processed.add(superType)) { // collect all declared methods superType.getEnclosedElements().stream() .filter(ExecutableElement.class::isInstance) .map(ExecutableElement.class::cast) .forEach( method -> { if (method.getModifiers().contains(Modifier.ABSTRACT)) { context.debug("ignoring abstract method: %s %s", superType, method); } else { method.getAnnotationMirrors().stream() .map(AnnotationMirror::getAnnotationType) .map(DeclaredType::asElement) .filter(TypeElement.class::isInstance) .map(TypeElement.class::cast) .filter(HttpMethod::hasAnnotation) .forEach( annotation -> { Stream.of(currentType, superType) .distinct() .forEach( routerClass -> registry .computeIfAbsent( routerClass, type -> new MvcRouter(context, type)) .put(annotation, method)); }); } }); } else { if (!currentType.equals(superType)) { // edge case when controller has no method and extends another class which has. registry.computeIfAbsent(currentType, key -> new MvcRouter(key, registry.get(superType))); } } } } @Override public Set getSupportedAnnotationTypes() { var supportedTypes = new HashSet(); supportedTypes.addAll(HttpPath.PATH.getAnnotations()); supportedTypes.addAll(HttpMethod.annotations()); return supportedTypes; } @Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); } @Override public Set getSupportedOptions() { var options = new HashSet<>(super.getSupportedOptions()); if (context.isIncremental()) { // Enables incremental annotation processing support in Gradle. // If service provider configuration is being generated, // only 'aggregating' mode is supported since it's likely that // more then one originating element is passed to the Filer // API on writing the resource file - isolating mode does not // allow this. options.add( String.format( "org.gradle.annotation.processing.%s", context.generateServices() ? "aggregating" : "isolating")); } return options; } /** * Throws any throwable 'sneakily' - you don't need to catch it, nor declare that you throw it * onwards. The exception is still thrown - javac will just stop whining about it. * *

Example usage: * *

public void run() {
   *     throw sneakyThrow(new IOException("You don't need to catch me!"));
   * }
* *

NB: The exception is not wrapped, ignored, swallowed, or redefined. The JVM actually does * not know or care about the concept of a 'checked exception'. All this method does is hide the * act of throwing a checked exception from the java compiler. * *

Note that this method has a return type of {@code RuntimeException}; it is advised you * always call this method as argument to the {@code throw} statement to avoid compiler errors * regarding no return statement and similar problems. This method won't of course return an * actual {@code RuntimeException} - it never returns, it always throws the provided exception. * * @param x The throwable to throw without requiring you to catch its type. * @return A dummy RuntimeException; this method never returns normally, it always throws * an exception! */ public static RuntimeException propagate(final Throwable x) { if (x == null) { throw new NullPointerException("x"); } return sneakyThrow0(x); } /** * Make a checked exception un-checked and rethrow it. * * @param x Exception to throw. * @param Exception type. * @throws E Exception to throw. */ @SuppressWarnings("unchecked") private static E sneakyThrow0(final Throwable x) throws E { throw (E) x; } }