From 5108f1d7ae2827c99580284dd8a06bd6f52e69ac Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Tue, 30 Jun 2026 13:52:06 +0200 Subject: [PATCH 1/3] feat: classical HttpClient GET methods + generation fixes (1.1.0) Make the classical (non-httpResource) mode usable and fix several code-generation bugs surfaced when adopting the generator in Artemis: - useHttpResource=false now emits classical Observable HttpClient.get() methods in the API service. Previously GET operations were always excluded from the service ({{^x-is-get}}) and only emitted via the httpResource template, so disabling httpResource silently dropped every GET endpoint. GET-only tags now also keep their -api.ts file in classical mode. - Model-to-model imports are now relative ('./other-model' instead of bare 'other-model'), which did not resolve. - Operations always emit an explicit response type parameter, so void endpoints return Observable instead of Observable. - POST/PUT/PATCH always pass a body argument (HttpClient requires it; a missing body produced a TS "expected 2-3 arguments" error). - Non-numeric path parameters are interpolated (\${param}) instead of being emitted as the literal text {param}, and numeric params no longer get a doubled $. - Array-of-enum model properties are wrapped in Array<...> instead of a single enum. - Build against openapi-generator 7.21.0 (aligned with the org.openapi.generator Gradle plugin used downstream). Co-Authored-By: Claude Opus 4.8 (1M context) --- build.gradle.kts | 4 +- .../cit/aet/openapi/Angular21Generator.java | 53 +++++++++++++++++-- .../resources/angular21/api-service.mustache | 24 ++++----- src/main/resources/angular21/model.mustache | 4 +- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index b9bf732..6cf7deb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "de.tum.cit.aet" -version = "1.0.0" +version = "1.1.0" java { toolchain { @@ -23,7 +23,7 @@ repositories { val openapiGeneratorCli by configurations.creating dependencies { - val openapiGeneratorVersion = "7.18.0" + val openapiGeneratorVersion = "7.21.0" // OpenAPI Generator core dependency implementation("org.openapitools:openapi-generator:$openapiGeneratorVersion") 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..bb93c45 100644 --- a/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java +++ b/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java @@ -171,7 +171,10 @@ public void processOpenAPI(OpenAPI openAPI) { for (Map.Entry entry : usageByTag.entrySet()) { String apiFilename = toApiFilename(entry.getKey()); TagUsage usage = entry.getValue(); - if (!usage.hasMutation) { + // In httpResource mode a GET-only tag has no mutations, so its API service file would be + // empty and is suppressed. In classical mode the GETs are rendered into the API service, + // so the file must be kept even when the tag has no mutations. + if (useHttpResource && !usage.hasMutation) { openapiGeneratorIgnoreList.add("api/" + apiFilename + "-api.ts"); } if (useHttpResource && separateResources && !usage.hasGet) { @@ -265,6 +268,29 @@ public Map postProcessAllModels(Map objs) public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { OperationsMap result = super.postProcessOperationsWithModels(objs, allModels); + // Each model must be imported from its own file. The default `imports` entries do not carry a + // per-entry filename, so `{{classFilename}}` in the api templates falls through to the + // enclosing API's filename and every model is (wrongly) imported from the same path. Compute + // the correct model filename per import here. + Object importsObj = result.get("imports"); + if (importsObj instanceof List importsList) { + for (Object item : importsList) { + if (item instanceof Map rawImport) { + Object className = rawImport.get("classname"); + if (className == null) { + className = rawImport.get("import"); + } + if (className != null) { + String simpleName = className.toString(); + simpleName = simpleName.substring(simpleName.lastIndexOf('.') + 1); + @SuppressWarnings("unchecked") + Map mutableImport = (Map) rawImport; + mutableImport.put("classFilename", toModelFilename(simpleName)); + } + } + } + } + OperationMap operations = result.getOperations(); List ops = operations.getOperation(); @@ -276,7 +302,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List{{/returnType}}(url, formData); + return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url, formData); {{/hasFormParams}} {{^hasFormParams}} {{#bodyParam}} - return this.http.{{httpMethod}}{{#returnType}}<{{{.}}}>{{/returnType}}(url, {{paramName}}); + return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url, {{paramName}}); {{/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}} +{{! POST/PUT/PATCH require a body argument even when the operation has none (pass null). }} +{{! GET/DELETE take only the URL. Query parameters live in the URL, not the body. }} +{{#vendorExtensions.x-needs-body-arg}} + return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url, null); +{{/vendorExtensions.x-needs-body-arg}} +{{^vendorExtensions.x-needs-body-arg}} + return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url); +{{/vendorExtensions.x-needs-body-arg}} {{/bodyParam}} {{/hasFormParams}} } -{{/vendorExtensions.x-is-get}} +{{/vendorExtensions.x-render-in-service}} {{/operation}} {{/operations}} } diff --git a/src/main/resources/angular21/model.mustache b/src/main/resources/angular21/model.mustache index 25126b8..8a53ce4 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}} @@ -22,7 +22,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<{{/isArray}}{{classname}}{{enumName}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}}; {{/vars}} } {{#hasEnums}} From f29c3fbb4b70d784213fe256a83685254e48c79c Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Tue, 30 Jun 2026 14:31:48 +0200 Subject: [PATCH 2/3] Publish to Maven Central instead of GitHub Packages Replace GitHub Packages publishing (which requires authentication even to *download*, so it is poor for general consumption) with the Sonatype Central Portal via the com.vanniktech.maven.publish plugin. Consumers now resolve the generator from a plain mavenCentral() repository with no auth. - Sign all publications with GPG, gated on a key being present so that `publishToMavenLocal` (build-from-source) still works without a key. - Update the release workflow to upload to the Central Portal on a v* tag. - Add RELEASING.md documenting the one-time namespace/token/signing-key setup and the tag-based release procedure. - Bump README install/version references to 1.1.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 16 +++++-- README.md | 22 +++++---- RELEASING.md | 86 +++++++++++++++++++++++++++++++++++ build.gradle.kts | 91 ++++++++++++++++++++----------------- 4 files changed, 159 insertions(+), 56 deletions(-) create mode 100644 RELEASING.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1559fa3..6212d8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,12 +51,18 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Publish to GitHub Packages - run: ./gradlew publish + - name: Publish to Maven Central + # Uploads the signed artifacts to the Central Portal and (automaticRelease = true) releases them. + # Requires four repository secrets — see RELEASING.md: + # MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD : a Central Portal user token + # SIGNING_KEY (ASCII-armored GPG private key) / SIGNING_PASSWORD : the signing key + passphrase + run: ./gradlew publishToMavenCentral --no-configuration-cache env: - GITHUB_ACTOR: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} + - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: diff --git a/README.md b/README.md index c3549c6..5590416 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ plugins { dependencies { // Add as a dependency to the openapi generator - openapiGenerator("de.tum.cit.aet:openapi-generator-angular21:1.0.0") + openapiGenerator("de.tum.cit.aet:openapi-generator-angular21:1.1.0") } openApiGenerate { @@ -160,7 +160,7 @@ plugins { dependencies { // Add as a dependency to the openapi generator - openapiGenerator 'de.tum.cit.aet:openapi-generator-angular21:1.0.0' + openapiGenerator 'de.tum.cit.aet:openapi-generator-angular21:1.1.0' } openApiGenerate { @@ -205,7 +205,7 @@ openApiGenerate { de.tum.cit.aet openapi-generator-angular21 - 1.0.0 + 1.1.0 @@ -215,10 +215,10 @@ openApiGenerate { ```bash # Download the generator JAR -wget https://github.com/ls1intum/openapi-generator-angular21/releases/download/v1.0.0/openapi-generator-angular21-1.0.0.jar +wget https://github.com/ls1intum/openapi-generator-angular21/releases/download/v1.1.0/openapi-generator-angular21-1.1.0.jar # Generate code -java -cp openapi-generator-angular21-1.0.0.jar:openapi-generator-cli-7.18.0.jar \ +java -cp openapi-generator-angular21-1.1.0.jar:openapi-generator-cli-7.18.0.jar \ org.openapitools.codegen.OpenAPIGenerator generate \ -g angular21 \ -i openapi.yaml \ @@ -328,11 +328,15 @@ This generates Angular client code into `build/generated/example` using `example ## Publishing -```bash -# To GitHub Packages -./gradlew publish +The generator is published to **Maven Central**, so consumers resolve it from a plain `mavenCentral()` +repository with no authentication. Releases are automated: pushing a `v*` tag runs the publish job in +[`.github/workflows/build.yml`](.github/workflows/build.yml), which uploads the signed artifacts to the +Central Portal. See **[RELEASING.md](RELEASING.md)** for the one-time account/secret setup and the full +release procedure. -# To Maven Local (for testing) +```bash +# Build the artifacts into your local Maven repository (no signing key required) — this is how +# downstream projects (e.g. Artemis) build the generator from source to regenerate their client. ./gradlew publishToMavenLocal ``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2ebfcaa --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,86 @@ +# Releasing + +This generator is published to **Maven Central** (the Sonatype Central Portal). Consumers — Artemis and +any other project — then resolve it from a plain `mavenCentral()` repository, with **no authentication**: + +```kotlin +repositories { mavenCentral() } +dependencies { /* openapiGenerator | classpath */ "de.tum.cit.aet:openapi-generator-angular21:1.1.0" } +``` + +Publishing is automated by [`.github/workflows/build.yml`](.github/workflows/build.yml): pushing a tag +that starts with `v` (e.g. `v1.1.0`) runs the `publish` job, which uploads the GPG-signed artifacts to +the Central Portal via the [`com.vanniktech.maven.publish`](https://vanniktech.github.io/gradle-maven-publish-plugin/) +plugin and (because `automaticRelease = true`) releases the deployment once it validates. + +## One-time setup + +These steps are done **once** by a maintainer/org admin; afterwards every release is just a tag push. + +### 1. Claim the `de.tum.cit.aet` namespace on the Central Portal + +1. Sign in at (GitHub or email). +2. Add the namespace `de.tum.cit.aet` and verify ownership of the `tum.de` domain by publishing the + **DNS `TXT` record** the portal shows you (a one-off verification token). + - This requires access to the `tum.de` DNS zone. If that is not available, the simplest alternative + is to switch the `group` to `io.github.ls1intum` (GitHub-verified, no DNS needed) — but that + changes the artifact coordinates everywhere, so the `tum.de` namespace is preferred. + +### 2. Generate a Central Portal user token + +In the Central Portal → *Account* → *Generate User Token*. This yields a username/password pair used +for the upload (it is **not** your login password). + +### 3. Create a GPG signing key + +```bash +gpg --gen-key # create a key for the maintainer/CI identity +gpg --list-secret-keys --keyid-format=long # note the key id +# Publish the public key so Central can verify signatures: +gpg --keyserver keyserver.ubuntu.com --send-keys +# Export the private key in the ASCII-armored form the plugin expects: +gpg --armor --export-secret-keys # the whole block, including the BEGIN/END lines +``` + +### 4. Add four GitHub Actions repository secrets + +`Settings → Secrets and variables → Actions → New repository secret`: + +| Secret | Value | +|--------------------------|-------------------------------------------------------------------| +| `MAVEN_CENTRAL_USERNAME` | Central Portal user-token **username** | +| `MAVEN_CENTRAL_PASSWORD` | Central Portal user-token **password** | +| `SIGNING_KEY` | the full ASCII-armored GPG **private** key from step 3 | +| `SIGNING_PASSWORD` | the passphrase for that key | + +The workflow maps these onto the Gradle properties the plugin reads +(`ORG_GRADLE_PROJECT_mavenCentralUsername`, `…Password`, `…signingInMemoryKey`, `…signingInMemoryKeyPassword`). + +## Cutting a release + +1. Bump `version` in [`build.gradle.kts`](build.gradle.kts) (and the version references in + [`README.md`](README.md)) to the new release version, e.g. `1.2.0`. Use a non-`SNAPSHOT` version. +2. Merge to `main`. +3. Tag and push: + ```bash + git tag v1.2.0 + git push origin v1.2.0 + ``` +4. The `publish` job signs and uploads to the Central Portal and creates a GitHub Release. The artifact + appears on Maven Central within ~15–30 minutes (search index can lag a few hours). + +## Releasing locally (fallback) + +If you ever need to publish without CI, provide the same four values as Gradle properties (e.g. in +`~/.gradle/gradle.properties`: `mavenCentralUsername`, `mavenCentralPassword`, `signingInMemoryKey`, +`signingInMemoryKeyPassword`) and run: + +```bash +./gradlew publishToMavenCentral --no-configuration-cache +``` + +## Building without releasing + +`./gradlew publishToMavenLocal` installs the artifacts into your local `~/.m2` repository and **requires +no signing key** (signing is gated on the key being present). This is the path downstream projects use to +build the generator from source when they want to regenerate their client before a version is on Central. diff --git a/build.gradle.kts b/build.gradle.kts index 6cf7deb..56be817 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,13 @@ +import com.vanniktech.maven.publish.SonatypeHost +import org.gradle.plugins.signing.Sign + plugins { java `java-library` - `maven-publish` - signing + // Publishes signed artifacts to the Maven Central (Sonatype) Portal. Replaces the manual + // maven-publish + signing setup: it wires the sources/javadoc jars, the POM, GPG signing, and the + // Central Portal upload. See RELEASING.md for the required credentials and the release procedure. + id("com.vanniktech.maven.publish") version "0.30.0" } group = "de.tum.cit.aet" @@ -12,8 +17,6 @@ java { toolchain { languageVersion = JavaLanguageVersion.of(25) } - withJavadocJar() - withSourcesJar() } repositories { @@ -63,48 +66,52 @@ tasks.withType { } } -publishing { - publications { - create("mavenJava") { - from(components["java"]) - - pom { - name.set("OpenAPI Generator Angular 21") - description.set("Custom OpenAPI Generator for modern Angular 21 with httpResource and signals") - url.set("https://github.com/ls1intum/openapi-generator-angular21") - - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/licenses/MIT") - } - } - - developers { - developer { - id.set("ls1intum") - name.set("LS1 TUM") - email.set("krusche@tum.de") - } - } - - scm { - connection.set("scm:git:git://github.com/ls1intum/openapi-generator-angular21.git") - developerConnection.set("scm:git:ssh://github.com/ls1intum/openapi-generator-angular21.git") - url.set("https://github.com/ls1intum/openapi-generator-angular21") - } +mavenPublishing { + // Upload to the Maven Central Portal (central.sonatype.com) and release automatically once the + // staged deployment validates. Consumers then resolve the artifact from plain mavenCentral() with + // no authentication. + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) + // All artifacts must be GPG-signed for Maven Central. The signing key is provided via Gradle + // properties / environment variables in CI (see RELEASING.md); signing is skipped for + // publishToMavenLocal, so building from source needs no key. + signAllPublications() + + coordinates(group.toString(), "openapi-generator-angular21", version.toString()) + + pom { + name.set("OpenAPI Generator Angular 21") + description.set("Custom OpenAPI Generator for modern Angular 21 with httpResource and signals") + url.set("https://github.com/ls1intum/openapi-generator-angular21") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") } } - } - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/ls1intum/openapi-generator-angular21") - credentials { - username = System.getenv("GITHUB_ACTOR") - password = System.getenv("GITHUB_TOKEN") + developers { + developer { + id.set("ls1intum") + name.set("LS1 TUM") + email.set("krusche@tum.de") } } + + scm { + connection.set("scm:git:git://github.com/ls1intum/openapi-generator-angular21.git") + developerConnection.set("scm:git:ssh://github.com/ls1intum/openapi-generator-angular21.git") + url.set("https://github.com/ls1intum/openapi-generator-angular21") + } } } + +// Only sign when a signing key is configured — i.e. during a Central Portal release in CI, where the +// SIGNING_KEY secret is provided as ORG_GRADLE_PROJECT_signingInMemoryKey. Building from source via +// `./gradlew publishToMavenLocal` (the fallback consumers use to regenerate the client) needs no GPG +// key: the local repository does not require signatures. The flag is read at configuration time so it +// stays compatible with the configuration cache. +val signingKeyPresent = providers.gradleProperty("signingInMemoryKey").isPresent +tasks.withType().configureEach { + onlyIf { signingKeyPresent } +} From daefab1cb78ea7472169fa938b0e6bfb235763be Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Tue, 30 Jun 2026 14:54:00 +0200 Subject: [PATCH 3/3] Emit responseType for non-JSON GET endpoints GET operations returning text or binary payloads were generated as http.get(url) / http.get(url) with no responseType, so Angular's HttpClient defaulted to responseType 'json' and threw while JSON-parsing iCalendar files, CSV exports, and plain-text tokens. Compute an x-response-type vendor extension: 'blob' for Blob returns, 'text' for string returns whose produced media types are all text/* (JSON-string endpoints, which produce application/json, keep the default parser). The template then emits http.get(url, { responseType: 'text' | 'blob' }), whose overloads already return Observable / Observable, so the declared return type is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cit/aet/openapi/Angular21Generator.java | 31 +++++++++++++++++++ .../resources/angular21/api-service.mustache | 8 +++++ 2 files changed, 39 insertions(+) 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 bb93c45..2fea239 100644 --- a/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java +++ b/src/main/java/de/tum/cit/aet/openapi/Angular21Generator.java @@ -313,6 +313,19 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List mediaType : op.produces) { + String type = mediaType.get("mediaType"); + if (type == null || !type.toLowerCase(Locale.ROOT).startsWith("text/")) { + return false; + } + } + return true; + } + /** * Convert string to kebab-case. */ diff --git a/src/main/resources/angular21/api-service.mustache b/src/main/resources/angular21/api-service.mustache index 65014c4..1de4a1c 100644 --- a/src/main/resources/angular21/api-service.mustache +++ b/src/main/resources/angular21/api-service.mustache @@ -74,7 +74,15 @@ export class {{classname}} { return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url, null); {{/vendorExtensions.x-needs-body-arg}} {{^vendorExtensions.x-needs-body-arg}} +{{! Non-JSON GETs (iCal, CSV, plain text) need an explicit responseType so HttpClient does not }} +{{! JSON-parse the body. The text/blob overloads return Observable/Observable, so the }} +{{! method's declared return type is preserved without the generic type argument. }} +{{#vendorExtensions.x-response-type}} + return this.http.{{httpMethod}}(url, { responseType: '{{vendorExtensions.x-response-type}}' }); +{{/vendorExtensions.x-response-type}} +{{^vendorExtensions.x-response-type}} return this.http.{{httpMethod}}<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}>(url); +{{/vendorExtensions.x-response-type}} {{/vendorExtensions.x-needs-body-arg}} {{/bodyParam}} {{/hasFormParams}}