gruber 0.3.0 → 0.4.0
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/CHANGELOG.md +17 -0
- package/README.md +67 -13
- package/core/configuration.d.ts +115 -17
- package/core/configuration.js +197 -69
- package/core/configuration.test.js +215 -16
- package/core/http.d.ts +30 -11
- package/core/http.js +42 -17
- package/core/http.test.js +57 -35
- package/core/mod.d.ts +1 -0
- package/core/mod.js +1 -0
- package/core/structures.d.ts +22 -9
- package/core/structures.js +87 -29
- package/core/structures.test.js +157 -32
- package/package.json +1 -1
package/core/structures.js
CHANGED
|
@@ -77,7 +77,7 @@ export class StructError extends Error {
|
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* @template T
|
|
80
|
-
* @typedef {(input?: unknown, context
|
|
80
|
+
* @typedef {(input?: unknown, context?: StructContext) => T} StructExec
|
|
81
81
|
*/
|
|
82
82
|
|
|
83
83
|
/**
|
|
@@ -100,22 +100,23 @@ export class Structure {
|
|
|
100
100
|
|
|
101
101
|
getSchema() {
|
|
102
102
|
return {
|
|
103
|
-
$schema: "https://json-schema.org/draft/
|
|
103
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
104
104
|
...this.schema,
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
* @param {string} fallback
|
|
109
|
+
* @param {string} [fallback]
|
|
110
110
|
* @returns {Structure<string>}
|
|
111
111
|
*/
|
|
112
|
-
static string(fallback) {
|
|
113
|
-
const schema = {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
112
|
+
static string(fallback = undefined) {
|
|
113
|
+
const schema = { type: "string" };
|
|
114
|
+
if (fallback !== undefined) schema.default = fallback;
|
|
115
|
+
|
|
116
|
+
return new Structure(schema, (input = fallback, context = undefined) => {
|
|
117
|
+
if (input === undefined) {
|
|
118
|
+
throw new StructError("Missing value", context?.path);
|
|
119
|
+
}
|
|
119
120
|
if (typeof input !== "string") {
|
|
120
121
|
throw new StructError("Expected a string", context?.path);
|
|
121
122
|
}
|
|
@@ -124,36 +125,59 @@ export class Structure {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
/**
|
|
127
|
-
* @param {number} fallback
|
|
128
|
+
* @param {number} [fallback]
|
|
128
129
|
* @returns {Structure<number>}
|
|
129
130
|
*/
|
|
130
131
|
static number(fallback) {
|
|
131
|
-
const schema = {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
const schema = { type: "number" };
|
|
133
|
+
if (fallback !== undefined) schema.default = fallback;
|
|
134
|
+
|
|
135
|
+
return new Structure(schema, (input = fallback, context = undefined) => {
|
|
136
|
+
if (input === undefined) {
|
|
137
|
+
throw new StructError("Missing value", context?.path);
|
|
138
|
+
}
|
|
139
|
+
// if (typeof input === "string") {
|
|
140
|
+
// const parsed = Number.parseFloat(input);
|
|
141
|
+
// if (!Number.isNaN(parsed)) return parsed;
|
|
142
|
+
// }
|
|
143
|
+
if (typeof input !== "number") {
|
|
144
|
+
throw new StructError("Expected a number", context?.path);
|
|
145
|
+
}
|
|
146
|
+
return input;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {boolean} fallback
|
|
152
|
+
* @returns {Structure<boolean>}
|
|
153
|
+
*/
|
|
154
|
+
static boolean(fallback) {
|
|
155
|
+
const schema = { type: "boolean" };
|
|
156
|
+
if (fallback !== undefined) schema.default = fallback;
|
|
157
|
+
|
|
135
158
|
return new Structure(schema, (input, context) => {
|
|
136
159
|
if (input === undefined) return fallback;
|
|
137
|
-
if (typeof input !== "
|
|
138
|
-
throw new StructError("Not a
|
|
160
|
+
if (typeof input !== "boolean") {
|
|
161
|
+
throw new StructError("Not a boolean", context?.path);
|
|
139
162
|
}
|
|
140
163
|
return input;
|
|
141
164
|
});
|
|
142
165
|
}
|
|
143
166
|
|
|
144
167
|
/**
|
|
145
|
-
* @param {string | URL} fallback
|
|
168
|
+
* @param {string | URL} [fallback]
|
|
146
169
|
* @returns {Structure<URL>}
|
|
147
170
|
*/
|
|
148
171
|
static url(fallback) {
|
|
149
|
-
const schema = {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
172
|
+
const schema = { type: "string", format: "uri" };
|
|
173
|
+
if (fallback !== undefined) schema.default = fallback.toString();
|
|
174
|
+
|
|
175
|
+
// ~ make sure the fallback is valid ~
|
|
176
|
+
const url = fallback ? new URL(fallback) : undefined;
|
|
177
|
+
return new Structure(schema, (input = url, context = undefined) => {
|
|
178
|
+
if (input === undefined) {
|
|
179
|
+
throw new StructError("Missing value", context?.path);
|
|
180
|
+
}
|
|
157
181
|
if (input instanceof URL) return input;
|
|
158
182
|
if (typeof input !== "string") {
|
|
159
183
|
throw new StructError("Not a string or URL", context?.path);
|
|
@@ -177,12 +201,11 @@ export class Structure {
|
|
|
177
201
|
for (const [key, struct] of Object.entries(fields)) {
|
|
178
202
|
schema.properties[key] = struct.schema;
|
|
179
203
|
}
|
|
180
|
-
return new Structure(schema, (input, context) => {
|
|
204
|
+
return new Structure(schema, (input = {}, context = undefined) => {
|
|
181
205
|
const path = context?.path ?? [];
|
|
182
206
|
if (input && typeof input !== "object") {
|
|
183
|
-
throw new StructError("
|
|
207
|
+
throw new StructError("Expected an object", path);
|
|
184
208
|
}
|
|
185
|
-
input = input ?? {};
|
|
186
209
|
const output = {};
|
|
187
210
|
const errors = [];
|
|
188
211
|
for (const key in fields) {
|
|
@@ -199,4 +222,39 @@ export class Structure {
|
|
|
199
222
|
return output;
|
|
200
223
|
});
|
|
201
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* **UNSTABLE** use at your own risk
|
|
228
|
+
*
|
|
229
|
+
* @template {Structure<any>} U
|
|
230
|
+
* @param {U} struct
|
|
231
|
+
* @returns {Structure<Array<Infer<U>>>}
|
|
232
|
+
*/
|
|
233
|
+
static array(struct) {
|
|
234
|
+
const schema = {
|
|
235
|
+
type: "array",
|
|
236
|
+
items: struct.schema,
|
|
237
|
+
default: [],
|
|
238
|
+
};
|
|
239
|
+
return new Structure(schema, (input = [], context = undefined) => {
|
|
240
|
+
const path = context?.path ?? [];
|
|
241
|
+
if (!Array.isArray(input)) {
|
|
242
|
+
throw new StructError("Expected an array", path);
|
|
243
|
+
}
|
|
244
|
+
const output = [];
|
|
245
|
+
const errors = [];
|
|
246
|
+
for (let i = 0; i < input.length; i++) {
|
|
247
|
+
const ctx = { path: [...path, `${i}`] };
|
|
248
|
+
try {
|
|
249
|
+
output.push(struct.process(input[i], ctx));
|
|
250
|
+
} catch (error) {
|
|
251
|
+
errors.push(StructError.chain(error, ctx));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (errors.length > 0) {
|
|
255
|
+
throw new StructError("Array item does not match schema", path, errors);
|
|
256
|
+
}
|
|
257
|
+
return output;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
202
260
|
}
|
package/core/structures.test.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { assertInstanceOf } from "../bundle/deno/core/test-deps.js";
|
|
2
1
|
import { StructError, Structure } from "./structures.js";
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
assertEquals,
|
|
4
|
+
assertThrows,
|
|
5
|
+
describe,
|
|
6
|
+
it,
|
|
7
|
+
assertInstanceOf,
|
|
8
|
+
} from "./test-deps.js";
|
|
4
9
|
|
|
5
10
|
describe("StructError", () => {
|
|
6
11
|
describe("constructor", () => {
|
|
@@ -139,7 +144,7 @@ describe("Structure", () => {
|
|
|
139
144
|
assertEquals(
|
|
140
145
|
struct.getSchema(),
|
|
141
146
|
{
|
|
142
|
-
$schema: "https://json-schema.org/draft/
|
|
147
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
143
148
|
type: "string",
|
|
144
149
|
default: "fallback",
|
|
145
150
|
},
|
|
@@ -169,18 +174,25 @@ describe("Structure", () => {
|
|
|
169
174
|
"should fall back to the default if undefined is passed",
|
|
170
175
|
);
|
|
171
176
|
});
|
|
172
|
-
it("
|
|
177
|
+
it("validates strings", () => {
|
|
173
178
|
const struct = Structure.string("fallback");
|
|
174
179
|
|
|
175
180
|
const error = assertThrows(
|
|
176
181
|
() => struct.process(42, { path: ["some", "path"] }),
|
|
177
182
|
StructError,
|
|
178
183
|
);
|
|
179
|
-
assertEquals(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
assertEquals(error.message, "Expected a string");
|
|
185
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
186
|
+
});
|
|
187
|
+
it("validates missing values", () => {
|
|
188
|
+
const struct = Structure.string();
|
|
189
|
+
|
|
190
|
+
const error = assertThrows(
|
|
191
|
+
() => struct.process(undefined, { path: ["some", "path"] }),
|
|
192
|
+
StructError,
|
|
183
193
|
);
|
|
194
|
+
assertEquals(error.message, "Missing value");
|
|
195
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
184
196
|
});
|
|
185
197
|
it("generates JSON schema", () => {
|
|
186
198
|
const struct = Structure.string("fallback");
|
|
@@ -209,22 +221,79 @@ describe("Structure", () => {
|
|
|
209
221
|
"should fall back to the default if undefined is passed",
|
|
210
222
|
);
|
|
211
223
|
});
|
|
212
|
-
|
|
224
|
+
// TODO: I'm not sure if this should be on Structure or Configuration
|
|
225
|
+
it.skip("parses string integers", () => {
|
|
226
|
+
const struct = Structure.number(42);
|
|
227
|
+
assertEquals(
|
|
228
|
+
struct.process("33"),
|
|
229
|
+
33,
|
|
230
|
+
"should parse the integer out of the string",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
it.skip("throws for non-numbers", () => {
|
|
213
234
|
const struct = Structure.number(42);
|
|
214
235
|
|
|
236
|
+
const error = assertThrows(
|
|
237
|
+
() => struct.process("a string", { path: ["some", "path"] }),
|
|
238
|
+
StructError,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
assertEquals(error.message, "Expected a number");
|
|
242
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
243
|
+
});
|
|
244
|
+
it("validates missing values", () => {
|
|
245
|
+
const struct = Structure.number();
|
|
246
|
+
|
|
247
|
+
const error = assertThrows(
|
|
248
|
+
() => struct.process(undefined, { path: ["some", "path"] }),
|
|
249
|
+
StructError,
|
|
250
|
+
);
|
|
251
|
+
assertEquals(error.message, "Missing value");
|
|
252
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
253
|
+
});
|
|
254
|
+
it("generates JSON schema", () => {
|
|
255
|
+
const struct = Structure.number(42);
|
|
256
|
+
assertEquals(struct.schema, { type: "number", default: 42 });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("boolean", () => {
|
|
261
|
+
it("creates a structure", () => {
|
|
262
|
+
const struct = Structure.boolean(false);
|
|
263
|
+
assertInstanceOf(struct, Structure);
|
|
264
|
+
});
|
|
265
|
+
it("allows booleans", () => {
|
|
266
|
+
const struct = Structure.boolean(false);
|
|
267
|
+
assertEquals(
|
|
268
|
+
struct.process(true),
|
|
269
|
+
true,
|
|
270
|
+
"should allow boolean values through",
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
it("uses the fallback", () => {
|
|
274
|
+
const struct = Structure.boolean(false);
|
|
275
|
+
assertEquals(
|
|
276
|
+
struct.process(undefined),
|
|
277
|
+
false,
|
|
278
|
+
"should fall back to the default if undefined is passed",
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
it("throws for non-booleans", () => {
|
|
282
|
+
const struct = Structure.boolean(false);
|
|
283
|
+
|
|
215
284
|
const error = assertThrows(
|
|
216
285
|
() => struct.process("a string", { path: ["some", "path"] }),
|
|
217
286
|
StructError,
|
|
218
287
|
);
|
|
219
288
|
assertEquals(
|
|
220
289
|
error,
|
|
221
|
-
new StructError("Expected a
|
|
290
|
+
new StructError("Expected a boolean", ["some", "path"]),
|
|
222
291
|
"should throw a StructError and capture the context",
|
|
223
292
|
);
|
|
224
293
|
});
|
|
225
294
|
it("generates JSON schema", () => {
|
|
226
|
-
const struct = Structure.
|
|
227
|
-
assertEquals(struct.schema, { type: "
|
|
295
|
+
const struct = Structure.boolean(false);
|
|
296
|
+
assertEquals(struct.schema, { type: "boolean", default: false });
|
|
228
297
|
});
|
|
229
298
|
});
|
|
230
299
|
|
|
@@ -260,18 +329,26 @@ describe("Structure", () => {
|
|
|
260
329
|
"should fall back to the default if undefined is passed",
|
|
261
330
|
);
|
|
262
331
|
});
|
|
263
|
-
it("
|
|
332
|
+
it("validates non-strings", () => {
|
|
264
333
|
const struct = Structure.url("https://fallback.example.com");
|
|
265
334
|
|
|
266
335
|
const error = assertThrows(
|
|
267
336
|
() => struct.process(42, { path: ["some", "path"] }),
|
|
268
337
|
StructError,
|
|
269
338
|
);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
339
|
+
|
|
340
|
+
assertEquals(error.message, "Not a string or URL");
|
|
341
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
342
|
+
});
|
|
343
|
+
it("validates missing values", () => {
|
|
344
|
+
const struct = Structure.url();
|
|
345
|
+
|
|
346
|
+
const error = assertThrows(
|
|
347
|
+
() => struct.process(undefined, { path: ["some", "path"] }),
|
|
348
|
+
StructError,
|
|
274
349
|
);
|
|
350
|
+
assertEquals(error.message, "Missing value");
|
|
351
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
275
352
|
});
|
|
276
353
|
it("generates JSON schema", () => {
|
|
277
354
|
const struct = Structure.url("https://fallback.example.com");
|
|
@@ -281,6 +358,14 @@ describe("Structure", () => {
|
|
|
281
358
|
default: "https://fallback.example.com",
|
|
282
359
|
});
|
|
283
360
|
});
|
|
361
|
+
it("stringifies URLs for JSON schema", () => {
|
|
362
|
+
const struct = Structure.url(new URL("https://fallback.example.com"));
|
|
363
|
+
assertEquals(struct.schema, {
|
|
364
|
+
type: "string",
|
|
365
|
+
format: "uri",
|
|
366
|
+
default: "https://fallback.example.com/",
|
|
367
|
+
});
|
|
368
|
+
});
|
|
284
369
|
});
|
|
285
370
|
|
|
286
371
|
describe("object", () => {
|
|
@@ -314,20 +399,18 @@ describe("Structure", () => {
|
|
|
314
399
|
() => struct.process("not an object", { path: ["some", "path"] }),
|
|
315
400
|
StructError,
|
|
316
401
|
);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
"should throw a StructError and capture the context",
|
|
321
|
-
);
|
|
402
|
+
|
|
403
|
+
assertEquals(error.message, "Expected an object");
|
|
404
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
322
405
|
});
|
|
323
|
-
it("
|
|
406
|
+
it("allows objects", () => {
|
|
324
407
|
const struct = Structure.object({
|
|
325
408
|
key: Structure.string("fallback"),
|
|
326
409
|
});
|
|
327
410
|
const result = struct.process({ key: "value" });
|
|
328
411
|
assertEquals(result, { key: "value" });
|
|
329
412
|
});
|
|
330
|
-
it("
|
|
413
|
+
it("validates nested values", () => {
|
|
331
414
|
const struct = Structure.object({
|
|
332
415
|
key: Structure.string("fallback"),
|
|
333
416
|
});
|
|
@@ -335,15 +418,57 @@ describe("Structure", () => {
|
|
|
335
418
|
() => struct.process({ key: 42 }),
|
|
336
419
|
StructError,
|
|
337
420
|
);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
421
|
+
|
|
422
|
+
assertEquals(error.message, "Object does not match schema");
|
|
423
|
+
assertEquals(error.path, []);
|
|
424
|
+
assertEquals(error.children.length, 1, "should have one nested error");
|
|
425
|
+
assertEquals(error.children[0].message, "Expected a string");
|
|
426
|
+
assertEquals(error.children[0].path, ["key"], "should capture context");
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe("array", () => {
|
|
431
|
+
it("creates a structure", () => {
|
|
432
|
+
const struct = Structure.array(Structure.string());
|
|
433
|
+
assertInstanceOf(struct, Structure);
|
|
434
|
+
});
|
|
435
|
+
it("generates JSON schema", () => {
|
|
436
|
+
const struct = Structure.array(Structure.string());
|
|
437
|
+
assertEquals(struct.schema, {
|
|
438
|
+
type: "array",
|
|
439
|
+
items: {
|
|
440
|
+
type: "string",
|
|
441
|
+
},
|
|
442
|
+
default: [],
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
it("throws for non-arrays", () => {
|
|
446
|
+
const struct = Structure.array(Structure.string());
|
|
447
|
+
const error = assertThrows(
|
|
448
|
+
() => struct.process("not an object", { path: ["some", "path"] }),
|
|
449
|
+
StructError,
|
|
346
450
|
);
|
|
451
|
+
|
|
452
|
+
assertEquals(error.message, "Expected an array");
|
|
453
|
+
assertEquals(error.path, ["some", "path"], "should capture the context");
|
|
454
|
+
});
|
|
455
|
+
it("allows arrays", () => {
|
|
456
|
+
const struct = Structure.array(Structure.string());
|
|
457
|
+
const result = struct.process(["a", "b", "c"]);
|
|
458
|
+
assertEquals(result, ["a", "b", "c"]);
|
|
459
|
+
});
|
|
460
|
+
it("validates array items", () => {
|
|
461
|
+
const struct = Structure.array(Structure.string());
|
|
462
|
+
const error = assertThrows(
|
|
463
|
+
() => struct.process(["a", 2, "c"]),
|
|
464
|
+
StructError,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
assertEquals(error.message, "Array item does not match schema");
|
|
468
|
+
assertEquals(error.path, []);
|
|
469
|
+
assertEquals(error.children.length, 1, "should have one nested error");
|
|
470
|
+
assertEquals(error.children[0].message, "Expected a string");
|
|
471
|
+
assertEquals(error.children[0].path, ["1"], "should capture context");
|
|
347
472
|
});
|
|
348
473
|
});
|
|
349
474
|
});
|
package/package.json
CHANGED