├── .babelrc ├── .eslintrc ├── .gitignore ├── .size-snapshot.json ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prettier.config.js ├── rollup.config.js ├── src ├── client.js ├── constants.js ├── context.js ├── functions.js ├── hooks.js ├── index.js ├── query.js ├── render.js ├── serializer.js └── tests │ ├── coerceValue.test.js │ ├── getTypeMap.test.js │ ├── parseQueryArg.test.js │ ├── parseSchema.test.js │ ├── schema.js │ └── serializer.test.js └── types └── index.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "modules": false, 7 | "exclude": [ 8 | "@babel/plugin-transform-regenerator" 9 | ] 10 | } 11 | ], 12 | "@babel/react" 13 | ], 14 | "plugins": [ 15 | [ 16 | "module:fast-async", 17 | { 18 | "compiler": { 19 | "noRuntime": true 20 | } 21 | } 22 | ] 23 | ], 24 | "env": { 25 | "test": { 26 | "presets": [ 27 | [ 28 | "@babel/env", 29 | { 30 | "modules": "commonjs", 31 | "exclude": [ 32 | "@babel/plugin-transform-regenerator" 33 | ] 34 | } 35 | ] 36 | ] 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["react-app", "prettier"], 4 | "env": { 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # builds 5 | dist 6 | 7 | # misc 8 | .DS_Store 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | .history 13 | -------------------------------------------------------------------------------- /.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "dist/index.js": { 3 | "bundled": 39194, 4 | "minified": 20347, 5 | "gzipped": 6123 6 | }, 7 | "dist/index.es.js": { 8 | "bundled": 38745, 9 | "minified": 19965, 10 | "gzipped": 6040, 11 | "treeshaked": { 12 | "rollup": { 13 | "code": 75, 14 | "import_statements": 57 15 | }, 16 | "webpack": { 17 | "code": 1135 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | script: 5 | - npm run test 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ari Bouius 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonapi-react 2 | A minimal [JSON:API](https://jsonapi.org/) client and [React](https://reactjs.org/) hooks for fetching, updating, and caching remote data. 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | ## Features 12 | - Declarative API queries and mutations 13 | - JSON:API schema serialization + normalization 14 | - Query caching + garbage collection 15 | - Automatic refetching (stale-while-revalidate) 16 | - SSR support 17 | 18 | ## Purpose 19 | In short, to provide a similar client experience to using `React` + [GraphQL](https://graphql.org/). 20 | 21 | The `JSON:API` specification offers numerous benefits for writing and consuming REST API's, but at the expense of clients being required to manage complex schema serializations. There are [several projects](https://jsonapi.org/implementations/) that provide good `JSON:API` implementations, 22 | but none offer a seamless integration with `React` without incorporating additional libraries and/or model abstractions. 23 | 24 | Libraries like [react-query](https://github.com/tannerlinsley/react-query) and [SWR](https://github.com/zeit/swr) (both of which are fantastic, and obvious inspirations for this project) go a far way in bridging the gap when coupled with a serialization library like [json-api-normalizer](https://github.com/yury-dymov/json-api-normalizer). But both require a non-trivial amount of cache invalidation configuration, given resources can be returned from any number of endpoints. 25 | 26 | 27 | ## Support 28 | - React 16.8 or later 29 | - Browsers [`> 1%, not dead`](https://browserl.ist/?q=%3E+1%25%2C+not+dead) 30 | - Consider polyfilling: 31 | - [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) 32 | - [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 33 | 34 | ## Documentation 35 | - [Installation](#installation) 36 | - [Getting Started](#getting-started) 37 | - [Queries](#queries) 38 | - [Mutations](#mutations) 39 | - [Deleting](#deleting-resources) 40 | - [Caching](#caching) 41 | - [Manual Requests](#manual-requests) 42 | - [Server-Side Rendering](#server-side-rendering) 43 | - [API Reference](#api) 44 | - [useQuery](#useQuery) 45 | - [useMutation](#useMutation) 46 | - [useIsFetching](#useClient) 47 | - [useClient](#useClient) 48 | - [ApiClient](#ApiClient) 49 | - [ApiProvider](#ApiProvider) 50 | - [renderWithData](#renderWithData) 51 | ## Installation 52 | ``` 53 | npm i --save jsonapi-react 54 | ``` 55 | 56 | ## Getting Started 57 | To begin you'll need to create an [ApiClient](#ApiClient) instance and wrap your app with a provider. 58 | ```javascript 59 | import { ApiClient, ApiProvider } from 'jsonapi-react' 60 | import schema from './schema' 61 | 62 | const client = new ApiClient({ 63 | url: 'https://my-api.com', 64 | schema, 65 | }) 66 | 67 | const Root = ( 68 | 69 | 70 | 71 | ) 72 | 73 | ReactDOM.render( 74 | Root, 75 | document.getElementById('root') 76 | ) 77 | ``` 78 | 79 | ### Schema Definition 80 | In order to accurately serialize mutations and track which resource types are associated with each request, the `ApiClient` class requires a schema object that describes your API's resources and their relationships. 81 | 82 | ```javascript 83 | new ApiClient({ 84 | schema: { 85 | todos: { 86 | type: 'todos', 87 | relationships: { 88 | user: { 89 | type: 'users', 90 | } 91 | } 92 | }, 93 | users: { 94 | type: 'users', 95 | relationships: { 96 | todos: { 97 | type: 'todos', 98 | } 99 | } 100 | } 101 | } 102 | }) 103 | ``` 104 | 105 | You can also describe and customize how fields get deserialized. Field configuration is entirely _additive_, so any omitted fields are simply passed through unchanged. 106 | ```javascript 107 | const schema = { 108 | todos: { 109 | type: 'todos', 110 | fields: { 111 | title: 'string', // shorthand 112 | status: { 113 | resolve: status => { 114 | return status.toUpperCase() 115 | }, 116 | }, 117 | created: { 118 | type: 'date', // converts value to a Date object 119 | readOnly: true // removes field for mutations 120 | } 121 | }, 122 | relationships: { 123 | user: { 124 | type: 'users', 125 | } 126 | } 127 | }, 128 | } 129 | ``` 130 | 131 | ## Queries 132 | To make a query, call the [useQuery](#useQuery) hook with the `type` of resource you are fetching. The returned object will contain the query result, as well as information relating to the request. 133 | ```javascript 134 | import { useQuery } from 'jsonapi-react' 135 | 136 | function Todos() { 137 | const { data, meta, error, isLoading, isFetching } = useQuery('todos') 138 | 139 | return ( 140 |
141 | isLoading ? ( 142 |
...loading
143 | ) : ( 144 | data.map(todo => ( 145 |
{todo.title}
146 | )) 147 | ) 148 |
149 | ) 150 | } 151 | ``` 152 | 153 | The argument simply gets converted to an API endpoint string, so the above is equivalent to doing 154 | ```javascript 155 | useQuery('/todos') 156 | ``` 157 | 158 | As syntactic sugar, you can also pass an array of URL segments. 159 | ```javascript 160 | useQuery(['todos', 1]) 161 | useQuery(['todos', 1, 'comments']) 162 | ``` 163 | 164 | To apply refinements such as filtering, pagination, or included resources, pass an object of URL query parameters as the _last_ value of the array. The object gets serialized to a `JSON:API` compatible query string using [qs](https://github.com/ljharb/qs). 165 | ```javascript 166 | useQuery(['todos', { 167 | filter: { 168 | complete: 0, 169 | }, 170 | include: [ 171 | 'comments', 172 | ], 173 | page: { 174 | number: 1, 175 | size: 20, 176 | }, 177 | }]) 178 | ``` 179 | 180 | If a query isn't ready to be requested yet, pass a _falsey_ value to defer execution. 181 | ```javascript 182 | const id = null 183 | const { data: todos } = useQuery(id && ['users', id, 'todos']) 184 | ``` 185 | 186 | ### Normalization 187 | The API response data gets automatically deserialized into a nested resource structure, meaning this... 188 | ```javascript 189 | { 190 | "data": { 191 | "id": "1", 192 | "type": "todos", 193 | "attributes": { 194 | "title": "Clean the kitchen!" 195 | }, 196 | "relationships": { 197 | "user": { 198 | "data": { 199 | "type": "users", 200 | "id": "2" 201 | } 202 | }, 203 | }, 204 | }, 205 | "included": [ 206 | { 207 | "id": 2, 208 | "type": "users", 209 | "attributes": { 210 | "name": "Steve" 211 | } 212 | } 213 | ], 214 | } 215 | ``` 216 | 217 | Gets normalized to... 218 | ```javascript 219 | { 220 | id: "1", 221 | title: "Clean the kitchen!", 222 | user: { 223 | id: "2", 224 | name: "Steve" 225 | } 226 | } 227 | ``` 228 | 229 | ## Mutations 230 | To run a mutation, first call the [useMutation](#useMutation) hook with a query key. The return value is a tuple that includes a `mutate` function, and an object with information related to the request. Then call the `mutate` function to execute the mutation, passing it the data to be submitted. 231 | ```javascript 232 | import { useMutation } from 'jsonapi-react' 233 | 234 | function AddTodo() { 235 | const [title, setTitle] = useState('') 236 | const [addTodo, { isLoading, data, error, errors }] = useMutation('todos') 237 | 238 | const handleSubmit = async e => { 239 | e.preventDefault() 240 | const result = await addTodo({ title }) 241 | } 242 | 243 | return ( 244 |
245 | setTitle(e.target.value)} 249 | /> 250 | 251 |
252 | ) 253 | } 254 | ``` 255 | 256 | ### Serialization 257 | The mutation function expects a [normalized](#normalization) resource object, and automatically handles serializing it. For example, this... 258 | ```javascript 259 | { 260 | id: "1", 261 | title: "Clean the kitchen!", 262 | user: { 263 | id: "1", 264 | name: "Steve", 265 | } 266 | } 267 | ``` 268 | 269 | Gets serialized to... 270 | ```javascript 271 | { 272 | "data": { 273 | "id": "1", 274 | "type": "todos", 275 | "attributes": { 276 | "title": "Clean the kitchen!" 277 | }, 278 | "relationships": { 279 | "user": { 280 | "data": { 281 | "type": "users", 282 | "id": "1" 283 | } 284 | } 285 | } 286 | } 287 | } 288 | ``` 289 | 290 | ## Deleting Resources 291 | `jsonapi-react` doesn't currently provide a hook for deleting resources, because there's typically not much local state management associated with the action. Instead, deleting resources is supported through a [manual request](#manual-requests) on the `client` instance. 292 | 293 | 294 | ## Caching 295 | `jsonapi-react` implements a `stale-while-revalidate` in-memory caching strategy that ensures queries are deduped across the application and only executed when needed. Caching is disabled by default, but can be configured both globally, and/or per query instance. 296 | 297 | ### Configuration 298 | Caching behavior is determined by two configuration values: 299 | - `cacheTime` - The number of seconds the response should be cached from the time it is received. 300 | - `staleTime` - The number of seconds until the response becomes stale. If a cached query that has become stale is requested, the cached response is returned, and the query is refetched in the background. The refetched response is delivered to any active query instances, and re-cached for future requests. 301 | 302 | To assign default caching rules for the whole application, configure the client instance. 303 | ```javascript 304 | const client = new ApiClient({ 305 | cacheTime: 5 * 60, 306 | staleTime: 60, 307 | }) 308 | ``` 309 | 310 | To override the global caching rules, pass a configuration object to `useQuery`. 311 | ```javascript 312 | useQuery('todos', { 313 | cacheTime: 5 * 60, 314 | staleTime: 60, 315 | }) 316 | ``` 317 | 318 | ### Invalidation 319 | When performing mutations, there's a good chance one or more cached queries should get invalidated, and potentially refetched immediately. 320 | 321 | Since the JSON:API schema allows us to determine which resources (including relationships) were updated, the following steps are automatically taken after successful mutations: 322 | 323 | - Any cached results that contain resources with a `type` that matches either the mutated resource, or its included relationships, are invalidated and refetched for active query instances. 324 | - If a query for the mutated resource is cached, and the query URL matches the mutation URL (i.e. the responses can be assumed analogous), the cache is updated with the mutation result and delivered to active instances. If the URL's don't match (e.g. one used refinements), then the cache is invalidated and the query refetched for active instances. 325 | 326 | To override which resource types get invalidated as part of a mutation, the `useMutation` hook accepts a `invalidate` option. 327 | ```JavaScript 328 | const [mutation] = useMutation(['todos', 1], { 329 | invalidate: ['todos', 'comments'] 330 | }) 331 | ``` 332 | 333 | To prevent any invalidation from taking place, pass false to the `invalidate` option. 334 | ```JavaScript 335 | const [mutation] = useMutation(['todos', 1], { 336 | invalidate: false 337 | }) 338 | ``` 339 | 340 | ## Manual Requests 341 | Manual API requests can be performed through the client instance, which can be obtained with the [useClient](#useClient) hook 342 | 343 | ```javascript 344 | import { useClient } from 'jsonapi-react' 345 | 346 | function Todos() { 347 | const client = useClient() 348 | } 349 | ``` 350 | 351 | The client instance is also included in the object returned from the `useQuery` and `useMutation` hooks. 352 | ```javascript 353 | function Todos() { 354 | const { client } = useQuery('todos') 355 | } 356 | 357 | function EditTodo() { 358 | const [mutate, { client }] = useMutation('todos') 359 | } 360 | ``` 361 | The client request methods have a similar signature as the hooks, and return the same response structure. 362 | 363 | ```javascript 364 | # Queries 365 | const { data, error } = await client.fetch(['todos', 1]) 366 | 367 | # Mutations 368 | const { data, error, errors } = await client.mutate(['todos', 1], { title: 'New Title' }) 369 | 370 | # Deletions 371 | const { error } = await client.delete(['todos', 1]) 372 | ``` 373 | 374 | ## Server-Side Rendering 375 | Full SSR support is included out of the box, and requires a small amount of extra configuration on the server. 376 | 377 | ```javascript 378 | import { ApiProvider, ApiClient, renderWithData } from 'jsonapi-react' 379 | 380 | const app = new Express() 381 | 382 | app.use(async (req, res) => { 383 | const client = new ApiClient({ 384 | ssrMode: true, 385 | url: 'https://my-api.com', 386 | schema, 387 | }) 388 | 389 | const Root = ( 390 | 391 | 392 | 393 | ) 394 | 395 | const [content, initialState] = await renderWithData(Root, client) 396 | 397 | const html = 398 | 399 | res.status(200) 400 | res.send(`\n${ReactDOM.renderToStaticMarkup(html)}`) 401 | res.end() 402 | }) 403 | ``` 404 | 405 | The above example assumes that the `Html` component exposes the `initialState` for client rehydration. 406 | ```html 407 | 410 | ``` 411 | 412 | On the client side you'll then need to hydrate the client instance. 413 | ```javascript 414 | const client = new ApiClient({ 415 | url: 'https://my-api.com',, 416 | }) 417 | 418 | client.hydrate( 419 | window.__APP_STATE__ 420 | ) 421 | ``` 422 | 423 | To prevent specific queries from being fetched during SSR, the `useQuery` hook accepts a `ssr` option. 424 | ```javascript 425 | const result = useQuery('todos', { ssr: false }) 426 | ``` 427 | 428 | ## API 429 | 430 | ### `useQuery` 431 | ### Options 432 | - `queryArg: String | [String, Int, Params: Object] | falsey` 433 | - A string, or array of strings/integers. 434 | - Array may contain a query parameter object as the last value. 435 | - If _falsey_, the query will not be executed. 436 | - `config: Object` 437 | - `cacheTime: Int | null`: 438 | - The number of seconds to cache the query. 439 | - Defaults to client configuration value. 440 | - `staleTime: Int | null` 441 | - The number of seconds until the query becomes stale. 442 | - Defaults to client configuration value. 443 | - `ssr: Boolean` 444 | - Set to `false` to disable server-side rendering of query. 445 | - Defaults to context value. 446 | - `client: ApiClient` 447 | - An optional separate client instance. 448 | - Defaults to context provided instance. 449 | ### Result 450 | - `data: Object | Array | undefined` 451 | - The normalized (deserialized) result from a successful request. 452 | - `meta: Object | undefined` 453 | - A `meta` object returned from a successful request, if present. 454 | - `links: Object | undefined` 455 | - A `links` object returned from a successful request, if present. 456 | - `error: Object | undefined` 457 | - A request error, if thrown or returned from the server. 458 | - `refetch: Function` 459 | - A function to manually refetch the query. 460 | - `setData: Function(Object | Array)` 461 | - A function to manually update the local state of the `data` value. 462 | - `client: ApiClient` 463 | - The client instance being used by the hook. 464 | 465 | ### `useMutation` 466 | ### Options 467 | - `queryArg: String | [String, Int, Params: Object]` 468 | - A string, or array of strings/integers. 469 | - Array may contain a query parameter object as the last value. 470 | - `config: Object` 471 | - `invalidate: String | Array` 472 | - One or more resource types to whose cache entries should be invalidated. 473 | - `method: String` 474 | - The request method to use. Defaults to `POST` when creating a resource, and `PATCH` when updating. 475 | - `client: ApiClient` 476 | - An optional separate client instance. 477 | - Defaults to context provided instance. 478 | ### Result 479 | - `mutate: Function(Object | Array)` 480 | - The mutation function you call with resource data to execute the mutation. 481 | - Returns a promise that resolves to the result of the mutation. 482 | - `data: Object | Array | undefined` 483 | - The normalized (deserialized) result from a successful request. 484 | - `meta: Object | undefined` 485 | - A `meta` object returned from a successful request, if present. 486 | - `links: Object | undefined` 487 | - A `links` object returned from a successful request, if present. 488 | - `error: Object | undefined` 489 | - A request error, if thrown or returned from the server. 490 | - `errors: Array | undefined` 491 | - Validation errors returned from the server. 492 | - `isLoading: Boolean` 493 | - Indicates whether the mutation is currently being submitted. 494 | - `client: ApiClient` 495 | - The client instance being used by the hook. 496 | 497 | ### `useIsFetching` 498 | ### Result 499 | - `isFetching: Boolean` 500 | - Returns `true` if any query in the application is fetching. 501 | 502 | ### `useClient` 503 | ### Result 504 | - `client: ApiClient` 505 | - The client instance on the current context. 506 | 507 | ### `ApiClient` 508 | - `url: String` 509 | - The full URL of the remote API. 510 | - `mediaType: String` 511 | - The media type to use in request headers. 512 | - Defaults to `application/vnd.api+json`. 513 | - `cacheTime: Int | Null`: 514 | - The number of seconds to cache the query. 515 | - Defaults to `0`. 516 | - `staleTime: Int | null` 517 | - The number of seconds until the query becomes stale. 518 | - Defaults to `null`. 519 | - `headers: Object` 520 | - Default headers to include on every request. 521 | - `ssrMode: Boolean` 522 | - Set to `true` when running in a server environment. 523 | - Defaults to result of `typeof window === 'undefined'`. 524 | - `formatError: Function(error)` 525 | - A function that formats a response error object. 526 | - `formatErrors: Function(errors)` 527 | - A function that formats a validation error objects. 528 | - `fetch: Function(url, options)` 529 | - Fetch implementation - defaults to the global `fetch`. 530 | - `fetchOptions: Object` 531 | - Default options to use when calling `fetch`. 532 | - See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) for available options. 533 | ### Methods 534 | - `fetch(queryKey: String | [String, Int, Params: Object], [config]: Object)` 535 | - Submits a query request. 536 | - `mutate(queryKey: String | [String, Int, Params: Object], data: Object | Array, [config]: Object)` 537 | - Submits a mutation request. 538 | - `delete(queryKey: String | [String, Int, Params: Object], [config]: Object)` 539 | - Submits a delete request. 540 | - `clearCache()` 541 | - Clears all cached requests. 542 | - `addHeader(key: String, value: String)` 543 | - Adds a default header to all requests. 544 | - `removeHeader(key: String)` 545 | - Removes a default header. 546 | - `isFetching()` 547 | - Returns `true` if a query is being fetched by the client. 548 | - `subscribe(callback: Function)` 549 | - Subscribes an event listener to client requests. 550 | - Returns a unsubscribe function. 551 | - `hydrate(state: Array)` 552 | - Hydrates a client instance with state after SSR. 553 | 554 | ### `ApiProvider` 555 | ### Options 556 | - `client: ApiClient` 557 | - The API client instance that should be used by the application. 558 | 559 | ### `renderWithData` 560 | ### Options 561 | - `element: Object` 562 | - The root React element of the application. 563 | - `client: ApiClient` 564 | - The client instance used during rendering. 565 | ### Result 566 | - `content: String` 567 | - The rendered application string. 568 | - `initialState: Array` 569 | - The extracted client state. 570 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsonapi-react", 3 | "version": "0.0.25", 4 | "description": "A minimal JSON:API client and React hooks for fetching, updating, and caching remote data.", 5 | "author": "aribouius", 6 | "license": "MIT", 7 | "repository": "aribouius/jsonapi-react", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "types": "types/index.d.ts", 11 | "scripts": { 12 | "test": "jest", 13 | "test:watch": "jest .", 14 | "build": "NODE_ENV=production rollup -c", 15 | "start": "rollup -c -w", 16 | "format": "prettier {src,src/**}/*.{md,js} --write", 17 | "publish": "npm publish" 18 | }, 19 | "peerDependencies": { 20 | "react": "^16.8.0 || ^17.0.0", 21 | "react-dom": "^16.8.6 || ^17.0.0" 22 | }, 23 | "dependencies": { 24 | "@types/react": "^16.8.0", 25 | "qs": "^6.9.1" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.7.4", 29 | "@babel/plugin-proposal-class-properties": "^7.4.4", 30 | "@babel/preset-env": "^7.4.5", 31 | "@babel/preset-react": "^7.0.0", 32 | "@svgr/rollup": "^4.3.0", 33 | "babel-core": "7.0.0-bridge.0", 34 | "babel-eslint": "9.x", 35 | "babel-jest": "^24.9.0", 36 | "cross-env": "^5.1.4", 37 | "eslint": "5.x", 38 | "eslint-config-prettier": "^4.3.0", 39 | "eslint-config-react-app": "^4.0.1", 40 | "eslint-config-standard": "^12.0.0", 41 | "eslint-config-standard-react": "^7.0.2", 42 | "eslint-plugin-import": "2.x", 43 | "eslint-plugin-jsx-a11y": "6.x", 44 | "eslint-plugin-node": "^9.1.0", 45 | "eslint-plugin-prettier": "^3.1.0", 46 | "eslint-plugin-promise": "^4.1.1", 47 | "eslint-plugin-react": "7.x", 48 | "eslint-plugin-react-hooks": "1.5.0", 49 | "eslint-plugin-standard": "^4.0.0", 50 | "fast-async": "^6.3.8", 51 | "jest": "^24.9.0", 52 | "prettier": "^1.18.2", 53 | "react": "^16.8.6", 54 | "react-dom": "^16.8.6", 55 | "rollup": "^1.12.4", 56 | "rollup-plugin-babel": "^4.3.2", 57 | "rollup-plugin-commonjs": "^10.0.0", 58 | "rollup-plugin-node-resolve": "^5.0.0", 59 | "rollup-plugin-peer-deps-external": "^2.2.0", 60 | "rollup-plugin-size": "^0.2.1", 61 | "rollup-plugin-size-snapshot": "^0.10.0", 62 | "rollup-plugin-terser": "^5.1.2" 63 | }, 64 | "files": [ 65 | "dist", 66 | "types" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: false, 6 | singleQuote: true, 7 | trailingComma: 'es5', 8 | bracketSpacing: true, 9 | jsxBracketSameLine: false, 10 | arrowParens: 'avoid', 11 | endOfLine: 'auto' 12 | }; 13 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import resolve from 'rollup-plugin-node-resolve' 5 | import { sizeSnapshot } from 'rollup-plugin-size-snapshot' 6 | import size from 'rollup-plugin-size' 7 | import pkg from './package.json' 8 | 9 | export default [ 10 | { 11 | input: 'src/index.js', 12 | output: { 13 | file: pkg.main, 14 | format: 'cjs', 15 | sourcemap: true, 16 | }, 17 | plugins: [ 18 | external({ 19 | includeDependencies: true, 20 | }), 21 | resolve(), 22 | babel(), 23 | commonjs(), 24 | size({ 25 | publish: true, 26 | exclude: pkg.module, 27 | filename: 'sizes-cjs.json', 28 | writeFile: false 29 | }), 30 | sizeSnapshot(), 31 | ], 32 | }, 33 | { 34 | input: 'src/index.js', 35 | output: { 36 | file: pkg.module, 37 | format: 'es', 38 | sourcemap: true, 39 | }, 40 | plugins: [ 41 | external({ 42 | includeDependencies: true, 43 | }), 44 | babel(), 45 | size({ 46 | publish: true, 47 | exclude: pkg.module, 48 | filename: 'sizes-es.json', 49 | writeFile: false 50 | }), 51 | sizeSnapshot(), 52 | ], 53 | }, 54 | ] 55 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | import { Query } from './query' 2 | import { parseSchema, getTypeMap } from './functions' 3 | import { Serializer } from './serializer' 4 | import * as actions from './constants' 5 | 6 | export class ApiClient { 7 | constructor({ schema, plugins, ...config } = {}) { 8 | this.subscribers = [] 9 | this.cache = [] 10 | this.isMounted = false 11 | 12 | this.config = { 13 | url: null, 14 | mediaType: 'application/vnd.api+json', 15 | headers: {}, 16 | cacheTime: 0, 17 | staleTime: null, 18 | ssrMode: typeof window === 'undefined', 19 | formatError: null, 20 | formatErrors: null, 21 | stringify: null, 22 | serialize: (type, data, schema) => { 23 | return new Serializer({ schema }).serialize(type, data) 24 | }, 25 | normalize: (data, schema) => { 26 | return new Serializer({ schema }).deserialize(data) 27 | }, 28 | ...config, 29 | fetch: config.fetch || fetch.bind(), 30 | } 31 | 32 | if (!this.config.url) { 33 | throw new Error('ApiClient requires a "url"') 34 | } 35 | 36 | if (schema) { 37 | this.schema = parseSchema(schema) 38 | } 39 | 40 | if (plugins) { 41 | plugins.forEach(plugin => { 42 | plugin.initialize(this) 43 | }) 44 | } 45 | } 46 | 47 | addHeader(key, value) { 48 | this.config.headers[key] = value 49 | return this 50 | } 51 | 52 | removeHeader(key) { 53 | delete this.config.headers[key] 54 | return this 55 | } 56 | 57 | serialize(type, data) { 58 | return this.config.serialize(type, data, this.schema) 59 | } 60 | 61 | normalize(data, extra) { 62 | const result = this.config.normalize(data, this.schema) 63 | 64 | if (!result) { 65 | return null 66 | } 67 | 68 | if (result.error && this.config.formatError) { 69 | result.error = this.config.formatError(result.error) 70 | } 71 | 72 | if (result.errors && this.config.formatErrors) { 73 | result.errors = this.config.formatErrors(result.errors, extra) 74 | } 75 | 76 | return result 77 | } 78 | 79 | subscribe(subscriber) { 80 | this.subscribers.push(subscriber) 81 | 82 | return () => { 83 | this.subscribers = this.subscribers.filter(s => s !== subscriber) 84 | } 85 | } 86 | 87 | onError(callback) { 88 | return this.subscribe(({ type, ...result }) => { 89 | if (result.error || result.errors) { 90 | callback(result) 91 | } 92 | }) 93 | } 94 | 95 | dispatch(action) { 96 | this.subscribers.forEach(callback => callback(action)) 97 | } 98 | 99 | isFetching() { 100 | return !!this.cache.find(q => q.isFetching) 101 | } 102 | 103 | isCached(query, cacheTime) { 104 | if (!query.cache) { 105 | return false 106 | } 107 | 108 | if (!this.isMounted) { 109 | return true 110 | } 111 | 112 | if (query.cache.error || query.cache.errors) { 113 | return false 114 | } 115 | 116 | if (cacheTime * 1000 + query.timestamp > new Date().getTime()) { 117 | return true 118 | } 119 | 120 | return false 121 | } 122 | 123 | createQuery(options) { 124 | return new Query({ 125 | stringify: this.config.stringify, 126 | ...options, 127 | }) 128 | } 129 | 130 | getQuery(query) { 131 | if (!(query instanceof Query)) { 132 | query = this.createQuery({ key: query }) 133 | } 134 | 135 | if (!query.url) { 136 | return query 137 | } 138 | 139 | const cached = this.cache.find(q => { 140 | return q.url === query.url 141 | }) 142 | 143 | if (cached) { 144 | query = cached 145 | } else { 146 | this.cache.push(query) 147 | } 148 | 149 | return query 150 | } 151 | 152 | fetch(queryArg, config = {}) { 153 | const { 154 | force = false, 155 | cacheTime = this.config.cacheTime, 156 | staleTime = this.config.staleTime, 157 | headers, 158 | hydrate, 159 | } = config 160 | 161 | const query = this.getQuery(queryArg) 162 | 163 | query.cacheTime = Math.max(cacheTime, query.cacheTime) 164 | query.hydrate = query.hydrate || hydrate 165 | 166 | if (query.promise) { 167 | return query.promise 168 | } 169 | 170 | if (!config.force && this.isCached(query, cacheTime)) { 171 | if (staleTime !== null && !this.isCached(query, staleTime)) { 172 | this.fetch(query, { force: true }) 173 | } 174 | 175 | return Promise.resolve(this.normalize(query.cache)) 176 | } 177 | 178 | if (query.timeout) { 179 | clearTimeout(query.timeout) 180 | } 181 | 182 | query.isFetching = true 183 | 184 | this.dispatch({ 185 | type: actions.REQUEST_QUERY, 186 | query, 187 | }) 188 | 189 | query.dispatch({ 190 | isFetching: true, 191 | }) 192 | 193 | const request = this.request(query.url, { headers }) 194 | 195 | query.promise = (async () => { 196 | query.cache = await request 197 | query.promise = null 198 | query.isFetching = false 199 | query.timestamp = new Date().getTime() 200 | 201 | let result = this.normalize(query.cache) 202 | 203 | this.dispatch({ 204 | type: actions.RECEIVE_QUERY, 205 | query, 206 | ...result, 207 | }) 208 | 209 | query.dispatch({ 210 | isFetching: false, 211 | result, 212 | }) 213 | 214 | if (!this.config.ssrMode && !query.subscribers.length) { 215 | this.scheduleGC(query) 216 | } 217 | 218 | return result 219 | })() 220 | 221 | query.promise.abort = request.abort 222 | 223 | return query.promise 224 | } 225 | 226 | async mutate(queryArg, data, config = {}) { 227 | const query = this.getQuery(queryArg) 228 | 229 | const { type, relationships } = getTypeMap(query, this.schema, data) 230 | 231 | const { invalidate, ...options } = config 232 | 233 | if (!options.method) { 234 | options.method = query.id ? 'PATCH' : 'POST' 235 | } 236 | 237 | if (data && data !== null) { 238 | data = this.serialize(type, query.id ? { id: query.id, ...data } : data) 239 | options.body = JSON.stringify(data) 240 | } 241 | 242 | this.dispatch({ 243 | type: actions.REQUEST_MUTATION, 244 | ...data, 245 | }) 246 | 247 | let result = await this.request(query.url, options) 248 | let schema = result 249 | 250 | result = this.normalize(result, { payload: data }) 251 | 252 | this.dispatch({ 253 | type: actions.RECEIVE_MUTATION, 254 | ...result, 255 | }) 256 | 257 | if (!schema.error && !schema.errors) { 258 | let invalid 259 | 260 | if (invalidate) { 261 | invalid = Array.isArray(invalidate) ? invalidate : [invalidate] 262 | } else if (invalidate !== false) { 263 | invalid = [type, ...relationships] 264 | } 265 | 266 | this.cache.forEach(q => { 267 | if (options.method === 'DELETE' && q.id && query.id === q.id) { 268 | q.cache = null 269 | return 270 | } 271 | 272 | if (q.id && q === query && schema.data) { 273 | q.cache = schema 274 | return q.dispatch({ result }) 275 | } 276 | 277 | if (!invalid) { 278 | return 279 | } 280 | 281 | if (!q.type) { 282 | Object.assign(q, getTypeMap(q, this.schema)) 283 | } 284 | 285 | const types = [q.type, ...q.relationships] 286 | 287 | if (types.find(t => invalid.indexOf(t) >= 0)) { 288 | q.cache = null 289 | 290 | if (q.subscribers.length) { 291 | this.fetch(q, { force: true }) 292 | } 293 | } 294 | }) 295 | } 296 | 297 | return result 298 | } 299 | 300 | delete(queryArg, config = {}) { 301 | return this.mutate(queryArg, undefined, { ...config, method: 'DELETE' }) 302 | } 303 | 304 | removeQuery(query) { 305 | this.cache = this.cache.filter(q => q !== query) 306 | } 307 | 308 | scheduleGC(query) { 309 | if (query.timeout) return 310 | 311 | const timestamp = query.timestamp || 0 312 | const cacheTime = query.cacheTime || 0 313 | const expires = timestamp + (cacheTime * 1000) 314 | const timeout = Math.max(0, expires - new Date().getTime()) 315 | 316 | if (timeout) { 317 | query.timeout = setTimeout(() => { 318 | this.removeQuery(query) 319 | }, timeout) 320 | } else { 321 | this.removeQuery(query) 322 | } 323 | } 324 | 325 | clearCache() { 326 | this.cache.forEach(q => { 327 | q.cache = null 328 | }) 329 | } 330 | 331 | request(path, { url, ...config } = {}) { 332 | const uri = (url || this.config.url).replace(/\/$/, '') + path 333 | 334 | let headers = { 335 | Accept: this.config.mediaType, 336 | ...this.config.headers, 337 | } 338 | 339 | if (config.body) { 340 | headers['Content-Type'] = this.config.mediaType 341 | } 342 | 343 | if (config.headers) { 344 | headers = { ...headers, ...config.headers } 345 | } 346 | 347 | for (let header in headers) { 348 | if (headers[header] === undefined || headers[header] === null) { 349 | delete headers[header] 350 | } 351 | } 352 | 353 | const options = { 354 | ...this.config.fetchOptions, 355 | ...config, 356 | headers: { 357 | ...this.config.headers, 358 | ...headers, 359 | }, 360 | } 361 | 362 | let abort 363 | if (typeof AbortController !== 'undefined') { 364 | const controller = new AbortController() 365 | options.signal = controller.signal 366 | abort = () => controller.abort() 367 | } 368 | 369 | const promise = this.config 370 | .fetch(uri, options) 371 | .then(res => { 372 | return res.status === 204 ? {} : res.json() 373 | }) 374 | .catch(error => { 375 | return { 376 | error: { 377 | status: String(error.code || 500), 378 | title: error.message, 379 | name: error.name, 380 | }, 381 | } 382 | }) 383 | 384 | if (abort) { 385 | promise.abort = abort 386 | } 387 | 388 | return promise 389 | } 390 | 391 | extract() { 392 | return this.cache.reduce((acc, q) => { 393 | if (q.cache && q.hydrate !== false) { 394 | acc.push([q.key, q.cache]) 395 | } 396 | return acc 397 | }, []) 398 | } 399 | 400 | hydrate(queries = []) { 401 | const timestamp = new Date().getTime() 402 | queries.forEach(([key, cache]) => { 403 | this.cache.push(this.createQuery({ key, cache, timestamp })) 404 | }) 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const REQUEST_QUERY = 'REQUEST_QUERY' 2 | export const RECEIVE_QUERY = 'RECEIVE_QUERY' 3 | export const REQUEST_MUTATION = 'REQUEST_MUTATION' 4 | export const RECEIVE_MUTATION = 'RECEIVE_MUTATION' 5 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ApiClient } from './client' 3 | 4 | export const ApiContext = React.createContext() 5 | 6 | export function ApiProvider({ children, ...config }) { 7 | const context = React.useContext(ApiContext) 8 | 9 | config = React.useMemo(() => { 10 | const result = { 11 | ...context, 12 | ...config, 13 | } 14 | 15 | if (!result.client) { 16 | throw new Error('ApiProvider requires a "client" prop') 17 | } 18 | 19 | if (!result.client instanceof ApiClient) { 20 | throw new Error('"client" prop must be an ApiClient instance') 21 | } 22 | 23 | if (context) { 24 | result.client.isMounted = context.client.isMounted 25 | } 26 | 27 | return result 28 | }, [context, config.client]) 29 | 30 | React.useEffect(() => { 31 | config.client.isMounted = true 32 | }, []) 33 | 34 | return 35 | } 36 | -------------------------------------------------------------------------------- /src/functions.js: -------------------------------------------------------------------------------- 1 | import { stringify as qs } from 'qs' 2 | 3 | export function isString(v) { 4 | return typeof v === 'string' 5 | } 6 | 7 | export function isObject(v) { 8 | return v && typeof v === 'object' && !Array.isArray(v) 9 | } 10 | 11 | export function isId(n) { 12 | return !!(n && String(n).match(/^[0-9]+/)) 13 | } 14 | 15 | export function isNumber(n) { 16 | return !isNaN(Number(n)) 17 | } 18 | 19 | export function isUUID(v) { 20 | return isString(v) && v.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i) 21 | } 22 | 23 | export function toArray(val) { 24 | return Array.isArray(val) ? val : [val] 25 | } 26 | 27 | export function stringify(params, options) { 28 | return qs(params, { 29 | sort: (a, b) => a.localeCompare(b), 30 | arrayFormat: 'comma', 31 | encodeValuesOnly: true, 32 | ...options, 33 | }) 34 | } 35 | 36 | export function parseSchema(schema = {}) { 37 | if (!isObject(schema)) { 38 | return {} 39 | } 40 | 41 | return Object.keys(schema).reduce((result, type) => { 42 | const obj = schema[type] 43 | 44 | if (!isObject(obj)) { 45 | return result 46 | } 47 | 48 | result[type] = { 49 | type: obj.type || type, 50 | fields: {}, 51 | relationships: {}, 52 | } 53 | 54 | for (let key of ['fields', 'relationships']) { 55 | const map = obj[key] 56 | 57 | if (isObject(map)) { 58 | let item 59 | 60 | for (let name in map) { 61 | item = map[name] 62 | 63 | if (isObject(item)) { 64 | result[type][key][name] = { ...item } 65 | } else { 66 | result[type][key][name] = { type: item } 67 | } 68 | } 69 | } 70 | } 71 | 72 | return result 73 | }, {}) 74 | } 75 | 76 | export function parseQueryArg(arg, options = {}) { 77 | if (!arg) { 78 | return {} 79 | } 80 | 81 | let keys = toArray(arg).reduce((acc, val) => { 82 | return acc.concat(isString(val) ? val.split('/').filter(Boolean) : val) 83 | }, []) 84 | 85 | let id = null 86 | let params 87 | 88 | if (isObject(keys[keys.length - 1])) { 89 | params = keys.pop() 90 | } 91 | 92 | let url = `/${keys.join('/')}` 93 | 94 | if (params) { 95 | url += '?' 96 | if (typeof options.stringify === 'function') { 97 | url += options.stringify(params, stringify) 98 | } else { 99 | url += stringify(params, options.stringify) 100 | } 101 | } else { 102 | params = {} 103 | } 104 | 105 | const last = keys[keys.length - 1] 106 | if (isId(last) || isUUID(last)) { 107 | id = String(keys.pop()) 108 | } 109 | 110 | keys = keys.filter(k => !isId(k) && !isUUID(k)) 111 | 112 | return { 113 | url, 114 | id, 115 | params, 116 | keys, 117 | } 118 | } 119 | 120 | export function parseTypes(keys, schema = {}) { 121 | let arr = [] 122 | let ref 123 | 124 | for (let val of keys) { 125 | if (!ref) { 126 | ref = schema[val] 127 | } else if (ref.relationships[val]) { 128 | ref = ref.relationships[val] 129 | } else { 130 | ref = null 131 | } 132 | 133 | if (ref) { 134 | arr.push(ref.type) 135 | ref = schema[ref.type] 136 | } 137 | } 138 | 139 | return arr.length ? arr : keys.slice(0, 1) 140 | } 141 | 142 | export function getTypeMap(query, schema, data) { 143 | const rels = parseTypes(query.keys, schema) 144 | const type = rels.pop() 145 | 146 | if (query.params.include) { 147 | toArray(query.params.include).forEach(str => { 148 | const arr = str.split(',').filter(Boolean) 149 | 150 | arr.forEach(path => { 151 | const types = [type].concat(path.trim().split('.')) 152 | rels.push(...parseTypes(types, schema).slice(1)) 153 | }) 154 | }) 155 | } 156 | 157 | if (data) { 158 | mergePayloadTypes(type, data, schema, rels) 159 | } 160 | 161 | return { 162 | type, 163 | relationships: rels.filter((r, i) => rels.indexOf(r) === i) 164 | } 165 | } 166 | 167 | export function mergePayloadTypes(type, data, schema, types = []) { 168 | const config = schema[type] 169 | 170 | if (!config || !config.relationships) { 171 | return 172 | } 173 | 174 | Object.keys(config.relationships).forEach(key => { 175 | if (data[key]) { 176 | const rel = config.relationships[key] 177 | types.push(rel.type) 178 | mergePayloadTypes(rel.type, data[key], schema, types) 179 | } 180 | }) 181 | } 182 | 183 | export function coerceValue(val, type) { 184 | switch (type) { 185 | case 'string': 186 | return String(val || (val === 0 ? 0 : '')) 187 | case 'number': 188 | return val ? parseInt(val, 10) : val 189 | case 'float': 190 | return val ? parseFloat(val, 10) : val 191 | case 'date': 192 | return val ? new Date(val) : val 193 | case 'boolean': 194 | return !!val 195 | default: 196 | return val 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/hooks.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ApiContext } from './context' 3 | 4 | export function useApiContext() { 5 | return React.useContext(ApiContext) || {} 6 | } 7 | 8 | export function useClient() { 9 | return useApiContext().client 10 | } 11 | 12 | export function useIsFetching() { 13 | const client = useClient() 14 | 15 | const [isFetching, setIsFetching] = React.useState(() => { 16 | return client.isFetching() 17 | }) 18 | 19 | React.useEffect(() => { 20 | return client.subscribe(() => { 21 | setIsFetching(client.isFetching()) 22 | }) 23 | }, []) 24 | 25 | return isFetching 26 | } 27 | 28 | export function useQuery(queryArg, config) { 29 | const { 30 | client, 31 | ssr = client.ssrMode, 32 | cacheTime = client.config.cacheTime, 33 | staleTime = client.config.staleTime, 34 | onSuccess, 35 | onError, 36 | initialData, 37 | hydrate, 38 | } = { 39 | ...useApiContext(), 40 | ...config, 41 | } 42 | 43 | const query = client.getQuery(queryArg) 44 | 45 | const stateRef = React.useRef() 46 | const rerender = React.useReducer(i => ++i, 0)[1] 47 | 48 | const mountedRef = React.useRef(false) 49 | 50 | const refetch = () => { 51 | if (!query.url) { 52 | return null 53 | } 54 | return client.fetch(query, { 55 | force: true, 56 | cacheTime, 57 | hydrate, 58 | }) 59 | } 60 | 61 | const setData = data => { 62 | data = typeof data === 'function' ? data(stateRef.current.data) : data 63 | stateRef.current.data = data 64 | rerender() 65 | } 66 | 67 | React.useEffect(() => { 68 | mountedRef.current = true 69 | return () => { mountedRef.current = null } 70 | }, []) 71 | 72 | React.useMemo(() => { 73 | if (!query.key) { 74 | stateRef.current = { 75 | data: initialData, 76 | isLoading: false, 77 | isFetching: false, 78 | ...stateRef.current, 79 | } 80 | } else if (client.isCached(query, cacheTime)) { 81 | stateRef.current = { 82 | isLoading: false, 83 | isFetching: staleTime !== null && !client.isCached(query, staleTime), 84 | ...client.normalize(query.cache), 85 | } 86 | } else { 87 | stateRef.current = { 88 | data: initialData, 89 | ...stateRef.current, 90 | isLoading: true, 91 | isFetching: true, 92 | } 93 | } 94 | }, [query.url]) 95 | 96 | React.useEffect(() => { 97 | const cleanup = query.subscribe(req => { 98 | let state 99 | 100 | if (req.result) { 101 | state = { isLoading: false, isFetching: false, ...req.result } 102 | 103 | if (state.data && onSuccess) { 104 | onSuccess(req.result) 105 | } else if (state.error && state.error.name === 'AbortError') { 106 | state = { ...stateRef.current, ...state } 107 | delete state.error 108 | } else if ((state.error || state.errors) && onError) { 109 | onError(req.result) 110 | } 111 | } else if (req.isFetching && !stateRef.current.isFetching) { 112 | state = { ...stateRef.current, isFetching: true } 113 | } 114 | 115 | if (state && mountedRef.current) { 116 | stateRef.current = state 117 | rerender() 118 | } 119 | }) 120 | 121 | if (stateRef.current.isFetching) { 122 | refetch() 123 | } 124 | 125 | return () => { 126 | cleanup() 127 | client.scheduleGC(query) 128 | } 129 | }, [query]) 130 | 131 | if ( 132 | ssr !== false && 133 | client.config.ssrMode && 134 | !query.cache 135 | ) { 136 | refetch() 137 | } 138 | 139 | return { 140 | ...stateRef.current, 141 | refetch, 142 | setData, 143 | client, 144 | } 145 | } 146 | 147 | export function useMutation(queryArg, config = {}) { 148 | const { 149 | client = useClient(), 150 | initialData, 151 | onSuccess, 152 | onError, 153 | ...options 154 | } = config 155 | 156 | const mountedRef = React.useRef(false) 157 | 158 | const query = client.getQuery(queryArg) 159 | 160 | const [state, setState] = React.useState({ 161 | data: initialData, 162 | isLoading: false, 163 | }) 164 | 165 | const setData = data => { 166 | setState(prev => ({ 167 | ...prev, 168 | data: typeof data === 'function' ? data(state.data) : data, 169 | })) 170 | } 171 | 172 | const setErrors = errors => { 173 | setState(prev => ({ 174 | ...prev, 175 | errors: typeof errors === 'function' ? errors(state.errors) : errors, 176 | })) 177 | } 178 | 179 | React.useEffect(() => { 180 | setData(initialData) 181 | }, [query]) 182 | 183 | React.useEffect(() => { 184 | mountedRef.current = true 185 | return () => { mountedRef.current = null } 186 | }, []) 187 | 188 | const mutate = async data => { 189 | if (state.promise) { 190 | return state.promise 191 | } 192 | 193 | const promise = client.mutate(query, data, options) 194 | 195 | setState(prev => ({ 196 | ...prev, 197 | isLoading: true, 198 | promise, 199 | })) 200 | 201 | const result = await promise 202 | 203 | if (mountedRef.current) { 204 | if (result.data) { 205 | if (onSuccess) { 206 | onSuccess(result) 207 | } 208 | setState({ 209 | isLoading: false, 210 | ...result, 211 | }) 212 | } else { 213 | if (onError) { 214 | onError(result) 215 | } 216 | setState(({ promise, error, errors, ...prev }) => ({ 217 | ...prev, 218 | ...result, 219 | isLoading: false, 220 | })) 221 | } 222 | } 223 | 224 | return result 225 | } 226 | 227 | return [mutate, { ...state, setData, setErrors, client }] 228 | } 229 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { ApiProvider } from './context' 2 | export { ApiClient } from './client' 3 | export { useQuery, useMutation, useClient, useIsFetching } from './hooks' 4 | export { renderWithData } from './render' 5 | export { Serializer } from './serializer' 6 | export { 7 | REQUEST_QUERY, 8 | RECEIVE_QUERY, 9 | REQUEST_MUTATION, 10 | RECEIVE_MUTATION 11 | } from './constants' 12 | -------------------------------------------------------------------------------- /src/query.js: -------------------------------------------------------------------------------- 1 | import { parseQueryArg } from './functions' 2 | 3 | export class Query { 4 | constructor({ key, cache, timestamp, stringify } = {}) { 5 | this.key = key 6 | this.cache = cache 7 | this.timestamp = timestamp 8 | this.cacheTime = 0 9 | this.subscribers = [] 10 | this.isFetching = false 11 | Object.assign(this, parseQueryArg(key, { stringify })) 12 | } 13 | 14 | subscribe(subscriber) { 15 | this.subscribers.push(subscriber) 16 | 17 | return () => { 18 | this.subscribers = this.subscribers.filter(s => s !== subscriber) 19 | } 20 | } 21 | 22 | dispatch(action) { 23 | this.subscribers.forEach(callback => { 24 | callback(action) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | import { renderToStaticMarkup } from 'react-dom/server' 2 | 3 | export async function renderWithData(element, client, config) { 4 | const { render } = { 5 | render: renderToStaticMarkup, 6 | ...config, 7 | } 8 | 9 | const content = render(element) 10 | const promises = client.cache.map(q => q.promise).filter(Boolean) 11 | 12 | if (promises.length) { 13 | await Promise.all(promises) 14 | return renderWithData(element, client, config) 15 | } 16 | 17 | return [content, client.extract()] 18 | } 19 | -------------------------------------------------------------------------------- /src/serializer.js: -------------------------------------------------------------------------------- 1 | import { isObject, coerceValue } from './functions' 2 | 3 | export class Serializer { 4 | constructor({ schema } = {}) { 5 | this.schema = schema || {} 6 | } 7 | 8 | serialize(type, attrs) { 9 | if (!attrs) { 10 | return { type, data: null } 11 | } 12 | 13 | if (Array.isArray(attrs)) { 14 | return { data: attrs.map(rec => this.parseResource(type, rec)) } 15 | } 16 | 17 | return { 18 | data: this.parseResource(type, attrs), 19 | } 20 | } 21 | 22 | parseResource(type, attrs = {}) { 23 | if (!attrs) { 24 | return null 25 | } 26 | 27 | attrs = { ...attrs } 28 | 29 | if (attrs._type) { 30 | type = attrs._type 31 | delete attrs._type 32 | } 33 | 34 | const data = { type } 35 | const rels = {} 36 | 37 | if (attrs.id) { 38 | data.id = String(attrs.id) 39 | delete attrs.id 40 | } 41 | 42 | const config = this.schema[type] 43 | if (!config) { 44 | return { ...data, attributes: attrs } 45 | } 46 | 47 | for (let field in config.relationships) { 48 | if (attrs[field] === undefined) { 49 | continue 50 | } 51 | 52 | const ref = config.relationships[field] 53 | const val = attrs[field] 54 | 55 | delete attrs[field] 56 | 57 | const relType = ref.type || (ref.getType ? ref.getType(attrs) : null) 58 | 59 | if (!ref.readOnly) { 60 | if (Array.isArray(val)) { 61 | rels[field] = { 62 | data: val.map(v => 63 | this.parseRelationship(relType, v) 64 | ), 65 | } 66 | } else { 67 | rels[field] = { 68 | data: this.parseRelationship(relType, val), 69 | } 70 | } 71 | } 72 | } 73 | 74 | for (let field in config.fields) { 75 | if (config.fields[field].readOnly) { 76 | delete attrs[field] 77 | } else if (attrs[field] !== undefined && config.fields[field].serialize) { 78 | attrs[field] = config.fields[field].serialize(attrs[field], attrs) 79 | } 80 | } 81 | 82 | data.attributes = attrs 83 | 84 | if (Object.entries(rels).length) { 85 | data.relationships = rels 86 | } 87 | 88 | return data 89 | } 90 | 91 | parseRelationship(type, attrs) { 92 | const res = this.parseResource(type, attrs) 93 | return { type: res.type, id: res.id || null } 94 | } 95 | 96 | deserialize(res) { 97 | if (!res) { 98 | return null 99 | } 100 | 101 | if (res.error) { 102 | if (isObject(res.error)) { 103 | return res 104 | } 105 | return { 106 | error: { 107 | status: String(res.status || 400), 108 | title: res.error, 109 | message: res.error, 110 | }, 111 | } 112 | } 113 | 114 | if (res.errors) { 115 | const error = res.errors.find(e => e.status !== '422') 116 | return error ? { error } : { errors: res.errors } 117 | } 118 | 119 | if (!res.data) { 120 | return res 121 | } 122 | 123 | let { data, included, ...rest } = res 124 | 125 | if (!Array.isArray(data)) { 126 | data = [data] 127 | } 128 | 129 | if (included) { 130 | data = data.concat(included) 131 | } 132 | 133 | const fields = {} 134 | 135 | Object.keys(this.schema).forEach(ref => { 136 | fields[ref] = this.schema[ref].fields 137 | }) 138 | 139 | data = data.map(rec => { 140 | const attrs = { 141 | id: rec.id, 142 | ...rec.attributes, 143 | } 144 | 145 | if (fields[rec.type]) { 146 | let ref 147 | 148 | for (let field in fields[rec.type]) { 149 | ref = fields[rec.type][field] 150 | 151 | if (ref.type) { 152 | attrs[field] = coerceValue(attrs[field], ref.type) 153 | } 154 | 155 | if (typeof ref.resolve === 'function') { 156 | attrs[field] = ref.resolve(attrs[field], attrs, rec) 157 | } 158 | } 159 | } 160 | 161 | return { 162 | ...rec, 163 | attributes: attrs, 164 | } 165 | }) 166 | 167 | data.forEach(rec => { 168 | if (!rec.relationships) { 169 | return 170 | } 171 | 172 | Object.keys(rec.relationships).forEach(key => { 173 | const rel = rec.relationships[key].data 174 | 175 | if (!rel) return 176 | 177 | if (Array.isArray(rel)) { 178 | rec.attributes[key] = rel.map(r => ( 179 | data.find(d => d.type === r.type && d.id === r.id) 180 | )).filter(Boolean).map(r => r.attributes) 181 | } else { 182 | const child = data.find(r => r.type === rel.type && r.id === rel.id) 183 | rec.attributes[key] = child ? child.attributes : null 184 | } 185 | }) 186 | }) 187 | 188 | if (Array.isArray(res.data)) { 189 | data = data.reduce( 190 | (acc, rec) => 191 | res.data.find(r => r.id === rec.id && r.type === rec.type) 192 | ? acc.concat(rec.attributes) 193 | : acc, 194 | [] 195 | ) 196 | } else { 197 | data = data.find(r => r.id === res.data.id).attributes 198 | } 199 | 200 | return { data, ...rest } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/tests/coerceValue.test.js: -------------------------------------------------------------------------------- 1 | import schema from './schema' 2 | import { coerceValue } from '../functions' 3 | 4 | test('it coerces dates', () => {}) 5 | -------------------------------------------------------------------------------- /src/tests/getTypeMap.test.js: -------------------------------------------------------------------------------- 1 | import schema from './schema' 2 | import { getTypeMap } from '../functions' 3 | 4 | test('it parses the primary type', () => { 5 | const result = getTypeMap( 6 | { 7 | keys: ['todos'], 8 | params: {}, 9 | }, 10 | schema 11 | ) 12 | 13 | expect(result).toEqual({ 14 | type: 'todos', 15 | relationships: [], 16 | }) 17 | }) 18 | 19 | test('it parses relationships', () => { 20 | const result = getTypeMap( 21 | { 22 | keys: ['users', 'todos'], 23 | params: {}, 24 | }, 25 | schema 26 | ) 27 | 28 | expect(result).toEqual({ 29 | type: 'todos', 30 | relationships: ['users'], 31 | }) 32 | }) 33 | 34 | test('it ignores unknown segments', () => { 35 | const result = getTypeMap( 36 | { 37 | keys: ['users', 'todos', 'relationships'], 38 | params: {}, 39 | }, 40 | schema 41 | ) 42 | 43 | expect(result).toEqual({ 44 | type: 'todos', 45 | relationships: ['users'], 46 | }) 47 | }) 48 | 49 | test('it parses include string', () => { 50 | const result = getTypeMap( 51 | { 52 | keys: ['todos'], 53 | params: { 54 | include: 'user', 55 | }, 56 | }, 57 | schema 58 | ) 59 | 60 | expect(result).toEqual({ 61 | type: 'todos', 62 | relationships: ['users'], 63 | }) 64 | }) 65 | 66 | test('it parses include array', () => { 67 | const result = getTypeMap( 68 | { 69 | keys: ['todos'], 70 | params: { 71 | include: ['user', 'comments'], 72 | }, 73 | }, 74 | schema 75 | ) 76 | 77 | expect(result).toEqual({ 78 | type: 'todos', 79 | relationships: ['users', 'comments'], 80 | }) 81 | }) 82 | 83 | test('it parses include with dot notation', () => { 84 | const result = getTypeMap( 85 | { 86 | keys: ['todos'], 87 | params: { 88 | include: ['comments.user'], 89 | }, 90 | }, 91 | schema 92 | ) 93 | 94 | expect(result).toEqual({ 95 | type: 'todos', 96 | relationships: ['comments', 'users'], 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/tests/parseQueryArg.test.js: -------------------------------------------------------------------------------- 1 | import schema from './schema' 2 | import { parseQueryArg } from '../functions' 3 | 4 | test('it parses a string', () => { 5 | const result = parseQueryArg('todos', schema) 6 | 7 | expect(result).toEqual({ 8 | url: '/todos', 9 | id: null, 10 | params: {}, 11 | keys: ['todos'], 12 | }) 13 | }) 14 | 15 | test('it parses a string with slashes', () => { 16 | const result = parseQueryArg('/todos/') 17 | 18 | expect(result).toEqual({ 19 | url: '/todos', 20 | id: null, 21 | params: {}, 22 | keys: ['todos'], 23 | }) 24 | }) 25 | 26 | test('it parses a string with an ID', () => { 27 | const result = parseQueryArg('/todos/1') 28 | 29 | expect(result).toEqual({ 30 | url: '/todos/1', 31 | id: '1', 32 | params: {}, 33 | keys: ['todos'], 34 | }) 35 | }) 36 | 37 | test('it parses an array', () => { 38 | const result = parseQueryArg(['todos']) 39 | 40 | expect(result).toEqual({ 41 | url: '/todos', 42 | id: null, 43 | params: {}, 44 | keys: ['todos'], 45 | }) 46 | }) 47 | 48 | test('it parses an array with an ID', () => { 49 | const result = parseQueryArg(['todos', 1]) 50 | 51 | expect(result).toEqual({ 52 | url: '/todos/1', 53 | id: '1', 54 | params: {}, 55 | keys: ['todos'], 56 | }) 57 | }) 58 | 59 | test('it parses an array with an ID', () => { 60 | const uuid = '48004eaf-d51d-4e2e-916d-ccd554245a5e' 61 | const result = parseQueryArg(['todos', uuid]) 62 | 63 | expect(result).toEqual({ 64 | url: `/todos/${uuid}`, 65 | id: uuid, 66 | params: {}, 67 | keys: ['todos'], 68 | }) 69 | }) 70 | 71 | test('it parses an array with multiple segments', () => { 72 | const result = parseQueryArg(['users', 1, 'todos']) 73 | 74 | expect(result).toEqual({ 75 | url: '/users/1/todos', 76 | id: null, 77 | params: {}, 78 | keys: ['users', 'todos'], 79 | }) 80 | }) 81 | 82 | test('it parses an array with page refinements', () => { 83 | const result = parseQueryArg(['todos', { page: { size: 20 } }]) 84 | 85 | expect(result).toEqual({ 86 | url: '/todos?page[size]=20', 87 | id: null, 88 | params: { page: { size: 20 } }, 89 | keys: ['todos'], 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /src/tests/parseSchema.test.js: -------------------------------------------------------------------------------- 1 | import { parseSchema } from '../functions' 2 | 3 | test('it normalizes a schema', () => { 4 | expect( 5 | parseSchema({ 6 | users: { 7 | type: 'users', 8 | relationships: { 9 | todos: 'todos', 10 | }, 11 | }, 12 | todos: { 13 | fields: { 14 | title: 'string', 15 | created: { 16 | type: 'date', 17 | }, 18 | }, 19 | relationships: { 20 | user: { 21 | type: 'users', 22 | }, 23 | }, 24 | }, 25 | }) 26 | ).toEqual({ 27 | users: { 28 | type: 'users', 29 | fields: {}, 30 | relationships: { 31 | todos: { 32 | type: 'todos', 33 | }, 34 | }, 35 | }, 36 | todos: { 37 | type: 'todos', 38 | fields: { 39 | title: { 40 | type: 'string', 41 | }, 42 | created: { 43 | type: 'date', 44 | }, 45 | }, 46 | relationships: { 47 | user: { 48 | type: 'users', 49 | }, 50 | }, 51 | }, 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/tests/schema.js: -------------------------------------------------------------------------------- 1 | export default { 2 | users: { 3 | type: 'users', 4 | fields: {}, 5 | relationships: { 6 | profile: { 7 | type: 'profiles', 8 | }, 9 | todos: { 10 | type: 'todos', 11 | }, 12 | }, 13 | }, 14 | profiles: { 15 | type: 'profiles', 16 | relationships: { 17 | user: { 18 | type: 'users', 19 | }, 20 | }, 21 | }, 22 | todos: { 23 | type: 'todos', 24 | fields: { 25 | status: { 26 | readOnly: true, 27 | resolve: status => (status ? status.toUpperCase() : status), 28 | }, 29 | created: { 30 | type: 'date', 31 | }, 32 | }, 33 | relationships: { 34 | user: { 35 | type: 'users', 36 | }, 37 | comments: { 38 | type: 'comments', 39 | }, 40 | photos: { 41 | type: 'photos', 42 | }, 43 | }, 44 | }, 45 | comments: { 46 | type: 'comments', 47 | relationships: { 48 | todo: { 49 | type: 'todos', 50 | }, 51 | user: { 52 | type: 'users', 53 | }, 54 | }, 55 | }, 56 | photos: { 57 | type: 'photos', 58 | fields: { 59 | owner_type: { 60 | readOnly: true, 61 | }, 62 | url: { 63 | resolve: (_, attrs) => `/photos/${attrs.name}` 64 | }, 65 | }, 66 | relationships: { 67 | owner: { 68 | getType: attrs => { 69 | return attrs.owner_type 70 | } 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/tests/serializer.test.js: -------------------------------------------------------------------------------- 1 | import schema from './schema' 2 | import { Serializer } from '../serializer' 3 | 4 | describe('serialize', () => { 5 | test('it serializes a mutation without a schema', () => { 6 | const serializer = new Serializer() 7 | const data = { id: 1, title: 'Clean the kitchen' } 8 | 9 | const result = serializer.serialize('todos', data) 10 | 11 | expect(result).toEqual({ 12 | data: { 13 | id: '1', 14 | type: 'todos', 15 | attributes: { 16 | title: 'Clean the kitchen', 17 | }, 18 | }, 19 | }) 20 | }) 21 | 22 | test('it serializes a mutation with a schema', () => { 23 | const serializer = new Serializer({ schema }) 24 | const data = { 25 | id: 1, 26 | title: 'Clean the kitchen', 27 | user: { 28 | id: 2, 29 | name: 'Steve', 30 | }, 31 | comments: [ 32 | { id: '1', text: 'Almost done...' } 33 | ], 34 | } 35 | 36 | const result = serializer.serialize('todos', data) 37 | 38 | expect(result).toEqual({ 39 | data: { 40 | id: '1', 41 | type: 'todos', 42 | attributes: { 43 | title: 'Clean the kitchen', 44 | }, 45 | relationships: { 46 | user: { 47 | data: { 48 | type: 'users', 49 | id: '2', 50 | }, 51 | }, 52 | comments: { 53 | data: [ 54 | { type: 'comments', id: '1' } 55 | ], 56 | }, 57 | }, 58 | }, 59 | }) 60 | }) 61 | 62 | test('it serializes polymorphic resources', () => { 63 | const serializer = new Serializer({ schema }) 64 | 65 | const data = { 66 | id: 1, 67 | name: 'todo.jpg', 68 | owner_type: 'todos', 69 | owner: { 70 | id: 1, 71 | } 72 | } 73 | 74 | const result = serializer.serialize('photos', data) 75 | 76 | expect(result).toEqual({ 77 | data: { 78 | id: '1', 79 | type: 'photos', 80 | attributes: { 81 | name: 'todo.jpg', 82 | }, 83 | relationships: { 84 | owner: { 85 | data: { 86 | type: 'todos', 87 | id: '1', 88 | }, 89 | }, 90 | }, 91 | }, 92 | }) 93 | }) 94 | 95 | test('it omits read-only fields', () => { 96 | const serializer = new Serializer({ schema }) 97 | const data = { 98 | id: 1, 99 | title: 'Clean the kitchen', 100 | status: 'done', 101 | } 102 | 103 | const result = serializer.serialize('todos', data) 104 | 105 | expect(result).toEqual({ 106 | data: { 107 | id: '1', 108 | type: 'todos', 109 | attributes: { 110 | title: 'Clean the kitchen', 111 | }, 112 | }, 113 | }) 114 | }) 115 | 116 | test('it supports a field serializer', () => { 117 | const serializer = new Serializer({ 118 | schema: { 119 | ...schema, 120 | todos: { 121 | ...schema.todos, 122 | fields: { 123 | ...schema.todos.fields, 124 | title: { 125 | serialize: (val, attrs) => { 126 | return `${val}${attrs.description}` 127 | } 128 | } 129 | } 130 | } 131 | } 132 | }) 133 | 134 | const data = { 135 | id: 1, 136 | title: 'foo', 137 | description: 'bar', 138 | } 139 | 140 | const result = serializer.serialize('todos', data) 141 | 142 | expect(result).toEqual({ 143 | data: { 144 | id: '1', 145 | type: 'todos', 146 | attributes: { 147 | title: 'foobar', 148 | description: 'bar', 149 | }, 150 | }, 151 | }) 152 | }) 153 | }) 154 | 155 | describe('deserialize', () => { 156 | const success = { 157 | data: { 158 | id: '1', 159 | type: 'todos', 160 | attributes: { 161 | title: 'Clean the kitchen!', 162 | created: '2020-01-01T00:00:00.000Z', 163 | }, 164 | relationships: { 165 | user: { 166 | data: { 167 | type: 'users', 168 | id: '2', 169 | }, 170 | }, 171 | }, 172 | }, 173 | included: [ 174 | { 175 | id: '2', 176 | type: 'users', 177 | attributes: { 178 | name: 'Steve', 179 | }, 180 | }, 181 | ], 182 | } 183 | 184 | test('it normalizes a successful response', () => { 185 | const serializer = new Serializer() 186 | const result = serializer.deserialize(success) 187 | 188 | expect(result).toEqual({ 189 | data: { 190 | id: '1', 191 | title: 'Clean the kitchen!', 192 | created: '2020-01-01T00:00:00.000Z', 193 | user: { 194 | id: '2', 195 | name: 'Steve', 196 | }, 197 | }, 198 | }) 199 | }) 200 | 201 | test('it coerces typed attributes', () => { 202 | const serializer = new Serializer({ 203 | schema: { 204 | todos: { 205 | fields: { 206 | created: { 207 | type: 'date', 208 | }, 209 | }, 210 | }, 211 | } 212 | }) 213 | const result = serializer.deserialize(success) 214 | 215 | const isDate = result.data.created instanceof Date 216 | expect(isDate).toEqual(true) 217 | }) 218 | 219 | test('it handles polymorphic resources', () => { 220 | const serializer = new Serializer({ schema }) 221 | 222 | const result = serializer.deserialize({ 223 | data: { 224 | id: '1', 225 | type: 'photos', 226 | attributes: { 227 | name: 'photo.jpg', 228 | }, 229 | relationships: { 230 | owner: { 231 | data: { 232 | type: 'todos', 233 | id: '1', 234 | }, 235 | }, 236 | }, 237 | }, 238 | included: [ 239 | { 240 | id: '1', 241 | type: 'todos', 242 | attributes: { 243 | title: 'Clean the kitchen!', 244 | status: 'done', 245 | }, 246 | }, 247 | ], 248 | }) 249 | 250 | expect(result).toEqual({ 251 | data: { 252 | id: '1', 253 | name: 'photo.jpg', 254 | url: '/photos/photo.jpg', 255 | owner: { 256 | id: '1', 257 | title: 'Clean the kitchen!', 258 | status: 'DONE', 259 | }, 260 | }, 261 | }) 262 | }) 263 | }) 264 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jsonapi-react' { 2 | interface IPlugin { 3 | initialize(client: ApiClient): void 4 | } 5 | 6 | type Falsey = false | undefined | null 7 | 8 | type QueryArg = Falsey | string | [ 9 | type: string, 10 | queryParams?: TQueryParams, 11 | ...queryKeys: any[], 12 | ] 13 | 14 | type StringMap = { [key: string]: any } 15 | 16 | interface IResult { 17 | data?: TData 18 | meta?: StringMap 19 | links?: StringMap 20 | error?: StringMap 21 | errors?: StringMap[] 22 | refetch?: () => void 23 | isLoading?: boolean 24 | isFetching?: boolean 25 | client: ApiClient 26 | } 27 | 28 | interface IConfig { 29 | url?: string 30 | mediaType?: string 31 | cacheTime?: number 32 | staleTime?: number 33 | headers?: {} 34 | ssrMode?: boolean 35 | formatError?: (error) => any 36 | formatErrors?: (errors) => any 37 | fetch?: (url: string, options: {}) => Promise<{}> 38 | stringify?: (q: TQueryParams) => string 39 | fetchOptions?: {} 40 | } 41 | 42 | export class ApiClient { 43 | constructor({ 44 | ...args 45 | }: { 46 | schema?: {} 47 | plugins?: IPlugin[] 48 | } & IConfig) 49 | 50 | addHeader(key: string, value: string): ApiClient 51 | 52 | clearCache(): void 53 | 54 | delete(queryArg: QueryArg, config?: IConfig): Promise 55 | 56 | fetch(queryArg: QueryArg, config?: IConfig): Promise 57 | 58 | isFetching(): boolean 59 | 60 | mutate( 61 | queryArg: QueryArg, 62 | data: {} | [], 63 | config?: IConfig 64 | ): Promise 65 | 66 | removeHeader(key: string): ApiClient 67 | } 68 | 69 | export function ApiProvider({ 70 | children, 71 | client, 72 | }: { 73 | children: React.ReactNode 74 | client: ApiClient 75 | }): JSX.Element 76 | 77 | export const ApiContext: React.Context 78 | 79 | export function renderWithData( 80 | element: JSX.Element, 81 | client: ApiClient, 82 | config?: {} 83 | ): [content: string, initialState: any] 84 | 85 | export function useClient(): ApiClient 86 | 87 | export function useIsFetching(): { isFetching: boolean } 88 | 89 | export function useMutation( 90 | queryArg: QueryArg, 91 | config?: { 92 | invalidate?: boolean | string | string[] 93 | method?: string 94 | client?: ApiClient 95 | } 96 | ): [mutate: (any) => Promise>, result: IResult] 97 | 98 | export function useQuery( 99 | queryArg: QueryArg, 100 | config?: { 101 | cacheTime?: number 102 | staleTime?: number 103 | ssr?: boolean 104 | client?: ApiClient 105 | initialData?: TData 106 | } 107 | ): IResult 108 | } 109 | --------------------------------------------------------------------------------