@ikatec/digisac-api-sdk 4.0.0 → 4.2.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 +34 -4
- package/dist/apis/authHistory/AuthHistoryApi.cjs +11 -17
- package/dist/apis/authHistory/AuthHistoryApi.cjs.map +1 -1
- package/dist/apis/authHistory/AuthHistoryApi.d.ts +10 -18
- package/dist/apis/authHistory/AuthHistoryApi.d.ts.map +1 -1
- package/dist/apis/authHistory/AuthHistoryApi.mjs +11 -15
- package/dist/apis/authHistory/AuthHistoryApi.mjs.map +1 -1
- package/dist/apis/authHistory/types.d.ts +4 -3
- package/dist/apis/authHistory/types.d.ts.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/customFields/types.d.ts +21 -19
- package/dist/apis/customFields/types.d.ts.map +1 -1
- package/dist/apis/dashboard/DashboardApi.cjs +19 -0
- package/dist/apis/dashboard/DashboardApi.cjs.map +1 -0
- package/dist/apis/dashboard/DashboardApi.d.ts +12 -0
- package/dist/apis/dashboard/DashboardApi.d.ts.map +1 -0
- package/dist/apis/dashboard/DashboardApi.mjs +18 -0
- package/dist/apis/dashboard/DashboardApi.mjs.map +1 -0
- package/dist/apis/dashboard/index.cjs +4 -0
- package/dist/apis/dashboard/index.d.ts +3 -0
- package/dist/apis/dashboard/index.d.ts.map +1 -0
- package/dist/apis/dashboard/index.mjs +3 -0
- package/dist/apis/dashboard/types.cjs +0 -0
- package/dist/apis/dashboard/types.d.ts +53 -0
- package/dist/apis/dashboard/types.d.ts.map +1 -0
- package/dist/apis/dashboard/types.mjs +0 -0
- package/dist/apis/holiday/types.d.ts +34 -8
- package/dist/apis/holiday/types.d.ts.map +1 -1
- package/dist/apis/index.cjs +6 -0
- package/dist/apis/index.d.ts +2 -0
- package/dist/apis/index.d.ts.map +1 -1
- package/dist/apis/index.mjs +5 -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/now/NowApi.cjs +37 -0
- package/dist/apis/now/NowApi.cjs.map +1 -0
- package/dist/apis/now/NowApi.d.ts +26 -0
- package/dist/apis/now/NowApi.d.ts.map +1 -0
- package/dist/apis/now/NowApi.mjs +36 -0
- package/dist/apis/now/NowApi.mjs.map +1 -0
- package/dist/apis/now/index.cjs +4 -0
- package/dist/apis/now/index.d.ts +3 -0
- package/dist/apis/now/index.d.ts.map +1 -0
- package/dist/apis/now/index.mjs +3 -0
- package/dist/apis/now/types.cjs +0 -0
- package/dist/apis/now/types.d.ts +45 -0
- package/dist/apis/now/types.d.ts.map +1 -0
- package/dist/apis/now/types.mjs +0 -0
- package/dist/apis/people/types.d.ts +7 -2
- package/dist/apis/people/types.d.ts.map +1 -1
- package/dist/apis/permissions/PermissionsApi.cjs +6 -2
- package/dist/apis/permissions/PermissionsApi.cjs.map +1 -1
- package/dist/apis/permissions/PermissionsApi.d.ts +7 -3
- package/dist/apis/permissions/PermissionsApi.d.ts.map +1 -1
- package/dist/apis/permissions/PermissionsApi.mjs +6 -2
- package/dist/apis/permissions/PermissionsApi.mjs.map +1 -1
- package/dist/apis/permissions/types.d.ts +4 -14
- package/dist/apis/permissions/types.d.ts.map +1 -1
- package/dist/apis/personalAccessTokens/PersonalAccessTokensApi.cjs +5 -0
- package/dist/apis/personalAccessTokens/PersonalAccessTokensApi.cjs.map +1 -1
- package/dist/apis/personalAccessTokens/PersonalAccessTokensApi.d.ts +5 -0
- package/dist/apis/personalAccessTokens/PersonalAccessTokensApi.d.ts.map +1 -1
- package/dist/apis/personalAccessTokens/PersonalAccessTokensApi.mjs +5 -0
- package/dist/apis/personalAccessTokens/PersonalAccessTokensApi.mjs.map +1 -1
- package/dist/apis/personalAccessTokens/types.d.ts +23 -4
- package/dist/apis/personalAccessTokens/types.d.ts.map +1 -1
- package/dist/apis/roles/RolesApi.cjs.map +1 -1
- package/dist/apis/roles/RolesApi.d.ts +2 -2
- package/dist/apis/roles/RolesApi.d.ts.map +1 -1
- package/dist/apis/roles/RolesApi.mjs.map +1 -1
- package/dist/apis/roles/types.d.ts +27 -4
- package/dist/apis/roles/types.d.ts.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 +7 -13
- package/dist/apis/terms/TermsApi.cjs.map +1 -1
- package/dist/apis/terms/TermsApi.d.ts +6 -5
- package/dist/apis/terms/TermsApi.d.ts.map +1 -1
- package/dist/apis/terms/TermsApi.mjs +7 -11
- package/dist/apis/terms/TermsApi.mjs.map +1 -1
- package/dist/apis/timetable/types.d.ts +12 -7
- package/dist/apis/timetable/types.d.ts.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 +4 -1
- package/dist/core/BaseApiClient.cjs.map +1 -1
- package/dist/core/BaseApiClient.d.ts +7 -1
- package/dist/core/BaseApiClient.d.ts.map +1 -1
- package/dist/core/BaseApiClient.mjs +4 -1
- package/dist/core/BaseApiClient.mjs.map +1 -1
- package/dist/core/BaseCrudApi.cjs +3 -40
- package/dist/core/BaseCrudApi.cjs.map +1 -1
- package/dist/core/BaseCrudApi.d.ts +4 -23
- package/dist/core/BaseCrudApi.d.ts.map +1 -1
- package/dist/core/BaseCrudApi.mjs +3 -38
- package/dist/core/BaseCrudApi.mjs.map +1 -1
- package/dist/core/BaseReadApi.cjs +44 -0
- package/dist/core/BaseReadApi.cjs.map +1 -0
- package/dist/core/BaseReadApi.d.ts +32 -0
- package/dist/core/BaseReadApi.d.ts.map +1 -0
- package/dist/core/BaseReadApi.mjs +43 -0
- package/dist/core/BaseReadApi.mjs.map +1 -0
- 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/index.cjs +4 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.mjs +3 -1
- 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 +4 -0
- package/dist/index.mjs +3 -1
- package/package.json +1 -1
- package/src/apis/authHistory/AuthHistoryApi.test.ts +11 -3
- package/src/apis/authHistory/AuthHistoryApi.ts +11 -37
- package/src/apis/authHistory/types.ts +4 -3
- 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/customFields/types.ts +21 -25
- package/src/apis/dashboard/DashboardApi.test.ts +82 -0
- package/src/apis/dashboard/DashboardApi.ts +18 -0
- package/src/apis/dashboard/index.ts +2 -0
- package/src/apis/dashboard/types.ts +53 -0
- package/src/apis/holiday/types.ts +36 -8
- package/src/apis/index.ts +2 -0
- package/src/apis/me/MeApi.ts +2 -8
- package/src/apis/messages/MessagesApi.test.ts +2 -2
- package/src/apis/now/NowApi.test.ts +125 -0
- package/src/apis/now/NowApi.ts +59 -0
- package/src/apis/now/index.ts +2 -0
- package/src/apis/now/types.ts +47 -0
- package/src/apis/payloads.test-d.ts +39 -0
- package/src/apis/payloads.test.ts +81 -0
- package/src/apis/people/types.ts +7 -2
- package/src/apis/permissions/PermissionsApi.test.ts +93 -0
- package/src/apis/permissions/PermissionsApi.ts +7 -7
- package/src/apis/permissions/types.ts +4 -16
- package/src/apis/personalAccessTokens/PersonalAccessTokensApi.ts +5 -0
- package/src/apis/personalAccessTokens/types.ts +25 -4
- package/src/apis/queryFormat.test.ts +273 -0
- package/src/apis/roles/RolesApi.ts +7 -2
- package/src/apis/roles/types.ts +27 -4
- 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 +7 -13
- package/src/apis/timetable/types.ts +13 -7
- 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 +10 -0
- package/src/core/BaseApiClient.ts +9 -1
- package/src/core/BaseCrudApi.test-d.ts +9 -0
- package/src/core/BaseCrudApi.test.ts +131 -60
- package/src/core/BaseCrudApi.ts +7 -63
- package/src/core/BaseReadApi.ts +70 -0
- package/src/core/bindMethods.ts +26 -0
- package/src/core/index.ts +4 -0
- 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,11 +1,12 @@
|
|
|
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 }
|
|
11
12
|
type ItemWithTags = Item & {
|
|
@@ -15,8 +16,9 @@ type ItemWithTags = Item & {
|
|
|
15
16
|
|
|
16
17
|
// ── Mock client factory ──────────────────────────────────────────────
|
|
17
18
|
|
|
18
|
-
function createMockClient(): ApiClient {
|
|
19
|
+
function createMockClient(queryFormat?: QueryFormat): ApiClient {
|
|
19
20
|
return {
|
|
21
|
+
...(queryFormat && { queryFormat }),
|
|
20
22
|
setAccessToken: vi.fn().mockReturnThis(),
|
|
21
23
|
request: vi.fn(),
|
|
22
24
|
get: vi.fn(),
|
|
@@ -32,6 +34,18 @@ function calledUrl(client: ApiClient, call = 0): string {
|
|
|
32
34
|
return decodeURIComponent(vi.mocked(client.get).mock.calls.at(call)?.at(0) as string)
|
|
33
35
|
}
|
|
34
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
|
+
|
|
35
49
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
36
50
|
|
|
37
51
|
describe('BaseCrudApi', () => {
|
|
@@ -97,8 +111,8 @@ describe('BaseCrudApi', () => {
|
|
|
97
111
|
await updateById('1', { name: 'x' })
|
|
98
112
|
await deleteById('1')
|
|
99
113
|
|
|
100
|
-
expect(
|
|
101
|
-
expect(
|
|
114
|
+
expect(calledQuery(client, 0)).toEqual({ paginate: false })
|
|
115
|
+
expect(calledQuery(client, 1)).toEqual({ limit: 1, paginate: false })
|
|
102
116
|
expect(calledUrl(client, 2)).toBe('/items/1')
|
|
103
117
|
expect(client.post).toHaveBeenCalledWith('/items', { name: 'x' }, undefined)
|
|
104
118
|
expect(client.put).toHaveBeenCalledWith('/items/1', { name: 'x' }, undefined)
|
|
@@ -131,20 +145,15 @@ describe('BaseCrudApi', () => {
|
|
|
131
145
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
132
146
|
|
|
133
147
|
await api.getMany({ page: 2, perPage: 25 })
|
|
134
|
-
|
|
135
|
-
expect(
|
|
136
|
-
expect(calledUrl).toContain('page=2')
|
|
137
|
-
expect(calledUrl).toContain('perPage=25')
|
|
148
|
+
expect(calledPath(client)).toBe('/items')
|
|
149
|
+
expect(calledQuery(client)).toEqual({ page: 2, perPage: 25 })
|
|
138
150
|
})
|
|
139
151
|
|
|
140
152
|
it('serializes where clause into query string', async () => {
|
|
141
153
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
142
154
|
|
|
143
155
|
await api.getMany({ where: { name: 'test' } })
|
|
144
|
-
|
|
145
|
-
expect(calledUrl).toContain('where')
|
|
146
|
-
expect(calledUrl).toContain('name')
|
|
147
|
-
expect(calledUrl).toContain('test')
|
|
156
|
+
expect(calledQuery(client)).toEqual({ where: { name: 'test' } })
|
|
148
157
|
})
|
|
149
158
|
|
|
150
159
|
it('forwards custom headers', async () => {
|
|
@@ -160,7 +169,7 @@ describe('BaseCrudApi', () => {
|
|
|
160
169
|
vi.mocked(client.get).mockResolvedValue(items)
|
|
161
170
|
|
|
162
171
|
const result = await api.getMany({ paginate: false })
|
|
163
|
-
expect(
|
|
172
|
+
expect(calledQuery(client)).toEqual({ paginate: false })
|
|
164
173
|
expect(result).toEqual(items)
|
|
165
174
|
})
|
|
166
175
|
|
|
@@ -173,7 +182,7 @@ describe('BaseCrudApi', () => {
|
|
|
173
182
|
page: number
|
|
174
183
|
}
|
|
175
184
|
await api.getMany(query)
|
|
176
|
-
expect(
|
|
185
|
+
expect(calledQuery(client)).toEqual({ page: 2, where: {} })
|
|
177
186
|
})
|
|
178
187
|
|
|
179
188
|
it('forwards headers with query', async () => {
|
|
@@ -193,10 +202,8 @@ describe('BaseCrudApi', () => {
|
|
|
193
202
|
vi.mocked(client.get).mockResolvedValue([item])
|
|
194
203
|
|
|
195
204
|
const result = await api.getOne()
|
|
196
|
-
|
|
197
|
-
expect(
|
|
198
|
-
expect(calledUrl).toContain('limit=1')
|
|
199
|
-
expect(calledUrl).toContain('paginate=false')
|
|
205
|
+
expect(calledPath(client)).toBe('/items')
|
|
206
|
+
expect(calledQuery(client)).toEqual({ limit: 1, paginate: false })
|
|
200
207
|
expect(result).toEqual(item)
|
|
201
208
|
})
|
|
202
209
|
|
|
@@ -209,9 +216,12 @@ describe('BaseCrudApi', () => {
|
|
|
209
216
|
limit: 50,
|
|
210
217
|
paginate: true,
|
|
211
218
|
})
|
|
212
|
-
expect(
|
|
213
|
-
|
|
214
|
-
|
|
219
|
+
expect(calledQuery(client)).toEqual({
|
|
220
|
+
where: { name: 'x' },
|
|
221
|
+
order: [['name', 'DESC']],
|
|
222
|
+
limit: 1,
|
|
223
|
+
paginate: false,
|
|
224
|
+
})
|
|
215
225
|
})
|
|
216
226
|
|
|
217
227
|
it('returns only the first item when the API returns more than one', async () => {
|
|
@@ -223,6 +233,12 @@ describe('BaseCrudApi', () => {
|
|
|
223
233
|
expect(await api.getOne()).toEqual({ id: '1', name: 'a' })
|
|
224
234
|
})
|
|
225
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
|
+
|
|
226
242
|
it('returns null when no records match', async () => {
|
|
227
243
|
vi.mocked(client.get).mockResolvedValue([])
|
|
228
244
|
|
|
@@ -255,7 +271,8 @@ describe('BaseCrudApi', () => {
|
|
|
255
271
|
vi.mocked(client.get).mockResolvedValue({ id: 'abc', name: 'Test' })
|
|
256
272
|
|
|
257
273
|
await api.getById('abc', { attributes: ['id', 'name'] })
|
|
258
|
-
expect(
|
|
274
|
+
expect(calledPath(client)).toBe('/items/abc')
|
|
275
|
+
expect(calledQuery(client)).toEqual({ attributes: ['id', 'name'] })
|
|
259
276
|
})
|
|
260
277
|
|
|
261
278
|
it('forwards query and headers together', async () => {
|
|
@@ -263,7 +280,10 @@ describe('BaseCrudApi', () => {
|
|
|
263
280
|
const headers = { 'X-Tenant': '123' }
|
|
264
281
|
|
|
265
282
|
await api.getById('1', { attributes: ['id'] }, headers)
|
|
266
|
-
expect(client.get).toHaveBeenCalledWith(
|
|
283
|
+
expect(client.get).toHaveBeenCalledWith(
|
|
284
|
+
`/items/1?query=${encodeURIComponent('{"attributes":["id"]}')}`,
|
|
285
|
+
headers,
|
|
286
|
+
)
|
|
267
287
|
})
|
|
268
288
|
|
|
269
289
|
it('forwards custom headers', async () => {
|
|
@@ -378,82 +398,133 @@ describe('BaseCrudApi', () => {
|
|
|
378
398
|
})
|
|
379
399
|
})
|
|
380
400
|
|
|
381
|
-
// ── Query serialization
|
|
401
|
+
// ── Query serialization: 'json' (default) ───────────────────────
|
|
382
402
|
|
|
383
|
-
describe(
|
|
384
|
-
it('
|
|
403
|
+
describe("query serialization (queryFormat: 'json', the default)", () => {
|
|
404
|
+
it('sends the query as ?query=<JSON>', async () => {
|
|
385
405
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
386
406
|
|
|
387
|
-
await api.getMany({ where: { name:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
+
)
|
|
391
412
|
})
|
|
392
413
|
|
|
393
|
-
it('
|
|
414
|
+
it('keeps null, booleans and numbers typed', async () => {
|
|
394
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
|
+
}
|
|
395
422
|
|
|
396
|
-
await api.getMany(
|
|
397
|
-
|
|
398
|
-
expect(calledUrl).toContain('order')
|
|
399
|
-
expect(calledUrl).toContain('name')
|
|
400
|
-
expect(calledUrl).toContain('ASC')
|
|
423
|
+
await api.getMany(query)
|
|
424
|
+
expect(calledQuery(client)).toEqual(query)
|
|
401
425
|
})
|
|
402
426
|
|
|
403
|
-
it('
|
|
427
|
+
it('keeps deeply nested includes intact', async () => {
|
|
404
428
|
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
405
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
|
+
}
|
|
436
|
+
|
|
437
|
+
await tagsApi.getMany(query)
|
|
438
|
+
expect(calledQuery(client)).toEqual(query)
|
|
439
|
+
})
|
|
440
|
+
|
|
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 () => {
|
|
449
|
+
vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
|
|
450
|
+
|
|
451
|
+
await api.getMany({})
|
|
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
|
+
})
|
|
406
474
|
|
|
407
|
-
|
|
475
|
+
it('serializes nested include with where and include', async () => {
|
|
476
|
+
await qsApi.getMany({
|
|
408
477
|
include: ['owner', { model: 'tags', where: { label: 'vip' }, required: true }],
|
|
409
478
|
})
|
|
410
|
-
expect(calledUrl(
|
|
479
|
+
expect(calledUrl(qsClient)).toBe(
|
|
411
480
|
'/items?include[0]=owner&include[1][model]=tags&include[1][where][label]=vip&include[1][required]=true',
|
|
412
481
|
)
|
|
413
482
|
})
|
|
414
483
|
|
|
415
484
|
it('serializes $in arrays with indices', async () => {
|
|
416
|
-
|
|
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')
|
|
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')
|
|
420
487
|
})
|
|
421
488
|
|
|
422
489
|
it('serializes $or/$and combinators', async () => {
|
|
423
|
-
|
|
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')
|
|
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')
|
|
427
492
|
})
|
|
428
493
|
|
|
429
494
|
it('serializes order exactly', async () => {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
await api.getMany({
|
|
495
|
+
await qsApi.getMany({
|
|
433
496
|
order: [
|
|
434
497
|
['name', 'ASC'],
|
|
435
498
|
['id', 'desc'],
|
|
436
499
|
],
|
|
437
500
|
})
|
|
438
|
-
expect(calledUrl(
|
|
501
|
+
expect(calledUrl(qsClient)).toBe(
|
|
439
502
|
'/items?order[0][0]=name&order[0][1]=ASC&order[1][0]=id&order[1][1]=desc',
|
|
440
503
|
)
|
|
441
504
|
})
|
|
442
505
|
|
|
443
506
|
it('serializes booleans and numbers as strings', async () => {
|
|
444
|
-
|
|
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
|
+
})
|
|
445
510
|
|
|
446
|
-
|
|
447
|
-
|
|
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]=')
|
|
448
514
|
})
|
|
449
515
|
|
|
450
|
-
it('
|
|
451
|
-
vi.mocked(
|
|
516
|
+
it('serializes getOne and getById queries', async () => {
|
|
517
|
+
vi.mocked(qsClient.get).mockResolvedValueOnce([]).mockResolvedValueOnce({ id: '1' })
|
|
452
518
|
|
|
453
|
-
await
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
expect(calledUrl).toBe('/items?')
|
|
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)
|
|
457
528
|
})
|
|
458
529
|
})
|
|
459
530
|
})
|
package/src/core/BaseCrudApi.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
3
|
-
import type { CrudApi, GetByIdQuery, ListQuery, Paginated } from './types'
|
|
1
|
+
import { BaseReadApi } from './BaseReadApi'
|
|
2
|
+
import type { CrudApi, ListQuery } from './types'
|
|
4
3
|
export type {
|
|
5
4
|
WhereClause,
|
|
6
5
|
IncludeItem,
|
|
@@ -15,71 +14,16 @@ export type {
|
|
|
15
14
|
CrudApi,
|
|
16
15
|
} from './types'
|
|
17
16
|
|
|
17
|
+
/** Generic CRUD resource: the reads of `BaseReadApi` plus `create`, `updateById` and `deleteById`. */
|
|
18
18
|
export class BaseCrudApi<
|
|
19
19
|
TResponse,
|
|
20
20
|
TCreate,
|
|
21
21
|
TUpdate = TCreate,
|
|
22
22
|
TQuery extends ListQuery<TResponse> = ListQuery<TResponse>,
|
|
23
|
-
>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
constructor(client: ApiClient, basePath: string) {
|
|
28
|
-
this.client = client
|
|
29
|
-
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
|
-
}
|
|
37
|
-
|
|
38
|
-
getMany(
|
|
39
|
-
query: TQuery & { paginate: false },
|
|
40
|
-
headers?: Record<string, string>,
|
|
41
|
-
): Promise<TResponse[]>
|
|
42
|
-
getMany(
|
|
43
|
-
query?: TQuery & { paginate?: true },
|
|
44
|
-
headers?: Record<string, string>,
|
|
45
|
-
): Promise<Paginated<TResponse>>
|
|
46
|
-
getMany(
|
|
47
|
-
query?: TQuery,
|
|
48
|
-
headers?: Record<string, string>,
|
|
49
|
-
): Promise<Paginated<TResponse> | TResponse[]> {
|
|
50
|
-
const queryString = query ? `?${qs.stringify(query)}` : ''
|
|
51
|
-
return this.client.get<Paginated<TResponse> | TResponse[]>(
|
|
52
|
-
`${this.basePath}${queryString}`,
|
|
53
|
-
headers,
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Returns the first resource matching the query, or `null` when none match.
|
|
59
|
-
* Convenience over `getMany` with `limit: 1`.
|
|
60
|
-
* @permissions $resourceName.view
|
|
61
|
-
*/
|
|
62
|
-
async getOne(query?: TQuery, headers?: Record<string, string>): Promise<TResponse | null> {
|
|
63
|
-
const results = await this.getMany(
|
|
64
|
-
{ ...query, limit: 1, paginate: false } as TQuery & { paginate: false },
|
|
65
|
-
headers,
|
|
66
|
-
)
|
|
67
|
-
return results[0] ?? null
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Returns a single resource by ID.
|
|
72
|
-
* @permissions $resourceName.view
|
|
73
|
-
*/
|
|
74
|
-
getById(
|
|
75
|
-
id: string,
|
|
76
|
-
query?: GetByIdQuery<TResponse>,
|
|
77
|
-
headers?: Record<string, string>,
|
|
78
|
-
): Promise<TResponse> {
|
|
79
|
-
const queryString = query ? `?${qs.stringify(query)}` : ''
|
|
80
|
-
return this.client.get<TResponse>(`${this.basePath}/${id}${queryString}`, headers)
|
|
81
|
-
}
|
|
82
|
-
|
|
23
|
+
>
|
|
24
|
+
extends BaseReadApi<TResponse, TQuery>
|
|
25
|
+
implements CrudApi<TResponse, TCreate, TUpdate, TQuery>
|
|
26
|
+
{
|
|
83
27
|
/**
|
|
84
28
|
* Creates a new resource.
|
|
85
29
|
* @permissions $resourceName.create
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ApiClient } from './ApiClient'
|
|
2
|
+
import { BaseApi } from './BaseApi'
|
|
3
|
+
import type { GetByIdQuery, HasGetById, HasGetMany, ListQuery, Paginated } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic read-only resource: `getMany`, `getOne` and `getById` under `basePath`. Base of
|
|
7
|
+
* `BaseCrudApi`, and of the resources the backend only serves for reading (e.g. permissions,
|
|
8
|
+
* auth history).
|
|
9
|
+
*/
|
|
10
|
+
export class BaseReadApi<TResponse, TQuery extends ListQuery<TResponse> = ListQuery<TResponse>>
|
|
11
|
+
extends BaseApi
|
|
12
|
+
implements HasGetMany<TResponse, TQuery>, HasGetById<TResponse>
|
|
13
|
+
{
|
|
14
|
+
public readonly basePath: string
|
|
15
|
+
|
|
16
|
+
constructor(client: ApiClient, basePath: string) {
|
|
17
|
+
super(client)
|
|
18
|
+
this.basePath = basePath.replace(/\/$/, '')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getMany(
|
|
22
|
+
query: TQuery & { paginate: false },
|
|
23
|
+
headers?: Record<string, string>,
|
|
24
|
+
): Promise<TResponse[]>
|
|
25
|
+
getMany(
|
|
26
|
+
query?: TQuery & { paginate?: true },
|
|
27
|
+
headers?: Record<string, string>,
|
|
28
|
+
): Promise<Paginated<TResponse>>
|
|
29
|
+
getMany(
|
|
30
|
+
query?: TQuery,
|
|
31
|
+
headers?: Record<string, string>,
|
|
32
|
+
): Promise<Paginated<TResponse> | TResponse[]>
|
|
33
|
+
getMany(
|
|
34
|
+
query?: TQuery,
|
|
35
|
+
headers?: Record<string, string>,
|
|
36
|
+
): Promise<Paginated<TResponse> | TResponse[]> {
|
|
37
|
+
const queryString = this.toQueryString(query)
|
|
38
|
+
return this.client.get<Paginated<TResponse> | TResponse[]>(
|
|
39
|
+
`${this.basePath}${queryString}`,
|
|
40
|
+
headers,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns the first resource matching the query, or `null` when none match.
|
|
46
|
+
* Convenience over `getMany` with `limit: 1`.
|
|
47
|
+
* @permissions $resourceName.view
|
|
48
|
+
*/
|
|
49
|
+
async getOne(query?: TQuery, headers?: Record<string, string>): Promise<TResponse | null> {
|
|
50
|
+
const results = await this.getMany(
|
|
51
|
+
{ ...query, limit: 1, paginate: false } as TQuery & { paginate: false },
|
|
52
|
+
headers,
|
|
53
|
+
)
|
|
54
|
+
// An empty response body resolves to `undefined`.
|
|
55
|
+
return results?.[0] ?? null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns a single resource by ID. When it does not exist most routes answer 200 with an empty
|
|
60
|
+
* body, which resolves to `undefined`.
|
|
61
|
+
* @permissions $resourceName.view
|
|
62
|
+
*/
|
|
63
|
+
getById(
|
|
64
|
+
id: string,
|
|
65
|
+
query?: GetByIdQuery<TResponse>,
|
|
66
|
+
headers?: Record<string, string>,
|
|
67
|
+
): Promise<TResponse> {
|
|
68
|
+
return this.client.get<TResponse>(`${this.basePath}/${id}${this.toQueryString(query)}`, headers)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds every method of `instance`'s class chain (up to `Object.prototype`) to `instance`, as own
|
|
3
|
+
* properties, so methods keep working when destructured (`const { getMany } = api`). The most
|
|
4
|
+
* derived implementation wins; getters, setters and `constructor` are skipped.
|
|
5
|
+
*/
|
|
6
|
+
export function bindMethods(instance: object): void {
|
|
7
|
+
const target = instance as Record<PropertyKey, unknown>
|
|
8
|
+
const bound = new Set<PropertyKey>()
|
|
9
|
+
|
|
10
|
+
for (
|
|
11
|
+
let prototype: object | null = Object.getPrototypeOf(instance);
|
|
12
|
+
prototype && prototype !== Object.prototype;
|
|
13
|
+
prototype = Object.getPrototypeOf(prototype)
|
|
14
|
+
) {
|
|
15
|
+
for (const key of Reflect.ownKeys(prototype)) {
|
|
16
|
+
if (key === 'constructor' || bound.has(key)) {
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
const { value } = Object.getOwnPropertyDescriptor(prototype, key) as PropertyDescriptor
|
|
20
|
+
if (typeof value === 'function') {
|
|
21
|
+
target[key] = value.bind(instance)
|
|
22
|
+
bound.add(key)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type { ApiClient, HttpMethod } from './ApiClient'
|
|
2
2
|
export { BaseApiClient } from './BaseApiClient'
|
|
3
|
+
export type { BaseApiClientOptions } from './BaseApiClient'
|
|
4
|
+
export type { QueryFormat } from './queryString'
|
|
3
5
|
export {
|
|
4
6
|
DigisacError,
|
|
5
7
|
ApiConnectionError,
|
|
@@ -17,6 +19,8 @@ export {
|
|
|
17
19
|
parseFieldErrors,
|
|
18
20
|
} from './errors'
|
|
19
21
|
export type { FieldError, ErrorClassName, ApiErrorOptions } from './errors'
|
|
22
|
+
export { BaseApi } from './BaseApi'
|
|
23
|
+
export { BaseReadApi } from './BaseReadApi'
|
|
20
24
|
export { BaseCrudApi } from './BaseCrudApi'
|
|
21
25
|
export { ArchivableCrudApi } from './ArchivableCrudApi'
|
|
22
26
|
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'>
|