@jskit-ai/http-runtime 0.1.54 → 0.1.55
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/package.descriptor.mjs +2 -4
- package/package.json +5 -5
- package/src/shared/clientRuntime/client.js +126 -9
- package/src/shared/clientRuntime/errors.js +6 -0
- package/src/shared/clientRuntime/jsonApiResourceTransport.js +241 -0
- package/src/shared/index.js +54 -5
- package/src/shared/validators/command.js +5 -4
- package/src/shared/validators/errorResponses.js +125 -62
- package/src/shared/validators/httpValidatorsApi.js +83 -12
- package/src/shared/validators/jsonApiQueryTransport.js +211 -0
- package/src/shared/validators/jsonApiResponses.js +3 -0
- package/src/shared/validators/jsonApiResult.js +83 -0
- package/src/shared/validators/jsonApiRouteTransport.js +800 -0
- package/src/shared/validators/jsonApiTransport.js +484 -0
- package/src/shared/validators/operationValidation.js +62 -101
- package/src/shared/validators/paginationQuery.js +14 -19
- package/src/shared/validators/resource.js +15 -17
- package/src/shared/validators/schemaUtils.js +18 -5
- package/src/shared/validators/transportSchemaEmbedding.js +81 -0
- package/test/client.test.js +279 -0
- package/test/command.test.js +38 -21
- package/test/entrypoints.boundary.test.js +8 -0
- package/test/errorResponses.test.js +49 -13
- package/test/jsonApiRouteTransport.test.js +349 -0
- package/test/jsonApiTransport.test.js +231 -0
- package/test/operationMessages.test.js +115 -66
- package/test/operationValidation.test.js +147 -159
- package/test/paginationQuery.test.js +4 -8
- package/test/resource.test.js +89 -55
- package/test/validationErrors.test.js +33 -0
- package/src/shared/validators/typeboxFormats.js +0 -43
- package/test/typeboxFormats.test.js +0 -42
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createSchema } from "json-rest-schema";
|
|
2
2
|
|
|
3
3
|
function createPaginationQuerySchema({
|
|
4
4
|
defaultPage = 1,
|
|
@@ -7,26 +7,21 @@ function createPaginationQuerySchema({
|
|
|
7
7
|
minPageSize = 1,
|
|
8
8
|
maxPageSize = 100
|
|
9
9
|
} = {}) {
|
|
10
|
-
return
|
|
11
|
-
{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
})
|
|
17
|
-
),
|
|
18
|
-
pageSize: Type.Optional(
|
|
19
|
-
Type.Integer({
|
|
20
|
-
minimum: minPageSize,
|
|
21
|
-
maximum: maxPageSize,
|
|
22
|
-
default: defaultPageSize
|
|
23
|
-
})
|
|
24
|
-
)
|
|
10
|
+
return createSchema({
|
|
11
|
+
page: {
|
|
12
|
+
type: "integer",
|
|
13
|
+
required: false,
|
|
14
|
+
min: minPage,
|
|
15
|
+
default: defaultPage
|
|
25
16
|
},
|
|
26
|
-
{
|
|
27
|
-
|
|
17
|
+
pageSize: {
|
|
18
|
+
type: "integer",
|
|
19
|
+
required: false,
|
|
20
|
+
min: minPageSize,
|
|
21
|
+
max: maxPageSize,
|
|
22
|
+
default: defaultPageSize
|
|
28
23
|
}
|
|
29
|
-
);
|
|
24
|
+
});
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
export { createPaginationQuerySchema };
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { asSchemaDefinition } from "./schemaUtils.js";
|
|
2
|
+
import { createCursorListValidator } from "@jskit-ai/kernel/shared/validators";
|
|
3
|
+
import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
|
|
3
4
|
|
|
4
5
|
function createCursorPagedListResponseSchema(itemSchema) {
|
|
5
|
-
|
|
6
|
-
return Type.Object(
|
|
7
|
-
{
|
|
8
|
-
items: Type.Array(normalizedItemSchema),
|
|
9
|
-
nextCursor: Type.Union([Type.String({ minLength: 1 }), Type.Null()])
|
|
10
|
-
},
|
|
11
|
-
{ additionalProperties: false }
|
|
12
|
-
);
|
|
6
|
+
return createCursorListValidator(itemSchema);
|
|
13
7
|
}
|
|
14
8
|
|
|
15
9
|
function createResource({
|
|
@@ -20,14 +14,18 @@ function createResource({
|
|
|
20
14
|
list = null,
|
|
21
15
|
listItem = null
|
|
22
16
|
} = {}) {
|
|
23
|
-
const normalizedRecordSchema =
|
|
24
|
-
const normalizedCreateSchema =
|
|
25
|
-
const normalizedReplaceSchema =
|
|
26
|
-
const normalizedPatchSchema =
|
|
27
|
-
const normalizedListItemSchema = listItem
|
|
28
|
-
|
|
17
|
+
const normalizedRecordSchema = asSchemaDefinition(record, "record", "replace");
|
|
18
|
+
const normalizedCreateSchema = asSchemaDefinition(create, "create", "create");
|
|
19
|
+
const normalizedReplaceSchema = asSchemaDefinition(replace, "replace", "replace");
|
|
20
|
+
const normalizedPatchSchema = asSchemaDefinition(patch, "patch", "patch");
|
|
21
|
+
const normalizedListItemSchema = listItem
|
|
22
|
+
? asSchemaDefinition(listItem, "listItem", "replace")
|
|
23
|
+
: normalizedRecordSchema;
|
|
24
|
+
const normalizedListSchema = list
|
|
25
|
+
? asSchemaDefinition(list, "list", "replace")
|
|
26
|
+
: createCursorPagedListResponseSchema(normalizedListItemSchema);
|
|
29
27
|
|
|
30
|
-
return
|
|
28
|
+
return deepFreeze({
|
|
31
29
|
record: normalizedRecordSchema,
|
|
32
30
|
create: normalizedCreateSchema,
|
|
33
31
|
replace: normalizedReplaceSchema,
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { normalizeSingleSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
|
|
2
|
+
|
|
3
|
+
function asSchemaDefinition(value, label, defaultMode, { required = true } = {}) {
|
|
4
|
+
if (value == null) {
|
|
5
|
+
if (!required) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
throw new TypeError(`${label} is required.`);
|
|
4
10
|
}
|
|
5
11
|
|
|
6
|
-
|
|
12
|
+
try {
|
|
13
|
+
return normalizeSingleSchemaDefinition(value, {
|
|
14
|
+
context: label,
|
|
15
|
+
defaultMode
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new TypeError(error?.message || `${label} must be a schema definition object.`);
|
|
19
|
+
}
|
|
7
20
|
}
|
|
8
21
|
|
|
9
|
-
export {
|
|
22
|
+
export { asSchemaDefinition };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
function rewriteEmbeddedTransportSchemaRefs(value, {
|
|
2
|
+
rootRef = "#",
|
|
3
|
+
definitionRefByName = {}
|
|
4
|
+
} = {}) {
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return value.map((entry) => rewriteEmbeddedTransportSchemaRefs(entry, {
|
|
7
|
+
rootRef,
|
|
8
|
+
definitionRefByName
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!value || typeof value !== "object") {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rewritten = {};
|
|
17
|
+
|
|
18
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
19
|
+
if (key === "$ref" && typeof entry === "string") {
|
|
20
|
+
if (entry === "#") {
|
|
21
|
+
rewritten[key] = rootRef;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (entry.startsWith("#/definitions/")) {
|
|
26
|
+
const definitionName = entry.slice("#/definitions/".length);
|
|
27
|
+
rewritten[key] = definitionRefByName[definitionName] || entry;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
rewritten[key] = rewriteEmbeddedTransportSchemaRefs(entry, {
|
|
33
|
+
rootRef,
|
|
34
|
+
definitionRefByName
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return rewritten;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createEmbeddableTransportSchemaDocument(schemaDocument = {}, rootDefinitionName = "TransportSchema") {
|
|
42
|
+
const {
|
|
43
|
+
$schema: _jsonSchemaDraft,
|
|
44
|
+
definitions: sourceDefinitions = {},
|
|
45
|
+
...rootSchema
|
|
46
|
+
} = schemaDocument || {};
|
|
47
|
+
|
|
48
|
+
const rootRef = `#/definitions/${rootDefinitionName}`;
|
|
49
|
+
const definitionRefByName = {};
|
|
50
|
+
const definitions = {};
|
|
51
|
+
|
|
52
|
+
for (const definitionName of Object.keys(sourceDefinitions)) {
|
|
53
|
+
definitionRefByName[definitionName] = `#/definitions/${rootDefinitionName}__${definitionName}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
definitions[rootDefinitionName] = rewriteEmbeddedTransportSchemaRefs(rootSchema, {
|
|
57
|
+
rootRef,
|
|
58
|
+
definitionRefByName
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
for (const [definitionName, definitionSchema] of Object.entries(sourceDefinitions)) {
|
|
62
|
+
definitions[`${rootDefinitionName}__${definitionName}`] = rewriteEmbeddedTransportSchemaRefs(definitionSchema, {
|
|
63
|
+
rootRef,
|
|
64
|
+
definitionRefByName
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
schema: {
|
|
70
|
+
allOf: [{
|
|
71
|
+
$ref: rootRef
|
|
72
|
+
}]
|
|
73
|
+
},
|
|
74
|
+
definitions
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
rewriteEmbeddedTransportSchemaRefs,
|
|
80
|
+
createEmbeddableTransportSchemaDocument
|
|
81
|
+
};
|
package/test/client.test.js
CHANGED
|
@@ -56,6 +56,285 @@ test("request serializes json body and injects csrf token for unsafe methods", a
|
|
|
56
56
|
assert.equal(calls[1][1].body, JSON.stringify({ demo: true }));
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test("request parses json:api responses as json payloads", async () => {
|
|
60
|
+
const fetchImpl = async () =>
|
|
61
|
+
mockResponse({
|
|
62
|
+
contentType: "application/vnd.api+json",
|
|
63
|
+
data: {
|
|
64
|
+
data: {
|
|
65
|
+
type: "contacts",
|
|
66
|
+
id: "2",
|
|
67
|
+
attributes: {
|
|
68
|
+
name: "ddd"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const client = createHttpClient({ fetchImpl });
|
|
75
|
+
const payload = await client.request("/api/contacts/2");
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(payload, {
|
|
78
|
+
data: {
|
|
79
|
+
type: "contacts",
|
|
80
|
+
id: "2",
|
|
81
|
+
attributes: {
|
|
82
|
+
name: "ddd"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("request encodes and decodes json:api resource transport for records", async () => {
|
|
89
|
+
const calls = [];
|
|
90
|
+
const fetchImpl = async (url, options) => {
|
|
91
|
+
calls.push([url, options]);
|
|
92
|
+
if (url === "/api/session") {
|
|
93
|
+
return mockResponse({
|
|
94
|
+
data: {
|
|
95
|
+
csrfToken: "csrf-jsonapi"
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return mockResponse({
|
|
101
|
+
contentType: "application/vnd.api+json",
|
|
102
|
+
data: {
|
|
103
|
+
data: {
|
|
104
|
+
type: "contacts",
|
|
105
|
+
id: "2",
|
|
106
|
+
attributes: {
|
|
107
|
+
name: "ddd",
|
|
108
|
+
subscribed: false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const client = createHttpClient({ fetchImpl });
|
|
116
|
+
const payload = await client.request("/api/contacts/2", {
|
|
117
|
+
method: "PATCH",
|
|
118
|
+
body: {
|
|
119
|
+
name: "ddd",
|
|
120
|
+
subscribed: false
|
|
121
|
+
},
|
|
122
|
+
transport: {
|
|
123
|
+
kind: "jsonapi-resource",
|
|
124
|
+
requestType: "contact-updates",
|
|
125
|
+
responseType: "contacts",
|
|
126
|
+
responseKind: "record"
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.deepEqual(payload, {
|
|
131
|
+
id: "2",
|
|
132
|
+
name: "ddd",
|
|
133
|
+
subscribed: false
|
|
134
|
+
});
|
|
135
|
+
assert.equal(calls[1][1].headers.Accept, "application/vnd.api+json");
|
|
136
|
+
assert.equal(calls[1][1].headers["Content-Type"], "application/vnd.api+json");
|
|
137
|
+
assert.equal(
|
|
138
|
+
calls[1][1].body,
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
data: {
|
|
141
|
+
type: "contact-updates",
|
|
142
|
+
attributes: {
|
|
143
|
+
name: "ddd",
|
|
144
|
+
subscribed: false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("request decodes json:api collection responses into JSKIT paged-list shape", async () => {
|
|
152
|
+
const fetchImpl = async () =>
|
|
153
|
+
mockResponse({
|
|
154
|
+
contentType: "application/vnd.api+json",
|
|
155
|
+
data: {
|
|
156
|
+
data: [
|
|
157
|
+
{
|
|
158
|
+
type: "contacts",
|
|
159
|
+
id: "2",
|
|
160
|
+
attributes: {
|
|
161
|
+
name: "ddd"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
meta: {
|
|
166
|
+
page: {
|
|
167
|
+
nextCursor: "cursor_2"
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
links: {
|
|
171
|
+
next: "/api/contacts?page[cursor]=cursor_2"
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const client = createHttpClient({ fetchImpl });
|
|
177
|
+
const payload = await client.request("/api/contacts", {
|
|
178
|
+
method: "GET",
|
|
179
|
+
transport: {
|
|
180
|
+
kind: "jsonapi-resource",
|
|
181
|
+
responseType: "contacts",
|
|
182
|
+
responseKind: "collection"
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
assert.deepEqual(payload, {
|
|
187
|
+
items: [
|
|
188
|
+
{
|
|
189
|
+
id: "2",
|
|
190
|
+
name: "ddd"
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
nextCursor: "cursor_2",
|
|
194
|
+
meta: {
|
|
195
|
+
page: {
|
|
196
|
+
nextCursor: "cursor_2"
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
links: {
|
|
200
|
+
next: "/api/contacts?page[cursor]=cursor_2"
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("request decodes native json-rest-api collection pagination metadata into JSKIT nextCursor", async () => {
|
|
206
|
+
const fetchImpl = async () =>
|
|
207
|
+
mockResponse({
|
|
208
|
+
contentType: "application/vnd.api+json",
|
|
209
|
+
data: {
|
|
210
|
+
data: [
|
|
211
|
+
{
|
|
212
|
+
type: "contacts",
|
|
213
|
+
id: "2",
|
|
214
|
+
attributes: {
|
|
215
|
+
name: "ddd"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
meta: {
|
|
220
|
+
pagination: {
|
|
221
|
+
cursor: {
|
|
222
|
+
next: "cursor_2"
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
links: {
|
|
227
|
+
next: "/api/contacts?page[after]=cursor_2&page[size]=20"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const client = createHttpClient({ fetchImpl });
|
|
233
|
+
const payload = await client.request("/api/contacts", {
|
|
234
|
+
method: "GET",
|
|
235
|
+
transport: {
|
|
236
|
+
kind: "jsonapi-resource",
|
|
237
|
+
responseType: "contacts",
|
|
238
|
+
responseKind: "collection"
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(payload, {
|
|
243
|
+
items: [
|
|
244
|
+
{
|
|
245
|
+
id: "2",
|
|
246
|
+
name: "ddd"
|
|
247
|
+
}
|
|
248
|
+
],
|
|
249
|
+
nextCursor: "cursor_2",
|
|
250
|
+
meta: {
|
|
251
|
+
pagination: {
|
|
252
|
+
cursor: {
|
|
253
|
+
next: "cursor_2"
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
links: {
|
|
258
|
+
next: "/api/contacts?page[after]=cursor_2&page[size]=20"
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("request encodes JSON:API query params for resource collections", async () => {
|
|
264
|
+
const calls = [];
|
|
265
|
+
const fetchImpl = async (url) => {
|
|
266
|
+
calls.push(url);
|
|
267
|
+
return mockResponse({
|
|
268
|
+
contentType: "application/vnd.api+json",
|
|
269
|
+
data: {
|
|
270
|
+
data: [],
|
|
271
|
+
meta: {
|
|
272
|
+
page: {
|
|
273
|
+
nextCursor: null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const client = createHttpClient({ fetchImpl });
|
|
281
|
+
await client.request("/api/contacts", {
|
|
282
|
+
method: "GET",
|
|
283
|
+
query: {
|
|
284
|
+
cursor: "cursor_2",
|
|
285
|
+
limit: 10,
|
|
286
|
+
q: "Merc",
|
|
287
|
+
include: "workspace,user",
|
|
288
|
+
workspaceId: "7"
|
|
289
|
+
},
|
|
290
|
+
transport: {
|
|
291
|
+
kind: "jsonapi-resource",
|
|
292
|
+
responseType: "contacts",
|
|
293
|
+
responseKind: "collection"
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
assert.equal(
|
|
298
|
+
calls[0],
|
|
299
|
+
"/api/contacts?page%5Bcursor%5D=cursor_2&page%5Blimit%5D=10&filter%5Bq%5D=Merc&include=workspace%2Cuser&filter%5BworkspaceId%5D=7"
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("request rejects json:api responses whose primary data type does not match the transport contract", async () => {
|
|
304
|
+
const fetchImpl = async () =>
|
|
305
|
+
mockResponse({
|
|
306
|
+
contentType: "application/vnd.api+json",
|
|
307
|
+
data: {
|
|
308
|
+
data: {
|
|
309
|
+
type: "user-settings",
|
|
310
|
+
id: "2",
|
|
311
|
+
attributes: {
|
|
312
|
+
name: "ddd"
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const client = createHttpClient({ fetchImpl });
|
|
319
|
+
|
|
320
|
+
await assert.rejects(
|
|
321
|
+
() =>
|
|
322
|
+
client.request("/api/contacts/2", {
|
|
323
|
+
method: "GET",
|
|
324
|
+
transport: {
|
|
325
|
+
kind: "jsonapi-resource",
|
|
326
|
+
responseType: "contacts",
|
|
327
|
+
responseKind: "record"
|
|
328
|
+
}
|
|
329
|
+
}),
|
|
330
|
+
(error) => {
|
|
331
|
+
assert.equal(error?.message, "JSON:API response decoding failed.");
|
|
332
|
+
assert.equal(error?.cause?.message, "Expected JSON:API resource type contacts, received user-settings.");
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
59
338
|
test("request retries once on retryable csrf failure and preserves stateful headers", async () => {
|
|
60
339
|
const calls = [];
|
|
61
340
|
const fetchImpl = async (url, options) => {
|
package/test/command.test.js
CHANGED
|
@@ -1,48 +1,65 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import { createSchema } from "json-rest-schema";
|
|
4
4
|
import { createCommand } from "../src/shared/validators/command.js";
|
|
5
5
|
|
|
6
|
-
test("createCommand requires input/output
|
|
7
|
-
assert.throws(() => createCommand({}), /input
|
|
6
|
+
test("createCommand requires input/output schema definitions", () => {
|
|
7
|
+
assert.throws(() => createCommand({}), /input is required/);
|
|
8
8
|
|
|
9
9
|
assert.throws(
|
|
10
10
|
() =>
|
|
11
11
|
createCommand({
|
|
12
|
-
input:
|
|
12
|
+
input: {
|
|
13
|
+
schema: createSchema({})
|
|
14
|
+
}
|
|
13
15
|
}),
|
|
14
|
-
/output
|
|
16
|
+
/output is required/
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
assert.throws(
|
|
20
|
+
() =>
|
|
21
|
+
createCommand({
|
|
22
|
+
input: createSchema({}),
|
|
23
|
+
output: {
|
|
24
|
+
schema: createSchema({})
|
|
25
|
+
}
|
|
26
|
+
}),
|
|
27
|
+
/input must be a schema definition object/
|
|
15
28
|
);
|
|
16
29
|
});
|
|
17
30
|
|
|
18
31
|
test("createCommand normalizes invalidates and preserves idempotent flag", () => {
|
|
19
32
|
const command = createCommand({
|
|
20
|
-
input:
|
|
21
|
-
{
|
|
22
|
-
token:
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
{ additionalProperties: false }
|
|
31
|
-
),
|
|
33
|
+
input: {
|
|
34
|
+
schema: createSchema({
|
|
35
|
+
token: { type: "string", required: true, minLength: 1 }
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
output: {
|
|
39
|
+
schema: createSchema({
|
|
40
|
+
ok: { type: "boolean", required: true }
|
|
41
|
+
})
|
|
42
|
+
},
|
|
32
43
|
idempotent: true,
|
|
33
44
|
invalidates: ["users-web", "users-web", "", "workspace.members"]
|
|
34
45
|
});
|
|
35
46
|
|
|
36
47
|
assert.equal(command.idempotent, true);
|
|
37
48
|
assert.deepEqual(command.invalidates, ["users-web", "workspace.members"]);
|
|
38
|
-
assert.equal(command.input.
|
|
39
|
-
assert.equal(command.output.
|
|
49
|
+
assert.equal(command.input.mode, "patch");
|
|
50
|
+
assert.equal(command.output.mode, "replace");
|
|
51
|
+
assert.equal(typeof command.input.schema.toJsonSchema, "function");
|
|
52
|
+
assert.equal(typeof command.output.schema.toJsonSchema, "function");
|
|
40
53
|
});
|
|
41
54
|
|
|
42
55
|
test("createCommand omits idempotent when not explicitly boolean", () => {
|
|
43
56
|
const command = createCommand({
|
|
44
|
-
input:
|
|
45
|
-
|
|
57
|
+
input: {
|
|
58
|
+
schema: createSchema({})
|
|
59
|
+
},
|
|
60
|
+
output: {
|
|
61
|
+
schema: createSchema({})
|
|
62
|
+
}
|
|
46
63
|
});
|
|
47
64
|
|
|
48
65
|
assert.equal(Object.hasOwn(command, "idempotent"), false);
|
|
@@ -32,6 +32,14 @@ test("shared entrypoint exports shared validators only", () => {
|
|
|
32
32
|
assert.equal(typeof sharedApi.enumSchema, "function");
|
|
33
33
|
assert.equal(typeof sharedApi.createResource, "function");
|
|
34
34
|
assert.equal(typeof sharedApi.createCommand, "function");
|
|
35
|
+
assert.equal(typeof sharedApi.createJsonApiDocument, "function");
|
|
36
|
+
assert.equal(typeof sharedApi.createJsonApiErrorDocumentFromFailure, "function");
|
|
37
|
+
assert.equal(typeof sharedApi.normalizeJsonApiDocument, "function");
|
|
38
|
+
assert.equal(typeof sharedApi.returnJsonApiDocument, "function");
|
|
39
|
+
assert.equal(typeof sharedApi.returnJsonApiData, "function");
|
|
40
|
+
assert.equal(typeof sharedApi.returnJsonApiMeta, "function");
|
|
41
|
+
assert.equal(typeof sharedApi.createJsonApiResourceRouteContract, "function");
|
|
42
|
+
assert.equal(typeof sharedApi.withJsonApiErrorResponses, "function");
|
|
35
43
|
assert.equal(typeof sharedApi.createHttpClient, "undefined");
|
|
36
44
|
assert.equal(typeof sharedApi.HttpValidatorsServiceProvider, "undefined");
|
|
37
45
|
});
|
|
@@ -3,10 +3,13 @@ import test from "node:test";
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
STANDARD_ERROR_STATUS_CODES,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
apiErrorOutputValidator,
|
|
7
|
+
apiValidationErrorOutputValidator,
|
|
8
|
+
apiErrorTransportSchema,
|
|
9
|
+
apiValidationErrorTransportSchema,
|
|
10
|
+
fastifyDefaultErrorTransportSchema,
|
|
9
11
|
enumSchema,
|
|
12
|
+
createTransportResponseSchema,
|
|
10
13
|
withStandardErrorResponses
|
|
11
14
|
} from "../src/shared/validators/errorResponses.js";
|
|
12
15
|
|
|
@@ -25,7 +28,17 @@ test("withStandardErrorResponses includes standard statuses", () => {
|
|
|
25
28
|
for (const statusCode of STANDARD_ERROR_STATUS_CODES) {
|
|
26
29
|
assert.ok(responses[statusCode], `missing status ${statusCode}`);
|
|
27
30
|
}
|
|
28
|
-
assert.equal(responses[400].
|
|
31
|
+
assert.equal(responses[400].transportSchema.anyOf.length, 2);
|
|
32
|
+
assert.deepEqual(responses[400].transportSchema.anyOf[0], {
|
|
33
|
+
allOf: [{
|
|
34
|
+
$ref: "#/definitions/ApiErrorOutput"
|
|
35
|
+
}]
|
|
36
|
+
});
|
|
37
|
+
assert.equal(responses[400].transportSchema.definitions.ApiErrorOutput.type, "object");
|
|
38
|
+
assert.equal(
|
|
39
|
+
responses[400].transportSchema.definitions.ApiErrorOutput.properties.details.allOf[0].$ref,
|
|
40
|
+
"#/definitions/ApiErrorOutput__SchemaNode_1_replace"
|
|
41
|
+
);
|
|
29
42
|
});
|
|
30
43
|
|
|
31
44
|
test("withStandardErrorResponses uses validation union for 400 when enabled", () => {
|
|
@@ -40,11 +53,20 @@ test("withStandardErrorResponses uses validation union for 400 when enabled", ()
|
|
|
40
53
|
{ includeValidation400: true }
|
|
41
54
|
);
|
|
42
55
|
|
|
43
|
-
assert.equal(responses[400].
|
|
44
|
-
assert.deepEqual(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
assert.equal(responses[400].transportSchema.anyOf.length, 3);
|
|
57
|
+
assert.deepEqual(responses[400].transportSchema.anyOf[0], {
|
|
58
|
+
allOf: [{
|
|
59
|
+
$ref: "#/definitions/ApiValidationErrorOutput"
|
|
60
|
+
}]
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(responses[400].transportSchema.anyOf[1], {
|
|
63
|
+
allOf: [{
|
|
64
|
+
$ref: "#/definitions/ApiErrorOutput"
|
|
65
|
+
}]
|
|
66
|
+
});
|
|
67
|
+
assert.equal(responses[400].transportSchema.anyOf[2].type, fastifyDefaultErrorTransportSchema.type);
|
|
68
|
+
assert.equal(responses[400].transportSchema.definitions.ApiValidationErrorOutput.type, "object");
|
|
69
|
+
assert.equal(responses[400].transportSchema.definitions.ApiErrorOutput.type, "object");
|
|
48
70
|
});
|
|
49
71
|
|
|
50
72
|
test("withStandardErrorResponses does not override existing error schemas", () => {
|
|
@@ -63,14 +85,12 @@ test("withStandardErrorResponses does not override existing error schemas", () =
|
|
|
63
85
|
type: "string"
|
|
64
86
|
}
|
|
65
87
|
},
|
|
66
|
-
400:
|
|
67
|
-
schema: custom400
|
|
68
|
-
}
|
|
88
|
+
400: createTransportResponseSchema(custom400)
|
|
69
89
|
},
|
|
70
90
|
{ includeValidation400: true }
|
|
71
91
|
);
|
|
72
92
|
|
|
73
|
-
assert.equal(responses[400].
|
|
93
|
+
assert.equal(responses[400].transportSchema, custom400);
|
|
74
94
|
});
|
|
75
95
|
|
|
76
96
|
test("enumSchema creates a literal union", () => {
|
|
@@ -82,3 +102,19 @@ test("enumSchema creates a literal union", () => {
|
|
|
82
102
|
["one", "two", "three"]
|
|
83
103
|
);
|
|
84
104
|
});
|
|
105
|
+
|
|
106
|
+
test("error response validators export transport schemas from the same contracts", () => {
|
|
107
|
+
assert.equal(apiErrorOutputValidator.mode, "replace");
|
|
108
|
+
assert.equal(apiValidationErrorOutputValidator.mode, "replace");
|
|
109
|
+
assert.equal(apiErrorTransportSchema.type, "object");
|
|
110
|
+
assert.equal(apiValidationErrorTransportSchema.type, "object");
|
|
111
|
+
assert.equal(apiErrorTransportSchema.properties.details.allOf[0].$ref, "#/definitions/SchemaNode_1_replace");
|
|
112
|
+
assert.equal(
|
|
113
|
+
apiValidationErrorTransportSchema.properties.details.allOf[0].$ref,
|
|
114
|
+
"#/definitions/SchemaNode_1_replace"
|
|
115
|
+
);
|
|
116
|
+
assert.equal(
|
|
117
|
+
apiValidationErrorTransportSchema.definitions.SchemaNode_1_replace.required.includes("fieldErrors"),
|
|
118
|
+
true
|
|
119
|
+
);
|
|
120
|
+
});
|