From 710704e30378ac84eef8da5db6d707d4f6aef68c Mon Sep 17 00:00:00 2001 From: Juha Jantunen Date: Mon, 22 May 2023 20:42:39 +0300 Subject: [PATCH] feat: introduce stronger types to fail fast (#5) feat: introduce stronger types to fail fast feat: don't export types that might still be renamed and are not essential for usage feat: add isRelationshipDataPresent -helper method task: remove unnecessary parameter from deserializeRelationship -methods feat: getItem* can return null and we can deal with it BREAKING-CHANGE: the ItemDeserializer methods have changed --- README.md | 3 + src/__tests__/Deserializer.test.ts | 35 +-- src/index.ts | 358 +++++++++++++++++------------ 3 files changed, 239 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 907b80f..694f78c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ const yourJsonData = fetch('https://api.example.com/api/folders'); const rootItems: any[] = deserializer.consume(yourJsonData).getRootItems(); ``` +Please note that the deserializer only supports successful responses, and you will get an error if the response is not compatible with the JSON:API specification. +I practise the response should be checked for errors before deserializing it and the error handling should be done separately. + ### Examples * [The JSON from the jsonapi.org example](docs/examples/jsonapiorg) diff --git a/src/__tests__/Deserializer.test.ts b/src/__tests__/Deserializer.test.ts index 279eb13..a52a75e 100644 --- a/src/__tests__/Deserializer.test.ts +++ b/src/__tests__/Deserializer.test.ts @@ -122,7 +122,7 @@ const jsonapiOrgExampleData2 = { self: 'https://example.com/articles/1/relationships/comments', related: 'https://example.com/articles/1/comments', }, - data: null, + data: [], }, }, links: { @@ -135,7 +135,7 @@ const jsonapiOrgExampleData2 = { type Article = { id: number; title: string; - author?: Person; + author?: Person|null; comments: Comment[]; }; @@ -156,13 +156,18 @@ const articleDeserializer: ItemDeserializer
= { type: 'articles', deserialize: (item: Item, relationshipDeserializer: RelationshipDeserializer): Article => { const article: Article = { + author: null, id: parseInt(item.id), title: item.attributes.title, comments: [], }; - article.author = relationshipDeserializer.deserializeRelationship(relationshipDeserializer, item, 'author'); - article.comments = relationshipDeserializer.deserializeRelationships(relationshipDeserializer, item, 'comments'); + if (relationshipDeserializer.isRelationshipDataPresent(item, 'author')) { + article.author = relationshipDeserializer.deserializeRelationship(item, 'author'); + } + if (relationshipDeserializer.isRelationshipDataPresent(item, 'comments')) { + article.comments = relationshipDeserializer.deserializeRelationships(item, 'comments'); + } return article; }, @@ -188,8 +193,9 @@ const commentDeserializer: ItemDeserializer = { body: item.attributes.body, }; - comment.author = relationshipDeserializer.deserializeRelationship(relationshipDeserializer, item, 'author'); - + if (relationshipDeserializer.isRelationshipDataPresent(item, 'author')) { + comment.author = relationshipDeserializer.deserializeRelationship(item, 'author'); + } return comment; }, }; @@ -347,7 +353,7 @@ const fileSystemExampleData3 = { related: 'https://example.com/folders/1/children', self: 'https://example.com/folders/1/relationships/children', }, - data: null, + data: [], }, }, }, @@ -373,8 +379,9 @@ const folderDeserializer: ItemDeserializer = { children: [], }; - folder.children = relationshipDeserializer.deserializeRelationships(relationshipDeserializer, item, 'children'); - + if (relationshipDeserializer.isRelationshipDataPresent(item, 'children')) { + folder.children = relationshipDeserializer.deserializeRelationships(item, 'children'); + } return folder; }, }; @@ -391,17 +398,19 @@ const fileDeserializer: ItemDeserializer = { describe('Deserializer', () => { it('deserializes the jsonapi.org example into an object graph', () => { - const deserializer: Deserializer = getDeserializer([articleDeserializer, personDeserializer, commentDeserializer]); + const deserializer: Deserializer = getDeserializer([articleDeserializer, personDeserializer, commentDeserializer]) + .consume(jsonapiOrgExampleData); - const rootItems: any[] = deserializer.consume(jsonapiOrgExampleData).getRootItems(); + const rootItems: any[] = deserializer.getRootItems(); expect(rootItems).toMatchSnapshot(); }); it('deserializes the second jsonapi.org example (without relationships but with relationships.*.links and data:null) into an object graph', () => { - const deserializer: Deserializer = getDeserializer([articleDeserializer, personDeserializer, commentDeserializer]); + const deserializer: Deserializer = getDeserializer([articleDeserializer, personDeserializer, commentDeserializer]) + .consume(jsonapiOrgExampleData2); - const rootItems: any[] = deserializer.consume(jsonapiOrgExampleData2).getRootItems(); + const rootItems: any[] = deserializer.getRootItems(); expect(rootItems).toMatchSnapshot(); }); diff --git a/src/index.ts b/src/index.ts index ad81c39..06fc8a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,66 @@ +/** + * RelationshipItem is an object that contains the link to a related entity in the "data" section of the JSON:API response. + */ +type RelationshipItem = { + id: string, + type: string, + meta?: { + [key: string]: unknown + } +} + /** * Item is an object that contains the raw data for a single entity in "data" or "included" sections of the JSON:API response. */ export type Item = { - id: string; - type: string; - attributes: any; - relationships?: any; - meta?: any; + id: string; + type: string; + attributes: { + [key: string]: any + }; + relationships?: { + [key: string]: { + links?: { + [key: string]: any + }, + data?: RelationshipItem | RelationshipItem[] | null + } + }; + meta?: { + [key: string]: any + }; + links?: { + [key: string]: any + } }; +/** + * JsonApiPayload is the JSON:API response payload. + */ +type JsonApiPayload = { + data: Item | Item[], + included?: Item[] + meta?: { + [key: string]: any + }, + links?: { + [key: string]: any + } +} + export type ItemDeserializer = { - /** - * The type of entity, for example "articles" or "comments" - */ - type: string; - - /** - * A function that returns an object (of type T) deserialized based on given data (of type Item). - * - * @param item - * @param relationshipDeserializer - */ - deserialize: (item: Item, relationshipDeserializer: RelationshipDeserializer) => T; + /** + * The type of entity, for example "articles" or "comments" + */ + type: string; + + /** + * A function that returns an object (of type T) deserialized based on given data (of type Item). + * + * @param item + * @param relationshipDeserializer + */ + deserialize: (item: Item, relationshipDeserializer: RelationshipDeserializer) => T; }; /** @@ -37,172 +76,203 @@ type EntityStoreCollection = { [key: string]: EntityStore }; type ItemDeserializerRegistry = { [key: string]: ItemDeserializer }; export interface RelationshipDeserializer { - deserializeRelationship(relationshipDeserializer: RelationshipDeserializer, item: Item, name: string): any; - deserializeRelationships(relationshipDeserializer: RelationshipDeserializer, item: Item, name: string): any[]; + /** + * Returns whether the data for item's relationship with given name is present in the included -section. + * + * Sometimes the JSON:API response contains a relationship to an entity that is not included in the "included" section of the response. + * If this is the case, the relationshipDeserializer will not be able to deserialize the relationship but will throw an error. To mitigate + * this, you can check if the data exists before deserializing the relationship. + */ + isRelationshipDataPresent(item: Item, name: string): boolean; + + deserializeRelationship(item: Item, name: string): any; + + deserializeRelationships(item: Item, name: string): any[]; } export class Deserializer implements RelationshipDeserializer { - private rootItems: EntityStore = {}; - private entityStoreCollection: EntityStoreCollection = {}; - private itemDeserializerRegistry: ItemDeserializerRegistry = {}; - - public registerItemDeserializer(itemDeserializer: ItemDeserializer): Deserializer { - this.itemDeserializerRegistry[itemDeserializer.type] = itemDeserializer; - - return this; - } - - /** - * Returns the root item from the JSON:API data, with any relationships embedded. - */ - public getRootItem(): any { - if (Object.keys(this.rootItems).length === 0) { - return null; - } else if (Object.keys(this.rootItems).length > 1) { - throw new Error( - `A singular JSON:API can only have up to one item, ${Object.keys(this.rootItems).length} items found.`, - ); + private rootItems: EntityStore = {}; + private entityStoreCollection: EntityStoreCollection = {}; + private itemDeserializerRegistry: ItemDeserializerRegistry = {}; + + public registerItemDeserializer(itemDeserializer: ItemDeserializer): Deserializer { + this.itemDeserializerRegistry[itemDeserializer.type] = itemDeserializer; + + return this; } - const item = this.rootItems[Object.keys(this.rootItems)[0]]; - const type = item.type; + /** + * Returns the root item from the JSON:API data, with any relationships embedded. + */ + public getRootItem(): any { + if (Object.keys(this.rootItems).length === 0) { + return null; + } else if (Object.keys(this.rootItems).length > 1) { + throw new Error( + `A singular JSON:API can only have up to one item, ${Object.keys(this.rootItems).length} items found.`, + ); + } - return this.getDeserializerForType(type).deserialize(item, this); - } + const item = this.rootItems[Object.keys(this.rootItems)[0]]; + const type = item.type; - /** - * Returns the root items (as an array) from the JSON:API data, with any relationships embedded. - */ - public getRootItems(): any[] { - if (Object.keys(this.rootItems).length === 0) { - return []; + return this.getDeserializerForType(type).deserialize(item, this); } - const items: any[] = []; + /** + * Returns the root items (as an array) from the JSON:API data, with any relationships embedded. + */ + public getRootItems(): any[] { + if (Object.keys(this.rootItems).length === 0) { + return []; + } - Object.keys(this.rootItems).forEach((id: string) => { - const item = this.rootItems[id]; - const type = item.type; - items.push(this.getDeserializerForType(type).deserialize(item, this)); - }); + const items: any[] = []; - return items; - } + Object.keys(this.rootItems).forEach((id: string) => { + const item = this.rootItems[id]; + const type = item.type; + items.push(this.getDeserializerForType(type).deserialize(item, this)); + }); - /** - * Parses and wires the relationship with the given name for the given item. - * - * @param relationshipDeserializer - * @param item - * @param name - */ - public deserializeRelationship(relationshipDeserializer: RelationshipDeserializer, item: Item, name: string): any { - if (!item?.relationships || !item.relationships[name]) return null; + return items; + } - const relationship = item.relationships[name]?.data; + public isRelationshipDataPresent(item: Item, name: string): boolean { + if (item?.relationships === undefined || item?.relationships === null) { + return false; + } + + const relationShips = item?.relationships[name] - if (!relationship) return null; + if (Array.isArray(relationShips?.data)) { + const relationships: RelationshipItem[] = relationShips.data as RelationshipItem[]; + return relationships.every((relationship) => { + return !!this.getItemByTypeAndId(relationship.type, relationship.id); + }); + } - let relationshipItem: Item; + if (relationShips?.data?.id && relationShips?.data?.type) { + const relationship: RelationshipItem = relationShips.data as RelationshipItem; + + return !!this.getItemByTypeAndId(relationship.type, relationship.id); + } - try { - relationshipItem = this.getItemByTypeAndId(relationship.type, relationship.id); - } catch (e) { - throw new Error( - `Failed to fetch relationship "${name}" for entity {id: "${item.id}", type: "${item.type}"}: ${e}`, - ); + return false; } - return this.getDeserializerForType(relationship.type).deserialize(relationshipItem, this); - } - - /** - * Parses and wires the relationships with the given name for the given item. Returns an array of deserialized items. - * - * @param relationshipDeserializer - * @param item - * @param name - */ - public deserializeRelationships(relationshipDeserializer: RelationshipDeserializer, item: Item, name: string): any[] { - if (!item?.relationships || !item.relationships[name]) return []; - - const ret: any[] = []; - - item.relationships[name].data?.forEach((relationship: any) => { - let relationshipItem: Item; - - try { - relationshipItem = this.getItemByTypeAndId(relationship.type, relationship.id); - } catch (e) { - throw new Error( - `Failed to fetch relationship "${name}" for entity {id: "${item.id}", type: "${item.type}"}: ${e}`, - ); - } - - ret.push(this.getDeserializerForType(relationship.type).deserialize(relationshipItem, this)); - }); + /** + * Parses and wires the relationship with the given name for the given item. + * + * @param item + * @param name + */ + public deserializeRelationship(item: Item, name: string): any { + if (!item?.relationships || !item.relationships[name] || !item.relationships[name]?.data) return null; + + const relationships: RelationshipItem | RelationshipItem[] | null | undefined = item.relationships[name]?.data + if (!relationships) return null; + if (Array.isArray(relationships)) { + throw new Error(`Relationship "${name}" is an array, use deserializeRelationships instead.`); + } + + const relationship: RelationshipItem = relationships as RelationshipItem - return ret; - } + if (!relationship) return null; - private getItemByTypeAndId(type: string, id: string): Item { - const item = this.entityStoreCollection[type][id]; + const relationshipItem = this.getItemByTypeAndId(relationship.type, relationship.id); + if (!relationshipItem) return null; - if (!item) { - throw new Error(`Entity {id: "${id}", type: "${type}"} not found.`); + return this.getDeserializerForType(relationship.type).deserialize(relationshipItem, this); } - return item; - } + /** + * Parses and wires the relationships with the given name for the given item. Returns an array of deserialized items. + * + * @param item + * @param name + */ + public deserializeRelationships(item: Item, name: string): any[] { + if (!item?.relationships || !item.relationships[name]) return []; + + const ret: any[] = []; + + const relationshipItems = item.relationships[name].data; + + if (!Array.isArray(relationshipItems)) return ret; - private getDeserializerForType(type: string): ItemDeserializer { - const deserializer = this.itemDeserializerRegistry[type]; - if (!deserializer) { - throw new Error(`An ItemDeserializer for type ${type} is not registered.`); + relationshipItems.forEach((relationship: RelationshipItem) => { + const relationshipItem = this.getItemByTypeAndId(relationship.type, relationship.id); + + if (!relationshipItem) return; + + ret.push(this.getDeserializerForType(relationship.type).deserialize(relationshipItem, this)); + }); + + return ret; } - return deserializer; - } - - /** - * @param json The "raw" json object from JSON:API response; must contain key "data" - */ - public consume(json: { data?: any; included?: any }): Deserializer { - if (!json.data) { - throw new Error('JSON-object must contain key `data`'); + private getItemByTypeAndId(type: string, id: string): Item|null { + if (!this.entityStoreCollection.hasOwnProperty(type)) { + return null + } + const item: Item = this.entityStoreCollection[type][id]; + + if (!item) { + return null + } + + return item; } - if (Array.isArray(json.data)) { - json.data.forEach((item: Item) => { - this.rootItems[item.id] = item; - // TODO the root items _should_ really be included in the collection as well, but that might cause circular references - }); - } else { - this.rootItems[json.data.id] = json.data; + private getDeserializerForType(type: string): ItemDeserializer { + const deserializer: ItemDeserializer = this.itemDeserializerRegistry[type]; + if (!deserializer) { + throw new Error(`An ItemDeserializer for type ${type} is not registered.`); + } + + return deserializer; } - if (Array.isArray(json.included)) { - json.included.forEach((item: Item) => { - if (!this.entityStoreCollection[item.type]) { - this.entityStoreCollection[item.type] = {}; + /** + * @param json The "raw" json object from JSON:API response; must contain key "data" + */ + public consume(json: JsonApiPayload): Deserializer { + if (!json.data) { + throw new Error('JSON-object must contain key `data`'); } - this.entityStoreCollection[item.type][item.id] = item; - }); + if (Array.isArray(json.data)) { + json.data.forEach((item: Item) => { + this.rootItems[item.id] = item; + // TODO the root items _should_ really be included in the collection as well, but that might cause circular references + }); + } else { + this.rootItems[json.data.id] = json.data; + } + + if (Array.isArray(json.included)) { + json.included.forEach((item: Item) => { + if (!this.entityStoreCollection[item.type]) { + this.entityStoreCollection[item.type] = {}; + } + + this.entityStoreCollection[item.type][item.id] = item; + }); + } + return this; } - return this; - } } /** * Returns a Deserializer with the given ItemDeserializers registered. */ export function getDeserializer(itemDeserializers: ItemDeserializer[]): Deserializer { - const deserializer: Deserializer = new Deserializer(); + const deserializer: Deserializer = new Deserializer(); - itemDeserializers.forEach((itemDeserializer: ItemDeserializer) => { - deserializer.registerItemDeserializer(itemDeserializer); - }); + itemDeserializers.forEach((itemDeserializer: ItemDeserializer) => { + deserializer.registerItemDeserializer(itemDeserializer); + }); - return deserializer; + return deserializer; }