@jskit-ai/http-runtime 0.1.21 → 0.1.23

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  "packageVersion": 1,
3
3
  "packageId": "@jskit-ai/http-runtime",
4
- "version": "0.1.21",
4
+ "version": "0.1.23",
5
5
  "kind": "runtime",
6
6
  "dependsOn": [],
7
7
  "capabilities": {
@@ -67,7 +67,7 @@ export default Object.freeze({
67
67
  "mutations": {
68
68
  "dependencies": {
69
69
  "runtime": {
70
- "@jskit-ai/kernel": "0.1.22",
70
+ "@jskit-ai/kernel": "0.1.24",
71
71
  "@fastify/type-provider-typebox": "^6.1.0",
72
72
  "typebox": "^1.0.81"
73
73
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/http-runtime",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -17,7 +17,7 @@
17
17
  "./shared/validators/operationValidation": "./src/shared/validators/operationValidation.js"
18
18
  },
19
19
  "dependencies": {
20
- "@jskit-ai/kernel": "0.1.22",
20
+ "@jskit-ai/kernel": "0.1.24",
21
21
  "@fastify/type-provider-typebox": "^6.1.0",
22
22
  "typebox": "^1.0.81"
23
23
  }
@@ -42,6 +42,171 @@ function resolveIssueField(issue = {}) {
42
42
  return "";
43
43
  }
44
44
 
45
+ function decodePointerSegment(value = "") {
46
+ return String(value).replace(/~1/g, "/").replace(/~0/g, "~");
47
+ }
48
+
49
+ function resolveSchemaPathSegments(schemaPath = "") {
50
+ const normalizedSchemaPath = normalizeText(schemaPath);
51
+ if (!normalizedSchemaPath.startsWith("#")) {
52
+ return [];
53
+ }
54
+
55
+ const pointer = normalizedSchemaPath.slice(1);
56
+ if (!pointer) {
57
+ return [];
58
+ }
59
+
60
+ return pointer
61
+ .replace(/^\//, "")
62
+ .split("/")
63
+ .map((segment) => decodePointerSegment(segment))
64
+ .filter((segment) => segment.length > 0);
65
+ }
66
+
67
+ function resolveFieldFromSchemaPath(schemaPath = "") {
68
+ const segments = resolveSchemaPathSegments(schemaPath);
69
+ for (let index = 0; index < segments.length - 1; index += 1) {
70
+ if (segments[index] !== "properties") {
71
+ continue;
72
+ }
73
+
74
+ const field = normalizeText(segments[index + 1]);
75
+ if (field) {
76
+ return field;
77
+ }
78
+ }
79
+
80
+ return "";
81
+ }
82
+
83
+ function resolveSchemaNode(schema = {}, schemaPath = "") {
84
+ const source = isRecord(schema) ? schema : {};
85
+ const segments = resolveSchemaPathSegments(schemaPath);
86
+ let current = source;
87
+
88
+ for (const segment of segments) {
89
+ if (Array.isArray(current)) {
90
+ const index = Number.parseInt(segment, 10);
91
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
92
+ return null;
93
+ }
94
+ current = current[index];
95
+ continue;
96
+ }
97
+
98
+ if (!isRecord(current) || !Object.hasOwn(current, segment)) {
99
+ return null;
100
+ }
101
+ current = current[segment];
102
+ }
103
+
104
+ return current;
105
+ }
106
+
107
+ function collectSchemaFieldCandidates(schema = {}) {
108
+ if (!isRecord(schema)) {
109
+ return [];
110
+ }
111
+
112
+ const fields = [];
113
+ const seenFields = new Set();
114
+ const addField = (value) => {
115
+ const normalized = normalizeText(value);
116
+ if (!normalized || seenFields.has(normalized)) {
117
+ return;
118
+ }
119
+
120
+ seenFields.add(normalized);
121
+ fields.push(normalized);
122
+ };
123
+
124
+ const properties = isRecord(schema.properties) ? schema.properties : {};
125
+ for (const field of Object.keys(properties)) {
126
+ addField(field);
127
+ }
128
+
129
+ const required = Array.isArray(schema.required) ? schema.required : [];
130
+ for (const field of required) {
131
+ addField(field);
132
+ }
133
+
134
+ const addFromSchema = (entry) => {
135
+ if (!isRecord(entry)) {
136
+ return;
137
+ }
138
+
139
+ const entryProperties = isRecord(entry.properties) ? entry.properties : {};
140
+ for (const field of Object.keys(entryProperties)) {
141
+ addField(field);
142
+ }
143
+
144
+ const entryRequired = Array.isArray(entry.required) ? entry.required : [];
145
+ for (const field of entryRequired) {
146
+ addField(field);
147
+ }
148
+ };
149
+
150
+ for (const keyword of ["anyOf", "oneOf", "allOf"]) {
151
+ const branches = Array.isArray(schema[keyword]) ? schema[keyword] : [];
152
+ for (const branch of branches) {
153
+ addFromSchema(branch);
154
+ }
155
+ }
156
+
157
+ for (const keyword of ["if", "then", "else"]) {
158
+ addFromSchema(schema[keyword]);
159
+ }
160
+
161
+ return fields;
162
+ }
163
+
164
+ function resolveConditionalIssueField(issue = {}, schema = {}) {
165
+ const params = isRecord(issue.params) ? issue.params : {};
166
+ const failingKeyword = normalizeText(params.failingKeyword).toLowerCase();
167
+ if (failingKeyword !== "then" && failingKeyword !== "else") {
168
+ return "";
169
+ }
170
+
171
+ const scopeSchema = resolveSchemaNode(schema, issue.schemaPath) || schema;
172
+ if (!isRecord(scopeSchema)) {
173
+ return "";
174
+ }
175
+
176
+ const conditionalSchema = scopeSchema[failingKeyword];
177
+ const candidates = collectSchemaFieldCandidates(conditionalSchema);
178
+ return candidates[0] || "";
179
+ }
180
+
181
+ function resolveFallbackIssueField(issue = {}, schema = {}) {
182
+ const fromSchemaPath = resolveFieldFromSchemaPath(issue.schemaPath);
183
+ if (fromSchemaPath) {
184
+ return fromSchemaPath;
185
+ }
186
+
187
+ const keyword = normalizeText(issue.keyword).toLowerCase();
188
+ if (keyword === "if") {
189
+ return resolveConditionalIssueField(issue, schema);
190
+ }
191
+
192
+ return "";
193
+ }
194
+
195
+ function shouldSuppressRootUnionGlobalIssue(issue = {}, fieldErrors = {}) {
196
+ if (Object.keys(fieldErrors).length < 1) {
197
+ return false;
198
+ }
199
+
200
+ const keyword = normalizeText(issue.keyword).toLowerCase();
201
+ if (keyword !== "anyof" && keyword !== "oneof" && keyword !== "allof") {
202
+ return false;
203
+ }
204
+
205
+ const instancePath = normalizeText(issue.instancePath);
206
+ const schemaPath = normalizeText(issue.schemaPath);
207
+ return !instancePath && (schemaPath === "#" || schemaPath === "#/");
208
+ }
209
+
45
210
  function resolveMissingRequiredFields(issue = {}) {
46
211
  const params = isRecord(issue.params) ? issue.params : {};
47
212
  const requiredProperties = Array.isArray(params.requiredProperties)
@@ -122,7 +287,7 @@ function mapOperationIssues(issues = [], schema = {}) {
122
287
  continue;
123
288
  }
124
289
 
125
- const field = resolveIssueField(issue);
290
+ const field = resolveIssueField(issue) || resolveFallbackIssueField(issue, schema);
126
291
  if (field) {
127
292
  if (!Object.hasOwn(fieldErrors, field)) {
128
293
  fieldErrors[field] = resolveIssueMessageFromSchema(field, issue, schema);
@@ -130,6 +295,10 @@ function mapOperationIssues(issues = [], schema = {}) {
130
295
  continue;
131
296
  }
132
297
 
298
+ if (shouldSuppressRootUnionGlobalIssue(issue, fieldErrors)) {
299
+ continue;
300
+ }
301
+
133
302
  globalErrors.push(resolveIssueMessageFromSchema("", issue, schema));
134
303
  }
135
304
 
@@ -1,5 +1,6 @@
1
1
  import { Check, Errors } from "typebox/value";
2
2
  import { mapOperationIssues } from "./operationMessages.js";
3
+ import { resolveFieldErrors } from "../support/fieldErrors.js";
3
4
  import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
4
5
 
5
6
  function defaultNormalize(value) {
@@ -49,7 +50,47 @@ function validateOperationSection({
49
50
  ? sectionDefinition.normalize
50
51
  : defaultNormalize;
51
52
 
52
- const normalized = normalize(value, context);
53
+ let normalized = null;
54
+ try {
55
+ normalized = normalize(value, context);
56
+ } catch (error) {
57
+ const explicitFieldErrors = resolveFieldErrors(error);
58
+ if (Object.keys(explicitFieldErrors).length > 0) {
59
+ return {
60
+ ok: false,
61
+ value: null,
62
+ normalized: value,
63
+ fieldErrors: explicitFieldErrors,
64
+ globalErrors: [],
65
+ issues: []
66
+ };
67
+ }
68
+
69
+ // If normalization throws, still surface field-level schema issues when possible.
70
+ const fallbackIssues = Check(schema, value) ? [] : [...Errors(schema, value)];
71
+ if (fallbackIssues.length > 0) {
72
+ const mapped = mapOperationIssues(fallbackIssues, schema);
73
+ return {
74
+ ok: false,
75
+ value: null,
76
+ normalized: value,
77
+ fieldErrors: mapped.fieldErrors,
78
+ globalErrors: mapped.globalErrors,
79
+ issues: fallbackIssues
80
+ };
81
+ }
82
+
83
+ const fallbackMessage = String(error?.message || "Invalid value.").trim() || "Invalid value.";
84
+ return {
85
+ ok: false,
86
+ value: null,
87
+ normalized: value,
88
+ fieldErrors: {},
89
+ globalErrors: [fallbackMessage],
90
+ issues: []
91
+ };
92
+ }
93
+
53
94
  const issues = Check(schema, normalized) ? [] : [...Errors(schema, normalized)];
54
95
  const mapped = mapOperationIssues(issues, schema);
55
96
 
@@ -81,6 +81,61 @@ test("mapOperationIssues falls back to keyword/global messages", () => {
81
81
  assert.equal(mapped.fieldErrors.extra, "Unexpected field.");
82
82
  });
83
83
 
84
+ 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" }))
89
+ },
90
+ {
91
+ if: {
92
+ properties: {
93
+ isVaccinated: {
94
+ const: true
95
+ }
96
+ }
97
+ },
98
+ then: {
99
+ required: ["adenovirusValidTo"]
100
+ },
101
+ messages: {
102
+ if: "Adenovirus valid-to date is required when vaccinated."
103
+ }
104
+ }
105
+ );
106
+
107
+ const issues = [...Errors(conditionalSchema, { isVaccinated: true })];
108
+ const mapped = mapOperationIssues(issues, conditionalSchema);
109
+
110
+ assert.equal(mapped.fieldErrors.adenovirusValidTo, "Adenovirus valid-to date is required when vaccinated.");
111
+ assert.deepEqual(mapped.globalErrors, []);
112
+ });
113
+
114
+ test("mapOperationIssues suppresses redundant root anyOf global issue when field errors exist", () => {
115
+ const unionSchema = Type.Union([
116
+ Type.Object(
117
+ {
118
+ kind: Type.Literal("dog"),
119
+ bark: Type.String({ minLength: 1 })
120
+ },
121
+ { additionalProperties: false }
122
+ ),
123
+ Type.Object(
124
+ {
125
+ kind: Type.Literal("cat"),
126
+ meow: Type.String({ minLength: 1 })
127
+ },
128
+ { additionalProperties: false }
129
+ )
130
+ ]);
131
+
132
+ const issues = [...Errors(unionSchema, { kind: "dog", bark: "" })];
133
+ const mapped = mapOperationIssues(issues, unionSchema);
134
+
135
+ assert.equal(typeof mapped.fieldErrors.bark, "string");
136
+ assert.deepEqual(mapped.globalErrors, []);
137
+ });
138
+
84
139
  test("schema message helpers resolve field and root messages", () => {
85
140
  const nameSchema = resolveFieldSchema(sampleSchema, "name");
86
141
  assert.equal(typeof nameSchema, "object");
@@ -90,6 +90,140 @@ test("validateOperationSection returns shared field errors", () => {
90
90
  assert.equal(parsed.fieldErrors.rogueField, "Unexpected field.");
91
91
  });
92
92
 
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
+ })
102
+ },
103
+ {
104
+ additionalProperties: false
105
+ }
106
+ ),
107
+ normalize(value) {
108
+ if (value?.temperament === "unknowne") {
109
+ throw new Error("Invalid pet temperament \"unknowne\".");
110
+ }
111
+
112
+ return value;
113
+ }
114
+ }
115
+ });
116
+
117
+ const parsed = validateOperationSection({
118
+ operation: operationWithThrowingNormalizer,
119
+ section: "bodyValidator",
120
+ value: {
121
+ temperament: "unknowne"
122
+ }
123
+ });
124
+
125
+ assert.equal(parsed.ok, false);
126
+ assert.equal(typeof parsed.fieldErrors.temperament, "string");
127
+ });
128
+
129
+ test("validateOperationSection prefers explicit thrown fieldErrors over raw fallback issues", () => {
130
+ const operationWithFieldScopedThrow = Object.freeze({
131
+ 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
155
+ }
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
+ }
166
+ }
167
+ });
168
+
169
+ const parsed = validateOperationSection({
170
+ operation: operationWithFieldScopedThrow,
171
+ section: "bodyValidator",
172
+ value: {
173
+ temperament: "unknowne",
174
+ photoUpdatedAt: "",
175
+ adenovirusValidTo: ""
176
+ }
177
+ });
178
+
179
+ assert.equal(parsed.ok, false);
180
+ assert.deepEqual(parsed.fieldErrors, {
181
+ temperament: "Invalid pet temperament \"unknowne\"."
182
+ });
183
+ assert.deepEqual(parsed.globalErrors, []);
184
+ });
185
+
186
+ test("validateOperationSection maps conditional validation failures to field errors", () => {
187
+ const operationWithConditionalConstraint = Object.freeze({
188
+ method: "PATCH",
189
+ bodyValidator: {
190
+ schema: Type.Object(
191
+ {
192
+ isVaccinated: Type.Boolean(),
193
+ adenovirusValidTo: Type.Optional(Type.String({ format: "date" }))
194
+ },
195
+ {
196
+ if: {
197
+ properties: {
198
+ isVaccinated: {
199
+ const: true
200
+ }
201
+ }
202
+ },
203
+ then: {
204
+ required: ["adenovirusValidTo"]
205
+ },
206
+ messages: {
207
+ if: "Adenovirus valid-to date is required when vaccinated."
208
+ }
209
+ }
210
+ )
211
+ }
212
+ });
213
+
214
+ const parsed = validateOperationSection({
215
+ operation: operationWithConditionalConstraint,
216
+ section: "bodyValidator",
217
+ value: {
218
+ isVaccinated: true
219
+ }
220
+ });
221
+
222
+ assert.equal(parsed.ok, false);
223
+ assert.equal(parsed.fieldErrors.adenovirusValidTo, "Adenovirus valid-to date is required when vaccinated.");
224
+ assert.deepEqual(parsed.globalErrors, []);
225
+ });
226
+
93
227
  test("validateOperationInput validates params/query/body together", () => {
94
228
  const viewOperation = Object.freeze({
95
229
  method: "GET",