@ikatec/digisac-api-sdk 3.1.0 → 4.1.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 +71 -9
- package/dist/apis/authHistory/AuthHistoryApi.cjs +3 -9
- package/dist/apis/authHistory/AuthHistoryApi.cjs.map +1 -1
- package/dist/apis/authHistory/AuthHistoryApi.d.ts +3 -4
- package/dist/apis/authHistory/AuthHistoryApi.d.ts.map +1 -1
- package/dist/apis/authHistory/AuthHistoryApi.mjs +3 -7
- package/dist/apis/authHistory/AuthHistoryApi.mjs.map +1 -1
- package/dist/apis/clientFeedback/ClientFeedbackApi.cjs +2 -5
- package/dist/apis/clientFeedback/ClientFeedbackApi.cjs.map +1 -1
- package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts +2 -4
- package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts.map +1 -1
- package/dist/apis/clientFeedback/ClientFeedbackApi.mjs +3 -5
- package/dist/apis/clientFeedback/ClientFeedbackApi.mjs.map +1 -1
- package/dist/apis/contacts/ContactsApi.cjs +2 -2
- package/dist/apis/contacts/ContactsApi.cjs.map +1 -1
- package/dist/apis/contacts/ContactsApi.mjs +2 -2
- package/dist/apis/contacts/ContactsApi.mjs.map +1 -1
- package/dist/apis/me/MeApi.cjs +2 -5
- package/dist/apis/me/MeApi.cjs.map +1 -1
- package/dist/apis/me/MeApi.d.ts +2 -4
- package/dist/apis/me/MeApi.d.ts.map +1 -1
- package/dist/apis/me/MeApi.mjs +3 -5
- package/dist/apis/me/MeApi.mjs.map +1 -1
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs +3 -9
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs.map +1 -1
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts +3 -4
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts.map +1 -1
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs +3 -7
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs.map +1 -1
- package/dist/apis/stickerUsers/StickerUsersApi.cjs +3 -9
- package/dist/apis/stickerUsers/StickerUsersApi.cjs.map +1 -1
- package/dist/apis/stickerUsers/StickerUsersApi.d.ts +3 -4
- package/dist/apis/stickerUsers/StickerUsersApi.d.ts.map +1 -1
- package/dist/apis/stickerUsers/StickerUsersApi.mjs +3 -7
- package/dist/apis/stickerUsers/StickerUsersApi.mjs.map +1 -1
- package/dist/apis/terms/TermsApi.cjs +3 -12
- package/dist/apis/terms/TermsApi.cjs.map +1 -1
- package/dist/apis/terms/TermsApi.d.ts +2 -4
- package/dist/apis/terms/TermsApi.d.ts.map +1 -1
- package/dist/apis/terms/TermsApi.mjs +3 -10
- package/dist/apis/terms/TermsApi.mjs.map +1 -1
- package/dist/apis/transcripts/TranscriptsApi.cjs +2 -5
- package/dist/apis/transcripts/TranscriptsApi.cjs.map +1 -1
- package/dist/apis/transcripts/TranscriptsApi.d.ts +2 -4
- package/dist/apis/transcripts/TranscriptsApi.d.ts.map +1 -1
- package/dist/apis/transcripts/TranscriptsApi.mjs +3 -5
- package/dist/apis/transcripts/TranscriptsApi.mjs.map +1 -1
- package/dist/core/ApiClient.d.ts +3 -0
- package/dist/core/ApiClient.d.ts.map +1 -1
- package/dist/core/ArchivableCrudApi.cjs +0 -2
- package/dist/core/ArchivableCrudApi.cjs.map +1 -1
- package/dist/core/ArchivableCrudApi.d.ts.map +1 -1
- package/dist/core/ArchivableCrudApi.mjs +0 -2
- package/dist/core/ArchivableCrudApi.mjs.map +1 -1
- package/dist/core/BaseApi.cjs +34 -0
- package/dist/core/BaseApi.cjs.map +1 -0
- package/dist/core/BaseApi.d.ts +20 -0
- package/dist/core/BaseApi.d.ts.map +1 -0
- package/dist/core/BaseApi.mjs +33 -0
- package/dist/core/BaseApi.mjs.map +1 -0
- package/dist/core/BaseApiClient.cjs +29 -57
- package/dist/core/BaseApiClient.cjs.map +1 -1
- package/dist/core/BaseApiClient.d.ts +8 -11
- package/dist/core/BaseApiClient.d.ts.map +1 -1
- package/dist/core/BaseApiClient.mjs +31 -57
- package/dist/core/BaseApiClient.mjs.map +1 -1
- package/dist/core/BaseCrudApi.cjs +6 -16
- package/dist/core/BaseCrudApi.cjs.map +1 -1
- package/dist/core/BaseCrudApi.d.ts +3 -2
- package/dist/core/BaseCrudApi.d.ts.map +1 -1
- package/dist/core/BaseCrudApi.mjs +6 -14
- package/dist/core/BaseCrudApi.mjs.map +1 -1
- package/dist/core/bindMethods.cjs +24 -0
- package/dist/core/bindMethods.cjs.map +1 -0
- package/dist/core/bindMethods.d.ts +7 -0
- package/dist/core/bindMethods.d.ts.map +1 -0
- package/dist/core/bindMethods.mjs +22 -0
- package/dist/core/bindMethods.mjs.map +1 -0
- 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 +18 -2
- package/dist/core/index.d.ts +6 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.mjs +4 -2
- package/dist/core/queryString.cjs +22 -0
- package/dist/core/queryString.cjs.map +1 -0
- package/dist/core/queryString.d.ts +14 -0
- package/dist/core/queryString.d.ts.map +1 -0
- package/dist/core/queryString.mjs +18 -0
- package/dist/core/queryString.mjs.map +1 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/withQueryFormat.cjs +42 -0
- package/dist/core/withQueryFormat.cjs.map +1 -0
- package/dist/core/withQueryFormat.d.ts +10 -0
- package/dist/core/withQueryFormat.d.ts.map +1 -0
- package/dist/core/withQueryFormat.mjs +40 -0
- package/dist/core/withQueryFormat.mjs.map +1 -0
- package/dist/index.cjs +18 -2
- package/dist/index.mjs +4 -2
- package/package.json +1 -1
- package/src/apis/authHistory/AuthHistoryApi.test.ts +1 -1
- package/src/apis/authHistory/AuthHistoryApi.ts +10 -10
- package/src/apis/campaigns/CampaignsApi.test.ts +1 -1
- package/src/apis/clientFeedback/ClientFeedbackApi.ts +5 -7
- package/src/apis/contacts/ContactsApi.test.ts +1 -1
- package/src/apis/contacts/ContactsApi.ts +2 -2
- package/src/apis/me/MeApi.ts +2 -8
- package/src/apis/messages/MessagesApi.test.ts +2 -2
- package/src/apis/queryFormat.test.ts +198 -0
- package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.test.ts +2 -2
- package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.ts +10 -10
- package/src/apis/stickerUsers/StickerUsersApi.test.ts +1 -1
- package/src/apis/stickerUsers/StickerUsersApi.ts +7 -10
- package/src/apis/terms/TermsApi.ts +3 -12
- package/src/apis/transcripts/TranscriptsApi.ts +2 -7
- package/src/core/ApiClient.ts +4 -0
- package/src/core/ArchivableCrudApi.ts +0 -2
- package/src/core/BaseApi.test.ts +164 -0
- package/src/core/BaseApi.ts +32 -0
- package/src/core/BaseApiClient.test.ts +193 -22
- package/src/core/BaseApiClient.ts +44 -85
- package/src/core/BaseCrudApi.test-d.ts +57 -0
- package/src/core/BaseCrudApi.test.ts +252 -31
- package/src/core/BaseCrudApi.ts +14 -14
- package/src/core/bindMethods.ts +26 -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 +21 -2
- package/src/core/queryString.test.ts +71 -0
- package/src/core/queryString.ts +30 -0
- package/src/core/types.ts +5 -0
- package/src/core/withQueryFormat.test.ts +136 -0
- package/src/core/withQueryFormat.ts +47 -0
|
@@ -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,25 @@
|
|
|
1
1
|
export type { ApiClient, HttpMethod } from './ApiClient'
|
|
2
|
-
export { BaseApiClient
|
|
3
|
-
export type {
|
|
2
|
+
export { BaseApiClient } from './BaseApiClient'
|
|
3
|
+
export type { BaseApiClientOptions } from './BaseApiClient'
|
|
4
|
+
export type { QueryFormat } from './queryString'
|
|
5
|
+
export {
|
|
6
|
+
DigisacError,
|
|
7
|
+
ApiConnectionError,
|
|
8
|
+
ApiError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
PaymentRequiredError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
ConflictError,
|
|
16
|
+
UnprocessableEntityError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
InternalServerError,
|
|
19
|
+
parseFieldErrors,
|
|
20
|
+
} from './errors'
|
|
21
|
+
export type { FieldError, ErrorClassName, ApiErrorOptions } from './errors'
|
|
22
|
+
export { BaseApi } from './BaseApi'
|
|
4
23
|
export { BaseCrudApi } from './BaseCrudApi'
|
|
5
24
|
export { ArchivableCrudApi } from './ArchivableCrudApi'
|
|
6
25
|
export type { WhereClause, IncludeItem, ListQuery, GetByIdQuery, Paginated } from './types'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import qs from 'qs'
|
|
3
|
+
import { toQueryString } from './queryString'
|
|
4
|
+
|
|
5
|
+
/** Decodes a `?query=<JSON>` string back into the query object. */
|
|
6
|
+
const decodeJson = (queryString: string): unknown =>
|
|
7
|
+
JSON.parse(new URLSearchParams(queryString.slice(1)).get('query') as string)
|
|
8
|
+
|
|
9
|
+
describe('toQueryString', () => {
|
|
10
|
+
it.each([
|
|
11
|
+
['undefined', undefined],
|
|
12
|
+
['null', null],
|
|
13
|
+
['an empty object', {}],
|
|
14
|
+
['only undefined values', { page: undefined, where: undefined }],
|
|
15
|
+
])('returns an empty string for %s', (_label, query) => {
|
|
16
|
+
expect(toQueryString(query, 'json')).toBe('')
|
|
17
|
+
expect(toQueryString(query, 'qs')).toBe('')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("defaults to 'json'", () => {
|
|
21
|
+
expect(toQueryString({ page: 1 })).toBe(toQueryString({ page: 1 }, 'json'))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("'json'", () => {
|
|
25
|
+
it('sends ?query=<JSON>', () => {
|
|
26
|
+
expect(toQueryString({ page: 2 }, 'json')).toBe(`?query=${encodeURIComponent('{"page":2}')}`)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('round-trips null, booleans, numbers, dates and deep nesting', () => {
|
|
30
|
+
const query = {
|
|
31
|
+
where: { deletedAt: null, isArchived: false, createdAt: { $gte: new Date('2026-01-01Z') } },
|
|
32
|
+
include: [{ model: 'a', include: [{ model: 'b', where: { id: { $in: ['x', 'y'] } } }] }],
|
|
33
|
+
limit: 10,
|
|
34
|
+
}
|
|
35
|
+
expect(decodeJson(toQueryString(query, 'json'))).toEqual({
|
|
36
|
+
...query,
|
|
37
|
+
where: { ...query.where, createdAt: { $gte: '2026-01-01T00:00:00.000Z' } },
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('drops undefined values', () => {
|
|
42
|
+
expect(decodeJson(toQueryString({ page: 1, where: { name: undefined } }, 'json'))).toEqual({
|
|
43
|
+
page: 1,
|
|
44
|
+
where: {},
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('encodes characters that are special in URLs', () => {
|
|
49
|
+
const queryString = toQueryString({ where: { name: 'a&b=c?#%+ ' } }, 'json')
|
|
50
|
+
expect(queryString.slice('?query='.length)).not.toMatch(/[&=?#+ ]/)
|
|
51
|
+
expect(decodeJson(queryString)).toEqual({ where: { name: 'a&b=c?#%+ ' } })
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe("'qs'", () => {
|
|
56
|
+
it('sends bracket notation', () => {
|
|
57
|
+
expect(decodeURIComponent(toQueryString({ where: { name: 'x' }, page: 2 }, 'qs'))).toBe(
|
|
58
|
+
'?where[name]=x&page=2',
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('matches qs.stringify', () => {
|
|
63
|
+
const query = { where: { id: { $in: ['a', 'b'] } }, order: [['name', 'ASC']] }
|
|
64
|
+
expect(toQueryString(query, 'qs')).toBe(`?${qs.stringify(query)}`)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('drops undefined values', () => {
|
|
68
|
+
expect(toQueryString({ page: 1, perPage: undefined }, 'qs')).toBe('?page=1')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import qs from 'qs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How list/get queries (`where`, `include`, `order`, ...) are put in the URL:
|
|
5
|
+
*
|
|
6
|
+
* - `'json'` — `?query=<JSON>`, merged into `req.query` by the backend `parseQuery` middleware.
|
|
7
|
+
* Keeps `null`, booleans and numbers typed and has no nesting limit.
|
|
8
|
+
* - `'qs'` — bracket notation (`?where[name]=x&page=2`). Readable, but the backend parses it with
|
|
9
|
+
* `qs` defaults: `null` arrives as `''`, booleans/numbers as strings, and objects nested deeper
|
|
10
|
+
* than 5 levels (e.g. an `include` inside an `include` with a `where`) are mangled.
|
|
11
|
+
*/
|
|
12
|
+
export type QueryFormat = 'json' | 'qs'
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_QUERY_FORMAT: QueryFormat = 'json'
|
|
15
|
+
|
|
16
|
+
/** Serializes a query in `format`, or returns `''` when there is nothing to send. */
|
|
17
|
+
export function toQueryString(
|
|
18
|
+
query?: object | null,
|
|
19
|
+
format: QueryFormat = DEFAULT_QUERY_FORMAT,
|
|
20
|
+
): string {
|
|
21
|
+
if (!query) {
|
|
22
|
+
return ''
|
|
23
|
+
}
|
|
24
|
+
if (format === 'qs') {
|
|
25
|
+
const queryString = qs.stringify(query)
|
|
26
|
+
return queryString ? `?${queryString}` : ''
|
|
27
|
+
}
|
|
28
|
+
const json = JSON.stringify(query)
|
|
29
|
+
return json === '{}' ? '' : `?query=${encodeURIComponent(json)}`
|
|
30
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -125,6 +125,11 @@ export interface HasGetMany<TResponse, TQuery extends ListQuery<TResponse> = Lis
|
|
|
125
125
|
query?: TQuery & { paginate?: true },
|
|
126
126
|
headers?: Record<string, string>,
|
|
127
127
|
): Promise<Paginated<TResponse>>
|
|
128
|
+
/** When `paginate` is only known at runtime (e.g. a `boolean` variable). */
|
|
129
|
+
getMany(
|
|
130
|
+
query?: TQuery,
|
|
131
|
+
headers?: Record<string, string>,
|
|
132
|
+
): Promise<Paginated<TResponse> | TResponse[]>
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
export type GetByIdQuery<T = Record<string, unknown>> = Pick<ListQuery<T>, 'include' | 'attributes'>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { ApiClient } from './ApiClient'
|
|
3
|
+
import { BaseApiClient } from './BaseApiClient'
|
|
4
|
+
import { BaseCrudApi } from './BaseCrudApi'
|
|
5
|
+
import { StickerUsersApi } from '../apis/stickerUsers/StickerUsersApi'
|
|
6
|
+
import { TermsApi } from '../apis/terms/TermsApi'
|
|
7
|
+
|
|
8
|
+
type Item = { id: string; name: string }
|
|
9
|
+
|
|
10
|
+
class ItemsApi extends BaseCrudApi<Item, { name: string }> {
|
|
11
|
+
constructor(client: ApiClient) {
|
|
12
|
+
super(client, '/items')
|
|
13
|
+
this.searchByName = this.searchByName.bind(this)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
searchByName(name: string) {
|
|
17
|
+
return this.getMany({ where: { name }, paginate: false })
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createMockClient(): ApiClient {
|
|
22
|
+
return {
|
|
23
|
+
setAccessToken: vi.fn().mockReturnThis(),
|
|
24
|
+
request: vi.fn(),
|
|
25
|
+
get: vi.fn().mockResolvedValue([]),
|
|
26
|
+
post: vi.fn(),
|
|
27
|
+
put: vi.fn(),
|
|
28
|
+
patch: vi.fn(),
|
|
29
|
+
delete: vi.fn(),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const calledUrl = (client: ApiClient, call = 0) =>
|
|
34
|
+
decodeURIComponent(vi.mocked(client.get).mock.calls.at(call)?.at(0) as string)
|
|
35
|
+
|
|
36
|
+
const JSON_QUERY = '/items?query={"where":{"name":"x"},"paginate":false}'
|
|
37
|
+
const QS_QUERY = '/items?where[name]=x&paginate=false'
|
|
38
|
+
|
|
39
|
+
describe('withQueryFormat', () => {
|
|
40
|
+
let client: ApiClient
|
|
41
|
+
let api: ItemsApi
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
client = createMockClient()
|
|
45
|
+
api = new ItemsApi(client)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns a copy that uses the given format', async () => {
|
|
49
|
+
await api.withQueryFormat('qs').getMany({ where: { name: 'x' }, paginate: false })
|
|
50
|
+
expect(calledUrl(client)).toBe(QS_QUERY)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('leaves the original API unchanged', async () => {
|
|
54
|
+
api.withQueryFormat('qs')
|
|
55
|
+
await api.getMany({ where: { name: 'x' }, paginate: false })
|
|
56
|
+
expect(calledUrl(client)).toBe(JSON_QUERY)
|
|
57
|
+
expect(api.client).toBe(client)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('keeps the class, basePath and fields', () => {
|
|
61
|
+
const copy = api.withQueryFormat('qs')
|
|
62
|
+
expect(copy).toBeInstanceOf(ItemsApi)
|
|
63
|
+
expect(copy).toBeInstanceOf(BaseCrudApi)
|
|
64
|
+
expect(copy.basePath).toBe('/items')
|
|
65
|
+
expect(copy).not.toBe(api)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('binds destructured methods to the copy', async () => {
|
|
69
|
+
const { getMany, getOne, getById } = api.withQueryFormat('qs')
|
|
70
|
+
await getMany({ where: { name: 'x' }, paginate: false })
|
|
71
|
+
await getOne({ where: { name: 'x' } })
|
|
72
|
+
await getById('1', { attributes: ['id'] })
|
|
73
|
+
expect(calledUrl(client, 0)).toBe(QS_QUERY)
|
|
74
|
+
expect(calledUrl(client, 1)).toBe('/items?where[name]=x&limit=1&paginate=false')
|
|
75
|
+
expect(calledUrl(client, 2)).toBe('/items/1?attributes[0]=id')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('rebinds methods bound by subclass constructors', async () => {
|
|
79
|
+
const { searchByName } = api.withQueryFormat('qs')
|
|
80
|
+
await searchByName('x')
|
|
81
|
+
expect(calledUrl(client)).toBe(QS_QUERY)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('can be chained again', async () => {
|
|
85
|
+
await api.withQueryFormat('qs').withQueryFormat('json').searchByName('x')
|
|
86
|
+
expect(calledUrl(client)).toBe(JSON_QUERY)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('overrides a qs client back to json', async () => {
|
|
90
|
+
const qsApi = new ItemsApi(Object.assign(createMockClient(), { queryFormat: 'qs' as const }))
|
|
91
|
+
await qsApi.withQueryFormat('json').searchByName('x')
|
|
92
|
+
expect(calledUrl(qsApi.client)).toBe(JSON_QUERY)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('still sends requests through the original client', async () => {
|
|
96
|
+
const copy = api.withQueryFormat('qs')
|
|
97
|
+
await copy.create({ name: 'x' })
|
|
98
|
+
expect(client.post).toHaveBeenCalledWith('/items', { name: 'x' }, undefined)
|
|
99
|
+
expect(copy.client.queryFormat).toBe('qs')
|
|
100
|
+
expect(client.queryFormat).toBeUndefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('works on APIs that do not extend BaseCrudApi', async () => {
|
|
104
|
+
await new StickerUsersApi(client).withQueryFormat('qs').getMany({ page: 2 })
|
|
105
|
+
await new TermsApi(client).withQueryFormat('qs').getById('t1', { attributes: ['id'] })
|
|
106
|
+
expect(calledUrl(client, 0)).toBe('/sticker-users?page=2')
|
|
107
|
+
expect(calledUrl(client, 1)).toBe('/terms/t1?attributes[0]=id')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('with BaseApiClient', () => {
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
vi.restoreAllMocks()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const mockFetch = () =>
|
|
116
|
+
vi
|
|
117
|
+
.spyOn(globalThis, 'fetch')
|
|
118
|
+
.mockResolvedValue(
|
|
119
|
+
new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
it('uses the base URL and a token set on the original client after copying', async () => {
|
|
123
|
+
const fetchSpy = mockFetch()
|
|
124
|
+
const baseClient = new BaseApiClient('https://api.example.com', 'old-token')
|
|
125
|
+
const copy = new ItemsApi(baseClient).withQueryFormat('qs')
|
|
126
|
+
baseClient.setAccessToken('new-token')
|
|
127
|
+
|
|
128
|
+
await copy.searchByName('x')
|
|
129
|
+
|
|
130
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
|
|
131
|
+
expect(decodeURIComponent(url)).toBe(`https://api.example.com${QS_QUERY}`)
|
|
132
|
+
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer new-token')
|
|
133
|
+
expect(baseClient.queryFormat).toBe('json')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ApiClient } from './ApiClient'
|
|
2
|
+
import type { QueryFormat } from './queryString'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A view of `client` that serializes queries in `format`. Everything else — methods, base URL and
|
|
6
|
+
* the access token — is read from `client`, so `client.setAccessToken()` still applies to it.
|
|
7
|
+
*/
|
|
8
|
+
function overrideQueryFormat<T extends ApiClient>(client: T, format: QueryFormat): T {
|
|
9
|
+
return Object.create(client, {
|
|
10
|
+
queryFormat: { value: format, enumerable: true, configurable: true },
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Copy of `api` whose client serializes queries in `format`; `api` itself is untouched.
|
|
16
|
+
*
|
|
17
|
+
* The copy has the same prototype and fields as `api`, with `client` replaced. Methods the
|
|
18
|
+
* constructor bound to `api` (`this.getMany = this.getMany.bind(this)`) are bound again to the
|
|
19
|
+
* copy, so they use its client.
|
|
20
|
+
*/
|
|
21
|
+
export function cloneWithQueryFormat<T extends object>(api: T, format: QueryFormat): T {
|
|
22
|
+
const source = api as T & { client: ApiClient }
|
|
23
|
+
const prototype = Object.getPrototypeOf(api) as Record<PropertyKey, unknown>
|
|
24
|
+
const copy = Object.create(prototype) as Record<PropertyKey, unknown>
|
|
25
|
+
|
|
26
|
+
for (const key of Reflect.ownKeys(api)) {
|
|
27
|
+
Object.defineProperty(
|
|
28
|
+
copy,
|
|
29
|
+
key,
|
|
30
|
+
Object.getOwnPropertyDescriptor(api, key) as PropertyDescriptor,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(copy, 'client', {
|
|
34
|
+
value: overrideQueryFormat(source.client, format),
|
|
35
|
+
enumerable: true,
|
|
36
|
+
configurable: true,
|
|
37
|
+
writable: false,
|
|
38
|
+
})
|
|
39
|
+
for (const key of Reflect.ownKeys(api)) {
|
|
40
|
+
const method = prototype[key]
|
|
41
|
+
if (typeof copy[key] === 'function' && typeof method === 'function') {
|
|
42
|
+
copy[key] = method.bind(copy)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return copy as T
|
|
47
|
+
}
|