diff --git a/.gitignore b/.gitignore index afdcfdbb3b765d079a0d72032c98a537ed52da5b..3113a65baaa818c4de238caf6cb724130b4992b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /vendor /composer.lock /dist +/out /node_modules .php_cs.*cache .idea diff --git a/composer.json b/composer.json index 794c815b00bf575641a11c4fa966a4e0e6dd2ada..51d0020dca849eb56888d3d1d7ec6387f1e31b40 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,9 @@ "sebastian/phpcpd": "^4.0" }, "scripts": { + "post-install-cmd": "@nodejs-build", + "post-update-cmd": "@nodejs-build", + "nodejs-build": "npm install && npm run-script build || exit 0", "fix-cs": "@php vendor/bin/php-cs-fixer fix --verbose", "phploc": "@php vendor/bin/phploc --no-interaction src/php tests", "test": [ diff --git a/package-lock.json b/package-lock.json index 7b82f5b2cce22f6c310df1d259770bfc2287ecce..db7ae56286e6dcef07e1a416641a736007c43014 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 7466024a1227ef4c5ddfa552ea6d74a2dfdc9739..cbe80b8ddbf2f6daf9a1cf9750c655ea3e9da6fb 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,16 @@ "name": "irstea-ng-model", "version": "1.0.0", "description": "Runtime libray for the composer package irstea/ng-model-generator-bundle.", + "types": "dist/index.d.ts", "main": "dist/index.js", + "module": "dist/index.esm.js", "directories": {}, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "prebuild": "rm -rf dist/ && tsc -p ./tsconfig.declaration.json", + "build": "tsc -p . && rollup -c", + "postbuild": "rm -rf out", + "clean": "rm -rf dist/* out/*" }, "repository": { "type": "git", @@ -18,26 +24,54 @@ "author": "Irstea - pôle IS", "license": "LGPL-3.0-or-later", "peerDependencies": { - "@angular/common": "^7.2.9", - "@angular/core": "^7.2.9" + "@angular/common": "^7.2.15" }, "dependencies": { "lodash": "^4.17.11", - "rxjs": "^6.4.0", - "rxjs-etc": "^9.4.0" + "rxjs": "^6.5.2", + "rxjs-etc": "^9.5.0", + "tslib": "^1.9.3" }, "devDependencies": { - "@angular/common": "^7.2.9", - "@angular/core": "^7.2.9", - "irstea-typescript-config": "^1.0.3", - "prettier": "^1.16.4", + "@angular/common": "^7.2.15", + "@angular/compiler": "^7.2.15", + "@angular/core": "^7.2.15", + "codelyzer": "^5.0.1", + "husky": "^2.3.0", + "irstea-typescript-config": "^1.0.6", + "lint-staged": "^8.1.7", + "prettier": "^1.17.1", "prettier-tslint": "^0.4.2", - "rxjs-marbles": "^5.0.0", - "rxjs-tslint-rules": "^4.18.2", - "tslint": "^5.14.0", + "rollup": "^1.12.1", + "rollup-plugin-node-resolve": "^5.0.0", + "rxjs-marbles": "^5.0.2", + "rxjs-tslint-rules": "^4.23.1", + "tslint": "^5.16.0", "tslint-config-prettier": "^1.18.0", + "tslint-defocus": "^2.0.6", "tslint-plugin-prettier": "^2.0.1", - "typescript": "^3.3.3333", + "typescript": "^3.4.5", "zone.js": "~0.8.26" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "src/**/*.{json,md}": [ + "prettier --write", + "git add" + ], + "src/**/*.ts": [ + "prettier-tslint fix", + "git add" + ] + }, + "prettier": { + "printWidth": 80, + "semi": true, + "singleQuote": true, + "trailingComma": "es5" } } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000000000000000000000000000000000000..5911d8511287b426e4584031be3cc3539887d232 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,16 @@ +import resolve from 'rollup-plugin-node-resolve'; + +import pkg from './package.json'; + +export default [ + // CommonJS (for Node) and ES module (for bundlers) build. + { + input: 'out/index.js', + external: Object.keys(pkg.dependencies).concat(Object.keys(pkg.peerDependencies)), + output: [ + { file: pkg.main, format: 'cjs' }, + { file: pkg.module, format: 'es' }, + ], + plugins: [resolve()], + }, +]; 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/index.ts b/src/ts/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..26160106861f5263eb0a59ace616e6c096ac3ec0 --- /dev/null +++ b/src/ts/index.ts @@ -0,0 +1,3 @@ +export * from './metadata'; +export * from './service'; +export * from './types'; diff --git a/src/ts/metadata/index.ts b/src/ts/metadata/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..93f3e0674e98b5d011a0f7ea02c9bf63fb739c40 --- /dev/null +++ b/src/ts/metadata/index.ts @@ -0,0 +1,3 @@ +export * from './iri.metadata'; +export * from './resource.metadata'; +export * from './registry'; diff --git a/src/ts/metadata/iri.metadata.ts b/src/ts/metadata/iri.metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f34dd3470ef8989ef162ed4bc34756e186a54a7 --- /dev/null +++ b/src/ts/metadata/iri.metadata.ts @@ -0,0 +1,40 @@ +import { IRI } from '../types'; + +/** + * Type des paramètres + */ +export type IRIParameters = string[] | string; + +/** + * 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; + } +} diff --git a/src/ts/metadata/registry.ts b/src/ts/metadata/registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c01077f2797e3abbf8afdafce38fa844ec84e21 --- /dev/null +++ b/src/ts/metadata/registry.ts @@ -0,0 +1,68 @@ +import { AbstractRepository } from '../service'; +import { Resource } from '../types'; + +import { IRIParameters } from './iri.metadata'; +import { ResourceMetadata } from './resource.metadata'; + +/** + * 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 = {} as { [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'] { + let metadata = this.instances[type]; + if (!metadata) { + metadata = this.instances[type] = this.builders[type](this); + } + return metadata; + } +} diff --git a/src/ts/metadata/resource.metadata.ts b/src/ts/metadata/resource.metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed75e538a4c987a0d25fbdf31f9afcb2cc6f9a24 --- /dev/null +++ b/src/ts/metadata/resource.metadata.ts @@ -0,0 +1,56 @@ +import { getResourceType, IRI, Resource } from '../types'; + +import { IRIMetadata, IRIParameters } from './iri.metadata'; + +/** + * 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: Array<keyof R>, + private readonly types: Array<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: 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/service/abstract-repository.ts b/src/ts/service/abstract-repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..0620ded78bea8340b1113f277f8ab4f2e76d40c3 --- /dev/null +++ b/src/ts/service/abstract-repository.ts @@ -0,0 +1,42 @@ +import { HttpClient } from '@angular/common/http'; + +import { APIMeta, IRIParameters, ResourceMetadata } from '../metadata'; +import { IRI, Resource } from '../types'; + +import { AbstractResourceCache } from './abstract-resource-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); + } +} + +/** + * 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']; +} diff --git a/src/ts/service/abstract-resource-cache.ts b/src/ts/service/abstract-resource-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..558a87e2916ea59db84de82af670606ca92e5cd6 --- /dev/null +++ b/src/ts/service/abstract-resource-cache.ts @@ -0,0 +1,63 @@ +import { HttpResponseBase } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { Collection, IRI, Resource } from '../types'; + +/** + * 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/service/api.ts b/src/ts/service/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..19e9d12183bbffc62151e115b4c7027ead802860 --- /dev/null +++ b/src/ts/service/api.ts @@ -0,0 +1,128 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { forkJoin, Observable } from 'rxjs'; + +import { APIMeta, APIMetadataRegistry } from '../metadata'; +import { IRI, Resource } from '../types'; + +import { APIRepositoryRegistry } from './abstract-repository'; +import { AbstractResourceCache } from './abstract-resource-cache'; + +/** + * Options supplémentaires pour les requêtes. + */ +export interface RequestOptions { + body?: any; + headers?: + | HttpHeaders + | { + [header: string]: string | string[]; + }; + params?: { [param: string]: string | string[] }; +} + +/** + * 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: Array<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: Array<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/service/index.ts b/src/ts/service/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..269a008b0b2105e404208e17282f688faf696b2e --- /dev/null +++ b/src/ts/service/index.ts @@ -0,0 +1,3 @@ +export * from './abstract-repository'; +export * from './abstract-resource-cache'; +export * from './api'; diff --git a/src/ts/types/collection.ts b/src/ts/types/collection.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c286890efc3e6b8076c8fc33b8f4c690b90aac0 --- /dev/null +++ b/src/ts/types/collection.ts @@ -0,0 +1,39 @@ +import { Resource } 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]: 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]; +} diff --git a/src/ts/types/date-time.ts b/src/ts/types/date-time.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4047319c3ecb0c17e261c8f81386bf6bf26bc62 --- /dev/null +++ b/src/ts/types/date-time.ts @@ -0,0 +1,4 @@ +/** + * Full DateTime in ISO-8601 format. + */ +export type DateTime = string; diff --git a/src/ts/types/index.ts b/src/ts/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0568a263e8deaf50787ce669de81e26de17ae82 --- /dev/null +++ b/src/ts/types/index.ts @@ -0,0 +1,4 @@ +export * from './collection'; +export * from './date-time'; +export * from './resource'; +export * from './uuid'; diff --git a/src/ts/types/resource.ts b/src/ts/types/resource.ts new file mode 100644 index 0000000000000000000000000000000000000000..1848919b7e8e38736d2244240ee8d249885b6619 --- /dev/null +++ b/src/ts/types/resource.ts @@ -0,0 +1,53 @@ +/** + * 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> extends String { + readonly [IRI]?: R; +} + +/** + * 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'; + +/** + * Resource + */ +export interface Resource { + readonly [IRI_PROPERTY]: IRI<any>; + readonly [TYPE_PROPERTY]: string; + [property: string]: any; +} + +/** + * Vérifie que l'argument est une ressource. + */ +export function isResource(what: unknown): what is Resource { + return ( + typeof what === 'object' && + what !== null && + TYPE_PROPERTY in what && + typeof (what as any)[TYPE_PROPERTY] === 'string' + ); +} + +/** + * Retourne le type d'une ressource, ou null + */ +export function getResourceType(that: unknown): string | null { + return isResource(that) ? that[TYPE_PROPERTY] : null; +} diff --git a/src/ts/types/uuid.ts b/src/ts/types/uuid.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dda7a7f1b505b4f298cf194341b2dae078f6c24 --- /dev/null +++ b/src/ts/types/uuid.ts @@ -0,0 +1,16 @@ +/** + * 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: unknown): 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 + ) + ); +} diff --git a/tsconfig.declaration.json b/tsconfig.declaration.json new file mode 100644 index 0000000000000000000000000000000000000000..ef88678a036029c12eefbac255413a1b75cb2dc3 --- /dev/null +++ b/tsconfig.declaration.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/ts/index.ts"], + "compilerOptions": { + "outFile": "dist/index.d.ts", + "declaration": true, + "emitDeclarationOnly": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 48c1e4955e1a4137fb24fdc9680564539506e0e0..4764da3b945c802afe1eaf14c36ac9e8287198da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,29 @@ { "extends": "./node_modules/irstea-typescript-config/tsconfig.json", "compileOnSave": false, + "include": ["src/ts/**/*"], + "exclude": ["src/ts/**/*.spec.ts"], "compilerOptions": { - "outDir": "./dist/out-tsc", - "sourceMap": false, - "inlineSourceMap": false, - "declaration": false, + "baseUrl": "./", + "paths": {}, + "strict": true, + "lib": ["dom", "es2017"], + "typeRoots": ["node_modules/@types"], + "outDir": "out/", + "target": "es5", + "module": "esnext", "moduleResolution": "node", + "declaration": false, + "importHelpers": true, + "noEmitHelpers": true, + "noEmitOnError": true, + "pretty": true, + "removeComments": true, + "sourceMap": true, + "stripInternal": true, + "inlineSourceMap": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, - "importHelpers": true, - "target": "es5", - "typeRoots": [ - "node_modules/@types" - ], - "lib": [ - "es2017", - "dom" - ], "locale": "fr" } }