Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bin/configs/typescript-angular-v22-with-http-resource.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
generatorName: typescript-angular
outputDir: samples/client/petstore/typescript-angular-v22-provided-in-root/builds/with-http-resource
inputSpec: modules/openapi-generator/src/test/resources/3_0/typescript-angular/http-resource.yaml
templateDir: modules/openapi-generator/src/main/resources/typescript-angular
additionalProperties:
ngVersion: 22.0.0
supportsES6: true
withHttpResource: true
1 change: 1 addition & 0 deletions docs/generators/typescript-angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|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|
|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|
|useSquareBracketsInArrayNames|Setting this property to true will add brackets to array attribute names, e.g. my_values[].| |false|
|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|
|withInterfaces|Setting this property to true will generate interfaces next to the default class implementations.| |false|
|zonejsVersion|The version of zone.js compatible with Angular (see ngVersion option).| |null|

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

import java.io.File;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

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

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

// same expression as Configuration.isJsonMime in configuration.mustache
private static final Pattern JSON_MIME_PATTERN = Pattern.compile("^(application/json|[^;/ \\t]+/[^;/ \\t]+[+]json)[ \\t]*(;.*)?$", Pattern.CASE_INSENSITIVE);

private static final String DEFAULT_IMPORT_PREFIX = "./";
private static final String DEFAULT_MODEL_IMPORT_DIRECTORY_PREFIX = "../";

Expand Down Expand Up @@ -87,6 +91,13 @@ public static enum PROVIDED_IN_LEVEL {none, root, any, platform}
public static final String RXJS_VERSION = "rxjsVersion";
public static final String NGPACKAGR_VERSION = "ngPackagrVersion";
public static final String ZONEJS_VERSION = "zonejsVersion";
public static final String WITH_HTTP_RESOURCE = "withHttpResource";

public static final String X_ANGULAR_HTTP_RESOURCE = "x-angular-http-resource";
public static final String X_ANGULAR_HTTP_RESOURCE_FACTORY = "x-angular-http-resource-factory";
public static final String X_ANGULAR_HTTP_RESOURCE_TYPE = "x-angular-http-resource-type";
public static final String X_ANGULAR_HTTP_RESOURCE_ACCEPT = "x-angular-http-resource-accept";
public static final String X_ANGULAR_REQUEST_PARAMS_INTERFACE = "x-angular-request-params-interface";

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

private boolean taggedUnions = false;
private boolean withHttpResource = false;

public TypeScriptAngularClientCodegen() {
super();
Expand Down Expand Up @@ -132,6 +144,10 @@ public TypeScriptAngularClientCodegen() {
this.cliOptions.add(CliOption.newBoolean(USE_SINGLE_REQUEST_PARAMETER,
"Setting this property to true will generate functions with a single argument containing all API endpoint parameters instead of one argument per parameter.",
false));
this.cliOptions.add(CliOption.newBoolean(WITH_HTTP_RESOURCE,
"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));
this.cliOptions.add(CliOption.newBoolean(TAGGED_UNIONS,
"Use discriminators to create tagged unions instead of extending interfaces.",
this.taggedUnions));
Expand Down Expand Up @@ -223,6 +239,17 @@ public void processOpts() {
throw new IllegalArgumentException("Invalid ngVersion: " + ngVersion + ". Only Angular v9+ is supported.");
}

if (additionalProperties.containsKey(WITH_HTTP_RESOURCE)) {
withHttpResource = convertPropertyToBoolean(WITH_HTTP_RESOURCE);
}
if (withHttpResource && !ngVersion.atLeast("20.0.0")) {
throw new IllegalArgumentException("Invalid ngVersion: " + ngVersion + ". " + WITH_HTTP_RESOURCE + " requires Angular v20+.");
}
writePropertyBack(WITH_HTTP_RESOURCE, withHttpResource);
if (withHttpResource) {
supportingFiles.add(new SupportingFile("httpResourceOptions.mustache", getIndexDirectory(), "http.resource.options.ts"));
}

if (additionalProperties.containsKey(NPM_NAME)) {
addNpmPackageGeneration(ngVersion);
}
Expand Down Expand Up @@ -428,13 +455,24 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L
objs.put("apiFilename", getApiFilenameFromClassname(objs.getClassname()));

List<CodegenOperation> ops = objs.getOperation();
Set<String> nicknames = ops.stream().map(op -> op.nickname).collect(Collectors.toSet());
boolean hasSomeFormParams = false;
boolean hasSomeEncodableParams = false;
boolean hasSomeHttpResource = false;
for (CodegenOperation op : ops) {
if (op.getHasFormParams()) {
hasSomeFormParams = true;
}
op.httpMethod = op.httpMethod.toLowerCase(Locale.ENGLISH);
if (markHttpResource(op, nicknames)) {
hasSomeHttpResource = true;
}
// the <Operation>RequestParams interface: for every operation with useSingleRequestParameter, else for the resource methods only
if (op.getHasParams() && (getUseSingleRequestParameter() || op.vendorExtensions.containsKey(X_ANGULAR_HTTP_RESOURCE))) {
op.vendorExtensions.put(X_ANGULAR_REQUEST_PARAMS_INTERFACE, true);
} else {
op.vendorExtensions.remove(X_ANGULAR_REQUEST_PARAMS_INTERFACE);
}
// deduplicate auth methods by name (as they will lead to duplicate code):
op.authMethods =
op.authMethods != null ? op.authMethods.stream().collect(Collectors.collectingAndThen(Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(x -> x.name))), ArrayList::new))
Expand Down Expand Up @@ -479,6 +517,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap operations, L

operations.put("hasSomeFormParams", hasSomeFormParams);
operations.put("hasSomeEncodableParams", hasSomeEncodableParams);
operations.put("hasSomeHttpResource", hasSomeHttpResource);

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

/**
* Marks a GET operation without a request body for an additional httpResource method.
* The factory mirrors the responseType the Observable method picks at runtime for the default
* Accept header (Configuration.selectHeaderAccept): JSON (or no produces) uses httpResource,
* text uses httpResource.text, anything else, and file responses, httpResource.blob.
*
* @param op the operation, with its http method already lower-cased
* @param nicknames the nicknames of all operations of the same API class
* @return true if the operation gets a resource method
*/
private boolean markHttpResource(CodegenOperation op, Set<String> nicknames) {
op.vendorExtensions.remove(X_ANGULAR_HTTP_RESOURCE);
if (!withHttpResource || !"get".equals(op.httpMethod) || op.getHasBodyOrFormParams()) {
return false;
}
String resourceName = op.nickname + "Resource";
if (nicknames.contains(resourceName) || nicknames.contains(resourceName + "Request")) {
LOGGER.warn("Not generating {} for operation {}: the name is taken by another operation.", resourceName, op.operationId);
return false;
}

List<String> produces = op.produces == null ? Collections.emptyList() : op.produces.stream()
.map(mediaType -> mediaType.get("mediaType"))
.filter(Objects::nonNull)
.collect(Collectors.toList());
String accept = produces.stream()
.filter(mediaType -> JSON_MIME_PATTERN.matcher(mediaType).matches())
.findFirst()
.orElse(produces.isEmpty() ? null : produces.get(0));

String factory;
String type;
if (op.isResponseFile) {
factory = ".blob";
type = "Blob";
} else if (accept != null && accept.startsWith("text")) {
factory = ".text";
type = "string";
} else if (accept == null || JSON_MIME_PATTERN.matcher(accept).matches()) {
factory = "";
type = op.returnType != null ? op.returnType : "any";
} else {
factory = ".blob";
type = "Blob";
}

op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE, true);
op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE_FACTORY, factory);
op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE_TYPE, type);
if (accept != null) {
op.vendorExtensions.put(X_ANGULAR_HTTP_RESOURCE_ACCEPT, accept);
}
return true;
}

@Override
public ModelsMap postProcessModels(ModelsMap objs) {
ModelsMap result = super.postProcessModels(objs);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,50 @@ export const appConfig: ApplicationConfig = {
};
```

{{#withHttpResource}}
### Signal-based resources

Every `GET` operation without a request body also has a `...Resource` method, next to its Observable method,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
which returns an Angular [`HttpResourceRef`](https://angular.dev/api/common/http/HttpResourceRef)
(see [`httpResource`](https://angular.dev/guide/http/http-resource)). The method is skipped, with a warning at
generation time, when its name would collide with another operation:

```typescript
import { Component, computed, inject, input } from '@angular/core';
import { PetService } from '{{npmName}}';

@Component({
selector: 'app-pet',
templateUrl: './pet.component.html',
})
export class PetComponent {
readonly petId = input.required<number>();

// requested again whenever petId changes
readonly pet = inject(PetService).getPetByIdResource(() => ({ petId: this.petId() }));

readonly name = computed(() => this.pet.hasValue() ? this.pet.value().name : '');
}
```

- The first argument returns the request parameters and is called in a reactive context: every signal it reads
sends a new request when it changes. While it returns `undefined`, or a required parameter is `null` or `undefined`,
the resource stays idle. Operations without parameters take no such argument, and it can be left out when all
parameters are optional.
- Call the method in an injection context (a field initializer or a constructor), or pass an `injector` in the options.
The resource belongs to that injector: it is destroyed with it, and it sends its request through the `HttpClient`
of that injector, so the interceptors of the caller apply.
- The credentials of the `{{configurationClassName}}` are read each time the request is built, so a credential
function that reads a signal sends a new request when the signal changes. Other credential changes need `reload()`.
- `transferCache` defaults to `true`, as for the Observable methods. Angular's transfer cache skips requests with an
`Authorization` header unless `includeRequestsWithAuthHeaders` is set in `withHttpTransferCacheOptions`.
- Resources are generated for reads only. A resource cancels its pending request when its parameters change, which
is not safe for a write, so operations that change data keep only their Observable method.

The `withHttpResource` option requires Angular 20 or later. `httpResource` is stable since Angular 22 and
experimental in Angular 20 and 21.

{{/withHttpResource}}
### Customizing path parameter encoding

Without further customization, only [path-parameters][parameter-locations-url] of [style][style-values-url] 'simple'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { Inject, Injectable, Optional } from '@angular/core
import { HttpClient, HttpHeaders, HttpParams,
HttpResponse, HttpEvent{{#httpContextInOptions}}, HttpContext {{/httpContextInOptions}}
} from '@angular/common/http';
{{#hasSomeHttpResource}}
import { HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http';
{{/hasSomeHttpResource}}
import { Observable } from 'rxjs';
import { OpenApiHttpParams, QueryParamStyle } from '../query.params';

Expand All @@ -17,29 +20,30 @@ import { {{ classname }} } from '{{ filename }}';
import { BASE_PATH, COLLECTION_FORMATS } from '../variables';
import { {{configurationClassName}} } from '../configuration';
import { BaseService } from '../api.base.service';
{{#hasSomeHttpResource}}
import { ApiHttpResourceOptions } from '../http.resource.options';
{{/hasSomeHttpResource}}
{{#withInterfaces}}
import {
{{classname}}Interface{{#useSingleRequestParameter}}{{#operations}}{{#operation}}{{#allParams.0}},
{{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams{{/allParams.0}}{{/operation}}{{/operations}}{{/useSingleRequestParameter}}
{{classname}}Interface{{#operations}}{{#operation}}{{#vendorExtensions.x-angular-request-params-interface}},
{{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams{{/vendorExtensions.x-angular-request-params-interface}}{{/operation}}{{/operations}}
} from './{{classFilename}}Interface';
{{/withInterfaces}}

{{#operations}}

{{^withInterfaces}}
{{#useSingleRequestParameter}}
{{#operation}}
{{#allParams.0}}
{{#vendorExtensions.x-angular-request-params-interface}}
export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams {
{{#allParams}}
{{#description}}/** {{.}} */
{{/description}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{#isNullable}} | null{{/isNullable}};
{{/allParams}}
}

{{/allParams.0}}
{{/vendorExtensions.x-angular-request-params-interface}}
{{/operation}}
{{/useSingleRequestParameter}}
{{/withInterfaces}}

{{#description}}
Expand Down Expand Up @@ -107,90 +111,7 @@ export class {{classname}} extends BaseService {
{{/required}}
{{/allParams}}

{{#hasQueryParamsOrAuth}}
let localVarQueryParameters = new OpenApiHttpParams(this.encoder);
{{#queryParams}}

localVarQueryParameters = this.addToHttpParams(
localVarQueryParameters,
'{{baseName}}',
<any>{{paramName}},
{{#isQueryParamObjectFormatJson}}
QueryParamStyle.Json,
{{/isQueryParamObjectFormatJson}}
{{^isQueryParamObjectFormatJson}}
{{^style}}
{{#queryIsJsonMimeType}}
QueryParamStyle.Json,
{{/queryIsJsonMimeType}}
{{^queryIsJsonMimeType}}
QueryParamStyle.Form,
{{/queryIsJsonMimeType}}
{{/style}}
{{#style}}
{{#isDeepObject}}
QueryParamStyle.DeepObject,
{{/isDeepObject}}
{{#isFormStyle}}
QueryParamStyle.Form,
{{/isFormStyle}}
{{#isSpaceDelimited}}
QueryParamStyle.SpaceDelimited,
{{/isSpaceDelimited}}
{{#isPipeDelimited}}
QueryParamStyle.PipeDelimited,
{{/isPipeDelimited}}
{{#queryIsJsonMimeType}}
QueryParamStyle.Json,
{{/queryIsJsonMimeType}}
{{/style}}
{{/isQueryParamObjectFormatJson}}
{{isExplode}},
);

{{/queryParams}}

{{/hasQueryParamsOrAuth}}
let localVarHeaders = this.defaultHeaders;
{{#headerParams}}
{{#isArray}}
if ({{paramName}}) {
localVarHeaders = localVarHeaders.set('{{baseName}}', [...{{paramName}}].join(COLLECTION_FORMATS['{{collectionFormat}}']));
}
{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined && {{paramName}} !== null) {
localVarHeaders = localVarHeaders.set('{{baseName}}', String({{paramName}}));
}
{{/isArray}}
{{/headerParams}}

{{#authMethods}}
// authentication ({{name}}) required
{{#isApiKey}}
{{#isKeyInHeader}}
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', '{{keyParamName}}', localVarHeaders);
{{/isKeyInHeader}}
{{#isKeyInQuery}}
localVarQueryParameters = this.configuration.addCredentialToQuery('{{name}}', '{{keyParamName}}', localVarQueryParameters);
{{/isKeyInQuery}}
{{/isApiKey}}
{{#isBasic}}
{{#isBasicBasic}}
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Basic ');
{{/isBasicBasic}}
{{#isBasicBearer}}
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Bearer ');
{{/isBasicBearer}}
{{/isBasic}}
{{#isOAuth}}
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Bearer ');
{{/isOAuth}}
{{#isOpenId}}
localVarHeaders = this.configuration.addCredentialToHeaders('{{name}}', 'Authorization', localVarHeaders, 'Bearer ');
{{/isOpenId}}

{{/authMethods}}
{{>apiRequestQueryAndHeaders}}
const localVarHttpHeaderAcceptSelected: string | undefined = options?.httpHeaderAccept ?? this.configuration.selectHeaderAccept([
{{#produces}}
'{{{mediaType}}}'{{^-last}},{{/-last}}
Expand Down Expand Up @@ -328,5 +249,8 @@ export class {{classname}} extends BaseService {
);
}

{{#vendorExtensions.x-angular-http-resource}}
{{>apiHttpResource}}
{{/vendorExtensions.x-angular-http-resource}}
{{/operation}}}
{{/operations}}
Loading
Loading