From 4bbeb32971dd8a74a73a5f3534146ff7bf1b2a6a Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Fri, 13 Dec 2024 02:40:20 -0800 Subject: [PATCH] feat(common): introduce experimental `httpResource` WIP: this commit is a work-in-progress of `httpResource`, a new entrypoint to `HttpClient` for declaratively configuring resources backed by HTTP requests. --- goldens/public-api/common/http/index.api.md | 52 +++++++ packages/common/http/public_api.ts | 1 + packages/common/http/src/resource.ts | 159 ++++++++++++++++++++ packages/core/src/core_private_export.ts | 2 + packages/core/src/resource/resource.ts | 4 +- 5 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 packages/common/http/src/resource.ts diff --git a/goldens/public-api/common/http/index.api.md b/goldens/public-api/common/http/index.api.md index eb7a169a79c0d..da2e5a05a0af0 100644 --- a/goldens/public-api/common/http/index.api.md +++ b/goldens/public-api/common/http/index.api.md @@ -8,9 +8,12 @@ import { EnvironmentInjector } from '@angular/core'; import { EnvironmentProviders } from '@angular/core'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; +import { Injector } from '@angular/core'; import { ModuleWithProviders } from '@angular/core'; import { Observable } from 'rxjs'; import { Provider } from '@angular/core'; +import { Signal } from '@angular/core'; +import { WritableResource } from '@angular/core'; import { XhrFactory } from '@angular/common'; // @public @@ -2153,6 +2156,55 @@ export class HttpRequest { readonly withCredentials: boolean; } +// @public (undocumented) +export interface HttpResource extends WritableResource { + // (undocumented) + headers: Signal; +} + +// @public (undocumented) +export function httpResource(url: string | (() => string), options: HttpResourceOptions & { + defaultValue: NoInfer; +}): HttpResource; + +// @public (undocumented) +export function httpResource(url: string | (() => string), options?: HttpResourceOptions): HttpResource; + +// @public (undocumented) +export function httpResource(request: HttpResourceRequest | (() => HttpResourceRequest), options?: HttpResourceOptions & { + defaultValue: NoInfer; +}): HttpResource; + +// @public (undocumented) +export function httpResource(request: HttpResourceRequest | (() => HttpResourceRequest), options?: HttpResourceOptions): HttpResource; + +// @public (undocumented) +export interface HttpResourceOptions { + defaultValue?: NoInfer; + // (undocumented) + injector?: Injector; + // (undocumented) + map?: (value: unknown) => T; +} + +// @public (undocumented) +export interface HttpResourceRequest { + // (undocumented) + body?: unknown; + // (undocumented) + headers?: HttpHeaders | Record>; + // (undocumented) + method?: string; + // (undocumented) + params?: HttpParams | Record>; + // (undocumented) + reportProgress?: boolean; + // (undocumented) + url: string; + // (undocumented) + 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 e40149cab142e..6cbf83974fddf 100644 --- a/packages/common/http/public_api.ts +++ b/packages/common/http/public_api.ts @@ -54,6 +54,7 @@ export { HttpUploadProgressEvent, HttpUserEvent, } from './src/response'; +export {httpResource, HttpResource, HttpResourceOptions, HttpResourceRequest} 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 0000000000000..50ce7eed604ab --- /dev/null +++ b/packages/common/http/src/resource.ts @@ -0,0 +1,159 @@ +/** + * @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, + WritableResource, + computed, + ɵResourceImpl as ResourceImpl, + inject, + linkedSignal, + WritableSignal, + assertInInjectionContext, + ValueEqualityFn, +} from '@angular/core'; +import {HttpRequest} from './request'; +import {HttpClient} from './client'; +import {HttpEventType, HttpProgressEvent} from './response'; +import {HttpHeaders} from './headers'; +import {HttpParams} from './params'; + +export interface HttpResourceRequest { + url: string; + method?: string; + body?: unknown; + params?: + | HttpParams + | Record>; + headers?: HttpHeaders | Record>; + reportProgress?: boolean; + withCredentials?: boolean; +} + +export interface HttpResourceOptions { + map?: (value: unknown) => T; + /** + * Value that the resource will take when in Idle, Loading, or Error states. + */ + defaultValue?: NoInfer; + + // TODO: equal? + injector?: Injector; +} + +export function httpResource( + url: string | (() => string), + options: HttpResourceOptions & {defaultValue: NoInfer}, +): HttpResource; +export function httpResource( + url: string | (() => string), + options?: HttpResourceOptions, +): HttpResource; +export function httpResource( + request: HttpResourceRequest | (() => HttpResourceRequest), + options?: HttpResourceOptions & {defaultValue: NoInfer}, +): HttpResource; +export function httpResource( + request: HttpResourceRequest | (() => HttpResourceRequest), + options?: HttpResourceOptions, +): HttpResource; +export function httpResource( + request: string | HttpResourceRequest | (() => string | HttpResourceRequest), + options?: HttpResourceOptions, +): HttpResource { + options?.injector || assertInInjectionContext(httpResource); + const injector = options?.injector ?? inject(Injector); + + const toHttpRequest = () => { + let unwrappedRequest = typeof request === 'function' ? request() : request; + if (typeof unwrappedRequest === 'string') { + unwrappedRequest = {url: unwrappedRequest}; + } + + return new HttpRequest( + unwrappedRequest.method ?? 'GET', + unwrappedRequest.url, + unwrappedRequest.body ?? null, + { + headers: + unwrappedRequest.headers instanceof HttpHeaders + ? unwrappedRequest.headers + : new HttpHeaders( + unwrappedRequest.headers as + | Record> + | undefined, + ), + params: + unwrappedRequest.params instanceof HttpParams + ? unwrappedRequest.params + : new HttpParams(unwrappedRequest.params), + reportProgress: unwrappedRequest.reportProgress, + withCredentials: unwrappedRequest.withCredentials, + responseType: 'json', + }, + ); + }; + + return new HttpResourceImpl(injector, toHttpRequest, options?.defaultValue, options?.map); +} + +export interface HttpResource extends WritableResource { + headers: Signal; +} + +class HttpResourceImpl extends ResourceImpl> implements HttpResource { + private client!: HttpClient; + private _headers = linkedSignal({ + source: this.extendedRequest, + computation: () => undefined as HttpHeaders | undefined, + }); + private _progress = linkedSignal({ + source: this.extendedRequest, + computation: () => undefined as HttpProgressEvent | undefined, + }); + + readonly headers = this._headers.asReadonly(); + readonly progress = this._progress.asReadonly(); + + constructor( + injector: Injector, + request: () => HttpRequest, + defaultValue: T, + map?: (value: unknown) => T, + ) { + super( + request, + ({request, set, error, abortSignal}) => { + const sub = this.client.request(request).subscribe({ + next: (event) => { + switch (event.type) { + case HttpEventType.Response: + this._headers.set(event.headers); + try { + set(map ? map(event.body) : (event.body as T)); + } catch (err) { + error(err); + } + break; + case HttpEventType.DownloadProgress: + this._progress.set(event); + break; + } + }, + error, + }); + abortSignal.addEventListener('abort', () => sub.unsubscribe()); + }, + defaultValue, + undefined, + injector, + ); + this.client = injector.get(HttpClient); + } +} diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index ed2f1869c032b..00442e53ec570 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 fe689617ec656..2bfe962f4bac9 100644 --- a/packages/core/src/resource/resource.ts +++ b/packages/core/src/resource/resource.ts @@ -97,7 +97,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 { /** * The current state of the resource. Status, value, and error are derived from this. */ @@ -107,7 +107,7 @@ class ResourceImpl extends BaseWritableResource implements ResourceRef< * Signal of both the request value `R` and a writable `reload` signal that's linked/associated * to the given request. Changing the value of the `reload` signal causes the resource to reload. */ - private readonly extendedRequest: Signal<{request: R; reload: WritableSignal}>; + protected readonly extendedRequest: Signal<{request: R; reload: WritableSignal}>; private readonly pendingTasks: PendingTasks; private readonly effectRef: EffectRef;