Generator.java

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

import ch.colabproject.colab.generator.model.interfaces.WithId;
import ch.colabproject.colab.generator.model.interfaces.WithJsonDiscriminator;
import ch.colabproject.colab.generator.model.tools.ClassDoc;
import ch.colabproject.colab.generator.model.tools.JavaDocExtractor;
import ch.colabproject.colab.generator.model.tools.JsonbProvider;
import ch.colabproject.colab.generator.plugin.rest.RestEndpoint;
import java.io.BufferedWriter;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.util.AbstractMap.SimpleEntry;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.stream.Collectors;
import javax.json.bind.Jsonb;
import javax.ws.rs.ApplicationPath;
import javax.ws.rs.Path;
import org.apache.maven.plugin.MojoFailureException;
import org.reflections.Reflections;

/**
 * @author maxence
 */
public class Generator {

    /**
     * Reflections one-stop-shop object.
     */
    private final Reflections reflections;

    /**
     * All rest controller found.
     */
    private Set<RestEndpoint> restEndpoints;

    /**
     * Client will be generated in this package
     */
    private final String packageName;

    /**
     * Generated client name
     */
    private final String clientName;

    /**
     * Javadoc as extracted by the annotation processor
     */
    private final Map<String, ClassDoc> javadoc;

    /**
     * Main REST application path
     */
    private String applicationPath = null;

    /**
     * Initialize the client generator.
     *
     * @param restPackages packages to analyze
     * @param packageName  package in which generate the client
     * @param clientName   client class name
     */
    public Generator(String[] restPackages,
        String packageName, String clientName
    ) {
        this.packageName = packageName;
        this.clientName = clientName;

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

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

    /**
     * Initialize the client generator.
     *
     * @param pkgs packages to analyze
     */
    public Generator(String[] pkgs) {
        this(pkgs, null, null);
    }

    /**
     * get JSON-B to use.
     *
     * @return jsbonb mapper
     */
    public Jsonb getJsonBMapper() {
        return JsonbProvider.getJsonb();
    }

    /**
     * Process all classes annotated with {@link Path}. Generate a {@link RestEndpoint} instance for
     * each class and store them in {@link #restEndpoints}
     */
    public void processPackages() {
        Set<Class<?>> appConfig = reflections.getTypesAnnotatedWith(ApplicationPath.class);
        if (!appConfig.isEmpty()) {
            if (appConfig.size() > 1) {
                Logger.warn("Several ApplicationPath found");
            }
            Class<?> applicationConfig = appConfig.iterator().next();
            ApplicationPath annotation = applicationConfig.getAnnotation(ApplicationPath.class);
            if (annotation != null) {
                this.applicationPath = annotation.value();
            }
        }

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

        this.restEndpoints = restClasses.stream()
            .map(klass -> RestEndpoint.build(klass, applicationPath))
            .collect(Collectors.toSet());
        /*
         * .map(p -> p.generateJavaClient()) .innerClasses(Collectors.toList());
         */
    }

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

        String innerClasses = this.restEndpoints.stream().map(controller -> {
            String javaCode = controller.generateJavaClient(
                imports,
                clientName,
                javadoc,
                reflections
            );
            return javaCode;
        }).collect(Collectors.joining(System.lineSeparator()));

        StringBuilder sb = new StringBuilder();

        sb.append("package ").append(packageName).append(";\n\n")
            .append(
                imports.values().stream()
                    .filter(pkg -> pkg != null)
                    .filter(pkg -> !pkg.startsWith("java.lang")) // do not import java.lang
                    .sorted()
                    .map(pkg -> "import " + pkg + ";")
                    .collect(Collectors.joining(System.lineSeparator()))
            )
            .append("\n"
                + "/**\n"
                + " * The ")
            .append(clientName)
            .append(" REST client"
                + " */\n"
                + "@SuppressWarnings(\"PMD.FieldDeclarationsShouldBeAtStartOfClass\")\n"
                + "public class ")
            .append(clientName)
            .append(" extends RestClient {"
                + "\n"
                + "\n"
                + "    /**\n"
                + "     * Get a REST client\n"
                + "     *\n"
                + "     * @param baseUri        base URI\n"
                + "     * @param cookieName     session cookie name\n"
                + "     * @param jsonb          jsonb\n"
                + "     * @param clientFeatures addition http client feature\n"
                + "     */\n"
                + "    public ")
            .append(clientName)
            .append("(String baseUri, String cookieName, Jsonb jsonb, Object... clientFeatures) {\n"
                + "        super(baseUri, cookieName, jsonb, clientFeatures);\n"
                + "    }")
            .append(innerClasses)
            .append("}");

        if (dryRun) {
            Logger.debug(sb.toString());
        } else {
            String packagePath = targetDir + "/" + packageName.replaceAll("\\.", "/");
            writeFile(sb.toString(), packagePath, clientName + ".java");
        }
    }

    /**
     * Get rest service description
     *
     * @return all rest resource
     */
    public Set<RestEndpoint> getRestEndpoints() {
        return restEndpoints;
    }

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

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

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

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

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

            String tsInterface = TypeScriptHelper.generateInterface(
                entry.getValue(),
                snowballedTypes,
                inheritance,
                reflections,
                javadoc
            );
            sb.append(tsInterface);

            snowballedTypes.forEach((name, type) -> {
                if (!extraTypes.containsKey(name)) {
                    extraTypes.put(name, type);
                    queue.add(0, new SimpleEntry<>(name, type));
                }
            });
        }

        sb.append("/**\n"
            + " * Some orthopedic tools\n"
            + " */\n\n"
            + "export interface TypeMap {\n  ")
            .append(
                inheritance.keySet().stream().map((key) -> key + ": " + key + ";")
                    .collect(Collectors.joining("\n  "))
            )
            .append("\n}\n\n")
            .append("const inheritance : {[key: string]: string[]} = {\n")
            .append(
                inheritance.entrySet().stream()
                    .map((entry) -> entry.getKey() + ": [" + entry.getValue().stream()
                        .map(v -> "'" + v + "'")
                        .collect(Collectors.joining(", ")) + "]"
                    ).collect(Collectors.joining(",\n  ")))
            .append("\n}\n\n")
            .append("export const entityIs = <T extends keyof TypeMap>(entity: unknown, klass: T)\n"
                + "    : entity is TypeMap[T] => {\n"
                + "\n"
                + "    if (typeof entity === 'object' && entity != null) {\n"
                + "        if (\"@class\" in entity) {\n"
                + "            const dis = entity[\"@class\"];\n"
                + "            if (typeof dis === 'string') {\n"
                + "                return inheritance[klass].includes(dis);\n"
                + "            }\n"
                + "        }\n"
                + "    }\n"
                + "    return false;\n"
                + "}")
            .append("\n\n\n/**\n"
                + "* The ")
            .append(clientName).append(" REST client\n"
                + " */\n"
                + "export const ")
            .append(clientName)
            .append(
                " = function (baseUrl: string, defaultErrorHandler: (error: unknown) => void) {")
            .append("\n    return {")
            .append(modules)
            .append("    }\n")
            .append("}");

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

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

    /**
     * Write file to disk.
     *
     * @param content   file content
     * @param directory directory, will be created if missing
     * @param filename  filename
     */
    private void writeFile(String content, String directory, String filename)
        throws MojoFailureException {
        try {
            Files.createDirectories(java.nio.file.Path.of(directory));

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

        } catch (IOException ex) {
            if (Logger.isInit()) {
                throw new MojoFailureException("Failed to create package directory "
                    + directory, ex);
            }
        }
    }

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

    /**
     * get TS client tempalte
     *
     * @return intial content of the TS client
     */
    private String getTsClientTemplate() {
        String tsConfig = FileHelper.readFile("templates/client.ts");
        return tsConfig.replaceAll(
            "\\{\\{MODULE_NAME\\}\\}",
            generateModuleName(clientName)
        );
    }

    /**
     * generate package.json
     *
     * @return content of package.json
     */
    private String generatePackageDotJson() {
        String tsConfig = FileHelper.readFile("templates/package.json");
        return tsConfig.replaceAll(
            "\\{\\{MODULE_NAME\\}\\}",
            generateModuleName(clientName)
        );
    }

    /**
     * generate tsconfig.json
     *
     * @return content of package.json
     */
    private String generateTsconfigDotJson() {
        String tsConfig = FileHelper.readFile("templates/tsconfig.json");
        return tsConfig.replaceAll("\\{\\{CLIENT_NAME\\}\\}", this.clientName);
    }
}