Concevoir un ORM qui permet de communiquer avec le service externe https://jsonplaceholder.typicode.com
Cet ORM sera réalisé en TypeScript et typé au maximum.
-
Je sais communiquer avec une API externe
-
Je sais restituer et aggréger des données de différentes sources
-
Je sais écrire du code propre et corrigé par un linter
-
Je sais utiliser le débuggeur de mon IDE
-
Je sais typer mes variables et fonctions avec TypeScript
-
Je sais utiliser les interfaces et enums avec TypeScript
-
Je sais créér des générics avec TypeScript
-
Je suis capable de concevoir un ORM qui me permet de communiquer avec un service externe ou micro-service
Ce TP donnera lieu a une première note individuelle sur la majeure WebService. Cette note sera compléter par la note de projet de groupe.
4 à 12h
Un lien de rendu sera partagé et épinglé sur le Slack.
Le rendu est individuel, et tout code identique sera fortement sanctionné. Il est OK de s'inspirer ou de demander des astuces, mais je ne cautionne pas le plagiat.
Votre projet sera évaluer par un système de test automatisé, il est donc bien important de respecter la nomenclature que je donne. Tout ce qui est en dehors de la nomenclature est libre.
Une seconde passe de correction manuelle sera faite en ce qui concerne :
- La qualité du code
- La propreté
- L'originalité de la solution
- Si les différentes compétences à acquérir le sont
Implémentez un certain nombre de méthodes sur chaque model afin de pouvoir requêter facilement, à la manière d'un ORM, l'API json-placeholder.
Pour ce faire, je vous conseille de faire les choses dans un ordre :
- Implémentez une première méthode sur un model : Album par exemple, de la manière la plus simple possible
- Rendez cette méthode plus générique, et placez là dans une classe parente qui sera héritée par tous les models
- Implémentez toutes les fonctions de manière simple (sans prendre en compte les options et arguments ou interfaces)
- Écrivez la logique qui permet de gérer les filtre, les includes, etc... en sommes de façon à coller au reste du squelette proposé çi-dessous.
Vous devez implémenter à minima les models suivants :
- Album
- User
- Photo
Voici les signatures que devront implémenter vos models
import { NonFunctionKeys } from 'utility-types';
type SchemaOf<T extends object> = Pick<T, NonFunctionKeys<T>>
enum QueryFilterOrder {
Asc = 'asc',
Desc = 'desc',
}
interface QueryFilter {
where?: Record<string, any>
limit?: number,
page?: number,
sort?: string,
order?: QueryFilterOrder,
}
interface FindByIdOptions {
includes: string[],
}
type ModelIdType = number | string;
enum RelationType {
BelongsTo = 'belongsTo',
HasMany = 'hasMany',
}
/**
* Define the configuration of a relation
*/
interface Relation {
/** Type of the relation: hasMany, belongsTo, ... */
type: RelationType,
/** The target Model */
model: any, // je vous donnerai une autre astuce plus tard pour faire marche `typeof Model`
/**
* The key containing the relation link
* - on the target model if hasMany
* - on the current model if belongsTo
*/
foreignKey: string,
}
interface ModelConfig {
/**
* The endpoint on the remote API, example 'users'
*/
endpoint: string,
/**
* The definition of the relations
*/
relations?: Record<string, Relation>,
}
declare abstract class Model {
protected static config: ModelConfig;
id: string | number;
constructor(data: SchemaOf<T>);
static create<T extends Model>(dataOrModel: SchemaOf<T> | T): Promise<T>;
static find<T extends Model>(filter?: QueryFilter): Promise<T[]>;
static findById<T extends Model>(id: ModelIdType, options?: FindByIdOptions): Promise<T>;
static updateById<T extends Model>(model: T): Promise<T>;
static updateById<T extends Model>(id: ModelIdType, data: Partial<SchemaOf<T>>): Promise<T>;
static deleteById(id: ModelIdType): Promise<boolean>;
/**
* Push changes that has occured on the instance
*/
save<T extends Model>(): Promise<T>;
/**
* Push given changes, and update the instance
*/
update<T extends Model>(data: Partial<SchemaOf<T>>): Promise<T>;
/**
* Remove the remote data
*/
remove(): Promise<void>;
}
Pour aller plus loin, certaines fonctions ci-dessus possèdent des arguments supplémentaires.
Permet de filtrer, trier, ou limiter les résultats.
Ce tableau de string permet de définir si on veut inclure des modèles liés dans la réponse finale.
Par exemple :
const album = await Album.findById(1, { include: ['user', 'photos'] })
console.log(album)
// Retournera :
/*
{
"id": 1,
"title": 'quidfelkfjskdjf',
"user": {
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {...},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {...}
},
photos: [
{
"id": 1,
}
...
]
}
*/
Pour ce faire vous aurez besoin de définir le mapping des relations dans la propriété static de chaque model : config.relations
Encore mieux : il est recommandé de conserver des instances, ainsi à terme il serait possible de faire :
const album = await Album.findById(1, { include: ['user'] })
album.user.username = 'Toto'
await album.user.save() // Mettra le user à jour
Bien entendu, un .gitignore
, ainsi qu'un package.json
sont indispensables.
Il me faudra également une configuration ESLint qui valide votre code.
Votre application doit également utiliser une variable d'environnement : API_BASE_URL
Ainsi pour utiliser l'api typicode (pas de slash final), je dois pouvoir faire :
export API_BASE_URL=https://jsonplaceholder.typicode.com
ts-node main.tss
Le tsconfig doit être configuré sur le mode strict
, et le type de module doit être commonjs
.
Vous pouvez installer json-server
et le démarrer en local, cf. la doc, mais cela vous permettra d'avoir une API identique en tout point à celle de jsonplaceholder, en local, qu'il est possible de modifier (notamment pour les opération destructives.
Rien de compliqué, la variable est statique, et dans une méthode statique, this
représente la classe, ainsi :
class Parent {
static test: string;
static showTestStatic() {
console.log(this.test)
}
}
class Child extends Parent {
static test = 'toto'
}
Child.showTestStatic()
C'est un peu plus compliqué, dans une méthode d'instance, le this
représente l'instance elle même, et l'instance n'a pas connaissance des variables de classe. Le seul moyen d'y accéder est donc de faire Child.test
, sauf que Child
peut être variable.
Une autre façon astucieuse, qui justifie ici l'emploi du mot clé as
. En JavaScript, une classe est une simple fonction qui possède un prototype. Lorsqu'on écrit sous forme de classe, cette fonction est en fait le constructor
. Ainsi, en javascript, la variable constructeur est la classe elle même. Toutes les instances ont également accès à cette fonction constructor
qui est une propriété non-énumérée et immutable.
Donc, this.constructor
correspond à la classe, ici : Child
, on a désormais un accès à la classe et donc aux variables de classes : this.constructor.test
. Pour en simplifier l'accès et acquérir le typing, on peut faire un getter :
class Parent {
static test: string;
showTest() {
console.log(this.modelClass.test)
}
get modelClass(): typeof Model {
return this.constructor as typeof Model // <= utilisation de `as`, car le constuctor est une simple Function pour TS
}
}
class Child extends Parent {
static test = 'toto'
}
const child = new Child()
child.showTest()
Un des problèmes auquel vous allez être confronté, est le fait d'instancier depuis une méthode statique, sans forcément connaitre le model de destination (les joies de l'héritage).
Exemple de la problématiquue :
abstract class Vehicule {
static findVehicule(firstArgument) { // <= Erreu: Pas de type de retour
return new this({ test: firstArgument }); // <= Impossible car Vehicule est abstract
}
}
class Voiture extends Vehicule {
}
const car = Voiture.findVehicule('le test');
Le premier soucis est qu'on ne déclare pas de type de retour à findVehicule
, en effet on s'attend à obtenir une voiture, une moto ou autre et cela dépend de l'instance.
Grâce aux generics, on peut fabriquer des "variables" de types :
interface VehiculeClass<T extends Vehicule> {
new(data: { test: string }): T;
}
abstract class Vehicule {
static findVehicule<T extends Vehicule>(firstArgument: string): T {
return new this({ test: firstArgument }); // <=== Impossible car Vehicule est abstract
}
}
class Voiture extends Vehicule {
}
const car = Voiture.findVehicule<Voiture>('le test'); // <= Ici on doit passer explicitement la valeur à notre Generic (variable de type)
<T extends Vehicule
permet de dire que la variable de type attendue ici devrait être quelque chose qui hérite de Vehicule.
Il est aussi possible de passer une valeur par défaut au générics : <T = any>
Notre second problème est le new this()
, on essai d'instancier sans connaitre la classe, car la classe actuelle est abstraite.
Pour solutionner ce problème, il suffit de dire à TypeScript que le type de this doit posséder une certaine signature. Il suffit donc d'indiquer le type de this
, juste avant le premier argument de la fonction :
interface VehiculeClass<T extends Vehicule> {
new(data: { test: string }): T;
}
abstract class Vehicule {
static findVehicule<T extends Vehicule>(
this: VehiculeClass<T>,
firstArgument: string,
): T {
return new this({ test: firstArgument });
}
}
class Voiture extends Vehicule {
}
const car = Voiture.findVehicule('le test'); // <= Comme Typescript sait que T est dépendant du type de this, donc de l'instance, plus besoin de faire findVehicule<Voiture>, c'est implicite !!
Gold !
À cette étape, vous devriez avoir une définition de model qui ressemble à cela :
export default class Album extends Model {
protected static config = {
path: '/albums',
relations: {
user: {
type: RelationType.BelongsTo,
model: User,
foreignKey: 'userId',
},
photos: {
type: RelationType.HasMany,
model: Photo,
foreignKey: 'albumId',
},
},
};
public id!: number;
public title!: string;
public userId!: number;
public readonly user?: User;
public readonly photos?: Photo[];
get titleCap() {
return this.title.toUpperCase();
}
}
Les décorateurs vont nous faciliter l'iimplémentation des albums :
@Entity({ endpoint: '/albums' })
export default class Album extends Model {
@Property()
id!: number;
@Property()
title!: string;
@Property()
userId!: number;
@BelongsTo(User, 'userId') // ou @BelongsTo(User) en déduisant userId
readonly user?: User;
@HasMany(Photo, 'albumId')
readonly photos?: Photo[];
get titleCap() {
return this.title.toUpperCase();
}
}
Joli, non ?
Il est ensuite possible de pouvoir spécifier des options aux propriétés, par exemple : valeurs par défaut, validations, etc...