@jskit-ai/http-runtime 0.1.4
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 +83 -0
- package/package.json +24 -0
- package/src/client/index.js +14 -0
- package/src/client/providers/HttpClientRuntimeClientProvider.js +21 -0
- package/src/client/providers/HttpValidatorsClientProvider.js +14 -0
- package/src/client/validationErrors.js +23 -0
- package/src/server/providers/HttpClientRuntimeServiceProvider.js +21 -0
- package/src/server/providers/HttpValidatorsServiceProvider.js +14 -0
- package/src/shared/clientRuntime/client.js +632 -0
- package/src/shared/clientRuntime/errors.js +35 -0
- package/src/shared/clientRuntime/headers.js +28 -0
- package/src/shared/clientRuntime/index.js +4 -0
- package/src/shared/clientRuntime/retry.js +38 -0
- package/src/shared/index.js +30 -0
- package/src/shared/providers/singletonApiProvider.js +27 -0
- package/src/shared/support/fieldErrors.js +31 -0
- package/src/shared/validators/command.js +34 -0
- package/src/shared/validators/errorResponses.js +108 -0
- package/src/shared/validators/httpValidatorsApi.js +58 -0
- package/src/shared/validators/operationMessages.js +149 -0
- package/src/shared/validators/operationValidation.js +126 -0
- package/src/shared/validators/paginationQuery.js +32 -0
- package/src/shared/validators/resource.js +43 -0
- package/src/shared/validators/schemaUtils.js +9 -0
- package/src/shared/validators/typeboxFormats.js +43 -0
- package/test/client.test.js +246 -0
- package/test/command.test.js +49 -0
- package/test/entrypoints.boundary.test.js +36 -0
- package/test/errorResponses.test.js +84 -0
- package/test/operationMessages.test.js +93 -0
- package/test/operationValidation.test.js +137 -0
- package/test/paginationQuery.test.js +32 -0
- package/test/providerRuntime.httpClient.test.js +35 -0
- package/test/providerRuntime.validators.test.js +39 -0
- package/test/resource.test.js +94 -0
- package/test/retry.test.js +41 -0
- package/test/typeboxFormats.test.js +42 -0
- package/test/validationErrors.test.js +100 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Format } from "typebox/format";
|
|
2
|
+
|
|
3
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
4
|
+
const ISO_UTC_DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
5
|
+
|
|
6
|
+
function isStrictUuid(value) {
|
|
7
|
+
return typeof value === "string" && UUID_PATTERN.test(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isStrictIsoUtcDateTime(value) {
|
|
11
|
+
if (typeof value !== "string" || !ISO_UTC_DATE_TIME_PATTERN.test(value)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parsed = new Date(value);
|
|
16
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return parsed.toISOString() === value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function registerTypeBoxFormats() {
|
|
24
|
+
return registerTypeBoxFormatsWith(Format);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function registerTypeBoxFormatsWith(formatRegistry) {
|
|
28
|
+
if (!formatRegistry.Has("uuid")) {
|
|
29
|
+
formatRegistry.Set("uuid", isStrictUuid);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!formatRegistry.Has("iso-utc-date-time")) {
|
|
33
|
+
formatRegistry.Set("iso-utc-date-time", isStrictIsoUtcDateTime);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const __testables = {
|
|
38
|
+
isStrictUuid,
|
|
39
|
+
isStrictIsoUtcDateTime,
|
|
40
|
+
registerTypeBoxFormatsWith
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export { registerTypeBoxFormats, __testables };
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { createHttpClient } from "../src/shared/clientRuntime/client.js";
|
|
5
|
+
|
|
6
|
+
function mockResponse({ status = 200, data = {}, contentType = "application/json; charset=utf-8", text = "" } = {}) {
|
|
7
|
+
return {
|
|
8
|
+
ok: status >= 200 && status < 300,
|
|
9
|
+
status,
|
|
10
|
+
headers: {
|
|
11
|
+
get(name) {
|
|
12
|
+
return String(name || "").toLowerCase() === "content-type" ? contentType : "";
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
async json() {
|
|
16
|
+
return data;
|
|
17
|
+
},
|
|
18
|
+
async text() {
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("request serializes json body and injects csrf token for unsafe methods", async () => {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const fetchImpl = async (url, options) => {
|
|
27
|
+
calls.push([url, options]);
|
|
28
|
+
if (url === "/api/session") {
|
|
29
|
+
return mockResponse({
|
|
30
|
+
data: {
|
|
31
|
+
csrfToken: "csrf-a"
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return mockResponse({
|
|
37
|
+
data: {
|
|
38
|
+
ok: true
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const client = createHttpClient({ fetchImpl });
|
|
44
|
+
const payload = await client.request("/api/custom", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
body: {
|
|
47
|
+
demo: true
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(payload, { ok: true });
|
|
52
|
+
assert.equal(calls.length, 2);
|
|
53
|
+
assert.equal(calls[0][0], "/api/session");
|
|
54
|
+
assert.equal(calls[1][1].headers["csrf-token"], "csrf-a");
|
|
55
|
+
assert.equal(calls[1][1].headers["Content-Type"], "application/json");
|
|
56
|
+
assert.equal(calls[1][1].body, JSON.stringify({ demo: true }));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("request retries once on retryable csrf failure and preserves stateful headers", async () => {
|
|
60
|
+
const calls = [];
|
|
61
|
+
const fetchImpl = async (url, options) => {
|
|
62
|
+
calls.push([url, options]);
|
|
63
|
+
if (url === "/api/session") {
|
|
64
|
+
return mockResponse({
|
|
65
|
+
data: {
|
|
66
|
+
csrfToken: calls.length < 3 ? "csrf-1" : "csrf-2"
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (calls.length === 2) {
|
|
72
|
+
return mockResponse({
|
|
73
|
+
status: 403,
|
|
74
|
+
data: {
|
|
75
|
+
error: "forbidden",
|
|
76
|
+
details: {
|
|
77
|
+
code: "FST_CSRF_INVALID_TOKEN"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return mockResponse({
|
|
84
|
+
data: {
|
|
85
|
+
ok: true
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const client = createHttpClient({
|
|
91
|
+
fetchImpl,
|
|
92
|
+
hooks: {
|
|
93
|
+
decorateHeaders({ headers, state }) {
|
|
94
|
+
state.commandId = state.commandId || "cmd_shared";
|
|
95
|
+
headers["x-command-id"] = state.commandId;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const state = {};
|
|
101
|
+
const payload = await client.request(
|
|
102
|
+
"/api/workspace/projects/1",
|
|
103
|
+
{
|
|
104
|
+
method: "PATCH",
|
|
105
|
+
body: {
|
|
106
|
+
name: "Updated"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
state
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
assert.deepEqual(payload, { ok: true });
|
|
113
|
+
assert.equal(calls.length, 4);
|
|
114
|
+
assert.equal(calls[1][1].headers["x-command-id"], "cmd_shared");
|
|
115
|
+
assert.equal(calls[3][1].headers["x-command-id"], "cmd_shared");
|
|
116
|
+
assert.equal(calls[1][1].headers["csrf-token"], "csrf-1");
|
|
117
|
+
assert.equal(calls[3][1].headers["csrf-token"], "csrf-2");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("requestStream parses ndjson and supports fallback hook", async () => {
|
|
121
|
+
const encoder = new TextEncoder();
|
|
122
|
+
const response = {
|
|
123
|
+
ok: true,
|
|
124
|
+
status: 200,
|
|
125
|
+
headers: {
|
|
126
|
+
get(name) {
|
|
127
|
+
return String(name || "").toLowerCase() === "content-type" ? "text/plain; charset=utf-8" : "";
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
body: new ReadableStream({
|
|
131
|
+
start(controller) {
|
|
132
|
+
controller.enqueue(encoder.encode('{"type":"delta","value":"he"}\n'));
|
|
133
|
+
controller.enqueue(encoder.encode('{"type":"done"}\n'));
|
|
134
|
+
controller.close();
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
};
|
|
138
|
+
const fetchImpl = async () => response;
|
|
139
|
+
const seenEvents = [];
|
|
140
|
+
|
|
141
|
+
const client = createHttpClient({
|
|
142
|
+
fetchImpl,
|
|
143
|
+
hooks: {
|
|
144
|
+
shouldTreatAsNdjsonStream() {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await client.requestStream(
|
|
151
|
+
"/api/stream",
|
|
152
|
+
{
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: {
|
|
155
|
+
"csrf-token": "provided"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
onEvent(event) {
|
|
160
|
+
seenEvents.push(event);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(seenEvents, [
|
|
166
|
+
{ type: "delta", value: "he" },
|
|
167
|
+
{ type: "done" }
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("requestStream retries once on retryable csrf failure and preserves stateful headers", async () => {
|
|
172
|
+
const calls = [];
|
|
173
|
+
const seenEvents = [];
|
|
174
|
+
const fetchImpl = async (url, options) => {
|
|
175
|
+
calls.push([url, options]);
|
|
176
|
+
if (url === "/api/session") {
|
|
177
|
+
return mockResponse({
|
|
178
|
+
data: {
|
|
179
|
+
csrfToken: calls.length < 3 ? "csrf-stream-1" : "csrf-stream-2"
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (calls.length === 2) {
|
|
185
|
+
return mockResponse({
|
|
186
|
+
status: 403,
|
|
187
|
+
data: {
|
|
188
|
+
error: "forbidden",
|
|
189
|
+
details: {
|
|
190
|
+
code: "FST_CSRF_INVALID_TOKEN"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return mockResponse({
|
|
197
|
+
contentType: "application/x-ndjson",
|
|
198
|
+
text: '{"type":"done"}\n'
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const client = createHttpClient({
|
|
203
|
+
fetchImpl,
|
|
204
|
+
hooks: {
|
|
205
|
+
decorateHeaders({ headers, state }) {
|
|
206
|
+
state.commandId = state.commandId || "cmd_stream";
|
|
207
|
+
headers["x-command-id"] = state.commandId;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const state = {};
|
|
213
|
+
await client.requestStream(
|
|
214
|
+
"/api/workspace/stream",
|
|
215
|
+
{
|
|
216
|
+
method: "POST"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
onEvent(event) {
|
|
220
|
+
seenEvents.push(event);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
state
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
assert.equal(calls.length, 4);
|
|
227
|
+
assert.equal(calls[1][1].headers["x-command-id"], "cmd_stream");
|
|
228
|
+
assert.equal(calls[3][1].headers["x-command-id"], "cmd_stream");
|
|
229
|
+
assert.equal(calls[1][1].headers["csrf-token"], "csrf-stream-1");
|
|
230
|
+
assert.equal(calls[3][1].headers["csrf-token"], "csrf-stream-2");
|
|
231
|
+
assert.deepEqual(seenEvents, [{ type: "done" }]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("request maps network errors to normalized transport error", async () => {
|
|
235
|
+
const networkFailure = new Error("offline");
|
|
236
|
+
const fetchImpl = async () => {
|
|
237
|
+
throw networkFailure;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const client = createHttpClient({ fetchImpl });
|
|
241
|
+
|
|
242
|
+
await assert.rejects(
|
|
243
|
+
() => client.request("/api/session"),
|
|
244
|
+
(error) => error.status === 0 && error.message === "Network request failed." && error.cause === networkFailure
|
|
245
|
+
);
|
|
246
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Type } from "@fastify/type-provider-typebox";
|
|
4
|
+
import { createCommand } from "../src/shared/validators/command.js";
|
|
5
|
+
|
|
6
|
+
test("createCommand requires input/output TypeBox schemas", () => {
|
|
7
|
+
assert.throws(() => createCommand({}), /input must be a TypeBox schema object/);
|
|
8
|
+
|
|
9
|
+
assert.throws(
|
|
10
|
+
() =>
|
|
11
|
+
createCommand({
|
|
12
|
+
input: Type.Object({}, { additionalProperties: false })
|
|
13
|
+
}),
|
|
14
|
+
/output must be a TypeBox schema object/
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("createCommand normalizes invalidates and preserves idempotent flag", () => {
|
|
19
|
+
const command = createCommand({
|
|
20
|
+
input: Type.Object(
|
|
21
|
+
{
|
|
22
|
+
token: Type.String({ minLength: 1 })
|
|
23
|
+
},
|
|
24
|
+
{ additionalProperties: false }
|
|
25
|
+
),
|
|
26
|
+
output: Type.Object(
|
|
27
|
+
{
|
|
28
|
+
ok: Type.Boolean()
|
|
29
|
+
},
|
|
30
|
+
{ additionalProperties: false }
|
|
31
|
+
),
|
|
32
|
+
idempotent: true,
|
|
33
|
+
invalidates: ["users-web", "users-web", "", "workspace.members"]
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
assert.equal(command.idempotent, true);
|
|
37
|
+
assert.deepEqual(command.invalidates, ["users-web", "workspace.members"]);
|
|
38
|
+
assert.equal(command.input.type, "object");
|
|
39
|
+
assert.equal(command.output.type, "object");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("createCommand omits idempotent when not explicitly boolean", () => {
|
|
43
|
+
const command = createCommand({
|
|
44
|
+
input: Type.Object({}, { additionalProperties: false }),
|
|
45
|
+
output: Type.Object({}, { additionalProperties: false })
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(Object.hasOwn(command, "idempotent"), false);
|
|
49
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
import * as clientApi from "../src/client/index.js";
|
|
6
|
+
import * as sharedApi from "../src/shared/index.js";
|
|
7
|
+
import { HttpValidatorsServiceProvider } from "../src/server/providers/HttpValidatorsServiceProvider.js";
|
|
8
|
+
import { HttpClientRuntimeServiceProvider } from "../src/server/providers/HttpClientRuntimeServiceProvider.js";
|
|
9
|
+
|
|
10
|
+
test("package exports include explicit shared entrypoint and no server barrel", async () => {
|
|
11
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
12
|
+
const exportsMap = packageJson && typeof packageJson === "object" ? packageJson.exports : {};
|
|
13
|
+
assert.equal(exportsMap["./server"], undefined);
|
|
14
|
+
assert.equal(exportsMap["./shared"], "./src/shared/index.js");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("client entrypoint exports client runtime and client providers only", () => {
|
|
18
|
+
assert.equal(typeof clientApi.createHttpClient, "function");
|
|
19
|
+
assert.equal(typeof clientApi.HttpValidatorsClientProvider, "function");
|
|
20
|
+
assert.equal(typeof clientApi.HttpClientRuntimeClientProvider, "function");
|
|
21
|
+
assert.equal(typeof clientApi.withStandardErrorResponses, "undefined");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("server provider modules export server providers only", () => {
|
|
25
|
+
assert.equal(typeof HttpValidatorsServiceProvider, "function");
|
|
26
|
+
assert.equal(typeof HttpClientRuntimeServiceProvider, "function");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("shared entrypoint exports shared validators only", () => {
|
|
30
|
+
assert.equal(typeof sharedApi.withStandardErrorResponses, "function");
|
|
31
|
+
assert.equal(typeof sharedApi.enumSchema, "function");
|
|
32
|
+
assert.equal(typeof sharedApi.createResource, "function");
|
|
33
|
+
assert.equal(typeof sharedApi.createCommand, "function");
|
|
34
|
+
assert.equal(typeof sharedApi.createHttpClient, "undefined");
|
|
35
|
+
assert.equal(typeof sharedApi.HttpValidatorsServiceProvider, "undefined");
|
|
36
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
STANDARD_ERROR_STATUS_CODES,
|
|
6
|
+
apiErrorResponseSchema,
|
|
7
|
+
apiValidationErrorResponseSchema,
|
|
8
|
+
fastifyDefaultErrorResponseSchema,
|
|
9
|
+
enumSchema,
|
|
10
|
+
withStandardErrorResponses
|
|
11
|
+
} from "../src/shared/validators/errorResponses.js";
|
|
12
|
+
|
|
13
|
+
test("withStandardErrorResponses includes standard statuses", () => {
|
|
14
|
+
const success = {
|
|
15
|
+
200: {
|
|
16
|
+
schema: {
|
|
17
|
+
type: "string"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const responses = withStandardErrorResponses(success);
|
|
23
|
+
|
|
24
|
+
assert.equal(responses[200].schema.type, "string");
|
|
25
|
+
for (const statusCode of STANDARD_ERROR_STATUS_CODES) {
|
|
26
|
+
assert.ok(responses[statusCode], `missing status ${statusCode}`);
|
|
27
|
+
}
|
|
28
|
+
assert.equal(responses[400].schema.anyOf.length, 2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("withStandardErrorResponses uses validation union for 400 when enabled", () => {
|
|
32
|
+
const responses = withStandardErrorResponses(
|
|
33
|
+
{
|
|
34
|
+
201: {
|
|
35
|
+
schema: {
|
|
36
|
+
type: "null"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{ includeValidation400: true }
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
assert.equal(responses[400].schema.anyOf.length, 3);
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
responses[400].schema.anyOf.map((schema) => schema.type),
|
|
46
|
+
[apiValidationErrorResponseSchema.type, apiErrorResponseSchema.type, fastifyDefaultErrorResponseSchema.type]
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("withStandardErrorResponses does not override existing error schemas", () => {
|
|
51
|
+
const custom400 = {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
error: {
|
|
55
|
+
type: "string"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const responses = withStandardErrorResponses(
|
|
60
|
+
{
|
|
61
|
+
200: {
|
|
62
|
+
schema: {
|
|
63
|
+
type: "string"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
400: {
|
|
67
|
+
schema: custom400
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{ includeValidation400: true }
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
assert.equal(responses[400].schema, custom400);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("enumSchema creates a literal union", () => {
|
|
77
|
+
const schema = enumSchema(["one", "two", "three"]);
|
|
78
|
+
|
|
79
|
+
assert.equal(schema.anyOf.length, 3);
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
schema.anyOf.map((entry) => entry.const),
|
|
82
|
+
["one", "two", "three"]
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Type } from "@fastify/type-provider-typebox";
|
|
4
|
+
import { Errors } from "typebox/value";
|
|
5
|
+
import {
|
|
6
|
+
mapOperationIssues,
|
|
7
|
+
resolveFieldSchema,
|
|
8
|
+
resolveIssueField,
|
|
9
|
+
resolveMissingRequiredFields,
|
|
10
|
+
resolveSchemaMessages
|
|
11
|
+
} from "../src/shared/validators/operationMessages.js";
|
|
12
|
+
|
|
13
|
+
const sampleSchema = Type.Object(
|
|
14
|
+
{
|
|
15
|
+
name: Type.String({
|
|
16
|
+
minLength: 1,
|
|
17
|
+
messages: {
|
|
18
|
+
required: "Workspace name is required.",
|
|
19
|
+
minLength: "Workspace name is required.",
|
|
20
|
+
default: "Invalid workspace name."
|
|
21
|
+
}
|
|
22
|
+
}),
|
|
23
|
+
color: Type.String({
|
|
24
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
25
|
+
messages: {
|
|
26
|
+
pattern: "Workspace color must be a hex value."
|
|
27
|
+
}
|
|
28
|
+
}),
|
|
29
|
+
invitesEnabled: Type.Boolean({
|
|
30
|
+
messages: {
|
|
31
|
+
default: "invitesEnabled must be true or false."
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
messages: {
|
|
38
|
+
additionalProperties: "Unexpected field."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
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");
|
|
46
|
+
|
|
47
|
+
assert.equal(resolveIssueField(requiredIssue), "name");
|
|
48
|
+
assert.deepEqual(resolveMissingRequiredFields(requiredIssue), ["name"]);
|
|
49
|
+
|
|
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");
|
|
64
|
+
assert.equal(resolveIssueField(minLengthIssue), "profile");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("mapOperationIssues applies field message overrides by keyword", () => {
|
|
68
|
+
const issues = [...Errors(sampleSchema, { name: "", color: "oops", invitesEnabled: "yes" })];
|
|
69
|
+
const mapped = mapOperationIssues(issues, sampleSchema);
|
|
70
|
+
|
|
71
|
+
assert.equal(mapped.fieldErrors.name, "Workspace name is required.");
|
|
72
|
+
assert.equal(mapped.fieldErrors.color, "Workspace color must be a hex value.");
|
|
73
|
+
assert.equal(mapped.fieldErrors.invitesEnabled, "invitesEnabled must be true or false.");
|
|
74
|
+
assert.deepEqual(mapped.globalErrors, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("mapOperationIssues falls back to keyword/global messages", () => {
|
|
78
|
+
const issues = [...Errors(sampleSchema, { color: "#0F6B54", invitesEnabled: true, extra: "x" })];
|
|
79
|
+
const mapped = mapOperationIssues(issues, sampleSchema);
|
|
80
|
+
|
|
81
|
+
assert.equal(mapped.fieldErrors.extra, "Unexpected field.");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("schema message helpers resolve field and root messages", () => {
|
|
85
|
+
const nameSchema = resolveFieldSchema(sampleSchema, "name");
|
|
86
|
+
assert.equal(typeof nameSchema, "object");
|
|
87
|
+
|
|
88
|
+
const nameMessages = resolveSchemaMessages(nameSchema);
|
|
89
|
+
const rootMessages = resolveSchemaMessages(sampleSchema);
|
|
90
|
+
|
|
91
|
+
assert.equal(nameMessages.minLength, "Workspace name is required.");
|
|
92
|
+
assert.equal(rootMessages.additionalProperties, "Unexpected field.");
|
|
93
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Type } from "@fastify/type-provider-typebox";
|
|
4
|
+
import {
|
|
5
|
+
validateOperationInput,
|
|
6
|
+
validateOperationSection
|
|
7
|
+
} from "../src/shared/validators/operationValidation.js";
|
|
8
|
+
|
|
9
|
+
const patchSchema = Type.Object(
|
|
10
|
+
{
|
|
11
|
+
name: Type.Optional(
|
|
12
|
+
Type.String({
|
|
13
|
+
minLength: 1,
|
|
14
|
+
maxLength: 160,
|
|
15
|
+
messages: {
|
|
16
|
+
minLength: "Workspace name is required."
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
),
|
|
20
|
+
color: Type.Optional(
|
|
21
|
+
Type.String({
|
|
22
|
+
pattern: "^#[0-9A-Fa-f]{6}$",
|
|
23
|
+
messages: {
|
|
24
|
+
pattern: "Workspace color must be a hex value."
|
|
25
|
+
}
|
|
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();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return normalized;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("validateOperationSection normalizes and validates one section", () => {
|
|
62
|
+
const parsed = validateOperationSection({
|
|
63
|
+
operation: patchOperation,
|
|
64
|
+
section: "bodyValidator",
|
|
65
|
+
value: {
|
|
66
|
+
name: " Acme ",
|
|
67
|
+
color: "#0F6B54"
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(parsed.ok, true);
|
|
72
|
+
assert.deepEqual(parsed.fieldErrors, {});
|
|
73
|
+
assert.equal(parsed.value.name, "Acme");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("validateOperationSection returns shared field errors", () => {
|
|
77
|
+
const parsed = validateOperationSection({
|
|
78
|
+
operation: patchOperation,
|
|
79
|
+
section: "bodyValidator",
|
|
80
|
+
value: {
|
|
81
|
+
name: "",
|
|
82
|
+
color: "bad",
|
|
83
|
+
rogueField: true
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert.equal(parsed.ok, false);
|
|
88
|
+
assert.equal(parsed.fieldErrors.name, "Workspace name is required.");
|
|
89
|
+
assert.equal(parsed.fieldErrors.color, "Workspace color must be a hex value.");
|
|
90
|
+
assert.equal(parsed.fieldErrors.rogueField, "Unexpected field.");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("validateOperationInput validates params/query/body together", () => {
|
|
94
|
+
const viewOperation = Object.freeze({
|
|
95
|
+
method: "GET",
|
|
96
|
+
paramsValidator: {
|
|
97
|
+
schema: Type.Object(
|
|
98
|
+
{
|
|
99
|
+
workspaceSlug: Type.String({ minLength: 1 })
|
|
100
|
+
},
|
|
101
|
+
{ additionalProperties: false }
|
|
102
|
+
)
|
|
103
|
+
},
|
|
104
|
+
queryValidator: {
|
|
105
|
+
schema: Type.Object(
|
|
106
|
+
{
|
|
107
|
+
includeArchived: Type.Optional(Type.Boolean())
|
|
108
|
+
},
|
|
109
|
+
{ additionalProperties: false }
|
|
110
|
+
),
|
|
111
|
+
normalize: (value) => {
|
|
112
|
+
if (!value || typeof value !== "object") {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
includeArchived: value.includeArchived === true
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const parsed = validateOperationInput({
|
|
124
|
+
operation: viewOperation,
|
|
125
|
+
input: {
|
|
126
|
+
params: {
|
|
127
|
+
workspaceSlug: "acme"
|
|
128
|
+
},
|
|
129
|
+
query: {}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
assert.equal(parsed.ok, true);
|
|
134
|
+
assert.equal(parsed.value.params.workspaceSlug, "acme");
|
|
135
|
+
assert.equal(parsed.value.query.includeArchived, false);
|
|
136
|
+
assert.equal(parsed.value.body, undefined);
|
|
137
|
+
});
|