From b3c1e4369b45f40c4b8c44b30fe7d19d2a50155a Mon Sep 17 00:00:00 2001 From: sumeyye Date: Tue, 7 Jul 2026 08:32:26 +0300 Subject: [PATCH] update: models, tests and utils for the resource api option --- .../src/commands/proxy-add/index.ts | 2 - .../src/models/generate-proxy-schema.ts | 5 + .../schematics/src/models/proxy-config.ts | 1 + .../packages/schematics/src/models/service.ts | 1 + .../proxy-service-template-render.spec.ts | 145 +++++++++++++++++- .../packages/schematics/src/utils/service.ts | 16 ++ .../packages/schematics/src/utils/source.ts | 7 + 7 files changed, 174 insertions(+), 3 deletions(-) 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 7952ee0cd8..4cf3b9fe73 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 @@ -52,8 +52,6 @@ export default function (schema: GenerateProxySchema) { const generateIndex = createProxyIndexGenerator(targetPath); - console.log('HELLO'); - return chain([ mergeAndAllowDelete(host, clearProxy), saveProxyConfig, 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 6d4c67bcd8..de4390e658 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 226833da12..34d2c933ff 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 98a18bddd8..de020e370b 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 04b070ae61..1860b088c7 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,35 @@ 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('getSampleAsyncResource'); + }); + + 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).not.toContain('getSampleAsyncResource'); + }); + test('json httpResponseType emits Accept but no responseType (default is json)', () => { const output = render(buildContext({ responseType: 'string', @@ -191,6 +226,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 +382,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 +504,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 +513,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 +539,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 ee1ed4630d..0932de02bb 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 392653b43e..9386cf35e0 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`;