Browse Source

implement the whole entity manager system from app

Olivier Massot 1 year ago
parent
commit
a24562fb9f

+ 1 - 1
.env.docker

@@ -3,5 +3,5 @@ NUXT_ENV=dev
 NUXT_DEBUG=1
 DEBUG=1
 
-NUXT_API_BASE_URL=https://local.maestro.opentalent.fr
+NUXT_API_BASE_URL=https://nginx_maestro
 NUXT_PUBLIC_API_BASE_URL=https://local.maestro.opentalent.fr

+ 42 - 0
composables/data/useEntityFetch.ts

@@ -0,0 +1,42 @@
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import ApiResource from "~/models/ApiResource";
+import type {AssociativeArray, Collection} from "~/types/data";
+import type {AsyncData} from "#app";
+import type {ComputedRef, Ref} from "vue";
+import {v4 as uuid4} from "uuid";
+
+interface useEntityFetchReturnType {
+  fetch: (model: typeof ApiResource, id: number) => AsyncData<ApiResource, ApiResource | true>,
+  fetchCollection: (model: typeof ApiResource, parent?: ApiResource | null, query?: Ref<AssociativeArray>) => AsyncData<Collection, any>
+  // @ts-ignore
+  getRef: <T extends ApiResource>(model: typeof T, id: Ref<number | null>) => ComputedRef<null | T>
+}
+
+// TODO: améliorer le typage des fonctions sur le modèle de getRef
+export const useEntityFetch = (lazy: boolean = false): useEntityFetchReturnType => {
+  const { em } = useEntityManager()
+
+  const fetch = (model: typeof ApiResource, id: number) => useAsyncData(
+    model.entity + '_' + id + '_' + uuid4(),
+    () => em.fetch(model, id, true),
+    { lazy }
+  )
+
+  const fetchCollection = (
+    model: typeof ApiResource,
+    parent: ApiResource | null = null,
+    query: Ref<AssociativeArray | null> = ref(null)
+  ) => useAsyncData(
+    model.entity + '_many_' + uuid4(),
+    () => em.fetchCollection(model, parent, query.value ?? undefined),
+    { lazy }
+  )
+
+  // @ts-ignore
+  const getRef = <T extends ApiResource>(model: typeof T, id: Ref<number | null>): ComputedRef<T | null> => {
+    return computed(() => (id.value ? em.find(model, id.value) as T : null))
+  }
+
+  //@ts-ignore
+  return { fetch, fetchCollection, getRef }
+}

+ 15 - 0
composables/data/useEntityManager.ts

@@ -0,0 +1,15 @@
+import EntityManager from "~/services/data/entityManager";
+import {useMaestroRequestService} from "~/composables/data/useMaestroRequestService";
+import {useRepo} from "pinia-orm";
+
+let entityManager: EntityManager | null = null
+
+export const useEntityManager = () => {
+  if (entityManager === null) {
+    const { apiRequestService } = useMaestroRequestService()
+    const getRepo = useRepo
+
+    entityManager = new EntityManager(apiRequestService, getRepo)
+  }
+  return { em: entityManager }
+}

+ 20 - 0
composables/data/useEnumFetch.ts

@@ -0,0 +1,20 @@
+import {useEnumManager} from "~/composables/data/useEnumManager";
+import type {Enum} from "~/types/data";
+import type {AsyncData} from "#app";
+
+interface useEnumFetchReturnType {
+  fetch: (enumName: string) => AsyncData<Enum, null | true | Error>,
+}
+
+export const useEnumFetch = (lazy: boolean = false): useEnumFetchReturnType => {
+  const { enumManager } = useEnumManager()
+
+  const fetch = (enumName: string) => useAsyncData(
+    enumName,
+    () => enumManager.fetch(enumName),
+    { lazy }
+  )
+
+  //@ts-ignore
+  return { fetch }
+}

+ 15 - 0
composables/data/useEnumManager.ts

@@ -0,0 +1,15 @@
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import EnumManager from "~/services/data/enumManager";
+import {useI18n} from "vue-i18n";
+
+let enumManager:EnumManager | null = null
+
+export const useEnumManager = () => {
+  //Avoid memory leak
+  if (enumManager === null) {
+    const { apiRequestService } = useAp2iRequestService()
+    const i18n = useI18n() as any
+    enumManager = new EnumManager(apiRequestService, i18n)
+  }
+  return { enumManager: enumManager }
+}

+ 3 - 2
composables/useMaestroRequestService.ts → composables/data/useMaestroRequestService.ts

@@ -3,11 +3,12 @@ import ApiRequestService from "~/services/data/apiRequestService";
 import {Ref} from "@vue/reactivity";
 
 /**
- * Retourne une instance de ApiRequestService configurée pour interroger l'api Ap2i
+ * Retourne une instance de ApiRequestService configurée pour interroger l'api Maestro
  *
  * @see https://github.com/unjs/ohmyfetch/blob/main/README.md#%EF%B8%8F-create-fetch-with-default-options
  */
-let apiRequestServiceClass:null|ApiRequestService = null
+let apiRequestServiceClass: null | ApiRequestService = null
+
 export const useMaestroRequestService = () => {
     const runtimeConfig = useRuntimeConfig()
 

+ 0 - 6
composables/useApiFetch.ts

@@ -1,6 +0,0 @@
-import { NitroFetchRequest } from 'nitropack'
-
-export function useApiFetch(request:NitroFetchRequest, options:any = {}) {
-  const config = useRuntimeConfig()
-  return useLazyFetch(request, { baseURL: config.apiBaseURL ?? config.public.apiBaseUrl, ...options })
-}

+ 0 - 11
composables/useDataProvider.ts

@@ -1,11 +0,0 @@
-import dataProvider from "~~/services/data/dataProvider";
-let data= null;
-
-export const useDataProvider = () => {
-  
-  if(dataProvider === null) {
-    const { apiRequestService } = useMaestroRequestService();
-    data = new dataProvider(apiRequestService);
-  }
-return dataProvider;
-}

+ 11 - 0
models/ApiModel.ts

@@ -0,0 +1,11 @@
+import ApiResource from "~/models/ApiResource";
+
+/**
+ * Entities from the API
+ *
+ * These models support CRUD operations
+ */
+class ApiModel extends ApiResource {
+}
+
+export default ApiModel

+ 40 - 0
models/ApiResource.ts

@@ -0,0 +1,40 @@
+import {Model} from "pinia-orm";
+
+/**
+ * Base class for resources that can be fetched from the API
+ */
+export class ApiResource extends Model {
+
+  protected static _iriEncodedFields: Record<string, ApiResource>
+
+  public static addIriEncodedField(name: string, target: ApiResource) {
+    if (!this._iriEncodedFields) {
+      this._iriEncodedFields = {}
+    }
+    this._iriEncodedFields[name] = target
+  }
+
+  public static getIriEncodedFields() {
+    return this._iriEncodedFields
+  }
+
+  /**
+   * Fix the 'Cannot stringify arbitrary non-POJOs' warning, meaning server can not parse the store
+   *
+   * @see https://github.com/vuex-orm/vuex-orm/issues/255#issuecomment-876378684
+   */
+  toJSON () {
+    return { ...this }
+  }
+
+  /**
+   * Is it a newly created entity?
+   *
+   * If it is, it means this entity does not exist in the data source and that it has a temporary id
+   */
+  public isNew(): boolean {
+    return !this.id || (typeof this.id === 'string' && this.id.slice(0, 3) === 'tmp')
+  }
+}
+
+export default ApiResource

+ 65 - 0
models/Maestro/JobPosting.ts

@@ -0,0 +1,65 @@
+import ApiModel from "~/models/ApiModel";
+import {Uid, Str, Bool, Attr} from "pinia-orm/dist/decorators";
+
+/**
+ * Maestro Model : JobPosting
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/maestro/-/blob/master/src/Entity/JobPosting/JobPosting.php?ref_type=heads
+ */
+export default class JobPosting extends ApiModel {
+  static entity = 'job-postings'
+
+  @Uid()
+  declare id: number
+
+  @Str(null)
+  declare type: string | null
+
+  @Str(null)
+  declare contractType: string | null
+
+  @Str(null)
+  declare title: string | null
+
+  @Str(null)
+  declare startPublication: string | null
+
+  @Str(null)
+  declare updatedAt: string | null
+
+  @Str(null)
+  declare endPublication: string | null
+
+  @Str(null)
+  declare city: string | null
+
+  @Str(null)
+  declare postalCode: string | null
+
+  @Str(null)
+  declare content: string | null
+
+  @Bool(false)
+  declare featured: boolean
+
+  @Attr([])
+  declare sector: string | null[]
+
+  @Attr([])
+  declare tags: any[]
+
+  @Str(null)
+  declare structureName: string | null
+
+  @Str(null)
+  declare structureNameText: any
+
+  @Str(null)
+  declare structureInfo: string | null
+
+  @Bool(false)
+  declare clientOpentalent: boolean
+
+  @Bool(false)
+  declare visible: boolean
+}

+ 70 - 0
models/Maestro/News.ts

@@ -0,0 +1,70 @@
+import ApiModel from "~/models/ApiModel";
+import {Uid, Str, Bool, Attr} from "pinia-orm/dist/decorators";
+
+/**
+ * Maestro Model : News
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/maestro/-/blob/master/src/Entity/News/News.php?ref_type=heads
+ */
+export default class News extends ApiModel {
+  static entity = 'news'
+
+  @Uid()
+  declare id: number | string
+
+  @Str(null)
+  declare type: string | null
+
+  @Str(null)
+  declare title: string | null
+
+  @Str(null)
+  declare leadText: string | null
+
+  @Str(null)
+  declare bodyText: string | null
+
+  declare featured: boolean
+
+  @Bool(false)
+  declare favorite: boolean
+
+  @Attr({})
+  declare attachmentFile: any
+
+  @Str(null)
+  declare attachment: string | null
+
+  @Str(null)
+  declare startPublication: string | null
+
+  @Str(null)
+  declare updatedAt: string | null
+
+  @Str(null)
+  declare endPublication: string | null
+
+  @Str(null)
+  declare domainType: string | null
+
+  @Str(null)
+  declare eventType: string | null
+
+  @Str(null)
+  declare categoryType: string | null
+
+  @Str(null)
+  declare productType: string | null
+
+  @Str(null)
+  declare optionsType: string | null
+
+  @Str(null)
+  declare subOptionsType: string | null
+
+  @Attr([])
+  declare tags: string[]
+
+  @Bool(false)
+  declare visible: boolean
+}

+ 20 - 0
models/decorators.ts

@@ -0,0 +1,20 @@
+import ApiResource from "~/models/ApiResource";
+
+/**
+ * Decorates an ApiResource's property to signal it as a field that is provided
+ * as an IRI or an array of IRI by the API
+ *
+ * If the property is decorated, the HydraNormalizer will parse the IRI when de-normalizing
+ * to get the id(s), then re-encode it as IRI(s) when re-normalizing.
+ */
+export function IriEncoded (
+  apiResource: typeof ApiResource
+): PropertyDecorator {
+  return (target: Object, propertyKey: string | symbol) => {
+    //@ts-ignore
+    const self = target.$self()
+
+    //@ts-ignore
+    self.addIriEncodedField(propertyKey, apiResource)
+  }
+}

+ 12 - 0
models/models.ts

@@ -0,0 +1,12 @@
+const modules = import.meta.glob('~/models/*/*.ts')
+import ApiResource from "~/models/ApiResource";
+
+const models: Record<string, typeof ApiResource> = {}
+
+for (const path in modules) {
+  modules[path]().then((mod: any) => {
+    models[mod.default.entity] = mod.default
+  })
+}
+
+export default models

+ 1 - 1
nuxt.config.ts

@@ -30,7 +30,7 @@ export default defineNuxtConfig({
   runtimeConfig: {
     // Private config that is only available on the server
     env: "",
-    apiBaseUrl: process.env.NUXT_API_BASE_URL,
+    apiBaseUrl: "",
     // Config within public will be also exposed to the client
     public: {
       env: "",

+ 2 - 0
package.json

@@ -49,6 +49,7 @@
     "scss": "^0.2.4",
     "three": "^0.157.0",
     "typeface-barlow": "^1.1.13",
+    "uuid": "^9.0.1",
     "vite-plugin-vuetify": "^1.0.2",
     "vue-social-sharing": "^3.0.9",
     "vue3-carousel": "^0.3.1",
@@ -68,6 +69,7 @@
     "@types/jest": "^29.4.0",
     "@types/leaflet": "^1.9.1",
     "@types/lodash": "^4.14.189",
+    "@types/uuid": "^9.0.7",
     "@typescript-eslint/eslint-plugin": "^5.51.0",
     "@typescript-eslint/parser": "^5.51.0",
     "@vitejs/plugin-vue": "^4.0.0",

+ 75 - 14
services/data/apiRequestService.ts

@@ -3,13 +3,18 @@
  *
  * It will send basic http requests and returns raw results
  */
-import { $Fetch, FetchOptions } from "ohmyfetch";
+import type {AssociativeArray} from "~/types/data";
+import {HTTP_METHOD} from "~/types/enum/data";
+import type {FetchOptions} from "ofetch";
+import type {$Fetch} from "nitropack";
 
 class ApiRequestService {
-  private readonly fetch: $Fetch;
+  private readonly fetch: $Fetch
 
-  public constructor(fetch: $Fetch) {
-    this.fetch = fetch;
+  public constructor(
+    fetch: $Fetch
+  ) {
+    this.fetch = fetch
   }
 
   /**
@@ -18,27 +23,83 @@ class ApiRequestService {
    * @param url
    * @param query
    */
-  public async get<T>(
+  public async get(
     url: string,
     query: AssociativeArray | null = null
-  ): Promise<T> {
-    return await this.request<T>("GET", url, null, query);
+  ) {
+    return await this.request(HTTP_METHOD.GET, url, null, query)
   }
 
-  protected async request<T>(
-    method: string,
+  /**
+   * Send a POST request
+   *
+   * @param url
+   * @param body
+   * @param query
+   */
+  public async post(
+    url: string,
+    body: string | null = null,
+    query: AssociativeArray | null = null
+  ) {
+    return await this.request(HTTP_METHOD.POST, url, body, query)
+  }
+
+  /**
+   * Send a PUT request
+   *
+   * @param url
+   * @param body
+   * @param query
+   */
+  public async put(
+    url: string,
+    body: string | null = null,
+    query: AssociativeArray | null = null
+  ) {
+    return await this.request(HTTP_METHOD.PUT, url, body, query)
+  }
+
+  /**
+   * Send a DELETE request
+   *
+   * @param url
+   * @param query
+   */
+  public async delete(
+    url: string,
+    query: AssociativeArray | null = null
+  ) {
+    return await this.request(HTTP_METHOD.DELETE, url, null, query)
+  }
+
+  /**
+   * Send an http request
+   *
+   * @param method
+   * @param url
+   * @param body
+   * @param query
+   * @protected
+   */
+  protected async request(
+    method: HTTP_METHOD,
     url: string,
     body: string | null = null,
     query: AssociativeArray | null = null
-  ): Promise<T> {
-    const config: FetchOptions = { method };
+  ): Promise<Response> {
+    const config: FetchOptions = { method }
     if (query) {
-      config.query = query;
+      config.query = query
+    }
+    if (method === HTTP_METHOD.POST || method === HTTP_METHOD.PUT) {
+      config.body = body
     }
 
     // @ts-ignore
-    return this.fetch<T>(url, config);
+    return this.fetch(url, config)
   }
 }
 
-export default ApiRequestService;
+export default ApiRequestService
+

+ 0 - 34
services/data/dataProvider.ts

@@ -1,34 +0,0 @@
-import ApiRequestService from "./apiRequestService";
-
-class dataProvider {
-  private apiRequestService: ApiRequestService;
-  public constructor(apiRequestService: ApiRequestService) {
-    this.apiRequestService = apiRequestService;
-  }
-
-  public async fetchNews(): Promise<{ news }> {
-    const response = await this.apiRequestService.get(
-      "/news"
-    );
-    console.log(response)
-
-    return {
-      news: response,
-    };
-    
-  }
-
-  public async fetchNew(
-    id: number
-  ): Promise<{ new }> {
-    const response = await this.apiRequestService.get(
-      "/news/" + id
-    );
-    console.log(response)
-      return {
-        new: response,
-      };
-  }
-}
-
-export default dataProvider;

+ 394 - 0
services/data/entityManager.ts

@@ -0,0 +1,394 @@
+import ApiRequestService from "./apiRequestService"
+import {Repository} from "pinia-orm"
+import UrlUtils from "~/services/utils/urlUtils"
+import ApiModel from "~/models/ApiModel"
+import ApiResource from "~/models/ApiResource"
+import {v4 as uuid4} from 'uuid'
+import type {AssociativeArray, Collection} from "~/types/data.d"
+import models from "~/models/models";
+import * as _ from "lodash-es"
+import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
+
+/**
+ * Entity manager: make operations on the models defined with the Pinia-Orm library
+ *
+ * TODO: améliorer le typage des méthodes sur le modèle de find()
+ * @see https://pinia-orm.codedredd.de/
+ */
+class EntityManager {
+  protected CLONE_PREFIX = '_clone_'
+
+  /**
+   * In instance of ApiRequestService
+   * @protected
+   */
+  protected apiRequestService: ApiRequestService
+
+  /**
+   * A method to retrieve the repository of a given ApiResource
+   * @protected
+   */
+  protected _getRepo: (model: typeof ApiResource) => Repository<ApiResource>
+
+  public constructor(
+    apiRequestService: ApiRequestService,
+    getRepo: (model: typeof ApiResource) => Repository<ApiResource>
+  ) {
+    this.apiRequestService = apiRequestService
+    this._getRepo = getRepo
+  }
+
+  /**
+   * Return the repository for the model
+   *
+   * @param model
+   */
+  public getRepository(model: typeof ApiResource): Repository<ApiResource> {
+    return this._getRepo(model)
+  }
+
+  /**
+   * Cast an object as an ApiResource
+   * This in used internally to ensure the object is recognized as an ApiResource
+   *
+   * @param model
+   * @param instance
+   * @protected
+   */
+  // noinspection JSMethodCanBeStatic
+  protected cast(model: typeof ApiResource, instance: ApiResource): ApiResource {
+    return new model(instance)
+  }
+
+  /**
+   * Return the model class with the given entity name
+   *
+   * @param entityName
+   */
+  public getModelFor(entityName: string): typeof ApiResource {
+    return models[entityName]
+  }
+
+  /**
+   * Return the model class from an Opentalent Api IRI
+   *
+   * @param iri An IRI of the form .../api/<entity>/...
+   */
+  public getModelFromIri(iri: string): typeof ApiResource {
+    const matches = iri.match(/^\/api\/(\w+)\/.*/)
+    if (!matches || !matches[1]) {
+      throw new Error('cannot parse the IRI')
+    }
+    return this.getModelFor(matches[1])
+  }
+
+  /**
+   * Create a new instance of the given model
+   *
+   * @param model
+   * @param properties
+   */
+  public newInstance(model: typeof ApiResource, properties: object = {}): ApiResource {
+    const repository = this.getRepository(model)
+
+    let instance = repository.make(properties)
+
+    // Keep track of the model
+    // TODO : attendre de voir si utile ou non
+    // instance.setModel(model)
+
+    // @ts-ignore
+    if (!properties.hasOwnProperty('id') || !properties.id) {
+      // Object has no id yet, we give him a temporary one
+      instance.id = 'tmp' + uuid4()
+    }
+
+    return this.save(model, instance, true)
+  }
+
+  /**
+   * Save the model instance into the store
+   *
+   * @param model
+   * @param instance
+   * @param permanent Is the change already persisted in the datasource? If this is the case, the initial state of this
+   *                  record is also updated.
+   */
+  public save(model: typeof ApiResource, instance: ApiResource, permanent: boolean = false): ApiResource {
+    instance = this.cast(model, instance)
+
+    if (permanent) {
+      this.saveInitialState(model, instance)
+    }
+
+    return this.getRepository(model).save(instance)
+  }
+
+  /**
+   * Find the model instance in the store
+   * TODO: comment réagit la fonction si l'id n'existe pas dans le store?
+   *
+   * @param model
+   * @param id
+   */
+  // @ts-ignore
+  public find<T extends ApiResource>(model: typeof T, id: number | string): T {
+    const repository = this.getRepository(model)
+    return repository.find(id) as T
+  }
+
+  /**
+   * Fetch an ApiModel / ApiResource by its id, save it to the store and returns it
+   *
+   * @param model  Model of the object to fetch
+   * @param id   Id of the object to fetch
+   * @param forceRefresh  Force a new get request to the api ;
+   *                      current object in store will be overwritten if it exists
+   */
+  public async fetch(model: typeof ApiResource, id: number, forceRefresh: boolean = false): Promise<ApiResource> {
+
+    // If the model instance is already in the store and forceRefresh is false, return the object in store
+    if (!forceRefresh) {
+      const item = this.find(model, id)
+      if (item && typeof item !== 'undefined') {
+        return item
+      }
+    }
+
+    // Else, get the object from the API
+    const url = UrlUtils.join('api', model.entity, String(id))
+    const response = await this.apiRequestService.get(url)
+
+    // deserialize the response
+    const attributes = HydraNormalizer.denormalize(response, model).data as object
+
+    return this.newInstance(model, attributes)
+  }
+
+  /**
+   * Fetch a collection of model instances
+   * The content of `query` is converted into a query-string in the request URL
+   *
+   * @param model
+   * @param query
+   * @param parent
+   */
+  public async fetchCollection(model: typeof ApiResource, parent: ApiResource | null, query: AssociativeArray | null = null): Promise<Collection> {
+    let url
+
+    if (parent !== null) {
+      url = UrlUtils.join('api', parent.entity, '' + parent.id, model.entity)
+    } else {
+      url = UrlUtils.join('api', model.entity)
+    }
+
+    const response = await this.apiRequestService.get(url, query)
+
+    // deserialize the response
+    const apiCollection = HydraNormalizer.denormalize(response, model)
+
+    const items = apiCollection.data.map((attributes: object) => {
+      return this.newInstance(model, attributes)
+    })
+
+    return {
+      items,
+      totalItems: apiCollection.metadata.totalItems,
+      pagination: {
+        first: apiCollection.metadata.firstPage || 1,
+        last: apiCollection.metadata.lastPage || 1,
+        next: apiCollection.metadata.nextPage || undefined,
+        previous: apiCollection.metadata.previousPage || undefined,
+      }
+    }
+  }
+
+  /**
+   * Persist the model instance as it is in the store into the data source via the API
+   *
+   * @param model
+   * @param instance
+   */
+  public async persist(model: typeof ApiModel, instance: ApiModel) {
+    // Recast in case class definition has been "lost"
+    // TODO: attendre de voir si cette ligne est nécessaire
+    instance = this.cast(model, instance)
+
+    let url = UrlUtils.join('api', model.entity)
+    let response
+
+    const data: any = HydraNormalizer.normalizeEntity(instance)
+
+    if (!instance.isNew()) {
+      url = UrlUtils.join(url, String(instance.id))
+      response = await this.apiRequestService.put(url, data)
+    } else {
+      delete data.id
+      response = await this.apiRequestService.post(url, data)
+    }
+
+    const hydraResponse = HydraNormalizer.denormalize(response, model)
+
+    const newInstance = this.newInstance(model, hydraResponse.data)
+
+    // Si l'instance était nouvelle avant d'être persistée, elle vient désormais de recevoir un id définitif. On
+    // peut donc supprimer l'instance temporaire.
+    if (instance.isNew()) {
+      this.removeTempAfterPersist(model, instance.id)
+    }
+
+    return newInstance
+  }
+
+  /**
+   * Send an update request (PUT) to the API with the given data on an existing model instance
+   *
+   * @param model
+   * @param id
+   * @param data
+   */
+  public async patch(model: typeof ApiModel, id: number, data: AssociativeArray) {
+    let url = UrlUtils.join('api', model.entity, ''+id)
+
+    const body = JSON.stringify(data)
+    const response = await this.apiRequestService.put(url, body)
+
+    const hydraResponse = await HydraNormalizer.denormalize(response, model)
+    return this.newInstance(model, hydraResponse.data)
+  }
+
+  /**
+   * Delete the model instance from the datasource via the API
+   *
+   * @param model
+   * @param instance
+   */
+  public async delete(model: typeof ApiModel, instance: ApiResource) {
+
+    const repository = this.getRepository(model)
+
+    // If object has been persisted to the datasource, send a delete request
+    if (!instance.isNew()) {
+      const url = UrlUtils.join('api', model.entity, String(instance.id))
+      await this.apiRequestService.delete(url)
+    }
+
+    // reactiveUpdate the store
+    repository.destroy(instance.id)
+  }
+
+  /**
+   * Reset the model instance to its initial state (i.e. the state it had when it was fetched from the API)
+   *
+   * @param model
+   * @param instance
+   */
+  public reset(model: typeof ApiResource, instance: ApiResource) {
+    const initialInstance = this.getInitialStateOf(model, instance.id)
+    if (initialInstance === null) {
+      throw new Error('no initial state recorded for this object - abort [' + model.entity + '/' + instance.id + ']')
+    }
+
+    const repository = this.getRepository(model)
+    repository.save(initialInstance)
+
+    return initialInstance
+  }
+
+  /**
+   * Delete all records in the repository of the model
+   *
+   * @param model
+   */
+  public flush(model: typeof ApiModel) {
+    const repository = this.getRepository(model)
+    repository.flush()
+  }
+
+  /**
+   * Is the model instance a new one, or does it already exist in the data source (=API)
+   *
+   * This is a convenient way of testing a model instance you did not already fetch, else prefer the use of the
+   * isNew() method of ApiResource
+   *
+   * @param model
+   * @param id
+   */
+  public isNewInstance(model: typeof ApiModel, id: number | string): boolean {
+    const repository = this.getRepository(model)
+
+    const item = repository.find(id)
+    if (!item || typeof item === 'undefined') {
+      // TODO: est-ce qu'il ne faudrait pas lever une erreur ici plutôt?
+      console.error(model.entity + '/' + id + ' does not exist!')
+      return false
+    }
+
+    return item.isNew()
+  }
+
+  /**
+   * Save the state of the model instance in the store, so this state could be restored later
+   *
+   * @param model
+   * @param instance
+   * @private
+   */
+  protected saveInitialState(model: typeof ApiResource, instance: ApiResource) {
+    const repository = this.getRepository(model)
+
+    // Clone and prefix id
+    const clone = _.cloneDeep(instance)
+    clone.id = this.CLONE_PREFIX + clone.id
+
+    repository.save(clone)
+  }
+
+  /**
+   * Return the saved state of the model instance from the store
+   *
+   * @param model
+   * @param id
+   * @private
+   */
+  protected getInitialStateOf(model: typeof ApiResource, id: string | number): ApiResource | null {
+    const repository = this.getRepository(model)
+
+    // Find the clone by id
+    const instance = repository.find(this.CLONE_PREFIX + id)
+    if (instance === null) {
+      return null
+    }
+
+    // Restore the initial id
+    instance.id = id
+    return instance
+  }
+
+  /**
+   * Delete the temporary model instance from the repo after it was persisted via the api, replaced by the instance
+   * that has been returned by the api with is definitive id.
+   *
+   * @param model
+   * @param tempInstanceId
+   * @private
+   */
+  protected removeTempAfterPersist(model: typeof ApiResource, tempInstanceId: number | string) {
+    const repository = this.getRepository(model)
+
+    const instance = repository.find(tempInstanceId)
+    if (!instance || typeof instance === 'undefined') {
+      // TODO: il vaudrait peut-être mieux lever une erreur ici?
+      console.error(model.entity + '/' + tempInstanceId + ' does not exist!')
+      return
+    }
+    if (!instance.isNew()) {
+      throw new Error('Error: Can not remove a non-temporary model instance')
+    }
+
+    repository.destroy(tempInstanceId)
+    repository.destroy(this.CLONE_PREFIX + tempInstanceId)
+  }
+}
+
+export default EntityManager

+ 34 - 0
services/data/enumManager.ts

@@ -0,0 +1,34 @@
+import ApiRequestService from "./apiRequestService";
+import UrlUtils from "~/services/utils/urlUtils";
+import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
+import type {Enum} from "~/types/data.d";
+import type {VueI18n} from "vue-i18n";
+
+class EnumManager {
+  private apiRequestService: ApiRequestService;
+  private i18n: VueI18n;
+
+  public constructor(apiRequestService: ApiRequestService, i18n: VueI18n) {
+    this.apiRequestService = apiRequestService
+    this.i18n = i18n
+  }
+
+  public async fetch(enumName: string): Promise<Enum> {
+    const url = UrlUtils.join('api', 'enum', enumName)
+
+    const response = await this.apiRequestService.get(url)
+
+    const { data } = HydraNormalizer.denormalize(response)
+
+    const enum_: Enum = []
+    for (const key in data.items) {
+      if (data.items.hasOwnProperty(key)) {
+        enum_.push({value: key, label: this.i18n.t(data.items[key])})
+      }
+    }
+
+    return enum_
+  }
+}
+
+export default EnumManager

+ 211 - 0
services/data/normalizer/hydraNormalizer.ts

@@ -0,0 +1,211 @@
+import type {AnyJson, ApiResponse, HydraMetadata} from '~/types/data'
+import UrlUtils from '~/services/utils/urlUtils'
+import {METADATA_TYPE} from '~/types/enum/data'
+import models from "~/models/models";
+import ApiResource from "~/models/ApiResource";
+import * as _ from 'lodash-es'
+
+/**
+ * Normalisation et dé-normalisation du format de données Hydra
+ */
+class HydraNormalizer {
+  static models = models
+
+  /**
+   * Normalize the given entity into a Hydra formatted content.
+   * @param entity
+   */
+  public static normalizeEntity(entity: ApiResource): AnyJson {
+    const iriEncodedFields = Object.getPrototypeOf(entity).constructor.getIriEncodedFields()
+
+    for (const field in iriEncodedFields) {
+      const value = entity[field]
+      const targetEntity = iriEncodedFields[field].entity
+
+      if (_.isArray(value)) {
+        entity[field] = value.map((id: number) => {
+          return UrlUtils.makeIRI(targetEntity, id)
+        })
+      } else {
+        entity[field] = value !== null ? UrlUtils.makeIRI(targetEntity, value) : null
+      }
+    }
+
+    return entity.$toJson()
+  }
+
+  /**
+   * Parse une réponse Hydra et retourne un objet ApiResponse
+   *
+   * @param {AnyJson} data
+   * @param model
+   * @return {AnyJson} réponse parsée
+   */
+  public static denormalize(data: AnyJson, model?: typeof ApiResource): ApiResponse {
+    return {
+      data: HydraNormalizer.getData(data, model),
+      metadata: HydraNormalizer.getMetadata(data)
+    }
+  }
+
+  protected static getData(hydraData: AnyJson, model?: typeof ApiResource): AnyJson {
+    if (hydraData['@type'] === 'hydra:Collection') {
+      const members = hydraData['hydra:member']
+      return members.map((item: AnyJson) => HydraNormalizer.denormalizeItem(item, model))
+    } else {
+      return HydraNormalizer.denormalizeItem(hydraData, model)
+    }
+  }
+
+  /**
+   * Génère les métadonnées d'un item ou d'une collection
+   *
+   * @param data
+   * @protected
+   */
+  protected static getMetadata(data: AnyJson): HydraMetadata {
+    if (data['@type'] !== 'hydra:Collection') {
+      // A single item, no metadata
+      return { type: METADATA_TYPE.ITEM }
+    }
+
+    const metadata: HydraMetadata = {
+      totalItems: data['hydra:totalItems']
+    }
+
+    if (data['hydra:view']) {
+      /**
+       * Extract the page number from the IRIs in the hydra:view section
+       */
+      const extractPageNumber = (pos: string, default_: number | undefined=undefined): number | undefined => {
+        const iri = data['hydra:view']['hydra:' + pos]
+        if (!iri) {
+          return default_
+        }
+        return UrlUtils.getParameter(
+          data['hydra:view']['hydra:' + pos],
+          'page',
+          default_
+        ) as number | undefined
+      }
+
+      // TODO: utile d'ajouter la page en cours?
+      metadata.firstPage = extractPageNumber('first', 1)
+      metadata.lastPage = extractPageNumber('last', 1)
+      metadata.nextPage = extractPageNumber('next')
+      metadata.previousPage = extractPageNumber('previous')
+    }
+
+    metadata.type = METADATA_TYPE.COLLECTION
+
+    return metadata
+  }
+
+  /**
+   * Dénormalise un item d'une réponse hydra
+   *
+   * @param item
+   * @param model
+   * @protected
+   */
+  protected static denormalizeItem(item: AnyJson, model?: typeof ApiResource): AnyJson {
+
+    if (model) {
+      return HydraNormalizer.denormalizeEntity(model, item)
+    }
+
+    if (!item.hasOwnProperty('@id')) {
+      // Not hydra formatted
+      console.error('De-normalization error : the item is not hydra formatted', item)
+      return item
+    }
+
+    if (item['@id'].match(/\/api\/enum\/\w+/)) {
+      return HydraNormalizer.denormalizeEnum(item)
+    }
+
+    let entity = null
+
+    // On essaie de déterminer la nature de l'objet à partir de son id
+    try {
+      const iri = HydraNormalizer.parseEntityIRI(item['@id'])
+      entity = iri.entity
+    } catch (e) {
+      console.error('De-normalization error : could not parse the IRI', item)
+      return item
+    }
+
+    if (entity && HydraNormalizer.models.hasOwnProperty(entity)) {
+      model = HydraNormalizer.models[entity]
+      return HydraNormalizer.denormalizeEntity(model, item)
+    }
+
+    throw Error('De-normalization error : Could not determine the type of the entity '
+      + item['@id'] + ' (found: ' + entity + ')')
+  }
+
+  protected static denormalizeEntity(model: typeof ApiResource, item: AnyJson) {
+    const instance = new model(item)
+    const iriEncodedFields = model.getIriEncodedFields()
+
+    for (const field in iriEncodedFields) {
+      const value = instance[field]
+      if (_.isEmpty(value)) {
+        continue
+      }
+
+      const targetEntity = iriEncodedFields[field].entity
+
+      if (_.isArray(value)) {
+        instance[field] = value.map((iri: string) => {
+          return HydraNormalizer.getIdFromEntityIri(iri, targetEntity)
+        })
+      } else {
+        instance[field] = HydraNormalizer.getIdFromEntityIri(value, targetEntity)
+      }
+    }
+
+    return instance
+  }
+
+  protected static denormalizeEnum(item: AnyJson): AnyJson {
+    return item
+  }
+
+  /**
+   * Parse the given IRI
+   * @param iri
+   * @protected
+   */
+  protected static parseEntityIRI(iri: string) {
+    const rx = /\/api\/(\w+)\/(\d+)/
+    const match = rx.exec(iri)
+    if (!match) {
+      throw Error('could not parse the IRI : ' + JSON.stringify(iri))
+    }
+
+    return {
+      entity: match[1],
+      id: parseInt(match[2])
+    }
+  }
+
+  /**
+   * Retrieve the entitie's id from the given IRI
+   * Throws an error if the IRI does not match the expected entity
+   *
+   * @param iri
+   * @param expectedEntity
+   * @protected
+   */
+  protected static getIdFromEntityIri(iri: string, expectedEntity: string): number | string {
+    const { entity, id } = HydraNormalizer.parseEntityIRI(iri)
+    if (entity !== expectedEntity) {
+      throw Error("IRI entity does not match the field's target entity (" + entity + ' != ' + expectedEntity + ")")
+    }
+    return id
+  }
+}
+
+export default HydraNormalizer
+

+ 143 - 0
services/utils/urlUtils.ts

@@ -0,0 +1,143 @@
+import _ from "lodash";
+
+/**
+ * Classe permettant de construire une URL pour l'interrogation d'une API externe
+ */
+class UrlUtils {
+  /**
+   * Concatenate a base url and a tail
+   * @param base
+   * @param tails
+   * @private
+   */
+  public static join (base: string|number, ...tails: Array<string|number>): string {
+    let url = String(base)
+    tails.forEach((tail: string|number) => {
+      url = url.replace(/^|\/$/g, '') + '/' + String(tail).replace(/^\/?|$/g, '')
+    })
+    return url
+  }
+
+  /**
+   * Prepend the 'https://' part if neither 'http://' nor 'https://' prefixes are present, else does nothing
+   *
+   * @param url
+   */
+  public static prependHttps (url: string): string {
+    if (!url.match(/^https?:\/\/.*/)) {
+      url = 'https://' + url;
+    }
+    return url;
+  }
+
+  /**
+   * Parse an URI to retrieve a parameter
+   *
+   * @param uri
+   * @param parameter
+   * @param default_
+   * @private
+   */
+  public static getParameter (
+    uri: string,
+    parameter: string,
+    default_: string | number | null = null
+  ): string | number | null {
+    uri = UrlUtils.prependHttps(uri)
+
+    const urlParams = new URL(uri).searchParams;
+    let value: string | number | null = urlParams.get(parameter);
+
+    if (value && (default_ === null || Number.isInteger(default_)) && /^\d+$/.test(value)) {
+      // On convertit automatiquement si et seulement la valeur par défaut est elle-même un entier ou n'est pas définie
+      value = parseInt(value)
+    }
+
+    return value ?? default_
+  }
+
+  /**
+   * Extrait l'ID de l'URI passée en paramètre
+   * L'URI est supposée être de la forme `.../foo/bar/{id}`,
+   * où l'id est un identifiant numérique, à moins que `isLiteral` soit défini comme vrai.
+   * Dans ce cas, si `isLiteral` est vrai, l'id sera retourné sous forme de texte.
+   *
+   * @param uri
+   * @param isLiteral
+   */
+  public static extractIdFromUri (uri: string, isLiteral: boolean = false): number|string|null {
+    const partUri: Array<string> = uri.split('/')
+    const id: string = partUri.pop() ?? ''
+
+    if (!id || (!isLiteral && isNaN(id as any))) {
+      throw new Error('no id found')
+    }
+    return isLiteral ? id : parseInt(id)
+  }
+
+  /**
+   * Découpe une URI au niveau des '/'
+   * Utilisé entre autres pour le breadcrumb
+   *
+   * Ex:
+   *
+   *    foo/bar/1   =>  ['foo', 'bar', '1']
+   *    /foo/bar/1   =>  ['foo', 'bar', '1']
+   *    https://domain.com/foo/bar/1   =>  ['https:', 'domain.com', 'foo', 'bar', '1']
+   *
+   *
+   * @param uri
+   */
+  public static split(uri: string) {
+    return uri.split('/').filter((s) => s.length > 0)
+  }
+
+  /**
+   * Format and add a query to an url
+   * If the url already has a query, append the new parameters to it.
+   * If the query is an empty object, does nothing.
+   *
+   * Ex:
+   *     addQuery('foo/bar', {})  =>  'foo/bar'
+   *     addQuery('foo/bar', {'a': 1})  =>  'foo/bar?a=1'
+   *     addQuery('foo/bar?a=1', {'b': 2})  =>  'foo/bar?a=1&b=2'
+   *
+   * @param url
+   * @param query
+   */
+  public static addQuery(url: string | URL, query: object): string {
+
+    let urlObj = new URL(url, 'https://temporary-dommain.inexistent') as URL
+
+    if (!_.isEmpty(query)) {
+      urlObj = new URL(
+        `${urlObj.origin}${urlObj.pathname}${urlObj.hash}?${new URLSearchParams([
+          ...Array.from(urlObj.searchParams.entries()),
+          ...Object.entries(query),
+        ])}`
+      )
+    }
+
+    let result = urlObj.toString()
+
+    if (urlObj.host === 'temporary-dommain.inexistent') {
+      result = result.replace('https://temporary-dommain.inexistent', '')
+    }
+
+    return result
+  }
+
+  /**
+   * Make an ApiPlatform IRI for the given entity and id
+   *
+   * @see https://api-platform.com/docs/admin/handling-relations/
+   */
+  public static makeIRI(entity: string, id: number) {
+    if (!_.isNumber(id)) {
+      throw Error('Invalid id : ' + id)
+    }
+    return `/api/${entity}/${id}`
+  }
+}
+
+export default UrlUtils

+ 58 - 0
types/data.d.ts

@@ -0,0 +1,58 @@
+import ApiResource from "~/models/ApiResource";
+import type {EnumChoice} from "~/types/interfaces";
+
+type AnyJson = Record<string, any>
+
+interface AssociativeArray {
+  [key: string]: any;
+}
+
+interface Connector {
+  request(
+    method: HTTP_METHOD,
+    url: string,
+    body: null | any,
+    params: null | AssociativeArray,
+    query: null | AssociativeArray
+  )
+}
+
+interface HydraMetadata {
+  readonly totalItems?: number
+  firstPage?: number
+  lastPage?: number
+  nextPage?: number
+  previousPage?: number
+  type?: METADATA_TYPE
+}
+
+interface ApiResponse {
+  data: AnyJson
+  metadata: HydraMetadata
+}
+
+interface ApiCollection extends ApiResponse {
+  data: AnyJson
+  metadata: HydraMetadata
+}
+
+
+interface Pagination {
+  first?: number
+  last?: number
+  next?: number
+  previous?: number
+}
+
+interface Collection {
+  items: Array<ApiResource>
+  pagination: Pagination
+  totalItems: number | undefined
+}
+
+interface EnumItem {
+  value: string
+  label: string
+}
+
+type Enum = Array<EnumChoice>

+ 11 - 0
types/enum/data.ts

@@ -0,0 +1,11 @@
+export const enum HTTP_METHOD {
+  POST = 'POST',
+  PUT = 'PUT',
+  GET = 'GET',
+  DELETE = 'DELETE'
+}
+
+export const enum METADATA_TYPE {
+  ITEM,
+  COLLECTION
+}

+ 31 - 0
types/enum/enums.ts

@@ -0,0 +1,31 @@
+
+export const enum PRODUCT {
+  SCHOOL = 'school',
+  SCHOOL_PREMIUM = 'school-premium',
+  ARTIST = 'artist',
+  ARTIST_PREMIUM = 'artist-premium',
+  MANAGER = 'manager'
+}
+
+export const enum NETWORK {
+  CMF = 'CMF',
+  FFEC = 'FFEC',
+}
+
+export const enum ORGANIZATION_ID {
+  CMF = 12097,
+  OPENTALENT_MANAGER = 93931
+}
+
+export const enum DENORMALIZER_TYPE {
+  HYDRA,
+  YAML
+}
+
+export const enum QUERY_TYPE {
+  DEFAULT,
+  MODEL,
+  ENUM,
+  IMAGE,
+  FILE
+}

+ 0 - 4
types/interface.d.ts

@@ -1,9 +1,5 @@
 import { StickyMenuActionType } from "~/types/enum/layout";
 
-interface AssociativeArray {
-  [key: string]: any;
-}
-
 interface StickyMenuAction {
   type: StickyMenuActionType
   bgColor: string,

+ 10 - 0
yarn.lock

@@ -3118,6 +3118,11 @@
   dependencies:
     "@types/node" "*"
 
+"@types/uuid@^9.0.7":
+  version "9.0.8"
+  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba"
+  integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==
+
 "@types/yargs-parser@*":
   version "21.0.0"
   resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
@@ -10720,6 +10725,11 @@ uuid@^8.3.0, uuid@^8.3.2:
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
   integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
 
+uuid@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
+  integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
+
 v8-to-istanbul@^9.0.0:
   version "9.1.0"
   resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265"