Skip to content

Commit 82e3159

Browse files
committed
feat: zod interoperability and better docs
1 parent c651bb4 commit 82e3159

File tree

8 files changed

+404
-35
lines changed

8 files changed

+404
-35
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project made by Monade Team are documented in this file. For info refer to team@monade.io
44

5+
## [1.1.0] - 2023-09-03
6+
7+
### Added
8+
- Zod interoperability with `defineModel()`
9+
- Improved toJSON method
10+
- Improved docs
11+
512
## [1.0.2] - 2022-08-16
613
### Added
714
- Utility interface `DTO<T>`

README.md

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,86 @@
33

44
# @monade/json-api-parser
55

6-
A parser for [JSON:API](https://jsonapi.org/) format that maps data to models using decorators, inspired by retrofit.
6+
This library provides a parser for the [JSON:API](https://jsonapi.org/) format, enabling seamless mapping of JSON data to TypeScript/JavaScript models using decorators.
7+
8+
## Table of Contents
9+
- [Installation](#installation)
10+
- [Quick Start](#quick-start)
11+
- [Features](#features)
12+
- [Model Declaration](#model-declaration)
13+
- [The Parser](#the-parser)
14+
- [Full Example](#full-example)
15+
- [Zod Interoperability (Experimental)](#zod-interoperability-experimental)
16+
- [TODO](#todo)
17+
718

819
## Installation
920

21+
Install the package via npm:
22+
1023
```bash
1124
npm install @monade/json-api-parser
1225
```
1326

14-
## Example usage
27+
## Quick Start
28+
To get started, simply declare your models using the `@JSONAPI` and `@Attr` decorators and use the `Parser` class to parse JSON:API data.
29+
30+
```typescript
31+
import { JSONAPI, Attr, Parser } from "@monade/json-api-parser";
32+
33+
@JSONAPI("posts")
34+
class Post {
35+
@Attr() title: string;
36+
}
37+
38+
const jsonData = /* fetch your JSON:API data here */;
39+
const parsedData = new Parser(jsonData).run<Post[]>();
40+
```
41+
42+
## Features
43+
### Models declaration
44+
**`@JSONAPI(type: string)`**
45+
46+
This decorator acts as the entry point for declaring a JSON:API model. It maps a JSON:API object type to the decorated class.
47+
48+
```typescript
49+
@JSONAPI("posts")
50+
class Post extends Model {}
51+
```
52+
53+
**`@Attr([name?: string, options?: { parser?: Function, default?: any }])`**
54+
55+
This decorator is used for declaring attributes on a JSON:API model. You can optionally specify a different name for the attribute, a default value and a parser function to transform the data.
56+
57+
```typescript
58+
@JSONAPI("posts")
59+
class Post extends Model {
60+
@Attr() title: string;
61+
}
62+
```
63+
64+
**`@Rel([name?: string, options?: { parser?: Function, default?: any }])`**
65+
66+
Use this decorator to declare relationships between JSON:API models. You can optionally specify a different name for the relationship, a default value and a parser function to transform the data.
67+
68+
```typescript
69+
@JSONAPI("posts")
70+
class Post extends Model {
71+
@Rel() author: User;
72+
}
73+
```
74+
75+
### The Parser
76+
The Parser class is responsible for transforming JSON:API objects into instances of the declared models. To use it, create a new instance of `Parser` and call its `run<T>` method.
77+
78+
Example:
79+
Usage:
80+
```typescript
81+
const jsonData = await fetch('https://some-api.com/posts').then(e => e.json());
82+
const parsedData = new Parser(jsonData).run<Post[]>();
83+
```
84+
85+
## Full Example
1586

1687
```typescript
1788
import { Attr, JSONAPI, Model, Rel } from "@monade/json-api-parser";
@@ -40,15 +111,55 @@ class User extends Model {
40111
}
41112
```
42113

114+
## Zod Interoperability [EXPERIMENTAL]
115+
This library also offers experimental support for [Zod](https://zod.dev/), allowing for runtime type-checking of your JSON:API models.
116+
117+
```typescript
118+
import { declareModel, InferModel, modelOfType } from "@monade/json-api-parser/zod";
119+
120+
const UserSchema = declareModel(
121+
"users",
122+
{
123+
attributes: z.object({
124+
firstName: z.string(),
125+
lastName: z.string(),
126+
created_at: z.string().transform((v) => new Date(v)),
127+
}),
128+
129+
relationships: z.object({
130+
// Circular references must be addressed like this
131+
favouritePost: modelOfType('posts'),
132+
}),
133+
}
134+
);
135+
136+
const PostSchema = declareModel(
137+
"posts",
138+
{
139+
attributes: z.object({
140+
name: z.string(),
141+
description: z.string(),
142+
created_at: z.string().transform((v) => new Date(v)),
143+
active: z.boolean().default(true),
144+
}),
145+
relationships: z.object({ user: UserSchema, reviewer: UserSchema })
146+
}
147+
);
148+
149+
type User = InferModel<typeof UserSchema>;
150+
type Post = InferModel<typeof PostSchema>;
151+
```
152+
43153
## TODO
44-
* Documentation
154+
* Improve Documentation
45155
* Edge case tests
156+
* Improve interoperability with zod
46157

47158

48159
About Monade
49160
----------------
50161

51-
![monade](https://monade.io/wp-content/uploads/2021/06/monadelogo.png)
162+
![monade](https://monade.io/wp-content/uploads/2023/02/logo-monade.svg)
52163

53164
json-api-parser is maintained by [mònade srl](https://monade.io/en/home-en/).
54165

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@monade/json-api-parser",
3-
"version": "1.0.2",
3+
"version": "1.1.0",
44
"description": "A parser for JSON:API format that maps data to models using decorators, inspired by retrofit.",
55
"main": "dist/index.cjs.js",
66
"module": "dist/index.esm.js",
@@ -22,6 +22,9 @@
2222
"ts-jest": "^27.1.4",
2323
"typescript": "^4.6.3"
2424
},
25+
"optionalDependencies": {
26+
"zod": "^3.0.0"
27+
},
2528
"scripts": {
2629
"build": "rm -rf dist && rollup -c && tsc --emitDeclarationOnly",
2730
"prepublish:public": "yarn build",

src/Model.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
export class Model {
22
id!: string;
3+
_type!: string;
34

4-
toJSON(): any {
5-
return { ...this };
5+
toJSON(maxDepth = 100): any {
6+
const response: any = { ...this };
7+
delete response._type;
8+
9+
for (const key in response) {
10+
if (response[key] instanceof Model) {
11+
if (maxDepth <= 0) {
12+
delete response[key]
13+
} else {
14+
response[key] = response[key].toJSON(maxDepth - 1);
15+
}
16+
}
17+
}
18+
return response;
619
}
720

821
toFormData() {

src/Parser.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class Parser {
4444
} else if ("data" in data && !("id" in data)) {
4545
return this.parse(data.data, data.included || included);
4646
} else {
47-
return this.parseElement(data, included);
47+
return this.parseElement(data as JSONModel, included);
4848
}
4949
}
5050

@@ -57,78 +57,91 @@ export class Parser {
5757
parseElement<T>(element: JSONModel, included: JSONModel[]): T {
5858
const uniqueKey = `${element.id}$${element.type}`;
5959
if (this.resolved[uniqueKey]) {
60-
return this.resolved[uniqueKey] as any;
60+
return this.resolved[uniqueKey] as T;
6161
}
6262

6363
const loadedElement = Parser.load(element, included);
6464

6565
const model = Parser.$registeredModels.find(
6666
(e) => e.type === loadedElement.type
6767
);
68+
69+
const instance = new (model?.klass || Model)();
70+
this.resolved[uniqueKey] = instance;
71+
72+
if (model && model.createFn) {
73+
return model.createFn(instance, loadedElement, (relation) => this.parse(relation, included)) as T;
74+
}
75+
6876
const attrData = Parser.$registeredAttributes.find(
6977
(e) => e.klass === model?.klass
7078
);
7179
const relsData = Parser.$registeredRelationships.find(
7280
(e) => e.klass === model?.klass
7381
);
7482

75-
const instance = new (model?.klass || Model)();
83+
instance.id = loadedElement.id;
84+
instance._type = loadedElement.type;
7685

77-
this.resolved[uniqueKey] = instance;
86+
this.parseAttributes(instance, loadedElement, attrData);
87+
this.parseRelationships(instance, loadedElement, relsData, included);
7888

79-
instance.id = loadedElement.id;
80-
for (const key in loadedElement.attributes) {
81-
const parser = attrData?.attributes?.[key];
89+
return instance as T;
90+
}
91+
92+
private parseRelationships(instance: Model, loadedElement: JSONModel, relsData: RegisteredAttribute | undefined, included: JSONModel[]) {
93+
for (const key in loadedElement.relationships) {
94+
const relation = loadedElement.relationships[key];
95+
const parser = relsData?.attributes?.[key];
8296
if (parser) {
8397
(instance as any)[parser.key] = parser.parser(
84-
loadedElement.attributes[key]
98+
this.parse(relation, included)
8599
);
86100
} else {
87-
(instance as any)[key] = loadedElement.attributes[key];
88-
debug(`Undeclared key "${key}" in "${loadedElement.type}"`);
101+
(instance as any)[key] = this.parse(relation, included);
102+
debug(`Undeclared relationship "${key}" in "${loadedElement.type}"`);
89103
}
90104
}
91105

92-
if (attrData) {
93-
for (const key in attrData.attributes) {
94-
const parser: RegisteredProperty = attrData.attributes[key];
106+
if (relsData) {
107+
for (const key in relsData.attributes) {
108+
const parser: RegisteredProperty = relsData.attributes[key];
95109
if (!(parser.key in instance)) {
96110
if ("default" in parser) {
97111
(instance as any)[parser.key] = parser.default;
98112
} else {
99-
debug(`Missing attribute "${key}" in "${loadedElement.type}"`);
113+
debug(`Missing relationships "${key}" in "${loadedElement.type}"`);
100114
}
101115
}
102116
}
103117
}
118+
}
104119

105-
for (const key in loadedElement.relationships) {
106-
const relation = loadedElement.relationships[key];
107-
const parser = relsData?.attributes?.[key];
120+
private parseAttributes(instance: Model, loadedElement: JSONModel, attrData: RegisteredAttribute | undefined) {
121+
for (const key in loadedElement.attributes) {
122+
const parser = attrData?.attributes?.[key];
108123
if (parser) {
109124
(instance as any)[parser.key] = parser.parser(
110-
this.parse(relation, included)
125+
loadedElement.attributes[key]
111126
);
112127
} else {
113-
(instance as any)[key] = this.parse(relation, included);
114-
debug(`Undeclared relationship "${key}" in "${loadedElement.type}"`);
128+
(instance as any)[key] = loadedElement.attributes[key];
129+
debug(`Undeclared key "${key}" in "${loadedElement.type}"`);
115130
}
116131
}
117132

118-
if (relsData) {
119-
for (const key in relsData.attributes) {
120-
const parser: RegisteredProperty = relsData.attributes[key];
133+
if (attrData) {
134+
for (const key in attrData.attributes) {
135+
const parser: RegisteredProperty = attrData.attributes[key];
121136
if (!(parser.key in instance)) {
122137
if ("default" in parser) {
123138
(instance as any)[parser.key] = parser.default;
124139
} else {
125-
debug(`Missing relationships "${key}" in "${loadedElement.type}"`);
140+
debug(`Missing attribute "${key}" in "${loadedElement.type}"`);
126141
}
127142
}
128143
}
129144
}
130-
131-
return instance as any;
132145
}
133146

134147
static load(element: JSONModel, included: JSONModel[]) {

src/interfaces/RegisteredModel.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Model } from "../Model";
2+
import { JSONModel } from "./JSONModel";
23

3-
export interface RegisteredModel {
4+
type CreateFunction = (instance: Model, modelData: JSONModel, resolverFn: (data: any) => Model | Model[] | null) => Model
5+
6+
export type RegisteredModel = {
47
type: string;
5-
klass: typeof Model;
8+
klass?: typeof Model;
9+
createFn?: CreateFunction;
610
}

0 commit comments

Comments
 (0)