|
1 |
| -# TanStack Query helpers for JSON:API |
| 1 | +# JSON:API Query Helpers utilizing Zod |
2 | 2 |
|
3 |
| -[](https://github.com/DASPRiD/mikro-orm-js-joda/actions/workflows/release.yml) |
| 3 | +[](https://github.com/DASPRiD/mikro-orm-js-joda/actions/workflows/release.yml) |
4 | 4 |
|
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 |
6 | 6 | and parse responses, it is assumed that you have [zod](https://www.npmjs.com/package/zod) installed.
|
7 | 7 |
|
| 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 | + |
8 | 14 | ## Installation
|
9 | 15 |
|
10 | 16 | ### npm
|
11 | 17 | ```bash
|
12 |
| -npm i tanstack-query-json-api |
| 18 | +npm i jsonapi-zod-query |
13 | 19 | ```
|
14 | 20 |
|
15 | 21 | ### pnpm
|
16 | 22 | ```bash
|
17 |
| -pnpm add tanstack-query-json-api |
| 23 | +pnpm add jsonapi-zod-query |
18 | 24 | ```
|
19 | 25 |
|
20 | 26 | ## Usage
|
21 | 27 |
|
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 |
23 | 35 |
|
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. |
25 | 37 |
|
26 | 38 | ```typescript
|
27 | 39 | import { z } from "zod";
|
| 40 | +import { createResourceSelector } from "jsonapi-zod-query"; |
28 | 41 |
|
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 | + }), |
33 | 47 | });
|
34 |
| -export type World = z.output<typeof worldSchema>; |
35 | 48 | ```
|
36 | 49 |
|
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: |
42 | 51 |
|
43 | 52 | ```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); |
47 | 56 | ```
|
48 | 57 |
|
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: |
50 | 62 |
|
51 | 63 | ```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); |
66 | 68 | ```
|
67 | 69 |
|
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`. |
69 | 72 |
|
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 |
75 | 74 |
|
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()`. |
77 | 77 |
|
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()`. |
79 | 79 |
|
80 |
| -### Responses with non-paginated collections |
| 80 | +### Extracting data |
81 | 81 |
|
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: |
84 | 84 |
|
85 | 85 | ```typescript
|
86 |
| -const worldsSelector = createDataSelector(z.array(worldSchema)); |
87 |
| -``` |
| 86 | +import { createDataSelector, createResourceSelector } from "jsonapi-zod-query"; |
88 | 87 |
|
89 |
| -Then just create a query hook with return type `UseQueryResult<World[]>`. |
| 88 | +const articleSelector = createDataSelector(createResourceSelector(/* … */)); |
| 89 | +``` |
90 | 90 |
|
91 |
| -### Responses with paginated collections |
| 91 | +### Handling pagination |
92 | 92 |
|
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: |
94 | 96 |
|
95 | 97 | ```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"; |
112 | 99 |
|
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 | +``` |
115 | 102 |
|
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. |
118 | 105 |
|
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: |
120 | 108 |
|
121 | 109 | ```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); |
141 | 114 | ```
|
142 | 115 |
|
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 | +> ``` |
0 commit comments