@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.
Files changed (139) hide show
  1. package/README.md +71 -9
  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 +29 -57
  62. package/dist/core/BaseApiClient.cjs.map +1 -1
  63. package/dist/core/BaseApiClient.d.ts +8 -11
  64. package/dist/core/BaseApiClient.d.ts.map +1 -1
  65. package/dist/core/BaseApiClient.mjs +31 -57
  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/errors.cjs +192 -0
  80. package/dist/core/errors.cjs.map +1 -0
  81. package/dist/core/errors.d.ts +117 -0
  82. package/dist/core/errors.d.ts.map +1 -0
  83. package/dist/core/errors.mjs +177 -0
  84. package/dist/core/errors.mjs.map +1 -0
  85. package/dist/core/index.cjs +18 -2
  86. package/dist/core/index.d.ts +6 -2
  87. package/dist/core/index.d.ts.map +1 -1
  88. package/dist/core/index.mjs +4 -2
  89. package/dist/core/queryString.cjs +22 -0
  90. package/dist/core/queryString.cjs.map +1 -0
  91. package/dist/core/queryString.d.ts +14 -0
  92. package/dist/core/queryString.d.ts.map +1 -0
  93. package/dist/core/queryString.mjs +18 -0
  94. package/dist/core/queryString.mjs.map +1 -0
  95. package/dist/core/types.d.ts +2 -0
  96. package/dist/core/types.d.ts.map +1 -1
  97. package/dist/core/withQueryFormat.cjs +42 -0
  98. package/dist/core/withQueryFormat.cjs.map +1 -0
  99. package/dist/core/withQueryFormat.d.ts +10 -0
  100. package/dist/core/withQueryFormat.d.ts.map +1 -0
  101. package/dist/core/withQueryFormat.mjs +40 -0
  102. package/dist/core/withQueryFormat.mjs.map +1 -0
  103. package/dist/index.cjs +18 -2
  104. package/dist/index.mjs +4 -2
  105. package/package.json +1 -1
  106. package/src/apis/authHistory/AuthHistoryApi.test.ts +1 -1
  107. package/src/apis/authHistory/AuthHistoryApi.ts +10 -10
  108. package/src/apis/campaigns/CampaignsApi.test.ts +1 -1
  109. package/src/apis/clientFeedback/ClientFeedbackApi.ts +5 -7
  110. package/src/apis/contacts/ContactsApi.test.ts +1 -1
  111. package/src/apis/contacts/ContactsApi.ts +2 -2
  112. package/src/apis/me/MeApi.ts +2 -8
  113. package/src/apis/messages/MessagesApi.test.ts +2 -2
  114. package/src/apis/queryFormat.test.ts +198 -0
  115. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.test.ts +2 -2
  116. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.ts +10 -10
  117. package/src/apis/stickerUsers/StickerUsersApi.test.ts +1 -1
  118. package/src/apis/stickerUsers/StickerUsersApi.ts +7 -10
  119. package/src/apis/terms/TermsApi.ts +3 -12
  120. package/src/apis/transcripts/TranscriptsApi.ts +2 -7
  121. package/src/core/ApiClient.ts +4 -0
  122. package/src/core/ArchivableCrudApi.ts +0 -2
  123. package/src/core/BaseApi.test.ts +164 -0
  124. package/src/core/BaseApi.ts +32 -0
  125. package/src/core/BaseApiClient.test.ts +193 -22
  126. package/src/core/BaseApiClient.ts +44 -85
  127. package/src/core/BaseCrudApi.test-d.ts +57 -0
  128. package/src/core/BaseCrudApi.test.ts +252 -31
  129. package/src/core/BaseCrudApi.ts +14 -14
  130. package/src/core/bindMethods.ts +26 -0
  131. package/src/core/errors.test-d.ts +14 -0
  132. package/src/core/errors.test.ts +389 -0
  133. package/src/core/errors.ts +284 -0
  134. package/src/core/index.ts +21 -2
  135. package/src/core/queryString.test.ts +71 -0
  136. package/src/core/queryString.ts +30 -0
  137. package/src/core/types.ts +5 -0
  138. package/src/core/withQueryFormat.test.ts +136 -0
  139. 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 ValidationError {
4
- path: string
5
- message: string
6
- code: string
7
- }
8
-
9
- /**
10
- * Digisac field errors: `{ errors: { <location>: { <field>: { messages: string[], types: string[] } } } }`,
11
- * where location is `body`, `params`, `query`, `headers` or `cookies` (backend `validate` middleware).
12
- */
13
- type DigisacFieldErrors = Record<string, Record<string, { messages?: unknown; types?: unknown }>>
14
-
15
- const isRecord = (value: unknown): value is Record<string, unknown> =>
16
- typeof value === 'object' && value !== null && !Array.isArray(value)
17
-
18
- /**
19
- * Flattens the Digisac `errors` object into `ValidationError[]` — one entry per message, with
20
- * `path` = `<location>.<field>` (e.g. `body.name`) and `code` = the rule type (e.g. `required`).
21
- * Returns `null` when the body carries no field errors.
22
- */
23
- function parseValidationErrors(errors: unknown): ValidationError[] | null {
24
- if (!isRecord(errors)) {
25
- return null
26
- }
27
-
28
- const parsed: ValidationError[] = []
29
- for (const [location, fields] of Object.entries(errors as DigisacFieldErrors)) {
30
- if (!isRecord(fields)) {
31
- continue
32
- }
33
- for (const [field, detail] of Object.entries(fields)) {
34
- const messages = Array.isArray(detail?.messages) ? detail.messages.map(String) : []
35
- const types = Array.isArray(detail?.types) ? detail.types.map(String) : []
36
- messages.forEach((message, index) => {
37
- parsed.push({ path: `${location}.${field}`, message, code: types[index] ?? types[0] ?? '' })
38
- })
39
- }
40
- }
41
-
42
- return parsed.length ? parsed : null
43
- }
44
-
45
- export class ApiError extends Error {
46
- public readonly errorClass?: string | undefined
47
- public readonly status?: number | undefined
48
- public readonly validationErrors: ValidationError[] | null
49
-
50
- constructor(
51
- message: string,
52
- errorClass?: string,
53
- status?: number,
54
- validationErrors: ValidationError[] | null = null,
55
- ) {
56
- super(message)
57
- this.name = 'ApiError'
58
- this.errorClass = errorClass
59
- this.status = status
60
- this.validationErrors = validationErrors
61
- }
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
- const contentType = response.headers.get('Content-Type')
102
- const isJson = contentType?.includes('application/json') ?? false
103
-
104
- let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`
105
- let errorClass: string | undefined
106
- let validationErrors: ValidationError[] | null = null
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
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
102
- expect(calledUrl).toContain('/items?')
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
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
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
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
143
- expect(calledUrl).toContain('/items?')
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 edge cases ───────────────────────────────
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
- describe('query serialization', () => {
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({ where: { name: { $like: '%test%' } } })
285
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
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('handles order parameter', async () => {
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 api.getMany({ order: [['name', 'ASC']] })
294
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
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('handles empty query object', async () => {
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
- const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
305
- // qs.stringify({}) produces '', so we get '/items?'
306
- expect(calledUrl).toBe('/items?')
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
  })
@@ -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
  /**