@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
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import { normalizeArray } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
import {
|
|
4
|
+
resolveSchemaTransportSchemaDefinition
|
|
5
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
6
|
+
import {
|
|
7
|
+
JSON_API_CONTENT_TYPE,
|
|
8
|
+
createJsonApiDocument,
|
|
9
|
+
createJsonApiErrorDocumentFromFailure,
|
|
10
|
+
createJsonApiResourceObject,
|
|
11
|
+
normalizeJsonApiDocument,
|
|
12
|
+
resolveJsonApiTransportTypes
|
|
13
|
+
} from "./jsonApiTransport.js";
|
|
14
|
+
import {
|
|
15
|
+
isJsonApiDataResult,
|
|
16
|
+
isJsonApiDocumentResult,
|
|
17
|
+
isJsonApiMetaResult,
|
|
18
|
+
unwrapJsonApiResult
|
|
19
|
+
} from "./jsonApiResult.js";
|
|
20
|
+
import {
|
|
21
|
+
createJsonApiResourceQueryTransportSchema,
|
|
22
|
+
decodeJsonApiResourceQueryObject
|
|
23
|
+
} from "./jsonApiQueryTransport.js";
|
|
24
|
+
import {
|
|
25
|
+
STANDARD_ERROR_STATUS_CODES,
|
|
26
|
+
createTransportResponseSchema
|
|
27
|
+
} from "./errorResponses.js";
|
|
28
|
+
import { createEmbeddableTransportSchemaDocument } from "./transportSchemaEmbedding.js";
|
|
29
|
+
|
|
30
|
+
const JSON_API_ID_SCHEMA = Object.freeze({
|
|
31
|
+
anyOf: [
|
|
32
|
+
{ type: "string", minLength: 1 },
|
|
33
|
+
{ type: "number" }
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const JSON_API_LINK_VALUE_SCHEMA = Object.freeze({
|
|
38
|
+
anyOf: [
|
|
39
|
+
{ type: "string", minLength: 1 },
|
|
40
|
+
{
|
|
41
|
+
type: "object",
|
|
42
|
+
additionalProperties: true
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const JSON_API_LINKS_SCHEMA = Object.freeze({
|
|
48
|
+
type: "object",
|
|
49
|
+
additionalProperties: JSON_API_LINK_VALUE_SCHEMA
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const JSON_API_META_SCHEMA = Object.freeze({
|
|
53
|
+
type: "object",
|
|
54
|
+
additionalProperties: true
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const JSON_API_ERROR_OBJECT_SCHEMA = Object.freeze({
|
|
58
|
+
type: "object",
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
properties: {
|
|
61
|
+
status: { type: "string", minLength: 1 },
|
|
62
|
+
code: { type: "string", minLength: 1 },
|
|
63
|
+
title: { type: "string", minLength: 1 },
|
|
64
|
+
detail: { type: "string", minLength: 1 },
|
|
65
|
+
source: {
|
|
66
|
+
type: "object",
|
|
67
|
+
additionalProperties: true
|
|
68
|
+
},
|
|
69
|
+
links: JSON_API_LINKS_SCHEMA,
|
|
70
|
+
meta: JSON_API_META_SCHEMA
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const JSON_API_ERROR_DOCUMENT_SCHEMA = Object.freeze({
|
|
75
|
+
type: "object",
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
required: ["errors"],
|
|
78
|
+
properties: {
|
|
79
|
+
errors: {
|
|
80
|
+
type: "array",
|
|
81
|
+
minItems: 1,
|
|
82
|
+
items: JSON_API_ERROR_OBJECT_SCHEMA
|
|
83
|
+
},
|
|
84
|
+
links: JSON_API_LINKS_SCHEMA,
|
|
85
|
+
meta: JSON_API_META_SCHEMA
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function isRecord(value) {
|
|
90
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createJsonApiTransportError(statusCode, message, code) {
|
|
94
|
+
const error = new Error(String(message || "JSON:API transport error."));
|
|
95
|
+
error.status = Number(statusCode) || 500;
|
|
96
|
+
error.statusCode = error.status;
|
|
97
|
+
error.code = String(code || "jsonapi_transport_error").trim() || "jsonapi_transport_error";
|
|
98
|
+
return error;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveRouteType(type = "") {
|
|
102
|
+
const normalizedType = String(type || "").trim();
|
|
103
|
+
if (!normalizedType) {
|
|
104
|
+
throw new TypeError("JSON:API resource transport requires a non-empty type.");
|
|
105
|
+
}
|
|
106
|
+
return normalizedType;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveRouteTypes(value = {}) {
|
|
110
|
+
const resolvedTypes = resolveJsonApiTransportTypes(value, {
|
|
111
|
+
context: "JSON:API resource transport"
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
requestType: resolvedTypes.requestType ? resolveRouteType(resolvedTypes.requestType) : "",
|
|
116
|
+
responseType: resolvedTypes.responseType ? resolveRouteType(resolvedTypes.responseType) : ""
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveEmbeddedAttributesTransportSchema(definition, {
|
|
121
|
+
context = "JSON:API resource",
|
|
122
|
+
defaultMode = "replace",
|
|
123
|
+
removeId = false
|
|
124
|
+
} = {}) {
|
|
125
|
+
const transportSchema = resolveSchemaTransportSchemaDefinition(definition, {
|
|
126
|
+
context,
|
|
127
|
+
defaultMode
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!transportSchema || typeof transportSchema !== "object" || Array.isArray(transportSchema)) {
|
|
131
|
+
throw new TypeError(`${context} transport schema must resolve to an object schema.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sourceSchema = normalizeObject(transportSchema);
|
|
135
|
+
const properties = normalizeObject(sourceSchema.properties);
|
|
136
|
+
const nextProperties = {
|
|
137
|
+
...properties
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (removeId) {
|
|
141
|
+
delete nextProperties.id;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const required = Array.isArray(sourceSchema.required)
|
|
145
|
+
? sourceSchema.required.filter((entry) => String(entry || "").trim() && (!removeId || entry !== "id"))
|
|
146
|
+
: [];
|
|
147
|
+
|
|
148
|
+
const attributeSchema = {
|
|
149
|
+
...sourceSchema,
|
|
150
|
+
type: "object",
|
|
151
|
+
properties: nextProperties
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (required.length > 0) {
|
|
155
|
+
attributeSchema.required = required;
|
|
156
|
+
} else {
|
|
157
|
+
delete attributeSchema.required;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return createEmbeddableTransportSchemaDocument(attributeSchema, `${normalizeText(context, { fallback: "JsonApiAttributes" }).replace(/[^a-z0-9]+/gi, "_")}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createJsonApiResourceObjectTransportSchema({
|
|
164
|
+
type = "",
|
|
165
|
+
attributes,
|
|
166
|
+
requireId = true,
|
|
167
|
+
includeLinks = false,
|
|
168
|
+
includeMeta = false
|
|
169
|
+
} = {}) {
|
|
170
|
+
const normalizedType = resolveRouteType(type);
|
|
171
|
+
const embeddedAttributes = resolveEmbeddedAttributesTransportSchema(attributes, {
|
|
172
|
+
context: `${normalizedType} resource attributes`,
|
|
173
|
+
defaultMode: "replace",
|
|
174
|
+
removeId: true
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const properties = {
|
|
178
|
+
type: {
|
|
179
|
+
const: normalizedType
|
|
180
|
+
},
|
|
181
|
+
attributes: embeddedAttributes.schema
|
|
182
|
+
};
|
|
183
|
+
const required = ["type", "attributes"];
|
|
184
|
+
|
|
185
|
+
if (requireId) {
|
|
186
|
+
properties.id = JSON_API_ID_SCHEMA;
|
|
187
|
+
required.push("id");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (includeLinks) {
|
|
191
|
+
properties.links = JSON_API_LINKS_SCHEMA;
|
|
192
|
+
}
|
|
193
|
+
if (includeMeta) {
|
|
194
|
+
properties.meta = JSON_API_META_SCHEMA;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
schema: {
|
|
199
|
+
type: "object",
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
required,
|
|
202
|
+
properties
|
|
203
|
+
},
|
|
204
|
+
definitions: embeddedAttributes.definitions
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function createJsonApiResourceRequestBodyTransportSchema({
|
|
209
|
+
type = "",
|
|
210
|
+
attributes,
|
|
211
|
+
requireId = false
|
|
212
|
+
} = {}) {
|
|
213
|
+
const resourceTransport = createJsonApiResourceObjectTransportSchema({
|
|
214
|
+
type,
|
|
215
|
+
attributes,
|
|
216
|
+
requireId
|
|
217
|
+
});
|
|
218
|
+
const embeddedResource = createEmbeddableTransportSchemaDocument(
|
|
219
|
+
{
|
|
220
|
+
...resourceTransport.schema,
|
|
221
|
+
definitions: resourceTransport.definitions
|
|
222
|
+
},
|
|
223
|
+
`${resolveRouteType(type)}RequestResource`
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
type: "object",
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
required: ["data"],
|
|
230
|
+
properties: {
|
|
231
|
+
data: embeddedResource.schema
|
|
232
|
+
},
|
|
233
|
+
definitions: embeddedResource.definitions
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createJsonApiMetaSuccessTransportSchema({
|
|
238
|
+
meta
|
|
239
|
+
} = {}) {
|
|
240
|
+
const embeddedMeta = resolveEmbeddedAttributesTransportSchema(meta, {
|
|
241
|
+
context: "JSON:API success meta",
|
|
242
|
+
defaultMode: "replace",
|
|
243
|
+
removeId: false
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
type: "object",
|
|
248
|
+
additionalProperties: false,
|
|
249
|
+
required: ["meta"],
|
|
250
|
+
properties: {
|
|
251
|
+
meta: embeddedMeta.schema
|
|
252
|
+
},
|
|
253
|
+
definitions: embeddedMeta.definitions
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function createJsonApiResourceSuccessTransportSchema({
|
|
258
|
+
type = "",
|
|
259
|
+
attributes,
|
|
260
|
+
kind = "record",
|
|
261
|
+
includeLinks = false,
|
|
262
|
+
includeMeta = false,
|
|
263
|
+
includeIncluded = false
|
|
264
|
+
} = {}) {
|
|
265
|
+
const normalizedKind = String(kind || "record").trim().toLowerCase();
|
|
266
|
+
if (!["record", "nullable-record", "collection", "meta"].includes(normalizedKind)) {
|
|
267
|
+
throw new TypeError(`Unsupported JSON:API success schema kind: ${normalizedKind || "<empty>"}.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (normalizedKind === "meta") {
|
|
271
|
+
return createJsonApiMetaSuccessTransportSchema({
|
|
272
|
+
meta: attributes
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const resourceTransport = createJsonApiResourceObjectTransportSchema({
|
|
277
|
+
type,
|
|
278
|
+
attributes,
|
|
279
|
+
requireId: true
|
|
280
|
+
});
|
|
281
|
+
const embeddedResource = createEmbeddableTransportSchemaDocument(
|
|
282
|
+
{
|
|
283
|
+
...resourceTransport.schema,
|
|
284
|
+
definitions: resourceTransport.definitions
|
|
285
|
+
},
|
|
286
|
+
`${resolveRouteType(type)}SuccessResource`
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const documentProperties = {};
|
|
290
|
+
const documentDefinitions = {
|
|
291
|
+
...resourceTransport.definitions,
|
|
292
|
+
...embeddedResource.definitions
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
if (normalizedKind === "collection") {
|
|
296
|
+
documentProperties.data = {
|
|
297
|
+
type: "array",
|
|
298
|
+
items: embeddedResource.schema
|
|
299
|
+
};
|
|
300
|
+
} else if (normalizedKind === "nullable-record") {
|
|
301
|
+
documentProperties.data = {
|
|
302
|
+
anyOf: [
|
|
303
|
+
{ type: "null" },
|
|
304
|
+
embeddedResource.schema
|
|
305
|
+
]
|
|
306
|
+
};
|
|
307
|
+
} else {
|
|
308
|
+
documentProperties.data = embeddedResource.schema;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (includeIncluded) {
|
|
312
|
+
documentProperties.included = {
|
|
313
|
+
type: "array",
|
|
314
|
+
items: embeddedResource.schema
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (includeLinks) {
|
|
318
|
+
documentProperties.links = JSON_API_LINKS_SCHEMA;
|
|
319
|
+
}
|
|
320
|
+
if (includeMeta) {
|
|
321
|
+
documentProperties.meta = JSON_API_META_SCHEMA;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
type: "object",
|
|
326
|
+
additionalProperties: false,
|
|
327
|
+
required: ["data"],
|
|
328
|
+
properties: documentProperties,
|
|
329
|
+
definitions: documentDefinitions
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function withJsonApiErrorResponses(successResponses, { includeValidation400 = false } = {}) {
|
|
334
|
+
const responses = {
|
|
335
|
+
...normalizeObject(successResponses)
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
for (const statusCode of STANDARD_ERROR_STATUS_CODES) {
|
|
339
|
+
if (responses[statusCode]) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (statusCode === 400 && !includeValidation400) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
responses[statusCode] = createTransportResponseSchema(JSON_API_ERROR_DOCUMENT_SCHEMA);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return responses;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function defaultRecordTypeResolver(type) {
|
|
354
|
+
return function resolveRecordType() {
|
|
355
|
+
return type;
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function defaultRecordIdResolver(record = {}) {
|
|
360
|
+
if (!isRecord(record) || record.id == null || String(record.id).trim() === "") {
|
|
361
|
+
throw createJsonApiTransportError(500, "JSON:API resource response requires record.id.", "jsonapi_record_id_missing");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return record.id;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function defaultRecordAttributesResolver(record = {}) {
|
|
368
|
+
if (!isRecord(record)) {
|
|
369
|
+
return {};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const attributes = {
|
|
373
|
+
...record
|
|
374
|
+
};
|
|
375
|
+
delete attributes.id;
|
|
376
|
+
return attributes;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function defaultCollectionItemsResolver(payload) {
|
|
380
|
+
if (Array.isArray(payload)) {
|
|
381
|
+
return payload;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return Array.isArray(payload?.items) ? payload.items : [];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function defaultCollectionMetaResolver(payload) {
|
|
388
|
+
const nextCursor = String(payload?.nextCursor || "").trim();
|
|
389
|
+
if (!nextCursor) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
page: {
|
|
395
|
+
nextCursor
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function assertJsonApiSuccessResult(payload) {
|
|
401
|
+
const result = unwrapJsonApiResult(payload);
|
|
402
|
+
if (!result) {
|
|
403
|
+
throw createJsonApiTransportError(
|
|
404
|
+
500,
|
|
405
|
+
"JSON:API route success payload must be returned with an explicit JSON:API result wrapper.",
|
|
406
|
+
"jsonapi_success_result_missing"
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function createJsonApiResourceRouteTransport({
|
|
414
|
+
type = "",
|
|
415
|
+
requestType = "",
|
|
416
|
+
responseType = "",
|
|
417
|
+
query = null,
|
|
418
|
+
allowBodyId = false,
|
|
419
|
+
successKind = "record",
|
|
420
|
+
pointerPrefix = "/data/attributes",
|
|
421
|
+
mapRequestRelationships = null,
|
|
422
|
+
getRecordType = null,
|
|
423
|
+
getRecordId = null,
|
|
424
|
+
getRecordAttributes = null,
|
|
425
|
+
getRecordRelationships = null,
|
|
426
|
+
getRecordLinks = null,
|
|
427
|
+
getRecordMeta = null,
|
|
428
|
+
getIncluded = null,
|
|
429
|
+
getDocumentLinks = null,
|
|
430
|
+
getDocumentMeta = null,
|
|
431
|
+
getCollectionItems = null
|
|
432
|
+
} = {}) {
|
|
433
|
+
const resolvedTypes = resolveRouteTypes({
|
|
434
|
+
type,
|
|
435
|
+
requestType,
|
|
436
|
+
responseType
|
|
437
|
+
});
|
|
438
|
+
const normalizedRequestType = resolvedTypes.requestType;
|
|
439
|
+
const normalizedResponseType = resolvedTypes.responseType;
|
|
440
|
+
const normalizedSuccessKind = String(successKind || "record").trim().toLowerCase();
|
|
441
|
+
|
|
442
|
+
const resolveRecordType = typeof getRecordType === "function" ? getRecordType : defaultRecordTypeResolver(normalizedResponseType);
|
|
443
|
+
const resolveRecordId = typeof getRecordId === "function" ? getRecordId : defaultRecordIdResolver;
|
|
444
|
+
const resolveRecordAttributes = typeof getRecordAttributes === "function" ? getRecordAttributes : defaultRecordAttributesResolver;
|
|
445
|
+
const resolveCollectionItems = typeof getCollectionItems === "function" ? getCollectionItems : defaultCollectionItemsResolver;
|
|
446
|
+
const resolveDocumentMeta = typeof getDocumentMeta === "function" ? getDocumentMeta : defaultCollectionMetaResolver;
|
|
447
|
+
|
|
448
|
+
function buildResourceObject(record, context) {
|
|
449
|
+
if (!isRecord(record)) {
|
|
450
|
+
throw createJsonApiTransportError(500, "JSON:API resource response requires an object record.", "jsonapi_record_invalid");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const resourceOptions = {
|
|
454
|
+
type: resolveRecordType(record, context),
|
|
455
|
+
id: resolveRecordId(record, context),
|
|
456
|
+
attributes: resolveRecordAttributes(record, context)
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
if (typeof getRecordRelationships === "function") {
|
|
460
|
+
const relationships = getRecordRelationships(record, context);
|
|
461
|
+
if (relationships !== undefined) {
|
|
462
|
+
resourceOptions.relationships = relationships;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (typeof getRecordLinks === "function") {
|
|
466
|
+
const links = getRecordLinks(record, context);
|
|
467
|
+
if (links !== undefined) {
|
|
468
|
+
resourceOptions.links = links;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (typeof getRecordMeta === "function") {
|
|
472
|
+
const meta = getRecordMeta(record, context);
|
|
473
|
+
if (meta !== undefined) {
|
|
474
|
+
resourceOptions.meta = meta;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return createJsonApiResourceObject(resourceOptions);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return Object.freeze({
|
|
482
|
+
kind: "jsonapi-resource",
|
|
483
|
+
contentType: JSON_API_CONTENT_TYPE,
|
|
484
|
+
request: {
|
|
485
|
+
body(payload) {
|
|
486
|
+
if (!normalizedRequestType) {
|
|
487
|
+
throw createJsonApiTransportError(500, "JSON:API request transport requires requestType for body parsing.", "jsonapi_request_type_missing");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const document = normalizeJsonApiDocument(payload);
|
|
491
|
+
if (document.kind !== "resource" || document.data == null) {
|
|
492
|
+
throw createJsonApiTransportError(400, "JSON:API request body must contain a resource document.", "jsonapi_request_body_invalid");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (String(document.data.type || "").trim() !== normalizedRequestType) {
|
|
496
|
+
throw createJsonApiTransportError(409, `JSON:API resource type must be ${normalizedRequestType}.`, "jsonapi_request_type_mismatch");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const nextBody = {
|
|
500
|
+
...(document.data.attributes || {})
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
if (allowBodyId && document.data.id != null && String(document.data.id).trim()) {
|
|
504
|
+
nextBody.id = document.data.id;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (typeof mapRequestRelationships === "function") {
|
|
508
|
+
Object.assign(nextBody, mapRequestRelationships(document.data.relationships || {}, {
|
|
509
|
+
payload,
|
|
510
|
+
document
|
|
511
|
+
}) || {});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return nextBody;
|
|
515
|
+
},
|
|
516
|
+
query(payload) {
|
|
517
|
+
if (!query) {
|
|
518
|
+
return payload;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return decodeJsonApiResourceQueryObject(payload, {
|
|
522
|
+
responseType: normalizedResponseType
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
response(payload, context = {}) {
|
|
527
|
+
if (normalizedSuccessKind === "no-content" || Number(context?.statusCode || 200) === 204) {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const result = assertJsonApiSuccessResult(payload);
|
|
532
|
+
|
|
533
|
+
if (isJsonApiDocumentResult(result)) {
|
|
534
|
+
const document = normalizeJsonApiDocument(result.value);
|
|
535
|
+
if (document.kind === "unknown") {
|
|
536
|
+
throw createJsonApiTransportError(
|
|
537
|
+
500,
|
|
538
|
+
"JSON:API document result must contain a valid JSON:API document.",
|
|
539
|
+
"jsonapi_document_result_invalid"
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (normalizedSuccessKind === "collection" && document.kind !== "collection") {
|
|
544
|
+
throw createJsonApiTransportError(
|
|
545
|
+
500,
|
|
546
|
+
"JSON:API collection route requires a collection document result.",
|
|
547
|
+
"jsonapi_document_result_kind_mismatch"
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (
|
|
552
|
+
(normalizedSuccessKind === "record" || normalizedSuccessKind === "nullable-record") &&
|
|
553
|
+
document.kind !== "resource"
|
|
554
|
+
) {
|
|
555
|
+
throw createJsonApiTransportError(
|
|
556
|
+
500,
|
|
557
|
+
"JSON:API record route requires a resource document result.",
|
|
558
|
+
"jsonapi_document_result_kind_mismatch"
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (normalizedSuccessKind === "meta" && document.kind !== "meta") {
|
|
563
|
+
throw createJsonApiTransportError(
|
|
564
|
+
500,
|
|
565
|
+
"JSON:API meta route requires a meta-only document result.",
|
|
566
|
+
"jsonapi_document_result_kind_mismatch"
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return result.value;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (normalizedSuccessKind === "meta") {
|
|
574
|
+
if (isJsonApiMetaResult(result)) {
|
|
575
|
+
return createJsonApiDocument({
|
|
576
|
+
meta: result.value
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
throw createJsonApiTransportError(
|
|
581
|
+
500,
|
|
582
|
+
"JSON:API meta route requires a meta result.",
|
|
583
|
+
"jsonapi_meta_result_missing"
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!isJsonApiDataResult(result)) {
|
|
588
|
+
throw createJsonApiTransportError(
|
|
589
|
+
500,
|
|
590
|
+
"JSON:API resource route requires a data or document result.",
|
|
591
|
+
"jsonapi_success_result_kind_invalid"
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const sourcePayload = result.value;
|
|
596
|
+
|
|
597
|
+
if (normalizedSuccessKind === "collection") {
|
|
598
|
+
const items = normalizeArray(resolveCollectionItems(sourcePayload, context));
|
|
599
|
+
const documentOptions = {
|
|
600
|
+
data: items.map((entry) => buildResourceObject(entry, context))
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
if (typeof getIncluded === "function") {
|
|
604
|
+
const included = normalizeArray(getIncluded(sourcePayload, context));
|
|
605
|
+
if (included.length > 0) {
|
|
606
|
+
documentOptions.included = included;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const links = typeof getDocumentLinks === "function" ? getDocumentLinks(sourcePayload, context) : undefined;
|
|
611
|
+
if (links !== undefined) {
|
|
612
|
+
documentOptions.links = links;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const meta = resolveDocumentMeta(sourcePayload, context);
|
|
616
|
+
if (meta !== undefined) {
|
|
617
|
+
documentOptions.meta = meta;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return createJsonApiDocument(documentOptions);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (sourcePayload == null) {
|
|
624
|
+
if (normalizedSuccessKind === "nullable-record") {
|
|
625
|
+
return createJsonApiDocument({
|
|
626
|
+
data: null
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
throw createJsonApiTransportError(500, "JSON:API resource response requires a record payload.", "jsonapi_record_missing");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const documentOptions = {
|
|
634
|
+
data: buildResourceObject(sourcePayload, context)
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
if (typeof getIncluded === "function") {
|
|
638
|
+
const included = normalizeArray(getIncluded(sourcePayload, context));
|
|
639
|
+
if (included.length > 0) {
|
|
640
|
+
documentOptions.included = included;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (typeof getDocumentLinks === "function") {
|
|
645
|
+
const links = getDocumentLinks(sourcePayload, context);
|
|
646
|
+
if (links !== undefined) {
|
|
647
|
+
documentOptions.links = links;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (typeof getDocumentMeta === "function") {
|
|
652
|
+
const meta = getDocumentMeta(sourcePayload, context);
|
|
653
|
+
if (meta !== undefined) {
|
|
654
|
+
documentOptions.meta = meta;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return createJsonApiDocument(documentOptions);
|
|
659
|
+
},
|
|
660
|
+
error(error, {
|
|
661
|
+
statusCode = 500,
|
|
662
|
+
code = ""
|
|
663
|
+
} = {}) {
|
|
664
|
+
const fieldErrors = isRecord(error?.fieldErrors)
|
|
665
|
+
? error.fieldErrors
|
|
666
|
+
: isRecord(error?.details?.fieldErrors)
|
|
667
|
+
? error.details.fieldErrors
|
|
668
|
+
: {};
|
|
669
|
+
|
|
670
|
+
return createJsonApiErrorDocumentFromFailure({
|
|
671
|
+
statusCode,
|
|
672
|
+
code,
|
|
673
|
+
message: error?.message,
|
|
674
|
+
fieldErrors,
|
|
675
|
+
validationIssues: Array.isArray(error?.validation) ? error.validation : [],
|
|
676
|
+
validationContext: String(error?.validationContext || "").trim(),
|
|
677
|
+
pointerPrefix
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function createJsonApiResourceRouteContract({
|
|
684
|
+
type = "",
|
|
685
|
+
requestType = "",
|
|
686
|
+
responseType = "",
|
|
687
|
+
body = null,
|
|
688
|
+
query = null,
|
|
689
|
+
output = null,
|
|
690
|
+
outputKind = "record",
|
|
691
|
+
successStatus = 200,
|
|
692
|
+
includeValidation400 = false,
|
|
693
|
+
allowBodyId = false,
|
|
694
|
+
pointerPrefix = "/data/attributes",
|
|
695
|
+
getRecordType = null,
|
|
696
|
+
getRecordId = null,
|
|
697
|
+
getRecordAttributes = null,
|
|
698
|
+
getRecordRelationships = null,
|
|
699
|
+
getRecordLinks = null,
|
|
700
|
+
getRecordMeta = null,
|
|
701
|
+
getIncluded = null,
|
|
702
|
+
getDocumentLinks = null,
|
|
703
|
+
getDocumentMeta = null,
|
|
704
|
+
getCollectionItems = null,
|
|
705
|
+
mapRequestRelationships = null
|
|
706
|
+
} = {}) {
|
|
707
|
+
const resolvedTypes = resolveRouteTypes({
|
|
708
|
+
type,
|
|
709
|
+
requestType,
|
|
710
|
+
responseType
|
|
711
|
+
});
|
|
712
|
+
const normalizedRequestType = resolvedTypes.requestType;
|
|
713
|
+
const normalizedResponseType = resolvedTypes.responseType;
|
|
714
|
+
const normalizedOutputKind = String(outputKind || "record").trim().toLowerCase();
|
|
715
|
+
const statusCode = Number(successStatus);
|
|
716
|
+
|
|
717
|
+
if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) {
|
|
718
|
+
throw new TypeError("JSON:API resource route contract requires a 2xx successStatus.");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const transport = createJsonApiResourceRouteTransport({
|
|
722
|
+
requestType: normalizedRequestType,
|
|
723
|
+
responseType: normalizedResponseType,
|
|
724
|
+
query,
|
|
725
|
+
allowBodyId,
|
|
726
|
+
successKind: normalizedOutputKind,
|
|
727
|
+
pointerPrefix,
|
|
728
|
+
getRecordType,
|
|
729
|
+
getRecordId,
|
|
730
|
+
getRecordAttributes,
|
|
731
|
+
getRecordRelationships,
|
|
732
|
+
getRecordLinks,
|
|
733
|
+
getRecordMeta,
|
|
734
|
+
getIncluded,
|
|
735
|
+
getDocumentLinks,
|
|
736
|
+
getDocumentMeta,
|
|
737
|
+
getCollectionItems,
|
|
738
|
+
mapRequestRelationships
|
|
739
|
+
});
|
|
740
|
+
const contract = {
|
|
741
|
+
transport
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const fastifySchema = {};
|
|
745
|
+
if (body) {
|
|
746
|
+
contract.body = body;
|
|
747
|
+
fastifySchema.body = createJsonApiResourceRequestBodyTransportSchema({
|
|
748
|
+
type: normalizedRequestType,
|
|
749
|
+
attributes: body,
|
|
750
|
+
requireId: allowBodyId
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
if (query) {
|
|
754
|
+
contract.query = query;
|
|
755
|
+
fastifySchema.querystring = createJsonApiResourceQueryTransportSchema({
|
|
756
|
+
query,
|
|
757
|
+
responseType: normalizedResponseType
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (Object.keys(fastifySchema).length > 0) {
|
|
762
|
+
contract.advanced = {
|
|
763
|
+
fastifySchema
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const successResponses = {};
|
|
768
|
+
if (normalizedOutputKind !== "no-content") {
|
|
769
|
+
if (!output) {
|
|
770
|
+
throw new TypeError(`JSON:API resource route contract for ${normalizedResponseType} requires output schema.`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
successResponses[statusCode] = createTransportResponseSchema(
|
|
774
|
+
createJsonApiResourceSuccessTransportSchema({
|
|
775
|
+
type: normalizedResponseType,
|
|
776
|
+
attributes: output,
|
|
777
|
+
kind: normalizedOutputKind,
|
|
778
|
+
includeLinks: typeof getDocumentLinks === "function",
|
|
779
|
+
includeMeta: typeof getDocumentMeta === "function" || normalizedOutputKind === "collection",
|
|
780
|
+
includeIncluded: typeof getIncluded === "function"
|
|
781
|
+
})
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
contract.responses = withJsonApiErrorResponses(successResponses, {
|
|
786
|
+
includeValidation400
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
return Object.freeze(contract);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export {
|
|
793
|
+
JSON_API_ERROR_DOCUMENT_SCHEMA,
|
|
794
|
+
createJsonApiResourceObjectTransportSchema,
|
|
795
|
+
createJsonApiResourceRequestBodyTransportSchema,
|
|
796
|
+
createJsonApiResourceSuccessTransportSchema,
|
|
797
|
+
withJsonApiErrorResponses,
|
|
798
|
+
createJsonApiResourceRouteTransport,
|
|
799
|
+
createJsonApiResourceRouteContract
|
|
800
|
+
};
|