@jskit-ai/http-runtime 0.1.54 → 0.1.56

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 (32) hide show
  1. package/package.descriptor.mjs +2 -4
  2. package/package.json +5 -5
  3. package/src/shared/clientRuntime/client.js +126 -9
  4. package/src/shared/clientRuntime/errors.js +6 -0
  5. package/src/shared/clientRuntime/jsonApiResourceTransport.js +241 -0
  6. package/src/shared/index.js +54 -5
  7. package/src/shared/validators/command.js +5 -4
  8. package/src/shared/validators/errorResponses.js +125 -62
  9. package/src/shared/validators/httpValidatorsApi.js +83 -12
  10. package/src/shared/validators/jsonApiQueryTransport.js +211 -0
  11. package/src/shared/validators/jsonApiResponses.js +3 -0
  12. package/src/shared/validators/jsonApiResult.js +83 -0
  13. package/src/shared/validators/jsonApiRouteTransport.js +800 -0
  14. package/src/shared/validators/jsonApiTransport.js +484 -0
  15. package/src/shared/validators/operationValidation.js +62 -101
  16. package/src/shared/validators/paginationQuery.js +14 -19
  17. package/src/shared/validators/resource.js +15 -17
  18. package/src/shared/validators/schemaUtils.js +18 -5
  19. package/src/shared/validators/transportSchemaEmbedding.js +81 -0
  20. package/test/client.test.js +279 -0
  21. package/test/command.test.js +38 -21
  22. package/test/entrypoints.boundary.test.js +8 -0
  23. package/test/errorResponses.test.js +49 -13
  24. package/test/jsonApiRouteTransport.test.js +349 -0
  25. package/test/jsonApiTransport.test.js +231 -0
  26. package/test/operationMessages.test.js +115 -66
  27. package/test/operationValidation.test.js +147 -159
  28. package/test/paginationQuery.test.js +4 -8
  29. package/test/resource.test.js +89 -55
  30. package/test/validationErrors.test.js +33 -0
  31. package/src/shared/validators/typeboxFormats.js +0 -43
  32. package/test/typeboxFormats.test.js +0 -42
@@ -0,0 +1,349 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ JSON_API_CONTENT_TYPE,
6
+ createJsonApiResourceQueryTransportSchema,
7
+ createJsonApiResourceRequestBodyTransportSchema,
8
+ createJsonApiResourceRouteContract,
9
+ createJsonApiResourceRouteTransport,
10
+ createJsonApiResourceSuccessTransportSchema,
11
+ returnJsonApiData,
12
+ returnJsonApiDocument,
13
+ returnJsonApiMeta
14
+ } from "../src/shared/index.js";
15
+ import { createSchema } from "../../kernel/shared/validators/index.js";
16
+ import { resolveRouteValidatorOptions } from "../../kernel/server/http/lib/routeValidator.js";
17
+
18
+ const CONTACT_BODY_SCHEMA = Object.freeze({
19
+ schema: createSchema({
20
+ name: {
21
+ type: "string",
22
+ required: true,
23
+ minLength: 1
24
+ },
25
+ subscribed: {
26
+ type: "boolean",
27
+ required: false
28
+ }
29
+ }),
30
+ mode: "create"
31
+ });
32
+
33
+ const CONTACT_RECORD_SCHEMA = Object.freeze({
34
+ schema: createSchema({
35
+ id: {
36
+ type: "string",
37
+ required: true,
38
+ minLength: 1
39
+ },
40
+ name: {
41
+ type: "string",
42
+ required: true,
43
+ minLength: 1
44
+ },
45
+ subscribed: {
46
+ type: "boolean",
47
+ required: true
48
+ }
49
+ }),
50
+ mode: "replace"
51
+ });
52
+
53
+ const CONTACT_LIST_QUERY_SCHEMA = Object.freeze({
54
+ schema: createSchema({
55
+ cursor: {
56
+ type: "string",
57
+ required: false,
58
+ minLength: 1
59
+ },
60
+ limit: {
61
+ type: "number",
62
+ required: false,
63
+ min: 1
64
+ },
65
+ q: {
66
+ type: "string",
67
+ required: false
68
+ },
69
+ include: {
70
+ type: "string",
71
+ required: false
72
+ },
73
+ workspaceId: {
74
+ type: "string",
75
+ required: false,
76
+ minLength: 1
77
+ }
78
+ }),
79
+ mode: "patch"
80
+ });
81
+
82
+ test("createJsonApiResourceRequestBodyTransportSchema wraps plain body schema in a JSON:API document", () => {
83
+ const schema = createJsonApiResourceRequestBodyTransportSchema({
84
+ type: "contacts",
85
+ attributes: CONTACT_BODY_SCHEMA
86
+ });
87
+
88
+ assert.equal(schema.type, "object");
89
+ assert.deepEqual(schema.required, ["data"]);
90
+ assert.equal(schema.properties.data.allOf[0].$ref, "#/definitions/contactsRequestResource");
91
+
92
+ const resourceSchema = schema.definitions.contactsRequestResource;
93
+ assert.deepEqual(resourceSchema.required, ["type", "attributes"]);
94
+ assert.equal(resourceSchema.properties.type.const, "contacts");
95
+ assert.equal(resourceSchema.properties.attributes.allOf[0].$ref, "#/definitions/contactsRequestResource__contacts_resource_attributes");
96
+ });
97
+
98
+ test("createJsonApiResourceSuccessTransportSchema wraps plain record schema in a JSON:API response document", () => {
99
+ const schema = createJsonApiResourceSuccessTransportSchema({
100
+ type: "contacts",
101
+ attributes: CONTACT_RECORD_SCHEMA,
102
+ kind: "record",
103
+ includeMeta: true
104
+ });
105
+
106
+ assert.equal(schema.type, "object");
107
+ assert.deepEqual(schema.required, ["data"]);
108
+ assert.equal(schema.properties.data.allOf[0].$ref, "#/definitions/contactsSuccessResource");
109
+ assert.equal(schema.properties.meta.type, "object");
110
+
111
+ const resourceSchema = schema.definitions.contactsSuccessResource;
112
+ assert.deepEqual(resourceSchema.required, ["type", "attributes", "id"]);
113
+ assert.equal(resourceSchema.properties.type.const, "contacts");
114
+ });
115
+
116
+ test("createJsonApiResourceRouteTransport unwraps request payloads and wraps resource/error responses", () => {
117
+ const transport = createJsonApiResourceRouteTransport({
118
+ type: "contacts",
119
+ successKind: "collection"
120
+ });
121
+
122
+ const plainBody = transport.request.body({
123
+ data: {
124
+ type: "contacts",
125
+ attributes: {
126
+ name: "Merc",
127
+ subscribed: true
128
+ }
129
+ }
130
+ });
131
+ assert.deepEqual(plainBody, {
132
+ name: "Merc",
133
+ subscribed: true
134
+ });
135
+
136
+ const response = transport.response(returnJsonApiData({
137
+ items: [
138
+ { id: "1", name: "Merc", subscribed: true },
139
+ { id: "2", name: "Tony", subscribed: false }
140
+ ],
141
+ nextCursor: "cursor_2"
142
+ }));
143
+ assert.deepEqual(response, {
144
+ data: [
145
+ {
146
+ type: "contacts",
147
+ id: "1",
148
+ attributes: {
149
+ name: "Merc",
150
+ subscribed: true
151
+ }
152
+ },
153
+ {
154
+ type: "contacts",
155
+ id: "2",
156
+ attributes: {
157
+ name: "Tony",
158
+ subscribed: false
159
+ }
160
+ }
161
+ ],
162
+ meta: {
163
+ page: {
164
+ nextCursor: "cursor_2"
165
+ }
166
+ }
167
+ });
168
+
169
+ const errorPayload = transport.error({
170
+ message: "Validation failed.",
171
+ validation: [
172
+ {
173
+ instancePath: "/data/attributes/name",
174
+ message: "must NOT have fewer than 1 characters"
175
+ }
176
+ ],
177
+ validationContext: "body"
178
+ }, {
179
+ statusCode: 400,
180
+ code: "validation_failed"
181
+ });
182
+
183
+ assert.equal(errorPayload.errors[0].status, "400");
184
+ assert.equal(errorPayload.errors[0].code, "validation_failed");
185
+ assert.equal(errorPayload.errors[0].source.pointer, "/data/attributes/name");
186
+ });
187
+
188
+ test("createJsonApiResourceQueryTransportSchema and route transport map list query params to JSON:API", () => {
189
+ const schema = createJsonApiResourceQueryTransportSchema({
190
+ query: CONTACT_LIST_QUERY_SCHEMA,
191
+ responseType: "contacts"
192
+ });
193
+
194
+ assert.equal(schema.type, "object");
195
+ assert.equal(schema.additionalProperties, false);
196
+ assert.ok(Object.hasOwn(schema.properties, "page[cursor]"));
197
+ assert.ok(Object.hasOwn(schema.properties, "page[limit]"));
198
+ assert.ok(Object.hasOwn(schema.properties, "filter[q]"));
199
+ assert.ok(Object.hasOwn(schema.properties, "include"));
200
+ assert.ok(Object.hasOwn(schema.properties, "filter[workspaceId]"));
201
+
202
+ const transport = createJsonApiResourceRouteTransport({
203
+ type: "contacts",
204
+ query: CONTACT_LIST_QUERY_SCHEMA,
205
+ successKind: "collection"
206
+ });
207
+
208
+ const plainQuery = transport.request.query({
209
+ "page[cursor]": "cursor_2",
210
+ "page[limit]": "10",
211
+ "filter[q]": "Merc",
212
+ include: "workspace",
213
+ "filter[workspaceId]": "7"
214
+ });
215
+
216
+ assert.deepEqual(plainQuery, {
217
+ cursor: "cursor_2",
218
+ limit: "10",
219
+ q: "Merc",
220
+ include: "workspace",
221
+ workspaceId: "7"
222
+ });
223
+ });
224
+
225
+ test("createJsonApiResourceRouteContract produces route options compatible with kernel route validator", () => {
226
+ const contract = createJsonApiResourceRouteContract({
227
+ requestType: "contact-updates",
228
+ responseType: "contacts",
229
+ body: CONTACT_BODY_SCHEMA,
230
+ query: CONTACT_LIST_QUERY_SCHEMA,
231
+ output: CONTACT_RECORD_SCHEMA,
232
+ outputKind: "record",
233
+ successStatus: 201,
234
+ includeValidation400: true
235
+ });
236
+
237
+ const resolved = resolveRouteValidatorOptions({
238
+ method: "POST",
239
+ path: "/api/contacts",
240
+ options: {
241
+ transport: contract.transport,
242
+ body: contract.body,
243
+ query: contract.query,
244
+ responses: contract.responses,
245
+ advanced: contract.advanced
246
+ }
247
+ });
248
+
249
+ assert.equal(resolved.transport.kind, "jsonapi-resource");
250
+ assert.equal(resolved.transport.contentType, JSON_API_CONTENT_TYPE);
251
+ assert.equal(resolved.schema.body.required[0], "data");
252
+ assert.ok(Object.hasOwn(resolved.schema.querystring.properties, "page[cursor]"));
253
+ assert.ok(Object.hasOwn(resolved.schema.querystring.properties, "filter[q]"));
254
+ assert.equal(resolved.schema.response["201"].required[0], "data");
255
+ assert.equal(resolved.schema.response["400"].required[0], "errors");
256
+ assert.equal(resolved.schema.body.definitions["contact-updatesRequestResource"].properties.type.const, "contact-updates");
257
+ assert.equal(resolved.schema.response["201"].definitions.contactsSuccessResource.properties.type.const, "contacts");
258
+
259
+ const unwrappedBody = resolved.transport.request.body({
260
+ data: {
261
+ type: "contact-updates",
262
+ attributes: {
263
+ name: "Merc",
264
+ subscribed: true
265
+ }
266
+ }
267
+ });
268
+
269
+ assert.deepEqual(resolved.input.body(unwrappedBody), {
270
+ name: "Merc",
271
+ subscribed: true
272
+ });
273
+
274
+ const unwrappedQuery = resolved.transport.request.query({
275
+ "page[cursor]": "cursor_3",
276
+ "filter[q]": "Merc"
277
+ });
278
+
279
+ assert.deepEqual(resolved.input.query(unwrappedQuery), {
280
+ cursor: "cursor_3",
281
+ q: "Merc"
282
+ });
283
+ });
284
+
285
+ test("createJsonApiResourceRouteTransport passes through tagged JSON:API document results", () => {
286
+ const transport = createJsonApiResourceRouteTransport({
287
+ type: "contacts",
288
+ successKind: "record"
289
+ });
290
+
291
+ const document = {
292
+ data: {
293
+ type: "contacts",
294
+ id: "1",
295
+ attributes: {
296
+ name: "Merc",
297
+ subscribed: true
298
+ }
299
+ }
300
+ };
301
+
302
+ assert.deepEqual(transport.response(returnJsonApiDocument(document)), document);
303
+ });
304
+
305
+ test("createJsonApiResourceRouteTransport wraps tagged meta results for meta routes", () => {
306
+ const contract = createJsonApiResourceRouteContract({
307
+ requestType: "password-changes",
308
+ body: CONTACT_BODY_SCHEMA,
309
+ output: {
310
+ schema: createSchema({
311
+ message: {
312
+ type: "string",
313
+ required: true,
314
+ minLength: 1
315
+ }
316
+ }),
317
+ mode: "replace"
318
+ },
319
+ outputKind: "meta",
320
+ successStatus: 200
321
+ });
322
+
323
+ assert.equal(contract.transport.kind, "jsonapi-resource");
324
+ assert.equal(contract.responses["200"].transportSchema.required[0], "meta");
325
+ assert.deepEqual(
326
+ contract.transport.response(returnJsonApiMeta({
327
+ message: "Password updated."
328
+ })),
329
+ {
330
+ meta: {
331
+ message: "Password updated."
332
+ }
333
+ }
334
+ );
335
+ });
336
+
337
+ test("createJsonApiResourceRouteTransport rejects untagged success payloads", () => {
338
+ const transport = createJsonApiResourceRouteTransport({
339
+ type: "contacts",
340
+ successKind: "record"
341
+ });
342
+
343
+ assert.throws(() => {
344
+ transport.response({
345
+ id: "1",
346
+ name: "Merc"
347
+ });
348
+ }, /explicit JSON:API result wrapper/);
349
+ });
@@ -0,0 +1,231 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ JSON_API_CONTENT_TYPE,
6
+ createJsonApiDocument,
7
+ createJsonApiErrorDocumentFromFailure,
8
+ createJsonApiResourceObject,
9
+ isJsonApiContentType,
10
+ isJsonContentType,
11
+ normalizeJsonApiDocument,
12
+ simplifyJsonApiDocument
13
+ } from "../src/shared/index.js";
14
+
15
+ test("json transport helpers recognize json and json:api media types", () => {
16
+ assert.equal(isJsonContentType("application/json"), true);
17
+ assert.equal(isJsonContentType("application/vnd.api+json"), true);
18
+ assert.equal(isJsonContentType("application/problem+json; charset=utf-8"), true);
19
+ assert.equal(isJsonContentType("text/plain"), false);
20
+
21
+ assert.equal(isJsonApiContentType(JSON_API_CONTENT_TYPE), true);
22
+ assert.equal(isJsonApiContentType("application/vnd.api+json; charset=utf-8"), true);
23
+ assert.equal(isJsonApiContentType("application/json"), false);
24
+ });
25
+
26
+ test("createJsonApiDocument builds normalized resource and collection documents", () => {
27
+ const invite = createJsonApiResourceObject({
28
+ type: "workspace-invites",
29
+ id: "9",
30
+ attributes: {
31
+ email: "tony@example.com"
32
+ },
33
+ relationships: {
34
+ workspace: {
35
+ data: {
36
+ type: "workspaces",
37
+ id: "1"
38
+ }
39
+ }
40
+ }
41
+ });
42
+
43
+ const document = createJsonApiDocument({
44
+ data: [invite],
45
+ included: [
46
+ createJsonApiResourceObject({
47
+ type: "workspaces",
48
+ id: "1",
49
+ attributes: {
50
+ name: "Acme"
51
+ }
52
+ })
53
+ ],
54
+ links: {
55
+ self: "/api/w/acme/invites"
56
+ },
57
+ meta: {
58
+ pageSize: 20
59
+ }
60
+ });
61
+
62
+ assert.equal(Array.isArray(document.data), true);
63
+ assert.equal(document.data[0].type, "workspace-invites");
64
+ assert.equal(document.included[0].type, "workspaces");
65
+ assert.equal(document.links.self, "/api/w/acme/invites");
66
+ assert.equal(document.meta.pageSize, 20);
67
+ });
68
+
69
+ test("createJsonApiDocument supports meta-only success documents", () => {
70
+ const document = createJsonApiDocument({
71
+ meta: {
72
+ message: "Password updated."
73
+ }
74
+ });
75
+
76
+ assert.deepEqual(document, {
77
+ meta: {
78
+ message: "Password updated."
79
+ }
80
+ });
81
+ });
82
+
83
+ test("normalizeJsonApiDocument preserves compound-document structure", () => {
84
+ const normalized = normalizeJsonApiDocument({
85
+ data: {
86
+ type: "assistant-messages",
87
+ id: "17",
88
+ attributes: {
89
+ contentText: "hello"
90
+ },
91
+ relationships: {
92
+ conversation: {
93
+ data: {
94
+ type: "assistant-conversations",
95
+ id: "2"
96
+ }
97
+ }
98
+ }
99
+ },
100
+ included: [
101
+ {
102
+ type: "assistant-conversations",
103
+ id: "2",
104
+ attributes: {
105
+ title: "Demo"
106
+ }
107
+ }
108
+ ],
109
+ links: {
110
+ self: "/api/assistant/admin/conversations/2/messages"
111
+ },
112
+ meta: {
113
+ page: 1
114
+ }
115
+ });
116
+
117
+ assert.equal(normalized.kind, "resource");
118
+ assert.equal(normalized.data.type, "assistant-messages");
119
+ assert.equal(normalized.data.relationships.conversation.data.id, "2");
120
+ assert.equal(normalized.included[0].attributes.title, "Demo");
121
+ assert.equal(normalized.links.self, "/api/assistant/admin/conversations/2/messages");
122
+ assert.equal(normalized.meta.page, 1);
123
+ });
124
+
125
+ test("simplifyJsonApiDocument keeps flat-record behavior for resource and collection documents", () => {
126
+ assert.deepEqual(
127
+ simplifyJsonApiDocument({
128
+ data: {
129
+ type: "contacts",
130
+ id: "3",
131
+ attributes: {
132
+ name: "Tony",
133
+ subscribed: true
134
+ }
135
+ }
136
+ }),
137
+ {
138
+ id: "3",
139
+ name: "Tony",
140
+ subscribed: true
141
+ }
142
+ );
143
+
144
+ assert.deepEqual(
145
+ simplifyJsonApiDocument({
146
+ data: [
147
+ {
148
+ type: "contacts",
149
+ id: "3",
150
+ attributes: {
151
+ name: "Tony"
152
+ }
153
+ },
154
+ {
155
+ type: "contacts",
156
+ id: "4",
157
+ attributes: {
158
+ name: "Merc"
159
+ }
160
+ }
161
+ ]
162
+ }),
163
+ [
164
+ {
165
+ id: "3",
166
+ name: "Tony"
167
+ },
168
+ {
169
+ id: "4",
170
+ name: "Merc"
171
+ }
172
+ ]
173
+ );
174
+ });
175
+
176
+ test("createJsonApiErrorDocumentFromFailure maps field errors and validation issues to JSON:API errors", () => {
177
+ assert.deepEqual(
178
+ createJsonApiErrorDocumentFromFailure({
179
+ statusCode: 422,
180
+ code: "invalid_contact",
181
+ message: "Validation failed.",
182
+ fieldErrors: {
183
+ name: "Name is required."
184
+ }
185
+ }),
186
+ {
187
+ errors: [
188
+ {
189
+ status: "422",
190
+ code: "invalid_contact",
191
+ title: "Validation failed.",
192
+ detail: "Name is required.",
193
+ source: {
194
+ pointer: "/data/attributes/name"
195
+ }
196
+ }
197
+ ]
198
+ }
199
+ );
200
+
201
+ assert.deepEqual(
202
+ createJsonApiErrorDocumentFromFailure({
203
+ statusCode: 400,
204
+ code: "validation_failed",
205
+ message: "Validation failed.",
206
+ validationIssues: [
207
+ {
208
+ instancePath: "/data/attributes",
209
+ message: "must have required property 'name'",
210
+ params: {
211
+ missingProperty: "name"
212
+ }
213
+ }
214
+ ],
215
+ validationContext: "body"
216
+ }),
217
+ {
218
+ errors: [
219
+ {
220
+ status: "400",
221
+ code: "validation_failed",
222
+ title: "Validation failed.",
223
+ detail: "must have required property 'name'",
224
+ source: {
225
+ pointer: "/data/attributes/name"
226
+ }
227
+ }
228
+ ]
229
+ }
230
+ );
231
+ });