Browse Source

add the IRIEncoded decorator, fuse HydraNormalizer and HydraDenormalizer

Olivier Massot 2 years ago
parent
commit
e13cac57e8

+ 1 - 1
components/Layout/Parameters/Attendances.vue

@@ -7,7 +7,7 @@
           <UiInputCheckbox
             v-model="parameters.sendAttendanceEmail"
             field="sendAttendanceEmail"
-            label="Prévenir automatiquement la famille par mail en cas d'absence non justifiée"
+            label="sendAttendanceEmail"
           />
 
           <UiInputCheckbox

+ 1 - 14
models/ApiResource.ts

@@ -6,7 +6,7 @@ import {Model} from "pinia-orm";
 export class ApiResource extends Model {
 
     private _model: typeof ApiResource | undefined = undefined;
-    static readonly relations: Record<string, ApiResource>
+    public static relations: Record<string, ApiResource>
 
     public getModel() {
         return this._model
@@ -16,10 +16,6 @@ export class ApiResource extends Model {
         this._model = model
     }
 
-    public getRelations() {
-        return this.relations
-    }
-
     /**
      * Fix the 'Cannot stringify arbitrary non-POJOs' warning, meaning server can not parse the store
      *
@@ -37,15 +33,6 @@ export class ApiResource extends Model {
     public isNew(): boolean {
         return !this.id || (typeof this.id === 'string' && this.id.slice(0, 3) === 'tmp')
     }
-
-    /**
-     * Get the IRI of the Entity
-     *
-     * @see https://api-platform.com/docs/admin/handling-relations/
-     */
-    public getIRI() {
-        return `/api/${this.entity}/${this.id}`
-    }
 }
 
 export default ApiResource

+ 2 - 3
models/Organization/Parameters.ts

@@ -2,6 +2,7 @@ import ApiModel from '~/models/ApiModel'
 import { Bool, Num, Str, Uid, Attr } from 'pinia-orm/dist/decorators'
 import Access from "~/models/Access/Access";
 import ApiResource from "~/models/ApiResource";
+import {IriEncoded} from "~/models/decorators";
 
 /**
  * AP2i Model : Parameters
@@ -10,9 +11,6 @@ import ApiResource from "~/models/ApiResource";
  */
 export default class Parameters extends ApiModel {
   static entity = 'parameters'
-  static readonly relations: Record<string, ApiResource> = {
-    publicationDirectors: Access
-  }
 
   @Uid()
   declare id: number | string | null
@@ -54,6 +52,7 @@ export default class Parameters extends ApiModel {
   declare desactivateOpentalentSiteWeb: boolean
 
   @Attr([])
+  @IriEncoded(Access)
   declare publicationDirectors: number[]
 
   @Str(null)

+ 24 - 0
models/decorators.ts

@@ -0,0 +1,24 @@
+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()
+
+        if (!self.hasOwnProperty("relations")) {
+            self.relations = {}
+        }
+
+        //@ts-ignore
+        self.relations[propertyKey] = apiResource
+    }
+}

+ 4 - 7
services/data/entityManager.ts

@@ -1,14 +1,11 @@
 import ApiRequestService from "./apiRequestService"
 import {Repository} from "pinia-orm"
 import UrlUtils from "~/services/utils/urlUtils"
-import HydraDenormalizer from "./normalizer/hydraDenormalizer"
 import ApiModel from "~/models/ApiModel"
 import ApiResource from "~/models/ApiResource"
-import MyProfile from "~/models/Access/MyProfile"
 import {v4 as uuid4} from 'uuid'
 import {AssociativeArray, Collection} from "~/types/data.d"
 import models from "~/models/models";
-import {useAccessProfileStore} from "~/stores/accessProfile"
 import * as _ from "lodash-es"
 import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
 
@@ -163,7 +160,7 @@ class EntityManager {
         const response = await this.apiRequestService.get(url)
 
         // deserialize the response
-        const attributes = HydraDenormalizer.denormalize(response).data as object
+        const attributes = HydraNormalizer.denormalize(response, model).data as object
 
         return this.newInstance(model, attributes)
     }
@@ -188,7 +185,7 @@ class EntityManager {
         const response = await this.apiRequestService.get(url, query)
 
         // deserialize the response
-        const apiCollection = HydraDenormalizer.denormalize(response)
+        const apiCollection = HydraNormalizer.denormalize(response, model)
 
         const items = apiCollection.data.map((attributes: object) => {
             return this.newInstance(model, attributes)
@@ -230,7 +227,7 @@ class EntityManager {
             response = await this.apiRequestService.post(url, data)
         }
 
-        const hydraResponse = await HydraDenormalizer.denormalize(response)
+        const hydraResponse = await HydraNormalizer.denormalize(response, model)
 
         const newInstance = this.newInstance(model, hydraResponse.data)
 
@@ -256,7 +253,7 @@ class EntityManager {
         const body = JSON.stringify(data)
         const response = await this.apiRequestService.put(url, body)
 
-        const hydraResponse = await HydraDenormalizer.denormalize(response)
+        const hydraResponse = await HydraNormalizer.denormalize(response, model)
         return this.newInstance(model, hydraResponse.data)
     }
 

+ 2 - 2
services/data/enumManager.ts

@@ -1,6 +1,6 @@
 import ApiRequestService from "./apiRequestService";
 import UrlUtils from "~/services/utils/urlUtils";
-import HydraDenormalizer from "~/services/data/normalizer/hydraDenormalizer";
+import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
 import {Enum} from "~/types/data.d";
 import {VueI18n} from "vue-i18n";
 
@@ -18,7 +18,7 @@ class EnumManager {
 
         const response = await this.apiRequestService.get(url)
 
-        const { data } = await HydraDenormalizer.denormalize(response)
+        const { data } = await HydraNormalizer.denormalize(response)
 
         const enum_: Enum = []
         for (const key in data.items) {

+ 0 - 115
services/data/normalizer/hydraDenormalizer.ts

@@ -1,115 +0,0 @@
-import {AnyJson, ApiResponse, HydraMetadata} from '~/types/data'
-import UrlUtils from '~/services/utils/urlUtils'
-import {METADATA_TYPE} from '~/types/enum/data'
-
-/**
- * Dénormalisation du format de données Hydra
- */
-class HydraDenormalizer {
-
-  /**
-   * Parse une réponse Hydra et retourne un objet ApiResponse
-   *
-   * @param {AnyJson} data
-   * @return {AnyJson} réponse parsée
-   */
-  public static denormalize(data: AnyJson): ApiResponse {
-    return {
-      data: HydraDenormalizer.getData(data),
-      metadata: HydraDenormalizer.getMetadata(data)
-    }
-  }
-
-  protected static getData(hydraData: AnyJson): AnyJson {
-    if (hydraData['@type'] === 'hydra:Collection') {
-      let members = hydraData['hydra:member']
-      let results = []
-
-      for (let item of members) {
-        results.push(HydraDenormalizer.denormalizeItem(item))
-      }
-
-      return results
-    } else {
-      return HydraDenormalizer.denormalizeItem(hydraData)
-    }
-  }
-
-  /**
-   * Dénormalise un item d'une réponse hydra
-   *
-   * @param item
-   * @protected
-   */
-  protected static denormalizeItem(item: AnyJson): AnyJson {
-    for (let key in item) {
-      if (item.hasOwnProperty(key)) {
-        let value = item[key]
-
-        // If value is an array of URIs (ex: `/api/persons/1`), replace this array with an array of numeric ids (ex: `1`)
-        if (
-            Array.isArray(value) &&
-            value.length > 0 &&
-            typeof value[0] === 'string' &&
-            value[0].match(/\/api\/[\w\/]+\d+/)
-        ) {
-          let childrenIds: Array<number> = []
-
-          for (let child of value) {
-            childrenIds.push(UrlUtils.extractIdFromUri(child) as number)
-          }
-
-          item[key] = childrenIds
-        }
-      }
-    }
-
-    return item
-  }
-
-  /**
-   * 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
-  }
-}
-
-export default HydraDenormalizer

+ 177 - 21
services/data/normalizer/hydraNormalizer.ts

@@ -1,37 +1,193 @@
-import {AnyJson} from "~/types/data";
+import {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 {isArray, isNumber} from "lodash";
-import UrlUtils from "~/services/utils/urlUtils";
+import {isArray} from "lodash";
 
 /**
- * Normalisation du format de données Hydra
+ * Normalisation et dé-normalisation du format de données Hydra
  */
 class HydraNormalizer {
-    public static normalizeEntity(entity: ApiResource): AnyJson {
 
-        const prototype = Object.getPrototypeOf(entity)
-        const relations = prototype.constructor.relations
+  /**
+   * Normalize the given entity into an Hydra formatted content.
+   * @param entity
+   */
+  public static normalizeEntity(entity: ApiResource): AnyJson {
+    const iriEncodedFields = HydraNormalizer.getIriEncodedFields(entity)
 
-        for (const field in relations) {
-            const value = entity[field]
+    for (const field in iriEncodedFields) {
+      const value = entity[field]
+      const targetEntity = iriEncodedFields[field].entity
 
-            if (!isArray(value)) {
-                console.warn("A model's relation is not an array")
-                continue
-            }
+      if (isArray(value)) {
+        entity[field] = value.map((id: number) => {
+          return UrlUtils.makeIRI(targetEntity, id)
+        })
+      } else {
+        entity[field] = UrlUtils.makeIRI(targetEntity, value)
+      }
+    }
+
+    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') {
+      let members = hydraData['hydra:member']
+      let results = []
+
+      for (let item of members) {
+        results.push(HydraNormalizer.denormalizeItem(item, model))
+      }
+
+      return results
+    } else {
+      return HydraNormalizer.denormalizeItem(hydraData, model)
+    }
+  }
 
-            const targetEntity = relations[field].entity
+  /**
+   * 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) {
+      const {entity, id} = HydraNormalizer.parseIRI(item['@id'])
+      if (!id) {
+        throw Error('De-normalization error : Could not determine the model of the entity')
+      }
+      model = models[entity]
+    }
+
+    const instance = new model(item)
+
+    const iriEncodedFields = HydraNormalizer.getIriEncodedFields(instance)
+
+    for (const field in iriEncodedFields) {
+      const value = instance[field]
+      const targetEntity = iriEncodedFields[field].entity
+
+      if (isArray(value)) {
+        instance[field] = value.map((iri: string) => {
+          return HydraNormalizer.getIdFromIri(iri, targetEntity)
+        })
+      } else {
+        instance[field] = HydraNormalizer.getIdFromIri(value, targetEntity)
+      }
+    }
 
-            entity[field] = value.map((id: number | string) => {
-                if (!isNumber(id)) {
-                    throw Error(field + ' : invalid id')
-                }
-                return UrlUtils.makeIRI(targetEntity, id)
-            })
+    return instance
+  }
+
+  /**
+   * 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
+  }
+
+  /**
+   * Parse the given IRI
+   * @param iri
+   * @protected
+   */
+  protected static parseIRI(iri: string) {
+    const rx = /\/api\/(\w+)\/(\d+)/
+    const match = rx.exec(iri)
+    if (!match) {
+      throw Error('could not parse the IRI : ' + iri)
+    }
+
+    return {
+      entity: match[1],
+      id: parseInt(match[2])
+    }
+  }
+
+  /**
+   * Get the array of the entity's fields marked as IRIEncoded
+   * @see models/decorators.ts
+   *
+   * @param entity
+   * @protected
+   */
+  protected static getIriEncodedFields(entity: ApiResource): Record<string, ApiResource> {
+    const prototype = Object.getPrototypeOf(entity)
+    return prototype.constructor.relations
+  }
 
-        return entity.$toJson()
+  /**
+   * 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 getIdFromIri(iri: string, expectedEntity: string): number {
+    const { entity, id } = HydraNormalizer.parseIRI(iri)
+    if (entity !== expectedEntity) {
+      throw Error("IRI entity does not match the field's target entity (" + entity + ' != ' + expectedEntity + ")")
     }
+    return id
+  }
 }
 
 export default HydraNormalizer

+ 32 - 0
services/data/normalizer/hydraNormalizer__.ts

@@ -0,0 +1,32 @@
+import {AnyJson} from "~/types/data";
+import ApiResource from "~/models/ApiResource";
+import {isArray, isNumber} from "lodash";
+import UrlUtils from "~/services/utils/urlUtils";
+
+/**
+ * Normalisation du format de données Hydra
+ */
+class HydraNormalizer__ {
+    public static normalizeEntity(entity: ApiResource): AnyJson {
+
+        const prototype = Object.getPrototypeOf(entity)
+        const relations = prototype.constructor.relations
+
+        for (const field in relations) {
+            const value = entity[field]
+            const targetEntity = relations[field].entity
+
+            if (isArray(value)) {
+                entity[field] = value.map((id: number) => {
+                    return UrlUtils.makeIRI(targetEntity, id)
+                })
+            } else {
+                entity[field] = UrlUtils.makeIRI(targetEntity, value)
+            }
+        }
+
+        return entity.$toJson()
+    }
+}
+
+export default HydraNormalizer

+ 4 - 1
services/utils/urlUtils.ts

@@ -1,4 +1,4 @@
-import _ from "lodash";
+import _, {isNumber} from "lodash";
 
 /**
  * Classe permettant de construire une URL pour l'interrogation d'une API externe
@@ -133,6 +133,9 @@ class UrlUtils {
    * @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}`
   }
 }

+ 13 - 13
tests/units/services/data/normalizer/hydraDenormalizer.test.ts

@@ -1,6 +1,6 @@
 import { describe, test, it, expect } from 'vitest'
-import {AnyJson, ApiResponse, HydraMetadata} from "~/types/data";
-import HydraDenormalizer from "~/services/data/normalizer/hydraDenormalizer";
+import {AnyJson} from "~/types/data";
+import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
 import {METADATA_TYPE} from "~/types/enum/data";
 
 describe('denormalize', () => {
@@ -20,12 +20,12 @@ describe('denormalize', () => {
             }
         }
 
-        const result = HydraDenormalizer.denormalize(data)
+        const result = HydraNormalizer.denormalize(data)
 
         // @ts-ignore
-        expect(result.data).toEqual(HydraDenormalizer.getData(data))
+        expect(result.data).toEqual(HydraNormalizer.getData(data))
         // @ts-ignore
-        expect(result.metadata).toEqual(HydraDenormalizer.getMetadata(data))
+        expect(result.metadata).toEqual(HydraNormalizer.getMetadata(data))
 
         const expected = {
             "data": {
@@ -86,12 +86,12 @@ describe('denormalize', () => {
             }
         }
 
-        const result = HydraDenormalizer.denormalize(data)
+        const result = HydraNormalizer.denormalize(data)
 
         // @ts-ignore
-        expect(result.data).toEqual(HydraDenormalizer.getData(data))
+        expect(result.data).toEqual(HydraNormalizer.getData(data))
         // @ts-ignore
-        expect(result.metadata).toEqual(HydraDenormalizer.getMetadata(data))
+        expect(result.metadata).toEqual(HydraNormalizer.getMetadata(data))
 
         const expected = JSON.stringify(
             {"data":[
@@ -143,7 +143,7 @@ describe('getData', () => {
         }
 
         // @ts-ignore
-        expect(HydraDenormalizer.getData(data)).toEqual([ 'foo' ])
+        expect(HydraNormalizer.getData(data)).toEqual([ 'foo' ])
     })
 
     test('With item', () => {
@@ -155,7 +155,7 @@ describe('getData', () => {
         }
 
         // @ts-ignore
-        expect(HydraDenormalizer.getData(data)).toEqual(data)
+        expect(HydraNormalizer.getData(data)).toEqual(data)
     })
 })
 
@@ -178,7 +178,7 @@ describe('getMetadata', () => {
         }
 
         // @ts-ignore
-        const metadata = HydraDenormalizer.getMetadata(data)
+        const metadata = HydraNormalizer.getMetadata(data)
 
         expect(metadata.totalItems).toEqual(10)
         expect(metadata.firstPage).toEqual(1)
@@ -202,7 +202,7 @@ describe('getMetadata', () => {
         }
 
         // @ts-ignore
-        const metadata = HydraDenormalizer.getMetadata(data)
+        const metadata = HydraNormalizer.getMetadata(data)
 
         expect(metadata.totalItems).toEqual(10)
         expect(metadata.firstPage).toEqual(1)
@@ -213,7 +213,7 @@ describe('getMetadata', () => {
 
     test('With item metadata', () => {
         // @ts-ignore
-        const metadata = HydraDenormalizer.getMetadata({})
+        const metadata = HydraNormalizer.getMetadata({})
         expect(metadata.type).toEqual(METADATA_TYPE.ITEM)
     })
 })