fetchja 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,45 +1,333 @@
1
1
  # Fetchja
2
2
 
3
- Welcome to **Fetchja**, the ultimate JavaScript library designed to make your JSON:API interactions seamless and intuitive. Inspired by the renowned [Kitsu](https://github.com/wopian/kitsu) library, Fetchja leverages the native Fetch API, ensuring a lightweight and efficient experience.
3
+ **A tiny, modern, zero-dependency JSON:API client built on the native Fetch API.**
4
4
 
5
- ## Why Fetchja?
5
+ Fetchja helps you talk to a [JSON:API](https://jsonapi.org) server. It uses the browser's own `fetch`, so it stays small and fast. You write plain objects, and Fetchja turns them into JSON:API requests. The server answers, and Fetchja turns the answer back into plain objects — with relationships already filled in. It was inspired by [Kitsu](https://github.com/wopian/kitsu), but it has **no dependencies**.
6
+
7
+ ```js
8
+ import Fetchja from 'fetchja'
9
+
10
+ const api = new Fetchja({
11
+ baseURL: 'https://api.example.com'
12
+ })
13
+
14
+ const { data } = await api.get('articles')
15
+ ```
16
+
17
+ ## Contents
6
18
 
7
- - ⚡️ **Lightweight and Fast**: Built on the native Fetch API, Fetchja ensures minimal overhead and maximum performance.
8
- - 🎨 **Intuitive Design**: Easy-to-understand methods and configurations make Fetchja accessible for developers of all levels.
9
- - 💪 **Flexible and Customizable**: Tailor Fetchja to your needs with customizable headers, query parameters, and resource cases.
19
+ - [Why Fetchja?](#why-fetchja)
20
+ - [Install](#install)
21
+ - [Quick start](#quick-start)
22
+ - [Options](#options)
23
+ - [Methods](#methods)
24
+ - [Relationships and included data](#relationships-and-included-data)
25
+ - [Query parameters](#query-parameters)
26
+ - [Make your own query formatter](#make-your-own-query-formatter)
27
+ - [Custom fetch](#custom-fetch)
28
+ - [Errors](#errors)
29
+ - [Retry on error](#retry-on-error)
30
+ - [Use with TanStack Query](#use-with-tanstack-query)
31
+ - [TypeScript](#typescript)
32
+ - [Plurals](#plurals)
33
+
34
+ ## Why Fetchja?
10
35
 
11
- ## Installation
36
+ - ⚡️ **No dependencies.** It only uses the built-in `fetch`. Nothing extra to download. About 5 KB when minified.
37
+ - 🧩 **Typed.** It is written in TypeScript and ships its own types. You get autocomplete out of the box.
38
+ - 🔄 **Less boilerplate.** You send and read plain objects. Fetchja does the JSON:API parts for you.
39
+ - 🪶 **Modern and small.** ESM only, `async`/`await`, no base class to extend.
40
+ - 🛡️ **Safe.** It guards against prototype pollution when it reads a response.
41
+ - 🎛️ **Flexible.** Set your own headers, query format, name case, plurals, `fetch`, and error handling.
12
42
 
13
- To get started with Fetchja, simply install it via `npm` along with the `pluralize` library:
43
+ ## Install
14
44
 
15
45
  ```bash
16
- $ npm install fetchja pluralize
46
+ npm install fetchja
17
47
  ```
18
48
 
19
- ## Getting Started
49
+ You don't need any other package. Fetchja needs a place where `fetch` exists: Node 18+, Deno, Bun, or any modern browser.
20
50
 
21
- Here's a quick example to get you up and running with Fetchja:
51
+ ## Quick start
22
52
 
23
- ```javascript
53
+ ```js
24
54
  import Fetchja from 'fetchja'
25
55
 
26
56
  const api = new Fetchja({
27
57
  baseURL: 'https://api.example.com'
28
- });
58
+ })
59
+
60
+ // GET /articles
61
+ const { data, meta } = await api.get('articles')
62
+
63
+ // GET /articles/1
64
+ const { data: article } = await api.get('articles/1')
65
+
66
+ console.log(article)
67
+ // { type: 'articles', id: '1', title: 'Hello world', ... }
68
+ ```
69
+
70
+ ## Options
71
+
72
+ You set everything when you create the client:
73
+
74
+ ```js
75
+ const api = new Fetchja({ /* options */ })
76
+ ```
77
+
78
+ | Option | Type | Default | What it does |
79
+ | --- | --- | --- | --- |
80
+ | `baseURL` | `string` | — | The start of every URL. You need it (or pass one per request). |
81
+ | `headers` | `Record<string, string>` | JSON:API headers | Headers added to every request. The JSON:API `Accept` and `Content-Type` are set for you. |
82
+ | `fetch` | `typeof fetch` | global `fetch` | Your own fetch function (for example, one that adds a token). |
83
+ | `queryFormatter` | `(params) => string \| URLSearchParams` | built-in | Turns the `params` object into a query string. |
84
+ | `resourceCase` | `'camel' \| 'kebab' \| 'snake' \| 'none'` | `'none'` | How the resource name in the URL is written. |
85
+ | `typeCase` | `'camel' \| 'kebab' \| 'snake' \| 'none'` | `'camel'` | How `type` names are written when you send data. |
86
+ | `pluralize` | `boolean \| ((word) => string)` | `true` (built-in) | Make resource names plural. Use `false` to turn it off, or pass your own function. |
87
+ | `onResponseError` | `(response) => Response \| void` | — | Runs when a request fails, before Fetchja throws. You can retry here. |
88
+
89
+ ## Methods
90
+
91
+ | Method | Alias | How to call it | HTTP |
92
+ | --- | --- | --- | --- |
93
+ | `get` | `fetch` | `get(model, options?)` | `GET` |
94
+ | `post` | `create` | `post(model, body, options?)` | `POST` |
95
+ | `patch` | `update` | `patch(model, body, options?)` | `PATCH` |
96
+ | `delete` | `remove` | `delete(model, id, options?)` | `DELETE` |
97
+
98
+ Pick the name you like. `api.get` and `api.fetch` do the same thing. So do `create`/`post`, `update`/`patch`, and `remove`/`delete`.
99
+
100
+ ```js
101
+ // Read a list
102
+ const { data } = await api.get('articles')
103
+
104
+ // Read one
105
+ const { data } = await api.get('articles/1')
106
+
107
+ // Create
108
+ await api.create('article', { title: 'Hello world' })
109
+
110
+ // Update (put the id in the body)
111
+ await api.update('article', { id: '1', title: 'New title' })
112
+
113
+ // Delete
114
+ await api.remove('article', '1') // DELETE /articles/1 (no body)
115
+ ```
116
+
117
+ Every call gives you back the data plus a few extra fields:
118
+
119
+ ```js
120
+ const response = await api.get('articles/1')
121
+
122
+ response.data // your data (an object, or an array for a list)
123
+ response.meta // the JSON:API `meta`, if the server sent it
124
+ response.status // 200
125
+ response.statusText // 'OK'
126
+ response.headers // the response headers, as a plain object
127
+ ```
128
+
129
+ ## Relationships and included data
130
+
131
+ To send a relationship, put an object (or a list of objects) with a `type` and an `id` inside your data. Fetchja moves it to the right place and adds the full resource to `included` for you:
132
+
133
+ ```js
134
+ await api.create('article', {
135
+ title: 'Hello world',
136
+ author: { type: 'people', id: '9' },
137
+ tags: [
138
+ { type: 'tags', id: '1' },
139
+ { type: 'tags', id: '2' }
140
+ ]
141
+ })
142
+ ```
143
+
144
+ When you read data back, Fetchja takes the resources from `included` and puts them right inside your data. So you can read a relationship like a normal nested object:
145
+
146
+ ```js
147
+ const { data } = await api.get('articles/1', {
148
+ params: { include: 'author' }
149
+ })
150
+
151
+ console.log(data.author.name) // comes from `included`
152
+ ```
153
+
154
+ ## Query parameters
155
+
156
+ Pass a `params` object. Fetchja turns nested objects and arrays into JSON:API-style query strings:
157
+
158
+ ```js
159
+ const { data, meta } = await api.get('articles', {
160
+ params: {
161
+ include: ['author', 'comments'],
162
+ fields: { articles: 'title,body' },
163
+ filter: { published: true },
164
+ sort: '-createdAt',
165
+ page: { number: 1, size: 10 }
166
+ }
167
+ })
168
+ ```
169
+
170
+ This becomes:
171
+
172
+ ```
173
+ /articles?include[]=author&include[]=comments&fields[articles]=title,body&filter[published]=true&sort=-createdAt&page[number]=1&page[size]=10
174
+ ```
175
+
176
+ ## Make your own query formatter
177
+
178
+ Some servers want a different format. For example, they may want `include=author,comments` (one key, values joined by commas) instead of `include[]=...`. You can pass your own function:
179
+
180
+ ```js
181
+ import Fetchja from 'fetchja'
182
+
183
+ function commaQueryFormatter (params) {
184
+ const search = new URLSearchParams()
185
+
186
+ for (const key in params) {
187
+ const value = params[key]
188
+
189
+ search.append(
190
+ key,
191
+ Array.isArray(value) ? value.join(',') : String(value)
192
+ )
193
+ }
194
+
195
+ return search
196
+ }
197
+
198
+ const api = new Fetchja({
199
+ baseURL: 'https://api.example.com',
200
+ queryFormatter: commaQueryFormatter
201
+ })
202
+
203
+ await api.get('articles', { params: { include: ['author', 'comments'] } })
204
+ // -> /articles?include=author,comments
205
+ ```
206
+
207
+ Your function takes the `params` object and returns a string or a `URLSearchParams`.
208
+
209
+ ## Custom fetch
210
+
211
+ You can swap in your own fetch function. This is handy for tokens, timeouts, or logging:
212
+
213
+ ```js
214
+ const api = new Fetchja({
215
+ baseURL: 'https://api.example.com',
216
+ fetch: (url, init) => myCustomFetch(url, init)
217
+ })
218
+ ```
219
+
220
+ ## Errors
221
+
222
+ When a request fails, Fetchja throws a `FetchjaError`. It holds the HTTP status and the JSON:API `errors` list:
223
+
224
+ ```js
225
+ import Fetchja, { FetchjaError } from 'fetchja'
29
226
 
30
227
  try {
31
- const response = await api.get('/posts')
32
- console.log(response)
228
+ await api.get('articles/999')
33
229
  } catch (error) {
34
- console.error(error)
230
+ if (error instanceof FetchjaError) {
231
+ console.log(error.status) // 404
232
+ console.log(error.statusText) // 'Not Found'
233
+ console.log(error.errors) // [{ status: '404', detail: 'Not found' }]
234
+ console.log(error.response) // the raw Response
235
+ }
236
+ }
237
+ ```
238
+
239
+ ## Retry on error
240
+
241
+ `onResponseError` runs when the server sends a failing response, before Fetchja throws. The response also gets a `replayRequest()` method. It sends the same request again. This is great for refreshing a token and trying once more:
242
+
243
+ ```js
244
+ const api = new Fetchja({
245
+ baseURL: 'https://api.example.com',
246
+ headers: { Authorization: `Bearer ${getToken()}` },
247
+ onResponseError: async (response) => {
248
+ if (response.status === 401) {
249
+ api.headers.Authorization = `Bearer ${await refreshToken()}`
250
+
251
+ return response.replayRequest() // try again with the new token
252
+ }
253
+ }
254
+ })
255
+ ```
256
+
257
+ Return a `Response` (like the one from `replayRequest()`) to keep going. Return nothing, and Fetchja throws the `FetchjaError`.
258
+
259
+ ## Use with TanStack Query
260
+
261
+ Fetchja works well with [TanStack Query](https://tanstack.com/query). Because Fetchja throws on a failing request, TanStack Query sees the error and handles it for you.
262
+
263
+ Reading data:
264
+
265
+ ```js
266
+ import { useQuery } from '@tanstack/react-query'
267
+ import Fetchja from 'fetchja'
268
+
269
+ const api = new Fetchja({ baseURL: 'https://api.example.com' })
270
+
271
+ function useArticles () {
272
+ return useQuery({
273
+ queryKey: ['articles'],
274
+ queryFn: () => api.get('articles')
275
+ })
276
+ }
277
+ ```
278
+
279
+ Creating data:
280
+
281
+ ```js
282
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
283
+
284
+ function useCreateArticle () {
285
+ const queryClient = useQueryClient()
286
+
287
+ return useMutation({
288
+ mutationFn: (article) => api.create('article', article),
289
+ onSuccess: () => {
290
+ queryClient.invalidateQueries({ queryKey: ['articles'] })
291
+ }
292
+ })
293
+ }
294
+ ```
295
+
296
+ ## TypeScript
297
+
298
+ Fetchja is written in TypeScript and brings its own types. You don't need an extra `@types` package.
299
+
300
+ ```ts
301
+ import Fetchja, { type FetchjaOptions, FetchjaError } from 'fetchja'
302
+
303
+ const options: FetchjaOptions = {
304
+ baseURL: 'https://api.example.com',
305
+ resourceCase: 'kebab'
35
306
  }
307
+
308
+ const api = new Fetchja(options)
309
+ ```
310
+
311
+ ## Plurals
312
+
313
+ By default, Fetchja makes resource names plural with a small built-in helper. It knows the common English rules (and `status` becomes `statuses`, not `statu`). It is also safe to run twice: `articles` stays `articles`.
314
+
315
+ For tricky words (like `person` → `people`), pass a bigger library such as [`pluralize`](https://www.npmjs.com/package/pluralize):
316
+
317
+ ```js
318
+ import pluralize from 'pluralize'
319
+
320
+ const api = new Fetchja({
321
+ baseURL: 'https://api.example.com',
322
+ pluralize
323
+ })
36
324
  ```
37
325
 
38
- ### Using a custom fetch function.
326
+ Or turn it off and use the exact names you write:
39
327
 
40
- ```javascript
328
+ ```js
41
329
  const api = new Fetchja({
42
330
  baseURL: 'https://api.example.com',
43
- fetchFunction: (url, options) => myCustomFetch(url.href, options)
331
+ pluralize: false
44
332
  })
45
333
  ```
package/src/index.js DELETED
@@ -1,243 +0,0 @@
1
- import pluralize from 'pluralize'
2
-
3
- import { deserialize } from './utils/deserialize.js'
4
- import { serialize } from './utils/serialize.js'
5
-
6
- import { errorParser } from './utils/error-parser.js'
7
- import { queryFormatter } from './utils/query-formatter.js'
8
- import { splitModel } from './utils/split-model.js'
9
-
10
- import { camelCase } from './utils/camel-case.js'
11
- import { kebabCase } from './utils/kebab-case.js'
12
- import { snakeCase } from './utils/snake-case.js'
13
-
14
- const jsonType = 'application/vnd.api+json'
15
-
16
- /**
17
- * Options for Fetchja.
18
- *
19
- * @typedef {Object} FetchjaOptions
20
- * @property {string} baseURL The base URL for all requests.
21
- * @property {Function} fetchFunction A custom fetch function to use in request.
22
- * @property {Object} headers The headers to include in all requests.
23
- * @property {Function} queryFormatter A function to format query parameters.
24
- * @property {string} resourceCase The case to use for resource names.
25
- * @property {boolean} pluralize Pluralize resource names.
26
- */
27
-
28
- /**
29
- * Fetchja is a simple wrapper around the Fetch API.
30
- *
31
- * @class Fetchja
32
- * @param {FetchjaOptions} [options] Options for Fetchja.
33
- */
34
- export default class Fetchja {
35
- constructor (options = {
36
- headers: {}
37
- }) {
38
- this.baseURL = options.baseURL
39
-
40
- // Headers
41
- this.headers = {
42
- Accept: jsonType,
43
- 'Content-Type': jsonType,
44
- ...options.headers
45
- }
46
-
47
- // Fetch Function
48
- this.fetchFunction = options.fetchFunction
49
-
50
- // Query
51
- this.queryFormatter = typeof options.queryFormatter === 'function'
52
- ? options.queryFormatter
53
- : object => queryFormatter(object)
54
-
55
- // Camel Case Types
56
- this.camelCaseTypes = options.camelCaseTypes === false
57
- ? string => string
58
- : camelCase
59
-
60
- // Resource Case
61
- const cases = {
62
- camel: camelCase,
63
- kebab: kebabCase,
64
- snake: snakeCase,
65
-
66
- default: string => string
67
- }
68
-
69
- this.resourceCase = cases[options.resourceCase] || cases.default
70
-
71
- // Pluralise
72
- this.pluralize = options.pluralize === false
73
- ? string => string
74
- : pluralize
75
-
76
- // Interceptors
77
- this.onResponseError = error => error
78
-
79
- // Alias
80
- this.fetch = this.get
81
- this.update = this.patch
82
- this.create = this.post
83
- this.remove = this.delete
84
- }
85
-
86
- #splitModel (model) {
87
- return splitModel(model, {
88
- resourceCase: this.resourceCase,
89
- pluralize: this.pluralize
90
- })
91
- }
92
-
93
- async request (options = {
94
- method: 'GET',
95
- headers: {}
96
- }) {
97
- const baseURL = this.baseURL || options.baseURL
98
-
99
- const url = new URL(
100
- options.url.startsWith('/') ? options.url.slice(1) : options.url,
101
- baseURL.endsWith('/') ? baseURL : baseURL + '/'
102
- )
103
-
104
- // Params
105
- if (options.params) {
106
- url.search = this.queryFormatter(options.params)
107
- }
108
-
109
- // Body
110
- if (options.body) {
111
- options.body = serialize(options.type, options.body, {
112
- camelCaseTypes: this.camelCaseTypes,
113
- pluralTypes: this.pluralize
114
- })
115
- }
116
-
117
- // Request
118
- const makeRequest = () => {
119
- // Headers
120
- const headers = new Headers({
121
- ...this.headers,
122
- ...options.headers
123
- })
124
-
125
- // Fetch
126
- const fetchOptions = {
127
- method: options.method,
128
- body: options.body,
129
- headers
130
- }
131
-
132
- if (typeof this.fetchFunction === 'function') {
133
- return this.fetchFunction(url, fetchOptions)
134
- }
135
-
136
- return fetch(url, fetchOptions)
137
- }
138
-
139
- try {
140
- let response = await makeRequest()
141
-
142
- if (!response.ok) {
143
- response.replayRequest = makeRequest
144
-
145
- const replayedResponse = await this.onResponseError(response)
146
-
147
- if (replayedResponse instanceof Response) {
148
- response = replayedResponse
149
- }
150
-
151
- if (!response.ok) {
152
- throw new Error(response.statusText)
153
- }
154
- }
155
-
156
- // Response Headers
157
- const responseHeaders = {}
158
-
159
- for (const [key, value] of response.headers.entries()) {
160
- responseHeaders[key] = value
161
- }
162
-
163
- const contentType = responseHeaders['content-type']
164
-
165
- // Response Data
166
- const data = contentType && contentType.includes(jsonType)
167
- ? await response.json()
168
- : {}
169
-
170
- // Return
171
- return {
172
- ...(data.errors ? data : deserialize(data)),
173
-
174
- status: response.status,
175
- statusText: response.statusText,
176
- headers: responseHeaders
177
- }
178
- } catch (error) {
179
- throw error
180
- }
181
- }
182
-
183
- get (model, options = { method: 'GET' }) {
184
- try {
185
- options.url = model.split('/')
186
- .map(part => this.resourceCase(part))
187
- .filter(Boolean)
188
- .join('/')
189
-
190
- return this.request(options)
191
- } catch (error) {
192
- throw errorParser(error)
193
- }
194
- }
195
-
196
- patch (model, body, options = { method: 'PATCH' }) {
197
- try {
198
- const [type, url] = this.#splitModel(model)
199
-
200
- return this.request({
201
- url: body?.id ? `${url}/${body.id}` : url,
202
- body,
203
- type,
204
-
205
- ...options
206
- })
207
- } catch (error) {
208
- throw errorParser(error)
209
- }
210
- }
211
-
212
- post (model, body, options = { method: 'POST' }) {
213
- try {
214
- const [type, url] = this.#splitModel(model)
215
-
216
- return this.request({
217
- url,
218
- body,
219
- type,
220
-
221
- ...options
222
- })
223
- } catch (error) {
224
- throw errorParser(error)
225
- }
226
- }
227
-
228
- delete (model, id, options = { method: 'DELETE' }) {
229
- try {
230
- const [type, url] = this.#splitModel(model)
231
-
232
- return this.request({
233
- url: `${url}/${id}`,
234
- body: { id },
235
- type,
236
-
237
- ...options
238
- })
239
- } catch (error) {
240
- throw errorParser(error)
241
- }
242
- }
243
- }
@@ -1,12 +0,0 @@
1
- /**
2
- * Convert a string from snake_case and kebab-case to camelCase.
3
- *
4
- * @param {string} input The string to convert.
5
- * @returns {string} The converted string.
6
- */
7
- export function camelCase (input) {
8
- return input
9
- .toLowerCase()
10
- .replace(/[-_](.)/g, (_, char) => char.toUpperCase())
11
- .replace(/^(.)/, (char) => char.toLowerCase())
12
- }
@@ -1,28 +0,0 @@
1
- /**
2
- * Deattribute JSON:API data.
3
- *
4
- * @param {Object|Object[]} data The JSON:API data to deattribute.
5
- * @returns {Object} The deattributed data.
6
- */
7
- export function deattribute (data) {
8
- if (Array.isArray(data)) {
9
- return data.map(deattribute)
10
- }
11
-
12
- const output = {
13
- type: data.type,
14
- id: data.id
15
- }
16
-
17
- for (const key in data.attributes) {
18
- output[key] = data.attributes[key]
19
- }
20
-
21
- for (const key in data.relationships) {
22
- if (data.relationships[key].data) {
23
- output[key] = data.relationships[key].data
24
- }
25
- }
26
-
27
- return output
28
- }