diff --git a/angular.json b/angular.json index 42c1a358..c82615ad 100644 --- a/angular.json +++ b/angular.json @@ -573,6 +573,33 @@ } } } + }, + "@ppwcode/ng-resource": { + "projectType": "library", + "root": "projects/ppwcode/ng-resource", + "sourceRoot": "projects/ppwcode/ng-resource/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "configurations": { + "production": { + "tsConfig": "projects/ppwcode/ng-resource/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ppwcode/ng-resource/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular/build:unit-test", + "options": { + "runnerConfig": "vitest.config.ts", + "tsConfig": "projects/ppwcode/ng-resource/tsconfig.spec.json" + } + } + } } }, "cli": { diff --git a/projects/ppwcode/ng-resource/README.md b/projects/ppwcode/ng-resource/README.md new file mode 100644 index 00000000..88aeda1e --- /dev/null +++ b/projects/ppwcode/ng-resource/README.md @@ -0,0 +1,3 @@ +# @ppwcode/ng-resource + +This package holds utilities for working with Angular's signal-based resources. diff --git a/projects/ppwcode/ng-resource/ng-package.json b/projects/ppwcode/ng-resource/ng-package.json new file mode 100644 index 00000000..867b269c --- /dev/null +++ b/projects/ppwcode/ng-resource/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist/ppwcode/ng-resource", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ppwcode/ng-resource/package.json b/projects/ppwcode/ng-resource/package.json new file mode 100644 index 00000000..6248705b --- /dev/null +++ b/projects/ppwcode/ng-resource/package.json @@ -0,0 +1,13 @@ +{ + "name": "@ppwcode/ng-resource", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^22.0.0", + "@angular/core": "^22.0.0", + "@ppwcode/ng-utils": "^22.1.1" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "sideEffects": false +} diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/base-resource-options.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/base-resource-options.ts new file mode 100644 index 00000000..3a2b94bc --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/base-resource-options.ts @@ -0,0 +1,23 @@ +import { HttpParams, HttpResourceOptions } from '@angular/common/http' +import { RequestHeadersConfig } from './get-request-headers' +import { RequestUrlConfig } from './get-request-url' + +export interface BaseResourceOptions { + /** The URL to post the resource to. */ + url: RequestUrlConfig + /** The mapper to map the raw response to the desired entity. */ + responseMapper?: (raw: TResultDto) => TResultEntity + /** Optional request options to pass to the httpResource. */ + requestOptions?: { queryParams?: () => HttpParams; headers?: RequestHeadersConfig } + /** Optional resource options to pass to the httpResource. */ + resourceOptions?: Pick, 'defaultValue'> +} + +export type BaseResourceOptionsWithDefaultValue = BaseResourceOptions< + TResultDto, + TResultEntity +> & { + resourceOptions: NonNullable['resourceOptions']> & { + defaultValue: TResultEntity + } +} diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-http-resource-options.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-http-resource-options.spec.ts new file mode 100644 index 00000000..4b05be38 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-http-resource-options.spec.ts @@ -0,0 +1,27 @@ +import { generateBaseHttpResourceOptions } from './generate-base-http-resource-options' + +describe('generateBaseHttpResourceOptions', () => { + it('should expose the response mapper as parse', () => { + const options = generateBaseHttpResourceOptions({ + url: '/api/items', + responseMapper: (value: string) => value.toUpperCase() + }) + + expect(options.parse?.('item')).toBe('ITEM') + }) + + it('should carry through a configured default value', () => { + const options = generateBaseHttpResourceOptions({ + url: '/api/items', + resourceOptions: { + defaultValue: ['item'] + } + }) + + expect(options.defaultValue).toEqual(['item']) + }) + + it('should leave default value undefined when none is configured', () => { + expect(generateBaseHttpResourceOptions({ url: '/api/items' }).defaultValue).toBeUndefined() + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-http-resource-options.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-http-resource-options.ts new file mode 100644 index 00000000..cbf5c6e6 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-http-resource-options.ts @@ -0,0 +1,35 @@ +import { HttpResourceOptions } from '@angular/common/http' +import { BaseResourceOptions, BaseResourceOptionsWithDefaultValue } from './base-resource-options' + +/** + * Generates the base HTTP resource options by combining the provided resource options + * with a default value for the result entity. + * + * @param options The base resource options that include the default value and configuration + * for transforming between DTOs and entities. + * @return A merged object containing the HTTP resource options and the default value + * for the result entity. + */ +export function generateBaseHttpResourceOptions( + options: BaseResourceOptionsWithDefaultValue +): HttpResourceOptions & { defaultValue: TResultEntity } + +/** + * Generates the base HTTP resource options by transforming the provided resource configuration. + * + * @param options The base resource configuration, including types for both the data transfer object (DTO) and the entity. + * @return The resulting HTTP resource options with entity transformation applied. + */ +export function generateBaseHttpResourceOptions( + options: BaseResourceOptions +): HttpResourceOptions + +export function generateBaseHttpResourceOptions( + options: BaseResourceOptions +): HttpResourceOptions { + return { + // Angular uses unknown for the type of `raw`, but we know which one it is because of TResultDto so this cast is safe. + parse: options.responseMapper as (raw: unknown) => TResultEntity, + defaultValue: options.resourceOptions?.defaultValue + } +} diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-resource-request.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-resource-request.spec.ts new file mode 100644 index 00000000..156dba3e --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-resource-request.spec.ts @@ -0,0 +1,31 @@ +import { HttpHeaders, HttpParams } from '@angular/common/http' +import { generateBaseResourceRequest } from './generate-base-resource-request' + +describe('generateBaseResourceRequest', () => { + it('should return undefined when no URL can be resolved', () => { + expect(generateBaseResourceRequest({ url: () => undefined })).toBeUndefined() + }) + + it('should return the resolved URL and headers', () => { + const result = generateBaseResourceRequest({ + url: '/api/items', + requestOptions: { + headers: new HttpHeaders({ Authorization: 'Bearer token' }) + } + }) + + expect(result?.url).toBe('/api/items') + expect((result?.headers as HttpHeaders | undefined)?.get('Authorization')).toBe('Bearer token') + }) + + it('should append query params through the URL helper', () => { + expect( + generateBaseResourceRequest({ + url: '/api/items', + requestOptions: { + queryParams: () => new HttpParams().set('search', 'unit') + } + })?.url + ).toBe('/api/items?search=unit') + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-resource-request.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-resource-request.ts new file mode 100644 index 00000000..54370166 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/generate-base-resource-request.ts @@ -0,0 +1,18 @@ +import { HttpResourceRequest } from '@angular/common/http' +import { BaseResourceOptions } from './base-resource-options' +import { getRequestHeaders } from './get-request-headers' +import { getRequestUrl } from './get-request-url' + +export const generateBaseResourceRequest = ( + options: BaseResourceOptions +): Pick | undefined => { + const url = getRequestUrl(options.url, options.requestOptions?.queryParams) + if (!url) { + return undefined + } + + return { + url, + headers: getRequestHeaders(options.requestOptions?.headers) + } +} diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-query-params-string.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-query-params-string.spec.ts new file mode 100644 index 00000000..402597a6 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-query-params-string.spec.ts @@ -0,0 +1,17 @@ +import { HttpParams } from '@angular/common/http' +import { getQueryParamsString } from './get-query-params-string' + +describe('getQueryParamsString', () => { + it.each([ + { scenario: 'no params', params: undefined, expected: '' }, + { scenario: 'empty params', params: new HttpParams(), expected: '' }, + { scenario: 'single param', params: new HttpParams().set('foo', 'bar'), expected: '?foo=bar' }, + { + scenario: 'multiple params', + params: new HttpParams().set('foo', 'bar').set('baz', 'qux'), + expected: '?foo=bar&baz=qux' + } + ])(`should return $expected for $scenario`, ({ params, expected }) => { + expect(getQueryParamsString(params)).toEqual(expected) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-query-params-string.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-query-params-string.ts new file mode 100644 index 00000000..69c23363 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-query-params-string.ts @@ -0,0 +1,9 @@ +import { HttpParams } from '@angular/common/http' + +export const getQueryParamsString = (httpParams?: HttpParams): string => { + const httpParamsString = httpParams?.toString() ?? '' + if (httpParamsString) { + return `?${httpParamsString}` + } + return '' +} diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-headers.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-headers.spec.ts new file mode 100644 index 00000000..ac327964 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-headers.spec.ts @@ -0,0 +1,41 @@ +import '@angular/compiler' +import { HttpHeaders } from '@angular/common/http' +import { getRequestHeaders } from './get-request-headers' + +describe('getRequestHeaders', () => { + it('should return undefined when no headers are configured', () => { + expect(getRequestHeaders()).toBeUndefined() + }) + + it('should keep existing HttpHeaders instances unchanged', () => { + const headers = new HttpHeaders({ Authorization: 'Bearer token' }) + + const result = getRequestHeaders(headers) + + expect(result).toBe(headers) + }) + + it('should create HttpHeaders from a raw headers object', () => { + const result = getRequestHeaders({ + 'Custom-Header': 'value', + Count: 3, + Tags: ['one', 'two'] + }) + + expect(result?.get('Custom-Header')).toEqual('value') + expect(result?.get('Count')).toEqual('3') + expect(result?.getAll('Tags')).toEqual(['one', 'two']) + }) + + it('should create HttpHeaders from a raw headers string', () => { + const result = getRequestHeaders('Custom-Header: value') + + expect(result?.get('Custom-Header')).toEqual('value') + }) + + it('should resolve headers from a factory', () => { + const result = getRequestHeaders(() => ({ 'Custom-Header': 'value' })) + + expect(result?.get('Custom-Header')).toEqual('value') + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-headers.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-headers.ts new file mode 100644 index 00000000..1e398655 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-headers.ts @@ -0,0 +1,52 @@ +import { HttpHeaders } from '@angular/common/http' + +/** + * Represents the parameter used to construct HTTP headers. + * + * This type can be one of the following: + * - A string: Typically used to pass raw header strings. + * - An object: A key-value map where each key corresponds to a header name, and the value can be a string, a number, or an array of strings/numbers. + * - An instance of the `Headers` class: Allows full utilization of the `Headers` API. + */ +type HttpHeadersConstructorParam = string | { [p: string]: string | number | (string | number)[] } | Headers + +/** + * Represents a configuration type for HTTP request headers. + * + * This type can be one of the following: + * - An instance of `HttpHeaders` + * - A `HttpHeadersConstructorParam`, which provides parameters for constructing `HttpHeaders` + * - A function that returns an `HttpHeaders` instance + * - A function that returns a `HttpHeadersConstructorParam` + * - `undefined`, indicating no headers configuration is provided + */ +export type RequestHeadersConfig = + | HttpHeaders + | HttpHeadersConstructorParam + | (() => HttpHeaders) + | (() => HttpHeadersConstructorParam) + | undefined + +/** + * Retrieves or constructs the HTTP headers based on the provided configuration. + * + * @param {RequestHeadersConfig} [config] - An optional configuration for HTTP headers. + * Can be an instance of HttpHeaders, an object, a string, + * or a function returning a valid configuration. + * @returns {HttpHeaders | undefined} The constructed HttpHeaders instance or undefined if no configuration is provided. + */ +export const getRequestHeaders = (config?: RequestHeadersConfig): HttpHeaders | undefined => { + if (config instanceof HttpHeaders) { + return config + } + + if (!config) { + return undefined + } + + if (typeof config === 'object' || typeof config === 'string') { + return new HttpHeaders(config) + } + + return getRequestHeaders(config()) +} diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-url.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-url.spec.ts new file mode 100644 index 00000000..043976e5 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-url.spec.ts @@ -0,0 +1,56 @@ +import '@angular/compiler' +import { HttpParams } from '@angular/common/http' +import { getRequestUrl } from './get-request-url' + +describe('getRequestUrl', () => { + it('should return the configured url without query params', () => { + const result = getRequestUrl('/fakeapi') + + expect(result).toEqual('/fakeapi') + }) + + it('should resolve the url from a factory', () => { + const result = getRequestUrl(() => '/fakeapi') + + expect(result).toEqual('/fakeapi') + }) + + it('should return undefined when the configured url is empty', () => { + const result = getRequestUrl('') + + expect(result).toBeUndefined() + }) + + it('should return undefined when the url factory returns undefined', () => { + const result = getRequestUrl(() => undefined) + + expect(result).toBeUndefined() + }) + + it('should append query params to the url', () => { + const result = getRequestUrl('/fakeapi', () => new HttpParams().set('foo', 'bar').set('baz', 'qux')) + + expect(result).toEqual('/fakeapi?foo=bar&baz=qux') + }) + + it('should keep the url unchanged when query params return undefined', () => { + const result = getRequestUrl('/fakeapi', () => undefined) + + expect(result).toEqual('/fakeapi') + }) + + it('should not resolve query params when no url can be resolved', () => { + let queryParamsResolved = false + + const result = getRequestUrl( + () => undefined, + () => { + queryParamsResolved = true + return new HttpParams().set('foo', 'bar') + } + ) + + expect(result).toBeUndefined() + expect(queryParamsResolved).toEqual(false) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-url.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-url.ts new file mode 100644 index 00000000..91038840 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/base/get-request-url.ts @@ -0,0 +1,35 @@ +import { HttpParams } from '@angular/common/http' +import { getQueryParamsString } from './get-query-params-string' + +/** + * Defines the configuration for a request URL. + * + * This type can either be a string representing the URL directly, + * or a function that returns a string or undefined. The function + * allows for dynamic URL generation at runtime based on specific + * requirements or conditions, allowing for recreation based on + * signal changes. + */ +export type RequestUrlConfig = string | (() => string | undefined) + +/** + * Represents a configuration for handling query parameters in an HTTP request. + * This type defines a function that, when invoked, returns either an instance of `HttpParams` to configure + * query parameters for the request or `undefined` if no query parameters are needed. + * + * Use this configuration to dynamically construct or conditionally supply query parameters + * for HTTP requests within an application. + */ +export type RequestQueryParamsConfig = () => HttpParams | undefined + +export const getRequestUrl = (config: RequestUrlConfig, queryParams?: RequestQueryParamsConfig): string | undefined => { + const url = typeof config === 'function' ? config() : config + if (!url) { + return undefined + } + + // Angular currently has no support for reactive query parameters. Converting them to a string allows + // us to use them in the URL. This is a workaround until Angular supports reactive query parameters. + const queryParamsString = getQueryParamsString(queryParams?.()) + return url + queryParamsString +} diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/delete-resource.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/delete-resource.spec.ts new file mode 100644 index 00000000..4285d208 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/delete-resource.spec.ts @@ -0,0 +1,107 @@ +import { HttpHeaders, HttpParams } from '@angular/common/http' +import { DeleteResourceOptions, _generateDeleteResourceRequest } from './delete-resource' + +describe('_generateDeleteResourceRequest', () => { + it('should return undefined when url function returns undefined', () => { + const options: DeleteResourceOptions = { + url: () => undefined + } + + const requestFn = _generateDeleteResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return undefined when url function returns empty string', () => { + const options: DeleteResourceOptions = { + url: () => '' + } + + const requestFn = _generateDeleteResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return a DELETE request with correct url when no query params', () => { + const options: DeleteResourceOptions = { + url: () => '/fakeapi' + } + + const requestFn = _generateDeleteResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'DELETE', + headers: undefined + }) + }) + + it('should return a DELETE request with query params appended to url', () => { + const options: DeleteResourceOptions = { + url: () => '/fakeapi', + requestOptions: { + queryParams: () => new HttpParams().set('foo', 'bar').set('baz', 'qux') + } + } + + const requestFn = _generateDeleteResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi?foo=bar&baz=qux', + method: 'DELETE', + headers: undefined + }) + }) + + it('should return a DELETE request with headers when provided', () => { + const headers = new HttpHeaders({ 'Custom-Header': 'value' }) + const options: DeleteResourceOptions = { + url: () => '/fakeapi', + requestOptions: { + headers: () => headers + } + } + + const requestFn = _generateDeleteResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'DELETE', + headers + }) + }) + + it('should return a DELETE request with mapped body when provided', () => { + const requestFn = _generateDeleteResourceRequest({ + url: () => '/fakeapi', + body: () => ({ id: 'mock-id' }), + bodyMapper: (body) => ({ mappedId: body.id }) + }) + + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'DELETE', + headers: undefined, + body: { + mappedId: 'mock-id' + } + }) + }) + + it('should return undefined when body options are provided but body returns undefined', () => { + const requestFn = _generateDeleteResourceRequest({ + url: () => '/fakeapi', + body: () => undefined as { id: string } | undefined, + bodyMapper: (body) => ({ mappedId: body.id }) + }) + + expect(requestFn()).toBeUndefined() + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/delete-resource.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/delete-resource.ts new file mode 100644 index 00000000..5868870f --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/delete-resource.ts @@ -0,0 +1,80 @@ +import { HttpResourceOptions, HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http' +import { BaseResourceOptions, BaseResourceOptionsWithDefaultValue } from './base/base-resource-options' +import { generateBaseResourceRequest } from './base/generate-base-resource-request' + +/** + * The options for executing the deleteResource function. + */ +export type DeleteResourceOptions = Omit, 'responseMapper'> + +export interface DeleteResourceOptionsWithBody extends DeleteResourceOptions { + /** The body to send with the request. */ + body: () => TBodyEntity | undefined + /** The mapper to map the body to the desired DTO format. */ + bodyMapper: (body: TBodyEntity) => TBodyDto +} + +export type DeleteResourceOptionsWithDefaultValue = Omit< + BaseResourceOptionsWithDefaultValue, + 'responseMapper' +> + +const hasBody = ( + options: DeleteResourceOptions | DeleteResourceOptionsWithBody +): options is DeleteResourceOptionsWithBody => 'body' in options + +/** + * Function to generate a delete resource request based on the provided options. + * @privateRemarks + * The only reason this function is exported is to allow for testing. + * @param options The options to generate the delete resource request. + */ +export const _generateDeleteResourceRequest = + ( + options: DeleteResourceOptions | DeleteResourceOptionsWithBody + ): (() => HttpResourceRequest | undefined) => + () => { + const request = generateBaseResourceRequest(options) + + if (!request) { + return undefined + } + + if (hasBody(options)) { + const body = options.body() + + if (body === undefined) { + return undefined + } + + return { + ...request, + method: 'DELETE', + body: options.bodyMapper(body) + } + } + + return { + ...request, + method: 'DELETE' + } + } + +interface DeleteResourceFn { + (options: DeleteResourceOptionsWithDefaultValue): HttpResourceRef + ( + options: DeleteResourceOptionsWithBody + ): HttpResourceRef + (options: DeleteResourceOptions): HttpResourceRef +} + +export const deleteResource: DeleteResourceFn = (( + options: DeleteResourceOptions +): HttpResourceRef => { + const resourceOptions: HttpResourceOptions = { + parse: () => null, + defaultValue: options.resourceOptions?.defaultValue + } + + return httpResource.text(_generateDeleteResourceRequest(options), resourceOptions) +}) as DeleteResourceFn diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/get-resource.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/get-resource.spec.ts new file mode 100644 index 00000000..75ce6dbe --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/get-resource.spec.ts @@ -0,0 +1,80 @@ +import { HttpHeaders, HttpParams } from '@angular/common/http' +import { GetResourceOptions, _generateGetResourceRequest } from './get-resource' + +describe('_generateGetResourceRequest', () => { + it('should return undefined when url function returns undefined', () => { + const options: GetResourceOptions = { + responseMapper: (v) => v, + url: () => undefined + } + + const requestFn = _generateGetResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return undefined when url function returns empty string', () => { + const options: GetResourceOptions = { + responseMapper: (v) => v, + url: () => '' + } + + const requestFn = _generateGetResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return a request with correct url when no query params', () => { + const options: GetResourceOptions = { + responseMapper: (v) => v, + url: () => '/fakeapi' + } + + const requestFn = _generateGetResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + headers: undefined + }) + }) + + it('should return a GET request with query params appended to url', () => { + const options: GetResourceOptions = { + responseMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + queryParams: () => new HttpParams().set('foo', 'bar').set('baz', 'qux') + } + } + + const requestFn = _generateGetResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi?foo=bar&baz=qux', + headers: undefined + }) + }) + + it('should return a GET request with headers when provided', () => { + const headers = new HttpHeaders({ 'Custom-Header': 'value' }) + const options: GetResourceOptions = { + responseMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + headers: () => headers + } + } + + const requestFn = _generateGetResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + headers: headers + }) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/get-resource.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/get-resource.ts new file mode 100644 index 00000000..8d29a1e7 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/get-resource.ts @@ -0,0 +1,111 @@ +import { HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http' +import { BaseResourceOptions, BaseResourceOptionsWithDefaultValue } from './base/base-resource-options' +import { generateBaseHttpResourceOptions } from './base/generate-base-http-resource-options' +import { generateBaseResourceRequest } from './base/generate-base-resource-request' + +/** + * The options for executing the getResource function. + */ +export type GetResourceOptions = BaseResourceOptions + +export type GetResourceOptionsWithDefaultValue< + TResultDto, + TResultEntity = TResultDto +> = BaseResourceOptionsWithDefaultValue + +/** + * Function to generate a get resource request based on the provided options. + * @privateRemarks + * The only reason this function is exported is to allow for testing. + * @param options The options to generate the get resource request. + */ +export const _generateGetResourceRequest = + ( + options: GetResourceOptions + ): (() => HttpResourceRequest | undefined) => + () => + generateBaseResourceRequest(options) + +interface GetResourceFn { + /** + * Gets a resource at the given URL and maps the response using the given mapper. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the get resource function. + */ + ( + options: GetResourceOptionsWithDefaultValue + ): HttpResourceRef + + /** + * Gets a resource at the given URL and maps the response using the given mapper. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the get resource function. + */ + ( + options: GetResourceOptions + ): HttpResourceRef + + /** + * Gets a resource at the given URL and maps the response using the given mapper. + * Reads the body as text. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the get resource function. + */ + text: { + ( + options: GetResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: GetResourceOptions + ): HttpResourceRef + } + + /** + * Gets a resource at the given URL and maps the response using the given mapper. + * Reads the body as blob. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the get resource function. + */ + blob: { + ( + options: GetResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: GetResourceOptions + ): HttpResourceRef + } +} + +export const getResource: GetResourceFn = ((): GetResourceFn => { + const getFn = (( + options: GetResourceOptions + ): HttpResourceRef => + httpResource( + _generateGetResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as GetResourceFn + + getFn.text = (( + options: GetResourceOptions + ): HttpResourceRef => + httpResource.text( + _generateGetResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as GetResourceFn['text'] + + getFn.blob = (( + options: GetResourceOptions + ): HttpResourceRef => + httpResource.blob( + _generateGetResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as GetResourceFn['blob'] + + return getFn +})() diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/patch-resource.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/patch-resource.spec.ts new file mode 100644 index 00000000..94693672 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/patch-resource.spec.ts @@ -0,0 +1,122 @@ +import { HttpHeaders, HttpParams } from '@angular/common/http' +import { PatchResourceOptions, _generatePatchResourceRequest } from './patch-resource' + +describe('_generatePatchResourceRequest', () => { + it('should return undefined when url function returns undefined', () => { + const options: PatchResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => undefined + } + + const requestFn = _generatePatchResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return undefined when url function returns empty string', () => { + const options: PatchResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '' + } + + const requestFn = _generatePatchResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return undefined when body function returns undefined', () => { + const options: PatchResourceOptions = { + body: () => undefined, + bodyMapper: (v) => v, + url: () => '/fakeapi' + } + + const requestFn = _generatePatchResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return a PATCH request with correct url when no query params', () => { + const options: PatchResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi' + } + + const requestFn = _generatePatchResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'PATCH', + headers: undefined, + body: {} + }) + }) + + it('should return a PATCH request with query params appended to url', () => { + const options: PatchResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + queryParams: () => new HttpParams().set('foo', 'bar').set('baz', 'qux') + } + } + + const requestFn = _generatePatchResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi?foo=bar&baz=qux', + method: 'PATCH', + headers: undefined, + body: {} + }) + }) + + it('should return a PATCH request with body mapped to DTO', () => { + const options: PatchResourceOptions<{ name: string }, { mappedName: string }, void, void> = { + body: () => ({ name: 'Test' }), + bodyMapper: (v) => ({ mappedName: v.name }), + url: () => '/fakeapi' + } + + const requestFn = _generatePatchResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'PATCH', + headers: undefined, + body: { mappedName: 'Test' } + }) + }) + + it('should return a PATCH request with headers when provided', () => { + const headers = new HttpHeaders({ 'Custom-Header': 'value' }) + const options: PatchResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + headers: () => headers + } + } + + const requestFn = _generatePatchResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'PATCH', + headers, + body: {} + }) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/patch-resource.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/patch-resource.ts new file mode 100644 index 00000000..ddd9406d --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/patch-resource.ts @@ -0,0 +1,113 @@ +import { HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http' +import { generateBaseHttpResourceOptions } from './base/generate-base-http-resource-options' +import { generateBaseResourceRequest } from './base/generate-base-resource-request' +import { PostResourceOptions, PostResourceOptionsWithDefaultValue } from './post-resource' + +/** + * The options for executing the patchResource function. + */ +export type PatchResourceOptions = PostResourceOptions< + TBodyEntity, + TBodyDto, + TResultDto, + TResultEntity +> + +export type PatchResourceOptionsWithDefaultValue = + PostResourceOptionsWithDefaultValue + +/** + * Function to generate a patch resource request based on the provided options. + * @privateRemarks + * The only reason this function is exported is to allow for testing. + * @param options The options to generate the patch resource request. + */ +export const _generatePatchResourceRequest = + ( + options: PatchResourceOptions + ): (() => HttpResourceRequest | undefined) => + () => { + const request = generateBaseResourceRequest(options) + const body = options.body() + + if (request && body !== undefined) { + return { + ...request, + method: 'PATCH', + body: options.bodyMapper(body) + } + } + + return undefined + } + +interface PatchResourceFn { + ( + options: PatchResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PatchResourceOptions + ): HttpResourceRef + + text: { + ( + options: PatchResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PatchResourceOptions + ): HttpResourceRef + } + + blob: { + ( + options: PatchResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PatchResourceOptions + ): HttpResourceRef + } +} + +export const patchResource: PatchResourceFn = ((): PatchResourceFn => { + /** + * Patches a resource at the given URL, maps the body and optionally the response using the given mappers. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the patch resource function. + */ + const patchFn = (( + options: PatchResourceOptions + ): HttpResourceRef => + httpResource( + _generatePatchResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PatchResourceFn + + /** + * Patches a resource at the given URL, maps the body and optionally the response using the given mappers. + * Reads the result as text. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the patch resource function. + */ + patchFn.text = (( + options: PatchResourceOptions + ): HttpResourceRef => + httpResource.text( + _generatePatchResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PatchResourceFn['text'] + + patchFn.blob = (( + options: PatchResourceOptions + ): HttpResourceRef => + httpResource.blob( + _generatePatchResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PatchResourceFn['blob'] + + return patchFn +})() diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/post-resource.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/post-resource.spec.ts new file mode 100644 index 00000000..6d0af53a --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/post-resource.spec.ts @@ -0,0 +1,109 @@ +import { HttpHeaders, HttpParams } from '@angular/common/http' +import { PostResourceOptions, _generatePostResourceRequest } from './post-resource' + +describe('_generatePostResourceRequest', () => { + it('should return undefined when url function returns undefined', () => { + const options: PostResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => undefined + } + + const requestFn = _generatePostResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return undefined when url function returns empty string', () => { + const options: PostResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '' + } + + const requestFn = _generatePostResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return a POST request with correct url when no query params', () => { + const options: PostResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi' + } + + const requestFn = _generatePostResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'POST', + headers: undefined, + body: {} + }) + }) + + it('should return a POST request with query params appended to url', () => { + const options: PostResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + queryParams: () => new HttpParams().set('foo', 'bar').set('baz', 'qux') + } + } + + const requestFn = _generatePostResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi?foo=bar&baz=qux', + method: 'POST', + headers: undefined, + body: {} + }) + }) + + it('should return a POST request with body mapped to DTO', () => { + const options: PostResourceOptions<{ name: string }, { mappedName: string }, void, void> = { + body: () => ({ name: 'Test' }), + bodyMapper: (v) => ({ mappedName: v.name }), + url: () => '/fakeapi' + } + + const requestFn = _generatePostResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'POST', + headers: undefined, + body: { mappedName: 'Test' } + }) + }) + + it('should return a POST request with headers when provided', () => { + const headers = new HttpHeaders({ 'Custom-Header': 'value' }) + const options: PostResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + headers: () => headers + } + } + + const requestFn = _generatePostResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'POST', + headers, + body: {} + }) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/post-resource.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/post-resource.ts new file mode 100644 index 00000000..8d5160f7 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/post-resource.ts @@ -0,0 +1,116 @@ +import { HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http' +import { BaseResourceOptions, BaseResourceOptionsWithDefaultValue } from './base/base-resource-options' +import { generateBaseHttpResourceOptions } from './base/generate-base-http-resource-options' +import { generateBaseResourceRequest } from './base/generate-base-resource-request' + +/** + * The options for executing the postResource function. + */ +export interface PostResourceOptions + extends BaseResourceOptions { + /** The body to send with the request. */ + body: () => TBodyEntity | undefined + /** The mapper to map the body to the desired DTO format. */ + bodyMapper: (body: TBodyEntity) => TBodyDto +} + +export interface PostResourceOptionsWithDefaultValue + extends PostResourceOptions { + resourceOptions: BaseResourceOptionsWithDefaultValue['resourceOptions'] +} + +/** + * Function to generate a post resource request based on the provided options. + * @privateRemarks + * The only reason this function is exported is to allow for testing. + * @param options The options to generate the post resource request. + */ +export const _generatePostResourceRequest = + ( + options: PostResourceOptions + ): (() => HttpResourceRequest | undefined) => + () => { + const request = generateBaseResourceRequest(options) + const body = options.body() + + if (request && body !== undefined) { + return { + ...request, + method: 'POST', + body: options.bodyMapper(body) + } + } + + return undefined + } + +interface PostResourceFn { + ( + options: PostResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PostResourceOptions + ): HttpResourceRef + + text: { + ( + options: PostResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PostResourceOptions + ): HttpResourceRef + } + + blob: { + ( + options: PostResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PostResourceOptions + ): HttpResourceRef + } +} + +export const postResource: PostResourceFn = ((): PostResourceFn => { + /** + * Posts a resource to the given URL, maps the body and optionally the response using the given mappers. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the post resource function. + */ + const postFn = (( + options: PostResourceOptions + ): HttpResourceRef => + httpResource( + _generatePostResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PostResourceFn + + /** + * Posts a resource to the given URL, maps the body and optionally the response using the given mappers. + * Reads the result as text. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the post resource function. + */ + postFn.text = (( + options: PostResourceOptions + ): HttpResourceRef => + httpResource.text( + _generatePostResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PostResourceFn['text'] + + postFn.blob = (( + options: PostResourceOptions + ): HttpResourceRef => + httpResource.blob( + _generatePostResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PostResourceFn['blob'] + + return postFn +})() diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/put-resource.spec.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/put-resource.spec.ts new file mode 100644 index 00000000..c0e582e0 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/put-resource.spec.ts @@ -0,0 +1,122 @@ +import { HttpHeaders, HttpParams } from '@angular/common/http' +import { PutResourceOptions, _generatePutResourceRequest } from './put-resource' + +describe('_generatePutResourceRequest', () => { + it('should return undefined when url function returns undefined', () => { + const options: PutResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => undefined + } + + const requestFn = _generatePutResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return undefined when url function returns empty string', () => { + const options: PutResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '' + } + + const requestFn = _generatePutResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return undefined when body function returns undefined', () => { + const options: PutResourceOptions = { + body: () => undefined, + bodyMapper: (v) => v, + url: () => '/fakeapi' + } + + const requestFn = _generatePutResourceRequest(options) + const result = requestFn() + + expect(result).toBeUndefined() + }) + + it('should return a PUT request with correct url when no query params', () => { + const options: PutResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi' + } + + const requestFn = _generatePutResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'PUT', + headers: undefined, + body: {} + }) + }) + + it('should return a PUT request with query params appended to url', () => { + const options: PutResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + queryParams: () => new HttpParams().set('foo', 'bar').set('baz', 'qux') + } + } + + const requestFn = _generatePutResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi?foo=bar&baz=qux', + method: 'PUT', + headers: undefined, + body: {} + }) + }) + + it('should return a PUT request with body mapped to DTO', () => { + const options: PutResourceOptions<{ name: string }, { mappedName: string }, void, void> = { + body: () => ({ name: 'Test' }), + bodyMapper: (v) => ({ mappedName: v.name }), + url: () => '/fakeapi' + } + + const requestFn = _generatePutResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'PUT', + headers: undefined, + body: { mappedName: 'Test' } + }) + }) + + it('should return a PUT request with headers when provided', () => { + const headers = new HttpHeaders({ 'Custom-Header': 'value' }) + const options: PutResourceOptions = { + body: () => ({}), + bodyMapper: (v) => v, + url: () => '/fakeapi', + requestOptions: { + headers: () => headers + } + } + + const requestFn = _generatePutResourceRequest(options) + const result = requestFn() + + expect(result).toEqual({ + url: '/fakeapi', + method: 'PUT', + headers, + body: {} + }) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/api-call-primitives/put-resource.ts b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/put-resource.ts new file mode 100644 index 00000000..0e0b0683 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/api-call-primitives/put-resource.ts @@ -0,0 +1,113 @@ +import { HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http' +import { generateBaseHttpResourceOptions } from './base/generate-base-http-resource-options' +import { generateBaseResourceRequest } from './base/generate-base-resource-request' +import { PostResourceOptions, PostResourceOptionsWithDefaultValue } from './post-resource' + +/** + * The options for executing the putResource function. + */ +export type PutResourceOptions = PostResourceOptions< + TBodyEntity, + TBodyDto, + TResultDto, + TResultEntity +> + +export type PutResourceOptionsWithDefaultValue = + PostResourceOptionsWithDefaultValue + +/** + * Function to generate a put resource request based on the provided options. + * @privateRemarks + * The only reason this function is exported is to allow for testing. + * @param options The options to generate the put resource request. + */ +export const _generatePutResourceRequest = + ( + options: PutResourceOptions + ): (() => HttpResourceRequest | undefined) => + () => { + const request = generateBaseResourceRequest(options) + const body = options.body() + + if (request && body !== undefined) { + return { + ...request, + method: 'PUT', + body: options.bodyMapper(body) + } + } + + return undefined + } + +interface PutResourceFn { + ( + options: PutResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PutResourceOptions + ): HttpResourceRef + + text: { + ( + options: PutResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PutResourceOptions + ): HttpResourceRef + } + + blob: { + ( + options: PutResourceOptionsWithDefaultValue + ): HttpResourceRef + + ( + options: PutResourceOptions + ): HttpResourceRef + } +} + +export const putResource: PutResourceFn = ((): PutResourceFn => { + /** + * Puts a resource to the given URL, maps the body and optionally the response using the given mappers. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the put resource function. + */ + const putFn = (( + options: PutResourceOptions + ): HttpResourceRef => + httpResource( + _generatePutResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PutResourceFn + + /** + * Puts a resource to the given URL, maps the body and optionally the response using the given mappers. + * Reads the result as text. + * The url parameter is a function to allow for dynamic URL generation based on signals. When a signal is used, + * the httpResource will track changes to the signal and automatically update the URL and execute the request. + * @param options The execution options for the put resource function. + */ + putFn.text = (( + options: PutResourceOptions + ): HttpResourceRef => + httpResource.text( + _generatePutResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PutResourceFn['text'] + + putFn.blob = (( + options: PutResourceOptions + ): HttpResourceRef => + httpResource.blob( + _generatePutResourceRequest(options), + generateBaseHttpResourceOptions(options) + )) as PutResourceFn['blob'] + + return putFn +})() diff --git a/projects/ppwcode/ng-resource/src/lib/error-handling/extractor.spec.ts b/projects/ppwcode/ng-resource/src/lib/error-handling/extractor.spec.ts new file mode 100644 index 00000000..bbd8695b --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/error-handling/extractor.spec.ts @@ -0,0 +1,18 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { PPW_RESOURCE_ERROR_EXTRACTOR, providePpwResourceErrorExtractor } from './extractor' + +describe('providePpwResourceErrorExtractor', () => { + it('should provide the supplied error extractor', () => { + const extractedError = new Error('Extracted error') + const extractor = vi.fn<(error: HttpErrorResponse) => Error>(() => extractedError) + const httpError = { status: 422 } as HttpErrorResponse + + const provider = providePpwResourceErrorExtractor(extractor) + + expect(provider).toEqual({ + provide: PPW_RESOURCE_ERROR_EXTRACTOR, + useValue: extractor + }) + expect(provider.useValue(httpError)).toBe(extractedError) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/error-handling/extractor.ts b/projects/ppwcode/ng-resource/src/lib/error-handling/extractor.ts new file mode 100644 index 00000000..99590543 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/error-handling/extractor.ts @@ -0,0 +1,20 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { InjectionToken, ValueProvider } from '@angular/core' + +/** Type definition for a function that extracts an error from an HttpErrorResponse. */ +export type PpwResourceErrorExtractor = (error: HttpErrorResponse) => Error + +/** Injection token for providing a custom error extractor for PpwResource. */ +export const PPW_RESOURCE_ERROR_EXTRACTOR = new InjectionToken( + 'PPW_RESOURCE_ERROR_EXTRACTOR' +) + +/** + * Provides a custom error extractor for PpwResource. + * @param extractor The error extractor function to provide. + * @returns A ValueProvider for the PPW_RESOURCE_ERROR_EXTRACTOR token. + */ +export const providePpwResourceErrorExtractor = (extractor: PpwResourceErrorExtractor): ValueProvider => ({ + provide: PPW_RESOURCE_ERROR_EXTRACTOR, + useValue: extractor +}) diff --git a/projects/ppwcode/ng-resource/src/lib/error-handling/handler.spec.ts b/projects/ppwcode/ng-resource/src/lib/error-handling/handler.spec.ts new file mode 100644 index 00000000..02b16176 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/error-handling/handler.spec.ts @@ -0,0 +1,17 @@ +import { PPW_RESOURCE_DEFAULT_ERROR_HANDLER, providePpwResourceDefaultErrorHandler } from './handler' + +describe('providePpwResourceDefaultErrorHandler', () => { + it('should provide the supplied default error handler', () => { + const handler = vi.fn<(error: Error) => void>() + const error = new Error('Failed') + + const provider = providePpwResourceDefaultErrorHandler(handler) + + expect(provider).toEqual({ + provide: PPW_RESOURCE_DEFAULT_ERROR_HANDLER, + useValue: handler + }) + provider.useValue(error) + expect(handler).toHaveBeenCalledWith(error) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/error-handling/handler.ts b/projects/ppwcode/ng-resource/src/lib/error-handling/handler.ts new file mode 100644 index 00000000..9d468185 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/error-handling/handler.ts @@ -0,0 +1,25 @@ +import { InjectionToken, ValueProvider } from '@angular/core' + +/** Type definition for a function that handles successes from PpwResource. */ +export type PpwResourceSuccessHandler = (value: Exclude) => void + +/** Type definition for a function that handles errors from PpwResource. */ +export type PpwResourceErrorHandler = (error: Error) => void + +/** Type definition for a function that handles finally blocks from PpwResource. */ +export type PpwResourceFinallyHandler = () => void + +/** Injection token for providing a default error handler for PpwResource. */ +export const PPW_RESOURCE_DEFAULT_ERROR_HANDLER = new InjectionToken<(error: Error) => void>( + 'PPW_RESOURCE_DEFAULT_ERROR_HANDLER' +) + +/** + * Provides a default error handler for PpwResource. + * @param handler The error handler function to provide. + * @returns A ValueProvider for the PPW_RESOURCE_DEFAULT_ERROR_HANDLER token. + */ +export const providePpwResourceDefaultErrorHandler = (handler: PpwResourceErrorHandler): ValueProvider => ({ + provide: PPW_RESOURCE_DEFAULT_ERROR_HANDLER, + useValue: handler +}) diff --git a/projects/ppwcode/ng-resource/src/lib/utils/to-http-params.spec.ts b/projects/ppwcode/ng-resource/src/lib/utils/to-http-params.spec.ts new file mode 100644 index 00000000..49eb8317 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/utils/to-http-params.spec.ts @@ -0,0 +1,111 @@ +import { paramsToHttpParams, toHttpParams } from './to-http-params' + +describe('toHttpParams', () => { + it('should keep flat primitive values unchanged', () => { + const result = toHttpParams({ foo: 'bar', count: 3, active: true }) + + expect(result.toString()).toEqual('foo=bar&count=3&active=true') + }) + + it('should encode nested object values with bracket notation', () => { + const result = toHttpParams({ user: { name: 'Ada' } }) + + expect(result.toString()).toEqual('user%5Bname%5D=Ada') + }) + + it('should encode multiple nesting levels with bracket notation', () => { + const result = toHttpParams({ filter: { range: { from: 1 } } }) + + expect(result.toString()).toEqual('filter%5Brange%5D%5Bfrom%5D=1') + }) + + it('should append arrays as repeated keys', () => { + const result = toHttpParams({ tags: ['a', 'b'] }) + + expect(result.toString()).toEqual('tags=a&tags=b') + }) + + it('should append nested arrays as repeated bracket keys', () => { + const result = toHttpParams({ filter: { ids: [1, 2] } }) + + expect(result.toString()).toEqual('filter%5Bids%5D=1&filter%5Bids%5D=2') + }) + + it('should omit null and undefined values', () => { + const result = toHttpParams({ foo: 'bar', empty: null, missing: undefined, nested: { skipped: null } }) + + expect(result.toString()).toEqual('foo=bar') + }) + + it('should not add params for empty objects', () => { + const result = toHttpParams({ filter: {} }) + + expect(result.toString()).toEqual('') + }) +}) + +describe('paramsToHttpParams', () => { + interface SearchParamsEntity { + search: string + includeArchived: boolean + selectedIds: number[] + } + + interface SearchParamsDto { + q: string + include_archived: boolean + selected_ids: number[] + } + + const mapSearchParamsEntityToDto = (entity: SearchParamsEntity): SearchParamsDto => ({ + q: entity.search, + include_archived: entity.includeArchived, + selected_ids: entity.selectedIds + }) + + it('should map the params entity to dto params before creating HttpParams', () => { + const queryParams = paramsToHttpParams( + () => ({ + search: 'rice', + includeArchived: false, + selectedIds: [3, 5] + }), + mapSearchParamsEntityToDto + ) + + expect(queryParams().toString()).toEqual('q=rice&include_archived=false&selected_ids=3&selected_ids=5') + }) + + it('should evaluate params lazily on each query params call', () => { + let search = 'rice' + const queryParams = paramsToHttpParams( + () => ({ + search, + includeArchived: true, + selectedIds: [] + }), + mapSearchParamsEntityToDto + ) + + const firstResult = queryParams() + search = 'pasta' + const secondResult = queryParams() + + expect(firstResult.toString()).toEqual('q=rice&include_archived=true') + expect(secondResult.toString()).toEqual('q=pasta&include_archived=true') + }) + + it('should not call the mapper when params are undefined', () => { + let mapperCalls = 0 + const queryParams = paramsToHttpParams( + () => undefined, + (entity: SearchParamsEntity): SearchParamsDto => { + mapperCalls++ + return mapSearchParamsEntityToDto(entity) + } + ) + + expect(queryParams().toString()).toEqual('') + expect(mapperCalls).toBe(0) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/utils/to-http-params.ts b/projects/ppwcode/ng-resource/src/lib/utils/to-http-params.ts new file mode 100644 index 00000000..cd520266 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/utils/to-http-params.ts @@ -0,0 +1,94 @@ +import { HttpParams } from '@angular/common/http' + +type HttpParamValue = string | number | boolean + +/** Narrows values to the primitive types Angular accepts for HttpParams. */ +const isHttpParamValue = (value: unknown): value is HttpParamValue => + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + +/** Treats only plain object-like values as nested params; arrays use repeated keys instead. */ +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** Uses bracket notation because the backend expects form-style nested parameter names. */ +const toNestedKey = (parentKey: string, key: string): string => `${parentKey}[${key}]` + +/** Recursively flattens supported values while omitting nullish and unsupported values. */ +const appendValue = (httpParams: HttpParams, key: string, value: unknown): HttpParams => { + if (value === null || value === undefined) { + return httpParams + } + + if (Array.isArray(value)) { + return value.reduce((params: HttpParams, item: unknown) => appendValue(params, key, item), httpParams) + } + + if (isRecord(value)) { + return Object.entries(value).reduce( + (params: HttpParams, [nestedKey, nestedValue]: [string, unknown]) => + appendValue(params, toNestedKey(key, nestedKey), nestedValue), + httpParams + ) + } + + if (isHttpParamValue(value)) { + return httpParams.append(key, value) + } + + return httpParams +} + +/** + * Converts a plain object to Angular HttpParams. + * + * Nested objects are flattened with bracket notation, arrays are appended as + * repeated keys, and null or undefined values are skipped. + */ +export const toHttpParams = (record?: object): HttpParams => { + record ??= {} + return Object.entries(record).reduce( + (httpParams: HttpParams, [key, value]: [string, unknown]) => appendValue(httpParams, key, value), + new HttpParams() + ) +} + +/** + * Creates a query-params function for resource request options. + * + * Use this helper when the service receives application-facing params entities + * that must be mapped to backend-facing params DTOs before conversion to + * Angular HttpParams. The returned function keeps the params and mapper lazy, so + * resources can re-evaluate signal-backed params when Angular rebuilds the + * request. + * + * When the params function returns `undefined`, the mapper is not called and the + * resulting query-params function returns empty HttpParams. + * + * @param params Function that returns the current application-facing params entity. + * @param mapper Pure mapper that converts the params entity to the backend DTO shape. + * @returns Function that can be assigned directly to `requestOptions.queryParams`. + * + * @example + * ```ts + * public getDishes( + * params: () => MealplanParamsEntity | undefined + * ): HttpResourceRef { + * return getResource({ + * url: () => (params() ? '/api/mealplan/dish' : undefined), + * requestOptions: { + * queryParams: paramsToHttpParams(params, mealplanParamsEntityToDto) + * }, + * responseMapper: mealplanDishResponseDtoToEntity + * }) + * } + * ``` + */ +export const paramsToHttpParams = + ( + params: () => TParamsEntity | undefined, + mapper: (entity: TParamsEntity) => TParamsDto + ): (() => HttpParams) => + () => { + const paramsEntity = params() + return toHttpParams(paramsEntity ? mapper(paramsEntity) : undefined) + } diff --git a/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-execution.spec.ts b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-execution.spec.ts new file mode 100644 index 00000000..873ea9d6 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-execution.spec.ts @@ -0,0 +1,465 @@ +import { HttpErrorResponse } from '@angular/common/http' +import { ResourceSnapshot, resourceFromSnapshots, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { PPW_RESOURCE_ERROR_EXTRACTOR } from '../error-handling/extractor' +import { PpwResourceExecution, PpwResourceExecutionOptions } from './ppw-resource-execution' +import { PpwResourceSource } from './ppw-resource-source' + +type TestResult = string | undefined + +interface FakeHttpResource { + resource: PpwResourceSource + reload: ReturnType boolean>> + snapshot: ReturnType>> +} + +const createFakeHttpResource = (initial: ResourceSnapshot): FakeHttpResource => { + const snapshot = signal(initial) + const reload = vi.fn(() => true) + const resource = Object.assign(resourceFromSnapshots(snapshot), { reload }) + + return { resource, reload, snapshot } +} + +const idleSnapshot = (): ResourceSnapshot => ({ status: 'idle', value: undefined }) +const loadingSnapshot = (value?: string): ResourceSnapshot => ({ status: 'loading', value }) +const resolvedSnapshot = (value: string): ResourceSnapshot => ({ status: 'resolved', value }) +const errorSnapshot = (error: Error): ResourceSnapshot => ({ status: 'error', error }) + +describe('PpwResourceExecution state', () => { + let errorExtractor: ReturnType Error>> + + beforeEach(() => { + vi.useFakeTimers() + errorExtractor = vi.fn((error: HttpErrorResponse) => new Error(`Extracted ${error.status}`)) + + TestBed.configureTestingModule({ + providers: [{ provide: PPW_RESOURCE_ERROR_EXTRACTOR, useValue: errorExtractor }] + }) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + TestBed.resetTestingModule() + }) + + const createExecution = ( + resource: PpwResourceSource, + options?: Partial> + ): PpwResourceExecution => + TestBed.runInInjectionContext( + () => + new PpwResourceExecution(resource, { + isTrackingBody: false, + ...options + }) + ) + + it('should expose whether the execution is tracking the body', () => { + const { resource } = createFakeHttpResource(idleSnapshot()) + + const execution = createExecution(resource, { isTrackingBody: true }) + + expect(execution.isTrackingBody).toBe(true) + }) + + it('should reload the underlying http resource', () => { + const { resource, reload } = createFakeHttpResource(idleSnapshot()) + const execution = createExecution(resource) + + execution.reload() + + expect(reload).toHaveBeenCalledOnce() + }) + + it('should expose the wrapped resource status, loading state, resolved state, value and error', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const execution = createExecution(resource) + + expect(execution.status()).toBe('idle') + expect(execution.isLoading()).toBe(false) + expect(execution.isResolved()).toBe(false) + expect(execution.value()).toBeUndefined() + expect(execution.error()).toBeUndefined() + + snapshot.set(loadingSnapshot()) + TestBed.tick() + + expect(execution.status()).toBe('loading') + expect(execution.isLoading()).toBe(true) + expect(execution.isResolved()).toBe(false) + expect(execution.value()).toBeUndefined() + + snapshot.set(resolvedSnapshot('result')) + TestBed.tick() + + expect(execution.status()).toBe('resolved') + expect(execution.isLoading()).toBe(false) + expect(execution.isResolved()).toBe(true) + expect(execution.value()).toBe('result') + + const error = new Error('Failed') + snapshot.set(errorSnapshot(error)) + TestBed.tick() + + expect(execution.status()).toBe('error') + expect(execution.isResolved()).toBe(false) + expect(execution.error()).toBe(error) + }) + + it('should allow the exposed value to be manually overwritten', () => { + const { resource, snapshot } = createFakeHttpResource(resolvedSnapshot('resource value')) + const execution = createExecution(resource) + TestBed.tick() + + execution.value.set('manual value') + + expect(execution.value()).toBe('manual value') + + snapshot.set(resolvedSnapshot('next resource value')) + TestBed.tick() + + expect(execution.value()).toBe('next resource value') + }) + + it('should call success callbacks in resource-success, resource-finally, execution-success, execution-finally order', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const calls: Array = [] + createExecution(resource, { + resourceOnSuccess: (value) => calls.push(`resource success ${value}`), + resourceOnFinally: () => calls.push('resource finally'), + onSuccess: (value) => calls.push(`execution success ${value}`), + onFinally: () => calls.push('execution finally') + }) + + snapshot.set(resolvedSnapshot('result')) + TestBed.tick() + + expect(calls).toEqual([]) + + vi.runOnlyPendingTimers() + + expect(calls).toEqual([ + 'resource success result', + 'resource finally', + 'execution success result', + 'execution finally' + ]) + }) + + it('should call error callbacks in resource-error, resource-finally, execution-error, execution-finally order', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const error = new Error('Failed') + const calls: Array = [] + createExecution(resource, { + resourceOnError: (value) => calls.push(`resource error ${value.message}`), + resourceOnFinally: () => calls.push('resource finally'), + onError: (value) => calls.push(`execution error ${value.message}`), + onFinally: () => calls.push('execution finally') + }) + + snapshot.set(errorSnapshot(error)) + TestBed.tick() + + expect(calls).toEqual([]) + + vi.runOnlyPendingTimers() + + expect(calls).toEqual([ + 'resource error Failed', + 'resource finally', + 'execution error Failed', + 'execution finally' + ]) + }) + + it('should not call lifecycle callbacks while status is idle or loading', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const onSuccess = vi.fn<(value: Exclude) => void>() + const onError = vi.fn<(error: Error) => void>() + const onFinally = vi.fn<() => void>() + createExecution(resource, { onSuccess, onError, onFinally }) + + TestBed.tick() + vi.runOnlyPendingTimers() + + snapshot.set(loadingSnapshot()) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expect(onFinally).not.toHaveBeenCalled() + }) + + it('should not fail when optional lifecycle callbacks are omitted', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + createExecution(resource) + + snapshot.set(resolvedSnapshot('result')) + TestBed.tick() + + expect(() => vi.runOnlyPendingTimers()).not.toThrow() + }) + + it('should not rerun success callbacks when only the exposed value changes', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const onSuccess = vi.fn<(value: Exclude) => void>() + const execution = createExecution(resource, { onSuccess }) + + snapshot.set(resolvedSnapshot('result')) + TestBed.tick() + vi.runOnlyPendingTimers() + + execution.value.set('manual value') + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith('result') + }) + + it('should not rerun error callbacks when only the error object changes without a status change', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const onError = vi.fn<(error: Error) => void>() + createExecution(resource, { onError }) + + snapshot.set(errorSnapshot(new Error('First'))) + TestBed.tick() + vi.runOnlyPendingTimers() + + snapshot.set(errorSnapshot(new Error('Second'))) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'First' })) + }) + + it('should destroy the status effect after the first terminal state for non-tracking executions', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const onSuccess = vi.fn<(value: Exclude) => void>() + createExecution(resource, { isTrackingBody: false, onSuccess }) + + snapshot.set(resolvedSnapshot('first')) + TestBed.tick() + vi.runOnlyPendingTimers() + + snapshot.set(loadingSnapshot()) + TestBed.tick() + snapshot.set(resolvedSnapshot('second')) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith('first') + }) + + it('should keep the status effect alive across repeated terminal states for tracking executions', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const onSuccess = vi.fn<(value: Exclude) => void>() + createExecution(resource, { isTrackingBody: true, onSuccess }) + + snapshot.set(resolvedSnapshot('first')) + TestBed.tick() + vi.runOnlyPendingTimers() + + snapshot.set(loadingSnapshot()) + TestBed.tick() + snapshot.set(resolvedSnapshot('second')) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onSuccess).toHaveBeenCalledTimes(2) + expect(onSuccess).toHaveBeenNthCalledWith(1, 'first') + expect(onSuccess).toHaveBeenNthCalledWith(2, 'second') + }) + + it('should convert an HttpErrorResponse through PPW_RESOURCE_ERROR_EXTRACTOR before exposing the error', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const extractedError = new Error('Extracted error') + errorExtractor.mockReturnValue(extractedError) + const execution = createExecution(resource) + const httpError = new HttpErrorResponse({ status: 422, statusText: 'Unprocessable Entity' }) + + TestBed.tick() + snapshot.set(errorSnapshot(httpError)) + TestBed.tick() + + expect(errorExtractor).toHaveBeenCalledWith(httpError) + expect(execution.error()).toBe(extractedError) + }) + + it('should pass the extracted error to resource-level and execution-level error handlers', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const extractedError = new Error('Extracted error') + errorExtractor.mockReturnValue(extractedError) + const resourceOnError = vi.fn<(error: Error) => void>() + const onError = vi.fn<(error: Error) => void>() + createExecution(resource, { resourceOnError, onError }) + + TestBed.tick() + snapshot.set(errorSnapshot(new HttpErrorResponse({ status: 500 }))) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(resourceOnError).toHaveBeenCalledWith(extractedError) + expect(onError).toHaveBeenCalledWith(extractedError) + }) + + it('should leave non-HttpErrorResponse errors unchanged', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const error = new Error('Domain error') + const execution = createExecution(resource) + + snapshot.set(errorSnapshot(error)) + TestBed.tick() + + expect(errorExtractor).not.toHaveBeenCalled() + expect(execution.error()).toBe(error) + }) + + it('should not re-extract an error when the previous snapshot was already an error', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const execution = createExecution(resource) + const firstHttpError = new HttpErrorResponse({ status: 400 }) + const secondHttpError = new HttpErrorResponse({ status: 401 }) + const extractedError = new Error('Extracted once') + errorExtractor.mockReturnValue(extractedError) + + TestBed.tick() + snapshot.set(errorSnapshot(firstHttpError)) + TestBed.tick() + + expect(execution.error()).toBe(extractedError) + + snapshot.set(errorSnapshot(secondHttpError)) + TestBed.tick() + + expect(errorExtractor).toHaveBeenCalledTimes(1) + expect(execution.error()).toBe(secondHttpError) + }) + + describe('executeTogether', () => { + it('should create executions lazily and start the group only when a factory returns an execution', () => { + const { resource } = createFakeHttpResource(idleSnapshot()) + const calls: Array = [] + const executionFactory = vi.fn(() => { + calls.push('create first execution') + + return createExecution(resource) + }) + const secondExecutionFactory = vi.fn(() => { + calls.push('create second execution') + + return createExecution(createFakeHttpResource(idleSnapshot()).resource) + }) + const skippedFactory = vi.fn(() => undefined) + const onStart = vi.fn(() => calls.push('start group')) + + expect(executionFactory).not.toHaveBeenCalled() + + PpwResourceExecution.executeTogether([skippedFactory, executionFactory, secondExecutionFactory], { + onStart + }) + + expect(skippedFactory).toHaveBeenCalledOnce() + expect(executionFactory).toHaveBeenCalledOnce() + expect(secondExecutionFactory).toHaveBeenCalledOnce() + expect(onStart).toHaveBeenCalledOnce() + expect(calls).toEqual(['create first execution', 'start group', 'create second execution']) + }) + + it('should not start a group when every factory is skipped', () => { + const onStart = vi.fn() + const onAllSuccess = vi.fn() + const onAnyError = vi.fn() + + PpwResourceExecution.executeTogether([() => undefined], { + onStart, + onAllSuccess, + onAnyError + }) + + expect(onStart).not.toHaveBeenCalled() + expect(onAllSuccess).not.toHaveBeenCalled() + expect(onAnyError).not.toHaveBeenCalled() + }) + + it('should report success only after every started execution succeeds', () => { + const firstResource = createFakeHttpResource(idleSnapshot()) + const secondResource = createFakeHttpResource(idleSnapshot()) + const onAllSuccess = vi.fn() + const onAnyError = vi.fn() + + PpwResourceExecution.executeTogether( + [() => createExecution(firstResource.resource), () => createExecution(secondResource.resource)], + { onAllSuccess, onAnyError } + ) + + firstResource.snapshot.set(resolvedSnapshot('first')) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onAllSuccess).not.toHaveBeenCalled() + expect(onAnyError).not.toHaveBeenCalled() + + secondResource.snapshot.set(resolvedSnapshot('second')) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onAllSuccess).toHaveBeenCalledOnce() + expect(onAnyError).not.toHaveBeenCalled() + }) + + it('should wait for every started execution before reporting an error', () => { + const firstResource = createFakeHttpResource(idleSnapshot()) + const secondResource = createFakeHttpResource(idleSnapshot()) + const onAllSuccess = vi.fn() + const onAnyError = vi.fn() + + PpwResourceExecution.executeTogether( + [() => createExecution(firstResource.resource), () => createExecution(secondResource.resource)], + { onAllSuccess, onAnyError } + ) + + firstResource.snapshot.set(errorSnapshot(new Error('Failed'))) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onAllSuccess).not.toHaveBeenCalled() + expect(onAnyError).not.toHaveBeenCalled() + + secondResource.snapshot.set(resolvedSnapshot('second')) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onAllSuccess).not.toHaveBeenCalled() + expect(onAnyError).toHaveBeenCalledOnce() + }) + + it('should treat a business failure reported by a success handler as an aggregate error', () => { + const { resource, snapshot } = createFakeHttpResource(idleSnapshot()) + const onAllSuccess = vi.fn() + const onAnyError = vi.fn() + + PpwResourceExecution.executeTogether( + [ + ({ treatAsFailure }) => + createExecution(resource, { + onSuccess: () => treatAsFailure() + }) + ], + { onAllSuccess, onAnyError } + ) + + snapshot.set(resolvedSnapshot('incomplete result')) + TestBed.tick() + vi.runOnlyPendingTimers() + + expect(onAllSuccess).not.toHaveBeenCalled() + expect(onAnyError).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-execution.ts b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-execution.ts new file mode 100644 index 00000000..dc4f7adb --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-execution.ts @@ -0,0 +1,346 @@ +import { HttpErrorResponse } from '@angular/common/http' +import { + computed, + effect, + EffectRef, + inject, + Injector, + linkedSignal, + Resource, + resourceFromSnapshots, + ResourceSnapshot, + runInInjectionContext, + untracked +} from '@angular/core' +import { notUndefined } from '@ppwcode/ng-utils' +import { PPW_RESOURCE_ERROR_EXTRACTOR } from '../error-handling/extractor' +import { + PpwResourceErrorHandler, + PpwResourceFinallyHandler, + PpwResourceSuccessHandler +} from '../error-handling/handler' +import { PpwResourceSource } from './ppw-resource-source' + +export interface PpwResourceExecutionHandling { + onSuccess?: PpwResourceSuccessHandler + onError?: PpwResourceErrorHandler + onFinally?: PpwResourceFinallyHandler +} + +/** + * Internal options used to configure a single execution of a PpwResource. + * + * PpwResourceExecutionHandling contains the hooks supplied by the consumer that starts an execution. The extra + * resourceOn... hooks are the hooks configured on the PpwResource definition itself. Keeping both sets of hooks here + * lets the execution be the one place that owns callback ordering: + * 1. PpwResource onSuccess or onError + * 2. PpwResource onFinally + * 3. PpwResourceExecution onSuccess or onError + * 4. PpwResourceExecution onFinally + * + * isTrackingBody distinguishes executions created by PpwResource.track from executions created by PpwResource.execute. + * Tracking executions keep listening to the underlying resource because the request body can change and trigger new + * resource states. Non-tracking executions are one-shot operations, so their effect can be destroyed after the first + * terminal state. + */ +export interface PpwResourceExecutionOptions extends PpwResourceExecutionHandling { + isTrackingBody: boolean + resourceOnSuccess?: PpwResourceSuccessHandler + resourceOnError?: PpwResourceErrorHandler + resourceOnFinally?: PpwResourceFinallyHandler +} + +/** + * A PpwResourceExecution represents one concrete run of a PpwResource. Where PpwResource is the reusable API + * definition, PpwResourceExecution is the stateful object that exposes the result, loading state, error state and + * lifecycle callbacks for a single reloadable Resource instance. + * + * This wrapper intentionally does a bit more than pass through Angular's ResourceRef: + * - It exposes a writable linked value so feature code can optimistically or manually adjust the current result. + * - It keeps the previous resolved value visible while a tracked resource reloads. + * - It converts HttpErrorResponse instances to the application error shape before exposing them. + * - It centralizes callback execution so resource-level hooks always run before execution-level hooks. + * - It tears down one-shot executions after success or failure, while tracked executions remain active for later body + * changes and reloads. + * + * The class is intended to be created by PpwResource rather than directly by feature code. PpwResource creates each + * execution inside the right injection context and supplies the shared resource hooks and default error handling. + */ +export class PpwResourceExecution { + // Keep the injector from the construction context so status handling can enter the same Angular context later. + // The execution can outlive the method that started it, but its effects and handlers still belong to this Angular + // object graph. + readonly #injector = inject(Injector) + + // Reference to the underlying reloadable Resource. This retains the reload API that would otherwise get lost when + // using the base Resource contract. + readonly #sourceResource: PpwResourceSource + + // The wrapped resource is typed as the generic Resource interface because withPreviousValue returns a derived + // Resource created from snapshots. Consumers should not need ResourceRef mutation APIs here; execution exposes only + // the state that is meaningful to the wrapper. + readonly #resourceRef: Resource + + // Store the complete lifecycle configuration for this execution. This includes both resource-level hooks and + // execution-level hooks so #handleStatusChanges can enforce a single callback order. + readonly #options: PpwResourceExecutionOptions + + // One-shot observers registered by executeTogether. They run after the regular resource and execution callbacks, + // allowing an execution-level onSuccess callback to classify an incomplete business result before the group + // determines its aggregate outcome. + readonly #completionHandlers = new Set<(hasFailed: boolean) => void>() + + /** + * Indicates whether this execution should keep reacting to body changes. + * + * Tracking executions are read-style flows such as "reload when the route id changes". Non-tracking executions are + * write-style flows such as "submit this form once". The distinction controls whether the status effect is kept + * alive after the first terminal state. + */ + public get isTrackingBody(): boolean { + return this.#options.isTrackingBody + } + + /** + * Creates a PpwResourceExecution around one reloadable Angular Resource. + * + * The resource is wrapped immediately, so all public state reads go through the same semantics: + * previous values survive reloads and HttpErrorResponse instances are converted through the configured extractor. + * Status handling starts during construction because Angular effects must be created while an injection context is + * available. + */ + public constructor(resource: PpwResourceSource, options: PpwResourceExecutionOptions) { + this.#sourceResource = resource + this.#resourceRef = withPreviousValue(this.#sourceResource) + this.#options = options + + this.#handleStatusChanges() + } + + /** + * The value returned by the loader. + * + * This is a linkedSignal instead of a computed signal on purpose. Consumers can set the value manually, for example + * to apply an optimistic update or to adapt the result after a successful child operation. Whenever the underlying + * resource resolves again, the linkedSignal computation runs again and the resource value becomes authoritative. + */ + public readonly value = linkedSignal(() => this.#resourceRef.value()) + + /** Whether the underlying resource is currently loading. */ + public readonly isLoading = computed(() => this.#resourceRef.isLoading()) + + /** + * Gets the error of loading the resource. + * + * When the original resource failed with an HttpErrorResponse, this value has already been converted by + * withPreviousValue through PPW_RESOURCE_ERROR_EXTRACTOR. + */ + public readonly error = computed(() => this.#resourceRef.error()) + + /** Gets the status of the resource after the wrapper snapshot semantics have been applied. */ + public readonly status = computed(() => this.#resourceRef.status()) + + /** Returns true if the resource has status resolved. */ + public readonly isResolved = computed(() => this.status() === 'resolved') + + /** Reloads the underlying resource. Executes the last call again. */ + public reload(): void { + this.#sourceResource.reload() + } + + /** + * Registers a one-shot observer for the terminal result of this execution. + * + * Completion observers run after the configured lifecycle callbacks. This allows an onSuccess callback to classify + * a technically successful response as a business failure before an execution group determines its final result. + */ + public onComplete(handler: (hasFailed: boolean) => void): void { + this.#completionHandlers.add(handler) + } + + /** + * Starts a set of optional executions and reports their aggregate result. + * + * Factories are invoked inside this method so no request starts before the group lifecycle begins. A factory can + * return undefined when its state slice has no changes. The group completes only after every started execution has + * reached a terminal state. + */ + public static executeTogether( + factories: ReadonlyArray< + (context: { treatAsFailure: () => void }) => + | { + onComplete: (handler: (hasFailed: boolean) => void) => void + } + | undefined + >, + handling: { + onStart?: () => void + onAllSuccess?: () => void + onAnyError?: () => void + } = {} + ): void { + let executionCount = 0 + const completionResults: Array = [] + + for (const factory of factories) { + let isTreatedAsFailure = false + const execution = factory({ + treatAsFailure: () => { + isTreatedAsFailure = true + } + }) + + if (!execution) { + continue + } + + executionCount += 1 + if (executionCount === 1) { + handling.onStart?.() + } + execution.onComplete((hasFailed) => { + completionResults.push(hasFailed || isTreatedAsFailure) + + if (completionResults.length !== executionCount) { + return + } + + if (completionResults.some((executionFailed) => executionFailed)) { + handling.onAnyError?.() + } else { + handling.onAllSuccess?.() + } + }) + } + } + + /** + * Handles status changes and executes appropriate callbacks. + * + * The effect tracks only the resource status. Values and errors are read with untracked, so a manual value change, + * error-object change, or other state read does not re-run the callback pipeline. Once the resource reaches a + * terminal state, callbacks are deferred with setTimeout to avoid the Angular issue linked below and to keep + * callback side effects outside the current reactive evaluation. + * + * For one-shot executions the effect is destroyed after success or failure. For tracking executions it remains + * alive, allowing later request-body changes to drive the resource through loading, resolved and error states again. + */ + #handleStatusChanges(): void { + const statusChangeEffect = effect(() => { + const status = this.status() + runInInjectionContext(this.#injector, () => { + switch (status) { + case 'resolved': { + // We only want to rely on the status change for the effect to run, not the value change. + const value = untracked(() => notUndefined(this.value())) as Exclude + + // Because of an issue in angular, we need to use a setTimeout. + // https://github.com/angular/angular/issues/62822#issuecomment-3127178466 + setTimeout(() => { + runInInjectionContext(this.#injector, () => { + this.#options.resourceOnSuccess?.(value) + this.#options.resourceOnFinally?.() + this.#options.onSuccess?.(value) + this.#options.onFinally?.() + }) + this.#notifyCompletion(false) + this.#destroyEffectWhenNotTrackingBody(statusChangeEffect) + }) + break + } + case 'error': { + // We only want to rely on the status change for the effect to run, not the error change. + const error = untracked(() => notUndefined(this.error())) + + // Because of an issue in angular, we need to use a setTimeout. + // https://github.com/angular/angular/issues/62822#issuecomment-3127178466 + setTimeout(() => { + runInInjectionContext(this.#injector, () => { + this.#options.resourceOnError?.(error) + this.#options.resourceOnFinally?.() + this.#options.onError?.(error) + this.#options.onFinally?.() + }) + this.#notifyCompletion(true) + this.#destroyEffectWhenNotTrackingBody(statusChangeEffect) + }) + break + } + default: + break + } + }) + }) + } + + /** + * When we are not tracking the body, we can already destroy the effect to save browser resources. A body that is + * not tracked means that the resource execution is only run once. + * @param effect The effect to destroy. + */ + #destroyEffectWhenNotTrackingBody(effect: EffectRef): void { + if (!this.isTrackingBody) { + effect.destroy() + } + } + + /** + * Notifies the observers waiting for this execution's terminal result. + * + * Completion handlers are cleared after notification because execute creates one-shot executions. This also + * prevents an execution from accidentally contributing to an aggregate result more than once. + * @param hasFailed Whether the execution ended in an error state. + */ + #notifyCompletion(hasFailed: boolean): void { + for (const handler of this.#completionHandlers) { + handler(hasFailed) + } + this.#completionHandlers.clear() + } +} + +/** + * Wraps a resource so it behaves better for feature screens than the raw ResourceRef. + * + * The main intention is to prevent reload flicker for tracked resources. When a previously resolved resource enters + * loading again, the derived snapshot keeps the previous value while still reporting status loading. This lets a screen + * show the current data and a loading indicator at the same time instead of dropping back to an empty value. + * + * The helper also normalizes HttpErrorResponse errors into the application-level error shape. This keeps callers from + * needing to know whether an error came from Angular HTTP internals or from the ppw resource wrapper. + * + * Error snapshots are not used as a previous value source. After an error, the next loading state is forwarded as-is so + * stale failed data does not accidentally get kept alive through a retry. + */ +function withPreviousValue(input: Resource): Resource { + const errorExtractor = inject(PPW_RESOURCE_ERROR_EXTRACTOR) + + const derived = linkedSignal, ResourceSnapshot>({ + source: input.snapshot, + computation: (snap, previous) => { + if (snap.status === 'loading' && previous && previous.value.status !== 'error') { + // When the input resource enters loading state, we keep the value + // from its previous state, if any. + return { status: 'loading' as const, value: previous.value.value } + } + + if ( + snap.status === 'error' && + previous && + previous.value.status !== 'error' && + 'error' in snap && + snap.error instanceof HttpErrorResponse + ) { + // When the input resource enters the error state, we extract the error from the HttpErrorResponse. + // Note that this doesn't keep the previous value. + const error = errorExtractor(snap.error) + return { ...snap, error } + } + + // Otherwise we simply forward the state of the input resource. + return { ...snap } + } + }) + + return resourceFromSnapshots(derived) +} diff --git a/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-source.ts b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-source.ts new file mode 100644 index 00000000..2ed8dd61 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource-source.ts @@ -0,0 +1,12 @@ +import { Resource, ResourceRef } from '@angular/core' + +/** + * Represents a type alias `PpwResourceSource` that combines the properties + * and behavior of a `Resource` with the `reload` method from `ResourceRef`. + * + * This is primarily used for defining resources that can be reloaded in + * addition to the main functionalities provided by the `Resource`. + * + * @template TResult The type of the resource content managed by this source. + */ +export type PpwResourceSource = Resource & Pick, 'reload'> diff --git a/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource.spec.ts b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource.spec.ts new file mode 100644 index 00000000..06844439 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource.spec.ts @@ -0,0 +1,370 @@ +import { HttpErrorResponse } from '@angular/common/http' +import { InjectionToken, ResourceSnapshot, inject, resourceFromSnapshots, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { PPW_RESOURCE_ERROR_EXTRACTOR } from '../error-handling/extractor' +import { PPW_RESOURCE_DEFAULT_ERROR_HANDLER } from '../error-handling/handler' +import { PpwResource } from './ppw-resource' +import { PpwResourceSource } from './ppw-resource-source' + +type TestBody = { id: number } +type TestResult = string | undefined + +interface FakeHttpResource { + resource: PpwResourceSource + snapshot: ReturnType>> +} + +const createFakeHttpResource = (initial: ResourceSnapshot): FakeHttpResource => { + const snapshot = signal(initial) + const resource = Object.assign(resourceFromSnapshots(snapshot), { reload: () => true }) + + return { resource, snapshot } +} + +const idleSnapshot = (): ResourceSnapshot => ({ status: 'idle', value: undefined }) +const loadingSnapshot = (): ResourceSnapshot => ({ status: 'loading', value: undefined }) +const resolvedSnapshot = (value: string): ResourceSnapshot => ({ status: 'resolved', value }) +const errorSnapshot = (error: Error): ResourceSnapshot => ({ status: 'error', error }) + +describe('PpwResource.fromHttpResource', () => { + let defaultOnError: ReturnType void>> + let controllers: Array> + let bodyFunctions: Array<() => TestBody> + + beforeEach(() => { + vi.useFakeTimers() + defaultOnError = vi.fn() + controllers = [] + bodyFunctions = [] + + TestBed.configureTestingModule({ + providers: [ + { provide: PPW_RESOURCE_DEFAULT_ERROR_HANDLER, useValue: defaultOnError }, + { + provide: PPW_RESOURCE_ERROR_EXTRACTOR, + useValue: (error: HttpErrorResponse): Error => new Error(`Extracted ${error.status}`) + } + ] + }) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + TestBed.resetTestingModule() + }) + + const createResourceFactory = ( + initialSnapshot: () => ResourceSnapshot = idleSnapshot + ): ((body: () => TestBody) => PpwResourceSource) => + vi.fn((body: () => TestBody) => { + bodyFunctions.push(body) + const controller = createFakeHttpResource(initialSnapshot()) + controllers.push(controller) + + return controller.resource + }) + + const flushStatusChange = (): void => { + TestBed.tick() + vi.runOnlyPendingTimers() + } + + it('should create a PpwResource from a resource factory function', () => { + const resourceFactory = createResourceFactory() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + + resource.execute({ id: 1 }) + + expect(resourceFactory).toHaveBeenCalledTimes(1) + }) + + it('should create a PpwResource from an options object', () => { + const resourceFactory = createResourceFactory() + const onSuccess = vi.fn<(value: Exclude) => void>() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource({ + resourceFactory, + onSuccess + }) + ) + + resource.execute({ id: 1 }) + controllers[0].snapshot.set(resolvedSnapshot('result')) + flushStatusChange() + + expect(resourceFactory).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith('result') + }) + + it('should create a new execution for the provided body value', () => { + const resourceFactory = createResourceFactory() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + const body = { id: 1 } + + const execution = resource.execute(body) + + expect(execution.isTrackingBody).toBe(false) + expect(bodyFunctions[0]()).toBe(body) + }) + + it('should pass a stable body function to the resource factory', () => { + const resourceFactory = createResourceFactory() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + let body = { id: 1 } + + resource.execute(body) + body = { id: 2 } + + expect(bodyFunctions[0]()).toEqual({ id: 1 }) + expect(bodyFunctions[0]()).not.toBe(body) + }) + + it('should create a fresh resource for every execute call', () => { + const resourceFactory = createResourceFactory() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + + const firstExecution = resource.execute({ id: 1 }) + const secondExecution = resource.execute({ id: 2 }) + + expect(resourceFactory).toHaveBeenCalledTimes(2) + expect(controllers).toHaveLength(2) + expect(firstExecution).not.toBe(secondExecution) + }) + + it('should run the resource factory inside the captured injection context for execute', () => { + const token = new InjectionToken('test token') + TestBed.resetTestingModule() + TestBed.configureTestingModule({ + providers: [ + { provide: PPW_RESOURCE_DEFAULT_ERROR_HANDLER, useValue: defaultOnError }, + { provide: PPW_RESOURCE_ERROR_EXTRACTOR, useValue: (error: HttpErrorResponse): Error => error }, + { provide: token, useValue: 'from injector' } + ] + }) + const resourceFactory = vi.fn(() => { + expect(inject(token)).toBe('from injector') + const controller = createFakeHttpResource(idleSnapshot()) + controllers.push(controller) + + return controller.resource + }) + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + + resource.execute({ id: 1 }) + + expect(resourceFactory).toHaveBeenCalledTimes(1) + }) + + it('should use the default error handler when no execution error handler is provided', () => { + const resourceFactory = createResourceFactory() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + const error = new Error('Failed') + + resource.execute({ id: 1 }) + controllers[0].snapshot.set(errorSnapshot(error)) + flushStatusChange() + + expect(defaultOnError).toHaveBeenCalledWith(error) + }) + + it('should use the execution error handler instead of the default error handler when provided', () => { + const resourceFactory = createResourceFactory() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + const onError = vi.fn<(error: Error) => void>() + const error = new Error('Failed') + + resource.execute({ id: 1 }, { onError }) + controllers[0].snapshot.set(errorSnapshot(error)) + flushStatusChange() + + expect(onError).toHaveBeenCalledWith(error) + expect(defaultOnError).not.toHaveBeenCalled() + }) + + it('should create a tracking execution for the provided body function', () => { + const resourceFactory = createResourceFactory() + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + const body = signal({ id: 1 }) + + const execution = resource.track(() => body(), {}) + + expect(execution.isTrackingBody).toBe(true) + expect(bodyFunctions[0]()).toEqual({ id: 1 }) + + body.set({ id: 2 }) + + expect(bodyFunctions[0]()).toEqual({ id: 2 }) + }) + + it('should run the resource factory inside the captured injection context for track', () => { + const token = new InjectionToken('test token') + TestBed.resetTestingModule() + TestBed.configureTestingModule({ + providers: [ + { provide: PPW_RESOURCE_DEFAULT_ERROR_HANDLER, useValue: defaultOnError }, + { provide: PPW_RESOURCE_ERROR_EXTRACTOR, useValue: (error: HttpErrorResponse): Error => error }, + { provide: token, useValue: 'from injector' } + ] + }) + const resourceFactory = vi.fn(() => { + expect(inject(token)).toBe('from injector') + const controller = createFakeHttpResource(idleSnapshot()) + controllers.push(controller) + + return controller.resource + }) + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + + resource.track(() => ({ id: 1 }), {}) + + expect(resourceFactory).toHaveBeenCalledTimes(1) + }) + + it('should invoke resource-level success hooks before execution-level success hooks', () => { + const resourceFactory = createResourceFactory() + const calls: Array = [] + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource({ + resourceFactory, + onSuccess: (value) => calls.push(`resource success ${value}`), + onFinally: () => calls.push('resource finally') + }) + ) + + resource.execute( + { id: 1 }, + { + onSuccess: (value) => calls.push(`execution success ${value}`), + onFinally: () => calls.push('execution finally') + } + ) + controllers[0].snapshot.set(resolvedSnapshot('result')) + flushStatusChange() + + expect(calls).toEqual([ + 'resource success result', + 'resource finally', + 'execution success result', + 'execution finally' + ]) + }) + + it('should invoke resource-level error hooks before execution-level error hooks', () => { + const resourceFactory = createResourceFactory() + const calls: Array = [] + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource({ + resourceFactory, + onError: (error) => calls.push(`resource error ${error.message}`), + onFinally: () => calls.push('resource finally') + }) + ) + + resource.execute( + { id: 1 }, + { + onError: (error) => calls.push(`execution error ${error.message}`), + onFinally: () => calls.push('execution finally') + } + ) + controllers[0].snapshot.set(errorSnapshot(new Error('Failed'))) + flushStatusChange() + + expect(calls).toEqual([ + 'resource error Failed', + 'resource finally', + 'execution error Failed', + 'execution finally' + ]) + }) + + it('should call execution finally after removing the execution from active executions', () => { + const resourceFactory = createResourceFactory(loadingSnapshot) + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + let isAnyLoadingDuringFinally: boolean | undefined + + resource.execute( + { id: 1 }, + { + onFinally: () => { + isAnyLoadingDuringFinally = resource.isAnyLoading() + } + } + ) + + expect(resource.isAnyLoading()).toBe(true) + + controllers[0].snapshot.set(resolvedSnapshot('result')) + flushStatusChange() + + expect(isAnyLoadingDuringFinally).toBe(false) + }) + + it('should be false before any execution starts', () => { + const resourceFactory = createResourceFactory(loadingSnapshot) + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + + expect(resource.isAnyLoading()).toBe(false) + }) + + it('should become true when an execution is loading and false after it finishes', () => { + const resourceFactory = createResourceFactory(loadingSnapshot) + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + + resource.execute({ id: 1 }) + + expect(resource.isAnyLoading()).toBe(true) + + controllers[0].snapshot.set(resolvedSnapshot('result')) + flushStatusChange() + + expect(resource.isAnyLoading()).toBe(false) + }) + + it('should stay true while at least one parallel execution is still loading', () => { + const resourceFactory = createResourceFactory(loadingSnapshot) + const resource = TestBed.runInInjectionContext(() => + PpwResource.fromHttpResource(resourceFactory) + ) + + resource.execute({ id: 1 }) + resource.execute({ id: 2 }) + + expect(resource.isAnyLoading()).toBe(true) + + controllers[0].snapshot.set(resolvedSnapshot('first')) + flushStatusChange() + + expect(resource.isAnyLoading()).toBe(true) + + controllers[1].snapshot.set(resolvedSnapshot('second')) + flushStatusChange() + + expect(resource.isAnyLoading()).toBe(false) + }) +}) diff --git a/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource.ts b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource.ts new file mode 100644 index 00000000..8a3524d4 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/lib/wrappers/ppw-resource.ts @@ -0,0 +1,195 @@ +import { Injector, computed, inject, runInInjectionContext, signal } from '@angular/core' +import { + PPW_RESOURCE_DEFAULT_ERROR_HANDLER, + PpwResourceErrorHandler, + PpwResourceFinallyHandler, + PpwResourceSuccessHandler +} from '../error-handling/handler' +import { + PpwResourceExecution, + PpwResourceExecutionHandling, + PpwResourceExecutionOptions +} from './ppw-resource-execution' +import { PpwResourceSource } from './ppw-resource-source' + +/** + * Options for a PpwResource. + */ +export interface PpwResourceOptions { + resourceFactory: (body: () => TBody) => PpwResourceSource + onSuccess?: PpwResourceSuccessHandler + onError?: PpwResourceErrorHandler + onFinally?: PpwResourceFinallyHandler +} + +/** + * A PpwResource encapsulates the creation and execution of HTTP resource references. It provides a way to define + * and manage the lifecycle of HTTP resources, including error handling and finalization. Look at a PpwResource + * as your definition container for interaction with the API. A PpwResource will instantiate a PpwResourceExecution + * when calling .create or .track and passing whatever configuration is necessary to that instance. + * + * Even though Angular documentation discourages using httpResource for mutation calls, we support this use case. + * We have thought this through and considered this to be safe when using the .execute method to start an execution. + * Running .execute twice will create a full new resource instance, meaning that it will be completely side by side + * with the previous one. This avoids meddling with the previous execution and allows for parallel operations. + * + * The .track method supports reactive scenarios where data should be reloaded based on input changes + * (like a changing id in the route). This allows for seamless data updates without the need for manual intervention. + * + * A PpwResource is intented to be created in the business logic layer of the application. It allows for setting three + * hooks: + * - onSuccess: A callback that is invoked when the resource execution is successful. + * - onError: A callback that is invoked when the resource execution fails. + * - onFinally: A callback that is invoked when the resource execution is either successful or fails. + * + * For running PpwResourceExecution instances of PpwResource, the hooks of the PpwResource definition are all executed + * before the hooks of the PpwResourceExecution. This allows for a clean separation of concerns and a logical flow of + * events: + * 1. PpwResource onSuccess or onError + * 2. PpwResource onFinally + * 3. PpwResourceExecution onSuccess or onError + * 4. PpwResourceExecution onFinally + */ +export class PpwResource { + // Injecting the injector requires the container to create the PpwResource instance during construction time, + // just like Angular requires this for running httpResource. This means that we are not imposing an extra complexity + // by this. The injector is used later to run the execution within the same injection context, allowing us to delay + // resource creation and execution at places where we normally wouldn't be able to. An example of such a case is + // the body of a component method. + readonly #injector = inject(Injector) + readonly #defaultOnErrorHandler = inject(PPW_RESOURCE_DEFAULT_ERROR_HANDLER) + + readonly #onSuccess?: PpwResourceSuccessHandler + readonly #onError?: PpwResourceErrorHandler + readonly #onFinally?: PpwResourceFinallyHandler + + // Keep track of all executions that have been started. This allows us to execute batch scenarios in parallel and + // track whether any of them is still executing without having to keep individual executions on the feature component itself. + readonly #executions = signal>>([]) + + // Function used to instantiate the reloadable Resource. This is provided during construction time but invoked in the + // #createExecution method. This allows us to delay resource creation and execution at places where we normally + // wouldn't be able to. + readonly #resourceFactory: (body: () => TBody) => PpwResourceSource + + protected constructor( + options: PpwResourceOptions['resourceFactory'] | PpwResourceOptions + ) { + if (typeof options === 'function') { + this.#resourceFactory = options + } else { + this.#resourceFactory = options.resourceFactory + this.#onSuccess = options?.onSuccess + this.#onError = options?.onError + this.#onFinally = options?.onFinally + } + } + + /** Computed property indicating whether any of the started executions are still loading. */ + public readonly isAnyLoading = computed(() => this.#executions().some((execution) => execution.isLoading())) + + /** + * Executes the resource for the given body function. This allows passing a signal, making it useful for + * scenarios where the body is dynamically changed. Mostly useful for READING data based on selection + * or route data. + * @param body The body to use for the resource execution. + * @param handling The handling options for the resource execution. + * @example + * ```ts + * // route: /my-feature/:id + * export class MyFeatureComponent { + * readonly #myFacade = inject(MyFacade); + * + * protected readonly id = input.required(); + * + * protected readonly myResource = this.#myFacade.getById(this.id).track(); + * } + * ``` + */ + public track( + body: () => TBody = () => undefined as TBody, + handling: PpwResourceExecutionHandling = {} + ): PpwResourceExecution { + return this.#createExecution(body, { isTrackingBody: true }, handling) + } + + /** + * Executes the resource for the given value. This doesn't track any changes to a request body, making it useful + * for scenarios where the request should only run once. Mostly useful for WRITING data. + * @param body The value to use for the resource execution. + * @param handling The handling options for the resource execution. + * @example + * ```ts + * // route: /my-feature/:id + * export class MyFeatureComponent { + * readonly #myFacade = inject(MyFacade); + * + * protected readonly id = input.required(); + * + * protected readonly myResource = this.#myFacade.getById(this.id).track(); + * protected readonly updateResource = this.#myFacade.update(this.id); + * + * protected executeUpdate(): void { + * const formValue = this.updateForm().value(); + * this.updateResource.execute(formValue, { + * onSuccess: () => this.myResource.reload() + * }); + * } + * } + */ + public execute(body: TBody, handling?: PpwResourceExecutionHandling): PpwResourceExecution { + return this.#createExecution(() => body, { isTrackingBody: false }, handling) + } + + #createExecution( + body: () => TBody, + options: PpwResourceExecutionOptions, + handling?: PpwResourceExecutionHandling + ): PpwResourceExecution { + // Instantiate the reloadable Resource and create a new PpwResourceExecution instance for it. + // This is run in the injection context to ensure that any dependencies are properly resolved, allowing + // invocation of the resource execution with the provided body outside construction time. + const execution = runInInjectionContext(this.#injector, () => { + const resourceRef = this.#resourceFactory(body) + return new PpwResourceExecution(resourceRef, { + ...options, + resourceOnError: this.#onError, + resourceOnFinally: this.#onFinally, + resourceOnSuccess: this.#onSuccess, + onSuccess: (value) => handling?.onSuccess?.(value), + onError: (error) => (handling?.onError ?? this.#defaultOnErrorHandler)(error), + onFinally: () => { + this.#removeExecution(execution) + + handling?.onFinally?.() + } + }) + }) + + // Add the current execution to the list of active executions so that the resource itself can keep track of the + // status of any ongoing execution. + this.#executions.update((executions) => [...executions, execution]) + + return execution + } + + /** + * Removes the provided execution from the list of active executions. + * @param execution The execution to remove. + */ + #removeExecution(execution: PpwResourceExecution): void { + this.#executions.update((executions) => executions.filter((e) => e !== execution)) + } + + /** + * Creates a new PpwResource instance from a lambda creating a reloadable Resource. This is useful for creating a + * PpwResource instance from a resource that has already been created elsewhere, including an HttpResourceRef. + * @param options The configuration options for the PpwResource instance. + * @returns A new PpwResource instance. + */ + public static fromHttpResource( + options: PpwResourceOptions['resourceFactory'] | PpwResourceOptions + ): PpwResource { + return new PpwResource(options) + } +} diff --git a/projects/ppwcode/ng-resource/src/public-api.ts b/projects/ppwcode/ng-resource/src/public-api.ts new file mode 100644 index 00000000..0916a6b8 --- /dev/null +++ b/projects/ppwcode/ng-resource/src/public-api.ts @@ -0,0 +1,15 @@ +/* + * Public API Surface of ng-resource + */ + +export { deleteResource } from './lib/api-call-primitives/delete-resource' +export { getResource } from './lib/api-call-primitives/get-resource' +export { patchResource } from './lib/api-call-primitives/patch-resource' +export { postResource } from './lib/api-call-primitives/post-resource' +export { putResource } from './lib/api-call-primitives/put-resource' +export * from './lib/error-handling/extractor' +export * from './lib/error-handling/handler' +export * from './lib/utils/to-http-params' +export * from './lib/wrappers/ppw-resource' +export * from './lib/wrappers/ppw-resource-execution' +export * from './lib/wrappers/ppw-resource-source' diff --git a/projects/ppwcode/ng-resource/tsconfig.lib.json b/projects/ppwcode/ng-resource/tsconfig.lib.json new file mode 100644 index 00000000..878bacf2 --- /dev/null +++ b/projects/ppwcode/ng-resource/tsconfig.lib.json @@ -0,0 +1,13 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../../tsconfig.angular.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ppwcode/ng-resource/tsconfig.lib.prod.json b/projects/ppwcode/ng-resource/tsconfig.lib.prod.json new file mode 100644 index 00000000..e8500081 --- /dev/null +++ b/projects/ppwcode/ng-resource/tsconfig.lib.prod.json @@ -0,0 +1,11 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/projects/ppwcode/ng-resource/tsconfig.spec.json b/projects/ppwcode/ng-resource/tsconfig.spec.json new file mode 100644 index 00000000..14cfb9ad --- /dev/null +++ b/projects/ppwcode/ng-resource/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +} diff --git a/projects/ppwcode/ng-unit-testing/package.json b/projects/ppwcode/ng-unit-testing/package.json index b4f55c96..c0caa563 100644 --- a/projects/ppwcode/ng-unit-testing/package.json +++ b/projects/ppwcode/ng-unit-testing/package.json @@ -13,6 +13,9 @@ "axe-core": "^4.0.0", "jasmine-core": "^3.9.0 || ^4.0.0 || ^5.0.0" }, + "optionalDependencies": { + "@ppwcode/ng-resource": "^0.0.1" + }, "dependencies": { "tslib": "^2.3.0" }, diff --git a/projects/ppwcode/ng-unit-testing/src/lib/resources/facade-mock-factory.spec.ts b/projects/ppwcode/ng-unit-testing/src/lib/resources/facade-mock-factory.spec.ts new file mode 100644 index 00000000..ed533345 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/resources/facade-mock-factory.spec.ts @@ -0,0 +1,111 @@ +import '@angular/compiler' +import { signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { PpwResource } from '@ppwcode/ng-resource' +import { FacadeMockFactory } from './facade-mock-factory' + +interface TestBody { + id: number +} + +interface TestResult { + name: string +} + +class TestFacade { + public readonly title = signal('original') + + public load(): PpwResource { + throw new Error('Not implemented') + } + + public count(): number { + throw new Error('Not implemented') + } + + public reset(): void { + throw new Error('Not implemented') + } + + public hasPermission(permission: string): boolean { + throw new Error(`Not implemented: ${permission}`) + } +} + +class ProviderTestFacade { + public readonly title = signal('original') + + public count(): number { + throw new Error('Not implemented') + } +} + +describe('FacadeMockFactory', () => { + afterEach(() => { + TestBed.resetTestingModule() + }) + + it('should mock facade properties and method return values', () => { + const factory = FacadeMockFactory.create(TestFacade, () => ({ + title: ['signal', 'mocked'], + load: ['resource', { name: 'Jane' }], + count: ['method', 42], + reset: ['method', undefined], + hasPermission: ['method', true] + }))() + + TestBed.configureTestingModule({ + providers: [factory.getProvider()] + }) + + const facade = TestBed.inject(TestFacade) + + expect(facade.title()).toBe('mocked') + expect(facade.count()).toBe(42) + expect(facade.reset()).toBeUndefined() + expect(facade.hasPermission('admin')).toBe(true) + expect(facade.load()).toBeInstanceOf(PpwResource) + }) + + it('should create spy methods', () => { + const factory = FacadeMockFactory.create(TestFacade, () => ({ + title: ['signal', 'mocked'], + load: ['resource', { name: 'Jane' }], + count: ['method', 42], + reset: ['method', undefined], + hasPermission: ['method', true] + }))() + + TestBed.configureTestingModule({ + providers: [factory.getProvider()] + }) + + const facade = TestBed.inject(TestFacade) + + facade.count() + facade.reset() + facade.load() + facade.hasPermission('admin') + + expect(facade.count).toHaveBeenCalledOnce() + expect(facade.reset).toHaveBeenCalledOnce() + expect(facade.load).toHaveBeenCalledOnce() + expect(facade.hasPermission).toHaveBeenCalledOnce() + }) + + it('should provide the created facade through Angular dependency injection', () => { + const factory = FacadeMockFactory.create(ProviderTestFacade, () => ({ + title: ['signal', 'mocked'], + count: ['method', 42] + }))() + + TestBed.configureTestingModule({ + providers: [factory.getProvider()] + }) + + const facade = TestBed.inject(ProviderTestFacade) + + expect(facade.title()).toBe('mocked') + expect(facade.count()).toBe(42) + }) +}) diff --git a/projects/ppwcode/ng-unit-testing/src/lib/resources/facade-mock-factory.ts b/projects/ppwcode/ng-unit-testing/src/lib/resources/facade-mock-factory.ts new file mode 100644 index 00000000..c64fbf95 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/resources/facade-mock-factory.ts @@ -0,0 +1,289 @@ +import { HttpErrorResponse } from '@angular/common/http' +import { ApplicationRef, FactoryProvider, Signal, Type } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { notUndefined } from '@ppwcode/ng-utils' +import { PpwResource, PpwResourceExecutionHandling } from '@ppwcode/ng-resource' +import { PpwResourceMock, PpwResourceMockResult } from './ppw-resource.mock' + +/** Configuration for mocking facade methods returning PpwResource instances. */ +type PpwResourceMethodMockConfig = ['resource', U | HttpErrorResponse] +/** Configuration for mocking facade properties that are signals or computed values. */ +type SignalPropertyMockConfig = ['signal', U] +/** Configuration for mocking facade methods returning a value. */ +type ValueMethodMockConfig = ['method', U] + +/** Configuration for mocking a facade method or signal property. */ +type MethodMockConfig = PpwResourceMethodMockConfig | SignalPropertyMockConfig | ValueMethodMockConfig + +/** Type returning the keys of the given generic that return a PpwResource instance. */ +export type PpwResourceMethodKeys = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in keyof T]: T[K] extends (...args: Array) => PpwResource ? K : never +}[keyof T] + +/** + * Gets whether the given config is for a method returning a PpwResource instance. + * @param config The config to check. + */ +const isPpwResourceMethodMockConfig = (config: unknown): config is PpwResourceMethodMockConfig => + Array.isArray(config) && config[0] === 'resource' + +/** + * Gets whether the given config is for a signal property. + * @param config The config to check. + */ +const isSignalPropertyMockConfig = (config: unknown): config is SignalPropertyMockConfig => + Array.isArray(config) && config[0] === 'signal' + +/** + * Gets whether the given config is for a method returning a value. + * @param config The config to check. + */ +const isValueMethodMockConfig = (config: unknown): config is ValueMethodMockConfig => + Array.isArray(config) && config[0] === 'method' + +// eslint-disable-next-line no-secrets/no-secrets +/** + * Configuration type for mocking a facade object. + * Maps each property of the provided generic type `T` to a corresponding mock configuration type. + * + * The mapping logic is as follows: + * - For properties that are functions returning a `PpwResource` type, the corresponding type is `PpwResourceMethodMockConfig`, where `U` is the inferred type of the resource. + * - For properties that match a computed `Signal` type, the corresponding type is `SignalPropertyMockConfig`, where `V` is the inferred value type of the signal. + * - For methods returning a value (but not a `PpwResource`), the corresponding type is `ValueMethodMockConfig>`, where `ReturnType` is the return type of the method. + * - For all other properties, the corresponding type is `MethodMockConfig`. + * + * This type facilitates the creation of mock configurations tailored to the structure of the provided type `T`. + * + * @template T The facade type for which the configuration is being defined. + */ +type MockFacadeConfig = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in keyof T]: T[K] extends (...args: Array) => PpwResource + ? PpwResourceMethodMockConfig + : T[K] extends Signal // For computed signals + ? SignalPropertyMockConfig + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + T[K] extends (...args: Array) => unknown + ? ValueMethodMockConfig> // For methods returning a value + : MethodMockConfig // For other properties like signals +} + +/** + * Options for creating a mock facade. + */ +export interface MockFacadeOptions { + /** Whether the resources should be automatically flushed when the facade is created. Defaults to true. */ + autoFlush?: boolean +} + +/** + * Generates a PpwResourceMock based on the given resource type and return value. + * @param returnValue The value that should be returned by the resource when it is flushed. + * @param options Options for creating the mock facade. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const getFacadeResourceMock = (returnValue: unknown, options: MockFacadeOptions): PpwResourceMock => { + const resourceMock = PpwResourceMock.create(returnValue) + const execute = resourceMock.resource.execute.bind(resourceMock.resource) + const track = resourceMock.resource.track.bind(resourceMock.resource) + + // Spy on the execute and track method to allow for easy verification of calls. + const executeSpy = vi.spyOn(resourceMock.resource, 'execute') + const trackSpy = vi.spyOn(resourceMock.resource, 'track') + + if (options.autoFlush ?? true) { + executeSpy.mockImplementation( + (body: unknown, handling: PpwResourceExecutionHandling> | undefined) => { + const execution = execute(body, handling) + void resourceMock.flush() + return execution + } + ) + trackSpy.mockImplementation( + ( + body: (() => unknown) | undefined, + handling: PpwResourceExecutionHandling> | undefined + ) => { + const execution = track(body, handling) + void resourceMock.flush() + return execution + } + ) + } + + return resourceMock +} + +/** + * Type mapping method names to their corresponding resource mock instances. + */ +type GeneratedResourceMocks = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [methodName: string]: PpwResourceMock +} + +/** + * Generates a mock facade based on the given configuration. + * @param config The configuration for the mock facade. + * @param options Options for creating the mock facade. + */ +const mockFacade = ( + config: MockFacadeConfig, + options: MockFacadeOptions +): { + facade: TFacade + resourceMocks: GeneratedResourceMocks +} => { + const facade: TFacade = {} as TFacade + + const resourceMocks: GeneratedResourceMocks = {} + + for (const [methodName, methodConfig] of Object.entries(config)) { + let returnValue: unknown + + if (isSignalPropertyMockConfig(methodConfig)) { + const signalValue = methodConfig[1] + Object.defineProperty(facade, methodName, { + value: vi.fn().mockReturnValue(signalValue), + writable: true + }) + continue // Skip the resource mock creation for signals + } + + if (isValueMethodMockConfig(methodConfig)) { + const methodValue = methodConfig[1] + Object.defineProperty(facade, methodName, { + value: vi.fn().mockReturnValue(methodValue), + writable: true + }) + continue // Skip the resource mock creation for methods + } + + if (isPpwResourceMethodMockConfig(methodConfig)) { + returnValue = methodConfig[1] + } else { + throw new Error(`Invalid method mock configuration for ${String(methodName)}`) + } + + const resourceMock = getFacadeResourceMock(returnValue, options) + resourceMocks[methodName] = resourceMock + Object.defineProperty(facade, methodName, { + value: vi.fn().mockReturnValue(resourceMock.resource), + writable: true + }) + } + + return { facade, resourceMocks } +} + +/** + * Factory for creating mock facades with methods returning PpwResource instances. + * The factory provides a provider for the mock facade, as well as methods for accessing + * the mock facade instance and the underlying resource mocks. + */ +export class FacadeMockFactory { + #resourceMockInstances?: GeneratedResourceMocks + #overrideConfig: Partial> = {} + + public constructor( + private readonly type: Type, + private readonly config: () => MockFacadeConfig + ) {} + + /** + * Gets a provider that can be used to provide the mock facade in a testing module. + */ + public getProvider(options: MockFacadeOptions = { autoFlush: true }): FactoryProvider { + return { + provide: this.type, + useFactory: () => { + const { facade, resourceMocks } = mockFacade({ ...this.config(), ...this.#overrideConfig }, options) + // Reset the override config after creating the facade to ensure it doesn't affect further calls. + // Subsequent calls are new tests, so they should start with a clean config. + this.#overrideConfig = {} + this.#resourceMockInstances = resourceMocks + return facade + } + } + } + + /** + * Flushes all resources in the mock facade. + * This will trigger all resources to return their configured values. + * If a resource was configured with an HttpErrorResponse, it will throw that error when flushed. + */ + public async flushAll(): Promise { + const methodNames: Array> = Object.keys( + notUndefined(this.#resourceMockInstances) + ) as Array> + await Promise.all(methodNames.map((methodName) => this.flush(methodName, false))) + + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } else { + await TestBed.inject(ApplicationRef).whenStable() + } + } + + /** + * Flushes a specific resource in the mock facade. + * This will trigger the resource to return its configured value. + * If the resource was configured with an HttpErrorResponse, it will throw that error when flushed. + * @param methodName The name of the method to flush. + * @param awaitWhenStable Whether to await the application being stable after flushing. Defaults to true. + */ + public async flush(methodName: PpwResourceMethodKeys, awaitWhenStable: boolean = true): Promise { + const resourceMock = notUndefined(this.#resourceMockInstances)[methodName as string] + if (!resourceMock) { + throw new Error(`No mock instance found for method: ${String(methodName)}`) + } + + await resourceMock.flush() + + if (awaitWhenStable) { + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } else { + await TestBed.inject(ApplicationRef).whenStable() + } + } + } + + /** + * Overrides the default configuration for the mock facade. + * This allows you to change the return values of specific methods for a specific test. + * @param overrideConfig The configuration to override the default config with. + */ + public override(overrideConfig: Partial>): void { + // Object.assign is used to allow multiple calls to override to accumulate changes. + Object.assign(this.#overrideConfig, overrideConfig) + } + + /** + * Gets the mock instance for a specific PpwResource method. + * @param methodName The name of the method to get the mock for. + */ + public mockedPpwResource>( + methodName: TMethodName + ): PpwResourceMock { + const mockInstance = notUndefined(this.#resourceMockInstances)[methodName as string] + if (!mockInstance) { + throw new Error(`No mock instance found for method: ${String(methodName)}`) + } + return notUndefined(mockInstance) as PpwResourceMock + } + + /** + * Creates a factory function that can be used to provide the mock facade with the given default configuration. + * This is a convenience method that allows you to create a provider without having to create an instance of the factory yourself. + * @param type The type of the facade to mock. + * @param config The default configuration for the mock facade. + */ + public static create( + type: Type, + config: () => MockFacadeConfig + ): () => FacadeMockFactory { + return () => new FacadeMockFactory(type, config) + } +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/resources/ppw-resource.mock.spec.ts b/projects/ppwcode/ng-unit-testing/src/lib/resources/ppw-resource.mock.spec.ts new file mode 100644 index 00000000..cfa05adb --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/resources/ppw-resource.mock.spec.ts @@ -0,0 +1,159 @@ +import '@angular/compiler' +import { HttpErrorResponse } from '@angular/common/http' +import { signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { PpwResource } from '@ppwcode/ng-resource' +import { PpwResourceMock } from './ppw-resource.mock' + +interface TestBody { + id: number +} + +interface TestResult { + name: string +} + +describe('PpwResourceMock', () => { + beforeEach(() => { + TestBed.configureTestingModule({}) + }) + + afterEach(() => { + TestBed.resetTestingModule() + }) + + it('should expose a PpwResource instance', () => { + const mock = PpwResourceMock.create({ name: 'Jane' }) + + expect(mock.resource).toBeInstanceOf(PpwResource) + }) + + it('should capture stable execute body values', () => { + const mock = PpwResourceMock.create({ name: 'Jane' }) + let body = { id: 1 } + + mock.resource.execute(body) + body = { id: 2 } + + expect(mock.executions).toHaveLength(1) + expect(mock.executions[0].isTrackingBody).toBe(false) + expect(mock.executions[0].body()).toEqual({ id: 1 }) + expect(mock.executions[0].body()).not.toBe(body) + }) + + it('should capture tracking body functions', () => { + const mock = PpwResourceMock.create({ name: 'Jane' }) + const body = signal({ id: 1 }) + + mock.resource.track(() => body(), {}) + + expect(mock.executions).toHaveLength(1) + expect(mock.executions[0].isTrackingBody).toBe(true) + expect(mock.executions[0].body()).toEqual({ id: 1 }) + + body.set({ id: 2 }) + + expect(mock.executions[0].body()).toEqual({ id: 2 }) + }) + + it('should create independent executions for every execute call', () => { + const mock = PpwResourceMock.create({ name: 'Jane' }) + + const firstExecution = mock.resource.execute({ id: 1 }) + const secondExecution = mock.resource.execute({ id: 2 }) + + expect(mock.executions).toHaveLength(2) + expect(mock.executions[0].resourceRef).not.toBe(mock.executions[1].resourceRef) + expect(firstExecution).not.toBe(secondExecution) + }) + + it('should count reloads on the fake ResourceRef backing an execution', () => { + const mock = PpwResourceMock.create({ name: 'Jane' }) + const execution = mock.resource.track(() => ({ id: 1 }), {}) + + expect(mock.executions[0].reloadCount).toBe(0) + + execution.reload() + + expect(mock.executions[0].reloadCount).toBe(1) + }) + + it('should toggle isAnyLoading when loading starts and finishes', async () => { + const mock = PpwResourceMock.create({ name: 'Jane' }) + + mock.resource.execute({ id: 1 }) + + expect(mock.resource.isAnyLoading()).toBe(false) + + mock.startLoading() + + expect(mock.resource.isAnyLoading()).toBe(true) + + await mock.flushSuccess() + + expect(mock.resource.isAnyLoading()).toBe(false) + }) + + it('should flush success and run execution handlers', async () => { + const mock = PpwResourceMock.create({ name: 'Jane' }) + const calls: Array = [] + const execution = mock.resource.execute( + { id: 1 }, + { + onSuccess: (value) => calls.push(`success ${value.name}`), + onFinally: () => calls.push('finally') + } + ) + + await mock.flushSuccess() + + expect(execution.value()).toEqual({ name: 'Jane' }) + expect(calls).toEqual(['success Jane', 'finally']) + }) + + it('should flush error and run execution handlers', async () => { + const error = new HttpErrorResponse({ status: 500, statusText: 'Internal Server Error' }) + const mock = PpwResourceMock.create(error) + const calls: Array = [] + const execution = mock.resource.execute( + { id: 1 }, + { + onError: (value) => calls.push(`error ${value.message}`), + onFinally: () => calls.push('finally') + } + ) + + await mock.flush() + + expect(execution.error()).toBe(error) + expect(calls).toEqual([`error ${error.message}`, 'finally']) + }) + + it('should flush parallel executions by index', async () => { + const mock = PpwResourceMock.create() + + mock.resource.execute({ id: 1 }) + mock.resource.execute({ id: 2 }) + mock.startLoading(0) + mock.startLoading(1) + + expect(mock.resource.isAnyLoading()).toBe(true) + + await mock.flushSuccess({ name: 'First' }, 0) + + expect(mock.resource.isAnyLoading()).toBe(true) + + await mock.flushSuccess({ name: 'Second' }, 1) + + expect(mock.resource.isAnyLoading()).toBe(false) + }) + + it('should not expose test-runner-specific APIs on the helper implementation', () => { + const source = PpwResourceMock.toString() + + expect(source).not.toContain('vi.') + expect(source).not.toContain('jasmine') + expect(source).not.toContain('spyOn') + expect(source).not.toContain('karma') + }) +}) diff --git a/projects/ppwcode/ng-unit-testing/src/lib/resources/ppw-resource.mock.ts b/projects/ppwcode/ng-unit-testing/src/lib/resources/ppw-resource.mock.ts new file mode 100644 index 00000000..37bcbd49 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/resources/ppw-resource.mock.ts @@ -0,0 +1,257 @@ +import { HttpErrorResponse } from '@angular/common/http' +import { + Injector, + resourceFromSnapshots, + ResourceSnapshot, + runInInjectionContext, + signal, + WritableSignal +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { + PPW_RESOURCE_DEFAULT_ERROR_HANDLER, + PPW_RESOURCE_ERROR_EXTRACTOR, + PpwResource, + PpwResourceExecution, + PpwResourceExecutionHandling, + PpwResourceSource +} from '@ppwcode/ng-resource' + +export type PpwResourceMockResult = TResult | undefined + +/** + * Metadata for a single PpwResource execution created through a PpwResourceMock. + */ +export interface PpwResourceMockExecution { + /** Function passed to the underlying ResourceRef factory. Execute calls expose a stable value function. */ + body: () => TBody + /** Whether this execution was created through PpwResource.track instead of PpwResource.execute. */ + isTrackingBody: boolean + /** Number of times reload was called on the fake ResourceRef backing this execution. */ + reloadCount: number + /** Fake reloadable Resource backing the real PpwResourceExecution for this call. */ + resourceRef: PpwResourceSource> +} + +interface PpwResourceMockController extends PpwResourceMockExecution { + snapshot: WritableSignal>> +} + +const idleSnapshot = (): ResourceSnapshot> => ({ + status: 'idle', + value: undefined +}) + +const loadingSnapshot = ( + value?: PpwResourceMockResult +): ResourceSnapshot> => ({ + status: 'loading', + value +}) + +const resolvedSnapshot = ( + value: PpwResourceMockResult +): ResourceSnapshot> => ({ + status: 'resolved', + value +}) + +const errorSnapshot = (error: Error): ResourceSnapshot> => ({ + status: 'error', + error +}) + +/** + * Test-library agnostic controller for a PpwResource. + * + * The exposed resource is a real PpwResource backed by signal-driven ResourceRef instances. Tests can return it + * from mocked facades or services, then drive each execution through loading, success, or error states manually. + * + * The helper intentionally avoids Vitest, Jasmine, Karma, or spy APIs. It only depends on Angular's testing runtime + * because the wrapped PpwResource creates Angular effects internally. + * + * @example + * ```ts + * const loginResourceMock = PpwResourceMock.create(loginResponse) + * authFacade.login = () => loginResourceMock.resource + * + * component.submit(loginEntity) + * await loginResourceMock.flushSuccess() + * ``` + */ +export class PpwResourceMock { + readonly #resolveValue?: TResult | HttpErrorResponse + readonly #controllers: Array> = [] + #isCreatingTrackingExecution = false + + /** Real PpwResource instance to return from mocked facades or services. */ + public readonly resource: PpwResource, TBody> + + public constructor(resolveValue?: TResult | HttpErrorResponse) { + this.#resolveValue = resolveValue + + const injector = Injector.create({ + providers: [ + { provide: PPW_RESOURCE_DEFAULT_ERROR_HANDLER, useValue: () => undefined }, + { provide: PPW_RESOURCE_ERROR_EXTRACTOR, useValue: (error: HttpErrorResponse) => error } + ], + parent: TestBed.inject(Injector) + }) + + this.resource = runInInjectionContext(injector, () => + PpwResource.fromHttpResource, TBody>((body) => this.#createResourceRef(body)) + ) + this.#captureExecutionKind() + } + + /** + * Executions created so far, in call order. + * + * Use this to inspect which body was passed to execute or track, or to select a specific execution index when + * flushing parallel operations. + */ + public get executions(): Array> { + return this.#controllers.map(({ body, resourceRef, isTrackingBody, reloadCount }) => ({ + body, + resourceRef, + isTrackingBody, + reloadCount + })) + } + + /** + * Moves the selected execution to loading. + * + * Defaults to the latest execution. Pass an index when testing parallel calls. + */ + public startLoading(index = this.#latestExecutionIndex()): this { + const controller = this.#getController(index) + controller.snapshot.set(loadingSnapshot(controller.resourceRef.value())) + + return this + } + + /** + * Resolves the selected execution and waits for PpwResource's deferred lifecycle handlers. + * + * When no value is provided, the value passed to create or the constructor is used. + */ + public async flushSuccess(value?: TResult, index = this.#latestExecutionIndex()): Promise { + this.#getController(index).snapshot.set(resolvedSnapshot(value ?? this.#successValue())) + TestBed.tick() + await waitForPpwResourceHandlers() + + return this + } + + /** + * Errors the selected execution and waits for PpwResource's deferred lifecycle handlers. + * + * When no error is provided, an HttpErrorResponse passed to create or the constructor is used. Otherwise a default + * Error is emitted. + */ + public async flushError(error?: Error, index = this.#latestExecutionIndex()): Promise { + this.#getController(index).snapshot.set(errorSnapshot(error ?? this.#errorValue())) + TestBed.tick() + await waitForPpwResourceHandlers() + + return this + } + + /** + * Flushes the selected execution using the constructor value. + * + * HttpErrorResponse values become error states. All other values become success states. + */ + public async flush(index = this.#latestExecutionIndex()): Promise { + if (this.#resolveValue instanceof HttpErrorResponse) { + return this.flushError(this.#resolveValue, index) + } + + return this.flushSuccess(this.#successValue(), index) + } + + #createResourceRef(body: () => TBody): PpwResourceSource> { + const snapshot = signal>>(idleSnapshot()) + const resourceRef = Object.assign(resourceFromSnapshots(snapshot), { + reload: () => { + controller.reloadCount += 1 + + return true + } + }) + const controller: PpwResourceMockController = { + body, + snapshot, + resourceRef, + reloadCount: 0, + isTrackingBody: this.#isCreatingTrackingExecution + } + + this.#controllers.push(controller) + + return resourceRef + } + + #captureExecutionKind(): void { + const execute = this.resource.execute.bind(this.resource) + const track = this.resource.track.bind(this.resource) + + this.resource.execute = ( + body: TBody, + handling?: PpwResourceExecutionHandling> + ): PpwResourceExecution> => { + this.#isCreatingTrackingExecution = false + + return execute(body, handling) + } + + this.resource.track = ( + body: () => TBody, + handling: PpwResourceExecutionHandling> + ): PpwResourceExecution> => { + this.#isCreatingTrackingExecution = true + + try { + return track(body, handling) + } finally { + this.#isCreatingTrackingExecution = false + } + } + } + + #getController(index: number): PpwResourceMockController { + const controller = this.#controllers[index] + + if (!controller) { + throw new Error(`No PpwResourceMock execution exists at index ${index}.`) + } + + return controller + } + + #latestExecutionIndex(): number { + return this.#controllers.length - 1 + } + + #successValue(): PpwResourceMockResult { + if (this.#resolveValue instanceof HttpErrorResponse) { + return undefined + } + + return this.#resolveValue + } + + #errorValue(): Error { + return this.#resolveValue instanceof HttpErrorResponse ? this.#resolveValue : new Error('PpwResourceMock error') + } + + /** + * Creates a PpwResourceMock with an optional default success value or HttpErrorResponse. + */ + public static create(resolveValue?: TResult | HttpErrorResponse): PpwResourceMock { + return new PpwResourceMock(resolveValue) + } +} + +const waitForPpwResourceHandlers = (): Promise => new Promise((resolve) => setTimeout(resolve)) diff --git a/scripts/ci/build-libs.sh b/scripts/ci/build-libs.sh index 11d7c88c..945860b3 100755 --- a/scripts/ci/build-libs.sh +++ b/scripts/ci/build-libs.sh @@ -1,7 +1,7 @@ #!/bin/bash export NODE_ENV="ci" -declare -a LIBRARIES_LIST=("ng-ppw-ds" "ng-e2e-testing" "ng-utils" "ng-common" "ng-common-components" "ng-async" "ng-dialogs" "ng-forms" "ng-router" "ng-state-management" "ng-unit-testing" "ng-wireframe" "ng-sdk") +declare -a LIBRARIES_LIST=("ng-ppw-ds" "ng-e2e-testing" "ng-utils" "ng-resource" "ng-common" "ng-common-components" "ng-async" "ng-dialogs" "ng-forms" "ng-router" "ng-state-management" "ng-unit-testing" "ng-wireframe" "ng-sdk") declare -a LIBRARIES_COUNT=${#LIBRARIES_LIST[@]} # Loop over libs diff --git a/scripts/ci/test-libs.js b/scripts/ci/test-libs.js index 285b27b8..f19d8706 100644 --- a/scripts/ci/test-libs.js +++ b/scripts/ci/test-libs.js @@ -6,6 +6,7 @@ const projects = [ '@ppwcode/ng-common-components', '@ppwcode/ng-dialogs', '@ppwcode/ng-forms', + '@ppwcode/ng-resource', '@ppwcode/ng-router', '@ppwcode/ng-state-management', '@ppwcode/ng-unit-testing', diff --git a/tsconfig.angular.json b/tsconfig.angular.json index bf1485db..395072f2 100644 --- a/tsconfig.angular.json +++ b/tsconfig.angular.json @@ -9,6 +9,7 @@ "@ppwcode/ng-dialogs": ["./dist/ppwcode/ng-dialogs"], "@ppwcode/ng-forms": ["./dist/ppwcode/ng-forms"], "@ppwcode/ng-ppw-ds": ["./dist/ppwcode/ng-ppw-ds"], + "@ppwcode/ng-resource": ["./dist/ppwcode/ng-resource"], "@ppwcode/ng-router": ["./dist/ppwcode/ng-router"], "@ppwcode/ng-state-management": ["./dist/ppwcode/ng-state-management"], "@ppwcode/ng-unit-testing": ["./dist/ppwcode/ng-unit-testing"], diff --git a/tsconfig.json b/tsconfig.json index 75b37db9..be0ee6cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "@ppwcode/ng-e2e-testing": ["./projects/ppwcode/ng-e2e-testing/src/public-api"], "@ppwcode/ng-forms": ["./projects/ppwcode/ng-forms/src/public-api"], "@ppwcode/ng-ppw-ds": ["./projects/ppwcode/ng-ppw-ds/src/public-api"], + "@ppwcode/ng-resource": ["./projects/ppwcode/ng-resource/src/public-api"], "@ppwcode/ng-router": ["./projects/ppwcode/ng-router/src/public-api"], "@ppwcode/ng-state-management": ["./projects/ppwcode/ng-state-management/src/public-api"], "@ppwcode/ng-unit-testing": ["./projects/ppwcode/ng-unit-testing/src/public-api"],