Skip to content

Commit

Permalink
feat(common): introduce experimental httpResource
Browse files Browse the repository at this point in the history
WIP: this commit is a work-in-progress of `httpResource`, a new entrypoint
to `HttpClient` for declaratively configuring resources backed by HTTP
requests.
  • Loading branch information
alxhub committed Dec 19, 2024
1 parent ab0bb37 commit 4bbeb32
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 2 deletions.
52 changes: 52 additions & 0 deletions goldens/public-api/common/http/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2153,6 +2156,55 @@ export class HttpRequest<T> {
readonly withCredentials: boolean;
}

// @public (undocumented)
export interface HttpResource<T> extends WritableResource<T> {
// (undocumented)
headers: Signal<HttpHeaders | undefined>;
}

// @public (undocumented)
export function httpResource<T = unknown>(url: string | (() => string), options: HttpResourceOptions<T> & {
defaultValue: NoInfer<T>;
}): HttpResource<T>;

// @public (undocumented)
export function httpResource<T = unknown>(url: string | (() => string), options?: HttpResourceOptions<T>): HttpResource<T | undefined>;

// @public (undocumented)
export function httpResource<T = unknown>(request: HttpResourceRequest | (() => HttpResourceRequest), options?: HttpResourceOptions<T> & {
defaultValue: NoInfer<T>;
}): HttpResource<T>;

// @public (undocumented)
export function httpResource<T = unknown>(request: HttpResourceRequest | (() => HttpResourceRequest), options?: HttpResourceOptions<T>): HttpResource<T | undefined>;

// @public (undocumented)
export interface HttpResourceOptions<T> {
defaultValue?: NoInfer<T>;
// (undocumented)
injector?: Injector;
// (undocumented)
map?: (value: unknown) => T;
}

// @public (undocumented)
export interface HttpResourceRequest {
// (undocumented)
body?: unknown;
// (undocumented)
headers?: HttpHeaders | Record<string, string | ReadonlyArray<string>>;
// (undocumented)
method?: string;
// (undocumented)
params?: HttpParams | Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
// (undocumented)
reportProgress?: boolean;
// (undocumented)
url: string;
// (undocumented)
withCredentials?: boolean;
}

// @public
export class HttpResponse<T> extends HttpResponseBase {
constructor(init?: {
Expand Down
1 change: 1 addition & 0 deletions packages/common/http/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export {
HttpUploadProgressEvent,
HttpUserEvent,
} from './src/response';
export {httpResource, HttpResource, HttpResourceOptions, HttpResourceRequest} from './src/resource';
export {
HttpTransferCacheOptions,
withHttpTransferCache as ɵwithHttpTransferCache,
Expand Down
159 changes: 159 additions & 0 deletions packages/common/http/src/resource.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
headers?: HttpHeaders | Record<string, string | ReadonlyArray<string>>;
reportProgress?: boolean;
withCredentials?: boolean;
}

export interface HttpResourceOptions<T> {
map?: (value: unknown) => T;
/**
* Value that the resource will take when in Idle, Loading, or Error states.
*/
defaultValue?: NoInfer<T>;

// TODO: equal?
injector?: Injector;
}

export function httpResource<T = unknown>(
url: string | (() => string),
options: HttpResourceOptions<T> & {defaultValue: NoInfer<T>},
): HttpResource<T>;
export function httpResource<T = unknown>(
url: string | (() => string),
options?: HttpResourceOptions<T>,
): HttpResource<T | undefined>;
export function httpResource<T = unknown>(
request: HttpResourceRequest | (() => HttpResourceRequest),
options?: HttpResourceOptions<T> & {defaultValue: NoInfer<T>},
): HttpResource<T>;
export function httpResource<T = unknown>(
request: HttpResourceRequest | (() => HttpResourceRequest),
options?: HttpResourceOptions<T>,
): HttpResource<T | undefined>;
export function httpResource(
request: string | HttpResourceRequest | (() => string | HttpResourceRequest),
options?: HttpResourceOptions<unknown>,
): HttpResource<unknown> {
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<string, string | number | Array<string | number>>
| 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<T> extends WritableResource<T> {
headers: Signal<HttpHeaders | undefined>;
}

class HttpResourceImpl<T> extends ResourceImpl<T, HttpRequest<unknown>> implements HttpResource<T> {
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<T>,
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);
}
}
2 changes: 2 additions & 0 deletions packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 2 additions & 2 deletions packages/core/src/resource/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,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> {
/**
* The current state of the resource. Status, value, and error are derived from this.
*/
Expand All @@ -107,7 +107,7 @@ class ResourceImpl<T, R> extends BaseWritableResource<T> 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<number>}>;
protected readonly extendedRequest: Signal<{request: R; reload: WritableSignal<number>}>;

private readonly pendingTasks: PendingTasks;
private readonly effectRef: EffectRef;
Expand Down

0 comments on commit 4bbeb32

Please sign in to comment.