diff --git a/README.md b/README.md index a5c74b0..a983c62 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ By appending metadata to specific classes, class fields, and class methods, we e ## 🌱 Install ```bash -npm install @tsmetadata/json-api +npm install @tsmetadata/json-api@latest ``` ## 📋 Feature Set @@ -28,6 +28,22 @@ npm install @tsmetadata/json-api - [Resource Object](#resource-object) - [Relationship Object](#relationship-object) - [Included Resource Objects](#included-resource-objects) +- [📄 Deserializers](#deserializers) + - [Resource Object](#resource-object-1) +- [✨ Types](#types) + - [Attributes Object](#attributes-object) + - [Error Object](#error-object) + - [JSON API Object](#json-api-object) + - [Link Object](#link-object) + - [Links Object](#links-object) + - [Meta Object](#meta-object) + - [Pagination Links](#pagination-links) + - [Relationship Object](#relationship-object) + - [Relationships Object](#relationships-object) + - [Resource Identifier Object](#resource-identifier-object) + - [Resource Linkage](#resource-linkage) + - [Resource Object](#resource-object-2) + - [Top Level Object](#top-level-object) ## ⚙️ Usage ### Metadata Decorators @@ -86,20 +102,20 @@ The foreign key is type-safe to the field type. ex. ```typescript -import { Relationship } from '@tsmetadata/json-api'; +import { Relationship, type JSONAPIResourceLinkage } from '@tsmetadata/json-api'; class Account { @Relationship('accounts') - primaryDebtor: Customer; + primaryDebtor: Customer | JSONAPIResourceLinkage; - @Relatioship('accounts') - coDebtors: Customer[]; + @Relationship('accounts') + coDebtors: Customer[] | JSONAPIResourceLinkage; } class Customer { @Relationship('primaryDebtor') @Relationship('coDebtors') - accounts: Account[]; + accounts: Account[] | JSONAPIResourceLinkage; } ``` @@ -224,7 +240,7 @@ The `serializeResourceObject(classInstance: object, keys: string[])` function wi ex. ```typescript -import { Resource, Id, Link, serializeIncludedResourceObjects } from '@tsmetadata/json-api'; +import { Resource, Id, Link, serializeIncludedResourceObjects, type JSONAPIResourceLinkage } from '@tsmetadata/json-api'; // For the sake of brevity, the `Account` class definition is not included. @@ -235,10 +251,10 @@ class User { @Relationship('primaryDebtor') @Relationship('coDebtors') - accounts: Account[]; + accounts: Account[] | JSONAPIResourceLinkage; @Relationship('spouse') - spouse: User; + spouse: User | JSONAPIResourceLinkage; } const user1 = new User(); @@ -251,9 +267,185 @@ user2.accounts = [someAccount, someOtherAccount]; serializeIncludedResourceObjects(user1, ['accounts', 'spouse']); ``` + +### Deserializers + +### Resource Object +The `deserializeResourceObject(resourceObject: JSONAPIResourceObject, cls: new (..._: any[]) => any)` function will produce a class instance from a [resource object](https://jsonapi.org/format/#document-resource-objects). + +ex. +```typescript +import { Resource, Id, Attribute, serializeResourceObject, deserializeResourceObject } from '@tsmetadata/json-api'; + +@Resource('users') +class User { + @Id() + customerId: string; + + @Attribute() + active: boolean; +} + +const user = new User(); +user.customerId = '123'; +user.active = false; + +const serializedUser = serializeResourceObject(user); + +/* + { + "type": "users". + "id": "123", + "attributes": { + "active": false + } + } +*/ + +const deserializedUser = deserializeResourceObject(user, User); + +/* + user.customerId === '123' + user.active === false +*/ +``` + +### Types + +#### Attributes Object + +- [Specification](https://jsonapi.org/format/#document-resource-object-attributes) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/attributesObject.ts) + +ex. +```typescript +import type { JSONAPIAttributesObject } from '@tsmetadata/json-api'; +``` + +#### Error Object + +- [Specification](https://jsonapi.org/format/#error-objects) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/errorObject.ts) + +ex. +```typescript +import type { JSONAPIErrorObject } from '@tsmetadata/json-api'; +``` + +#### JSON API Object + +- [Specification](https://jsonapi.org/format/#document-jsonapi-object) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/jsonApiObject.ts) + +ex. +```typescript +import type { JSONAPIObject } from '@tsmetadata/json-api'; +``` + +#### Link Object + +- [Specification](https://jsonapi.org/format/#document-links-link-object) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/linkObject.ts) + +ex. +```typescript +import type { JSONAPILinkObject } from '@tsmetadata/json-api'; +``` + +#### Links Object + +- [Specification](https://jsonapi.org/format/#document-links-link-object) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/linksObject.ts) + +ex. +```typescript +import type { JSONAPILinksObject } from '@tsmetadata/json-api'; +``` + +#### Meta Object + +- [Specification](https://jsonapi.org/format/#document-meta) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/metaObject.ts) + +ex. +```typescript +import type { JSONAPIMetaObject } from '@tsmetadata/json-api'; +``` + +#### Pagination Links + +- [Specification](https://jsonapi.org/format/#fetching-pagination) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/paginationLinks.ts) + +ex. +```typescript +import type { JSONAPIPaginationLinks } from '@tsmetadata/json-api'; +``` + +#### Relationship Object + +- [Specification](https://jsonapi.org/format/#document-resource-object-relationships) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/relationshipObject.ts) + +ex. +```typescript +import type { JSONAPIRelationshipObject } from '@tsmetadata/json-api'; +``` + +#### Relationships Object + +- [Specification](https://jsonapi.org/format/#document-resource-object-relationships) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/relationshipsObject.ts) + +ex. +```typescript +import type { JSONAPIRelationshipsObject } from '@tsmetadata/json-api'; +``` + +#### Resource Identifier Object + +- [Specification](https://jsonapi.org/format/#document-resource-identifier-objects) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/resourceIdentifierObject.ts) + +ex. +```typescript +import type { JSONAPIResourceIdentifierObject } from '@tsmetadata/json-api'; +``` + +#### Resource Linkage + +- [Specification](https://jsonapi.org/format/#document-resource-object-linkage) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/resourceLinkage.ts) + +ex. +```typescript +import type { JSONAPIResourceLinkage } from '@tsmetadata/json-api'; +``` + +#### Resource Object + +- [Specification](https://jsonapi.org/format/#document-resource-objects) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/resourceObject.ts) + +ex. +```typescript +import type { JSONAPIResourceObject } from '@tsmetadata/json-api'; +``` + +#### Top Level Object + +- [Specification](https://jsonapi.org/format/#document-top-level) +- [Definition](https://github.com/tsmetadata/json-api/blob/main/src/types/topLevelObject.ts) + +ex. +```typescript +import type { JSONAPITopLevelObject } from '@tsmetadata/json-api'; +``` + ## 😍 Full Example ```typescript -import { Attribute, Link, Meta, Relationship, Resource, serializeIncludedResourceObjects, serializeResourceObject } from '@tsmetadata/json-api'; +import { Attribute, Link, Meta, Relationship, Resource, serializeIncludedResourceObjects, + serializeResourceObject, deserializeResourceObject, type JSONAPIResourceLinkage } from '@tsmetadata/json-api'; @Resource('accounts') export class Account { @@ -264,10 +456,10 @@ export class Account { pastDue: boolean; @Relationship('accounts') - primaryDebtor: Customer; + primaryDebtor: Customer | JSONAPIResourceLinkage; @Relationship('accounts') - coDebtors: Customer[]; + coDebtors: Customer[] | JSONAPIResourceLinkage; @Link() self: string; @@ -286,7 +478,7 @@ export class Customer { @Relationship('primaryDebtor') @Relationship('coDebtors') - accounts: Account[]; + accounts: Account[] | JSONAPIResourceLinkage; @Link() self: string; @@ -307,15 +499,20 @@ customer.self = 'some-url'; account.primaryDebtor = customer; customer.accounts = [account]; +const serializedCustomer = serializeResourceObject(customer); + // Try logging out the results on your own! console.log( - serializeResourceObject(customer), + serializedCustomer, serializeRelationshipObject(customer), serializeIncludedResourceObjects(customer, ['accounts']) ); + +// You can deserialize too! +const customerWithResourceLinkages = deserializeResourceObject(serializedCustomer, Customer); ``` ## ❓ FAQ -### Q: I'm using a legacy runtime that doesn't yet support the metadata Symbol. +### Q: I'm using a legacy runtime that doesn't yet support Symbol metadata. A: You may be able to take advantage of our `Symbol.metadata` polyfill found [here](https://github.com/tsmetadata/polyfill). diff --git a/__tests__/decorators/links.test.ts b/__tests__/decorators/link.test.ts similarity index 100% rename from __tests__/decorators/links.test.ts rename to __tests__/decorators/link.test.ts diff --git a/__tests__/serializers/deserializeResourceObject.test.ts b/__tests__/serializers/deserializeResourceObject.test.ts new file mode 100644 index 0000000..a03238f --- /dev/null +++ b/__tests__/serializers/deserializeResourceObject.test.ts @@ -0,0 +1,123 @@ +import { Chance } from 'chance'; +import { attributesSymbol } from '../../src/decorators/attribute'; +import { idSymbol } from '../../src/decorators/id'; +import { linksSymbol } from '../../src/decorators/link'; +import { metaSymbol } from '../../src/decorators/meta'; +import { relationshipsSymbol } from '../../src/decorators/relationship'; +import { resourceSymbol } from '../../src/decorators/resource'; +import { deserializeResourceObject } from '../../src/serializers/deserializeResourceObject'; +import { getMetadataBySymbol } from '../../src/serializers/utils/getMetadataBySymbol'; + +import type { JSONAPIResourceLinkage } from '../../src/types/resourceLinkage'; + +jest.mock('../../src/serializers/utils/getMetadataBySymbol'); +const getMetadataBySymbolMocked = jest.mocked(getMetadataBySymbol); + +describe('`deserializeResourceObject`', () => { + let chance: Chance.Chance; + + beforeEach(() => { + chance = new Chance(); + }); + + describe('`type`', () => { + it('should throw an error if the type of the resource object does not match the expected type', () => { + const expectedType = chance.string(); + + getMetadataBySymbolMocked.mockImplementation((_, symbol) => { + if (symbol === resourceSymbol) { + return expectedType; + } + }); + + const resourceObject = { + type: chance.string(), + id: chance.string(), + }; + + class SomeResource {} + + expect(() => + deserializeResourceObject(resourceObject, SomeResource), + ).toThrow( + `Failed to deserialize resource object because the type ${resourceObject.type} does not match the expected type ${expectedType}.`, + ); + }); + }); + + it('should deserialize a resource object into a class instance', () => { + class SomeResource { + someIdField!: string; + someAttributeField!: string; + someLinkField!: string; + someMetaField!: string; + someRelationshipField!: JSONAPIResourceLinkage; + } + + const classInstance = new SomeResource(); + classInstance.someIdField = chance.string(); + classInstance.someAttributeField = chance.string(); + classInstance.someLinkField = chance.string(); + classInstance.someMetaField = chance.string(); + + const type = chance.string(); + + getMetadataBySymbolMocked.mockImplementation((_, symbol) => { + if (symbol === resourceSymbol) { + return type; + } + + if (symbol === idSymbol) { + return 'someIdField'; + } + + if (symbol === attributesSymbol) { + return ['someAttributeField']; + } + + if (symbol === linksSymbol) { + return ['someLinkField']; + } + + if (symbol === metaSymbol) { + return ['someMetaField']; + } + + if (symbol === relationshipsSymbol) { + return [['someRelationshipField']]; + } + }); + + const resourceObject = { + type, + id: classInstance.someIdField, + attributes: { + someAttributeField: classInstance.someAttributeField, + }, + links: { + someLinkField: classInstance.someLinkField, + }, + meta: { + someMetaField: classInstance.someMetaField, + }, + relationships: { + someRelationshipField: { + data: { + type: chance.string(), + id: chance.string(), + }, + }, + }, + }; + + const result = deserializeResourceObject(resourceObject, SomeResource); + + expect(result.someIdField).toBe(classInstance.someIdField); + expect(result.someAttributeField).toBe(classInstance.someAttributeField); + expect(result.someLinkField).toBe(classInstance.someLinkField); + expect(result.someMetaField).toBe(classInstance.someMetaField); + expect(result.someRelationshipField).toBe( + resourceObject.relationships.someRelationshipField.data, + ); + }); +}); diff --git a/__tests__/serializers/serializeResourceLinkage.test.ts b/__tests__/serializers/serializeResourceLinkage.test.ts new file mode 100644 index 0000000..e86ecec --- /dev/null +++ b/__tests__/serializers/serializeResourceLinkage.test.ts @@ -0,0 +1,168 @@ +import { Chance } from 'chance'; +import { idSymbol } from '../../src'; +import { resourceSymbol } from '../../src/decorators/resource'; +import { serializeResourceLinkage } from '../../src/serializers/serializeResourceLinkage'; +import { collect } from '../../src/serializers/utils/collect'; +import { getMetadataBySymbol } from '../../src/serializers/utils/getMetadataBySymbol'; + +jest.mock('../../src/serializers/utils/getMetadataBySymbol'); +const getMetadataBySymbolMocked = jest.mocked(getMetadataBySymbol); + +jest.mock('../../src/serializers/utils/collect'); +const collectMocked = jest.mocked(collect); + +describe('`serializeResourceLinkage`', () => { + let chance: Chance.Chance; + + beforeEach(() => { + chance = new Chance(); + }); + + describe('when given an array of class instances', () => { + it('should throw an error if some element in the array is not an object', () => { + const classInstances = [1]; + + expect(() => serializeResourceLinkage(classInstances)).toThrow( + 'Failed to serialize resource linkage because not all elements in the array are objects.', + ); + }); + + it('should throw an error if some element in the array is not a resource', () => { + const classInstances = [ + { + a: 'a', + }, + ]; + + getMetadataBySymbolMocked.mockImplementation((_, symbol) => { + if (symbol === resourceSymbol) { + return undefined; + } + }); + + expect(() => serializeResourceLinkage(classInstances)).toThrow( + 'Failed to serialize relationship object because the provided class instance is not a resource.', + ); + }); + + it('should throw an error if some element in the array does not have an id', () => { + const classInstances = [ + { + a: 'a', + }, + ]; + + getMetadataBySymbolMocked.mockImplementation((_, symbol) => { + if (symbol === resourceSymbol) { + return 'type'; + } + }); + + collectMocked.mockImplementation(() => undefined); + + expect(() => serializeResourceLinkage(classInstances)).toThrow( + 'Failed to serialize relationship object because the provided class instance does not have an id.', + ); + }); + + it('should serialize an array of class instances into an array of resource identifier objects', () => { + const classInstances = [ + { + someIdField: chance.string(), + someTypeField: chance.string(), + }, + { + someIdField: chance.string(), + someTypeField: chance.string(), + }, + ]; + + getMetadataBySymbolMocked.mockImplementation((object, symbol) => { + if (symbol === resourceSymbol) { + // @ts-expect-error + return object.someTypeField; + } + }); + + collectMocked.mockImplementation((object, symbol) => { + if (symbol === idSymbol) { + // @ts-expect-error + return object.someIdField; + } + }); + + const result = serializeResourceLinkage(classInstances); + + expect(result).toEqual( + classInstances.map(({ someIdField, someTypeField }) => ({ + type: someTypeField, + id: someIdField, + })), + ); + }); + }); + + describe('when given a single class instance', () => { + it('should throw an error if the class instance is not a resource', () => { + const classInstance = { + a: 'a', + }; + + getMetadataBySymbolMocked.mockImplementation((_, symbol) => { + if (symbol === resourceSymbol) { + return undefined; + } + }); + + expect(() => serializeResourceLinkage(classInstance)).toThrow( + 'Failed to serialize relationship object because the provided class instance is not a resource.', + ); + }); + + it('should throw an error if the class instance does not have an id', () => { + const classInstance = { + a: 'a', + }; + + getMetadataBySymbolMocked.mockImplementation((_, symbol) => { + if (symbol === resourceSymbol) { + return 'type'; + } + }); + + collectMocked.mockImplementation(() => undefined); + + expect(() => serializeResourceLinkage(classInstance)).toThrow( + 'Failed to serialize relationship object because the provided class instance does not have an id.', + ); + }); + + it('should serialize a class instance into a resource identifier object', () => { + const classInstance = { + someIdField: chance.string(), + someTypeField: chance.string(), + }; + + getMetadataBySymbolMocked.mockImplementation((object, symbol) => { + if (symbol === resourceSymbol) { + // @ts-expect-error + return object.someTypeField; + } + }); + + collectMocked.mockImplementation((object, symbol) => { + if (symbol === idSymbol) { + // @ts-expect-error + return object.someIdField; + } + }); + + const result = serializeResourceLinkage(classInstance); + + expect(result).toEqual({ + type: classInstance.someTypeField, + id: classInstance.someIdField, + }); + }); + }); +}); diff --git a/__tests__/serializers/serializeResourceObject.test.ts b/__tests__/serializers/serializeResourceObject.test.ts index 25dba47..1fb7444 100644 --- a/__tests__/serializers/serializeResourceObject.test.ts +++ b/__tests__/serializers/serializeResourceObject.test.ts @@ -1,24 +1,25 @@ import { Chance } from 'chance'; import { idSymbol } from '../../src/decorators/id'; -import { linksSymbol } from '../../src/decorators/links'; +import { linksSymbol } from '../../src/decorators/link'; import { metaSymbol } from '../../src/decorators/meta'; import { relationshipsSymbol } from '../../src/decorators/relationship'; import { resourceSymbol } from '../../src/decorators/resource'; -import { serializeRelationshipObject } from '../../src/serializers/serializeRelationshipObject'; +import { serializeResourceLinkage } from '../../src/serializers/serializeResourceLinkage'; import { serializeResourceObject } from '../../src/serializers/serializeResourceObject'; import { collect } from '../../src/serializers/utils/collect'; import { getMetadataBySymbol } from '../../src/serializers/utils/getMetadataBySymbol'; +import type { JSONAPIResourceIdentifierObject } from '../../src'; +import type { JSONAPIResourceLinkage } from '../../src/types/resourceLinkage'; + jest.mock('../../src/serializers/utils/getMetadataBySymbol'); const getMetadataBySymbolMocked = jest.mocked(getMetadataBySymbol); jest.mock('../../src/serializers/utils/collect'); const collectMocked = jest.mocked(collect); -jest.mock('../../src/serializers/serializeRelationshipObject'); -const serializeRelationshipObjectMocked = jest.mocked( - serializeRelationshipObject, -); +jest.mock('../../src/serializers/serializeResourceLinkage'); +const serializeResourceLinkageMocked = jest.mocked(serializeResourceLinkage); jest.mock('../../src/serializers/utils/assertMetadataIsPresent'); @@ -49,7 +50,7 @@ describe('`serializeResourceObject`', () => { }; expect(() => serializeResourceObject(classInstance)).toThrow( - `Failed to serialize relationship object for ${key} becuase not all elements in the array are objects.`, + 'Failed to serialize resource object because the provided class instance is not a resource.', ); }); @@ -92,28 +93,31 @@ describe('`serializeResourceObject`', () => { const b = chance.string(); - serializeRelationshipObjectMocked.mockImplementation( - (classInstance) => - ({ - ...classInstance, - b, - // biome-ignore lint/suspicious/noExplicitAny: okay for mock - }) as any, - ); + serializeResourceLinkageMocked.mockImplementation((classInstance_s) => { + return (classInstance_s as object[]).map( + (classInstance) => + ({ + ...classInstance, + b, + }) as unknown as JSONAPIResourceIdentifierObject, + ); + }); const result = serializeResourceObject(classInstance); expect(result.relationships).toEqual({ - [key]: [ - { - ...relatedClassInstance, - b, - }, - { - ...secondRelatedClassInstance, - b, - }, - ], + [key]: { + data: [ + { + ...relatedClassInstance, + b, + }, + { + ...secondRelatedClassInstance, + b, + }, + ], + }, }); }); }); @@ -141,7 +145,7 @@ describe('`serializeResourceObject`', () => { ); }); - it('should serialize the relationship object and return it', () => { + it('should serialize the relationships object and return it', () => { const key = chance.string(); getMetadataBySymbolMocked.mockImplementation( @@ -176,21 +180,22 @@ describe('`serializeResourceObject`', () => { const b = chance.string(); - serializeRelationshipObjectMocked.mockImplementation( + serializeResourceLinkageMocked.mockImplementation( (classInstance) => ({ ...classInstance, b, - // biome-ignore lint/suspicious/noExplicitAny: okay for mock - }) as any, + }) as unknown as Exclude, ); const result = serializeResourceObject(classInstance); expect(result.relationships).toEqual({ [key]: { - ...relatedClassInstance, - b, + data: { + ...relatedClassInstance, + b, + }, }, }); }); @@ -250,7 +255,7 @@ describe('`serializeResourceObject`', () => { [key]: null, }); - expect(serializeRelationshipObjectMocked).not.toHaveBeenCalled(); + expect(serializeResourceLinkageMocked).not.toHaveBeenCalled(); expect(result.relationships).toBeUndefined(); }); diff --git a/__tests__/serializers/serializeResourceRelationshipObject.test.ts b/__tests__/serializers/serializeResourceRelationshipObject.test.ts deleted file mode 100644 index e7f544d..0000000 --- a/__tests__/serializers/serializeResourceRelationshipObject.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { Chance } from 'chance'; -import { idSymbol } from '../../src/decorators/id'; -import { linksSymbol } from '../../src/decorators/links'; -import { metaSymbol } from '../../src/decorators/meta'; -import { resourceSymbol } from '../../src/decorators/resource'; -import { serializeRelationshipObject } from '../../src/serializers/serializeRelationshipObject'; -import { collect } from '../../src/serializers/utils/collect'; -import { getMetadataBySymbol } from '../../src/serializers/utils/getMetadataBySymbol'; - -jest.mock('../../src/serializers/utils/getMetadataBySymbol'); -const getMetadataBySymbolMocked = jest.mocked(getMetadataBySymbol); - -jest.mock('../../src/serializers/utils/collect'); -const collectMocked = jest.mocked(collect); - -describe('`serializeRelationshipObject`', () => { - let chance: Chance.Chance; - - beforeEach(() => { - chance = new Chance(); - }); - - describe('`data`', () => { - describe('`type`', () => { - it('should get the type from the class instance and return it', () => { - const type = chance.string(); - - getMetadataBySymbolMocked.mockImplementation( - (_: object, symbol: symbol) => { - if (symbol === resourceSymbol) { - return type; - } - - return undefined; - }, - ); - - collectMocked.mockImplementation((_object: object, symbol: symbol) => { - if (symbol === idSymbol) { - return 'id'; - } - - return undefined; - }); - - const result = serializeRelationshipObject({}); - - expect(result.data).toHaveProperty('type', type); - }); - - it('should throw an error if no type is found on the class instance', () => { - getMetadataBySymbolMocked.mockImplementation( - (_: object, symbol: symbol) => { - if (symbol === resourceSymbol) { - return undefined; - } - - return undefined; - }, - ); - - collectMocked.mockImplementation((_object: object, symbol: symbol) => { - if (symbol === idSymbol) { - return 'id'; - } - - return undefined; - }); - - expect(() => serializeRelationshipObject({})).toThrow( - 'Failed to serialize relationship object because the provided class instance is not a resource.', - ); - }); - }); - - describe('`id`', () => { - it('should get the id from the class instance and return it', () => { - const id = chance.string(); - - getMetadataBySymbolMocked.mockImplementation( - (_: object, symbol: symbol) => { - if (symbol === resourceSymbol) { - return 'type'; - } - - return undefined; - }, - ); - - collectMocked.mockImplementation((_object: object, symbol: symbol) => { - if (symbol === idSymbol) { - return id; - } - - return undefined; - }); - - const result = serializeRelationshipObject({}); - - expect(result.data).toHaveProperty('id', id); - }); - - it('should throw an error if no id is found on the class instance', () => { - getMetadataBySymbolMocked.mockImplementation( - (_: object, symbol: symbol) => { - if (symbol === resourceSymbol) { - return 'type'; - } - - return undefined; - }, - ); - - collectMocked.mockImplementation( - (_object: object, symbol: symbol) => undefined, - ); - - expect(() => serializeRelationshipObject({})).toThrow( - 'Failed to serialize relationship object because the provided class instance does not have an id field.', - ); - }); - }); - }); - - describe('`links`', () => { - it('should get the links from the class instance and return them', () => { - const links = { - self: chance.url(), - }; - - getMetadataBySymbolMocked.mockImplementation( - (_: object, symbol: symbol) => { - if (symbol === resourceSymbol) { - return 'type'; - } - - return undefined; - }, - ); - - collectMocked.mockImplementation((_: object, symbol: symbol) => { - if (symbol === idSymbol) { - return 'id'; - } - - if (symbol === linksSymbol) { - return links; - } - - return undefined; - }); - - const result = serializeRelationshipObject({}); - - expect(result.links).toEqual(links); - }); - }); - - describe('`meta`', () => { - it('should get the meta from the class instance and return them', () => { - const meta = { - self: chance.url(), - }; - - getMetadataBySymbolMocked.mockImplementation( - (_: object, symbol: symbol) => { - if (symbol === resourceSymbol) { - return 'type'; - } - - return undefined; - }, - ); - - collectMocked.mockImplementation((_: object, symbol: symbol) => { - if (symbol === idSymbol) { - return 'id'; - } - - if (symbol === metaSymbol) { - return meta; - } - - return undefined; - }); - - const result = serializeRelationshipObject({}); - - expect(result.meta).toEqual(meta); - }); - }); -}); diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 614b197..3688b49 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -1,6 +1,6 @@ export * from './attribute'; export * from './id'; -export * from './links'; +export * from './link'; export * from './meta'; export * from './relationship'; export * from './resource'; diff --git a/src/decorators/links.ts b/src/decorators/link.ts similarity index 100% rename from src/decorators/links.ts rename to src/decorators/link.ts diff --git a/src/decorators/relationship.ts b/src/decorators/relationship.ts index b2fb5b5..eaed634 100644 --- a/src/decorators/relationship.ts +++ b/src/decorators/relationship.ts @@ -1,12 +1,17 @@ -import type { NonArray } from '../types/utils/nonArray'; - import { buildSymbol } from './utils/buildSymbol'; import { isValidFieldKey } from './utils/isValidFieldKey'; +import type { JSONAPIResourceLinkage } from '../types/resourceLinkage'; +import type { NonArray } from '../types/utils/nonArray'; + export const relationshipsSymbol = buildSymbol('relationships'); +type ForeignKey = Extract extends never + ? never + : keyof NonArray>; + export const Relationship = - (foreignKey: keyof NonArray) => + (foreignKey: ForeignKey) => ( _target: undefined, { name, metadata }: ClassFieldDecoratorContext, diff --git a/src/serializers/deserializeResourceObject.ts b/src/serializers/deserializeResourceObject.ts new file mode 100644 index 0000000..014253e --- /dev/null +++ b/src/serializers/deserializeResourceObject.ts @@ -0,0 +1,90 @@ +import { attributesSymbol } from '../decorators/attribute'; +import { idSymbol } from '../decorators/id'; +import { linksSymbol } from '../decorators/link'; +import { metaSymbol } from '../decorators/meta'; +import { relationshipsSymbol } from '../decorators/relationship'; +import { resourceSymbol } from '../decorators/resource'; +import { getMetadataBySymbol } from './utils/getMetadataBySymbol'; + +import type { JSONAPIResourceObject } from '../types/resourceObject'; + +export const deserializeResourceObject = < + C, + T extends JSONAPIResourceObject = JSONAPIResourceObject, +>( + resourceObject: T, + // biome-ignore lint/suspicious/noExplicitAny: `any` is required to support all class constructors. + { prototype }: new (..._: any[]) => C, +): C => { + const classInstance = Object.create(prototype); + + const type = getMetadataBySymbol(classInstance, resourceSymbol); + + if (type !== resourceObject.type) { + throw new Error( + `Failed to deserialize resource object because the type ${resourceObject.type} does not match the expected type ${type}.`, + ); + } + + const id = getMetadataBySymbol(classInstance, idSymbol); + + if (id !== undefined) { + classInstance[id] = resourceObject.id; + } + + if (resourceObject.attributes !== undefined) { + const attributes = getMetadataBySymbol( + classInstance, + attributesSymbol, + ); + + if (attributes !== undefined) { + for (const attribute of attributes) { + classInstance[attribute] = resourceObject.attributes[attribute]; + } + } + } + + if (resourceObject.links !== undefined) { + const links = getMetadataBySymbol(classInstance, linksSymbol); + + if (links !== undefined) { + for (const link of links) { + classInstance[link] = resourceObject.links[link]; + } + } + } + + if (resourceObject.meta !== undefined) { + const metas = getMetadataBySymbol(classInstance, metaSymbol); + + if (metas !== undefined) { + for (const meta of metas) { + classInstance[meta] = resourceObject.meta[meta]; + } + } + } + + if (resourceObject.relationships !== undefined) { + const relationshipTuples = getMetadataBySymbol<[string, string][]>( + classInstance, + relationshipsSymbol, + ); + + if (relationshipTuples !== undefined) { + for (const [key] of relationshipTuples) { + const relationship = resourceObject.relationships[key]; + + if (relationship !== undefined) { + const resourceLinkage = relationship.data; + + if (resourceLinkage !== undefined) { + classInstance[key] = resourceLinkage; + } + } + } + } + } + + return classInstance; +}; diff --git a/src/serializers/index.ts b/src/serializers/index.ts index 2beb809..02c24d4 100644 --- a/src/serializers/index.ts +++ b/src/serializers/index.ts @@ -1,4 +1,5 @@ export * from './utils'; export * from './serializeIncludedResourceObjects'; export * from './serializeResourceObject'; -export * from './serializeRelationshipObject'; +export * from './serializeResourceLinkage'; +export * from './deserializeResourceObject'; diff --git a/src/serializers/serializeIncludedResourceObjects.ts b/src/serializers/serializeIncludedResourceObjects.ts index d084890..3c37693 100644 --- a/src/serializers/serializeIncludedResourceObjects.ts +++ b/src/serializers/serializeIncludedResourceObjects.ts @@ -1,7 +1,6 @@ -import { getMetadataBySymbol } from './utils/getMetadataBySymbol'; - -import { relationshipsSymbol } from '../decorators'; +import { relationshipsSymbol } from '../decorators/relationship'; import { serializeResourceObject } from './serializeResourceObject'; +import { getMetadataBySymbol } from './utils/getMetadataBySymbol'; import { isObject } from './utils/isObject'; import type { JSONAPIResourceObject } from '../types'; diff --git a/src/serializers/serializeRelationshipObject.ts b/src/serializers/serializeRelationshipObject.ts deleted file mode 100644 index 1ba8d3b..0000000 --- a/src/serializers/serializeRelationshipObject.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - idSymbol, - linksSymbol, - metaSymbol, - resourceSymbol, -} from '../decorators'; -import { collect } from './utils/collect'; -import { getMetadataBySymbol } from './utils/getMetadataBySymbol'; - -import type { - JSONAPILinksObject, - JSONAPIMetaObject, - JSONAPIRelationshipObject, -} from '../types'; -import { clean } from './utils/clean'; - -export const serializeRelationshipObject = ( - classInstance: I, -): JSONAPIRelationshipObject => { - const type = getMetadataBySymbol(classInstance, resourceSymbol); - - if (type === undefined) { - throw new Error( - 'Failed to serialize relationship object because the provided class instance is not a resource.', - ); - } - - const id = collect(classInstance, idSymbol); - - if (id === undefined) { - throw new Error( - 'Failed to serialize relationship object because the provided class instance does not have an id field.', - ); - } - - return clean({ - data: { - type, - id, - }, - links: collect(classInstance, linksSymbol), - meta: collect(classInstance, metaSymbol), - }); -}; diff --git a/src/serializers/serializeResourceLinkage.ts b/src/serializers/serializeResourceLinkage.ts new file mode 100644 index 0000000..4d6cd3b --- /dev/null +++ b/src/serializers/serializeResourceLinkage.ts @@ -0,0 +1,63 @@ +import { idSymbol } from '../decorators/id'; +import { resourceSymbol } from '../decorators/resource'; +import { collect } from './utils/collect'; +import { getMetadataBySymbol } from './utils/getMetadataBySymbol'; +import { isObject } from './utils/isObject'; + +import type { JSONAPIResourceLinkage } from '../types/resourceLinkage'; + +export const serializeResourceLinkage = ( + classInstance_s: I, +): Exclude => { + if (Array.isArray(classInstance_s)) { + if (!classInstance_s.every(isObject)) { + throw new Error( + 'Failed to serialize resource linkage because not all elements in the array are objects.', + ); + } + + return classInstance_s.map((classInstance) => { + const type = getMetadataBySymbol(classInstance, resourceSymbol); + + if (type === undefined) { + throw new Error( + 'Failed to serialize relationship object because the provided class instance is not a resource.', + ); + } + + const id = collect(classInstance, idSymbol); + + if (id === undefined) { + throw new Error( + 'Failed to serialize relationship object because the provided class instance does not have an id.', + ); + } + + return { + type, + id, + }; + }); + } + + const type = getMetadataBySymbol(classInstance_s, resourceSymbol); + + if (type === undefined) { + throw new Error( + 'Failed to serialize relationship object because the provided class instance is not a resource.', + ); + } + + const id = collect(classInstance_s, idSymbol); + + if (id === undefined) { + throw new Error( + 'Failed to serialize relationship object because the provided class instance does not have an id.', + ); + } + + return { + type, + id, + }; +}; diff --git a/src/serializers/serializeResourceObject.ts b/src/serializers/serializeResourceObject.ts index f5ea391..ed034d2 100644 --- a/src/serializers/serializeResourceObject.ts +++ b/src/serializers/serializeResourceObject.ts @@ -1,12 +1,11 @@ -import { - attributesSymbol, - idSymbol, - linksSymbol, - metaSymbol, - relationshipsSymbol, - resourceSymbol, -} from '../decorators'; -import { serializeRelationshipObject } from './serializeRelationshipObject'; +import { attributesSymbol } from '../decorators/attribute'; +import { idSymbol } from '../decorators/id'; +import { linksSymbol } from '../decorators/link'; +import { metaSymbol } from '../decorators/meta'; +import { relationshipsSymbol } from '../decorators/relationship'; +import { resourceSymbol } from '../decorators/resource'; +import { serializeResourceLinkage } from './serializeResourceLinkage'; +import { clean } from './utils/clean'; import { collect } from './utils/collect'; import { getMetadataBySymbol } from './utils/getMetadataBySymbol'; import { isObject } from './utils/isObject'; @@ -14,11 +13,10 @@ import { isObject } from './utils/isObject'; import type { JSONAPILinksObject, JSONAPIMetaObject, - JSONAPIRelationshipObject, + JSONAPIRelationshipsObject, JSONAPIResourceObject, JSONObject, } from '../types'; -import { clean } from './utils/clean'; export const serializeResourceObject = ( classInstance: I, @@ -29,46 +27,28 @@ export const serializeResourceObject = ( relationshipsSymbol, ) ?? []; - const relationships = relationshipTuples.reduce( - (acc, [key]) => { - const relatedClassInstance_s = classInstance[key]; - - if ( - relatedClassInstance_s === null || - relatedClassInstance_s === undefined - ) { - return acc; - } - - if (!isObject(relatedClassInstance_s)) { - throw new Error( - `Failed to serialize relationship object for ${key.toString()} because the value is not an object.`, - ); - } + const relationships = relationshipTuples.reduce((acc, [key]) => { + const relatedClassInstance_s = classInstance[key]; - if (Array.isArray(relatedClassInstance_s)) { - if (!relatedClassInstance_s.every(isObject)) { - throw new Error( - `Failed to serialize relationship object for ${key.toString()} becuase not all elements in the array are objects.`, - ); - } - - acc[key] = relatedClassInstance_s.map((classInstance) => - serializeRelationshipObject(classInstance), - ); + if ( + relatedClassInstance_s === null || + relatedClassInstance_s === undefined + ) { + return acc; + } - return acc; - } + if (!isObject(relatedClassInstance_s)) { + throw new Error( + `Failed to serialize relationship object for ${key.toString()} because the value is not an object.`, + ); + } - acc[key] = serializeRelationshipObject(relatedClassInstance_s); + acc[key.toString()] = { + data: serializeResourceLinkage(relatedClassInstance_s), + }; - return acc; - }, - {} as Record< - keyof I, - JSONAPIRelationshipObject | JSONAPIRelationshipObject[] - >, - ); + return acc; + }, {} as JSONAPIRelationshipsObject); const type = getMetadataBySymbol(classInstance, resourceSymbol); diff --git a/src/types/errorObject.ts b/src/types/errorObject.ts index e38ca2e..11cac4d 100644 --- a/src/types/errorObject.ts +++ b/src/types/errorObject.ts @@ -1,5 +1,5 @@ import type { JSONObject } from './json/object'; -import type { JSONAPILinkObject } from './linkObject'; +import type { JSONAPILink } from './link'; import type { JSONAPILinksObject } from './linksObject'; import type { JSONAPIMetaObject } from './metaObject'; import type { Satisfies } from './utils/satisfies'; @@ -7,10 +7,14 @@ import type { Satisfies } from './utils/satisfies'; export type JSONAPIErrorObject = Satisfies< { id: string; - links: JSONAPILinksObject & { - about?: JSONAPILinkObject; - type?: JSONAPILinkObject; - }; + links: JSONAPILinksObject & + Satisfies< + { + about?: JSONAPILink; + type?: JSONAPILink; + }, + JSONAPILinksObject + >; status: string; code: string; title: string; diff --git a/src/types/link.ts b/src/types/link.ts new file mode 100644 index 0000000..df8ab14 --- /dev/null +++ b/src/types/link.ts @@ -0,0 +1,8 @@ +import type { JSONObject, JSONPrimitives } from './json'; +import type { JSONAPILinkObject } from './linkObject'; +import type { Satisfies } from './utils/satisfies'; + +export type JSONAPILink = Satisfies< + JSONAPILinkObject | string | null, + JSONObject | JSONPrimitives +>; diff --git a/src/types/linksObject.ts b/src/types/linksObject.ts index 169eafe..eaab329 100644 --- a/src/types/linksObject.ts +++ b/src/types/linksObject.ts @@ -1,7 +1,8 @@ import type { JSONObject } from './json/object'; +import type { JSONAPILink } from './link'; import type { Satisfies } from './utils/satisfies'; export type JSONAPILinksObject = Satisfies< - { [key: string]: string | JSONAPILinksObject | null }, + { [key: string]: JSONAPILink }, JSONObject >; diff --git a/src/types/paginationLinks.ts b/src/types/paginationLinks.ts index 9698a2d..5a6e2b4 100644 --- a/src/types/paginationLinks.ts +++ b/src/types/paginationLinks.ts @@ -1,8 +1,13 @@ -import type { JSONAPILinkObject } from './linkObject'; +import type { JSONAPILink } from './link'; +import type { JSONAPILinksObject } from './linksObject'; +import type { Satisfies } from './utils/satisfies'; -export type JSONAPIPaginationLinks = { - first?: JSONAPILinkObject; - last?: JSONAPILinkObject; - prev?: JSONAPILinkObject; - next?: JSONAPILinkObject; -}; +export type JSONAPIPaginationLinks = Satisfies< + { + first?: JSONAPILink; + last?: JSONAPILink; + prev?: JSONAPILink; + next?: JSONAPILink; + }, + JSONAPILinksObject +>; diff --git a/src/types/relationshipObject.ts b/src/types/relationshipObject.ts index bebb825..828bd36 100644 --- a/src/types/relationshipObject.ts +++ b/src/types/relationshipObject.ts @@ -1,12 +1,12 @@ import type { JSONObject } from './json/object'; import type { JSONAPILinksObject } from './linksObject'; import type { JSONAPIMetaObject } from './metaObject'; -import type { JSONAPIResourceIdentifierObject } from './resourceIdentifierObject'; +import type { JSONAPIResourceLinkage } from './resourceLinkage'; import type { Satisfies } from './utils/satisfies'; export type JSONAPIRelationshipObject = Satisfies< { - data: JSONAPIResourceIdentifierObject; + data: JSONAPIResourceLinkage; links?: JSONAPILinksObject; meta?: JSONAPIMetaObject; }, diff --git a/src/types/relationshipsObject.ts b/src/types/relationshipsObject.ts index 845f0cc..031f5a5 100644 --- a/src/types/relationshipsObject.ts +++ b/src/types/relationshipsObject.ts @@ -1,8 +1,8 @@ -import type { JSONObject } from './json/object'; +import type { JSONObject } from './json'; import type { JSONAPIRelationshipObject } from './relationshipObject'; import type { Satisfies } from './utils/satisfies'; export type JSONAPIRelationshipsObject = Satisfies< - { [key: string]: JSONAPIRelationshipObject | JSONAPIRelationshipObject[] }, + { [key: string]: JSONAPIRelationshipObject }, JSONObject >; diff --git a/src/types/resourceIdentifierObject.ts b/src/types/resourceIdentifierObject.ts index c76de8d..cc1e2f0 100644 --- a/src/types/resourceIdentifierObject.ts +++ b/src/types/resourceIdentifierObject.ts @@ -4,6 +4,10 @@ import type { Satisfies } from './utils/satisfies'; export type JSONAPIResourceIdentifierObject = Satisfies< { type: string; - } & ({ id: string } | { lid: string }), + } & { id: string }, + /* + * Due to poor specification, the following is not yet supported: + * | { lid: string }) + **/ JSONObject >; diff --git a/src/types/resourceLinkage.ts b/src/types/resourceLinkage.ts new file mode 100644 index 0000000..89e8728 --- /dev/null +++ b/src/types/resourceLinkage.ts @@ -0,0 +1,6 @@ +import type { JSONAPIResourceIdentifierObject } from './resourceIdentifierObject'; + +export type JSONAPIResourceLinkage = + | JSONAPIResourceIdentifierObject + | JSONAPIResourceIdentifierObject[] + | null; diff --git a/src/types/resourceObject.ts b/src/types/resourceObject.ts index be01736..103436c 100644 --- a/src/types/resourceObject.ts +++ b/src/types/resourceObject.ts @@ -1,6 +1,6 @@ import type { JSONAPIAttributesObject } from './attributesObject'; import type { JSONObject } from './json/object'; -import type { JSONAPILinkObject } from './linkObject'; +import type { JSONAPILink } from './link'; import type { JSONAPILinksObject } from './linksObject'; import type { JSONAPIMetaObject } from './metaObject'; import type { JSONAPIRelationshipsObject } from './relationshipsObject'; @@ -12,9 +12,12 @@ export type JSONAPIResourceObject = Satisfies< id: string; attributes?: JSONAPIAttributesObject; relationships?: JSONAPIRelationshipsObject; - links?: JSONAPILinksObject & { - self?: JSONAPILinkObject; - }; + links?: Satisfies< + JSONAPILinksObject & { + self?: JSONAPILink; + }, + JSONAPILinksObject + >; meta?: JSONAPIMetaObject; }, JSONObject diff --git a/src/types/topLevelObject.ts b/src/types/topLevelObject.ts index a60226d..80c4caf 100644 --- a/src/types/topLevelObject.ts +++ b/src/types/topLevelObject.ts @@ -1,7 +1,7 @@ import type { JSONAPIErrorObject } from './errorObject'; import type { JSONObject } from './json/object'; import type { JSONAPIObject } from './jsonApiObject'; -import type { JSONAPILinkObject } from './linkObject'; +import type { JSONAPILink } from './link'; import type { JSONAPILinksObject } from './linksObject'; import type { JSONAPIMetaObject } from './metaObject'; import type { JSONAPIPaginationLinks } from './paginationLinks'; @@ -26,11 +26,14 @@ export type JSONAPITopLevelObject = Satisfies< ) & { jsonapi?: JSONAPIObject; meta?: JSONAPIMetaObject; - links?: JSONAPILinksObject & { - self?: JSONAPILinkObject; - related?: JSONAPILinkObject; - describedby?: JSONAPILinkObject; - } & JSONAPIPaginationLinks; + links?: Satisfies< + JSONAPILinksObject & { + self?: JSONAPILink; + related?: JSONAPILink; + describedby?: JSONAPILink; + } & JSONAPIPaginationLinks, + JSONAPILinksObject + >; }, JSONObject >;