Commit 0f163805 authored by Guillaume Perréal's avatar Guillaume Perréal
Browse files

Refactoring des metadonnées générées.

parent de1d2100
{% extends '@NgModelGenerator/_layout.ts.twig' %}
{% block content %}
import { HttpResponseBase } from '@angular/common/http';
import { HttpClient, HttpResponseBase } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Typage flexible d'une ressource.
* Nom de la propriété d'une resource contenant son IRI.
*/
export interface GenericResource {
readonly '@id': string;
[propertyName: string]: any;
}
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
*/
export type IRI<T extends GenericResource> = string;
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> {
readonly [ IRI ]?: R;
}
/**
* Collection représente une collection de respoucres JSON-LD pour un type T donné.
*/
export interface Collection<T extends GenericResource> {
'hydra:member': T[];
export interface Collection<R> {
'hydra:member': R[];
'hydra:totalItems': number;
}
/**
* 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;
/**
* AbstractResourceCache est une classe abstraite qui définit l'interface d'un cache de ressources.
*
......@@ -40,49 +68,120 @@ export interface Collection<T extends GenericResource> {
* [ provider: AbstractResourceCache, useClass: ResourceCache ],
* ]
*/
export abstract class AbstractResourceCache<T extends GenericResource> {
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(iri: IRI<T>, requestFactory: () => Observable<T>): Observable<T>;
public abstract get<R extends { readonly [IRI_PROPERTY]: IRI<any> }>(
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(iri: IRI<T>, request: Observable<T>): Observable<T>;
public abstract put<R>(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(request: Observable<T>): Observable<T>;
public abstract post<R extends { readonly [IRI_PROPERTY]: IRI<any> }>(request: Observable<R>): Observable<R>;
/**
* Supprime une ressource en distant et dans le cache.
*/
public abstract delete(iri: IRI<T>, request: Observable<HttpResponseBase>): Observable<HttpResponseBase>;
public abstract delete<R>(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(request: Observable<Collection<T>>): Observable<Collection<T>>;
public abstract getAll<R extends { readonly [IRI_PROPERTY]: IRI<any> }>(
request: Observable<Collection<R>>
): Observable<Collection<R>>;
/**
* Invalide une ressource en cache.
*/
public abstract invalidate<R>(iri: IRI<R>): void;
}
/**
* Universally Unique IDentifier - RFC 4122
* Informations sur l'IRI d'une resource.
*
* P: types des paramètres.
*/
export type UUID = string;
export class IRIMetadata<P extends string[]> {
public constructor(
private readonly testPattern: RegExp,
private readonly capturePattern: RegExp,
public readonly generate: (parameters: P) => string
) {}
public validate(path: string): boolean {
return this.testPattern.test(path);
}
public parse(path: string): P {
const matches = this.capturePattern.exec(path);
if (!matches) {
throw new Error(`Invalid path: ${path} does not match ${this.capturePattern}`);
}
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
);
}
/**
* Teste si une donnée (chaîne) est formatée selon un UUID.
* Metadonnées d'une ressource.
*
* R : type de resource, e.g. Person
* T : valeur de la propriété '@type', e.g. 'Person'.
*/
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);
export class ResourceMetadata<R, T> {
public constructor(
public readonly type: T,
private readonly iri: IRIMetadata<any>,
private readonly requiredProperties: (keyof R)[],
private readonly types: ResourceMetadata<any, any>[] = []
) {
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);
}
}
/**
* Full DateTime in ISO-8601 format.
* Classe de base d'un repository.
*/
export type DateTime = string;
export abstract class AbstractRepository<R, T> {
public constructor(
public readonly metadata: ResourceMetadata<R, T>,
protected readonly client: HttpClient,
protected readonly cache: AbstractResourceCache
) {}
}
{% endblock content %}
......@@ -2,7 +2,11 @@
{% block content %}
{% autoescape false %}
import { IRI, UUID } from './common';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { IRI, AbstractResourceCache, IRIMetadata, ResourceMetadata, UUID, IRI_PROPERTY } from './common';
import {
{% for repo in repositories %}
{{ repo.resourceName }}{% if not loop.last %},{% endif %}
......@@ -15,302 +19,136 @@ import {
} from './repositories';
/**
* Liste de toutes les classes de repositories.
*
* Elle peut être utilisée pour définir massivement les providers dans un NgModule :
* providers: [
* ...REPOSITORIES
* ]
* Interface permettant de faire le lien entre le "@type", la ressource, les metadonnées et son repository.
*/
export const REPOSITORIES = [
{% for repo in repositories %}
{{ repo.usage }},
{% endfor %}
];
interface Types {
{%- for repo in repositories -%}
/**
* Union de tous les types de ressources.
*/
export type Resource = {% for repo in repositories -%}
{%- if not loop.first %} | {% endif -%}
{{- repo.resourceName -}}
{%- endfor -%}
;
/**
* Métatypes des ressources.
*
* Cette interface peut être utilisée pour du typage en fonction d'un @type de resource.
*/
export interface Metatypes {
{% for repo in repositories %}
{{ repo.resourceName | objectKey }}: {
identifier: { key: {{ repo.identifier.name | quoteString }}, type: {{ repo.identifier.type.usage }} };
resource: {{ repo.resourceName }};
repository: typeof {{ repo.usage }};
repository: {{ repo.usage }};
metadata: ResourceMetadata<{{ repo.resourceName }}, {{ repo.resourceName | quoteString }}>;
};
{% endfor %}
}
/**
* Type des clefs de Metatypes.
* Ensemble des valeurs valides de @type.
*/
export type ResourceType = keyof Metatypes;
type ResourceTypes = keyof Types;
/**
* Type des instances d'une ressource de type R.
* Alias pour faciliter le typage MetadataRegistry.
*/
export type ResourceModel<T extends ResourceType> = Metatypes[T]['resource'];
type MetadataRegistryType = { [T in ResourceTypes]?: Types[T]['metadata'] };
/**
* Metadonnées d'une propriété d'une ressource.
*
* R : type de Resource, e.g. Person.
*/
export class PropertyMetadata<R> {
public constructor(
public readonly name: keyof R,
public readonly required: boolean,
public readonly readonly: boolean
) {}
}
/**
* Metadonnées des IRI d'une ressource.
* R : type de Resource, e.g. Person.
* P : paramètres de l'IRI, e.g. [UUID]
* Metadonnées des ressources.
*/
export abstract class IRIMetadata<R extends Resource, P> {
protected constructor(private readonly property: keyof R) {}
/**
* Extrait l'IRI d'une ressource.
*/
public getIRI(resource: R): IRI<R> | null {
const iri = resource[this.property];
return typeof iri === "string" ? iri : null;
}
/**
* Vérifie si l'argument représente une IRI valide.
*/
public abstract check(that: any): that is IRI<R>;
/**
* Retourne les paramètres d'une IRI.
*/
public abstract parse(iri: IRI<R>): P;
/**
* Génère une IRI à partir de ses paramètres.
*/
public abstract generate(parameters: P): IRI<R>;
}
@Injectable()
export class MetadataRegistry {
private readonly instances: Partial<MetadataRegistryType> = {};
private readonly builders: {
readonly [T in ResourceTypes]: (r?: MetadataRegistryType) => MetadataRegistryType[T]
} = {
{%- for repo in repositories -%}
{%- set name = repo.resourceName -%}
{%- set subTypes -%}
{%- for type in repo.atTypes if type.name != name %}r.{{ type.name }}, {% endfor -%}
{%- endset -%}
{{ name | objectKey }}: {{ subTypes ? 'r' : '()' }} => new ResourceMetadata<{{ name }}, {{ name | quoteString }}>(
{{ name | quoteString }},
new IRIMetadata(
/^{{ repo.iri.testPattern }}$/u,
/^{{ repo.iri.capturePattern }}$/u,
([
{%- for param in repo.iri.parameters -%}
{{ param.name }}{% if not loop.last %}, {% endif -%}
{%- endfor -%}
]: [
{%- for param in repo.iri.parameters -%}
{{ param.type.usage }}{% if not loop.last %}, {% endif -%}
{%- endfor -%}
]) => {{ repo.iri.usage }}
),
[ {% if repo.resource.properties is defined -%}
{%- for name, property in repo.resource.properties if not property.nullable %}{{ property.name | quoteString }}, {% endfor -%}
{%- endif %}],
{% if subTypes %}[ {{ subTypes }} ]{% endif %}
),
{%- endfor %}
/**
* Metadonnées d'IRI statique.
*/
export class StaticIRIMetadata<R extends Resource> extends IRIMetadata<R, null> {
public constructor(property: keyof R, private readonly iri: string) {
super(property);
}
};
public check(that: any): that is IRI<R> {
return that === this.iri;
}
{% for repo in repositories %}
public get {{ repo.resourceName }}(): MetadataRegistryType[{{ repo.resourceName | quoteString }}] { return this.getOrCreate({{ repo.resourceName | quoteString }}); }
{% endfor %}
public parse(iri: IRI<R>): null {
return null;
public get<T extends ResourceTypes>(type: T): MetadataRegistryType[T] {
if (typeof type !== 'string' || !(type in this.builders)) {
throw new Error(`Invalid resource type: ${type}`);
}
return this.getOrCreate(type);
}
public generate(parameters: null): IRI<R> {
return this.iri;
private getOrCreate<T extends ResourceTypes>(type: T): MetadataRegistryType[T] {
if (!(type in this.instances)) {
this.instances[type] = this.builders[type](this);
}
return this.instances[type];
}
}
/**
* Metadonnées d'IRI paramétrisée.
* Façade de l'API.
*/
export class ParametrizedIRIMetadata<R extends Resource, P extends any[]> extends IRIMetadata<R, P> {
public constructor(
property: keyof R,
private readonly pattern: RegExp,
private readonly template: (parameters: P) => IRI<R>
) {
super(property);
}
@Injectable()
export class API {
public check(that: any): that is IRI<R> {
return typeof that === 'string' && this.pattern.test(that);
}
public parse(iri: IRI<R>): P {
const parameters = this.pattern.exec(iri);
parameters.shift();
return parameters as any;
}
public generate(parameters: P): IRI<R> {
return this.template(parameters);
}
}
/**
* Metadonnées d'une ressource.
*
* T : valeur de la propriété '@type', e.g. 'Person'.
* R : type de resource, e.g. Person
*/
export class ResourceMetadata<T extends ResourceType, R extends Resource = ResourceModel<T>> {
private readonly properties = {} as { [N in keyof R]: PropertyMetadata<R>; };
{% for repo in repositories %}
public readonly {{ repo.resourceName }} = new {{ repo.usage }}(this.metadata.{{ repo.resourceName }}, this.client, this.cache);
{% endfor %}
public constructor(
public readonly type: T,
public readonly id: Metatypes[T]['identifier']['key'],
private readonly types: ResourceType[],
private readonly iri: IRIMetadata<R, any>,
public readonly repositoryClass: Metatypes[T]['repository'],
props: [keyof R, boolean, boolean][]
) {
for (const [name, required, readonly] of props) {
this.properties[name] = new PropertyMetadata(name, required, readonly);
}
}
/**
* Vérifie si l'argument représente une ressource R ou dérivée.
*/
public isResource(that: any): that is R {
return that !== null && typeof that === 'object' && typeof that['@type'] === 'string' && this.types.includes(that['@type']);
}
/**
* Vérifie si l'argument représente bien une IRI d'une ressource Rou dérivée..
*/
public isIRI(that: any): that is IRI<R> {
return typeof that === 'string' && this.types.some(t => METADATA[t].checkIRI(that));
}
public readonly metadata: MetadataRegistry,
private readonly cache: AbstractResourceCache,
private readonly client: HttpClient
) {}
/**
* Tente d'extraire une IRI de R ou dérivée de l'argument.
*/
public findIRI(that: R | IRI<R> | null): IRI<R> | null {
if (this.isIRI(that)) {
return that;
}
if (this.isResource(that)) {
const meta: ResourceMetadata<any, any> = METADATA[that['@type']];
return meta.getIRI(that);
}
return null;
public getMetadata<T extends ResourceTypes>(type: T): Types[T]['metadata'] {
return this.metadata.get(type);
}
/**
* Vérifie si une chaîne est bien une IRI de la ressource R.
*/
public checkIRI(iri: string): boolean {
return this.iri.check(iri);
public getRepository<T extends ResourceTypes>(type: T): Types[T]['repository'] {
return this[type];
}
/**
* Extrait l'IRI d'une ressource R.
*/
public getIRI(that: R): IRI<R> {
return this.iri.getIRI(that);
public get<R extends { [IRI_PROPERTY]: IRI<R> }>(iri: IRI<R>): Observable<R> {
return this.cache.get(iri, () => this.client.get<R>(iri as any));
}
/**
* Vérifie si une propriété est obligatoire de la resource R.
*/
public isRequired(property: keyof R): boolean {
return typeof property === 'string' && property in this.properties && this.properties[property].required;
public invalidate(iri: IRI<any>): void {
this.cache.invalidate(iri);
}
}
/**
* Metadonnées des ressources.
*/
{% for repo in repositories %}
export const {{ repo.resourceName }}Metadata = new ResourceMetadata<{{ repo.resourceName | quoteString }}>(
{{ repo.resourceName | quoteString }},
{{ repo.identifier.name | quoteString }},
[ {% for type in repo.atTypes -%}{{ type.usage }},{% endfor %} ],
{%- if repo.iri.parameters is empty -%}
new StaticIRIMetadata('@id', {{ repo.iri.pattern | quoteString }}),
{% else %}
new ParametrizedIRIMetadata(
'@id',
/^{{ repo.iri.pattern }}$/u,
([
{%- for param in repo.iri.parameters -%}
{{ param.name }}{% if not loop.last %}, {% endif -%}
{%- endfor -%}
]: [
{%- for param in repo.iri.parameters -%}
{{ param.type.usage }}{% if not loop.last %}, {% endif -%}
{%- endfor -%}
]) => {{ repo.iri.usage }}
),
{%- endif %}
{{ repo.usage }},
[
{%- if repo.resource.properties is defined -%}
{%- for name, property in repo.resource.properties %}
[{{ property.name | quoteString }}, {{ (not property.nullable) | json_encode }}, {{ property.readonly | json_encode }}],
{%- endfor -%}
{%- endif %}
],
);
{% endfor %}
/**
* Dictionnaire des metadonnées.
*/
const METADATA: { [T in ResourceType]: ResourceMetadata<T> } = {
/* Provider factories */
{% for repo in repositories %}
{{ repo.resourceName | objectKey }}: {{ repo.resourceName }}Metadata,
export function {{ repo.usage }}Factory(api: API): {{ repo.usage }} { return api.{{ repo.resourceName }}; }
{% endfor %}
};
/**
* Recupère les métadonnées d'un type de ressource.
*/
export function getResourceMetadata<T extends ResourceType>(type: T): ResourceMetadata<T> {
if (typeof type !== 'string') {
throw new Error(`Resource type must be a string, got ${typeof type}`);
}
if (!(type in METADATA)) {
throw new Error(`Unknown resource type "${type}"`);
}
return METADATA[type] as any;
}
/**
* Typeguard pour un type de resource T (e.g. 'Person').
*/
export function isResourceOfType<T extends ResourceType>(that: any, type: T): that is ResourceModel<T> {
return getResourceMetadata(type).isResource(that);
}
/**
* Typeguard de Resource.
*/
export function isResource(that: any): that is Resource {
return typeof that === 'object' && that !== null && typeof that['@type'] === 'string' && isResourceOfType(that, that['@type']);
}
/**
* Typeguards par type de resource.
* Providers Angular
*/
export const PROVIDERS = [
MetadataRegistry,
API,
{% for repo in repositories %}
export function is{{ repo.resourceName }}(that: any): that is {{ repo.resourceName }} {
return {{ repo.resourceName }}Metadata.isResource(that);
}
{ provides: {{ repo.usage }}, useFactory: {{ repo.usage }}Factory, deps: [API] },
{% endfor %}
];
{% endautoescape -%}
{% endblock content %}
......@@ -2,13 +2,10 @@
{% block content %}
{% autoescape false %}
import { Injectable } from '@angular/core';
import { HttpClient, HttpResponseBase } from '@angular/common/http';
import { ActivatedRouteSnapshot, RouterStateSnapshot, Resolve } from '@angular/router';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { HttpResponseBase } from '@angular/common/http';
import { AbstractResourceCache, Collection, IRI, isUUID, UUID } from './common';
import { AbstractRepository, Collection, IRI, UUID } from './common';
import { {% for name in repoImports %}
{{ name }}{% if not loop.last %}, {% endif %}
{%- endfor %}
......@@ -18,7 +15,23 @@ import { {% for name in repoImports %}
* Repositories
*********************************************************************************/
{% for repo in repositories %}
{{ repo.declaration }}
export class {{ repo.name }} extends AbstractRepository<{{ repo.resource.usage }}, {{ repo.resourceName | quoteString }}> {
{% for op in repo.operations %}
{% if op.description -%}
/**
{{ op.description | trim | indent(" * ") }}
*/
{% endif -%}
public {{ op.name }}(
{%- for p in op.parameters -%}