@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
|
@@ -1,73 +1,21 @@
|
|
|
1
1
|
import type { ApiClient, HttpMethod } from './ApiClient'
|
|
2
|
+
import { ApiConnectionError, ApiError } from './errors'
|
|
3
|
+
import { DEFAULT_QUERY_FORMAT, type QueryFormat } from './queryString'
|
|
2
4
|
|
|
3
|
-
export interface
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
}
|
|
5
|
+
export interface BaseApiClientOptions {
|
|
6
|
+
/** How list/get queries are serialized in the URL. Defaults to `'json'`. */
|
|
7
|
+
queryFormat?: QueryFormat
|
|
62
8
|
}
|
|
63
9
|
|
|
64
10
|
export class BaseApiClient implements ApiClient {
|
|
65
11
|
protected readonly baseUrl: string
|
|
66
12
|
protected accessToken: string | null = null
|
|
13
|
+
public readonly queryFormat: QueryFormat
|
|
67
14
|
|
|
68
|
-
constructor(baseUrl: string, accessToken?: string) {
|
|
15
|
+
constructor(baseUrl: string, accessToken?: string, options: BaseApiClientOptions = {}) {
|
|
69
16
|
this.baseUrl = baseUrl.replace(/\/$/, '')
|
|
70
17
|
this.accessToken = accessToken || null
|
|
18
|
+
this.queryFormat = options.queryFormat ?? DEFAULT_QUERY_FORMAT
|
|
71
19
|
}
|
|
72
20
|
|
|
73
21
|
setAccessToken(accessToken: string): this {
|
|
@@ -82,8 +30,7 @@ export class BaseApiClient implements ApiClient {
|
|
|
82
30
|
headers?: Record<string, string>,
|
|
83
31
|
): Promise<TResponse> {
|
|
84
32
|
const url = `${this.baseUrl}/${endpoint.replace(/^\//, '')}`
|
|
85
|
-
|
|
86
|
-
const response = await fetch(url, {
|
|
33
|
+
const init: RequestInit = {
|
|
87
34
|
method,
|
|
88
35
|
headers: {
|
|
89
36
|
'Content-Type': 'application/json',
|
|
@@ -91,35 +38,29 @@ export class BaseApiClient implements ApiClient {
|
|
|
91
38
|
...headers,
|
|
92
39
|
},
|
|
93
40
|
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
94
|
-
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let response: Response
|
|
44
|
+
try {
|
|
45
|
+
response = await fetch(url, init)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
48
|
+
throw new ApiConnectionError(`Request to ${method} ${url} failed: ${reason}`, {
|
|
49
|
+
cause: error,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
95
52
|
|
|
96
53
|
return this.handleResponse<TResponse>(response)
|
|
97
54
|
}
|
|
98
55
|
|
|
99
56
|
protected async handleResponse<TResponse>(response: Response): Promise<TResponse> {
|
|
100
57
|
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)
|
|
58
|
+
throw ApiError.fromResponse(
|
|
59
|
+
response.status,
|
|
60
|
+
response.statusText,
|
|
61
|
+
response.headers,
|
|
62
|
+
await this.readErrorBody(response),
|
|
63
|
+
)
|
|
123
64
|
}
|
|
124
65
|
|
|
125
66
|
// 204 No Content never has a body; short-circuit before reading the stream.
|
|
@@ -151,6 +92,24 @@ export class BaseApiClient implements ApiClient {
|
|
|
151
92
|
}
|
|
152
93
|
}
|
|
153
94
|
|
|
95
|
+
/** Reads an error body: parsed JSON when possible, the raw text otherwise, `undefined` when empty. */
|
|
96
|
+
protected async readErrorBody(response: Response): Promise<unknown> {
|
|
97
|
+
let text: string
|
|
98
|
+
try {
|
|
99
|
+
text = await response.text()
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
if (!text) {
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text)
|
|
108
|
+
} catch {
|
|
109
|
+
return text
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
154
113
|
get<TResponse = unknown>(endpoint: string, headers?: Record<string, string>): Promise<TResponse> {
|
|
155
114
|
return this.request<TResponse>('GET', endpoint, undefined, headers)
|
|
156
115
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
declare const paginate: boolean
|
|
24
|
+
expectType<Promise<Paginated<Item> | Item[]>>(api.getMany({ paginate }))
|
|
25
|
+
|
|
26
|
+
// ── getOne / getById ────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
expectType<Promise<Item | null>>(api.getOne())
|
|
29
|
+
expectType<Promise<Item | null>>(api.getOne({ where: { name: 'x' } }))
|
|
30
|
+
expectType<Promise<Item>>(api.getById('1'))
|
|
31
|
+
expectType<Promise<Item>>(api.getById('1', { include: ['tags'], attributes: ['id'] }))
|
|
32
|
+
|
|
33
|
+
// getById only accepts include/attributes
|
|
34
|
+
expectError(api.getById('1', { where: { name: 'x' } }))
|
|
35
|
+
expectError(api.getById('1', { attributes: ['missing'] }))
|
|
36
|
+
|
|
37
|
+
// ── create / updateById / deleteById ────────────────────────────────
|
|
38
|
+
|
|
39
|
+
expectType<Promise<Item>>(api.create({ name: 'x' }))
|
|
40
|
+
expectType<Promise<Item>>(api.updateById('1', {}))
|
|
41
|
+
expectType<Promise<Item>>(api.deleteById('1'))
|
|
42
|
+
|
|
43
|
+
expectError(api.create({}))
|
|
44
|
+
expectError(api.create({ name: 1 }))
|
|
45
|
+
expectError(api.updateById('1', { name: 1 }))
|
|
46
|
+
|
|
47
|
+
// ── Query typing ────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
expectError(api.getMany({ where: { missing: 'x' } }))
|
|
50
|
+
expectError(api.getMany({ order: [['missing', 'ASC']] }))
|
|
51
|
+
expectError(api.getMany({ include: ['name'] }))
|
|
52
|
+
|
|
53
|
+
// ── withQueryFormat ─────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
expectType<typeof api>(api.withQueryFormat('qs'))
|
|
56
|
+
expectType<Promise<Item[]>>(api.withQueryFormat('json').getMany({ paginate: false }))
|
|
57
|
+
expectError(api.withQueryFormat('xml'))
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { BaseCrudApi } from './BaseCrudApi'
|
|
3
3
|
import type { ApiClient } from './ApiClient'
|
|
4
|
+
import type { QueryFormat } from './queryString'
|
|
4
5
|
import type { Paginated } from './types'
|
|
5
6
|
|
|
6
7
|
// ── Test types ───────────────────────────────────────────────────────
|
|
7
8
|
|
|
8
|
-
type Item = { id: string; name: string }
|
|
9
|
+
type Item = { id: string; name: string; deletedAt?: string | null }
|
|
9
10
|
type CreateItem = { name: string }
|
|
10
11
|
type UpdateItem = { name?: string }
|
|
12
|
+
type ItemWithTags = Item & {
|
|
13
|
+
owner?: { id: string; name: string }
|
|
14
|
+
tags?: { id: string; label: string }[]
|
|
15
|
+
}
|
|
11
16
|
|
|
12
17
|
// ── Mock client factory ──────────────────────────────────────────────
|
|
13
18
|
|
|
14
|
-
function createMockClient(): ApiClient {
|
|
19
|
+
function createMockClient(queryFormat?: QueryFormat): ApiClient {
|
|
15
20
|
return {
|
|
21
|
+
...(queryFormat && { queryFormat }),
|
|
16
22
|
setAccessToken: vi.fn().mockReturnThis(),
|
|
17
23
|
request: vi.fn(),
|
|
18
24
|
get: vi.fn(),
|
|
@@ -23,6 +29,23 @@ function createMockClient(): ApiClient {
|
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/** The URL passed to the nth `client.get` call, with the query string percent-decoded. */
|
|
33
|
+
function calledUrl(client: ApiClient, call = 0): string {
|
|
34
|
+
return decodeURIComponent(vi.mocked(client.get).mock.calls.at(call)?.at(0) as string)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The path of the nth `client.get` call, without the query string. */
|
|
38
|
+
function calledPath(client: ApiClient, call = 0): string {
|
|
39
|
+
return (vi.mocked(client.get).mock.calls.at(call)?.at(0) as string).split('?')[0] as string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The query sent by the nth `client.get` call, decoded from `?query=<JSON>`. */
|
|
43
|
+
function calledQuery(client: ApiClient, call = 0): unknown {
|
|
44
|
+
const url = vi.mocked(client.get).mock.calls.at(call)?.at(0) as string
|
|
45
|
+
const json = new URLSearchParams(url.split('?')[1] ?? '').get('query')
|
|
46
|
+
return json === null ? undefined : JSON.parse(json)
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
27
50
|
|
|
28
51
|
describe('BaseCrudApi', () => {
|
|
@@ -73,6 +96,30 @@ describe('BaseCrudApi', () => {
|
|
|
73
96
|
})
|
|
74
97
|
})
|
|
75
98
|
|
|
99
|
+
describe('method binding', () => {
|
|
100
|
+
it('keeps `this` when methods are destructured', async () => {
|
|
101
|
+
vi.mocked(client.get).mockResolvedValue([{ id: '1', name: 'x' }])
|
|
102
|
+
vi.mocked(client.post).mockResolvedValue({ id: '1', name: 'x' })
|
|
103
|
+
vi.mocked(client.put).mockResolvedValue({ id: '1', name: 'x' })
|
|
104
|
+
vi.mocked(client.delete).mockResolvedValue({ id: '1', name: 'x' })
|
|
105
|
+
const { getMany, getOne, getById, create, updateById, deleteById } = api
|
|
106
|
+
|
|
107
|
+
await getMany({ paginate: false })
|
|
108
|
+
await getOne()
|
|
109
|
+
await getById('1')
|
|
110
|
+
await create({ name: 'x' })
|
|
111
|
+
await updateById('1', { name: 'x' })
|
|
112
|
+
await deleteById('1')
|
|
113
|
+
|
|
114
|
+
expect(calledQuery(client, 0)).toEqual({ paginate: false })
|
|
115
|
+
expect(calledQuery(client, 1)).toEqual({ limit: 1, paginate: false })
|
|
116
|
+
expect(calledUrl(client, 2)).toBe('/items/1')
|
|
117
|
+
expect(client.post).toHaveBeenCalledWith('/items', { name: 'x' }, undefined)
|
|
118
|
+
expect(client.put).toHaveBeenCalledWith('/items/1', { name: 'x' }, undefined)
|
|
119
|
+
expect(client.delete).toHaveBeenCalledWith('/items/1', undefined)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
76
123
|
// ── getMany ──────────────────────────────────────────────────────
|
|
77
124
|
|
|
78
125
|
describe('getMany', () => {
|
|
@@ -98,20 +145,15 @@ describe('BaseCrudApi', () => {
|
|
|
98
145
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
99
146
|
|
|
100
147
|
await api.getMany({ page: 2, perPage: 25 })
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
expect(calledUrl).toContain('page=2')
|
|
104
|
-
expect(calledUrl).toContain('perPage=25')
|
|
148
|
+
expect(calledPath(client)).toBe('/items')
|
|
149
|
+
expect(calledQuery(client)).toEqual({ page: 2, perPage: 25 })
|
|
105
150
|
})
|
|
106
151
|
|
|
107
152
|
it('serializes where clause into query string', async () => {
|
|
108
153
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
109
154
|
|
|
110
155
|
await api.getMany({ where: { name: 'test' } })
|
|
111
|
-
|
|
112
|
-
expect(calledUrl).toContain('where')
|
|
113
|
-
expect(calledUrl).toContain('name')
|
|
114
|
-
expect(calledUrl).toContain('test')
|
|
156
|
+
expect(calledQuery(client)).toEqual({ where: { name: 'test' } })
|
|
115
157
|
})
|
|
116
158
|
|
|
117
159
|
it('forwards custom headers', async () => {
|
|
@@ -122,6 +164,27 @@ describe('BaseCrudApi', () => {
|
|
|
122
164
|
expect(client.get).toHaveBeenCalledWith('/items', headers)
|
|
123
165
|
})
|
|
124
166
|
|
|
167
|
+
it('returns the plain array when paginate is false', async () => {
|
|
168
|
+
const items: Item[] = [{ id: '1', name: 'a' }]
|
|
169
|
+
vi.mocked(client.get).mockResolvedValue(items)
|
|
170
|
+
|
|
171
|
+
const result = await api.getMany({ paginate: false })
|
|
172
|
+
expect(calledQuery(client)).toEqual({ paginate: false })
|
|
173
|
+
expect(result).toEqual(items)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('omits undefined query values', async () => {
|
|
177
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
178
|
+
|
|
179
|
+
// Explicit undefined is rejected by exactOptionalPropertyTypes but reaches the SDK from JS
|
|
180
|
+
// callers or from consumers compiled without that flag.
|
|
181
|
+
const query = { page: 2, perPage: undefined, where: { name: undefined } } as unknown as {
|
|
182
|
+
page: number
|
|
183
|
+
}
|
|
184
|
+
await api.getMany(query)
|
|
185
|
+
expect(calledQuery(client)).toEqual({ page: 2, where: {} })
|
|
186
|
+
})
|
|
187
|
+
|
|
125
188
|
it('forwards headers with query', async () => {
|
|
126
189
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
127
190
|
const headers = { Authorization: 'Bearer token' }
|
|
@@ -139,13 +202,43 @@ describe('BaseCrudApi', () => {
|
|
|
139
202
|
vi.mocked(client.get).mockResolvedValue([item])
|
|
140
203
|
|
|
141
204
|
const result = await api.getOne()
|
|
142
|
-
|
|
143
|
-
expect(
|
|
144
|
-
expect(calledUrl).toContain('limit=1')
|
|
145
|
-
expect(calledUrl).toContain('paginate=false')
|
|
205
|
+
expect(calledPath(client)).toBe('/items')
|
|
206
|
+
expect(calledQuery(client)).toEqual({ limit: 1, paginate: false })
|
|
146
207
|
expect(result).toEqual(item)
|
|
147
208
|
})
|
|
148
209
|
|
|
210
|
+
it('keeps the caller query and forces limit 1 and paginate false', async () => {
|
|
211
|
+
vi.mocked(client.get).mockResolvedValue([])
|
|
212
|
+
|
|
213
|
+
await api.getOne({
|
|
214
|
+
where: { name: 'x' },
|
|
215
|
+
order: [['name', 'DESC']],
|
|
216
|
+
limit: 50,
|
|
217
|
+
paginate: true,
|
|
218
|
+
})
|
|
219
|
+
expect(calledQuery(client)).toEqual({
|
|
220
|
+
where: { name: 'x' },
|
|
221
|
+
order: [['name', 'DESC']],
|
|
222
|
+
limit: 1,
|
|
223
|
+
paginate: false,
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('returns only the first item when the API returns more than one', async () => {
|
|
228
|
+
vi.mocked(client.get).mockResolvedValue([
|
|
229
|
+
{ id: '1', name: 'a' },
|
|
230
|
+
{ id: '2', name: 'b' },
|
|
231
|
+
])
|
|
232
|
+
|
|
233
|
+
expect(await api.getOne()).toEqual({ id: '1', name: 'a' })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('returns null when the API returns an empty body', async () => {
|
|
237
|
+
vi.mocked(client.get).mockResolvedValue(undefined)
|
|
238
|
+
|
|
239
|
+
expect(await api.getOne()).toBeNull()
|
|
240
|
+
})
|
|
241
|
+
|
|
149
242
|
it('returns null when no records match', async () => {
|
|
150
243
|
vi.mocked(client.get).mockResolvedValue([])
|
|
151
244
|
|
|
@@ -174,6 +267,25 @@ describe('BaseCrudApi', () => {
|
|
|
174
267
|
expect(result).toEqual(item)
|
|
175
268
|
})
|
|
176
269
|
|
|
270
|
+
it('serializes include and attributes into the query string', async () => {
|
|
271
|
+
vi.mocked(client.get).mockResolvedValue({ id: 'abc', name: 'Test' })
|
|
272
|
+
|
|
273
|
+
await api.getById('abc', { attributes: ['id', 'name'] })
|
|
274
|
+
expect(calledPath(client)).toBe('/items/abc')
|
|
275
|
+
expect(calledQuery(client)).toEqual({ attributes: ['id', 'name'] })
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('forwards query and headers together', async () => {
|
|
279
|
+
vi.mocked(client.get).mockResolvedValue({ id: '1', name: 'x' })
|
|
280
|
+
const headers = { 'X-Tenant': '123' }
|
|
281
|
+
|
|
282
|
+
await api.getById('1', { attributes: ['id'] }, headers)
|
|
283
|
+
expect(client.get).toHaveBeenCalledWith(
|
|
284
|
+
`/items/1?query=${encodeURIComponent('{"attributes":["id"]}')}`,
|
|
285
|
+
headers,
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
177
289
|
it('forwards custom headers', async () => {
|
|
178
290
|
vi.mocked(client.get).mockResolvedValue({ id: '1', name: 'x' })
|
|
179
291
|
const headers = { 'X-Tenant': '123' }
|
|
@@ -269,41 +381,150 @@ describe('BaseCrudApi', () => {
|
|
|
269
381
|
await expect(api.updateById('1', { name: 'x' })).rejects.toThrow('Conflict')
|
|
270
382
|
})
|
|
271
383
|
|
|
384
|
+
it('propagates errors from client.get in getOne', async () => {
|
|
385
|
+
vi.mocked(client.get).mockRejectedValue(new Error('Unauthorized'))
|
|
386
|
+
await expect(api.getOne()).rejects.toThrow('Unauthorized')
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('rethrows the client error instance unchanged', async () => {
|
|
390
|
+
const error = Object.assign(new Error('Bad request'), { status: 400 })
|
|
391
|
+
vi.mocked(client.post).mockRejectedValue(error)
|
|
392
|
+
await expect(api.create({ name: 'bad' })).rejects.toBe(error)
|
|
393
|
+
})
|
|
394
|
+
|
|
272
395
|
it('propagates errors from client.delete in deleteById', async () => {
|
|
273
396
|
vi.mocked(client.delete).mockRejectedValue(new Error('Forbidden'))
|
|
274
397
|
await expect(api.deleteById('1')).rejects.toThrow('Forbidden')
|
|
275
398
|
})
|
|
276
399
|
})
|
|
277
400
|
|
|
278
|
-
// ── Query serialization
|
|
401
|
+
// ── Query serialization: 'json' (default) ───────────────────────
|
|
402
|
+
|
|
403
|
+
describe("query serialization (queryFormat: 'json', the default)", () => {
|
|
404
|
+
it('sends the query as ?query=<JSON>', async () => {
|
|
405
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
406
|
+
|
|
407
|
+
await api.getMany({ where: { name: 'x' }, page: 2 })
|
|
408
|
+
expect(client.get).toHaveBeenCalledWith(
|
|
409
|
+
`/items?query=${encodeURIComponent('{"where":{"name":"x"},"page":2}')}`,
|
|
410
|
+
undefined,
|
|
411
|
+
)
|
|
412
|
+
})
|
|
279
413
|
|
|
280
|
-
|
|
281
|
-
it('handles complex where with operators', async () => {
|
|
414
|
+
it('keeps null, booleans and numbers typed', async () => {
|
|
282
415
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
416
|
+
const query = {
|
|
417
|
+
where: { deletedAt: null, id: { $ne: 'x' }, name: { $ne: 'y' } },
|
|
418
|
+
paranoid: false,
|
|
419
|
+
distinct: true,
|
|
420
|
+
limit: 10,
|
|
421
|
+
}
|
|
283
422
|
|
|
284
|
-
await api.getMany(
|
|
285
|
-
|
|
286
|
-
expect(calledUrl).toMatch(/^\/items\?/)
|
|
287
|
-
expect(calledUrl).toContain('%25test%25')
|
|
423
|
+
await api.getMany(query)
|
|
424
|
+
expect(calledQuery(client)).toEqual(query)
|
|
288
425
|
})
|
|
289
426
|
|
|
290
|
-
it('
|
|
427
|
+
it('keeps deeply nested includes intact', async () => {
|
|
291
428
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
429
|
+
const tagsApi = new BaseCrudApi<ItemWithTags, CreateItem>(client, '/items')
|
|
430
|
+
const query = {
|
|
431
|
+
include: [
|
|
432
|
+
'owner' as const,
|
|
433
|
+
{ model: 'tags' as const, where: { label: { $in: ['a', 'b'] } }, required: true },
|
|
434
|
+
],
|
|
435
|
+
}
|
|
292
436
|
|
|
293
|
-
await
|
|
294
|
-
|
|
295
|
-
expect(calledUrl).toContain('order')
|
|
296
|
-
expect(calledUrl).toContain('name')
|
|
297
|
-
expect(calledUrl).toContain('ASC')
|
|
437
|
+
await tagsApi.getMany(query)
|
|
438
|
+
expect(calledQuery(client)).toEqual(query)
|
|
298
439
|
})
|
|
299
440
|
|
|
300
|
-
it('
|
|
441
|
+
it('encodes special characters', async () => {
|
|
442
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
443
|
+
|
|
444
|
+
await api.getMany({ where: { name: { $like: '%a&b=c?#%' } } })
|
|
445
|
+
expect(calledQuery(client)).toEqual({ where: { name: { $like: '%a&b=c?#%' } } })
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('sends no query string for an empty query object', async () => {
|
|
301
449
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
302
450
|
|
|
303
451
|
await api.getMany({})
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
452
|
+
expect(client.get).toHaveBeenCalledWith('/items', undefined)
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
// ── Query serialization: 'qs' ────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
describe("query serialization (queryFormat: 'qs')", () => {
|
|
459
|
+
let qsClient: ApiClient
|
|
460
|
+
let qsApi: BaseCrudApi<ItemWithTags, CreateItem, UpdateItem>
|
|
461
|
+
|
|
462
|
+
beforeEach(() => {
|
|
463
|
+
qsClient = createMockClient('qs')
|
|
464
|
+
qsApi = new BaseCrudApi<ItemWithTags, CreateItem, UpdateItem>(qsClient, '/items')
|
|
465
|
+
vi.mocked(qsClient.get).mockResolvedValue({ data: [], total: 0 })
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('serializes where with operators', async () => {
|
|
469
|
+
await qsApi.getMany({ where: { name: { $like: '%test%' } } })
|
|
470
|
+
expect(vi.mocked(qsClient.get).mock.calls.at(0)?.at(0)).toBe(
|
|
471
|
+
'/items?where%5Bname%5D%5B%24like%5D=%25test%25',
|
|
472
|
+
)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('serializes nested include with where and include', async () => {
|
|
476
|
+
await qsApi.getMany({
|
|
477
|
+
include: ['owner', { model: 'tags', where: { label: 'vip' }, required: true }],
|
|
478
|
+
})
|
|
479
|
+
expect(calledUrl(qsClient)).toBe(
|
|
480
|
+
'/items?include[0]=owner&include[1][model]=tags&include[1][where][label]=vip&include[1][required]=true',
|
|
481
|
+
)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('serializes $in arrays with indices', async () => {
|
|
485
|
+
await qsApi.getMany({ where: { id: { $in: ['a', 'b'] } } })
|
|
486
|
+
expect(calledUrl(qsClient)).toBe('/items?where[id][$in][0]=a&where[id][$in][1]=b')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('serializes $or/$and combinators', async () => {
|
|
490
|
+
await qsApi.getMany({ where: { $or: [{ name: 'a' }, { name: 'b' }] } })
|
|
491
|
+
expect(calledUrl(qsClient)).toBe('/items?where[$or][0][name]=a&where[$or][1][name]=b')
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('serializes order exactly', async () => {
|
|
495
|
+
await qsApi.getMany({
|
|
496
|
+
order: [
|
|
497
|
+
['name', 'ASC'],
|
|
498
|
+
['id', 'desc'],
|
|
499
|
+
],
|
|
500
|
+
})
|
|
501
|
+
expect(calledUrl(qsClient)).toBe(
|
|
502
|
+
'/items?order[0][0]=name&order[0][1]=ASC&order[1][0]=id&order[1][1]=desc',
|
|
503
|
+
)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('serializes booleans and numbers as strings', async () => {
|
|
507
|
+
await qsApi.getMany({ paranoid: false, distinct: true, limit: 10, offset: 20 })
|
|
508
|
+
expect(calledUrl(qsClient)).toBe('/items?paranoid=false&distinct=true&limit=10&offset=20')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('serializes null as an empty value (the backend reads it as an empty string)', async () => {
|
|
512
|
+
await qsApi.getMany({ where: { deletedAt: null } })
|
|
513
|
+
expect(calledUrl(qsClient)).toBe('/items?where[deletedAt]=')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('serializes getOne and getById queries', async () => {
|
|
517
|
+
vi.mocked(qsClient.get).mockResolvedValueOnce([]).mockResolvedValueOnce({ id: '1' })
|
|
518
|
+
|
|
519
|
+
await qsApi.getOne({ where: { name: 'x' } })
|
|
520
|
+
await qsApi.getById('1', { attributes: ['id'] })
|
|
521
|
+
expect(calledUrl(qsClient, 0)).toBe('/items?where[name]=x&limit=1&paginate=false')
|
|
522
|
+
expect(calledUrl(qsClient, 1)).toBe('/items/1?attributes[0]=id')
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('sends no query string for an empty query object', async () => {
|
|
526
|
+
await qsApi.getMany({})
|
|
527
|
+
expect(qsClient.get).toHaveBeenCalledWith('/items', undefined)
|
|
307
528
|
})
|
|
308
529
|
})
|
|
309
530
|
})
|
package/src/core/BaseCrudApi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import qs from 'qs'
|
|
2
1
|
import type { ApiClient } from './ApiClient'
|
|
2
|
+
import { BaseApi } from './BaseApi'
|
|
3
3
|
import type { CrudApi, GetByIdQuery, ListQuery, Paginated } from './types'
|
|
4
4
|
export type {
|
|
5
5
|
WhereClause,
|
|
@@ -20,19 +20,15 @@ export class BaseCrudApi<
|
|
|
20
20
|
TCreate,
|
|
21
21
|
TUpdate = TCreate,
|
|
22
22
|
TQuery extends ListQuery<TResponse> = ListQuery<TResponse>,
|
|
23
|
-
>
|
|
24
|
-
|
|
23
|
+
>
|
|
24
|
+
extends BaseApi
|
|
25
|
+
implements CrudApi<TResponse, TCreate, TUpdate, TQuery>
|
|
26
|
+
{
|
|
25
27
|
public readonly basePath: string
|
|
26
28
|
|
|
27
29
|
constructor(client: ApiClient, basePath: string) {
|
|
28
|
-
|
|
30
|
+
super(client)
|
|
29
31
|
this.basePath = basePath.replace(/\/$/, '')
|
|
30
|
-
this.getMany = this.getMany.bind(this)
|
|
31
|
-
this.getOne = this.getOne.bind(this)
|
|
32
|
-
this.getById = this.getById.bind(this)
|
|
33
|
-
this.create = this.create.bind(this)
|
|
34
|
-
this.updateById = this.updateById.bind(this)
|
|
35
|
-
this.deleteById = this.deleteById.bind(this)
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
getMany(
|
|
@@ -43,11 +39,15 @@ export class BaseCrudApi<
|
|
|
43
39
|
query?: TQuery & { paginate?: true },
|
|
44
40
|
headers?: Record<string, string>,
|
|
45
41
|
): Promise<Paginated<TResponse>>
|
|
42
|
+
getMany(
|
|
43
|
+
query?: TQuery,
|
|
44
|
+
headers?: Record<string, string>,
|
|
45
|
+
): Promise<Paginated<TResponse> | TResponse[]>
|
|
46
46
|
getMany(
|
|
47
47
|
query?: TQuery,
|
|
48
48
|
headers?: Record<string, string>,
|
|
49
49
|
): Promise<Paginated<TResponse> | TResponse[]> {
|
|
50
|
-
const queryString =
|
|
50
|
+
const queryString = this.toQueryString(query)
|
|
51
51
|
return this.client.get<Paginated<TResponse> | TResponse[]>(
|
|
52
52
|
`${this.basePath}${queryString}`,
|
|
53
53
|
headers,
|
|
@@ -64,7 +64,8 @@ export class BaseCrudApi<
|
|
|
64
64
|
{ ...query, limit: 1, paginate: false } as TQuery & { paginate: false },
|
|
65
65
|
headers,
|
|
66
66
|
)
|
|
67
|
-
|
|
67
|
+
// An empty response body resolves to `undefined`.
|
|
68
|
+
return results?.[0] ?? null
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/**
|
|
@@ -76,8 +77,7 @@ export class BaseCrudApi<
|
|
|
76
77
|
query?: GetByIdQuery<TResponse>,
|
|
77
78
|
headers?: Record<string, string>,
|
|
78
79
|
): Promise<TResponse> {
|
|
79
|
-
|
|
80
|
-
return this.client.get<TResponse>(`${this.basePath}/${id}${queryString}`, headers)
|
|
80
|
+
return this.client.get<TResponse>(`${this.basePath}/${id}${this.toQueryString(query)}`, headers)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|