@uniflowed/validator 0.0.0-alpha.4 → 0.0.0-alpha.5
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/action.js +309 -0
- package/collection.js +338 -0
- package/index.js +245 -763
- package/infer.js +107 -0
- package/issue.js +129 -0
- package/json-schema.js +307 -0
- package/lazy.js +93 -0
- package/namespace.js +229 -0
- package/object.js +241 -0
- package/optional.js +132 -0
- package/package.json +19 -4
- package/parse.js +116 -0
- package/pipe.js +300 -0
- package/plain-object.js +105 -0
- package/primitive.js +183 -0
- package/schema.js +388 -0
- package/union.js +229 -0
package/infer.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/infer`: reading a value's type off its schema.
|
|
4
|
+
//
|
|
5
|
+
// This module compiles to nothing. Every name in it is a type, and the reason
|
|
6
|
+
// they are here rather than beside the parsers is that they are the answer to
|
|
7
|
+
// a question the parsers do not ask: given a schema, what is the type of the
|
|
8
|
+
// thing it produces, and what is the type of the thing you may hand it?
|
|
9
|
+
//
|
|
10
|
+
// const Account = object({
|
|
11
|
+
// email: pipe(string(), email()),
|
|
12
|
+
// age: pipe(string(), transform(Number), min(18)),
|
|
13
|
+
// });
|
|
14
|
+
//
|
|
15
|
+
// type Raw = InferInput<typeof Account>; // {| email: string, age: string |}
|
|
16
|
+
// type Account = InferOutput<typeof Account>; // {| email: string, age: number |}
|
|
17
|
+
//
|
|
18
|
+
// Neither type was written down. That is the point: a schema is the single
|
|
19
|
+
// source of truth, and a hand-written `type Account` beside it is a second
|
|
20
|
+
// truth that will disagree with the first on a Tuesday.
|
|
21
|
+
//
|
|
22
|
+
// # What Flow can do here
|
|
23
|
+
//
|
|
24
|
+
// More than this package used to assume. Conditional types with `infer` are
|
|
25
|
+
// what read a parameter back out of `Schema<Out, In>`; mapped types are what
|
|
26
|
+
// turn a shape — an object whose values are schemas — into an object whose
|
|
27
|
+
// values are those schemas' outputs, key by key, without naming the keys.
|
|
28
|
+
// `object`, `partial`, `looseObject`, `tuple`, `union`, `variant` and `record`
|
|
29
|
+
// all infer, so none of them takes an explicit type argument any more, and a
|
|
30
|
+
// field added to a shape appears in the inferred type with nothing else
|
|
31
|
+
// edited.
|
|
32
|
+
//
|
|
33
|
+
// The inferred object types are **exact**. `InferOutput<typeof Account>` does
|
|
34
|
+
// not accept an extra property, which matches what `object()` does at run time
|
|
35
|
+
// — it drops keys the shape does not name — and is why `looseObject` has a
|
|
36
|
+
// separate spelling with `...` in its result.
|
|
37
|
+
//
|
|
38
|
+
// # What it cannot
|
|
39
|
+
//
|
|
40
|
+
// **Paths are not typed.** An `Issue`'s `path` is `$ReadOnlyArray<string>`.
|
|
41
|
+
// Flow has no template-literal types, so there is no way to say "one of the
|
|
42
|
+
// paths that exist in this schema", and a `SchemaPath<S>` alias that was
|
|
43
|
+
// really `string` would be a type that looks like it checks something and does
|
|
44
|
+
// not. `@uniflowed/form` makes the same call for the same reason.
|
|
45
|
+
//
|
|
46
|
+
// **Branded outputs are not nominal.** `pipe(string(), brand("UserId"))` is
|
|
47
|
+
// `Schema<string, string>`. Flow's opaque types are per module and cannot be
|
|
48
|
+
// generated from a call, so the brand is a label in the description — real for
|
|
49
|
+
// `json-schema.js`, and honest about being nothing to the checker. A project
|
|
50
|
+
// that wants `UserId` to be a distinct type should declare
|
|
51
|
+
// `opaque type UserId = string` in the module that owns it and annotate there.
|
|
52
|
+
//
|
|
53
|
+
// **`InferInput` describes shape, not provenance.** It says a valid input to
|
|
54
|
+
// `pipe(string(), transform(Number))` is a `string`. It cannot say that the
|
|
55
|
+
// string has to parse as a number, because that is what the parse is for.
|
|
56
|
+
//
|
|
57
|
+
// **A recursive schema still needs one annotation.** `lazy(() => …)` builds a
|
|
58
|
+
// type that mentions itself, and Flow will not solve for that on its own; the
|
|
59
|
+
// type argument on `lazy` is where the cycle is cut. `lazy.js` has the
|
|
60
|
+
// example.
|
|
61
|
+
|
|
62
|
+
import type { Schema } from "./schema.js";
|
|
63
|
+
|
|
64
|
+
/** The type a schema produces. The one every consumer wants. */
|
|
65
|
+
export type InferOutput<TSchema> = TSchema extends Schema<infer TValue, infer TSource>
|
|
66
|
+
? TValue
|
|
67
|
+
: empty;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The type a valid input to a schema has.
|
|
71
|
+
*
|
|
72
|
+
* Equal to [`InferOutput`] until a `transform` is in the pipeline. Where they
|
|
73
|
+
* differ, this is the one a form's `defaultValues` wants and the other is the
|
|
74
|
+
* one its `onValid` receives.
|
|
75
|
+
*/
|
|
76
|
+
export type InferInput<TSchema> = TSchema extends Schema<infer TValue, infer TSource>
|
|
77
|
+
? TSource
|
|
78
|
+
: empty;
|
|
79
|
+
|
|
80
|
+
/** The older name for [`InferOutput`], kept so existing annotations compile. */
|
|
81
|
+
export type Infer<TSchema> = InferOutput<TSchema>;
|
|
82
|
+
|
|
83
|
+
/** An object whose values are schemas: what `object` and `variant` take. */
|
|
84
|
+
export type Shape = { readonly [string]: Schema<mixed, mixed>, ... };
|
|
85
|
+
|
|
86
|
+
/** A list of schemas: what `union` and `tuple` take. */
|
|
87
|
+
export type Options = $ReadOnlyArray<Schema<mixed, mixed>>;
|
|
88
|
+
|
|
89
|
+
/** The object a shape produces, key by key. */
|
|
90
|
+
export type ShapeOutput<TShape extends Shape> = {
|
|
91
|
+
[Key in keyof TShape]: InferOutput<TShape[Key]>,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The object a shape accepts, key by key. */
|
|
95
|
+
export type ShapeInput<TShape extends Shape> = {
|
|
96
|
+
[Key in keyof TShape]: InferInput<TShape[Key]>,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The tuple a list of schemas produces, position by position. */
|
|
100
|
+
export type ItemsOutput<TItems extends Options> = {
|
|
101
|
+
[Index in keyof TItems]: InferOutput<TItems[Index]>,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** The tuple a list of schemas accepts, position by position. */
|
|
105
|
+
export type ItemsInput<TItems extends Options> = {
|
|
106
|
+
[Index in keyof TItems]: InferInput<TItems[Index]>,
|
|
107
|
+
};
|
package/issue.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/issue`: what a failure is, and where it happened.
|
|
4
|
+
//
|
|
5
|
+
// A validator that answers "no" has told the caller nothing. A form needs to
|
|
6
|
+
// put a message under one input, an HTTP handler needs to name the field in
|
|
7
|
+
// its 422 body, and a log needs to say which row of a thousand was wrong. All
|
|
8
|
+
// three want the same thing: the path from the root of the value to the place
|
|
9
|
+
// that failed, as data.
|
|
10
|
+
//
|
|
11
|
+
// So an issue is `{ code, message, path }` — `["users", "2", "email"]`, not
|
|
12
|
+
// `"users[2].email"`. Segments compose without a grammar: joining them with a
|
|
13
|
+
// dot is one line at the boundary that wants a string, and splitting a string
|
|
14
|
+
// back into segments is a parser nobody should have to write. Array indices
|
|
15
|
+
// are their decimal spelling, because a path is a path whether the container
|
|
16
|
+
// was an object or an array, and a consumer that has to branch on the segment
|
|
17
|
+
// type gains nothing from the distinction.
|
|
18
|
+
//
|
|
19
|
+
// # Why this is separate from the schemas
|
|
20
|
+
//
|
|
21
|
+
// Because nothing here knows what a schema is. `issue.js` is a leaf: it is
|
|
22
|
+
// imported by the kernel, by every combinator that reports a failure, and by
|
|
23
|
+
// `@uniflowed/form`, and it imports one thing itself. Keeping it that way is
|
|
24
|
+
// what lets a consumer translate issues — into field errors, into a response
|
|
25
|
+
// body — without resolving the schema engine at all.
|
|
26
|
+
|
|
27
|
+
import { put } from "./plain-object.js";
|
|
28
|
+
|
|
29
|
+
/** Where an issue happened, as object keys and array indices from the root. */
|
|
30
|
+
export type Path = $ReadOnlyArray<string>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The mutable buffer the synchronous walk descends with.
|
|
34
|
+
*
|
|
35
|
+
* `schema.js` explains why one array is pushed and popped rather than a fresh
|
|
36
|
+
* array being allocated per field. The type is separate from [`Path`] so that
|
|
37
|
+
* the distinction between "the buffer, which is being mutated right now" and
|
|
38
|
+
* "a path, which is a value" is visible in every signature.
|
|
39
|
+
*/
|
|
40
|
+
export type PathBuffer = Array<string>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One reason a value was rejected.
|
|
44
|
+
*
|
|
45
|
+
* `code` is for programs — `"type"`, `"min_length"`, `"unknown_key"` — and is
|
|
46
|
+
* stable across message changes, so a caller can tell "this is not an email
|
|
47
|
+
* address" from "we need an email address" without matching on prose.
|
|
48
|
+
*
|
|
49
|
+
* `path` is absent rather than empty when the issue is about the whole value,
|
|
50
|
+
* because the overwhelmingly common case is a successful parse and an object
|
|
51
|
+
* with one fewer field is one fewer allocation on the path that matters.
|
|
52
|
+
*/
|
|
53
|
+
export type Issue = {|
|
|
54
|
+
readonly code: string,
|
|
55
|
+
readonly message: string,
|
|
56
|
+
readonly path?: Path,
|
|
57
|
+
|};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An issue at wherever the walk currently is.
|
|
61
|
+
*
|
|
62
|
+
* The `slice` is the only copy of a path the package makes, and it happens
|
|
63
|
+
* exactly when a value was going to be rejected anyway.
|
|
64
|
+
*/
|
|
65
|
+
export function issue(code: string, message: string, path: Path): Issue {
|
|
66
|
+
return path.length === 0 ? { code, message } : { code, message, path: path.slice() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An issue at `path` with `keys` appended: a cross-field rule's landing spot. */
|
|
70
|
+
export function issueUnder(code: string, message: string, path: Path, keys: Path): Issue {
|
|
71
|
+
const at = path.concat(keys);
|
|
72
|
+
return at.length === 0 ? { code, message } : { code, message, path: at };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describeIssue(entry: Issue): string {
|
|
76
|
+
const at = entry.path == null || entry.path.length === 0 ? "" : ` at ${entry.path.join(".")}`;
|
|
77
|
+
return `${entry.message}${at}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What [`parse`] raises.
|
|
82
|
+
*
|
|
83
|
+
* A real `Error` subclass so it survives `instanceof`, logging and a `catch`
|
|
84
|
+
* that only knows about errors, and it carries `issues` so a caller can build
|
|
85
|
+
* a field-by-field response without parsing the message back apart.
|
|
86
|
+
*/
|
|
87
|
+
export class ValidationError extends Error {
|
|
88
|
+
readonly issues: $ReadOnlyArray<Issue>;
|
|
89
|
+
|
|
90
|
+
constructor(issues: $ReadOnlyArray<Issue>) {
|
|
91
|
+
super(issues.map(describeIssue).join("; "));
|
|
92
|
+
this.name = "ValidationError";
|
|
93
|
+
this.issues = issues;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Issues grouped the way a form renders them.
|
|
99
|
+
*
|
|
100
|
+
* `root` is everything that was about the value as a whole; `nested` is keyed
|
|
101
|
+
* by the dotted path, which is the same string `@uniflowed/form`'s `register`
|
|
102
|
+
* was given. Written through [`put`] because a payload's own `__proto__` key
|
|
103
|
+
* reaches this function as a path segment.
|
|
104
|
+
*/
|
|
105
|
+
export type FlatIssues = {|
|
|
106
|
+
readonly root: $ReadOnlyArray<string>,
|
|
107
|
+
readonly nested: { readonly [string]: $ReadOnlyArray<string>, ... },
|
|
108
|
+
|};
|
|
109
|
+
|
|
110
|
+
/** Group `issues` by their path, for a caller that renders per field. */
|
|
111
|
+
export function flatten(issues: $ReadOnlyArray<Issue>): FlatIssues {
|
|
112
|
+
const root: Array<string> = [];
|
|
113
|
+
const nested: { [string]: Array<string>, ... } = {};
|
|
114
|
+
for (const entry of issues) {
|
|
115
|
+
const path = entry.path;
|
|
116
|
+
if (path == null || path.length === 0) {
|
|
117
|
+
root.push(entry.message);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const key = path.join(".");
|
|
121
|
+
const already = Object.hasOwn(nested, key) ? nested[key] : null;
|
|
122
|
+
if (already == null) {
|
|
123
|
+
put(nested, key, [entry.message]);
|
|
124
|
+
} else {
|
|
125
|
+
already.push(entry.message);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { root, nested };
|
|
129
|
+
}
|
package/json-schema.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/json-schema`: a schema as a document somebody else can
|
|
4
|
+
// read.
|
|
5
|
+
//
|
|
6
|
+
// const { schema, unrepresentable } = toJsonSchema(Account);
|
|
7
|
+
// // { $schema: "…/2020-12/schema", type: "object", properties: { … } }
|
|
8
|
+
//
|
|
9
|
+
// The roadmap calls this "schema exports", and it is the reason `schema.js`
|
|
10
|
+
// makes every schema carry a [`Description`] beside its parse function. A
|
|
11
|
+
// closure cannot be read: a schema that was *only* the four lines that check a
|
|
12
|
+
// value can be run and can never be published, documented, handed to an
|
|
13
|
+
// OpenAPI generator, or turned into types for a consumer written in another
|
|
14
|
+
// language. The description is the readable half, and this module is the first
|
|
15
|
+
// consumer of it.
|
|
16
|
+
//
|
|
17
|
+
// # What "unrepresentable" means, and why it is a return value
|
|
18
|
+
//
|
|
19
|
+
// JSON Schema describes JSON. `map`, `set`, `bigint`, `date`, `instance` and
|
|
20
|
+
// `custom` describe values that JSON does not have, and `check` describes a
|
|
21
|
+
// predicate no declarative format can express. A converter has three options
|
|
22
|
+
// for those: throw, lie, or say so. Throwing makes one `Date` field fatal for a
|
|
23
|
+
// document that is otherwise fine. Lying — emitting `{}` and moving on — is
|
|
24
|
+
// what makes an exported schema quietly weaker than the one the application
|
|
25
|
+
// runs.
|
|
26
|
+
//
|
|
27
|
+
// So this says so. The node becomes `{}`, which accepts anything and is
|
|
28
|
+
// therefore *never wrong*, only imprecise, and the place it happened is
|
|
29
|
+
// reported in `unrepresentable` with the path to it. A caller that wants the
|
|
30
|
+
// strict behaviour asserts the list is empty; a caller documenting an API that
|
|
31
|
+
// happens to have one `Date` in it gets its document.
|
|
32
|
+
//
|
|
33
|
+
// # The exported document describes the input, not the output
|
|
34
|
+
//
|
|
35
|
+
// `pipe(string(), transform(Number))` exports as `{ "type": "string" }`. That
|
|
36
|
+
// is the whole point of a JSON Schema: it validates what arrives on the wire,
|
|
37
|
+
// and what arrives is the input. `InferOutput` is for the code on this side of
|
|
38
|
+
// the boundary; the document is for the code on the other side, which has
|
|
39
|
+
// never heard of the transform.
|
|
40
|
+
//
|
|
41
|
+
// The consequence is easy to get wrong, and this module got it wrong first: a
|
|
42
|
+
// step *after* a transform constrains the output, not the input.
|
|
43
|
+
// `pipe(string(), transform(Number), min(18))` must not export as `{ "type":
|
|
44
|
+
// "string", "minimum": 18 }`, which would ask a consumer to compare a string
|
|
45
|
+
// against a number. It exports as `{ "type": "string" }`, and `min after a
|
|
46
|
+
// transform` is reported as unrepresentable — because the schema does reject
|
|
47
|
+
// `"5"` and the document does not, and that is exactly what the list is for.
|
|
48
|
+
//
|
|
49
|
+
// `fallback` follows the same rule to its conclusion: a fallback accepts every
|
|
50
|
+
// value, so it exports as `{}`. Nothing is lost that JSON Schema could have
|
|
51
|
+
// held.
|
|
52
|
+
//
|
|
53
|
+
// # Recursion
|
|
54
|
+
//
|
|
55
|
+
// A `lazy` schema is a cycle, and the description of one is a thunk rather
|
|
56
|
+
// than a value for exactly that reason. Each `lazy` carries an identity, and
|
|
57
|
+
// the walk keeps a table from that identity to a name under `$defs`: the first
|
|
58
|
+
// visit converts the body, and every visit after it emits a `$ref`. A comment
|
|
59
|
+
// tree comes out as one definition and a reference to it, which is what JSON
|
|
60
|
+
// Schema is for.
|
|
61
|
+
//
|
|
62
|
+
// # What this is not, yet
|
|
63
|
+
//
|
|
64
|
+
// `uf prepare` lists a `GenerateValidatorTypes` step, and there is no code
|
|
65
|
+
// behind it — no crate reads a schema and writes a `.js.flow`. This module is
|
|
66
|
+
// the half that can exist without one: a description a generator would read,
|
|
67
|
+
// and a converter proving the description is complete enough to build
|
|
68
|
+
// something from. When that step is implemented it should consume
|
|
69
|
+
// [`describe`], not re-derive a shape from the source.
|
|
70
|
+
|
|
71
|
+
import type { Path } from "./issue.js";
|
|
72
|
+
import { put } from "./plain-object.js";
|
|
73
|
+
import type { Constraint, Description, Schema } from "./schema.js";
|
|
74
|
+
import { describe } from "./schema.js";
|
|
75
|
+
|
|
76
|
+
/** One node of a JSON Schema document. */
|
|
77
|
+
export type JsonSchemaNode = { readonly [string]: mixed, ... };
|
|
78
|
+
|
|
79
|
+
/** Somewhere the document is less precise than the schema it came from. */
|
|
80
|
+
export type Unrepresentable = {|
|
|
81
|
+
readonly path: Path,
|
|
82
|
+
readonly kind: string,
|
|
83
|
+
|};
|
|
84
|
+
|
|
85
|
+
/** A JSON Schema document, and everything JSON Schema could not say. */
|
|
86
|
+
export type JsonSchemaExport = {|
|
|
87
|
+
readonly schema: JsonSchemaNode,
|
|
88
|
+
readonly unrepresentable: $ReadOnlyArray<Unrepresentable>,
|
|
89
|
+
|};
|
|
90
|
+
|
|
91
|
+
const DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
92
|
+
|
|
93
|
+
/** `left` with `right`'s keywords on top, written prototype-safely. */
|
|
94
|
+
function merge(left: JsonSchemaNode, right: JsonSchemaNode): JsonSchemaNode {
|
|
95
|
+
const out: { [string]: mixed, ... } = {};
|
|
96
|
+
for (const key of Object.keys(left)) {
|
|
97
|
+
put(out, key, left[key]);
|
|
98
|
+
}
|
|
99
|
+
for (const key of Object.keys(right)) {
|
|
100
|
+
put(out, key, right[key]);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a field may be absent.
|
|
107
|
+
*
|
|
108
|
+
* `optional`, `nullish` and a default all mean the key can be missing, and any
|
|
109
|
+
* of them may be wrapped in the steps of a pipeline — `pipe(optional(string()),
|
|
110
|
+
* …)` is an `optional` under a `constrained` — so the wrappers are unwrapped
|
|
111
|
+
* before the question is asked. `nullable` is not here: `null` is a value, and
|
|
112
|
+
* a key holding it is present.
|
|
113
|
+
*/
|
|
114
|
+
function mayBeAbsent(description: Description): boolean {
|
|
115
|
+
return match (description) {
|
|
116
|
+
{kind: "optional", inner: _} => true,
|
|
117
|
+
{kind: "nullish", inner: _} => true,
|
|
118
|
+
{kind: "default", inner: _} => true,
|
|
119
|
+
{kind: "fallback", inner: _} => true,
|
|
120
|
+
{kind: "constrained", inner: const inner, constraint: _} => mayBeAbsent(inner),
|
|
121
|
+
{kind: "transformed", inner: const inner} => mayBeAbsent(inner),
|
|
122
|
+
_ => false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether a description is of a value some step already changed.
|
|
128
|
+
*
|
|
129
|
+
* Only the spine of pipeline wrappers is followed. A transform *inside* an
|
|
130
|
+
* object's field does not make the object transformed: the field's own node is
|
|
131
|
+
* where that is handled.
|
|
132
|
+
*/
|
|
133
|
+
function describesTransformedValue(description: Description): boolean {
|
|
134
|
+
return match (description) {
|
|
135
|
+
{kind: "transformed", inner: _} => true,
|
|
136
|
+
{kind: "constrained", inner: const inner, constraint: _} => describesTransformedValue(inner),
|
|
137
|
+
_ => false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What to call a constraint in the `unrepresentable` list. */
|
|
142
|
+
function labelFor(constraint: Constraint): string {
|
|
143
|
+
return constraint.kind === "opaque" ? `check ${constraint.label}` : constraint.kind;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The JSON Schema keywords one `pipe` step contributes, if any. */
|
|
147
|
+
function keywordsFor(constraint: Constraint): JsonSchemaNode {
|
|
148
|
+
return match (constraint) {
|
|
149
|
+
{kind: "minLength", value: const value} => { minLength: value },
|
|
150
|
+
{kind: "maxLength", value: const value} => { maxLength: value },
|
|
151
|
+
{kind: "length", value: const value} => { minLength: value, maxLength: value },
|
|
152
|
+
{kind: "minItems", value: const value} => { minItems: value },
|
|
153
|
+
{kind: "maxItems", value: const value} => { maxItems: value },
|
|
154
|
+
{kind: "min", value: const value} => { minimum: value },
|
|
155
|
+
{kind: "max", value: const value} => { maximum: value },
|
|
156
|
+
{kind: "integer"} => { type: "integer" },
|
|
157
|
+
{kind: "multipleOf", value: const value} => { multipleOf: value },
|
|
158
|
+
{kind: "pattern", source: const source} => { pattern: source },
|
|
159
|
+
{kind: "format", name: const name} => { format: name },
|
|
160
|
+
{kind: "brand", name: const name} => { title: name },
|
|
161
|
+
{kind: "opaque", label: _} => {},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Convert `schema` to a JSON Schema document.
|
|
167
|
+
*
|
|
168
|
+
* The walk keeps three pieces of state — the definitions a recursive schema
|
|
169
|
+
* needs, the names already given out, and the places JSON Schema could not
|
|
170
|
+
* say what the schema meant — which is why it is a closure over a converter
|
|
171
|
+
* rather than a free function.
|
|
172
|
+
*/
|
|
173
|
+
export function toJsonSchema(schema: Schema<mixed, mixed>): JsonSchemaExport {
|
|
174
|
+
const definitions: { [string]: JsonSchemaNode, ... } = {};
|
|
175
|
+
const names = new Map<symbol, string>();
|
|
176
|
+
const unrepresentable: Array<Unrepresentable> = [];
|
|
177
|
+
|
|
178
|
+
function unsupported(kind: string, path: Path): JsonSchemaNode {
|
|
179
|
+
unrepresentable.push({ path: path.slice(), kind });
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function object(
|
|
184
|
+
entries: $ReadOnlyArray<[string, Description]>,
|
|
185
|
+
unknownKeys: "strip" | "reject" | "keep",
|
|
186
|
+
path: Path,
|
|
187
|
+
): JsonSchemaNode {
|
|
188
|
+
const properties: { [string]: JsonSchemaNode, ... } = {};
|
|
189
|
+
const required: Array<string> = [];
|
|
190
|
+
for (const [key, inner] of entries) {
|
|
191
|
+
put(properties, key, convert(inner, path.concat(key)));
|
|
192
|
+
if (!mayBeAbsent(inner)) {
|
|
193
|
+
required.push(key);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const base = { type: "object", properties, required };
|
|
197
|
+
return unknownKeys === "reject" ? merge(base, { additionalProperties: false }) : base;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function recursive(id: symbol, inner: () => Description, path: Path): JsonSchemaNode {
|
|
201
|
+
const already = names.get(id);
|
|
202
|
+
if (already != null) {
|
|
203
|
+
return { $ref: `#/$defs/${already}` };
|
|
204
|
+
}
|
|
205
|
+
const name = `definition${String(names.size)}`;
|
|
206
|
+
names.set(id, name);
|
|
207
|
+
put(definitions, name, convert(inner(), path));
|
|
208
|
+
return { $ref: `#/$defs/${name}` };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function convert(description: Description, path: Path): JsonSchemaNode {
|
|
212
|
+
return match (description) {
|
|
213
|
+
{kind: "unknown"} => {},
|
|
214
|
+
{kind: "never"} => { not: {} },
|
|
215
|
+
{kind: "string"} => { type: "string" },
|
|
216
|
+
{kind: "number"} => { type: "number" },
|
|
217
|
+
{kind: "boolean"} => { type: "boolean" },
|
|
218
|
+
{kind: "null"} => { type: "null" },
|
|
219
|
+
{kind: "bigint"} => unsupported("bigint", path),
|
|
220
|
+
{kind: "undefined"} => unsupported("undefined", path),
|
|
221
|
+
{kind: "date"} => unsupported("date", path),
|
|
222
|
+
{kind: "instance", name: const name} => unsupported(`instance ${name}`, path),
|
|
223
|
+
{kind: "custom", name: const name} => unsupported(`custom ${name}`, path),
|
|
224
|
+
{kind: "map", key: _, value: _} => unsupported("map", path),
|
|
225
|
+
{kind: "set", item: _} => unsupported("set", path),
|
|
226
|
+
{kind: "literal", value: const value} => { const: value },
|
|
227
|
+
{kind: "enum", values: const values} => { enum: values.slice() },
|
|
228
|
+
{kind: "array", item: const item} =>
|
|
229
|
+
{
|
|
230
|
+
type: "array",
|
|
231
|
+
items: convert(item, path.concat("*")),
|
|
232
|
+
},
|
|
233
|
+
{kind: "tuple", items: const items} =>
|
|
234
|
+
{
|
|
235
|
+
type: "array",
|
|
236
|
+
prefixItems: items.map((item, index) => convert(item, path.concat(String(index)))),
|
|
237
|
+
minItems: items.length,
|
|
238
|
+
maxItems: items.length,
|
|
239
|
+
},
|
|
240
|
+
{kind: "record", value: const value} =>
|
|
241
|
+
{
|
|
242
|
+
type: "object",
|
|
243
|
+
additionalProperties: convert(value, path.concat("*")),
|
|
244
|
+
},
|
|
245
|
+
{kind: "object", entries: const entries, unknownKeys: const unknownKeys} =>
|
|
246
|
+
object(entries, unknownKeys, path),
|
|
247
|
+
{kind: "union", options: const options} =>
|
|
248
|
+
{
|
|
249
|
+
anyOf: options.map((option) => convert(option, path)),
|
|
250
|
+
},
|
|
251
|
+
{kind: "variant", key: _, branches: const branches} =>
|
|
252
|
+
{
|
|
253
|
+
oneOf: branches.map(([, branch]) => convert(branch, path)),
|
|
254
|
+
},
|
|
255
|
+
{kind: "intersect", parts: const parts} =>
|
|
256
|
+
{
|
|
257
|
+
allOf: parts.map((part) => convert(part, path)),
|
|
258
|
+
},
|
|
259
|
+
{kind: "optional", inner: const inner} => convert(inner, path),
|
|
260
|
+
{kind: "default", inner: const inner} => convert(inner, path),
|
|
261
|
+
{kind: "nullable", inner: const inner} =>
|
|
262
|
+
{
|
|
263
|
+
anyOf: [convert(inner, path), { type: "null" }],
|
|
264
|
+
},
|
|
265
|
+
{kind: "nullish", inner: const inner} =>
|
|
266
|
+
{
|
|
267
|
+
anyOf: [convert(inner, path), { type: "null" }],
|
|
268
|
+
},
|
|
269
|
+
{kind: "fallback", inner: _} => {},
|
|
270
|
+
{kind: "transformed", inner: const inner} => convert(inner, path),
|
|
271
|
+
{kind: "lazy", id: const id, inner: const inner} => recursive(id, inner, path),
|
|
272
|
+
{kind: "constrained", inner: const inner, constraint: const constraint} =>
|
|
273
|
+
constrained(inner, constraint, path),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A pipeline step's keywords on top of what it refined.
|
|
279
|
+
*
|
|
280
|
+
* Two of them contribute nothing. A `check`'s predicate is a closure, and a
|
|
281
|
+
* step that sits above a `transform` is about the output rather than the
|
|
282
|
+
* input this document describes. Both leave the node alone and add a line
|
|
283
|
+
* saying the document is looser than the schema at that path.
|
|
284
|
+
*/
|
|
285
|
+
function constrained(inner: Description, constraint: Constraint, path: Path): JsonSchemaNode {
|
|
286
|
+
const node = convert(inner, path);
|
|
287
|
+
if (describesTransformedValue(inner)) {
|
|
288
|
+
unrepresentable.push({
|
|
289
|
+
path: path.slice(),
|
|
290
|
+
kind: `${labelFor(constraint)} after a transform`,
|
|
291
|
+
});
|
|
292
|
+
return node;
|
|
293
|
+
}
|
|
294
|
+
if (constraint.kind === "opaque") {
|
|
295
|
+
unrepresentable.push({ path: path.slice(), kind: labelFor(constraint) });
|
|
296
|
+
return node;
|
|
297
|
+
}
|
|
298
|
+
return merge(node, keywordsFor(constraint));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const root = convert(describe(schema), []);
|
|
302
|
+
const document =
|
|
303
|
+
Object.keys(definitions).length === 0
|
|
304
|
+
? merge({ $schema: DIALECT }, root)
|
|
305
|
+
: merge(merge({ $schema: DIALECT }, root), { $defs: definitions });
|
|
306
|
+
return { schema: document, unrepresentable };
|
|
307
|
+
}
|
package/lazy.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/lazy`: a schema that does not exist yet.
|
|
4
|
+
//
|
|
5
|
+
// A comment has replies, and a reply is a comment. There is no order in which
|
|
6
|
+
// to write that down, because the initialiser of `Comment` would have to name
|
|
7
|
+
// `Comment`, and at that moment it is still `undefined`. So the schema is
|
|
8
|
+
// wrapped in a function, and the function is not called until the first parse:
|
|
9
|
+
//
|
|
10
|
+
// type Comment = {| text: string, replies: $ReadOnlyArray<Comment> |};
|
|
11
|
+
//
|
|
12
|
+
// const Comment: Schema<Comment> = lazy(() =>
|
|
13
|
+
// object({ text: string(), replies: array(Comment) }),
|
|
14
|
+
// );
|
|
15
|
+
//
|
|
16
|
+
// The annotation is the other half, and it is not optional. Flow will infer
|
|
17
|
+
// the type of almost every schema in this package, but it will not solve a
|
|
18
|
+
// type that mentions itself; the `Schema<Comment>` on the binding is where the
|
|
19
|
+
// cycle is cut, and `infer.js` says so among the other limits.
|
|
20
|
+
//
|
|
21
|
+
// # Why the result is memoised
|
|
22
|
+
//
|
|
23
|
+
// Without it, every node of the tree calls `build()` and gets a fresh object
|
|
24
|
+
// graph — a thousand-comment thread would construct a thousand schemas, none
|
|
25
|
+
// of which is reused, on a parse that should have allocated nothing. With it,
|
|
26
|
+
// recursion costs one closure for the whole schema.
|
|
27
|
+
//
|
|
28
|
+
// # Why there are two of these
|
|
29
|
+
//
|
|
30
|
+
// Everything else in the package works out whether it is asynchronous when it
|
|
31
|
+
// is built: `object` asks its fields, `array` asks its item. A lazy schema
|
|
32
|
+
// cannot ask, because the thing it would ask does not exist yet, and building
|
|
33
|
+
// it to find out is the infinite loop the laziness was there to avoid.
|
|
34
|
+
//
|
|
35
|
+
// So the answer is declared instead of discovered. [`lazy`] is synchronous and
|
|
36
|
+
// [`lazyAsync`] is not, and a recursive schema with a `checkAsync` anywhere
|
|
37
|
+
// inside it must use the second — otherwise the objects and arrays above it
|
|
38
|
+
// build their synchronous halves, and the first `await` that never happens
|
|
39
|
+
// surfaces as a thrown "use safeParseAsync" from the middle of a parse rather
|
|
40
|
+
// than as a type the caller could have seen.
|
|
41
|
+
|
|
42
|
+
import type { Description, Schema } from "./schema.js";
|
|
43
|
+
import { describe, makeAsyncSchema, makeSchema, run, runAsync } from "./schema.js";
|
|
44
|
+
|
|
45
|
+
/** Build once, then hand back the same schema for the life of the process. */
|
|
46
|
+
function memoise<TOutput, TInput>(
|
|
47
|
+
build: () => Schema<TOutput, TInput>,
|
|
48
|
+
): () => Schema<TOutput, TInput> {
|
|
49
|
+
let built: null | Schema<TOutput, TInput> = null;
|
|
50
|
+
return () => {
|
|
51
|
+
const already = built;
|
|
52
|
+
if (already != null) {
|
|
53
|
+
return already;
|
|
54
|
+
}
|
|
55
|
+
const made = build();
|
|
56
|
+
built = made;
|
|
57
|
+
return made;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A synchronous schema built on first use.
|
|
63
|
+
*
|
|
64
|
+
* `id` is what a converter keys its definitions on: `json-schema.js` sees the
|
|
65
|
+
* same symbol every time it walks back around the cycle, which is how a
|
|
66
|
+
* recursive schema becomes a `$ref` rather than a stack overflow.
|
|
67
|
+
*/
|
|
68
|
+
export function lazy<TOutput, TInput = mixed>(
|
|
69
|
+
build: () => Schema<TOutput, TInput>,
|
|
70
|
+
): Schema<TOutput, TInput> {
|
|
71
|
+
const resolve = memoise(build);
|
|
72
|
+
const id = Symbol("lazy");
|
|
73
|
+
const description = (): Description => ({
|
|
74
|
+
kind: "lazy",
|
|
75
|
+
id,
|
|
76
|
+
inner: () => describe(resolve()),
|
|
77
|
+
});
|
|
78
|
+
return makeSchema((value, path) => run(resolve(), value, path), description);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A recursive schema with an asynchronous step somewhere inside it. */
|
|
82
|
+
export function lazyAsync<TOutput, TInput = mixed>(
|
|
83
|
+
build: () => Schema<TOutput, TInput>,
|
|
84
|
+
): Schema<TOutput, TInput> {
|
|
85
|
+
const resolve = memoise(build);
|
|
86
|
+
const id = Symbol("lazyAsync");
|
|
87
|
+
const description = (): Description => ({
|
|
88
|
+
kind: "lazy",
|
|
89
|
+
id,
|
|
90
|
+
inner: () => describe(resolve()),
|
|
91
|
+
});
|
|
92
|
+
return makeAsyncSchema((value, path) => runAsync(resolve(), value, path), description);
|
|
93
|
+
}
|