json-api-models
Advanced tools
Comparing version
export * from './types'; | ||
export * from './store'; | ||
export * from './model'; | ||
export * from './query'; |
@@ -1,1 +0,1 @@ | ||
class t{constructor(t,e){this.store=e,this.attributes={},this.relationships={},this.meta={},this.links={},this.casts={},this.merge(t)}getAttribute(t){const e=this.attributes[t],i=this.casts[t];return i&&null!=e?i!==String&&i!==Number&&i!==Boolean&&((s=i).prototype&&s.prototype.constructor.name)?new i(e):i(e):e;var s}getRelationship(t){const e=this.relationships[t].data;return Array.isArray(e),this.store.find(e)}identifier(){return{id:this.id,type:this.type}}merge(t){"type"in t&&(this.type=t.type),"id"in t&&(this.id=t.id),"attributes"in t&&(Object.assign(this.attributes,t.attributes),Object.keys(t.attributes).forEach((t=>{Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this),t)||Object.getOwnPropertyDescriptor(this,t)||(this[t]=null,Object.defineProperty(this,t,{get:()=>this.getAttribute(t),configurable:!0,enumerable:!0}))}))),"relationships"in t&&Object.entries(t.relationships).forEach((([t,e])=>{this.relationships[t]=this.relationships[t]||{},Object.assign(this.relationships[t],e),Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this),t)||Object.getOwnPropertyDescriptor(this,t)||(this[t]=null,Object.defineProperty(this,t,{get:()=>this.getRelationship(t),configurable:!0,enumerable:!0}))})),"links"in t&&(this.links=t.links),"meta"in t&&(this.meta=t.meta)}}class e{constructor(t={}){this.models=t,this.graph={}}find(t,e){return null===t?null:Array.isArray(t)?t.map((t=>this.find(t))):"object"==typeof t?this.find(t.type,t.id):this.graph[t]&&this.graph[t][e]||null}findAll(t){return this.graph[t]?Object.keys(this.graph[t]).map((e=>this.graph[t][e])):[]}sync(t){const e=this.syncResource.bind(this);return"included"in t&&t.included.map(e),Array.isArray(t.data)?t.data.map(e):e(t.data)}syncResource(t){const{type:e,id:i}=t;return this.graph[e]=this.graph[e]||{},this.graph[e][i]?this.graph[e][i].merge(t):this.graph[e][i]=this.createModel(t),this.graph[e][i]}createModel(e){return new(this.models[e.type]||this.models["*"]||t)(e,this)}forget(t){delete this.graph[t.type][t.id]}reset(){this.graph={}}}function i(t){return encodeURIComponent(t).replace(/[!'()*]/g,(function(t){return"%"+t.charCodeAt(0).toString(16).toUpperCase()}))}class s{constructor(t={}){this.query=Object.assign({},t)}append(t,e){return"object"==typeof t?Object.entries(t).map((t=>this.append.apply(this,t))):this.query[t]=(this.query[t]?this.query[t]+",":"")+e,this}set(t,e){return"object"==typeof t?Object.entries(t).map((t=>this.set.apply(this,t))):this.query[t]=e,this}delete(t){return Array.isArray(t)?t.forEach((t=>this.delete(t))):delete this.query[t],this}toString(){return Object.entries(this.query).sort(((t,e)=>t[0].localeCompare(e[0]))).map((([t,e])=>i(t)+"="+i(e))).join("&")}}export{t as Model,s as Query,e as Store}; | ||
const e=class{constructor(e){Object.defineProperty(this,"type",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),Object.defineProperty(this,"id",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),Object.defineProperty(this,"attributes",{enumerable:!0,configurable:!0,writable:!0,value:{}}),Object.defineProperty(this,"relationships",{enumerable:!0,configurable:!0,writable:!0,value:{}}),Object.defineProperty(this,"meta",{enumerable:!0,configurable:!0,writable:!0,value:{}}),Object.defineProperty(this,"links",{enumerable:!0,configurable:!0,writable:!0,value:{}}),this.type=e.type,this.id=e.id,this.merge(e)}identifier(){return{id:this.id,type:this.type}}merge(e){this.links=e.links??this.links,this.meta=e.meta??this.meta,e.attributes&&Object.assign(this.attributes,e.attributes),e.relationships&&Object.entries(e.relationships).forEach((([e,t])=>{this.relationships[e]=this.relationships[e]||{},Object.assign(this.relationships[e],t)}))}};class t{constructor(e={}){Object.defineProperty(this,"models",{enumerable:!0,configurable:!0,writable:!0,value:e}),Object.defineProperty(this,"graph",{enumerable:!0,configurable:!0,writable:!0,value:{}})}find(e,t){return null===e?null:Array.isArray(e)?e.map((e=>this.find(e))):"object"==typeof e?this.find(e.type,e.id):t&&this.graph[e]?.[t]||null}findAll(e){return this.graph[e]?Object.keys(this.graph[e]).map((t=>this.graph[e][t])):[]}sync(e){return e.included?.map((e=>this.syncResource(e))),Array.isArray(e.data)?e.data.map((e=>this.syncResource(e))):this.syncResource(e.data)}syncResource(e){const{type:t,id:i}=e;return this.graph[t]=this.graph[t]||{},this.graph[t][i]?this.graph[t][i].merge(e):this.graph[t][i]=this.createModel(e),this.graph[t][i]}createModel(t){const i=this.models[t.type]||e;return new Proxy(new i(t),{get:(e,t,i)=>{if("string"==typeof t){if(e.attributes?.[t])return e.attributes[t];const i=e.relationships?.[t]?.data;if(i)return Array.isArray(i),this.find(i)}return Reflect.get(e,t,i)}})}forget(e){delete this.graph[e.type][e.id]}reset(){this.graph={}}}export{e as Model,t as Store}; |
@@ -1,27 +0,37 @@ | ||
import { Store } from './store'; | ||
import { JsonApiIdentifier, JsonApiRelationships, JsonApiResource, KeyValueObject } from './types'; | ||
export declare type CastAttributes = { | ||
[key: string]: StringConstructor | NumberConstructor | BooleanConstructor | ((value: any) => any) | (new (value: any) => any); | ||
import { JsonApiResource, ModelForType, SchemaCollection } from './types'; | ||
type PartialJsonApiResource<T extends JsonApiResource> = { | ||
[P in keyof T]?: Partial<T[P]>; | ||
}; | ||
export declare class Model<Type extends string = any> implements JsonApiResource<Type> { | ||
protected store: Store; | ||
type: Type; | ||
id: string; | ||
attributes: KeyValueObject; | ||
relationships: JsonApiRelationships; | ||
meta: KeyValueObject; | ||
links: KeyValueObject; | ||
protected casts: CastAttributes; | ||
declare class ModelBase<Schema extends JsonApiResource = JsonApiResource> { | ||
type: Schema['type']; | ||
id: Schema['id']; | ||
attributes: NonNullable<Schema['attributes']>; | ||
relationships: NonNullable<Schema['relationships']>; | ||
meta: Schema['meta']; | ||
links: Schema['links']; | ||
[field: string]: any; | ||
constructor(data: JsonApiResource<Type>, store: Store); | ||
getAttribute(name: string): any; | ||
getRelationship(name: string): any; | ||
constructor(data: Schema); | ||
/** | ||
* Make a resource identifier object for this model. | ||
*/ | ||
identifier(): JsonApiIdentifier<Type>; | ||
identifier(): { | ||
id: Schema["id"]; | ||
type: Schema["type"]; | ||
}; | ||
/** | ||
* Merge new JSON:API resource data into the model. | ||
*/ | ||
merge(data: Partial<JsonApiResource<Type>>): void; | ||
merge(data: PartialJsonApiResource<Schema>): void; | ||
} | ||
type ProxiedModel<Schema extends JsonApiResource, Schemas extends Record<string, JsonApiResource>> = Schema & Schema['attributes'] & { | ||
[Property in keyof NonNullable<Schema['relationships']>]: NonNullable<NonNullable<Schema['relationships']>[Property]> extends { | ||
data?: infer Data; | ||
} ? Data extends { | ||
type: infer RelatedType extends string; | ||
} ? ModelForType<RelatedType, Schemas> | undefined : Data extends { | ||
type: infer RelatedType extends string; | ||
}[] ? ModelForType<RelatedType, Schemas>[] : null : never; | ||
}; | ||
export type Model<Schema extends JsonApiResource = JsonApiResource, Schemas extends SchemaCollection = SchemaCollection> = JsonApiResource<Schema['type']> & ModelBase<Schema> & ProxiedModel<Schema, Schemas>; | ||
export declare const Model: new <Schema extends JsonApiResource = JsonApiResource, Schemas extends SchemaCollection = SchemaCollection>(data: JsonApiResource<Schema['type']>) => Model<Schema, Schemas>; | ||
export {}; |
@@ -1,31 +0,19 @@ | ||
import { Model } from './model'; | ||
import { JsonApiDocument, JsonApiIdentifier, JsonApiResource } from './types'; | ||
declare type Graph = { | ||
[type: string]: { | ||
[id: string]: any; | ||
import { JsonApiDocument, JsonApiIdentifier, JsonApiResource, ModelForType, ModelMap, SchemaCollection } from './types'; | ||
export declare class Store<Schemas extends SchemaCollection = {}> { | ||
models: ModelMap<Schemas>; | ||
protected graph: { | ||
[type: string]: { | ||
[id: string]: any; | ||
}; | ||
}; | ||
}; | ||
export declare type ModelConstructor<Type extends string> = { | ||
new (data: JsonApiResource<Type>, store: Store): Model<Type>; | ||
}; | ||
export declare type ModelCollection<Models> = { | ||
[Type in keyof Models & string]: ModelConstructor<Type>; | ||
} & { | ||
'*'?: ModelConstructor<any>; | ||
}; | ||
export declare type ModelForType<Type extends string, Models extends ModelCollection<Models>> = Type extends keyof Models ? InstanceType<Models[Type]> : Model; | ||
export declare class Store<Models extends ModelCollection<Models> = any> { | ||
models: Models; | ||
protected graph: Graph; | ||
constructor(models?: Models); | ||
find<Type extends string>(identifier: JsonApiIdentifier<Type>): ModelForType<Type, Models> | null; | ||
find<Type extends string>(identifiers: JsonApiIdentifier<Type>[]): ModelForType<Type, Models>[]; | ||
find<Type extends string>(type: Type, id: string): ModelForType<Type, Models> | null; | ||
findAll<Type extends string>(type: Type): ModelForType<Type, Models>[]; | ||
sync<Type extends string>(document: JsonApiDocument<Type>): ModelForType<Type, Models> | ModelForType<Type, Models>[] | null; | ||
syncResource<Type extends string>(data: JsonApiResource<Type>): ModelForType<Type, Models>; | ||
protected createModel<Type extends string>(data: JsonApiResource<Type>): ModelForType<Type, Models>; | ||
constructor(models?: ModelMap<Schemas>); | ||
find<Type extends (keyof Schemas & string) | (string & {})>(identifier: JsonApiIdentifier<Type> | null): ModelForType<Type, Schemas> | null; | ||
find<Type extends (keyof Schemas & string) | (string & {})>(identifiers: JsonApiIdentifier<Type>[]): ModelForType<Type, Schemas>[]; | ||
find<Type extends (keyof Schemas & string) | (string & {})>(type: Type, id: string): ModelForType<Type, Schemas> | null; | ||
findAll<Type extends (keyof Schemas & string) | (string & {})>(type: Type): ModelForType<Type, Schemas>[]; | ||
sync<Type extends (keyof Schemas & string) | (string & {})>(document: JsonApiDocument<Type>): ModelForType<Type, Schemas> | ModelForType<Type, Schemas>[] | null; | ||
syncResource<Type extends (keyof Schemas & string) | (string & {})>(data: JsonApiResource<Type>): ModelForType<Type, Schemas>; | ||
createModel<Type extends (keyof Schemas & string) | (string & {})>(data: JsonApiResource<Type>): ModelForType<Type, Schemas>; | ||
forget(data: JsonApiIdentifier): void; | ||
reset(): void; | ||
} | ||
export {}; |
@@ -1,9 +0,7 @@ | ||
export declare type KeyValueObject = { | ||
[key: string]: any; | ||
}; | ||
import { Model } from './model.ts'; | ||
export interface JsonApiDocument<Type extends string = string> { | ||
data: JsonApiResource<Type> | JsonApiResource<Type>[]; | ||
included?: JsonApiResource[]; | ||
meta?: KeyValueObject; | ||
links?: KeyValueObject; | ||
meta?: Record<string, any>; | ||
links?: Record<string, any>; | ||
} | ||
@@ -15,14 +13,12 @@ export interface JsonApiIdentifier<Type extends string = string> { | ||
export interface JsonApiResource<Type extends string = string> extends JsonApiIdentifier<Type> { | ||
attributes?: KeyValueObject; | ||
attributes?: Record<string, any>; | ||
relationships?: JsonApiRelationships; | ||
meta?: KeyValueObject; | ||
links?: KeyValueObject; | ||
meta?: Record<string, any>; | ||
links?: Record<string, any>; | ||
} | ||
export interface JsonApiRelationships { | ||
[relationName: string]: JsonApiRelationship; | ||
} | ||
export type JsonApiRelationships = Record<string, JsonApiRelationship>; | ||
export interface JsonApiRelationship<Type extends string = string> { | ||
data?: JsonApiIdentifier<Type> | JsonApiIdentifier<Type>[] | null; | ||
meta?: KeyValueObject; | ||
links?: KeyValueObject; | ||
meta?: Record<string, any>; | ||
links?: Record<string, any>; | ||
} | ||
@@ -35,1 +31,8 @@ export interface JsonApiRelationshipToOne<Type extends string = string> extends JsonApiRelationship<Type> { | ||
} | ||
export type SchemaCollection = { | ||
[Type in string]: JsonApiResource<Type>; | ||
}; | ||
export type ModelMap<Schemas extends SchemaCollection = SchemaCollection> = { | ||
[Type in keyof Schemas & string]?: new (data: JsonApiResource<Type>) => Schemas[Type]; | ||
}; | ||
export type ModelForType<Type extends string, Schemas> = Type extends keyof Schemas ? Schemas[Type] : Model<JsonApiResource<Type>>; |
{ | ||
"name": "json-api-models", | ||
"description": "A lightweight layer for working with JSON:API data.", | ||
"version": "0.1.0-beta.8", | ||
"author": "Toby Zerner", | ||
"license": "MIT", | ||
"main": "./dist/index.cjs.js", | ||
"module": "./dist/index.es.js", | ||
"unpkg": "./dist/index.js", | ||
"types": "./dist/index.d.ts", | ||
"files": [ | ||
"src", | ||
"dist", | ||
"README.md", | ||
"!**/.DS_Store" | ||
], | ||
"scripts": { | ||
"test": "jest", | ||
"build": "rollup -c", | ||
"build:watch": "rollup -cw", | ||
"release": "release-it --npm.tag=latest" | ||
}, | ||
"devDependencies": { | ||
"@release-it/keep-a-changelog": "^2.3.0", | ||
"@rollup/plugin-typescript": "^8.2.5", | ||
"@types/jest": "^26.0.20", | ||
"jest": "^26.6.3", | ||
"release-it": "^14.11.5", | ||
"rollup": "^2.56.3", | ||
"rollup-plugin-terser": "^7.0.2", | ||
"ts-jest": "^26.4.4", | ||
"tslib": "^2.3.1", | ||
"typescript": "^4.6.3" | ||
}, | ||
"release-it": { | ||
"github": { | ||
"release": true | ||
"name": "json-api-models", | ||
"description": "A lightweight layer for working with JSON:API data.", | ||
"version": "0.2.0-beta.1", | ||
"author": "Toby Zerner", | ||
"license": "MIT", | ||
"module": "./dist/index.es.js", | ||
"types": "./dist/index.d.ts", | ||
"files": [ | ||
"src", | ||
"dist", | ||
"README.md", | ||
"!**/.DS_Store" | ||
], | ||
"scripts": { | ||
"test": "jest", | ||
"build": "rollup -c && tsc --emitDeclarationOnly --declaration", | ||
"release": "release-it --npm.tag=latest" | ||
}, | ||
"plugins": { | ||
"@release-it/keep-a-changelog": { | ||
"filename": "CHANGELOG.md", | ||
"addUnreleased": true, | ||
"addVersionUrl": true | ||
} | ||
"devDependencies": { | ||
"@release-it/keep-a-changelog": "^5.0.0", | ||
"@rollup/plugin-typescript": "^11.1.6", | ||
"@rollup/plugin-terser": "^0.4.4", | ||
"@types/jest": "^26.0.20", | ||
"jest": "^29.7.0", | ||
"prettier": "^3.2.5", | ||
"release-it": "^17.2.1", | ||
"rollup": "^4.17.2", | ||
"ts-jest": "^29.1.2", | ||
"tslib": "^2.6.2", | ||
"typescript": "^5.4" | ||
}, | ||
"hooks": { | ||
"after:bump": "npm run build" | ||
"release-it": { | ||
"github": { | ||
"release": true | ||
}, | ||
"plugins": { | ||
"@release-it/keep-a-changelog": { | ||
"filename": "CHANGELOG.md", | ||
"addUnreleased": true, | ||
"addVersionUrl": true | ||
} | ||
}, | ||
"hooks": { | ||
"after:bump": "npm run build" | ||
} | ||
}, | ||
"prettier": { | ||
"singleQuote": true | ||
} | ||
} | ||
} |
154
README.md
@@ -21,20 +21,22 @@ # json-api-models | ||
data: { | ||
type: 'humans', | ||
type: 'users', | ||
id: '1', | ||
attributes: { name: 'Toby' }, | ||
relationships: { | ||
dog: { data: { type: 'dogs', id: '1' }} | ||
} | ||
pet: { data: { type: 'dogs', id: '1' } }, | ||
}, | ||
}, | ||
included: [{ | ||
type: 'dogs', | ||
id: '1', | ||
attributes: { name: 'Rosie' } | ||
}] | ||
included: [ | ||
{ | ||
type: 'dogs', | ||
id: '1', | ||
attributes: { name: 'Rosie' }, | ||
}, | ||
], | ||
}); | ||
// Resource data is transformed into easy-to-consume models | ||
const human = models.find('humans', '1'); | ||
human.name // Toby | ||
human.dog // { type: 'dogs', id: '1', name: 'Rosie' } | ||
const user = models.find('users', '1'); | ||
user.name; // Toby | ||
user.pet; // { type: 'dogs', id: '1', name: 'Rosie' } | ||
``` | ||
@@ -44,3 +46,4 @@ | ||
Use the `sync` method to load your JSON:API response document into the store. Both the primary `data` and any `included` resources will be synced. The return value will be a model, or an array of models, corresponding to the primary data. | ||
Use the `sync` method to load your JSON:API response document into the store. Both the primary `data` and any `included` | ||
resources will be synced. The return value will be a model, or an array of models, corresponding to the primary data. | ||
@@ -51,3 +54,4 @@ ```ts | ||
If any of the synced resources already exist within the store, the new data will be **merged** into the old model. The model instance will not change so references to it throughout your application will remain intact. | ||
If any of the synced resources already exist within the store, the new data will be **merged** into the old model. The | ||
model instance will not change so references to it throughout your application will remain intact. | ||
@@ -60,3 +64,3 @@ You can also sync an individual resource using the `syncResource` method: | ||
id: '1', | ||
attributes: { name: 'Toby' } | ||
attributes: { name: 'Toby' }, | ||
}); | ||
@@ -67,10 +71,11 @@ ``` | ||
Specific models can be retrieved from the store using the `find` method. Pass it a type and an ID, a resource identifier object, or an array of resource identifier objects: | ||
Specific models can be retrieved from the store using the `find` method. Pass it a type and an ID, a resource identifier | ||
object, or an array of resource identifier objects: | ||
```ts | ||
const model = models.find('users', '1'); | ||
const model = models.find({ type: 'users', id: '1' }); | ||
const models = models.find([ | ||
const user = models.find('users', '1'); | ||
const user = models.find({ type: 'users', id: '1' }); | ||
const users = models.find([ | ||
{ type: 'users', id: '1' }, | ||
{ type: 'users', id: '2' } | ||
{ type: 'users', id: '2' }, | ||
]); | ||
@@ -82,3 +87,3 @@ ``` | ||
```ts | ||
const models = models.findAll('users'); | ||
const users = models.findAll('users'); | ||
``` | ||
@@ -88,15 +93,18 @@ | ||
Models are a *superset* of JSON:API resource objects, meaning they contain all of the members you would expect (`type`, `id`, `attributes`, `relationships`, `meta`, `links`) plus some additional functionality. | ||
Models are a _superset_ of JSON:API resource objects, meaning they contain all of the members you would | ||
expect (`type`, `id`, `attributes`, `relationships`, `meta`, `links`) plus some additional functionality. | ||
Getters are automatically defined for all fields, allowing you to easily access their contents. Relationship fields are automatically resolved to their related models (if present within the store): | ||
Getters are automatically defined for all fields, allowing you to easily access their contents. Relationship fields are | ||
automatically resolved to their related models (if present within the store): | ||
```ts | ||
model.name // => model.attributes.name | ||
model.dog // => models.find(model.relationships.dog.data) | ||
model.name; // => model.attributes.name | ||
model.pet; // => models.find(model.relationships.pet.data) | ||
``` | ||
To easily retrieve a resource identifier object for the model, the `identifier` method is available. This is useful when constructing relationships in JSON:API request documents. | ||
To easily retrieve a resource identifier object for the model, the `identifier` method is available. This is useful when | ||
constructing relationships in JSON:API request documents. | ||
```ts | ||
model.identifier() // { type: 'users', id: '1' } | ||
model.identifier(); // { type: 'users', id: '1' } | ||
``` | ||
@@ -106,3 +114,4 @@ | ||
Remove a model from the store using the `forget` method, which accepts a resource identifier object. This means you can pass a model directly into it: | ||
Remove a model from the store using the `forget` method, which accepts a resource identifier object. This means you can | ||
pass a model directly into it: | ||
@@ -115,3 +124,4 @@ ```ts | ||
You can define custom model classes to add your own functionality. Custom models must extend the `Model` base class. This is useful if you wish to add any custom getters or methods to models for a specific resource type, and also to define types for each resource field: | ||
You can define custom model classes to add your own functionality. Custom models must extend the `Model` base class. | ||
This is useful if you wish to add any custom getters or methods to models for a specific resource type: | ||
@@ -121,6 +131,3 @@ ```ts | ||
class User extends Model<'users'> { | ||
public declare name: string; | ||
public declare age: number; | ||
class User extends Model { | ||
get firstName() { | ||
@@ -140,20 +147,41 @@ return this.name.split(' ')[0]; | ||
#### Attribute Casts | ||
### TypeScript | ||
You can define typecasts for attributes on your custom models: | ||
For TypeScript autocompletion of model attributes and relationships, provide the raw JSON:API resource schema when defining your models. | ||
```ts | ||
class User extends Model<'users'> { | ||
declare public name: string; | ||
declare public createdAt: Date; | ||
protected casts = { | ||
createdAt: Date, | ||
type UsersSchema = { | ||
type: 'users'; | ||
id: string; | ||
attributes: { | ||
name: string; | ||
}; | ||
} | ||
relationships: { | ||
dog: { data?: { type: 'dogs'; id: string } | null }; | ||
}; | ||
}; | ||
class User extends Model<UsersSchema> {} | ||
``` | ||
To type related resources, you can provide a collection of all models as the second generic. | ||
```ts | ||
type DogsSchema = { | ||
// ... | ||
}; | ||
type Schemas = { | ||
users: User; | ||
dogs: Dog; | ||
}; | ||
class User extends Model<UsersSchema, Schemas> {} | ||
class Dog extends Model<DogsSchema, Schemas> {} | ||
``` | ||
### API Consumption Tips | ||
This library is completely unopinionated about how you interact with your JSON:API server. It merely gives you an easy way to work with the resulting JSON:API data. An example integration with `fetch` is demonstrated below: | ||
This library is completely unopinionated about how you interact with your JSON:API server. It merely gives you an easy | ||
way to work with the resulting JSON:API data. An example integration with `fetch` is demonstrated below: | ||
@@ -172,5 +200,5 @@ ```ts | ||
return fetch('http://example.org/api/' + url, options) | ||
.then(async response => { | ||
if (response.status === 204) { | ||
return fetch('http://example.org/api/' + url, options).then( | ||
async (response) => { | ||
if (response.status === 204) { | ||
return { response }; | ||
@@ -182,3 +210,4 @@ } else { | ||
} | ||
}); | ||
}, | ||
); | ||
} | ||
@@ -191,3 +220,5 @@ | ||
When constructing API requests, remember that JSON:API resource objects contain `links` that can be used instead of rebuilding the URL. Also, models contain an `identifier` method that can be used to spread the `type` and `id` members into the document `data` (required by the specification). Here is an example of a request to update a resource: | ||
When constructing API requests, remember that JSON:API resource objects contain `links` that can be used instead of | ||
rebuilding the URL. Also, models contain an `identifier` method that can be used to spread the `type` and `id` members | ||
into the document `data` (required by the specification). Here is an example of a request to update a resource: | ||
@@ -202,31 +233,8 @@ ```ts | ||
...user.identifier(), | ||
attributes: { name: 'Changed' } | ||
} | ||
} | ||
attributes: { name: 'Changed' }, | ||
}, | ||
}, | ||
}); | ||
``` | ||
### Building Queries | ||
Building query strings for your JSON:API requests can be tedious, and sometimes they may need to be constructed dynamically with merge logic for certain parameters. The `Query` class takes care of this: | ||
```ts | ||
import { Query } from 'json-api-models'; | ||
const query = new Query({ | ||
'include': 'foo', | ||
'fields[users]': 'name', | ||
}); | ||
query.append('include', 'bar'); | ||
query.append('fields[users]', 'age'); | ||
query.toString(); // include=foo,bar&fields[users]=name,age | ||
query.delete('fields[users]'); | ||
query.set('include', 'replaced'); | ||
query.toString(); // include=replaced | ||
``` | ||
## Contributing | ||
@@ -233,0 +241,0 @@ |
export * from './types'; | ||
export * from './store'; | ||
export * from './model'; | ||
export * from './query'; |
151
src/model.ts
@@ -1,56 +0,31 @@ | ||
import { Store } from './store'; | ||
import { JsonApiIdentifier, JsonApiRelationships, JsonApiResource, KeyValueObject } from './types'; | ||
import { JsonApiResource, ModelForType, SchemaCollection } from './types'; | ||
export type CastAttributes = { | ||
[key: string]: StringConstructor | NumberConstructor | BooleanConstructor | ((value: any) => any) | (new(value: any) => any); | ||
} | ||
type PartialJsonApiResource<T extends JsonApiResource> = { | ||
[P in keyof T]?: Partial<T[P]>; | ||
}; | ||
function isConstructor(obj: any): obj is (new(value: any) => any) { | ||
return !! obj.prototype && !! obj.prototype.constructor.name; | ||
} | ||
class ModelBase<Schema extends JsonApiResource = JsonApiResource> { | ||
public type: Schema['type']; | ||
public id: Schema['id']; | ||
public attributes: NonNullable<Schema['attributes']> = {}; | ||
public relationships: NonNullable<Schema['relationships']> = {}; | ||
public meta: Schema['meta'] = {}; | ||
public links: Schema['links'] = {}; | ||
export class Model<Type extends string = any> implements JsonApiResource<Type> { | ||
public type: Type; | ||
public id: string; | ||
public attributes: KeyValueObject = {}; | ||
public relationships: JsonApiRelationships = {}; | ||
public meta: KeyValueObject = {}; | ||
public links: KeyValueObject = {}; | ||
[field: string]: any; | ||
protected casts: CastAttributes = {}; | ||
constructor(data: Schema) { | ||
this.type = data.type; | ||
this.id = data.id; | ||
[field: string]: any; | ||
constructor(data: JsonApiResource<Type>, protected store: Store) { | ||
this.merge(data); | ||
} | ||
public getAttribute(name: string): any { | ||
const value = this.attributes[name]; | ||
const cast = this.casts[name]; | ||
if (cast && value !== null && value !== undefined) { | ||
if (cast === String || cast === Number || cast === Boolean || ! isConstructor(cast)) { | ||
return (cast as any)(value); | ||
} | ||
return new cast(value); | ||
} | ||
return value; | ||
} | ||
public getRelationship(name: string): any { | ||
const data = this.relationships[name].data; | ||
// https://github.com/microsoft/TypeScript/issues/14107 | ||
return Array.isArray(data) ? this.store.find(data) : this.store.find(data); | ||
} | ||
/** | ||
* Make a resource identifier object for this model. | ||
*/ | ||
identifier(): JsonApiIdentifier<Type> { | ||
public identifier() { | ||
return { | ||
id: this.id, | ||
type: this.type | ||
type: this.type, | ||
}; | ||
@@ -62,59 +37,49 @@ } | ||
*/ | ||
merge(data: Partial<JsonApiResource<Type>>): void { | ||
if ('type' in data) { | ||
this.type = data.type as Type; | ||
} | ||
public merge(data: PartialJsonApiResource<Schema>): void { | ||
this.links = data.links ?? this.links; | ||
this.meta = data.meta ?? this.meta; | ||
if ('id' in data) { | ||
this.id = data.id; | ||
if (data.attributes) { | ||
Object.assign(this.attributes, data.attributes); | ||
} | ||
if ('attributes' in data) { | ||
Object.assign(this.attributes, data.attributes); | ||
Object.keys(data.attributes).forEach(name => { | ||
if ( | ||
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), name) | ||
|| Object.getOwnPropertyDescriptor(this, name) | ||
) return; | ||
this[name] = null; | ||
Object.defineProperty(this, name, { | ||
get: () => this.getAttribute(name), | ||
configurable: true, | ||
enumerable: true, | ||
}); | ||
}); | ||
if (data.relationships) { | ||
Object.entries(data.relationships).forEach( | ||
([name, relationship]) => { | ||
this.relationships[name] = this.relationships[name] || {}; | ||
Object.assign(this.relationships[name], relationship); | ||
}, | ||
); | ||
} | ||
} | ||
} | ||
if ('relationships' in data) { | ||
Object.entries(data.relationships).forEach(([name, relationship]) => { | ||
this.relationships[name] = this.relationships[name] || {}; | ||
type ProxiedModel< | ||
Schema extends JsonApiResource, | ||
Schemas extends Record<string, JsonApiResource>, | ||
> = Schema & | ||
Schema['attributes'] & { | ||
[Property in keyof NonNullable<Schema['relationships']>]: NonNullable< | ||
NonNullable<Schema['relationships']>[Property] | ||
> extends { data?: infer Data } | ||
? Data extends { type: infer RelatedType extends string } | ||
? ModelForType<RelatedType, Schemas> | undefined | ||
: Data extends { type: infer RelatedType extends string }[] | ||
? ModelForType<RelatedType, Schemas>[] | ||
: null | ||
: never; | ||
}; | ||
Object.assign(this.relationships[name], relationship); | ||
export type Model< | ||
Schema extends JsonApiResource = JsonApiResource, | ||
Schemas extends SchemaCollection = SchemaCollection, | ||
> = JsonApiResource<Schema['type']> & | ||
ModelBase<Schema> & | ||
ProxiedModel<Schema, Schemas>; | ||
if ( | ||
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(this), name) | ||
|| Object.getOwnPropertyDescriptor(this, name) | ||
) return; | ||
this[name] = null; | ||
Object.defineProperty(this, name, { | ||
get: () => this.getRelationship(name), | ||
configurable: true, | ||
enumerable: true, | ||
}); | ||
}); | ||
} | ||
if ('links' in data) { | ||
this.links = data.links; | ||
} | ||
if ('meta' in data) { | ||
this.meta = data.meta; | ||
} | ||
} | ||
} | ||
export const Model: new < | ||
Schema extends JsonApiResource = JsonApiResource, | ||
Schemas extends SchemaCollection = SchemaCollection, | ||
>( | ||
data: JsonApiResource<Schema['type']>, | ||
) => Model<Schema, Schemas> = ModelBase as any; |
105
src/store.ts
import { Model } from './model'; | ||
import { JsonApiDocument, JsonApiIdentifier, JsonApiResource } from './types'; | ||
import { | ||
JsonApiDocument, | ||
JsonApiIdentifier, | ||
JsonApiResource, | ||
ModelForType, | ||
ModelMap, | ||
SchemaCollection, | ||
} from './types'; | ||
type Graph = { | ||
[type: string]: { | ||
[id: string]: any; | ||
}; | ||
}; | ||
export class Store<Schemas extends SchemaCollection = {}> { | ||
protected graph: { [type: string]: { [id: string]: any } } = {}; | ||
export type ModelConstructor<Type extends string> = { | ||
new(data: JsonApiResource<Type>, store: Store): Model<Type>; | ||
}; | ||
public constructor(public models: ModelMap<Schemas> = {}) {} | ||
export type ModelCollection<Models> = { [Type in keyof Models & string]: ModelConstructor<Type> } & { '*'?: ModelConstructor<any> }; | ||
export type ModelForType<Type extends string, Models extends ModelCollection<Models>> = Type extends keyof Models ? InstanceType<Models[Type]> : Model; | ||
export class Store<Models extends ModelCollection<Models> = any> { | ||
protected graph: Graph = {}; | ||
public constructor(public models: Models = {} as Models) {} | ||
public find<Type extends string>(identifier: JsonApiIdentifier<Type>): ModelForType<Type, Models> | null; | ||
public find<Type extends string>(identifiers: JsonApiIdentifier<Type>[]): ModelForType<Type, Models>[]; | ||
public find<Type extends string>(type: Type, id: string): ModelForType<Type, Models> | null; | ||
public find<Type extends string>(a: JsonApiIdentifier<Type> | JsonApiIdentifier<Type>[] | string, b?: string) { | ||
public find<Type extends (keyof Schemas & string) | (string & {})>( | ||
identifier: JsonApiIdentifier<Type> | null, | ||
): ModelForType<Type, Schemas> | null; | ||
public find<Type extends (keyof Schemas & string) | (string & {})>( | ||
identifiers: JsonApiIdentifier<Type>[], | ||
): ModelForType<Type, Schemas>[]; | ||
public find<Type extends (keyof Schemas & string) | (string & {})>( | ||
type: Type, | ||
id: string, | ||
): ModelForType<Type, Schemas> | null; | ||
public find<Type extends (keyof Schemas & string) | (string & {})>( | ||
a: JsonApiIdentifier<Type> | JsonApiIdentifier<Type>[] | Type | null, | ||
b?: string, | ||
) { | ||
if (a === null) { | ||
@@ -32,3 +35,3 @@ return null; | ||
if (Array.isArray(a)) { | ||
return a.map((identifier: JsonApiIdentifier<Type>) => this.find(identifier)); | ||
return a.map((identifier) => this.find(identifier)); | ||
} | ||
@@ -40,27 +43,32 @@ | ||
return (this.graph[a] && this.graph[a][b]) || null; | ||
if (b) { | ||
return this.graph[a]?.[b] || null; | ||
} | ||
return null; | ||
} | ||
public findAll<Type extends string>(type: Type): ModelForType<Type, Models>[] { | ||
if (! this.graph[type]) { | ||
public findAll<Type extends (keyof Schemas & string) | (string & {})>( | ||
type: Type, | ||
): ModelForType<Type, Schemas>[] { | ||
if (!this.graph[type]) { | ||
return []; | ||
} | ||
return Object.keys(this.graph[type]) | ||
.map(id => this.graph[type][id]); | ||
return Object.keys(this.graph[type]).map((id) => this.graph[type][id]); | ||
} | ||
public sync<Type extends string>(document: JsonApiDocument<Type>): ModelForType<Type, Models> | ModelForType<Type, Models>[] | null { | ||
const syncResource = this.syncResource.bind(this); | ||
public sync<Type extends (keyof Schemas & string) | (string & {})>( | ||
document: JsonApiDocument<Type>, | ||
): ModelForType<Type, Schemas> | ModelForType<Type, Schemas>[] | null { | ||
document.included?.map((resource) => this.syncResource(resource)); | ||
if ('included' in document) { | ||
document.included.map(syncResource); | ||
} | ||
return Array.isArray(document.data) | ||
? document.data.map(syncResource) | ||
: syncResource(document.data); | ||
? document.data.map((resource) => this.syncResource(resource)) | ||
: this.syncResource(document.data); | ||
} | ||
public syncResource<Type extends string>(data: JsonApiResource<Type>): ModelForType<Type, Models> { | ||
public syncResource<Type extends (keyof Schemas & string) | (string & {})>( | ||
data: JsonApiResource<Type>, | ||
): ModelForType<Type, Schemas> { | ||
const { type, id } = data; | ||
@@ -79,6 +87,23 @@ | ||
protected createModel<Type extends string>(data: JsonApiResource<Type>): ModelForType<Type, Models> { | ||
const ModelClass = this.models[data.type as keyof Models] || this.models['*'] || Model; | ||
public createModel<Type extends (keyof Schemas & string) | (string & {})>( | ||
data: JsonApiResource<Type>, | ||
): ModelForType<Type, Schemas> { | ||
const ModelClass = this.models[data.type] || Model; | ||
return new ModelClass(data, this) as ModelForType<Type, Models>; | ||
return new Proxy(new ModelClass(data), { | ||
get: (target, prop, receiver) => { | ||
if (typeof prop === 'string') { | ||
if (target.attributes?.[prop]) { | ||
return target.attributes[prop]; | ||
} | ||
const data = target.relationships?.[prop]?.data; | ||
if (data) { | ||
return Array.isArray(data) | ||
? this.find(data) | ||
: this.find(data); | ||
} | ||
} | ||
return Reflect.get(target, prop, receiver); | ||
}, | ||
}) as ModelForType<Type, Schemas>; | ||
} | ||
@@ -85,0 +110,0 @@ |
@@ -1,4 +0,2 @@ | ||
export type KeyValueObject = { | ||
[key: string]: any; | ||
} | ||
import { Model } from './model.ts'; | ||
@@ -8,4 +6,4 @@ export interface JsonApiDocument<Type extends string = string> { | ||
included?: JsonApiResource[]; | ||
meta?: KeyValueObject; | ||
links?: KeyValueObject; | ||
meta?: Record<string, any>; | ||
links?: Record<string, any>; | ||
} | ||
@@ -18,25 +16,39 @@ | ||
export interface JsonApiResource<Type extends string = string> extends JsonApiIdentifier<Type> { | ||
attributes?: KeyValueObject; | ||
export interface JsonApiResource<Type extends string = string> | ||
extends JsonApiIdentifier<Type> { | ||
attributes?: Record<string, any>; | ||
relationships?: JsonApiRelationships; | ||
meta?: KeyValueObject; | ||
links?: KeyValueObject; | ||
meta?: Record<string, any>; | ||
links?: Record<string, any>; | ||
} | ||
export interface JsonApiRelationships { | ||
[relationName: string]: JsonApiRelationship; | ||
} | ||
export type JsonApiRelationships = Record<string, JsonApiRelationship>; | ||
export interface JsonApiRelationship<Type extends string = string> { | ||
data?: JsonApiIdentifier<Type> | JsonApiIdentifier<Type>[] | null; | ||
meta?: KeyValueObject; | ||
links?: KeyValueObject; | ||
meta?: Record<string, any>; | ||
links?: Record<string, any>; | ||
} | ||
export interface JsonApiRelationshipToOne<Type extends string = string> extends JsonApiRelationship<Type> { | ||
export interface JsonApiRelationshipToOne<Type extends string = string> | ||
extends JsonApiRelationship<Type> { | ||
data?: JsonApiIdentifier<Type> | null; | ||
} | ||
export interface JsonApiRelationshipToMany<Type extends string = string> extends JsonApiRelationship<Type> { | ||
export interface JsonApiRelationshipToMany<Type extends string = string> | ||
extends JsonApiRelationship<Type> { | ||
data?: JsonApiIdentifier<Type>[]; | ||
} | ||
export type SchemaCollection = { [Type in string]: JsonApiResource<Type> }; | ||
export type ModelMap<Schemas extends SchemaCollection = SchemaCollection> = { | ||
[Type in keyof Schemas & string]?: new ( | ||
data: JsonApiResource<Type>, | ||
) => Schemas[Type]; | ||
}; | ||
export type ModelForType< | ||
Type extends string, | ||
Schemas, | ||
> = Type extends keyof Schemas ? Schemas[Type] : Model<JsonApiResource<Type>>; |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
232
3.57%23300
-24.12%11
10%12
-25%316
-17.71%1
Infinity%