Skip to content

Commit

Permalink
feat: strengthen types across all functions
Browse files Browse the repository at this point in the history
* chore: separate JSON:API types from library types
* BREAKING CHANGE: strengthen the JSON:API types and align with the specification document
* BREAKING CHANGE: strengthen the types in the deserialisation functions
* refactor: remove redendant relationship double-detection routines when deserialising
  • Loading branch information
David Brooks committed Dec 13, 2021
1 parent 12e3211 commit b83df46
Show file tree
Hide file tree
Showing 24 changed files with 640 additions and 395 deletions.
12 changes: 12 additions & 0 deletions spec/decorators/attribute.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "@jest/globals";
import { attribute } from "../../src";

describe("attribute", () => {
it("should throw an error", () => {
const nonJsonapiEntity = () => attribute()(class extends Object {}, "foo");

expect(nonJsonapiEntity).toThrow(
"`@attribute` must only be applied to properties of `JsonapiEntity` subtypes"
);
});
});
10 changes: 10 additions & 0 deletions spec/decorators/entity.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@jest/globals";
import { entity } from "../../src";
import { Address, Person } from "../test-data";

const address1: Address = new Address({
Expand Down Expand Up @@ -46,4 +47,13 @@ describe("entity", () => {
})
);
});

it("should throw an error", () => {
const nonJsonapiEntity = () =>
entity({ type: "foo" })(class extends Object {});

expect(nonJsonapiEntity).toThrow(
"`@entity` must only be applied to `JsonapiEntity` subtypes"
);
});
});
12 changes: 12 additions & 0 deletions spec/decorators/link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "@jest/globals";
import { link } from "../../src";

describe("link", () => {
it("should throw an error", () => {
const nonJsonapiEntity = () => link()(class extends Object {}, "foo");

expect(nonJsonapiEntity).toThrow(
"`@link` must only be applied to properties of `JsonapiEntity` subtypes"
);
});
});
12 changes: 12 additions & 0 deletions spec/decorators/meta.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "@jest/globals";
import { meta } from "../../src";

describe("meta", () => {
it("should throw an error", () => {
const nonJsonapiEntity = () => meta()(class extends Object {}, "foo");

expect(nonJsonapiEntity).toThrow(
"`@meta` must only be applied to properties of `JsonapiEntity` subtypes"
);
});
});
13 changes: 13 additions & 0 deletions spec/decorators/relationship.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "@jest/globals";
import { relationship } from "../../src";

describe("relationship", () => {
it("should throw an error", () => {
const nonJsonapiEntity = () =>
relationship()(class extends Object {}, "foo");

expect(nonJsonapiEntity).toThrow(
"`@relationship` must only be applied to properties of `JsonapiEntity` subtypes"
);
});
});
32 changes: 23 additions & 9 deletions spec/serialisation/deserialisers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from "@jest/globals";
import { fromJsonApiTopLevel } from "../../src";
import {
fromJsonApiTopLevelResourceDatum,
fromJsonApiTopLevelResourcesData,
} from "../../src";

import {
Address,
Expand All @@ -23,7 +26,8 @@ import * as FAKE_README_BLOG_EXAMPLE_WITH_INCLUDES from "../test-data/jsonapi/fa
describe("deserialisers", () => {
describe("fromJsonApiTopLevel", () => {
describe("JSON API top-level datum deserialisation", () => {
const { deserialised } = fromJsonApiTopLevel(FAKE_SINGLE_RESPONSE);
const { deserialised } =
fromJsonApiTopLevelResourceDatum<Person>(FAKE_SINGLE_RESPONSE);
const PERSON_1: Person = deserialised;

it("should deserialise the top-level datum from the response, populating object attributes", () => {
Expand Down Expand Up @@ -98,7 +102,9 @@ describe("deserialisers", () => {
});

describe("JSON API top-level data deserialisation", () => {
const { deserialised } = fromJsonApiTopLevel(FAKE_MULTIPLE_RESPONSE);
const { deserialised } = fromJsonApiTopLevelResourcesData<Person>(
FAKE_MULTIPLE_RESPONSE
);
const PEOPLE: Person[] = deserialised;

it("should deserialise each item in the top-level data from the response", () => {
Expand Down Expand Up @@ -195,7 +201,7 @@ describe("deserialisers", () => {
});

describe("JSON API top-level datum deserialisation of a subtype of an unregistered entity", () => {
const { deserialised } = fromJsonApiTopLevel(
const { deserialised } = fromJsonApiTopLevelResourceDatum<Cat>(
FAKE_SINGLE_SUBTYPE_RESPONSE
);
const CAT_1: Cat = deserialised;
Expand Down Expand Up @@ -239,7 +245,9 @@ describe("deserialisers", () => {

describe("README examples", () => {
describe("author only", () => {
const { deserialised } = fromJsonApiTopLevel(FAKE_README_AUTHOR_ONLY);
const { deserialised } = fromJsonApiTopLevelResourceDatum<Author>(
FAKE_README_AUTHOR_ONLY
);
const AUTHOR_1: Author = deserialised;

it("should deserialise the primary datum from the response", () => {
Expand All @@ -256,7 +264,9 @@ describe("deserialisers", () => {
});

describe("tag1 only", () => {
const { deserialised } = fromJsonApiTopLevel(FAKE_README_TAG1_ONLY);
const { deserialised } = fromJsonApiTopLevelResourceDatum<Tag>(
FAKE_README_TAG1_ONLY
);
const TAG_1: Tag = deserialised;

it("should deserialise the primary datum from the response", () => {
Expand All @@ -269,7 +279,9 @@ describe("deserialisers", () => {
});

describe("tag2 only", () => {
const { deserialised } = fromJsonApiTopLevel(FAKE_README_TAG2_ONLY);
const { deserialised } = fromJsonApiTopLevelResourceDatum<Tag>(
FAKE_README_TAG2_ONLY
);
const TAG_2: Tag = deserialised;

it("should deserialise the primary datum from the response", () => {
Expand All @@ -282,7 +294,9 @@ describe("deserialisers", () => {
});

describe("blog post only", () => {
const { deserialised } = fromJsonApiTopLevel(FAKE_README_BLOG_ONLY);
const { deserialised } = fromJsonApiTopLevelResourceDatum<BlogPost>(
FAKE_README_BLOG_ONLY
);
const POST_1: BlogPost = deserialised;

it("should deserialise the primary datum from the response", () => {
Expand Down Expand Up @@ -315,7 +329,7 @@ describe("deserialisers", () => {
});

describe("full example with includes", () => {
const { deserialised } = fromJsonApiTopLevel(
const { deserialised } = fromJsonApiTopLevelResourceDatum<BlogPost>(
FAKE_README_BLOG_EXAMPLE_WITH_INCLUDES
);
const POST_1: BlogPost = deserialised;
Expand Down
1 change: 1 addition & 0 deletions spec/test-data/animal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { attribute, JsonapiEntity, link, meta, relationship } from "../../src";

export abstract class Animal<A extends Animal<A>> extends JsonapiEntity<A> {
@attribute() name: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@relationship() chases: Animal<any> | null;
@link() self: string;
@meta({ name: "created_date_time" }) createdDateTime: string;
Expand Down
34 changes: 20 additions & 14 deletions src/decorators/attribute.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
import { JsonapiEntity } from "../jsonapi";
import { MetadataMap } from "./metadata-map";

import { getEntityPrototypeChain } from "./utils";
import {
ConstructorType,
entityConstructor,
getEntityPrototypeChain,
} from "./utils";

const ATTRIBUTES_MAP = new MetadataMap<AttributeOptions>();

export interface AttributeOptions {
name?: string;
}

export function attribute(options?: AttributeOptions): PropertyDecorator {
export function attribute<T extends JsonapiEntity<T>>(
options?: AttributeOptions
): PropertyDecorator {
const opts = options || {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target: any, key: string) => {
return (target: JsonapiEntity<T>, key: string) => {
if (!(target instanceof JsonapiEntity)) {
throw new Error(
"`@attribute` must only be applied to properties of `JsonapiEntity` subtypes"
);
}
ATTRIBUTES_MAP.setMetadataByType(
target.constructor,
entityConstructor(target),
key,
Object.assign(
{
name: key,
},
opts
)
Object.assign({ name: key }, opts)
);
};
}

export type AttributeMetadata = { [name: string]: AttributeOptions };

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export function getAttributeMetadata(target: any): AttributeMetadata {
export function getAttributeMetadata<T extends JsonapiEntity<T>>(
target: ConstructorType<T>
): AttributeMetadata {
return getEntityPrototypeChain(target).reduce(
(soFar, prototype) =>
Object.assign(soFar, ATTRIBUTES_MAP.getMetadataByType(prototype)),
Expand Down
75 changes: 29 additions & 46 deletions src/decorators/entity.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
import { ResourceIdentifier } from "../jsonapi";

export interface ResourceIdentifierConstructor {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new (...args: any[]): ResourceIdentifier;
}
import { JsonapiEntity } from "../jsonapi";
import { ConstructorType, getFullPrototypeChain } from "./utils";

export class TypeMap {
/* eslint-disable @typescript-eslint/no-explicit-any */
private constructorsByJsonapiType: {
[typeName: string]: ResourceIdentifierConstructor;
[typeName: string]: ConstructorType<any>;
} = {};
/* eslint-enable @typescript-eslint/no-explicit-any */

get(typeName: string): ResourceIdentifierConstructor {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(typeName: string): ConstructorType<any> {
return this.constructorsByJsonapiType[typeName];
}

set(typeName: string, constructorType: ResourceIdentifierConstructor): void {
set<T extends JsonapiEntity<T>>(
typeName: string,
constructorType: ConstructorType<T>
): void {
this.constructorsByJsonapiType[typeName] = constructorType;
}
}

export const ENTITIES_MAP = new TypeMap();

export function getClassForJsonapiType(
export function getConstructorForJsonapiType(
type: string
): ResourceIdentifierConstructor {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ConstructorType<JsonapiEntity<any>> {
return ENTITIES_MAP.get(type);
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function getConstructorForJsonapiType(type: string): Function {
const clazz = getClassForJsonapiType(type);
return clazz && clazz.prototype && clazz.prototype.constructor;
}

export interface EntityOptions {
type: string;
Expand All @@ -42,43 +40,28 @@ export interface EntityOptions {
* Any class annotated with `@entity` should be considered serialisable to JSON:API,
* and should have `@attribute` and `@relationship` decorators to indicate properties
* to be serialisable to and deserialisable from appropriate JSON:API data.
*
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function entity(
options: EntityOptions
): (ResourceIdentifierConstructor) => any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): (c: ConstructorType<any>) => ConstructorType<any> {
const { type } = options;

return (original: ResourceIdentifierConstructor) => {
// a utility function to generate instances of a class
const construct = (
constructorFunc: ResourceIdentifierConstructor,
args
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomJsonapiEntity: any = function () {
return new constructorFunc(...args);
};
CustomJsonapiEntity.prototype = constructorFunc.prototype;

// construct an instance and bind "type" correctly
const instance = new CustomJsonapiEntity();
instance.type = type;
return instance;
};

// the new constructor behaviour
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrappedConstructor: any = (...args) => construct(original, args);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (original: ConstructorType<any>): ConstructorType<any> => {
const chain = getFullPrototypeChain(original);
if (!chain.includes(JsonapiEntity)) {
throw new Error(
"`@entity` must only be applied to `JsonapiEntity` subtypes"
);
}

// copy prototype so intanceof operator still works
wrappedConstructor.prototype = original.prototype;
// initialises the readonly property `type` for all instances of the constructor
original.prototype.type = type;

// add the type to the reverse lookup for deserialisation
ENTITIES_MAP.set(type, wrappedConstructor);
// adds `{ type: ConstructorType }` to the reverse lookup for deserialisation
ENTITIES_MAP.set(type, original);

// return new constructor (will override original)
return wrappedConstructor;
return original;
};
}
33 changes: 20 additions & 13 deletions src/decorators/link.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
import { JsonapiEntity } from "../jsonapi";
import { MetadataMap } from "./metadata-map";

import { getEntityPrototypeChain } from "./utils";
import {
ConstructorType,
entityConstructor,
getEntityPrototypeChain,
} from "./utils";

const LINKS_MAP = new MetadataMap<LinkOptions>();

export interface LinkOptions {
name?: string;
}

export function link(options?: LinkOptions): PropertyDecorator {
export function link<T extends JsonapiEntity<T>>(
options?: LinkOptions
): PropertyDecorator {
const opts = options || {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target: any, key: string) => {
return (target: JsonapiEntity<T>, key: string) => {
if (!(target instanceof JsonapiEntity)) {
throw new Error(
"`@link` must only be applied to properties of `JsonapiEntity` subtypes"
);
}
LINKS_MAP.setMetadataByType(
target.constructor,
entityConstructor(target),
key,
Object.assign(
{
name: key,
},
opts
)
Object.assign({ name: key }, opts)
);
};
}

export type LinkMetadata = { [name: string]: LinkOptions };

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export function getLinkMetadata(target: any): LinkMetadata {
export function getLinkMetadata<T extends JsonapiEntity<T>>(
target: ConstructorType<T>
): LinkMetadata {
return getEntityPrototypeChain(target).reduce(
(soFar, prototype) =>
Object.assign(soFar, LINKS_MAP.getMetadataByType(prototype)),
Expand Down
Loading

0 comments on commit b83df46

Please sign in to comment.