@ikatec/digisac-api-sdk 3.0.0 → 4.0.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 +42 -7
- package/dist/apis/cards/types.d.ts +89 -7
- package/dist/apis/cards/types.d.ts.map +1 -1
- package/dist/apis/pipeline/types.d.ts +67 -3
- package/dist/apis/pipeline/types.d.ts.map +1 -1
- package/dist/core/BaseApiClient.cjs +25 -31
- package/dist/core/BaseApiClient.cjs.map +1 -1
- package/dist/core/BaseApiClient.d.ts +2 -11
- package/dist/core/BaseApiClient.d.ts.map +1 -1
- package/dist/core/BaseApiClient.mjs +27 -31
- package/dist/core/BaseApiClient.mjs.map +1 -1
- package/dist/core/errors.cjs +192 -0
- package/dist/core/errors.cjs.map +1 -0
- package/dist/core/errors.d.ts +117 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.mjs +177 -0
- package/dist/core/errors.mjs.map +1 -0
- package/dist/core/index.cjs +16 -2
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.mjs +3 -2
- package/dist/incommingWebhooks/index.d.ts +3 -3
- package/dist/incommingWebhooks/index.d.ts.map +1 -1
- package/dist/index.cjs +16 -2
- package/dist/index.mjs +3 -2
- package/package.json +1 -1
- package/src/apis/cards/types.ts +104 -7
- package/src/apis/pipeline/types.ts +71 -3
- package/src/core/BaseApiClient.test.ts +221 -7
- package/src/core/BaseApiClient.ts +37 -50
- package/src/core/BaseCrudApi.test-d.ts +48 -0
- package/src/core/BaseCrudApi.test.ts +150 -0
- package/src/core/errors.test-d.ts +14 -0
- package/src/core/errors.test.ts +389 -0
- package/src/core/errors.ts +284 -0
- package/src/core/index.ts +18 -2
- package/src/incommingWebhooks/index.ts +3 -3
package/dist/index.cjs
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
const require_core_errors = require('./core/errors.cjs');
|
|
2
3
|
const require_core_BaseApiClient = require('./core/BaseApiClient.cjs');
|
|
3
4
|
const require_core_BaseCrudApi = require('./core/BaseCrudApi.cjs');
|
|
4
5
|
const require_core_ArchivableCrudApi = require('./core/ArchivableCrudApi.cjs');
|
|
5
6
|
require('./core/index.cjs');
|
|
6
7
|
|
|
7
|
-
exports.
|
|
8
|
+
exports.ApiConnectionError = require_core_errors.ApiConnectionError;
|
|
9
|
+
exports.ApiError = require_core_errors.ApiError;
|
|
8
10
|
exports.ArchivableCrudApi = require_core_ArchivableCrudApi.ArchivableCrudApi;
|
|
11
|
+
exports.AuthenticationError = require_core_errors.AuthenticationError;
|
|
12
|
+
exports.BadRequestError = require_core_errors.BadRequestError;
|
|
9
13
|
exports.BaseApiClient = require_core_BaseApiClient.BaseApiClient;
|
|
10
|
-
exports.BaseCrudApi = require_core_BaseCrudApi.BaseCrudApi;
|
|
14
|
+
exports.BaseCrudApi = require_core_BaseCrudApi.BaseCrudApi;
|
|
15
|
+
exports.ConflictError = require_core_errors.ConflictError;
|
|
16
|
+
exports.DigisacError = require_core_errors.DigisacError;
|
|
17
|
+
exports.InternalServerError = require_core_errors.InternalServerError;
|
|
18
|
+
exports.NotFoundError = require_core_errors.NotFoundError;
|
|
19
|
+
exports.PaymentRequiredError = require_core_errors.PaymentRequiredError;
|
|
20
|
+
exports.PermissionDeniedError = require_core_errors.PermissionDeniedError;
|
|
21
|
+
exports.RateLimitError = require_core_errors.RateLimitError;
|
|
22
|
+
exports.UnprocessableEntityError = require_core_errors.UnprocessableEntityError;
|
|
23
|
+
exports.ValidationError = require_core_errors.ValidationError;
|
|
24
|
+
exports.parseFieldErrors = require_core_errors.parseFieldErrors;
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ApiError,
|
|
1
|
+
import { ApiConnectionError, ApiError, AuthenticationError, BadRequestError, ConflictError, DigisacError, InternalServerError, NotFoundError, PaymentRequiredError, PermissionDeniedError, RateLimitError, UnprocessableEntityError, ValidationError, parseFieldErrors } from "./core/errors.mjs";
|
|
2
|
+
import { BaseApiClient } from "./core/BaseApiClient.mjs";
|
|
2
3
|
import { BaseCrudApi } from "./core/BaseCrudApi.mjs";
|
|
3
4
|
import { ArchivableCrudApi } from "./core/ArchivableCrudApi.mjs";
|
|
4
5
|
import "./core/index.mjs";
|
|
5
6
|
|
|
6
|
-
export { ApiError, ArchivableCrudApi, BaseApiClient, BaseCrudApi };
|
|
7
|
+
export { ApiConnectionError, ApiError, ArchivableCrudApi, AuthenticationError, BadRequestError, BaseApiClient, BaseCrudApi, ConflictError, DigisacError, InternalServerError, NotFoundError, PaymentRequiredError, PermissionDeniedError, RateLimitError, UnprocessableEntityError, ValidationError, parseFieldErrors };
|
package/package.json
CHANGED
package/src/apis/cards/types.ts
CHANGED
|
@@ -1,14 +1,79 @@
|
|
|
1
1
|
import type { Contact } from '../contacts/types'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Pipeline,
|
|
4
|
+
PipelineStage,
|
|
5
|
+
PipelineStageReason,
|
|
6
|
+
PipelineStageStatus,
|
|
7
|
+
} from '../pipeline/types'
|
|
3
8
|
import type { User } from '../users/types'
|
|
4
9
|
|
|
10
|
+
type NamedRef = { id: string; name: string }
|
|
11
|
+
|
|
12
|
+
/** One entry of the card history — written on create and on every stage/status/owner change. */
|
|
13
|
+
export type CardMovement = {
|
|
14
|
+
id: string
|
|
15
|
+
cardId: string
|
|
16
|
+
accountId: string
|
|
17
|
+
/** The user who made the change (the requester), not the card owner. */
|
|
18
|
+
userId: string
|
|
19
|
+
pipelineId: string
|
|
20
|
+
fromPipelineId: string | null
|
|
21
|
+
toPipelineId: string | null
|
|
22
|
+
fromPipelineStageId: string | null
|
|
23
|
+
toPipelineStageId: string | null
|
|
24
|
+
fromStageStatusId: string | null
|
|
25
|
+
toStageStatusId: string | null
|
|
26
|
+
fromOwnerId: string | null
|
|
27
|
+
toOwnerId: string | null
|
|
28
|
+
createdAt: string
|
|
29
|
+
// relationships (GET /cards/{id})
|
|
30
|
+
user?: NamedRef | null
|
|
31
|
+
from_pipeline?: NamedRef | null
|
|
32
|
+
to_pipeline?: NamedRef | null
|
|
33
|
+
from_pipeline_stage?: NamedRef | null
|
|
34
|
+
to_pipeline_stage?: NamedRef | null
|
|
35
|
+
from_stage_status?: NamedRef | null
|
|
36
|
+
to_stage_status?: NamedRef | null
|
|
37
|
+
from_owner?: NamedRef | null
|
|
38
|
+
to_owner?: NamedRef | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CardProduct = {
|
|
42
|
+
id: string
|
|
43
|
+
cardId: string
|
|
44
|
+
name: string
|
|
45
|
+
/** Quantity (the backend column is spelled `ammount`). */
|
|
46
|
+
ammount: number
|
|
47
|
+
value: number
|
|
48
|
+
createdAt: string
|
|
49
|
+
updatedAt: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type CardComment = {
|
|
53
|
+
id: string
|
|
54
|
+
cardId: string
|
|
55
|
+
userId: string | null
|
|
56
|
+
comment: string | null
|
|
57
|
+
createdAt: string
|
|
58
|
+
updatedAt: string
|
|
59
|
+
user?: User | null
|
|
60
|
+
files?: Record<string, unknown>[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A sales funnel card (opportunity).
|
|
65
|
+
*
|
|
66
|
+
* `create` and `updateById` answer the raw entity (with `updatedAt`/`archivedAt`). `getMany` and
|
|
67
|
+
* `getById` go through the backend transformer: no `updatedAt`/`archivedAt`, plus `url`,
|
|
68
|
+
* `lastMessage` and `totalValue`; `getById` also joins the relationships below.
|
|
69
|
+
*/
|
|
5
70
|
export type Card = {
|
|
6
71
|
id: string
|
|
7
72
|
title: string
|
|
8
73
|
description: string | null
|
|
74
|
+
/** Position in the stage — set by the backend to the number of cards already in it. */
|
|
9
75
|
order: number | null
|
|
10
76
|
isArchived: boolean
|
|
11
|
-
archivedAt: string | null
|
|
12
77
|
success: boolean | null
|
|
13
78
|
finishedAt: string | null
|
|
14
79
|
organization: string | null
|
|
@@ -23,41 +88,73 @@ export type Card = {
|
|
|
23
88
|
reasonId: string | null
|
|
24
89
|
ownerId: string | null
|
|
25
90
|
createdAt: string
|
|
26
|
-
|
|
91
|
+
/** Only on create/update answers. */
|
|
92
|
+
updatedAt?: string
|
|
93
|
+
/** Only on create/update answers. */
|
|
94
|
+
archivedAt?: string | null
|
|
95
|
+
/** Only on getMany/getById answers (contact avatar URL, or empty string). */
|
|
96
|
+
url?: unknown
|
|
97
|
+
/** Only on getMany/getById answers (contact last message, or empty string). */
|
|
98
|
+
lastMessage?: unknown
|
|
99
|
+
/** Only on getMany/getById answers: sum of the products (`ammount * value`). */
|
|
100
|
+
totalValue?: number
|
|
27
101
|
// relationships
|
|
28
102
|
contact?: Contact | null
|
|
29
103
|
owner?: User | null
|
|
30
104
|
pipeline?: Pipeline
|
|
105
|
+
pipeline_stage?: PipelineStage | null
|
|
106
|
+
stage_status?: PipelineStageStatus | null
|
|
107
|
+
stage_reason?: PipelineStageReason | null
|
|
108
|
+
movements?: CardMovement[]
|
|
109
|
+
products?: CardProduct[]
|
|
110
|
+
comments?: CardComment[]
|
|
31
111
|
}
|
|
32
112
|
|
|
33
|
-
export type CardRelationships =
|
|
113
|
+
export type CardRelationships =
|
|
114
|
+
| 'contact'
|
|
115
|
+
| 'owner'
|
|
116
|
+
| 'pipeline'
|
|
117
|
+
| 'pipeline_stage'
|
|
118
|
+
| 'stage_status'
|
|
119
|
+
| 'stage_reason'
|
|
120
|
+
| 'movements'
|
|
121
|
+
| 'products'
|
|
122
|
+
| 'comments'
|
|
34
123
|
|
|
35
124
|
export type CreateCardPayload = {
|
|
125
|
+
/** Up to 255 characters. */
|
|
36
126
|
title: string
|
|
37
127
|
pipelineId: string
|
|
38
|
-
contactId
|
|
39
|
-
|
|
128
|
+
contactId: string
|
|
129
|
+
/** Must be a stage of `pipelineId`. */
|
|
130
|
+
pipelineStageId: string
|
|
40
131
|
description?: string
|
|
132
|
+
ownerId?: string
|
|
133
|
+
statusId?: string
|
|
134
|
+
reasonId?: string
|
|
41
135
|
organization?: string
|
|
42
136
|
organizationSegment?: string
|
|
43
137
|
originChannel?: string
|
|
44
138
|
originCampaign?: string
|
|
45
|
-
ownerId?: string
|
|
46
139
|
}
|
|
47
140
|
|
|
48
141
|
export type UpdateCardPayload = {
|
|
49
142
|
title?: string
|
|
50
143
|
description?: string | null
|
|
51
144
|
contactId?: string | null
|
|
145
|
+
/** Moves the card to another pipeline (send the target `pipelineStageId` along). */
|
|
146
|
+
pipelineId?: string
|
|
52
147
|
pipelineStageId?: string | null
|
|
53
148
|
statusId?: string | null
|
|
54
149
|
reasonId?: string | null
|
|
55
150
|
ownerId?: string | null
|
|
151
|
+
order?: number
|
|
56
152
|
organization?: string | null
|
|
57
153
|
organizationSegment?: string | null
|
|
58
154
|
originChannel?: string | null
|
|
59
155
|
originCampaign?: string | null
|
|
60
156
|
isArchived?: boolean
|
|
157
|
+
/** Marks the card as won (`true`) or lost (`false`); the backend fills `finishedAt`. */
|
|
61
158
|
success?: boolean | null
|
|
62
159
|
finishedAt?: string | null
|
|
63
160
|
}
|
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
import type { Department } from '../departments/types'
|
|
2
2
|
|
|
3
|
-
export type
|
|
3
|
+
export type PipelineStageStatus = {
|
|
4
|
+
id: string
|
|
5
|
+
/** The default "Finalizados" stage carries `STATUS_WON` and `STATUS_LOOSE`. */
|
|
6
|
+
name: string
|
|
7
|
+
position: number
|
|
8
|
+
pipelineId: string
|
|
9
|
+
stageId: string
|
|
10
|
+
accountId: string
|
|
11
|
+
createdAt: string
|
|
12
|
+
updatedAt: string
|
|
13
|
+
deletedAt: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PipelineStageReason = {
|
|
17
|
+
id: string
|
|
18
|
+
name: string
|
|
19
|
+
position: number
|
|
20
|
+
isWon: boolean
|
|
21
|
+
pipelineId: string
|
|
22
|
+
stageId: string
|
|
23
|
+
accountId: string
|
|
24
|
+
createdAt: string
|
|
25
|
+
updatedAt: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PipelineStage = {
|
|
29
|
+
id: string
|
|
30
|
+
name: string
|
|
31
|
+
position: number
|
|
32
|
+
pipelineId: string
|
|
33
|
+
accountId: string
|
|
34
|
+
createdAt: string
|
|
35
|
+
updatedAt: string
|
|
36
|
+
deletedAt: string | null
|
|
37
|
+
statuses?: PipelineStageStatus[]
|
|
38
|
+
reasons?: PipelineStageReason[]
|
|
39
|
+
/** Card totals — present on create/update/getById, absent on the listing `include`. */
|
|
40
|
+
totalCards?: number
|
|
41
|
+
totalCardsValue?: number
|
|
42
|
+
totalWonCardsValue?: number
|
|
43
|
+
totalLostCardsValue?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The pipeline row as stored — what the webhook events (`pipeline.created`/`.updated`) carry. */
|
|
47
|
+
export type PipelineRecord = {
|
|
4
48
|
id: string
|
|
5
49
|
name: string
|
|
6
50
|
goBack: boolean
|
|
@@ -10,20 +54,44 @@ export type Pipeline = {
|
|
|
10
54
|
createdAt: string
|
|
11
55
|
updatedAt: string
|
|
12
56
|
deletedAt: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A sales funnel pipeline.
|
|
61
|
+
*
|
|
62
|
+
* `create`, `updateById` and `getById` answer the full entity, always with `stages`. The listing
|
|
63
|
+
* (`getMany`) goes through the backend transformer and only carries `id`, `name`, `goBack` and
|
|
64
|
+
* `archivedAt` (plus the included relationships) — hence the optional fields below.
|
|
65
|
+
*/
|
|
66
|
+
export type Pipeline = {
|
|
67
|
+
id: string
|
|
68
|
+
name: string
|
|
69
|
+
goBack: boolean
|
|
70
|
+
archivedAt: string | null
|
|
71
|
+
accountId?: string
|
|
72
|
+
permissionToViewCard?: boolean | null
|
|
73
|
+
createdAt?: string
|
|
74
|
+
updatedAt?: string
|
|
75
|
+
deletedAt?: string | null
|
|
13
76
|
// relationships
|
|
77
|
+
stages?: PipelineStage[]
|
|
14
78
|
departments?: Department[]
|
|
15
79
|
}
|
|
16
80
|
|
|
17
|
-
export type PipelineRelationships = 'departments'
|
|
81
|
+
export type PipelineRelationships = 'departments' | 'stages'
|
|
18
82
|
|
|
19
83
|
export type CreatePipelinePayload = {
|
|
84
|
+
/** Up to 100 characters; must be unique in the account. */
|
|
20
85
|
name: string
|
|
86
|
+
/** `false` = only move forward, `true` = move forward and backward. */
|
|
21
87
|
goBack?: boolean
|
|
88
|
+
/** When `true`, non-admin users only see the cards they own. */
|
|
22
89
|
permissionToViewCard?: boolean
|
|
23
90
|
}
|
|
24
91
|
|
|
25
92
|
export type UpdatePipelinePayload = {
|
|
26
|
-
|
|
93
|
+
/** Required by the backend on every update (400 `required` otherwise). */
|
|
94
|
+
name: string
|
|
27
95
|
goBack?: boolean
|
|
28
96
|
permissionToViewCard?: boolean | null
|
|
29
97
|
}
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { BaseApiClient
|
|
2
|
+
import { BaseApiClient } from './BaseApiClient'
|
|
3
|
+
import {
|
|
4
|
+
ApiConnectionError,
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
DigisacError,
|
|
10
|
+
InternalServerError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PaymentRequiredError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
UnprocessableEntityError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
} from './errors'
|
|
3
18
|
|
|
4
19
|
function mockFetch(response: {
|
|
5
20
|
ok: boolean
|
|
@@ -216,20 +231,219 @@ describe('BaseApiClient', () => {
|
|
|
216
231
|
})
|
|
217
232
|
})
|
|
218
233
|
|
|
219
|
-
it('
|
|
220
|
-
const validationErrors = [{ path: 'name', message: 'required', code: 'REQUIRED' }]
|
|
234
|
+
it('maps the backend field errors (errors.<location>.<field>) into validationErrors', async () => {
|
|
221
235
|
mockFetch({
|
|
222
236
|
ok: false,
|
|
223
|
-
status:
|
|
224
|
-
statusText: '
|
|
237
|
+
status: 400,
|
|
238
|
+
statusText: 'Bad Request',
|
|
239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({
|
|
241
|
+
error: 'ValidationError',
|
|
242
|
+
message: 'The given data was invalid.',
|
|
243
|
+
status: 400,
|
|
244
|
+
errors: {
|
|
245
|
+
body: {
|
|
246
|
+
name: { messages: ['Required.'], types: ['required'] },
|
|
247
|
+
title: {
|
|
248
|
+
messages: ['Required.', 'Should contain lesser than or equals 255 characters.'],
|
|
249
|
+
types: ['required', 'hasLengthLesserThanOrEqual'],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
params: { contactId: { messages: ['Should be an UUID v4 string.'], types: ['uuid4'] } },
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
})
|
|
256
|
+
const error = await client.post('/fail', {}).catch((e: unknown) => e)
|
|
257
|
+
expect(error).toBeInstanceOf(ValidationError)
|
|
258
|
+
expect(error).toBeInstanceOf(BadRequestError)
|
|
259
|
+
expect(error).toMatchObject({
|
|
260
|
+
message: 'The given data was invalid.',
|
|
261
|
+
errorClass: 'ValidationError',
|
|
262
|
+
status: 400,
|
|
263
|
+
validationErrors: [
|
|
264
|
+
{
|
|
265
|
+
location: 'body',
|
|
266
|
+
field: 'name',
|
|
267
|
+
path: 'body.name',
|
|
268
|
+
message: 'Required.',
|
|
269
|
+
code: 'required',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
location: 'body',
|
|
273
|
+
field: 'title',
|
|
274
|
+
path: 'body.title',
|
|
275
|
+
message: 'Required.',
|
|
276
|
+
code: 'required',
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
location: 'body',
|
|
280
|
+
field: 'title',
|
|
281
|
+
path: 'body.title',
|
|
282
|
+
message: 'Should contain lesser than or equals 255 characters.',
|
|
283
|
+
code: 'hasLengthLesserThanOrEqual',
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
location: 'params',
|
|
287
|
+
field: 'contactId',
|
|
288
|
+
path: 'params.contactId',
|
|
289
|
+
message: 'Should be an UUID v4 string.',
|
|
290
|
+
code: 'uuid4',
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('keeps validationErrors null when the error body has no field errors', async () => {
|
|
297
|
+
mockFetch({
|
|
298
|
+
ok: false,
|
|
299
|
+
status: 400,
|
|
300
|
+
statusText: 'Bad Request',
|
|
301
|
+
headers: { 'Content-Type': 'application/json' },
|
|
302
|
+
body: JSON.stringify({ error: 'BadRequestHttpError', message: 'Já existe', status: 400 }),
|
|
303
|
+
})
|
|
304
|
+
await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('ignores malformed field errors', async () => {
|
|
308
|
+
mockFetch({
|
|
309
|
+
ok: false,
|
|
310
|
+
status: 400,
|
|
311
|
+
statusText: 'Bad Request',
|
|
225
312
|
headers: { 'Content-Type': 'application/json' },
|
|
226
|
-
body: JSON.stringify({
|
|
313
|
+
body: JSON.stringify({
|
|
314
|
+
error: 'BadRequestHttpError',
|
|
315
|
+
errors: { body: { name: 'Required.', title: { messages: 'Required.' } }, query: [] },
|
|
316
|
+
}),
|
|
317
|
+
})
|
|
318
|
+
await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it.each([
|
|
322
|
+
[400, BadRequestError],
|
|
323
|
+
[401, AuthenticationError],
|
|
324
|
+
[402, PaymentRequiredError],
|
|
325
|
+
[403, PermissionDeniedError],
|
|
326
|
+
[404, NotFoundError],
|
|
327
|
+
[409, ConflictError],
|
|
328
|
+
[422, UnprocessableEntityError],
|
|
329
|
+
[429, RateLimitError],
|
|
330
|
+
[500, InternalServerError],
|
|
331
|
+
[503, InternalServerError],
|
|
332
|
+
])('throws the matching subclass for status %i', async (status, ErrorClass) => {
|
|
333
|
+
mockFetch({ ok: false, status, statusText: 'Error' })
|
|
334
|
+
const error = await client.get('/fail').catch((e: unknown) => e)
|
|
335
|
+
expect(error).toBeInstanceOf(ErrorClass)
|
|
336
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
337
|
+
expect(error).toBeInstanceOf(DigisacError)
|
|
338
|
+
expect(error).toMatchObject({ name: ErrorClass.name, status })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('throws a plain ApiError for unmapped statuses', async () => {
|
|
342
|
+
mockFetch({ ok: false, status: 418, statusText: "I'm a teapot" })
|
|
343
|
+
const error = await client.get('/fail').catch((e: unknown) => e)
|
|
344
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
345
|
+
expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype)
|
|
346
|
+
expect(error).toMatchObject({ name: 'ApiError', status: 418 })
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('exposes the response headers and the parsed body', async () => {
|
|
350
|
+
const body = { error: 'PaymentRequired', message: 'Limit', status: 402, extra: { limit: 10 } }
|
|
351
|
+
mockFetch({
|
|
352
|
+
ok: false,
|
|
353
|
+
status: 402,
|
|
354
|
+
statusText: 'Payment Required',
|
|
355
|
+
headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'req-1' },
|
|
356
|
+
body: JSON.stringify(body),
|
|
357
|
+
})
|
|
358
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as ApiError
|
|
359
|
+
expect(error.body).toEqual(body)
|
|
360
|
+
expect(error.headers?.get('X-Request-Id')).toBe('req-1')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('exposes a non-JSON error body as text', async () => {
|
|
364
|
+
mockFetch({
|
|
365
|
+
ok: false,
|
|
366
|
+
status: 502,
|
|
367
|
+
statusText: 'Bad Gateway',
|
|
368
|
+
body: '<html>bad gateway</html>',
|
|
227
369
|
})
|
|
228
370
|
await expect(client.get('/fail')).rejects.toMatchObject({
|
|
229
|
-
|
|
371
|
+
message: 'HTTP Error 502: Bad Gateway',
|
|
372
|
+
body: '<html>bad gateway</html>',
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('reads Retry-After (seconds) on RateLimitError', async () => {
|
|
377
|
+
mockFetch({
|
|
378
|
+
ok: false,
|
|
379
|
+
status: 429,
|
|
380
|
+
statusText: 'Too Many Requests',
|
|
381
|
+
headers: { 'Retry-After': '30' },
|
|
382
|
+
})
|
|
383
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
384
|
+
expect(error.retryAfter).toBe(30)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('reads Retry-After (HTTP date) on RateLimitError', async () => {
|
|
388
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
|
|
389
|
+
try {
|
|
390
|
+
mockFetch({
|
|
391
|
+
ok: false,
|
|
392
|
+
status: 429,
|
|
393
|
+
statusText: 'Too Many Requests',
|
|
394
|
+
headers: { 'Retry-After': 'Thu, 01 Jan 2026 00:01:00 GMT' },
|
|
395
|
+
})
|
|
396
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
397
|
+
expect(error.retryAfter).toBe(60)
|
|
398
|
+
} finally {
|
|
399
|
+
vi.useRealTimers()
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('reads X-RateLimit-Reset (Unix seconds) on RateLimitError', async () => {
|
|
404
|
+
vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
|
|
405
|
+
try {
|
|
406
|
+
mockFetch({
|
|
407
|
+
ok: false,
|
|
408
|
+
status: 429,
|
|
409
|
+
statusText: 'Too Many Requests',
|
|
410
|
+
headers: { 'X-RateLimit-Reset': String(Date.parse('2026-01-01T01:00:00Z') / 1000) },
|
|
411
|
+
})
|
|
412
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
413
|
+
expect(error.retryAfter).toBe(3600)
|
|
414
|
+
} finally {
|
|
415
|
+
vi.useRealTimers()
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('returns null retryAfter when the header is absent', async () => {
|
|
420
|
+
mockFetch({ ok: false, status: 429, statusText: 'Too Many Requests' })
|
|
421
|
+
const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
|
|
422
|
+
expect(error.retryAfter).toBeNull()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('wraps network failures in ApiConnectionError', async () => {
|
|
426
|
+
const cause = new TypeError('fetch failed')
|
|
427
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
|
|
428
|
+
const error = await client.get('/fail').catch((e: unknown) => e)
|
|
429
|
+
expect(error).toBeInstanceOf(ApiConnectionError)
|
|
430
|
+
expect(error).toBeInstanceOf(DigisacError)
|
|
431
|
+
expect(error).not.toBeInstanceOf(ApiError)
|
|
432
|
+
expect(error).toMatchObject({
|
|
433
|
+
message: 'Request to GET https://api.example.com/fail failed: fetch failed',
|
|
434
|
+
cause,
|
|
230
435
|
})
|
|
231
436
|
})
|
|
232
437
|
|
|
438
|
+
it('does not report a body serialization failure as a connection error', async () => {
|
|
439
|
+
const spy = vi.spyOn(globalThis, 'fetch')
|
|
440
|
+
const circular: Record<string, unknown> = {}
|
|
441
|
+
circular.self = circular
|
|
442
|
+
const error = await client.post('/fail', circular).catch((e: unknown) => e)
|
|
443
|
+
expect(error).toBeInstanceOf(TypeError)
|
|
444
|
+
expect(spy).not.toHaveBeenCalled()
|
|
445
|
+
})
|
|
446
|
+
|
|
233
447
|
it('falls back to default message when JSON parsing fails', async () => {
|
|
234
448
|
mockFetch({
|
|
235
449
|
ok: false,
|
|
@@ -1,29 +1,5 @@
|
|
|
1
1
|
import type { ApiClient, HttpMethod } from './ApiClient'
|
|
2
|
-
|
|
3
|
-
export interface ValidationError {
|
|
4
|
-
path: string
|
|
5
|
-
message: string
|
|
6
|
-
code: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export class ApiError extends Error {
|
|
10
|
-
public readonly errorClass?: string | undefined
|
|
11
|
-
public readonly status?: number | undefined
|
|
12
|
-
public readonly validationErrors: ValidationError[] | null
|
|
13
|
-
|
|
14
|
-
constructor(
|
|
15
|
-
message: string,
|
|
16
|
-
errorClass?: string,
|
|
17
|
-
status?: number,
|
|
18
|
-
validationErrors: ValidationError[] | null = null,
|
|
19
|
-
) {
|
|
20
|
-
super(message)
|
|
21
|
-
this.name = 'ApiError'
|
|
22
|
-
this.errorClass = errorClass
|
|
23
|
-
this.status = status
|
|
24
|
-
this.validationErrors = validationErrors
|
|
25
|
-
}
|
|
26
|
-
}
|
|
2
|
+
import { ApiConnectionError, ApiError } from './errors'
|
|
27
3
|
|
|
28
4
|
export class BaseApiClient implements ApiClient {
|
|
29
5
|
protected readonly baseUrl: string
|
|
@@ -46,8 +22,7 @@ export class BaseApiClient implements ApiClient {
|
|
|
46
22
|
headers?: Record<string, string>,
|
|
47
23
|
): Promise<TResponse> {
|
|
48
24
|
const url = `${this.baseUrl}/${endpoint.replace(/^\//, '')}`
|
|
49
|
-
|
|
50
|
-
const response = await fetch(url, {
|
|
25
|
+
const init: RequestInit = {
|
|
51
26
|
method,
|
|
52
27
|
headers: {
|
|
53
28
|
'Content-Type': 'application/json',
|
|
@@ -55,35 +30,29 @@ export class BaseApiClient implements ApiClient {
|
|
|
55
30
|
...headers,
|
|
56
31
|
},
|
|
57
32
|
...(body !== undefined && { body: JSON.stringify(body) }),
|
|
58
|
-
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let response: Response
|
|
36
|
+
try {
|
|
37
|
+
response = await fetch(url, init)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
40
|
+
throw new ApiConnectionError(`Request to ${method} ${url} failed: ${reason}`, {
|
|
41
|
+
cause: error,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
59
44
|
|
|
60
45
|
return this.handleResponse<TResponse>(response)
|
|
61
46
|
}
|
|
62
47
|
|
|
63
48
|
protected async handleResponse<TResponse>(response: Response): Promise<TResponse> {
|
|
64
49
|
if (!response.ok) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (isJson) {
|
|
73
|
-
try {
|
|
74
|
-
const errorText = await response.text()
|
|
75
|
-
if (errorText) {
|
|
76
|
-
const errorData = JSON.parse(errorText)
|
|
77
|
-
errorMessage = errorData.message || errorData.error || errorMessage
|
|
78
|
-
errorClass = errorData.error
|
|
79
|
-
validationErrors = errorData.validationErrors ?? null
|
|
80
|
-
}
|
|
81
|
-
} catch {
|
|
82
|
-
// If JSON parsing fails, use the default error message
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
throw new ApiError(errorMessage, errorClass, response.status, validationErrors)
|
|
50
|
+
throw ApiError.fromResponse(
|
|
51
|
+
response.status,
|
|
52
|
+
response.statusText,
|
|
53
|
+
response.headers,
|
|
54
|
+
await this.readErrorBody(response),
|
|
55
|
+
)
|
|
87
56
|
}
|
|
88
57
|
|
|
89
58
|
// 204 No Content never has a body; short-circuit before reading the stream.
|
|
@@ -115,6 +84,24 @@ export class BaseApiClient implements ApiClient {
|
|
|
115
84
|
}
|
|
116
85
|
}
|
|
117
86
|
|
|
87
|
+
/** Reads an error body: parsed JSON when possible, the raw text otherwise, `undefined` when empty. */
|
|
88
|
+
protected async readErrorBody(response: Response): Promise<unknown> {
|
|
89
|
+
let text: string
|
|
90
|
+
try {
|
|
91
|
+
text = await response.text()
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
if (!text) {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text)
|
|
100
|
+
} catch {
|
|
101
|
+
return text
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
118
105
|
get<TResponse = unknown>(endpoint: string, headers?: Record<string, string>): Promise<TResponse> {
|
|
119
106
|
return this.request<TResponse>('GET', endpoint, undefined, headers)
|
|
120
107
|
}
|