fetchja 1.3.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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The options accepted by the {@link Fetchja} constructor.
3
+ */
4
+ interface FetchjaOptions {
5
+ /** The base URL prepended to every request. */
6
+ baseURL?: string;
7
+ /** Headers merged into every request. */
8
+ headers?: Record<string, string>;
9
+ /** A custom fetch implementation. Defaults to the global `fetch`. */
10
+ fetch?: typeof fetch;
11
+ /** A custom serializer for the request query parameters. */
12
+ queryFormatter?: (params: unknown) => string | URLSearchParams;
13
+ /** How resource names in the URL path are cased. */
14
+ resourceCase?: 'camel' | 'kebab' | 'snake' | 'none';
15
+ /** How `type` names are cased when serializing request bodies. */
16
+ typeCase?: 'camel' | 'kebab' | 'snake' | 'none';
17
+ /**
18
+ * Pluralize resource names. `true` uses the built-in pluralizer,
19
+ * `false` disables it, and a function injects a custom one.
20
+ */
21
+ pluralize?: boolean | ((word: string) => string);
22
+ /**
23
+ * Interceptor invoked on a non-OK response, before Fetchja throws.
24
+ * The response is augmented with `replayRequest` so the original
25
+ * request can be retried. Return a `Response` to continue with it.
26
+ */
27
+ onResponseError?: (response: Response & {
28
+ replayRequest: () => Promise<Response>;
29
+ }) => Response | void | Promise<Response | void>;
30
+ }
31
+ /**
32
+ * The options accepted by {@link Fetchja.request} and the request
33
+ * methods. Most callers only set `params`, `headers`, or `method`.
34
+ */
35
+ interface RequestOptions {
36
+ /** The request path, relative to the base URL. */
37
+ url?: string;
38
+ /** A base URL for this request only. */
39
+ baseURL?: string;
40
+ /** The HTTP method. */
41
+ method?: string;
42
+ /** Headers for this request only. */
43
+ headers?: Record<string, string>;
44
+ /** The query parameters to serialize. */
45
+ params?: unknown;
46
+ /** The request body to serialize. */
47
+ body?: Record<string, unknown>;
48
+ /** The resource type used to serialize the body. */
49
+ type?: string;
50
+ }
51
+
52
+ /**
53
+ * A super simple, lightweight JSON:API client built on the Fetch API.
54
+ */
55
+ declare class Fetchja {
56
+ #private;
57
+ /** The base URL prepended to every request. */
58
+ baseURL?: string;
59
+ /** Headers merged into every request. */
60
+ headers: Record<string, string>;
61
+ /** Serializes the request query parameters. */
62
+ queryFormatter: (params: unknown) => string | URLSearchParams;
63
+ /** Cases `type` names when serializing request bodies. */
64
+ typeCase: (value: string) => string;
65
+ /** Cases resource names in the URL path. */
66
+ resourceCase: (value: string) => string;
67
+ /** Pluralizes resource names. */
68
+ pluralize: (value: string) => string;
69
+ /** Interceptor invoked on a non-OK response, before throwing. */
70
+ onResponseError: FetchjaOptions['onResponseError'];
71
+ /** Alias of {@link Fetchja.get}. */
72
+ fetch: Fetchja['get'];
73
+ /** Alias of {@link Fetchja.post}. */
74
+ create: Fetchja['post'];
75
+ /** Alias of {@link Fetchja.patch}. */
76
+ update: Fetchja['patch'];
77
+ /** Alias of {@link Fetchja.delete}. */
78
+ remove: Fetchja['delete'];
79
+ /**
80
+ * @param options - The client options.
81
+ */
82
+ constructor(options?: FetchjaOptions);
83
+ /**
84
+ * Perform a request and return the deserialized response. Throws a
85
+ * {@link FetchjaError} on a non-OK response.
86
+ *
87
+ * @param options - The request options.
88
+ * @returns The deserialized response.
89
+ */
90
+ request(options: RequestOptions): Promise<Record<string, unknown>>;
91
+ /**
92
+ * Read one or more resources.
93
+ *
94
+ * @param model - The resource path, e.g. `articles` or `articles/1`.
95
+ * @param options - Extra request options.
96
+ * @returns The deserialized response.
97
+ */
98
+ get(model: string, options?: RequestOptions): Promise<Record<string, unknown>>;
99
+ /**
100
+ * Create a resource.
101
+ *
102
+ * @param model - The resource name, e.g. `article`.
103
+ * @param body - The resource to create.
104
+ * @param options - Extra request options.
105
+ * @returns The deserialized response.
106
+ */
107
+ post(model: string, body: Record<string, unknown>, options?: RequestOptions): Promise<Record<string, unknown>>;
108
+ /**
109
+ * Update a resource. When the body has an `id`, it is appended to the
110
+ * URL.
111
+ *
112
+ * @param model - The resource name, e.g. `article`.
113
+ * @param body - The fields to update.
114
+ * @param options - Extra request options.
115
+ * @returns The deserialized response.
116
+ */
117
+ patch(model: string, body: Record<string, unknown>, options?: RequestOptions): Promise<Record<string, unknown>>;
118
+ /**
119
+ * Delete a resource. No request body is sent.
120
+ *
121
+ * @param model - The resource name, e.g. `article`.
122
+ * @param id - The id of the resource to delete.
123
+ * @param options - Extra request options.
124
+ * @returns The deserialized response.
125
+ */
126
+ delete(model: string, id: string, options?: RequestOptions): Promise<Record<string, unknown>>;
127
+ }
128
+
129
+ /**
130
+ * A single error object as defined by the JSON:API specification.
131
+ *
132
+ * @see https://jsonapi.org/format/#error-objects
133
+ */
134
+ interface JsonApiError {
135
+ /** The HTTP status code, as a string. */
136
+ status?: string;
137
+ /** An application-specific error code. */
138
+ code?: string;
139
+ /** A short, human-readable summary of the problem. */
140
+ title?: string;
141
+ /** A human-readable explanation of this occurrence of the problem. */
142
+ detail?: string;
143
+ /** References to the source of the error. */
144
+ source?: Record<string, unknown>;
145
+ [key: string]: unknown;
146
+ }
147
+ /**
148
+ * The extra fields used to build a {@link FetchjaError}.
149
+ */
150
+ interface FetchjaErrorInit {
151
+ /** The HTTP status code of the failed response. */
152
+ status?: number;
153
+ /** The HTTP status text of the failed response. */
154
+ statusText?: string;
155
+ /** The JSON:API error objects returned by the server. */
156
+ errors?: JsonApiError[];
157
+ /** The raw failed response. */
158
+ response?: Response;
159
+ }
160
+ /**
161
+ * The error thrown when a request fails. It carries the HTTP status and
162
+ * any JSON:API error objects returned by the server.
163
+ */
164
+ declare class FetchjaError extends Error {
165
+ /** The HTTP status code of the failed response. */
166
+ status?: number;
167
+ /** The HTTP status text of the failed response. */
168
+ statusText?: string;
169
+ /** The JSON:API error objects returned by the server. */
170
+ errors?: JsonApiError[];
171
+ /** The raw failed response. */
172
+ response?: Response;
173
+ /**
174
+ * @param message - The error message.
175
+ * @param init - The extra fields to attach to the error.
176
+ */
177
+ constructor(message: string, init?: FetchjaErrorInit);
178
+ }
179
+
180
+ /**
181
+ * Convert a string to `camelCase` from `snake_case`, `kebab-case`, or
182
+ * `SCREAMING_SNAKE_CASE`.
183
+ *
184
+ * @param input - The string to convert.
185
+ * @returns The converted string.
186
+ */
187
+ declare function camelCase(input: string): string;
188
+ /**
189
+ * Convert a string to `kebab-case` from `camelCase` or `snake_case`.
190
+ *
191
+ * @param input - The string to convert.
192
+ * @returns The converted string.
193
+ */
194
+ declare function kebabCase(input: string): string;
195
+ /**
196
+ * Convert a string to `snake_case` from `camelCase` or `kebab-case`.
197
+ *
198
+ * @param input - The string to convert.
199
+ * @returns The converted string.
200
+ */
201
+ declare function snakeCase(input: string): string;
202
+
203
+ /**
204
+ * Pluralize an English word using a small set of common rules.
205
+ *
206
+ * This is a lightweight, dependency-free helper that covers the cases
207
+ * most JSON:API resource names need. It is intentionally simple and
208
+ * idempotent: a word that already looks plural is returned unchanged.
209
+ * For irregular words (such as `person` becoming `people`), inject a
210
+ * fuller implementation through the `pluralize` option.
211
+ *
212
+ * @param word - The word to pluralize.
213
+ * @returns The pluralized word.
214
+ */
215
+ declare function pluralize(word: string): string;
216
+
217
+ export { FetchjaError, type FetchjaErrorInit, type FetchjaOptions, type JsonApiError, type RequestOptions, camelCase, Fetchja as default, kebabCase, pluralize, snakeCase };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import x from"pluralize";function p(t){if(Array.isArray(t))return t.map(p);let e={id:t.id,type:t.type};for(let r in t.attributes)e[r]=t.attributes[r];for(let r in t.relationships)t.relationships[r].data&&(e[r]=t.relationships[r].data);return e}function g(t){let e={};for(let r of t)e[r.type]||(e[r.type]={}),e[r.type][r.id]=p(r);return e}function f(t){return typeof t=="object"&&t!==null}function d(t){let e={};if(t.data&&(e.data=p(t.data)),t.meta&&(e.meta=t.meta),t.included){let r=g(t.included),a=s=>s.type in r?r[s.type][s.id]:s,i=s=>Array.isArray(s)?s.map(a):a(s);for(let s in r)for(let o in r[s])for(let u in r[s][o]){let c=r[s][o][u];f(c)&&(r[s][o][u]=i(c))}if(Array.isArray(e.data))for(let s of e.data)for(let o in s)f(s[o])&&(s[o]=i(s[o]));else if(f(e.data))for(let s in e.data)f(e.data[s])&&(e.data[s]=i(e.data[s]))}return e}function n(t){if(t.response){let{data:e}=t.response;e?.errors&&(t.errors=e.errors)}throw t}function q(t,e,r){return typeof t=="object"&&t!==null?(r.relationships||(r.relationships={}),r.relationships[e]={data:t.id?{id:t.id,type:t.type||e}:t,links:t.links,meta:t.meta}):(r.attributes||(r.attributes={}),r.attributes[e]=t),r}function C(t,e,r={camelCaseTypes:a=>a,pluralTypes:a=>a}){try{if(e===null||Array.isArray(e)&&!e.length)return{data:e};let a={type:r.pluralTypes(r.camelCaseTypes(t))};e.id&&(a.id=String(e.id));for(let i in e)["id","type"].includes(i)||q(e[i],i,a);return JSON.stringify({data:a})}catch(a){n(a)}}function b(t,e={},r=""){let a=Array.isArray(e);for(let i in e){let s=e[i],o=r?`${r}[${a?"":i}]`:i;s instanceof Object?b(t,s,o):t.append(o,s)}}function T(t={}){let e=new URLSearchParams;return b(e,t),e}function w(t,e={resourceCase:r=>r,pluralize:r=>r}){let r=t.split("/"),a=r.pop(),i=r.join("/");return[a,`${i}/${e.pluralize(e.resourceCase(a))}`]}function h(t){return t.toLowerCase().replace(/[-_](.)/g,(e,r)=>r.toUpperCase()).replace(/^(.)/,e=>e.toLowerCase())}function k(t){return t.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/_/g,"-").toLowerCase()}function z(t){return t.replace(/([a-z])([A-Z])/g,"$1_$2").replace(/-/g,"_").toLowerCase()}var y="application/vnd.api+json",m=class{constructor(e={headers:{}}){this.baseURL=e.baseURL,this.headers={Accept:y,"Content-Type":y,...e.headers},this.queryFormatter=typeof e.queryFormatter=="function"?e.queryFormatter:a=>T(a),this.camelCaseTypes=e.camelCaseTypes===!1?a=>a:h;let r={camel:h,kebab:k,snake:z,default:a=>a};this.resourceCase=r[e.resourceCase]||r.default,this.pluralize=e.pluralize===!1?a=>a:x,this.onResponseError=a=>a,this.fetch=this.get,this.update=this.patch,this.create=this.post,this.remove=this.delete}#e(e){return w(e,{resourceCase:this.resourceCase,pluralize:this.pluralize})}async request(e={method:"GET",headers:{}}){let r=this.baseURL||e.baseURL,a=new URL(e.url.startsWith("/")?e.url.slice(1):e.url,r.endsWith("/")?r:r+"/");e.params&&(a.search=this.queryFormatter(e.params)),e.body&&(e.body=C(e.type,e.body,{camelCaseTypes:this.camelCaseTypes,pluralTypes:this.pluralize}));let i=()=>{let s=new Headers({...this.headers,...e.headers});return fetch(a,{method:e.method,body:e.body,headers:s})};try{let s=await i();if(s.ok){if(!s.ok)throw new Error(s.statusText)}else{s.replayRequest=i;let l=await this.onResponseError(s);l instanceof Response&&(s=l)}let o={};for(let[l,A]of s.headers.entries())o[l]=A;let u=o["content-type"],c=u&&u.includes(y)?await s.json():{};return{...c.errors?c:d(c),status:s.status,statusText:s.statusText,headers:o}}catch(s){throw s}}get(e,r={method:"GET"}){try{return r.url=e.split("/").map(a=>this.resourceCase(a)).filter(Boolean).join("/"),this.request(r)}catch(a){throw n(a)}}patch(e,r,a={method:"PATCH"}){try{let[i,s]=this.#e(e);return this.request({url:r?.id?`${s}/${r.id}`:s,body:r,type:i,...a})}catch(i){throw n(i)}}post(e,r,a={method:"POST"}){try{let[i,s]=this.#e(e);return this.request({url:s,body:r,type:i,...a})}catch(i){throw n(i)}}delete(e,r,a={method:"DELETE"}){try{let[i,s]=this.#e(e);return this.request({url:`${s}/${r}`,body:{id:r},type:i,...a})}catch(i){throw n(i)}}};export{m as default};
2
- //# sourceMappingURL=index.js.map
1
+ var q=new Set(["__proto__","constructor","prototype"]);function w(e){if(Array.isArray(e))return e.map(r=>w(r));let t={type:e.type,id:e.id};for(let r in e.attributes)q.has(r)||(t[r]=e.attributes[r]);for(let r in e.relationships){if(q.has(r))continue;let n=e.relationships[r];n&&"data"in n&&(t[r]=n.data)}return t}function E(e){return typeof e=="object"&&e!==null}function z(e){let t={};if(e.data&&(t.data=w(e.data)),e.meta&&(t.meta=e.meta),!Array.isArray(e.included))return t;let r=new Map;for(let o of e.included){let d=w(o);r.set(`${o.type}:${o.id}`,d)}function n(o){return E(o)?r.get(`${o.type}:${o.id}`)??o:o}function s(o){return Array.isArray(o)?o.map(n):n(o)}function i(o){for(let d in o)E(o[d])&&(o[d]=s(o[d]))}for(let o of r.values())i(o);let{data:u}=t;return Array.isArray(u)?u.forEach(i):E(u)&&i(u),t}var h=class extends Error{status;statusText;errors;response;constructor(t,r={}){super(t),this.name="FetchjaError",this.status=r.status,this.statusText=r.statusText,this.errors=r.errors,this.response=r.response}};var I=new Set(["__proto__","constructor","prototype"]);function T(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)&&!(e instanceof Date)}function _(e,t,r){let n=[],s=new Set;function i(a){return r.pluralTypes(r.caseType(a))}function u(a,p){return a.type??i(p)}function o(a,p){return{type:u(a,p),id:String(a.id)}}function d(a,p){if(!T(a))return;if(a.id==null)throw new h("All included resources must have an ID.");let g=`${a.type??p}:${a.id}`;s.has(g)||(s.add(g),n.push(m(a,p)))}function m(a,p){let g={type:u(a,p)},c={},y={};for(let l in a){if(I.has(l)||l==="type")continue;if(l==="id"){g.id=String(a.id);continue}let f=a[l];if(Array.isArray(f)){c[l]={data:f.map(k=>(d(k,l),o(k,l)))};continue}if(T(f)){c[l]={data:o(f,l)},d(f,l);continue}y[l]=f}return Object.keys(y).length>0&&(g.attributes=y),Object.keys(c).length>0&&(g.relationships=c),g}let R=m(t,e);return JSON.stringify({data:R,included:n})}var M=new Set(["__proto__","constructor","prototype"]);function D(e){return typeof e=="object"&&e!==null&&!(e instanceof Date)}function F(e,t,r=""){let n=Array.isArray(t);for(let s in t){if(M.has(s))continue;let i=t[s],u=r?`${r}[${n?"":s}]`:s;D(i)?F(e,i,u):e.append(u,String(i))}}function P(e={}){let t=new URLSearchParams;return F(t,e),t}function J(e){return/^\d+$/.test(e)}function U(e,t){let r=e.split("/").filter(Boolean),n=r.reduce((s,i,u)=>J(i)?s:u,-1);return r.map((s,i)=>i===n?t.pluralize(t.resourceCase(s)):s).join("/")}function b(e,t){let r=e.split("/").filter(Boolean),n=r.pop()??"",s=r.join("/"),i=t.pluralize(t.resourceCase(n));return[n,s?`${s}/${i}`:i]}function x(e){return e.toLowerCase().replace(/[-_](.)/g,(t,r)=>r.toUpperCase()).replace(/^(.)/,t=>t.toLowerCase())}function j(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/_/g,"-").toLowerCase()}function A(e){return e.replace(/([a-z])([A-Z])/g,"$1_$2").replace(/-/g,"_").toLowerCase()}function C(e){return e===""?e:/us$/i.test(e)?`${e}es`:/s$/i.test(e)?e:/[^aeiou]y$/i.test(e)?e.replace(/y$/i,"ies"):/(x|z|ch|sh)$/i.test(e)?`${e}es`:`${e}s`}var S="application/vnd.api+json";function $(e){return e}var L=Object.assign(Object.create(null),{camel:x,kebab:j,snake:A,none:$});function H(e){return P(e)}function K(e){return e===!1?$:typeof e=="function"?e:C}function N(e){let t={};for(let[r,n]of e.headers.entries())t[r]=n;return t}var O=class{baseURL;headers;queryFormatter;typeCase;resourceCase;pluralize;onResponseError;#t;fetch;create;update;remove;constructor(t={}){this.baseURL=t.baseURL,this.headers={Accept:S,"Content-Type":S,...t.headers},this.#t=t.fetch,this.queryFormatter=typeof t.queryFormatter=="function"?t.queryFormatter:H,this.resourceCase=L[t.resourceCase??"none"]??$,this.typeCase=L[t.typeCase??"camel"]??x,this.pluralize=K(t.pluralize),this.onResponseError=t.onResponseError,this.fetch=this.get,this.create=this.post,this.update=this.patch,this.remove=this.delete}get#e(){return{resourceCase:this.resourceCase,pluralize:this.pluralize}}async request(t){let r=this.baseURL??t.baseURL;if(r===void 0||r==="")throw new h("A `baseURL` is required to make requests.");let n=t.url??"",s=n.startsWith("/")?n.slice(1):n,i=r.endsWith("/")?r:`${r}/`,u=new URL(s,i);t.params!==void 0&&(u.search=String(this.queryFormatter(t.params)));let o=t.body!==void 0&&t.type!==void 0?_(t.type,t.body,{caseType:this.typeCase,pluralTypes:this.pluralize}):void 0,d=this.#t??fetch,m=this.headers;function R(){let k=new Headers({...m,...t.headers}),v={method:t.method,headers:k,body:o};return d(u,v)}let a=await R(),p=Object.assign(a,{replayRequest:R}),g=p.ok||this.onResponseError===void 0?p:await this.onResponseError(p),c=g instanceof Response?g:p,y=N(c),f=(y["content-type"]??"").includes(S)?await c.json():{};if(!c.ok)throw new h(c.statusText||"Request failed",{status:c.status,statusText:c.statusText,errors:f.errors,response:c});return{...z(f),status:c.status,statusText:c.statusText,headers:y}}get(t,r={}){return this.request({...r,method:r.method??"GET",url:U(t,this.#e)})}post(t,r,n={}){let[s,i]=b(t,this.#e);return this.request({...n,method:n.method??"POST",url:i,body:r,type:s})}patch(t,r,n={}){let[s,i]=b(t,this.#e),u=r.id!==void 0?`${i}/${String(r.id)}`:i;return this.request({...n,method:n.method??"PATCH",url:u,body:r,type:s})}delete(t,r,n={}){let[,s]=b(t,this.#e);return this.request({...n,method:n.method??"DELETE",url:`${s}/${r}`})}};export{h as FetchjaError,x as camelCase,O as default,j as kebabCase,C as pluralize,A as snakeCase};
2
+ //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,7 +1 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/index.js", "../src/utils/deattribute.js", "../src/utils/deserialize.js", "../src/utils/error-parser.js", "../src/utils/serialize.js", "../src/utils/query-formatter.js", "../src/utils/split-model.js", "../src/utils/camel-case.js", "../src/utils/kebab-case.js", "../src/utils/snake-case.js"],
4
- "sourcesContent": ["import pluralize from 'pluralize'\n\nimport { deserialize } from './utils/deserialize.js'\nimport { serialize } from './utils/serialize.js'\n\nimport { errorParser } from './utils/error-parser.js'\nimport { queryFormatter } from './utils/query-formatter.js'\nimport { splitModel } from './utils/split-model.js'\n\nimport { camelCase } from './utils/camel-case.js'\nimport { kebabCase } from './utils/kebab-case.js'\nimport { snakeCase } from './utils/snake-case.js'\n\nconst jsonType = 'application/vnd.api+json'\n\n/**\n * Options for Fetchja.\n * \n * @typedef {Object} FetchjaOptions\n * @property {string} baseURL The base URL for all requests.\n * @property {Object} headers The headers to include in all requests.\n * @property {Function} queryFormatter A function to format query parameters.\n * @property {string} resourceCase The case to use for resource names.\n * @property {boolean} pluralize Pluralize resource names.\n */\n\n/**\n * Fetchja is a simple wrapper around the Fetch API.\n * \n * @class Fetchja\n * @param {FetchjaOptions} [options] Options for Fetchja.\n */\nexport default class Fetchja {\n constructor (options = {\n headers: {}\n }) {\n this.baseURL = options.baseURL\n\n // Headers\n this.headers = {\n Accept: jsonType,\n 'Content-Type': jsonType,\n ...options.headers\n }\n\n // Query\n this.queryFormatter = typeof options.queryFormatter === 'function'\n ? options.queryFormatter\n : object => queryFormatter(object)\n\n // Camel Case Types\n this.camelCaseTypes = options.camelCaseTypes === false\n ? string => string\n : camelCase\n\n // Resource Case\n const cases = {\n camel: camelCase,\n kebab: kebabCase,\n snake: snakeCase,\n\n default: string => string\n }\n\n this.resourceCase = cases[options.resourceCase] || cases.default\n \n // Pluralise\n this.pluralize = options.pluralize === false\n ? string => string\n : pluralize\n \n // Interceptors\n this.onResponseError = error => error\n\n // Alias\n this.fetch = this.get\n this.update = this.patch\n this.create = this.post\n this.remove = this.delete\n }\n\n #splitModel (model) {\n return splitModel(model, {\n resourceCase: this.resourceCase,\n pluralize: this.pluralize\n })\n }\n\n async request (options = {\n method: 'GET',\n headers: {}\n }) {\n const baseURL = this.baseURL || options.baseURL\n\n const url = new URL(\n options.url.startsWith('/') ? options.url.slice(1) : options.url,\n baseURL.endsWith('/') ? baseURL : baseURL + '/'\n )\n\n // Params\n if (options.params) {\n url.search = this.queryFormatter(options.params)\n }\n\n // Body\n if (options.body) {\n options.body = serialize(options.type, options.body, {\n camelCaseTypes: this.camelCaseTypes,\n pluralTypes: this.pluralize\n })\n }\n\n // Request\n const makeRequest = () => {\n // Headers\n const headers = new Headers({\n ...this.headers,\n ...options.headers\n })\n\n // Fetch\n return fetch(url, {\n method: options.method,\n body: options.body,\n headers\n })\n }\n\n try {\n let response = await makeRequest()\n\n if (!response.ok) {\n response.replayRequest = makeRequest\n const replayedResponse = await this.onResponseError(response)\n\n if (replayedResponse instanceof Response) {\n response = replayedResponse\n }\n } else if (!response.ok) {\n throw new Error(response.statusText)\n }\n\n // Response Headers\n const responseHeaders = {}\n\n for (const [key, value] of response.headers.entries()) {\n responseHeaders[key] = value\n }\n\n const contentType = responseHeaders['content-type']\n\n // Response Data\n const data = contentType && contentType.includes(jsonType)\n ? await response.json()\n : {}\n\n // Return\n return {\n ...(data.errors ? data : deserialize(data)),\n\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders\n }\n } catch (error) {\n throw error\n }\n }\n\n get (model, options = { method: 'GET' }) {\n try {\n options.url = model.split('/')\n .map(part => this.resourceCase(part))\n .filter(Boolean)\n .join('/')\n\n return this.request(options)\n } catch (error) {\n throw errorParser(error)\n }\n }\n\n patch (model, body, options = { method: 'PATCH' }) {\n try {\n const [type, url] = this.#splitModel(model)\n\n return this.request({\n url: body?.id ? `${url}/${body.id}` : url,\n body,\n type,\n\n ...options\n })\n } catch (error) {\n throw errorParser(error)\n }\n }\n\n post (model, body, options = { method: 'POST' }) {\n try {\n const [type, url] = this.#splitModel(model)\n\n return this.request({\n url,\n body,\n type,\n\n ...options\n })\n } catch (error) {\n throw errorParser(error)\n }\n }\n\n delete (model, id, options = { method: 'DELETE' }) {\n try {\n const [type, url] = this.#splitModel(model)\n\n return this.request({\n url: `${url}/${id}`,\n body: { id },\n type,\n\n ...options\n })\n } catch (error) {\n throw errorParser(error)\n }\n }\n}\n", "/**\n * Deattribute JSON:API data.\n *\n * @param {Object|Object[]} data The JSON:API data to deattribute.\n * @returns {Object} The deattributed data.\n */\nexport function deattribute (data) {\n if (Array.isArray(data)) {\n return data.map(deattribute)\n }\n\n const output = {\n id: data.id,\n type: data.type\n }\n\n for (const key in data.attributes) {\n output[key] = data.attributes[key]\n }\n\n for (const key in data.relationships) {\n if (data.relationships[key].data) {\n output[key] = data.relationships[key].data\n }\n }\n\n return output\n}", "import { deattribute } from './deattribute.js'\n\n/**\n * Group included JSON:API data by type and ID.\n *\n * @param {Object[]} included The included JSON:API data.\n * @returns {Object} The grouped included data.\n */\nfunction groupIncluded (included) {\n const groups = {}\n\n for (const item of included) {\n if (!groups[item.type]) {\n groups[item.type] = {}\n }\n\n groups[item.type][item.id] = deattribute(item)\n }\n\n return groups\n}\n\n/**\n * Checks if a value is an object.\n *\n * @param {*} object The value to check.\n * @returns {boolean} Whether the value is an object.\n */\nfunction hasObject (object) {\n return typeof object === 'object' && object !== null\n}\n\n/**\n * Deserialises a JSON-API response.\n *\n * @param {Object} response The JSON-API response.\n * @returns {Object} The deserialised response.\n */\nexport function deserialize (response) {\n const output = {}\n\n if (response.data) {\n output.data = deattribute(response.data)\n }\n\n if (response.meta) {\n output.meta = response.meta\n }\n\n if (response.included) {\n const included = groupIncluded(response.included)\n\n const getIncluded = item => item.type in included\n ? included[item.type][item.id]\n : item\n\n const replace = item => Array.isArray(item)\n ? item.map(getIncluded)\n : getIncluded(item)\n\n // Replace relationships with included data.\n for (const type in included) {\n for (const id in included[type]) {\n for (const key in included[type][id]) {\n const item = included[type][id][key]\n\n if (hasObject(item)) {\n included[type][id][key] = replace(item)\n }\n }\n }\n }\n\n // Replace relationships in the main data with included data.\n if (Array.isArray(output.data)) {\n for (const item of output.data) {\n for (const key in item) {\n if (hasObject(item[key])) {\n item[key] = replace(item[key])\n }\n }\n }\n } else if (hasObject(output.data)) {\n for (const key in output.data) {\n if (hasObject(output.data[key])) {\n output.data[key] = replace(output.data[key])\n }\n }\n }\n }\n\n return output\n}", "/**\n * Parse the error response from the API.\n *\n * @param {Object} error The error object.\n * @throws {Error} The parsed error object.\n */\nexport function errorParser (error) {\n if (error.response) {\n const { data } = error.response\n\n if (data?.errors) {\n error.errors = data.errors\n }\n }\n\n throw error\n}\n", "import { errorParser } from './error-parser.js'\n\nfunction serializeNode (node, key, data) {\n if (typeof node === 'object' && node !== null) {\n if (!data.relationships) {\n data.relationships = {}\n }\n\n data.relationships[key] = {\n data: node.id ? { id: node.id, type: node.type || key } : node,\n links: node.links,\n meta: node.meta\n }\n } else {\n if (!data.attributes) {\n data.attributes = {}\n }\n\n data.attributes[key] = node\n }\n\n return data\n}\n\nexport function serialize (type, data, options = {\n camelCaseTypes: string => string,\n pluralTypes: string => string\n}) {\n try {\n if (data === null || (Array.isArray(data) && !data.length)) {\n return { data }\n }\n\n const output = {\n type: options.pluralTypes(options.camelCaseTypes(type))\n }\n\n if (data.id) {\n output.id = String(data.id)\n }\n\n for (const key in data) {\n if (['id', 'type'].includes(key)) {\n continue\n }\n\n serializeNode(data[key], key, output)\n }\n\n return JSON.stringify({ data: output })\n } catch (error) {\n errorParser(error)\n }\n}", "/**\n * Loop through an object and build a query string.\n * \n * @param {URLSearchParams} query The query to append to.\n * @param {Object} object The object to loop through.\n * @param {string} prefix The prefix to use.\n * @returns {void}\n * @private\n */\nfunction buildQuery (query, object = {}, prefix = '') {\n const isArray = Array.isArray(object)\n\n for (const key in object) {\n const value = object[key]\n const withPrefix = prefix ? `${prefix}[${isArray ? '' : key}]` : key\n\n value instanceof Object\n ? buildQuery(query, value, withPrefix)\n : query.append(withPrefix, value)\n }\n}\n\n/**\n * Format query parameters.\n * \n * @param {Object} parameters The parameters to format.\n * @returns {URLSearchParams} The formatted query.\n */\nexport function queryFormatter (parameters = {}) {\n const query = new URLSearchParams()\n buildQuery(query, parameters)\n\n return query\n}\n", "/**\n * Split a model name from a URL.\n *\n * @param {string} url The URL to split.\n * @param {Object} options The options to use.\n * @returns {string[]} The model and resource.\n */\nexport function splitModel (url, options = {\n resourceCase: string => string,\n pluralize: string => string\n}) {\n const parts = url.split('/')\n const model = parts.pop()\n const resource = parts.join('/')\n\n return [\n model,\n `${resource}/${options.pluralize(options.resourceCase(model))}`\n ]\n}\n", "/**\n * Convert a string from snake_case and kebab-case to camelCase.\n *\n * @param {string} input The string to convert.\n * @returns {string} The converted string.\n */\nexport function camelCase (input) {\n return input\n .toLowerCase()\n .replace(/[-_](.)/g, (_, char) => char.toUpperCase())\n .replace(/^(.)/, (char) => char.toLowerCase())\n}\n", "/**\n * Convert a string from camelCase and snake_case to kebab-case.\n *\n * @param {string} input The string to convert.\n * @returns {string} The converted string.\n */\nexport function kebabCase (input) {\n return input\n .replace(/([a-z])([A-Z])/g, '$1-$2')\n .replace(/_/g, '-')\n .toLowerCase()\n}\n", "/**\n * Converts a string from camelCase and kebab-case to snake_case.\n *\n * @param {string} input The string to convert.\n * @returns {string} The converted string.\n */\nexport function snakeCase (input) {\n return input\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n .replace(/-/g, '_')\n .toLowerCase()\n}\n"],
5
- "mappings": "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",
6
- "names": ["pluralize", "deattribute", "data", "output", "key", "groupIncluded", "included", "groups", "item", "deattribute", "hasObject", "object", "deserialize", "response", "output", "getIncluded", "replace", "type", "id", "key", "errorParser", "error", "data", "serializeNode", "node", "key", "data", "serialize", "type", "options", "string", "output", "error", "errorParser", "buildQuery", "query", "object", "prefix", "isArray", "key", "value", "withPrefix", "queryFormatter", "parameters", "splitModel", "url", "options", "string", "parts", "model", "resource", "camelCase", "input", "_", "char", "kebabCase", "input", "snakeCase", "input", "jsonType", "Fetchja", "options", "object", "queryFormatter", "string", "camelCase", "cases", "kebabCase", "snakeCase", "pluralize", "error", "#splitModel", "model", "splitModel", "baseURL", "url", "serialize", "makeRequest", "headers", "response", "replayedResponse", "responseHeaders", "key", "value", "contentType", "data", "deserialize", "part", "errorParser", "body", "type", "id"]
7
- }
1
+ {"version":3,"sources":["../src/deattribute.ts","../src/deserialize.ts","../src/errors.ts","../src/serialize.ts","../src/query.ts","../src/model.ts","../src/case.ts","../src/pluralize.ts","../src/client.ts"],"sourcesContent":["/**\n * Property names that could pollute an object's prototype. They are\n * skipped whenever data from a response is copied into a new object.\n */\nconst DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\n\n/**\n * A single JSON:API resource object.\n */\nexport interface Resource {\n /** The resource type. */\n type?: string\n\n /** The resource id. */\n id?: string\n\n /** The resource attributes. */\n attributes?: Record<string, unknown>\n\n /** The resource relationships. */\n relationships?: Record<string, { data?: unknown }>\n}\n\n/**\n * Flatten a JSON:API resource (or array of resources) by lifting its\n * `attributes` and `relationships` onto the top level, next to `type`\n * and `id`.\n *\n * @param data - The resource or resources to flatten.\n * @returns The flattened object or array of objects.\n */\nexport function deattribute (\n data: Resource | Resource[]\n): Record<string, unknown> | Record<string, unknown>[] {\n if (Array.isArray(data)) {\n return data.map(item => deattribute(item) as Record<string, unknown>)\n }\n\n const output: Record<string, unknown> = {\n type: data.type,\n id: data.id\n }\n\n for (const key in data.attributes) {\n if (DANGEROUS_KEYS.has(key)) {\n continue\n }\n\n output[key] = data.attributes[key]\n }\n\n for (const key in data.relationships) {\n if (DANGEROUS_KEYS.has(key)) {\n continue\n }\n\n const relation = data.relationships[key]\n\n if (relation && 'data' in relation) {\n output[key] = relation.data\n }\n }\n\n return output\n}\n","import { deattribute } from './deattribute.js'\n\n/**\n * Check whether a value is a non-null object.\n *\n * @param value - The value to check.\n * @returns `true` when the value is a non-null object.\n */\nfunction isObject (value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null\n}\n\n/**\n * Deserialize a JSON:API response document into a flat object. Resources\n * found in `included` are resolved (by `type` and `id`) straight into\n * the relationships that reference them.\n *\n * @param response - The JSON:API document to deserialize.\n * @returns The flattened response, with `data` and optional `meta`.\n */\nexport function deserialize (\n response: Record<string, any>\n): Record<string, unknown> {\n const output: Record<string, unknown> = {}\n\n if (response.data) {\n output.data = deattribute(response.data)\n }\n\n if (response.meta) {\n output.meta = response.meta\n }\n\n if (!Array.isArray(response.included)) {\n return output\n }\n\n const resourcesByKey = new Map<string, Record<string, unknown>>()\n\n for (const resource of response.included) {\n const flat = deattribute(resource) as Record<string, unknown>\n\n resourcesByKey.set(`${resource.type}:${resource.id}`, flat)\n }\n\n /**\n * Replace a resource identifier with its full resource, when known.\n *\n * @param reference - The identifier or value to resolve.\n * @returns The resolved resource, or the value unchanged.\n */\n function resolve (reference: unknown): unknown {\n if (!isObject(reference)) {\n return reference\n }\n\n return resourcesByKey.get(`${reference.type}:${reference.id}`) ??\n reference\n }\n\n /**\n * Resolve a relationship value, mapping over to-many arrays.\n *\n * @param value - The relationship value to resolve.\n * @returns The resolved value.\n */\n function replace (value: unknown): unknown {\n return Array.isArray(value) ? value.map(resolve) : resolve(value)\n }\n\n /**\n * Resolve every relationship reference on a flattened resource.\n *\n * @param entry - The flattened resource to link.\n */\n function link (entry: Record<string, unknown>): void {\n for (const key in entry) {\n if (isObject(entry[key])) {\n entry[key] = replace(entry[key])\n }\n }\n }\n\n for (const resource of resourcesByKey.values()) {\n link(resource)\n }\n\n const { data } = output\n\n if (Array.isArray(data)) {\n data.forEach(link)\n } else if (isObject(data)) {\n link(data)\n }\n\n return output\n}\n","/**\n * A single error object as defined by the JSON:API specification.\n *\n * @see https://jsonapi.org/format/#error-objects\n */\nexport interface JsonApiError {\n /** The HTTP status code, as a string. */\n status?: string\n\n /** An application-specific error code. */\n code?: string\n\n /** A short, human-readable summary of the problem. */\n title?: string\n\n /** A human-readable explanation of this occurrence of the problem. */\n detail?: string\n\n /** References to the source of the error. */\n source?: Record<string, unknown>\n\n [key: string]: unknown\n}\n\n/**\n * The extra fields used to build a {@link FetchjaError}.\n */\nexport interface FetchjaErrorInit {\n /** The HTTP status code of the failed response. */\n status?: number\n\n /** The HTTP status text of the failed response. */\n statusText?: string\n\n /** The JSON:API error objects returned by the server. */\n errors?: JsonApiError[]\n\n /** The raw failed response. */\n response?: Response\n}\n\n/**\n * The error thrown when a request fails. It carries the HTTP status and\n * any JSON:API error objects returned by the server.\n */\nexport class FetchjaError extends Error {\n /** The HTTP status code of the failed response. */\n status?: number\n\n /** The HTTP status text of the failed response. */\n statusText?: string\n\n /** The JSON:API error objects returned by the server. */\n errors?: JsonApiError[]\n\n /** The raw failed response. */\n response?: Response\n\n /**\n * @param message - The error message.\n * @param init - The extra fields to attach to the error.\n */\n constructor (message: string, init: FetchjaErrorInit = {}) {\n super(message)\n\n this.name = 'FetchjaError'\n this.status = init.status\n this.statusText = init.statusText\n this.errors = init.errors\n this.response = init.response\n }\n}\n","import { FetchjaError } from './errors.js'\n\n/**\n * Property names that could pollute an object's prototype. They are\n * skipped while reading the input object.\n */\nconst DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\n\n/**\n * The transforms applied to resource `type` names while serializing.\n */\nexport interface SerializeOptions {\n /** Cases a `type` name. */\n caseType: (type: string) => string\n\n /** Pluralizes a `type` name. */\n pluralTypes: (type: string) => string\n}\n\n/**\n * Check whether a value is a plain object. Arrays, `null`, and `Date`\n * instances are treated as values, not relationships.\n *\n * @param value - The value to check.\n * @returns `true` when the value is a plain object.\n */\nfunction isPlainObject (\n value: unknown\n): value is Record<string, unknown> {\n return (\n typeof value === 'object' &&\n value !== null &&\n !Array.isArray(value) &&\n !(value instanceof Date)\n )\n}\n\n/**\n * Serialize a plain object into a JSON:API request document. Nested\n * objects with an `id` become relationships, and the full resources are\n * collected into the top-level `included` array.\n *\n * @param type - The resource type of the root object.\n * @param input - The plain object to serialize.\n * @param options - The type-name transforms.\n * @returns The JSON:API document as a JSON string.\n */\nexport function serialize (\n type: string,\n input: Record<string, unknown>,\n options: SerializeOptions\n): string {\n const included: Record<string, unknown>[] = []\n const includedKeys = new Set<string>()\n\n /**\n * Apply the configured type-name transforms.\n *\n * @param rawType - The raw type name.\n * @returns The transformed type name.\n */\n function formatType (rawType: string): string {\n return options.pluralTypes(options.caseType(rawType))\n }\n\n /**\n * Resolve a resource's `type`: its own `type` when present, otherwise\n * one derived from the relationship key. Used for both the identifier\n * and the included resource, so the two always match.\n *\n * @param node - The resource.\n * @param fallbackType - The relationship key to fall back to.\n * @returns The resource type.\n */\n function resourceType (\n node: Record<string, unknown>,\n fallbackType: string\n ): string {\n return (node.type as string) ?? formatType(fallbackType)\n }\n\n /**\n * Build a JSON:API resource identifier (`{ type, id }`).\n *\n * @param resource - The related resource.\n * @param fallbackType - The type to use when the resource has none.\n * @returns The resource identifier.\n */\n function toIdentifier (\n resource: Record<string, unknown>,\n fallbackType: string\n ): Record<string, unknown> {\n return {\n type: resourceType(resource, fallbackType),\n id: String(resource.id)\n }\n }\n\n /**\n * Collect a related resource into `included`, de-duplicated by its\n * `type` and `id`.\n *\n * @param resource - The related resource.\n * @param fallbackType - The type to use when the resource has none.\n */\n function collectIncluded (\n resource: unknown,\n fallbackType: string\n ): void {\n if (!isPlainObject(resource)) {\n return\n }\n\n if (resource.id == null) {\n throw new FetchjaError('All included resources must have an ID.')\n }\n\n const key = `${resource.type ?? fallbackType}:${resource.id}`\n\n if (includedKeys.has(key)) {\n return\n }\n\n includedKeys.add(key)\n included.push(extractResource(resource, fallbackType))\n }\n\n /**\n * Build a JSON:API resource object from a plain object, splitting its\n * fields into `attributes` and `relationships`.\n *\n * @param node - The plain object to convert.\n * @param rawType - The raw type name of the resource.\n * @returns The JSON:API resource object.\n */\n function extractResource (\n node: Record<string, unknown>,\n rawType: string\n ): Record<string, unknown> {\n const data: Record<string, unknown> = {\n type: resourceType(node, rawType)\n }\n const relationships: Record<string, unknown> = {}\n const attributes: Record<string, unknown> = {}\n\n for (const key in node) {\n if (DANGEROUS_KEYS.has(key) || key === 'type') {\n continue\n }\n\n if (key === 'id') {\n data.id = String(node.id)\n\n continue\n }\n\n const value = node[key]\n\n if (Array.isArray(value)) {\n relationships[key] = {\n data: value.map(item => {\n collectIncluded(item, key)\n\n return toIdentifier(item, key)\n })\n }\n\n continue\n }\n\n if (isPlainObject(value)) {\n relationships[key] = { data: toIdentifier(value, key) }\n collectIncluded(value, key)\n\n continue\n }\n\n attributes[key] = value\n }\n\n if (Object.keys(attributes).length > 0) {\n data.attributes = attributes\n }\n\n if (Object.keys(relationships).length > 0) {\n data.relationships = relationships\n }\n\n return data\n }\n\n const data = extractResource(input, type)\n\n return JSON.stringify({ data, included })\n}\n","/**\n * Property names that could pollute an object's prototype. They are\n * skipped while reading the parameters object.\n */\nconst DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])\n\n/**\n * Check whether a value should be walked into when building the query.\n * Arrays and plain objects are traversable; `Date` instances are not.\n *\n * @param value - The value to check.\n * @returns `true` when the value should be traversed.\n */\nfunction isTraversable (value: unknown): value is object {\n return (\n typeof value === 'object' &&\n value !== null &&\n !(value instanceof Date)\n )\n}\n\n/**\n * Append an object's entries to a query, recursing into nested objects\n * and arrays using JSON:API-friendly bracket notation.\n *\n * @param query - The query to append to.\n * @param object - The object or array to walk.\n * @param prefix - The key prefix built from parent keys.\n */\nfunction buildQuery (\n query: URLSearchParams,\n object: object,\n prefix = ''\n): void {\n const isArray = Array.isArray(object)\n\n for (const key in object) {\n if (DANGEROUS_KEYS.has(key)) {\n continue\n }\n\n const value = (object as Record<string, unknown>)[key]\n const path = prefix ? `${prefix}[${isArray ? '' : key}]` : key\n\n if (isTraversable(value)) {\n buildQuery(query, value, path)\n } else {\n query.append(path, String(value))\n }\n }\n}\n\n/**\n * Serialize a plain object into URL query parameters.\n *\n * @param parameters - The parameters to serialize.\n * @returns The serialized query parameters.\n */\nexport function queryFormatter (\n parameters: Record<string, unknown> = {}\n): URLSearchParams {\n const query = new URLSearchParams()\n\n buildQuery(query, parameters)\n\n return query\n}\n","/**\n * The transforms used to turn a model name into a URL path segment.\n */\nexport interface ModelOptions {\n /** Cases a single path segment. */\n resourceCase: (segment: string) => string\n\n /** Pluralizes a single path segment. */\n pluralize: (segment: string) => string\n}\n\n/**\n * Check whether a path segment is a numeric resource id.\n *\n * @param segment - The path segment to check.\n * @returns `true` when the segment is all digits.\n */\nfunction isResourceId (segment: string): boolean {\n return /^\\d+$/.test(segment)\n}\n\n/**\n * Normalize a `get` model into a URL path. Only the resource being\n * addressed (the last non-numeric segment) is cased and pluralized, so\n * namespaces and numeric ids are left untouched. This keeps `get`\n * consistent with the path built by {@link splitModel}.\n *\n * @param model - The model path, e.g. `article`, `articles/1`.\n * @param options - The casing and pluralization transforms.\n * @returns The normalized URL path.\n */\nexport function normalizePath (\n model: string,\n options: ModelOptions\n): string {\n const segments = model.split('/').filter(Boolean)\n\n const targetIndex = segments.reduce(\n (last, segment, index) => (isResourceId(segment) ? last : index),\n -1\n )\n\n return segments\n .map((segment, index) =>\n index === targetIndex\n ? options.pluralize(options.resourceCase(segment))\n : segment\n )\n .join('/')\n}\n\n/**\n * Split a write model into its resource `type` and URL path. The last\n * segment is treated as the resource and is cased and pluralized.\n *\n * @param url - The model path, e.g. `article`, `admin/article`.\n * @param options - The casing and pluralization transforms.\n * @returns A `[type, path]` tuple.\n */\nexport function splitModel (\n url: string,\n options: ModelOptions\n): [string, string] {\n const parts = url.split('/').filter(Boolean)\n const model = parts.pop() ?? ''\n const namespace = parts.join('/')\n const path = options.pluralize(options.resourceCase(model))\n\n return [model, namespace ? `${namespace}/${path}` : path]\n}\n","/**\n * Convert a string to `camelCase` from `snake_case`, `kebab-case`, or\n * `SCREAMING_SNAKE_CASE`.\n *\n * @param input - The string to convert.\n * @returns The converted string.\n */\nexport function camelCase (input: string): string {\n return input\n .toLowerCase()\n .replace(/[-_](.)/g, (_match, character: string) =>\n character.toUpperCase()\n )\n .replace(/^(.)/, character => character.toLowerCase())\n}\n\n/**\n * Convert a string to `kebab-case` from `camelCase` or `snake_case`.\n *\n * @param input - The string to convert.\n * @returns The converted string.\n */\nexport function kebabCase (input: string): string {\n return input\n .replace(/([a-z])([A-Z])/g, '$1-$2')\n .replace(/_/g, '-')\n .toLowerCase()\n}\n\n/**\n * Convert a string to `snake_case` from `camelCase` or `kebab-case`.\n *\n * @param input - The string to convert.\n * @returns The converted string.\n */\nexport function snakeCase (input: string): string {\n return input\n .replace(/([a-z])([A-Z])/g, '$1_$2')\n .replace(/-/g, '_')\n .toLowerCase()\n}\n","/**\n * Pluralize an English word using a small set of common rules.\n *\n * This is a lightweight, dependency-free helper that covers the cases\n * most JSON:API resource names need. It is intentionally simple and\n * idempotent: a word that already looks plural is returned unchanged.\n * For irregular words (such as `person` becoming `people`), inject a\n * fuller implementation through the `pluralize` option.\n *\n * @param word - The word to pluralize.\n * @returns The pluralized word.\n */\nexport function pluralize (word: string): string {\n if (word === '') {\n return word\n }\n\n // Singular words ending in \"us\" take \"es\": status -> statuses,\n // bus -> buses, virus -> viruses.\n if (/us$/i.test(word)) {\n return `${word}es`\n }\n\n // Anything else ending in \"s\" is treated as already plural.\n if (/s$/i.test(word)) {\n return word\n }\n\n // A consonant followed by \"y\" becomes \"ies\": category -> categories.\n if (/[^aeiou]y$/i.test(word)) {\n return word.replace(/y$/i, 'ies')\n }\n\n // Sibilant endings take \"es\": box -> boxes, match -> matches.\n if (/(x|z|ch|sh)$/i.test(word)) {\n return `${word}es`\n }\n\n return `${word}s`\n}\n","import { deserialize } from './deserialize.js'\nimport { serialize } from './serialize.js'\nimport { queryFormatter } from './query.js'\nimport { normalizePath, splitModel, type ModelOptions } from './model.js'\nimport { camelCase, kebabCase, snakeCase } from './case.js'\nimport { pluralize as defaultPluralize } from './pluralize.js'\nimport { FetchjaError } from './errors.js'\nimport type { FetchjaOptions, RequestOptions } from './types.js'\n\n/** The media type required by the JSON:API specification. */\nconst JSON_API_MEDIA_TYPE = 'application/vnd.api+json'\n\n/**\n * Return the given string unchanged. Used as the default transform when\n * no resource casing or pluralization is requested.\n *\n * @param value - The string to return.\n * @returns The same string.\n */\nfunction identity (value: string): string {\n return value\n}\n\n/**\n * The resource-casing strategies selectable through `resourceCase`.\n * Stored on a null-prototype object so an unexpected option value cannot\n * reach inherited properties.\n */\nconst RESOURCE_CASES: Record<string, (value: string) => string> =\n Object.assign(Object.create(null), {\n camel: camelCase,\n kebab: kebabCase,\n snake: snakeCase,\n none: identity\n })\n\n/**\n * The default query serializer, used when no `queryFormatter` option is\n * provided.\n *\n * @param params - The query parameters to serialize.\n * @returns The serialized query parameters.\n */\nfunction defaultQueryFormatter (\n params: unknown\n): string | URLSearchParams {\n return queryFormatter(params as Record<string, unknown>)\n}\n\n/**\n * Resolve the `pluralize` option into a transform function.\n *\n * @param option - The `pluralize` option value.\n * @returns The pluralization transform.\n */\nfunction resolvePluralizer (\n option: FetchjaOptions['pluralize']\n): (value: string) => string {\n if (option === false) {\n return identity\n }\n\n if (typeof option === 'function') {\n return option\n }\n\n return defaultPluralize\n}\n\n/**\n * Copy every response header into a plain object.\n *\n * @param response - The response to read headers from.\n * @returns A plain object of header names to values.\n */\nfunction collectHeaders (response: Response): Record<string, string> {\n const headers: Record<string, string> = {}\n\n for (const [key, value] of response.headers.entries()) {\n headers[key] = value\n }\n\n return headers\n}\n\n/**\n * A super simple, lightweight JSON:API client built on the Fetch API.\n */\nexport default class Fetchja {\n /** The base URL prepended to every request. */\n baseURL?: string\n\n /** Headers merged into every request. */\n headers: Record<string, string>\n\n /** Serializes the request query parameters. */\n queryFormatter: (params: unknown) => string | URLSearchParams\n\n /** Cases `type` names when serializing request bodies. */\n typeCase: (value: string) => string\n\n /** Cases resource names in the URL path. */\n resourceCase: (value: string) => string\n\n /** Pluralizes resource names. */\n pluralize: (value: string) => string\n\n /** Interceptor invoked on a non-OK response, before throwing. */\n onResponseError: FetchjaOptions['onResponseError']\n\n /** The custom fetch implementation, when provided. */\n readonly #fetch?: typeof fetch\n\n /** Alias of {@link Fetchja.get}. */\n fetch!: Fetchja['get']\n\n /** Alias of {@link Fetchja.post}. */\n create!: Fetchja['post']\n\n /** Alias of {@link Fetchja.patch}. */\n update!: Fetchja['patch']\n\n /** Alias of {@link Fetchja.delete}. */\n remove!: Fetchja['delete']\n\n /**\n * @param options - The client options.\n */\n constructor (options: FetchjaOptions = {}) {\n this.baseURL = options.baseURL\n\n this.headers = {\n 'Accept': JSON_API_MEDIA_TYPE,\n 'Content-Type': JSON_API_MEDIA_TYPE,\n ...options.headers\n }\n\n this.#fetch = options.fetch\n\n this.queryFormatter = typeof options.queryFormatter === 'function'\n ? options.queryFormatter\n : defaultQueryFormatter\n\n this.resourceCase =\n RESOURCE_CASES[options.resourceCase ?? 'none'] ?? identity\n\n this.typeCase =\n RESOURCE_CASES[options.typeCase ?? 'camel'] ?? camelCase\n\n this.pluralize = resolvePluralizer(options.pluralize)\n\n this.onResponseError = options.onResponseError\n\n this.fetch = this.get\n this.create = this.post\n this.update = this.patch\n this.remove = this.delete\n }\n\n /** The transforms shared by the path-building helpers. */\n get #modelOptions (): ModelOptions {\n return {\n resourceCase: this.resourceCase,\n pluralize: this.pluralize\n }\n }\n\n /**\n * Perform a request and return the deserialized response. Throws a\n * {@link FetchjaError} on a non-OK response.\n *\n * @param options - The request options.\n * @returns The deserialized response.\n */\n async request (\n options: RequestOptions\n ): Promise<Record<string, unknown>> {\n const base = this.baseURL ?? options.baseURL\n\n if (base === undefined || base === '') {\n throw new FetchjaError(\n 'A `baseURL` is required to make requests.'\n )\n }\n\n const requestPath = options.url ?? ''\n\n const relativePath = requestPath.startsWith('/')\n ? requestPath.slice(1)\n : requestPath\n\n const baseWithSlash = base.endsWith('/') ? base : `${base}/`\n const url = new URL(relativePath, baseWithSlash)\n\n if (options.params !== undefined) {\n url.search = String(this.queryFormatter(options.params))\n }\n\n const body = options.body !== undefined && options.type !== undefined\n ? serialize(options.type, options.body, {\n caseType: this.typeCase,\n pluralTypes: this.pluralize\n })\n : undefined\n\n const requestFetch = this.#fetch ?? fetch\n const baseHeaders = this.headers\n\n /**\n * Send the request. Defined as a closure so it can be replayed by\n * the `onResponseError` interceptor with the current headers.\n *\n * @returns The raw response.\n */\n function sendRequest (): Promise<Response> {\n const headers = new Headers({\n ...baseHeaders,\n ...options.headers\n })\n\n const init: RequestInit = {\n method: options.method,\n headers,\n body\n }\n\n return requestFetch(url, init)\n }\n\n const initialResponse = await sendRequest()\n\n const augmentedResponse = Object.assign(initialResponse, {\n replayRequest: sendRequest\n })\n\n const handledResponse =\n augmentedResponse.ok || this.onResponseError === undefined\n ? augmentedResponse\n : await this.onResponseError(augmentedResponse)\n\n const response = handledResponse instanceof Response\n ? handledResponse\n : augmentedResponse\n\n const responseHeaders = collectHeaders(response)\n const contentType = responseHeaders['content-type'] ?? ''\n\n const payload = contentType.includes(JSON_API_MEDIA_TYPE)\n ? await response.json()\n : {}\n\n if (!response.ok) {\n throw new FetchjaError(response.statusText || 'Request failed', {\n status: response.status,\n statusText: response.statusText,\n errors: payload.errors,\n response\n })\n }\n\n return {\n ...deserialize(payload),\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders\n }\n }\n\n /**\n * Read one or more resources.\n *\n * @param model - The resource path, e.g. `articles` or `articles/1`.\n * @param options - Extra request options.\n * @returns The deserialized response.\n */\n get (\n model: string,\n options: RequestOptions = {}\n ): Promise<Record<string, unknown>> {\n return this.request({\n ...options,\n method: options.method ?? 'GET',\n url: normalizePath(model, this.#modelOptions)\n })\n }\n\n /**\n * Create a resource.\n *\n * @param model - The resource name, e.g. `article`.\n * @param body - The resource to create.\n * @param options - Extra request options.\n * @returns The deserialized response.\n */\n post (\n model: string,\n body: Record<string, unknown>,\n options: RequestOptions = {}\n ): Promise<Record<string, unknown>> {\n const [type, url] = splitModel(model, this.#modelOptions)\n\n return this.request({\n ...options,\n method: options.method ?? 'POST',\n url,\n body,\n type\n })\n }\n\n /**\n * Update a resource. When the body has an `id`, it is appended to the\n * URL.\n *\n * @param model - The resource name, e.g. `article`.\n * @param body - The fields to update.\n * @param options - Extra request options.\n * @returns The deserialized response.\n */\n patch (\n model: string,\n body: Record<string, unknown>,\n options: RequestOptions = {}\n ): Promise<Record<string, unknown>> {\n const [type, url] = splitModel(model, this.#modelOptions)\n\n const resourceUrl = body.id !== undefined\n ? `${url}/${String(body.id)}`\n : url\n\n return this.request({\n ...options,\n method: options.method ?? 'PATCH',\n url: resourceUrl,\n body,\n type\n })\n }\n\n /**\n * Delete a resource. No request body is sent.\n *\n * @param model - The resource name, e.g. `article`.\n * @param id - The id of the resource to delete.\n * @param options - Extra request options.\n * @returns The deserialized response.\n */\n delete (\n model: string,\n id: string,\n options: RequestOptions = {}\n ): Promise<Record<string, unknown>> {\n const [, url] = splitModel(model, this.#modelOptions)\n\n return this.request({\n ...options,\n method: options.method ?? 'DELETE',\n url: `${url}/${id}`\n })\n }\n}\n"],"mappings":"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","names":["DANGEROUS_KEYS","deattribute","data","item","output","key","relation","isObject","value","deserialize","response","output","deattribute","resourcesByKey","resource","flat","resolve","reference","replace","link","entry","key","data","FetchjaError","message","init","DANGEROUS_KEYS","isPlainObject","value","serialize","type","input","options","included","includedKeys","formatType","rawType","resourceType","node","fallbackType","toIdentifier","resource","collectIncluded","FetchjaError","key","extractResource","data","relationships","attributes","item","DANGEROUS_KEYS","isTraversable","value","buildQuery","query","object","prefix","isArray","key","path","queryFormatter","parameters","isResourceId","segment","normalizePath","model","options","segments","targetIndex","last","index","splitModel","url","parts","namespace","path","camelCase","input","_match","character","kebabCase","snakeCase","pluralize","word","JSON_API_MEDIA_TYPE","identity","value","RESOURCE_CASES","camelCase","kebabCase","snakeCase","defaultQueryFormatter","params","queryFormatter","resolvePluralizer","option","pluralize","collectHeaders","response","headers","key","Fetchja","#fetch","options","#modelOptions","base","FetchjaError","requestPath","relativePath","baseWithSlash","url","body","serialize","requestFetch","baseHeaders","sendRequest","init","initialResponse","augmentedResponse","handledResponse","responseHeaders","payload","deserialize","model","normalizePath","type","splitModel","resourceUrl","id"]}
package/package.json CHANGED
@@ -1,20 +1,40 @@
1
1
  {
2
2
  "name": "fetchja",
3
- "version": "1.3.0",
4
- "main": "dist/index.js",
3
+ "version": "2.0.0",
5
4
  "type": "module",
6
- "scripts": {
7
- "build": "esbuild src/index.js --bundle --format=esm --packages=external --minify --sourcemap --outfile=dist/index.js",
8
- "test": "echo \"Error: no test specified\" && exit 1"
9
- },
5
+ "description": "A super simple, modern, lightweight, zero-dependency JSON:API client built on the Fetch API.",
10
6
  "author": "Caio Tarifa <caio@yahoo.com>",
11
7
  "license": "ISC",
12
- "description": "A super simple, modern, and lightweight library for dealing with JSON:API (Kitsu-like, but using Fetch API instead of Axios).",
13
- "dependencies": {
14
- "pluralize": "^8.0.0"
8
+ "sideEffects": false,
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm --dts --minify --sourcemap --clean",
23
+ "test": "node --import tsx --test tests/*.test.ts",
24
+ "typecheck": "tsc --noEmit",
25
+ "lint": "eslint .",
26
+ "lint:fix": "eslint . --fix",
27
+ "prepublishOnly": "npm run build"
15
28
  },
16
29
  "devDependencies": {
17
- "esbuild": "0.23.0"
30
+ "@eslint/js": "^10.0.1",
31
+ "@stylistic/eslint-plugin": "^5.10.0",
32
+ "@types/node": "^25.9.1",
33
+ "eslint": "^10.4.1",
34
+ "tsup": "^8.0.0",
35
+ "tsx": "^4.7.0",
36
+ "typescript": "^5.9.3",
37
+ "typescript-eslint": "^8.60.0"
18
38
  },
19
39
  "repository": {
20
40
  "type": "git",
package/readme.md CHANGED
@@ -1,36 +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
+ ```
6
16
 
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.
17
+ ## Contents
10
18
 
11
- ## Installation
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)
12
33
 
13
- To get started with Fetchja, simply install it via `npm` along with the `pluralize` library:
34
+ ## Why Fetchja?
35
+
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.
42
+
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
24
- import { Fetchja } from 'fetchja'
53
+ ```js
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
+ })
35
293
  }
36
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'
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
+ })
324
+ ```
325
+
326
+ Or turn it off and use the exact names you write:
327
+
328
+ ```js
329
+ const api = new Fetchja({
330
+ baseURL: 'https://api.example.com',
331
+ pluralize: false
332
+ })
333
+ ```
package/src/index.js DELETED
@@ -1,230 +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 {Object} headers The headers to include in all requests.
22
- * @property {Function} queryFormatter A function to format query parameters.
23
- * @property {string} resourceCase The case to use for resource names.
24
- * @property {boolean} pluralize Pluralize resource names.
25
- */
26
-
27
- /**
28
- * Fetchja is a simple wrapper around the Fetch API.
29
- *
30
- * @class Fetchja
31
- * @param {FetchjaOptions} [options] Options for Fetchja.
32
- */
33
- export default class Fetchja {
34
- constructor (options = {
35
- headers: {}
36
- }) {
37
- this.baseURL = options.baseURL
38
-
39
- // Headers
40
- this.headers = {
41
- Accept: jsonType,
42
- 'Content-Type': jsonType,
43
- ...options.headers
44
- }
45
-
46
- // Query
47
- this.queryFormatter = typeof options.queryFormatter === 'function'
48
- ? options.queryFormatter
49
- : object => queryFormatter(object)
50
-
51
- // Camel Case Types
52
- this.camelCaseTypes = options.camelCaseTypes === false
53
- ? string => string
54
- : camelCase
55
-
56
- // Resource Case
57
- const cases = {
58
- camel: camelCase,
59
- kebab: kebabCase,
60
- snake: snakeCase,
61
-
62
- default: string => string
63
- }
64
-
65
- this.resourceCase = cases[options.resourceCase] || cases.default
66
-
67
- // Pluralise
68
- this.pluralize = options.pluralize === false
69
- ? string => string
70
- : pluralize
71
-
72
- // Interceptors
73
- this.onResponseError = error => error
74
-
75
- // Alias
76
- this.fetch = this.get
77
- this.update = this.patch
78
- this.create = this.post
79
- this.remove = this.delete
80
- }
81
-
82
- #splitModel (model) {
83
- return splitModel(model, {
84
- resourceCase: this.resourceCase,
85
- pluralize: this.pluralize
86
- })
87
- }
88
-
89
- async request (options = {
90
- method: 'GET',
91
- headers: {}
92
- }) {
93
- const baseURL = this.baseURL || options.baseURL
94
-
95
- const url = new URL(
96
- options.url.startsWith('/') ? options.url.slice(1) : options.url,
97
- baseURL.endsWith('/') ? baseURL : baseURL + '/'
98
- )
99
-
100
- // Params
101
- if (options.params) {
102
- url.search = this.queryFormatter(options.params)
103
- }
104
-
105
- // Body
106
- if (options.body) {
107
- options.body = serialize(options.type, options.body, {
108
- camelCaseTypes: this.camelCaseTypes,
109
- pluralTypes: this.pluralize
110
- })
111
- }
112
-
113
- // Request
114
- const makeRequest = () => {
115
- // Headers
116
- const headers = new Headers({
117
- ...this.headers,
118
- ...options.headers
119
- })
120
-
121
- // Fetch
122
- return fetch(url, {
123
- method: options.method,
124
- body: options.body,
125
- headers
126
- })
127
- }
128
-
129
- try {
130
- let response = await makeRequest()
131
-
132
- if (!response.ok) {
133
- response.replayRequest = makeRequest
134
- const replayedResponse = await this.onResponseError(response)
135
-
136
- if (replayedResponse instanceof Response) {
137
- response = replayedResponse
138
- }
139
- } else if (!response.ok) {
140
- throw new Error(response.statusText)
141
- }
142
-
143
- // Response Headers
144
- const responseHeaders = {}
145
-
146
- for (const [key, value] of response.headers.entries()) {
147
- responseHeaders[key] = value
148
- }
149
-
150
- const contentType = responseHeaders['content-type']
151
-
152
- // Response Data
153
- const data = contentType && contentType.includes(jsonType)
154
- ? await response.json()
155
- : {}
156
-
157
- // Return
158
- return {
159
- ...(data.errors ? data : deserialize(data)),
160
-
161
- status: response.status,
162
- statusText: response.statusText,
163
- headers: responseHeaders
164
- }
165
- } catch (error) {
166
- throw error
167
- }
168
- }
169
-
170
- get (model, options = { method: 'GET' }) {
171
- try {
172
- options.url = model.split('/')
173
- .map(part => this.resourceCase(part))
174
- .filter(Boolean)
175
- .join('/')
176
-
177
- return this.request(options)
178
- } catch (error) {
179
- throw errorParser(error)
180
- }
181
- }
182
-
183
- patch (model, body, options = { method: 'PATCH' }) {
184
- try {
185
- const [type, url] = this.#splitModel(model)
186
-
187
- return this.request({
188
- url: body?.id ? `${url}/${body.id}` : url,
189
- body,
190
- type,
191
-
192
- ...options
193
- })
194
- } catch (error) {
195
- throw errorParser(error)
196
- }
197
- }
198
-
199
- post (model, body, options = { method: 'POST' }) {
200
- try {
201
- const [type, url] = this.#splitModel(model)
202
-
203
- return this.request({
204
- url,
205
- body,
206
- type,
207
-
208
- ...options
209
- })
210
- } catch (error) {
211
- throw errorParser(error)
212
- }
213
- }
214
-
215
- delete (model, id, options = { method: 'DELETE' }) {
216
- try {
217
- const [type, url] = this.#splitModel(model)
218
-
219
- return this.request({
220
- url: `${url}/${id}`,
221
- body: { id },
222
- type,
223
-
224
- ...options
225
- })
226
- } catch (error) {
227
- throw errorParser(error)
228
- }
229
- }
230
- }
@@ -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
- id: data.id,
14
- type: data.type
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
- }
@@ -1,93 +0,0 @@
1
- import { deattribute } from './deattribute.js'
2
-
3
- /**
4
- * Group included JSON:API data by type and ID.
5
- *
6
- * @param {Object[]} included The included JSON:API data.
7
- * @returns {Object} The grouped included data.
8
- */
9
- function groupIncluded (included) {
10
- const groups = {}
11
-
12
- for (const item of included) {
13
- if (!groups[item.type]) {
14
- groups[item.type] = {}
15
- }
16
-
17
- groups[item.type][item.id] = deattribute(item)
18
- }
19
-
20
- return groups
21
- }
22
-
23
- /**
24
- * Checks if a value is an object.
25
- *
26
- * @param {*} object The value to check.
27
- * @returns {boolean} Whether the value is an object.
28
- */
29
- function hasObject (object) {
30
- return typeof object === 'object' && object !== null
31
- }
32
-
33
- /**
34
- * Deserialises a JSON-API response.
35
- *
36
- * @param {Object} response The JSON-API response.
37
- * @returns {Object} The deserialised response.
38
- */
39
- export function deserialize (response) {
40
- const output = {}
41
-
42
- if (response.data) {
43
- output.data = deattribute(response.data)
44
- }
45
-
46
- if (response.meta) {
47
- output.meta = response.meta
48
- }
49
-
50
- if (response.included) {
51
- const included = groupIncluded(response.included)
52
-
53
- const getIncluded = item => item.type in included
54
- ? included[item.type][item.id]
55
- : item
56
-
57
- const replace = item => Array.isArray(item)
58
- ? item.map(getIncluded)
59
- : getIncluded(item)
60
-
61
- // Replace relationships with included data.
62
- for (const type in included) {
63
- for (const id in included[type]) {
64
- for (const key in included[type][id]) {
65
- const item = included[type][id][key]
66
-
67
- if (hasObject(item)) {
68
- included[type][id][key] = replace(item)
69
- }
70
- }
71
- }
72
- }
73
-
74
- // Replace relationships in the main data with included data.
75
- if (Array.isArray(output.data)) {
76
- for (const item of output.data) {
77
- for (const key in item) {
78
- if (hasObject(item[key])) {
79
- item[key] = replace(item[key])
80
- }
81
- }
82
- }
83
- } else if (hasObject(output.data)) {
84
- for (const key in output.data) {
85
- if (hasObject(output.data[key])) {
86
- output.data[key] = replace(output.data[key])
87
- }
88
- }
89
- }
90
- }
91
-
92
- return output
93
- }
@@ -1,17 +0,0 @@
1
- /**
2
- * Parse the error response from the API.
3
- *
4
- * @param {Object} error The error object.
5
- * @throws {Error} The parsed error object.
6
- */
7
- export function errorParser (error) {
8
- if (error.response) {
9
- const { data } = error.response
10
-
11
- if (data?.errors) {
12
- error.errors = data.errors
13
- }
14
- }
15
-
16
- throw error
17
- }
@@ -1,12 +0,0 @@
1
- /**
2
- * Convert a string from camelCase and snake_case to kebab-case.
3
- *
4
- * @param {string} input The string to convert.
5
- * @returns {string} The converted string.
6
- */
7
- export function kebabCase (input) {
8
- return input
9
- .replace(/([a-z])([A-Z])/g, '$1-$2')
10
- .replace(/_/g, '-')
11
- .toLowerCase()
12
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Loop through an object and build a query string.
3
- *
4
- * @param {URLSearchParams} query The query to append to.
5
- * @param {Object} object The object to loop through.
6
- * @param {string} prefix The prefix to use.
7
- * @returns {void}
8
- * @private
9
- */
10
- function buildQuery (query, object = {}, prefix = '') {
11
- const isArray = Array.isArray(object)
12
-
13
- for (const key in object) {
14
- const value = object[key]
15
- const withPrefix = prefix ? `${prefix}[${isArray ? '' : key}]` : key
16
-
17
- value instanceof Object
18
- ? buildQuery(query, value, withPrefix)
19
- : query.append(withPrefix, value)
20
- }
21
- }
22
-
23
- /**
24
- * Format query parameters.
25
- *
26
- * @param {Object} parameters The parameters to format.
27
- * @returns {URLSearchParams} The formatted query.
28
- */
29
- export function queryFormatter (parameters = {}) {
30
- const query = new URLSearchParams()
31
- buildQuery(query, parameters)
32
-
33
- return query
34
- }
@@ -1,54 +0,0 @@
1
- import { errorParser } from './error-parser.js'
2
-
3
- function serializeNode (node, key, data) {
4
- if (typeof node === 'object' && node !== null) {
5
- if (!data.relationships) {
6
- data.relationships = {}
7
- }
8
-
9
- data.relationships[key] = {
10
- data: node.id ? { id: node.id, type: node.type || key } : node,
11
- links: node.links,
12
- meta: node.meta
13
- }
14
- } else {
15
- if (!data.attributes) {
16
- data.attributes = {}
17
- }
18
-
19
- data.attributes[key] = node
20
- }
21
-
22
- return data
23
- }
24
-
25
- export function serialize (type, data, options = {
26
- camelCaseTypes: string => string,
27
- pluralTypes: string => string
28
- }) {
29
- try {
30
- if (data === null || (Array.isArray(data) && !data.length)) {
31
- return { data }
32
- }
33
-
34
- const output = {
35
- type: options.pluralTypes(options.camelCaseTypes(type))
36
- }
37
-
38
- if (data.id) {
39
- output.id = String(data.id)
40
- }
41
-
42
- for (const key in data) {
43
- if (['id', 'type'].includes(key)) {
44
- continue
45
- }
46
-
47
- serializeNode(data[key], key, output)
48
- }
49
-
50
- return JSON.stringify({ data: output })
51
- } catch (error) {
52
- errorParser(error)
53
- }
54
- }
@@ -1,12 +0,0 @@
1
- /**
2
- * Converts a string from camelCase and kebab-case to snake_case.
3
- *
4
- * @param {string} input The string to convert.
5
- * @returns {string} The converted string.
6
- */
7
- export function snakeCase (input) {
8
- return input
9
- .replace(/([a-z])([A-Z])/g, '$1_$2')
10
- .replace(/-/g, '_')
11
- .toLowerCase()
12
- }
@@ -1,20 +0,0 @@
1
- /**
2
- * Split a model name from a URL.
3
- *
4
- * @param {string} url The URL to split.
5
- * @param {Object} options The options to use.
6
- * @returns {string[]} The model and resource.
7
- */
8
- export function splitModel (url, options = {
9
- resourceCase: string => string,
10
- pluralize: string => string
11
- }) {
12
- const parts = url.split('/')
13
- const model = parts.pop()
14
- const resource = parts.join('/')
15
-
16
- return [
17
- model,
18
- `${resource}/${options.pluralize(options.resourceCase(model))}`
19
- ]
20
- }