Generator.java

  1. /*
  2.  * The coLAB project
  3.  * Copyright (C) 2021-2023 AlbaSim, MEI, HEIG-VD, HES-SO
  4.  *
  5.  * Licensed under the MIT License
  6.  */
  7. package ch.colabproject.colab.generator.plugin;

  8. import ch.colabproject.colab.generator.model.interfaces.WithId;
  9. import ch.colabproject.colab.generator.model.interfaces.WithJsonDiscriminator;
  10. import ch.colabproject.colab.generator.model.tools.ClassDoc;
  11. import ch.colabproject.colab.generator.model.tools.JavaDocExtractor;
  12. import ch.colabproject.colab.generator.model.tools.JsonbProvider;
  13. import ch.colabproject.colab.generator.plugin.rest.RestEndpoint;
  14. import java.io.BufferedWriter;
  15. import java.io.IOException;
  16. import java.lang.reflect.Type;
  17. import java.nio.file.Files;
  18. import java.util.AbstractMap.SimpleEntry;
  19. import java.util.ArrayList;
  20. import java.util.Arrays;
  21. import java.util.HashMap;
  22. import java.util.List;
  23. import java.util.Map;
  24. import java.util.Map.Entry;
  25. import java.util.Set;
  26. import java.util.stream.Collectors;
  27. import javax.json.bind.Jsonb;
  28. import javax.ws.rs.ApplicationPath;
  29. import javax.ws.rs.Path;
  30. import org.apache.maven.plugin.MojoFailureException;
  31. import org.reflections.Reflections;

  32. /**
  33.  * @author maxence
  34.  */
  35. public class Generator {

  36.     /**
  37.      * Reflections one-stop-shop object.
  38.      */
  39.     private final Reflections reflections;

  40.     /**
  41.      * All rest controller found.
  42.      */
  43.     private Set<RestEndpoint> restEndpoints;

  44.     /**
  45.      * Client will be generated in this package
  46.      */
  47.     private final String packageName;

  48.     /**
  49.      * Generated client name
  50.      */
  51.     private final String clientName;

  52.     /**
  53.      * Javadoc as extracted by the annotation processor
  54.      */
  55.     private final Map<String, ClassDoc> javadoc;

  56.     /**
  57.      * Main REST application path
  58.      */
  59.     private String applicationPath = null;

  60.     /**
  61.      * Initialize the client generator.
  62.      *
  63.      * @param restPackages packages to analyze
  64.      * @param packageName  package in which generate the client
  65.      * @param clientName   client class name
  66.      */
  67.     public Generator(String[] restPackages,
  68.         String packageName, String clientName
  69.     ) {
  70.         this.packageName = packageName;
  71.         this.clientName = clientName;

  72.         List<Object> pkgs = new ArrayList<>();
  73.         pkgs.add("ch.colabproject.colab.generator.model");
  74.         pkgs.addAll(Arrays.asList(restPackages));

  75.         this.reflections = new Reflections(pkgs.toArray());
  76.         this.javadoc = JavaDocExtractor.loadJavaDocFromJson();
  77.     }

  78.     /**
  79.      * Initialize the client generator.
  80.      *
  81.      * @param pkgs packages to analyze
  82.      */
  83.     public Generator(String[] pkgs) {
  84.         this(pkgs, null, null);
  85.     }

  86.     /**
  87.      * get JSON-B to use.
  88.      *
  89.      * @return jsbonb mapper
  90.      */
  91.     public Jsonb getJsonBMapper() {
  92.         return JsonbProvider.getJsonb();
  93.     }

  94.     /**
  95.      * Process all classes annotated with {@link Path}. Generate a {@link RestEndpoint} instance for
  96.      * each class and store them in {@link #restEndpoints}
  97.      */
  98.     public void processPackages() {
  99.         Set<Class<?>> appConfig = reflections.getTypesAnnotatedWith(ApplicationPath.class);
  100.         if (!appConfig.isEmpty()) {
  101.             if (appConfig.size() > 1) {
  102.                 Logger.warn("Several ApplicationPath found");
  103.             }
  104.             Class<?> applicationConfig = appConfig.iterator().next();
  105.             ApplicationPath annotation = applicationConfig.getAnnotation(ApplicationPath.class);
  106.             if (annotation != null) {
  107.                 this.applicationPath = annotation.value();
  108.             }
  109.         }

  110.         Set<Class<?>> restClasses = reflections.getTypesAnnotatedWith(Path.class);

  111.         this.restEndpoints = restClasses.stream()
  112.             .map(klass -> RestEndpoint.build(klass, applicationPath))
  113.             .collect(Collectors.toSet());
  114.         /*
  115.          * .map(p -> p.generateJavaClient()) .innerClasses(Collectors.toList());
  116.          */
  117.     }

  118.     /**
  119.      * One all classes have been processed with {@link #processPackages() }, this method will create
  120.      * subdirectories that match the <code>packageName</code>.Then the client will be generated in
  121.      * the <code>clientName</code>.java file.
  122.      *
  123.      * @param targetDir target directory
  124.      * @param dryRun    if true, do not generate files but print output to console
  125.      *
  126.      * @throws org.apache.maven.plugin.MojoFailureException if generation fails
  127.      */
  128.     public void generateJavaClient(String targetDir, boolean dryRun) throws MojoFailureException {
  129.         Map<String, String> imports = new HashMap<>();
  130.         imports.put("RestClient", "ch.colabproject.colab.generator.plugin.rest.RestClient");
  131.         imports.put("Jsonb", "javax.json.bind.Jsonb");
  132.         imports.put("GenericType", "javax.ws.rs.core.GenericType");
  133.         imports.put("PathPattern", "org.glassfish.jersey.uri.PathPattern");
  134.         imports.put("UriTemplate", "org.glassfish.jersey.uri.UriTemplate");
  135.         imports.put("void", null); // null means no import statement

  136.         String innerClasses = this.restEndpoints.stream().map(controller -> {
  137.             String javaCode = controller.generateJavaClient(
  138.                 imports,
  139.                 clientName,
  140.                 javadoc,
  141.                 reflections
  142.             );
  143.             return javaCode;
  144.         }).collect(Collectors.joining(System.lineSeparator()));

  145.         StringBuilder sb = new StringBuilder();

  146.         sb.append("package ").append(packageName).append(";\n\n")
  147.             .append(
  148.                 imports.values().stream()
  149.                     .filter(pkg -> pkg != null)
  150.                     .filter(pkg -> !pkg.startsWith("java.lang")) // do not import java.lang
  151.                     .sorted()
  152.                     .map(pkg -> "import " + pkg + ";")
  153.                     .collect(Collectors.joining(System.lineSeparator()))
  154.             )
  155.             .append("\n"
  156.                 + "/**\n"
  157.                 + " * The ")
  158.             .append(clientName)
  159.             .append(" REST client"
  160.                 + " */\n"
  161.                 + "@SuppressWarnings(\"PMD.FieldDeclarationsShouldBeAtStartOfClass\")\n"
  162.                 + "public class ")
  163.             .append(clientName)
  164.             .append(" extends RestClient {"
  165.                 + "\n"
  166.                 + "\n"
  167.                 + "    /**\n"
  168.                 + "     * Get a REST client\n"
  169.                 + "     *\n"
  170.                 + "     * @param baseUri        base URI\n"
  171.                 + "     * @param cookieName     session cookie name\n"
  172.                 + "     * @param jsonb          jsonb\n"
  173.                 + "     * @param clientFeatures addition http client feature\n"
  174.                 + "     */\n"
  175.                 + "    public ")
  176.             .append(clientName)
  177.             .append("(String baseUri, String cookieName, Jsonb jsonb, Object... clientFeatures) {\n"
  178.                 + "        super(baseUri, cookieName, jsonb, clientFeatures);\n"
  179.                 + "    }")
  180.             .append(innerClasses)
  181.             .append("}");

  182.         if (dryRun) {
  183.             Logger.debug(sb.toString());
  184.         } else {
  185.             String packagePath = targetDir + "/" + packageName.replaceAll("\\.", "/");
  186.             writeFile(sb.toString(), packagePath, clientName + ".java");
  187.         }
  188.     }

  189.     /**
  190.      * Get rest service description
  191.      *
  192.      * @return all rest resource
  193.      */
  194.     public Set<RestEndpoint> getRestEndpoints() {
  195.         return restEndpoints;
  196.     }

  197.     /**
  198.      * Generate typescript client in targetDir
  199.      *
  200.      * @param targetDir generate TS module in this directory
  201.      * @param dryRun    if true, do not generate any file but print output to console
  202.      *
  203.      * @throws org.apache.maven.plugin.MojoFailureException if generation fails
  204.      */
  205.     public void generateTypescriptClient(String targetDir, boolean dryRun)
  206.         throws MojoFailureException {
  207.         Map<String, Type> extraTypes = new HashMap<>();
  208.         StringBuilder sb = new StringBuilder();
  209.         sb.append(this.getTsClientTemplate());
  210.         extraTypes.put("WithJsonDiscriminator", WithJsonDiscriminator.class);
  211.         extraTypes.put("WithId", WithId.class);

  212.         String modules = this.restEndpoints.stream().map(
  213.             controller -> controller.generateTypescriptClient(extraTypes, this.javadoc, reflections)
  214.         ).collect(Collectors.joining(System.lineSeparator()));

  215.         // TS interface name => list of @class values
  216.         Map<String, List<String>> inheritance = new HashMap<>();

  217.         List<Entry<String, Type>> queue = new ArrayList<>(extraTypes.entrySet());
  218.         while (!queue.isEmpty()) {
  219.             Map<String, Type> snowballedTypes = new HashMap<>();

  220.             Entry<String, Type> entry = queue.remove(0);

  221.             String tsInterface = TypeScriptHelper.generateInterface(
  222.                 entry.getValue(),
  223.                 snowballedTypes,
  224.                 inheritance,
  225.                 reflections,
  226.                 javadoc
  227.             );
  228.             sb.append(tsInterface);

  229.             snowballedTypes.forEach((name, type) -> {
  230.                 if (!extraTypes.containsKey(name)) {
  231.                     extraTypes.put(name, type);
  232.                     queue.add(0, new SimpleEntry<>(name, type));
  233.                 }
  234.             });
  235.         }

  236.         sb.append("/**\n"
  237.             + " * Some orthopedic tools\n"
  238.             + " */\n\n"
  239.             + "export interface TypeMap {\n  ")
  240.             .append(
  241.                 inheritance.keySet().stream().map((key) -> key + ": " + key + ";")
  242.                     .collect(Collectors.joining("\n  "))
  243.             )
  244.             .append("\n}\n\n")
  245.             .append("const inheritance : {[key: string]: string[]} = {\n")
  246.             .append(
  247.                 inheritance.entrySet().stream()
  248.                     .map((entry) -> entry.getKey() + ": [" + entry.getValue().stream()
  249.                         .map(v -> "'" + v + "'")
  250.                         .collect(Collectors.joining(", ")) + "]"
  251.                     ).collect(Collectors.joining(",\n  ")))
  252.             .append("\n}\n\n")
  253.             .append("export const entityIs = <T extends keyof TypeMap>(entity: unknown, klass: T)\n"
  254.                 + "    : entity is TypeMap[T] => {\n"
  255.                 + "\n"
  256.                 + "    if (typeof entity === 'object' && entity != null) {\n"
  257.                 + "        if (\"@class\" in entity) {\n"
  258.                 + "            const dis = entity[\"@class\"];\n"
  259.                 + "            if (typeof dis === 'string') {\n"
  260.                 + "                return inheritance[klass].includes(dis);\n"
  261.                 + "            }\n"
  262.                 + "        }\n"
  263.                 + "    }\n"
  264.                 + "    return false;\n"
  265.                 + "}")
  266.             .append("\n\n\n/**\n"
  267.                 + "* The ")
  268.             .append(clientName).append(" REST client\n"
  269.                 + " */\n"
  270.                 + "export const ")
  271.             .append(clientName)
  272.             .append(
  273.                 " = function (baseUrl: string, defaultErrorHandler: (error: unknown) => void) {")
  274.             .append("\n    return {")
  275.             .append(modules)
  276.             .append("    }\n")
  277.             .append("}");

  278.         if (dryRun) {
  279.             Logger.debug(sb.toString());
  280.         } else {
  281.             String srcPath = targetDir + "/src";
  282.             writeFile(sb.toString(), srcPath, clientName + ".ts");

  283.             writeFile("export * from './dist/" + clientName + "';", targetDir, "index.ts");
  284.             writeFile(generatePackageDotJson(), targetDir, "package.json");
  285.             writeFile(generateTsconfigDotJson(), targetDir, "tsconfig.json");
  286.         }
  287.     }

  288.     /**
  289.      * Write file to disk.
  290.      *
  291.      * @param content   file content
  292.      * @param directory directory, will be created if missing
  293.      * @param filename  filename
  294.      */
  295.     private void writeFile(String content, String directory, String filename)
  296.         throws MojoFailureException {
  297.         try {
  298.             Files.createDirectories(java.nio.file.Path.of(directory));

  299.             try (BufferedWriter writer = Files.newBufferedWriter(
  300.                 java.nio.file.Path.of(directory, filename))) {
  301.                 writer.write(content);
  302.             } catch (IOException ex) {
  303.                 if (Logger.isInit()) {
  304.                     throw new MojoFailureException("Failed to write '"
  305.                         + filename + "' in '" + directory + "'", ex);
  306.                 }
  307.             }

  308.         } catch (IOException ex) {
  309.             if (Logger.isInit()) {
  310.                 throw new MojoFailureException("Failed to create package directory "
  311.                     + directory, ex);
  312.             }
  313.         }
  314.     }

  315.     /**
  316.      * Convert Java className-like to dash-separated-lower-case version.
  317.      *
  318.      * @param name eg. MyAwesomeRestClient
  319.      *
  320.      * @return eg. my-awesome-rest-client
  321.      */
  322.     private String generateModuleName(String name) {
  323.         return name
  324.             .trim()
  325.             // prefix all uppercase char preceded by something with an dash
  326.             .replaceAll("(?<!^)[A-Z](?!$)", "-$0")
  327.             .toLowerCase();
  328.     }

  329.     /**
  330.      * get TS client tempalte
  331.      *
  332.      * @return intial content of the TS client
  333.      */
  334.     private String getTsClientTemplate() {
  335.         String tsConfig = FileHelper.readFile("templates/client.ts");
  336.         return tsConfig.replaceAll(
  337.             "\\{\\{MODULE_NAME\\}\\}",
  338.             generateModuleName(clientName)
  339.         );
  340.     }

  341.     /**
  342.      * generate package.json
  343.      *
  344.      * @return content of package.json
  345.      */
  346.     private String generatePackageDotJson() {
  347.         String tsConfig = FileHelper.readFile("templates/package.json");
  348.         return tsConfig.replaceAll(
  349.             "\\{\\{MODULE_NAME\\}\\}",
  350.             generateModuleName(clientName)
  351.         );
  352.     }

  353.     /**
  354.      * generate tsconfig.json
  355.      *
  356.      * @return content of package.json
  357.      */
  358.     private String generateTsconfigDotJson() {
  359.         String tsConfig = FileHelper.readFile("templates/tsconfig.json");
  360.         return tsConfig.replaceAll("\\{\\{CLIENT_NAME\\}\\}", this.clientName);
  361.     }
  362. }