From b0e4a9477fa87bc6597a236ff37e8d12fdb9ec1c Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 18:24:24 +0800 Subject: [PATCH] Echo declared Accept media types and degrade IRemoteStreamContent collections to any[] in Angular schematic --- .../packages/schematics/src/models/method.ts | 3 + .../src/tests/action-to-body-mapper.spec.ts | 67 ++++++++++++++----- .../proxy-service-template-render.spec.ts | 14 ++++ .../packages/schematics/src/utils/service.ts | 32 +++++---- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/npm/ng-packs/packages/schematics/src/models/method.ts b/npm/ng-packs/packages/schematics/src/models/method.ts index 920bddba0f..c160359770 100644 --- a/npm/ng-packs/packages/schematics/src/models/method.ts +++ b/npm/ng-packs/packages/schematics/src/models/method.ts @@ -87,6 +87,9 @@ export class Body { } isBlobMethod() { + if (this.httpResponseType === 'blob') { + return true; + } return VOLO_REMOTE_STREAM_CONTENT.some(x => x === this.responseTypeWithNamespace); } 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 index 83984e7a4b..d6a80d7230 100644 --- 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 @@ -90,7 +90,7 @@ describe('createActionToBodyMapper — IRemoteStreamContent return value', () => expect(body.isBlobMethod()).toBe(true); }); - test('binary-only contentTypes also picks blob + octet-stream', () => { + test('binary-only contentTypes picks blob and echoes back the actual binary media type', () => { const body = mapBody( buildAction({ returnValue: { @@ -102,7 +102,7 @@ describe('createActionToBodyMapper — IRemoteStreamContent return value', () => ); expect(body.httpResponseType).toBe('blob'); - expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.acceptHeader).toBe('application/pdf'); }); }); @@ -172,6 +172,40 @@ describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { expect(body.httpResponseType).toBe('blob'); expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.isBlobMethod()).toBe(true); + }); + + test('[Volo.Abp.Content.IRemoteStreamContent] (real ABP square-bracket form) must NOT pick blob and degrades responseType to any[]', () => { + // ABP serialises collections as `[T]` (not `T[]`) — pin the on-the-wire shape. + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Collections.Generic.IList', + typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + expect(body.isBlobMethod()).toBe(false); + expect(body.responseType).toBe('any[]'); + }); + + test('IRemoteStreamContent[] array return must NOT pick blob (server falls back to JSON metadata)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent[]', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + expect(body.isBlobMethod()).toBe(false); + expect(body.responseType).toBe('any[]'); }); test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => { @@ -192,7 +226,7 @@ describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { describe('createActionToBodyMapper — +json suffix detection', () => { const mapBody = createActionToBodyMapper(); - test('application/problem+json picked as json', () => { + test('application/problem+json echoes back as Accept (decoder stays json)', () => { const body = mapBody( buildAction({ returnValue: { @@ -204,10 +238,10 @@ describe('createActionToBodyMapper — +json suffix detection', () => { ); expect(body.httpResponseType).toBe('json'); - expect(body.acceptHeader).toBe('application/json'); + expect(body.acceptHeader).toBe('application/problem+json'); }); - test('text/json picked as json (informal but real-world)', () => { + test('text/json echoes back as text/json (decoder stays json)', () => { const body = mapBody( buildAction({ returnValue: { @@ -219,10 +253,10 @@ describe('createActionToBodyMapper — +json suffix detection', () => { ); expect(body.httpResponseType).toBe('json'); - expect(body.acceptHeader).toBe('application/json'); + expect(body.acceptHeader).toBe('text/json'); }); - test('application/vnd.api+json picked as json', () => { + test('application/vnd.api+json echoes back as Accept (decoder stays json)', () => { const body = mapBody( buildAction({ returnValue: { @@ -234,6 +268,7 @@ describe('createActionToBodyMapper — +json suffix detection', () => { ); expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/vnd.api+json'); }); }); @@ -287,7 +322,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.acceptHeader).toBe('application/octet-stream'); }); - test('IRemoteStreamContent[] array also picked as blob', () => { + test('IRemoteStreamContent[] array falls through to defaults (server returns JSON metadata, not binary)', () => { const body = mapBody( buildAction({ returnValue: { @@ -297,8 +332,8 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', } as Partial), ); - expect(body.httpResponseType).toBe('blob'); - expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); }); test('case-insensitive json detection (APPLICATION/JSON)', () => { @@ -316,7 +351,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.acceptHeader).toBe('application/json'); }); - test('image/* contentTypes alone picks blob', () => { + test('image/* contentTypes alone picks blob and echoes back the first image type', () => { const body = mapBody( buildAction({ returnValue: { @@ -328,7 +363,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', ); expect(body.httpResponseType).toBe('blob'); - expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.acceptHeader).toBe('image/png'); }); test('video/* and audio/* picked as blob', () => { @@ -370,7 +405,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.acceptHeader).toBeUndefined(); }); - test('mixed text/* and application/json picks json', () => { + test('mixed text/* and application/json picks json and echoes the first json-shaped media type', () => { const body = mapBody( buildAction({ returnValue: { @@ -382,7 +417,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', ); expect(body.httpResponseType).toBe('json'); - expect(body.acceptHeader).toBe('application/json'); + expect(body.acceptHeader).toBe('text/json'); }); test('contentTypes with json-suffix variants still picks json', () => { @@ -458,7 +493,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.httpResponseType).toBe('json'); }); - test('text/csv only (custom text format) picks text', () => { + test('text/csv only (custom text format) picks text and echoes the content type back', () => { const body = mapBody( buildAction({ returnValue: { @@ -470,7 +505,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', ); expect(body.httpResponseType).toBe('text'); - expect(body.acceptHeader).toBe('text/plain'); + expect(body.acceptHeader).toBe('text/csv'); }); }); 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 fb611c0088..87edc87b4f 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 @@ -138,6 +138,20 @@ describe('proxy service template — rendered output', () => { expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); }); + test('IRemoteStreamContent[] degradation renders any[] return type and does not reference IRemoteStreamContent', () => { + const output = render(buildContext({ + responseType: 'any[]', + responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]', + isBlobMethod: () => false, + httpResponseType: undefined, + acceptHeader: undefined, + })); + + expect(output).toContain('any[]'); + expect(output).not.toContain('IRemoteStreamContent'); + expect(output).not.toContain("responseType: 'blob'"); + }); + test('arraybuffer httpResponseType emits responseType', () => { const output = render(buildContext({ responseType: 'ArrayBuffer', diff --git a/npm/ng-packs/packages/schematics/src/utils/service.ts b/npm/ng-packs/packages/schematics/src/utils/service.ts index c0cc4b0cbc..d5d65326d9 100644 --- a/npm/ng-packs/packages/schematics/src/utils/service.ts +++ b/npm/ng-packs/packages/schematics/src/utils/service.ts @@ -96,6 +96,9 @@ export function createActionToBodyMapper() { responseType = adaptType(normalizedType); } } + if (isRemoteStreamContentArray(returnValue.typeSimple)) { + responseType = 'any[]'; + } const responseTypeWithNamespace = returnValue.typeSimple; const { httpResponseType, acceptHeader } = resolveHttpResponseAndAccept( responseType, @@ -175,28 +178,24 @@ function resolveHttpResponseAndAccept( contentTypes: string[] | undefined, isRemoteStreamFlag: boolean | undefined, ): { httpResponseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; acceptHeader?: string } { - if ( - isRemoteStreamFlag || - isRemoteStreamContent(responseTypeWithNamespace) || - isRemoteStreamContentArray(responseTypeWithNamespace) - ) { + if (isRemoteStreamFlag || isRemoteStreamContent(responseTypeWithNamespace)) { return { httpResponseType: 'blob', acceptHeader: 'application/octet-stream' }; } if (contentTypes && contentTypes.length > 0) { const normalized = contentTypes.map(normalizeMediaType); - if (normalized.some(isJsonMediaType)) { - return { httpResponseType: 'json', acceptHeader: 'application/json' }; + const firstJsonShaped = normalized.find(isJsonMediaType); + if (firstJsonShaped) { + return { httpResponseType: 'json', acceptHeader: firstJsonShaped }; } - if (normalized.every(ct => ct.startsWith('text/'))) { - return { httpResponseType: 'text', acceptHeader: 'text/plain' }; + return { httpResponseType: 'text', acceptHeader: normalized[0] }; } - if (normalized.every(isBinaryMediaType)) { - return { httpResponseType: 'blob', acceptHeader: 'application/octet-stream' }; + return { httpResponseType: 'blob', acceptHeader: normalized[0] }; } + return { acceptHeader: normalized[0] }; } if (responseType === 'string') { @@ -247,13 +246,18 @@ export function isRemoteStreamContent(type: string) { } export function isRemoteStreamContentArray(type: string) { - // Check for array types like Volo.Abp.Content.IRemoteStreamContent[] if (VOLO_REMOTE_STREAM_CONTENT.map(x => `${x}[]`).some(x => x === type)) { return true; } - // Check for collection types like List, IEnumerable, ICollection, Collection, IList - // This matches any generic type from System.Collections.Generic that implements IEnumerable + // ABP serialises collections as `[T]` (see ApiTypeNameHelper.GetSimpleTypeName). + if (type.startsWith('[') && type.endsWith(']')) { + const inner = type.slice(1, -1); + if (VOLO_REMOTE_STREAM_CONTENT.includes(inner)) { + return true; + } + } + if (isCollectionType(type)) { const { generics } = extractGenerics(type); if (generics.length > 0 && VOLO_REMOTE_STREAM_CONTENT.includes(generics[0])) {