@jskit-ai/http-runtime 0.1.20 → 0.1.22
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
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.1.22",
|
|
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.
|
|
70
|
+
"@jskit-ai/kernel": "0.1.23",
|
|
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.
|
|
3
|
+
"version": "0.1.22",
|
|
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.
|
|
20
|
+
"@jskit-ai/kernel": "0.1.23",
|
|
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
|
-
|
|
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",
|