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

dk.mada.jaxrs.generator.mpclient.api.ApiGenerator Maven / Gradle / Ivy

There is a newer version: 0.11.8
Show newest version
package dk.mada.jaxrs.generator.mpclient.api;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import dk.mada.jaxrs.generator.mpclient.CommonPathFinder;
import dk.mada.jaxrs.generator.mpclient.GeneratorOpts;
import dk.mada.jaxrs.generator.mpclient.MediaTypes;
import dk.mada.jaxrs.generator.mpclient.StringRenderer;
import dk.mada.jaxrs.generator.mpclient.Templates;
import dk.mada.jaxrs.generator.mpclient.ValidationGenerator;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.CtxApi;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.CtxApi.CtxOperationRef;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.CtxApiExt;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.CtxApiOp;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.CtxApiOpExt;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.CtxApiParam;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.CtxApiResponse;
import dk.mada.jaxrs.generator.mpclient.api.tmpl.ImmutableCtxApiParam;
import dk.mada.jaxrs.generator.mpclient.dto.tmpl.CtxValidation;
import dk.mada.jaxrs.generator.mpclient.imports.Imports;
import dk.mada.jaxrs.generator.mpclient.imports.Jspecify;
import dk.mada.jaxrs.generator.mpclient.imports.MicroProfile;
import dk.mada.jaxrs.generator.mpclient.imports.RestEasy;
import dk.mada.jaxrs.model.Info;
import dk.mada.jaxrs.model.Model;
import dk.mada.jaxrs.model.Validation;
import dk.mada.jaxrs.model.api.Content;
import dk.mada.jaxrs.model.api.ContentSelector.ContentContext;
import dk.mada.jaxrs.model.api.ContentSelector.Location;
import dk.mada.jaxrs.model.api.Operation;
import dk.mada.jaxrs.model.api.Parameter;
import dk.mada.jaxrs.model.api.Response;
import dk.mada.jaxrs.model.api.StatusCode;
import dk.mada.jaxrs.model.naming.Naming;
import dk.mada.jaxrs.model.types.Primitive;
import dk.mada.jaxrs.model.types.Reference;
import dk.mada.jaxrs.model.types.Type;
import dk.mada.jaxrs.model.types.TypeByteArray;
import dk.mada.jaxrs.model.types.TypeContainer;
import dk.mada.jaxrs.model.types.TypeReference;
import dk.mada.jaxrs.model.types.TypeSet;
import dk.mada.jaxrs.model.types.TypeVoid;

/**
 * API generator.
 *
 * Processes the model's APIs and prepares API contexts for template rendering.
 */
public class ApiGenerator {
    private static final Logger logger = LoggerFactory.getLogger(ApiGenerator.class);

    /** Naming. */
    private final Naming naming;
    /** Generator options. */
    private final GeneratorOpts opts;
    /** Templates. */
    private final Templates templates;
    /** The data model. */
    private final Model model;

    /** Common path finder. */
    private final CommonPathFinder commonPathFinder = new CommonPathFinder();
    /** Validation generator. */
    private ValidationGenerator validationGenerator;

    /**
     * Constructs a new API generator.
     *
     * @param generatorOpts the generator options
     * @param templates     the templates instance
     * @param model         the data model
     */
    public ApiGenerator(GeneratorOpts generatorOpts, Templates templates, Model model) {
        this.naming = model.naming();
        this.opts = generatorOpts;
        this.templates = templates;
        this.model = model;

        validationGenerator = new ValidationGenerator(generatorOpts);
    }

    /**
     * Generates all API classes.
     *
     * @param apiDir the directory to generate API classes in
     */
    public void generateApiClasses(Path apiDir) {
        model.operations().getByGroup().entrySet().stream()
                .sorted((a, b) -> a.getKey().compareTo(b.getKey()))
                .forEach(ops -> {
                    String group = ops.getKey();
                    String classname = makeClassName(group);

                    processApi(apiDir, ops, classname);
                });
    }

    private void processApi(Path apiDir, Entry> ops, String classname) {
        CtxApi ctx = toCtx(classname, ops.getValue());
        templates.renderApiTemplate(apiDir, ctx);
    }

    private String makeClassName(String groupInput) {
        String defaultApiName = opts.getDefaultApiName().orElse(groupInput);

        String group = groupInput;
        if ("Default".equals(groupInput)) {
            group = defaultApiName;
        }
        String input = group.endsWith("Api") ? group : group + "Api";
        return naming.convertApiName(input);
    }

    private CtxApi toCtx(String classname, List operations) {
        var imports = Imports.newApi(opts);

        List paths = operations.stream()
                .map(Operation::path)
                .toList();
        String commonPath = commonPathFinder.findCommonPath(paths);

        int trimPathLength = commonPath.length();

        List ops = makeOperations(operations, imports, trimPathLength);

        imports.trimContainerImplementations();

        Optional clientKey = opts.getMpClientConfigKey();
        if (clientKey.isPresent()) {
            imports.add(MicroProfile.REGISTER_REST_CLIENT);
        }

        List mpProviders = opts.getMpProviders().stream()
                .sorted()
                .toList();
        if (!mpProviders.isEmpty()) {
            imports.add(MicroProfile.REGISTER_PROVIDER);
        }

        CtxApiExt apiExt = CtxApiExt.builder()
                .mpRestClientConfigKey(clientKey)
                .mpProviders(mpProviders)
                .isJspecify(opts.isJspecify())
                .build();

        Info info = model.info();
        return CtxApi.builder()
                .appDescription(info.description())
                .appName(info.title())
                .version(info.version())
                .infoEmail(info.contact().email())
                .generatedAnnotationClass(opts.getGeneratorAnnotationClass())
                .generatedDate(opts.getGeneratedAtTime())
                .generatorClass(opts.generatorId())
                .classname(classname)
                .operations(ops)
                .packageName(opts.apiPackage())
                .imports(imports.get())
                .commonPath(commonPath)
                .madaApi(apiExt)
                .build();
    }

    private List makeOperations(List operations, Imports imports, int trimPathLength) {
        return operations.stream()
                .sorted(this::operatorOrdering)
                .map(op -> toCtxApiOperation(imports, trimPathLength, op))
                .toList();
    }

    private int operatorOrdering(Operation a, Operation b) {
        int pathComparison = a.path().compareTo(b.path());
        if (pathComparison != 0) {
            return pathComparison;
        }

        return a.httpMethod().compareTo(b.httpMethod());
    }

    private CtxOperationRef toCtxApiOperation(Imports imports, int trimPathLength, Operation op) {
        addOperationImports(imports, op);

        String nickname = op.operationId()
                .orElse(op.syntheticOpId());

        // Gets type for OK if present, or else default, or else void
        Reference returnTypeRef = getTypeForStatus(op, StatusCode.HTTP_OK)
                .or(() -> getTypeForStatus(op, StatusCode.HTTP_CREATED))
                .or(() -> getTypeForStatus(op, StatusCode.HTTP_DEFAULT))
                .orElse(TypeVoid.getRef());

        // If OK is declared as Void, replace with default type (if available)
        Content okContent = getContentForStatus(op, StatusCode.HTTP_OK)
                .orElse(null);
        Content defaultContent = getContentForStatus(op, StatusCode.HTTP_DEFAULT)
                .orElse(null);
        if (okContent != null && okContent.reference().isVoid()
                && defaultContent != null) {
            returnTypeRef = defaultContent.reference();
        }

        // Gets matching media types, check for input-stream replacement
        Set mediaTypes = getMediaTypeForStatus(op, StatusCode.HTTP_OK)
                .or(() -> getMediaTypeForStatus(op, StatusCode.HTTP_CREATED))
                .or(() -> getMediaTypeForStatus(op, StatusCode.HTTP_DEFAULT))
                .orElse(Set.of());
        boolean replaceResponseWithInputStream = opts.getResponseInputStreamMediaTypes().stream()
                .anyMatch(mediaTypes::contains);
        if (replaceResponseWithInputStream) {
            returnTypeRef = TypeReference.of(TypeByteArray.getStream(), returnTypeRef.validation());
            imports.add(returnTypeRef);
        }

        String path = op.path().substring(trimPathLength);
        if (path.isEmpty()) {
            path = null;
        }

        Optional producesMediaType = makeProduces(imports, op, returnTypeRef);

        List allParams = getParams(imports, op);
        List responses = getResponses(imports, op, producesMediaType.stream().toList());

        boolean onlySimpleResponse = addResponseImports(imports, op);

        boolean renderJavadocReturn = !returnTypeRef.isVoid();
        boolean renderJavadocMacroSpacer = renderJavadocReturn || !allParams.isEmpty();

        Optional summary = op.summary();

        Optional opSummaryString = StringRenderer.encodeForString(summary);
        if (summary.isPresent()) {
            imports.add(MicroProfile.OPERATION);
        }

        CtxApiOpExt ext = CtxApiOpExt.builder()
                .consumes(makeConsumes(imports, op))
                .produces(producesMediaType)
                .renderJavadocMacroSpacer(renderJavadocMacroSpacer)
                .renderJavadocReturn(renderJavadocReturn)
                .responseSchema(onlySimpleResponse)
                .hasResponses(!responses.isEmpty())
                .summaryString(opSummaryString)
                .build();

        Optional description = op.description();

        return new CtxOperationRef(CtxApiOp.builder()
                .nickname(nickname)
                .returnType(returnTypeRef.typeName().name())
                .path(Optional.ofNullable(path))
                .httpMethod(op.httpMethod().name())
                .allParams(allParams)
                .responses(responses)
                .summary(summary.flatMap(StringRenderer::makeValidOperationJavadocSummary))
                .notes(description)
                .madaOp(ext)
                .build());
    }

    private Optional getTypeForStatus(Operation op, StatusCode statusCode) {
        return getContentForStatus(op, statusCode)
                .map(Content::reference);
    }

    private Optional> getMediaTypeForStatus(Operation op, StatusCode statusCode) {
        return getContentForStatus(op, statusCode)
                .map(Content::mediaTypes);
    }

    private Optional getContentForStatus(Operation op, StatusCode statusCode) {
        return op.responses().stream()
                .filter(r -> r.code() == statusCode)
                .map(Response::content)
                .findFirst();
    }

    private boolean addResponseImports(Imports imports, Operation op) {
        if (op.responses().isEmpty()) {
            return false;
        }

        boolean onlySimpleResponse = isOnlySimpleResponse(op.responses());
        if (onlySimpleResponse) {
            imports.add(MicroProfile.API_RESPONSE_SCHEMA);
        } else {
            imports.add(MicroProfile.API_RESPONSE, MicroProfile.API_RESPONSES);

            if (!op.isVoid()) {
                imports.add(MicroProfile.CONTENT, MicroProfile.SCHEMA);
            }
        }
        return onlySimpleResponse;
    }

    /**
     * Determine if all the response types can be rendered via the simple @APIResponseSchema annotation.
     *
     * Probably needs to be more clever - must consider that description matches code(). But this will do for now.
     *
     * @param responses the responses
     */
    private boolean isOnlySimpleResponse(List responses) {
        if (responses.size() != 1) {
            return false;
        }

        Response r = responses.get(0);
        boolean isContainer = r.content().reference().isContainer();
        boolean isSimpleResponse = !isContainer
                && r.code() == StatusCode.HTTP_OK
                && !r.isVoid();
        logger.debug(" simple: {}", isSimpleResponse);
        return isSimpleResponse;
    }

    private void addOperationImports(Imports imports, Operation op) {
        op.responses().stream()
                .map(r -> r.content().reference())
                .forEach(imports::add);
    }

    /**
     * Note from https://docs.oracle.com/cd/E19776-01/820-4867/ghrst/index.html If @DefaultValue is not used in conjunction
     * with @QueryParam, and the query parameter is not present in the request, then value will be an empty collection for
     * List, Set, or SortedSet; null for other object types; and the Java-defined default for primitive types.
     *
     * So the primitive types can be used instead of wrapper types.
     *
     * @param imports the imports for the API
     * @param op      the operation to extract parameters from
     */
    private List getParams(Imports imports, Operation op) {
        List params = new ArrayList<>();
        if (op.addAuthorizationHeader()) {
            params.add(CtxApiParam.builder()
                    .baseName("Authorization")
                    .paramName("auth")
                    .dataType(Primitive.STRING.typeName().name())
                    .isContainer(false)
                    .isBodyParam(false)
                    .isFormParam(false)
                    .isHeaderParam(true)
                    .isPathParam(false)
                    .isQueryParam(false)
                    .validation(Optional.of(validationGenerator.makeRequired()))
                    .isMultipartForm(false)
                    .isNullable(false)
                    .build());
        }

        for (Parameter p : op.parameters()) {
            Reference ref = p.reference();
            imports.add(ref);

            String name = p.name();
            String paramName = naming.convertParameterName(name);

            Type type = ref.refType();
            String dataType = paramDataType(type);

            Validation validation = ref.validation();
            logger.debug("See param {} : {} : {}", paramName, type, validation);

            Optional valCtx = validationGenerator.makeValidation(imports, type, validation);

            boolean isNullableRef = type.isPrimitive(Primitive.STRING)
                    || (!type.isPrimitive() || opts.isUseApiWrappedPrimitives());
            boolean validationAllowsNull = !valCtx.map(v -> v.notNull()).orElse(false);
            boolean isNullable = validationAllowsNull
                    && isNullableRef
                    && (p.isQueryParam() || p.isHeaderParam());
            if (isNullable && opts.isJspecify()) {
                imports.add(Jspecify.NULLABLE);
            }

            ImmutableCtxApiParam param = CtxApiParam.builder()
                    .baseName(name)
                    .paramName(paramName)
                    .dataType(dataType)
                    .validation(valCtx)
                    .description(p.description())
                    .isContainer(false)
                    .isBodyParam(false)
                    .isFormParam(p.isFormParam())
                    .isHeaderParam(p.isHeaderParam())
                    .isQueryParam(p.isQueryParam())
                    .isPathParam(p.isPathParam())
                    .isMultipartForm(false)
                    .isNullable(isNullable)
                    .build();

            logger.debug("PARAM {} : {}", name, p.isFormParam());
            params.add(param);
        }

        op.requestBody().ifPresent(body -> {
            Reference ref = body.content().reference();
            imports.add(ref);

            String preferredDtoParamName = naming.convertEntityName(ref.typeName().name());

            // Guard against (simple) conflict with other parameters
            boolean dtoParamNameNotUnique = params.stream()
                    .anyMatch(p -> p.paramName().equals(preferredDtoParamName));
            String dtoParamName = dtoParamNameNotUnique ? preferredDtoParamName + "Entity" : preferredDtoParamName;

            String dataType = paramDataType(ref);

            Optional valCtx = validationGenerator.makeValidation(imports, ref.refType(), ref.validation());

            boolean isMultipartForm = body.isMultipartForm();
            if (isMultipartForm) {
                imports.add(RestEasy.MULTIPART_FORM);
            }
            CtxApiParam bodyParam = CtxApiParam.builder()
                    .baseName("unused")
                    .paramName(dtoParamName)
                    .dataType(dataType)
                    .validation(valCtx)
                    .description(body.description())
                    .isContainer(false)
                    .isBodyParam(true)
                    .isFormParam(false)
                    .isHeaderParam(false)
                    .isPathParam(false)
                    .isQueryParam(false)
                    .isMultipartForm(isMultipartForm)
                    .isNullable(false)
                    .build();

            // Only include body param if it is not void. It may be void
            // when there are form parameters.
            if (!ref.isVoid()) {
                params.add(bodyParam);
            }
            logger.debug("BODY {}", bodyParam);
        });

        logger.debug("Params: {}", params);

        return params;
    }

    private String paramDataType(Type type) {
        return opts.isUseApiWrappedPrimitives()
                ? type.wrapperTypeName().name()
                : type.typeName().name();
    }

    private List getResponses(Imports imports, Operation op, List producesMediaTypes) {
        return op.responses().stream()
                .sorted((a, b) -> a.code().compareTo(b.code()))
                .map(r -> makeResponse(imports, op, r, producesMediaTypes))
                .toList();
    }

    private CtxApiResponse makeResponse(Imports imports, Operation op, Response r, List opResponseMediaTypes) {
        String baseType;
        String containerType;
        Reference typeRef = r.content().reference();
        Type type = typeRef.refType();
        boolean isUnique = false;

        if (type instanceof TypeContainer tc) {
            baseType = tc.innerType().wrapperTypeName().name();
            containerType = "SchemaType.ARRAY";
            imports.add(MicroProfile.SCHEMA_TYPE);

            isUnique = type instanceof TypeSet;
        } else if (type.isVoid()) {
            baseType = null;
            containerType = null;
        } else {
            baseType = type.wrapperTypeName().name();
            containerType = null;
        }

        Set responseMediaTypes = r.content().mediaTypes();

        // Only define an explicit media-type for the response, iff it is
        // not same media-type already declared for the operation
        ContentContext context = new ContentContext(op.path(), r.code(), true, Location.RESPONSE, false);
        Optional mediaType = model.contentSelector()
                .selectPreferredMediaType(responseMediaTypes, context)
                .map(mt -> MediaTypes.toMediaType(imports, mt))
                .filter(mt -> !opResponseMediaTypes.contains(mt));

        String description = r.description()
                .orElse("");

        return CtxApiResponse.builder()
                .baseType(baseType)
                .code(r.code().asOpenApiStatus())
                .containerType(containerType)
                .description(StringRenderer.encodeForString(description))
                .isUnique(isUnique)
                .mediaType(mediaType)
                .build();
    }

    // TODO: these should be combined smarter - base should take Content as argument instead, avoid use of streams

    private Optional makeConsumes(Imports imports, Operation op) {
        List mediaTypes = op.requestBody()
                .map(rb -> rb.content().mediaTypes().stream()
                        .sorted()
                        .distinct()
                        .toList())
                .orElse(List.of());

        ContentContext context = new ContentContext(op.path(), StatusCode.HTTP_DEFAULT, false, Location.REQUEST, false);
        return model.contentSelector().selectPreferredMediaType(mediaTypes, context)
                .map(mt -> MediaTypes.toMediaType(imports, mt));
    }

    /**
     * Makes list of media types for @Produces
     *
     * Note that the client's @Produces determines what it puts in the Accept-header.
     *
     * So while the operation's correct @Produces media-types will be the sum of the media-types from all return codes, it
     * needs to be set to the desired media-type of the primary return type.
     *
     * @param imports       the imports for the API
     * @param op            the operation
     * @param returnTypeRef the return type
     * @return the optional media-type of the return type
     */
    private Optional makeProduces(Imports imports, Operation op, Reference returnTypeRef) {
        Optional mainResponse = getOperationMainResponse(op);

        List potentialMediaTypes = mainResponse
                .map(r -> List.copyOf(r.content().mediaTypes()))
                .orElse(List.of());

        // If there are no media types declared, and the return type is void
        // a produces media-type can be specified.
        if (potentialMediaTypes.isEmpty() && returnTypeRef.isVoid()) {
            return opts.getVoidProducesMediaType()
                    .map(mt -> MediaTypes.toMediaType(imports, mt));
        }

        StatusCode code = mainResponse.map(Response::code)
                .orElse(StatusCode.HTTP_DEFAULT);

        ContentContext context = new ContentContext(op.path(), code, true, Location.RESPONSE, false);
        return model.contentSelector().selectPreferredMediaType(potentialMediaTypes, context)
                .map(mt -> MediaTypes.toMediaType(imports, mt));
    }

    /**
     * Returns the main response of the operation.
     *
     * Not sure if that is always the result of 200. But assume so for now.
     *
     * @param op the operation
     * @return the main response of the operation
     */
    private Optional getOperationMainResponse(Operation op) {
        return op.responses().stream()
                .sorted((a, b) -> Integer.compare(a.code().code(), b.code().code()))
                .findFirst();
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy