@constructive-io/coerce 0.1.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/LICENSE +23 -0
- package/README.md +155 -0
- package/coerce.d.ts +97 -0
- package/coerce.js +169 -0
- package/esm/coerce.js +154 -0
- package/esm/index.js +1 -0
- package/index.d.ts +1 -0
- package/index.js +17 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
|
|
4
|
+
Copyright (c) 2025 Constructive <developers@constructive.io>
|
|
5
|
+
Copyright (c) 2020-present, Interweb, Inc.
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# @constructive-io/coerce
|
|
2
|
+
|
|
3
|
+
<p align="center" width="100%">
|
|
4
|
+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center" width="100%">
|
|
8
|
+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
|
|
9
|
+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
|
|
10
|
+
</a>
|
|
11
|
+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/@constructive-io/coerce"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=packages%2Fcoerce%2Fpackage.json"/></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
Coerce untrusted `unknown` values โ JSON request bodies, database rows, Kubernetes specs, env strings โ into narrow TypeScript types. Zero dependencies.
|
|
16
|
+
|
|
17
|
+
## why?
|
|
18
|
+
|
|
19
|
+
Because every service ends up re-typing the same four functions inline, each subtly different:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// don't
|
|
23
|
+
const asString = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
|
24
|
+
const asInteger = (v: unknown) => (typeof v === 'number' && Number.isInteger(v) ? v : null);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm install @constructive-io/coerce
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## usage
|
|
34
|
+
|
|
35
|
+
Two families. `as*` is lenient and returns `null`; `require*` throws a labelled `CoerceError`.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { asInteger, asOneOf, asString, requireString } from '@constructive-io/coerce';
|
|
39
|
+
|
|
40
|
+
const model = asString(body.model); // string | null
|
|
41
|
+
const attempt = asInteger(body.attempt); // number | null
|
|
42
|
+
const role = asOneOf(body.author_role, ['user', 'assistant']); // 'user' | 'assistant' | null
|
|
43
|
+
const messageId = requireString(body.message_id, 'message_id'); // string, or throws
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| lenient | strict | accepts |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `asString` | `requireString` | a non-empty, non-whitespace string |
|
|
49
|
+
| `asNumber` | `requireNumber` | a finite number |
|
|
50
|
+
| `asInteger` | `requireInteger` | an integer |
|
|
51
|
+
| `asBoolean` | `requireBoolean` | a boolean, or `true/false`, `1/0`, `yes/no`, `on/off`, `t/f`, `y/n` |
|
|
52
|
+
| `asStringArray` | `requireStringArray` | an array whose every entry is a non-empty string |
|
|
53
|
+
| `asStringList` | `requireStringList` | an array, or a delimited string (`'a, b'`) |
|
|
54
|
+
| `asRecord` | `requireRecord` | a plain object (not `null`, not an array) |
|
|
55
|
+
| `asDate` | `requireDate` | a valid `Date`, or a parseable date string |
|
|
56
|
+
| `asIsoString` | `requireIsoString` | a `Date` (serialised) or a string (verbatim) |
|
|
57
|
+
| `asOneOf` | `requireOneOf` | one of the given string literals |
|
|
58
|
+
|
|
59
|
+
## rules
|
|
60
|
+
|
|
61
|
+
- **No guessing across types.** `'5'` is not an integer and `1` is not a string. Only `asBoolean` and `asDate` read strings, because env vars and query strings carry nothing else.
|
|
62
|
+
- **A blank string is absent.** `''` and `' '` coerce to `null`, so an empty value can never read as a supplied identifier.
|
|
63
|
+
- **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
|
+
- **No transport semantics.** `CoerceError` carries the `label` that failed, not an HTTP status. Map it at your boundary:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
try {
|
|
68
|
+
return handle(requireString(body.message_id, 'message_id'));
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof CoerceError) throw new HttpError(400, err.message);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## related
|
|
76
|
+
|
|
77
|
+
`12factor-env` builds on this package for its lenient env parsing, and adds envalid-backed environment *schemas* (`cleanEnv`, redaction, cross-field checks). Reach for `12factor-env` when validating `process.env` at boot, and for this package when validating a value at runtime.
|
|
78
|
+
|
|
79
|
+
## license
|
|
80
|
+
|
|
81
|
+
MIT
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Education and Tutorials
|
|
86
|
+
|
|
87
|
+
1. ๐ [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
|
|
88
|
+
Get started with modular databases in minutes. Install prerequisites and deploy your first module.
|
|
89
|
+
|
|
90
|
+
2. ๐ฆ [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
|
|
91
|
+
Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
|
|
92
|
+
|
|
93
|
+
3. โ๏ธ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
|
|
94
|
+
Master the workflow for adding, organizing, and managing database changes with pgpm.
|
|
95
|
+
|
|
96
|
+
4. ๐งช [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
|
|
97
|
+
Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
|
|
98
|
+
|
|
99
|
+
5. โก [Supabase Testing](https://constructive.io/learn/supabase)
|
|
100
|
+
Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
|
|
101
|
+
|
|
102
|
+
6. ๐ง [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
|
|
103
|
+
Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
|
|
104
|
+
|
|
105
|
+
7. ๐ง [Troubleshooting](https://constructive.io/learn/troubleshooting)
|
|
106
|
+
Common issues and solutions for pgpm, PostgreSQL, and testing.
|
|
107
|
+
|
|
108
|
+
## Related Constructive Tooling
|
|
109
|
+
|
|
110
|
+
### ๐ฆ Package Management
|
|
111
|
+
|
|
112
|
+
* [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **๐ฅ๏ธ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
|
|
113
|
+
|
|
114
|
+
### ๐งช Testing
|
|
115
|
+
|
|
116
|
+
* [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **๐ Isolated testing environments** with per-test transaction rollbacksโideal for integration tests, complex migrations, and RLS simulation.
|
|
117
|
+
* [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **๐ชถ Drop-in pgsql-test replacement backed by PGlite** โ in-process Postgres, no server required, instance-per-suite isolation.
|
|
118
|
+
* [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **๐ฑ PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
|
|
119
|
+
* [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **๐งช Supabase-native test harness** preconfigured for the local Supabase stackโper-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
|
|
120
|
+
* [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **๐ Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
|
|
121
|
+
* [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **๐ Session context injection** to add session-local context (e.g., `SET LOCAL`) into queriesโideal for setting `role`, `jwt.claims`, and other session settings.
|
|
122
|
+
|
|
123
|
+
### ๐ง Parsing & AST
|
|
124
|
+
|
|
125
|
+
* [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **๐ SQL conversion engine** that interprets and converts PostgreSQL syntax.
|
|
126
|
+
* [libpg-query-node](https://www.npmjs.com/package/libpg-query): **๐ Node.js bindings** for `libpg_query`, converting SQL into parse trees.
|
|
127
|
+
* [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **๐ฆ Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
|
|
128
|
+
* [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **๐ท๏ธ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
|
|
129
|
+
* [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **๐ Type definitions** for PostgreSQL AST nodes in TypeScript.
|
|
130
|
+
* [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **๐ ๏ธ AST utilities** for constructing and transforming PostgreSQL syntax trees.
|
|
131
|
+
|
|
132
|
+
### ๐ Documentation & Skills
|
|
133
|
+
|
|
134
|
+
* [constructive-skills](https://github.com/constructive-io/constructive-skills): **๐ Platform documentation and AI agent skills** โ feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
|
|
135
|
+
|
|
136
|
+
Install skills for AI coding agents:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# All platform skills (security, blueprints, codegen, billing, etc.)
|
|
140
|
+
npx skills add constructive-io/constructive-skills
|
|
141
|
+
|
|
142
|
+
# Individual repo skills (pgpm, testing, CLI, search, etc.)
|
|
143
|
+
npx skills add https://github.com/constructive-io/constructive --skill pgpm
|
|
144
|
+
npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Credits
|
|
148
|
+
|
|
149
|
+
**๐ Built by the [Constructive](https://constructive.io) team โ creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
|
|
150
|
+
|
|
151
|
+
## Disclaimer
|
|
152
|
+
|
|
153
|
+
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
154
|
+
|
|
155
|
+
No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
package/coerce.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coercion of untrusted `unknown` values โ JSON request bodies, database rows,
|
|
3
|
+
* Kubernetes specs, env strings โ into narrow TypeScript types.
|
|
4
|
+
*
|
|
5
|
+
* Two families, one rule each:
|
|
6
|
+
*
|
|
7
|
+
* - `as*` is lenient: it answers "is this value already a usable T?" and
|
|
8
|
+
* returns `null` when it is not. It never guesses across types (`'5'` is
|
|
9
|
+
* not an integer, `1` is not a string) and never treats a blank string as
|
|
10
|
+
* present, so a caller cannot mistake `''` for a supplied identifier.
|
|
11
|
+
* - `require*` is strict: same test, but a miss throws {@link CoerceError}.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately transport-agnostic: `CoerceError` carries the offending
|
|
14
|
+
* `label`, not an HTTP status. A server maps it to 400 at its own boundary,
|
|
15
|
+
* which keeps this package usable off the request path.
|
|
16
|
+
*/
|
|
17
|
+
/** A value that was required but absent or of the wrong shape. */
|
|
18
|
+
export declare class CoerceError extends Error {
|
|
19
|
+
/** What the caller was asking for, e.g. `'message_id'`. */
|
|
20
|
+
readonly label: string;
|
|
21
|
+
constructor(label: string, expected: string);
|
|
22
|
+
}
|
|
23
|
+
/** A non-empty, non-whitespace string, else `null`. */
|
|
24
|
+
export declare const asString: (value: unknown) => string | null;
|
|
25
|
+
/** A finite number, else `null`. Strings are not parsed. */
|
|
26
|
+
export declare const asNumber: (value: unknown) => number | null;
|
|
27
|
+
/** A safe integer, else `null`. `1.5` and `'1'` are both `null`. */
|
|
28
|
+
export declare const asInteger: (value: unknown) => number | null;
|
|
29
|
+
/**
|
|
30
|
+
* A boolean. Booleans pass through; the string spellings people actually type
|
|
31
|
+
* (`true/false`, `1/0`, `yes/no`, `on/off`, `t/f`, `y/n`) are recognised
|
|
32
|
+
* case-insensitively, because env vars and query strings only carry strings.
|
|
33
|
+
* Anything else is `null` โ notably `''`, so "unset" stays distinguishable
|
|
34
|
+
* from "false".
|
|
35
|
+
*/
|
|
36
|
+
export declare const asBoolean: (value: unknown) => boolean | null;
|
|
37
|
+
/**
|
|
38
|
+
* An array whose every entry is a non-empty string, else `null`.
|
|
39
|
+
*
|
|
40
|
+
* All-or-nothing on purpose: silently dropping the malformed entries of a
|
|
41
|
+
* list of role names or allowed origins is how a security list quietly
|
|
42
|
+
* shrinks.
|
|
43
|
+
*/
|
|
44
|
+
export declare const asStringArray: (value: unknown) => string[] | null;
|
|
45
|
+
/**
|
|
46
|
+
* A list of non-empty strings from either an array (see
|
|
47
|
+
* {@link asStringArray}) or a delimited string, else `null`.
|
|
48
|
+
*
|
|
49
|
+
* Entries are trimmed and blanks dropped, because the delimited form is what a
|
|
50
|
+
* `PATH`-style env var, a query parameter or a header carries, and
|
|
51
|
+
* `'a, b,'` means two entries in all three.
|
|
52
|
+
*/
|
|
53
|
+
export declare const asStringList: (value: unknown, separator?: string) => string[] | null;
|
|
54
|
+
/**
|
|
55
|
+
* A plain object, else `null`. Arrays and `null` are not records โ both are
|
|
56
|
+
* `typeof 'object'`, and that check is the classic source of a `.length`
|
|
57
|
+
* lookup on something that was supposed to be a map.
|
|
58
|
+
*/
|
|
59
|
+
export declare const asRecord: (value: unknown) => Record<string, unknown> | null;
|
|
60
|
+
/** A valid `Date`, else `null`. An invalid date parse is not a `Date`. */
|
|
61
|
+
export declare const asDate: (value: unknown) => Date | null;
|
|
62
|
+
/**
|
|
63
|
+
* A timestamp as an ISO-8601 string, else `null`.
|
|
64
|
+
*
|
|
65
|
+
* A `Date` is serialised; a string is passed through **verbatim** rather than
|
|
66
|
+
* round-tripped through `Date`, so a caller's own cursor value survives
|
|
67
|
+
* byte-for-byte โ the format a database returned is the format it compares
|
|
68
|
+
* against.
|
|
69
|
+
*/
|
|
70
|
+
export declare const asIsoString: (value: unknown) => string | null;
|
|
71
|
+
/** {@link asString}, throwing {@link CoerceError} when absent. */
|
|
72
|
+
export declare const requireString: (value: unknown, label: string) => string;
|
|
73
|
+
/** {@link asNumber}, throwing {@link CoerceError} when absent. */
|
|
74
|
+
export declare const requireNumber: (value: unknown, label: string) => number;
|
|
75
|
+
/** {@link asInteger}, throwing {@link CoerceError} when absent. */
|
|
76
|
+
export declare const requireInteger: (value: unknown, label: string) => number;
|
|
77
|
+
/** {@link asBoolean}, throwing {@link CoerceError} when absent. */
|
|
78
|
+
export declare const requireBoolean: (value: unknown, label: string) => boolean;
|
|
79
|
+
/** {@link asStringArray}, throwing {@link CoerceError} when absent. */
|
|
80
|
+
export declare const requireStringArray: (value: unknown, label: string) => string[];
|
|
81
|
+
/** {@link asStringList}, throwing {@link CoerceError} when absent. */
|
|
82
|
+
export declare const requireStringList: (value: unknown, label: string) => string[];
|
|
83
|
+
/** {@link asRecord}, throwing {@link CoerceError} when absent. */
|
|
84
|
+
export declare const requireRecord: (value: unknown, label: string) => Record<string, unknown>;
|
|
85
|
+
/** {@link asDate}, throwing {@link CoerceError} when absent. */
|
|
86
|
+
export declare const requireDate: (value: unknown, label: string) => Date;
|
|
87
|
+
/** {@link asIsoString}, throwing {@link CoerceError} when absent. */
|
|
88
|
+
export declare const requireIsoString: (value: unknown, label: string) => string;
|
|
89
|
+
/**
|
|
90
|
+
* Narrow a value to one of `allowed`, else `null`.
|
|
91
|
+
*
|
|
92
|
+
* The literal tuple is preserved, so `asOneOf(body.role, ['user', 'admin'])`
|
|
93
|
+
* is typed `'user' | 'admin' | null` without a cast at the call site.
|
|
94
|
+
*/
|
|
95
|
+
export declare const asOneOf: <const T extends readonly string[]>(value: unknown, allowed: T) => T[number] | null;
|
|
96
|
+
/** {@link asOneOf}, throwing {@link CoerceError} when the value is not allowed. */
|
|
97
|
+
export declare const requireOneOf: <const T extends readonly string[]>(value: unknown, allowed: T, label: string) => T[number];
|
package/coerce.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Coercion of untrusted `unknown` values โ JSON request bodies, database rows,
|
|
4
|
+
* Kubernetes specs, env strings โ into narrow TypeScript types.
|
|
5
|
+
*
|
|
6
|
+
* Two families, one rule each:
|
|
7
|
+
*
|
|
8
|
+
* - `as*` is lenient: it answers "is this value already a usable T?" and
|
|
9
|
+
* returns `null` when it is not. It never guesses across types (`'5'` is
|
|
10
|
+
* not an integer, `1` is not a string) and never treats a blank string as
|
|
11
|
+
* present, so a caller cannot mistake `''` for a supplied identifier.
|
|
12
|
+
* - `require*` is strict: same test, but a miss throws {@link CoerceError}.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately transport-agnostic: `CoerceError` carries the offending
|
|
15
|
+
* `label`, not an HTTP status. A server maps it to 400 at its own boundary,
|
|
16
|
+
* which keeps this package usable off the request path.
|
|
17
|
+
*/
|
|
18
|
+
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;
|
|
20
|
+
/** A value that was required but absent or of the wrong shape. */
|
|
21
|
+
class CoerceError extends Error {
|
|
22
|
+
/** What the caller was asking for, e.g. `'message_id'`. */
|
|
23
|
+
label;
|
|
24
|
+
constructor(label, expected) {
|
|
25
|
+
super(`${label} is required (expected ${expected})`);
|
|
26
|
+
this.name = 'CoerceError';
|
|
27
|
+
this.label = label;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.CoerceError = CoerceError;
|
|
31
|
+
/** A non-empty, non-whitespace string, else `null`. */
|
|
32
|
+
const asString = (value) => typeof value === 'string' && value.trim() !== '' ? value : null;
|
|
33
|
+
exports.asString = asString;
|
|
34
|
+
/** A finite number, else `null`. Strings are not parsed. */
|
|
35
|
+
const asNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
36
|
+
exports.asNumber = asNumber;
|
|
37
|
+
/** A safe integer, else `null`. `1.5` and `'1'` are both `null`. */
|
|
38
|
+
const asInteger = (value) => typeof value === 'number' && Number.isInteger(value) ? value : null;
|
|
39
|
+
exports.asInteger = asInteger;
|
|
40
|
+
/**
|
|
41
|
+
* A boolean. Booleans pass through; the string spellings people actually type
|
|
42
|
+
* (`true/false`, `1/0`, `yes/no`, `on/off`, `t/f`, `y/n`) are recognised
|
|
43
|
+
* case-insensitively, because env vars and query strings only carry strings.
|
|
44
|
+
* Anything else is `null` โ notably `''`, so "unset" stays distinguishable
|
|
45
|
+
* from "false".
|
|
46
|
+
*/
|
|
47
|
+
const asBoolean = (value) => {
|
|
48
|
+
if (typeof value === 'boolean')
|
|
49
|
+
return value;
|
|
50
|
+
const str = (0, exports.asString)(value)?.trim().toLowerCase();
|
|
51
|
+
if (str === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
if (TRUTHY.has(str))
|
|
54
|
+
return true;
|
|
55
|
+
if (FALSY.has(str))
|
|
56
|
+
return false;
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
exports.asBoolean = asBoolean;
|
|
60
|
+
const TRUTHY = new Set(['1', 'on', 't', 'true', 'y', 'yes']);
|
|
61
|
+
const FALSY = new Set(['0', 'f', 'false', 'n', 'no', 'off']);
|
|
62
|
+
/**
|
|
63
|
+
* An array whose every entry is a non-empty string, else `null`.
|
|
64
|
+
*
|
|
65
|
+
* All-or-nothing on purpose: silently dropping the malformed entries of a
|
|
66
|
+
* list of role names or allowed origins is how a security list quietly
|
|
67
|
+
* shrinks.
|
|
68
|
+
*/
|
|
69
|
+
const asStringArray = (value) => {
|
|
70
|
+
if (!Array.isArray(value))
|
|
71
|
+
return null;
|
|
72
|
+
const strings = [];
|
|
73
|
+
for (const entry of value) {
|
|
74
|
+
const str = (0, exports.asString)(entry);
|
|
75
|
+
if (str === null)
|
|
76
|
+
return null;
|
|
77
|
+
strings.push(str);
|
|
78
|
+
}
|
|
79
|
+
return strings;
|
|
80
|
+
};
|
|
81
|
+
exports.asStringArray = asStringArray;
|
|
82
|
+
/**
|
|
83
|
+
* A list of non-empty strings from either an array (see
|
|
84
|
+
* {@link asStringArray}) or a delimited string, else `null`.
|
|
85
|
+
*
|
|
86
|
+
* Entries are trimmed and blanks dropped, because the delimited form is what a
|
|
87
|
+
* `PATH`-style env var, a query parameter or a header carries, and
|
|
88
|
+
* `'a, b,'` means two entries in all three.
|
|
89
|
+
*/
|
|
90
|
+
const asStringList = (value, separator = ',') => {
|
|
91
|
+
const str = (0, exports.asString)(value);
|
|
92
|
+
if (str !== null)
|
|
93
|
+
return str.split(separator).map(entry => entry.trim()).filter(Boolean);
|
|
94
|
+
return (0, exports.asStringArray)(value);
|
|
95
|
+
};
|
|
96
|
+
exports.asStringList = asStringList;
|
|
97
|
+
/**
|
|
98
|
+
* A plain object, else `null`. Arrays and `null` are not records โ both are
|
|
99
|
+
* `typeof 'object'`, and that check is the classic source of a `.length`
|
|
100
|
+
* lookup on something that was supposed to be a map.
|
|
101
|
+
*/
|
|
102
|
+
const asRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
103
|
+
? value
|
|
104
|
+
: null;
|
|
105
|
+
exports.asRecord = asRecord;
|
|
106
|
+
/** A valid `Date`, else `null`. An invalid date parse is not a `Date`. */
|
|
107
|
+
const asDate = (value) => {
|
|
108
|
+
if (value instanceof Date)
|
|
109
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
110
|
+
const str = (0, exports.asString)(value);
|
|
111
|
+
if (str === null)
|
|
112
|
+
return null;
|
|
113
|
+
const parsed = new Date(str);
|
|
114
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
115
|
+
};
|
|
116
|
+
exports.asDate = asDate;
|
|
117
|
+
/**
|
|
118
|
+
* A timestamp as an ISO-8601 string, else `null`.
|
|
119
|
+
*
|
|
120
|
+
* A `Date` is serialised; a string is passed through **verbatim** rather than
|
|
121
|
+
* round-tripped through `Date`, so a caller's own cursor value survives
|
|
122
|
+
* byte-for-byte โ the format a database returned is the format it compares
|
|
123
|
+
* against.
|
|
124
|
+
*/
|
|
125
|
+
const asIsoString = (value) => value instanceof Date ? (0, exports.asDate)(value)?.toISOString() ?? null : (0, exports.asString)(value);
|
|
126
|
+
exports.asIsoString = asIsoString;
|
|
127
|
+
const require_ = (as, expected) => (value, label) => {
|
|
128
|
+
const coerced = as(value);
|
|
129
|
+
if (coerced === null)
|
|
130
|
+
throw new CoerceError(label, expected);
|
|
131
|
+
return coerced;
|
|
132
|
+
};
|
|
133
|
+
/** {@link asString}, throwing {@link CoerceError} when absent. */
|
|
134
|
+
exports.requireString = require_(exports.asString, 'a non-empty string');
|
|
135
|
+
/** {@link asNumber}, throwing {@link CoerceError} when absent. */
|
|
136
|
+
exports.requireNumber = require_(exports.asNumber, 'a finite number');
|
|
137
|
+
/** {@link asInteger}, throwing {@link CoerceError} when absent. */
|
|
138
|
+
exports.requireInteger = require_(exports.asInteger, 'an integer');
|
|
139
|
+
/** {@link asBoolean}, throwing {@link CoerceError} when absent. */
|
|
140
|
+
exports.requireBoolean = require_(exports.asBoolean, 'a boolean');
|
|
141
|
+
/** {@link asStringArray}, throwing {@link CoerceError} when absent. */
|
|
142
|
+
exports.requireStringArray = require_(exports.asStringArray, 'an array of non-empty strings');
|
|
143
|
+
/** {@link asStringList}, throwing {@link CoerceError} when absent. */
|
|
144
|
+
exports.requireStringList = require_(exports.asStringList, 'a list of non-empty strings');
|
|
145
|
+
/** {@link asRecord}, throwing {@link CoerceError} when absent. */
|
|
146
|
+
exports.requireRecord = require_(exports.asRecord, 'an object');
|
|
147
|
+
/** {@link asDate}, throwing {@link CoerceError} when absent. */
|
|
148
|
+
exports.requireDate = require_(exports.asDate, 'a date');
|
|
149
|
+
/** {@link asIsoString}, throwing {@link CoerceError} when absent. */
|
|
150
|
+
exports.requireIsoString = require_(exports.asIsoString, 'a timestamp');
|
|
151
|
+
/**
|
|
152
|
+
* Narrow a value to one of `allowed`, else `null`.
|
|
153
|
+
*
|
|
154
|
+
* The literal tuple is preserved, so `asOneOf(body.role, ['user', 'admin'])`
|
|
155
|
+
* is typed `'user' | 'admin' | null` without a cast at the call site.
|
|
156
|
+
*/
|
|
157
|
+
const asOneOf = (value, allowed) => {
|
|
158
|
+
const str = (0, exports.asString)(value);
|
|
159
|
+
return str !== null && allowed.includes(str) ? str : null;
|
|
160
|
+
};
|
|
161
|
+
exports.asOneOf = asOneOf;
|
|
162
|
+
/** {@link asOneOf}, throwing {@link CoerceError} when the value is not allowed. */
|
|
163
|
+
const requireOneOf = (value, allowed, label) => {
|
|
164
|
+
const coerced = (0, exports.asOneOf)(value, allowed);
|
|
165
|
+
if (coerced === null)
|
|
166
|
+
throw new CoerceError(label, `one of ${allowed.join(', ')}`);
|
|
167
|
+
return coerced;
|
|
168
|
+
};
|
|
169
|
+
exports.requireOneOf = requireOneOf;
|
package/esm/coerce.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coercion of untrusted `unknown` values โ JSON request bodies, database rows,
|
|
3
|
+
* Kubernetes specs, env strings โ into narrow TypeScript types.
|
|
4
|
+
*
|
|
5
|
+
* Two families, one rule each:
|
|
6
|
+
*
|
|
7
|
+
* - `as*` is lenient: it answers "is this value already a usable T?" and
|
|
8
|
+
* returns `null` when it is not. It never guesses across types (`'5'` is
|
|
9
|
+
* not an integer, `1` is not a string) and never treats a blank string as
|
|
10
|
+
* present, so a caller cannot mistake `''` for a supplied identifier.
|
|
11
|
+
* - `require*` is strict: same test, but a miss throws {@link CoerceError}.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately transport-agnostic: `CoerceError` carries the offending
|
|
14
|
+
* `label`, not an HTTP status. A server maps it to 400 at its own boundary,
|
|
15
|
+
* which keeps this package usable off the request path.
|
|
16
|
+
*/
|
|
17
|
+
/** A value that was required but absent or of the wrong shape. */
|
|
18
|
+
export class CoerceError extends Error {
|
|
19
|
+
/** What the caller was asking for, e.g. `'message_id'`. */
|
|
20
|
+
label;
|
|
21
|
+
constructor(label, expected) {
|
|
22
|
+
super(`${label} is required (expected ${expected})`);
|
|
23
|
+
this.name = 'CoerceError';
|
|
24
|
+
this.label = label;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** A non-empty, non-whitespace string, else `null`. */
|
|
28
|
+
export const asString = (value) => typeof value === 'string' && value.trim() !== '' ? value : null;
|
|
29
|
+
/** A finite number, else `null`. Strings are not parsed. */
|
|
30
|
+
export const asNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
31
|
+
/** A safe integer, else `null`. `1.5` and `'1'` are both `null`. */
|
|
32
|
+
export const asInteger = (value) => typeof value === 'number' && Number.isInteger(value) ? value : null;
|
|
33
|
+
/**
|
|
34
|
+
* A boolean. Booleans pass through; the string spellings people actually type
|
|
35
|
+
* (`true/false`, `1/0`, `yes/no`, `on/off`, `t/f`, `y/n`) are recognised
|
|
36
|
+
* case-insensitively, because env vars and query strings only carry strings.
|
|
37
|
+
* Anything else is `null` โ notably `''`, so "unset" stays distinguishable
|
|
38
|
+
* from "false".
|
|
39
|
+
*/
|
|
40
|
+
export const asBoolean = (value) => {
|
|
41
|
+
if (typeof value === 'boolean')
|
|
42
|
+
return value;
|
|
43
|
+
const str = asString(value)?.trim().toLowerCase();
|
|
44
|
+
if (str === undefined)
|
|
45
|
+
return null;
|
|
46
|
+
if (TRUTHY.has(str))
|
|
47
|
+
return true;
|
|
48
|
+
if (FALSY.has(str))
|
|
49
|
+
return false;
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
const TRUTHY = new Set(['1', 'on', 't', 'true', 'y', 'yes']);
|
|
53
|
+
const FALSY = new Set(['0', 'f', 'false', 'n', 'no', 'off']);
|
|
54
|
+
/**
|
|
55
|
+
* An array whose every entry is a non-empty string, else `null`.
|
|
56
|
+
*
|
|
57
|
+
* All-or-nothing on purpose: silently dropping the malformed entries of a
|
|
58
|
+
* list of role names or allowed origins is how a security list quietly
|
|
59
|
+
* shrinks.
|
|
60
|
+
*/
|
|
61
|
+
export const asStringArray = (value) => {
|
|
62
|
+
if (!Array.isArray(value))
|
|
63
|
+
return null;
|
|
64
|
+
const strings = [];
|
|
65
|
+
for (const entry of value) {
|
|
66
|
+
const str = asString(entry);
|
|
67
|
+
if (str === null)
|
|
68
|
+
return null;
|
|
69
|
+
strings.push(str);
|
|
70
|
+
}
|
|
71
|
+
return strings;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* A list of non-empty strings from either an array (see
|
|
75
|
+
* {@link asStringArray}) or a delimited string, else `null`.
|
|
76
|
+
*
|
|
77
|
+
* Entries are trimmed and blanks dropped, because the delimited form is what a
|
|
78
|
+
* `PATH`-style env var, a query parameter or a header carries, and
|
|
79
|
+
* `'a, b,'` means two entries in all three.
|
|
80
|
+
*/
|
|
81
|
+
export const asStringList = (value, separator = ',') => {
|
|
82
|
+
const str = asString(value);
|
|
83
|
+
if (str !== null)
|
|
84
|
+
return str.split(separator).map(entry => entry.trim()).filter(Boolean);
|
|
85
|
+
return asStringArray(value);
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* A plain object, else `null`. Arrays and `null` are not records โ both are
|
|
89
|
+
* `typeof 'object'`, and that check is the classic source of a `.length`
|
|
90
|
+
* lookup on something that was supposed to be a map.
|
|
91
|
+
*/
|
|
92
|
+
export const asRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
93
|
+
? value
|
|
94
|
+
: null;
|
|
95
|
+
/** A valid `Date`, else `null`. An invalid date parse is not a `Date`. */
|
|
96
|
+
export const asDate = (value) => {
|
|
97
|
+
if (value instanceof Date)
|
|
98
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
99
|
+
const str = asString(value);
|
|
100
|
+
if (str === null)
|
|
101
|
+
return null;
|
|
102
|
+
const parsed = new Date(str);
|
|
103
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* A timestamp as an ISO-8601 string, else `null`.
|
|
107
|
+
*
|
|
108
|
+
* A `Date` is serialised; a string is passed through **verbatim** rather than
|
|
109
|
+
* round-tripped through `Date`, so a caller's own cursor value survives
|
|
110
|
+
* byte-for-byte โ the format a database returned is the format it compares
|
|
111
|
+
* against.
|
|
112
|
+
*/
|
|
113
|
+
export const asIsoString = (value) => value instanceof Date ? asDate(value)?.toISOString() ?? null : asString(value);
|
|
114
|
+
const require_ = (as, expected) => (value, label) => {
|
|
115
|
+
const coerced = as(value);
|
|
116
|
+
if (coerced === null)
|
|
117
|
+
throw new CoerceError(label, expected);
|
|
118
|
+
return coerced;
|
|
119
|
+
};
|
|
120
|
+
/** {@link asString}, throwing {@link CoerceError} when absent. */
|
|
121
|
+
export const requireString = require_(asString, 'a non-empty string');
|
|
122
|
+
/** {@link asNumber}, throwing {@link CoerceError} when absent. */
|
|
123
|
+
export const requireNumber = require_(asNumber, 'a finite number');
|
|
124
|
+
/** {@link asInteger}, throwing {@link CoerceError} when absent. */
|
|
125
|
+
export const requireInteger = require_(asInteger, 'an integer');
|
|
126
|
+
/** {@link asBoolean}, throwing {@link CoerceError} when absent. */
|
|
127
|
+
export const requireBoolean = require_(asBoolean, 'a boolean');
|
|
128
|
+
/** {@link asStringArray}, throwing {@link CoerceError} when absent. */
|
|
129
|
+
export const requireStringArray = require_(asStringArray, 'an array of non-empty strings');
|
|
130
|
+
/** {@link asStringList}, throwing {@link CoerceError} when absent. */
|
|
131
|
+
export const requireStringList = require_(asStringList, 'a list of non-empty strings');
|
|
132
|
+
/** {@link asRecord}, throwing {@link CoerceError} when absent. */
|
|
133
|
+
export const requireRecord = require_(asRecord, 'an object');
|
|
134
|
+
/** {@link asDate}, throwing {@link CoerceError} when absent. */
|
|
135
|
+
export const requireDate = require_(asDate, 'a date');
|
|
136
|
+
/** {@link asIsoString}, throwing {@link CoerceError} when absent. */
|
|
137
|
+
export const requireIsoString = require_(asIsoString, 'a timestamp');
|
|
138
|
+
/**
|
|
139
|
+
* Narrow a value to one of `allowed`, else `null`.
|
|
140
|
+
*
|
|
141
|
+
* The literal tuple is preserved, so `asOneOf(body.role, ['user', 'admin'])`
|
|
142
|
+
* is typed `'user' | 'admin' | null` without a cast at the call site.
|
|
143
|
+
*/
|
|
144
|
+
export const asOneOf = (value, allowed) => {
|
|
145
|
+
const str = asString(value);
|
|
146
|
+
return str !== null && allowed.includes(str) ? str : null;
|
|
147
|
+
};
|
|
148
|
+
/** {@link asOneOf}, throwing {@link CoerceError} when the value is not allowed. */
|
|
149
|
+
export const requireOneOf = (value, allowed, label) => {
|
|
150
|
+
const coerced = asOneOf(value, allowed);
|
|
151
|
+
if (coerced === null)
|
|
152
|
+
throw new CoerceError(label, `one of ${allowed.join(', ')}`);
|
|
153
|
+
return coerced;
|
|
154
|
+
};
|
package/esm/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './coerce';
|
package/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './coerce';
|
package/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./coerce"), exports);
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@constructive-io/coerce",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"author": "Constructive <developers@constructive.io>",
|
|
5
|
+
"description": "Coerce untrusted unknown values into narrow TypeScript types",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "esm/index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"homepage": "https://github.com/constructive-io/constructive",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"directory": "dist"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/constructive-io/constructive"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/constructive-io/constructive/issues"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"clean": "makage clean",
|
|
24
|
+
"prepack": "npm run build",
|
|
25
|
+
"build": "makage build",
|
|
26
|
+
"build:dev": "makage build --dev",
|
|
27
|
+
"lint": "eslint . --fix",
|
|
28
|
+
"test": "jest",
|
|
29
|
+
"test:watch": "jest --watch"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"makage": "^0.3.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"coerce",
|
|
36
|
+
"validation",
|
|
37
|
+
"typescript"
|
|
38
|
+
],
|
|
39
|
+
"gitHead": "50a5ab31f64974a079bfbef6714ffa4b0044ffee"
|
|
40
|
+
}
|