@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.
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
@@ -1,7 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { Type } from "@fastify/type-provider-typebox";
4
- import { Errors } from "typebox/value";
5
3
  import {
6
4
  mapOperationIssues,
7
5
  resolveFieldSchema,
@@ -10,62 +8,72 @@ import {
10
8
  resolveSchemaMessages
11
9
  } from "../src/shared/validators/operationMessages.js";
12
10
 
13
- const sampleSchema = Type.Object(
14
- {
15
- name: Type.String({
11
+ const sampleSchema = {
12
+ type: "object",
13
+ properties: {
14
+ name: {
15
+ type: "string",
16
16
  minLength: 1,
17
17
  messages: {
18
18
  required: "Workspace name is required.",
19
19
  minLength: "Workspace name is required.",
20
20
  default: "Invalid workspace name."
21
21
  }
22
- }),
23
- color: Type.String({
22
+ },
23
+ color: {
24
+ type: "string",
24
25
  pattern: "^#[0-9A-Fa-f]{6}$",
25
26
  messages: {
26
27
  pattern: "Workspace color must be a hex value."
27
28
  }
28
- }),
29
- invitesEnabled: Type.Boolean({
29
+ },
30
+ invitesEnabled: {
31
+ type: "boolean",
30
32
  messages: {
31
33
  default: "invitesEnabled must be true or false."
32
34
  }
33
- })
34
- },
35
- {
36
- additionalProperties: false,
37
- messages: {
38
- additionalProperties: "Unexpected field."
39
35
  }
36
+ },
37
+ additionalProperties: false,
38
+ messages: {
39
+ additionalProperties: "Unexpected field."
40
40
  }
41
- );
41
+ };
42
42
 
43
43
  test("resolveIssueField resolves missing and nested fields", () => {
44
- const missingIssues = [...Errors(sampleSchema, { color: "#0F6B54", invitesEnabled: true })];
45
- const requiredIssue = missingIssues.find((entry) => entry.keyword === "required");
44
+ const requiredIssue = {
45
+ keyword: "required",
46
+ params: {
47
+ missingProperty: "name",
48
+ requiredProperties: ["name"]
49
+ }
50
+ };
46
51
 
47
52
  assert.equal(resolveIssueField(requiredIssue), "name");
48
53
  assert.deepEqual(resolveMissingRequiredFields(requiredIssue), ["name"]);
49
54
 
50
- const nestedSchema = Type.Object(
51
- {
52
- profile: Type.Object(
53
- {
54
- displayName: Type.String({ minLength: 1 })
55
- },
56
- { additionalProperties: false }
57
- )
58
- },
59
- { additionalProperties: false }
60
- );
61
-
62
- const nestedIssues = [...Errors(nestedSchema, { profile: { displayName: "" } })];
63
- const minLengthIssue = nestedIssues.find((entry) => entry.keyword === "minLength");
55
+ const minLengthIssue = {
56
+ keyword: "minLength",
57
+ instancePath: "/profile/displayName"
58
+ };
64
59
  assert.equal(resolveIssueField(minLengthIssue), "profile");
65
60
  });
66
61
 
67
62
  test("mapOperationIssues applies field message overrides by keyword", () => {
68
- const issues = [...Errors(sampleSchema, { name: "", color: "oops", invitesEnabled: "yes" })];
63
+ const issues = [
64
+ {
65
+ keyword: "minLength",
66
+ instancePath: "/name"
67
+ },
68
+ {
69
+ keyword: "pattern",
70
+ instancePath: "/color"
71
+ },
72
+ {
73
+ keyword: "type",
74
+ instancePath: "/invitesEnabled"
75
+ }
76
+ ];
69
77
  const mapped = mapOperationIssues(issues, sampleSchema);
70
78
 
71
79
  assert.equal(mapped.fieldErrors.name, "Workspace name is required.");
@@ -75,36 +83,54 @@ test("mapOperationIssues applies field message overrides by keyword", () => {
75
83
  });
76
84
 
77
85
  test("mapOperationIssues falls back to keyword/global messages", () => {
78
- const issues = [...Errors(sampleSchema, { color: "#0F6B54", invitesEnabled: true, extra: "x" })];
86
+ const issues = [
87
+ {
88
+ keyword: "additionalProperties",
89
+ params: {
90
+ additionalProperty: "extra"
91
+ }
92
+ }
93
+ ];
79
94
  const mapped = mapOperationIssues(issues, sampleSchema);
80
95
 
81
96
  assert.equal(mapped.fieldErrors.extra, "Unexpected field.");
82
97
  });
83
98
 
84
99
  test("mapOperationIssues maps conditional schema failures to field errors", () => {
85
- const conditionalSchema = Type.Object(
86
- {
87
- isVaccinated: Type.Boolean(),
88
- adenovirusValidTo: Type.Optional(Type.String({ format: "date" }))
100
+ const conditionalSchema = {
101
+ type: "object",
102
+ properties: {
103
+ isVaccinated: {
104
+ type: "boolean"
105
+ },
106
+ adenovirusValidTo: {
107
+ type: "string"
108
+ }
89
109
  },
90
- {
91
- if: {
92
- properties: {
93
- isVaccinated: {
94
- const: true
95
- }
110
+ if: {
111
+ properties: {
112
+ isVaccinated: {
113
+ const: true
96
114
  }
97
- },
98
- then: {
99
- required: ["adenovirusValidTo"]
100
- },
101
- messages: {
102
- if: "Adenovirus valid-to date is required when vaccinated."
103
115
  }
116
+ },
117
+ then: {
118
+ required: ["adenovirusValidTo"]
119
+ },
120
+ messages: {
121
+ if: "Adenovirus valid-to date is required when vaccinated."
104
122
  }
105
- );
123
+ };
106
124
 
107
- const issues = [...Errors(conditionalSchema, { isVaccinated: true })];
125
+ const issues = [
126
+ {
127
+ keyword: "if",
128
+ schemaPath: "#",
129
+ params: {
130
+ failingKeyword: "then"
131
+ }
132
+ }
133
+ ];
108
134
  const mapped = mapOperationIssues(issues, conditionalSchema);
109
135
 
110
136
  assert.equal(mapped.fieldErrors.adenovirusValidTo, "Adenovirus valid-to date is required when vaccinated.");
@@ -112,24 +138,47 @@ test("mapOperationIssues maps conditional schema failures to field errors", () =
112
138
  });
113
139
 
114
140
  test("mapOperationIssues suppresses redundant root anyOf global issue when field errors exist", () => {
115
- const unionSchema = Type.Union([
116
- Type.Object(
141
+ const unionSchema = {
142
+ anyOf: [
117
143
  {
118
- kind: Type.Literal("dog"),
119
- bark: Type.String({ minLength: 1 })
144
+ type: "object",
145
+ properties: {
146
+ kind: {
147
+ const: "dog"
148
+ },
149
+ bark: {
150
+ type: "string",
151
+ minLength: 1
152
+ }
153
+ },
154
+ additionalProperties: false
120
155
  },
121
- { additionalProperties: false }
122
- ),
123
- Type.Object(
124
156
  {
125
- kind: Type.Literal("cat"),
126
- meow: Type.String({ minLength: 1 })
127
- },
128
- { additionalProperties: false }
129
- )
130
- ]);
157
+ type: "object",
158
+ properties: {
159
+ kind: {
160
+ const: "cat"
161
+ },
162
+ meow: {
163
+ type: "string",
164
+ minLength: 1
165
+ }
166
+ },
167
+ additionalProperties: false
168
+ }
169
+ ]
170
+ };
131
171
 
132
- const issues = [...Errors(unionSchema, { kind: "dog", bark: "" })];
172
+ const issues = [
173
+ {
174
+ keyword: "minLength",
175
+ instancePath: "/bark"
176
+ },
177
+ {
178
+ keyword: "anyOf",
179
+ schemaPath: "#"
180
+ }
181
+ ];
133
182
  const mapped = mapOperationIssues(issues, unionSchema);
134
183
 
135
184
  assert.equal(typeof mapped.fieldErrors.bark, "string");
@@ -1,67 +1,46 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { Type } from "@fastify/type-provider-typebox";
3
+ import { createSchema } from "json-rest-schema";
4
+
4
5
  import {
5
6
  validateOperationInput,
6
7
  validateOperationSection
7
8
  } from "../src/shared/validators/operationValidation.js";
8
9
 
9
- const patchSchema = Type.Object(
10
- {
11
- name: Type.Optional(
12
- Type.String({
10
+ const patchOperation = Object.freeze({
11
+ method: "PATCH",
12
+ body: {
13
+ schema: createSchema({
14
+ name: {
15
+ type: "string",
13
16
  minLength: 1,
14
17
  maxLength: 160,
15
18
  messages: {
16
19
  minLength: "Workspace name is required."
17
20
  }
18
- })
19
- ),
20
- color: Type.Optional(
21
- Type.String({
21
+ },
22
+ color: {
23
+ type: "string",
22
24
  pattern: "^#[0-9A-Fa-f]{6}$",
23
25
  messages: {
24
26
  pattern: "Workspace color must be a hex value."
25
27
  }
26
- })
27
- ),
28
- invitesEnabled: Type.Optional(Type.Boolean())
29
- },
30
- {
31
- additionalProperties: false,
32
- minProperties: 1,
33
- messages: {
34
- additionalProperties: "Unexpected field."
35
- }
36
- }
37
- );
38
-
39
- const patchOperation = Object.freeze({
40
- method: "PATCH",
41
- bodyValidator: {
42
- schema: patchSchema,
43
- normalize: (value) => {
44
- if (!value || typeof value !== "object" || Array.isArray(value)) {
45
- return {};
46
- }
47
-
48
- const normalized = {
49
- ...value
50
- };
51
-
52
- if (Object.hasOwn(normalized, "name")) {
53
- normalized.name = String(normalized.name || "").trim();
28
+ },
29
+ invitesEnabled: {
30
+ type: "boolean",
31
+ strictBoolean: true,
32
+ messages: {
33
+ default: "invitesEnabled must be a boolean."
34
+ }
54
35
  }
55
-
56
- return normalized;
57
- }
36
+ })
58
37
  }
59
38
  });
60
39
 
61
- test("validateOperationSection normalizes and validates one section", () => {
40
+ test("validateOperationSection validates one section and returns normalized json-rest-schema output", () => {
62
41
  const parsed = validateOperationSection({
63
42
  operation: patchOperation,
64
- section: "bodyValidator",
43
+ section: "body",
65
44
  value: {
66
45
  name: " Acme ",
67
46
  color: "#0F6B54"
@@ -76,7 +55,7 @@ test("validateOperationSection normalizes and validates one section", () => {
76
55
  test("validateOperationSection returns shared field errors", () => {
77
56
  const parsed = validateOperationSection({
78
57
  operation: patchOperation,
79
- section: "bodyValidator",
58
+ section: "body",
80
59
  value: {
81
60
  name: "",
82
61
  color: "bad",
@@ -87,135 +66,129 @@ test("validateOperationSection returns shared field errors", () => {
87
66
  assert.equal(parsed.ok, false);
88
67
  assert.equal(parsed.fieldErrors.name, "Workspace name is required.");
89
68
  assert.equal(parsed.fieldErrors.color, "Workspace color must be a hex value.");
90
- assert.equal(parsed.fieldErrors.rogueField, "Unexpected field.");
69
+ assert.equal(typeof parsed.fieldErrors.rogueField, "string");
91
70
  });
92
71
 
93
- test("validateOperationSection converts normalizer throws into validation result", () => {
94
- const operationWithThrowingNormalizer = Object.freeze({
95
- method: "PATCH",
96
- bodyValidator: {
97
- schema: Type.Object(
98
- {
99
- temperament: Type.String({
100
- enum: ["calm", "playful"]
101
- })
72
+ test("validateOperationSection honors json-rest-schema field message overrides", () => {
73
+ const operation = Object.freeze({
74
+ method: "POST",
75
+ body: {
76
+ schema: createSchema({
77
+ name: {
78
+ type: "string",
79
+ required: true,
80
+ messages: {
81
+ required: "Workspace name is required."
82
+ }
102
83
  },
103
- {
104
- additionalProperties: false
105
- }
106
- ),
107
- normalize(value) {
108
- if (value?.temperament === "unknowne") {
109
- throw new Error("Invalid pet temperament \"unknowne\".");
84
+ invitesEnabled: {
85
+ type: "boolean",
86
+ required: true,
87
+ strictBoolean: true,
88
+ messages: {
89
+ default: "invitesEnabled must be a boolean."
90
+ }
110
91
  }
111
-
112
- return value;
113
- }
92
+ }),
93
+ mode: "create"
114
94
  }
115
95
  });
116
96
 
117
- const parsed = validateOperationSection({
118
- operation: operationWithThrowingNormalizer,
119
- section: "bodyValidator",
97
+ const missingFieldParsed = validateOperationSection({
98
+ operation,
99
+ section: "body",
100
+ value: {}
101
+ });
102
+ assert.equal(missingFieldParsed.ok, false);
103
+ assert.equal(missingFieldParsed.fieldErrors.name, "Workspace name is required.");
104
+
105
+ const strictBooleanParsed = validateOperationSection({
106
+ operation,
107
+ section: "body",
120
108
  value: {
121
- temperament: "unknowne"
109
+ name: "Acme",
110
+ invitesEnabled: "yes"
122
111
  }
123
112
  });
124
-
125
- assert.equal(parsed.ok, false);
126
- assert.equal(typeof parsed.fieldErrors.temperament, "string");
113
+ assert.equal(strictBooleanParsed.ok, false);
114
+ assert.equal(strictBooleanParsed.fieldErrors.invitesEnabled, "invitesEnabled must be a boolean.");
127
115
  });
128
116
 
129
- test("validateOperationSection prefers explicit thrown fieldErrors over raw fallback issues", () => {
130
- const operationWithFieldScopedThrow = Object.freeze({
117
+ test("validateOperationSection returns field errors for invalid enum values", () => {
118
+ const operationWithEnumConstraint = Object.freeze({
131
119
  method: "PATCH",
132
- bodyValidator: {
133
- schema: Type.Object(
134
- {
135
- temperament: Type.String({
136
- enum: ["calm", "playful"]
137
- }),
138
- photoUpdatedAt: Type.Union([
139
- Type.String({
140
- format: "date-time",
141
- minLength: 1
142
- }),
143
- Type.Null()
144
- ]),
145
- adenovirusValidTo: Type.Union([
146
- Type.String({
147
- format: "date",
148
- minLength: 1
149
- }),
150
- Type.Null()
151
- ])
152
- },
153
- {
154
- additionalProperties: false
120
+ body: {
121
+ schema: createSchema({
122
+ temperament: {
123
+ type: "string",
124
+ enum: ["calm", "playful"],
125
+ required: true
155
126
  }
156
- ),
157
- normalize() {
158
- const error = new Error("Invalid pet temperament \"unknowne\".");
159
- error.details = {
160
- fieldErrors: {
161
- temperament: "Invalid pet temperament \"unknowne\"."
162
- }
163
- };
164
- throw error;
165
- }
127
+ }),
128
+ mode: "patch"
166
129
  }
167
130
  });
168
131
 
169
132
  const parsed = validateOperationSection({
170
- operation: operationWithFieldScopedThrow,
171
- section: "bodyValidator",
133
+ operation: operationWithEnumConstraint,
134
+ section: "body",
172
135
  value: {
173
- temperament: "unknowne",
174
- photoUpdatedAt: "",
175
- adenovirusValidTo: ""
136
+ temperament: "unknowne"
176
137
  }
177
138
  });
178
139
 
179
140
  assert.equal(parsed.ok, false);
180
- assert.deepEqual(parsed.fieldErrors, {
181
- temperament: "Invalid pet temperament \"unknowne\"."
182
- });
183
- assert.deepEqual(parsed.globalErrors, []);
141
+ assert.equal(typeof parsed.fieldErrors.temperament, "string");
184
142
  });
185
143
 
186
- test("validateOperationSection maps conditional validation failures to field errors", () => {
144
+ test("validateOperationSection rethrows malformed operation contracts", () => {
145
+ assert.throws(
146
+ () =>
147
+ validateOperationSection({
148
+ operation: {
149
+ body: {
150
+ schema: null,
151
+ mode: "patch"
152
+ }
153
+ },
154
+ section: "body",
155
+ value: {}
156
+ }),
157
+ /must be a json-rest-schema schema instance/
158
+ );
159
+ });
160
+
161
+ test("validateOperationSection surfaces custom validator failures as field errors", () => {
187
162
  const operationWithConditionalConstraint = Object.freeze({
188
163
  method: "PATCH",
189
- bodyValidator: {
190
- schema: Type.Object(
191
- {
192
- isVaccinated: Type.Boolean(),
193
- adenovirusValidTo: Type.Optional(Type.String({ format: "date" }))
164
+ body: {
165
+ schema: createSchema({
166
+ isVaccinated: {
167
+ type: "boolean",
168
+ strictBoolean: true,
169
+ required: false
194
170
  },
195
- {
196
- if: {
197
- properties: {
198
- isVaccinated: {
199
- const: true
200
- }
171
+ adenovirusValidTo: {
172
+ type: "string",
173
+ required: false,
174
+ validator(value, object = {}) {
175
+ if (object.isVaccinated === true && !String(value || "").trim()) {
176
+ return "Adenovirus valid-to date is required when vaccinated.";
201
177
  }
202
- },
203
- then: {
204
- required: ["adenovirusValidTo"]
205
- },
206
- messages: {
207
- if: "Adenovirus valid-to date is required when vaccinated."
178
+ return undefined;
208
179
  }
209
180
  }
210
- )
181
+ }),
182
+ mode: "patch"
211
183
  }
212
184
  });
213
185
 
214
186
  const parsed = validateOperationSection({
215
187
  operation: operationWithConditionalConstraint,
216
- section: "bodyValidator",
188
+ section: "body",
217
189
  value: {
218
- isVaccinated: true
190
+ isVaccinated: true,
191
+ adenovirusValidTo: ""
219
192
  }
220
193
  });
221
194
 
@@ -227,30 +200,17 @@ test("validateOperationSection maps conditional validation failures to field err
227
200
  test("validateOperationInput validates params/query/body together", () => {
228
201
  const viewOperation = Object.freeze({
229
202
  method: "GET",
230
- paramsValidator: {
231
- schema: Type.Object(
232
- {
233
- workspaceSlug: Type.String({ minLength: 1 })
234
- },
235
- { additionalProperties: false }
236
- )
203
+ params: {
204
+ schema: createSchema({
205
+ workspaceSlug: { type: "string", required: true, minLength: 1 }
206
+ }),
207
+ mode: "patch"
237
208
  },
238
- queryValidator: {
239
- schema: Type.Object(
240
- {
241
- includeArchived: Type.Optional(Type.Boolean())
242
- },
243
- { additionalProperties: false }
244
- ),
245
- normalize: (value) => {
246
- if (!value || typeof value !== "object") {
247
- return {};
248
- }
249
-
250
- return {
251
- includeArchived: value.includeArchived === true
252
- };
253
- }
209
+ query: {
210
+ schema: createSchema({
211
+ includeArchived: { type: "boolean", strictBoolean: true }
212
+ }),
213
+ mode: "patch"
254
214
  }
255
215
  });
256
216
 
@@ -266,6 +226,34 @@ test("validateOperationInput validates params/query/body together", () => {
266
226
 
267
227
  assert.equal(parsed.ok, true);
268
228
  assert.equal(parsed.value.params.workspaceSlug, "acme");
269
- assert.equal(parsed.value.query.includeArchived, false);
229
+ assert.deepEqual(parsed.value.query, {});
270
230
  assert.equal(parsed.value.body, undefined);
271
231
  });
232
+
233
+ test("validateOperationInput collects json-rest-schema field errors", () => {
234
+ const parsed = validateOperationInput({
235
+ operation: {
236
+ body: {
237
+ schema: createSchema({
238
+ name: {
239
+ type: "string",
240
+ required: true,
241
+ minLength: 1,
242
+ messages: {
243
+ minLength: "Name is required."
244
+ }
245
+ }
246
+ }),
247
+ mode: "patch"
248
+ }
249
+ },
250
+ input: {
251
+ body: {
252
+ name: " "
253
+ }
254
+ }
255
+ });
256
+
257
+ assert.equal(parsed.ok, false);
258
+ assert.equal(parsed.fieldErrors.name, "Name is required.");
259
+ });
@@ -3,30 +3,26 @@ import test from "node:test";
3
3
 
4
4
  import { createPaginationQuerySchema } from "../src/shared/validators/paginationQuery.js";
5
5
 
6
- test("createPaginationQuerySchema uses expected defaults", () => {
7
- const schema = createPaginationQuerySchema();
6
+ test("createPaginationQuerySchema exports the expected transport bounds", () => {
7
+ const schema = createPaginationQuerySchema().toJsonSchema({ mode: "patch" });
8
8
 
9
9
  assert.equal(schema.type, "object");
10
10
  assert.equal(schema.additionalProperties, false);
11
11
  assert.equal(schema.properties.page.minimum, 1);
12
- assert.equal(schema.properties.page.default, 1);
13
12
  assert.equal(schema.properties.pageSize.minimum, 1);
14
13
  assert.equal(schema.properties.pageSize.maximum, 100);
15
- assert.equal(schema.properties.pageSize.default, 10);
16
14
  });
17
15
 
18
- test("createPaginationQuerySchema applies custom bounds and defaults", () => {
16
+ test("createPaginationQuerySchema applies custom transport bounds", () => {
19
17
  const schema = createPaginationQuerySchema({
20
18
  defaultPage: 2,
21
19
  defaultPageSize: 25,
22
20
  minPage: 2,
23
21
  minPageSize: 5,
24
22
  maxPageSize: 250
25
- });
23
+ }).toJsonSchema({ mode: "patch" });
26
24
 
27
25
  assert.equal(schema.properties.page.minimum, 2);
28
- assert.equal(schema.properties.page.default, 2);
29
26
  assert.equal(schema.properties.pageSize.minimum, 5);
30
27
  assert.equal(schema.properties.pageSize.maximum, 250);
31
- assert.equal(schema.properties.pageSize.default, 25);
32
28
  });