@uniflowed/validator 0.0.0-alpha.4 → 0.0.0-alpha.6
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/parse.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/parse`: the four ways to run a schema.
|
|
4
|
+
//
|
|
5
|
+
// safeParse(User, body) // a Result: failure is a value
|
|
6
|
+
// parse(User, body) // the value, or a thrown ValidationError
|
|
7
|
+
// await safeParseAsync(…) // the same, for a schema that has to wait
|
|
8
|
+
// await parseAsync(…)
|
|
9
|
+
//
|
|
10
|
+
// Two axes, and both of them are a real choice rather than a style.
|
|
11
|
+
//
|
|
12
|
+
// # Throwing or not
|
|
13
|
+
//
|
|
14
|
+
// A boundary that already has a failure path — an HTTP handler building a 422,
|
|
15
|
+
// a form collecting field errors — wants [`safeParse`], because a `throw` there
|
|
16
|
+
// is a control-flow detour to reach a value it was going to inspect anyway.
|
|
17
|
+
// Code with no failure path — a configuration file the process cannot start
|
|
18
|
+
// without, a fixture in a test — wants [`parse`], because the alternative is an
|
|
19
|
+
// `if (!result.ok) throw` at every call site.
|
|
20
|
+
//
|
|
21
|
+
// [`parse`] raises [`ValidationError`], which carries the structured issues
|
|
22
|
+
// rather than only a joined message. An HTTP handler needs the field paths to
|
|
23
|
+
// build a response body, and re-parsing them out of a string is not a thing an
|
|
24
|
+
// API should make anybody do.
|
|
25
|
+
//
|
|
26
|
+
// # Waiting or not
|
|
27
|
+
//
|
|
28
|
+
// A schema is asynchronous, or it is not, and it knew which when it was built
|
|
29
|
+
// — `schema.js` says why. So the synchronous entry points do not return a
|
|
30
|
+
// promise sometimes: given a schema with a `checkAsync` in it they throw an
|
|
31
|
+
// `Error` naming the asynchronous pair, which is a programmer's mistake
|
|
32
|
+
// reported as one. It is not a [`ValidationError`], because nothing was
|
|
33
|
+
// invalid.
|
|
34
|
+
//
|
|
35
|
+
// The asynchronous entry points accept both kinds. A schema with nothing to
|
|
36
|
+
// wait for resolves on the first microtask, so a caller that does not know
|
|
37
|
+
// which it has — a generic resolver, a request handler taking a schema from a
|
|
38
|
+
// route table — can always use them.
|
|
39
|
+
|
|
40
|
+
import type { Result, Schema } from "./schema.js";
|
|
41
|
+
import { runAsync, run } from "./schema.js";
|
|
42
|
+
import { ValidationError } from "./issue.js";
|
|
43
|
+
|
|
44
|
+
/** Parse into a result, so failure is a value rather than control flow. */
|
|
45
|
+
export function safeParse<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): Result<TOutput> {
|
|
46
|
+
return run(schema, value, []);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse, or raise a [`ValidationError`] carrying every issue found. */
|
|
50
|
+
export function parse<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): TOutput {
|
|
51
|
+
const result = safeParse(schema, value);
|
|
52
|
+
if (result.ok) {
|
|
53
|
+
return result.value;
|
|
54
|
+
}
|
|
55
|
+
throw new ValidationError(result.issues);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** [`safeParse`], for a schema with something to wait for. */
|
|
59
|
+
export function safeParseAsync<TOutput>(
|
|
60
|
+
schema: Schema<TOutput, mixed>,
|
|
61
|
+
value: mixed,
|
|
62
|
+
): Promise<Result<TOutput>> {
|
|
63
|
+
return runAsync(schema, value, []);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** [`parse`], for a schema with something to wait for. */
|
|
67
|
+
export async function parseAsync<TOutput>(
|
|
68
|
+
schema: Schema<TOutput, mixed>,
|
|
69
|
+
value: mixed,
|
|
70
|
+
): Promise<TOutput> {
|
|
71
|
+
const result = await safeParseAsync(schema, value);
|
|
72
|
+
if (result.ok) {
|
|
73
|
+
return result.value;
|
|
74
|
+
}
|
|
75
|
+
throw new ValidationError(result.issues);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether `value` would parse.
|
|
80
|
+
*
|
|
81
|
+
* A `boolean` and not a type guard. Flow's `value is T` needs the predicate to
|
|
82
|
+
* be provable from the function's body, and here the proof is a closure the
|
|
83
|
+
* checker cannot see through; a guard would be a claim rather than a check.
|
|
84
|
+
* Narrow with [`safeParse`] and read `result.value`, which is the same
|
|
85
|
+
* information with the value attached.
|
|
86
|
+
*/
|
|
87
|
+
export function is(schema: Schema<mixed, mixed>, value: mixed): boolean {
|
|
88
|
+
return safeParse(schema, value).ok;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A schema as a standalone function.
|
|
93
|
+
*
|
|
94
|
+
* `safeParse(schema, value)` needs both halves at the call site, which is fine
|
|
95
|
+
* where the schema is in scope and useless where it is not — a boundary that
|
|
96
|
+
* wants to validate what arrives takes a *function*, not a schema and an
|
|
97
|
+
* import of this package. `parser(User)` is that function, and it is why
|
|
98
|
+
* `@uniflowed/fetch` can check a response body without depending on the
|
|
99
|
+
* validator at all.
|
|
100
|
+
*/
|
|
101
|
+
export function parser<TOutput>(schema: Schema<TOutput, mixed>): (value: mixed) => Result<TOutput> {
|
|
102
|
+
return (value: mixed) => safeParse(schema, value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate a value during render.
|
|
107
|
+
*
|
|
108
|
+
* A hook rather than a plain call so the React Compiler memoises it with the
|
|
109
|
+
* rest of the component: re-rendering for an unrelated reason does not re-walk
|
|
110
|
+
* the payload. It is here rather than in a module of its own because it is
|
|
111
|
+
* [`safeParse`] at a render boundary and not a second idea — and because
|
|
112
|
+
* nothing in this package imports React to provide it.
|
|
113
|
+
*/
|
|
114
|
+
export hook useValidation<TOutput>(schema: Schema<TOutput, mixed>, value: mixed): Result<TOutput> {
|
|
115
|
+
return safeParse(schema, value);
|
|
116
|
+
}
|
package/pipe.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/pipe`: a schema with steps after it.
|
|
4
|
+
//
|
|
5
|
+
// const Handle = pipe(string(), trim(), minLength(3), startsWith("@"));
|
|
6
|
+
// const Age = pipe(string(), transform(Number), integer(), min(18));
|
|
7
|
+
//
|
|
8
|
+
// A step takes a schema and returns a schema, so a pipeline is a fold and
|
|
9
|
+
// nothing more. `pipe` is variadic because refinement is cumulative in
|
|
10
|
+
// practice — a handle is a string that is trimmed *and* long enough *and*
|
|
11
|
+
// starts with an at-sign — and making that `pipe(pipe(pipe(…)))` is a tax on
|
|
12
|
+
// the only case anybody has.
|
|
13
|
+
//
|
|
14
|
+
// # A pipeline may change the output type
|
|
15
|
+
//
|
|
16
|
+
// `transform` is the reason the type has two parameters. `pipe(string(),
|
|
17
|
+
// transform(Number))` accepts a `string` and produces a `number`, and both
|
|
18
|
+
// halves survive: `InferInput` is `string`, `InferOutput` is `number`. That is
|
|
19
|
+
// what lets `@uniflowed/form` type `defaultValues` as what the controls hold
|
|
20
|
+
// and `onValid` as what the application wanted, from one schema.
|
|
21
|
+
//
|
|
22
|
+
// A step is polymorphic in the input type — `<TInput>(Schema<TFrom, TInput>)
|
|
23
|
+
// => Schema<TTo, TInput>` — which is how the pipeline's input survives every
|
|
24
|
+
// step after the first. It is also why a step is written as a generic arrow
|
|
25
|
+
// rather than a plain one.
|
|
26
|
+
//
|
|
27
|
+
// # Why the overload table
|
|
28
|
+
//
|
|
29
|
+
// Flow cannot fold a type over a variadic argument list, so the relationship
|
|
30
|
+
// "the output of step *n* is the input of step *n+1*" has to be spelled out
|
|
31
|
+
// once per arity. Eight of them, which covers every pipeline this repository
|
|
32
|
+
// or Valibot's own examples contain; a ninth step is a type error, and the fix
|
|
33
|
+
// is to name the first eight and pipe the result. The implementation under the
|
|
34
|
+
// table is arity-agnostic and there is exactly one cast in this package to
|
|
35
|
+
// join the two, marked where it happens.
|
|
36
|
+
//
|
|
37
|
+
// The alternative was the previous signature, `pipe(schema, ...steps:
|
|
38
|
+
// Step<any, any>): Schema<any>`, which type-checked everything and knew
|
|
39
|
+
// nothing: it silently erased the output type of every pipeline in every
|
|
40
|
+
// application, and `uf lint` was right to reject it.
|
|
41
|
+
//
|
|
42
|
+
// # Where a cross-field rule reports
|
|
43
|
+
//
|
|
44
|
+
// [`check`] takes an optional path. A rule that compares two fields lives on
|
|
45
|
+
// the object — it needs both of them — but the message belongs under the
|
|
46
|
+
// control the user has to change:
|
|
47
|
+
//
|
|
48
|
+
// pipe(
|
|
49
|
+
// object({ password: string(), confirm: string() }),
|
|
50
|
+
// check((form) => form.password === form.confirm, "Passwords must match", ["confirm"]),
|
|
51
|
+
// );
|
|
52
|
+
//
|
|
53
|
+
// Without it the issue arrives with the object's own path and a form has
|
|
54
|
+
// nowhere to put it.
|
|
55
|
+
|
|
56
|
+
import type { Path } from "./issue.js";
|
|
57
|
+
import { issueUnder } from "./issue.js";
|
|
58
|
+
import type { Constraint, Description, Schema } from "./schema.js";
|
|
59
|
+
import { describe, isAsync, makeAsyncSchema, makeSchema, ok, run, runAsync } from "./schema.js";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One stage of a pipeline.
|
|
63
|
+
*
|
|
64
|
+
* Polymorphic in `TInput` so that the type of the pipeline's *input* is
|
|
65
|
+
* carried through every step rather than being flattened to `mixed` at the
|
|
66
|
+
* first one.
|
|
67
|
+
*/
|
|
68
|
+
export type Step<TFrom, TTo> = <TInput>(Schema<TFrom, TInput>) => Schema<TTo, TInput>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `pipe`'s type: one call signature per number of steps.
|
|
72
|
+
*
|
|
73
|
+
* Inexact, because an exact object type with call properties cannot be
|
|
74
|
+
* inhabited by a function.
|
|
75
|
+
*/
|
|
76
|
+
export type Pipe = {
|
|
77
|
+
<A, AIn>(schema: Schema<A, AIn>): Schema<A, AIn>,
|
|
78
|
+
<A, AIn, B>(schema: Schema<A, AIn>, a: Step<A, B>): Schema<B, AIn>,
|
|
79
|
+
<A, AIn, B, C>(schema: Schema<A, AIn>, a: Step<A, B>, b: Step<B, C>): Schema<C, AIn>,
|
|
80
|
+
<A, AIn, B, C, D>(
|
|
81
|
+
schema: Schema<A, AIn>,
|
|
82
|
+
a: Step<A, B>,
|
|
83
|
+
b: Step<B, C>,
|
|
84
|
+
c: Step<C, D>,
|
|
85
|
+
): Schema<D, AIn>,
|
|
86
|
+
<A, AIn, B, C, D, E>(
|
|
87
|
+
schema: Schema<A, AIn>,
|
|
88
|
+
a: Step<A, B>,
|
|
89
|
+
b: Step<B, C>,
|
|
90
|
+
c: Step<C, D>,
|
|
91
|
+
d: Step<D, E>,
|
|
92
|
+
): Schema<E, AIn>,
|
|
93
|
+
<A, AIn, B, C, D, E, F>(
|
|
94
|
+
schema: Schema<A, AIn>,
|
|
95
|
+
a: Step<A, B>,
|
|
96
|
+
b: Step<B, C>,
|
|
97
|
+
c: Step<C, D>,
|
|
98
|
+
d: Step<D, E>,
|
|
99
|
+
e: Step<E, F>,
|
|
100
|
+
): Schema<F, AIn>,
|
|
101
|
+
<A, AIn, B, C, D, E, F, G>(
|
|
102
|
+
schema: Schema<A, AIn>,
|
|
103
|
+
a: Step<A, B>,
|
|
104
|
+
b: Step<B, C>,
|
|
105
|
+
c: Step<C, D>,
|
|
106
|
+
d: Step<D, E>,
|
|
107
|
+
e: Step<E, F>,
|
|
108
|
+
f: Step<F, G>,
|
|
109
|
+
): Schema<G, AIn>,
|
|
110
|
+
<A, AIn, B, C, D, E, F, G, H>(
|
|
111
|
+
schema: Schema<A, AIn>,
|
|
112
|
+
a: Step<A, B>,
|
|
113
|
+
b: Step<B, C>,
|
|
114
|
+
c: Step<C, D>,
|
|
115
|
+
d: Step<D, E>,
|
|
116
|
+
e: Step<E, F>,
|
|
117
|
+
f: Step<F, G>,
|
|
118
|
+
g: Step<G, H>,
|
|
119
|
+
): Schema<H, AIn>,
|
|
120
|
+
<A, AIn, B, C, D, E, F, G, H, I>(
|
|
121
|
+
schema: Schema<A, AIn>,
|
|
122
|
+
a: Step<A, B>,
|
|
123
|
+
b: Step<B, C>,
|
|
124
|
+
c: Step<C, D>,
|
|
125
|
+
d: Step<D, E>,
|
|
126
|
+
e: Step<E, F>,
|
|
127
|
+
f: Step<F, G>,
|
|
128
|
+
g: Step<G, H>,
|
|
129
|
+
h: Step<H, I>,
|
|
130
|
+
): Schema<I, AIn>,
|
|
131
|
+
...
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function applySteps<TInput>(
|
|
135
|
+
schema: Schema<mixed, TInput>,
|
|
136
|
+
...steps: $ReadOnlyArray<Step<mixed, mixed>>
|
|
137
|
+
): Schema<mixed, TInput> {
|
|
138
|
+
let piped = schema;
|
|
139
|
+
for (const step of steps) {
|
|
140
|
+
piped = step<TInput>(piped);
|
|
141
|
+
}
|
|
142
|
+
return piped;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Apply steps to a schema, left to right. */
|
|
146
|
+
// $FlowFixMe[incompatible-type] the overload table above is the checked surface.
|
|
147
|
+
export const pipe: Pipe = applySteps as Pipe;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A schema with one more thing that must be true of its output.
|
|
151
|
+
*
|
|
152
|
+
* Every named step in `action.js` is a call to this. The `constraint` is what
|
|
153
|
+
* makes the step visible to `json-schema.js`: a refinement that cannot say
|
|
154
|
+
* what it refined is invisible to every exporter.
|
|
155
|
+
*/
|
|
156
|
+
export function refine<TOutput, TInput>(
|
|
157
|
+
schema: Schema<TOutput, TInput>,
|
|
158
|
+
accepts: (value: TOutput) => boolean,
|
|
159
|
+
code: string,
|
|
160
|
+
message: string,
|
|
161
|
+
constraint: Constraint,
|
|
162
|
+
at: Path = [],
|
|
163
|
+
): Schema<TOutput, TInput> {
|
|
164
|
+
const description = (): Description => ({
|
|
165
|
+
kind: "constrained",
|
|
166
|
+
inner: describe(schema),
|
|
167
|
+
constraint,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (isAsync(schema)) {
|
|
171
|
+
return makeAsyncSchema(async (value, path) => {
|
|
172
|
+
const result = await runAsync(schema, value, path);
|
|
173
|
+
if (!result.ok || accepts(result.value)) {
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
return { ok: false, issues: [issueUnder(code, message, path, at)] };
|
|
177
|
+
}, description);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return makeSchema((value, path) => {
|
|
181
|
+
const result = run(schema, value, path);
|
|
182
|
+
if (!result.ok || accepts(result.value)) {
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
return { ok: false, issues: [issueUnder(code, message, path, at)] };
|
|
186
|
+
}, description);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* An arbitrary predicate, with the message it should report.
|
|
191
|
+
*
|
|
192
|
+
* Every other step in the package is a special case of this one. It exists so
|
|
193
|
+
* that a rule the library did not anticipate — a checksum, a business rule,
|
|
194
|
+
* one field agreeing with another — is a one-liner rather than a reason to
|
|
195
|
+
* abandon the schema and hand-roll validation.
|
|
196
|
+
*
|
|
197
|
+
* `at` is where the issue lands, relative to the value being checked. See the
|
|
198
|
+
* module docs for the cross-field case it is there for.
|
|
199
|
+
*/
|
|
200
|
+
export function check<TValue>(
|
|
201
|
+
accepts: (value: TValue) => boolean,
|
|
202
|
+
message: string,
|
|
203
|
+
at: Path = [],
|
|
204
|
+
): Step<TValue, TValue> {
|
|
205
|
+
return <TInput>(schema: Schema<TValue, TInput>): Schema<TValue, TInput> =>
|
|
206
|
+
refine(schema, accepts, "check", message, { kind: "opaque", label: message }, at);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A predicate that has to ask something.
|
|
211
|
+
*
|
|
212
|
+
* "Is this username taken" is a question with a network on the other end, and
|
|
213
|
+
* a schema containing one is asynchronous from here up: the object around it,
|
|
214
|
+
* the array around that, and [`safeParse`] will refuse it and say to use
|
|
215
|
+
* [`safeParseAsync`].
|
|
216
|
+
*/
|
|
217
|
+
export function checkAsync<TValue>(
|
|
218
|
+
accepts: (value: TValue) => Promise<boolean>,
|
|
219
|
+
message: string,
|
|
220
|
+
at: Path = [],
|
|
221
|
+
): Step<TValue, TValue> {
|
|
222
|
+
return <TInput>(schema: Schema<TValue, TInput>): Schema<TValue, TInput> =>
|
|
223
|
+
makeAsyncSchema(
|
|
224
|
+
async (value, path) => {
|
|
225
|
+
const result = await runAsync(schema, value, path);
|
|
226
|
+
if (!result.ok) {
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
return (await accepts(result.value))
|
|
230
|
+
? result
|
|
231
|
+
: { ok: false, issues: [issueUnder("check", message, path, at)] };
|
|
232
|
+
},
|
|
233
|
+
() => ({
|
|
234
|
+
kind: "constrained",
|
|
235
|
+
inner: describe(schema),
|
|
236
|
+
constraint: { kind: "opaque", label: message },
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Change the value, and with it the schema's output type.
|
|
243
|
+
*
|
|
244
|
+
* Runs after everything before it in the pipeline has accepted, so a transform
|
|
245
|
+
* never sees a value the steps above rejected — which is why
|
|
246
|
+
* `pipe(string(), minLength(2), transform((text) => text.length))` is safe to
|
|
247
|
+
* write in that order and means something different in the other.
|
|
248
|
+
*/
|
|
249
|
+
export function transform<TFrom, TTo>(change: (value: TFrom) => TTo): Step<TFrom, TTo> {
|
|
250
|
+
return <TInput>(schema: Schema<TFrom, TInput>): Schema<TTo, TInput> => {
|
|
251
|
+
const description = (): Description => ({ kind: "transformed", inner: describe(schema) });
|
|
252
|
+
if (isAsync(schema)) {
|
|
253
|
+
return makeAsyncSchema(async (value, path) => {
|
|
254
|
+
const result = await runAsync(schema, value, path);
|
|
255
|
+
return result.ok ? ok(change(result.value)) : result;
|
|
256
|
+
}, description);
|
|
257
|
+
}
|
|
258
|
+
return makeSchema((value, path) => {
|
|
259
|
+
const result = run(schema, value, path);
|
|
260
|
+
return result.ok ? ok(change(result.value)) : result;
|
|
261
|
+
}, description);
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A transform that has to wait: a lookup, a hash, a decode off the main path. */
|
|
266
|
+
export function transformAsync<TFrom, TTo>(
|
|
267
|
+
change: (value: TFrom) => Promise<TTo>,
|
|
268
|
+
): Step<TFrom, TTo> {
|
|
269
|
+
return <TInput>(schema: Schema<TFrom, TInput>): Schema<TTo, TInput> =>
|
|
270
|
+
makeAsyncSchema(
|
|
271
|
+
async (value, path) => {
|
|
272
|
+
const result = await runAsync(schema, value, path);
|
|
273
|
+
return result.ok ? ok(await change(result.value)) : result;
|
|
274
|
+
},
|
|
275
|
+
() => ({ kind: "transformed", inner: describe(schema) }),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A name on an otherwise ordinary value.
|
|
281
|
+
*
|
|
282
|
+
* It checks nothing at run time and it is not pretending to. The name reaches
|
|
283
|
+
* the description, so an exported schema can say "this string is a `UserId`";
|
|
284
|
+
* it does not reach the Flow type, because Flow's opaque types are declared in
|
|
285
|
+
* a module and cannot be produced by a call. `infer.js` says what to do
|
|
286
|
+
* instead when the distinction has to be enforced.
|
|
287
|
+
*/
|
|
288
|
+
export function brand<TValue>(name: string): Step<TValue, TValue> {
|
|
289
|
+
return <TInput>(schema: Schema<TValue, TInput>): Schema<TValue, TInput> => {
|
|
290
|
+
const description = (): Description => ({
|
|
291
|
+
kind: "constrained",
|
|
292
|
+
inner: describe(schema),
|
|
293
|
+
constraint: { kind: "brand", name },
|
|
294
|
+
});
|
|
295
|
+
if (isAsync(schema)) {
|
|
296
|
+
return makeAsyncSchema((value, path) => runAsync(schema, value, path), description);
|
|
297
|
+
}
|
|
298
|
+
return makeSchema((value, path) => run(schema, value, path), description);
|
|
299
|
+
};
|
|
300
|
+
}
|
package/plain-object.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/validator/plain-object`: reading and writing an object whose
|
|
4
|
+
// keys came from outside.
|
|
5
|
+
//
|
|
6
|
+
// Three functions, and they are separate from every schema that uses them
|
|
7
|
+
// because they are the package's whole answer to one question: what happens
|
|
8
|
+
// when a payload contains a key that means something to JavaScript.
|
|
9
|
+
//
|
|
10
|
+
// `{"__proto__": {"isAdmin": true}}` is valid JSON, and it is what an attacker
|
|
11
|
+
// sends. `out[key] = value` runs the legacy `__proto__` setter rather than
|
|
12
|
+
// adding a property, so the parsed object would come back with a prototype the
|
|
13
|
+
// attacker chose and every `record.isAdmin` downstream would read `true` from
|
|
14
|
+
// a property nobody ever validated. Reading is the mirror image: `input[key]`
|
|
15
|
+
// finds inherited properties, so a shape asking for `constructor` would be
|
|
16
|
+
// handed `Object`.
|
|
17
|
+
//
|
|
18
|
+
// `object.js`, `collection.js`, `union.js` and `issue.js` all build or read an
|
|
19
|
+
// object out of untrusted input, and every one of them goes through here
|
|
20
|
+
// rather than keeping its own copy of the rule. That is the reason this is a
|
|
21
|
+
// module and not three helpers at the top of the object parser: the defence is
|
|
22
|
+
// only a defence if there is exactly one of it.
|
|
23
|
+
|
|
24
|
+
/** Whether `value` is an object a shape or a record could be read from. */
|
|
25
|
+
export function isPlainObject(value: mixed): boolean {
|
|
26
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `value` as something with string keys.
|
|
31
|
+
*
|
|
32
|
+
* The single object boundary in the package. Every caller has already asked
|
|
33
|
+
* [`isPlainObject`], and every field that leaves a schema went through that
|
|
34
|
+
* schema first, so the `mixed` values this exposes are narrowed before they
|
|
35
|
+
* reach an output type.
|
|
36
|
+
*/
|
|
37
|
+
export function plainRecord(value: mixed): { readonly [string]: mixed, ... } {
|
|
38
|
+
// $FlowFixMe[incompatible-type] guarded by `isPlainObject` at every call.
|
|
39
|
+
return value as { readonly [string]: mixed, ... };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The own keys of `value`, and nothing inherited.
|
|
44
|
+
*
|
|
45
|
+
* `Object.keys` already skips the prototype chain, which is why it is here
|
|
46
|
+
* rather than `for (const key in value)`.
|
|
47
|
+
*/
|
|
48
|
+
export function ownKeys(value: { readonly [string]: mixed, ... }): $ReadOnlyArray<string> {
|
|
49
|
+
return Object.keys(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read one key, without falling through to the prototype.
|
|
54
|
+
*
|
|
55
|
+
* `record.constructor` is `Object` on every object in the language; a shape
|
|
56
|
+
* with a `constructor` field would otherwise be handed a function and report
|
|
57
|
+
* that the payload was fine. It also makes `object(shape)` answer the same way
|
|
58
|
+
* for `{}` and for a class instance with getters on its prototype, which is
|
|
59
|
+
* the sort of difference that is discovered in production.
|
|
60
|
+
*
|
|
61
|
+
* `Object.hasOwn` on every field read costs about 13% of a field-dense parse,
|
|
62
|
+
* measured on the workload in `index.js`. That is the price of the paragraph
|
|
63
|
+
* above and it is being paid deliberately: a payload out of `JSON.parse` has
|
|
64
|
+
* only own keys, so the check earns nothing there, and it earns everything the
|
|
65
|
+
* first time somebody hands a schema an object they built themselves.
|
|
66
|
+
*/
|
|
67
|
+
export function ownValue(source: { readonly [string]: mixed, ... }, key: string): mixed {
|
|
68
|
+
return Object.hasOwn(source, key) ? source[key] : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write one parsed field into the object being built.
|
|
73
|
+
*
|
|
74
|
+
* `__proto__` is the only key that needs `defineProperty`, and it is worth
|
|
75
|
+
* knowing why rather than reaching for it on every field. `Object.prototype`
|
|
76
|
+
* has exactly one accessor on it — `__proto__` — and assignment to a key an
|
|
77
|
+
* accessor owns runs the setter instead of adding a property. Every other
|
|
78
|
+
* inherited name (`constructor`, `toString`, `valueOf`) is a *data* property,
|
|
79
|
+
* and assigning to one of those shadows it with an own property on the
|
|
80
|
+
* receiver, which is what a parsed field is supposed to be.
|
|
81
|
+
*
|
|
82
|
+
* So one string comparison is the whole defence against a hostile payload, and
|
|
83
|
+
* `defineProperty` is the slow path for the one key that needs it. That is not
|
|
84
|
+
* a micro-optimisation: `defineProperty` on every field made a thousand-record
|
|
85
|
+
* parse three times slower than the same parse with an assignment in it — more
|
|
86
|
+
* than the entire rest of the walk cost — and the measurement is in
|
|
87
|
+
* `index.js`.
|
|
88
|
+
*
|
|
89
|
+
* What this does not defend against is an `Object.prototype` that some other
|
|
90
|
+
* code has already given a setter to. Nothing in a parser can: a process whose
|
|
91
|
+
* `Object.prototype` is writable by an attacker has lost, and every read the
|
|
92
|
+
* consumer makes afterwards goes through the same polluted object.
|
|
93
|
+
*/
|
|
94
|
+
export function put<Value>(out: { [string]: Value, ... }, key: string, value: Value): void {
|
|
95
|
+
if (key !== "__proto__") {
|
|
96
|
+
out[key] = value;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
Object.defineProperty(out, key, {
|
|
100
|
+
value,
|
|
101
|
+
writable: true,
|
|
102
|
+
enumerable: true,
|
|
103
|
+
configurable: true,
|
|
104
|
+
});
|
|
105
|
+
}
|