From 3d5dd0d645ed8a2c25abcb4df55aee73f32fc577 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 14:05:34 +0100 Subject: [PATCH 01/14] implement a few interfaces from the specification --- .../kitsu-core/src/resources/attributes.ts | 21 +++++++++++++++++++ .../src/resources/resource-identifier.ts | 18 ++++++++++++++++ .../src/resources/resource-object.ts | 15 +++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 packages/kitsu-core/src/resources/attributes.ts create mode 100644 packages/kitsu-core/src/resources/resource-identifier.ts create mode 100644 packages/kitsu-core/src/resources/resource-object.ts diff --git a/packages/kitsu-core/src/resources/attributes.ts b/packages/kitsu-core/src/resources/attributes.ts new file mode 100644 index 00000000..92b6e58a --- /dev/null +++ b/packages/kitsu-core/src/resources/attributes.ts @@ -0,0 +1,21 @@ +// https://jsonapi.org/format/#document-resource-object-attributes +// +// The value of the attributes key MUST be an object (an “attributes object”). Members of the attributes object (“attributes”) represent information about the resource object in which it’s defined. +// Attributes may contain any valid JSON value, including complex data structures involving JSON objects and arrays. +// Keys that reference related resources (e.g. author_id) SHOULD NOT appear as attributes. Instead, relationships SHOULD be used. + +// Valid JSON values +// https://datatracker.ietf.org/doc/html/rfc8259#section-3 +export type JsonValues = object | number | string | false | null | true | Array; + +// https://datatracker.ietf.org/doc/html/rfc8259#section-4 +// "A name is a string" +export interface Attributes { + [name: string]: JsonValues; +} + +export function isAttributes(attrs: any): attrs is Attributes { + return typeof attrs.attributes === 'object' && + attrs.attributes !== null && + !Array.isArray(attrs.attributes) +} diff --git a/packages/kitsu-core/src/resources/resource-identifier.ts b/packages/kitsu-core/src/resources/resource-identifier.ts new file mode 100644 index 00000000..193aeb69 --- /dev/null +++ b/packages/kitsu-core/src/resources/resource-identifier.ts @@ -0,0 +1,18 @@ +// https://jsonapi.org/format/#document-resource-object-identification +// +// > As noted above, every resource object MUST contain a type member. +// > Every resource object MUST also contain an id member, except when the resource object originates at the client and represents a new resource to be created on the server. +// > If id is omitted due to this exception, a lid member MAY be included to uniquely identify the resource by type locally within the document. +// > The value of the lid member MUST be identical for every representation of the resource in the document, including resource identifier objects. + +export interface LocalResourceIdentifier { + lid: string; + type: string; +} + +export interface RemoteResourceIdentifier { + id: string; + type: string; +} + +export type ResourceIdentifier = LocalResourceIdentifier | RemoteResourceIdentifier; diff --git a/packages/kitsu-core/src/resources/resource-object.ts b/packages/kitsu-core/src/resources/resource-object.ts new file mode 100644 index 00000000..781c1c0c --- /dev/null +++ b/packages/kitsu-core/src/resources/resource-object.ts @@ -0,0 +1,15 @@ +import { Attributes } from "./attributes.js"; +import { ResourceIdentifier } from "./resource-identifier.js"; + +type Relationships = void; +type Links = void; +type Meta = void; + +export interface _ResourceObject { + attributes?: Attributes; + relationships?: Relationships; + links?: Links; + meta?: Meta; +} + +export type ResourceObject = _ResourceObject & ResourceIdentifier; From 6c562754aad8a071bfd6539fbf5a75f0e2e14fda Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 14:06:31 +0100 Subject: [PATCH 02/14] refactor deattribute using new interfaces and type guards --- .../kitsu-core/src/components/deattribute.ts | 51 ++++++++----------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/kitsu-core/src/components/deattribute.ts b/packages/kitsu-core/src/components/deattribute.ts index 6051d47b..7cbed5a3 100644 --- a/packages/kitsu-core/src/components/deattribute.ts +++ b/packages/kitsu-core/src/components/deattribute.ts @@ -1,37 +1,26 @@ -interface Data { - id: string - type: string - attributes?: { - [key: string]: - | string - | number - | boolean - | null - | undefined - | object - | object[] - | string[] - | number[] - | boolean[] - } -} +import { Attributes, isAttributes } from "../resources/attributes.js"; +import { ResourceIdentifier } from "../resources/resource-identifier.js"; +import { ResourceObject } from "../resources/resource-object.js"; + +export type DeattributedResourceObject = ResourceIdentifier & Attributes; // Write a function that hoists the attributes of a given object to the top level -export const deattribute = (data: Data | Data[]): Data | Data[] => { - let output = data - if (Array.isArray(data)) output = data.map(deattribute) as Data[] - else if ( - typeof data.attributes === 'object' && - data.attributes !== null && - !Array.isArray(data.attributes) - ) { - output = { - ...data, - ...data.attributes - } as Data +export function deattribute(data: ResourceObject): DeattributedResourceObject; +export function deattribute(data: ResourceObject[]): DeattributedResourceObject[]; +export function deattribute(data: ResourceObject | ResourceObject[]): DeattributedResourceObject | DeattributedResourceObject[] { + return isResourceObjectArray(data) ? data.map(_deattribute) : _deattribute(data); +} - if (output.attributes === data.attributes) delete output.attributes +function _deattribute(data: ResourceObject): DeattributedResourceObject { + const output = { + ...data, + ...data.attributes } - return output + if (output.attributes === data.attributes) delete output.attributes + return output; +} + +function isResourceObjectArray(obj: ResourceObject | ResourceObject[]): obj is ResourceObject[] { + return Array.isArray(obj) } From 617c11bd345a13fcc6223248d958c3add98237b2 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 14:06:57 +0100 Subject: [PATCH 03/14] implement isDeepEqual --- .../src/components/deepEqual.spec.ts | 75 +++++++++++++++++++ .../kitsu-core/src/components/deepEqual.ts | 25 +++++++ packages/kitsu-core/src/index.ts | 1 + 3 files changed, 101 insertions(+) create mode 100644 packages/kitsu-core/src/components/deepEqual.spec.ts create mode 100644 packages/kitsu-core/src/components/deepEqual.ts diff --git a/packages/kitsu-core/src/components/deepEqual.spec.ts b/packages/kitsu-core/src/components/deepEqual.spec.ts new file mode 100644 index 00000000..b24b294e --- /dev/null +++ b/packages/kitsu-core/src/components/deepEqual.spec.ts @@ -0,0 +1,75 @@ +import test from 'ava' + +import { isDeepEqual } from '../index.js' + +const people = { + one: { + firstName: 'John', + lastName: 'Doe', + age: 35 + }, + two: { + firstName: 'John', + lastName: 'Doe', + age: 35 + }, + three: { + firstName: 'Akash', + lastName: 'Thakur', + age: 35 + }, + four: { + firstName: 'Jane', + lastName: 'Doe' + }, + five: { + address: { + street: '123 Main St', + inhabitants: ['Chuck', 'Howard', {name: "Jimmy", age: 35}] + } + }, + six: { + address: { + street: '123 Main St', + inhabitants: ['Chuck', 'Howard', {name: "Jimmy", age: 35}] + } + }, + seven: { + address: { + street: '456 Main St', + inhabitants: ['Chuck', 'Howard', {name: "Jimmy", age: 35}] + } + }, + eight: { + address: { + street: '123 Main St', + inhabitants: ['Howard', {name: "Jimmy", age: 35}, 'Chuck'] + } + } +} + +test('checks identical objects are equal', t => { + t.true(isDeepEqual(people.one, people.two)) + t.deepEqual(people.one, people.two) +}) + +test('checks different objects are not equal', t => { + t.false(isDeepEqual(people.one, people.three)) + t.notDeepEqual(people.one, people.three) +}) + +test('checks objects have the same number of keys', t => { + t.false(isDeepEqual(people.one, people.four)) + t.notDeepEqual(people.one, people.four) +}) + +test('checks nested objects are equal', t => { + t.true(isDeepEqual(people.five, people.six)) + t.deepEqual(people.five, people.six) + + t.false(isDeepEqual(people.five, people.seven)) + t.notDeepEqual(people.five, people.seven) + + t.false(isDeepEqual(people.five, people.eight)) + t.notDeepEqual(people.five, people.eight) +}) diff --git a/packages/kitsu-core/src/components/deepEqual.ts b/packages/kitsu-core/src/components/deepEqual.ts new file mode 100644 index 00000000..34906194 --- /dev/null +++ b/packages/kitsu-core/src/components/deepEqual.ts @@ -0,0 +1,25 @@ +export const isDeepEqual = (left: any, right: any) => { + if (!left || !right) return left === right + + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + + if (leftKeys.length !== rightKeys.length) return false + + for (const key of leftKeys) { + const leftValue = left[key] + const rightValue = right[key] + + const isObjects = isObject(leftValue) && isObject(rightValue) + + if ((isObjects && !isDeepEqual(leftValue, rightValue)) || (!isObjects && leftValue !== rightValue)) { + return false + } + } + + return true +} + +function isObject(obj: any): obj is object { + return obj != undefined && typeof obj === 'object' +} diff --git a/packages/kitsu-core/src/index.ts b/packages/kitsu-core/src/index.ts index 4c7e68ea..f8709b9b 100644 --- a/packages/kitsu-core/src/index.ts +++ b/packages/kitsu-core/src/index.ts @@ -1 +1,2 @@ export * from './components/deattribute.js' +export * from './components/deepEqual.js' From 7202f7d9279584a12d8c2ed3727721d2a56a9703 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 16:02:46 +0100 Subject: [PATCH 04/14] fix linting and apply isAttribute in _deattribute --- .../src/components/deattribute.spec.ts | 24 +++++++++-- .../kitsu-core/src/components/deattribute.ts | 42 ++++++++++++++----- .../src/components/deepEqual.spec.ts | 8 ++-- .../kitsu-core/src/components/deepEqual.ts | 15 +++++-- .../kitsu-core/src/resources/attributes.ts | 21 +++++++--- .../src/resources/resource-object.ts | 15 ------- ...ce-identifier.ts => resourceIdentifier.ts} | 12 +++--- .../src/resources/resourceObject.ts | 15 +++++++ 8 files changed, 104 insertions(+), 48 deletions(-) delete mode 100644 packages/kitsu-core/src/resources/resource-object.ts rename packages/kitsu-core/src/resources/{resource-identifier.ts => resourceIdentifier.ts} (82%) create mode 100644 packages/kitsu-core/src/resources/resourceObject.ts diff --git a/packages/kitsu-core/src/components/deattribute.spec.ts b/packages/kitsu-core/src/components/deattribute.spec.ts index fdad36ce..ef7cf95a 100644 --- a/packages/kitsu-core/src/components/deattribute.spec.ts +++ b/packages/kitsu-core/src/components/deattribute.spec.ts @@ -1,8 +1,9 @@ import test from 'ava' import { deattribute } from '../index.js' +import { ResourceObject } from '../resources/resourceObject.js' -test('deattribute', t => { +test('deattributes a valid ResourceObject', t => { t.deepEqual( deattribute({ id: '1', @@ -35,7 +36,7 @@ test('deattribute', t => { ) }) -test('deattribute with attributes.attributes', t => { +test('deattributes a ResourceObject when attributes has the key "attributes"', t => { t.deepEqual( deattribute({ id: '2', @@ -60,7 +61,7 @@ test('deattribute with attributes.attributes', t => { ) }) -test('deattribute array', t => { +test('deattributes arrays of ResourceObject', t => { t.deepEqual( deattribute([ { @@ -88,3 +89,20 @@ test('deattribute array', t => { ] ) }) + +// subject to change +const fun = () => 'im a function' +test('performs no operation on a ResourceObject with invalid attributes', t => { + t.deepEqual( + deattribute({ + id: '1', + type: 'test', + attributes: fun + } as ResourceObject), + { + id: '1', + type: 'test', + attributes: fun + } + ) +}) diff --git a/packages/kitsu-core/src/components/deattribute.ts b/packages/kitsu-core/src/components/deattribute.ts index 7cbed5a3..1f6f5561 100644 --- a/packages/kitsu-core/src/components/deattribute.ts +++ b/packages/kitsu-core/src/components/deattribute.ts @@ -1,26 +1,46 @@ -import { Attributes, isAttributes } from "../resources/attributes.js"; -import { ResourceIdentifier } from "../resources/resource-identifier.js"; -import { ResourceObject } from "../resources/resource-object.js"; +import { Attributes, isAttributes } from '../resources/attributes.js' +import { ResourceIdentifier } from '../resources/resourceIdentifier.js' +import { ResourceObject } from '../resources/resourceObject.js' -export type DeattributedResourceObject = ResourceIdentifier & Attributes; +export type DeattributedResourceObject = ResourceIdentifier & Attributes // Write a function that hoists the attributes of a given object to the top level -export function deattribute(data: ResourceObject): DeattributedResourceObject; -export function deattribute(data: ResourceObject[]): DeattributedResourceObject[]; -export function deattribute(data: ResourceObject | ResourceObject[]): DeattributedResourceObject | DeattributedResourceObject[] { - return isResourceObjectArray(data) ? data.map(_deattribute) : _deattribute(data); +export function deattribute(data: ResourceObject): DeattributedResourceObject +export function deattribute( + data: ResourceObject[] +): DeattributedResourceObject[] +export function deattribute( + data: ResourceObject | ResourceObject[] +): DeattributedResourceObject | DeattributedResourceObject[] { + return isResourceObjectArray(data) + ? data.map(_deattribute) + : _deattribute(data) } function _deattribute(data: ResourceObject): DeattributedResourceObject { + // FIXME: what is the best behaviour when given an invalid attributes key? + // 1. (Current) the same invalid object is returned. + // a. This results in deattribute returning potentially invalid DeattributedResourceObjects + // 2. the object is modified, and has the invalid key removed + // a. This would guarantee valid returns, but will also change the current default behaviour. + // 3. the object is not touched, and an error is thrown + // a. this would function closer to how JSON.parse does, throwing errors when unexpected input is given + // + // This should not be an issue for projects using typescript natively, since the compiler will warn when passing + // objects with mismatched types to deattribute + if (!isAttributes(data.attributes)) return data as DeattributedResourceObject + const output = { ...data, ...data.attributes } if (output.attributes === data.attributes) delete output.attributes - return output; + return output } -function isResourceObjectArray(obj: ResourceObject | ResourceObject[]): obj is ResourceObject[] { - return Array.isArray(obj) +function isResourceObjectArray( + object: ResourceObject | ResourceObject[] +): object is ResourceObject[] { + return Array.isArray(object) } diff --git a/packages/kitsu-core/src/components/deepEqual.spec.ts b/packages/kitsu-core/src/components/deepEqual.spec.ts index b24b294e..40998573 100644 --- a/packages/kitsu-core/src/components/deepEqual.spec.ts +++ b/packages/kitsu-core/src/components/deepEqual.spec.ts @@ -25,25 +25,25 @@ const people = { five: { address: { street: '123 Main St', - inhabitants: ['Chuck', 'Howard', {name: "Jimmy", age: 35}] + inhabitants: ['Chuck', 'Howard', { name: 'Jimmy', age: 35 }] } }, six: { address: { street: '123 Main St', - inhabitants: ['Chuck', 'Howard', {name: "Jimmy", age: 35}] + inhabitants: ['Chuck', 'Howard', { name: 'Jimmy', age: 35 }] } }, seven: { address: { street: '456 Main St', - inhabitants: ['Chuck', 'Howard', {name: "Jimmy", age: 35}] + inhabitants: ['Chuck', 'Howard', { name: 'Jimmy', age: 35 }] } }, eight: { address: { street: '123 Main St', - inhabitants: ['Howard', {name: "Jimmy", age: 35}, 'Chuck'] + inhabitants: ['Howard', { name: 'Jimmy', age: 35 }, 'Chuck'] } } } diff --git a/packages/kitsu-core/src/components/deepEqual.ts b/packages/kitsu-core/src/components/deepEqual.ts index 34906194..d0da01d6 100644 --- a/packages/kitsu-core/src/components/deepEqual.ts +++ b/packages/kitsu-core/src/components/deepEqual.ts @@ -1,4 +1,8 @@ -export const isDeepEqual = (left: any, right: any) => { +// isDeepEqual is able to compare every possible input, so we allow explicit any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Comparable = any + +export function isDeepEqual(left: Comparable, right: Comparable): boolean { if (!left || !right) return left === right const leftKeys = Object.keys(left) @@ -12,7 +16,10 @@ export const isDeepEqual = (left: any, right: any) => { const isObjects = isObject(leftValue) && isObject(rightValue) - if ((isObjects && !isDeepEqual(leftValue, rightValue)) || (!isObjects && leftValue !== rightValue)) { + if ( + (isObjects && !isDeepEqual(leftValue, rightValue)) || + (!isObjects && leftValue !== rightValue) + ) { return false } } @@ -20,6 +27,6 @@ export const isDeepEqual = (left: any, right: any) => { return true } -function isObject(obj: any): obj is object { - return obj != undefined && typeof obj === 'object' +function isObject(object: unknown): object is object { + return object != undefined && typeof object === 'object' } diff --git a/packages/kitsu-core/src/resources/attributes.ts b/packages/kitsu-core/src/resources/attributes.ts index 92b6e58a..7dfe5900 100644 --- a/packages/kitsu-core/src/resources/attributes.ts +++ b/packages/kitsu-core/src/resources/attributes.ts @@ -6,16 +6,25 @@ // Valid JSON values // https://datatracker.ietf.org/doc/html/rfc8259#section-3 -export type JsonValues = object | number | string | false | null | true | Array; +export type JsonValues = + | object + | number + | string + | false + | null + | true + | Array // https://datatracker.ietf.org/doc/html/rfc8259#section-4 // "A name is a string" export interface Attributes { - [name: string]: JsonValues; + [name: string]: JsonValues } -export function isAttributes(attrs: any): attrs is Attributes { - return typeof attrs.attributes === 'object' && - attrs.attributes !== null && - !Array.isArray(attrs.attributes) +export function isAttributes(attributes: unknown): attributes is Attributes { + return ( + typeof attributes === 'object' && + attributes !== null && + !Array.isArray(attributes) + ) } diff --git a/packages/kitsu-core/src/resources/resource-object.ts b/packages/kitsu-core/src/resources/resource-object.ts deleted file mode 100644 index 781c1c0c..00000000 --- a/packages/kitsu-core/src/resources/resource-object.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Attributes } from "./attributes.js"; -import { ResourceIdentifier } from "./resource-identifier.js"; - -type Relationships = void; -type Links = void; -type Meta = void; - -export interface _ResourceObject { - attributes?: Attributes; - relationships?: Relationships; - links?: Links; - meta?: Meta; -} - -export type ResourceObject = _ResourceObject & ResourceIdentifier; diff --git a/packages/kitsu-core/src/resources/resource-identifier.ts b/packages/kitsu-core/src/resources/resourceIdentifier.ts similarity index 82% rename from packages/kitsu-core/src/resources/resource-identifier.ts rename to packages/kitsu-core/src/resources/resourceIdentifier.ts index 193aeb69..e0ec3f29 100644 --- a/packages/kitsu-core/src/resources/resource-identifier.ts +++ b/packages/kitsu-core/src/resources/resourceIdentifier.ts @@ -6,13 +6,15 @@ // > The value of the lid member MUST be identical for every representation of the resource in the document, including resource identifier objects. export interface LocalResourceIdentifier { - lid: string; - type: string; + lid: string + type: string } export interface RemoteResourceIdentifier { - id: string; - type: string; + id: string + type: string } -export type ResourceIdentifier = LocalResourceIdentifier | RemoteResourceIdentifier; +export type ResourceIdentifier = + | LocalResourceIdentifier + | RemoteResourceIdentifier diff --git a/packages/kitsu-core/src/resources/resourceObject.ts b/packages/kitsu-core/src/resources/resourceObject.ts new file mode 100644 index 00000000..69bf237e --- /dev/null +++ b/packages/kitsu-core/src/resources/resourceObject.ts @@ -0,0 +1,15 @@ +import { Attributes } from './attributes.js' +import { ResourceIdentifier } from './resource-identifier.js' + +type Relationships = void +type Links = void +type Meta = void + +export interface ResourceObjectFields { + attributes?: Attributes + relationships?: Relationships + links?: Links + meta?: Meta +} + +export type ResourceObject = ResourceObjectFields & ResourceIdentifier From c84d29ca2b4f89f2e599d22a07d4e7ed8ea6942b Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 16:06:42 +0100 Subject: [PATCH 05/14] expand comment with another option --- packages/kitsu-core/src/components/deattribute.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kitsu-core/src/components/deattribute.ts b/packages/kitsu-core/src/components/deattribute.ts index 1f6f5561..3ac53d40 100644 --- a/packages/kitsu-core/src/components/deattribute.ts +++ b/packages/kitsu-core/src/components/deattribute.ts @@ -21,6 +21,8 @@ function _deattribute(data: ResourceObject): DeattributedResourceObject { // FIXME: what is the best behaviour when given an invalid attributes key? // 1. (Current) the same invalid object is returned. // a. This results in deattribute returning potentially invalid DeattributedResourceObjects + // b. Change the return type to include this scenario. Doing this will possibly cause issues + // down the road in kitsu and kitsu-core // 2. the object is modified, and has the invalid key removed // a. This would guarantee valid returns, but will also change the current default behaviour. // 3. the object is not touched, and an error is thrown From 7ee07a371a83e0dcfb0fb0aac6e596130a2d8e32 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 16:12:27 +0100 Subject: [PATCH 06/14] add test for better coverage in deepEqual --- packages/kitsu-core/src/components/deepEqual.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/kitsu-core/src/components/deepEqual.spec.ts b/packages/kitsu-core/src/components/deepEqual.spec.ts index 40998573..a0bbbcb1 100644 --- a/packages/kitsu-core/src/components/deepEqual.spec.ts +++ b/packages/kitsu-core/src/components/deepEqual.spec.ts @@ -48,6 +48,17 @@ const people = { } } +test('checks if both objects are truthy', t => { + t.false(isDeepEqual(people.one, false)) + t.notDeepEqual(people.one, false) + + t.false(isDeepEqual(false, people.one)) + t.notDeepEqual(false, people.one) + + t.false(isDeepEqual(false, 0)) + t.notDeepEqual(false, 0) +}) + test('checks identical objects are equal', t => { t.true(isDeepEqual(people.one, people.two)) t.deepEqual(people.one, people.two) From e26f850d7314031b46f1f25b4a7011f58db0d0b1 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 16:21:08 +0100 Subject: [PATCH 07/14] bump LTS version of node in CI --- .github/workflows/ci.yml | 4 ++-- package.json | 12 ++++-------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf067f70..f40fef80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: [push, pull_request] env: FORCE_COLOR: true - NODE_VERSION: latest + NODE_VERSION: 20 jobs: setup: @@ -42,7 +42,7 @@ jobs: strategy: matrix: - node_version: [16, 18] + node_version: [18, 20] steps: - uses: actions/checkout@v3 diff --git a/package.json b/package.json index b8c5a753..18cf2ca0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "https://github.com/wopian/kitsu/issues" }, "engines": { - "node": ">= 16" + "node": ">= 18" }, "workspaces": [ "packages/*" @@ -17,19 +17,15 @@ "build": "yarn workspaces foreach -pt run build", "lint": "eslint . --ext .js,.cjs,.mjs,.ts,.cts,.mts --fix --ignore-path .gitignore", "lint:ci": "eslint . --ext .js,.cjs,.mjs,.ts,.cts,.mts --ignore-path .gitignore", - "test": "ava", - "coverage": "c8 ava", + "test": "NODE_OPTIONS='--loader=tsx --no-warnings' ava", + "coverage": "NODE_OPTIONS='--loader=tsx --no-warnings' c8 ava", "document": "typedoc src/index.ts --name preferred-locale --includeVersion --hideGenerator --searchInComments --plugin @mxssfd/typedoc-theme --theme my-theme --entryPointStrategy expand" }, "ava": { "utilizeParallelBuilds": true, "extensions": { "ts": "module" - }, - "nodeArguments": [ - "--loader", - "tsx" - ] + } }, "c8": { "all": true, From dd4b942aa12572749b4d1d6b58d178008655de87 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 17:11:04 +0100 Subject: [PATCH 08/14] use correct import path --- packages/kitsu-core/src/resources/resourceObject.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kitsu-core/src/resources/resourceObject.ts b/packages/kitsu-core/src/resources/resourceObject.ts index 69bf237e..2caa1924 100644 --- a/packages/kitsu-core/src/resources/resourceObject.ts +++ b/packages/kitsu-core/src/resources/resourceObject.ts @@ -1,5 +1,5 @@ import { Attributes } from './attributes.js' -import { ResourceIdentifier } from './resource-identifier.js' +import { ResourceIdentifier } from './resourceIdentifier.js' type Relationships = void type Links = void From ca67dca27a417f350ea1141f9da420497ea3cfcb Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Thu, 2 Nov 2023 17:43:12 +0100 Subject: [PATCH 09/14] implement query --- .../kitsu-core/src/components/deepEqual.ts | 10 +- .../kitsu-core/src/components/isObject.ts | 3 + .../kitsu-core/src/components/query.spec.ts | 94 +++++++++++++++++++ packages/kitsu-core/src/components/query.ts | 24 +++++ packages/kitsu-core/src/index.ts | 1 + 5 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 packages/kitsu-core/src/components/isObject.ts create mode 100644 packages/kitsu-core/src/components/query.spec.ts create mode 100644 packages/kitsu-core/src/components/query.ts diff --git a/packages/kitsu-core/src/components/deepEqual.ts b/packages/kitsu-core/src/components/deepEqual.ts index d0da01d6..b6f016e5 100644 --- a/packages/kitsu-core/src/components/deepEqual.ts +++ b/packages/kitsu-core/src/components/deepEqual.ts @@ -14,11 +14,11 @@ export function isDeepEqual(left: Comparable, right: Comparable): boolean { const leftValue = left[key] const rightValue = right[key] - const isObjects = isObject(leftValue) && isObject(rightValue) + const goDeeper = isDeep(leftValue) && isDeep(rightValue) if ( - (isObjects && !isDeepEqual(leftValue, rightValue)) || - (!isObjects && leftValue !== rightValue) + (goDeeper && !isDeepEqual(leftValue, rightValue)) || + (!goDeeper && leftValue !== rightValue) ) { return false } @@ -27,6 +27,6 @@ export function isDeepEqual(left: Comparable, right: Comparable): boolean { return true } -function isObject(object: unknown): object is object { - return object != undefined && typeof object === 'object' +function isDeep(object: unknown): boolean { + return typeof object === 'object' && object !== null } diff --git a/packages/kitsu-core/src/components/isObject.ts b/packages/kitsu-core/src/components/isObject.ts new file mode 100644 index 00000000..b23654c5 --- /dev/null +++ b/packages/kitsu-core/src/components/isObject.ts @@ -0,0 +1,3 @@ +export function isObject(object: unknown): object is object { + return typeof object === 'object' && object !== null && !Array.isArray(object) +} diff --git a/packages/kitsu-core/src/components/query.spec.ts b/packages/kitsu-core/src/components/query.spec.ts new file mode 100644 index 00000000..83132075 --- /dev/null +++ b/packages/kitsu-core/src/components/query.spec.ts @@ -0,0 +1,94 @@ +import test from 'ava' + +import { query } from '../index.js' + +test('returns an empty string by default', t => { + t.is(query({}), '') +}) + +test('builds a filter query string', t => { + t.is( + query({ + filter: { + slug: 'cowboy-bebop', + title: { + value: 'foo' + } + } + }), + 'filter%5Bslug%5D=cowboy-bebop&filter%5Btitle%5D%5Bvalue%5D=foo' + ) +}) + +test('builds an include query string', t => { + t.is( + query({ + include: 'author,comments.author' + }), + 'include=author%2Ccomments.author' + ) +}) + +test('builds a fields query string', t => { + t.is( + query({ + fields: { + articles: 'title', + author: 'name' + } + }), + 'fields%5Barticles%5D=title&fields%5Bauthor%5D=name' + ) +}) + +test('appends multiple queries', t => { + t.is( + query({ + page: { limit: 1 }, + sort: '-popularityRank' + }), + 'page%5Blimit%5D=1&sort=-popularityRank' + ) +}) + +test('builds nested parameters', t => { + t.is( + query({ + fields: { + abc: { + def: { + ghi: { + jkl: 'mno' + } + } + } + } + }), + 'fields%5Babc%5D%5Bdef%5D%5Bghi%5D%5Bjkl%5D=mno' + ) +}) + +test('builds list parameters', t => { + t.is( + query({ + filter: { + id_in: [1, 2, 3] + } + }), + 'filter%5Bid_in%5D=1&filter%5Bid_in%5D=2&filter%5Bid_in%5D=3' + ) +}) + +test('builds nested list parameters', t => { + t.is( + query({ + filter: { + users: [ + { id: 1, type: 'users' }, + { id: 2, type: 'users' } + ] + } + }), + 'filter%5Busers%5D%5Bid%5D=1&filter%5Busers%5D%5Btype%5D=users&filter%5Busers%5D%5Bid%5D=2&filter%5Busers%5D%5Btype%5D=users' + ) +}) diff --git a/packages/kitsu-core/src/components/query.ts b/packages/kitsu-core/src/components/query.ts new file mode 100644 index 00000000..c92d247a --- /dev/null +++ b/packages/kitsu-core/src/components/query.ts @@ -0,0 +1,24 @@ +import { isObject } from './isObject.js' + +// https://github.com/microsoft/TypeScript/blob/73bc0eba5fd35c3a31cc9a4e6d28d3e89564ce6f/src/lib/es5.d.ts#L66 +type EncodableValue = string | number | boolean + +type QueryValue = EncodableValue | EncodableValue[] | Query +export interface Query { + [key: string]: QueryValue +} + +function queryFormat(key: string, value: QueryValue): string { + if (Array.isArray(value)) return value.map(v => queryFormat(key, v)).join('&') + if (isObject(value)) return query(value, key) + + return encodeURIComponent(key) + '=' + encodeURIComponent(value) +} + +export function query(parameters: Query, prefix?: string) { + return Object.keys(parameters) + .map(key => + queryFormat(prefix ? `${prefix}[${key}]` : key, parameters[key]) + ) + .join('&') +} diff --git a/packages/kitsu-core/src/index.ts b/packages/kitsu-core/src/index.ts index f8709b9b..a396b800 100644 --- a/packages/kitsu-core/src/index.ts +++ b/packages/kitsu-core/src/index.ts @@ -1,2 +1,3 @@ export * from './components/deattribute.js' export * from './components/deepEqual.js' +export * from './components/query.js' From b4214b4cb80cca6f2683b7567bc5c5042366f23f Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Mon, 6 Nov 2023 16:16:12 +0100 Subject: [PATCH 10/14] implement serialise & error --- packages/kitsu-core/package.json | 3 + .../kitsu-core/src/components/error.spec.ts | 75 ++ packages/kitsu-core/src/components/error.ts | 26 + packages/kitsu-core/src/components/query.ts | 2 +- .../src/components/serialise.spec.ts | 651 ++++++++++++++++++ .../kitsu-core/src/components/serialise.ts | 259 +++++++ packages/kitsu-core/src/index.ts | 2 + .../src/{components => }/isObject.ts | 0 .../kitsu-core/src/resources/attributes.ts | 17 +- packages/kitsu-core/src/resources/json.ts | 14 + packages/kitsu-core/src/resources/meta.ts | 12 + .../kitsu-core/src/resources/relationships.ts | 37 + .../src/resources/resourceIdentifier.ts | 16 +- .../src/resources/resourceObject.ts | 4 +- .../src/utilities/hasOwnProperty.ts | 3 + yarn.lock | 88 +++ 16 files changed, 1191 insertions(+), 18 deletions(-) create mode 100644 packages/kitsu-core/src/components/error.spec.ts create mode 100644 packages/kitsu-core/src/components/error.ts create mode 100644 packages/kitsu-core/src/components/serialise.spec.ts create mode 100644 packages/kitsu-core/src/components/serialise.ts rename packages/kitsu-core/src/{components => }/isObject.ts (100%) create mode 100644 packages/kitsu-core/src/resources/json.ts create mode 100644 packages/kitsu-core/src/resources/meta.ts create mode 100644 packages/kitsu-core/src/resources/relationships.ts create mode 100644 packages/kitsu-core/src/utilities/hasOwnProperty.ts diff --git a/packages/kitsu-core/package.json b/packages/kitsu-core/package.json index b248f2c3..6c2b5f1f 100644 --- a/packages/kitsu-core/package.json +++ b/packages/kitsu-core/package.json @@ -40,5 +40,8 @@ ], "dependencies": { "case-anything": "^2.1.10" + }, + "devDependencies": { + "@types/axios": "~0.14.0" } } diff --git a/packages/kitsu-core/src/components/error.spec.ts b/packages/kitsu-core/src/components/error.spec.ts new file mode 100644 index 00000000..5662d168 --- /dev/null +++ b/packages/kitsu-core/src/components/error.spec.ts @@ -0,0 +1,75 @@ +import test from 'ava' + +import { error } from '../index.js' + +test('handles axios response errors', t => { + t.plan(1) + + const object = { response: {} } + try { + error(object) + } catch(err: unknown) { + t.deepEqual(err, {response: {}}) + } +}) + +test('throws all other errors', t => { + t.plan(2) + + try { + error('Hello') + } catch(err: unknown) { + t.is(err, 'Hello') + } + + t.throws( + () => { + error(new Error('Hello')) + }, + { message: 'Hello' } + ) +}) + +test('handles axios response errors with JSON:API errors', t => { + t.plan(1) + const object = { + response: { + data: { + errors: [ + { + title: 'Filter is not allowed', + detail: 'x is not allowed', + code: '102', + status: '400' + } + ] + } + } + } + try { + error(object) + } catch ({ errors }) { + t.deepEqual(errors, [ + { + title: 'Filter is not allowed', + detail: 'x is not allowed', + code: '102', + status: '400' + } + ]) + } +}) + +test('handles top-level JSON:API errors', t => { + t.plan(1) + const object = { + errors: [{ code: 400 }] + } + try { + error(object) + } catch (error_) { + t.deepEqual(error_, { + errors: [{ code: 400 }] + }) + } +}) diff --git a/packages/kitsu-core/src/components/error.ts b/packages/kitsu-core/src/components/error.ts new file mode 100644 index 00000000..ba117937 --- /dev/null +++ b/packages/kitsu-core/src/components/error.ts @@ -0,0 +1,26 @@ +import { AxiosError as BaseAxiosError } from 'axios'; + +// Extend AxiosError to facilitate 'error' function legacy behaviour +class AxiosError extends BaseAxiosError { + public errors?: unknown; +} + +import { isObject } from "../isObject.js" +import { hasOwnProperty } from "../utilities/hasOwnProperty.js" + +// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#unknown-on-catch-clause-bindings +// catch must be typed as any or unknown. +export function error(sourceError: unknown): void { + if (isAxiosError<{errors?: unknown}>(sourceError)) { + const e = sourceError.response?.data + if (e?.errors) sourceError.errors = e.errors + } + + throw sourceError +} + +// TODO: should this be replaced with the 'correct' axios implementation? +// https://github.com/axios/axios/blob/main/lib/helpers/isAxiosError.js +function isAxiosError(obj: unknown): obj is AxiosError { + return isObject(obj) && (hasOwnProperty(obj, 'response')) +} diff --git a/packages/kitsu-core/src/components/query.ts b/packages/kitsu-core/src/components/query.ts index c92d247a..afbba3e5 100644 --- a/packages/kitsu-core/src/components/query.ts +++ b/packages/kitsu-core/src/components/query.ts @@ -1,4 +1,4 @@ -import { isObject } from './isObject.js' +import { isObject } from '../isObject.js' // https://github.com/microsoft/TypeScript/blob/73bc0eba5fd35c3a31cc9a4e6d28d3e89564ce6f/src/lib/es5.d.ts#L66 type EncodableValue = string | number | boolean diff --git a/packages/kitsu-core/src/components/serialise.spec.ts b/packages/kitsu-core/src/components/serialise.spec.ts new file mode 100644 index 00000000..d54fbc19 --- /dev/null +++ b/packages/kitsu-core/src/components/serialise.spec.ts @@ -0,0 +1,651 @@ +import test from 'ava' +import { camelCase } from 'case-anything' + +import { serialise } from '../index' + +const camel = camelCase +function plural(s: string) { + if (['anime'].includes(s)) return s + if (s.endsWith('y')) return `${s.slice(0, -1)}ies` + if (s.endsWith('s')) return s + + return `${s}s` +} + +test('accepts camelCaseTypes as an option (default)', t => { + const input = serialise('library-entries', { id: '1' }) + t.deepEqual(input, { + data: { + id: '1', + type: 'library-entries' + } + }) +}) + +test('accepts camelCaseTypes as an option (value set)', t => { + const input = serialise('library-entries', { id: '1' }, undefined, { + camelCaseTypes: camel + }) + t.deepEqual(input, { + data: { + id: '1', + type: 'libraryEntries' + } + }) +}) + +test('accepts pluralTypes as an option (default)', t => { + const input = serialise('libraryEntry', { id: '1' }) + t.deepEqual(input, { + data: { + id: '1', + type: 'libraryEntry' + } + }) +}) + +test('accepts pluralTypes as an option (value set)', t => { + const input = serialise('libraryEntry', { id: '1' }, undefined, { + pluralTypes: plural + }) + t.deepEqual(input, { + data: { + id: '1', + type: 'libraryEntries' + } + }) +}) + +test('accepts typeTransform as an option (default)', t => { + const input = serialise('library-entries', { id: '1' }, undefined, {}) + t.deepEqual(input, { + data: { + id: '1', + type: 'library-entries' + } + }) +}) + +test('accepts typeTransform as an option (value set)', t => { + const input = serialise('library-entries', { id: '1' }, undefined, { + typeTransform: s => s.toUpperCase() + }) + t.deepEqual(input, { + data: { + id: '1', + type: 'LIBRARY-ENTRIES' + } + }) +}) + +test('serialises to a JSON API compliant object', t => { + const input = serialise( + 'libraryEntries', + { + ratingTwenty: 20 + }, + undefined, + { + camelCaseTypes: camel, + pluralTypes: plural + } + ) + t.deepEqual(input, { + data: { + attributes: { + ratingTwenty: 20 + }, + type: 'libraryEntries' + } + }) +}) + +test('serialises JSON API relationships', t => { + const input = serialise( + 'libraryEntries', + { + user: { + data: { + id: '2' + } + } + }, + undefined, + { + camelCaseTypes: camel, + pluralTypes: plural + } + ) + t.deepEqual(input, { + data: { + relationships: { + user: { + data: { + id: '2', + type: 'users' + } + } + }, + type: 'libraryEntries' + } + }) +}) + +test('serialises JSON API array relationships', t => { + const input = serialise( + 'libraryEntries', + { + user: { + data: [ + { + id: '2', + type: 'users', + content: 'yuzu' + }, + { + id: '3' + } + ] + } + }, + undefined, + { + camelCaseTypes: camel, + pluralTypes: plural + } + ) + t.deepEqual(input, { + data: { + relationships: { + user: { + data: [ + { + id: '2', + type: 'users', + attributes: { + content: 'yuzu' + } + }, + { + id: '3', + type: 'users' + } + ] + } + }, + type: 'libraryEntries' + } + }) +}) + +test('serialises JSON API with a client-generated ID', t => { + const input = serialise( + 'libraryEntries', + { + id: '123456789', + ratingTwenty: 20 + }, + undefined, + { + camelCaseTypes: camel, + pluralTypes: plural + } + ) + t.deepEqual(input, { + data: { + id: '123456789', + type: 'libraryEntries', + attributes: { + ratingTwenty: 20 + } + } + }) +}) + +test('pluralises type', t => { + const input = serialise( + 'libraryEntry', + { + rating: '1' + }, + undefined, + { + camelCaseTypes: camel, + pluralTypes: plural + } + ) + t.deepEqual(input, { + data: { + type: 'libraryEntries', + attributes: { + rating: '1' + } + } + }) +}) + +test('does not pluralise mass nouns', t => { + const input = serialise( + 'anime', + { + slug: 'Cowboy Bebop 2' + }, + undefined, + { + camelCaseTypes: camel, + pluralTypes: plural + } + ) + t.deepEqual(input, { + data: { + type: 'anime', + attributes: { + slug: 'Cowboy Bebop 2' + } + } + }) +}) + +test('does not pluralise type', t => { + const input = serialise('libraryEntry', { + rating: '1' + }) + t.deepEqual(input, { + data: { + type: 'libraryEntry', + attributes: { + rating: '1' + } + } + }) +}) + +test('throws an error if obj is missing', t => { + t.throws(() => serialise('post'), { + message: 'POST requires an object or array body' + }) +}) + +test('throws an error if obj is not an Object', t => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t.throws(() => serialise('post', 'id: 1' as any, 'DELETE'), { + message: 'DELETE requires an object or array body' + }) +}) + +test('throws an error when missing ID', t => { + t.throws(() => serialise('user', { theme: 'dark' }, 'PATCH'), { + message: 'PATCH requires an ID for the user type' + }) +}) + +test('throws an error when missing ID in array', t => { + t.throws(() => serialise('user', [{ theme: 'dark' }], 'PATCH'), { + message: 'PATCH requires an ID for the user type' + }) +}) + +test('serialises strings/numbers/booleans into attributes', t => { + const input = serialise('resourceModel', { + string: 'shark', + number: 1, + boolean: true + }) + t.deepEqual(input, { + data: { + type: 'resourceModel', + attributes: { + string: 'shark', + number: 1, + boolean: true + } + } + }) +}) + +test('serialises bare objects into attributes', t => { + const input = serialise('resourceModel', { + object: { + string: 'shark' + }, + blank: {} + }) + t.deepEqual(input, { + data: { + type: 'resourceModel', + attributes: { + object: { + string: 'shark' + }, + blank: {} + } + } + }) +}) + +test('serialises type objects into relationships', t => { + const input = serialise('resourceModel', { + myRelationship: { + data: { + id: '1', + type: 'relationshipModel', + content: 'Hello', + attributes: 'Keep me' + } + } + }) + t.deepEqual(input, { + data: { + type: 'resourceModel', + relationships: { + myRelationship: { + data: { + id: '1', + type: 'relationshipModel', + attributes: { + content: 'Hello', + attributes: 'Keep me' + } + } + } + } + } + }) +}) + +test('serialises type objects into relationships inside arrays', t => { + const input = serialise('resourceModel', [ + { + myRelationship: { + data: { + id: '1', + type: 'relationshipModel', + content: 'Hello' + } + } + } + ]) + t.deepEqual(input, { + data: [ + { + type: 'resourceModel', + relationships: { + myRelationship: { + data: { + id: '1', + type: 'relationshipModel', + attributes: { + content: 'Hello' + } + } + } + } + } + ] + }) +}) + +test('serialises bare arrays into attributes', t => { + const input = serialise('resourceModel', { + array: [0], + deepArray: [[0]], + arrayObject: [{ string: 'shark' }], + blank: [] + }) + t.deepEqual(input, { + data: { + type: 'resourceModel', + attributes: { + array: [0], + deepArray: [[0]], + arrayObject: [{ string: 'shark' }], + blank: [] + } + } + }) +}) + +test('serialises type arrays into relationships', t => { + const input = serialise('resourceModels', { + arrayRelation: { + data: [ + { + id: '1', + type: 'arrayRelations', + content: 'Hey', + attributes: 'Keep me' + } + ] + } + }) + t.deepEqual(input, { + data: { + type: 'resourceModels', + relationships: { + arrayRelation: { + data: [ + { + id: '1', + type: 'arrayRelations', + attributes: { + content: 'Hey', + attributes: 'Keep me' + } + } + ] + } + } + } + }) +}) + +test('serialises relationship clearing (to-one)', t => { + const input = serialise('resourceModel', null) // eslint-disable-line unicorn/no-null + t.deepEqual(input, { + data: null // eslint-disable-line unicorn/no-null + }) +}) + +test('serialises relationship clearing (to-many)', t => { + const input = serialise('resourceModel', []) + t.deepEqual(input, { + data: [] + }) +}) + +test('serialises a data array without ID (POST)', t => { + const resource = { content: 'some new content' } + const resourceOutput = { + type: 'posts', + attributes: { content: 'some new content' } + } + const input = serialise('posts', [resource, resource]) + t.deepEqual(input, { + data: [resourceOutput, resourceOutput] + }) +}) + +test('serialises a data array with ID (PATCH/DELETE)', t => { + const resource = { id: '1', content: 'some new content' } + const resourceOutput = { + id: '1', + type: 'posts', + attributes: { content: 'some new content' } + } + const input = serialise('posts', [resource, resource]) + t.deepEqual(input, { + data: [resourceOutput, resourceOutput] + }) +}) + +test('does not error with an invalid JSON value (undefined)', t => { + const resource = { id: '1', content: undefined } + const resourceOutput = { + id: '1', + type: 'posts', + attributes: { content: undefined } + } + const input = serialise('posts', resource) + t.deepEqual(input, { data: resourceOutput }) +}) + +test('serialises object and array relationships', t => { + const input = { + id: '1', + type: 'libraryEntries', + links: { self: 'library-entries/1' }, + meta: { extra: true }, + ratingTwenty: 10, + user: { + links: { + self: 'library-entries/1/relationships/user', + related: 'library-entries/1/user' + }, + meta: { some: 'meta info' }, + data: { + id: '2', + type: 'users', + name: 'Example', + links: { self: 'users/2' } + } + }, + unit: { + links: { + self: 'library-entries/1/relationships/unit', + related: 'library-entries/1/unit' + }, + meta: { extra: 'info' }, + data: [ + { + id: '3', + type: 'episodes', + number: 12, + links: { self: 'episodes/3' } + } + ] + } + } + const output = { + data: { + id: '1', + type: 'libraryEntries', + links: { self: 'library-entries/1' }, + meta: { extra: true }, + attributes: { ratingTwenty: 10 }, + relationships: { + user: { + links: { + self: 'library-entries/1/relationships/user', + related: 'library-entries/1/user' + }, + meta: { some: 'meta info' }, + data: { + id: '2', + type: 'users', + attributes: { name: 'Example' }, + links: { self: 'users/2' } + } + }, + unit: { + links: { + self: 'library-entries/1/relationships/unit', + related: 'library-entries/1/unit' + }, + meta: { extra: 'info' }, + data: [ + { + id: '3', + type: 'episodes', + attributes: { number: 12 }, + links: { self: 'episodes/3' } + } + ] + } + } + } + } + t.deepEqual(serialise('libraryEntries', input), output) +}) + +test('keeps non-JSON:API links/meta properties in attributes', t => { + const input = { + id: '1', + type: 'libraryEntries', + links: 'Not JSON:API link object', + meta: 'Not JSON:API meta object', + user: { + data: { + id: '1', + links: 'Not JSON:API link object', + meta: 'Not JSON:API meta object' + } + } + } + const output = { + data: { + id: '1', + type: 'libraryEntries', + attributes: { + links: 'Not JSON:API link object', + meta: 'Not JSON:API meta object' + }, + relationships: { + user: { + data: { + id: '1', + type: 'user', + attributes: { + links: 'Not JSON:API link object', + meta: 'Not JSON:API meta object' + } + } + } + } + } + } + t.deepEqual(serialise('libraryEntries', input), output) +}) + +test('deletes a to-one relationship', t => { + const input = { + id: '1', + type: 'libraryEntries', + user: { + data: null // eslint-disable-line unicorn/no-null + } + } + const output = { + data: { + id: '1', + type: 'libraryEntries', + relationships: { + user: { + data: null // eslint-disable-line unicorn/no-null + } + } + } + } + t.deepEqual(serialise('libraryEntries', input), output) +}) + +test('deletes a to-many relationship', t => { + const input = { + id: '1', + type: 'libraryEntries', + user: { + data: [] + } + } + const output = { + data: { + id: '1', + type: 'libraryEntries', + relationships: { + user: { + data: [] + } + } + } + } + t.deepEqual(serialise('libraryEntries', input), output) +}) diff --git a/packages/kitsu-core/src/components/serialise.ts b/packages/kitsu-core/src/components/serialise.ts new file mode 100644 index 00000000..14ff2eb9 --- /dev/null +++ b/packages/kitsu-core/src/components/serialise.ts @@ -0,0 +1,259 @@ +import { isObject } from '../isObject.js' +import { JsonKey, JsonValue } from '../resources/json.js' +import { RelationshipObject } from '../resources/relationships.js' +import { + isRemoteResource, + ResourceIdentifier +} from '../resources/resourceIdentifier.js' +import { ResourceObject } from '../resources/resourceObject.js' +import { hasOwnProperty } from '../utilities/hasOwnProperty.js' +import { error } from './error.js' + +// TODO: rename nodes to something more JSON:API like ResourceObject +type Node = any +type NodeType = ResourceIdentifier['type'] +type Method = 'POST' | 'PATCH' | 'DELETE' + +interface DeprecatedSerialiseOptions { + pluralTypes?: (type: string) => string + camelCaseTypes?: (type: string) => string +} + +interface SerialiseOptions { + typeTransform: (nodeType: NodeType) => NodeType +} + +type SerialisableObject = { + [key: JsonKey]: JsonValue +} + +type Serialisable = SerialisableObject | SerialisableObject[] + +interface JsonapiDocument { + data: ResourceObject | ResourceObject[] | null + // errors: ErrorObjects + // meta: Meta + // + // jsonapi?: unknown + // links?: Links + // included?: ResourceObject[] +} + +function validateArrayPayload( + type: NodeType, + payload: SerialisableObject[], + method: Method +): void { + const requireID = new Error(`${method} requires an ID for the ${type} type`) + + if (type === undefined) { + throw new Error(`${method} requires a resource type`) + } + + // A POST request is the only request to not require an ID in spec + if (method !== 'POST' && payload.length > 0) { + for (const resource of payload) { + if (!hasOwnProperty(resource, 'id')) throw requireID + } + } +} + +function validateObjectPayload( + type: NodeType, + payload: SerialisableObject, + method: Method +): void { + const requireID = new Error(`${method} requires an ID for the ${type} type`) + + if (type === undefined) { + throw new Error(`${method} requires a resource type`) + } + + if (typeof payload !== 'object' || Object.keys(payload).length === 0) { + throw new Error(`${method} requires an object or array body`) + } + // A POST request is the only request to not require an ID in spec + if (method !== 'POST' && !hasOwnProperty(payload, 'id')) { + throw requireID + } +} + +function serialiseRelationOne(node: Node, nodeType?: NodeType) { + // Handle empty to-one relationship + if (node === null) return node + let relation: Partial = {} + for (const property of Object.keys(node)) { + if (['id', 'type'].includes(property)) relation[property] = node[property] + else relation = serialiseAttribute(node[property], property, relation) + } + // Guess relationship type if not provided + if (!relation.type) relation.type = nodeType + return relation +} + +function serialiseRelationMany(node: Node, nodeType: NodeType) { + const relation = [] + for (const property of node) { + const serialised = serialiseRelationOne(property) + // Guess relationship type if not provided + if (!serialised.type) serialised.type = nodeType + relation.push(serialised) + } + return relation +} + +function serialiseRelation( + node: Node, + nodeType: NodeType, + key: keyof SerialisableObject, + data: Partial +) { + if (!data.relationships) data.relationships = {} + data.relationships[key] = { + data: Array.isArray(node.data) + ? serialiseRelationMany(node.data, nodeType) + : serialiseRelationOne(node.data, nodeType) + } + if (node?.links?.self || node?.links?.related) + data.relationships[key].links = node.links + if (node?.meta) data.relationships[key].meta = node.meta + return data +} + +function serialiseAttribute( + node: Node, + key: keyof SerialisableObject, + data: Partial +) { + if (!data.attributes) data.attributes = {} + if ( + key === 'links' && + (typeof node.self === 'string' || typeof node.related === 'string') + ) + data.links = node + else if ( + key === 'meta' && + typeof node === 'object' && + !Array.isArray(node) && + node !== null + ) + data.meta = node + else data.attributes[key] = node + return data +} + +function hasID(node: Node) { + // Handle empty to-one and to-many relationships + if ( + node?.data === null || + (Array.isArray(node?.data) && node?.data?.length === 0) + ) + return true + if (!node.data) return false + // Check if relationship is to-many + const nodeData = Array.isArray(node.data) ? node.data[0] : node.data + return Object.prototype.hasOwnProperty.call(nodeData, 'id') +} + +function serialiseRootArray( + type: NodeType, + payload: SerialisableObject[], + method: Method, + options: SerialiseOptions +) { + validateArrayPayload(type, payload, method) + + const data: ResourceObject[] = [] + for (const resource of payload) { + data.push(serialiseRootObject(type, resource, method, options).data) + } + return { data } +} + +function serialiseRootObject( + type: NodeType, + payload: SerialisableObject, + method: Method, + options: SerialiseOptions +) { + validateObjectPayload(type, payload, method) + + type = options.typeTransform(type) + // ID not required for POST requests + let data: Partial = isRemoteResource(payload) + ? { type, id: String(payload.id) } + : { type } + + for (const key in payload) { + const node = payload[key] + const nodeType = options.typeTransform(key) + // 1. Only grab objects, 2. Filter to only serialise relationable objects + if (isObject(node) && hasID(node)) { + data = serialiseRelation(node, nodeType, key, data) + // 1. Don't place id/key inside attributes object + } else if (key !== 'id' && key !== 'type') { + data = serialiseAttribute(node, key, data) + } + } + return { data: data as ResourceObject } +} + +export function serialise( + type: NodeType, + data: Serialisable, + method?: Method, + options?: SerialiseOptions +): JsonapiDocument +/** @deprecated + * pluralTypes and camelCaseTypes are deprecated. Use typeTransform instead. + **/ +export function serialise( + type: NodeType, + data: Serialisable, + method?: Method, + options?: DeprecatedSerialiseOptions +): JsonapiDocument +export function serialise( + type: NodeType, + data: Serialisable, + method: Method = 'POST', + options: Partial & DeprecatedSerialiseOptions = {} +): JsonapiDocument { + try { + // Delete relationship to-one (data: null) or to-many (data: []) + if (data === null) return { data: null } // eslint-disable-line unicorn/no-null + if (Array.isArray(data) && data.length === 0) return { data: [] } + + const options_ = applyDefaultOptions(options) + + return Array.isArray(data) + ? serialiseRootArray(type, data, method, options_) + : serialiseRootObject(type, data, method, options_) + } catch (error_: unknown) { + throw error(error_) + } +} + +const noop = (type: string) => type + +function applyDefaultOptions( + options: Partial & DeprecatedSerialiseOptions +): SerialiseOptions { + if (isSerialiseOptions(options)) { + return options + } + + if (options.camelCaseTypes || options.pluralTypes) { + const camel = options.camelCaseTypes || noop + const plural = options.pluralTypes || noop + return { typeTransform: (type: string) => plural(camel(type)) } + } + + return { typeTransform: noop } +} + +function isSerialiseOptions( + options: Partial & DeprecatedSerialiseOptions = {} +): options is SerialiseOptions { + return !!options.typeTransform +} diff --git a/packages/kitsu-core/src/index.ts b/packages/kitsu-core/src/index.ts index a396b800..126987e1 100644 --- a/packages/kitsu-core/src/index.ts +++ b/packages/kitsu-core/src/index.ts @@ -1,3 +1,5 @@ export * from './components/deattribute.js' export * from './components/deepEqual.js' +export * from './components/error.js' export * from './components/query.js' +export * from './components/serialise.js' diff --git a/packages/kitsu-core/src/components/isObject.ts b/packages/kitsu-core/src/isObject.ts similarity index 100% rename from packages/kitsu-core/src/components/isObject.ts rename to packages/kitsu-core/src/isObject.ts diff --git a/packages/kitsu-core/src/resources/attributes.ts b/packages/kitsu-core/src/resources/attributes.ts index 7dfe5900..cf0dc713 100644 --- a/packages/kitsu-core/src/resources/attributes.ts +++ b/packages/kitsu-core/src/resources/attributes.ts @@ -1,24 +1,13 @@ +import { JsonKey, JsonValue } from './json.js' + // https://jsonapi.org/format/#document-resource-object-attributes // // The value of the attributes key MUST be an object (an “attributes object”). Members of the attributes object (“attributes”) represent information about the resource object in which it’s defined. // Attributes may contain any valid JSON value, including complex data structures involving JSON objects and arrays. // Keys that reference related resources (e.g. author_id) SHOULD NOT appear as attributes. Instead, relationships SHOULD be used. -// Valid JSON values -// https://datatracker.ietf.org/doc/html/rfc8259#section-3 -export type JsonValues = - | object - | number - | string - | false - | null - | true - | Array - -// https://datatracker.ietf.org/doc/html/rfc8259#section-4 -// "A name is a string" export interface Attributes { - [name: string]: JsonValues + [name: JsonKey]: JsonValue } export function isAttributes(attributes: unknown): attributes is Attributes { diff --git a/packages/kitsu-core/src/resources/json.ts b/packages/kitsu-core/src/resources/json.ts new file mode 100644 index 00000000..fa9067d8 --- /dev/null +++ b/packages/kitsu-core/src/resources/json.ts @@ -0,0 +1,14 @@ +// Valid JSON values +// https://datatracker.ietf.org/doc/html/rfc8259#section-3 +export type JsonValue = + | object + | number + | string + | false + | null + | true + | Array + +// https://datatracker.ietf.org/doc/html/rfc8259#section-4 +// "A name is a string" +export type JsonKey = string diff --git a/packages/kitsu-core/src/resources/meta.ts b/packages/kitsu-core/src/resources/meta.ts new file mode 100644 index 00000000..08f4a63c --- /dev/null +++ b/packages/kitsu-core/src/resources/meta.ts @@ -0,0 +1,12 @@ +import { JsonKey, JsonValue } from './json.js' + +// https://jsonapi.org/format/#document-meta +// +// Meta Information +// Where specified, a meta member can be used to include non-standard meta-information. The value of each meta member MUST be an object (a “meta object”). +// +// Any members MAY be specified within meta objects. + +export interface Meta { + [name: JsonKey]: JsonValue +} diff --git a/packages/kitsu-core/src/resources/relationships.ts b/packages/kitsu-core/src/resources/relationships.ts new file mode 100644 index 00000000..79de1c39 --- /dev/null +++ b/packages/kitsu-core/src/resources/relationships.ts @@ -0,0 +1,37 @@ +import { JsonKey, JsonValue } from './json.js' +import { Meta } from './meta.js' + +// https://jsonapi.org/format/#document-resource-object-relationships +// +// Relationships +// The value of the relationships key MUST be an object (a “relationships object”). Each member of a relationships object represents a “relationship” from the resource object in which it has been defined to other resource objects. +// Relationships may be to-one or to-many. +// A relationship’s name is given by its key. The value at that key MUST be an object (“relationship object”). + +type Links = void +type Data = void + +export interface Relationships { + [name: JsonKey]: RelationshipObject +} + +// A “relationship object” MUST contain at least one of the following: +// +// - links: a links object containing at least one of the following: +// - data: resource linkage +// - meta: a meta object that contains non-standard meta-information about the relationship. +// - a member defined by an applied extension. +export type _Relationship = { + links: Links + data: Data + meta: Meta +} + +type RelationshipKeys = keyof _Relationship +// "at least one of" type circus +export type RelationshipObject = { + [Key in RelationshipKeys]-?: Required> & + Partial>> +}[RelationshipKeys] & { + [extensionKey: string]: JsonValue +} diff --git a/packages/kitsu-core/src/resources/resourceIdentifier.ts b/packages/kitsu-core/src/resources/resourceIdentifier.ts index e0ec3f29..9a5be900 100644 --- a/packages/kitsu-core/src/resources/resourceIdentifier.ts +++ b/packages/kitsu-core/src/resources/resourceIdentifier.ts @@ -5,8 +5,10 @@ // > If id is omitted due to this exception, a lid member MAY be included to uniquely identify the resource by type locally within the document. // > The value of the lid member MUST be identical for every representation of the resource in the document, including resource identifier objects. +import { hasOwnProperty } from '../utilities/hasOwnProperty.js' + export interface LocalResourceIdentifier { - lid: string + lid?: string type: string } @@ -18,3 +20,15 @@ export interface RemoteResourceIdentifier { export type ResourceIdentifier = | LocalResourceIdentifier | RemoteResourceIdentifier + +export function isLocalResource( + object: T +): object is T & LocalResourceIdentifier { + return !hasOwnProperty(object, 'id') || hasOwnProperty(object, 'lid') +} + +export function isRemoteResource( + object: T +): object is T & RemoteResourceIdentifier { + return hasOwnProperty(object, 'id') && !hasOwnProperty(object, 'lid') +} diff --git a/packages/kitsu-core/src/resources/resourceObject.ts b/packages/kitsu-core/src/resources/resourceObject.ts index 2caa1924..2208bfa8 100644 --- a/packages/kitsu-core/src/resources/resourceObject.ts +++ b/packages/kitsu-core/src/resources/resourceObject.ts @@ -1,9 +1,9 @@ import { Attributes } from './attributes.js' +import { Meta } from './meta.js' +import { Relationships } from './relationships.js' import { ResourceIdentifier } from './resourceIdentifier.js' -type Relationships = void type Links = void -type Meta = void export interface ResourceObjectFields { attributes?: Attributes diff --git a/packages/kitsu-core/src/utilities/hasOwnProperty.ts b/packages/kitsu-core/src/utilities/hasOwnProperty.ts new file mode 100644 index 00000000..a7554b9c --- /dev/null +++ b/packages/kitsu-core/src/utilities/hasOwnProperty.ts @@ -0,0 +1,3 @@ +export function hasOwnProperty(object: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(object, key) +} diff --git a/yarn.lock b/yarn.lock index edc5c5ad..1ecce8ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -853,6 +853,15 @@ __metadata: languageName: node linkType: hard +"@types/axios@npm:~0.14.0": + version: 0.14.0 + resolution: "@types/axios@npm:0.14.0" + dependencies: + axios: "*" + checksum: 12a230b9404055d81804cb57fe4739b2317111b28a39e2477b2513250e8b85725e6f6ce509fc2a9494a6da60facb8d80df875fcd747f62f6c3abebc7db60ae66 + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.4 resolution: "@types/istanbul-lib-coverage@npm:2.0.4" @@ -1324,6 +1333,13 @@ __metadata: languageName: node linkType: hard +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 7b78c451df768adba04e2d02e63e2d0bf3b07adcd6e42b4cf665cb7ce899bedd344c69a1dcbce355b5f972d597b25aaa1c1742b52cffd9caccb22f348114f6be + languageName: node + linkType: hard + "ava@npm:~5.2.0": version: 5.2.0 resolution: "ava@npm:5.2.0" @@ -1391,6 +1407,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:*": + version: 1.6.0 + resolution: "axios@npm:1.6.0" + dependencies: + follow-redirects: ^1.15.0 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: c7c9f2ae9e0b9bad7d6f9a4dff030930b12ee667dedf54c3c776714f91681feb743c509ac0796ae5c01e12c4ab4a2bee74905068dd200fbc1ab86f9814578fb0 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1856,6 +1883,15 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: ~1.0.0 + checksum: 49fa4aeb4916567e33ea81d088f6584749fc90c7abec76fd516bf1c5aa5c79f3584b5ba3de6b86d26ddd64bae5329c4c7479343250cfe71c75bb366eae53bb7c + languageName: node + linkType: hard + "common-ancestor-path@npm:^1.0.1": version: 1.0.1 resolution: "common-ancestor-path@npm:1.0.1" @@ -2170,6 +2206,13 @@ __metadata: languageName: node linkType: hard +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 46fe6e83e2cb1d85ba50bd52803c68be9bd953282fa7096f51fc29edd5d67ff84ff753c51966061e5ba7cb5e47ef6d36a91924eddb7f3f3483b1c560f77a0020 + languageName: node + linkType: hard + "delegates@npm:^1.0.0": version: 1.0.0 resolution: "delegates@npm:1.0.0" @@ -2963,6 +3006,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.0": + version: 1.15.3 + resolution: "follow-redirects@npm:1.15.3" + peerDependenciesMeta: + debug: + optional: true + checksum: 584da22ec5420c837bd096559ebfb8fe69d82512d5585004e36a3b4a6ef6d5905780e0c74508c7b72f907d1fa2b7bd339e613859e9c304d0dc96af2027fd0231 + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -2982,6 +3035,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.8 + mime-types: ^2.1.12 + checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c + languageName: node + linkType: hard + "from2@npm:^2.3.0": version: 2.3.0 resolution: "from2@npm:2.3.0" @@ -4137,6 +4201,7 @@ __metadata: version: 0.0.0-use.local resolution: "kitsu-core@workspace:packages/kitsu-core" dependencies: + "@types/axios": ~0.14.0 case-anything: ^2.1.10 languageName: unknown linkType: soft @@ -4629,6 +4694,22 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: 1.52.0 + checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836 + languageName: node + linkType: hard + "mime@npm:^3.0.0": version: 3.0.0 resolution: "mime@npm:3.0.0" @@ -5771,6 +5852,13 @@ __metadata: languageName: node linkType: hard +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4 + languageName: node + linkType: hard + "punycode@npm:^2.1.0": version: 2.3.0 resolution: "punycode@npm:2.3.0" From 72694d26834b2f8ad6dbc73ebe0decea2abfcdad Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Mon, 6 Nov 2023 18:52:15 +0100 Subject: [PATCH 11/14] specs: cover remaining branch --- .../kitsu-core/src/components/error.spec.ts | 10 +++---- packages/kitsu-core/src/components/error.ts | 22 ++++++++-------- .../src/components/serialise.spec.ts | 26 +++++++++++++++++++ .../kitsu-core/src/components/serialise.ts | 25 ++++++++++++------ .../kitsu-core/src/resources/relationships.ts | 18 +++++++++++++ .../src/resources/resourceIdentifier.spec.ts | 16 ++++++++++++ 6 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 packages/kitsu-core/src/resources/resourceIdentifier.spec.ts diff --git a/packages/kitsu-core/src/components/error.spec.ts b/packages/kitsu-core/src/components/error.spec.ts index 5662d168..2891f6d8 100644 --- a/packages/kitsu-core/src/components/error.spec.ts +++ b/packages/kitsu-core/src/components/error.spec.ts @@ -8,8 +8,8 @@ test('handles axios response errors', t => { const object = { response: {} } try { error(object) - } catch(err: unknown) { - t.deepEqual(err, {response: {}}) + } catch (error_: unknown) { + t.deepEqual(error_, { response: {} }) } }) @@ -17,9 +17,9 @@ test('throws all other errors', t => { t.plan(2) try { - error('Hello') - } catch(err: unknown) { - t.is(err, 'Hello') + error('Hello') + } catch (error_: unknown) { + t.is(error_, 'Hello') } t.throws( diff --git a/packages/kitsu-core/src/components/error.ts b/packages/kitsu-core/src/components/error.ts index ba117937..eaadd81f 100644 --- a/packages/kitsu-core/src/components/error.ts +++ b/packages/kitsu-core/src/components/error.ts @@ -1,19 +1,19 @@ -import { AxiosError as BaseAxiosError } from 'axios'; +import { AxiosError as BaseAxiosError } from 'axios' + +import { isObject } from '../isObject.js' +import { hasOwnProperty } from '../utilities/hasOwnProperty.js' // Extend AxiosError to facilitate 'error' function legacy behaviour -class AxiosError extends BaseAxiosError { - public errors?: unknown; +interface AxiosError extends BaseAxiosError { + errors?: unknown } -import { isObject } from "../isObject.js" -import { hasOwnProperty } from "../utilities/hasOwnProperty.js" - // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#unknown-on-catch-clause-bindings // catch must be typed as any or unknown. export function error(sourceError: unknown): void { - if (isAxiosError<{errors?: unknown}>(sourceError)) { - const e = sourceError.response?.data - if (e?.errors) sourceError.errors = e.errors + if (isAxiosError<{ errors?: unknown }>(sourceError)) { + const responseData = sourceError.response?.data + if (responseData?.errors) sourceError.errors = responseData.errors } throw sourceError @@ -21,6 +21,6 @@ export function error(sourceError: unknown): void { // TODO: should this be replaced with the 'correct' axios implementation? // https://github.com/axios/axios/blob/main/lib/helpers/isAxiosError.js -function isAxiosError(obj: unknown): obj is AxiosError { - return isObject(obj) && (hasOwnProperty(obj, 'response')) +function isAxiosError(object: unknown): object is AxiosError { + return isObject(object) && hasOwnProperty(object, 'response') } diff --git a/packages/kitsu-core/src/components/serialise.spec.ts b/packages/kitsu-core/src/components/serialise.spec.ts index d54fbc19..f797fc5d 100644 --- a/packages/kitsu-core/src/components/serialise.spec.ts +++ b/packages/kitsu-core/src/components/serialise.spec.ts @@ -285,6 +285,16 @@ test('throws an error when missing ID in array', t => { }) }) +test('throws an error if type is missing', t => { + t.throws(() => serialise(undefined, { id: 2 }), { + message: 'POST requires a resource type' + }) + + t.throws(() => serialise(undefined, [{ id: 2 }]), { + message: 'POST requires a resource type' + }) +}) + test('serialises strings/numbers/booleans into attributes', t => { const input = serialise('resourceModel', { string: 'shark', @@ -496,6 +506,14 @@ test('serialises object and array relationships', t => { links: { self: 'library-entries/1' }, meta: { extra: true }, ratingTwenty: 10, + genres: { + "metrix:count": 12 + }, + tags: { + links: { + related: 'library-entries/1/tags' + } + }, user: { links: { self: 'library-entries/1/relationships/user', @@ -533,6 +551,14 @@ test('serialises object and array relationships', t => { meta: { extra: true }, attributes: { ratingTwenty: 10 }, relationships: { + genres: { + "metrix:count": 12 + }, + tags: { + links: { + related: 'library-entries/1/tags' + } + }, user: { links: { self: 'library-entries/1/relationships/user', diff --git a/packages/kitsu-core/src/components/serialise.ts b/packages/kitsu-core/src/components/serialise.ts index 14ff2eb9..bd4f88bb 100644 --- a/packages/kitsu-core/src/components/serialise.ts +++ b/packages/kitsu-core/src/components/serialise.ts @@ -1,6 +1,9 @@ import { isObject } from '../isObject.js' import { JsonKey, JsonValue } from '../resources/json.js' -import { RelationshipObject } from '../resources/relationships.js' +import { + isRelationshipObject, + RelationshipObject +} from '../resources/relationships.js' import { isRemoteResource, ResourceIdentifier @@ -51,11 +54,10 @@ function validateArrayPayload( } // A POST request is the only request to not require an ID in spec - if (method !== 'POST' && payload.length > 0) { + if (method !== 'POST' && payload.length > 0) for (const resource of payload) { if (!hasOwnProperty(resource, 'id')) throw requireID } - } } function validateObjectPayload( @@ -109,14 +111,21 @@ function serialiseRelation( data: Partial ) { if (!data.relationships) data.relationships = {} - data.relationships[key] = { - data: Array.isArray(node.data) + + data.relationships[key] = {} as RelationshipObject // TODO: investigate impact of pre-initializing + + if (node.data !== undefined) + data.relationships[key].data = Array.isArray(node.data) ? serialiseRelationMany(node.data, nodeType) : serialiseRelationOne(node.data, nodeType) - } if (node?.links?.self || node?.links?.related) data.relationships[key].links = node.links if (node?.meta) data.relationships[key].meta = node.meta + + for (const k of Object.keys(node)) { + if (k.includes(":")) data.relationships[key][k] = node[k] + } + return data } @@ -149,7 +158,7 @@ function hasID(node: Node) { (Array.isArray(node?.data) && node?.data?.length === 0) ) return true - if (!node.data) return false + if (!node?.data) return false // Check if relationship is to-many const nodeData = Array.isArray(node.data) ? node.data[0] : node.data return Object.prototype.hasOwnProperty.call(nodeData, 'id') @@ -188,7 +197,7 @@ function serialiseRootObject( const node = payload[key] const nodeType = options.typeTransform(key) // 1. Only grab objects, 2. Filter to only serialise relationable objects - if (isObject(node) && hasID(node)) { + if (hasID(node) || isRelationshipObject(node)) { data = serialiseRelation(node, nodeType, key, data) // 1. Don't place id/key inside attributes object } else if (key !== 'id' && key !== 'type') { diff --git a/packages/kitsu-core/src/resources/relationships.ts b/packages/kitsu-core/src/resources/relationships.ts index 79de1c39..0995356c 100644 --- a/packages/kitsu-core/src/resources/relationships.ts +++ b/packages/kitsu-core/src/resources/relationships.ts @@ -35,3 +35,21 @@ export type RelationshipObject = { }[RelationshipKeys] & { [extensionKey: string]: JsonValue } + +// https://jsonapi.org/format/#document-resource-object-relationships +// A “relationship object” MUST contain at least one of the following: 'links', 'data', 'meta', a member defined by an applied extension. +export function isRelationshipObject( + object: unknown +): object is RelationshipObject { + if (object === null || typeof object !== 'object') return false + + const keys = Object.keys(object) + for (const key of keys) { + if (['links', 'data', 'meta'].includes(key)) return true + // https://jsonapi.org/format/#extension-members + // The name of every new member introduced by an extension MUST be prefixed with the extension’s namespace followed by a colon (:). + if (key.includes(':')) return true + } + + return false +} diff --git a/packages/kitsu-core/src/resources/resourceIdentifier.spec.ts b/packages/kitsu-core/src/resources/resourceIdentifier.spec.ts new file mode 100644 index 00000000..038b6ac4 --- /dev/null +++ b/packages/kitsu-core/src/resources/resourceIdentifier.spec.ts @@ -0,0 +1,16 @@ +import test from 'ava' + +import { isLocalResource, isRemoteResource } from './resourceIdentifier' + +test('isLocalResource determines if a resourceIdentifier originates from the client', t => { + t.is(isLocalResource({ lid: '12' }), true) + t.is(isLocalResource({ type: 'users' }), true) + t.is(isLocalResource({ id: '12', type: 'users' }), false) +}) + +test('isRemoteResource determines if a resourceIdentifier originates from the server', t => { + t.is(isRemoteResource({ lid: '12' }), false) + t.is(isRemoteResource({ type: 'users' }), false) + t.is(isRemoteResource({ id: '12', type: 'users' }), true) + t.is(isRemoteResource({ id: '12', lid: '14' }), false) +}) From 099e17cdbebea44dddf10b4e2b0ffb42e28154e8 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Mon, 6 Nov 2023 18:57:50 +0100 Subject: [PATCH 12/14] remove dependency on axios, use local partial interface --- packages/kitsu-core/package.json | 3 - packages/kitsu-core/src/components/error.ts | 11 +-- .../src/components/serialise.spec.ts | 4 +- .../kitsu-core/src/components/serialise.ts | 5 +- yarn.lock | 88 ------------------- 5 files changed, 10 insertions(+), 101 deletions(-) diff --git a/packages/kitsu-core/package.json b/packages/kitsu-core/package.json index 6c2b5f1f..b248f2c3 100644 --- a/packages/kitsu-core/package.json +++ b/packages/kitsu-core/package.json @@ -40,8 +40,5 @@ ], "dependencies": { "case-anything": "^2.1.10" - }, - "devDependencies": { - "@types/axios": "~0.14.0" } } diff --git a/packages/kitsu-core/src/components/error.ts b/packages/kitsu-core/src/components/error.ts index eaadd81f..9654b9dc 100644 --- a/packages/kitsu-core/src/components/error.ts +++ b/packages/kitsu-core/src/components/error.ts @@ -1,11 +1,12 @@ -import { AxiosError as BaseAxiosError } from 'axios' - import { isObject } from '../isObject.js' import { hasOwnProperty } from '../utilities/hasOwnProperty.js' -// Extend AxiosError to facilitate 'error' function legacy behaviour -interface AxiosError extends BaseAxiosError { - errors?: unknown +interface AxiosError { + errors?: T + + response?: { + data: T + } } // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#unknown-on-catch-clause-bindings diff --git a/packages/kitsu-core/src/components/serialise.spec.ts b/packages/kitsu-core/src/components/serialise.spec.ts index f797fc5d..695bf226 100644 --- a/packages/kitsu-core/src/components/serialise.spec.ts +++ b/packages/kitsu-core/src/components/serialise.spec.ts @@ -507,7 +507,7 @@ test('serialises object and array relationships', t => { meta: { extra: true }, ratingTwenty: 10, genres: { - "metrix:count": 12 + 'metrix:count': 12 }, tags: { links: { @@ -552,7 +552,7 @@ test('serialises object and array relationships', t => { attributes: { ratingTwenty: 10 }, relationships: { genres: { - "metrix:count": 12 + 'metrix:count': 12 }, tags: { links: { diff --git a/packages/kitsu-core/src/components/serialise.ts b/packages/kitsu-core/src/components/serialise.ts index bd4f88bb..ffaf456d 100644 --- a/packages/kitsu-core/src/components/serialise.ts +++ b/packages/kitsu-core/src/components/serialise.ts @@ -1,4 +1,3 @@ -import { isObject } from '../isObject.js' import { JsonKey, JsonValue } from '../resources/json.js' import { isRelationshipObject, @@ -13,7 +12,7 @@ import { hasOwnProperty } from '../utilities/hasOwnProperty.js' import { error } from './error.js' // TODO: rename nodes to something more JSON:API like ResourceObject -type Node = any +type Node = any // eslint-disable-line @typescript-eslint/no-explicit-any type NodeType = ResourceIdentifier['type'] type Method = 'POST' | 'PATCH' | 'DELETE' @@ -123,7 +122,7 @@ function serialiseRelation( if (node?.meta) data.relationships[key].meta = node.meta for (const k of Object.keys(node)) { - if (k.includes(":")) data.relationships[key][k] = node[k] + if (k.includes(':')) data.relationships[key][k] = node[k] } return data diff --git a/yarn.lock b/yarn.lock index 1ecce8ed..edc5c5ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -853,15 +853,6 @@ __metadata: languageName: node linkType: hard -"@types/axios@npm:~0.14.0": - version: 0.14.0 - resolution: "@types/axios@npm:0.14.0" - dependencies: - axios: "*" - checksum: 12a230b9404055d81804cb57fe4739b2317111b28a39e2477b2513250e8b85725e6f6ce509fc2a9494a6da60facb8d80df875fcd747f62f6c3abebc7db60ae66 - languageName: node - linkType: hard - "@types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.4 resolution: "@types/istanbul-lib-coverage@npm:2.0.4" @@ -1333,13 +1324,6 @@ __metadata: languageName: node linkType: hard -"asynckit@npm:^0.4.0": - version: 0.4.0 - resolution: "asynckit@npm:0.4.0" - checksum: 7b78c451df768adba04e2d02e63e2d0bf3b07adcd6e42b4cf665cb7ce899bedd344c69a1dcbce355b5f972d597b25aaa1c1742b52cffd9caccb22f348114f6be - languageName: node - linkType: hard - "ava@npm:~5.2.0": version: 5.2.0 resolution: "ava@npm:5.2.0" @@ -1407,17 +1391,6 @@ __metadata: languageName: node linkType: hard -"axios@npm:*": - version: 1.6.0 - resolution: "axios@npm:1.6.0" - dependencies: - follow-redirects: ^1.15.0 - form-data: ^4.0.0 - proxy-from-env: ^1.1.0 - checksum: c7c9f2ae9e0b9bad7d6f9a4dff030930b12ee667dedf54c3c776714f91681feb743c509ac0796ae5c01e12c4ab4a2bee74905068dd200fbc1ab86f9814578fb0 - languageName: node - linkType: hard - "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1883,15 +1856,6 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.8": - version: 1.0.8 - resolution: "combined-stream@npm:1.0.8" - dependencies: - delayed-stream: ~1.0.0 - checksum: 49fa4aeb4916567e33ea81d088f6584749fc90c7abec76fd516bf1c5aa5c79f3584b5ba3de6b86d26ddd64bae5329c4c7479343250cfe71c75bb366eae53bb7c - languageName: node - linkType: hard - "common-ancestor-path@npm:^1.0.1": version: 1.0.1 resolution: "common-ancestor-path@npm:1.0.1" @@ -2206,13 +2170,6 @@ __metadata: languageName: node linkType: hard -"delayed-stream@npm:~1.0.0": - version: 1.0.0 - resolution: "delayed-stream@npm:1.0.0" - checksum: 46fe6e83e2cb1d85ba50bd52803c68be9bd953282fa7096f51fc29edd5d67ff84ff753c51966061e5ba7cb5e47ef6d36a91924eddb7f3f3483b1c560f77a0020 - languageName: node - linkType: hard - "delegates@npm:^1.0.0": version: 1.0.0 resolution: "delegates@npm:1.0.0" @@ -3006,16 +2963,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.0": - version: 1.15.3 - resolution: "follow-redirects@npm:1.15.3" - peerDependenciesMeta: - debug: - optional: true - checksum: 584da22ec5420c837bd096559ebfb8fe69d82512d5585004e36a3b4a6ef6d5905780e0c74508c7b72f907d1fa2b7bd339e613859e9c304d0dc96af2027fd0231 - languageName: node - linkType: hard - "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -3035,17 +2982,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.0 - resolution: "form-data@npm:4.0.0" - dependencies: - asynckit: ^0.4.0 - combined-stream: ^1.0.8 - mime-types: ^2.1.12 - checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c - languageName: node - linkType: hard - "from2@npm:^2.3.0": version: 2.3.0 resolution: "from2@npm:2.3.0" @@ -4201,7 +4137,6 @@ __metadata: version: 0.0.0-use.local resolution: "kitsu-core@workspace:packages/kitsu-core" dependencies: - "@types/axios": ~0.14.0 case-anything: ^2.1.10 languageName: unknown linkType: soft @@ -4694,22 +4629,6 @@ __metadata: languageName: node linkType: hard -"mime-db@npm:1.52.0": - version: 1.52.0 - resolution: "mime-db@npm:1.52.0" - checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f - languageName: node - linkType: hard - -"mime-types@npm:^2.1.12": - version: 2.1.35 - resolution: "mime-types@npm:2.1.35" - dependencies: - mime-db: 1.52.0 - checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836 - languageName: node - linkType: hard - "mime@npm:^3.0.0": version: 3.0.0 resolution: "mime@npm:3.0.0" @@ -5852,13 +5771,6 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^1.1.0": - version: 1.1.0 - resolution: "proxy-from-env@npm:1.1.0" - checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4 - languageName: node - linkType: hard - "punycode@npm:^2.1.0": version: 2.3.0 resolution: "punycode@npm:2.3.0" From 0e8342dda8c5e9129b47d7ad9a42a4f90b5dd814 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Mon, 6 Nov 2023 19:13:39 +0100 Subject: [PATCH 13/14] implement filterIncludes --- .../src/components/filterIncludes.spec.ts | 52 +++++++++++++++++++ .../src/components/filterIncludes.ts | 28 ++++++++++ 2 files changed, 80 insertions(+) create mode 100644 packages/kitsu-core/src/components/filterIncludes.spec.ts create mode 100644 packages/kitsu-core/src/components/filterIncludes.ts diff --git a/packages/kitsu-core/src/components/filterIncludes.spec.ts b/packages/kitsu-core/src/components/filterIncludes.spec.ts new file mode 100644 index 00000000..5a810b7e --- /dev/null +++ b/packages/kitsu-core/src/components/filterIncludes.spec.ts @@ -0,0 +1,52 @@ +import test from 'ava' + +import { filterIncludes } from './filterIncludes' + +test('throws an error if included is not an array', t => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t.throws(() => filterIncludes({} as any, { id: '1', type: 'anime' }), { + message: 'included.find is not a function' + }) +}) + +test('returns id and type if included is empty', t => { + const response = filterIncludes([], { id: '1', type: 'comments' }) + t.deepEqual(response, { id: '1', type: 'comments' }) +}) + +test('returns an empty object if id is undefined', t => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response = filterIncludes([], {} as any) + t.deepEqual(response, {}) +}) + +test('filters included relationships', t => { + const includes = [ + { + id: '1', + type: 'users', + attributes: { + name: 'Emma' + } + }, + { + id: '2', + type: 'users', + attributes: { + name: 'Josh' + } + } + ] + const relationship = { + id: '1', + type: 'users' + } + const response = filterIncludes(includes, relationship) + t.deepEqual(response, { + id: '1', + type: 'users', + attributes: { + name: 'Emma' + } + }) +}) diff --git a/packages/kitsu-core/src/components/filterIncludes.ts b/packages/kitsu-core/src/components/filterIncludes.ts new file mode 100644 index 00000000..fa07b919 --- /dev/null +++ b/packages/kitsu-core/src/components/filterIncludes.ts @@ -0,0 +1,28 @@ +import { + isRemoteResource, + RemoteResourceIdentifier +} from '../resources/resourceIdentifier.js' +import { ResourceObject } from '../resources/resourceObject.js' +import { error } from './error.js' + +export function filterIncludes( + included: ResourceObject[], + { id, type }: RemoteResourceIdentifier +) { + try { + if (id && type) { + const filtered = included.find(element => { + return ( + isRemoteResource(element) && + element.id === id && + element.type === type + ) + }) || { id, type } + return Object.assign({}, filtered) + } else { + return {} + } + } catch (error_: unknown) { + error(error_) + } +} From be8ac309ffe5e1c300c8fa08195d0a64a1e941e1 Mon Sep 17 00:00:00 2001 From: Vincent Plannthin Date: Mon, 6 Nov 2023 19:49:39 +0100 Subject: [PATCH 14/14] implement splitModel --- .../src/components/splitModel.spec.ts | 83 +++++++++++++++++++ .../kitsu-core/src/components/splitModel.ts | 39 +++++++++ .../kitsu-core/src/utilities/transform.ts | 11 +++ 3 files changed, 133 insertions(+) create mode 100644 packages/kitsu-core/src/components/splitModel.spec.ts create mode 100644 packages/kitsu-core/src/components/splitModel.ts create mode 100644 packages/kitsu-core/src/utilities/transform.ts diff --git a/packages/kitsu-core/src/components/splitModel.spec.ts b/packages/kitsu-core/src/components/splitModel.spec.ts new file mode 100644 index 00000000..777f396a --- /dev/null +++ b/packages/kitsu-core/src/components/splitModel.spec.ts @@ -0,0 +1,83 @@ +import test from 'ava' +import { kebabCase, snakeCase } from 'case-anything' + +import { splitModel } from './splitModel' + +function plural(s: string) { + if (['anime'].includes(s)) return s + + return `${s}s` +} + +test('anime -> anime', t => { + t.deepEqual(splitModel('anime'), ['anime', 'anime']) +}) + +test('anime -> anime (plural, mass noun)', t => { + t.deepEqual( + splitModel('anime', { + pluralModel: plural + }), + ['anime', 'anime'] + ) +}) + +test('post -> post', t => { + t.deepEqual(splitModel('post'), ['post', 'post']) +}) + +test('post -> posts (plural)', t => { + t.deepEqual( + splitModel('post', { + pluralModel: plural + }), + ['post', 'posts'] + ) +}) + +test('post/1/relationships/comment -> comment', t => { + t.deepEqual(splitModel('post/1/relationships/comment'), [ + 'comment', + 'post/1/relationships/comment' + ]) +}) + +test('post/1/relationships/comment -> comment (plural)', t => { + t.deepEqual( + splitModel('post/1/relationships/comment', { + pluralModel: plural + }), + ['comment', 'post/1/relationships/comments'] + ) +}) + +test('libraryEntry -> library-entry', t => { + t.deepEqual( + splitModel('libraryEntry', { + resourceCase: kebabCase + }), + ['libraryEntry', 'library-entry'] + ) +}) + +test('libraryEntry -> library_entry', t => { + t.deepEqual( + splitModel('libraryEntry', { + resourceCase: snakeCase + }), + ['libraryEntry', 'library_entry'] + ) +}) + +test('applies transformations to output according to tramsforms array', t => { + t.deepEqual( + splitModel('libraryEntry', { + transforms: [ + s => s.toUpperCase(), + s => s.slice(0, -2), + s => [...s].reverse().join('') + ] + }), + ['libraryEntry', 'TNEYRARBIL'] + ) +}) diff --git a/packages/kitsu-core/src/components/splitModel.ts b/packages/kitsu-core/src/components/splitModel.ts new file mode 100644 index 00000000..4a879131 --- /dev/null +++ b/packages/kitsu-core/src/components/splitModel.ts @@ -0,0 +1,39 @@ +import { transform, Transformer } from '../utilities/transform.js' + +type URL = string +export interface SplitModelOptions { + transforms?: Transformer[] +} + +export function splitModel(url: URL, options?: SplitModelOptions): [URL, URL] +/** @deprecated + * pluralModel and resourceCase are deprecated. Use transforms instead. + **/ +export function splitModel( + url: URL, + options?: { + pluralModel?: (s: string) => string + resourceCase?: (s: string) => string + } +): [URL, URL] +export function splitModel( + url: URL, + options: SplitModelOptions & { + pluralModel?: (s: string) => string + resourceCase?: (s: string) => string + } = {} +): [URL, URL] { + const transforms: Transformer[] = options.transforms || [] + + if (options.pluralModel) transforms.push(options.pluralModel) + if (options.resourceCase) transforms.push(options.resourceCase) + + const urlSegments = url.split('/') + const resourceModel = urlSegments.pop() as string // url.split().pop() always returns a string, even when url is empty + + // urlSegments.push(options.pluralModel(options.resourceCase(resourceModel))) + urlSegments.push(transform(transforms, resourceModel)) + const newUrl = urlSegments.join('/') + + return [resourceModel, newUrl] +} diff --git a/packages/kitsu-core/src/utilities/transform.ts b/packages/kitsu-core/src/utilities/transform.ts new file mode 100644 index 00000000..8f23115d --- /dev/null +++ b/packages/kitsu-core/src/utilities/transform.ts @@ -0,0 +1,11 @@ +export type Transformer = (transformSubject: T) => T + +export function transform( + transforms: Transformer[], + transformSubject: T +): T { + return transforms.reduce( + (subject, transform) => transform(subject), + transformSubject + ) +}