diff --git a/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java b/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java index 6c6f72c..9cb85e9 100644 --- a/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java +++ b/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java @@ -22,15 +22,36 @@ import java.util.regex.Pattern; /** - * OpenAPI Generator for Angular 21 with modern best practices: + * Custom OpenAPI Generator for Angular 21+ with modern best practices. + * + *

This generator extends the default TypeScript Angular generator and produces + * a clean, signal-based Angular client. It generates three types of files per API tag:

+ * + *
    + *
  1. API Service ({@code *-api.ts}) — Injectable service with mutation methods (POST, PUT, DELETE) + * using {@code HttpClient} and the {@code inject()} function.
  2. + *
  3. API Resource ({@code *-resources.ts}) — Signal-based {@code httpResource} wrappers + * for GET operations, enabling reactive data fetching. Only generated for tags that have GET operations.
  4. + *
  5. Model ({@code *.ts}) — TypeScript interfaces with readonly properties, + * plus const enum objects for runtime enum access (e.g., {@code JobDetailDTOStateEnum.Draft}).
  6. + *
+ * + *

Generation Pipeline

+ * The generator hooks into four lifecycle stages of the OpenAPI Generator framework: + *
    + *
  1. {@link #processOpts()} — Reads CLI options and registers mustache templates.
  2. + *
  3. {@link #processOpenAPI(OpenAPI)} — Scans paths to determine which tags need resource files.
  4. + *
  5. {@link #postProcessAllModels(Map)} — Marks models as readonly or mutable.
  6. + *
  7. {@link #postProcessOperationsWithModels(OperationsMap, List)} — Splits operations, + * builds URL templates, and collects imports.
  8. + *
+ * + *

Naming Conventions

* - * - * @author TUM AET */ public class Angular21Generator extends TypeScriptAngularClientCodegen { @@ -56,17 +77,27 @@ public class Angular21Generator extends TypeScriptAngularClientCodegen { /** Whether to add readonly modifiers to response models. */ protected boolean readonlyModels = true; - /** Creates a configured Angular 21 generator with default options. */ + // ============================================================================================= + // 1) Constructor — Register templates, set naming conventions, define CLI options + // ============================================================================================= + + /** + * Initializes the Angular 21 generator with custom templates, naming conventions, and CLI options. + * + *

Registers three template files:

+ * + */ public Angular21Generator() { super(); - // Override template directory embeddedTemplateDir = templateDir = GENERATOR_NAME; - - // Set output folder structure outputFolder = "generated-code" + File.separator + GENERATOR_NAME; - // Configure model and API naming modelTemplateFiles.clear(); modelTemplateFiles.put("model.mustache", ".ts"); @@ -74,10 +105,8 @@ public Angular21Generator() { apiTemplateFiles.clear(); apiTemplateFiles.put("api-service.mustache", "-api.ts"); - // Add resource templates for GET operations supportingFiles.clear(); - // CLI options cliOptions.add(new CliOption(USE_HTTP_RESOURCE, "Use httpResource for GET requests (signal-based reactive fetching)") .defaultValue("true")); @@ -92,25 +121,46 @@ public Angular21Generator() { .defaultValue("true")); } + /** + * Returns the unique identifier for this generator, used by the CLI ({@code -g angular21}). + * + * @return the generator name ({@code "angular21"}) + */ @Override public String getName() { return GENERATOR_NAME; } + /** + * Returns a human-readable description shown in the CLI help output. + * + * @return a description of this generator's capabilities + */ @Override public String getHelp() { return "Generates Angular 21 client code with modern best practices including " + "httpResource for GET requests, inject() function, and signal-based reactivity."; } + // ============================================================================================= + // 2) processOpts — Read CLI options and conditionally register the resource template + // ============================================================================================= + + /** + * Reads user-provided CLI options from {@code --additional-properties} and configures + * the generator accordingly. + * + *

Each boolean option (useHttpResource, useInjectFunction, separateResources, readonlyModels) + * defaults to {@code true} if not explicitly provided. When both {@code useHttpResource} and + * {@code separateResources} are enabled, the {@code api-resource.mustache} template is registered + * to generate signal-based httpResource wrapper files.

+ */ @Override public void processOpts() { super.processOpts(); - // Replace base generator supporting files with our template set only. supportingFiles.clear(); - // Process custom options if (additionalProperties.containsKey(USE_HTTP_RESOURCE)) { useHttpResource = Boolean.parseBoolean(additionalProperties.get(USE_HTTP_RESOURCE).toString()); } @@ -131,18 +181,26 @@ public void processOpts() { } additionalProperties.put(READONLY_MODELS, readonlyModels); - // Add resource template if enabled if (useHttpResource && separateResources) { apiTemplateFiles.put("api-resource.mustache", "-resources.ts"); } - // Update supporting files - LOGGER.info("Angular21 Generator initialized with: useHttpResource={}, useInjectFunction={}, " + "separateResources={}, readonlyModels={}", useHttpResource, useInjectFunction, separateResources, readonlyModels); } + // ============================================================================================= + // 3) processOpenAPI — Scan the spec to determine which tags need resource files + // ============================================================================================= + + /** + * Scans all paths in the OpenAPI spec to classify each tag as having GET operations, + * mutation operations, or both. Tags without any GET operations get their resource file + * added to the generator's ignore list, since there is nothing to wrap in an httpResource. + * + * @param openAPI the parsed OpenAPI specification + */ @Override public void processOpenAPI(OpenAPI openAPI) { super.processOpenAPI(openAPI); @@ -171,43 +229,25 @@ public void processOpenAPI(OpenAPI openAPI) { for (Map.Entry entry : usageByTag.entrySet()) { String apiFilename = toApiFilename(entry.getKey()); TagUsage usage = entry.getValue(); - if (!usage.hasMutation) { - openapiGeneratorIgnoreList.add("api/" + apiFilename + "-api.ts"); - } - if (useHttpResource && separateResources && !usage.hasGet) { + if (!useHttpResource || !separateResources) { + // No resource files when httpResource is disabled or resources are inline + openapiGeneratorIgnoreList.add("api/" + apiFilename + "-resources.ts"); + } else if (!usage.hasGet) { + // No resource file for tags without GET operations openapiGeneratorIgnoreList.add("api/" + apiFilename + "-resources.ts"); } + // Service files are always generated — they contain Observable methods for all operations } } - @Override - public String toModelFilename(String name) { - // Use kebab-case for filenames without .model suffix (new Angular style guide) - return toKebabCase(name); - } - - @Override - public String toApiFilename(String name) { - // Use kebab-case for API files - return toKebabCase(name); - } - - @Override - public String toApiName(String name) { - return StringUtils.camelize(name) + "Api"; - } - - @Override - public String toOperationId(String operationId) { - String name = super.toOperationId(operationId); - String normalized = name.replaceFirst("^_+", ""); - normalized = normalized.replaceFirst("\\d+$", ""); - if (normalized.isBlank()) { - normalized = "operation"; - } - return normalized; - } - + /** + * Records whether a single operation is a GET or a mutation for each of its tags. + * Operations without tags are assigned to the "default" tag. + * + * @param operation the OpenAPI operation to classify (may be {@code null}) + * @param isGet {@code true} if this is a GET operation, {@code false} for mutations + * @param usageByTag the map accumulating GET/mutation flags per tag + */ private void addOperationUsage(Operation operation, boolean isGet, Map usageByTag) { if (operation == null) { return; @@ -228,21 +268,40 @@ private void addOperationUsage(Operation operation, boolean isGet, MapModels whose names end with {@code Create}, {@code Update}, {@code Request}, or {@code Input} + * are considered input DTOs and get mutable properties. All other models are treated as output + * DTOs and receive the {@code readonly} modifier on every property.

+ * + *

The decision is passed to the mustache template via vendor extensions:

+ *
    + *
  • {@code x-is-input-dto} on the model — whether this is a mutable input DTO
  • + *
  • {@code x-is-readonly} on each property — whether to emit the {@code readonly} keyword
  • + *
+ * + * @param objs the map of all models, keyed by model name + * @return the post-processed models map + */ @Override public Map postProcessAllModels(Map objs) { Map result = super.postProcessAllModels(objs); - // Add readonly modifier info to properties for (ModelsMap modelsMap : result.values()) { for (ModelMap modelMap : modelsMap.getModels()) { CodegenModel model = modelMap.getModel(); - // Mark whether this is a Create/Update DTO (should not have readonly) boolean isInputDto = model.name.endsWith("Create") || model.name.endsWith("Update") || model.name.endsWith("Request") || @@ -251,7 +310,6 @@ public Map postProcessAllModels(Map objs) model.vendorExtensions.put("x-is-input-dto", isInputDto); model.vendorExtensions.put("x-use-readonly", readonlyModels && !isInputDto); - // Process properties for (CodegenProperty property : model.vars) { property.vendorExtensions.put("x-is-readonly", readonlyModels && !isInputDto); } @@ -261,24 +319,53 @@ public Map postProcessAllModels(Map objs) return result; } + // ============================================================================================= + // 5) postProcessOperationsWithModels — Split ops, build URL templates, collect imports + // ============================================================================================= + + /** + * Post-processes all operations for a single API tag. This is the main processing step + * that prepares the data consumed by the mustache templates. + * + *

Processing steps:

+ *
    + *
  1. Save original OpenAPI paths before the parent class URL-encodes them
  2. + *
  3. Split operations into GET (for httpResource) and mutation (for HttpClient) lists
  4. + *
  5. Process path parameters — convert to camelCase, detect numeric types
  6. + *
  7. Process query parameters — generate a TypeScript params interface name
  8. + *
  9. Build TypeScript template literal URL paths from the original OpenAPI paths
  10. + *
  11. Collect all referenced model imports and map them to kebab-case filenames
  12. + *
+ * + * @param objs the operations map for the current API tag + * @param allModels all models available in the spec + * @return the post-processed operations map with additional template data + */ @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { + // Step 1: Save original paths before super transforms them + OperationMap operationsBefore = objs.getOperations(); + Map originalPaths = new HashMap<>(); + for (CodegenOperation op : operationsBefore.getOperation()) { + originalPaths.put(op.operationId, op.path); + } + OperationsMap result = super.postProcessOperationsWithModels(objs, allModels); OperationMap operations = result.getOperations(); List ops = operations.getOperation(); - // Separate GET operations from mutations + // Step 2: Split into GETs (httpResource) and mutations (HttpClient) List getOperations = new ArrayList<>(); List mutationOperations = new ArrayList<>(); for (CodegenOperation op : ops) { - // Add custom vendor extensions op.vendorExtensions.put("x-use-inject", useInjectFunction); if ("GET".equalsIgnoreCase(op.httpMethod)) { op.vendorExtensions.put("x-is-get", true); - op.vendorExtensions.put("x-use-http-resource", useHttpResource); + op.vendorExtensions.put("x-use-http-resource", useHttpResource && separateResources); + op.vendorExtensions.put("x-inline-resource", useHttpResource && !separateResources); getOperations.add(op); } else { op.vendorExtensions.put("x-is-get", false); @@ -286,15 +373,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List modelImports = new LinkedHashSet<>(); + for (CodegenOperation op : ops) { + modelImports.addAll(op.imports); + } + List> tsImports = new ArrayList<>(); + for (String im : modelImports) { + Map tsImport = new HashMap<>(); + tsImport.put("classname", im); + tsImport.put("filename", toModelFilename(im)); + tsImports.add(tsImport); + } + result.put("tsImports", tsImports); return result; } + // ============================================================================================= + // Parameter Processing Helpers + // ============================================================================================= + /** - * Process path parameters for the operation. + * Processes path parameters for a single operation: converts parameter names to camelCase + * for TypeScript and detects numeric parameters (which don't need URI encoding). + * + *

Sets vendor extensions on each parameter:

+ *
    + *
  • {@code x-ts-name} — the camelCase TypeScript variable name
  • + *
  • {@code x-is-numeric} — whether the parameter is a number type
  • + *
+ * + * @param op the operation whose path parameters should be processed */ private void processPathParameters(CodegenOperation op) { if (op.pathParams != null) { for (CodegenParameter param : op.pathParams) { - // Convert to camelCase for TypeScript param.vendorExtensions.put("x-ts-name", toCamelCase(param.paramName)); param.vendorExtensions.put("x-is-numeric", isNumericParam(param)); } @@ -325,81 +438,80 @@ private void processPathParameters(CodegenOperation op) { } /** - * Process query parameters for the operation. + * Processes query parameters for a single operation: generates a TypeScript interface name + * for the grouped query params and converts individual parameter names to camelCase. + * + *

Sets vendor extensions on the operation:

+ *
    + *
  • {@code x-has-query-params} — whether the operation has any query parameters
  • + *
  • {@code x-params-interface-name} — PascalCase interface name (e.g., {@code GetJobsParams})
  • + *
+ * + * @param op the operation whose query parameters should be processed */ private void processQueryParameters(CodegenOperation op) { if (op.queryParams != null && !op.queryParams.isEmpty()) { op.vendorExtensions.put("x-has-query-params", true); - // Generate interface name for query params String paramsInterfaceName = toPascalCase(op.operationId) + "Params"; op.vendorExtensions.put("x-params-interface-name", paramsInterfaceName); + boolean allOptional = true; for (CodegenParameter param : op.queryParams) { param.vendorExtensions.put("x-ts-name", toCamelCase(param.paramName)); + if (param.required) { + allOptional = false; + } } + op.vendorExtensions.put("x-all-query-params-optional", allOptional); } else { op.vendorExtensions.put("x-has-query-params", false); } } + // ============================================================================================= + // URL Path Template Builder + // ============================================================================================= + /** - * Build a URL path template that encodes path params without using Configuration. + * Builds a TypeScript template literal URL from the original OpenAPI path by replacing + * {@code {paramName}} placeholders with {@code ${variable}} expressions. + * + *

The variable naming depends on the context:

+ *
    + *
  • Service methods ({@code useSignalValue=false}): string params use + * {@code paramPath} (URI-encoded via {@code encodeURIComponent}), numeric params + * use the raw variable name.
  • + *
  • httpResource methods ({@code useSignalValue=true}): string params use + * {@code paramPath}, numeric params use {@code paramValue} (unwrapped from signals).
  • + *
+ * + * @param op the operation being processed + * @param originalPath the raw OpenAPI path before URL encoding (e.g., {@code /api/jobs/{id}/pdf}) + * @param useSignalValue {@code true} for httpResource templates, {@code false} for HttpClient services + * @return the TypeScript template literal path (e.g., {@code /api/jobs/${idPath}/pdf}), + * or {@code null} if {@code originalPath} is {@code null} */ - private String buildPathTemplate(CodegenOperation op, boolean useSignalValue) { - if (op.path == null) { + private String buildPathTemplate(CodegenOperation op, String originalPath, boolean useSignalValue) { + if (originalPath == null) { return null; } - String rawPath = unescapeHtmlEntities(op.path); - Map signalValueByParamName = new HashMap<>(); - Map templateVarByParamName = new HashMap<>(); - if (useSignalValue && op.pathParams != null) { - for (CodegenParameter param : op.pathParams) { - Object tsName = param.vendorExtensions.get("x-ts-name"); - String baseName = tsName != null ? tsName.toString() : param.paramName; - signalValueByParamName.put(param.paramName, baseName + "Value"); - } - } + String path = originalPath; + if (op.pathParams != null) { for (CodegenParameter param : op.pathParams) { Object tsName = param.vendorExtensions.get("x-ts-name"); String baseName = tsName != null ? tsName.toString() : param.paramName; boolean isNumeric = Boolean.TRUE.equals(param.vendorExtensions.get("x-is-numeric")); + + String valueVar; if (useSignalValue) { - templateVarByParamName.put(param.paramName, isNumeric ? baseName + "Value" : baseName + "Path"); + valueVar = isNumeric ? baseName + "Value" : baseName + "Path"; } else { - templateVarByParamName.put(param.paramName, isNumeric ? baseName : baseName + "Path"); + valueVar = isNumeric ? baseName : baseName + "Path"; } - } - } - Pattern pattern = Pattern.compile("\\$\\{this\\.configuration\\.encodeParam\\([^)]*?value: ([^,}]+)[^)]*\\)\\}"); - Matcher matcher = pattern.matcher(rawPath); - StringBuffer buffer = new StringBuffer(); - - while (matcher.find()) { - String valueVar = matcher.group(1).trim(); - String replacementVar = valueVar; - if (templateVarByParamName.containsKey(valueVar)) { - replacementVar = templateVarByParamName.get(valueVar); - } else if (useSignalValue && signalValueByParamName.containsKey(valueVar)) { - replacementVar = signalValueByParamName.get(valueVar); - } - String replacement = "{" + replacementVar + "}"; - matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement)); - } - matcher.appendTail(buffer); - - String path = buffer.toString(); - if (op.pathParams != null) { - for (CodegenParameter param : op.pathParams) { - Object tsName = param.vendorExtensions.get("x-ts-name"); - String baseName = tsName != null ? tsName.toString() : param.paramName; - String valueVar = useSignalValue ? baseName + "Value" : baseName; - if (templateVarByParamName.containsKey(param.paramName)) { - valueVar = templateVarByParamName.get(param.paramName); - } String placeholder = "{" + param.baseName + "}"; path = path.replace(placeholder, "${" + valueVar + "}"); } @@ -408,37 +520,103 @@ private String buildPathTemplate(CodegenOperation op, boolean useSignalValue) { return path; } - private String unescapeHtmlEntities(String value) { - return value.replace(""", "\"").replace("'", "'"); + // ============================================================================================= + // Naming Convention Overrides + // ============================================================================================= + + /** + * Converts a model class name to a kebab-case filename. + * + * @param name the PascalCase model name (e.g., {@code JobDetailDTO}) + * @return the kebab-case filename without extension (e.g., {@code job-detail-dto}) + */ + @Override + public String toModelFilename(String name) { + return toKebabCase(name); + } + + /** + * Converts an API tag name to a kebab-case filename. + * + * @param name the API tag name (e.g., {@code JobResource}) + * @return the kebab-case filename without extension (e.g., {@code job-resource}) + */ + @Override + public String toApiFilename(String name) { + return toKebabCase(name); } + /** + * Converts an API tag name to a PascalCase class name with the {@code Api} suffix. + * + * @param name the API tag name (e.g., {@code job-resource}) + * @return the PascalCase class name (e.g., {@code JobResourceApi}) + */ + @Override + public String toApiName(String name) { + return StringUtils.camelize(name) + "Api"; + } + + /** + * Cleans up operation IDs by stripping leading underscores and trailing digits + * that the OpenAPI spec sometimes adds to disambiguate overloaded endpoints. + * + * @param operationId the raw operation ID from the OpenAPI spec + * @return the cleaned operation ID, or {@code "operation"} if the result would be blank + */ + @Override + public String toOperationId(String operationId) { + String name = super.toOperationId(operationId); + String normalized = name.replaceFirst("^_+", ""); + normalized = normalized.replaceFirst("\\d+$", ""); + if (normalized.isBlank()) { + normalized = "operation"; + } + return normalized; + } + + // ============================================================================================= + // String Conversion Utilities + // ============================================================================================= + + /** + * Checks whether a parameter represents a numeric type (integer or number), + * which determines whether it needs URI encoding in the generated URL template. + * + * @param param the codegen parameter to check + * @return {@code true} if the parameter is numeric, {@code false} otherwise + */ private boolean isNumericParam(CodegenParameter param) { if (Boolean.TRUE.equals(param.isInteger) || Boolean.TRUE.equals(param.isNumber)) { return true; } - if ("number".equals(param.dataType) || "number".equals(param.baseType) || "integer".equals(param.baseType)) { - return true; - } - return false; + return "number".equals(param.dataType) || "number".equals(param.baseType) || "integer".equals(param.baseType); } /** - * Convert string to kebab-case. + * Converts a PascalCase or camelCase string to kebab-case. + * + * @param name the input string (e.g., {@code "JobDetailDTO"}) + * @return the kebab-case result (e.g., {@code "job-detail-d-t-o"}) */ private String toKebabCase(String name) { return name.replaceAll("([a-z])([A-Z])", "$1-$2") .replaceAll("([A-Z]+)([A-Z][a-z])", "$1-$2") + .replaceAll("_", "-") .toLowerCase(); } /** - * Convert string to camelCase. + * Converts a snake_case or kebab-case string to camelCase. + * + * @param name the input string (e.g., {@code "job_id"} or {@code "job-id"}) + * @return the camelCase result (e.g., {@code "jobId"}), or the input unchanged + * if it is {@code null} or empty */ private String toCamelCase(String name) { if (name == null || name.isEmpty()) { return name; } - // Handle snake_case and kebab-case Pattern pattern = Pattern.compile("[-_]([a-zA-Z0-9])"); Matcher matcher = pattern.matcher(name); StringBuilder buffer = new StringBuilder(); @@ -447,12 +625,15 @@ private String toCamelCase(String name) { } matcher.appendTail(buffer); String result = buffer.toString(); - // Ensure first character is lowercase return Character.toLowerCase(result.charAt(0)) + result.substring(1); } /** - * Convert string to PascalCase. + * Converts a string to PascalCase by capitalizing the first letter of the camelCase result. + * + * @param name the input string (e.g., {@code "get_jobs"}) + * @return the PascalCase result (e.g., {@code "GetJobs"}), or the input unchanged + * if it is {@code null} or empty */ private String toPascalCase(String name) { String camel = toCamelCase(name); @@ -462,18 +643,37 @@ private String toPascalCase(String name) { return Character.toUpperCase(camel.charAt(0)) + camel.substring(1); } + // ============================================================================================= + // Output Directory Configuration + // ============================================================================================= + + /** + * Returns the generator type, which determines how the OpenAPI Generator CLI categorizes it. + * + * @return {@link CodegenType#CLIENT} + */ @Override public CodegenType getTag() { return CodegenType.CLIENT; } + /** + * Returns the output folder for generated API files. + * + * @return the path to the {@code api/} subdirectory within the output folder + */ @Override public String apiFileFolder() { return outputFolder + File.separator + "api"; } + /** + * Returns the output folder for generated model files. + * + * @return the path to the {@code model/} subdirectory within the output folder + */ @Override public String modelFileFolder() { - return outputFolder + File.separator + "models"; + return outputFolder + File.separator + "model"; } } diff --git a/src/main/resources/angular21/api-resource.mustache b/src/main/resources/angular21/api-resource.mustache index 0dae347..beab86d 100644 --- a/src/main/resources/angular21/api-resource.mustache +++ b/src/main/resources/angular21/api-resource.mustache @@ -6,9 +6,9 @@ */ import { httpResource, HttpResourceRef } from '@angular/common/http'; import { Signal } from '@angular/core'; -{{#imports}} -import { {{classname}} } from '../models/{{classFilename}}'; -{{/imports}} +{{#tsImports}} +import { {{classname}} } from '../model/{{filename}}'; +{{/tsImports}} const BASE_PATH = '{{contextPath}}'; @@ -37,7 +37,7 @@ export interface {{vendorExtensions.x-params-interface-name}} { * @param params Optional signal containing query parameters {{/vendorExtensions.x-has-query-params}} */ -export function {{nickname}}Resource({{#pathParams}}{{vendorExtensions.x-ts-name}}: Signal<{{{dataType}}}> | {{{dataType}}}{{^-last}}, {{/-last}}{{/pathParams}}{{#vendorExtensions.x-has-query-params}}{{#hasPathParams}}, {{/hasPathParams}}params?: Signal<{{vendorExtensions.x-params-interface-name}}>{{/vendorExtensions.x-has-query-params}}): HttpResourceRef<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}unknown{{/returnType}} | undefined> { +export function {{nickname}}Resource({{#pathParams}}{{vendorExtensions.x-ts-name}}: Signal<{{{dataType}}}> | {{{dataType}}}{{^-last}}, {{/-last}}{{/pathParams}}{{#vendorExtensions.x-has-query-params}}{{#hasPathParams}}, {{/hasPathParams}}{{#vendorExtensions.x-all-query-params-optional}}params?: Signal<{{vendorExtensions.x-params-interface-name}}>{{/vendorExtensions.x-all-query-params-optional}}{{^vendorExtensions.x-all-query-params-optional}}params: Signal<{{vendorExtensions.x-params-interface-name}}>{{/vendorExtensions.x-all-query-params-optional}}{{/vendorExtensions.x-has-query-params}}): HttpResourceRef<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}unknown{{/returnType}} | undefined> { return httpResource<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}unknown{{/returnType}}>(() => { {{#pathParams}} const {{vendorExtensions.x-ts-name}}Value = typeof {{vendorExtensions.x-ts-name}} === 'function' ? {{vendorExtensions.x-ts-name}}() : {{vendorExtensions.x-ts-name}}; @@ -46,7 +46,12 @@ export function {{nickname}}Resource({{#pathParams}}{{vendorExtensions.x-ts-name {{/vendorExtensions.x-is-numeric}} {{/pathParams}} {{#vendorExtensions.x-has-query-params}} +{{#vendorExtensions.x-all-query-params-optional}} const queryParams = params?.() ?? {}; +{{/vendorExtensions.x-all-query-params-optional}} +{{^vendorExtensions.x-all-query-params-optional}} + const queryParams = params(); +{{/vendorExtensions.x-all-query-params-optional}} const searchParams = new URLSearchParams(); {{#queryParams}} {{#isArray}} diff --git a/src/main/resources/angular21/api-service.mustache b/src/main/resources/angular21/api-service.mustache index e26c05d..062ea03 100644 --- a/src/main/resources/angular21/api-service.mustache +++ b/src/main/resources/angular21/api-service.mustache @@ -1,14 +1,21 @@ {{>licenseInfo}} /** - * {{classname}} - API service for mutations (POST, PUT, DELETE, PATCH) + * {{classname}} - API service * @generated from OpenAPI specification */ -import { HttpClient{{#operations.operation}}{{#hasFormParams}}, HttpHeaders{{/hasFormParams}}{{/operations.operation}} } from '@angular/common/http'; +{{#operations.hasServiceClass}} +import { HttpClient, HttpResponse } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -{{#imports}} -import { {{classname}} } from '../models/{{classFilename}}'; -{{/imports}} +{{/operations.hasServiceClass}} +{{#operations.hasInlineResources}} +import { httpResource, HttpResourceRef } from '@angular/common/http'; +import { Signal } from '@angular/core'; +{{/operations.hasInlineResources}} +{{#tsImports}} +import { {{classname}} } from '../model/{{filename}}'; +{{/tsImports}} +{{#operations.hasServiceClass}} @Injectable({ providedIn: 'root' }) export class {{classname}} { @@ -17,7 +24,6 @@ export class {{classname}} { {{#operations}} {{#operation}} -{{^vendorExtensions.x-is-get}} /** * {{summary}} * {{notes}} @@ -25,25 +31,25 @@ export class {{classname}} { * @param {{paramName}} {{description}} {{/allParams}} */ - {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{^-last}}, {{/-last}}{{/allParams}}): Observable<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}> { + {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{^-last}}, {{/-last}}{{/allParams}}): Observable<{{#isResponseFile}}HttpResponse{{/isResponseFile}}{{^isResponseFile}}{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}{{/isResponseFile}}> { {{#pathParams}} {{^vendorExtensions.x-is-numeric}} const {{paramName}}Path = encodeURIComponent(String({{paramName}})); {{/vendorExtensions.x-is-numeric}} {{/pathParams}} {{#hasQueryParams}} - const queryParams: Record = {}; + const queryParams = new URLSearchParams(); {{#queryParams}} if ({{paramName}} !== undefined && {{paramName}} !== null) { {{#isArray}} - queryParams['{{baseName}}'] = {{paramName}}.join(','); + {{paramName}}.forEach(item => queryParams.append('{{baseName}}', String(item))); {{/isArray}} {{^isArray}} - queryParams['{{baseName}}'] = String({{paramName}}); + queryParams.set('{{baseName}}', String({{paramName}})); {{/isArray}} } {{/queryParams}} - const queryString = new URLSearchParams(queryParams).toString(); + const queryString = queryParams.toString(); const url = `${this.basePath}{{{vendorExtensions.xPathTemplate}}}${queryString ? `?${queryString}` : ''}`; {{/hasQueryParams}} {{^hasQueryParams}} @@ -61,26 +67,111 @@ export class {{classname}} { {{/isArray}} } {{/formParams}} - return this.http.{{httpMethod}}{{#returnType}}<{{{.}}}>{{/returnType}}(url, formData); +{{#isResponseFile}} + return this.http.{{httpMethod}}(url, formData, { responseType: 'blob', observe: 'response' }); +{{/isResponseFile}} +{{^isResponseFile}} + return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url, formData); +{{/isResponseFile}} {{/hasFormParams}} {{^hasFormParams}} {{#bodyParam}} - return this.http.{{httpMethod}}{{#returnType}}<{{{.}}}>{{/returnType}}(url, {{paramName}}); +{{#isResponseFile}} + return this.http.{{httpMethod}}(url, {{paramName}}, { responseType: 'blob', observe: 'response' }); +{{/isResponseFile}} +{{^isResponseFile}} + return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url, {{paramName}}); +{{/isResponseFile}} {{/bodyParam}} {{^bodyParam}} -{{#hasQueryParams}} - return this.http.{{httpMethod}}{{#returnType}}<{{{.}}}>{{/returnType}}(url); -{{/hasQueryParams}} -{{^hasQueryParams}} -{{#vendorExtensions.x-is-mutation}} - return this.http.{{httpMethod}}{{#returnType}}<{{{.}}}>{{/returnType}}(url{{#isBodyAllowed}}, null{{/isBodyAllowed}}); -{{/vendorExtensions.x-is-mutation}} -{{/hasQueryParams}} +{{#isResponseFile}} + return this.http.{{httpMethod}}(url{{#isBodyAllowed}}, null{{/isBodyAllowed}}, { responseType: 'blob', observe: 'response' }); +{{/isResponseFile}} +{{^isResponseFile}} + return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url{{#isBodyAllowed}}, null{{/isBodyAllowed}}); +{{/isResponseFile}} {{/bodyParam}} {{/hasFormParams}} } -{{/vendorExtensions.x-is-get}} {{/operation}} {{/operations}} } +{{/operations.hasServiceClass}} +{{#operations.hasInlineResources}} + +const BASE_PATH = '{{contextPath}}'; + +{{#operations}} +{{#operation}} +{{#vendorExtensions.x-inline-resource}} +{{#vendorExtensions.x-has-query-params}} +/** + * Query parameters for {{nickname}} + */ +export interface {{vendorExtensions.x-params-interface-name}} { +{{#queryParams}} + {{vendorExtensions.x-ts-name}}{{^required}}?{{/required}}: {{{dataType}}}; +{{/queryParams}} +} + +{{/vendorExtensions.x-has-query-params}} +/** + * {{summary}} + * {{notes}} + * Creates a reactive HTTP resource that automatically refetches when signals change. +{{#pathParams}} + * @param {{vendorExtensions.x-ts-name}} {{description}} +{{/pathParams}} +{{#vendorExtensions.x-has-query-params}} + * @param params Optional signal containing query parameters +{{/vendorExtensions.x-has-query-params}} + */ +export function {{nickname}}Resource({{#pathParams}}{{vendorExtensions.x-ts-name}}: Signal<{{{dataType}}}> | {{{dataType}}}{{^-last}}, {{/-last}}{{/pathParams}}{{#vendorExtensions.x-has-query-params}}{{#hasPathParams}}, {{/hasPathParams}}{{#vendorExtensions.x-all-query-params-optional}}params?: Signal<{{vendorExtensions.x-params-interface-name}}>{{/vendorExtensions.x-all-query-params-optional}}{{^vendorExtensions.x-all-query-params-optional}}params: Signal<{{vendorExtensions.x-params-interface-name}}>{{/vendorExtensions.x-all-query-params-optional}}{{/vendorExtensions.x-has-query-params}}): HttpResourceRef<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}unknown{{/returnType}} | undefined> { + return httpResource<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}unknown{{/returnType}}>(() => { +{{#pathParams}} + const {{vendorExtensions.x-ts-name}}Value = typeof {{vendorExtensions.x-ts-name}} === 'function' ? {{vendorExtensions.x-ts-name}}() : {{vendorExtensions.x-ts-name}}; +{{^vendorExtensions.x-is-numeric}} + const {{vendorExtensions.x-ts-name}}Path = encodeURIComponent(String({{vendorExtensions.x-ts-name}}Value)); +{{/vendorExtensions.x-is-numeric}} +{{/pathParams}} +{{#vendorExtensions.x-has-query-params}} +{{#vendorExtensions.x-all-query-params-optional}} + const queryParams = params?.() ?? {}; +{{/vendorExtensions.x-all-query-params-optional}} +{{^vendorExtensions.x-all-query-params-optional}} + const queryParams = params(); +{{/vendorExtensions.x-all-query-params-optional}} + const searchParams = new URLSearchParams(); +{{#queryParams}} +{{#isArray}} + if (queryParams.{{vendorExtensions.x-ts-name}}?.length) { + queryParams.{{vendorExtensions.x-ts-name}}.forEach(value => searchParams.append('{{baseName}}', String(value))); + } +{{/isArray}} +{{^isArray}} +{{#isBoolean}} + if (queryParams.{{vendorExtensions.x-ts-name}} !== undefined) { + searchParams.set('{{baseName}}', String(queryParams.{{vendorExtensions.x-ts-name}})); + } +{{/isBoolean}} +{{^isBoolean}} + if (queryParams.{{vendorExtensions.x-ts-name}} !== undefined && queryParams.{{vendorExtensions.x-ts-name}} !== null) { + searchParams.set('{{baseName}}', String(queryParams.{{vendorExtensions.x-ts-name}})); + } +{{/isBoolean}} +{{/isArray}} +{{/queryParams}} + const query = searchParams.toString(); + return `${BASE_PATH}{{{vendorExtensions.xResourcePathTemplate}}}${query ? `?${query}` : ''}`; +{{/vendorExtensions.x-has-query-params}} +{{^vendorExtensions.x-has-query-params}} + return `${BASE_PATH}{{{vendorExtensions.xResourcePathTemplate}}}`; +{{/vendorExtensions.x-has-query-params}} + }); +} + +{{/vendorExtensions.x-inline-resource}} +{{/operation}} +{{/operations}} +{{/operations.hasInlineResources}} diff --git a/src/main/resources/angular21/model.mustache b/src/main/resources/angular21/model.mustache index 25126b8..cd63443 100644 --- a/src/main/resources/angular21/model.mustache +++ b/src/main/resources/angular21/model.mustache @@ -2,7 +2,7 @@ {{#models}} {{#model}} {{#tsImports}} -import type { {{classname}} } from '{{filename}}'; +import type { {{classname}} } from './{{filename}}'; {{/tsImports}} {{#description}} @@ -13,6 +13,10 @@ import type { {{classname}} } from '{{filename}}'; {{#isEnum}} export type {{classname}} = {{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}} | {{/-last}}{{/enumVars}}{{/allowableValues}}; +export const {{classname}} = { +{{#allowableValues}}{{#enumVars}} {{name}}: {{{value}}} as const, +{{/enumVars}}{{/allowableValues}}} as const; + export const {{classname}}Values = [{{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}}, {{/-last}}{{/enumVars}}{{/allowableValues}}] as const; {{/isEnum}} {{^isEnum}} @@ -22,7 +26,7 @@ export interface {{classname}}{{#parent}} extends {{.}}{{/parent}} { {{#description}} /** {{{.}}} */ {{/description}} -{{#vendorExtensions.x-is-readonly}} readonly {{/vendorExtensions.x-is-readonly}}{{^vendorExtensions.x-is-readonly}} {{/vendorExtensions.x-is-readonly}}{{name}}{{^required}}?{{/required}}: {{#isEnum}}{{classname}}{{enumName}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}}; +{{#vendorExtensions.x-is-readonly}} readonly {{/vendorExtensions.x-is-readonly}}{{^vendorExtensions.x-is-readonly}} {{/vendorExtensions.x-is-readonly}}{{name}}{{^required}}?{{/required}}: {{#isEnum}}{{#isArray}}Array<{{classname}}{{enumName}}>{{/isArray}}{{^isArray}}{{classname}}{{enumName}}{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}}; {{/vars}} } {{#hasEnums}} @@ -31,6 +35,10 @@ export interface {{classname}}{{#parent}} extends {{.}}{{/parent}} { {{#isEnum}} export type {{classname}}{{enumName}} = {{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}} | {{/-last}}{{/enumVars}}{{/allowableValues}}; +export const {{classname}}{{enumName}} = { +{{#allowableValues}}{{#enumVars}} {{name}}: {{{value}}} as const, +{{/enumVars}}{{/allowableValues}}} as const; + export const {{classname}}{{enumName}}Values = [{{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}}, {{/-last}}{{/enumVars}}{{/allowableValues}}] as const; {{/isEnum}} diff --git a/src/main/resources/generator-config.yaml b/src/main/resources/generator-config.yaml new file mode 100644 index 0000000..b86d1fc --- /dev/null +++ b/src/main/resources/generator-config.yaml @@ -0,0 +1,31 @@ +# Configuration for the Angular 21 OpenAPI generator. +# See each property below for available options. + +additionalProperties: + + # useHttpResource=true -> GET operations use httpResource (Signal-based, reactive) + # Combined with separateResources, controls file layout: + # - separateResources=true -> GETs in *-resources.ts, mutations in *-api.ts + # - separateResources=false -> Both inline in *-api.ts (httpResource GETs + Observable mutations) + # + # useHttpResource=false -> GET operations use HttpClient (Observable/subscription-based) + # Generated alongside mutations in *-api.ts service files. + # Compatible with existing .subscribe() patterns — less migration effort. + useHttpResource: true + + # useInjectFunction=true -> Services use inject(HttpClient) (modern, functional style) + # useInjectFunction=false -> Services use constructor(private http: HttpClient) (classic style) + useInjectFunction: true + + # separateResources=true -> GET httpResource functions in separate *-resources.ts files (only with useHttpResource=true) + # separateResources=false -> Everything in one *-api.ts file per tag + separateResources: true + + # readonlyModels=true -> Output DTO properties are marked readonly + # Input DTOs (names ending with Create/Update/Request/Input) stay mutable + # readonlyModels=false -> All model properties are mutable + readonlyModels: true + + # supportsES6=true -> Generate ES6+ compatible code (template literals, const enums) + # supportsES6=false -> Generate ES5 compatible code + supportsES6: true