From 49c4ff5a05895b91f89b7fad94d49a164e737cc3 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 18:24:23 +0800 Subject: [PATCH] Unwrap ABP error envelope from Blob body and validationErrors-only payloads in RestService --- .../core/src/lib/services/rest.service.ts | 60 ++++++++++++--- .../core/src/lib/tests/rest.service.spec.ts | 75 ++++++++++++++++++- 2 files changed, 123 insertions(+), 12 deletions(-) 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 65a98d15f1..4121eaa75a 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,7 +1,7 @@ import { HttpClient, HttpHeaders, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { Observable, from, of, throwError } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { ExternalHttpClient } from '../clients/http.client'; import { ABP } from '../models/common'; import { Rest } from '../models/rest'; @@ -57,10 +57,12 @@ export class RestService { } as any) .pipe( catchError(err => { - if (!skipHandleError) { - this.tryUnwrapJsonErrorBody(err, effectiveResponseType); + if (skipHandleError) { + return throwError(() => err); } - return skipHandleError ? throwError(() => err) : this.handleError(err); + return this.normalizeErrorBody(err, effectiveResponseType).pipe( + switchMap(normalizedErr => this.handleError(normalizedErr)), + ); }), ); } @@ -97,12 +99,46 @@ export class RestService { } } - private tryUnwrapJsonErrorBody(err: any, responseType: Rest.ResponseType): void { - if (responseType === Rest.ResponseType.JSON || !err) return; - if (typeof err.error !== 'string' || err.error.length === 0) return; + private normalizeErrorBody(err: any, responseType: Rest.ResponseType): Observable { + if (!err || responseType === Rest.ResponseType.JSON) { + return of(err); + } + + if (typeof err.error === 'string') { + this.tryParseJsonErrorText(err, err.error); + return of(err); + } + + if (typeof Blob !== 'undefined' && err.error instanceof Blob) { + return from(this.readBlobAsText(err.error)).pipe( + map((text: string) => { + this.tryParseJsonErrorText(err, text); + return err; + }), + catchError(() => of(err)), + ); + } + + return of(err); + } + + private readBlobAsText(blob: Blob): Promise { + if (typeof (blob as any).text === 'function') { + return (blob as any).text(); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + } + + private tryParseJsonErrorText(err: any, text: string): void { + if (!text || text.length === 0) return; let parsed: any; try { - parsed = JSON.parse(err.error); + parsed = JSON.parse(text); } catch { return; } @@ -115,7 +151,11 @@ export class RestService { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; const inner = parsed.error; if (!inner || typeof inner !== 'object') return false; - return typeof inner.code === 'string' || typeof inner.message === 'string'; + return ( + typeof inner.code === 'string' || + typeof inner.message === 'string' || + Array.isArray(inner.validationErrors) + ); } private getParams(params: Rest.Params, encoder?: HttpParameterCodec): HttpParams { 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 cf967a1d47..3009b0d6dc 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 @@ -278,6 +278,34 @@ describe('HttpClient testing', () => { await completion; }); + test('should unwrap ABP envelope that carries only validationErrors (no message / code)', async () => { + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .pipe( + catchError(err => { + try { + expect(typeof err.error).toBe('object'); + expect(err.error.error.validationErrors).toHaveLength(1); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush( + '{"error":{"validationErrors":[{"message":"Required","members":["Email"]}]}}', + { status: 400, statusText: 'Bad Request' }, + ); + + await completion; + }); + test('should leave non-ABP-envelope JSON body alone in text mode', async () => { const completion = new Promise((resolve, reject) => { spectator.service @@ -285,7 +313,6 @@ describe('HttpClient testing', () => { .pipe( catchError(err => { try { - // JSON parses fine but no error.code/error.message → keep raw string expect(typeof err.error).toBe('string'); expect(err.error).toBe('{"foo":"bar"}'); resolve(); @@ -340,6 +367,51 @@ describe('HttpClient testing', () => { await completion; }); + test('should unwrap ABP error envelope from a Blob body in blob mode', async () => { + // HttpTestingController doesn't deliver Blob in HttpErrorResponse; call the helper directly. + const err: any = { + error: new Blob( + ['{"error":{"code":"AbpAuthorization.002","message":"forbidden-blob"}}'], + { type: 'application/json' }, + ), + }; + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized.error).toEqual({ + error: { code: 'AbpAuthorization.002', message: 'forbidden-blob' }, + }); + }); + + test('should leave non-JSON Blob body alone in blob mode', async () => { + const blob = new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }); + const err: any = { error: blob }; + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized.error).toBe(blob); + }); + + test('should swallow Blob.text() rejection and keep the original error in blob mode', async () => { + const fakeBlob = { + text: () => Promise.reject(new Error('boom')), + } as unknown as Blob; + const err: any = { error: fakeBlob, message: 'original' }; + + Object.setPrototypeOf(fakeBlob, Blob.prototype); + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized).toBe(err); + expect(normalized.error).toBe(fakeBlob); + }); + test('should leave non-JSON error body alone in text mode', async () => { const spy = vi.spyOn(httpErrorReporter, 'reportError'); @@ -400,7 +472,6 @@ describe('HttpClient testing', () => { catchError(() => { try { const errArg: any = spy.mock.calls[0][0]; - // Angular HttpClient already parsed JSON in json mode → err.error is object expect(errArg.error).toEqual({ error: { code: 'X', message: 'y' } }); resolve(); } catch (e) {