@constructive-io/coerce 0.1.0 → 0.2.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/README.md +25 -6
- package/coerce.d.ts +93 -0
- package/coerce.js +204 -1
- package/esm/coerce.js +189 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -56,9 +56,32 @@ const messageId = requireString(body.message_id, 'message_id'); // string, or th
|
|
|
56
56
|
| `asIsoString` | `requireIsoString` | a `Date` (serialised) or a string (verbatim) |
|
|
57
57
|
| `asOneOf` | `requireOneOf` | one of the given string literals |
|
|
58
58
|
|
|
59
|
+
Then the text-carried and named shapes — what an env var, a query string or a header hands you:
|
|
60
|
+
|
|
61
|
+
| lenient | strict | accepts |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `asNumeric` | `requireNumeric` | a finite number, or a numeric string (`'5'`) |
|
|
64
|
+
| `asNumericInteger` | `requireNumericInteger` | an integer, or an integral string (`'5'`) |
|
|
65
|
+
| `asNumberIn` | `requireNumberIn` | a finite number within `{ min, max }`, inclusive |
|
|
66
|
+
| `asIntegerIn` | `requireIntegerIn` | an integer within `{ min, max }`, inclusive |
|
|
67
|
+
| `asPort` | `requirePort` | a port: an integer in `1..65535`, from a number or a string |
|
|
68
|
+
| `asBigInt` | `requireBigInt` | a `bigint`, an integer, or a digit string |
|
|
69
|
+
| `asUrl` | `requireUrl` | an absolute URL — a scheme is required |
|
|
70
|
+
| `asHostname` | `requireHostname` | a hostname or IP address — no scheme, port or path |
|
|
71
|
+
| `asEmail` | `requireEmail` | an email address |
|
|
72
|
+
| `asUuid` | `requireUuid` | a canonical UUID |
|
|
73
|
+
| `asDuration` | `requireDuration` | milliseconds, or a suffixed duration (`'30s'`, `'5m'`, `'2h'`, `'1d'`, `'1w'`) |
|
|
74
|
+
| `asJson` | `requireJson` | an object or array, or a string that parses to one |
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const port = asPort(process.env.PORT); // 1..65535, or null
|
|
78
|
+
const ttl = asDuration('30s'); // 30000
|
|
79
|
+
const timeout = asIntegerIn(body.timeout_ms, { min: 1, max: 60_000 });
|
|
80
|
+
```
|
|
81
|
+
|
|
59
82
|
## rules
|
|
60
83
|
|
|
61
|
-
- **No guessing across types.** `'5'` is not an integer and `1` is not a string.
|
|
84
|
+
- **No guessing across types.** `'5'` is not an integer to `asInteger` and `1` is not a string. A JSON body that sent a string meant a string; the coercers that do read text (`asNumeric`, `asPort`, `asDuration`, `asBoolean`, `asDate`, `asJson`) say so in their names, because an env var or a query string carries nothing else.
|
|
62
85
|
- **A blank string is absent.** `''` and `' '` coerce to `null`, so an empty value can never read as a supplied identifier.
|
|
63
86
|
- **All-or-nothing lists.** `asStringArray` rejects the whole array rather than dropping bad entries — that is how a list of allowed origins quietly shrinks.
|
|
64
87
|
- **No transport semantics.** `CoerceError` carries the `label` that failed, not an HTTP status. Map it at your boundary:
|
|
@@ -74,11 +97,7 @@ try {
|
|
|
74
97
|
|
|
75
98
|
## related
|
|
76
99
|
|
|
77
|
-
`12factor-env`
|
|
78
|
-
|
|
79
|
-
## license
|
|
80
|
-
|
|
81
|
-
MIT
|
|
100
|
+
`12factor-env` coerces through this package: its `port`/`url`/`host`/`email`/`uuid`/`duration`/`num`/`int`/`list`/`enumerated` validators are these coercers wrapped in envalid specs, so a variable and a request body agree on what each shape accepts. On top it adds what is environment-only — schemas (`cleanEnv`), defaults, secret redaction, cross-field checks. Reach for `12factor-env` when validating `process.env` at boot, and for this package when validating a value at runtime.
|
|
82
101
|
|
|
83
102
|
---
|
|
84
103
|
|
package/coerce.d.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* present, so a caller cannot mistake `''` for a supplied identifier.
|
|
11
11
|
* - `require*` is strict: same test, but a miss throws {@link CoerceError}.
|
|
12
12
|
*
|
|
13
|
+
* A value that arrives as text — an env var, a query parameter, a header — has
|
|
14
|
+
* its own family (`asNumeric`, `asPort`, `asDuration`, `asBigInt`, `asJson`,
|
|
15
|
+
* and the domain coercers) which parses the string spelling, because for those
|
|
16
|
+
* the string IS the wire format. `asNumber`/`asInteger` stay strict so that a
|
|
17
|
+
* JSON body cannot pass `'5'` where a number was declared.
|
|
18
|
+
*
|
|
13
19
|
* Deliberately transport-agnostic: `CoerceError` carries the offending
|
|
14
20
|
* `label`, not an HTTP status. A server maps it to 400 at its own boundary,
|
|
15
21
|
* which keeps this package usable off the request path.
|
|
@@ -68,6 +74,69 @@ export declare const asDate: (value: unknown) => Date | null;
|
|
|
68
74
|
* against.
|
|
69
75
|
*/
|
|
70
76
|
export declare const asIsoString: (value: unknown) => string | null;
|
|
77
|
+
/**
|
|
78
|
+
* A number carried as text, else `null` — the env-var/query-string form of
|
|
79
|
+
* {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
|
|
80
|
+
* what bare `Number('')` would produce.
|
|
81
|
+
*/
|
|
82
|
+
export declare const asNumeric: (value: unknown) => number | null;
|
|
83
|
+
/** {@link asNumeric} restricted to integers, else `null`. */
|
|
84
|
+
export declare const asNumericInteger: (value: unknown) => number | null;
|
|
85
|
+
/** Inclusive bounds for {@link asNumberIn} and {@link asIntegerIn}. */
|
|
86
|
+
export interface Bounds {
|
|
87
|
+
min?: number;
|
|
88
|
+
max?: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* A finite number within `bounds`, else `null`.
|
|
92
|
+
*
|
|
93
|
+
* Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
|
|
94
|
+
* `1` would turn "invalid" into a plausible value nobody configured.
|
|
95
|
+
*/
|
|
96
|
+
export declare const asNumberIn: (value: unknown, { min, max }?: Bounds) => number | null;
|
|
97
|
+
/** {@link asNumberIn} restricted to integers, else `null`. */
|
|
98
|
+
export declare const asIntegerIn: (value: unknown, bounds?: Bounds) => number | null;
|
|
99
|
+
/** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
|
|
100
|
+
export declare const asPort: (value: unknown) => number | null;
|
|
101
|
+
/**
|
|
102
|
+
* A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
|
|
103
|
+
* `number`, or a digit string — the three shapes a 64-bit database id takes on
|
|
104
|
+
* its way through JSON, where it cannot survive as a `number`.
|
|
105
|
+
*/
|
|
106
|
+
export declare const asBigInt: (value: unknown) => bigint | null;
|
|
107
|
+
/**
|
|
108
|
+
* An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
|
|
109
|
+
* `'example.com'` — which is a path, and a classic way to fetch the wrong
|
|
110
|
+
* thing — does not qualify.
|
|
111
|
+
*/
|
|
112
|
+
export declare const asUrl: (value: unknown) => string | null;
|
|
113
|
+
/**
|
|
114
|
+
* A bare hostname or IP address, else `null`. No scheme, port or path: this is
|
|
115
|
+
* what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
|
|
116
|
+
* port ends up inside a DNS lookup.
|
|
117
|
+
*/
|
|
118
|
+
export declare const asHostname: (value: unknown) => string | null;
|
|
119
|
+
/** An email address, else `null`. Shape only — deliverability is not syntax. */
|
|
120
|
+
export declare const asEmail: (value: unknown) => string | null;
|
|
121
|
+
/** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
|
|
122
|
+
export declare const asUuid: (value: unknown) => string | null;
|
|
123
|
+
/**
|
|
124
|
+
* A duration normalised to **milliseconds**, else `null`. Accepts a bare number
|
|
125
|
+
* of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
|
|
126
|
+
*
|
|
127
|
+
* A unit-less string is milliseconds, which is what every timeout in the house
|
|
128
|
+
* is already denominated in.
|
|
129
|
+
*/
|
|
130
|
+
export declare const asDuration: (value: unknown) => number | null;
|
|
131
|
+
/**
|
|
132
|
+
* A JSON document — an object or an array — else `null`. A string is parsed;
|
|
133
|
+
* an already-parsed object or array passes through, so a value keeps coercing
|
|
134
|
+
* the same way on both sides of a serialisation boundary.
|
|
135
|
+
*
|
|
136
|
+
* A bare scalar is not a document: `'5'` reaching a config var that was meant
|
|
137
|
+
* to carry a map is a mistake worth surfacing.
|
|
138
|
+
*/
|
|
139
|
+
export declare const asJson: (value: unknown) => Record<string, unknown> | unknown[] | null;
|
|
71
140
|
/** {@link asString}, throwing {@link CoerceError} when absent. */
|
|
72
141
|
export declare const requireString: (value: unknown, label: string) => string;
|
|
73
142
|
/** {@link asNumber}, throwing {@link CoerceError} when absent. */
|
|
@@ -86,6 +155,30 @@ export declare const requireRecord: (value: unknown, label: string) => Record<st
|
|
|
86
155
|
export declare const requireDate: (value: unknown, label: string) => Date;
|
|
87
156
|
/** {@link asIsoString}, throwing {@link CoerceError} when absent. */
|
|
88
157
|
export declare const requireIsoString: (value: unknown, label: string) => string;
|
|
158
|
+
/** {@link asNumeric}, throwing {@link CoerceError} when absent. */
|
|
159
|
+
export declare const requireNumeric: (value: unknown, label: string) => number;
|
|
160
|
+
/** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
|
|
161
|
+
export declare const requireNumericInteger: (value: unknown, label: string) => number;
|
|
162
|
+
/** {@link asPort}, throwing {@link CoerceError} when absent. */
|
|
163
|
+
export declare const requirePort: (value: unknown, label: string) => number;
|
|
164
|
+
/** {@link asBigInt}, throwing {@link CoerceError} when absent. */
|
|
165
|
+
export declare const requireBigInt: (value: unknown, label: string) => bigint;
|
|
166
|
+
/** {@link asUrl}, throwing {@link CoerceError} when absent. */
|
|
167
|
+
export declare const requireUrl: (value: unknown, label: string) => string;
|
|
168
|
+
/** {@link asHostname}, throwing {@link CoerceError} when absent. */
|
|
169
|
+
export declare const requireHostname: (value: unknown, label: string) => string;
|
|
170
|
+
/** {@link asEmail}, throwing {@link CoerceError} when absent. */
|
|
171
|
+
export declare const requireEmail: (value: unknown, label: string) => string;
|
|
172
|
+
/** {@link asUuid}, throwing {@link CoerceError} when absent. */
|
|
173
|
+
export declare const requireUuid: (value: unknown, label: string) => string;
|
|
174
|
+
/** {@link asDuration}, throwing {@link CoerceError} when absent. */
|
|
175
|
+
export declare const requireDuration: (value: unknown, label: string) => number;
|
|
176
|
+
/** {@link asJson}, throwing {@link CoerceError} when absent. */
|
|
177
|
+
export declare const requireJson: (value: unknown, label: string) => unknown[] | Record<string, unknown>;
|
|
178
|
+
/** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
|
|
179
|
+
export declare const requireNumberIn: (value: unknown, bounds: Bounds, label: string) => number;
|
|
180
|
+
/** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
|
|
181
|
+
export declare const requireIntegerIn: (value: unknown, bounds: Bounds, label: string) => number;
|
|
89
182
|
/**
|
|
90
183
|
* Narrow a value to one of `allowed`, else `null`.
|
|
91
184
|
*
|
package/coerce.js
CHANGED
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
* present, so a caller cannot mistake `''` for a supplied identifier.
|
|
12
12
|
* - `require*` is strict: same test, but a miss throws {@link CoerceError}.
|
|
13
13
|
*
|
|
14
|
+
* A value that arrives as text — an env var, a query parameter, a header — has
|
|
15
|
+
* its own family (`asNumeric`, `asPort`, `asDuration`, `asBigInt`, `asJson`,
|
|
16
|
+
* and the domain coercers) which parses the string spelling, because for those
|
|
17
|
+
* the string IS the wire format. `asNumber`/`asInteger` stay strict so that a
|
|
18
|
+
* JSON body cannot pass `'5'` where a number was declared.
|
|
19
|
+
*
|
|
14
20
|
* Deliberately transport-agnostic: `CoerceError` carries the offending
|
|
15
21
|
* `label`, not an HTTP status. A server maps it to 400 at its own boundary,
|
|
16
22
|
* which keeps this package usable off the request path.
|
|
17
23
|
*/
|
|
18
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.requireOneOf = exports.asOneOf = exports.requireIsoString = exports.requireDate = exports.requireRecord = exports.requireStringList = exports.requireStringArray = exports.requireBoolean = exports.requireInteger = exports.requireNumber = exports.requireString = exports.asIsoString = exports.asDate = exports.asRecord = exports.asStringList = exports.asStringArray = exports.asBoolean = exports.asInteger = exports.asNumber = exports.asString = exports.CoerceError = void 0;
|
|
25
|
+
exports.requireOneOf = exports.asOneOf = exports.requireIntegerIn = exports.requireNumberIn = exports.requireJson = exports.requireDuration = exports.requireUuid = exports.requireEmail = exports.requireHostname = exports.requireUrl = exports.requireBigInt = exports.requirePort = exports.requireNumericInteger = exports.requireNumeric = exports.requireIsoString = exports.requireDate = exports.requireRecord = exports.requireStringList = exports.requireStringArray = exports.requireBoolean = exports.requireInteger = exports.requireNumber = exports.requireString = exports.asJson = exports.asDuration = exports.asUuid = exports.asEmail = exports.asHostname = exports.asUrl = exports.asBigInt = exports.asPort = exports.asIntegerIn = exports.asNumberIn = exports.asNumericInteger = exports.asNumeric = exports.asIsoString = exports.asDate = exports.asRecord = exports.asStringList = exports.asStringArray = exports.asBoolean = exports.asInteger = exports.asNumber = exports.asString = exports.CoerceError = void 0;
|
|
20
26
|
/** A value that was required but absent or of the wrong shape. */
|
|
21
27
|
class CoerceError extends Error {
|
|
22
28
|
/** What the caller was asking for, e.g. `'message_id'`. */
|
|
@@ -124,6 +130,160 @@ exports.asDate = asDate;
|
|
|
124
130
|
*/
|
|
125
131
|
const asIsoString = (value) => value instanceof Date ? (0, exports.asDate)(value)?.toISOString() ?? null : (0, exports.asString)(value);
|
|
126
132
|
exports.asIsoString = asIsoString;
|
|
133
|
+
/**
|
|
134
|
+
* A number carried as text, else `null` — the env-var/query-string form of
|
|
135
|
+
* {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
|
|
136
|
+
* what bare `Number('')` would produce.
|
|
137
|
+
*/
|
|
138
|
+
const asNumeric = (value) => {
|
|
139
|
+
if (typeof value === 'number')
|
|
140
|
+
return (0, exports.asNumber)(value);
|
|
141
|
+
const str = (0, exports.asString)(value);
|
|
142
|
+
return str === null ? null : (0, exports.asNumber)(Number(str));
|
|
143
|
+
};
|
|
144
|
+
exports.asNumeric = asNumeric;
|
|
145
|
+
/** {@link asNumeric} restricted to integers, else `null`. */
|
|
146
|
+
const asNumericInteger = (value) => (0, exports.asInteger)((0, exports.asNumeric)(value));
|
|
147
|
+
exports.asNumericInteger = asNumericInteger;
|
|
148
|
+
/**
|
|
149
|
+
* A finite number within `bounds`, else `null`.
|
|
150
|
+
*
|
|
151
|
+
* Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
|
|
152
|
+
* `1` would turn "invalid" into a plausible value nobody configured.
|
|
153
|
+
*/
|
|
154
|
+
const asNumberIn = (value, { min, max } = {}) => {
|
|
155
|
+
const num = (0, exports.asNumber)(value);
|
|
156
|
+
if (num === null)
|
|
157
|
+
return null;
|
|
158
|
+
if (min !== undefined && num < min)
|
|
159
|
+
return null;
|
|
160
|
+
if (max !== undefined && num > max)
|
|
161
|
+
return null;
|
|
162
|
+
return num;
|
|
163
|
+
};
|
|
164
|
+
exports.asNumberIn = asNumberIn;
|
|
165
|
+
/** {@link asNumberIn} restricted to integers, else `null`. */
|
|
166
|
+
const asIntegerIn = (value, bounds = {}) => (0, exports.asInteger)((0, exports.asNumberIn)(value, bounds));
|
|
167
|
+
exports.asIntegerIn = asIntegerIn;
|
|
168
|
+
/** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
|
|
169
|
+
const asPort = (value) => (0, exports.asIntegerIn)((0, exports.asNumeric)(value), { min: 1, max: 65535 });
|
|
170
|
+
exports.asPort = asPort;
|
|
171
|
+
/**
|
|
172
|
+
* A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
|
|
173
|
+
* `number`, or a digit string — the three shapes a 64-bit database id takes on
|
|
174
|
+
* its way through JSON, where it cannot survive as a `number`.
|
|
175
|
+
*/
|
|
176
|
+
const asBigInt = (value) => {
|
|
177
|
+
if (typeof value === 'bigint')
|
|
178
|
+
return value;
|
|
179
|
+
if (typeof value === 'number')
|
|
180
|
+
return Number.isInteger(value) ? BigInt(value) : null;
|
|
181
|
+
const str = (0, exports.asString)(value)?.trim();
|
|
182
|
+
return str !== undefined && INTEGER_TEXT.test(str) ? BigInt(str) : null;
|
|
183
|
+
};
|
|
184
|
+
exports.asBigInt = asBigInt;
|
|
185
|
+
const INTEGER_TEXT = /^[+-]?\d+$/;
|
|
186
|
+
/**
|
|
187
|
+
* An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
|
|
188
|
+
* `'example.com'` — which is a path, and a classic way to fetch the wrong
|
|
189
|
+
* thing — does not qualify.
|
|
190
|
+
*/
|
|
191
|
+
const asUrl = (value) => {
|
|
192
|
+
const str = (0, exports.asString)(value);
|
|
193
|
+
if (str === null)
|
|
194
|
+
return null;
|
|
195
|
+
try {
|
|
196
|
+
new URL(str);
|
|
197
|
+
return str;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
exports.asUrl = asUrl;
|
|
204
|
+
/**
|
|
205
|
+
* A bare hostname or IP address, else `null`. No scheme, port or path: this is
|
|
206
|
+
* what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
|
|
207
|
+
* port ends up inside a DNS lookup.
|
|
208
|
+
*/
|
|
209
|
+
const asHostname = (value) => {
|
|
210
|
+
const str = (0, exports.asString)(value)?.trim();
|
|
211
|
+
if (str === undefined)
|
|
212
|
+
return null;
|
|
213
|
+
return HOSTNAME.test(str) || IPV6.test(str) ? str : null;
|
|
214
|
+
};
|
|
215
|
+
exports.asHostname = asHostname;
|
|
216
|
+
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
|
217
|
+
// Deliberately loose: enough to reject a URL, a port suffix or a path, not a
|
|
218
|
+
// substitute for the resolver that will parse it for real.
|
|
219
|
+
const IPV6 = /^\[?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f.]{0,4}\]?$/i;
|
|
220
|
+
/** An email address, else `null`. Shape only — deliverability is not syntax. */
|
|
221
|
+
const asEmail = (value) => {
|
|
222
|
+
const str = (0, exports.asString)(value)?.trim();
|
|
223
|
+
return str !== undefined && EMAIL.test(str) ? str : null;
|
|
224
|
+
};
|
|
225
|
+
exports.asEmail = asEmail;
|
|
226
|
+
const EMAIL = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
|
|
227
|
+
/** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
|
|
228
|
+
const asUuid = (value) => {
|
|
229
|
+
const str = (0, exports.asString)(value)?.trim();
|
|
230
|
+
return str !== undefined && UUID.test(str) ? str : null;
|
|
231
|
+
};
|
|
232
|
+
exports.asUuid = asUuid;
|
|
233
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
234
|
+
/**
|
|
235
|
+
* A duration normalised to **milliseconds**, else `null`. Accepts a bare number
|
|
236
|
+
* of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
|
|
237
|
+
*
|
|
238
|
+
* A unit-less string is milliseconds, which is what every timeout in the house
|
|
239
|
+
* is already denominated in.
|
|
240
|
+
*/
|
|
241
|
+
const asDuration = (value) => {
|
|
242
|
+
if (typeof value === 'number')
|
|
243
|
+
return (0, exports.asNumber)(value);
|
|
244
|
+
const str = (0, exports.asString)(value)?.trim();
|
|
245
|
+
if (str === undefined)
|
|
246
|
+
return null;
|
|
247
|
+
const match = DURATION.exec(str);
|
|
248
|
+
if (!match)
|
|
249
|
+
return null;
|
|
250
|
+
return Number(match[1]) * DURATION_MS[(match[2] ?? 'ms').toLowerCase()];
|
|
251
|
+
};
|
|
252
|
+
exports.asDuration = asDuration;
|
|
253
|
+
const DURATION = /^([+-]?\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/i;
|
|
254
|
+
const DURATION_MS = {
|
|
255
|
+
ms: 1,
|
|
256
|
+
s: 1_000,
|
|
257
|
+
m: 60_000,
|
|
258
|
+
h: 3_600_000,
|
|
259
|
+
d: 86_400_000,
|
|
260
|
+
w: 604_800_000
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* A JSON document — an object or an array — else `null`. A string is parsed;
|
|
264
|
+
* an already-parsed object or array passes through, so a value keeps coercing
|
|
265
|
+
* the same way on both sides of a serialisation boundary.
|
|
266
|
+
*
|
|
267
|
+
* A bare scalar is not a document: `'5'` reaching a config var that was meant
|
|
268
|
+
* to carry a map is a mistake worth surfacing.
|
|
269
|
+
*/
|
|
270
|
+
const asJson = (value) => {
|
|
271
|
+
if (Array.isArray(value))
|
|
272
|
+
return value;
|
|
273
|
+
const record = (0, exports.asRecord)(value);
|
|
274
|
+
if (record !== null)
|
|
275
|
+
return record;
|
|
276
|
+
const str = (0, exports.asString)(value);
|
|
277
|
+
if (str === null)
|
|
278
|
+
return null;
|
|
279
|
+
try {
|
|
280
|
+
return (0, exports.asJson)(JSON.parse(str));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
exports.asJson = asJson;
|
|
127
287
|
const require_ = (as, expected) => (value, label) => {
|
|
128
288
|
const coerced = as(value);
|
|
129
289
|
if (coerced === null)
|
|
@@ -148,6 +308,49 @@ exports.requireRecord = require_(exports.asRecord, 'an object');
|
|
|
148
308
|
exports.requireDate = require_(exports.asDate, 'a date');
|
|
149
309
|
/** {@link asIsoString}, throwing {@link CoerceError} when absent. */
|
|
150
310
|
exports.requireIsoString = require_(exports.asIsoString, 'a timestamp');
|
|
311
|
+
/** {@link asNumeric}, throwing {@link CoerceError} when absent. */
|
|
312
|
+
exports.requireNumeric = require_(exports.asNumeric, 'a number');
|
|
313
|
+
/** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
|
|
314
|
+
exports.requireNumericInteger = require_(exports.asNumericInteger, 'an integer');
|
|
315
|
+
/** {@link asPort}, throwing {@link CoerceError} when absent. */
|
|
316
|
+
exports.requirePort = require_(exports.asPort, 'a port in 1..65535');
|
|
317
|
+
/** {@link asBigInt}, throwing {@link CoerceError} when absent. */
|
|
318
|
+
exports.requireBigInt = require_(exports.asBigInt, 'a whole number');
|
|
319
|
+
/** {@link asUrl}, throwing {@link CoerceError} when absent. */
|
|
320
|
+
exports.requireUrl = require_(exports.asUrl, 'an absolute URL');
|
|
321
|
+
/** {@link asHostname}, throwing {@link CoerceError} when absent. */
|
|
322
|
+
exports.requireHostname = require_(exports.asHostname, 'a hostname or IP address');
|
|
323
|
+
/** {@link asEmail}, throwing {@link CoerceError} when absent. */
|
|
324
|
+
exports.requireEmail = require_(exports.asEmail, 'an email address');
|
|
325
|
+
/** {@link asUuid}, throwing {@link CoerceError} when absent. */
|
|
326
|
+
exports.requireUuid = require_(exports.asUuid, 'a UUID');
|
|
327
|
+
/** {@link asDuration}, throwing {@link CoerceError} when absent. */
|
|
328
|
+
exports.requireDuration = require_(exports.asDuration, 'a duration');
|
|
329
|
+
/** {@link asJson}, throwing {@link CoerceError} when absent. */
|
|
330
|
+
exports.requireJson = require_(exports.asJson, 'a JSON object or array');
|
|
331
|
+
/** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
|
|
332
|
+
const requireNumberIn = (value, bounds, label) => {
|
|
333
|
+
const num = (0, exports.asNumberIn)(value, bounds);
|
|
334
|
+
if (num === null)
|
|
335
|
+
throw new CoerceError(label, describeBounds('a number', bounds));
|
|
336
|
+
return num;
|
|
337
|
+
};
|
|
338
|
+
exports.requireNumberIn = requireNumberIn;
|
|
339
|
+
/** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
|
|
340
|
+
const requireIntegerIn = (value, bounds, label) => {
|
|
341
|
+
const num = (0, exports.asIntegerIn)(value, bounds);
|
|
342
|
+
if (num === null)
|
|
343
|
+
throw new CoerceError(label, describeBounds('an integer', bounds));
|
|
344
|
+
return num;
|
|
345
|
+
};
|
|
346
|
+
exports.requireIntegerIn = requireIntegerIn;
|
|
347
|
+
const describeBounds = (what, { min, max }) => min !== undefined && max !== undefined
|
|
348
|
+
? `${what} in ${min}..${max}`
|
|
349
|
+
: min !== undefined
|
|
350
|
+
? `${what} >= ${min}`
|
|
351
|
+
: max !== undefined
|
|
352
|
+
? `${what} <= ${max}`
|
|
353
|
+
: what;
|
|
151
354
|
/**
|
|
152
355
|
* Narrow a value to one of `allowed`, else `null`.
|
|
153
356
|
*
|
package/esm/coerce.js
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* present, so a caller cannot mistake `''` for a supplied identifier.
|
|
11
11
|
* - `require*` is strict: same test, but a miss throws {@link CoerceError}.
|
|
12
12
|
*
|
|
13
|
+
* A value that arrives as text — an env var, a query parameter, a header — has
|
|
14
|
+
* its own family (`asNumeric`, `asPort`, `asDuration`, `asBigInt`, `asJson`,
|
|
15
|
+
* and the domain coercers) which parses the string spelling, because for those
|
|
16
|
+
* the string IS the wire format. `asNumber`/`asInteger` stay strict so that a
|
|
17
|
+
* JSON body cannot pass `'5'` where a number was declared.
|
|
18
|
+
*
|
|
13
19
|
* Deliberately transport-agnostic: `CoerceError` carries the offending
|
|
14
20
|
* `label`, not an HTTP status. A server maps it to 400 at its own boundary,
|
|
15
21
|
* which keeps this package usable off the request path.
|
|
@@ -111,6 +117,148 @@ export const asDate = (value) => {
|
|
|
111
117
|
* against.
|
|
112
118
|
*/
|
|
113
119
|
export const asIsoString = (value) => value instanceof Date ? asDate(value)?.toISOString() ?? null : asString(value);
|
|
120
|
+
/**
|
|
121
|
+
* A number carried as text, else `null` — the env-var/query-string form of
|
|
122
|
+
* {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
|
|
123
|
+
* what bare `Number('')` would produce.
|
|
124
|
+
*/
|
|
125
|
+
export const asNumeric = (value) => {
|
|
126
|
+
if (typeof value === 'number')
|
|
127
|
+
return asNumber(value);
|
|
128
|
+
const str = asString(value);
|
|
129
|
+
return str === null ? null : asNumber(Number(str));
|
|
130
|
+
};
|
|
131
|
+
/** {@link asNumeric} restricted to integers, else `null`. */
|
|
132
|
+
export const asNumericInteger = (value) => asInteger(asNumeric(value));
|
|
133
|
+
/**
|
|
134
|
+
* A finite number within `bounds`, else `null`.
|
|
135
|
+
*
|
|
136
|
+
* Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
|
|
137
|
+
* `1` would turn "invalid" into a plausible value nobody configured.
|
|
138
|
+
*/
|
|
139
|
+
export const asNumberIn = (value, { min, max } = {}) => {
|
|
140
|
+
const num = asNumber(value);
|
|
141
|
+
if (num === null)
|
|
142
|
+
return null;
|
|
143
|
+
if (min !== undefined && num < min)
|
|
144
|
+
return null;
|
|
145
|
+
if (max !== undefined && num > max)
|
|
146
|
+
return null;
|
|
147
|
+
return num;
|
|
148
|
+
};
|
|
149
|
+
/** {@link asNumberIn} restricted to integers, else `null`. */
|
|
150
|
+
export const asIntegerIn = (value, bounds = {}) => asInteger(asNumberIn(value, bounds));
|
|
151
|
+
/** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
|
|
152
|
+
export const asPort = (value) => asIntegerIn(asNumeric(value), { min: 1, max: 65535 });
|
|
153
|
+
/**
|
|
154
|
+
* A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
|
|
155
|
+
* `number`, or a digit string — the three shapes a 64-bit database id takes on
|
|
156
|
+
* its way through JSON, where it cannot survive as a `number`.
|
|
157
|
+
*/
|
|
158
|
+
export const asBigInt = (value) => {
|
|
159
|
+
if (typeof value === 'bigint')
|
|
160
|
+
return value;
|
|
161
|
+
if (typeof value === 'number')
|
|
162
|
+
return Number.isInteger(value) ? BigInt(value) : null;
|
|
163
|
+
const str = asString(value)?.trim();
|
|
164
|
+
return str !== undefined && INTEGER_TEXT.test(str) ? BigInt(str) : null;
|
|
165
|
+
};
|
|
166
|
+
const INTEGER_TEXT = /^[+-]?\d+$/;
|
|
167
|
+
/**
|
|
168
|
+
* An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
|
|
169
|
+
* `'example.com'` — which is a path, and a classic way to fetch the wrong
|
|
170
|
+
* thing — does not qualify.
|
|
171
|
+
*/
|
|
172
|
+
export const asUrl = (value) => {
|
|
173
|
+
const str = asString(value);
|
|
174
|
+
if (str === null)
|
|
175
|
+
return null;
|
|
176
|
+
try {
|
|
177
|
+
new URL(str);
|
|
178
|
+
return str;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* A bare hostname or IP address, else `null`. No scheme, port or path: this is
|
|
186
|
+
* what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
|
|
187
|
+
* port ends up inside a DNS lookup.
|
|
188
|
+
*/
|
|
189
|
+
export const asHostname = (value) => {
|
|
190
|
+
const str = asString(value)?.trim();
|
|
191
|
+
if (str === undefined)
|
|
192
|
+
return null;
|
|
193
|
+
return HOSTNAME.test(str) || IPV6.test(str) ? str : null;
|
|
194
|
+
};
|
|
195
|
+
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
|
196
|
+
// Deliberately loose: enough to reject a URL, a port suffix or a path, not a
|
|
197
|
+
// substitute for the resolver that will parse it for real.
|
|
198
|
+
const IPV6 = /^\[?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f.]{0,4}\]?$/i;
|
|
199
|
+
/** An email address, else `null`. Shape only — deliverability is not syntax. */
|
|
200
|
+
export const asEmail = (value) => {
|
|
201
|
+
const str = asString(value)?.trim();
|
|
202
|
+
return str !== undefined && EMAIL.test(str) ? str : null;
|
|
203
|
+
};
|
|
204
|
+
const EMAIL = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
|
|
205
|
+
/** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
|
|
206
|
+
export const asUuid = (value) => {
|
|
207
|
+
const str = asString(value)?.trim();
|
|
208
|
+
return str !== undefined && UUID.test(str) ? str : null;
|
|
209
|
+
};
|
|
210
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
211
|
+
/**
|
|
212
|
+
* A duration normalised to **milliseconds**, else `null`. Accepts a bare number
|
|
213
|
+
* of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
|
|
214
|
+
*
|
|
215
|
+
* A unit-less string is milliseconds, which is what every timeout in the house
|
|
216
|
+
* is already denominated in.
|
|
217
|
+
*/
|
|
218
|
+
export const asDuration = (value) => {
|
|
219
|
+
if (typeof value === 'number')
|
|
220
|
+
return asNumber(value);
|
|
221
|
+
const str = asString(value)?.trim();
|
|
222
|
+
if (str === undefined)
|
|
223
|
+
return null;
|
|
224
|
+
const match = DURATION.exec(str);
|
|
225
|
+
if (!match)
|
|
226
|
+
return null;
|
|
227
|
+
return Number(match[1]) * DURATION_MS[(match[2] ?? 'ms').toLowerCase()];
|
|
228
|
+
};
|
|
229
|
+
const DURATION = /^([+-]?\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/i;
|
|
230
|
+
const DURATION_MS = {
|
|
231
|
+
ms: 1,
|
|
232
|
+
s: 1_000,
|
|
233
|
+
m: 60_000,
|
|
234
|
+
h: 3_600_000,
|
|
235
|
+
d: 86_400_000,
|
|
236
|
+
w: 604_800_000
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* A JSON document — an object or an array — else `null`. A string is parsed;
|
|
240
|
+
* an already-parsed object or array passes through, so a value keeps coercing
|
|
241
|
+
* the same way on both sides of a serialisation boundary.
|
|
242
|
+
*
|
|
243
|
+
* A bare scalar is not a document: `'5'` reaching a config var that was meant
|
|
244
|
+
* to carry a map is a mistake worth surfacing.
|
|
245
|
+
*/
|
|
246
|
+
export const asJson = (value) => {
|
|
247
|
+
if (Array.isArray(value))
|
|
248
|
+
return value;
|
|
249
|
+
const record = asRecord(value);
|
|
250
|
+
if (record !== null)
|
|
251
|
+
return record;
|
|
252
|
+
const str = asString(value);
|
|
253
|
+
if (str === null)
|
|
254
|
+
return null;
|
|
255
|
+
try {
|
|
256
|
+
return asJson(JSON.parse(str));
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
114
262
|
const require_ = (as, expected) => (value, label) => {
|
|
115
263
|
const coerced = as(value);
|
|
116
264
|
if (coerced === null)
|
|
@@ -135,6 +283,47 @@ export const requireRecord = require_(asRecord, 'an object');
|
|
|
135
283
|
export const requireDate = require_(asDate, 'a date');
|
|
136
284
|
/** {@link asIsoString}, throwing {@link CoerceError} when absent. */
|
|
137
285
|
export const requireIsoString = require_(asIsoString, 'a timestamp');
|
|
286
|
+
/** {@link asNumeric}, throwing {@link CoerceError} when absent. */
|
|
287
|
+
export const requireNumeric = require_(asNumeric, 'a number');
|
|
288
|
+
/** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
|
|
289
|
+
export const requireNumericInteger = require_(asNumericInteger, 'an integer');
|
|
290
|
+
/** {@link asPort}, throwing {@link CoerceError} when absent. */
|
|
291
|
+
export const requirePort = require_(asPort, 'a port in 1..65535');
|
|
292
|
+
/** {@link asBigInt}, throwing {@link CoerceError} when absent. */
|
|
293
|
+
export const requireBigInt = require_(asBigInt, 'a whole number');
|
|
294
|
+
/** {@link asUrl}, throwing {@link CoerceError} when absent. */
|
|
295
|
+
export const requireUrl = require_(asUrl, 'an absolute URL');
|
|
296
|
+
/** {@link asHostname}, throwing {@link CoerceError} when absent. */
|
|
297
|
+
export const requireHostname = require_(asHostname, 'a hostname or IP address');
|
|
298
|
+
/** {@link asEmail}, throwing {@link CoerceError} when absent. */
|
|
299
|
+
export const requireEmail = require_(asEmail, 'an email address');
|
|
300
|
+
/** {@link asUuid}, throwing {@link CoerceError} when absent. */
|
|
301
|
+
export const requireUuid = require_(asUuid, 'a UUID');
|
|
302
|
+
/** {@link asDuration}, throwing {@link CoerceError} when absent. */
|
|
303
|
+
export const requireDuration = require_(asDuration, 'a duration');
|
|
304
|
+
/** {@link asJson}, throwing {@link CoerceError} when absent. */
|
|
305
|
+
export const requireJson = require_(asJson, 'a JSON object or array');
|
|
306
|
+
/** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
|
|
307
|
+
export const requireNumberIn = (value, bounds, label) => {
|
|
308
|
+
const num = asNumberIn(value, bounds);
|
|
309
|
+
if (num === null)
|
|
310
|
+
throw new CoerceError(label, describeBounds('a number', bounds));
|
|
311
|
+
return num;
|
|
312
|
+
};
|
|
313
|
+
/** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
|
|
314
|
+
export const requireIntegerIn = (value, bounds, label) => {
|
|
315
|
+
const num = asIntegerIn(value, bounds);
|
|
316
|
+
if (num === null)
|
|
317
|
+
throw new CoerceError(label, describeBounds('an integer', bounds));
|
|
318
|
+
return num;
|
|
319
|
+
};
|
|
320
|
+
const describeBounds = (what, { min, max }) => min !== undefined && max !== undefined
|
|
321
|
+
? `${what} in ${min}..${max}`
|
|
322
|
+
: min !== undefined
|
|
323
|
+
? `${what} >= ${min}`
|
|
324
|
+
: max !== undefined
|
|
325
|
+
? `${what} <= ${max}`
|
|
326
|
+
: what;
|
|
138
327
|
/**
|
|
139
328
|
* Narrow a value to one of `allowed`, else `null`.
|
|
140
329
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/coerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Coerce untrusted unknown values into narrow TypeScript types",
|
|
6
6
|
"main": "index.js",
|
|
@@ -36,5 +36,5 @@
|
|
|
36
36
|
"validation",
|
|
37
37
|
"typescript"
|
|
38
38
|
],
|
|
39
|
-
"gitHead": "
|
|
39
|
+
"gitHead": "10ee73e43c2277405c0e19423e2075baef6c2755"
|
|
40
40
|
}
|