mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
Allows throwing from the resource's params function to transition the resource to a status other than resolved. In particular, the following values can be thrown from params: - `ResourceParamsStatus.IDLE` causes the resource to become `idle` (equivalent to returning `undefined`) - `ResourceParamsStatus.LOADING` causes the resource to become `loading` - Any `Error` object causes the resource to become `error` and report the error that was thrown via `.error()` To simplify chaining together resources, this PR also introduces a context object passed into to the `params` functon. This context contains a `chain` function that can be used to get the value of a resource that the params want to depend on, while automatically propagating the idle, loading, and erorr states of the resource forward.
452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
/**
|
|
* @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 {
|
|
assertInInjectionContext,
|
|
computed,
|
|
ɵencapsulateResourceError as encapsulateResourceError,
|
|
inject,
|
|
Injector,
|
|
linkedSignal,
|
|
ɵResourceImpl as ResourceImpl,
|
|
type ResourceParamsContext,
|
|
ResourceStreamItem,
|
|
Signal,
|
|
signal,
|
|
TransferState,
|
|
type ValueEqualityFn,
|
|
ɵRuntimeError,
|
|
ɵRuntimeErrorCode,
|
|
} from '@angular/core';
|
|
import type {Subscription} from 'rxjs';
|
|
|
|
import {HttpClient} from './client';
|
|
import {HttpHeaders} from './headers';
|
|
import {HttpParams} from './params';
|
|
import {HttpRequest} from './request';
|
|
import {HttpResourceOptions, HttpResourceRef, HttpResourceRequest} from './resource_api';
|
|
import {HttpErrorResponse, HttpEventType, HttpProgressEvent} from './response';
|
|
import {
|
|
CACHE_OPTIONS,
|
|
HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
|
retrieveStateFromCache,
|
|
} from './transfer_cache';
|
|
|
|
/**
|
|
* 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 19.2
|
|
*/
|
|
export interface HttpResourceFn {
|
|
/**
|
|
* Create a `Resource` that fetches data with an HTTP GET request to the given 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 19.2
|
|
*/
|
|
<TResult = unknown>(
|
|
url: (ctx: ResourceParamsContext) => 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.
|
|
*
|
|
* 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 19.2
|
|
*/
|
|
<TResult = unknown>(
|
|
url: (ctx: ResourceParamsContext) => string | undefined,
|
|
options?: HttpResourceOptions<TResult, unknown>,
|
|
): HttpResourceRef<TResult | undefined>;
|
|
|
|
/**
|
|
* Create a `Resource` that fetches data with the configured HTTP 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 19.2
|
|
*/
|
|
<TResult = unknown>(
|
|
request: (ctx: ResourceParamsContext) => HttpResourceRequest | undefined,
|
|
options: HttpResourceOptions<TResult, unknown> & {defaultValue: NoInfer<TResult>},
|
|
): HttpResourceRef<TResult>;
|
|
|
|
/**
|
|
* Create a `Resource` that fetches data with the configured HTTP 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 19.2
|
|
*/
|
|
<TResult = unknown>(
|
|
request: (ctx: ResourceParamsContext) => HttpResourceRequest | undefined,
|
|
options?: HttpResourceOptions<TResult, unknown>,
|
|
): HttpResourceRef<TResult | undefined>;
|
|
|
|
/**
|
|
* Create a `Resource` that fetches data with the configured HTTP 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 19.2
|
|
*/
|
|
arrayBuffer: {
|
|
<TResult = ArrayBuffer>(
|
|
url: (ctx: ResourceParamsContext) => string | undefined,
|
|
options: HttpResourceOptions<TResult, ArrayBuffer> & {defaultValue: NoInfer<TResult>},
|
|
): HttpResourceRef<TResult>;
|
|
|
|
<TResult = ArrayBuffer>(
|
|
url: (ctx: ResourceParamsContext) => string | undefined,
|
|
options?: HttpResourceOptions<TResult, ArrayBuffer>,
|
|
): HttpResourceRef<TResult | undefined>;
|
|
|
|
<TResult = ArrayBuffer>(
|
|
request: (ctx: ResourceParamsContext) => HttpResourceRequest | undefined,
|
|
options: HttpResourceOptions<TResult, ArrayBuffer> & {defaultValue: NoInfer<TResult>},
|
|
): HttpResourceRef<TResult>;
|
|
|
|
<TResult = ArrayBuffer>(
|
|
request: (ctx: ResourceParamsContext) => HttpResourceRequest | undefined,
|
|
options?: HttpResourceOptions<TResult, ArrayBuffer>,
|
|
): HttpResourceRef<TResult | undefined>;
|
|
};
|
|
|
|
/**
|
|
* Create a `Resource` that fetches data with the configured HTTP 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 19.2
|
|
*/
|
|
blob: {
|
|
<TResult = Blob>(
|
|
url: (ctx: ResourceParamsContext) => string | undefined,
|
|
options: HttpResourceOptions<TResult, Blob> & {defaultValue: NoInfer<TResult>},
|
|
): HttpResourceRef<TResult>;
|
|
|
|
<TResult = Blob>(
|
|
url: (ctx: ResourceParamsContext) => string | undefined,
|
|
options?: HttpResourceOptions<TResult, Blob>,
|
|
): HttpResourceRef<TResult | undefined>;
|
|
|
|
<TResult = Blob>(
|
|
request: (ctx: ResourceParamsContext) => HttpResourceRequest | undefined,
|
|
options: HttpResourceOptions<TResult, Blob> & {defaultValue: NoInfer<TResult>},
|
|
): HttpResourceRef<TResult>;
|
|
|
|
<TResult = Blob>(
|
|
request: (ctx: ResourceParamsContext) => HttpResourceRequest | undefined,
|
|
options?: HttpResourceOptions<TResult, Blob>,
|
|
): HttpResourceRef<TResult | undefined>;
|
|
};
|
|
|
|
/**
|
|
* Create a `Resource` that fetches data with the configured HTTP 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 19.2
|
|
*/
|
|
text: {
|
|
<TResult = string>(
|
|
url: (ctx: ResourceParamsContext) => string | undefined,
|
|
options: HttpResourceOptions<TResult, string> & {defaultValue: NoInfer<TResult>},
|
|
): HttpResourceRef<TResult>;
|
|
|
|
<TResult = string>(
|
|
url: (ctx: ResourceParamsContext) => string | undefined,
|
|
options?: HttpResourceOptions<TResult, string>,
|
|
): HttpResourceRef<TResult | undefined>;
|
|
|
|
<TResult = string>(
|
|
request: (ctx: ResourceParamsContext) => HttpResourceRequest | undefined,
|
|
options: HttpResourceOptions<TResult, string> & {defaultValue: NoInfer<TResult>},
|
|
): HttpResourceRef<TResult>;
|
|
|
|
<TResult = string>(
|
|
request: (ctx: ResourceParamsContext) => 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 19.2
|
|
* @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;
|
|
})();
|
|
|
|
/**
|
|
* The expected response type of the server.
|
|
*
|
|
* This is used to parse the response appropriately before returning it to
|
|
* the requestee.
|
|
*/
|
|
type ResponseType = 'arraybuffer' | 'blob' | 'json' | 'text';
|
|
type RawRequestType =
|
|
| ((ctx: ResourceParamsContext) => string | undefined)
|
|
| ((ctx: ResourceParamsContext) => HttpResourceRequest | undefined);
|
|
|
|
function makeHttpResourceFn<TRaw>(responseType: ResponseType) {
|
|
return function httpResource<TResult = TRaw>(
|
|
request: RawRequestType,
|
|
options?: HttpResourceOptions<TResult, TRaw>,
|
|
): HttpResourceRef<TResult> {
|
|
if (ngDevMode && !options?.injector) {
|
|
assertInInjectionContext(httpResource);
|
|
}
|
|
const injector = options?.injector ?? inject(Injector);
|
|
|
|
const cacheOptions = injector.get(CACHE_OPTIONS, null, {optional: true});
|
|
const transferState = injector.get(TransferState, null, {optional: true});
|
|
const originMap = injector.get(HTTP_TRANSFER_CACHE_ORIGIN_MAP, null, {optional: true});
|
|
|
|
const getInitialStream = (req: HttpRequest<unknown> | undefined) => {
|
|
if (cacheOptions && transferState && req) {
|
|
const cachedResponse = retrieveStateFromCache(req, cacheOptions, transferState, originMap);
|
|
if (cachedResponse) {
|
|
try {
|
|
const body = cachedResponse.body as TRaw;
|
|
const parsed = options?.parse ? options.parse(body) : (body as unknown as TResult);
|
|
return signal({value: parsed});
|
|
} catch (e) {
|
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
console.warn(
|
|
`Angular detected an error while parsing the cached response for the httpResource at \`${req.url}\`. ` +
|
|
`The resource will fall back to its default value and try again asynchronously.`,
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
return new HttpResourceImpl(
|
|
injector,
|
|
(ctx: ResourceParamsContext) => normalizeRequest(ctx, request, responseType),
|
|
options?.defaultValue,
|
|
options?.debugName,
|
|
options?.parse as (value: unknown) => TResult,
|
|
options?.equal as ValueEqualityFn<unknown>,
|
|
getInitialStream,
|
|
) as HttpResourceRef<TResult>;
|
|
};
|
|
}
|
|
|
|
function normalizeRequest(
|
|
ctx: ResourceParamsContext,
|
|
request: RawRequestType,
|
|
responseType: ResponseType,
|
|
): HttpRequest<unknown> | undefined {
|
|
let unwrappedRequest = typeof request === 'function' ? request(ctx) : 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,
|
|
keepalive: unwrappedRequest.keepalive,
|
|
cache: unwrappedRequest.cache as RequestCache,
|
|
priority: unwrappedRequest.priority as RequestPriority,
|
|
mode: unwrappedRequest.mode as RequestMode,
|
|
redirect: unwrappedRequest.redirect as RequestRedirect,
|
|
responseType,
|
|
context: unwrappedRequest.context,
|
|
transferCache: unwrappedRequest.transferCache,
|
|
credentials: unwrappedRequest.credentials as RequestCredentials,
|
|
referrer: unwrappedRequest.referrer,
|
|
referrerPolicy: unwrappedRequest.referrerPolicy as ReferrerPolicy,
|
|
integrity: unwrappedRequest.integrity,
|
|
timeout: unwrappedRequest.timeout,
|
|
},
|
|
);
|
|
}
|
|
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() === 'resolved' || this.status() === 'error' ? this._headers() : undefined,
|
|
);
|
|
readonly progress = this._progress.asReadonly();
|
|
readonly statusCode = this._statusCode.asReadonly();
|
|
|
|
constructor(
|
|
injector: Injector,
|
|
request: (ctx: ResourceParamsContext) => HttpRequest<T> | undefined,
|
|
defaultValue: T,
|
|
debugName?: string,
|
|
parse?: (value: unknown) => T,
|
|
equal?: ValueEqualityFn<unknown>,
|
|
getInitialStream?: (
|
|
request: HttpRequest<unknown> | undefined,
|
|
) => Signal<ResourceStreamItem<T>> | undefined,
|
|
) {
|
|
super(
|
|
request,
|
|
({params: 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: parse ? parse(event.body) : (event.body as T)});
|
|
} catch (error) {
|
|
send({error: encapsulateResourceError(error)});
|
|
}
|
|
break;
|
|
case HttpEventType.DownloadProgress:
|
|
this._progress.set(event);
|
|
break;
|
|
}
|
|
},
|
|
error: (error) => {
|
|
if (error instanceof HttpErrorResponse) {
|
|
this._headers.set(error.headers);
|
|
this._statusCode.set(error.status);
|
|
}
|
|
|
|
send({error});
|
|
abortSignal.removeEventListener('abort', onAbort);
|
|
},
|
|
complete: () => {
|
|
if (resolve) {
|
|
send({
|
|
error: new ɵRuntimeError(
|
|
ɵRuntimeErrorCode.RESOURCE_COMPLETED_BEFORE_PRODUCING_VALUE,
|
|
ngDevMode && 'Resource completed before producing a value',
|
|
),
|
|
});
|
|
}
|
|
abortSignal.removeEventListener('abort', onAbort);
|
|
},
|
|
});
|
|
|
|
return promise;
|
|
},
|
|
defaultValue,
|
|
equal,
|
|
debugName,
|
|
injector,
|
|
getInitialStream,
|
|
);
|
|
this.client = injector.get(HttpClient);
|
|
}
|
|
|
|
override set(value: T): void {
|
|
super.set(value);
|
|
|
|
this._headers.set(undefined);
|
|
this._progress.set(undefined);
|
|
this._statusCode.set(undefined);
|
|
}
|
|
|
|
// This is a type only override of the method
|
|
declare hasValue: () => this is HttpResourceRef<Exclude<T, undefined>>;
|
|
}
|