Skip to content

Commit 5124c05

Browse files
committed
feat(typescript-angular): add withHttpResource option for signal-based GET resources
Opt-in, Angular 20+. Every GET operation without a request body gets a <operation>Resource method next to its Observable method, returning an Angular httpResource (httpResource.text or httpResource.blob when the response is text or binary). Writes keep only their Observable method. The query, header and credential code moves into a partial shared by both paths, so the output of every existing configuration is unchanged. Adds a sample build inside typescript-angular-v22-provided-in-root with an HttpTestingController spec.
1 parent dda8e0e commit 5124c05

36 files changed

Lines changed: 2930 additions & 95 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
generatorName: typescript-angular
2+
outputDir: samples/client/petstore/typescript-angular-v22-provided-in-root/builds/with-http-resource
3+
inputSpec: modules/openapi-generator/src/test/resources/3_0/typescript-angular/http-resource.yaml
4+
templateDir: modules/openapi-generator/src/main/resources/typescript-angular
5+
additionalProperties:
6+
ngVersion: 22.0.0
7+
supportsES6: true
8+
withHttpResource: true

‎docs/generators/typescript-angular.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
5555
|tsVersion|The version of typescript compatible with Angular (see ngVersion option).| |null|
5656
|useSingleRequestParameter|Setting this property to true will generate functions with a single argument containing all API endpoint parameters instead of one argument per parameter.| |false|
5757
|useSquareBracketsInArrayNames|Setting this property to true will add brackets to array attribute names, e.g. my_values[].| |false|
58+
|withHttpResource|Setting this property to true will generate, next to the Observable method of each GET operation without a request body, a method returning a signal-based Angular httpResource. Requires Angular v20+.| |false|
5859
|withInterfaces|Setting this property to true will generate interfaces next to the default class implementations.| |false|
5960
|zonejsVersion|The version of zone.js compatible with Angular (see ngVersion option).| |null|
6061

‎modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
import java.io.File;
4040
import java.util.*;
41+
import java.util.regex.Pattern;
4142
import java.util.stream.Collectors;
4243
import java.util.stream.Stream;
4344

@@ -59,6 +60,9 @@ public static enum QUERY_PARAM_OBJECT_FORMAT_TYPE {dot, json, key}
5960

6061
public static enum PROVIDED_IN_LEVEL {none, root, any, platform}
6162

63+
// same expression as Configuration.isJsonMime in configuration.mustache
64+
private static final Pattern JSON_MIME_PATTERN = Pattern.compile("^(application/json|[^;/ \\t]+/[^;/ \\t]+[+]json)[ \\t]*(;.*)?$", Pattern.CASE_INSENSITIVE);
65+
6266
private static final String DEFAULT_IMPORT_PREFIX = "./";
6367
private static final String DEFAULT_MODEL_IMPORT_DIRECTORY_PREFIX = "../";
6468

@@ -86,6 +90,13 @@ public static enum PROVIDED_IN_LEVEL {none, root, any, platform}
8690
public static final String RXJS_VERSION = "rxjsVersion";
8791
public static final String NGPACKAGR_VERSION = "ngPackagrVersion";
8892
public static final String ZONEJS_VERSION = "zonejsVersion";
93+
public static final String WITH_HTTP_RESOURCE = "withHttpResource";
94+
95+
public static final String X_ANGULAR_HTTP_RESOURCE = "x-angular-http-resource";
96+
public static final String X_ANGULAR_HTTP_RESOURCE_FACTORY = "x-angular-http-resource-factory";
97+
public static final String X_ANGULAR_HTTP_RESOURCE_TYPE = "x-angular-http-resource-type";
98+
public static final String X_ANGULAR_HTTP_RESOURCE_ACCEPT = "x-angular-http-resource-accept";
99+
public static final String X_ANGULAR_REQUEST_PARAMS_INTERFACE = "x-angular-request-params-interface";
89100

90101
protected String ngVersion = "22.0.0";
91102
@Getter @Setter
@@ -101,6 +112,7 @@ public static enum PROVIDED_IN_LEVEL {none, root, any, platform}
101112
protected PROVIDED_IN_LEVEL providedIn = PROVIDED_IN_LEVEL.root;
102113

103114
private boolean taggedUnions = false;
115+
private boolean withHttpResource = false;
104116

105117
public TypeScriptAngularClientCodegen() {
106118
super();
@@ -131,6 +143,10 @@ public TypeScriptAngularClientCodegen() {
131143
this.cliOptions.add(CliOption.newBoolean(USE_SINGLE_REQUEST_PARAMETER,
132144
"Setting this property to true will generate functions with a single argument containing all API endpoint parameters instead of one argument per parameter.",
133145
false));
146+
this.cliOptions.add(CliOption.newBoolean(WITH_HTTP_RESOURCE,
147+
"Setting this property to true will generate, next to the Observable method of each GET operation without a request body, "
148+
+ "a method returning a signal-based Angular httpResource. Requires Angular v20+.",
149+
false));
134150
this.cliOptions.add(CliOption.newBoolean(TAGGED_UNIONS,
135151
"Use discriminators to create tagged unions instead of extending interfaces.",
136152
this.taggedUnions));
@@ -221,6 +237,17 @@ public void processOpts() {
221237
throw new IllegalArgumentException("Invalid ngVersion: " + ngVersion + ". Only Angular v9+ is supported.");
222238
}
223239

240+
if (additionalProperties.containsKey(WITH_HTTP_RESOURCE)) {
241+
withHttpResource = convertPropertyToBoolean(WITH_HTTP_RESOURCE);
242+
}
243+
if (withHttpResource && !ngVersion.atLeast("20.0.0")) {
244+
throw new IllegalArgumentException("Invalid ngVersion: " + ngVersion + ". " + WITH_HTTP_RESOURCE + " requires Angular v20+.");
245+
}
246+
writePropertyBack(WITH_HTTP_RESOURCE, withHttpResource);
247+
if (withHttpResource) {
248+
supportingFiles.add(new SupportingFile("httpResourceOptions.mustache", getIndexDirectory(), "http.resource.options.ts"));
249+
}
250+
224251
if (additionalProperties.containsKey(NPM_NAME)) {
225252
addNpmPackageGeneration(ngVersion);
226253
}
@@ -423,13 +450,24 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L
423450
objs.put("apiFilename", getApiFilenameFromClassname(objs.getClassname()));
424451

425452
List<CodegenOperation> ops = objs.getOperation();
453+
Set<String> nicknames = ops.stream().map(op -> op.nickname).collect(Collectors.toSet());
426454
boolean hasSomeFormParams = false;
427455
boolean hasSomeEncodableParams = false;
456+
boolean hasSomeHttpResource = false;
428457
for (CodegenOperation op : ops) {
429458
if (op.getHasFormParams()) {
430459
hasSomeFormParams = true;
431460
}
432461
op.httpMethod = op.httpMethod.toLowerCase(Locale.ENGLISH);
462+
if (markHttpResource(op, nicknames)) {
463+
hasSomeHttpResource = true;
464+
}
465+
// the <Operation>RequestParams interface: for every operation with useSingleRequestParameter, else for the resource methods only
466+
if (op.getHasParams() && (getUseSingleRequestParameter() || op.vendorExtensions.containsKey(X_ANGULAR_HTTP_RESOURCE))) {
467+
op.vendorExtensions.put(X_ANGULAR_REQUEST_PARAMS_INTERFACE, true);
468+
} else {
469+
op.vendorExtensions.remove(X_ANGULAR_REQUEST_PARAMS_INTERFACE);
470+
}
433471
// deduplicate auth methods by name (as they will lead to duplicate code):
434472
op.authMethods =
435473
op.authMethods != null ? op.authMethods.stream().collect(Collectors.collectingAndThen(Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(x -> x.name))), ArrayList::new))
@@ -474,6 +512,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L
474512

475513
operations.put("hasSomeFormParams", hasSomeFormParams);
476514
operations.put("hasSomeEncodableParams", hasSomeEncodableParams);
515+
operations.put("hasSomeHttpResource", hasSomeHttpResource);
477516

478517
// Add additional filename information for model imports in the services
479518
List<Map<String, String>> imports = operations.getImports();
@@ -486,6 +525,61 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L
486525
return operations;
487526
}
488527

528+
/**
529+
* Marks a GET operation without a request body for an additional httpResource method.
530+
* The factory mirrors the responseType the Observable method picks at runtime for the default
531+
* Accept header (Configuration.selectHeaderAccept): JSON (or no produces) uses httpResource,
532+
* text uses httpResource.text, anything else, and file responses, httpResource.blob.
533+
*
534+
* @param op the operation, with its http method already lower-cased
535+
* @param nicknames the nicknames of all operations of the same API class
536+
* @return true if the operation gets a resource method
537+
*/
538+
private boolean markHttpResource(CodegenOperation op, Set<String> nicknames) {
539+
op.vendorExtensions.remove(X_ANGULAR_HTTP_RESOURCE);
540+
if (!withHttpResource || !"get".equals(op.httpMethod) || op.getHasBodyOrFormParams()) {
541+
return false;
542+
}
543+
String resourceName = op.nickname + "Resource";
544+
if (nicknames.contains(resourceName) || nicknames.contains(resourceName + "Request")) {
545+
LOGGER.warn("Not generating {} for operation {}: the name is taken by another operation.", resourceName, op.operationId);
546+
return false;
547+
}
548+
549+
List<String> produces = op.produces == null ? Collections.emptyList() : op.produces.stream()
550+
.map(mediaType -> mediaType.get("mediaType"))
551+
.filter(Objects::nonNull)
552+
.collect(Collectors.toList());
553+
String accept = produces.stream()
554+
.filter(mediaType -> JSON_MIME_PATTERN.matcher(mediaType).matches())
555+
.findFirst()
556+
.orElse(produces.isEmpty() ? null : produces.get(0));
557+
558+
String factory;
559+
String type;
560+
if (op.isResponseFile) {
561+
factory = ".blob";
562+
type = "Blob";
563+
} else if (accept != null && accept.startsWith("text")) {
564+
factory = ".text";
565+
type = "string";
566+
} else if (accept == null || JSON_MIME_PATTERN.matcher(accept).matches()) {
567+
factory = "";
568+
type = op.returnType != null ? op.returnType : "any";
569+
} else {
570+
factory = ".blob";
571+
type = "Blob";
572+
}
573+
574+
op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE, true);
575+
op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE_FACTORY, factory);
576+
op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE_TYPE, type);
577+
if (accept != null) {
578+
op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE_ACCEPT, accept);
579+
}
580+
return true;
581+
}
582+
489583
@Override
490584
public ModelsMap postProcessModels(ModelsMap objs) {
491585
ModelsMap result = super.postProcessModels(objs);

‎modules/openapi-generator/src/main/resources/typescript-angular/README.mustache‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,49 @@ export const appConfig: ApplicationConfig = {
160160
};
161161
```
162162

163+
{{#withHttpResource}}
164+
### Signal-based resources
165+
166+
Every `GET` operation without a request body also has a `...Resource` method, next to its Observable method,
167+
which returns an Angular [`HttpResourceRef`](https://angular.dev/api/common/http/HttpResourceRef)
168+
(see [`httpResource`](https://angular.dev/guide/http/http-resource)):
169+
170+
```typescript
171+
import { Component, computed, inject, input } from '@angular/core';
172+
import { PetService } from '{{npmName}}';
173+
174+
@Component({
175+
selector: 'app-pet',
176+
templateUrl: './pet.component.html',
177+
})
178+
export class PetComponent {
179+
readonly petId = input.required<number>();
180+
181+
// requested again whenever petId changes
182+
readonly pet = inject(PetService).getPetByIdResource(() => ({ petId: this.petId() }));
183+
184+
readonly name = computed(() => this.pet.hasValue() ? this.pet.value().name : '');
185+
}
186+
```
187+
188+
- The first argument returns the request parameters and is called in a reactive context: every signal it reads
189+
sends a new request when it changes. While it returns `undefined`, or a required parameter is `null` or `undefined`,
190+
the resource stays idle. Operations without parameters take no such argument, and it can be left out when all
191+
parameters are optional.
192+
- Call the method in an injection context (a field initializer or a constructor), or pass an `injector` in the options.
193+
The resource belongs to that injector: it is destroyed with it, and it sends its request through the `HttpClient`
194+
of that injector, so the interceptors of the caller apply.
195+
- The credentials of the `{{configurationClassName}}` are read each time the request is built, so a credential
196+
function that reads a signal sends a new request when the signal changes. Other credential changes need `reload()`.
197+
- `transferCache` defaults to `true`, as for the Observable methods. Angular's transfer cache skips requests with an
198+
`Authorization` header unless `includeRequestsWithAuthHeaders` is set in `withHttpTransferCacheOptions`.
199+
- Resources are generated for reads only. A resource cancels its pending request when its parameters change, which
200+
is not safe for a write, so operations that change data keep only their Observable method.
201+
202+
The `withHttpResource` option requires Angular 20 or later. `httpResource` is stable since Angular 22 and
203+
experimental in Angular 20 and 21.
204+
205+
{{/withHttpResource}}
163206
### Customizing path parameter encoding
164207

165208
Without further customization, only [path-parameters][parameter-locations-url] of [style][style-values-url] 'simple'

‎modules/openapi-generator/src/main/resources/typescript-angular/api.service.mustache‎

Lines changed: 14 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { Inject, Injectable, Optional } from '@angular/core
55
import { HttpClient, HttpHeaders, HttpParams,
66
HttpResponse, HttpEvent{{#httpContextInOptions}}, HttpContext {{/httpContextInOptions}}
77
} from '@angular/common/http';
8+
{{#hasSomeHttpResource}}
9+
import { HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http';
10+
{{/hasSomeHttpResource}}
811
import { Observable } from 'rxjs';
912
import { OpenApiHttpParams, QueryParamStyle } from '../query.params';
1013

@@ -17,29 +20,30 @@ import { {{ classname }} } from '{{ filename }}';
1720
import { BASE_PATH, COLLECTION_FORMATS } from '../variables';
1821
import { {{configurationClassName}} } from '../configuration';
1922
import { BaseService } from '../api.base.service';
23+
{{#hasSomeHttpResource}}
24+
import { ApiHttpResourceOptions } from '../http.resource.options';
25+
{{/hasSomeHttpResource}}
2026
{{#withInterfaces}}
2127
import {
22-
{{classname}}Interface{{#useSingleRequestParameter}}{{#operations}}{{#operation}}{{#allParams.0}},
23-
{{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams{{/allParams.0}}{{/operation}}{{/operations}}{{/useSingleRequestParameter}}
28+
{{classname}}Interface{{#operations}}{{#operation}}{{#vendorExtensions.x-angular-request-params-interface}},
29+
{{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams{{/vendorExtensions.x-angular-request-params-interface}}{{/operation}}{{/operations}}
2430
} from './{{classFilename}}Interface';
2531
{{/withInterfaces}}
2632

2733
{{#operations}}
2834

2935
{{^withInterfaces}}
30-
{{#useSingleRequestParameter}}
3136
{{#operation}}
32-
{{#allParams.0}}
37+
{{#vendorExtensions.x-angular-request-params-interface}}
3338
export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams {
3439
{{#allParams}}
3540
{{#description}}/** {{.}} */
3641
{{/description}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{#isNullable}} | null{{/isNullable}};
3742
{{/allParams}}
3843
}
3944

40-
{{/allParams.0}}
45+
{{/vendorExtensions.x-angular-request-params-interface}}
4146
{{/operation}}
42-
{{/useSingleRequestParameter}}
4347
{{/withInterfaces}}
4448

4549
{{#description}}
@@ -107,90 +111,7 @@ export class {{classname}} extends BaseService {
107111
{{/required}}
108112
{{/allParams}}
109113

110-
{{#hasQueryParamsOrAuth}}
111-
let localVarQueryParameters = new OpenApiHttpParams(this.encoder);
112-
{{#queryParams}}
113-
114-
localVarQueryParameters = this.addToHttpParams(
115-
localVarQueryParameters,
116-
'{{baseName}}',
117-
<any>{{paramName}},
118-
{{#isQueryParamObjectFormatJson}}
119-
QueryParamStyle.Json,
120-
{{/isQueryParamObjectFormatJson}}
121-
{{^isQueryParamObjectFormatJson}}
122-
{{^style}}
123-
{{#queryIsJsonMimeType}}
124-
QueryParamStyle.Json,
125-
{{/queryIsJsonMimeType}}
126-
{{^queryIsJsonMimeType}}
127-
QueryParamStyle.Form,
128-
{{/queryIsJsonMimeType}}
129-
{{/style}}
130-
{{#style}}
131-
{{#isDeepObject}}
132-
QueryParamStyle.DeepObject,
133-
{{/isDeepObject}}
134-
{{#isFormStyle}}
135-
QueryParamStyle.Form,
136-
{{/isFormStyle}}
137-
{{#isSpaceDelimited}}
138-
QueryParamStyle.SpaceDelimited,
139-
{{/isSpaceDelimited}}
140-
{{#isPipeDelimited}}
141-
QueryParamStyle.PipeDelimited,
142-
{{/isPipeDelimited}}
143-
{{#queryIsJsonMimeType}}
144-
QueryParamStyle.Json,
145-
{{/queryIsJsonMimeType}}
146-
{{/style}}
147-
{{/isQueryParamObjectFormatJson}}
148-
{{isExplode}},
149-
);
150-
151-
{{/queryParams}}
152-
153-
{{/hasQueryParamsOrAuth}}
154-
let localVarHeaders = this.defaultHeaders;
155-
{{#headerParams}}
156-
{{#isArray}}
157-
if ({{paramName}}) {
158-
localVarHeaders = localVarHeaders.set('{{baseName}}', [...{{paramName}}].join(COLLECTION_FORMATS['{{collectionFormat}}']));
159-
}
160-
{{/isArray}}
161-
{{^isArray}}
162-
if ({{paramName}} !== undefined && {{paramName}} !== null) {
163-
localVarHeaders = localVarHeaders.set('{{baseName}}', String({{paramName}}));
164-
}
165-
{{/isArray}}
166-
{{/headerParams}}
167-
168-
{{#authMethods}}
169-
// authentication ({{name}}) required
170-
{{#isApiKey}}
171-
{{#isKeyInHeader}}
172-
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', '{{keyParamName}}', localVarHeaders);
173-
{{/isKeyInHeader}}
174-
{{#isKeyInQuery}}
175-
localVarQueryParameters = this.configuration.addCredentialToQuery('{{name}}', '{{keyParamName}}', localVarQueryParameters);
176-
{{/isKeyInQuery}}
177-
{{/isApiKey}}
178-
{{#isBasic}}
179-
{{#isBasicBasic}}
180-
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Basic ');
181-
{{/isBasicBasic}}
182-
{{#isBasicBearer}}
183-
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Bearer ');
184-
{{/isBasicBearer}}
185-
{{/isBasic}}
186-
{{#isOAuth}}
187-
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Bearer ');
188-
{{/isOAuth}}
189-
{{#isOpenId}}
190-
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Bearer ');
191-
{{/isOpenId}}
192-
193-
{{/authMethods}}
114+
{{>apiRequestQueryAndHeaders}}
194115
const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([
195116
{{#produces}}
196117
'{{{mediaType}}}'{{^-last}},{{/-last}}
@@ -328,5 +249,8 @@ export class {{classname}} extends BaseService {
328249
);
329250
}
330251

252+
{{#vendorExtensions.x-angular-http-resource}}
253+
{{>apiHttpResource}}
254+
{{/vendorExtensions.x-angular-http-resource}}
331255
{{/operation}}}
332256
{{/operations}}

0 commit comments

Comments
 (0)