@empyria/common 0.1.8 → 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 +42 -8
- package/lib/Ata.js +191 -35
- package/lib/Env.js +11 -2
- package/package.json +6 -6
- package/test/Ata.test.js +128 -9
- package/test/Env.test.js +30 -0
package/README.md
CHANGED
|
@@ -41,14 +41,14 @@ import { defineSchema } from '@empyria/common/lib/Ata.js'
|
|
|
41
41
|
|
|
42
42
|
## Modules
|
|
43
43
|
|
|
44
|
-
| Module | Purpose
|
|
45
|
-
| -------------------------------------- |
|
|
46
|
-
| [index.js](./index.js) | Package entry point; re-exports every module below plus Moleculer meta helpers (`moleculerMeta`, `moleculerEmpyriaMeta`) and workflow ID helpers (`workflowName`, `workflowId`, `identifyWorkflow`).
|
|
47
|
-
| [lib/Errors.js](./lib/Errors.js) | `EmpyriaError`, the `ErrorCreator` factory, and `BaseErrors` — Empyria's catalog of pre-built, templated errors.
|
|
48
|
-
| [lib/Env.js](./lib/Env.js) | `createEnv` — builds a service's environment-variable JSON schema from Empyria-wide defaults.
|
|
49
|
-
| [lib/Ata.js](./lib/Ata.js) | JSON schema building blocks (`string`, `number`, `bool`, `enumType`, `email`, `password`, `ip4`, `ip6`, ...) and validation — throwing (`defineSchema`, `createValidator`, `validate`) or non-throwing/structured (`createChecker`, `checkSchema`) — built on [ata-validator](https://www.npmjs.com/package/ata-validator). |
|
|
50
|
-
| [lib/Codogram.js](./lib/Codogram.js) | `generateCodogram` — one-time challenge codes (PIN, alphanumeric, alphabetic) for SMS/TOTP-style verification flows.
|
|
51
|
-
| [lib/Defaulter.js](./lib/Defaulter.js) | `defaultsFromSchema` — builds a fully-populated default object for a JSON schema.
|
|
44
|
+
| Module | Purpose |
|
|
45
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| [index.js](./index.js) | Package entry point; re-exports every module below plus Moleculer meta helpers (`moleculerMeta`, `moleculerEmpyriaMeta`) and workflow ID helpers (`workflowName`, `workflowId`, `identifyWorkflow`). |
|
|
47
|
+
| [lib/Errors.js](./lib/Errors.js) | `EmpyriaError`, the `ErrorCreator` factory, and `BaseErrors` — Empyria's catalog of pre-built, templated errors. |
|
|
48
|
+
| [lib/Env.js](./lib/Env.js) | `createEnv` — builds a service's environment-variable JSON schema from Empyria-wide defaults; pass `{ closed: true }` to reject undeclared vars and bind `{ coerceTypes, removeAdditional }` to the schema. |
|
|
49
|
+
| [lib/Ata.js](./lib/Ata.js) | JSON schema building blocks (`string`, `number`, `bool`, `enumType`, `email`, `password`, `ip4`, `ip6`, ...) and validation — throwing (`defineSchema`, `createValidator`, `validate`), transform-intent aliases (`parse`, `createParser`), or non-throwing/structured (`createChecker`, `checkSchema`, now returning the parsed `data`) — built on [ata-validator](https://www.npmjs.com/package/ata-validator). See _Validation options_ below. |
|
|
50
|
+
| [lib/Codogram.js](./lib/Codogram.js) | `generateCodogram` — one-time challenge codes (PIN, alphanumeric, alphabetic) for SMS/TOTP-style verification flows. |
|
|
51
|
+
| [lib/Defaulter.js](./lib/Defaulter.js) | `defaultsFromSchema` — builds a fully-populated default object for a JSON schema. |
|
|
52
52
|
|
|
53
53
|
Every exported function is documented with JSDoc directly in its source file — hovering
|
|
54
54
|
a function in VSCode or Zed shows its parameters and return type without any extra
|
|
@@ -56,6 +56,40 @@ tooling, since both editors read JSDoc from plain `.js` files automatically.
|
|
|
56
56
|
|
|
57
57
|
Tests live under [test/](./test/), one file per module, separate from the `lib/` sources.
|
|
58
58
|
|
|
59
|
+
## Validation options
|
|
60
|
+
|
|
61
|
+
`createValidator` / `validate` / `parse` / `createChecker` / `checkSchema` forward
|
|
62
|
+
[`ata-validator`'s options](https://www.npmjs.com/package/ata-validator) to the underlying
|
|
63
|
+
`Validator`. Options are resolved in three layers, **last wins**:
|
|
64
|
+
|
|
65
|
+
1. **library defaults** — `setAtaDefaults({ ... })`, called once at startup for a
|
|
66
|
+
deployment-wide policy; `getAtaDefaults()` reads it back, `setAtaDefaults()` clears it.
|
|
67
|
+
2. **schema-bound** — `defineSchema(map, { validation: { ... } })` stores options on a
|
|
68
|
+
non-enumerable key, so a schema carries its own parsing policy (`schemaOptions(schema)`
|
|
69
|
+
reads it). The schema still serialises and compares as plain JSON Schema.
|
|
70
|
+
3. **per-call** — the `options` argument to any of the functions above.
|
|
71
|
+
|
|
72
|
+
`formats` is merged one level deep, with `EMPYRIA_FORMATS` always underneath.
|
|
73
|
+
|
|
74
|
+
| Option | What it does (Empyria relevance) |
|
|
75
|
+
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `coerceTypes` | `"4040"` → `4040` before validation. The reason `env.js` files work — every `process.env` value is a string. |
|
|
77
|
+
| `useDefaults` | Fill missing props from their schema `default`. **On by default**; every `string()/number()/bool()/enumType()` emits one. |
|
|
78
|
+
| `removeAdditional` | Strip keys not covered by the schema. Only visible with `additionalProperties: false` (i.e. `defineSchema({ strict: true })`). |
|
|
79
|
+
| `assertFormat` | Whether a `format` mismatch is a hard error. On by default. |
|
|
80
|
+
| `abortEarly` | Stop at the first error, skip enrichment. For hot verdict-only paths (pair with `createChecker`, not the throwing path). |
|
|
81
|
+
| `richErrors` | Keep `ATA####` codes / doc URLs / hints that `renderPretty` & co. consume. On by default; leave it on. |
|
|
82
|
+
| `verbose` | Add `parentSchema` to each error (ajv's `verbose`). |
|
|
83
|
+
| `formats` | Custom `format` checkers. `EMPYRIA_FORMATS` (`empyria-email`, `empyria-password`, `empyria-ipv4`, `empyria-ipv6`) is folded in automatically. |
|
|
84
|
+
|
|
85
|
+
**Return value / mutation.** `createValidator` & `validate` return `ata-validator`'s
|
|
86
|
+
`result.data` — the same object as the input unless `coerceTypes` / `removeAdditional` /
|
|
87
|
+
`useDefaults` applied. Those passes also **mutate the input object in place**, so hand them a
|
|
88
|
+
copy when the caller must keep its input pristine (as the `env.js` files do with
|
|
89
|
+
`{ ...process.env }`). `parse` / `createParser` are behavioural aliases for call sites whose
|
|
90
|
+
intent is to transform rather than merely assert. `createChecker` / `checkSchema` now also
|
|
91
|
+
return `data` (the parsed value on success, `undefined` on failure).
|
|
92
|
+
|
|
59
93
|
## Scripts
|
|
60
94
|
|
|
61
95
|
```bash
|
package/lib/Ata.js
CHANGED
|
@@ -31,6 +31,98 @@ export const P_IPv6 = `
|
|
|
31
31
|
.replace(/\n/g, '')
|
|
32
32
|
.trim()
|
|
33
33
|
|
|
34
|
+
const RE_EMAIL = new RegExp(`^${P_EMAIL}`, 'i')
|
|
35
|
+
const RE_PASS = new RegExp(P_PASS)
|
|
36
|
+
const RE_IPv4 = new RegExp(`^${P_IPv4}`)
|
|
37
|
+
const RE_IPv6 = new RegExp(`^${P_IPv6}`)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Empyria's custom `format` checkers, keyed by the `format` name a schema references.
|
|
41
|
+
* Registered automatically on every validator this module builds (see `resolveOptions`), so
|
|
42
|
+
* a schema carrying `{ type: 'string', format: 'empyria-email' }` — as produced by
|
|
43
|
+
* {@link email}, {@link password}, {@link ip4}, {@link ip6} — is enforced with no per-call
|
|
44
|
+
* wiring.
|
|
45
|
+
*
|
|
46
|
+
* The names are deliberately prefixed rather than reusing `email` / `ipv4` / `ipv6`:
|
|
47
|
+
* `ata-validator`'s compiled path only lets a custom `formats` entry override a *built-in*
|
|
48
|
+
* name at the schema root, not on a nested property, whereas an unknown name is honoured
|
|
49
|
+
* everywhere. A caller's own `options.formats` entry for the same key still wins.
|
|
50
|
+
*
|
|
51
|
+
* A schema from {@link email} & co. is only enforced when validated through this module (or
|
|
52
|
+
* through a validator the caller seeded with this map); a bare `new Validator(schema)` sees
|
|
53
|
+
* these as unknown (ignored) formats.
|
|
54
|
+
* @type {Record<string, (value: unknown) => boolean>}
|
|
55
|
+
*/
|
|
56
|
+
export const EMPYRIA_FORMATS = {
|
|
57
|
+
'empyria-email': (s) => typeof s === 'string' && RE_EMAIL.test(s),
|
|
58
|
+
'empyria-password': (s) => typeof s === 'string' && RE_PASS.test(s),
|
|
59
|
+
'empyria-ipv4': (s) => typeof s === 'string' && RE_IPv4.test(s),
|
|
60
|
+
'empyria-ipv6': (s) => typeof s === 'string' && RE_IPv6.test(s),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Non-enumerable key under which {@link defineSchema} stashes a schema's bound options. */
|
|
64
|
+
const SCHEMA_OPTIONS = Symbol('ataOptions')
|
|
65
|
+
|
|
66
|
+
/** Process-wide default `ata-validator` options; the base layer for every call here. */
|
|
67
|
+
let libraryDefaults = {}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sets this module's process-wide default `ata-validator` options — the base layer every
|
|
71
|
+
* {@link createValidator} / {@link validate} / {@link parse} / {@link createChecker} call in
|
|
72
|
+
* this process starts from. Meant to be called once at startup to express a deployment-wide
|
|
73
|
+
* policy (e.g. `{ coerceTypes: true }`). Individual calls stay free to override any key, and
|
|
74
|
+
* a schema built by {@link defineSchema} with a `validation` bag overrides these too.
|
|
75
|
+
*
|
|
76
|
+
* Merge order, lowest to highest precedence: **library defaults → schema-bound → per-call**.
|
|
77
|
+
* `formats` is merged one level deep, with {@link EMPYRIA_FORMATS} always underneath.
|
|
78
|
+
* @param {import('ata-validator').ValidatorOptions} [options={}] - Replaces the current
|
|
79
|
+
* defaults wholesale. Pass `{}` (or call with no argument) to clear them.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
export function setAtaDefaults(options = {}) {
|
|
83
|
+
libraryDefaults = { ...options }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns a shallow copy of the current process-wide default options (see {@link setAtaDefaults}).
|
|
88
|
+
* @returns {import('ata-validator').ValidatorOptions}
|
|
89
|
+
*/
|
|
90
|
+
export function getAtaDefaults() {
|
|
91
|
+
return { ...libraryDefaults }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns a shallow copy of the `ata-validator` options bound to `schema` by
|
|
96
|
+
* {@link defineSchema}'s `validation` option, or `{}` if none were bound.
|
|
97
|
+
* @param {Object} schema
|
|
98
|
+
* @returns {import('ata-validator').ValidatorOptions}
|
|
99
|
+
*/
|
|
100
|
+
export function schemaOptions(schema) {
|
|
101
|
+
const bound = schema?.[SCHEMA_OPTIONS]
|
|
102
|
+
return bound ? { ...bound } : {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Merges the three option layers for a single call and folds in {@link EMPYRIA_FORMATS}.
|
|
107
|
+
* @param {Object} schema - Schema being validated (may carry bound options).
|
|
108
|
+
* @param {import('ata-validator').ValidatorOptions} [perCall] - Options passed to this call.
|
|
109
|
+
* @returns {import('ata-validator').ValidatorOptions}
|
|
110
|
+
*/
|
|
111
|
+
function resolveOptions(schema, perCall) {
|
|
112
|
+
const bound = schema?.[SCHEMA_OPTIONS] ?? {}
|
|
113
|
+
return {
|
|
114
|
+
...libraryDefaults,
|
|
115
|
+
...bound,
|
|
116
|
+
...perCall,
|
|
117
|
+
formats: {
|
|
118
|
+
...EMPYRIA_FORMATS,
|
|
119
|
+
...libraryDefaults.formats,
|
|
120
|
+
...bound.formats,
|
|
121
|
+
...(perCall && perCall.formats),
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
34
126
|
/**
|
|
35
127
|
* Builds a function that clones only the keys declared on a schema's `properties`.
|
|
36
128
|
* @param {Object} schema - JSON schema with a `properties` map.
|
|
@@ -45,12 +137,22 @@ export function cloneBySchema(schema) {
|
|
|
45
137
|
* Builds a JSON schema object from a flat map of property schemas.
|
|
46
138
|
* @param {Object} schemaDef - Map of property name to its JSON schema.
|
|
47
139
|
* @param {Object} [options={}]
|
|
48
|
-
* @param {boolean} [options.strict] - If true, disallows properties not listed in `schemaDef
|
|
140
|
+
* @param {boolean} [options.strict] - If true, disallows properties not listed in `schemaDef`
|
|
141
|
+
* (`additionalProperties: false`). Combine with `validation: { removeAdditional: true }` to
|
|
142
|
+
* *strip* unknown keys instead of rejecting them. Ignored when `options.additionalProperties`
|
|
143
|
+
* is given.
|
|
144
|
+
* @param {boolean|Object} [options.additionalProperties] - Explicit `additionalProperties`
|
|
145
|
+
* value (a boolean, or a sub-schema for the extra keys). Overrides `options.strict`.
|
|
49
146
|
* @param {boolean} [options.optional] - If true, a property whose own schema carries
|
|
50
147
|
* `optional: true` is excluded from `required` (that marker is then stripped from the
|
|
51
148
|
* property itself — never leaked into the resulting schema, since it isn't a real JSON
|
|
52
149
|
* Schema keyword). Off by default: every declared property is required, exactly as before —
|
|
53
150
|
* a caller that never sets `optional: true` on any property sees no difference either way.
|
|
151
|
+
* @param {import('ata-validator').ValidatorOptions} [options.validation] - `ata-validator`
|
|
152
|
+
* options bound to the returned schema: every {@link createValidator} / {@link validate} /
|
|
153
|
+
* {@link parse} / {@link createChecker} call for it starts from these (below per-call
|
|
154
|
+
* options, above {@link setAtaDefaults}). Stored on a non-enumerable key, so the schema
|
|
155
|
+
* still serialises and compares as a plain JSON Schema.
|
|
54
156
|
* @returns {Object} A JSON schema: `{ type: 'object', properties, required, additionalProperties }`.
|
|
55
157
|
*/
|
|
56
158
|
export function defineSchema(schemaDef, options = {}) {
|
|
@@ -70,7 +172,16 @@ export function defineSchema(schemaDef, options = {}) {
|
|
|
70
172
|
type: 'object',
|
|
71
173
|
properties,
|
|
72
174
|
required,
|
|
73
|
-
additionalProperties:
|
|
175
|
+
additionalProperties: defined(options.additionalProperties)
|
|
176
|
+
? options.additionalProperties
|
|
177
|
+
: !options.strict,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (options.validation) {
|
|
181
|
+
Object.defineProperty(schema, SCHEMA_OPTIONS, {
|
|
182
|
+
value: { ...options.validation },
|
|
183
|
+
enumerable: false,
|
|
184
|
+
})
|
|
74
185
|
}
|
|
75
186
|
|
|
76
187
|
return schema
|
|
@@ -78,17 +189,26 @@ export function defineSchema(schemaDef, options = {}) {
|
|
|
78
189
|
|
|
79
190
|
/**
|
|
80
191
|
* Builds a validating function for a JSON schema.
|
|
192
|
+
*
|
|
193
|
+
* Options are resolved in three layers — {@link setAtaDefaults} → the schema's bound
|
|
194
|
+
* `validation` (see {@link defineSchema}) → the `options` argument here — with the last
|
|
195
|
+
* winning, and {@link EMPYRIA_FORMATS} always folded into `formats`.
|
|
196
|
+
*
|
|
197
|
+
* The returned function returns the schema-shaped value: `ata-validator`'s `result.data`,
|
|
198
|
+
* which is `data` itself unless `coerceTypes` / `removeAdditional` / `useDefaults` (the last
|
|
199
|
+
* on by default) applied. Those passes also mutate `data` in place, so pass a copy when the
|
|
200
|
+
* caller must keep its input pristine (see `createEnv` / the `env.js` files).
|
|
81
201
|
* @param {Object} schema - JSON schema to validate against.
|
|
82
|
-
* @param {
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @returns {(data: *) => *} Function that returns
|
|
202
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Per-call options (see above):
|
|
203
|
+
* `coerceTypes`, `removeAdditional`, `useDefaults`, `assertFormat`, `abortEarly`,
|
|
204
|
+
* `richErrors`, `verbose`, `formats`, ... Left unset, validation is strict: a schema
|
|
205
|
+
* expecting `type: 'number'` rejects `"32"`.
|
|
206
|
+
* @returns {(data: *) => *} Function that returns the (possibly coerced/defaulted) value.
|
|
87
207
|
* @throws {EmpyriaError} `BaseErrors.ValidationError` (with a pretty-rendered violation list
|
|
88
208
|
* in its `validation` param) if `data` doesn't satisfy the schema.
|
|
89
209
|
*/
|
|
90
210
|
export function createValidator(schema, options) {
|
|
91
|
-
const v = new Validator(schema, options)
|
|
211
|
+
const v = new Validator(schema, resolveOptions(schema, options))
|
|
92
212
|
|
|
93
213
|
return (data) => {
|
|
94
214
|
const result = v.validate(data)
|
|
@@ -99,7 +219,7 @@ export function createValidator(schema, options) {
|
|
|
99
219
|
validation: renderPretty(result.errors),
|
|
100
220
|
})
|
|
101
221
|
}
|
|
102
|
-
return data
|
|
222
|
+
return result.data ?? data
|
|
103
223
|
}
|
|
104
224
|
}
|
|
105
225
|
|
|
@@ -107,8 +227,8 @@ export function createValidator(schema, options) {
|
|
|
107
227
|
* Validates `input` against `schema` in one call.
|
|
108
228
|
* @param {Object} schema - JSON schema to validate against.
|
|
109
229
|
* @param {*} input - Value to validate.
|
|
110
|
-
* @param {
|
|
111
|
-
* @returns {*} `input`
|
|
230
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createValidator}.
|
|
231
|
+
* @returns {*} `input` — coerced/defaulted/stripped per the resolved options — if valid.
|
|
112
232
|
* @throws {EmpyriaError} `BaseErrors.ValidationError` if `input` doesn't satisfy the schema.
|
|
113
233
|
*/
|
|
114
234
|
export function validate(schema, input, options) {
|
|
@@ -118,25 +238,57 @@ export function validate(schema, input, options) {
|
|
|
118
238
|
return validatedInput
|
|
119
239
|
}
|
|
120
240
|
|
|
241
|
+
/**
|
|
242
|
+
* {@link createValidator} under an intent-revealing name, for call sites whose purpose is to
|
|
243
|
+
* *transform* input — coerce string-typed values, strip unknown keys, apply schema defaults —
|
|
244
|
+
* rather than merely assert its shape. Behaviour is identical; the name documents that the
|
|
245
|
+
* returned value is expected to differ from the input.
|
|
246
|
+
* @param {Object} schema - JSON schema to validate/parse against.
|
|
247
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Typically `{ coerceTypes: true }`
|
|
248
|
+
* and/or `{ removeAdditional: true }` (with a closed schema).
|
|
249
|
+
* @returns {(data: *) => *} Function returning the parsed value.
|
|
250
|
+
* @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
|
|
251
|
+
*/
|
|
252
|
+
export function createParser(schema, options) {
|
|
253
|
+
return createValidator(schema, options)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* {@link validate} under an intent-revealing name — see {@link createParser}.
|
|
258
|
+
* @param {Object} schema - JSON schema to parse against.
|
|
259
|
+
* @param {*} input - Value to parse.
|
|
260
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createParser}.
|
|
261
|
+
* @returns {*} The parsed (coerced/defaulted/stripped) value, if valid.
|
|
262
|
+
* @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
|
|
263
|
+
*/
|
|
264
|
+
export function parse(schema, input, options) {
|
|
265
|
+
return createParser(schema, options)(input)
|
|
266
|
+
}
|
|
267
|
+
|
|
121
268
|
/**
|
|
122
269
|
* Builds a non-throwing checking function for a JSON schema — the structured-result
|
|
123
270
|
* counterpart to {@link createValidator}. Use it when a caller needs `ata-validator`'s own
|
|
124
271
|
* error list (to build its own error type, count violations, or render them itself with
|
|
125
272
|
* {@link renderCompact} / {@link renderJSON}) rather than a `BaseErrors.ValidationError`
|
|
126
|
-
* carrying a pre-rendered string
|
|
273
|
+
* carrying a pre-rendered string, or when it wants the parsed value without a `try`/`catch`.
|
|
127
274
|
* @param {Object} schema - JSON schema to validate against.
|
|
128
|
-
* @param {
|
|
129
|
-
*
|
|
130
|
-
* @returns {(data: *) => { valid: boolean, errors: Array<Object> }} Function that
|
|
131
|
-
* verdict
|
|
132
|
-
*
|
|
275
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Resolved the same way as for
|
|
276
|
+
* {@link createValidator} (see its docs for the layers and the common flags).
|
|
277
|
+
* @returns {(data: *) => { valid: boolean, data: *, errors: Array<Object> }} Function that
|
|
278
|
+
* returns the verdict, the parsed value (`data` when valid — coerced/defaulted/stripped per
|
|
279
|
+
* the resolved options; `undefined` when invalid), and, when invalid, `ata-validator`'s raw
|
|
280
|
+
* error objects (empty array when valid). Never throws on a validation failure.
|
|
133
281
|
*/
|
|
134
282
|
export function createChecker(schema, options) {
|
|
135
|
-
const v = new Validator(schema, options)
|
|
283
|
+
const v = new Validator(schema, resolveOptions(schema, options))
|
|
136
284
|
|
|
137
285
|
return (data) => {
|
|
138
286
|
const result = v.validate(data)
|
|
139
|
-
return {
|
|
287
|
+
return {
|
|
288
|
+
valid: result.valid,
|
|
289
|
+
data: result.valid ? (result.data ?? data) : undefined,
|
|
290
|
+
errors: result.valid ? [] : result.errors,
|
|
291
|
+
}
|
|
140
292
|
}
|
|
141
293
|
}
|
|
142
294
|
|
|
@@ -144,9 +296,9 @@ export function createChecker(schema, options) {
|
|
|
144
296
|
* Checks `input` against `schema` in one call, without throwing.
|
|
145
297
|
* @param {Object} schema - JSON schema to validate against.
|
|
146
298
|
* @param {*} input - Value to validate.
|
|
147
|
-
* @param {
|
|
148
|
-
* @returns {{ valid: boolean, errors: Array<Object> }} The verdict,
|
|
149
|
-
*
|
|
299
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createChecker}.
|
|
300
|
+
* @returns {{ valid: boolean, data: *, errors: Array<Object> }} The verdict, the parsed value
|
|
301
|
+
* (when valid), plus `ata-validator`'s raw error objects when invalid.
|
|
150
302
|
*/
|
|
151
303
|
export function checkSchema(schema, input, options) {
|
|
152
304
|
return createChecker(schema, options)(input)
|
|
@@ -250,50 +402,54 @@ export function number(defaultValue) {
|
|
|
250
402
|
}
|
|
251
403
|
|
|
252
404
|
/**
|
|
253
|
-
* JSON schema for an email-address string,
|
|
254
|
-
* @
|
|
405
|
+
* JSON schema for an email-address string, checked against the `email` format
|
|
406
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_EMAIL}).
|
|
407
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
255
408
|
*/
|
|
256
409
|
export function email() {
|
|
257
410
|
return {
|
|
258
411
|
type: 'string',
|
|
259
|
-
|
|
412
|
+
format: 'empyria-email',
|
|
260
413
|
description: 'Email address',
|
|
261
414
|
}
|
|
262
415
|
}
|
|
263
416
|
|
|
264
417
|
/**
|
|
265
|
-
* JSON schema for a password string,
|
|
266
|
-
* (
|
|
267
|
-
*
|
|
418
|
+
* JSON schema for a password string, checked against the `password` format
|
|
419
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_PASS}: lower-case, upper-case, a digit,
|
|
420
|
+
* and at least 8 characters).
|
|
421
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
268
422
|
*/
|
|
269
423
|
export function password() {
|
|
270
424
|
return {
|
|
271
425
|
type: 'string',
|
|
272
|
-
|
|
426
|
+
format: 'empyria-password',
|
|
273
427
|
description: 'password',
|
|
274
428
|
}
|
|
275
429
|
}
|
|
276
430
|
|
|
277
431
|
/**
|
|
278
|
-
* JSON schema for an IPv4-address string,
|
|
279
|
-
* @
|
|
432
|
+
* JSON schema for an IPv4-address string, checked against the `ipv4` format
|
|
433
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv4}).
|
|
434
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
280
435
|
*/
|
|
281
436
|
export function ip4() {
|
|
282
437
|
return {
|
|
283
438
|
type: 'string',
|
|
284
|
-
|
|
439
|
+
format: 'empyria-ipv4',
|
|
285
440
|
description: 'IP address v4',
|
|
286
441
|
}
|
|
287
442
|
}
|
|
288
443
|
|
|
289
444
|
/**
|
|
290
|
-
* JSON schema for an IPv6-address string,
|
|
291
|
-
* @
|
|
445
|
+
* JSON schema for an IPv6-address string, checked against the `ipv6` format
|
|
446
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv6}).
|
|
447
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
292
448
|
*/
|
|
293
449
|
export function ip6() {
|
|
294
450
|
return {
|
|
295
451
|
type: 'string',
|
|
296
|
-
|
|
452
|
+
format: 'empyria-ipv6',
|
|
297
453
|
description: 'IP address v6',
|
|
298
454
|
}
|
|
299
455
|
}
|
package/lib/Env.js
CHANGED
|
@@ -27,8 +27,17 @@ const DEFAULT_SERVICE_SCHEMA = {
|
|
|
27
27
|
* Builds a JSON schema for a microservice's environment variables, merging the
|
|
28
28
|
* Empyria-wide defaults with any service-specific additions/overrides.
|
|
29
29
|
* @param {Object} [specificSchema] - Additional or overriding property schemas, keyed by env var name.
|
|
30
|
+
* @param {Object} [options={}]
|
|
31
|
+
* @param {boolean} [options.closed] - When true, the schema rejects the whole of
|
|
32
|
+
* `process.env` beyond its declared keys and binds `{ coerceTypes: true, removeAdditional:
|
|
33
|
+
* true }` to itself, so `validate`/`parse` return an object holding *only* the declared
|
|
34
|
+
* vars — no undeclared environment riding along into `settings`. Off by default:
|
|
35
|
+
* `additionalProperties: true`, every extra var passes through unchanged.
|
|
30
36
|
* @returns {Object} A JSON schema object (see `defineSchema` in `./Ata.js`).
|
|
31
37
|
*/
|
|
32
|
-
export function createEnv(specificSchema) {
|
|
33
|
-
return defineSchema(
|
|
38
|
+
export function createEnv(specificSchema, { closed = false } = {}) {
|
|
39
|
+
return defineSchema(
|
|
40
|
+
Object.assign({}, DEFAULT_SERVICE_SCHEMA, specificSchema),
|
|
41
|
+
closed ? { strict: true, validation: { coerceTypes: true, removeAdditional: true } } : {},
|
|
42
|
+
)
|
|
34
43
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empyria/common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared backend goodies for Empyria",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Imre Fazekas <imre.fazekas@icloud.com>",
|
|
@@ -19,17 +19,17 @@
|
|
|
19
19
|
"test:watch": "bun test --watch"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@empyria/classification": "0.1.
|
|
23
|
-
"ata-validator": "1.
|
|
22
|
+
"@empyria/classification": "0.1.6",
|
|
23
|
+
"ata-validator": "1.14.1",
|
|
24
24
|
"clerobee": "1.0.8",
|
|
25
25
|
"ms": "2.1.3"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"oxfmt": "0.
|
|
29
|
-
"oxlint": "1.
|
|
28
|
+
"oxfmt": "0.67.0",
|
|
29
|
+
"oxlint": "1.82.0"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
|
-
"bun": ">=1.4.
|
|
32
|
+
"bun": ">=1.4.2",
|
|
33
33
|
"node": ">=26"
|
|
34
34
|
},
|
|
35
35
|
"packageManager": "bun@1.4.2"
|
package/test/Ata.test.js
CHANGED
|
@@ -7,8 +7,15 @@ import {
|
|
|
7
7
|
cloneBySchema,
|
|
8
8
|
defineSchema,
|
|
9
9
|
validate,
|
|
10
|
+
parse,
|
|
11
|
+
createParser,
|
|
12
|
+
createValidator,
|
|
10
13
|
createChecker,
|
|
11
14
|
checkSchema,
|
|
15
|
+
schemaOptions,
|
|
16
|
+
setAtaDefaults,
|
|
17
|
+
getAtaDefaults,
|
|
18
|
+
EMPYRIA_FORMATS,
|
|
12
19
|
bool,
|
|
13
20
|
string,
|
|
14
21
|
enumType,
|
|
@@ -89,6 +96,31 @@ describe('defineSchema', () => {
|
|
|
89
96
|
test('disallows extra properties when strict', () => {
|
|
90
97
|
expect(defineSchema({ a: string() }, { strict: true }).additionalProperties).toBe(false)
|
|
91
98
|
})
|
|
99
|
+
|
|
100
|
+
test('an explicit additionalProperties overrides strict', () => {
|
|
101
|
+
expect(
|
|
102
|
+
defineSchema({ a: string() }, { strict: true, additionalProperties: true })
|
|
103
|
+
.additionalProperties,
|
|
104
|
+
).toBe(true)
|
|
105
|
+
expect(
|
|
106
|
+
defineSchema({ a: string() }, { additionalProperties: false }).additionalProperties,
|
|
107
|
+
).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('binds validation options on a non-enumerable key, invisible to JSON/toEqual', () => {
|
|
111
|
+
const schema = defineSchema(
|
|
112
|
+
{ a: string() },
|
|
113
|
+
{ validation: { coerceTypes: true, removeAdditional: true } },
|
|
114
|
+
)
|
|
115
|
+
expect(schema).toEqual({
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: { a: { type: 'string' } },
|
|
118
|
+
required: ['a'],
|
|
119
|
+
additionalProperties: true,
|
|
120
|
+
})
|
|
121
|
+
expect(JSON.stringify(schema)).not.toContain('coerceTypes')
|
|
122
|
+
expect(schemaOptions(schema)).toEqual({ coerceTypes: true, removeAdditional: true })
|
|
123
|
+
})
|
|
92
124
|
})
|
|
93
125
|
|
|
94
126
|
describe('cloneBySchema', () => {
|
|
@@ -171,11 +203,11 @@ describe('schema builders', () => {
|
|
|
171
203
|
})
|
|
172
204
|
})
|
|
173
205
|
|
|
174
|
-
test('email/password/ip4/ip6 reference their
|
|
175
|
-
expect(email().
|
|
176
|
-
expect(password().
|
|
177
|
-
expect(ip4().
|
|
178
|
-
expect(ip6().
|
|
206
|
+
test('email/password/ip4/ip6 reference their named formats', () => {
|
|
207
|
+
expect(email().format).toBe('empyria-email')
|
|
208
|
+
expect(password().format).toBe('empyria-password')
|
|
209
|
+
expect(ip4().format).toBe('empyria-ipv4')
|
|
210
|
+
expect(ip6().format).toBe('empyria-ipv6')
|
|
179
211
|
})
|
|
180
212
|
})
|
|
181
213
|
|
|
@@ -189,8 +221,16 @@ describe('renderCompact / renderJSON re-exports', () => {
|
|
|
189
221
|
describe('createChecker / checkSchema', () => {
|
|
190
222
|
const schema = defineSchema({ name: string() })
|
|
191
223
|
|
|
192
|
-
test('checkSchema returns valid:true and no errors for good input', () => {
|
|
193
|
-
expect(checkSchema(schema, { name: 'Bob' })).toEqual({
|
|
224
|
+
test('checkSchema returns valid:true, the parsed value, and no errors for good input', () => {
|
|
225
|
+
expect(checkSchema(schema, { name: 'Bob' })).toEqual({
|
|
226
|
+
valid: true,
|
|
227
|
+
data: { name: 'Bob' },
|
|
228
|
+
errors: [],
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('checkSchema reports data:undefined for bad input', () => {
|
|
233
|
+
expect(checkSchema(schema, {}).data).toBeUndefined()
|
|
194
234
|
})
|
|
195
235
|
|
|
196
236
|
test('checkSchema returns valid:false and a non-empty error list for bad input, without throwing', () => {
|
|
@@ -205,8 +245,87 @@ describe('createChecker / checkSchema', () => {
|
|
|
205
245
|
expect(renderCompact(errors)).toContain('name')
|
|
206
246
|
})
|
|
207
247
|
|
|
208
|
-
test('createChecker builds a reusable checker
|
|
248
|
+
test('createChecker builds a reusable checker, forwards options, and returns the parsed value', () => {
|
|
209
249
|
const check = createChecker(defineSchema({ port: number() }), { coerceTypes: true })
|
|
210
|
-
expect(check({ port: '4040' })).toEqual({ valid: true, errors: [] })
|
|
250
|
+
expect(check({ port: '4040' })).toEqual({ valid: true, data: { port: 4040 }, errors: [] })
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('named formats', () => {
|
|
255
|
+
test('EMPYRIA_FORMATS enforce their patterns', () => {
|
|
256
|
+
expect(EMPYRIA_FORMATS['empyria-email']('user@example.com')).toBe(true)
|
|
257
|
+
expect(EMPYRIA_FORMATS['empyria-email']('user@example!com')).toBe(false)
|
|
258
|
+
expect(EMPYRIA_FORMATS['empyria-password']('Abcdefg1')).toBe(true)
|
|
259
|
+
expect(EMPYRIA_FORMATS['empyria-password']('Abcdefgh')).toBe(false)
|
|
260
|
+
expect(EMPYRIA_FORMATS['empyria-ipv4']('192.168.0.1')).toBe(true)
|
|
261
|
+
expect(EMPYRIA_FORMATS['empyria-ipv4']('999.0.0.1')).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('a schema from email() is enforced through this module without per-call wiring', () => {
|
|
265
|
+
const schema = defineSchema({ addr: email() })
|
|
266
|
+
expect(validate(schema, { addr: 'user@example.com' })).toEqual({ addr: 'user@example.com' })
|
|
267
|
+
expect(() => validate(schema, { addr: 'nope' })).toThrow(EmpyriaError)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('a schema from password() is enforced (no ata-validator built-in for it)', () => {
|
|
271
|
+
const schema = defineSchema({ pw: password() })
|
|
272
|
+
expect(() => validate(schema, { pw: 'weak' })).toThrow(EmpyriaError)
|
|
273
|
+
expect(validate(schema, { pw: 'Abcdefg1' })).toEqual({ pw: 'Abcdefg1' })
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('a per-call formats entry overrides the Empyria default', () => {
|
|
277
|
+
const schema = defineSchema({ addr: email() })
|
|
278
|
+
const only = { formats: { 'empyria-email': (s) => s === 'ok@ok.io' } }
|
|
279
|
+
expect(validate(schema, { addr: 'ok@ok.io' }, only)).toEqual({ addr: 'ok@ok.io' })
|
|
280
|
+
expect(() => validate(schema, { addr: 'user@example.com' }, only)).toThrow(EmpyriaError)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('option layering: setAtaDefaults / schema-bound / per-call', () => {
|
|
285
|
+
test('getAtaDefaults starts empty', () => {
|
|
286
|
+
expect(getAtaDefaults()).toEqual({})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('a library default is applied when no per-call option is given', () => {
|
|
290
|
+
const schema = defineSchema({ port: number() })
|
|
291
|
+
expect(() => validate(schema, { port: '8080' })).toThrow(EmpyriaError)
|
|
292
|
+
setAtaDefaults({ coerceTypes: true })
|
|
293
|
+
try {
|
|
294
|
+
expect(validate(schema, { port: '8080' })).toEqual({ port: 8080 })
|
|
295
|
+
} finally {
|
|
296
|
+
setAtaDefaults()
|
|
297
|
+
}
|
|
298
|
+
expect(getAtaDefaults()).toEqual({})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('schema-bound validation options beat library defaults, per-call beats both', () => {
|
|
302
|
+
setAtaDefaults({ coerceTypes: false })
|
|
303
|
+
try {
|
|
304
|
+
const bound = defineSchema({ port: number() }, { validation: { coerceTypes: true } })
|
|
305
|
+
// schema-bound coerceTypes:true wins over the library default
|
|
306
|
+
expect(validate(bound, { port: '80' })).toEqual({ port: 80 })
|
|
307
|
+
// per-call coerceTypes:false wins over the schema-bound value
|
|
308
|
+
expect(() => validate(bound, { port: '80' }, { coerceTypes: false })).toThrow(
|
|
309
|
+
EmpyriaError,
|
|
310
|
+
)
|
|
311
|
+
} finally {
|
|
312
|
+
setAtaDefaults()
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('parse / createParser', () => {
|
|
318
|
+
test('parse coerces and strips against a closed schema', () => {
|
|
319
|
+
const schema = defineSchema(
|
|
320
|
+
{ port: number() },
|
|
321
|
+
{ strict: true, validation: { coerceTypes: true, removeAdditional: true } },
|
|
322
|
+
)
|
|
323
|
+
expect(parse(schema, { port: '3000', stray: 'x' })).toEqual({ port: 3000 })
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('createParser is a reusable createValidator under an intent-revealing name', () => {
|
|
327
|
+
const p = createParser(defineSchema({ n: number() }), { coerceTypes: true })
|
|
328
|
+
expect(p({ n: '7' })).toEqual({ n: 7 })
|
|
329
|
+
expect(typeof createValidator).toBe('function')
|
|
211
330
|
})
|
|
212
331
|
})
|
package/test/Env.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test'
|
|
2
2
|
import { createEnv } from '../lib/Env.js'
|
|
3
|
+
import { validate, schemaOptions } from '../lib/Ata.js'
|
|
3
4
|
|
|
4
5
|
describe('createEnv', () => {
|
|
5
6
|
test('includes the shared service defaults plus service-specific additions', () => {
|
|
@@ -15,4 +16,33 @@ describe('createEnv', () => {
|
|
|
15
16
|
const schema = createEnv()
|
|
16
17
|
expect(schema.properties.EMPYRIA_MONGO_URI).toBeUndefined()
|
|
17
18
|
})
|
|
19
|
+
|
|
20
|
+
test('is open by default: undeclared vars pass through untouched', () => {
|
|
21
|
+
const schema = createEnv()
|
|
22
|
+
expect(schema.additionalProperties).toBe(true)
|
|
23
|
+
expect(schemaOptions(schema)).toEqual({})
|
|
24
|
+
const out = validate(
|
|
25
|
+
schema,
|
|
26
|
+
{ NODE_ENV: 'test', UNDECLARED: 'ride-along' },
|
|
27
|
+
{ coerceTypes: true },
|
|
28
|
+
)
|
|
29
|
+
expect(out.UNDECLARED).toBe('ride-along')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('closed: rejects the wider environment and returns only the declared keys', () => {
|
|
33
|
+
const schema = createEnv({}, { closed: true })
|
|
34
|
+
expect(schema.additionalProperties).toBe(false)
|
|
35
|
+
expect(schemaOptions(schema)).toEqual({ coerceTypes: true, removeAdditional: true })
|
|
36
|
+
|
|
37
|
+
const out = validate(schema, {
|
|
38
|
+
NODE_ENV: 'test',
|
|
39
|
+
EMPYRIA_ID_LENGTH: '48',
|
|
40
|
+
PATH: '/usr/bin',
|
|
41
|
+
HOME: '/root',
|
|
42
|
+
})
|
|
43
|
+
expect(out.EMPYRIA_ID_LENGTH).toBe(48)
|
|
44
|
+
expect(out.NODE_ENV).toBe('test')
|
|
45
|
+
expect(out.PATH).toBeUndefined()
|
|
46
|
+
expect(out.HOME).toBeUndefined()
|
|
47
|
+
})
|
|
18
48
|
})
|