feat(common): introduce experimental httpResource (#59876)

`httpResource` is a new frontend to the `HttpClient` infrastructure. It
declares a dependency on an HTTP endpoint. The request to be made can be
reactive, updating in response to signals for the URL, method, or otherwise.
The response is returned as an instance of `HttpResource`, a
`WritableResource` with some additional signals which represent parts of the
HTTP response metadata (status, headers, etc).

PR Close #59876
This commit is contained in:
Alex Rickabaugh 2024-12-13 02:40:20 -08:00 committed by Jessica Janiuk
parent 538c4d4c9d
commit 3e39da593a
7 changed files with 850 additions and 2 deletions

View file

@ -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<T> {
readonly withCredentials: boolean;
}
// @public
export const httpResource: HttpResourceFn;
// @public
export interface HttpResourceFn {
<TResult = unknown>(url: string | (() => string | undefined), options: HttpResourceOptions<TResult, unknown> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = unknown>(url: string | (() => string | undefined), options?: HttpResourceOptions<TResult, unknown>): HttpResourceRef<TResult | undefined>;
<TResult = unknown>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions<TResult, unknown> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = unknown>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions<TResult, unknown>): HttpResourceRef<TResult | undefined>;
arrayBuffer: {
<TResult = ArrayBuffer>(url: string | (() => string | undefined), options: HttpResourceOptions<TResult, ArrayBuffer> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = ArrayBuffer>(url: string | (() => string | undefined), options?: HttpResourceOptions<TResult, ArrayBuffer>): HttpResourceRef<TResult | undefined>;
<TResult = ArrayBuffer>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions<TResult, ArrayBuffer> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = ArrayBuffer>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions<TResult, ArrayBuffer>): HttpResourceRef<TResult | undefined>;
};
blob: {
<TResult = Blob>(url: string | (() => string | undefined), options: HttpResourceOptions<TResult, Blob> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = Blob>(url: string | (() => string | undefined), options?: HttpResourceOptions<TResult, Blob>): HttpResourceRef<TResult | undefined>;
<TResult = Blob>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions<TResult, Blob> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = Blob>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions<TResult, Blob>): HttpResourceRef<TResult | undefined>;
};
text: {
<TResult = string>(url: string | (() => string | undefined), options: HttpResourceOptions<TResult, string> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = string>(url: string | (() => string | undefined), options?: HttpResourceOptions<TResult, string>): HttpResourceRef<TResult | undefined>;
<TResult = string>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options: HttpResourceOptions<TResult, string> & {
defaultValue: NoInfer<TResult>;
}): HttpResourceRef<TResult>;
<TResult = string>(request: HttpResourceRequest | (() => HttpResourceRequest | undefined), options?: HttpResourceOptions<TResult, string>): HttpResourceRef<TResult | undefined>;
};
}
// @public
export interface HttpResourceOptions<TResult, TRaw> {
defaultValue?: NoInfer<TResult>;
equal?: ValueEqualityFn<NoInfer<TResult>>;
injector?: Injector;
map?: (value: TRaw) => TResult;
}
// @public
export interface HttpResourceRef<T> extends WritableResource<T>, ResourceRef<T> {
// (undocumented)
destroy(): void;
// (undocumented)
hasValue(): this is HttpResourceRef<Exclude<T, undefined>>;
readonly headers: Signal<HttpHeaders | undefined>;
readonly progress: Signal<HttpProgressEvent | undefined>;
readonly statusCode: Signal<number | undefined>;
}
// @public
export interface HttpResourceRequest {
body?: unknown;
headers?: HttpHeaders | Record<string, string | ReadonlyArray<string>>;
method?: string;
params?: HttpParams | Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
reportProgress?: boolean;
transferCache?: {
includeHeaders?: string[];
} | boolean;
url: string;
withCredentials?: boolean;
}
// @public
export class HttpResponse<T> extends HttpResponseBase {
constructor(init?: {

View file

@ -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,

View file

@ -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
*/
<TResult = unknown>(
url: string | (() => string | undefined),
options: HttpResourceOptions<TResult, unknown> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
/**
* 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
*/
<TResult = unknown>(
url: string | (() => string | undefined),
options?: HttpResourceOptions<TResult, unknown>,
): HttpResourceRef<TResult | undefined>;
/**
* 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
*/
<TResult = unknown>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options: HttpResourceOptions<TResult, unknown> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
/**
* 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
*/
<TResult = unknown>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options?: HttpResourceOptions<TResult, unknown>,
): HttpResourceRef<TResult | undefined>;
/**
* 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: {
<TResult = ArrayBuffer>(
url: string | (() => string | undefined),
options: HttpResourceOptions<TResult, ArrayBuffer> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
<TResult = ArrayBuffer>(
url: string | (() => string | undefined),
options?: HttpResourceOptions<TResult, ArrayBuffer>,
): HttpResourceRef<TResult | undefined>;
<TResult = ArrayBuffer>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options: HttpResourceOptions<TResult, ArrayBuffer> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
<TResult = ArrayBuffer>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options?: HttpResourceOptions<TResult, ArrayBuffer>,
): HttpResourceRef<TResult | undefined>;
};
/**
* 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: {
<TResult = Blob>(
url: string | (() => string | undefined),
options: HttpResourceOptions<TResult, Blob> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
<TResult = Blob>(
url: string | (() => string | undefined),
options?: HttpResourceOptions<TResult, Blob>,
): HttpResourceRef<TResult | undefined>;
<TResult = Blob>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options: HttpResourceOptions<TResult, Blob> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
<TResult = Blob>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options?: HttpResourceOptions<TResult, Blob>,
): HttpResourceRef<TResult | undefined>;
};
/**
* 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: {
<TResult = string>(
url: string | (() => string | undefined),
options: HttpResourceOptions<TResult, string> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
<TResult = string>(
url: string | (() => string | undefined),
options?: HttpResourceOptions<TResult, string>,
): HttpResourceRef<TResult | undefined>;
<TResult = string>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options: HttpResourceOptions<TResult, string> & {defaultValue: NoInfer<TResult>},
): HttpResourceRef<TResult>;
<TResult = string>(
request: HttpResourceRequest | (() => HttpResourceRequest | undefined),
options?: HttpResourceOptions<TResult, string>,
): HttpResourceRef<TResult | undefined>;
};
}
/**
* `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<unknown>('json') as HttpResourceFn;
jsonFn.arrayBuffer = makeHttpResourceFn<ArrayBuffer>('arraybuffer');
jsonFn.blob = makeHttpResourceFn('blob');
jsonFn.text = makeHttpResourceFn('text');
return jsonFn;
})();
type RawRequestType =
| string
| (() => string | undefined)
| HttpResourceRequest
| (() => HttpResourceRequest | undefined);
function makeHttpResourceFn<TRaw>(responseType: 'arraybuffer' | 'blob' | 'json' | 'text') {
return function httpResourceRef<TResult = TRaw>(
request: RawRequestType,
options?: HttpResourceOptions<TResult, TRaw>,
): HttpResourceRef<TResult> {
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<TResult>;
};
}
function normalizeRequest(
request: RawRequestType,
responseType: 'arraybuffer' | 'blob' | 'json' | 'text',
): HttpRequest<unknown> | 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<string, string | number | Array<string | number>>
| 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<T>
extends ResourceImpl<T, HttpRequest<unknown> | undefined>
implements HttpResourceRef<T>
{
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<T> | 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<ResourceStreamItem<T>>({value: undefined as T});
let resolve: ((value: Signal<ResourceStreamItem<T>>) => void) | undefined;
const promise = new Promise<Signal<ResourceStreamItem<T>>>((r) => (resolve = r));
const send = (value: ResourceStreamItem<T>): 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<Exclude<T, undefined>> {
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<HttpResponseBase | undefined> {
readonly status: Signal<ResourceStatus>;
readonly value: WritableSignal<HttpResponseBase | undefined>;
readonly error: Signal<unknown>;
readonly isLoading: Signal<boolean>;
constructor(
private parent: Resource<unknown>,
request: Signal<unknown>,
) {
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<HttpResponseBase> {
return this.value() !== undefined;
}
reload(): boolean {
// TODO: should you be able to reload this way?
return this.parent.reload();
}
}

View file

@ -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<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
/**
* Dictionary of headers to include with the outgoing request.
*/
headers?: HttpHeaders | Record<string, string | ReadonlyArray<string>>;
/**
* 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<TResult, TRaw> {
/**
* 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<TResult>;
/**
* 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<NoInfer<TResult>>;
}
/**
* 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<T> extends WritableResource<T>, ResourceRef<T> {
/**
* Signal of the response headers, when available.
*/
readonly headers: Signal<HttpHeaders | undefined>;
/**
* Signal of the response status code, when available.
*/
readonly statusCode: Signal<number | undefined>;
/**
* Signal of the latest progress update, if the request was made with `reportProgress: true`.
*/
readonly progress: Signal<HttpProgressEvent | undefined>;
hasValue(): this is HttpResourceRef<Exclude<T, undefined>>;
destroy(): void;
}

View file

@ -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);
});
});

View file

@ -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';

View file

@ -130,7 +130,7 @@ abstract class BaseWritableResource<T> implements WritableResource<T> {
/**
* Implementation for `resource()` which uses a `linkedSignal` to manage the resource's state.
*/
class ResourceImpl<T, R> extends BaseWritableResource<T> implements ResourceRef<T> {
export class ResourceImpl<T, R> extends BaseWritableResource<T> implements ResourceRef<T> {
private readonly pendingTasks: PendingTasks;
/**
@ -142,7 +142,7 @@ class ResourceImpl<T, R> extends BaseWritableResource<T> implements ResourceRef<
* Combines the current request with a reload counter which allows the resource to be reloaded on
* imperative command.
*/
private readonly extRequest: WritableSignal<WrappedRequest>;
protected readonly extRequest: WritableSignal<WrappedRequest>;
private readonly effectRef: EffectRef;
private pendingController: AbortController | undefined;