diff --git a/goldens/public-api/common/http/index.api.md b/goldens/public-api/common/http/index.api.md index eb7a169a79c..61538327ac4 100644 --- a/goldens/public-api/common/http/index.api.md +++ b/goldens/public-api/common/http/index.api.md @@ -8,9 +8,14 @@ import { EnvironmentInjector } from '@angular/core'; import { EnvironmentProviders } from '@angular/core'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; +import type { Injector } from '@angular/core'; import { ModuleWithProviders } from '@angular/core'; import { Observable } from 'rxjs'; import { Provider } from '@angular/core'; +import type { ResourceRef } from '@angular/core'; +import type { Signal } from '@angular/core'; +import type { ValueEqualityFn } from '@angular/core'; +import type { WritableResource } from '@angular/core'; import { XhrFactory } from '@angular/common'; // @public @@ -2153,6 +2158,84 @@ export class HttpRequest { readonly withCredentials: boolean; } +// @public +export const httpResource: HttpResourceFn; + +// @public +export interface HttpResourceFn { + (url: string | (() => string | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (url: string | (() => string | undefined), options?: HttpResourceOptions): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions): HttpResourceRef; + arrayBuffer: { + (url: string | (() => string | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (url: string | (() => string | undefined), options?: HttpResourceOptions): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions): HttpResourceRef; + }; + blob: { + (url: string | (() => string | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (url: string | (() => string | undefined), options?: HttpResourceOptions): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions): HttpResourceRef; + }; + text: { + (url: string | (() => string | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (url: string | (() => string | undefined), options?: HttpResourceOptions): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions & { + defaultValue: NoInfer; + }): HttpResourceRef; + (request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions): HttpResourceRef; + }; +} + +// @public +export interface HttpResourceOptions { + defaultValue?: NoInfer; + equal?: ValueEqualityFn>; + injector?: Injector; + map?: (value: TRaw) => TResult; +} + +// @public +export interface HttpResourceRef extends WritableResource, ResourceRef { + // (undocumented) + destroy(): void; + // (undocumented) + hasValue(): this is HttpResourceRef>; + readonly headers: Signal; + readonly progress: Signal; + readonly statusCode: Signal; +} + +// @public +export interface HttpResourceRequest { + body?: unknown; + headers?: HttpHeaders | Record>; + method?: string; + params?: HttpParams | Record>; + reportProgress?: boolean; + transferCache?: { + includeHeaders?: string[]; + } | boolean; + url: string; + withCredentials?: boolean; +} + // @public export class HttpResponse extends HttpResponseBase { constructor(init?: { diff --git a/packages/common/http/public_api.ts b/packages/common/http/public_api.ts index e40149cab14..332e1ff454c 100644 --- a/packages/common/http/public_api.ts +++ b/packages/common/http/public_api.ts @@ -54,6 +54,8 @@ export { HttpUploadProgressEvent, HttpUserEvent, } from './src/response'; +export {HttpResourceRef, HttpResourceOptions, HttpResourceRequest} from './src/resource_api'; +export {httpResource, HttpResourceFn} from './src/resource'; export { HttpTransferCacheOptions, withHttpTransferCache as ɵwithHttpTransferCache, diff --git a/packages/common/http/src/resource.ts b/packages/common/http/src/resource.ts new file mode 100644 index 00000000000..1db25db8116 --- /dev/null +++ b/packages/common/http/src/resource.ts @@ -0,0 +1,421 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + Injector, + Signal, + ɵResourceImpl as ResourceImpl, + inject, + linkedSignal, + assertInInjectionContext, + signal, + ResourceStatus, + computed, + Resource, + WritableSignal, + ResourceStreamItem, +} from '@angular/core'; +import {Subscription} from 'rxjs'; + +import {HttpRequest} from './request'; +import {HttpClient} from './client'; +import {HttpEventType, HttpProgressEvent, HttpResponseBase} from './response'; +import {HttpHeaders} from './headers'; +import {HttpParams} from './params'; +import {HttpResourceRef, HttpResourceOptions, HttpResourceRequest} from './resource_api'; + +/** + * Type for the `httpRequest` top-level function, which includes the call signatures for the JSON- + * based `httpRequest` as well as sub-functions for `ArrayBuffer`, `Blob`, and `string` type + * requests. + * + * @experimental + */ +export interface HttpResourceFn { + /** + * Create a `Resource` that fetches data with an HTTP GET request to the given URL. + * + * If a reactive function is passed for the URL, the resource will update when the URL changes via + * signals. + * + * Uses `HttpClient` to make requests and supports interceptors, testing, and the other features + * of the `HttpClient` API. Data is parsed as JSON by default - use a sub-function of + * `httpResource`, such as `httpResource.text()`, to parse the response differently. + * + * @experimental + */ + ( + url: string | (() => string | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + /** + * Create a `Resource` that fetches data with an HTTP GET request to the given URL. + * + * If a reactive function is passed for the URL, the resource will update when the URL changes via + * signals. + * + * Uses `HttpClient` to make requests and supports interceptors, testing, and the other features + * of the `HttpClient` API. Data is parsed as JSON by default - use a sub-function of + * `httpResource`, such as `httpResource.text()`, to parse the response differently. + * + * @experimental + */ + ( + url: string | (() => string | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + + /** + * Create a `Resource` that fetches data with the configured HTTP request. + * + * If a reactive function is passed for the request, the resource will update when the request + * changes via signals. + * + * Uses `HttpClient` to make requests and supports interceptors, testing, and the other features + * of the `HttpClient` API. Data is parsed as JSON by default - use a sub-function of + * `httpResource`, such as `httpResource.text()`, to parse the response differently. + * + * @experimental + */ + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + /** + * Create a `Resource` that fetches data with the configured HTTP request. + * + * If a reactive function is passed for the request, the resource will update when the request + * changes via signals. + * + * Uses `HttpClient` to make requests and supports interceptors, testing, and the other features + * of the `HttpClient` API. Data is parsed as JSON by default - use a sub-function of + * `httpResource`, such as `httpResource.text()`, to parse the response differently. + * + * @experimental + */ + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + + /** + * Create a `Resource` that fetches data with the configured HTTP request. + * + * If a reactive function is passed for the URL or request, the resource will update when the + * URL or request changes via signals. + * + * Uses `HttpClient` to make requests and supports interceptors, testing, and the other features + * of the `HttpClient` API. Data is parsed into an `ArrayBuffer`. + * + * @experimental + */ + arrayBuffer: { + ( + url: string | (() => string | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + ( + url: string | (() => string | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + }; + + /** + * Create a `Resource` that fetches data with the configured HTTP request. + * + * If a reactive function is passed for the URL or request, the resource will update when the + * URL or request changes via signals. + * + * Uses `HttpClient` to make requests and supports interceptors, testing, and the other features + * of the `HttpClient` API. Data is parsed into a `Blob`. + * + * @experimental + */ + blob: { + ( + url: string | (() => string | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + ( + url: string | (() => string | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + }; + + /** + * Create a `Resource` that fetches data with the configured HTTP request. + * + * If a reactive function is passed for the URL or request, the resource will update when the + * URL or request changes via signals. + * + * Uses `HttpClient` to make requests and supports interceptors, testing, and the other features + * of the `HttpClient` API. Data is parsed as a `string`. + * + * @experimental + */ + text: { + ( + url: string | (() => string | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + ( + url: string | (() => string | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options: HttpResourceOptions & {defaultValue: NoInfer}, + ): HttpResourceRef; + + ( + request: HttpResourceRequest | (() => HttpResourceRequest | undefined), + options?: HttpResourceOptions, + ): HttpResourceRef; + }; +} + +/** + * `httpResource` makes a reactive HTTP request and exposes the request status and response value as + * a `WritableResource`. By default, it assumes that the backend will return JSON data. To make a + * request that expects a different kind of data, you can use a sub-constructor of `httpResource`, + * such as `httpResource.text`. + * + * @experimental + * @initializerApiFunction + */ +export const httpResource: HttpResourceFn = (() => { + const jsonFn = makeHttpResourceFn('json') as HttpResourceFn; + jsonFn.arrayBuffer = makeHttpResourceFn('arraybuffer'); + jsonFn.blob = makeHttpResourceFn('blob'); + jsonFn.text = makeHttpResourceFn('text'); + return jsonFn; +})(); + +type RawRequestType = + | string + | (() => string | undefined) + | HttpResourceRequest + | (() => HttpResourceRequest | undefined); + +function makeHttpResourceFn(responseType: 'arraybuffer' | 'blob' | 'json' | 'text') { + return function httpResourceRef( + request: RawRequestType, + options?: HttpResourceOptions, + ): HttpResourceRef { + options?.injector || assertInInjectionContext(httpResource); + const injector = options?.injector ?? inject(Injector); + return new HttpResourceImpl( + injector, + () => normalizeRequest(request, responseType), + options?.defaultValue, + options?.map as (value: unknown) => TResult, + ) as HttpResourceRef; + }; +} + +function normalizeRequest( + request: RawRequestType, + responseType: 'arraybuffer' | 'blob' | 'json' | 'text', +): HttpRequest | undefined { + let unwrappedRequest = typeof request === 'function' ? request() : request; + if (unwrappedRequest === undefined) { + return undefined; + } else if (typeof unwrappedRequest === 'string') { + unwrappedRequest = {url: unwrappedRequest}; + } + + const headers = + unwrappedRequest.headers instanceof HttpHeaders + ? unwrappedRequest.headers + : new HttpHeaders( + unwrappedRequest.headers as + | Record> + | undefined, + ); + + const params = + unwrappedRequest.params instanceof HttpParams + ? unwrappedRequest.params + : new HttpParams({fromObject: unwrappedRequest.params}); + + return new HttpRequest( + unwrappedRequest.method ?? 'GET', + unwrappedRequest.url, + unwrappedRequest.body ?? null, + { + headers, + params, + reportProgress: unwrappedRequest.reportProgress, + withCredentials: unwrappedRequest.withCredentials, + responseType, + }, + ); +} +class HttpResourceImpl + extends ResourceImpl | undefined> + implements HttpResourceRef +{ + private client!: HttpClient; + private _headers = linkedSignal({ + source: this.extRequest, + computation: () => undefined as HttpHeaders | undefined, + }); + private _progress = linkedSignal({ + source: this.extRequest, + computation: () => undefined as HttpProgressEvent | undefined, + }); + private _statusCode = linkedSignal({ + source: this.extRequest, + computation: () => undefined as number | undefined, + }); + + readonly headers = computed(() => + this.status() === ResourceStatus.Resolved || this.status() === ResourceStatus.Error + ? this._headers() + : undefined, + ); + readonly progress = this._progress.asReadonly(); + readonly statusCode = this._statusCode.asReadonly(); + + constructor( + injector: Injector, + request: () => HttpRequest | undefined, + defaultValue: T, + map?: (value: unknown) => T, + ) { + super( + request, + ({request, abortSignal}) => { + let sub: Subscription; + + // Track the abort listener so it can be removed if the Observable completes (as a memory + // optimization). + const onAbort = () => sub.unsubscribe(); + abortSignal.addEventListener('abort', onAbort); + + // Start off stream as undefined. + const stream = signal>({value: undefined as T}); + let resolve: ((value: Signal>) => void) | undefined; + const promise = new Promise>>((r) => (resolve = r)); + + const send = (value: ResourceStreamItem): void => { + stream.set(value); + resolve?.(stream); + resolve = undefined; + }; + + sub = this.client.request(request!).subscribe({ + next: (event) => { + switch (event.type) { + case HttpEventType.Response: + this._headers.set(event.headers); + this._statusCode.set(event.status); + try { + send({value: map ? map(event.body) : (event.body as T)}); + } catch (error) { + send({error}); + } + break; + case HttpEventType.DownloadProgress: + this._progress.set(event); + break; + } + }, + error: (error) => send({error}), + complete: () => { + if (resolve) { + send({error: new Error('Resource completed before producing a value')}); + } + abortSignal.removeEventListener('abort', onAbort); + }, + }); + + return promise; + }, + defaultValue, + undefined, + injector, + ); + this.client = injector.get(HttpClient); + } + + override hasValue(): this is HttpResourceRef> { + return super.hasValue(); + } +} + +/** + * A `Resource` of the `HttpResponse` meant for use in `HttpResource` if we decide to go this route. + * + * TODO(alxhub): delete this if we decide we don't want it. + */ +class HttpResponseResource implements Resource { + readonly status: Signal; + readonly value: WritableSignal; + readonly error: Signal; + readonly isLoading: Signal; + + constructor( + private parent: Resource, + request: Signal, + ) { + this.status = computed(() => { + // There are two kinds of errors which can occur in an HTTP request: HTTP errors or normal JS + // errors. Since we have a response for HTTP errors, we report `Resolved` status even if the + // overall request is considered to be in an Error state. + if (parent.status() === ResourceStatus.Error) { + return this.value() !== undefined ? ResourceStatus.Resolved : ResourceStatus.Error; + } + return parent.status(); + }); + this.error = computed(() => { + // Filter out HTTP errors. + return this.value() === undefined ? parent.error() : undefined; + }); + this.value = linkedSignal({ + source: request, + computation: () => undefined as HttpResponseBase | undefined, + }); + this.isLoading = parent.isLoading; + } + + hasValue(): this is Resource { + return this.value() !== undefined; + } + + reload(): boolean { + // TODO: should you be able to reload this way? + return this.parent.reload(); + } +} diff --git a/packages/common/http/src/resource_api.ts b/packages/common/http/src/resource_api.ts new file mode 100644 index 00000000000..f99f971397e --- /dev/null +++ b/packages/common/http/src/resource_api.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type {Injector, ResourceRef, Signal, ValueEqualityFn, WritableResource} from '@angular/core'; +import type {HttpHeaders} from './headers'; +import type {HttpParams} from './params'; +import type {HttpProgressEvent} from './response'; + +/** + * The structure of an `httpResource` request which will be sent to the backend. + * + * @experimental + */ +export interface HttpResourceRequest { + /** + * URL of the request. + * + * This URL should not include query parameters. Instead, specify query parameters through the + * `params` field. + */ + url: string; + + /** + * HTTP method of the request, which defaults to GET if not specified. + */ + method?: string; + + /** + * Body to send with the request, if there is one. + * + * If no Content-Type header is specified by the user, Angular will attempt to set one based on + * the type of `body`. + */ + body?: unknown; + + /** + * Dictionary of query parameters which will be appeneded to the request URL. + */ + params?: + | HttpParams + | Record>; + + /** + * Dictionary of headers to include with the outgoing request. + */ + headers?: HttpHeaders | Record>; + + /** + * If `true`, progress events will be enabled for the request and delivered through the + * `HttpResource.progress` signal. + */ + reportProgress?: boolean; + + /** + * Specifies whether the `withCredentials` flag should be set on the outgoing request. + * + * This flag causes the browser to send cookies and other authentication information along with + * the request. + */ + withCredentials?: boolean; + + /** + * Configures the server-side rendering transfer cache for this request. + * + * See the documentation on the transfer cache for more information. + */ + transferCache?: {includeHeaders?: string[]} | boolean; +} + +/** + * Options for creating an `httpResource`. + * + * @experimental + */ +export interface HttpResourceOptions { + /** + * Transform the result of the HTTP request before it's delivered to the resource. + * + * `map` receives the value from the HTTP layer as its raw type (e.g. as `unknown` for JSON data). + * It can be used to validate or transform the type of the resource, and return a more specific + * type. This is also useful for validating backend responses using a runtime schema validation + * library such as Zod. + */ + map?: (value: TRaw) => TResult; + + /** + * Value that the resource will take when in Idle, Loading, or Error states. + * + * If not set, the resource will use `undefined` as its default value. + */ + defaultValue?: NoInfer; + + /** + * The `Injector` in which to create the `httpResource`. + * + * If this is not provided, the current [injection context](guide/di/dependency-injection-context) + * will be used instead (via `inject`). + */ + injector?: Injector; + + /** + * A comparison function which defines equality for the response value. + */ + equal?: ValueEqualityFn>; +} + +/** + * A `WritableResource` that represents the results of a reactive HTTP request. + * + * `HttpResource`s are backed by `HttpClient`, including support for interceptors, testing, and the + * other features of the `HttpClient` API. + * + * @experimental + */ +export interface HttpResourceRef extends WritableResource, ResourceRef { + /** + * Signal of the response headers, when available. + */ + readonly headers: Signal; + + /** + * Signal of the response status code, when available. + */ + readonly statusCode: Signal; + + /** + * Signal of the latest progress update, if the request was made with `reportProgress: true`. + */ + readonly progress: Signal; + + hasValue(): this is HttpResourceRef>; + destroy(): void; +} diff --git a/packages/common/http/test/resource_spec.ts b/packages/common/http/test/resource_spec.ts new file mode 100644 index 00000000000..631c5325d89 --- /dev/null +++ b/packages/common/http/test/resource_spec.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ApplicationRef, Injector, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {HttpEventType, provideHttpClient, httpResource} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; + +describe('httpResource', () => { + beforeEach(() => { + TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting()]}); + }); + + it('should send a basic request', async () => { + const backend = TestBed.inject(HttpTestingController); + const res = httpResource('/data', {injector: TestBed.inject(Injector)}); + TestBed.flushEffects(); + const req = backend.expectOne('/data'); + req.flush([]); + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual([]); + }); + + it('should be reactive in its request URL', async () => { + const id = signal(0); + const backend = TestBed.inject(HttpTestingController); + const res = httpResource(() => `/data/${id()}`, {injector: TestBed.inject(Injector)}); + TestBed.flushEffects(); + const req1 = backend.expectOne('/data/0'); + req1.flush(0); + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual(0); + + id.set(1); + TestBed.flushEffects(); + const req2 = backend.expectOne('/data/1'); + req2.flush(1); + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual(1); + }); + + it('should not make backend requests if the request is undefined', async () => { + const id = signal(0); + const backend = TestBed.inject(HttpTestingController); + const res = httpResource(() => (id() !== 1 ? `/data/${id()}` : undefined), { + injector: TestBed.inject(Injector), + }); + TestBed.flushEffects(); + backend.expectOne('/data/0').flush(0); + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual(0); + + id.set(1); + TestBed.flushEffects(); + + // Verify no requests have been made. + backend.verify({ignoreCancelled: false}); + await TestBed.inject(ApplicationRef).whenStable(); + backend.verify({ignoreCancelled: false}); + + id.set(2); + TestBed.flushEffects(); + backend.expectOne('/data/2').flush(2); + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toBe(2); + }); + + it('should support the suite of HttpRequest APIs', async () => { + const backend = TestBed.inject(HttpTestingController); + const res = httpResource( + { + url: '/data', + method: 'POST', + body: {message: 'Hello, backend!'}, + headers: { + 'X-Special': 'true', + }, + params: { + 'fast': 'yes', + }, + withCredentials: true, + }, + {injector: TestBed.inject(Injector)}, + ); + TestBed.flushEffects(); + const req = backend.expectOne('/data?fast=yes'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({message: 'Hello, backend!'}); + expect(req.request.headers.get('X-Special')).toBe('true'); + expect(req.request.withCredentials).toBe(true); + + req.flush([]); + + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual([]); + }); + + it('should return response headers & status when resolved', async () => { + const backend = TestBed.inject(HttpTestingController); + const res = httpResource('/data', {injector: TestBed.inject(Injector)}); + TestBed.flushEffects(); + const req = backend.expectOne('/data'); + req.flush([], { + headers: { + 'X-Special': '123', + }, + }); + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual([]); + expect(res.headers()?.get('X-Special')).toBe('123'); + expect(res.statusCode()).toBe(200); + }); + + it('should support progress events', async () => { + const backend = TestBed.inject(HttpTestingController); + const res = httpResource( + { + url: '/data', + reportProgress: true, + }, + {injector: TestBed.inject(Injector)}, + ); + TestBed.flushEffects(); + const req = backend.expectOne('/data'); + req.event({ + type: HttpEventType.DownloadProgress, + loaded: 100, + total: 200, + }); + + expect(res.progress()).toEqual({ + type: HttpEventType.DownloadProgress, + loaded: 100, + total: 200, + }); + + req.flush([]); + + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual([]); + }); + + it('should allow mapping data to an arbitrary type', async () => { + const backend = TestBed.inject(HttpTestingController); + const res = httpResource( + { + url: '/data', + reportProgress: true, + }, + { + injector: TestBed.inject(Injector), + map: (value) => JSON.stringify(value), + }, + ); + TestBed.flushEffects(); + const req = backend.expectOne('/data'); + req.flush([1, 2, 3]); + + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual('[1,2,3]'); + }); + + it('should support text responses', async () => { + const backend = TestBed.inject(HttpTestingController); + const res = httpResource.text( + { + url: '/data', + reportProgress: true, + }, + {injector: TestBed.inject(Injector)}, + ); + TestBed.flushEffects(); + const req = backend.expectOne('/data'); + req.flush('[1,2,3]'); + + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toEqual('[1,2,3]'); + }); + + it('should support ArrayBuffer responses', async () => { + const backend = TestBed.inject(HttpTestingController); + const res = httpResource.arrayBuffer( + { + url: '/data', + reportProgress: true, + }, + {injector: TestBed.inject(Injector)}, + ); + TestBed.flushEffects(); + const req = backend.expectOne('/data'); + const buffer = new ArrayBuffer(); + req.flush(buffer); + + await TestBed.inject(ApplicationRef).whenStable(); + expect(res.value()).toBe(buffer); + }); +}); diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index ed2f1869c03..00442e53ec5 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -148,4 +148,6 @@ export { disableProfiling as ɵdisableProfiling, } from './profiler'; +export {ResourceImpl as ɵResourceImpl} from './resource/resource'; + export {getClosestComponentName as ɵgetClosestComponentName} from './internal/get_closest_component_name'; diff --git a/packages/core/src/resource/resource.ts b/packages/core/src/resource/resource.ts index 79d889c07f2..2e56258cb0b 100644 --- a/packages/core/src/resource/resource.ts +++ b/packages/core/src/resource/resource.ts @@ -130,7 +130,7 @@ abstract class BaseWritableResource implements WritableResource { /** * Implementation for `resource()` which uses a `linkedSignal` to manage the resource's state. */ -class ResourceImpl extends BaseWritableResource implements ResourceRef { +export class ResourceImpl extends BaseWritableResource implements ResourceRef { private readonly pendingTasks: PendingTasks; /** @@ -142,7 +142,7 @@ class ResourceImpl extends BaseWritableResource implements ResourceRef< * Combines the current request with a reload counter which allows the resource to be reloaded on * imperative command. */ - private readonly extRequest: WritableSignal; + protected readonly extRequest: WritableSignal; private readonly effectRef: EffectRef; private pendingController: AbortController | undefined;