diff --git a/docs/en/cli/index.md b/docs/en/cli/index.md index 8196ba993ec..1174c12f4b4 100644 --- a/docs/en/cli/index.md +++ b/docs/en/cli/index.md @@ -809,14 +809,15 @@ abp generate-proxy -t csharp -url https://localhost:44302/ - `csharp`: C#, work in the `*.HttpApi.Client` project directory. There are some additional options for this client: - `--without-contracts`: Avoid generating the application service interface, class, enum and dto types. - `--folder`: Folder name to place generated CSharp code in. Default value: `ClientProxies`. - - `ng`: Angular. There are some additional options for this client: - - `--api-name` or `-a`: The name of the API endpoint defined in the `/src/environments/environment.ts`. Default value: `default`. - - `--source` or `-s`: Specifies the Angular project name to resolve the root namespace & API definition URL from. Default value: `defaultProject`. - - `--target`: Specifies the Angular project name to place generated code in. Default value: `defaultProject`. - - `--module`: Backend module name. Default value: `app`. - - `--entry-point`: Targets the Angular project to place the generated code. - - `--url`: Specifies api definition url. Default value is API Name's url in environment file. - - `--prompt` or `-p`: Asks the options from the command line prompt (for the unspecified options). + - `ng`: Angular. There are some additional options for this client: + - `--api-name` or `-a`: The name of the API endpoint defined in the `/src/environments/environment.ts`. Default value: `default`. + - `--source` or `-s`: Specifies the Angular project name to resolve the root namespace & API definition URL from. Default value: `defaultProject`. + - `--target`: Specifies the Angular project name to place generated code in. Default value: `defaultProject`. + - `--module`: Backend module name. Default value: `app`. + - `--entry-point`: Targets the Angular project to place the generated code. + - `--url`: Specifies api definition url. Default value is API Name's url in environment file. + - `--resource-api`: Adds optional Resource API helpers for `GET` endpoints while keeping the generated Observable services. This parameter requires Angular v22 or later. + - `--prompt` or `-p`: Asks the options from the command line prompt (for the unspecified options). - `js`: JavaScript. work in the `*.Web` project directory. There are some additional options for this client: - `--output` or `-o`: JavaScript file path or folder to place generated code in. - `--module` or `-m`: Specifies the name of the backend module you wish to generate proxies for. Default value: `app`. @@ -1314,4 +1315,3 @@ var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync( - [Examples for the new command](./new-command-samples.md) - [Video tutorial](https://abp.io/video-courses/essentials/abp-cli) - diff --git a/docs/en/framework/ui/angular/service-proxies.md b/docs/en/framework/ui/angular/service-proxies.md index c816158f779..4f1c4abc5dc 100644 --- a/docs/en/framework/ui/angular/service-proxies.md +++ b/docs/en/framework/ui/angular/service-proxies.md @@ -88,12 +88,15 @@ export const environment: Config.Environment = { - **target:** Target for the Angular project to place the generated code. For example, if it's `permission-management`, it'll look like this (npm/ng-packs/packages/*permission-management*). - **entryPoint:** To create the generated proxy folder in the target. The directory is `permission-management/proxy/src/lib/proxy` and the `permission-management` is the value of target. If you want to create a folder for the generated proxy, there are two options, you should either set the value `proxy` as the entryPoint or go to project.json and change the `sourceRoot` from `packages/permission-management/src` to `packages/permission-management/proxy/src`. No need to change the sourceRoot of project with the property. if you keep it empty, the proxy will be generated into the folder defined in the sourceRoot property. - **serviceType:** The service type of the generated proxy. The options are `application`, `integration` and `all`. The default value is `application`. A developer can mark a service "integration service". If you want to skip proxy generation for the service, then this is the correct setting. More info about [Integration Services](../../api-development/integration-services.md) +- **resourceApi:** Generates optional `rxResource` helpers for `GET` endpoints. This is off by default and keeps the current Observable-based services unchanged. This parameter requires Angular v22 or later ### Services The `generate-proxy` command generates one service per back-end controller and a method (property with a function value actually) for each action in the controller. These methods call backend APIs via [RestService](./http-requests#restservice). +If you pass `--resource-api`, the generator keeps those methods and adds matching `rxResource` helpers for read operations. + A variable named `apiName` (available as of v2.4) is defined in each service. `apiName` matches the module's `RemoteServiceName`. This variable passes to the `RestService` as a parameter at each request. If there is no microservice API defined in the environment, `RestService` uses the default. See [getting a specific API endpoint from application config](./http-requests#how-to-get-a-specific-api-endpoint-from-application-config) The `providedIn` property of the services is defined as `'root'`. Therefore there is no need to provide them in a module. You can use them directly by injecting as shown below: @@ -188,4 +191,4 @@ When you run a project on Visual Studio using IIS Express as the web server, the ## See Also -* [Video tutorial](https://abp.io/video-courses/essentials/generating-client-proxies) \ No newline at end of file +* [Video tutorial](https://abp.io/video-courses/essentials/generating-client-proxies) diff --git a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts index 4121eaa75aa..958e06dfc33 100644 --- a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts @@ -1,5 +1,6 @@ import { HttpClient, HttpHeaders, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; +import { Injectable, Signal, inject } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; import { Observable, from, of, throwError } from 'rxjs'; import { catchError, map, switchMap } from 'rxjs/operators'; import { ExternalHttpClient } from '../clients/http.client'; @@ -66,6 +67,18 @@ export class RestService { }), ); } + + requestResource( + request: Signal>, + config?: Rest.Config, + api?: string, + ) { + return rxResource({ + params: () => request(), + stream: ({ params }) => this.request(params, config, api), + }); + } + private getHttpClient(isExternal: boolean) { return isExternal ? this.externalHttp : this.http; } diff --git a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts index 3009b0d6dcd..a2fcbdee568 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts @@ -1,4 +1,6 @@ import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { effect, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { createHttpFactory, HttpMethod, SpectatorHttp, SpyObject } from '@ngneat/spectator/vitest'; import { OAuthService } from 'angular-oauth2-oidc'; import { of, throwError } from 'rxjs'; @@ -92,6 +94,20 @@ describe('HttpClient testing', () => { await completionPromise; }); + test('should create a resource-based request that still uses the ABP request pipeline', () => { + const resource = TestBed.runInInjectionContext(() => { + const resource = spectator.service.requestResource(signal({ method: HttpMethod.GET, url: '/test' })); + effect(() => { + resource.value(); + }); + return resource; + }); + + expect(typeof resource.reload).toBe('function'); + expect(typeof resource.value).toBe('function'); + expect(typeof resource.hasValue).toBe('function'); + }); + test('should handle the error', () => { const spy = vi.spyOn(httpErrorReporter, 'reportError'); diff --git a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts index cbc2924c787..e0b167dc2ac 100644 --- a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts +++ b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.d.ts @@ -5,5 +5,6 @@ export interface GenerateProxyGeneratorSchema { target: string; url: string; serviceType: string; + resourceApi: boolean; entryPoint: string; } diff --git a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json index 51f39168482..b9263f58b78 100644 --- a/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json +++ b/npm/ng-packs/packages/generators/src/generators/generate-proxy/schema.json @@ -80,6 +80,11 @@ ] } }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template index 22443a5a889..aaa4220d9b3 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template @@ -9,12 +9,45 @@ export class <%= name %>Service { apiName = '<%= apiName %>';<% for (let {body, signature} of methods) { %> <% - const isBlob = body.isBlobMethod() ; - const responseType = isBlob ? "Blob":body.responseType; + const isBlob = body.isBlobMethod(); + const responseType = isBlob ? 'Blob' : body.responseType; const httpResponseType = body.httpResponseType; const acceptHeader = body.acceptHeader; + const resourceParameters = signature.parameters.filter(p => p.name !== 'config'); + const resourceType = resourceParameters.length + ? `{ ${resourceParameters.map(p => `${p.name}${p.optional}: ${p.type}`).join('; ')} }` + : ''; + const resourceArgs = resourceParameters.map(p => `params.${p.name}`).join(', '); %> + <% if (resourceApi && body.method === 'GET') { %> + <%= camel(signature.name) %> = (<% + if (resourceParameters.length) { %> + params: Signal<<%= resourceType %>>,<% } %> + config?: Partial + ): ResourceRef<<%= responseType %> | undefined> => this.restService.requestResource( + computed(() => {<% + if (resourceParameters.length) { %> + const { <%= resourceParameters.map(p => p.name).join(', ') %> } = params();<% } %> + return ({ + method: '<%= body.method %>',<% + if (httpResponseType && httpResponseType !== 'json') { %> + responseType: '<%= httpResponseType %>',<% } %><% + if (acceptHeader) { %> + headers: { Accept: '<%= acceptHeader %>' },<% } %> + url: <%= body.url %>,<% + if (body.dictParamVar && !body.params.length) { %> + params: <%= body.dictParamVar %>,<% } %><% + if (body.dictParamVar && body.params.length) { %> + params: { ...<%= body.dictParamVar %>, <%= body.params.join(', ') %> },<% } %><% + if (!body.dictParamVar && body.params.length) { %> + params: { <%= body.params.join(', ') %> },<% } + if (body.body) { %> + body: <%= body.body %>,<% } %> + }); + }), + { apiName: this.apiName, ...config }, + );<% } else { %> <%= camel(signature.name) %> = (<%= serializeParameters(signature.parameters) %>) => this.restService.request<<%= body.requestType %>, <%= responseType %>>({ method: '<%= body.method %>',<% @@ -32,5 +65,6 @@ export class <%= name %>Service { if (body.body) { %> body: <%= body.body %>,<% } %> }, - { apiName: this.apiName,...config });<% } %> -} \ No newline at end of file + { apiName: this.apiName,...config });<% } %><% + } %> +} diff --git a/npm/ng-packs/packages/schematics/src/commands/api/index.ts b/npm/ng-packs/packages/schematics/src/commands/api/index.ts index 15dfb8ab2de..ee759cb9cf8 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/api/index.ts @@ -56,6 +56,7 @@ export default function (schema: GenerateProxySchema) { const types = data.types; const modules = data.modules; const serviceType = schema.serviceType || defaultEServiceType; + const resourceApi = schema.resourceApi ?? false; if (!types || !modules) { throw new SchematicsException(Exception.InvalidApiDefinition); @@ -82,6 +83,7 @@ export default function (schema: GenerateProxySchema) { apiName, controllers, serviceImports, + resourceApi, }); const modelImports: Record = {}; @@ -177,7 +179,7 @@ function createModelGenerator(params: ModelGeneratorParams) { } function createServiceGenerator(params: ServiceGeneratorParams) { - const { targetPath, controllers, serviceImports } = params; + const { targetPath, controllers, serviceImports, resourceApi } = params; const mapControllerToService = createControllerToServiceMapper(params); return chain( @@ -195,6 +197,7 @@ function createServiceGenerator(params: ServiceGeneratorParams) { applyTemplates({ ...cases, serializeParameters, + resourceApi, ...service, }), move(normalize(targetPath)), diff --git a/npm/ng-packs/packages/schematics/src/commands/api/schema.json b/npm/ng-packs/packages/schematics/src/commands/api/schema.json index b5fde3ad79a..ec2a49d0b38 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/api/schema.json @@ -79,6 +79,11 @@ } ] } + }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false } }, diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts b/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts index 3c621ddb873..4cf3b9fe73a 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-add/index.ts @@ -11,6 +11,7 @@ import { createProxyWarningSaver, mergeAndAllowDelete, removeDefaultPlaceholders, + resolveProxyResourceApi, resolveProject, } from '../../utils'; @@ -24,17 +25,21 @@ export default function (schema: GenerateProxySchema) { const targetPath = buildTargetPath(target.definition, params.entryPoint); const readProxyConfig = createProxyConfigReader(targetPath); let generated: string[] = []; + let previousResourceApi = false; try { - generated = readProxyConfig(host).generated; + const previousConfig = readProxyConfig(host); + generated = previousConfig.generated; + previousResourceApi = resolveProxyResourceApi(params, previousConfig); const index = generated.findIndex(m => m === moduleName); if (index < 0) generated.push(moduleName); } catch (_) { generated.push(moduleName); + previousResourceApi = resolveProxyResourceApi(params); } const getApiDefinition = createApiDefinitionGetter(params); - const data = { generated, ...(await getApiDefinition(host)) }; + const data = { generated, resourceApi: previousResourceApi, ...(await getApiDefinition(host)) }; data.generated = []; const clearProxy = createProxyClearer(targetPath); @@ -43,7 +48,7 @@ export default function (schema: GenerateProxySchema) { const saveProxyWarning = createProxyWarningSaver(targetPath); - const generateApis = createApisGenerator(schema, generated); + const generateApis = createApisGenerator({ ...schema, resourceApi: previousResourceApi }, generated); const generateIndex = createProxyIndexGenerator(targetPath); diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json b/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json index 8c3ca7fe533..77466413153 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-add/schema.json @@ -80,6 +80,11 @@ ] } }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts index dcea70bb395..ac4b50ae4cd 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/index.ts @@ -10,6 +10,7 @@ import { createProxyIndexGenerator, mergeAndAllowDelete, removeDefaultPlaceholders, + resolveProxyResourceApi, resolveProject, } from '../../utils'; @@ -21,17 +22,19 @@ export default function (schema: GenerateProxySchema) { const targetPath = buildTargetPath(target.definition, params.entryPoint); const readProxyConfig = createProxyConfigReader(targetPath); - const { generated } = readProxyConfig(host); + const previousConfig = readProxyConfig(host); + const { generated } = previousConfig; + const resourceApi = resolveProxyResourceApi(params, previousConfig); const getApiDefinition = createApiDefinitionGetter(params); - const data = { generated, ...(await getApiDefinition(host)) }; + const data = { generated, resourceApi, ...(await getApiDefinition(host)) }; data.generated = []; const clearProxy = createProxyClearer(targetPath); const saveProxyConfig = createProxyConfigSaver(data, targetPath); - const generateApis = createApisGenerator(schema, generated); + const generateApis = createApisGenerator({ ...schema, resourceApi }, generated); const generateIndex = createProxyIndexGenerator(targetPath); diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json index c5fd51f9b46..68a7958eb74 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-refresh/schema.json @@ -49,6 +49,11 @@ }, "x-prompt": "Please enter URL for api definition (default: API Name's url in environment file)" }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts index 23864b730d6..24acb351c0c 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/index.ts @@ -10,6 +10,7 @@ import { createProxyIndexGenerator, mergeAndAllowDelete, removeDefaultPlaceholders, + resolveProxyResourceApi, resolveProject, } from '../../utils'; @@ -23,21 +24,23 @@ export default function (schema: GenerateProxySchema) { const targetPath = buildTargetPath(target.definition, params.entryPoint); const readProxyConfig = createProxyConfigReader(targetPath); - const { generated } = readProxyConfig(host); + const previousConfig = readProxyConfig(host); + const { generated } = previousConfig; + const resourceApi = resolveProxyResourceApi(params, previousConfig); const index = generated.findIndex(m => m === moduleName); if (index < 0) return host; generated.splice(index, 1); const getApiDefinition = createApiDefinitionGetter(params); - const data = { generated, ...(await getApiDefinition(host)) }; + const data = { generated, resourceApi, ...(await getApiDefinition(host)) }; data.generated = []; const clearProxy = createProxyClearer(targetPath); const saveProxyConfig = createProxyConfigSaver(data, targetPath); - const generateApis = createApisGenerator(schema, generated); + const generateApis = createApisGenerator({ ...schema, resourceApi }, generated); const generateIndex = createProxyIndexGenerator(targetPath); diff --git a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json index 253ac0b0c32..f0ed7c7b957 100644 --- a/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json +++ b/npm/ng-packs/packages/schematics/src/commands/proxy-remove/schema.json @@ -49,6 +49,11 @@ }, "x-prompt": "Please enter URL for API definition (default: API Name's url in environment file)" }, + "resourceApi": { + "description": "Generate Resource API helpers for read endpoints", + "type": "boolean", + "default": false + }, "entryPoint": { "description": "Target Angular project to place the generated code", "type": "string", diff --git a/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts b/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts index 6d4c67bcd84..de4390e6586 100644 --- a/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts +++ b/npm/ng-packs/packages/schematics/src/models/generate-proxy-schema.ts @@ -31,4 +31,9 @@ export interface GenerateProxySchema { */ entryPoint?: string; serviceType?: eServiceType; + + /** + * Generate Resource API helpers for read operations. + */ + resourceApi?: boolean; } diff --git a/npm/ng-packs/packages/schematics/src/models/proxy-config.ts b/npm/ng-packs/packages/schematics/src/models/proxy-config.ts index 226833da124..34d2c933fff 100644 --- a/npm/ng-packs/packages/schematics/src/models/proxy-config.ts +++ b/npm/ng-packs/packages/schematics/src/models/proxy-config.ts @@ -2,4 +2,5 @@ import { ApiDefinition } from './api-definition'; export interface ProxyConfig extends ApiDefinition { generated: string[]; + resourceApi?: boolean; } diff --git a/npm/ng-packs/packages/schematics/src/models/service.ts b/npm/ng-packs/packages/schematics/src/models/service.ts index 98a18bddd88..de020e370bc 100644 --- a/npm/ng-packs/packages/schematics/src/models/service.ts +++ b/npm/ng-packs/packages/schematics/src/models/service.ts @@ -10,6 +10,7 @@ export interface ServiceGeneratorParams { apiName: string; controllers: Controller[]; serviceImports: Record; + resourceApi?: boolean; } export class Service { diff --git a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts index 04b070ae615..611c0ef5990 100644 --- a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -36,14 +36,20 @@ function render(context: Record): string { return compiled(context); } -function buildContext(body: Partial) { +function buildContext(body: Partial, resourceApi = false) { return { apiName: 'Default', name: 'Sample', namespace: 'app', + resourceApi, imports: [ { keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' }, { keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' }, + ...(resourceApi + ? [ + { keyword: 'import', specifiers: ['Signal', 'computed', 'ResourceRef'], path: '@angular/core' }, + ] + : []), ], methods: [ { @@ -100,6 +106,38 @@ describe('proxy service template — rendered output', () => { expect(output).not.toContain('headers:'); }); + test('resource api mode emits requestResource helper for GET methods', () => { + const ctx = buildContext({ + responseType: 'MyDto', + responseTypeWithNamespace: 'My.Project.MyDto', + }, true); + ctx.methods[0].signature.parameters = [ + { name: 'input', type: 'GetSampleListInput' } as any, + { name: 'config', type: 'Partial' } as any, + ]; + const output = render(ctx); + + expect(output).toContain('Signal'); + expect(output).toContain('computed(() => {'); + expect(output).toContain('const { input } = params();'); + expect(output).toContain('this.restService.requestResource('); + expect(output).toContain('{ apiName: this.apiName, ...config }'); + expect(output).toContain('getSampleAsync = ('); + expect(output).not.toContain('getSampleAsyncResource'); + expect(output).not.toContain('this.restService.request'); + }); + + test('resource api mode does not emit helpers for non-GET methods', () => { + const output = render(buildContext({ + method: 'POST', + responseType: 'MyDto', + responseTypeWithNamespace: 'My.Project.MyDto', + }, true)); + + expect(output).toContain('getSampleAsync = ('); + expect(output).not.toContain('requestResource('); + }); + test('json httpResponseType emits Accept but no responseType (default is json)', () => { const output = render(buildContext({ responseType: 'string', @@ -191,6 +229,109 @@ describe('proxy service template — rendered output', () => { expect(output.match(/}/g)!.length).toBeGreaterThanOrEqual(3); }); + test('resource api rendered service compiles cleanly', () => { + const ts = require('typescript'); + const ctx = buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'json', + acceptHeader: 'application/json', + }, true); + ctx.methods[0].signature.parameters = [ + { name: 'config', type: 'Partial' } as any, + ]; + const output = render(ctx); + + const abpStub = ` + declare module '@abp/ng.core' { + export namespace Rest { + export interface Config { + apiName?: string; + observe?: any; + skipHandleError?: boolean; + responseType?: string; + [key: string]: any; + } + export type Observe = any; + } + export class RestService { + request(req: any, config?: any): import('rxjs').Observable; + requestResource(request: any, config?: any, api?: string): any; + } + } + `; + const angularCoreStub = ` + declare module '@angular/core' { + export function Injectable(opts?: any): ClassDecorator; + export function inject(token: { new (...args: any[]): T }): T; + export function inject(token: any): T; + export interface Signal { (): T; } + export function computed(fn: () => T): Signal; + export interface ResourceRef { value?: T; } + } + `; + const rxjsStub = ` + declare module 'rxjs' { + export class Observable { subscribe(...args: any[]): unknown; } + } + `; + + const ambient = abpStub + angularCoreStub + rxjsStub; + const sources: Record = { + '/proxy/sample.service.ts': output, + '/proxy/ambient.d.ts': ambient, + }; + + const compilerOptions: any = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ES2020, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + ignoreDeprecations: '6.0', + experimentalDecorators: true, + emitDecoratorMetadata: true, + strict: true, + noEmit: true, + skipLibCheck: true, + }; + + const baseHost = ts.createCompilerHost(compilerOptions, true); + const host: any = { + ...baseHost, + getSourceFile: (fileName: string, languageVersion: any, onError: any) => { + if (sources[fileName]) { + return ts.createSourceFile(fileName, sources[fileName], languageVersion, true); + } + return baseHost.getSourceFile(fileName, languageVersion, onError); + }, + fileExists: (fileName: string) => + sources[fileName] != null || baseHost.fileExists(fileName), + readFile: (fileName: string) => + sources[fileName] ?? baseHost.readFile(fileName), + }; + + const program = ts.createProgram(Object.keys(sources), compilerOptions, host); + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d: any) => d.category === ts.DiagnosticCategory.Error && d.code !== 6053); + + if (errors.length) { + const messages = errors + .map((d: any) => { + const where = d.file + ? (() => { + const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); + const lineText = d.file.text.split('\n')[p.line]; + return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; + })() + : '(no file)'; + return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; + }) + .join('\n---\n'); + throw new Error(`Resource proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); + } + expect(errors).toHaveLength(0); + }); + test.each([ { name: 'string + json Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'json', acceptHeader: 'application/problem+json' } }, { name: 'string + text Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'text', acceptHeader: 'text/csv' } }, @@ -244,6 +385,7 @@ describe('proxy service template — rendered output', () => { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020, moduleResolution: ts.ModuleResolutionKind.NodeJs, + ignoreDeprecations: '6.0', experimentalDecorators: true, emitDecoratorMetadata: true, strict: true, @@ -365,6 +507,7 @@ describe('proxy service template — rendered output', () => { } export class RestService { request(req: any, config?: any): import('rxjs').Observable; + requestResource(request: any, config?: any, api?: string): any; } } `; @@ -373,6 +516,8 @@ describe('proxy service template — rendered output', () => { export function Injectable(opts?: any): ClassDecorator; export function inject(token: { new (...args: any[]): T }): T; export function inject(token: any): T; + export function computed(fn: () => T): any; + export interface ResourceRef { value?: T; } } `; const rxjsStub = ` @@ -397,6 +542,7 @@ describe('proxy service template — rendered output', () => { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ES2020, moduleResolution: ts.ModuleResolutionKind.NodeJs, + ignoreDeprecations: '6.0', experimentalDecorators: true, emitDecoratorMetadata: true, strict: true, diff --git a/npm/ng-packs/packages/schematics/src/utils/service.ts b/npm/ng-packs/packages/schematics/src/utils/service.ts index ee1ed4630d3..0932de02bbe 100644 --- a/npm/ng-packs/packages/schematics/src/utils/service.ts +++ b/npm/ng-packs/packages/schematics/src/utils/service.ts @@ -35,6 +35,7 @@ export function createControllerToServiceMapper({ solution, types, apiName, + resourceApi, }: ServiceGeneratorParams) { const mapActionToMethod = createActionToMethodMapper(); @@ -49,6 +50,11 @@ export function createControllerToServiceMapper({ ); imports.push(new Import({ path: '@abp/ng.core', specifiers: ['RestService', 'Rest'] })); imports.push(new Import({ path: '@angular/core', specifiers: ['Injectable', 'inject'] })); + if (resourceApi) { + appendImportSpec(imports, '@angular/core', 'computed'); + appendImportSpec(imports, '@angular/core', 'Signal'); + appendImportSpec(imports, '@angular/core', 'ResourceRef'); + } sortImports(imports); const methods = actions.map(mapActionToMethod); sortMethods(methods); @@ -56,6 +62,16 @@ export function createControllerToServiceMapper({ }; } +function appendImportSpec(imports: Import[], path: string, specifier: string) { + const existing = imports.find(x => x.path === path); + if (!existing) { + imports.push(new Import({ path, specifiers: [specifier] })); + return; + } + + existing.specifiers = [...new Set([...existing.specifiers, specifier])]; +} + function getTypesWithoutIRemoteStreamContent(types: Record) { const newType = { ...types }; VOLO_REMOTE_STREAM_CONTENT.forEach(fileType => { diff --git a/npm/ng-packs/packages/schematics/src/utils/source.ts b/npm/ng-packs/packages/schematics/src/utils/source.ts index 392653b43e9..9386cf35e0b 100644 --- a/npm/ng-packs/packages/schematics/src/utils/source.ts +++ b/npm/ng-packs/packages/schematics/src/utils/source.ts @@ -108,6 +108,13 @@ export function createProxyConfigReader(targetPath: string) { }; } +export function resolveProxyResourceApi( + params: GenerateProxySchema, + previousConfig?: ProxyConfig, +) { + return params.resourceApi ?? previousConfig?.resourceApi ?? false; +} + export function createProxyClearer(targetPath: string) { targetPath += PROXY_PATH; const proxyIndexPath = `${targetPath}/index.ts`;