diff --git a/libs/ngrx-hateoas/src/lib/provide.spec.ts b/libs/ngrx-hateoas/src/lib/provide.spec.ts index d47b10f..66d6144 100644 --- a/libs/ngrx-hateoas/src/lib/provide.spec.ts +++ b/libs/ngrx-hateoas/src/lib/provide.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from "@angular/core/testing"; import { AntiForgeryOptions, CustomHeadersOptions, HATEOAS_ANTI_FORGERY, HATEOAS_CUSTOM_HEADERS, HATEOAS_LOGIN_REDIRECT, HATEOAS_METADATA_PROVIDER, LoginRedirectOptions, MetadataProvider, provideHateoas, withAntiForgery, withCustomHeaders, withLoginRedirect, withMetadataProvider } from "./provide"; -import { DynamicResource, DynamicResourceValue, ResourceAction, ResourceLink, ResourceSocket } from "./models"; +import { ResourceAction, ResourceLink, ResourceSocket } from "./models"; describe('provideHateaos', () => { @@ -68,13 +68,13 @@ describe('provideHateaos', () => { const dummyMetadataProvider: MetadataProvider = { linkLookup(resource, linkName) { - return { href: (resource as any)['myMeta'][`_link_${linkName}`] } satisfies ResourceLink; + return { href: (resource as Record>)['myMeta'][`_link_${linkName}`] } satisfies ResourceLink; }, actionLookup(resource, actionName) { - return { href: (resource as any)['myMeta'][`_action_${actionName}`], method: 'PUT' } satisfies ResourceAction; + return { href: (resource as Record>)['myMeta'][`_action_${actionName}`], method: 'PUT' } satisfies ResourceAction; }, socketLookup(resource, socketName) { - return { href: (resource as any)['myMeta'][`_socket_${socketName}`], method: 'update' } satisfies ResourceSocket; + return { href: (resource as Record>)['myMeta'][`_socket_${socketName}`], method: 'update' } satisfies ResourceSocket; } } diff --git a/libs/ngrx-hateoas/src/lib/provide.ts b/libs/ngrx-hateoas/src/lib/provide.ts index 316e400..cd5a91d 100644 --- a/libs/ngrx-hateoas/src/lib/provide.ts +++ b/libs/ngrx-hateoas/src/lib/provide.ts @@ -1,7 +1,7 @@ import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders, Provider } from "@angular/core"; import { HateoasService } from "./services/hateoas.service"; import { RequestService } from "./services/request.service"; -import { DynamicResource, Resource, ResourceAction, ResourceLink, ResourceSocket } from "./models"; +import { Resource, ResourceAction, ResourceLink, ResourceSocket } from "./models"; export enum HateoasFeatureKind { AntiForgery, @@ -49,30 +49,52 @@ export interface MetadataProvider { socketLookup(resource: unknown, socketName: string): ResourceSocket | undefined; } -function isResource(resource: unknown): resource is DynamicResource { +function isResource(resource: unknown): resource is Resource { return typeof resource === 'object' && resource !== null; } -function isResourceLinkCollection(resourceLinks: unknown): resourceLinks is Record { +function isResourceLinkRecord(resourceLinks: unknown): resourceLinks is Record { return typeof resourceLinks === 'object' && resourceLinks !== null; } +function isResourceActionRecord(resourceActions: unknown): resourceActions is Record { + return typeof resourceActions === 'object' && resourceActions !== null; +} + +function isResourceSocketRecord(resourceSockets: unknown): resourceSockets is Record { + return typeof resourceSockets === 'object' && resourceSockets !== null; +} + function isResourceLink(resourceLink: unknown): resourceLink is ResourceLink { - return typeof resourceLink === 'object' && resourceLink !== null && 'href' in resourceLink; + return typeof resourceLink === 'object' && resourceLink !== null && 'href' in resourceLink; +} + +function isResourceAction(resourceAction: unknown): resourceAction is ResourceAction { + return typeof resourceAction === 'object' && resourceAction !== null && 'href' in resourceAction && 'method' in resourceAction; +} + +function isResourceSocket(resourceSocket: unknown): resourceSocket is ResourceSocket { + return typeof resourceSocket === 'object' && resourceSocket !== null && 'href' in resourceSocket && 'method' in resourceSocket; } const defaultMetadataProvider: MetadataProvider = { linkLookup(resource: unknown, linkName: string): ResourceLink | undefined { - if(isResource(resource) && isResourceLinkCollection(resource['_links'] && isResourceLink(resource['_links'][linkName]))) + if(isResource(resource) && isResourceLinkRecord(resource['_links']) && isResourceLink(resource['_links'][linkName])) return resource['_links'][linkName]; else return undefined; }, actionLookup(resource: unknown, actionName: string): ResourceAction | undefined { - return (resource as any)?._actions?.[actionName]; + if(isResource(resource) && isResourceActionRecord(resource['_actions']) && isResourceAction(resource['_actions'][actionName])) + return resource['_actions'][actionName]; + else + return undefined; }, socketLookup(resource: unknown, socketName: string): ResourceSocket | undefined { - return (resource as any)?._sockets?.[socketName]; + if(isResource(resource) && isResourceSocketRecord(resource['_sockets']) && isResourceSocket(resource['_sockets'][socketName])) + return resource['_sockets'][socketName]; + else + return undefined; } } diff --git a/libs/ngrx-hateoas/src/lib/services/hateoas.service.spec.ts b/libs/ngrx-hateoas/src/lib/services/hateoas.service.spec.ts index 317da5a..809ec5e 100644 --- a/libs/ngrx-hateoas/src/lib/services/hateoas.service.spec.ts +++ b/libs/ngrx-hateoas/src/lib/services/hateoas.service.spec.ts @@ -5,13 +5,13 @@ import { HATEOAS_METADATA_PROVIDER, MetadataProvider } from '../provide'; const dummyHateoasMetadataProvider: MetadataProvider = { linkLookup(resource, linkName) { - return { href: (resource as any)['myMeta'][`_link_${linkName}`] } satisfies ResourceLink; + return { href: (resource as Record>)['myMeta'][`_link_${linkName}`] } satisfies ResourceLink; }, actionLookup(resource, actionName) { - return { href: (resource as any)['myMeta'][`_action_${actionName}`], method: 'PUT' } satisfies ResourceAction; + return { href: (resource as Record>)['myMeta'][`_action_${actionName}`], method: 'PUT' } satisfies ResourceAction; }, socketLookup(resource, socketName) { - return { href: (resource as any)['myMeta'][`_socket_${socketName}`], method: 'update' } satisfies ResourceSocket; + return { href: (resource as Record>)['myMeta'][`_socket_${socketName}`], method: 'update' } satisfies ResourceSocket; } } diff --git a/libs/ngrx-hateoas/src/lib/services/request.service.spec.ts b/libs/ngrx-hateoas/src/lib/services/request.service.spec.ts index 4d32cec..ab6deb8 100644 --- a/libs/ngrx-hateoas/src/lib/services/request.service.spec.ts +++ b/libs/ngrx-hateoas/src/lib/services/request.service.spec.ts @@ -165,9 +165,13 @@ describe('RequestService', () => { const serverRequest = httpTestingController.expectOne('/api/test'); expect(serverRequest.request.method).toBe('GET'); serverRequest.flush(null, { status: 401, statusText: 'Unauthorized' }); - const clientRequest = await clientRequestPromise; - expect(clientRequest).toBeUndefined(); - expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + try { + await clientRequestPromise; + } catch { + expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + return; + } + expect(false).toBeTrue(); }); it('makes PUT requests correctly', async () => { @@ -175,9 +179,13 @@ describe('RequestService', () => { const serverRequest = httpTestingController.expectOne('/api/test'); expect(serverRequest.request.method).toBe('PUT'); serverRequest.flush(null, { status: 401, statusText: 'Unauthorized' }); - const clientRequest = await clientRequestPromise; - expect(clientRequest).toBeUndefined(); - expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + try { + await clientRequestPromise; + } catch { + expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + return; + } + expect(false).toBeTrue(); }); it('makes POST requests correctly', async () => { @@ -185,9 +193,13 @@ describe('RequestService', () => { const serverRequest = httpTestingController.expectOne('/api/test'); expect(serverRequest.request.method).toBe('POST'); serverRequest.flush(null, { status: 401, statusText: 'Unauthorized' }); - const clientRequest = await clientRequestPromise; - expect(clientRequest).toBeUndefined(); - expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + try { + await clientRequestPromise; + } catch { + expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + return; + } + expect(false).toBeTrue(); }); it('makes DELETE requests correctly', async () => { @@ -195,9 +207,13 @@ describe('RequestService', () => { const serverRequest = httpTestingController.expectOne('/api/test'); expect(serverRequest.request.method).toBe('DELETE'); serverRequest.flush(null, { status: 401, statusText: 'Unauthorized' }); - const clientRequest = await clientRequestPromise; - expect(clientRequest).toBeUndefined(); - expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + try { + await clientRequestPromise; + } catch { + expect(currentLocation).toBe('/login-path?redirect-url=%2Fangular%2Froute'); + return; + } + expect(true).toBe(false); }); }); diff --git a/libs/ngrx-hateoas/src/lib/services/request.service.ts b/libs/ngrx-hateoas/src/lib/services/request.service.ts index f6b1724..5782b01 100644 --- a/libs/ngrx-hateoas/src/lib/services/request.service.ts +++ b/libs/ngrx-hateoas/src/lib/services/request.service.ts @@ -14,7 +14,7 @@ export class RequestService { private httpClient = inject(HttpClient); - public async request(method: 'GET' | 'PUT' | 'POST' | 'DELETE', url: string, body?: unknown): Promise { + public async request(method: 'GET' | 'PUT' | 'POST' | 'DELETE', url: string, body?: unknown): Promise { let headers = new HttpHeaders().set('Content-Type', 'application/json'); if(this.customHeadersOptions) { @@ -38,10 +38,8 @@ export class RequestService { // Redirect to sign in const currentUrl = this.window.location.href; this.window.location.href = `${this.loginRedirectOptions.loginUrl}?${this.loginRedirectOptions.redirectUrlParamName}=` + encodeURIComponent(currentUrl); - return undefined; - } else { - throw errorResponse; } + throw errorResponse; } } diff --git a/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts b/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts index 881886c..b62b8e4 100644 --- a/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts +++ b/libs/ngrx-hateoas/src/lib/store-features/with-linked-hypermedia-resource.ts @@ -7,15 +7,17 @@ import { DeepPatchableSignal, toDeepPatchableSignal } from "../util/deep-patchab import { RequestService } from "../services/request.service"; import { HateoasService } from "../services/hateoas.service"; +export type ResourceStateProps = { + url: string, + isLoading: boolean, + isAvailable: boolean, + initiallyLoaded: boolean, + resource: TResource +} + export type LinkedHypermediaResourceState = { - [K in ResourceName]: { - url: string, - isLoading: boolean, - isAvailable: boolean, - initiallyLoaded: boolean, - resource: TResource - } + [K in ResourceName]: ResourceStateProps }; export type ConnectLinkedHypermediaResourceMethod = { @@ -52,6 +54,10 @@ type linkedRxInput = { linkName: string } +function getState(store: unknown, stateKey: string): ResourceStateProps { + return (store as Record>>)[stateKey]() +} + export function withLinkedHypermediaResource( resourceName: ResourceName, initialValue: TResource): SignalStoreFeature< { state: object; computed: Record>; methods: Record }, @@ -70,7 +76,7 @@ export function withLinkedHypermediaResource hateoasService.getLink(input.resource, input.linkName)?.href), filter(href => isValidHref(href)), map(href => href!), - filter(href => store[stateKey].url() !== href), - tap(href => patchState(store, { [stateKey]: { ...store[stateKey](), url: href, isLoading: true, isAvailable: true } })), + filter(href => getState(store, stateKey).url !== href), + tap(href => patchState(store, { [stateKey]: { ...getState(store, stateKey), url: href, isLoading: true, isAvailable: true } })), switchMap(href => requestService.request('GET', href)), - tap(resource => patchState(store, { [stateKey]: { ...store[stateKey](), resource, isLoading: false, initiallyLoaded: true } })) + tap(resource => patchState(store, { [stateKey]: { ...getState(store, stateKey), resource, isLoading: false, initiallyLoaded: true } })) ) ); - const patchableSignal = toDeepPatchableSignal(newVal => patchState(store, { [stateKey]: { ...store[stateKey](), resource: newVal } }), store[stateKey].resource); + const patchableSignal = toDeepPatchableSignal(newVal => patchState(store, { [stateKey]: { ...getState(store, stateKey), resource: newVal } }), (store as Record>>)[stateKey].resource); return { [connectMehtodName]: (linkRoot: Signal, linkName: string) => { @@ -102,15 +108,15 @@ export function withLinkedHypermediaResource => { - const currentUrl = store[stateKey].url(); + const currentUrl = getState(store, stateKey).url; if(currentUrl) { - patchState(store, { [stateKey]: { ...store[stateKey](), isLoading: true } }); + patchState(store, { [stateKey]: { ...getState(store, stateKey), isLoading: true } }); try { - const resource = await requestService.request('GET', currentUrl); - patchState(store, { [stateKey]: { ...store[stateKey](), isLoading: false, resource } }); + const resource = await requestService.request('GET', currentUrl); + patchState(store, { [stateKey]: { ...getState(store, stateKey), isLoading: false, resource } }); } catch(e) { - patchState(store, { [stateKey]: { ...store[stateKey](), isLoading: false, resource: initialValue } }); + patchState(store, { [stateKey]: { ...getState(store, stateKey), isLoading: false, resource: initialValue } }); throw e; } }