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.
@@ -77,7 +77,7 @@ export class StructError extends Error {
77
77
 
78
78
  /**
79
79
  * @template T
80
- * @typedef {(input?: unknown, context: StructContext) => T} StructExec
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/2019-09/schema",
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
- type: "string",
115
- default: fallback,
116
- };
117
- return new Structure(schema, (input, context) => {
118
- if (input === undefined) return fallback;
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
- type: "number",
133
- default: fallback,
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 !== "number") {
138
- throw new StructError("Not a number", context?.path);
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
- type: "string",
151
- format: "uri",
152
- default: fallback,
153
- };
154
- const fallbackUrl = new URL(fallback); // ~ make sure the fallback is valid ~
155
- return new Structure(schema, (input, context) => {
156
- if (input === undefined) return fallbackUrl;
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("Not an object", path);
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
  }
@@ -1,6 +1,11 @@
1
- import { assertInstanceOf } from "../bundle/deno/core/test-deps.js";
2
1
  import { StructError, Structure } from "./structures.js";
3
- import { assertEquals, assertThrows, describe, it } from "./test-deps.js";
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/2019-09/schema",
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("throws for non-strings", () => {
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
- error,
181
- new StructError("Expected a string", ["some", "path"]),
182
- "should throw a StructError and capture the context",
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
- it("throws for non-numbers", () => {
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 number", ["some", "path"]),
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.number(42);
227
- assertEquals(struct.schema, { type: "number", default: 42 });
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("throws for non-strings", () => {
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
- assertEquals(
271
- error,
272
- new StructError("Not a string or URL", ["some", "path"]),
273
- "should throw a StructError and capture the context",
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
- assertEquals(
318
- error,
319
- new StructError("Not an object", ["some", "path"]),
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("validates nested values", () => {
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("fails on nested values", () => {
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
- assertEquals(
339
- error,
340
- new StructError(
341
- "Object does not match schema",
342
- [],
343
- [new StructError("Not a string", ["key"])],
344
- ),
345
- "should validate properties against their schema",
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
@@ -28,5 +28,5 @@
28
28
  "import": "./source/*.js"
29
29
  }
30
30
  },
31
- "version": "0.3.0"
31
+ "version": "0.4.0"
32
32
  }