@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
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { BaseApiClient
|
|
2
|
+
import { BaseApiClient } from './BaseApiClient'
|
|
3
|
+
import {
|
|
4
|
+
ApiConnectionError,
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
DigisacError,
|
|
10
|
+
InternalServerError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PaymentRequiredError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
UnprocessableEntityError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
} from './errors'
|
|
3
18
|
|
|
4
19
|
function mockFetch(response: {
|
|
5
20
|
ok: boolean
|
|
@@ -216,21 +231,7 @@ describe('BaseApiClient', () => {
|
|
|
216
231
|
})
|
|
217
232
|
})
|
|
218
233
|
|
|
219
|
-
it('
|
|
220
|
-
const validationErrors = [{ path: 'name', message: 'required', code: 'REQUIRED' }]
|
|
221
|
-
mockFetch({
|
|
222
|
-
ok: false,
|
|
223
|
-
status: 422,
|
|
224
|
-
statusText: 'Unprocessable Entity',
|
|
225
|
-
headers: { 'Content-Type': 'application/json' },
|
|
226
|
-
body: JSON.stringify({ message: 'Bad input', validationErrors }),
|
|
227
|
-
})
|
|
228
|
-
await expect(client.get('/fail')).rejects.toMatchObject({
|
|
229
|
-
validationErrors,
|
|
230
|
-
})
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
it('maps the Digisac field errors (errors.<location>.<field>) into validationErrors', async () => {
|
|
234
|
+
it('maps the backend field errors (errors.<location>.<field>) into validationErrors', async () => {
|
|
234
235
|
mockFetch({
|
|
235
236
|
ok: false,
|
|
236
237
|
status: 400,
|
|
@@ -252,23 +253,58 @@ describe('BaseApiClient', () => {
|
|
|
252
253
|
},
|
|
253
254
|
}),
|
|
254
255
|
})
|
|
255
|
-
await
|
|
256
|
+
const error = await client.post('/fail', {}).catch((e: unknown) => e)
|
|
257
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
258
|
+
expect(error).toBeInstanceOf(BadRequestError)
|
|
259
|
+
expect(error).toMatchObject({
|
|
260
|
+
message: 'The given data was invalid.',
|
|
256
261
|
errorClass: 'ValidationError',
|
|
257
262
|
status: 400,
|
|
258
263
|
validationErrors: [
|
|
259
|
-
{ path: 'body.name', message: 'Required.', code: 'required' },
|
|
260
|
-
{ path: 'body.title', message: 'Required.', code: 'required' },
|
|
261
264
|
{
|
|
265
|
+
location: 'body',
|
|
266
|
+
field: 'name',
|
|
267
|
+
path: 'body.name',
|
|
268
|
+
message: 'Required.',
|
|
269
|
+
code: 'required',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
location: 'body',
|
|
273
|
+
field: 'title',
|
|
274
|
+
path: 'body.title',
|
|
275
|
+
message: 'Required.',
|
|
276
|
+
code: 'required',
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
location: 'body',
|
|
280
|
+
field: 'title',
|
|
262
281
|
path: 'body.title',
|
|
263
282
|
message: 'Should contain lesser than or equals 255 characters.',
|
|
264
283
|
code: 'hasLengthLesserThanOrEqual',
|
|
265
284
|
},
|
|
266
|
-
{
|
|
285
|
+
{
|
|
286
|
+
location: 'params',
|
|
287
|
+
field: 'contactId',
|
|
288
|
+
path: 'params.contactId',
|
|
289
|
+
message: 'Should be an UUID v4 string.',
|
|
290
|
+
code: 'uuid4',
|
|
291
|
+
},
|
|
267
292
|
],
|
|
268
293
|
})
|
|
269
294
|
})
|
|
270
295
|
|
|
271
296
|
it('keeps validationErrors null when the error body has no field errors', async () => {
|
|
297
|
+
mockFetch({
|
|
298
|
+
ok: false,
|
|
299
|
+
status: 400,
|
|
300
|
+
statusText: 'Bad Request',
|
|
301
|
+
headers: { 'Content-Type': 'application/json' },
|
|
302
|
+
body: JSON.stringify({ error: 'BadRequestHttpError', message: 'Já existe', status: 400 }),
|
|
303
|
+
})
|
|
304
|
+
await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('ignores malformed field errors', async () => {
|
|
272
308
|
mockFetch({
|
|
273
309
|
ok: false,
|
|
274
310
|
status: 400,
|
|
@@ -276,13 +312,138 @@ describe('BaseApiClient', () => {
|
|
|
276
312
|
headers: { 'Content-Type': 'application/json' },
|
|
277
313
|
body: JSON.stringify({
|
|
278
314
|
error: 'BadRequestHttpError',
|
|
279
|
-
|
|
280
|
-
status: 400,
|
|
315
|
+
errors: { body: { name: 'Required.', title: { messages: 'Required.' } }, query: [] },
|
|
281
316
|
}),
|
|
282
317
|
})
|
|
283
318
|
await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
|
|
284
319
|
})
|
|
285
320
|
|
|
321
|
+
it.each([
|
|
322
|
+
[400, BadRequestError],
|
|
323
|
+
[401, AuthenticationError],
|
|
324
|
+
[402, PaymentRequiredError],
|
|
325
|
+
[403, PermissionDeniedError],
|
|
326
|
+
[404, NotFoundError],
|
|
327
|
+
[409, ConflictError],
|
|
328
|
+
[422, UnprocessableEntityError],
|
|
329
|
+
[429, RateLimitError],
|
|
330
|
+
[500, InternalServerError],
|
|
331
|
+
[503, InternalServerError],
|
|
332
|
+
])('throws the matching subclass for status %i', async (status, ErrorClass) => {
|
|
333
|
+
mockFetch({ ok: false, status, statusText: 'Error' })
|
|
334
|
+
const error = await client.get('/fail').catch((e: unknown) => e)
|
|
335
|
+
expect(error).toBeInstanceOf(ErrorClass)
|
|
336
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
337
|
+
expect(error).toBeInstanceOf(DigisacError)
|
|
338
|
+
expect(error).toMatchObject({ name: ErrorClass.name, status })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('throws a plain ApiError for unmapped statuses', async () => {
|
|
342
|
+
mockFetch({ ok: false, status: 418, statusText: "I'm a teapot" })
|
|
343
|
+
const error = await client.get('/fail').catch((e: unknown) => e)
|
|
344
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
345
|
+
expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype)
|
|
346
|
+
expect(error).toMatchObject({ name: 'ApiError', status: 418 })
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('exposes the response headers and the parsed body', async () => {
|
|
350
|
+
const body = { error: 'PaymentRequired', message: 'Limit', status: 402, extra: { limit: 10 } }
|
|
351
|
+
mockFetch({
|
|
352
|
+
ok: false,
|
|
353
|
+
status: 402,
|
|
354
|
+
statusText: 'Payment Required',
|
|
355
|
+
headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'req-1' },
|
|
356
|
+
body: JSON.stringify(body),
|
|
357
|
+
})
|
|
358
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as ApiError
|
|
359
|
+
expect(error.body).toEqual(body)
|
|
360
|
+
expect(error.headers?.get('X-Request-Id')).toBe('req-1')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('exposes a non-JSON error body as text', async () => {
|
|
364
|
+
mockFetch({
|
|
365
|
+
ok: false,
|
|
366
|
+
status: 502,
|
|
367
|
+
statusText: 'Bad Gateway',
|
|
368
|
+
body: '<html>bad gateway</html>',
|
|
369
|
+
})
|
|
370
|
+
await expect(client.get('/fail')).rejects.toMatchObject({
|
|
371
|
+
message: 'HTTP Error 502: Bad Gateway',
|
|
372
|
+
body: '<html>bad gateway</html>',
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('reads Retry-After (seconds) on RateLimitError', async () => {
|
|
377
|
+
mockFetch({
|
|
378
|
+
ok: false,
|
|
379
|
+
status: 429,
|
|
380
|
+
statusText: 'Too Many Requests',
|
|
381
|
+
headers: { 'Retry-After': '30' },
|
|
382
|
+
})
|
|
383
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
384
|
+
expect(error.retryAfter).toBe(30)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('reads Retry-After (HTTP date) on RateLimitError', async () => {
|
|
388
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
|
|
389
|
+
try {
|
|
390
|
+
mockFetch({
|
|
391
|
+
ok: false,
|
|
392
|
+
status: 429,
|
|
393
|
+
statusText: 'Too Many Requests',
|
|
394
|
+
headers: { 'Retry-After': 'Thu, 01 Jan 2026 00:01:00 GMT' },
|
|
395
|
+
})
|
|
396
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
397
|
+
expect(error.retryAfter).toBe(60)
|
|
398
|
+
} finally {
|
|
399
|
+
vi.useRealTimers()
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('reads X-RateLimit-Reset (Unix seconds) on RateLimitError', async () => {
|
|
404
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
|
|
405
|
+
try {
|
|
406
|
+
mockFetch({
|
|
407
|
+
ok: false,
|
|
408
|
+
status: 429,
|
|
409
|
+
statusText: 'Too Many Requests',
|
|
410
|
+
headers: { 'X-RateLimit-Reset': String(Date.parse('2026-01-01T01:00:00Z') / 1000) },
|
|
411
|
+
})
|
|
412
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
413
|
+
expect(error.retryAfter).toBe(3600)
|
|
414
|
+
} finally {
|
|
415
|
+
vi.useRealTimers()
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('returns null retryAfter when the header is absent', async () => {
|
|
420
|
+
mockFetch({ ok: false, status: 429, statusText: 'Too Many Requests' })
|
|
421
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
422
|
+
expect(error.retryAfter).toBeNull()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('wraps network failures in ApiConnectionError', async () => {
|
|
426
|
+
const cause = new TypeError('fetch failed')
|
|
427
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
|
|
428
|
+
const error = await client.get('/fail').catch((e: unknown) => e)
|
|
429
|
+
expect(error).toBeInstanceOf(ApiConnectionError)
|
|
430
|
+
expect(error).toBeInstanceOf(DigisacError)
|
|
431
|
+
expect(error).not.toBeInstanceOf(ApiError)
|
|
432
|
+
expect(error).toMatchObject({
|
|
433
|
+
message: 'Request to GET https://api.example.com/fail failed: fetch failed',
|
|
434
|
+
cause,
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('does not report a body serialization failure as a connection error', async () => {
|
|
439
|
+
const spy = vi.spyOn(globalThis, 'fetch')
|
|
440
|
+
const circular: Record<string, unknown> = {}
|
|
441
|
+
circular.self = circular
|
|
442
|
+
const error = await client.post('/fail', circular).catch((e: unknown) => e)
|
|
443
|
+
expect(error).toBeInstanceOf(TypeError)
|
|
444
|
+
expect(spy).not.toHaveBeenCalled()
|
|
445
|
+
})
|
|
446
|
+
|
|
286
447
|
it('falls back to default message when JSON parsing fails', async () => {
|
|
287
448
|
mockFetch({
|
|
288
449
|
ok: false,
|
|
@@ -1,65 +1,5 @@
|
|
|
1
1
|
import type { ApiClient, HttpMethod } from './ApiClient'
|
|
2
|
-
|
|
3
|
-
export interface ValidationError {
|
|
4
|
-
path: string
|
|
5
|
-
message: string
|
|
6
|
-
code: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Digisac field errors: `{ errors: { <location>: { <field>: { messages: string[], types: string[] } } } }`,
|
|
11
|
-
* where location is `body`, `params`, `query`, `headers` or `cookies` (backend `validate` middleware).
|
|
12
|
-
*/
|
|
13
|
-
type DigisacFieldErrors = Record<string, Record<string, { messages?: unknown; types?: unknown }>>
|
|
14
|
-
|
|
15
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
16
|
-
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Flattens the Digisac `errors` object into `ValidationError[]` — one entry per message, with
|
|
20
|
-
* `path` = `<location>.<field>` (e.g. `body.name`) and `code` = the rule type (e.g. `required`).
|
|
21
|
-
* Returns `null` when the body carries no field errors.
|
|
22
|
-
*/
|
|
23
|
-
function parseValidationErrors(errors: unknown): ValidationError[] | null {
|
|
24
|
-
if (!isRecord(errors)) {
|
|
25
|
-
return null
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const parsed: ValidationError[] = []
|
|
29
|
-
for (const [location, fields] of Object.entries(errors as DigisacFieldErrors)) {
|
|
30
|
-
if (!isRecord(fields)) {
|
|
31
|
-
continue
|
|
32
|
-
}
|
|
33
|
-
for (const [field, detail] of Object.entries(fields)) {
|
|
34
|
-
const messages = Array.isArray(detail?.messages) ? detail.messages.map(String) : []
|
|
35
|
-
const types = Array.isArray(detail?.types) ? detail.types.map(String) : []
|
|
36
|
-
messages.forEach((message, index) => {
|
|
37
|
-
parsed.push({ path: `${location}.${field}`, message, code: types[index] ?? types[0] ?? '' })
|
|
38
|
-
})
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return parsed.length ? parsed : null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class ApiError extends Error {
|
|
46
|
-
public readonly errorClass?: string | undefined
|
|
47
|
-
public readonly status?: number | undefined
|
|
48
|
-
public readonly validationErrors: ValidationError[] | null
|
|
49
|
-
|
|
50
|
-
constructor(
|
|
51
|
-
message: string,
|
|
52
|
-
errorClass?: string,
|
|
53
|
-
status?: number,
|
|
54
|
-
validationErrors: ValidationError[] | null = null,
|
|
55
|
-
) {
|
|
56
|
-
super(message)
|
|
57
|
-
this.name = 'ApiError'
|
|
58
|
-
this.errorClass = errorClass
|
|
59
|
-
this.status = status
|
|
60
|
-
this.validationErrors = validationErrors
|
|
61
|
-
}
|
|
62
|
-
}
|
|
2
|
+
import { ApiConnectionError, ApiError } from './errors'
|
|
63
3
|
|
|
64
4
|
export class BaseApiClient implements ApiClient {
|
|
65
5
|
protected readonly baseUrl: string
|
|
@@ -82,8 +22,7 @@ export class BaseApiClient implements ApiClient {
|
|
|
82
22
|
headers?: Record<string, string>,
|
|
83
23
|
): Promise<TResponse> {
|
|
84
24
|
const url = `${this.baseUrl}/${endpoint.replace(/^\//, '')}`
|
|
85
|
-
|
|
86
|
-
const response = await fetch(url, {
|
|
25
|
+
const init: RequestInit = {
|
|
87
26
|
method,
|
|
88
27
|
headers: {
|
|
89
28
|
'Content-Type': 'application/json',
|
|
@@ -91,35 +30,29 @@ export class BaseApiClient implements ApiClient {
|
|
|
91
30
|
...headers,
|
|
92
31
|
},
|
|
93
32
|
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
94
|
-
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let response: Response
|
|
36
|
+
try {
|
|
37
|
+
response = await fetch(url, init)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
40
|
+
throw new ApiConnectionError(`Request to ${method} ${url} failed: ${reason}`, {
|
|
41
|
+
cause: error,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
95
44
|
|
|
96
45
|
return this.handleResponse<TResponse>(response)
|
|
97
46
|
}
|
|
98
47
|
|
|
99
48
|
protected async handleResponse<TResponse>(response: Response): Promise<TResponse> {
|
|
100
49
|
if (!response.ok) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (isJson) {
|
|
109
|
-
try {
|
|
110
|
-
const errorText = await response.text()
|
|
111
|
-
if (errorText) {
|
|
112
|
-
const errorData = JSON.parse(errorText)
|
|
113
|
-
errorMessage = errorData.message || errorData.error || errorMessage
|
|
114
|
-
errorClass = errorData.error
|
|
115
|
-
validationErrors = errorData.validationErrors ?? parseValidationErrors(errorData.errors)
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
// If JSON parsing fails, use the default error message
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
throw new ApiError(errorMessage, errorClass, response.status, validationErrors)
|
|
50
|
+
throw ApiError.fromResponse(
|
|
51
|
+
response.status,
|
|
52
|
+
response.statusText,
|
|
53
|
+
response.headers,
|
|
54
|
+
await this.readErrorBody(response),
|
|
55
|
+
)
|
|
123
56
|
}
|
|
124
57
|
|
|
125
58
|
// 204 No Content never has a body; short-circuit before reading the stream.
|
|
@@ -151,6 +84,24 @@ export class BaseApiClient implements ApiClient {
|
|
|
151
84
|
}
|
|
152
85
|
}
|
|
153
86
|
|
|
87
|
+
/** Reads an error body: parsed JSON when possible, the raw text otherwise, `undefined` when empty. */
|
|
88
|
+
protected async readErrorBody(response: Response): Promise<unknown> {
|
|
89
|
+
let text: string
|
|
90
|
+
try {
|
|
91
|
+
text = await response.text()
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
if (!text) {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text)
|
|
100
|
+
} catch {
|
|
101
|
+
return text
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
154
105
|
get<TResponse = unknown>(endpoint: string, headers?: Record<string, string>): Promise<TResponse> {
|
|
155
106
|
return this.request<TResponse>('GET', endpoint, undefined, headers)
|
|
156
107
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { expectError, expectType } from 'tsd'
|
|
2
|
+
import type { ApiClient } from './ApiClient'
|
|
3
|
+
import { BaseCrudApi } from './BaseCrudApi'
|
|
4
|
+
import type { Paginated } from './types'
|
|
5
|
+
|
|
6
|
+
// ── Test model ──────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
type Item = { id: string; name: string; tags?: { id: string; label: string }[] }
|
|
9
|
+
type CreateItem = { name: string }
|
|
10
|
+
type UpdateItem = { name?: string }
|
|
11
|
+
|
|
12
|
+
declare const client: ApiClient
|
|
13
|
+
const api = new BaseCrudApi<Item, CreateItem, UpdateItem>(client, '/items')
|
|
14
|
+
|
|
15
|
+
// ── getMany overloads ───────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
expectType<Promise<Paginated<Item>>>(api.getMany())
|
|
18
|
+
expectType<Promise<Paginated<Item>>>(api.getMany({ page: 1 }))
|
|
19
|
+
expectType<Promise<Paginated<Item>>>(api.getMany({ paginate: true }))
|
|
20
|
+
expectType<Promise<Item[]>>(api.getMany({ paginate: false }))
|
|
21
|
+
expectType<Promise<Item[]>>(api.getMany({ where: { name: 'x' }, paginate: false }))
|
|
22
|
+
|
|
23
|
+
// ── getOne / getById ────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
expectType<Promise<Item | null>>(api.getOne())
|
|
26
|
+
expectType<Promise<Item | null>>(api.getOne({ where: { name: 'x' } }))
|
|
27
|
+
expectType<Promise<Item>>(api.getById('1'))
|
|
28
|
+
expectType<Promise<Item>>(api.getById('1', { include: ['tags'], attributes: ['id'] }))
|
|
29
|
+
|
|
30
|
+
// getById only accepts include/attributes
|
|
31
|
+
expectError(api.getById('1', { where: { name: 'x' } }))
|
|
32
|
+
expectError(api.getById('1', { attributes: ['missing'] }))
|
|
33
|
+
|
|
34
|
+
// ── create / updateById / deleteById ────────────────────────────────
|
|
35
|
+
|
|
36
|
+
expectType<Promise<Item>>(api.create({ name: 'x' }))
|
|
37
|
+
expectType<Promise<Item>>(api.updateById('1', {}))
|
|
38
|
+
expectType<Promise<Item>>(api.deleteById('1'))
|
|
39
|
+
|
|
40
|
+
expectError(api.create({}))
|
|
41
|
+
expectError(api.create({ name: 1 }))
|
|
42
|
+
expectError(api.updateById('1', { name: 1 }))
|
|
43
|
+
|
|
44
|
+
// ── Query typing ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
expectError(api.getMany({ where: { missing: 'x' } }))
|
|
47
|
+
expectError(api.getMany({ order: [['missing', 'ASC']] }))
|
|
48
|
+
expectError(api.getMany({ include: ['name'] }))
|
|
@@ -8,6 +8,10 @@ import type { Paginated } from './types'
|
|
|
8
8
|
type Item = { id: string; name: string }
|
|
9
9
|
type CreateItem = { name: string }
|
|
10
10
|
type UpdateItem = { name?: string }
|
|
11
|
+
type ItemWithTags = Item & {
|
|
12
|
+
owner?: { id: string; name: string }
|
|
13
|
+
tags?: { id: string; label: string }[]
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
// ── Mock client factory ──────────────────────────────────────────────
|
|
13
17
|
|
|
@@ -23,6 +27,11 @@ function createMockClient(): ApiClient {
|
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** The URL passed to the nth `client.get` call, with the query string percent-decoded. */
|
|
31
|
+
function calledUrl(client: ApiClient, call = 0): string {
|
|
32
|
+
return decodeURIComponent(vi.mocked(client.get).mock.calls.at(call)?.at(0) as string)
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
27
36
|
|
|
28
37
|
describe('BaseCrudApi', () => {
|
|
@@ -73,6 +82,30 @@ describe('BaseCrudApi', () => {
|
|
|
73
82
|
})
|
|
74
83
|
})
|
|
75
84
|
|
|
85
|
+
describe('method binding', () => {
|
|
86
|
+
it('keeps `this` when methods are destructured', async () => {
|
|
87
|
+
vi.mocked(client.get).mockResolvedValue([{ id: '1', name: 'x' }])
|
|
88
|
+
vi.mocked(client.post).mockResolvedValue({ id: '1', name: 'x' })
|
|
89
|
+
vi.mocked(client.put).mockResolvedValue({ id: '1', name: 'x' })
|
|
90
|
+
vi.mocked(client.delete).mockResolvedValue({ id: '1', name: 'x' })
|
|
91
|
+
const { getMany, getOne, getById, create, updateById, deleteById } = api
|
|
92
|
+
|
|
93
|
+
await getMany({ paginate: false })
|
|
94
|
+
await getOne()
|
|
95
|
+
await getById('1')
|
|
96
|
+
await create({ name: 'x' })
|
|
97
|
+
await updateById('1', { name: 'x' })
|
|
98
|
+
await deleteById('1')
|
|
99
|
+
|
|
100
|
+
expect(calledUrl(client, 0)).toBe('/items?paginate=false')
|
|
101
|
+
expect(calledUrl(client, 1)).toBe('/items?limit=1&paginate=false')
|
|
102
|
+
expect(calledUrl(client, 2)).toBe('/items/1')
|
|
103
|
+
expect(client.post).toHaveBeenCalledWith('/items', { name: 'x' }, undefined)
|
|
104
|
+
expect(client.put).toHaveBeenCalledWith('/items/1', { name: 'x' }, undefined)
|
|
105
|
+
expect(client.delete).toHaveBeenCalledWith('/items/1', undefined)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
76
109
|
// ── getMany ──────────────────────────────────────────────────────
|
|
77
110
|
|
|
78
111
|
describe('getMany', () => {
|
|
@@ -122,6 +155,27 @@ describe('BaseCrudApi', () => {
|
|
|
122
155
|
expect(client.get).toHaveBeenCalledWith('/items', headers)
|
|
123
156
|
})
|
|
124
157
|
|
|
158
|
+
it('returns the plain array when paginate is false', async () => {
|
|
159
|
+
const items: Item[] = [{ id: '1', name: 'a' }]
|
|
160
|
+
vi.mocked(client.get).mockResolvedValue(items)
|
|
161
|
+
|
|
162
|
+
const result = await api.getMany({ paginate: false })
|
|
163
|
+
expect(calledUrl(client)).toBe('/items?paginate=false')
|
|
164
|
+
expect(result).toEqual(items)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('omits undefined query values', async () => {
|
|
168
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
169
|
+
|
|
170
|
+
// Explicit undefined is rejected by exactOptionalPropertyTypes but reaches the SDK from JS
|
|
171
|
+
// callers or from consumers compiled without that flag.
|
|
172
|
+
const query = { page: 2, perPage: undefined, where: { name: undefined } } as unknown as {
|
|
173
|
+
page: number
|
|
174
|
+
}
|
|
175
|
+
await api.getMany(query)
|
|
176
|
+
expect(calledUrl(client)).toBe('/items?page=2')
|
|
177
|
+
})
|
|
178
|
+
|
|
125
179
|
it('forwards headers with query', async () => {
|
|
126
180
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
127
181
|
const headers = { Authorization: 'Bearer token' }
|
|
@@ -146,6 +200,29 @@ describe('BaseCrudApi', () => {
|
|
|
146
200
|
expect(result).toEqual(item)
|
|
147
201
|
})
|
|
148
202
|
|
|
203
|
+
it('keeps the caller query and forces limit 1 and paginate false', async () => {
|
|
204
|
+
vi.mocked(client.get).mockResolvedValue([])
|
|
205
|
+
|
|
206
|
+
await api.getOne({
|
|
207
|
+
where: { name: 'x' },
|
|
208
|
+
order: [['name', 'DESC']],
|
|
209
|
+
limit: 50,
|
|
210
|
+
paginate: true,
|
|
211
|
+
})
|
|
212
|
+
expect(calledUrl(client)).toBe(
|
|
213
|
+
'/items?where[name]=x&order[0][0]=name&order[0][1]=DESC&limit=1&paginate=false',
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('returns only the first item when the API returns more than one', async () => {
|
|
218
|
+
vi.mocked(client.get).mockResolvedValue([
|
|
219
|
+
{ id: '1', name: 'a' },
|
|
220
|
+
{ id: '2', name: 'b' },
|
|
221
|
+
])
|
|
222
|
+
|
|
223
|
+
expect(await api.getOne()).toEqual({ id: '1', name: 'a' })
|
|
224
|
+
})
|
|
225
|
+
|
|
149
226
|
it('returns null when no records match', async () => {
|
|
150
227
|
vi.mocked(client.get).mockResolvedValue([])
|
|
151
228
|
|
|
@@ -174,6 +251,21 @@ describe('BaseCrudApi', () => {
|
|
|
174
251
|
expect(result).toEqual(item)
|
|
175
252
|
})
|
|
176
253
|
|
|
254
|
+
it('serializes include and attributes into the query string', async () => {
|
|
255
|
+
vi.mocked(client.get).mockResolvedValue({ id: 'abc', name: 'Test' })
|
|
256
|
+
|
|
257
|
+
await api.getById('abc', { attributes: ['id', 'name'] })
|
|
258
|
+
expect(calledUrl(client)).toBe('/items/abc?attributes[0]=id&attributes[1]=name')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('forwards query and headers together', async () => {
|
|
262
|
+
vi.mocked(client.get).mockResolvedValue({ id: '1', name: 'x' })
|
|
263
|
+
const headers = { 'X-Tenant': '123' }
|
|
264
|
+
|
|
265
|
+
await api.getById('1', { attributes: ['id'] }, headers)
|
|
266
|
+
expect(client.get).toHaveBeenCalledWith('/items/1?attributes%5B0%5D=id', headers)
|
|
267
|
+
})
|
|
268
|
+
|
|
177
269
|
it('forwards custom headers', async () => {
|
|
178
270
|
vi.mocked(client.get).mockResolvedValue({ id: '1', name: 'x' })
|
|
179
271
|
const headers = { 'X-Tenant': '123' }
|
|
@@ -269,6 +361,17 @@ describe('BaseCrudApi', () => {
|
|
|
269
361
|
await expect(api.updateById('1', { name: 'x' })).rejects.toThrow('Conflict')
|
|
270
362
|
})
|
|
271
363
|
|
|
364
|
+
it('propagates errors from client.get in getOne', async () => {
|
|
365
|
+
vi.mocked(client.get).mockRejectedValue(new Error('Unauthorized'))
|
|
366
|
+
await expect(api.getOne()).rejects.toThrow('Unauthorized')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('rethrows the client error instance unchanged', async () => {
|
|
370
|
+
const error = Object.assign(new Error('Bad request'), { status: 400 })
|
|
371
|
+
vi.mocked(client.post).mockRejectedValue(error)
|
|
372
|
+
await expect(api.create({ name: 'bad' })).rejects.toBe(error)
|
|
373
|
+
})
|
|
374
|
+
|
|
272
375
|
it('propagates errors from client.delete in deleteById', async () => {
|
|
273
376
|
vi.mocked(client.delete).mockRejectedValue(new Error('Forbidden'))
|
|
274
377
|
await expect(api.deleteById('1')).rejects.toThrow('Forbidden')
|
|
@@ -297,6 +400,53 @@ describe('BaseCrudApi', () => {
|
|
|
297
400
|
expect(calledUrl).toContain('ASC')
|
|
298
401
|
})
|
|
299
402
|
|
|
403
|
+
it('serializes nested include with where and include', async () => {
|
|
404
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
405
|
+
const tagsApi = new BaseCrudApi<ItemWithTags, CreateItem>(client, '/items')
|
|
406
|
+
|
|
407
|
+
await tagsApi.getMany({
|
|
408
|
+
include: ['owner', { model: 'tags', where: { label: 'vip' }, required: true }],
|
|
409
|
+
})
|
|
410
|
+
expect(calledUrl(client)).toBe(
|
|
411
|
+
'/items?include[0]=owner&include[1][model]=tags&include[1][where][label]=vip&include[1][required]=true',
|
|
412
|
+
)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('serializes $in arrays with indices', async () => {
|
|
416
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
417
|
+
|
|
418
|
+
await api.getMany({ where: { id: { $in: ['a', 'b'] } } })
|
|
419
|
+
expect(calledUrl(client)).toBe('/items?where[id][$in][0]=a&where[id][$in][1]=b')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('serializes $or/$and combinators', async () => {
|
|
423
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
424
|
+
|
|
425
|
+
await api.getMany({ where: { $or: [{ name: 'a' }, { name: 'b' }] } })
|
|
426
|
+
expect(calledUrl(client)).toBe('/items?where[$or][0][name]=a&where[$or][1][name]=b')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('serializes order exactly', async () => {
|
|
430
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
431
|
+
|
|
432
|
+
await api.getMany({
|
|
433
|
+
order: [
|
|
434
|
+
['name', 'ASC'],
|
|
435
|
+
['id', 'desc'],
|
|
436
|
+
],
|
|
437
|
+
})
|
|
438
|
+
expect(calledUrl(client)).toBe(
|
|
439
|
+
'/items?order[0][0]=name&order[0][1]=ASC&order[1][0]=id&order[1][1]=desc',
|
|
440
|
+
)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('serializes booleans and numbers as strings', async () => {
|
|
444
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
445
|
+
|
|
446
|
+
await api.getMany({ paranoid: false, distinct: true, limit: 10, offset: 20 })
|
|
447
|
+
expect(calledUrl(client)).toBe('/items?paranoid=false&distinct=true&limit=10&offset=20')
|
|
448
|
+
})
|
|
449
|
+
|
|
300
450
|
it('handles empty query object', async () => {
|
|
301
451
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
302
452
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { expectAssignable, expectType } from 'tsd'
|
|
2
|
+
import type { ApiError, ErrorClassName, FieldError, ValidationError } from './errors'
|
|
3
|
+
|
|
4
|
+
declare const apiError: ApiError
|
|
5
|
+
declare const validationError: ValidationError
|
|
6
|
+
|
|
7
|
+
// ValidationError always carries an array; ApiError may not
|
|
8
|
+
expectType<FieldError[]>(validationError.validationErrors)
|
|
9
|
+
expectType<FieldError[] | null>(apiError.validationErrors)
|
|
10
|
+
|
|
11
|
+
// errorClass accepts the known backend names and any other string
|
|
12
|
+
expectAssignable<ErrorClassName>('PaymentRequired')
|
|
13
|
+
expectAssignable<ErrorClassName>('SomeFutureError')
|
|
14
|
+
expectType<ErrorClassName | undefined>(apiError.errorClass)
|