Skip to content

Commit 3fe558b

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 e8687a8 commit 3fe558b

36 files changed

Lines changed: 2968 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
@@ -56,6 +56,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
5656
|useDotNotationForFormObjects|Use parameter-prefixed dot notation for exploded form query objects instead of the OpenAPI standard unprefixed property names. Enable only for servers requiring this convention.| |false|
5757
|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|
5858
|useSquareBracketsInArrayNames|Setting this property to true will add brackets to array attribute names, e.g. my_values[].| |false|
59+
|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|
5960
|withInterfaces|Setting this property to true will generate interfaces next to the default class implementations.| |false|
6061
|zonejsVersion|The version of zone.js compatible with Angular (see ngVersion option).| |null|
6162

‎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

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

91102
protected String ngVersion = "22.0.0";
92103
@Getter @Setter
@@ -102,6 +113,7 @@ public static enum PROVIDED_IN_LEVEL {none, root, any, platform}
102113
protected PROVIDED_IN_LEVEL providedIn = PROVIDED_IN_LEVEL.root;
103114

104115
private boolean taggedUnions = false;
116+
private boolean withHttpResource = false;
105117

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

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

430457
List<CodegenOperation> ops = objs.getOperation();
458+
Set<String> nicknames = ops.stream().map(op -> op.nickname).collect(Collectors.toSet());
431459
boolean hasSomeFormParams = false;
432460
boolean hasSomeEncodableParams = false;
461+
boolean hasSomeHttpResource = false;
433462
for (CodegenOperation op : ops) {
434463
if (op.getHasFormParams()) {
435464
hasSomeFormParams = true;
436465
}
437466
op.httpMethod = op.httpMethod.toLowerCase(Locale.ENGLISH);
467+
if (markHttpResource(op, nicknames)) {
468+
hasSomeHttpResource = true;
469+
}
470+
// the <Operation>RequestParams interface: for every operation with useSingleRequestParameter, else for the resource methods only
471+
if (op.getHasParams() && (getUseSingleRequestParameter() || op.vendorExtensions.containsKey(X_ANGULAR_HTTP_RESOURCE))) {
472+
op.vendorExtensions.put(X_ANGULAR_REQUEST_PARAMS_INTERFACE, true);
473+
} else {
474+
op.vendorExtensions.remove(X_ANGULAR_REQUEST_PARAMS_INTERFACE);
475+
}
438476
// deduplicate auth methods by name (as they will lead to duplicate code):
439477
op.authMethods =
440478
op.authMethods != null ? op.authMethods.stream().collect(Collectors.collectingAndThen(Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(x -> x.name))), ArrayList::new))
@@ -479,6 +517,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L
479517

480518
operations.put("hasSomeFormParams", hasSomeFormParams);
481519
operations.put("hasSomeEncodableParams", hasSomeEncodableParams);
520+
operations.put("hasSomeHttpResource", hasSomeHttpResource);
482521

483522
// Add additional filename information for model imports in the services
484523
List<Map<String, String>> imports = operations.getImports();
@@ -491,6 +530,61 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L
491530
return operations;
492531
}
493532

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

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,50 @@ 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)). The method is skipped, with a warning at
169+
generation time, when its name would collide with another operation:
170+
171+
```typescript
172+
import { Component, computed, inject, input } from '@angular/core';
173+
import { PetService } from '{{npmName}}';
174+
175+
@Component({
176+
selector: 'app-pet',
177+
templateUrl: './pet.component.html',
178+
})
179+
export class PetComponent {
180+
readonly petId = input.required<number>();
181+
182+
// requested again whenever petId changes
183+
readonly pet = inject(PetService).getPetByIdResource(() => ({ petId: this.petId() }));
184+
185+
readonly name = computed(() => this.pet.hasValue() ? this.pet.value().name : '');
186+
}
187+
```
188+
189+
- The first argument returns the request parameters and is called in a reactive context: every signal it reads
190+
sends a new request when it changes. While it returns `undefined`, or a required parameter is `null` or `undefined`,
191+
the resource stays idle. Operations without parameters take no such argument, and it can be left out when all
192+
parameters are optional.
193+
- Call the method in an injection context (a field initializer or a constructor), or pass an `injector` in the options.
194+
The resource belongs to that injector: it is destroyed with it, and it sends its request through the `HttpClient`
195+
of that injector, so the interceptors of the caller apply.
196+
- The credentials of the `{{configurationClassName}}` are read each time the request is built, so a credential
197+
function that reads a signal sends a new request when the signal changes. Other credential changes need `reload()`.
198+
- `transferCache` defaults to `true`, as for the Observable methods. Angular's transfer cache skips requests with an
199+
`Authorization` header unless `includeRequestsWithAuthHeaders` is set in `withHttpTransferCacheOptions`.
200+
- Resources are generated for reads only. A resource cancels its pending request when its parameters change, which
201+
is not safe for a write, so operations that change data keep only their Observable method.
202+
203+
The `withHttpResource` option requires Angular 20 or later. `httpResource` is stable since Angular 22 and
204+
experimental in Angular 20 and 21.
205+
206+
{{/withHttpResource}}
163207
### Customizing path parameter encoding
164208

165209
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)