diff --git a/src/php/ModelGenerator.php b/src/php/ModelGenerator.php index 945af5ad038a58b7f9eb555dcc5c348bbe0ca565..351029d3b964959a8df34c6d056b306800387955 100644 --- a/src/php/ModelGenerator.php +++ b/src/php/ModelGenerator.php @@ -149,7 +149,6 @@ final class ModelGenerator } sort($context['repoImports']); - $this->generateFile($writer, 'common.ts', $context); $this->generateFile($writer, 'resources.ts', $context); $this->generateFile($writer, 'repositories.ts', $context); $this->generateFile($writer, 'metadata.ts', $context); diff --git a/src/php/Resources/views/common.ts.twig b/src/php/Resources/views/common.ts.twig deleted file mode 100644 index b03fe268f5648edf8eced428c4d733314e5a656f..0000000000000000000000000000000000000000 --- a/src/php/Resources/views/common.ts.twig +++ /dev/null @@ -1,418 +0,0 @@ -{% extends '@NgModelGenerator/_layout.ts.twig' %} - -{% block content %} -import {HttpClient, HttpHeaders, HttpResponseBase} from '@angular/common/http'; -import { forkJoin, Observable } from 'rxjs'; - -/** - * Nom de la propriété d'une resource contenant son IRI. - */ -export const IRI_PROPERTY = '@id'; - -/** - * Nom de la propriété d'une resource contenant son type. - */ -export const TYPE_PROPERTY = '@type'; - -/** - * Nom de la propriété contenant les resource d'une collection. - */ -export const COLLECTION_MEMBERS = 'hydra:member'; - -/** - * Nom de la propriété contenant le nombre total d'objet d'une collection. - */ -export const COLLECTION_TOTAL_COUNT = 'hydra:totalItems'; - -/** - * IRI typé. - * - * Internationalized Resource Identifier - RFC 3987 - */ -const IRI = Symbol('IRI'); -/* Les IRI sont en fait des chaînes mais pour forcer un typage fort on les définit - * comme un type "opaque". De cette façon, il est impossible de mélanger - * IRI et chaînes, et le type générique R permet d'interdire les assignations entre - * IRI de resources différentes. - */ -export interface IRI<R extends Resource> { - readonly [IRI]?: R; -} - -/** - * Resource - */ -export interface Resource { - readonly [IRI_PROPERTY]: IRI<any>; - readonly [TYPE_PROPERTY]: string; - [property: string]: any; -}; - -/** - * Collection représente une collection de respoucres JSON-LD pour un type T donné. - */ -export interface Collection<R extends Resource> { - [COLLECTION_MEMBERS]: R[]; - [COLLECTION_TOTAL_COUNT]: number; - [property: string]: any; -} - -/** - * Retourne les membres d'une collection. - */ -export function getCollectionMembers<R extends Resource>(collection: Collection<R>): R[] { - return collection[COLLECTION_MEMBERS]; -} - -/** - * Retourne le nombre total d'items - * @param collection - */ -export function getCollectionTotalCount<R extends Resource>(collection: Collection<R>): number { - return collection[COLLECTION_TOTAL_COUNT]; -} - -/** - * Universally Unique IDentifier - RFC 4122 - */ -export type UUID = string; - -/** - * Teste si une donnée (chaîne) est formatée selon un UUID. - */ -export function isUUID(data: any): data is UUID { - return typeof data === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(data); -} -/** - * Full DateTime in ISO-8601 format. - */ -export type DateTime = string; - -/** - * Options supplémentaires pour les requêtes. - */ -export interface RequestOptions { - body?: any; - headers?: HttpHeaders | { - [header: string]: string | string[]; - }; - params?: { [param: string]: string | string[]; }; -} - -/** - * AbstractResourceCache est une classe abstraite qui définit l'interface d'un cache de ressources. - * - * Elle doit être implémentée puis être fournie en provider d'un module core. Par exemple: - * - * final class ResourceCache<T extends Resource> extends AbstractResourceCache<T> { - * // Implémentation - * } - * - * providers: [ - * [ provider: AbstractResourceCache, useClass: ResourceCache ], - * ] - */ -export abstract class AbstractResourceCache { - /** - * Récupère une ressource par son IRI. N'exécute la requête requestFactory que si on ne dispose - * pas d'une version en cache. - */ - public abstract get<R extends Resource>( - iri: IRI<R>, - requestFactory: () => Observable<R> - ): Observable<R>; - - /** - * Met à jour une ressource existante, rafraîchit le cache local avec la réponse. - */ - public abstract put<R extends Resource>(iri: IRI<R>, request: Observable<R>): Observable<R>; - - /** - * Crée une nouvelle ressource et met la ressource créée dans le cache. - */ - public abstract post<R extends Resource>(request: Observable<R>): Observable<R>; - - /** - * Supprime une ressource en distant et dans le cache. - */ - public abstract delete<R extends Resource>(iri: IRI<R>, request: Observable<HttpResponseBase>): Observable<HttpResponseBase>; - - /** - * Effectue une recherche et met en cache toutes les ressources récupérées. - */ - public abstract getAll<R extends Resource>( - request: Observable<Collection<R>> - ): Observable<Collection<R>>; - - /** - * Invalide une ressource en cache. - */ - public abstract invalidate<R extends Resource>(iri: IRI<R>): void; -} - -/** - * Type des paramètrès - */ -type IRIParameter = string | number; -export type IRIParameters = IRIParameter[] | IRIParameter; - -/** - * Informations sur l'IRI d'une resource. - * - * P: types des paramètres. - */ -export class IRIMetadata<P extends IRIParameters> { - public constructor( - private readonly testPattern: RegExp, - private readonly capturePattern: RegExp, - private readonly template: (parameters: P) => string - ) {} - - public validate(path: string): boolean { - return this.testPattern.test(path); - } - - public generate(parameters: P): IRI<any> { - return this.template(parameters) as any; - } - - public parse(path: string): P { - const matches = this.capturePattern.exec(path); - if (!matches) { - throw new Error(`Invalid path: ${path} does not match ${this.capturePattern}`); - } - if (matches.length == 2) { - return matches[1] as any; - } - return matches.slice(1) as any; - } -} - -function getResourceType(that: any): string | null { - return ( - (that !== null && typeof that === 'object' && typeof that[TYPE_PROPERTY] === 'string' && that[TYPE_PROPERTY]) || - null - ); -} - -/** - * Metadonnées d'une ressource. - * - * R : type de resource, e.g. Person - * T : valeur de la propriété '@type', e.g. 'Person'. - */ -export class ResourceMetadata<R extends Resource, T extends string, P extends IRIParameters> { - public constructor( - public readonly type: T, - public readonly iri: IRIMetadata<P>, - private readonly requiredProperties: (keyof R)[], - private readonly types: ResourceMetadata<any, any, P>[] = [] - ) { - this.types.unshift(this); - } - - /** - * Vérifie si l'argument représente une ressource R ou dérivée. - */ - public isResource(that: any): that is R { - const type = getResourceType(that); - if (!type) { - return false; - } - return this.types.some(t => t.type === type); - } - - /** - * Vérifie si une propriété est obligatoire dans la resource R. - */ - public isRequired(property: keyof R): boolean { - return this.requiredProperties.includes(property); - } - - /** - * Génère une IRI à partir de ses paramètres. - */ - public generateIRI(parameters: P): IRI<R> { - return this.iri.generate(parameters); - } - - /** - * Extrait les paramètres d'une IRI. - */ - public getIRIParameters(iri: IRI<R>): P { - return this.iri.parse(iri as any); - } -} - -/** - * Classe de base d'un repository. - */ -export abstract class AbstractRepository<R extends Resource, T extends string, P extends IRIParameters> { - public constructor( - public readonly metadata: ResourceMetadata<R, T, P>, - protected readonly client: HttpClient, - protected readonly cache: AbstractResourceCache - ) {} - - /** - * Génère une IRI à partir de ses paramètres. - */ - public generateIRI(parameters: P): IRI<R> { - return this.metadata.generateIRI(parameters); - } - - /** - * Extrait les paramètres d'une IRI. - */ - public getIRIParameters(iri: IRI<R>): P { - return this.metadata.getIRIParameters(iri); - } -} - -/** - * Sur-type encadrant des API. - */ -export interface APIMeta { - [type: string]: { - resource: Resource; - metadata: ResourceMetadata<any, any, IRIParameters>; - repository: AbstractRepository<any, any, IRIParameters>; - iriParameters: IRIParameters; - }; -} - -/** - * Classe abstraite d'un registre des metadonnées des ressources. - */ -export interface APIMetadataRegistry<API extends APIMeta> { - /** - * Vérifie si on a des métadonnées pour le type de ressourcce indiqué. - */ - has<T extends keyof API>(type: T): type is T; - - /** - * (Construit et) retourne l'instance de métadonnées pour le type de resource 'type'. - */ - get<T extends keyof API>(type: T): API[T]['metadata']; -} - -/** - * Registre de métadonnées qui construit les instances à la demande (ce qui permet de gérer les - * dépendances entre métadonnées). - */ -export class LazyMetadataRegistry<API extends APIMeta> implements APIMetadataRegistry<API> { - private readonly instances: { [T in keyof API]?: API[T]['metadata'] } = {}; - - protected constructor( - private readonly builders: { readonly [T in keyof API]: (r?: APIMetadataRegistry<API>) => API[T]['metadata'] } - ) {} - - public has<T extends keyof API>(type: T): type is T { - return typeof type === 'string' && type in this.builders; - } - - public get<T extends keyof API>(type: T): API[T]['metadata'] { - if (!this.has(type)) { - throw new Error(`Invalid resource type: ${type}`); - } - return this.getOrCreate(type); - } - - protected getOrCreate<T extends keyof API>(type: T): API[T]['metadata'] { - if (!(type in this.instances)) { - this.instances[type] = this.builders[type](this); - } - return this.instances[type]; - } -} - -/** - * Classe abstraite de la façade de l'API. - */ -export interface APIRepositoryRegistry<API extends APIMeta> { - get<T extends keyof API>(type: T): API[T]['repository']; -} - -/** - * Service permettant d'accéder à l'API. - */ -export interface APIService<API extends APIMeta> { - /** - * Métadonnées de l'API. - */ - readonly metadata: APIMetadataRegistry<API>; - - /** - * Repositories de l'API - */ - readonly repositories: APIRepositoryRegistry<API>; - - /** - * Récupère une ressource par son IRI ou par son type et les paramètres de son IRI. - */ - get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R> ; - get<T extends keyof API>(type: T, parameters: API[T]['iriParameters'], options?: RequestOptions): Observable<API[T]['resource']> ; - - /** - * Récupère des ressources par leurs IRIs. - */ - getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]>; - - /** - * Génère l'IRI d'une resource à partir de son type et des paramètres d'IRI. - */ - generateIRI<T extends keyof API, P extends string[]>(type: T, parameters: P): IRI<any>; - - /** - * Invalide le cache pour une IRI. - */ - invalidate<R extends Resource>(iri: IRI<R>): void; -} - -/** - * Implémentation de base d'une api - */ -export abstract class AbstractAPIService< - API extends APIMeta, - MR extends APIMetadataRegistry<API>, - RR extends APIRepositoryRegistry<API> -> implements APIService<API> { - public constructor( - public readonly metadata: MR, - public readonly repositories: RR, - private readonly cache: AbstractResourceCache, - private readonly client: HttpClient - ) {} - - public get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R>; - public get<T extends keyof API>(type: T, parameters: API[T]['iriParameters'], options?: RequestOptions): Observable<API[T]['resource']>; - - public get<T extends keyof API, R extends Resource = API[T]['resource']>( - typeOrIRI: T | IRI<R>, - parametersOrOptions?: API[T]['iriParameters'] | RequestOptions, - options?: RequestOptions - ): Observable<R> { - let iri: IRI<R>; - if (this.metadata.has(typeOrIRI as string)) { - iri = this.metadata.get(typeOrIRI as string).generateIRI(parametersOrOptions as API[T]['iriParameters']); - } else { - iri = typeOrIRI as IRI<R>; - options = parametersOrOptions as RequestOptions; - } - return this.cache.get(iri, () => this.client.get<R>(iri as any, options)); - } - - public getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]> { - return forkJoin(iris.map(iri => this.get(iri, options))); - } - - public generateIRI<T extends keyof API>(type: T, parameters: API[T]['iriParameters']): IRI<API[T]['resource']> { - return this.metadata.get(type).generateIRI(parameters); - } - - public invalidate<R extends Resource>(iri: IRI<R>): void { - this.cache.invalidate(iri); - } -} - -{% endblock %} diff --git a/src/php/Resources/views/index.ts.twig b/src/php/Resources/views/index.ts.twig index 7940dacb54a4364d6f9a87267cece08a0d866763..ce383629cf677cd237d07b0a4386ab7b8a3e729e 100644 --- a/src/php/Resources/views/index.ts.twig +++ b/src/php/Resources/views/index.ts.twig @@ -1,7 +1,8 @@ {% extends '@NgModelGenerator/_layout.ts.twig' %} {% block content %} -export * from './common'; +export * from 'irstea-ng-model/types'; + export * from './resources'; export * from './repositories'; export * from './metadata'; diff --git a/src/php/Resources/views/metadata.ts.twig b/src/php/Resources/views/metadata.ts.twig index ae30a26c5f579d0d62b687e3fd3c4e9a3f45404d..cc66efd1fe4d966f97ab1193d1f9942129105cf1 100644 --- a/src/php/Resources/views/metadata.ts.twig +++ b/src/php/Resources/views/metadata.ts.twig @@ -6,7 +6,6 @@ {% autoescape false %} import { Injectable, Provider } from '@angular/core'; import { HttpClient } from '@angular/common/http'; - import { AbstractAPIService, AbstractResourceCache, @@ -16,7 +15,8 @@ import { LazyMetadataRegistry, ResourceMetadata, UUID, -} from './common'; +} from 'irstea-ng-model/types'; + import { {% for repo in repositories %} {{ repo.usage }}{% if not loop.last %},{% endif %} diff --git a/src/php/Resources/views/repositories.ts.twig b/src/php/Resources/views/repositories.ts.twig index 688e7c0c2da98845ebee723c966dc4328309b331..9ea2f740da2e641cbafd2ef101ed990534905e71 100644 --- a/src/php/Resources/views/repositories.ts.twig +++ b/src/php/Resources/views/repositories.ts.twig @@ -8,7 +8,7 @@ import { Observable } from 'rxjs'; import { HttpResponseBase } from '@angular/common/http'; // @ts-ignore -import { AbstractRepository, Collection, IRI, RequestOptions, UUID } from './common'; +import { AbstractRepository, Collection, IRI, RequestOptions, UUID } from 'irstea-ng-model/types'; import { {% for name in repoImports %} {{ name }}{% if not loop.last %}, {% endif %} {%- endfor %} diff --git a/src/php/Resources/views/resources.ts.twig b/src/php/Resources/views/resources.ts.twig index a60203d1d252fb8a41a38d9f97d7b254758eb473..5f98c8b91cc714dd03356f4821a58bb4bf991a42 100644 --- a/src/php/Resources/views/resources.ts.twig +++ b/src/php/Resources/views/resources.ts.twig @@ -3,7 +3,7 @@ {% block content %} {% autoescape false %} // @ts-ignore -import { DateTime, IRI, UUID } from './common'; +import { DateTime, IRI, UUID } from 'irstea-ng-model/types'; /********************************************************************************* * Ressources diff --git a/src/ts/cache.service.spec.ts b/src/ts/cache.service.spec.ts deleted file mode 100644 index 4b23a8bf8ba35294bf0616ea73d69a574207d70f..0000000000000000000000000000000000000000 --- a/src/ts/cache.service.spec.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { HttpHeaderResponse } from '@angular/common/http'; -import { inject, TestBed } from '@angular/core/testing'; -import * as _ from 'lodash'; -import { forkJoin, Observable } from 'rxjs'; -import { catchError, map, mergeMap } from 'rxjs/operators'; - -import { MarbleTestScheduler } from '../../../testing/marbles'; -import { Collection, IRI, IRI_PROPERTY, Resource } from '../../shared/models'; -import { safeForkJoin } from '../../shared/rxjs'; - -import { IRIMismatchError, MissingIRIError, ResourceCache, ValueHolder } from './cache.service'; - -describe('cache.service', () => { - interface MyResource extends Resource { - readonly '@id': IRI<MyResource>; - readonly '@type': 'MyResource'; - value: string; - value2?: string; - } - - function iri(x: string): IRI<MyResource> { - return x as any; - } - - const MY_IRI = iri('/bla/a'); - const OTHER_IRI = iri('/bla/B'); - const VALUES: { [name: string]: MyResource } = { - a: { '@id': MY_IRI, '@type': 'MyResource', value: 'foo' }, - b: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar' }, - c: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar', value2: 'quz' }, - d: { '@id': OTHER_IRI, '@type': 'MyResource', value: 'zig' }, - }; - let scheduler: MarbleTestScheduler<any>; - beforeEach(() => { - scheduler = MarbleTestScheduler.create(VALUES, 'error'); - }); - - describe('ValueHolder', () => { - let holder: ValueHolder<any>; - - beforeEach(() => { - holder = new ValueHolder<MyResource>(iri('/bla/a')); - }); - - it('should be created', () => { - expect(holder).toBeTruthy(); - }); - - describe('.set()', () => { - function testSet({ value, error, SET_M }: any) { - scheduler.withError(error).run(({ expectObservable }) => { - expectObservable(holder.set(value)).toBe(SET_M); - }); - } - - it('should provide the value', () => - testSet({ - value: VALUES.a, - SET_M: '(a|)', - })); - - it(`should refuse value without ${IRI_PROPERTY}`, () => - testSet({ - value: {}, - error: new MissingIRIError(), - SET_M: '#', - })); - - it('should refuse value with different @id', () => - testSet({ - value: { '@id': iri('bar') }, - error: new IRIMismatchError(MY_IRI, iri('bar')), - SET_M: '#', - })); - - it('should always points to the same instance', () => { - forkJoin(holder.set(VALUES.a), holder.set(VALUES.b)).subscribe(([a, b]: MyResource[]) => { - expect(a).toBe(b); - }); - }); - }); - - describe('.update()', () => { - it('should provide the value from the server', () => - scheduler.run(({ cold, expectObservable }) => { - const REQ_M = '---a|'; - const UPD_M = '--(a|) '; - - const request$ = cold(REQ_M); - expectObservable(holder.update(request$)).toBe(UPD_M); - })); - - it('should cancel pending requests', () => { - const LOCAL_VALUES = { - a: VALUES.a, - b: VALUES.b, - j: [VALUES.b, VALUES.b], - }; - const REQ1_M = '---a|'; - const REQ2_M = 'b| '; - - const UPDA_M = '(j|) '; - const REQ1_S = '(^!) '; - const REQ2_S = '(^!) '; - - scheduler.withValues(LOCAL_VALUES).run(({ cold, expectObservable, expectSubscriptions }) => { - const request1$ = cold(REQ1_M); - const request2$ = cold(REQ2_M); - - expectObservable( - safeForkJoin([ - // - holder.update(request1$), - holder.update(request2$), - ]) - ).toBe(UPDA_M); - expectSubscriptions(request1$.subscriptions).toBe(REQ1_S); - expectSubscriptions(request2$.subscriptions).toBe(REQ2_S); - }); - }); - - it('should propagate errors', () => - scheduler.run(({ cold, expectObservable }) => { - const REQ_M = '#'; - const UPD_M = '#'; - - const request$ = cold(REQ_M); - expectObservable(holder.update(request$)).toBe(UPD_M); - })); - - it('should restart on errors', () => - scheduler.run(({ cold, expectObservable }) => { - const REQ1_M = '#'; - const UPD1_M = '#'; - - const obs$ = holder.update(cold(REQ1_M)); - expectObservable(obs$).toBe(UPD1_M); - - const REQ2_M = '-a|'; - const UPD2_M = '-a|'; - - const obs2$ = holder.update(cold(REQ2_M)); - expectObservable(obs2$).toBe(UPD2_M); - })); - }); - - describe('.listen()', () => { - function testListen({ REQUEST_M, LISTEN_M, initial }: any) { - scheduler.run(({ cold, expectObservable }) => { - if (initial) { - holder.set(initial); - } - expectObservable(holder.listen(() => cold(REQUEST_M))).toBe(LISTEN_M); - }); - } - - it('should provide the value', () => - testListen({ - initial: VALUES.a, - REQUEST_M: /**/ ' ', - LISTEN_M: /***/ '(a|)', - })); - - it('should cache the value', () => - testListen({ - initial: VALUES.a, - REQUEST_M: /**/ 'b| ', - LISTEN_M: /***/ '(a|)', - })); - - it('should propagate errors', () => - testListen({ - REQUEST_M: /**/ '#', - LISTEN_M: /***/ '#', - })); - }); - - it('.invalidate() should cause the value to be requested again', () => - scheduler.run(({ cold, expectObservable }) => { - const REQUEST_M = /**/ '(a|)'; - const LISTEN_M = /***/ '(a|)'; - const requestFactory = jasmine.createSpy('requestFactory'); - requestFactory.and.returnValue(cold(REQUEST_M)); - - return holder - .set(VALUES.a) - .toPromise() - .then(() => holder.invalidate()) - .then(() => expectObservable(holder.listen(requestFactory)).toBe(LISTEN_M)) - .then(() => expect(requestFactory).toHaveBeenCalled()); - })); - }); - - describe('ResourceCache', () => { - beforeEach(() => - TestBed.configureTestingModule({ - providers: [ResourceCache], - }) - ); - - it('should be created', inject([ResourceCache], (service: ResourceCache) => { - expect(service).toBeDefined(); - })); - - describe('.get()', () => { - it('should provide the value', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const getQuery$ = cold('a|'); - - expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('(a|)'); - }) - )); - - it('should cache the value', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const getQuery$ = cold('a|'); - const getQuery2$ = cold('b|'); - // tslint:disable-next-line:rxjs-finnish - const queries$ = cold('ab|', { a: getQuery$, b: getQuery2$ }); - - expectObservable(queries$.pipe(mergeMap(query$ => service.get(MY_IRI, () => query$)))).toBe('aa|'); - }) - )); - - it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const getQuery$ = cold('#'); - - expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('#'); - }) - )); - }); - - describe('.put()', () => { - it('should provide the value', inject([ResourceCache], (service: ResourceCache) => { - const putRequest$ = scheduler.createColdObservable('a|'); - - scheduler.expectObservable(service.put(MY_IRI, putRequest$)).toBe('a|'); - })); - - it('should not cache the value', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const putRequest$ = cold('a|'); - const putRequest2$ = cold('b|'); - // tslint:disable-next-line:rxjs-finnish - const requests$ = cold('ab|', { a: putRequest$, b: putRequest2$ }); - - expectObservable( - requests$.pipe( - mergeMap((request$: Observable<MyResource>) => service.put(MY_IRI, request$)), - map(x => _.clone(x)) - ) - ).toBe('ab|'); - }) - )); - - it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const putRequest$ = cold('#'); - - expectObservable(service.put(MY_IRI, putRequest$)).toBe('#'); - }) - )); - }); - - describe('.post()', () => { - it('should provide the value', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const postRequest$ = cold('a|'); - - expectObservable(service.post(postRequest$)).toBe('a|'); - }) - )); - - it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const postRequest$ = cold('#'); - - expectObservable(service.post(postRequest$)).toBe('#'); - }) - )); - }); - - describe('.delete()', () => { - it('should clear the cache on successful fetch', inject([ResourceCache], (service: ResourceCache) => { - const response = new HttpHeaderResponse({ status: 200 }); - const values = { r: response, a: VALUES.a, b: VALUES.b }; - const sched = scheduler.withValues(values); - - sched.run(async ({ cold, expectObservable }) => { - await service.get(MY_IRI, () => cold('a|')).toPromise(); - await service.delete(MY_IRI, cold('r|')).toPromise(); - - return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(b|)'); - }); - })); - - it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const deleteRequest$ = cold('#'); - - expectObservable(service.delete(MY_IRI, deleteRequest$)).toBe('#'); - }) - )); - - it('should not clear the cache on error', inject([ResourceCache], (service: ResourceCache) => { - const error = new HttpHeaderResponse({ status: 500 }); - const values = { a: VALUES.a, b: VALUES.b, e: error }; - scheduler - .withValues(values) - .withError(error) - .run(async ({ cold, expectObservable }) => { - await service.get(MY_IRI, () => cold('a|')).toPromise(); - await service - .delete(MY_IRI, cold('#')) - .pipe(catchError(e => e)) - .toPromise(); - - return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(a|)'); - }); - })); - }); - - describe('.getAll()', () => { - it('should provide the returned value', inject([ResourceCache], (service: ResourceCache) => { - const values = { - a: { 'hydra:member': [VALUES.a, VALUES.d], 'hydra:totalItems': 2 }, - }; - scheduler.withValues(values).run(({ cold, expectObservable }) => { - const getAllRequest$ = cold('a|'); - expectObservable(service.getAll(getAllRequest$)).toBe('a|'); - }); - })); - - it('should nicely handle empty collections', inject([ResourceCache], (service: ResourceCache) => { - const values = { - a: { 'hydra:member': [] as any, 'hydra:totalItems': 0 }, - }; - scheduler.withValues(values).run(({ cold, expectObservable }) => { - const getAllRequest$ = cold('a|'); - expectObservable(service.getAll(getAllRequest$)).toBe('a|'); - }); - })); - - it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => - scheduler.run(({ cold, expectObservable }) => { - const getAllRequest$ = cold<Collection<MyResource>>('#'); - expectObservable(service.getAll(getAllRequest$)).toBe('#'); - }) - )); - - it('should populate the cache', inject([ResourceCache], (service: ResourceCache) => { - const values = { - a: VALUES.a, - b: VALUES.d, - h: { - 'hydra:member': [VALUES.a, VALUES.d], - 'hydra:totalItems': 2, - }, - }; - scheduler.withValues(values).run(({ cold, expectObservable }) => { - const getAllRequest$ = cold('h|'); - const getRequest$ = cold('b|'); - const requests$ = cold<() => Observable<any>>('ab|', { - a: () => service.getAll(getAllRequest$), - b: () => service.get(MY_IRI, () => getRequest$), - }); - - expectObservable(requests$.pipe(mergeMap((sendRequest: () => Observable<any>) => sendRequest()))).toBe('ha|'); - }); - })); - }); - }); -}); diff --git a/src/ts/cache.service.ts b/src/ts/cache.service.ts deleted file mode 100644 index 1ed095f362209e42fe751692014de22392e2b468..0000000000000000000000000000000000000000 --- a/src/ts/cache.service.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { HttpResponseBase } from '@angular/common/http'; -import * as _ from 'lodash'; -import { Observable, of, race, Subject, throwError } from 'rxjs'; -import { map, switchMap, take, tap } from 'rxjs/operators'; - -import { - AbstractResourceCache, - Collection, - COLLECTION_MEMBERS, - getCollectionMembers, - IRI, - IRI_PROPERTY, - Resource, -} from '../../shared/models'; -import { safeForkJoin } from '../../shared/rxjs'; - -export class APICacheError extends Error {} - -export class MissingIRIError extends APICacheError { - public constructor() { - super(`resource must have an ${IRI_PROPERTY} property`); - } -} - -export class IRIMismatchError extends APICacheError { - public constructor(expected: any, actual: any) { - super(`${IRI_PROPERTY}s mismatch: ${actual} !== ${expected}`); - } -} - -/** - * ValueHolder gère les requêtes d'une seule ressource. - * - * @internal - */ -export class ValueHolder<R extends Resource> { - private readonly value$ = new Subject<R>(); - - private readonly value = {} as R; - private version = 0; - - constructor(private readonly iri: IRI<R>) {} - - public set(value: R): Observable<R> { - if (!(IRI_PROPERTY in value)) { - return throwError(new MissingIRIError()); - } - if (value[IRI_PROPERTY] !== this.iri) { - return throwError(new IRIMismatchError(this.iri, value[IRI_PROPERTY])); - } - - _.assign(this.value, value); - _(this.value) - .keys() - .difference(_.keys(value)) - .forEach(key => delete this.value[key]); - this.version++; - this.value$.next(this.value); - - return of(this.value); - } - - public listen(queryFactory: () => Observable<R>): Observable<R> { - if (this.version > 0) { - return of(this.value); - } - return this.update(queryFactory()); - } - - public update(request$: Observable<R>): Observable<R> { - return race(this.value$.pipe(take(1)), request$.pipe(switchMap((item: R) => this.set(item)))); - } - - public invalidate(): void { - this.version = 0; - } - - public delete(): void { - this.value$.complete(); - } -} - -/** - * Implémentation d'un cache de resource. - * - * Cette implémentation met en place une queue de requête ainsi qu'un observable pour chaque ressource. - * - * La queue de requête permet de mettre à jour une ressource en cache. switchMap est utilisé pour prendre en compte - * les valeurs des dernières requêtes. - * - * L'Observable s'assure de retourner toujours la même référence d'objet tout au long de la vie - * de la ressource dans le cache, il permet aussi de faire suivre tout mise à jour à d'eventuels subscribers. - */ -export class ResourceCache extends AbstractResourceCache { - private readonly holders = new Map<IRI<Resource>, ValueHolder<Resource>>(); - - /** - * Retourne la ressource identifiée par l'IRI donné. - * - * Effectue une requête si on le connait pas. - */ - public get<R extends Resource>(iri: IRI<R>, requestFactory: () => Observable<R>): Observable<R> { - return this.getHolder(iri).listen(requestFactory); - } - - /** - * Envoie une requête de mise à jour puis met à jour le cache avec la réponse du serveur. - */ - public put<R extends Resource>(iri: IRI<R>, query$: Observable<R>): Observable<R> { - return this.getHolder(iri).update(query$); - } - - /** - * Envoie une requête de création puis met à jour le chache avec la réponse. - */ - public post<R extends Resource>(query$: Observable<R>): Observable<R> { - return query$.pipe(switchMap(item => this.received(item))); - } - - /** - * Supprime une ressource sur le serveur puis en cache. - */ - public delete<R extends Resource>(iri: IRI<R>, query$: Observable<HttpResponseBase>): Observable<HttpResponseBase> { - return query$.pipe( - tap(() => { - if (!this.holders.has(iri)) { - return; - } - this.holders.get(iri).delete(); - this.holders.delete(iri); - }) - ); - } - - /** - * Fait une requête pour plusieurs ressources puis les mets en cache. - */ - public getAll<R extends Resource>(query$: Observable<Collection<R>>): Observable<Collection<R>> { - return query$.pipe( - switchMap((coll: Collection<R>) => { - const members = getCollectionMembers(coll); - const memberObservables$ = members.map(item => this.received(item)); - return safeForkJoin(memberObservables$).pipe( - map(items => Object.assign({} as Collection<R>, coll, { [COLLECTION_MEMBERS]: items })) - ); - }) - ); - } - - /** - * Invalide la valeur d'une IRI pour forcer une mise-à -jour. - */ - public invalidate<R extends Resource>(iri: IRI<R>): void { - if (!this.holders.has(iri)) { - return; - } - this.holders.get(iri).invalidate(); - } - - /** - * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire. - */ - private getHolder<R extends Resource>(iri: IRI<R>): ValueHolder<R> { - let holder = this.holders.get(iri) as ValueHolder<R>; - if (!holder) { - holder = new ValueHolder<R>(iri); - this.holders.set(iri, holder); - } - return holder; - } - - /** - * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire. - */ - private received<R extends Resource>(item: R): Observable<R> { - return this.getHolder(item[IRI_PROPERTY]).set(item); - } -} diff --git a/src/ts/cache/cache.service.spec.ts b/src/ts/cache/cache.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8002eacde182fe94a610407d5c4aa7fe75a5264 --- /dev/null +++ b/src/ts/cache/cache.service.spec.ts @@ -0,0 +1,378 @@ +import { HttpHeaderResponse } from '@angular/common/http'; +import { inject, TestBed } from '@angular/core/testing'; +import * as _ from 'lodash'; +import { forkJoin, Observable } from 'rxjs'; +import { catchError, map, mergeMap } from 'rxjs/operators'; + +import { MarbleTestScheduler } from '../../../testing/marbles'; +import { Collection, IRI, IRI_PROPERTY, Resource } from '../../shared/models'; +import { safeForkJoin } from '../../shared/rxjs'; + +import { IRIMismatchError, MissingIRIError, ResourceCache, ValueHolder } from './cache.service'; + +describe('cache.service', () => { + interface MyResource extends Resource { + readonly '@id': IRI<MyResource>; + readonly '@type': 'MyResource'; + value: string; + value2?: string; + } + + function iri(x: string): IRI<MyResource> { + return x as any; + } + + const MY_IRI = iri('/bla/a'); + const OTHER_IRI = iri('/bla/B'); + const VALUES: { [name: string]: MyResource } = { + a: { '@id': MY_IRI, '@type': 'MyResource', value: 'foo' }, + b: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar' }, + c: { '@id': MY_IRI, '@type': 'MyResource', value: 'bar', value2: 'quz' }, + d: { '@id': OTHER_IRI, '@type': 'MyResource', value: 'zig' }, + }; + let scheduler: MarbleTestScheduler<any>; + beforeEach(() => { + scheduler = MarbleTestScheduler.create(VALUES, 'error'); + }); + + describe('ValueHolder', () => { + let holder: ValueHolder<any>; + + beforeEach(() => { + holder = new ValueHolder<MyResource>(iri('/bla/a')); + }); + + it('should be created', () => { + expect(holder).toBeTruthy(); + }); + + describe('.set()', () => { + function testSet({ value, error, SET_M }: any) { + scheduler.withError(error).run(({ expectObservable }) => { + expectObservable(holder.set(value)).toBe(SET_M); + }); + } + + it('should provide the value', () => + testSet({ + value: VALUES.a, + SET_M: '(a|)', + })); + + it(`should refuse value without ${IRI_PROPERTY}`, () => + testSet({ + value: {}, + error: new MissingIRIError(), + SET_M: '#', + })); + + it('should refuse value with different @id', () => + testSet({ + value: { '@id': iri('bar') }, + error: new IRIMismatchError(MY_IRI, iri('bar')), + SET_M: '#', + })); + + it('should always points to the same instance', () => { + forkJoin(holder.set(VALUES.a), holder.set(VALUES.b)).subscribe(([a, b]: MyResource[]) => { + expect(a).toBe(b); + }); + }); + }); + + describe('.update()', () => { + it('should provide the value from the server', () => + scheduler.run(({ cold, expectObservable }) => { + const REQ_M = '---a|'; + const UPD_M = '--(a|) '; + + const request$ = cold(REQ_M); + expectObservable(holder.update(request$)).toBe(UPD_M); + })); + + it('should cancel pending requests', () => { + const LOCAL_VALUES = { + a: VALUES.a, + b: VALUES.b, + j: [VALUES.b, VALUES.b], + }; + const REQ1_M = '---a|'; + const REQ2_M = 'b| '; + + const UPDA_M = '(j|) '; + const REQ1_S = '(^!) '; + const REQ2_S = '(^!) '; + + scheduler.withValues(LOCAL_VALUES).run(({ cold, expectObservable, expectSubscriptions }) => { + const request1$ = cold(REQ1_M); + const request2$ = cold(REQ2_M); + + expectObservable( + safeForkJoin([ + // + holder.update(request1$), + holder.update(request2$), + ]), + ).toBe(UPDA_M); + expectSubscriptions(request1$.subscriptions).toBe(REQ1_S); + expectSubscriptions(request2$.subscriptions).toBe(REQ2_S); + }); + }); + + it('should propagate errors', () => + scheduler.run(({ cold, expectObservable }) => { + const REQ_M = '#'; + const UPD_M = '#'; + + const request$ = cold(REQ_M); + expectObservable(holder.update(request$)).toBe(UPD_M); + })); + + it('should restart on errors', () => + scheduler.run(({ cold, expectObservable }) => { + const REQ1_M = '#'; + const UPD1_M = '#'; + + const obs$ = holder.update(cold(REQ1_M)); + expectObservable(obs$).toBe(UPD1_M); + + const REQ2_M = '-a|'; + const UPD2_M = '-a|'; + + const obs2$ = holder.update(cold(REQ2_M)); + expectObservable(obs2$).toBe(UPD2_M); + })); + }); + + describe('.listen()', () => { + function testListen({ REQUEST_M, LISTEN_M, initial }: any) { + scheduler.run(({ cold, expectObservable }) => { + if (initial) { + holder.set(initial); + } + expectObservable(holder.listen(() => cold(REQUEST_M))).toBe(LISTEN_M); + }); + } + + it('should provide the value', () => + testListen({ + initial: VALUES.a, + REQUEST_M: /**/ ' ', + LISTEN_M: /***/ '(a|)', + })); + + it('should cache the value', () => + testListen({ + initial: VALUES.a, + REQUEST_M: /**/ 'b| ', + LISTEN_M: /***/ '(a|)', + })); + + it('should propagate errors', () => + testListen({ + REQUEST_M: /**/ '#', + LISTEN_M: /***/ '#', + })); + }); + + it('.invalidate() should cause the value to be requested again', () => + scheduler.run(({ cold, expectObservable }) => { + const REQUEST_M = /**/ '(a|)'; + const LISTEN_M = /***/ '(a|)'; + const requestFactory = jasmine.createSpy('requestFactory'); + requestFactory.and.returnValue(cold(REQUEST_M)); + + return holder + .set(VALUES.a) + .toPromise() + .then(() => holder.invalidate()) + .then(() => expectObservable(holder.listen(requestFactory)).toBe(LISTEN_M)) + .then(() => expect(requestFactory).toHaveBeenCalled()); + })); + }); + + describe('ResourceCache', () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [ResourceCache], + }), + ); + + it('should be created', inject([ResourceCache], (service: ResourceCache) => { + expect(service).toBeDefined(); + })); + + describe('.get()', () => { + it('should provide the value', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const getQuery$ = cold('a|'); + + expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('(a|)'); + }), + )); + + it('should cache the value', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const getQuery$ = cold('a|'); + const getQuery2$ = cold('b|'); + // tslint:disable-next-line:rxjs-finnish + const queries$ = cold('ab|', { a: getQuery$, b: getQuery2$ }); + + expectObservable(queries$.pipe(mergeMap((query$) => service.get(MY_IRI, () => query$)))).toBe( + 'aa|', + ); + }), + )); + + it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const getQuery$ = cold('#'); + + expectObservable(service.get(MY_IRI, () => getQuery$)).toBe('#'); + }), + )); + }); + + describe('.put()', () => { + it('should provide the value', inject([ResourceCache], (service: ResourceCache) => { + const putRequest$ = scheduler.createColdObservable('a|'); + + scheduler.expectObservable(service.put(MY_IRI, putRequest$)).toBe('a|'); + })); + + it('should not cache the value', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const putRequest$ = cold('a|'); + const putRequest2$ = cold('b|'); + // tslint:disable-next-line:rxjs-finnish + const requests$ = cold('ab|', { a: putRequest$, b: putRequest2$ }); + + expectObservable( + requests$.pipe( + mergeMap((request$: Observable<MyResource>) => service.put(MY_IRI, request$)), + map((x) => _.clone(x)), + ), + ).toBe('ab|'); + }), + )); + + it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const putRequest$ = cold('#'); + + expectObservable(service.put(MY_IRI, putRequest$)).toBe('#'); + }), + )); + }); + + describe('.post()', () => { + it('should provide the value', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const postRequest$ = cold('a|'); + + expectObservable(service.post(postRequest$)).toBe('a|'); + }), + )); + + it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const postRequest$ = cold('#'); + + expectObservable(service.post(postRequest$)).toBe('#'); + }), + )); + }); + + describe('.delete()', () => { + it('should clear the cache on successful fetch', inject([ResourceCache], (service: ResourceCache) => { + const response = new HttpHeaderResponse({ status: 200 }); + const values = { r: response, a: VALUES.a, b: VALUES.b }; + const sched = scheduler.withValues(values); + + sched.run(async ({ cold, expectObservable }) => { + await service.get(MY_IRI, () => cold('a|')).toPromise(); + await service.delete(MY_IRI, cold('r|')).toPromise(); + + return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(b|)'); + }); + })); + + it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const deleteRequest$ = cold('#'); + + expectObservable(service.delete(MY_IRI, deleteRequest$)).toBe('#'); + }), + )); + + it('should not clear the cache on error', inject([ResourceCache], (service: ResourceCache) => { + const error = new HttpHeaderResponse({ status: 500 }); + const values = { a: VALUES.a, b: VALUES.b, e: error }; + scheduler + .withValues(values) + .withError(error) + .run(async ({ cold, expectObservable }) => { + await service.get(MY_IRI, () => cold('a|')).toPromise(); + await service + .delete(MY_IRI, cold('#')) + .pipe(catchError((e) => e)) + .toPromise(); + + return expectObservable(service.get(MY_IRI, () => cold('b|'))).toBe('(a|)'); + }); + })); + }); + + describe('.getAll()', () => { + it('should provide the returned value', inject([ResourceCache], (service: ResourceCache) => { + const values = { + a: { 'hydra:member': [VALUES.a, VALUES.d], 'hydra:totalItems': 2 }, + }; + scheduler.withValues(values).run(({ cold, expectObservable }) => { + const getAllRequest$ = cold('a|'); + expectObservable(service.getAll(getAllRequest$)).toBe('a|'); + }); + })); + + it('should nicely handle empty collections', inject([ResourceCache], (service: ResourceCache) => { + const values = { + a: { 'hydra:member': [] as any, 'hydra:totalItems': 0 }, + }; + scheduler.withValues(values).run(({ cold, expectObservable }) => { + const getAllRequest$ = cold('a|'); + expectObservable(service.getAll(getAllRequest$)).toBe('a|'); + }); + })); + + it('should propagate errors', inject([ResourceCache], (service: ResourceCache) => + scheduler.run(({ cold, expectObservable }) => { + const getAllRequest$ = cold<Collection<MyResource>>('#'); + expectObservable(service.getAll(getAllRequest$)).toBe('#'); + }), + )); + + it('should populate the cache', inject([ResourceCache], (service: ResourceCache) => { + const values = { + a: VALUES.a, + b: VALUES.d, + h: { + 'hydra:member': [VALUES.a, VALUES.d], + 'hydra:totalItems': 2, + }, + }; + scheduler.withValues(values).run(({ cold, expectObservable }) => { + const getAllRequest$ = cold('h|'); + const getRequest$ = cold('b|'); + const requests$ = cold<() => Observable<any>>('ab|', { + a: () => service.getAll(getAllRequest$), + b: () => service.get(MY_IRI, () => getRequest$), + }); + + expectObservable( + requests$.pipe(mergeMap((sendRequest: () => Observable<any>) => sendRequest())), + ).toBe('ha|'); + }); + })); + }); + }); +}); diff --git a/src/ts/cache/cache.service.ts b/src/ts/cache/cache.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b3c7e9cd737f91a1cff86dc76064bac3349ac14 --- /dev/null +++ b/src/ts/cache/cache.service.ts @@ -0,0 +1,178 @@ +import { HttpResponseBase } from '@angular/common/http'; +import * as _ from 'lodash'; +import { Observable, of, race, Subject, throwError } from 'rxjs'; +import { map, switchMap, take, tap } from 'rxjs/operators'; + +import { + AbstractResourceCache, + Collection, + COLLECTION_MEMBERS, + getCollectionMembers, + IRI, + IRI_PROPERTY, + Resource, +} from '../../shared/models'; +import { safeForkJoin } from '../../shared/rxjs'; + +export class APICacheError extends Error {} + +export class MissingIRIError extends APICacheError { + public constructor() { + super(`resource must have an ${IRI_PROPERTY} property`); + } +} + +export class IRIMismatchError extends APICacheError { + public constructor(expected: any, actual: any) { + super(`${IRI_PROPERTY}s mismatch: ${actual} !== ${expected}`); + } +} + +/** + * ValueHolder gère les requêtes d'une seule ressource. + * + * @internal + */ +export class ValueHolder<R extends Resource> { + private readonly value$ = new Subject<R>(); + + private readonly value = {} as R; + private version = 0; + + constructor(private readonly iri: IRI<R>) {} + + public set(value: R): Observable<R> { + if (!(IRI_PROPERTY in value)) { + return throwError(new MissingIRIError()); + } + if (value[IRI_PROPERTY] !== this.iri) { + return throwError(new IRIMismatchError(this.iri, value[IRI_PROPERTY])); + } + + _.assign(this.value, value); + _(this.value) + .keys() + .difference(_.keys(value)) + .forEach((key) => delete this.value[key]); + this.version++; + this.value$.next(this.value); + + return of(this.value); + } + + public listen(queryFactory: () => Observable<R>): Observable<R> { + if (this.version > 0) { + return of(this.value); + } + return this.update(queryFactory()); + } + + public update(request$: Observable<R>): Observable<R> { + return race(this.value$.pipe(take(1)), request$.pipe(switchMap((item: R) => this.set(item)))); + } + + public invalidate(): void { + this.version = 0; + } + + public delete(): void { + this.value$.complete(); + } +} + +/** + * Implémentation d'un cache de resource. + * + * Cette implémentation met en place une queue de requête ainsi qu'un observable pour chaque ressource. + * + * La queue de requête permet de mettre à jour une ressource en cache. switchMap est utilisé pour prendre en compte + * les valeurs des dernières requêtes. + * + * L'Observable s'assure de retourner toujours la même référence d'objet tout au long de la vie + * de la ressource dans le cache, il permet aussi de faire suivre tout mise à jour à d'eventuels subscribers. + */ +export class ResourceCache extends AbstractResourceCache { + private readonly holders = new Map<IRI<Resource>, ValueHolder<Resource>>(); + + /** + * Retourne la ressource identifiée par l'IRI donné. + * + * Effectue une requête si on le connait pas. + */ + public get<R extends Resource>(iri: IRI<R>, requestFactory: () => Observable<R>): Observable<R> { + return this.getHolder(iri).listen(requestFactory); + } + + /** + * Envoie une requête de mise à jour puis met à jour le cache avec la réponse du serveur. + */ + public put<R extends Resource>(iri: IRI<R>, query$: Observable<R>): Observable<R> { + return this.getHolder(iri).update(query$); + } + + /** + * Envoie une requête de création puis met à jour le chache avec la réponse. + */ + public post<R extends Resource>(query$: Observable<R>): Observable<R> { + return query$.pipe(switchMap((item) => this.received(item))); + } + + /** + * Supprime une ressource sur le serveur puis en cache. + */ + public delete<R extends Resource>(iri: IRI<R>, query$: Observable<HttpResponseBase>): Observable<HttpResponseBase> { + return query$.pipe( + tap(() => { + if (!this.holders.has(iri)) { + return; + } + this.holders.get(iri).delete(); + this.holders.delete(iri); + }), + ); + } + + /** + * Fait une requête pour plusieurs ressources puis les mets en cache. + */ + public getAll<R extends Resource>(query$: Observable<Collection<R>>): Observable<Collection<R>> { + return query$.pipe( + switchMap((coll: Collection<R>) => { + const members = getCollectionMembers(coll); + const memberObservables$ = members.map((item) => this.received(item)); + return safeForkJoin(memberObservables$).pipe( + map((items) => Object.assign({} as Collection<R>, coll, { [COLLECTION_MEMBERS]: items })), + ); + }), + ); + } + + /** + * Invalide la valeur d'une IRI pour forcer une mise-à -jour. + */ + public invalidate<R extends Resource>(iri: IRI<R>): void { + if (!this.holders.has(iri)) { + return; + } + this.holders.get(iri).invalidate(); + } + + /** + * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire. + */ + private getHolder<R extends Resource>(iri: IRI<R>): ValueHolder<R> { + let holder = this.holders.get(iri) as ValueHolder<R>; + if (!holder) { + holder = new ValueHolder<R>(iri); + this.holders.set(iri, holder); + } + return holder; + } + + /** + * Retourne le ValueHolder d'une IRI, ou le crée si nécessaire. + */ + private received<R extends Resource>(item: R): Observable<R> { + return this.getHolder(item[IRI_PROPERTY]).set(item); + } +} diff --git a/src/ts/types/api.ts b/src/ts/types/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a2e7bb2698c4b073938e5ba08d77a6af03cd9f7 --- /dev/null +++ b/src/ts/types/api.ts @@ -0,0 +1,173 @@ +import { forkJoin, Observable } from 'rxjs'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { IRI, Resource } from './resource'; +import { IRIParameters, ResourceMetadata } from './metadata'; +import { AbstractRepository } from './repositories'; +import { AbstractResourceCache } from './cache'; + +/** + * Options supplémentaires pour les requêtes. + */ +export interface RequestOptions { + body?: any; + headers?: + | HttpHeaders + | { + [header: string]: string | string[]; + }; + params?: { [param: string]: string | string[] }; +} + +/** + * Sur-type encadrant des API. + */ +export interface APIMeta { + [type: string]: { + resource: Resource; + metadata: ResourceMetadata<any, any, IRIParameters>; + repository: AbstractRepository<any, any, IRIParameters>; + iriParameters: IRIParameters; + }; +} + +/** + * Classe abstraite d'un registre des metadonnées des ressources. + */ +export interface APIMetadataRegistry<API extends APIMeta> { + /** + * Vérifie si on a des métadonnées pour le type de ressourcce indiqué. + */ + has<T extends keyof API>(type: T): type is T; + + /** + * (Construit et) retourne l'instance de métadonnées pour le type de resource 'type'. + */ + get<T extends keyof API>(type: T): API[T]['metadata']; +} + +/** + * Registre de métadonnées qui construit les instances à la demande (ce qui permet de gérer les + * dépendances entre métadonnées). + */ +export class LazyMetadataRegistry<API extends APIMeta> implements APIMetadataRegistry<API> { + private readonly instances: { [T in keyof API]?: API[T]['metadata'] } = {}; + + protected constructor( + private readonly builders: { readonly [T in keyof API]: (r?: APIMetadataRegistry<API>) => API[T]['metadata'] }, + ) {} + + public has<T extends keyof API>(type: T): type is T { + return typeof type === 'string' && type in this.builders; + } + + public get<T extends keyof API>(type: T): API[T]['metadata'] { + if (!this.has(type)) { + throw new Error(`Invalid resource type: ${type}`); + } + return this.getOrCreate(type); + } + + protected getOrCreate<T extends keyof API>(type: T): API[T]['metadata'] { + if (!(type in this.instances)) { + this.instances[type] = this.builders[type](this); + } + return this.instances[type] as API[T]['metadata']; + } +} + +/** + * Classe abstraite de la façade de l'API. + */ +export interface APIRepositoryRegistry<API extends APIMeta> { + get<T extends keyof API>(type: T): API[T]['repository']; +} + +/** + * Service permettant d'accéder à l'API. + */ +export interface APIService<API extends APIMeta> { + /** + * Métadonnées de l'API. + */ + readonly metadata: APIMetadataRegistry<API>; + + /** + * Repositories de l'API + */ + readonly repositories: APIRepositoryRegistry<API>; + + /** + * Récupère une ressource par son IRI ou par son type et les paramètres de son IRI. + */ + get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R>; + get<T extends keyof API>( + type: T, + parameters: API[T]['iriParameters'], + options?: RequestOptions, + ): Observable<API[T]['resource']>; + + /** + * Récupère des ressources par leurs IRIs. + */ + getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]>; + + /** + * Génère l'IRI d'une resource à partir de son type et des paramètres d'IRI. + */ + generateIRI<T extends keyof API, P extends string[]>(type: T, parameters: P): IRI<any>; + + /** + * Invalide le cache pour une IRI. + */ + invalidate<R extends Resource>(iri: IRI<R>): void; +} + +/** + * Implémentation de base d'une api + */ +export abstract class AbstractAPIService< + API extends APIMeta, + MR extends APIMetadataRegistry<API>, + RR extends APIRepositoryRegistry<API> +> implements APIService<API> { + public constructor( + public readonly metadata: MR, + public readonly repositories: RR, + private readonly cache: AbstractResourceCache, + private readonly client: HttpClient, + ) {} + + public get<R extends Resource>(iri: IRI<R>, options?: RequestOptions): Observable<R>; + public get<T extends keyof API>( + type: T, + parameters: API[T]['iriParameters'], + options?: RequestOptions, + ): Observable<API[T]['resource']>; + + public get<T extends keyof API, R extends Resource = API[T]['resource']>( + typeOrIRI: T | IRI<R>, + parametersOrOptions?: API[T]['iriParameters'] | RequestOptions, + options?: RequestOptions, + ): Observable<R> { + let iri: IRI<R>; + if (this.metadata.has(typeOrIRI as string)) { + iri = this.metadata.get(typeOrIRI as string).generateIRI(parametersOrOptions as API[T]['iriParameters']); + } else { + iri = typeOrIRI as IRI<R>; + options = parametersOrOptions as RequestOptions; + } + return this.cache.get(iri, () => this.client.get<R>(iri as any, options)); + } + + public getMany<R extends Resource>(iris: IRI<R>[], options?: RequestOptions): Observable<R[]> { + return forkJoin(iris.map((iri) => this.get(iri, options))); + } + + public generateIRI<T extends keyof API>(type: T, parameters: API[T]['iriParameters']): IRI<API[T]['resource']> { + return this.metadata.get(type).generateIRI(parameters); + } + + public invalidate<R extends Resource>(iri: IRI<R>): void { + this.cache.invalidate(iri); + } +} diff --git a/src/ts/types/cache.ts b/src/ts/types/cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ec31f72111284f5af6c0d48e80e9e25a3ba7276 --- /dev/null +++ b/src/ts/types/cache.ts @@ -0,0 +1,54 @@ +import { IRI, Resource } from './resource'; +import { Observable } from 'rxjs'; +import { HttpResponseBase } from '@angular/common/http'; +import { Collection } from './collection'; + +/** + * AbstractResourceCache est une classe abstraite qui définit l'interface d'un cache de ressources. + * + * Elle doit être implémentée puis être fournie en provider d'un module core. Par exemple: + * + * final class ResourceCache<T extends Resource> extends AbstractResourceCache<T> { + * // Implémentation + * } + * + * providers: [ + * [ provider: AbstractResourceCache, useClass: ResourceCache ], + * ] + */ + +export abstract class AbstractResourceCache { + /** + * Récupère une ressource par son IRI. N'exécute la requête requestFactory que si on ne dispose + * pas d'une version en cache. + */ + public abstract get<R extends Resource>(iri: IRI<R>, requestFactory: () => Observable<R>): Observable<R>; + + /** + * Met à jour une ressource existante, rafraîchit le cache local avec la réponse. + */ + public abstract put<R extends Resource>(iri: IRI<R>, request: Observable<R>): Observable<R>; + + /** + * Crée une nouvelle ressource et met la ressource créée dans le cache. + */ + public abstract post<R extends Resource>(request: Observable<R>): Observable<R>; + + /** + * Supprime une ressource en distant et dans le cache. + */ + public abstract delete<R extends Resource>( + iri: IRI<R>, + request: Observable<HttpResponseBase>, + ): Observable<HttpResponseBase>; + + /** + * Effectue une recherche et met en cache toutes les ressources récupérées. + */ + public abstract getAll<R extends Resource>(request: Observable<Collection<R>>): Observable<Collection<R>>; + + /** + * Invalide une ressource en cache. + */ + public abstract invalidate<R extends Resource>(iri: IRI<R>): void; +} diff --git a/src/ts/types/collection.ts b/src/ts/types/collection.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6a34e0c99e0b643859d7c500cf84ce9e05556a0 --- /dev/null +++ b/src/ts/types/collection.ts @@ -0,0 +1,35 @@ +import { Resource, JSONValue } from './resource'; + +/** + * Nom de la propriété contenant les resource d'une collection. + */ +export const COLLECTION_MEMBERS = 'hydra:member'; + +/** + * Nom de la propriété contenant le nombre total d'objet d'une collection. + */ +export const COLLECTION_TOTAL_COUNT = 'hydra:totalItems'; + +/** + * Collection représente une collection de respoucres JSON-LD pour un type T donné. + */ +export interface Collection<R extends Resource> { + [COLLECTION_MEMBERS]: R[]; + [COLLECTION_TOTAL_COUNT]: number; + [property: string]: JSONValue; +} + +/** + * Retourne les membres d'une collection. + */ +export function getCollectionMembers<R extends Resource>(collection: Collection<R>): R[] { + return collection[COLLECTION_MEMBERS]; +} + +/** + * Retourne le nombre total d'items + * @param collection + */ +export function getCollectionTotalCount<R extends Resource>(collection: Collection<R>): number { + return collection[COLLECTION_TOTAL_COUNT]; +} diff --git a/src/ts/types/index.ts b/src/ts/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a4d5409a17580928849d57d0c5743f6310d7a72 --- /dev/null +++ b/src/ts/types/index.ts @@ -0,0 +1,3 @@ +export * from './resource'; +export * from './collection'; +export * from './misc'; diff --git a/src/ts/types/interfaces.ts b/src/ts/types/interfaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f88283a381095e4cc1eb7432e07fa853db70fd8 --- /dev/null +++ b/src/ts/types/interfaces.ts @@ -0,0 +1,3 @@ +import { JSONValue, Resource } from './resource'; +import { IRIParameters, ResourceMetadata } from './metadata'; +import { AbstractRepository } from './common'; diff --git a/src/ts/types/metadata.ts b/src/ts/types/metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffd833c06b726e9bec6f9209bfb083ecccf5e763 --- /dev/null +++ b/src/ts/types/metadata.ts @@ -0,0 +1,102 @@ +/** + * Type des paramètrès + */ +import { IRI, Resource, TYPE_PROPERTY } from './resource'; + +type IRIParameter = string | number; +export type IRIParameters = IRIParameter[] | IRIParameter; + +/** + * Informations sur l'IRI d'une resource. + * + * P: types des paramètres. + */ +export class IRIMetadata<P extends IRIParameters> { + public constructor( + private readonly testPattern: RegExp, + private readonly capturePattern: RegExp, + private readonly template: (parameters: P) => string, + ) {} + + public validate(path: string): boolean { + return this.testPattern.test(path); + } + + public generate(parameters: P): IRI<any> { + return this.template(parameters) as any; + } + + public parse(path: string): P { + const matches = this.capturePattern.exec(path); + if (!matches) { + throw new Error(`Invalid path: ${path} does not match ${this.capturePattern}`); + } + if (matches.length == 2) { + return matches[1] as any; + } + return matches.slice(1) as any; + } +} + +function hasTypeProperty(that: unknown): that is { [TYPE_PROPERTY]: unknown } { + return typeof that === 'object' && that !== null && TYPE_PROPERTY in that; +} + +function getResourceType(that: unknown): string | null { + if (hasTypeProperty(that)) { + const type = that[TYPE_PROPERTY]; + if (typeof type === 'string') { + return type; + } + } + return null; +} + +/** + * Metadonnées d'une ressource. + * + * R : type de resource, e.g. Person + * T : valeur de la propriété '@type', e.g. 'Person'. + */ +export class ResourceMetadata<R extends Resource, T extends string, P extends IRIParameters> { + public constructor( + public readonly type: T, + public readonly iri: IRIMetadata<P>, + private readonly requiredProperties: (keyof R)[], + private readonly types: ResourceMetadata<any, any, any>[] = [], + ) { + this.types.unshift(this); + } + + /** + * Vérifie si l'argument représente une ressource R ou dérivée. + */ + public isResource(that: unknown): that is R { + const type = getResourceType(that); + if (!type) { + return false; + } + return this.types.some((t) => t.type === type); + } + + /** + * Vérifie si une propriété est obligatoire dans la resource R. + */ + public isRequired(property: keyof R): boolean { + return this.requiredProperties.includes(property); + } + + /** + * Génère une IRI à partir de ses paramètres. + */ + public generateIRI(parameters: P): IRI<R> { + return this.iri.generate(parameters); + } + + /** + * Extrait les paramètres d'une IRI. + */ + public getIRIParameters(iri: IRI<R>): P { + return this.iri.parse(iri as any); + } +} diff --git a/src/ts/types/misc.spec.ts b/src/ts/types/misc.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5a700ffcf3fde5ef6a361f38f9207596bb0b21c --- /dev/null +++ b/src/ts/types/misc.spec.ts @@ -0,0 +1,35 @@ +import { isDateTime } from './misc'; + +describe('isDateTime', () => { + const itShouldRejectType = (value: unknown) => + it(`should reject ${JSON.stringify(value)}`, () => expect(isDateTime(value)).toBeFalsy()); + + itShouldRejectType(false); + itShouldRejectType(null); + itShouldRejectType(undefined); + itShouldRejectType([]); + itShouldRejectType({ value: 'foo' }); + + const itShouldReject = (value: string) => it(`should reject ${value}`, () => expect(isDateTime(value)).toBeFalsy()); + + itShouldReject('foobar'); + itShouldReject('2000-01-32'); + itShouldReject('2000-01-32T00:00'); + + itShouldReject('2000-01-32T00:00:00Z'); + itShouldReject('2000-13-00T00:00:00Z'); + itShouldReject('2000-26-00T00:00:00Z'); + itShouldReject('2000-01-01T25:00:00Z'); + itShouldReject('2000-01-01T35:00:00Z'); + itShouldReject('2000-01-01T00:60:00Z'); + itShouldReject('2000-01-01T00:00:72Z'); + itShouldReject('2000-01-51T00:00:00Z'); + + const itShouldAccept = (value: string) => + it(`should accept ${value}`, () => expect(isDateTime(value)).toBeTruthy()); + + for (let t = Date.parse('2000-01-01T00:00:00Z'); t < Date.parse('2001-01-01T00:00:00Z'); t += 86400000) { + const dateStr = new Date(t).toISOString(); + itShouldAccept(dateStr); + } +}); diff --git a/src/ts/types/misc.ts b/src/ts/types/misc.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd7fb16a75efb96b99662da62e07162bab727709 --- /dev/null +++ b/src/ts/types/misc.ts @@ -0,0 +1,24 @@ +/** + * Full DateTime in ISO-8601 format. + */ +export type DateTime = string; + +const DateTimeRegex = /^\d{4}-(?:0\d|1[012])-(?:3[01]|[012]\d)T(?:2[0-3]|[01]\d):[0-5]\d:[0-5]\d(?:\.\d+)?(?:[-+]\d{2}:\d{2}|Z)$/iu; + +export function isDateTime(data: unknown): data is DateTime { + return typeof data === 'string' && DateTimeRegex.test(data) && !Number.isNaN(Date.parse(data)); +} + +/** + * Universally Unique Identifier - RFC 4122 + */ +export type UUID = string; + +const UUIDRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +/** + * Teste si une donnée (chaîne) est formatée selon un UUID. + */ +export function isUUID(data: unknown): data is UUID { + return typeof data === 'string' && UUIDRegex.test(data); +} diff --git a/src/ts/types/repositories.ts b/src/ts/types/repositories.ts new file mode 100644 index 0000000000000000000000000000000000000000..788d1bb78db6ee7f764343639ff7f660b4cf763a --- /dev/null +++ b/src/ts/types/repositories.ts @@ -0,0 +1,29 @@ +import { HttpClient } from '@angular/common/http'; +import { IRI, Resource } from './resource'; +import { IRIParameters, ResourceMetadata } from './metadata'; +import { AbstractResourceCache } from './cache'; + +/** + * Classe de base d'un repository. + */ +export abstract class AbstractRepository<R extends Resource, T extends string, P extends IRIParameters> { + public constructor( + public readonly metadata: ResourceMetadata<R, T, P>, + protected readonly client: HttpClient, + protected readonly cache: AbstractResourceCache, + ) {} + + /** + * Génère une IRI à partir de ses paramètres. + */ + public generateIRI(parameters: P): IRI<R> { + return this.metadata.generateIRI(parameters); + } + + /** + * Extrait les paramètres d'une IRI. + */ + public getIRIParameters(iri: IRI<R>): P { + return this.metadata.getIRIParameters(iri); + } +} diff --git a/src/ts/types/resource.ts b/src/ts/types/resource.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a56ed0715ba26bb426ec9440dc60292be857539 --- /dev/null +++ b/src/ts/types/resource.ts @@ -0,0 +1,43 @@ +/** + * Nom de la propriété d'une resource contenant son IRI. + */ +export const IRI_PROPERTY = '@id'; + +/** + * Nom de la propriété d'une resource contenant son type. + */ +export const TYPE_PROPERTY = '@type'; + +/** + * IRI typé. + * + * Internationalized Resource Identifier - RFC 3987 + */ +const IRI = Symbol('IRI'); + +/* Les IRI sont en fait des chaînes mais pour forcer un typage fort on les définit + * comme un type "opaque". De cette façon, il est impossible de mélanger + * IRI et chaînes, et le type générique R permet d'interdire les assignations entre + * IRI de resources différentes. + */ +export interface IRI<R extends Resource> { + readonly [IRI]?: R; +} + +/** + * Resource + */ +export interface Resource { + readonly [IRI_PROPERTY]: IRI<any>; + readonly [TYPE_PROPERTY]: string; + [property: string]: JSONValue; +} + +/** + * Sous-typage de valeurs JSON + */ +export type JSONValue = string | IRI<any> | number | null | JSONArray | JSONObject | Resource; +export type JSONArray = Array<JSONValue>; +export interface JSONObject { + [key: string]: JSONValue; +}