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 c445286162..22443a5a88 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 @@ -11,15 +11,17 @@ export class <%= name %>Service { <% const isBlob = body.isBlobMethod() ; const responseType = isBlob ? "Blob":body.responseType; + const httpResponseType = body.httpResponseType; + const acceptHeader = body.acceptHeader; %> <%= camel(signature.name) %> = (<%= serializeParameters(signature.parameters) %>) => this.restService.request<<%= body.requestType %>, <%= responseType %>>({ method: '<%= body.method %>',<% - if (body.responseType === 'string') { %> - responseType: 'text',<% } %><% - if (isBlob) { %> - responseType: 'blob',<% } %> + if (httpResponseType && httpResponseType !== 'json') { %> + responseType: '<%= httpResponseType %>',<% } %><% + if (acceptHeader) { %> + headers: { Accept: '<%= acceptHeader %>' },<% } %> url: <%= body.url %>,<% if (body.dictParamVar && !body.params.length) { %> params: <%= body.dictParamVar %>,<% } %><% diff --git a/npm/ng-packs/packages/schematics/src/models/api-definition.ts b/npm/ng-packs/packages/schematics/src/models/api-definition.ts index 70dca68dd5..3e6458e91d 100644 --- a/npm/ng-packs/packages/schematics/src/models/api-definition.ts +++ b/npm/ng-packs/packages/schematics/src/models/api-definition.ts @@ -68,7 +68,7 @@ export interface Action { supportedVersions: string[]; parametersOnMethod: ParameterInSignature[]; parameters: ParameterInBody[]; - returnValue: TypeDef; + returnValue: ReturnValueDef; } export interface ParameterInSignature { @@ -100,6 +100,11 @@ export interface TypeDef { typeSimple: string; } +export interface ReturnValueDef extends TypeDef { + contentTypes?: string[]; + isRemoteStream?: boolean; +} + export interface TypeWithEnum { isEnum: boolean; type: string; diff --git a/npm/ng-packs/packages/schematics/src/models/method.ts b/npm/ng-packs/packages/schematics/src/models/method.ts index 404fa9d233..920bddba0f 100644 --- a/npm/ng-packs/packages/schematics/src/models/method.ts +++ b/npm/ng-packs/packages/schematics/src/models/method.ts @@ -45,6 +45,8 @@ export class Body { responseTypeWithNamespace: string; requestType = 'any'; responseType: string; + httpResponseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; + acceptHeader?: string; url: string; registerActionParameter = (param: ParameterInBody) => { diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts new file mode 100644 index 0000000000..83984e7a4b --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -0,0 +1,543 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, test } from 'vitest'; +import { eBindingSourceId } from '../enums'; +import { Action } from '../models'; +import { createActionToBodyMapper } from '../utils/service'; + +const TEMPLATE_PATH = join( + __dirname, + '..', + 'commands', + 'api', + 'files-service', + 'proxy', + '__namespace@dir__', + '__name@kebab__.service.ts.template', +); + +function buildAction(overrides: Partial): Action { + return { + uniqueName: 'GetStatusAsync', + name: 'GetStatus', + httpMethod: 'GET', + url: 'api/app/test-service/status', + supportedVersions: [], + parametersOnMethod: [], + parameters: [], + returnValue: { type: 'System.String', typeSimple: 'string' }, + ...overrides, + } as Action; +} + +describe('createActionToBodyMapper — string return value', () => { + const mapBody = createActionToBodyMapper(); + + test('without contentTypes falls back to text mode (legacy behavior)', () => { + const body = mapBody(buildAction({})); + + expect(body.responseType).toBe('string'); + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('with contentTypes containing application/json picks json + Accept: application/json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json', 'text/plain'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('with only text/* contentTypes picks text + Accept: text/plain', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain', 'text/csv'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); +}); + +describe('createActionToBodyMapper — IRemoteStreamContent return value', () => { + const mapBody = createActionToBodyMapper(); + + test('always picks blob + Accept: application/octet-stream regardless of contentTypes', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.isBlobMethod()).toBe(true); + }); + + test('binary-only contentTypes also picks blob + octet-stream', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['application/pdf'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); +}); + +describe('createActionToBodyMapper — other return values', () => { + const mapBody = createActionToBodyMapper(); + + test('object return without contentTypes has no httpResponseType / acceptHeader (defaults to json)', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('void return has no httpResponseType / acceptHeader', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.Void', typeSimple: 'void' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('registers a query parameter via the binding source', () => { + const body = mapBody( + buildAction({ + parameters: [ + { + nameOnMethod: 'id', + name: 'id', + jsonName: null, + type: 'System.Guid', + typeSimple: 'string', + isOptional: false, + defaultValue: null, + constraintTypes: null, + bindingSourceId: eBindingSourceId.Query, + descriptorName: '', + }, + ], + }), + ); + + expect(body.params).toEqual(['id']); + }); +}); + +describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { + const mapBody = createActionToBodyMapper(); + + test('isRemoteStream=true forces blob even if Type is a custom subclass name', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'My.Project.CustomStreamContent', + typeSimple: 'My.Project.CustomStreamContent', + isRemoteStream: true, + contentTypes: ['text/plain', 'application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); +}); + +describe('createActionToBodyMapper — +json suffix detection', () => { + const mapBody = createActionToBodyMapper(); + + test('application/problem+json picked as json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/problem+json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('text/json picked as json (informal but real-world)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('application/vnd.api+json picked as json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/vnd.api+json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + }); +}); + +describe('createActionToBodyMapper — expanded binary whitelist', () => { + const mapBody = createActionToBodyMapper(); + + test.each([ + ['application/wasm'], + ['font/woff2'], + ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['application/vnd.ms-excel'], + ['application/vnd.oasis.opendocument.spreadsheet'], + ['application/x-msdownload'], + ['application/rtf'], + ['application/x-rar-compressed'], + ['application/x-bzip2'], + ['application/x-iso9660-image'], + ['application/java-archive'], + ['application/epub+zip'], + ['model/gltf-binary'], + ])('"%s" picked as blob', mediaType => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: [mediaType], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + }); +}); + +describe('createActionToBodyMapper — contentTypes precedence and edge cases', () => { + const mapBody = createActionToBodyMapper(); + + test('isBlobMethod() type detection wins over json in contentTypes', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.RemoteStreamContent', + typeSimple: 'Volo.Abp.Content.RemoteStreamContent', + contentTypes: ['application/json', 'text/plain'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('IRemoteStreamContent[] array also picked as blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent[]', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('case-insensitive json detection (APPLICATION/JSON)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['APPLICATION/JSON'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('image/* contentTypes alone picks blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['image/png', 'image/jpeg'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('video/* and audio/* picked as blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['video/mp4', 'audio/mpeg'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + }); + + test('application/pdf contentTypes picked as blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['application/pdf'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + }); + + test('empty contentTypes falls through to legacy string→text behavior', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.String', typeSimple: 'string', contentTypes: [] }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('mixed text/* and application/json picks json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/json', 'text/plain', 'application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('contentTypes with json-suffix variants still picks json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + }); + + test('non-string non-blob type with contentTypes containing json defaults appropriately', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'My.Project.UserDto', + typeSimple: 'My.Project.UserDto', + contentTypes: ['application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('json contentType with charset parameter is normalized', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('text contentType with charset parameter is normalized', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain ; charset=utf-8 '], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); + + test('mixed text/plain (with charset) and application/json picks json after normalize', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain; charset=utf-8', 'application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + }); + + test('text/csv only (custom text format) picks text', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/csv'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); +}); + +describe('createActionToBodyMapper — backward compatibility', () => { + const mapBody = createActionToBodyMapper(); + + test('legacy api-definition without contentTypes works (string)', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.String', typeSimple: 'string' } as Partial['returnValue'], + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('legacy api-definition without contentTypes works (IRemoteStreamContent)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('legacy api-definition without contentTypes works (object)', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('void / no return preserves legacy behavior', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.Void', typeSimple: 'void' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); +}); + +describe('proxy service template emission', () => { + const template = readFileSync(TEMPLATE_PATH, 'utf8'); + + test('reads body.httpResponseType and body.acceptHeader from body', () => { + expect(template).toContain('body.httpResponseType'); + expect(template).toContain('body.acceptHeader'); + }); + + test('emits Accept header conditional', () => { + expect(template).toMatch(/headers:\s*\{\s*Accept:/); + }); + + test('emits responseType only for non-json httpResponseType', () => { + expect(template).toMatch(/httpResponseType\s*&&\s*httpResponseType\s*!==\s*'json'/); + }); +}); 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 new file mode 100644 index 0000000000..fb611c0088 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -0,0 +1,179 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { template as lodashTemplate } from 'lodash'; +import { describe, expect, test } from 'vitest'; + +/** + * Smoke test that actually renders the proxy `.service.ts.template` against + * representative body configurations and asserts the emitted code matches + * what the runtime contract requires. + * + * This catches template-syntax / control-flow regressions that a string + * `toContain` check on the template source would silently let through. + */ + +const TEMPLATE_PATH = join( + __dirname, + '..', + 'commands', + 'api', + 'files-service', + 'proxy', + '__namespace@dir__', + '__name@kebab__.service.ts.template', +); + +const TEMPLATE_SRC = readFileSync(TEMPLATE_PATH, 'utf8'); + +function render(context: Record): string { + const compiled = lodashTemplate(TEMPLATE_SRC, { + imports: { + camel: (s: string) => s.charAt(0).toLowerCase() + s.slice(1), + serializeParameters: (params: Array<{ name: string; type: string; default?: string }>) => + params.map(p => `${p.name}: ${p.type}`).join(', '), + }, + }); + return compiled(context); +} + +function buildContext(body: Partial) { + return { + apiName: 'Default', + name: 'Sample', + namespace: 'app', + imports: [ + { keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' }, + { keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' }, + ], + methods: [ + { + body: makeBody(body), + signature: { + name: 'GetSampleAsync', + parameters: [], + }, + }, + ], + }; +} + +interface MockBody { + method: string; + url: string; + responseType: string; + responseTypeWithNamespace: string; + httpResponseType?: string; + acceptHeader?: string; + body?: string; + params: string[]; + dictParamVar?: string; + requestType: string; + isBlobMethod(): boolean; +} + +function makeBody(overrides: Partial): MockBody { + return { + method: 'GET', + url: "'/api/sample'", + responseType: 'any', + responseTypeWithNamespace: 'any', + httpResponseType: undefined, + acceptHeader: undefined, + body: undefined, + params: [], + dictParamVar: undefined, + requestType: 'any', + isBlobMethod: () => false, + ...overrides, + }; +} + +describe('proxy service template — rendered output', () => { + test('default JSON body emits no responseType and no Accept header', () => { + const output = render(buildContext({ + responseType: 'MyDto', + responseTypeWithNamespace: 'My.Project.MyDto', + })); + + expect(output).toContain("method: 'GET'"); + expect(output).not.toContain('responseType:'); + expect(output).not.toContain('headers:'); + }); + + test('json httpResponseType emits Accept but no responseType (default is json)', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'json', + acceptHeader: 'application/json', + })); + + expect(output).toContain("headers: { Accept: 'application/json' }"); + expect(output).not.toContain('responseType:'); + }); + + test('text httpResponseType emits both responseType and Accept', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'text', + acceptHeader: 'text/plain', + })); + + expect(output).toContain("responseType: 'text'"); + expect(output).toContain("headers: { Accept: 'text/plain' }"); + }); + + test('blob (IRemoteStreamContent) emits Blob return type + responseType + Accept', () => { + const output = render(buildContext({ + responseType: 'Volo.Abp.Content.IRemoteStreamContent', + responseTypeWithNamespace: 'Volo.Abp.Content.IRemoteStreamContent', + isBlobMethod: () => true, + httpResponseType: 'blob', + acceptHeader: 'application/octet-stream', + })); + + expect(output).toContain("responseType: 'blob'"); + expect(output).toContain('Blob>'); + expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); + }); + + test('arraybuffer httpResponseType emits responseType', () => { + const output = render(buildContext({ + responseType: 'ArrayBuffer', + responseTypeWithNamespace: 'ArrayBuffer', + httpResponseType: 'arraybuffer', + acceptHeader: 'application/octet-stream', + })); + + expect(output).toContain("responseType: 'arraybuffer'"); + expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); + }); + + test('no acceptHeader → no headers line', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'text', + acceptHeader: undefined, + })); + + expect(output).toContain("responseType: 'text'"); + expect(output).not.toContain('headers:'); + }); + + test('rendered service code is valid TypeScript-shaped (closing braces / semicolons)', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'json', + acceptHeader: 'application/json', + })); + + expect(output).toContain('@Injectable({'); + expect(output).toContain('providedIn: \'root\''); + expect(output).toContain('export class SampleService'); + expect(output).toContain('this.restService.request 0) { + const normalized = contentTypes.map(normalizeMediaType); + + if (normalized.some(isJsonMediaType)) { + return { httpResponseType: 'json', acceptHeader: 'application/json' }; + } + + if (normalized.every(ct => ct.startsWith('text/'))) { + return { httpResponseType: 'text', acceptHeader: 'text/plain' }; + } + + if (normalized.every(isBinaryMediaType)) { + return { httpResponseType: 'blob', acceptHeader: 'application/octet-stream' }; + } + } + + if (responseType === 'string') { + return { httpResponseType: 'text' }; + } + + return {}; +} + export function createActionToSignatureMapper() { const adaptType = createTypeAdapter();