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, 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 6051d47b..3ac53d40 100644 --- a/packages/kitsu-core/src/components/deattribute.ts +++ b/packages/kitsu-core/src/components/deattribute.ts @@ -1,37 +1,48 @@ -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/resourceIdentifier.js' +import { ResourceObject } from '../resources/resourceObject.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 { + // 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 + // 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 } + +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 new file mode 100644 index 00000000..a0bbbcb1 --- /dev/null +++ b/packages/kitsu-core/src/components/deepEqual.spec.ts @@ -0,0 +1,86 @@ +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 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) +}) + +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..b6f016e5 --- /dev/null +++ b/packages/kitsu-core/src/components/deepEqual.ts @@ -0,0 +1,32 @@ +// 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) + 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 goDeeper = isDeep(leftValue) && isDeep(rightValue) + + if ( + (goDeeper && !isDeepEqual(leftValue, rightValue)) || + (!goDeeper && leftValue !== rightValue) + ) { + return false + } + } + + return true +} + +function isDeep(object: unknown): boolean { + return typeof object === 'object' && object !== null +} 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..2891f6d8 --- /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 (error_: unknown) { + t.deepEqual(error_, { response: {} }) + } +}) + +test('throws all other errors', t => { + t.plan(2) + + try { + error('Hello') + } catch (error_: unknown) { + t.is(error_, '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..9654b9dc --- /dev/null +++ b/packages/kitsu-core/src/components/error.ts @@ -0,0 +1,27 @@ +import { isObject } from '../isObject.js' +import { hasOwnProperty } from '../utilities/hasOwnProperty.js' + +interface AxiosError { + errors?: T + + response?: { + data: T + } +} + +// 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 responseData = sourceError.response?.data + if (responseData?.errors) sourceError.errors = responseData.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(object: unknown): object is AxiosError { + return isObject(object) && hasOwnProperty(object, 'response') +} 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_) + } +} 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..afbba3e5 --- /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/components/serialise.spec.ts b/packages/kitsu-core/src/components/serialise.spec.ts new file mode 100644 index 00000000..695bf226 --- /dev/null +++ b/packages/kitsu-core/src/components/serialise.spec.ts @@ -0,0 +1,677 @@ +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('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', + 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, + genres: { + 'metrix:count': 12 + }, + tags: { + links: { + related: 'library-entries/1/tags' + } + }, + 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: { + genres: { + 'metrix:count': 12 + }, + tags: { + links: { + related: 'library-entries/1/tags' + } + }, + 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..ffaf456d --- /dev/null +++ b/packages/kitsu-core/src/components/serialise.ts @@ -0,0 +1,267 @@ +import { JsonKey, JsonValue } from '../resources/json.js' +import { + isRelationshipObject, + 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 // eslint-disable-line @typescript-eslint/no-explicit-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] = {} 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 +} + +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 (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') { + 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/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/index.ts b/packages/kitsu-core/src/index.ts index 4c7e68ea..126987e1 100644 --- a/packages/kitsu-core/src/index.ts +++ b/packages/kitsu-core/src/index.ts @@ -1 +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/isObject.ts b/packages/kitsu-core/src/isObject.ts new file mode 100644 index 00000000..b23654c5 --- /dev/null +++ b/packages/kitsu-core/src/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/resources/attributes.ts b/packages/kitsu-core/src/resources/attributes.ts new file mode 100644 index 00000000..cf0dc713 --- /dev/null +++ b/packages/kitsu-core/src/resources/attributes.ts @@ -0,0 +1,19 @@ +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. + +export interface Attributes { + [name: JsonKey]: JsonValue +} + +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/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..0995356c --- /dev/null +++ b/packages/kitsu-core/src/resources/relationships.ts @@ -0,0 +1,55 @@ +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 +} + +// 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) +}) diff --git a/packages/kitsu-core/src/resources/resourceIdentifier.ts b/packages/kitsu-core/src/resources/resourceIdentifier.ts new file mode 100644 index 00000000..9a5be900 --- /dev/null +++ b/packages/kitsu-core/src/resources/resourceIdentifier.ts @@ -0,0 +1,34 @@ +// 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. + +import { hasOwnProperty } from '../utilities/hasOwnProperty.js' + +export interface LocalResourceIdentifier { + lid?: string + type: string +} + +export interface RemoteResourceIdentifier { + id: string + type: string +} + +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 new file mode 100644 index 00000000..2208bfa8 --- /dev/null +++ b/packages/kitsu-core/src/resources/resourceObject.ts @@ -0,0 +1,15 @@ +import { Attributes } from './attributes.js' +import { Meta } from './meta.js' +import { Relationships } from './relationships.js' +import { ResourceIdentifier } from './resourceIdentifier.js' + +type Links = void + +export interface ResourceObjectFields { + attributes?: Attributes + relationships?: Relationships + links?: Links + meta?: Meta +} + +export type ResourceObject = ResourceObjectFields & ResourceIdentifier 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/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 + ) +}