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:
+ *
+ *
+ * - API Service ({@code *-api.ts}) — Injectable service with mutation methods (POST, PUT, DELETE)
+ * using {@code HttpClient} and the {@code inject()} function.
+ * - 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.
+ * - Model ({@code *.ts}) — TypeScript interfaces with readonly properties,
+ * plus const enum objects for runtime enum access (e.g., {@code JobDetailDTOStateEnum.Draft}).
+ *
+ *
+ * Generation Pipeline
+ * The generator hooks into four lifecycle stages of the OpenAPI Generator framework:
+ *
+ * - {@link #processOpts()} — Reads CLI options and registers mustache templates.
+ * - {@link #processOpenAPI(OpenAPI)} — Scans paths to determine which tags need resource files.
+ * - {@link #postProcessAllModels(Map)} — Marks models as readonly or mutable.
+ * - {@link #postProcessOperationsWithModels(OperationsMap, List)} — Splits operations,
+ * builds URL templates, and collects imports.
+ *
+ *
+ * Naming Conventions
*
- * - Signal-based httpResource for GET requests
- * - Injectable services with inject() function for mutations
- * - Standalone services (providedIn: 'root')
- * - Strict TypeScript with readonly modifiers
+ * - File names: kebab-case (e.g., {@code job-resource-api.ts}, {@code job-detail-dto.ts})
+ * - Class names: PascalCase with {@code Api} suffix (e.g., {@code JobResourceApi})
+ * - Operation IDs: camelCase, stripped of leading underscores and trailing digits
*
- *
- * @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:
+ *
+ * - {@code model.mustache} → model TypeScript files
+ * - {@code api-service.mustache} → API service files ({@code *-api.ts})
+ * - {@code api-resource.mustache} → httpResource files ({@code *-resources.ts}),
+ * conditionally added in {@link #processOpts()}
+ *
+ */
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:
+ *
+ * - Save original OpenAPI paths before the parent class URL-encodes them
+ * - Split operations into GET (for httpResource) and mutation (for HttpClient) lists
+ * - Process path parameters — convert to camelCase, detect numeric types
+ * - Process query parameters — generate a TypeScript params interface name
+ * - Build TypeScript template literal URL paths from the original OpenAPI paths
+ * - Collect all referenced model imports and map them to kebab-case filenames
+ *
+ *
+ * @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