Skip to content

Instantly share code, notes, and snippets.

@ProGM
Last active March 30, 2022 20:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ProGM/0211b8d7c42fa0b5de7a10455437123f to your computer and use it in GitHub Desktop.
Save ProGM/0211b8d7c42fa0b5de7a10455437123f to your computer and use it in GitHub Desktop.
New Model Parser for JSON::API. Used as a bases for: https://github.com/monade/json-api-parser
import { Parser } from './parser';
import 'models';
const parsable = `{
"data": [
{
"id": "2", "type": "posts",
"attributes": { "name": "My post", "ciao": null, "description": "ciao", "created_at": "2020-10-10T10:32:00Z" },
"relationships": { "user": { "data": { "id": "3", "type": "users" } } }
}
],
"included": [
{
"id": "3", "type": "users",
"attributes": { "firstName": "Gino", "lastName": "Pino", "created_at": "2020-10-15T10:32:00Z" },
"relationships": { "favouritePost": { "data": { "id": "2", "type": "posts" } } }
}
]
}`
const parsed = JSON.parse(parsable)
console.log(new Parser(parsed.data, parsed.included).run());
import { JSONAPI, Attr, Rel, DateParser } from './parser';
@JSONAPI('posts')
class Post extends Model {
@Attr() name!: string;
@Attr('description') content!: string;
@Attr('created_at', { parser: DateParser }) createdAt!: Date;
@Attr('active', { default: true }) enabled!: boolean;
@Attr() missing!: boolean;
@Rel('user') author!: User;
}
@JSONAPI('users')
class User extends Model {
@Attr() firstName!: string;
@Attr() lastName!: string;
@Attr('created_at', { parser: DateParser }) createdAt!: string;
@Rel() favouritePost!: Post;
}
export interface JSONModel {
id: string;
type: string;
attributes?: { [key: string]: any };
relationships?: { [key: string]: JSONData };
}
export interface JSONData {
data: JSONModel | JSONModel[];
included: JSONModel[];
}
export class Model {
id!: string;
toJSON(): any {
return { ...this };
}
toFormData() {
const data = this.toJSON();
const formData = new FormData();
for (const key in data) {
if (data[key] !== null && data[key] !== undefined) {
if (Array.isArray(data[key])) {
for (const value of data[key]) {
formData.append(key + '[]', value);
}
} else if (data[key] instanceof File) {
formData.append(key, data[key], data[key].filename);
} else {
formData.append(key, data[key]);
}
}
}
return formData;
}
}
interface RegisteredProperty {
key: string;
default?: any;
parser: (value: any) => any;
}
interface RegisteredModel {
type: string;
klass: typeof Model;
}
interface RegisteredAttribute {
klass: any;
attributes: Record<string, RegisteredProperty>;
}
function debug(...args: any[]) {
console.warn(...args);
}
export class Parser {
static $registeredModels: RegisteredModel[] = [];
static $registeredAttributes: RegisteredAttribute[] = [];
static $registeredRelationships: RegisteredAttribute[] = [];
readonly resolved: Record<string, Model> = {};
constructor(private data: JSONModel[] | JSONModel, private included: JSONModel[] = []) {
}
run<T>(): T | T[] | null {
if (!this.data) {
return null;
}
const { data, included } = this;
const fullIncluded = Array.isArray(data) ? [...data, ...included] : [data, ...included];
return this.parse(data, fullIncluded);
}
private parse<T>(data: JSONData | JSONModel[] | JSONModel, included: JSONModel[] = []): T | T[] | null {
if (!this.data) {
return null;
}
if (Array.isArray(data)) {
return this.parseList(data, included) as T[];
} else if ('data' in data && !('id' in data)) {
return this.parse(data.data, data.included || included);
} else {
return this.parseElement(data, included) as T;
}
}
parseList(list: JSONModel[], included: JSONModel[]) {
return list.map((e) => {
return this.parseElement(e, included);
});
}
parseElement<T>(element: JSONModel, included: JSONModel[]): T {
const uniqueKey = `${element.id}$${element.type}`;
if (this.resolved[uniqueKey]) {
return this.resolved[uniqueKey] as any;
}
const loadedElement = Parser.load(element, included);
const model = Parser.$registeredModels.find(e => e.type === loadedElement.type);
const attrData = Parser.$registeredAttributes.find(e => e.klass === model?.klass);
const relsData = Parser.$registeredRelationships.find(e => e.klass === model?.klass);
const instance = new (model?.klass || Model)();
this.resolved[uniqueKey] = instance;
instance.id = loadedElement.id;
for (const key in loadedElement.attributes) {
const parser = attrData?.attributes?.[key];
if (parser) {
(instance as any)[parser.key] = parser.parser(loadedElement.attributes[key]);
} else {
(instance as any)[key] = loadedElement.attributes[key];
debug(`Undeclared key "${key}" in "${loadedElement.type}"`)
}
}
if (attrData) {
for (const key in attrData.attributes) {
const parser: RegisteredProperty = attrData.attributes[key];
if (!(parser.key in instance)) {
if ('default' in parser) {
(instance as any)[parser.key] = parser.default;
} else {
debug(`Missing attribute "${key}" in "${loadedElement.type}"`)
}
}
}
}
for (const key in loadedElement.relationships) {
const relation = loadedElement.relationships[key];
const parser = relsData?.attributes?.[key];
if (parser) {
(instance as any)[parser.key] = parser.parser(this.parse(relation, included));
} else {
(instance as any)[key] = this.parse(relation, included);
debug(`Undeclared relationship "${key}" in "${loadedElement.type}"`)
}
}
if (relsData) {
for (const key in relsData.attributes) {
const parser: RegisteredProperty = relsData.attributes[key];
if (!(parser.key in instance)) {
if ('default' in parser) {
(instance as any)[parser.key] = parser.default;
} else {
debug(`Missing relationships "${key}" in "${loadedElement.type}"`)
}
}
}
}
return instance as any;
}
static load(element: JSONModel, included: JSONModel[]) {
const found = included.find((e) => e.id == element.id && e.type === element.type);
if (!found) {
debug(`Relationship with type ${element.type} with id ${element.id} not present in included`);
}
return (
found || { ...element, $_partial: true }
);
}
}
export function JSONAPI(type: string) {
return function _Model<T extends typeof Model>(constructor: T) {
Parser.$registeredModels.push({
klass: constructor,
type,
})
}
}
export function Attr(sourceKey?: string, options: { default?: any; parser?: (v: any) => any } = { parser: ((v) => v) }) {
return function _Attr<T extends Model>(klass: T, key: string) {
let model = Parser.$registeredAttributes.find(e => e.klass === klass.constructor)
if (!model) {
model = { attributes: {}, klass: klass.constructor };
Parser.$registeredAttributes.push(model);
}
const data: RegisteredProperty = {
parser: options.parser ?? ((v) => v), key
};
if ('default' in options) {
data.default = options.default;
}
model.attributes[sourceKey ?? key] = data;
}
}
export function Rel(sourceKey?: string, options: { default?: any; parser?: (v: any) => any } = { parser: ((v) => v) }) {
return function _Rel<T extends Model>(klass: T, key: string) {
let model = Parser.$registeredRelationships.find(e => e.klass === klass.constructor)
if (!model) {
model = { attributes: {}, klass: klass.constructor };
Parser.$registeredRelationships.push(model);
}
model.attributes[sourceKey ?? key] = {
parser: options.parser ?? ((v) => v), key, default: options.default
};
}
}
// FIXME: Use moment or date-fns
export const DateParser = (data: any) => new Date(data);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment