Commit 2afefafc authored by Guillaume Perréal's avatar Guillaume Perréal
Browse files

Débute l'écriture du module TS.

parent f742e178
...@@ -149,7 +149,6 @@ final class ModelGenerator ...@@ -149,7 +149,6 @@ final class ModelGenerator
} }
sort($context['repoImports']); sort($context['repoImports']);
$this->generateFile($writer, 'common.ts', $context);
$this->generateFile($writer, 'resources.ts', $context); $this->generateFile($writer, 'resources.ts', $context);
$this->generateFile($writer, 'repositories.ts', $context); $this->generateFile($writer, 'repositories.ts', $context);
$this->generateFile($writer, 'metadata.ts', $context); $this->generateFile($writer, 'metadata.ts', $context);
......
{% 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 %}
{% extends '@NgModelGenerator/_layout.ts.twig' %} {% extends '@NgModelGenerator/_layout.ts.twig' %}
{% block content %} {% block content %}
export * from './common'; export * from 'irstea-ng-model/types';
export * from './resources'; export * from './resources';
export * from './repositories'; export * from './repositories';
export * from './metadata'; export * from './metadata';
......
...@@ -6,7 +6,6 @@ ...@@ -6,7 +6,6 @@
{% autoescape false %} {% autoescape false %}
import { Injectable, Provider } from '@angular/core'; import { Injectable, Provider } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { import {
AbstractAPIService, AbstractAPIService,
AbstractResourceCache, AbstractResourceCache,
...@@ -16,7 +15,8 @@ import { ...@@ -16,7 +15,8 @@ import {
LazyMetadataRegistry, LazyMetadataRegistry,
ResourceMetadata, ResourceMetadata,
UUID, UUID,
} from './common'; } from 'irstea-ng-model/types';
import { import {
{% for repo in repositories %} {% for repo in repositories %}
{{ repo.usage }}{% if not loop.last %},{% endif %} {{ repo.usage }}{% if not loop.last %},{% endif %}
......
...@@ -8,7 +8,7 @@ import { Observable } from 'rxjs'; ...@@ -8,7 +8,7 @@ import { Observable } from 'rxjs';
import { HttpResponseBase } from '@angular/common/http'; import { HttpResponseBase } from '@angular/common/http';
// @ts-ignore // @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 %} import { {% for name in repoImports %}
{{ name }}{% if not loop.last %}, {% endif %} {{ name }}{% if not loop.last %}, {% endif %}
{%- endfor %} {%- endfor %}
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
{% block content %} {% block content %}
{% autoescape false %} {% autoescape false %}
// @ts-ignore // @ts-ignore
import { DateTime, IRI, UUID } from './common'; import { DateTime, IRI, UUID } from 'irstea-ng-model/types';
/********************************************************************************* /*********************************************************************************
* Ressources * Ressources
......
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);