@ikatec/digisac-api-sdk 4.0.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.
Files changed (130) hide show
  1. package/README.md +29 -2
  2. package/dist/apis/authHistory/AuthHistoryApi.cjs +3 -9
  3. package/dist/apis/authHistory/AuthHistoryApi.cjs.map +1 -1
  4. package/dist/apis/authHistory/AuthHistoryApi.d.ts +3 -4
  5. package/dist/apis/authHistory/AuthHistoryApi.d.ts.map +1 -1
  6. package/dist/apis/authHistory/AuthHistoryApi.mjs +3 -7
  7. package/dist/apis/authHistory/AuthHistoryApi.mjs.map +1 -1
  8. package/dist/apis/clientFeedback/ClientFeedbackApi.cjs +2 -5
  9. package/dist/apis/clientFeedback/ClientFeedbackApi.cjs.map +1 -1
  10. package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts +2 -4
  11. package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts.map +1 -1
  12. package/dist/apis/clientFeedback/ClientFeedbackApi.mjs +3 -5
  13. package/dist/apis/clientFeedback/ClientFeedbackApi.mjs.map +1 -1
  14. package/dist/apis/contacts/ContactsApi.cjs +2 -2
  15. package/dist/apis/contacts/ContactsApi.cjs.map +1 -1
  16. package/dist/apis/contacts/ContactsApi.mjs +2 -2
  17. package/dist/apis/contacts/ContactsApi.mjs.map +1 -1
  18. package/dist/apis/me/MeApi.cjs +2 -5
  19. package/dist/apis/me/MeApi.cjs.map +1 -1
  20. package/dist/apis/me/MeApi.d.ts +2 -4
  21. package/dist/apis/me/MeApi.d.ts.map +1 -1
  22. package/dist/apis/me/MeApi.mjs +3 -5
  23. package/dist/apis/me/MeApi.mjs.map +1 -1
  24. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs +3 -9
  25. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs.map +1 -1
  26. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts +3 -4
  27. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts.map +1 -1
  28. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs +3 -7
  29. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs.map +1 -1
  30. package/dist/apis/stickerUsers/StickerUsersApi.cjs +3 -9
  31. package/dist/apis/stickerUsers/StickerUsersApi.cjs.map +1 -1
  32. package/dist/apis/stickerUsers/StickerUsersApi.d.ts +3 -4
  33. package/dist/apis/stickerUsers/StickerUsersApi.d.ts.map +1 -1
  34. package/dist/apis/stickerUsers/StickerUsersApi.mjs +3 -7
  35. package/dist/apis/stickerUsers/StickerUsersApi.mjs.map +1 -1
  36. package/dist/apis/terms/TermsApi.cjs +3 -12
  37. package/dist/apis/terms/TermsApi.cjs.map +1 -1
  38. package/dist/apis/terms/TermsApi.d.ts +2 -4
  39. package/dist/apis/terms/TermsApi.d.ts.map +1 -1
  40. package/dist/apis/terms/TermsApi.mjs +3 -10
  41. package/dist/apis/terms/TermsApi.mjs.map +1 -1
  42. package/dist/apis/transcripts/TranscriptsApi.cjs +2 -5
  43. package/dist/apis/transcripts/TranscriptsApi.cjs.map +1 -1
  44. package/dist/apis/transcripts/TranscriptsApi.d.ts +2 -4
  45. package/dist/apis/transcripts/TranscriptsApi.d.ts.map +1 -1
  46. package/dist/apis/transcripts/TranscriptsApi.mjs +3 -5
  47. package/dist/apis/transcripts/TranscriptsApi.mjs.map +1 -1
  48. package/dist/core/ApiClient.d.ts +3 -0
  49. package/dist/core/ApiClient.d.ts.map +1 -1
  50. package/dist/core/ArchivableCrudApi.cjs +0 -2
  51. package/dist/core/ArchivableCrudApi.cjs.map +1 -1
  52. package/dist/core/ArchivableCrudApi.d.ts.map +1 -1
  53. package/dist/core/ArchivableCrudApi.mjs +0 -2
  54. package/dist/core/ArchivableCrudApi.mjs.map +1 -1
  55. package/dist/core/BaseApi.cjs +34 -0
  56. package/dist/core/BaseApi.cjs.map +1 -0
  57. package/dist/core/BaseApi.d.ts +20 -0
  58. package/dist/core/BaseApi.d.ts.map +1 -0
  59. package/dist/core/BaseApi.mjs +33 -0
  60. package/dist/core/BaseApi.mjs.map +1 -0
  61. package/dist/core/BaseApiClient.cjs +4 -1
  62. package/dist/core/BaseApiClient.cjs.map +1 -1
  63. package/dist/core/BaseApiClient.d.ts +7 -1
  64. package/dist/core/BaseApiClient.d.ts.map +1 -1
  65. package/dist/core/BaseApiClient.mjs +4 -1
  66. package/dist/core/BaseApiClient.mjs.map +1 -1
  67. package/dist/core/BaseCrudApi.cjs +6 -16
  68. package/dist/core/BaseCrudApi.cjs.map +1 -1
  69. package/dist/core/BaseCrudApi.d.ts +3 -2
  70. package/dist/core/BaseCrudApi.d.ts.map +1 -1
  71. package/dist/core/BaseCrudApi.mjs +6 -14
  72. package/dist/core/BaseCrudApi.mjs.map +1 -1
  73. package/dist/core/bindMethods.cjs +24 -0
  74. package/dist/core/bindMethods.cjs.map +1 -0
  75. package/dist/core/bindMethods.d.ts +7 -0
  76. package/dist/core/bindMethods.d.ts.map +1 -0
  77. package/dist/core/bindMethods.mjs +22 -0
  78. package/dist/core/bindMethods.mjs.map +1 -0
  79. package/dist/core/index.cjs +2 -0
  80. package/dist/core/index.d.ts +3 -0
  81. package/dist/core/index.d.ts.map +1 -1
  82. package/dist/core/index.mjs +2 -1
  83. package/dist/core/queryString.cjs +22 -0
  84. package/dist/core/queryString.cjs.map +1 -0
  85. package/dist/core/queryString.d.ts +14 -0
  86. package/dist/core/queryString.d.ts.map +1 -0
  87. package/dist/core/queryString.mjs +18 -0
  88. package/dist/core/queryString.mjs.map +1 -0
  89. package/dist/core/types.d.ts +2 -0
  90. package/dist/core/types.d.ts.map +1 -1
  91. package/dist/core/withQueryFormat.cjs +42 -0
  92. package/dist/core/withQueryFormat.cjs.map +1 -0
  93. package/dist/core/withQueryFormat.d.ts +10 -0
  94. package/dist/core/withQueryFormat.d.ts.map +1 -0
  95. package/dist/core/withQueryFormat.mjs +40 -0
  96. package/dist/core/withQueryFormat.mjs.map +1 -0
  97. package/dist/index.cjs +2 -0
  98. package/dist/index.mjs +2 -1
  99. package/package.json +1 -1
  100. package/src/apis/authHistory/AuthHistoryApi.test.ts +1 -1
  101. package/src/apis/authHistory/AuthHistoryApi.ts +10 -10
  102. package/src/apis/campaigns/CampaignsApi.test.ts +1 -1
  103. package/src/apis/clientFeedback/ClientFeedbackApi.ts +5 -7
  104. package/src/apis/contacts/ContactsApi.test.ts +1 -1
  105. package/src/apis/contacts/ContactsApi.ts +2 -2
  106. package/src/apis/me/MeApi.ts +2 -8
  107. package/src/apis/messages/MessagesApi.test.ts +2 -2
  108. package/src/apis/queryFormat.test.ts +198 -0
  109. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.test.ts +2 -2
  110. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.ts +10 -10
  111. package/src/apis/stickerUsers/StickerUsersApi.test.ts +1 -1
  112. package/src/apis/stickerUsers/StickerUsersApi.ts +7 -10
  113. package/src/apis/terms/TermsApi.ts +3 -12
  114. package/src/apis/transcripts/TranscriptsApi.ts +2 -7
  115. package/src/core/ApiClient.ts +4 -0
  116. package/src/core/ArchivableCrudApi.ts +0 -2
  117. package/src/core/BaseApi.test.ts +164 -0
  118. package/src/core/BaseApi.ts +32 -0
  119. package/src/core/BaseApiClient.test.ts +10 -0
  120. package/src/core/BaseApiClient.ts +9 -1
  121. package/src/core/BaseCrudApi.test-d.ts +9 -0
  122. package/src/core/BaseCrudApi.test.ts +131 -60
  123. package/src/core/BaseCrudApi.ts +14 -14
  124. package/src/core/bindMethods.ts +26 -0
  125. package/src/core/index.ts +3 -0
  126. package/src/core/queryString.test.ts +71 -0
  127. package/src/core/queryString.ts +30 -0
  128. package/src/core/types.ts +5 -0
  129. package/src/core/withQueryFormat.test.ts +136 -0
  130. package/src/core/withQueryFormat.ts +47 -0
@@ -20,6 +20,9 @@ expectType<Promise<Paginated<Item>>>(api.getMany({ paginate: true }))
20
20
  expectType<Promise<Item[]>>(api.getMany({ paginate: false }))
21
21
  expectType<Promise<Item[]>>(api.getMany({ where: { name: 'x' }, paginate: false }))
22
22
 
23
+ declare const paginate: boolean
24
+ expectType<Promise<Paginated<Item> | Item[]>>(api.getMany({ paginate }))
25
+
23
26
  // ── getOne / getById ────────────────────────────────────────────────
24
27
 
25
28
  expectType<Promise<Item | null>>(api.getOne())
@@ -46,3 +49,9 @@ expectError(api.updateById('1', { name: 1 }))
46
49
  expectError(api.getMany({ where: { missing: 'x' } }))
47
50
  expectError(api.getMany({ order: [['missing', 'ASC']] }))
48
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,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(calledUrl(client, 0)).toBe('/items?paginate=false')
101
- expect(calledUrl(client, 1)).toBe('/items?limit=1&paginate=false')
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
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
135
- expect(calledUrl).toContain('/items?')
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
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
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(calledUrl(client)).toBe('/items?paginate=false')
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(calledUrl(client)).toBe('/items?page=2')
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
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
197
- expect(calledUrl).toContain('/items?')
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(calledUrl(client)).toBe(
213
- '/items?where[name]=x&order[0][0]=name&order[0][1]=DESC&limit=1&paginate=false',
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(calledUrl(client)).toBe('/items/abc?attributes[0]=id&attributes[1]=name')
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('/items/1?attributes%5B0%5D=id', headers)
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 edge cases ───────────────────────────────
401
+ // ── Query serialization: 'json' (default) ───────────────────────
382
402
 
383
- describe('query serialization', () => {
384
- it('handles complex where with operators', async () => {
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: { $like: '%test%' } } })
388
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
389
- expect(calledUrl).toMatch(/^\/items\?/)
390
- expect(calledUrl).toContain('%25test%25')
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('handles order parameter', async () => {
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({ order: [['name', 'ASC']] })
397
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
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('serializes nested include with where and include', async () => {
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
- await tagsApi.getMany({
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(client)).toBe(
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
- vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
417
-
418
- await api.getMany({ where: { id: { $in: ['a', 'b'] } } })
419
- expect(calledUrl(client)).toBe('/items?where[id][$in][0]=a&where[id][$in][1]=b')
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
- vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
424
-
425
- await api.getMany({ where: { $or: [{ name: 'a' }, { name: 'b' }] } })
426
- expect(calledUrl(client)).toBe('/items?where[$or][0][name]=a&where[$or][1][name]=b')
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
- vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
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(client)).toBe(
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
- vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
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
- await api.getMany({ paranoid: false, distinct: true, limit: 10, offset: 20 })
447
- expect(calledUrl(client)).toBe('/items?paranoid=false&distinct=true&limit=10&offset=20')
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('handles empty query object', async () => {
451
- vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
516
+ it('serializes getOne and getById queries', async () => {
517
+ vi.mocked(qsClient.get).mockResolvedValueOnce([]).mockResolvedValueOnce({ id: '1' })
452
518
 
453
- await api.getMany({})
454
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
455
- // qs.stringify({}) produces '', so we get '/items?'
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
  })
@@ -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
- > implements CrudApi<TResponse, TCreate, TUpdate, TQuery> {
24
- public readonly client: ApiClient
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
- this.client = client
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 = query ? `?${qs.stringify(query)}` : ''
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
- return results[0] ?? null
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
- const queryString = query ? `?${qs.stringify(query)}` : ''
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
  /**
@@ -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,7 @@ export {
17
19
  parseFieldErrors,
18
20
  } from './errors'
19
21
  export type { FieldError, ErrorClassName, ApiErrorOptions } from './errors'
22
+ export { BaseApi } from './BaseApi'
20
23
  export { BaseCrudApi } from './BaseCrudApi'
21
24
  export { ArchivableCrudApi } from './ArchivableCrudApi'
22
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'>