Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: kitsu-core typescript implementations & JSON:API v1.1 type definitions #930

Open
wants to merge 14 commits into
base: refactor/typescript
Choose a base branch
from
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on: [push, pull_request]

env:
FORCE_COLOR: true
NODE_VERSION: latest
NODE_VERSION: 20

jobs:
setup:
Expand Down Expand Up @@ -42,7 +42,7 @@ jobs:

strategy:
matrix:
node_version: [16, 18]
node_version: [18, 20]

steps:
- uses: actions/checkout@v3
Expand Down
12 changes: 4 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"url": "https://github.com/wopian/kitsu/issues"
},
"engines": {
"node": ">= 16"
"node": ">= 18"
},
"workspaces": [
"packages/*"
Expand All @@ -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",
wopian marked this conversation as resolved.
Show resolved Hide resolved
"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,
Expand Down
24 changes: 21 additions & 3 deletions packages/kitsu-core/src/components/deattribute.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -60,7 +61,7 @@ test('deattribute with attributes.attributes', t => {
)
})

test('deattribute array', t => {
test('deattributes arrays of ResourceObject', t => {
t.deepEqual(
deattribute([
{
Expand Down Expand Up @@ -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
}
)
})
71 changes: 41 additions & 30 deletions packages/kitsu-core/src/components/deattribute.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be named deattributeObject

// 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)
}
86 changes: 86 additions & 0 deletions packages/kitsu-core/src/components/deepEqual.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
32 changes: 32 additions & 0 deletions packages/kitsu-core/src/components/deepEqual.ts
Original file line number Diff line number Diff line change
@@ -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
}
75 changes: 75 additions & 0 deletions packages/kitsu-core/src/components/error.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }]
})
}
})
Loading