@godxjp/sdk 0.2.0 → 0.4.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 +72 -0
- package/dist/laravel/index.d.ts +77 -3
- package/dist/laravel/index.js +101 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,78 @@ Authentication is the session cookie, so a write sends the `XSRF-TOKEN` cookie b
|
|
|
83
83
|
|
|
84
84
|
A cancellation stays an `AbortError` and is never converted to an `ApiError` — TanStack Query tells a cancelled query from a failed one by exactly that.
|
|
85
85
|
|
|
86
|
+
### Other ways to authenticate
|
|
87
|
+
|
|
88
|
+
Every option below is optional; left out, the client behaves exactly as described above.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
export const apiClient = createClient({
|
|
92
|
+
baseUrl: "https://api.example.com/v1",
|
|
93
|
+
// Read before every request — and again before a retry — so a token
|
|
94
|
+
// refreshed a moment ago is the one that goes out. Wins over the defaults.
|
|
95
|
+
headers: async () => ({ Authorization: `Bearer ${await tokens.access()}` }),
|
|
96
|
+
// `fetch` credentials mode, `same-origin` by default. `include` also sets
|
|
97
|
+
// `withCredentials` on an upload that goes out over XHR.
|
|
98
|
+
credentials: "include",
|
|
99
|
+
// On a 401, before `signInUrl`: resolve `true` and the request is sent once
|
|
100
|
+
// more; resolve `false` and the 401 takes its usual course. Never more than
|
|
101
|
+
// one retry per request, and 401s that arrive together share one call.
|
|
102
|
+
// `ctx` is the request that was answered 401 — method, URL and the headers
|
|
103
|
+
// it was sent with — so a login that failed is not mistaken for an expired
|
|
104
|
+
// token.
|
|
105
|
+
onUnauthorized: ({ headers }) =>
|
|
106
|
+
headers.Authorization === undefined
|
|
107
|
+
? Promise.resolve(false)
|
|
108
|
+
: tokens.refresh(),
|
|
109
|
+
// A request that never reached the server: `ApiError` with status 0 by
|
|
110
|
+
// default ("wrap"); "throw" rethrows fetch's own `TypeError`.
|
|
111
|
+
networkErrors: "throw",
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A request body is always JSON text or `FormData`, never a stream, so it can be sent a second time. A request cancelled while the token was refreshed stays an `AbortError`, whatever `networkErrors` says.
|
|
116
|
+
|
|
117
|
+
### Downloading a file
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const { blob, headers, status } = await apiClient.download("/exports", {
|
|
121
|
+
method: "POST", // "GET" unless given
|
|
122
|
+
body: { ids: [1, 2] },
|
|
123
|
+
query: { format: "csv" },
|
|
124
|
+
});
|
|
125
|
+
const disposition = headers.get("Content-Disposition");
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Same headers, XSRF header, retry and errors as every other method: a 4xx, a 419 or an unreachable server is still an `ApiError`.
|
|
129
|
+
|
|
130
|
+
### Error messages
|
|
131
|
+
|
|
132
|
+
`ApiError.message` is the server's `message` when it sent a non-empty one. Otherwise it comes from `messages`, or is empty — the client has no language of its own, and never uses `statusText`, which HTTP/2 does not send.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
createClient({
|
|
136
|
+
baseUrl: "/api",
|
|
137
|
+
messages: (status) => (status === 419 ? t("session.expired") : undefined),
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Everything else the server sent is on `ApiError.body` — the parsed JSON, or `undefined` when there was none, it was not JSON, or the server was never reached. Read an `error` code, a `message` that is not a string, or every message per field from there:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
if (error instanceof ApiError) {
|
|
145
|
+
const { error: code, errors } = (error.body ?? {}) as {
|
|
146
|
+
error?: string;
|
|
147
|
+
errors?: Record<string, string[]>;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An unreachable server (`status: 0`) keeps its built-in text, `Could not reach the server. Check your connection and try again.`, unless `messages(0)` answers one.
|
|
153
|
+
|
|
154
|
+
**Changed in 0.3.0.** Before, a 419 always carried `Your session has expired. Reload the page and try again.` and a response without a message fell back to `statusText`. Both are now the server's message or empty. To keep the old 419 text, pass `messages: (status) => status === 419 ? "Your session has expired. Reload the page and try again." : undefined`.
|
|
155
|
+
|
|
156
|
+
**Changed in 0.4.0.** Additions only; a client configured for 0.3.0 sends and throws exactly what it did. `ApiError` gains `body` (also on a `download()` failure). `onUnauthorized` now receives the request that was answered 401 (`{ method, url, headers }`); a function that takes no argument still works. `networkErrors: "throw"` rethrows `fetch`'s own error instead of `ApiError(0)` — including a response body that breaks off mid-way.
|
|
157
|
+
|
|
86
158
|
## `./realtime`
|
|
87
159
|
|
|
88
160
|
```ts
|
package/dist/laravel/index.d.ts
CHANGED
|
@@ -13,7 +13,14 @@ declare class ApiError extends Error {
|
|
|
13
13
|
readonly status: number;
|
|
14
14
|
/** Laravel's `{errors: {field: string[]}}`, first message per key; `{}` when absent. */
|
|
15
15
|
readonly errors: Record<string, string>;
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The error response's body, parsed as JSON — everything `message` and
|
|
18
|
+
* `errors` leave out: an `error` code, a `message` that is not a string,
|
|
19
|
+
* every message per field. `undefined` when there was no body, it was not
|
|
20
|
+
* JSON, or the server was never reached.
|
|
21
|
+
*/
|
|
22
|
+
readonly body: unknown;
|
|
23
|
+
constructor(status: number, message: string, errors?: Record<string, string>, body?: unknown);
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
@@ -45,6 +52,26 @@ type WriteOptions = RequestOptions & {
|
|
|
45
52
|
*/
|
|
46
53
|
onUploadProgress?: (percent: number) => void;
|
|
47
54
|
};
|
|
55
|
+
type DownloadOptions = RequestOptions & {
|
|
56
|
+
/** `GET` unless given. A `POST` carries the XSRF header like any write. */
|
|
57
|
+
method?: "GET" | "POST";
|
|
58
|
+
body?: Body;
|
|
59
|
+
query?: ApiQuery;
|
|
60
|
+
};
|
|
61
|
+
/** A binary response, with the headers a screen reads the file name from. */
|
|
62
|
+
type DownloadResult = {
|
|
63
|
+
blob: Blob;
|
|
64
|
+
headers: Headers;
|
|
65
|
+
status: number;
|
|
66
|
+
};
|
|
67
|
+
/** The request a 401 answered, as `onUnauthorized` receives it. */
|
|
68
|
+
type UnauthorizedContext = {
|
|
69
|
+
method: string;
|
|
70
|
+
/** The full URL, `baseUrl` and query string included. */
|
|
71
|
+
url: string;
|
|
72
|
+
/** Exactly the headers the request was sent with. */
|
|
73
|
+
headers: Record<string, string>;
|
|
74
|
+
};
|
|
48
75
|
type ClientConfig = {
|
|
49
76
|
/** Prefixed to every path, e.g. `/api/app/v1`. */
|
|
50
77
|
baseUrl: string;
|
|
@@ -53,6 +80,44 @@ type ClientConfig = {
|
|
|
53
80
|
* Omitted for a public API whose visitor has nowhere to sign in.
|
|
54
81
|
*/
|
|
55
82
|
signInUrl?: (returnTo: string) => string;
|
|
83
|
+
/**
|
|
84
|
+
* Extra headers, read again before EVERY request (and before a retry), so a
|
|
85
|
+
* bearer token refreshed a moment ago is the one that goes out. Applied
|
|
86
|
+
* after the defaults, so a header named here wins.
|
|
87
|
+
*/
|
|
88
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
89
|
+
/**
|
|
90
|
+
* `fetch` credentials mode; `same-origin` unless given. `include` is what a
|
|
91
|
+
* cross-origin API behind a cookie needs, and also turns on
|
|
92
|
+
* `withCredentials` for an upload that goes out over XHR.
|
|
93
|
+
*/
|
|
94
|
+
credentials?: RequestCredentials;
|
|
95
|
+
/**
|
|
96
|
+
* Called on a 401 before `signInUrl`. Resolve `true` (a token was refreshed,
|
|
97
|
+
* say) and the request is sent once more, headers read afresh; resolve
|
|
98
|
+
* `false` and the 401 takes its usual course. A request is retried at most
|
|
99
|
+
* once, and 401s that arrive together share one call — made with the
|
|
100
|
+
* context of the request that started it.
|
|
101
|
+
*
|
|
102
|
+
* `ctx` describes the request that was answered 401, with the headers it
|
|
103
|
+
* was actually sent with — so a host can refresh only when that request
|
|
104
|
+
* carried a token.
|
|
105
|
+
*/
|
|
106
|
+
onUnauthorized?: (ctx: UnauthorizedContext) => Promise<boolean>;
|
|
107
|
+
/**
|
|
108
|
+
* What a request that never reached the server throws. `wrap` (the
|
|
109
|
+
* default) turns it into `ApiError` with status `0`; `throw` rethrows what
|
|
110
|
+
* `fetch` threw (a `TypeError`), for a host that tells a network failure
|
|
111
|
+
* from an HTTP one by exactly that. A cancelled request is an `AbortError`
|
|
112
|
+
* either way.
|
|
113
|
+
*/
|
|
114
|
+
networkErrors?: "wrap" | "throw";
|
|
115
|
+
/**
|
|
116
|
+
* The message for an `ApiError` whose response carried none — the client
|
|
117
|
+
* has no language of its own. Unset, or answering `undefined`, leaves
|
|
118
|
+
* `message` empty; status `0` then keeps its built-in text.
|
|
119
|
+
*/
|
|
120
|
+
messages?: (status: number) => string | undefined;
|
|
56
121
|
};
|
|
57
122
|
type ApiClient = {
|
|
58
123
|
get: <T>(path: string, options?: GetOptions) => Promise<T>;
|
|
@@ -60,8 +125,17 @@ type ApiClient = {
|
|
|
60
125
|
put: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
|
|
61
126
|
patch: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
|
|
62
127
|
delete: <T>(path: string, options?: GetOptions) => Promise<T>;
|
|
128
|
+
/**
|
|
129
|
+
* The response as a `Blob` plus its headers, through the same error
|
|
130
|
+
* handling as every other method: a failure is still an `ApiError`.
|
|
131
|
+
*/
|
|
132
|
+
download: (path: string, options?: DownloadOptions) => Promise<DownloadResult>;
|
|
63
133
|
};
|
|
64
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* `FormData` goes out as multipart untouched; anything else is JSON-encoded.
|
|
136
|
+
* Either way the payload can be sent twice, which is what makes the retry
|
|
137
|
+
* after `onUnauthorized` safe — a stream body is never produced.
|
|
138
|
+
*/
|
|
65
139
|
type Body = FormData | object;
|
|
66
140
|
/** Laravel reads `key[]=a&key[]=b` as an array and '1'/'0' as booleans. */
|
|
67
141
|
declare function queryString(query: ApiQuery | undefined): string;
|
|
@@ -69,4 +143,4 @@ declare function queryString(query: ApiQuery | undefined): string;
|
|
|
69
143
|
declare function flattenErrors(errors: unknown): Record<string, string>;
|
|
70
144
|
declare function createClient(config: ClientConfig): ApiClient;
|
|
71
145
|
|
|
72
|
-
export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type GetOptions, type WriteOptions, createClient, flattenErrors, queryString };
|
|
146
|
+
export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type DownloadOptions, type DownloadResult, type GetOptions, type UnauthorizedContext, type WriteOptions, createClient, flattenErrors, queryString };
|
package/dist/laravel/index.js
CHANGED
|
@@ -3,16 +3,23 @@ var ApiError = class extends Error {
|
|
|
3
3
|
status;
|
|
4
4
|
/** Laravel's `{errors: {field: string[]}}`, first message per key; `{}` when absent. */
|
|
5
5
|
errors;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The error response's body, parsed as JSON — everything `message` and
|
|
8
|
+
* `errors` leave out: an `error` code, a `message` that is not a string,
|
|
9
|
+
* every message per field. `undefined` when there was no body, it was not
|
|
10
|
+
* JSON, or the server was never reached.
|
|
11
|
+
*/
|
|
12
|
+
body;
|
|
13
|
+
constructor(status, message, errors = {}, body) {
|
|
7
14
|
super(message);
|
|
8
15
|
this.name = "ApiError";
|
|
9
16
|
this.status = status;
|
|
10
17
|
this.errors = errors;
|
|
18
|
+
this.body = body;
|
|
11
19
|
}
|
|
12
20
|
};
|
|
13
21
|
|
|
14
22
|
// src/laravel/api-client.ts
|
|
15
|
-
var SESSION_EXPIRED = "Your session has expired. Reload the page and try again.";
|
|
16
23
|
var UNREACHABLE = "Could not reach the server. Check your connection and try again.";
|
|
17
24
|
var redirectingToSignIn = false;
|
|
18
25
|
function readCookie(name) {
|
|
@@ -75,18 +82,13 @@ function redirectToSignIn(signInUrl) {
|
|
|
75
82
|
}
|
|
76
83
|
function send(url, init, onUploadProgress) {
|
|
77
84
|
if (onUploadProgress === void 0) {
|
|
78
|
-
return fetch(url,
|
|
79
|
-
async (response) => ({
|
|
80
|
-
status: response.status,
|
|
81
|
-
statusText: response.statusText,
|
|
82
|
-
text: await response.text()
|
|
83
|
-
})
|
|
84
|
-
);
|
|
85
|
+
return fetch(url, init);
|
|
85
86
|
}
|
|
86
87
|
return new Promise((resolve, reject) => {
|
|
87
88
|
const xhr = new XMLHttpRequest();
|
|
88
89
|
const abort = () => xhr.abort();
|
|
89
90
|
xhr.open(init.method, url);
|
|
91
|
+
xhr.withCredentials = init.credentials === "include";
|
|
90
92
|
for (const [name, value] of Object.entries(init.headers)) {
|
|
91
93
|
xhr.setRequestHeader(name, value);
|
|
92
94
|
}
|
|
@@ -97,11 +99,16 @@ function send(url, init, onUploadProgress) {
|
|
|
97
99
|
};
|
|
98
100
|
xhr.onload = () => {
|
|
99
101
|
init.signal?.removeEventListener("abort", abort);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const empty = xhr.responseText === "" || [204, 205, 304].includes(xhr.status);
|
|
103
|
+
try {
|
|
104
|
+
resolve(
|
|
105
|
+
new Response(empty ? null : xhr.responseText, {
|
|
106
|
+
status: xhr.status
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
reject(error);
|
|
111
|
+
}
|
|
105
112
|
};
|
|
106
113
|
xhr.onerror = () => reject(new TypeError("Network request failed"));
|
|
107
114
|
xhr.onabort = () => reject(new DOMException("The request was aborted.", "AbortError"));
|
|
@@ -113,7 +120,23 @@ function send(url, init, onUploadProgress) {
|
|
|
113
120
|
xhr.send(init.body ?? null);
|
|
114
121
|
});
|
|
115
122
|
}
|
|
116
|
-
|
|
123
|
+
function unreachable(config, error) {
|
|
124
|
+
if (config.networkErrors === "throw") {
|
|
125
|
+
return error;
|
|
126
|
+
}
|
|
127
|
+
return new ApiError(0, config.messages?.(0) ?? UNREACHABLE);
|
|
128
|
+
}
|
|
129
|
+
async function read(response, as, config, signal) {
|
|
130
|
+
try {
|
|
131
|
+
return await response[as]();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (signal?.aborted) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
throw unreachable(config, error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function exchange({ config, unauthorized }, method, path, body, { signal, query, onUploadProgress }, retried = false) {
|
|
117
140
|
const headers = {
|
|
118
141
|
Accept: "application/json",
|
|
119
142
|
"X-Requested-With": "XMLHttpRequest"
|
|
@@ -129,51 +152,95 @@ async function request(config, method, path, body, { signal, query, onUploadProg
|
|
|
129
152
|
headers["Content-Type"] = "application/json";
|
|
130
153
|
payload = JSON.stringify(body);
|
|
131
154
|
}
|
|
155
|
+
Object.assign(headers, await config.headers?.());
|
|
156
|
+
const url = `${config.baseUrl}${path}${queryString(query)}`;
|
|
132
157
|
let response;
|
|
133
158
|
try {
|
|
134
159
|
response = await send(
|
|
135
|
-
|
|
136
|
-
{
|
|
160
|
+
url,
|
|
161
|
+
{
|
|
162
|
+
method,
|
|
163
|
+
headers,
|
|
164
|
+
body: payload,
|
|
165
|
+
signal,
|
|
166
|
+
credentials: config.credentials ?? "same-origin"
|
|
167
|
+
},
|
|
137
168
|
onUploadProgress
|
|
138
169
|
);
|
|
139
170
|
} catch (error) {
|
|
140
171
|
if (signal?.aborted) {
|
|
141
172
|
throw error;
|
|
142
173
|
}
|
|
143
|
-
throw
|
|
174
|
+
throw unreachable(config, error);
|
|
144
175
|
}
|
|
145
|
-
if (response.status
|
|
146
|
-
return
|
|
176
|
+
if (response.status >= 200 && response.status < 300) {
|
|
177
|
+
return response;
|
|
147
178
|
}
|
|
179
|
+
if (response.status === 401 && !retried && config.onUnauthorized !== void 0 && await unauthorized({ method, url, headers: { ...headers } })) {
|
|
180
|
+
return exchange(
|
|
181
|
+
{ config, unauthorized },
|
|
182
|
+
method,
|
|
183
|
+
path,
|
|
184
|
+
body,
|
|
185
|
+
{ signal, query, onUploadProgress },
|
|
186
|
+
true
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const text = await read(response, "text", config, signal);
|
|
148
190
|
let json = void 0;
|
|
149
191
|
try {
|
|
150
|
-
json =
|
|
192
|
+
json = text === "" ? void 0 : JSON.parse(text);
|
|
151
193
|
} catch {
|
|
152
194
|
}
|
|
153
|
-
if (response.status >= 200 && response.status < 300) {
|
|
154
|
-
return json;
|
|
155
|
-
}
|
|
156
195
|
const bag = json ?? {};
|
|
157
196
|
if (response.status === 401 && config.signInUrl !== void 0) {
|
|
158
197
|
redirectToSignIn(config.signInUrl);
|
|
159
198
|
}
|
|
160
|
-
if (response.status === 419) {
|
|
161
|
-
throw new ApiError(419, SESSION_EXPIRED);
|
|
162
|
-
}
|
|
163
199
|
throw new ApiError(
|
|
164
200
|
response.status,
|
|
165
|
-
typeof bag.message === "string" && bag.message !== "" ? bag.message : response.
|
|
166
|
-
flattenErrors(bag.errors)
|
|
201
|
+
typeof bag.message === "string" && bag.message !== "" ? bag.message : config.messages?.(response.status) ?? "",
|
|
202
|
+
flattenErrors(bag.errors),
|
|
203
|
+
json
|
|
167
204
|
);
|
|
168
205
|
}
|
|
206
|
+
async function request(client, method, path, body, options) {
|
|
207
|
+
const response = await exchange(client, method, path, body, options);
|
|
208
|
+
if (response.status === 204) {
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
const text = await read(response, "text", client.config, options.signal);
|
|
212
|
+
try {
|
|
213
|
+
return text === "" ? void 0 : JSON.parse(text);
|
|
214
|
+
} catch {
|
|
215
|
+
return void 0;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
169
218
|
function createClient(config) {
|
|
219
|
+
let refreshing;
|
|
220
|
+
const client = {
|
|
221
|
+
config,
|
|
222
|
+
unauthorized: (ctx) => refreshing ??= (config.onUnauthorized?.(ctx) ?? Promise.resolve(false)).finally(() => {
|
|
223
|
+
refreshing = void 0;
|
|
224
|
+
})
|
|
225
|
+
};
|
|
170
226
|
return {
|
|
171
|
-
get: (path, options = {}) => request(
|
|
172
|
-
post: (path, body, options = {}) => request(
|
|
173
|
-
put: (path, body, options = {}) => request(
|
|
174
|
-
patch: (path, body, options = {}) => request(
|
|
227
|
+
get: (path, options = {}) => request(client, "GET", path, void 0, options),
|
|
228
|
+
post: (path, body, options = {}) => request(client, "POST", path, body, options),
|
|
229
|
+
put: (path, body, options = {}) => request(client, "PUT", path, body, options),
|
|
230
|
+
patch: (path, body, options = {}) => request(client, "PATCH", path, body, options),
|
|
175
231
|
/** `query` for what identifies the targets (`?ids[]=1&ids[]=2`). */
|
|
176
|
-
delete: (path, options = {}) => request(
|
|
232
|
+
delete: (path, options = {}) => request(client, "DELETE", path, void 0, options),
|
|
233
|
+
download: async (path, { method = "GET", body, query, signal } = {}) => {
|
|
234
|
+
const response = await exchange(client, method, path, body, {
|
|
235
|
+
query,
|
|
236
|
+
signal
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
blob: await read(response, "blob", config, signal),
|
|
240
|
+
headers: response.headers,
|
|
241
|
+
status: response.status
|
|
242
|
+
};
|
|
243
|
+
}
|
|
177
244
|
};
|
|
178
245
|
}
|
|
179
246
|
|