Skip to content

Commit 0af0eaa

Browse files
committed
feat: allow proper JSON:API parsing
1 parent 9cf0d16 commit 0af0eaa

22 files changed

+1728
-252
lines changed

.github/workflows/release.yml

+10-4
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ jobs:
1111
runs-on: ubuntu-latest
1212

1313
steps:
14-
- uses: actions/checkout@v3
14+
- uses: actions/checkout@v4
1515

1616
- name: Use pnpm 8.x
17-
uses: pnpm/action-setup@v2
17+
uses: pnpm/action-setup@v3
1818
with:
1919
version: 8
2020

2121
- name: Use Node.js 20.x
22-
uses: actions/setup-node@v3
22+
uses: actions/setup-node@v4
2323
with:
2424
node-version: 20
2525
cache: 'pnpm'
@@ -30,11 +30,17 @@ jobs:
3030
- name: Biome CI
3131
run: pnpm exec biome ci .
3232

33+
- name: Test
34+
run: pnpm coverage
35+
36+
- name: Codecov
37+
uses: codecov/codecov-action@v4
38+
3339
- name: Build
3440
run: pnpm build
3541

3642
- name: Semantic Release
37-
uses: cycjimmy/semantic-release-action@v3
43+
uses: cycjimmy/semantic-release-action@v4
3844
env:
3945
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4046
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
/node_modules
22
/dist
3+
/coverage

README.md

+112-92
Original file line numberDiff line numberDiff line change
@@ -1,143 +1,163 @@
1-
# TanStack Query helpers for JSON:API
1+
# JSON:API Query Helpers utilizing Zod
22

3-
[![Release](https://github.com/DASPRiD/tanstack-query-json-api/actions/workflows/release.yml/badge.svg)](https://github.com/DASPRiD/mikro-orm-js-joda/actions/workflows/release.yml)
3+
[![Release](https://github.com/DASPRiD/jsonapi-zod-query/actions/workflows/release.yml/badge.svg)](https://github.com/DASPRiD/mikro-orm-js-joda/actions/workflows/release.yml)
44

5-
This package helps you working with [JSON:API 1.1](https://jsonapi.org/) compliant API servers. In order to validate
5+
This package helps you to work with [JSON:API 1.1](https://jsonapi.org/) compliant API servers. In order to validate
66
and parse responses, it is assumed that you have [zod](https://www.npmjs.com/package/zod) installed.
77

8+
There are no assumption made about how you query your data, except that you are using `fetch`. Selectors from this
9+
library can be used with e.g. [TanStack Query](https://tanstack.com/query/latest).
10+
11+
Selectors from this library will flatten down all resources and relationships. This makes it easier to work with those
12+
entities in the frontend.
13+
814
## Installation
915

1016
### npm
1117
```bash
12-
npm i tanstack-query-json-api
18+
npm i jsonapi-zod-query
1319
```
1420

1521
### pnpm
1622
```bash
17-
pnpm add tanstack-query-json-api
23+
pnpm add jsonapi-zod-query
1824
```
1925

2026
## Usage
2127

22-
### Responses with data only
28+
At its core you create selectors there are three kinds of selectors you can create, namely resource, nullable resource
29+
and resource collection. All selectors take the same configuration but will yield different parsers.
30+
31+
First you define the primary resource of the document. A resource is defined by its type and optionally an attributes
32+
schema and a relationships definition.
33+
34+
### Simple example
2335

24-
#### 1. Create data schema
36+
In this example we show off how to create a selector for a single resource with only attributes defined.
2537

2638
```typescript
2739
import { z } from "zod";
40+
import { createResourceSelector } from "jsonapi-zod-query";
2841

29-
const worldSchema = z.object({
30-
id: z.string().uuid(),
31-
name: z.string(),
32-
someTransform: z.string().toLowerCase(),
42+
const articleSelector = createResourceSelector({
43+
type: "article",
44+
attributesSchema: z.object({
45+
title: z.string(),
46+
}),
3347
});
34-
export type World = z.output<typeof worldSchema>;
3548
```
3649

37-
This schema is used to both validate the data in the response and doing possible transformations. Since transformations
38-
are done within the selector, this allows caching the transformation result as long as the server responds with the
39-
same JSON body.
40-
41-
#### 2. Creator selector
50+
You can now use the selector in your query functions like this:
4251

4352
```typescript
44-
import { createDataSelector } from "tanstack-query-json-api";
45-
46-
const worldSelector = createDataSelector(worldSchema);
53+
const response = await fetch("https://example.com");
54+
const body = await response.json() as unknown;
55+
const document = articleSelector(body);
4756
```
4857

49-
#### 3. Create query
58+
### Response error handling
59+
60+
Of course, the example above assumes that the response is always successful. In the real world you cannot make that
61+
assumption. For this reason there is a utility function which automatically handles errors for your:
5062

5163
```typescript
52-
import { handleJsonApiError } from "tanstack-query-json-api";
53-
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
54-
55-
export const useWorldQuery = (worldId: string): UseQueryResult<World> => {
56-
return useQuery({
57-
queryKey: ["world", worldId],
58-
queryFn: async ({ signal }) => {
59-
const response = await fetch(`https://my.api/worlds/${worldId}`, { signal });
60-
await handleJsonApiError(response);
61-
return response.json();
62-
},
63-
select: worldSelector,
64-
});
65-
};
64+
import { handleJsonApiError } from "jsonapi-zod-query";
65+
66+
const response = await fetch("https://example.com");
67+
await handleJsonApiError(response);
6668
```
6769

68-
Please note:
70+
If the request is successful (2xx range), the function call is a no-op. Otherwise, it will try to parse the error
71+
response and throw a matching `JsonApiError`.
6972

70-
- `handleJsonApiError` will take care of checking the response for 4xx or 5xx status codes. If the response is not
71-
successful it will throw a `JsonApiError` which contains all error information. This will be available in
72-
`queryResult.error`.
73-
- There is no need to annotate the type of `response.json()` as the selector takes care of validating and inferring
74-
the type.
73+
### Nullable and collection selectors
7574

76-
#### 4. Use the query
75+
If a response can contain a nullable primary resource, you want to use `createNullableResourceSelector()` instead.
76+
If the response is for a resource collection, you must use `createResourceCollectionSelector()`.
7777

78-
You can now use the query hook anywhere you want. The `queryResult.data` property will be of type `World`.
78+
They are configured the exact same way as `createResourceSelector()`.
7979

80-
### Responses with non-paginated collections
80+
### Extracting data
8181

82-
Handling non-paginated collection responses is not much different from handling individual entities. All you have to do
83-
is change the selector to accept arrays:
82+
The resource selectors will always return the entire (flattened) document. In most cases you might only be interested
83+
in the `data` property. To facilitate this you can wrap the selector:
8484

8585
```typescript
86-
const worldsSelector = createDataSelector(z.array(worldSchema));
87-
```
86+
import { createDataSelector, createResourceSelector } from "jsonapi-zod-query";
8887

89-
Then just create a query hook with return type `UseQueryResult<World[]>`.
88+
const articleSelector = createDataSelector(createResourceSelector(/**/));
89+
```
9090

91-
### Responses with paginated collections
91+
### Handling pagination
9292

93-
If a collection uses pagination you have to create a complex selector. Despite its name this is pretty simple:
93+
This library assumes that you never actually use the `links` properties in the JSON:API documents, but are primarily
94+
interested in the pagination functionality for your own queries. For this you need to wrap the collection selector
95+
with another selector:
9496

9597
```typescript
96-
import { requiredPageParams, parsePageParamsFromLink } from "tanstack-query-json-api";
97-
98-
const selectWorldsPaginated = createComplexApiSelector({
99-
dataSchema: z.array(worldSchema),
100-
transformer: (document) => ({
101-
pageParams: {
102-
first: requirePageParams(parsePageParamsFromLink(document.links?.first)),
103-
prev: parsePageParamsFromLink(document.links?.prev),
104-
next: parsePageParamsFromLink(document.links?.next),
105-
last: requirePageParams(parsePageParamsFromLink(document.links?.last)),
106-
},
107-
worlds: document.data,
108-
}),
109-
});
110-
export type PaginatedWorlds = ReturnType<typeof selectWorldsPaginated>;
111-
```
98+
import { createPaginatedCollectionSelector, createResourceSelector } from "jsonapi-zod-query";
11299

113-
Here we extract the world collection into the `world` property and define the page params. Note that we mark `first` and
114-
`last` to be required in this example, while `prev` and `next` are allowed to be null.
100+
const articlesSelector = createPaginatedCollectionSelector(createResourceCollectionSelector(/**/));
101+
```
115102

116-
If you need access to specific metadata and want to validate them, you can additionally supply a `metaSchema` to the
117-
selector creation options. Otherwise `document.meta` will default to `Record<string, unknown> | undefined`.
103+
This will result in an object with a `data` and a `pageParams` property. The `pageParams` object will contain the
104+
parameters defined in the links through the `first`, `prev`, `next` and `last` properties.
118105

119-
Now we can write our query hook for the selector:
106+
You can pass these parameters to your query function. Before performing your fetch, you have to inject the parameters
107+
into the URL again:
120108

121109
```typescript
122-
import { injectPageParams } from "tanstack-query-json-api";
123-
124-
export const useWorldsQuery = (
125-
pageParams?: PageParams,
126-
): UseQueryResult<PaginatedWorlds> => {
127-
return useQuery({
128-
queryKey: ["worlds", { pageParams }],
129-
queryFn: async ({ signal }) => {
130-
const url = new URL("https://my.api/worlds/");
131-
injectPageParams(url, pageParams);
132-
133-
const response = await fetch(apiUrl("/worlds"), { signal });
134-
await handleApiError(response);
135-
return response.json();
136-
},
137-
select: selectWorldsPaginated,
138-
placeholderData: keepPreviousData,
139-
});
140-
};
110+
import { injectPageParams } from "jsonapi-zod-query";
111+
112+
const url = new URL("https://example.com");
113+
injectPageParams(pageParams);
141114
```
142115

143-
Here the function `injectPageParams()` takes care of injecting the page parameters into the URL when defined.
116+
### Relationships
117+
118+
You can define relationships for each resource through the `relationships` object. Each key matches the field name
119+
in the JSON:API body and an object defines how the relationship should be handled.
120+
121+
You must always define a `relationshipType`, which can be either `one`, `one_nullable` or `many`. Additionally, you
122+
must define one of the following two properties:
123+
124+
- `resourceType`
125+
126+
When defining the resource type, the relationship is considered be just an identifier. In this case it will result in
127+
an entity with just an `id` defined.
128+
129+
- `include`
130+
131+
If the response document contains included resource, you can define this to inline the resource into the result. This
132+
parameter has the same configuration as the primary resource.
133+
134+
> **TypeScript limitation**
135+
>
136+
> Due to limitations in TypeScript, the configuration fails to apply type hinting for relationships within
137+
> relationships. To work around this, you can utilize the `satisfies` operator:
138+
>
139+
> ```typescript
140+
> const selector = createResourceSelector({
141+
> type: "article",
142+
> relationships: {
143+
> author: {
144+
> resourceType: "person",
145+
> relationshipType: "one",
146+
> include: {
147+
> type: "person",
148+
> relationships: {
149+
> profile: {
150+
> relationshipType: "one",
151+
> include: {
152+
> type: "profile",
153+
> attributesSchema: z.object({
154+
> emailAddress: z.string(),
155+
> }),
156+
> },
157+
> },
158+
> } satisfies Relationships,
159+
> },
160+
> },
161+
> },
162+
> });
163+
> ```

biome.json

+29-3
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,45 @@
66
"files": {
77
"include": [
88
"biome.json",
9-
"cdk.json",
109
"release.config.cjs",
1110
"commitlint.config.cjs",
12-
"src/**/*"
11+
"src/**/*",
12+
"test/**/*"
1313
]
1414
},
1515
"linter": {
1616
"enabled": true,
1717
"rules": {
1818
"recommended": true,
1919
"nursery": {
20+
"noEmptyTypeParameters": "error",
21+
"noInvalidUseBeforeDeclaration": "error",
22+
"noUnusedImports": "error",
23+
"noUnusedPrivateClassMembers": "error",
24+
"noUselessLoneBlockStatements": "error",
25+
"noUselessTernary": "error",
26+
"useExportType": "error",
2027
"useImportType": "error",
21-
"noUnusedImports": "error"
28+
"useForOf": "error",
29+
"useGroupedTypeImport": "error"
30+
},
31+
"complexity": {
32+
"noExcessiveCognitiveComplexity": "warn",
33+
"useSimplifiedLogicExpression": "error"
34+
},
35+
"correctness": {
36+
"noNewSymbol": "error"
37+
},
38+
"style": {
39+
"useBlockStatements": "error",
40+
"useCollapsedElseIf": "error",
41+
"useShorthandArrayType": "error",
42+
"useShorthandAssign": "error",
43+
"useSingleCaseStatement": "error"
44+
},
45+
"suspicious": {
46+
"noApproximativeNumericConstant": "warn",
47+
"noConsoleLog": "error"
2248
}
2349
}
2450
},

package.json

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
{
2-
"name": "tanstack-query-json-api",
2+
"name": "jsonapi-zod-query",
33
"version": "1.0.1",
4-
"description": "Helpers for TanStack Query to work with JSON:API",
4+
"description": "Utilities to work with JSON:API on the frontend",
55
"type": "module",
66
"author": "Ben Scholzen 'DASPRiD'",
77
"license": "BSD-3-Clause",
88
"keywords": [
9-
"tanstack",
109
"query",
11-
"zod"
10+
"zod",
11+
"tanstack"
1212
],
1313
"repository": {
1414
"type": "git",
15-
"url": "https://github.com/dasprid/tanstack-query-json-api.git"
15+
"url": "https://github.com/dasprid/jsonapi-zod-query.git"
1616
},
1717
"files": [
1818
"dist/**/*"
@@ -29,6 +29,8 @@
2929
"types": "./dist/index.d.ts",
3030
"scripts": {
3131
"build": "tsc && vite build",
32+
"test": "vitest",
33+
"coverage": "vitest run --coverage",
3234
"format": "biome format . --write",
3335
"check": "biome check . --apply"
3436
},
@@ -37,10 +39,12 @@
3739
"@commitlint/cli": "^18.4.4",
3840
"@commitlint/config-conventional": "^18.4.4",
3941
"@tsconfig/vite-react": "^3.0.0",
42+
"@vitest/coverage-v8": "^1.2.2",
4043
"lefthook": "^1.5.6",
4144
"typescript": "^5.3.3",
4245
"vite": "^5.0.12",
4346
"vite-plugin-dts": "^3.7.2",
47+
"vitest": "^1.2.2",
4448
"zod": "^3.22.4"
4549
},
4650
"peerDependencies": {

0 commit comments

Comments
 (0)