Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions projects/ppwcode/ng-resource/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @ppwcode/ng-resource

This package holds utilities for working with Angular's signal-based resources.
7 changes: 7 additions & 0 deletions projects/ppwcode/ng-resource/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/ppwcode/ng-resource",
"lib": {
"entryFile": "src/public-api.ts"
}
}
13 changes: 13 additions & 0 deletions projects/ppwcode/ng-resource/package.json
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<TResultDto, TResultEntity> {
/** 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<HttpResourceOptions<TResultEntity, TResultDto>, 'defaultValue'>
}

export type BaseResourceOptionsWithDefaultValue<TResultDto, TResultEntity> = BaseResourceOptions<
TResultDto,
TResultEntity
> & {
resourceOptions: NonNullable<BaseResourceOptions<TResultDto, TResultEntity>['resourceOptions']> & {
defaultValue: TResultEntity
}
}
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
@@ -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<TResultDto, TResultEntity>(
options: BaseResourceOptionsWithDefaultValue<TResultDto, TResultEntity>
): HttpResourceOptions<TResultEntity, unknown> & { 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<TResultDto, TResultEntity>(
options: BaseResourceOptions<TResultDto, TResultEntity>
): HttpResourceOptions<TResultEntity, unknown>

export function generateBaseHttpResourceOptions<TResultDto, TResultEntity>(
options: BaseResourceOptions<TResultDto, TResultEntity>
): HttpResourceOptions<TResultEntity, unknown> {
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
}
}
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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 = <TResultDto, TResultEntity>(
options: BaseResourceOptions<TResultDto, TResultEntity>
): Pick<HttpResourceRequest, 'url' | 'headers'> | undefined => {
const url = getRequestUrl(options.url, options.requestOptions?.queryParams)
if (!url) {
return undefined
}

return {
url,
headers: getRequestHeaders(options.requestOptions?.headers)
}
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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 ''
}
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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())
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading