@ikatec/digisac-api-sdk 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -7
- package/dist/core/BaseApiClient.cjs +25 -56
- package/dist/core/BaseApiClient.cjs.map +1 -1
- package/dist/core/BaseApiClient.d.ts +2 -11
- package/dist/core/BaseApiClient.d.ts.map +1 -1
- package/dist/core/BaseApiClient.mjs +27 -56
- package/dist/core/BaseApiClient.mjs.map +1 -1
- package/dist/core/errors.cjs +192 -0
- package/dist/core/errors.cjs.map +1 -0
- package/dist/core/errors.d.ts +117 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.mjs +177 -0
- package/dist/core/errors.mjs.map +1 -0
- package/dist/core/index.cjs +16 -2
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.mjs +3 -2
- package/dist/index.cjs +16 -2
- package/dist/index.mjs +3 -2
- package/package.json +1 -1
- package/src/core/BaseApiClient.test.ts +183 -22
- package/src/core/BaseApiClient.ts +37 -86
- package/src/core/BaseCrudApi.test-d.ts +48 -0
- package/src/core/BaseCrudApi.test.ts +150 -0
- package/src/core/errors.test-d.ts +14 -0
- package/src/core/errors.test.ts +389 -0
- package/src/core/errors.ts +284 -0
- package/src/core/index.ts +18 -2
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ApiConnectionError,
|
|
4
|
+
ApiError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
BadRequestError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
DigisacError,
|
|
9
|
+
InternalServerError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
PaymentRequiredError,
|
|
12
|
+
PermissionDeniedError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
UnprocessableEntityError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
parseFieldErrors,
|
|
17
|
+
} from './errors'
|
|
18
|
+
|
|
19
|
+
const fromResponse = (status: number, body?: unknown, headers: Record<string, string> = {}) =>
|
|
20
|
+
ApiError.fromResponse(status, 'Status Text', new Headers(headers), body)
|
|
21
|
+
|
|
22
|
+
describe('errors', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.useRealTimers()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// ── parseFieldErrors ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('parseFieldErrors', () => {
|
|
30
|
+
it('flattens one entry per message with location, field, path and code', () => {
|
|
31
|
+
expect(
|
|
32
|
+
parseFieldErrors({
|
|
33
|
+
body: {
|
|
34
|
+
title: {
|
|
35
|
+
messages: ['Required.', 'Too long.'],
|
|
36
|
+
types: ['required', 'hasLengthLesserThanOrEqual'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
query: { page: { messages: ['Should be a number.'], types: ['number'] } },
|
|
40
|
+
}),
|
|
41
|
+
).toEqual([
|
|
42
|
+
{
|
|
43
|
+
location: 'body',
|
|
44
|
+
field: 'title',
|
|
45
|
+
path: 'body.title',
|
|
46
|
+
message: 'Required.',
|
|
47
|
+
code: 'required',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
location: 'body',
|
|
51
|
+
field: 'title',
|
|
52
|
+
path: 'body.title',
|
|
53
|
+
message: 'Too long.',
|
|
54
|
+
code: 'hasLengthLesserThanOrEqual',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
location: 'query',
|
|
58
|
+
field: 'page',
|
|
59
|
+
path: 'query.page',
|
|
60
|
+
message: 'Should be a number.',
|
|
61
|
+
code: 'number',
|
|
62
|
+
},
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to the first type when there are fewer types than messages', () => {
|
|
67
|
+
expect(
|
|
68
|
+
parseFieldErrors({ body: { name: { messages: ['a', 'b'], types: ['required'] } } }),
|
|
69
|
+
).toMatchObject([{ code: 'required' }, { code: 'required' }])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses an empty code when there are no types', () => {
|
|
73
|
+
expect(parseFieldErrors({ body: { name: { messages: ['Required.'] } } })).toMatchObject([
|
|
74
|
+
{ message: 'Required.', code: '' },
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('stringifies non-string messages and types', () => {
|
|
79
|
+
expect(parseFieldErrors({ body: { n: { messages: [42], types: [true] } } })).toMatchObject([
|
|
80
|
+
{ message: '42', code: 'true' },
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('skips fields without a messages array', () => {
|
|
85
|
+
expect(
|
|
86
|
+
parseFieldErrors({
|
|
87
|
+
body: {
|
|
88
|
+
a: { messages: 'Required.' },
|
|
89
|
+
b: { types: ['required'] },
|
|
90
|
+
c: 'Required.',
|
|
91
|
+
d: null,
|
|
92
|
+
e: ['Required.'],
|
|
93
|
+
ok: { messages: ['Required.'], types: ['required'] },
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
).toEqual([
|
|
97
|
+
{ location: 'body', field: 'ok', path: 'body.ok', message: 'Required.', code: 'required' },
|
|
98
|
+
])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('skips locations that are not objects', () => {
|
|
102
|
+
expect(parseFieldErrors({ body: 'invalid', params: [], query: null })).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
['undefined', undefined],
|
|
107
|
+
['null', null],
|
|
108
|
+
['a string', 'invalid'],
|
|
109
|
+
['an array', [{ path: 'name', message: 'required' }]],
|
|
110
|
+
['an empty object', {}],
|
|
111
|
+
['locations without fields', { body: {} }],
|
|
112
|
+
])('returns null for %s', (_label, input) => {
|
|
113
|
+
expect(parseFieldErrors(input)).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ── Class hierarchy ──────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe('DigisacError', () => {
|
|
120
|
+
it('is an Error with name DigisacError', () => {
|
|
121
|
+
const error = new DigisacError('boom')
|
|
122
|
+
expect(error).toBeInstanceOf(Error)
|
|
123
|
+
expect(error).toMatchObject({ name: 'DigisacError', message: 'boom' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('keeps the cause', () => {
|
|
127
|
+
const cause = new Error('root')
|
|
128
|
+
expect(new DigisacError('boom', { cause }).cause).toBe(cause)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('ApiConnectionError', () => {
|
|
133
|
+
it('extends DigisacError but not ApiError', () => {
|
|
134
|
+
const error = new ApiConnectionError()
|
|
135
|
+
expect(error).toBeInstanceOf(DigisacError)
|
|
136
|
+
expect(error).not.toBeInstanceOf(ApiError)
|
|
137
|
+
expect(error).toMatchObject({ name: 'ApiConnectionError', message: 'Connection error.' })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('keeps the message and cause', () => {
|
|
141
|
+
const cause = new TypeError('fetch failed')
|
|
142
|
+
const error = new ApiConnectionError('Request failed', { cause })
|
|
143
|
+
expect(error).toMatchObject({ message: 'Request failed', cause })
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('ValidationError constructor', () => {
|
|
148
|
+
it('turns a null validationErrors into an empty array', () => {
|
|
149
|
+
const error = new ValidationError('Invalid', 'ValidationError', 400)
|
|
150
|
+
expect(error.validationErrors).toEqual([])
|
|
151
|
+
expect(error.name).toBe('ValidationError')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('ApiError constructor', () => {
|
|
156
|
+
it('keeps the positional arguments backward compatible', () => {
|
|
157
|
+
const error = new ApiError('Not found', 'NotFoundHttpError', 404)
|
|
158
|
+
expect(error).toBeInstanceOf(DigisacError)
|
|
159
|
+
expect(error).toMatchObject({
|
|
160
|
+
name: 'ApiError',
|
|
161
|
+
message: 'Not found',
|
|
162
|
+
errorClass: 'NotFoundHttpError',
|
|
163
|
+
status: 404,
|
|
164
|
+
validationErrors: null,
|
|
165
|
+
headers: undefined,
|
|
166
|
+
body: undefined,
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('accepts headers and body', () => {
|
|
171
|
+
const headers = new Headers({ 'X-Request-Id': 'req-1' })
|
|
172
|
+
const error = new ApiError('x', undefined, 500, null, { headers, body: 'raw' })
|
|
173
|
+
expect(error.headers).toBe(headers)
|
|
174
|
+
expect(error.body).toBe('raw')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// ── ApiError.fromResponse ────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe('ApiError.fromResponse', () => {
|
|
181
|
+
it.each([
|
|
182
|
+
[400, BadRequestError, 'BadRequestError'],
|
|
183
|
+
[401, AuthenticationError, 'AuthenticationError'],
|
|
184
|
+
[402, PaymentRequiredError, 'PaymentRequiredError'],
|
|
185
|
+
[403, PermissionDeniedError, 'PermissionDeniedError'],
|
|
186
|
+
[404, NotFoundError, 'NotFoundError'],
|
|
187
|
+
[409, ConflictError, 'ConflictError'],
|
|
188
|
+
[422, UnprocessableEntityError, 'UnprocessableEntityError'],
|
|
189
|
+
[429, RateLimitError, 'RateLimitError'],
|
|
190
|
+
[500, InternalServerError, 'InternalServerError'],
|
|
191
|
+
[502, InternalServerError, 'InternalServerError'],
|
|
192
|
+
[599, InternalServerError, 'InternalServerError'],
|
|
193
|
+
])('maps %i to %o', (status, ErrorClass, name) => {
|
|
194
|
+
const error = fromResponse(status)
|
|
195
|
+
expect(error).toBeInstanceOf(ErrorClass)
|
|
196
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
197
|
+
expect(error).toBeInstanceOf(DigisacError)
|
|
198
|
+
expect(error).toMatchObject({ name, status })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it.each([405, 408, 410, 418, 499])('maps unlisted status %i to a plain ApiError', (status) => {
|
|
202
|
+
const error = fromResponse(status)
|
|
203
|
+
expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype)
|
|
204
|
+
expect(error.name).toBe('ApiError')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('ValidationError', () => {
|
|
208
|
+
it('throws ValidationError for a 400 ValidationError, with its field errors', () => {
|
|
209
|
+
const error = fromResponse(400, {
|
|
210
|
+
error: 'ValidationError',
|
|
211
|
+
errors: { body: { name: { messages: ['Required.'], types: ['required'] } } },
|
|
212
|
+
})
|
|
213
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
214
|
+
expect(error).toBeInstanceOf(BadRequestError)
|
|
215
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
216
|
+
expect(error).toMatchObject({
|
|
217
|
+
name: 'ValidationError',
|
|
218
|
+
errorClass: 'ValidationError',
|
|
219
|
+
validationErrors: [{ path: 'body.name', code: 'required' }],
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('gives ValidationError an empty validationErrors when there are no field details', () => {
|
|
224
|
+
const error = fromResponse(400, { error: 'ValidationError', message: 'Invalid file size.' })
|
|
225
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
226
|
+
expect(error.validationErrors).toEqual([])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('keeps the status class for other backend names', () => {
|
|
230
|
+
const error = fromResponse(402, { error: 'PaymentRequired' })
|
|
231
|
+
expect(Object.getPrototypeOf(error)).toBe(PaymentRequiredError.prototype)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it.each([
|
|
235
|
+
['ValidationError', 422, UnprocessableEntityError],
|
|
236
|
+
['ValidationError', 500, InternalServerError],
|
|
237
|
+
])('uses the status class for %s with status %i', (errorClass, status, ErrorClass) => {
|
|
238
|
+
const error = fromResponse(status, { error: errorClass })
|
|
239
|
+
expect(Object.getPrototypeOf(error)).toBe(ErrorClass.prototype)
|
|
240
|
+
expect(error.errorClass).toBe(errorClass)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it.each(['NotFoundHttpError', 'HttpError', 'constructor', 'toString', '__proto__'])(
|
|
244
|
+
'uses the status class for %s',
|
|
245
|
+
(errorClass) => {
|
|
246
|
+
const error = fromResponse(404, { error: errorClass })
|
|
247
|
+
expect(Object.getPrototypeOf(error)).toBe(NotFoundError.prototype)
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('reads message, errorClass and field errors from the backend body', () => {
|
|
253
|
+
const body = {
|
|
254
|
+
error: 'ValidationError',
|
|
255
|
+
message: 'The given data was invalid.',
|
|
256
|
+
status: 400,
|
|
257
|
+
errors: { body: { name: { messages: ['Required.'], types: ['required'] } } },
|
|
258
|
+
}
|
|
259
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
260
|
+
const error = fromResponse(400, body, headers)
|
|
261
|
+
expect(error).toMatchObject({
|
|
262
|
+
message: 'The given data was invalid.',
|
|
263
|
+
errorClass: 'ValidationError',
|
|
264
|
+
validationErrors: [{ path: 'body.name', code: 'required' }],
|
|
265
|
+
body,
|
|
266
|
+
})
|
|
267
|
+
expect(error.headers?.get('Content-Type')).toBe('application/json')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('uses the error name as message when message is missing or empty', () => {
|
|
271
|
+
expect(fromResponse(403, { error: 'AccessDenied' }).message).toBe('AccessDenied')
|
|
272
|
+
expect(fromResponse(403, { error: 'AccessDenied', message: '' }).message).toBe('AccessDenied')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('uses the default message when the body has neither message nor error', () => {
|
|
276
|
+
expect(fromResponse(500, {}).message).toBe('HTTP Error 500: Status Text')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('ignores non-string message and error', () => {
|
|
280
|
+
const error = fromResponse(400, { error: 123, message: { text: 'x' } })
|
|
281
|
+
expect(error).toMatchObject({
|
|
282
|
+
message: 'HTTP Error 400: Status Text',
|
|
283
|
+
errorClass: undefined,
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it.each([
|
|
288
|
+
['undefined', undefined],
|
|
289
|
+
['a string', '<html>bad gateway</html>'],
|
|
290
|
+
['an array', ['error']],
|
|
291
|
+
['null', null],
|
|
292
|
+
])('uses the default message when the body is %s and keeps it as body', (_label, body) => {
|
|
293
|
+
const error = fromResponse(502, body)
|
|
294
|
+
expect(error).toMatchObject({
|
|
295
|
+
message: 'HTTP Error 502: Status Text',
|
|
296
|
+
errorClass: undefined,
|
|
297
|
+
validationErrors: null,
|
|
298
|
+
body,
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// ── RateLimitError.retryAfter ────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
describe('RateLimitError.retryAfter', () => {
|
|
306
|
+
const retryAfter = (value?: string) =>
|
|
307
|
+
(
|
|
308
|
+
fromResponse(
|
|
309
|
+
429,
|
|
310
|
+
undefined,
|
|
311
|
+
value === undefined ? {} : { 'Retry-After': value },
|
|
312
|
+
) as RateLimitError
|
|
313
|
+
).retryAfter
|
|
314
|
+
|
|
315
|
+
it('reads delay seconds', () => {
|
|
316
|
+
expect(retryAfter('120')).toBe(120)
|
|
317
|
+
expect(retryAfter('0')).toBe(0)
|
|
318
|
+
expect(retryAfter('1.5')).toBe(1.5)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('clamps negative seconds to 0', () => {
|
|
322
|
+
expect(retryAfter('-5')).toBe(0)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('reads an HTTP date as seconds from now, rounded up', () => {
|
|
326
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00.500Z') })
|
|
327
|
+
expect(retryAfter('Thu, 01 Jan 2026 00:00:30 GMT')).toBe(30)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('returns 0 for an HTTP date in the past', () => {
|
|
331
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
|
|
332
|
+
expect(retryAfter('Wed, 31 Dec 2025 23:59:00 GMT')).toBe(0)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('returns null when the header is missing, empty or invalid', () => {
|
|
336
|
+
expect(retryAfter()).toBeNull()
|
|
337
|
+
expect(retryAfter('')).toBeNull()
|
|
338
|
+
expect(retryAfter('soon')).toBeNull()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('returns null for a whitespace-only header', () => {
|
|
342
|
+
expect(retryAfter(' ')).toBeNull()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('X-RateLimit-Reset (Digisac backend)', () => {
|
|
346
|
+
const withHeaders = (headers: Record<string, string>) =>
|
|
347
|
+
(fromResponse(429, undefined, headers) as RateLimitError).retryAfter
|
|
348
|
+
|
|
349
|
+
it('reads a Unix time in seconds as seconds from now, rounded up', () => {
|
|
350
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00.500Z') })
|
|
351
|
+
const resetsAt = Date.parse('2026-01-01T00:10:00Z') / 1000
|
|
352
|
+
expect(withHeaders({ 'X-RateLimit-Reset': String(resetsAt) })).toBe(600)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('returns 0 for a reset time in the past', () => {
|
|
356
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
|
|
357
|
+
const resetsAt = Date.parse('2025-12-31T23:00:00Z') / 1000
|
|
358
|
+
expect(withHeaders({ 'X-RateLimit-Reset': String(resetsAt) })).toBe(0)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('reads small values as delay seconds', () => {
|
|
362
|
+
expect(withHeaders({ 'X-RateLimit-Reset': '45' })).toBe(45)
|
|
363
|
+
expect(withHeaders({ 'X-RateLimit-Reset': '0' })).toBe(0)
|
|
364
|
+
expect(withHeaders({ 'X-RateLimit-Reset': '-3' })).toBe(0)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('matches the header name case-insensitively', () => {
|
|
368
|
+
expect(withHeaders({ 'x-ratelimit-reset': '45' })).toBe(45)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('prefers Retry-After when both headers are present', () => {
|
|
372
|
+
expect(withHeaders({ 'Retry-After': '10', 'X-RateLimit-Reset': '45' })).toBe(10)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('falls back to X-RateLimit-Reset when Retry-After is invalid', () => {
|
|
376
|
+
expect(withHeaders({ 'Retry-After': 'soon', 'X-RateLimit-Reset': '45' })).toBe(45)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('returns null when the header is empty or not a number', () => {
|
|
380
|
+
expect(withHeaders({ 'X-RateLimit-Reset': '' })).toBeNull()
|
|
381
|
+
expect(withHeaders({ 'X-RateLimit-Reset': 'tomorrow' })).toBeNull()
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('returns null when the error has no headers', () => {
|
|
386
|
+
expect(new RateLimitError('x', undefined, 429).retryAfter).toBeNull()
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
})
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field-level error from the backend `validate` middleware — one entry per failed rule.
|
|
3
|
+
*/
|
|
4
|
+
export interface FieldError {
|
|
5
|
+
/** Where the field was read from: `body`, `params`, `query`, `headers` or `cookies`. */
|
|
6
|
+
location: string
|
|
7
|
+
/** The field name, e.g. `name`. */
|
|
8
|
+
field: string
|
|
9
|
+
/** `<location>.<field>`, e.g. `body.name`. */
|
|
10
|
+
path: string
|
|
11
|
+
message: string
|
|
12
|
+
/** The failed rule, e.g. `required` or `hasLengthLesserThanOrEqual`. */
|
|
13
|
+
code: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The backend error name sent as `error` in the body (`ApiError.errorClass`). Known names are listed
|
|
18
|
+
* for autocomplete; any other string is still accepted. `HttpError` is the generic one: most 400s
|
|
19
|
+
* and every unexpected 500.
|
|
20
|
+
*/
|
|
21
|
+
export type ErrorClassName =
|
|
22
|
+
| 'HttpError'
|
|
23
|
+
| 'ValidationError'
|
|
24
|
+
| 'BadRequestHttpError'
|
|
25
|
+
| 'UnauthorizedHttpError'
|
|
26
|
+
| 'PaymentRequired'
|
|
27
|
+
| 'ForbiddenHttpError'
|
|
28
|
+
| 'NotFoundHttpError'
|
|
29
|
+
| 'TooManyRequestsHttpError'
|
|
30
|
+
| (string & {})
|
|
31
|
+
|
|
32
|
+
export interface ApiErrorOptions {
|
|
33
|
+
/** Response headers. */
|
|
34
|
+
headers?: Headers
|
|
35
|
+
/** The parsed JSON error body, or the raw text when it is not JSON. */
|
|
36
|
+
body?: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
40
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Flattens the backend `errors` object — `{ <location>: { <field>: { messages: string[], types: string[] } } }`
|
|
44
|
+
* — into one {@link FieldError} per message. Returns `null` when there are no field errors.
|
|
45
|
+
*/
|
|
46
|
+
export function parseFieldErrors(errors: unknown): FieldError[] | null {
|
|
47
|
+
if (!isRecord(errors)) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parsed: FieldError[] = []
|
|
52
|
+
for (const [location, fields] of Object.entries(errors)) {
|
|
53
|
+
if (!isRecord(fields)) {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
for (const [field, detail] of Object.entries(fields)) {
|
|
57
|
+
if (!isRecord(detail)) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
const messages = Array.isArray(detail.messages) ? detail.messages.map(String) : []
|
|
61
|
+
const types = Array.isArray(detail.types) ? detail.types.map(String) : []
|
|
62
|
+
messages.forEach((message, index) => {
|
|
63
|
+
parsed.push({
|
|
64
|
+
location,
|
|
65
|
+
field,
|
|
66
|
+
path: `${location}.${field}`,
|
|
67
|
+
message,
|
|
68
|
+
code: types[index] ?? types[0] ?? '',
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return parsed.length ? parsed : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Base class of every error thrown by the SDK. */
|
|
78
|
+
export class DigisacError extends Error {
|
|
79
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
80
|
+
super(message, options)
|
|
81
|
+
this.name = 'DigisacError'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The request never got an HTTP response (network failure, DNS, connection refused, ...). */
|
|
86
|
+
export class ApiConnectionError extends DigisacError {
|
|
87
|
+
constructor(message = 'Connection error.', options?: { cause?: unknown }) {
|
|
88
|
+
super(message, options)
|
|
89
|
+
this.name = 'ApiConnectionError'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The API answered with a non-2xx status. Thrown as the subclass matching the status
|
|
95
|
+
* (`NotFoundError`, `BadRequestError`, ...) — or `ValidationError` for a 400 field validation
|
|
96
|
+
* failure — so callers can narrow with `instanceof`.
|
|
97
|
+
*/
|
|
98
|
+
export class ApiError extends DigisacError {
|
|
99
|
+
/** The backend error name (`error` in the body), e.g. `ValidationError` or `PaymentRequired`. */
|
|
100
|
+
public readonly errorClass?: ErrorClassName | undefined
|
|
101
|
+
public readonly status?: number | undefined
|
|
102
|
+
/** Field errors of a 400 `ValidationError`; `null` when the response carries none. */
|
|
103
|
+
public readonly validationErrors: FieldError[] | null
|
|
104
|
+
public readonly headers?: Headers | undefined
|
|
105
|
+
/** The parsed JSON error body (`{ error, message, status, errors?, extra? }`), or the raw text. */
|
|
106
|
+
public readonly body?: unknown
|
|
107
|
+
|
|
108
|
+
constructor(
|
|
109
|
+
message: string,
|
|
110
|
+
errorClass?: ErrorClassName,
|
|
111
|
+
status?: number,
|
|
112
|
+
validationErrors: FieldError[] | null = null,
|
|
113
|
+
options: ApiErrorOptions = {},
|
|
114
|
+
) {
|
|
115
|
+
super(message)
|
|
116
|
+
this.name = 'ApiError'
|
|
117
|
+
this.errorClass = errorClass
|
|
118
|
+
this.status = status
|
|
119
|
+
this.validationErrors = validationErrors
|
|
120
|
+
this.headers = options.headers
|
|
121
|
+
this.body = options.body
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Builds the `ApiError` subclass for a response and its (already read) body: `ValidationError`
|
|
126
|
+
* for a 400 `ValidationError`, else the class of the status.
|
|
127
|
+
*/
|
|
128
|
+
static fromResponse(
|
|
129
|
+
status: number,
|
|
130
|
+
statusText: string,
|
|
131
|
+
headers: Headers,
|
|
132
|
+
body: unknown,
|
|
133
|
+
): ApiError {
|
|
134
|
+
let message = `HTTP Error ${status}: ${statusText}`
|
|
135
|
+
let errorClass: string | undefined
|
|
136
|
+
let validationErrors: FieldError[] | null = null
|
|
137
|
+
|
|
138
|
+
if (isRecord(body)) {
|
|
139
|
+
const apiMessage = typeof body.message === 'string' ? body.message : undefined
|
|
140
|
+
errorClass = typeof body.error === 'string' ? body.error : undefined
|
|
141
|
+
message = apiMessage || errorClass || message
|
|
142
|
+
validationErrors = parseFieldErrors(body.errors)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ErrorClass = errorClassFor(status, errorClass)
|
|
146
|
+
return new ErrorClass(message, errorClass, status, validationErrors, { headers, body })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 400 (see {@link ValidationError} for field validation failures). */
|
|
151
|
+
export class BadRequestError extends ApiError {
|
|
152
|
+
override name = 'BadRequestError'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 400 `ValidationError` — the request failed field validation. `validationErrors` is always an
|
|
157
|
+
* array here (empty when the backend sent no field details).
|
|
158
|
+
*/
|
|
159
|
+
export class ValidationError extends BadRequestError {
|
|
160
|
+
override name = 'ValidationError'
|
|
161
|
+
declare public readonly validationErrors: FieldError[]
|
|
162
|
+
|
|
163
|
+
constructor(
|
|
164
|
+
message: string,
|
|
165
|
+
errorClass?: ErrorClassName,
|
|
166
|
+
status?: number,
|
|
167
|
+
validationErrors: FieldError[] | null = null,
|
|
168
|
+
options: ApiErrorOptions = {},
|
|
169
|
+
) {
|
|
170
|
+
super(message, errorClass, status, validationErrors ?? [], options)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 401 — missing or invalid access token. */
|
|
175
|
+
export class AuthenticationError extends ApiError {
|
|
176
|
+
override name = 'AuthenticationError'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 402 — the account is out of credits or over a plan limit. */
|
|
180
|
+
export class PaymentRequiredError extends ApiError {
|
|
181
|
+
override name = 'PaymentRequiredError'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 403 — the user lacks the permission for this action. */
|
|
185
|
+
export class PermissionDeniedError extends ApiError {
|
|
186
|
+
override name = 'PermissionDeniedError'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 404 */
|
|
190
|
+
export class NotFoundError extends ApiError {
|
|
191
|
+
override name = 'NotFoundError'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 409 */
|
|
195
|
+
export class ConflictError extends ApiError {
|
|
196
|
+
override name = 'ConflictError'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 422 */
|
|
200
|
+
export class UnprocessableEntityError extends ApiError {
|
|
201
|
+
override name = 'UnprocessableEntityError'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 429 */
|
|
205
|
+
export class RateLimitError extends ApiError {
|
|
206
|
+
override name = 'RateLimitError'
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Seconds to wait before retrying, or `null` when the response does not say.
|
|
210
|
+
*
|
|
211
|
+
* Reads the standard `Retry-After` header (delay seconds or HTTP date) first, then the
|
|
212
|
+
* `X-RateLimit-Reset` header the Digisac backend sends (Unix time in seconds).
|
|
213
|
+
*/
|
|
214
|
+
get retryAfter(): number | null {
|
|
215
|
+
return (
|
|
216
|
+
parseRetryAfter(this.headers?.get('Retry-After')) ??
|
|
217
|
+
parseRateLimitReset(this.headers?.get('X-RateLimit-Reset'))
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const secondsUntil = (epochMs: number): number =>
|
|
223
|
+
Math.max(0, Math.ceil((epochMs - Date.now()) / 1000))
|
|
224
|
+
|
|
225
|
+
/** `Retry-After`: delay seconds or an HTTP date. */
|
|
226
|
+
function parseRetryAfter(value: string | null | undefined): number | null {
|
|
227
|
+
if (!value?.trim()) {
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
const seconds = Number(value)
|
|
231
|
+
if (Number.isFinite(seconds)) {
|
|
232
|
+
return Math.max(0, seconds)
|
|
233
|
+
}
|
|
234
|
+
const date = Date.parse(value)
|
|
235
|
+
return Number.isNaN(date) ? null : secondsUntil(date)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `X-RateLimit-Reset`: Unix time in seconds (what the Digisac backend sends). Small values are
|
|
240
|
+
* read as delay seconds instead, the other convention in use for this header.
|
|
241
|
+
*/
|
|
242
|
+
function parseRateLimitReset(value: string | null | undefined): number | null {
|
|
243
|
+
if (!value?.trim()) {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
const reset = Number(value)
|
|
247
|
+
if (!Number.isFinite(reset)) {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
// Anything before 2001-09-09 (1e9 s) can't be a reset time, so it is a delay.
|
|
251
|
+
return reset < 1e9 ? Math.max(0, reset) : secondsUntil(reset * 1000)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 5xx */
|
|
255
|
+
export class InternalServerError extends ApiError {
|
|
256
|
+
override name = 'InternalServerError'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function errorClassFor(status: number, errorClass: string | undefined): typeof ApiError {
|
|
260
|
+
if (status === 400 && errorClass === 'ValidationError') {
|
|
261
|
+
return ValidationError
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
switch (status) {
|
|
265
|
+
case 400:
|
|
266
|
+
return BadRequestError
|
|
267
|
+
case 401:
|
|
268
|
+
return AuthenticationError
|
|
269
|
+
case 402:
|
|
270
|
+
return PaymentRequiredError
|
|
271
|
+
case 403:
|
|
272
|
+
return PermissionDeniedError
|
|
273
|
+
case 404:
|
|
274
|
+
return NotFoundError
|
|
275
|
+
case 409:
|
|
276
|
+
return ConflictError
|
|
277
|
+
case 422:
|
|
278
|
+
return UnprocessableEntityError
|
|
279
|
+
case 429:
|
|
280
|
+
return RateLimitError
|
|
281
|
+
default:
|
|
282
|
+
return status >= 500 ? InternalServerError : ApiError
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
export type { ApiClient, HttpMethod } from './ApiClient'
|
|
2
|
-
export { BaseApiClient
|
|
3
|
-
export
|
|
2
|
+
export { BaseApiClient } from './BaseApiClient'
|
|
3
|
+
export {
|
|
4
|
+
DigisacError,
|
|
5
|
+
ApiConnectionError,
|
|
6
|
+
ApiError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
PaymentRequiredError,
|
|
11
|
+
PermissionDeniedError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
UnprocessableEntityError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
InternalServerError,
|
|
17
|
+
parseFieldErrors,
|
|
18
|
+
} from './errors'
|
|
19
|
+
export type { FieldError, ErrorClassName, ApiErrorOptions } from './errors'
|
|
4
20
|
export { BaseCrudApi } from './BaseCrudApi'
|
|
5
21
|
export { ArchivableCrudApi } from './ArchivableCrudApi'
|
|
6
22
|
export type { WhereClause, IncludeItem, ListQuery, GetByIdQuery, Paginated } from './types'
|