@empyria/common 0.1.9 → 0.3.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 +73 -8
- package/lib/Ata.js +401 -49
- package/lib/Env.js +11 -2
- package/package.json +3 -3
- package/test/Ata.test.js +284 -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`, `
|
|
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`, `integer`, `bool`, `enumType`, `constant`, `nullable`, `array`, `discriminated`, `email`, `isoDateTime`, ...) 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 _Schema builders_ and _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,71 @@ 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
|
+
|
|
93
|
+
## Schema builders
|
|
94
|
+
|
|
95
|
+
Each returns a plain JSON Schema fragment; compose them in `defineSchema(map, opts)`.
|
|
96
|
+
|
|
97
|
+
| Builder | Produces |
|
|
98
|
+
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `bool(default?)` | `{ type: 'boolean' }` |
|
|
100
|
+
| `string(default?, minLength \| { minLength, maxLength, pattern, format, description })` | `{ type: 'string', … }` |
|
|
101
|
+
| `number(default?, { min, max, exclusiveMin, exclusiveMax, multipleOf, description })` | `{ type: 'number', … }` |
|
|
102
|
+
| `integer(default?, …)` | `{ type: 'integer', … }` — use for ports/counts; rejects `3.5` |
|
|
103
|
+
| `enumType(list, default?, { type, description })` | `{ enum, type? }` |
|
|
104
|
+
| `constant(value, { description })` | `{ const: value }` |
|
|
105
|
+
| `nullable(schema)` | `type: [t, 'null']`, or `{ anyOf: [schema, { type: 'null' }] }` |
|
|
106
|
+
| `array(items?, { minItems, maxItems, uniqueItems, default, description })` | `{ type: 'array', … }` |
|
|
107
|
+
| `email() / password() / ip4() / ip6()` | `{ type: 'string', format: 'empyria-*' }` (see `EMPYRIA_FORMATS`) |
|
|
108
|
+
| `isoDateTime() / isoDate() / isoTime() / duration() / uri() / uuid()` | `{ type: 'string', format: … }` (ata built-in formats) |
|
|
109
|
+
| `logLevel() / nodeEnv() / tmpFile() / logFile()` | domain presets |
|
|
110
|
+
| `discriminated(prop, { value: schema, … }, { description })` | JSON Schema **v1** `propertyDependencies` — an object whose shape depends on `prop`'s value |
|
|
111
|
+
|
|
112
|
+
### Dialect
|
|
113
|
+
|
|
114
|
+
`defineSchema(map, { dialect: 'v1' \| '2020-12' \| 'draft-07' \| <uri> })` stamps `$schema`
|
|
115
|
+
(`ata-validator` reads it to pick the dialect). `setDefaultDialect('v1')` / `getDefaultDialect()`
|
|
116
|
+
set a process-wide default a per-call `dialect` overrides. v1 differs from 2020-12 only in
|
|
117
|
+
`$dynamicRef` scoping and unlocks `propertyDependencies`; `discriminated()` works on any
|
|
118
|
+
dialect (ata supports the keyword as an extension) but `dialect: 'v1'` makes it spec-legitimate.
|
|
119
|
+
|
|
120
|
+
`defineSchema(map, { strict: 'unevaluated' })` sets `unevaluatedProperties: false` while
|
|
121
|
+
leaving `additionalProperties` open — the composable form of strict, for schemas meant to be
|
|
122
|
+
`allOf`-combined.
|
|
123
|
+
|
|
59
124
|
## Scripts
|
|
60
125
|
|
|
61
126
|
```bash
|
package/lib/Ata.js
CHANGED
|
@@ -31,6 +31,134 @@ 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
|
+
/**
|
|
67
|
+
* Known JSON Schema dialect URIs, keyed by short name. `defineSchema({ dialect })` accepts a
|
|
68
|
+
* short name or a raw URI and stamps it as the schema's `$schema`. `ata-validator` reads
|
|
69
|
+
* `$schema` to pick the dialect; `'v1'` (the forthcoming IETF standard) differs from
|
|
70
|
+
* `'2020-12'` only in `$dynamicRef` scoping and unlocks `propertyDependencies` (see
|
|
71
|
+
* {@link discriminated}).
|
|
72
|
+
*/
|
|
73
|
+
export const DIALECTS = {
|
|
74
|
+
'2020-12': 'https://json-schema.org/draft/2020-12/schema',
|
|
75
|
+
'draft-07': 'http://json-schema.org/draft-07/schema#',
|
|
76
|
+
v1: 'https://json-schema.org/v1',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Process-wide default dialect for {@link defineSchema}; `null` = emit no `$schema`. */
|
|
80
|
+
let defaultDialect = null
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sets the process-wide default dialect for {@link defineSchema} — the `$schema` it stamps
|
|
84
|
+
* when a call passes no `dialect` of its own. Call once at startup to move a service to a
|
|
85
|
+
* dialect (e.g. `'v1'`) without touching every schema.
|
|
86
|
+
* @param {('2020-12'|'draft-07'|'v1'|string|null)} [dialect=null] - Short name, raw URI, or
|
|
87
|
+
* `null` to go back to emitting no `$schema`.
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
export function setDefaultDialect(dialect = null) {
|
|
91
|
+
defaultDialect = dialect
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns the process-wide default dialect (see {@link setDefaultDialect}).
|
|
96
|
+
* @returns {string|null}
|
|
97
|
+
*/
|
|
98
|
+
export function getDefaultDialect() {
|
|
99
|
+
return defaultDialect
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Process-wide default `ata-validator` options; the base layer for every call here. */
|
|
103
|
+
let libraryDefaults = {}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sets this module's process-wide default `ata-validator` options — the base layer every
|
|
107
|
+
* {@link createValidator} / {@link validate} / {@link parse} / {@link createChecker} call in
|
|
108
|
+
* this process starts from. Meant to be called once at startup to express a deployment-wide
|
|
109
|
+
* policy (e.g. `{ coerceTypes: true }`). Individual calls stay free to override any key, and
|
|
110
|
+
* a schema built by {@link defineSchema} with a `validation` bag overrides these too.
|
|
111
|
+
*
|
|
112
|
+
* Merge order, lowest to highest precedence: **library defaults → schema-bound → per-call**.
|
|
113
|
+
* `formats` is merged one level deep, with {@link EMPYRIA_FORMATS} always underneath.
|
|
114
|
+
* @param {import('ata-validator').ValidatorOptions} [options={}] - Replaces the current
|
|
115
|
+
* defaults wholesale. Pass `{}` (or call with no argument) to clear them.
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
export function setAtaDefaults(options = {}) {
|
|
119
|
+
libraryDefaults = { ...options }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns a shallow copy of the current process-wide default options (see {@link setAtaDefaults}).
|
|
124
|
+
* @returns {import('ata-validator').ValidatorOptions}
|
|
125
|
+
*/
|
|
126
|
+
export function getAtaDefaults() {
|
|
127
|
+
return { ...libraryDefaults }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Returns a shallow copy of the `ata-validator` options bound to `schema` by
|
|
132
|
+
* {@link defineSchema}'s `validation` option, or `{}` if none were bound.
|
|
133
|
+
* @param {Object} schema
|
|
134
|
+
* @returns {import('ata-validator').ValidatorOptions}
|
|
135
|
+
*/
|
|
136
|
+
export function schemaOptions(schema) {
|
|
137
|
+
const bound = schema?.[SCHEMA_OPTIONS]
|
|
138
|
+
return bound ? { ...bound } : {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Merges the three option layers for a single call and folds in {@link EMPYRIA_FORMATS}.
|
|
143
|
+
* @param {Object} schema - Schema being validated (may carry bound options).
|
|
144
|
+
* @param {import('ata-validator').ValidatorOptions} [perCall] - Options passed to this call.
|
|
145
|
+
* @returns {import('ata-validator').ValidatorOptions}
|
|
146
|
+
*/
|
|
147
|
+
function resolveOptions(schema, perCall) {
|
|
148
|
+
const bound = schema?.[SCHEMA_OPTIONS] ?? {}
|
|
149
|
+
return {
|
|
150
|
+
...libraryDefaults,
|
|
151
|
+
...bound,
|
|
152
|
+
...perCall,
|
|
153
|
+
formats: {
|
|
154
|
+
...EMPYRIA_FORMATS,
|
|
155
|
+
...libraryDefaults.formats,
|
|
156
|
+
...bound.formats,
|
|
157
|
+
...(perCall && perCall.formats),
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
34
162
|
/**
|
|
35
163
|
* Builds a function that clones only the keys declared on a schema's `properties`.
|
|
36
164
|
* @param {Object} schema - JSON schema with a `properties` map.
|
|
@@ -45,12 +173,27 @@ export function cloneBySchema(schema) {
|
|
|
45
173
|
* Builds a JSON schema object from a flat map of property schemas.
|
|
46
174
|
* @param {Object} schemaDef - Map of property name to its JSON schema.
|
|
47
175
|
* @param {Object} [options={}]
|
|
48
|
-
* @param {boolean} [options.strict] -
|
|
176
|
+
* @param {(boolean|'unevaluated')} [options.strict] - `true` disallows properties not listed
|
|
177
|
+
* in `schemaDef` (`additionalProperties: false`); combine with
|
|
178
|
+
* `validation: { removeAdditional: true }` to *strip* unknown keys instead of rejecting
|
|
179
|
+
* them. `'unevaluated'` sets `unevaluatedProperties: false` and leaves `additionalProperties`
|
|
180
|
+
* open — the composable form of strict, for a schema meant to be `allOf`-combined. Ignored
|
|
181
|
+
* when `options.additionalProperties` is given.
|
|
182
|
+
* @param {boolean|Object} [options.additionalProperties] - Explicit `additionalProperties`
|
|
183
|
+
* value (a boolean, or a sub-schema for the extra keys). Overrides `options.strict`.
|
|
184
|
+
* @param {('2020-12'|'draft-07'|'v1'|string)} [options.dialect] - Stamp this dialect as the
|
|
185
|
+
* schema's `$schema` (short name from {@link DIALECTS}, or a raw URI). Defaults to
|
|
186
|
+
* {@link getDefaultDialect}; when both are unset no `$schema` is emitted.
|
|
49
187
|
* @param {boolean} [options.optional] - If true, a property whose own schema carries
|
|
50
188
|
* `optional: true` is excluded from `required` (that marker is then stripped from the
|
|
51
189
|
* property itself — never leaked into the resulting schema, since it isn't a real JSON
|
|
52
190
|
* Schema keyword). Off by default: every declared property is required, exactly as before —
|
|
53
191
|
* a caller that never sets `optional: true` on any property sees no difference either way.
|
|
192
|
+
* @param {import('ata-validator').ValidatorOptions} [options.validation] - `ata-validator`
|
|
193
|
+
* options bound to the returned schema: every {@link createValidator} / {@link validate} /
|
|
194
|
+
* {@link parse} / {@link createChecker} call for it starts from these (below per-call
|
|
195
|
+
* options, above {@link setAtaDefaults}). Stored on a non-enumerable key, so the schema
|
|
196
|
+
* still serialises and compares as a plain JSON Schema.
|
|
54
197
|
* @returns {Object} A JSON schema: `{ type: 'object', properties, required, additionalProperties }`.
|
|
55
198
|
*/
|
|
56
199
|
export function defineSchema(schemaDef, options = {}) {
|
|
@@ -66,11 +209,25 @@ export function defineSchema(schemaDef, options = {}) {
|
|
|
66
209
|
required.push(key)
|
|
67
210
|
}
|
|
68
211
|
}
|
|
212
|
+
const unevaluated = options.strict === 'unevaluated'
|
|
69
213
|
const schema = {
|
|
70
214
|
type: 'object',
|
|
71
215
|
properties,
|
|
72
216
|
required,
|
|
73
|
-
additionalProperties:
|
|
217
|
+
additionalProperties: defined(options.additionalProperties)
|
|
218
|
+
? options.additionalProperties
|
|
219
|
+
: !options.strict || unevaluated,
|
|
220
|
+
}
|
|
221
|
+
if (unevaluated) schema.unevaluatedProperties = false
|
|
222
|
+
|
|
223
|
+
const dialect = options.dialect ?? defaultDialect
|
|
224
|
+
if (dialect) schema.$schema = DIALECTS[dialect] ?? dialect
|
|
225
|
+
|
|
226
|
+
if (options.validation) {
|
|
227
|
+
Object.defineProperty(schema, SCHEMA_OPTIONS, {
|
|
228
|
+
value: { ...options.validation },
|
|
229
|
+
enumerable: false,
|
|
230
|
+
})
|
|
74
231
|
}
|
|
75
232
|
|
|
76
233
|
return schema
|
|
@@ -78,17 +235,26 @@ export function defineSchema(schemaDef, options = {}) {
|
|
|
78
235
|
|
|
79
236
|
/**
|
|
80
237
|
* Builds a validating function for a JSON schema.
|
|
238
|
+
*
|
|
239
|
+
* Options are resolved in three layers — {@link setAtaDefaults} → the schema's bound
|
|
240
|
+
* `validation` (see {@link defineSchema}) → the `options` argument here — with the last
|
|
241
|
+
* winning, and {@link EMPYRIA_FORMATS} always folded into `formats`.
|
|
242
|
+
*
|
|
243
|
+
* The returned function returns the schema-shaped value: `ata-validator`'s `result.data`,
|
|
244
|
+
* which is `data` itself unless `coerceTypes` / `removeAdditional` / `useDefaults` (the last
|
|
245
|
+
* on by default) applied. Those passes also mutate `data` in place, so pass a copy when the
|
|
246
|
+
* caller must keep its input pristine (see `createEnv` / the `env.js` files).
|
|
81
247
|
* @param {Object} schema - JSON schema to validate against.
|
|
82
|
-
* @param {
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @returns {(data: *) => *} Function that returns
|
|
248
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Per-call options (see above):
|
|
249
|
+
* `coerceTypes`, `removeAdditional`, `useDefaults`, `assertFormat`, `abortEarly`,
|
|
250
|
+
* `richErrors`, `verbose`, `formats`, ... Left unset, validation is strict: a schema
|
|
251
|
+
* expecting `type: 'number'` rejects `"32"`.
|
|
252
|
+
* @returns {(data: *) => *} Function that returns the (possibly coerced/defaulted) value.
|
|
87
253
|
* @throws {EmpyriaError} `BaseErrors.ValidationError` (with a pretty-rendered violation list
|
|
88
254
|
* in its `validation` param) if `data` doesn't satisfy the schema.
|
|
89
255
|
*/
|
|
90
256
|
export function createValidator(schema, options) {
|
|
91
|
-
const v = new Validator(schema, options)
|
|
257
|
+
const v = new Validator(schema, resolveOptions(schema, options))
|
|
92
258
|
|
|
93
259
|
return (data) => {
|
|
94
260
|
const result = v.validate(data)
|
|
@@ -99,7 +265,7 @@ export function createValidator(schema, options) {
|
|
|
99
265
|
validation: renderPretty(result.errors),
|
|
100
266
|
})
|
|
101
267
|
}
|
|
102
|
-
return data
|
|
268
|
+
return result.data ?? data
|
|
103
269
|
}
|
|
104
270
|
}
|
|
105
271
|
|
|
@@ -107,8 +273,8 @@ export function createValidator(schema, options) {
|
|
|
107
273
|
* Validates `input` against `schema` in one call.
|
|
108
274
|
* @param {Object} schema - JSON schema to validate against.
|
|
109
275
|
* @param {*} input - Value to validate.
|
|
110
|
-
* @param {
|
|
111
|
-
* @returns {*} `input`
|
|
276
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createValidator}.
|
|
277
|
+
* @returns {*} `input` — coerced/defaulted/stripped per the resolved options — if valid.
|
|
112
278
|
* @throws {EmpyriaError} `BaseErrors.ValidationError` if `input` doesn't satisfy the schema.
|
|
113
279
|
*/
|
|
114
280
|
export function validate(schema, input, options) {
|
|
@@ -118,25 +284,57 @@ export function validate(schema, input, options) {
|
|
|
118
284
|
return validatedInput
|
|
119
285
|
}
|
|
120
286
|
|
|
287
|
+
/**
|
|
288
|
+
* {@link createValidator} under an intent-revealing name, for call sites whose purpose is to
|
|
289
|
+
* *transform* input — coerce string-typed values, strip unknown keys, apply schema defaults —
|
|
290
|
+
* rather than merely assert its shape. Behaviour is identical; the name documents that the
|
|
291
|
+
* returned value is expected to differ from the input.
|
|
292
|
+
* @param {Object} schema - JSON schema to validate/parse against.
|
|
293
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Typically `{ coerceTypes: true }`
|
|
294
|
+
* and/or `{ removeAdditional: true }` (with a closed schema).
|
|
295
|
+
* @returns {(data: *) => *} Function returning the parsed value.
|
|
296
|
+
* @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
|
|
297
|
+
*/
|
|
298
|
+
export function createParser(schema, options) {
|
|
299
|
+
return createValidator(schema, options)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* {@link validate} under an intent-revealing name — see {@link createParser}.
|
|
304
|
+
* @param {Object} schema - JSON schema to parse against.
|
|
305
|
+
* @param {*} input - Value to parse.
|
|
306
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createParser}.
|
|
307
|
+
* @returns {*} The parsed (coerced/defaulted/stripped) value, if valid.
|
|
308
|
+
* @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
|
|
309
|
+
*/
|
|
310
|
+
export function parse(schema, input, options) {
|
|
311
|
+
return createParser(schema, options)(input)
|
|
312
|
+
}
|
|
313
|
+
|
|
121
314
|
/**
|
|
122
315
|
* Builds a non-throwing checking function for a JSON schema — the structured-result
|
|
123
316
|
* counterpart to {@link createValidator}. Use it when a caller needs `ata-validator`'s own
|
|
124
317
|
* error list (to build its own error type, count violations, or render them itself with
|
|
125
318
|
* {@link renderCompact} / {@link renderJSON}) rather than a `BaseErrors.ValidationError`
|
|
126
|
-
* carrying a pre-rendered string
|
|
319
|
+
* carrying a pre-rendered string, or when it wants the parsed value without a `try`/`catch`.
|
|
127
320
|
* @param {Object} schema - JSON schema to validate against.
|
|
128
|
-
* @param {
|
|
129
|
-
*
|
|
130
|
-
* @returns {(data: *) => { valid: boolean, errors: Array<Object> }} Function that
|
|
131
|
-
* verdict
|
|
132
|
-
*
|
|
321
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Resolved the same way as for
|
|
322
|
+
* {@link createValidator} (see its docs for the layers and the common flags).
|
|
323
|
+
* @returns {(data: *) => { valid: boolean, data: *, errors: Array<Object> }} Function that
|
|
324
|
+
* returns the verdict, the parsed value (`data` when valid — coerced/defaulted/stripped per
|
|
325
|
+
* the resolved options; `undefined` when invalid), and, when invalid, `ata-validator`'s raw
|
|
326
|
+
* error objects (empty array when valid). Never throws on a validation failure.
|
|
133
327
|
*/
|
|
134
328
|
export function createChecker(schema, options) {
|
|
135
|
-
const v = new Validator(schema, options)
|
|
329
|
+
const v = new Validator(schema, resolveOptions(schema, options))
|
|
136
330
|
|
|
137
331
|
return (data) => {
|
|
138
332
|
const result = v.validate(data)
|
|
139
|
-
return {
|
|
333
|
+
return {
|
|
334
|
+
valid: result.valid,
|
|
335
|
+
data: result.valid ? (result.data ?? data) : undefined,
|
|
336
|
+
errors: result.valid ? [] : result.errors,
|
|
337
|
+
}
|
|
140
338
|
}
|
|
141
339
|
}
|
|
142
340
|
|
|
@@ -144,9 +342,9 @@ export function createChecker(schema, options) {
|
|
|
144
342
|
* Checks `input` against `schema` in one call, without throwing.
|
|
145
343
|
* @param {Object} schema - JSON schema to validate against.
|
|
146
344
|
* @param {*} input - Value to validate.
|
|
147
|
-
* @param {
|
|
148
|
-
* @returns {{ valid: boolean, errors: Array<Object> }} The verdict,
|
|
149
|
-
*
|
|
345
|
+
* @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createChecker}.
|
|
346
|
+
* @returns {{ valid: boolean, data: *, errors: Array<Object> }} The verdict, the parsed value
|
|
347
|
+
* (when valid), plus `ata-validator`'s raw error objects when invalid.
|
|
150
348
|
*/
|
|
151
349
|
export function checkSchema(schema, input, options) {
|
|
152
350
|
return createChecker(schema, options)(input)
|
|
@@ -175,15 +373,26 @@ export function bool(defaultValue) {
|
|
|
175
373
|
/**
|
|
176
374
|
* JSON schema for a string property.
|
|
177
375
|
* @param {string} [defaultValue] - Default value, if any.
|
|
178
|
-
* @param {number} [
|
|
179
|
-
*
|
|
376
|
+
* @param {number|Object} [opts] - A bare number is shorthand for `{ minLength }` (kept for
|
|
377
|
+
* back-compat). As an object:
|
|
378
|
+
* @param {number} [opts.minLength] - Minimum length (`0` is honoured, unlike the old
|
|
379
|
+
* number-shorthand which dropped non-positive values).
|
|
380
|
+
* @param {number} [opts.maxLength] - Maximum length.
|
|
381
|
+
* @param {string} [opts.pattern] - `pattern` regex (string form, unanchored per JSON Schema).
|
|
382
|
+
* @param {string} [opts.format] - `format` name (e.g. an {@link EMPYRIA_FORMATS} key or a
|
|
383
|
+
* built-in like `'uri'`; see {@link isoDateTime} & co. for ready-made wrappers).
|
|
384
|
+
* @param {string} [opts.description]
|
|
385
|
+
* @returns {Object} `{ type: 'string', default?, minLength?, maxLength?, pattern?, format?, description? }`.
|
|
180
386
|
*/
|
|
181
|
-
export function string(defaultValue,
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
}
|
|
387
|
+
export function string(defaultValue, opts) {
|
|
388
|
+
const o = typeof opts === 'number' ? { minLength: opts } : (opts ?? {})
|
|
389
|
+
const schema = { type: 'string' }
|
|
185
390
|
if (defined(defaultValue)) schema.default = `${defaultValue}`
|
|
186
|
-
if (minLength
|
|
391
|
+
if (defined(o.minLength) && o.minLength >= 0) schema.minLength = o.minLength
|
|
392
|
+
if (defined(o.maxLength)) schema.maxLength = o.maxLength
|
|
393
|
+
if (defined(o.pattern)) schema.pattern = o.pattern
|
|
394
|
+
if (defined(o.format)) schema.format = o.format
|
|
395
|
+
if (defined(o.description)) schema.description = o.description
|
|
187
396
|
return schema
|
|
188
397
|
}
|
|
189
398
|
|
|
@@ -191,13 +400,19 @@ export function string(defaultValue, minLength) {
|
|
|
191
400
|
* JSON schema for an enumerated property.
|
|
192
401
|
* @param {Array} list - Allowed values.
|
|
193
402
|
* @param {*} [defaultValue] - Default value, if any.
|
|
194
|
-
* @
|
|
403
|
+
* @param {Object} [opts]
|
|
404
|
+
* @param {string} [opts.type] - Optional `type` alongside `enum` (helps tooling / type
|
|
405
|
+
* inference; validation is unchanged since `enum` already pins the values).
|
|
406
|
+
* @param {string} [opts.description]
|
|
407
|
+
* @returns {Object} `{ enum: list, type?, default?, description? }`.
|
|
195
408
|
*/
|
|
196
|
-
export function enumType(list, defaultValue) {
|
|
409
|
+
export function enumType(list, defaultValue, opts = {}) {
|
|
197
410
|
const schema = {
|
|
198
411
|
enum: list,
|
|
199
412
|
}
|
|
413
|
+
if (defined(opts.type)) schema.type = opts.type
|
|
200
414
|
if (defined(defaultValue)) schema.default = defaultValue
|
|
415
|
+
if (defined(opts.description)) schema.description = opts.description
|
|
201
416
|
return schema
|
|
202
417
|
}
|
|
203
418
|
|
|
@@ -237,63 +452,200 @@ export function nodeEnv() {
|
|
|
237
452
|
}
|
|
238
453
|
|
|
239
454
|
/**
|
|
240
|
-
*
|
|
455
|
+
* @param {'number'|'integer'} type
|
|
456
|
+
* @param {number} [defaultValue]
|
|
457
|
+
* @param {Object} [opts] - `min`/`max` (inclusive → `minimum`/`maximum`),
|
|
458
|
+
* `exclusiveMin`/`exclusiveMax`, `multipleOf`, `description`.
|
|
459
|
+
* @returns {Object}
|
|
460
|
+
*/
|
|
461
|
+
function numeric(type, defaultValue, opts = {}) {
|
|
462
|
+
const schema = { type }
|
|
463
|
+
if (defined(defaultValue)) schema.default = Number(defaultValue)
|
|
464
|
+
if (defined(opts.min)) schema.minimum = opts.min
|
|
465
|
+
if (defined(opts.max)) schema.maximum = opts.max
|
|
466
|
+
if (defined(opts.exclusiveMin)) schema.exclusiveMinimum = opts.exclusiveMin
|
|
467
|
+
if (defined(opts.exclusiveMax)) schema.exclusiveMaximum = opts.exclusiveMax
|
|
468
|
+
if (defined(opts.multipleOf)) schema.multipleOf = opts.multipleOf
|
|
469
|
+
if (defined(opts.description)) schema.description = opts.description
|
|
470
|
+
return schema
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* JSON schema for a numeric property (`type: 'number'` — accepts integers and reals).
|
|
241
475
|
* @param {number} [defaultValue] - Default value, if any.
|
|
242
|
-
* @
|
|
476
|
+
* @param {Object} [opts] - `min`, `max`, `exclusiveMin`, `exclusiveMax`, `multipleOf`,
|
|
477
|
+
* `description` (see {@link numeric}).
|
|
478
|
+
* @returns {Object} `{ type: 'number', default?, minimum?, maximum?, ... }`.
|
|
243
479
|
*/
|
|
244
|
-
export function number(defaultValue) {
|
|
480
|
+
export function number(defaultValue, opts) {
|
|
481
|
+
return numeric('number', defaultValue, opts)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* JSON schema for an integer property (`type: 'integer'` — rejects `3.5`, and with
|
|
486
|
+
* `coerceTypes` rejects `"3.5"`). Prefer this over {@link number} for ports, counts, lengths.
|
|
487
|
+
* @param {number} [defaultValue] - Default value, if any.
|
|
488
|
+
* @param {Object} [opts] - Same as {@link number}.
|
|
489
|
+
* @returns {Object} `{ type: 'integer', default?, minimum?, maximum?, ... }`.
|
|
490
|
+
*/
|
|
491
|
+
export function integer(defaultValue, opts) {
|
|
492
|
+
return numeric('integer', defaultValue, opts)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* JSON schema for a constant value (`const`). Useful as a discriminator tag or a pinned
|
|
497
|
+
* config value.
|
|
498
|
+
* @param {*} value - The only accepted value (compared structurally).
|
|
499
|
+
* @param {Object} [opts] - `description`.
|
|
500
|
+
* @returns {Object} `{ const: value, description? }`.
|
|
501
|
+
*/
|
|
502
|
+
export function constant(value, opts = {}) {
|
|
503
|
+
const schema = { const: value }
|
|
504
|
+
if (defined(opts.description)) schema.description = opts.description
|
|
505
|
+
return schema
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Wraps a schema so `null` is also accepted. A schema with a single string `type` gets
|
|
510
|
+
* `type: [t, 'null']`; anything else (an `enum`, a `$ref`, a composed schema) is wrapped as
|
|
511
|
+
* `{ anyOf: [schema, { type: 'null' }] }`.
|
|
512
|
+
* @param {Object} schema - The schema to make nullable.
|
|
513
|
+
* @returns {Object}
|
|
514
|
+
*/
|
|
515
|
+
export function nullable(schema) {
|
|
516
|
+
if (schema && typeof schema.type === 'string') {
|
|
517
|
+
return { ...schema, type: [schema.type, 'null'] }
|
|
518
|
+
}
|
|
519
|
+
return { anyOf: [schema, { type: 'null' }] }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* JSON schema for an array property.
|
|
524
|
+
* @param {Object} [items] - Schema every element must satisfy. Omit for "any element".
|
|
525
|
+
* @param {Object} [opts] - `minItems`, `maxItems`, `uniqueItems` (boolean), `default`,
|
|
526
|
+
* `description`.
|
|
527
|
+
* @returns {Object} `{ type: 'array', items?, minItems?, maxItems?, uniqueItems?, default?, description? }`.
|
|
528
|
+
*/
|
|
529
|
+
export function array(items, opts = {}) {
|
|
530
|
+
const schema = { type: 'array' }
|
|
531
|
+
if (defined(items)) schema.items = items
|
|
532
|
+
if (defined(opts.minItems)) schema.minItems = opts.minItems
|
|
533
|
+
if (defined(opts.maxItems)) schema.maxItems = opts.maxItems
|
|
534
|
+
if (opts.uniqueItems) schema.uniqueItems = true
|
|
535
|
+
if (defined(opts.default)) schema.default = opts.default
|
|
536
|
+
if (defined(opts.description)) schema.description = opts.description
|
|
537
|
+
return schema
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* JSON Schema v1 `propertyDependencies`: an object that takes a different shape depending on
|
|
542
|
+
* the value of a discriminator property. Replaces the `oneOf` + `if`/`then`/`const` pattern.
|
|
543
|
+
* Validates on every `ata-validator` engine (supported since 1.5.0); pair with
|
|
544
|
+
* `defineSchema`'s `dialect: 'v1'` — or `setDefaultDialect('v1')` — for a spec-legitimate
|
|
545
|
+
* `$schema`.
|
|
546
|
+
* The result is open (`additionalProperties` unset): `additionalProperties: false` cannot be
|
|
547
|
+
* used alongside `propertyDependencies` — it does not see through the branches and would
|
|
548
|
+
* reject every branch key. For a closed union, give each branch schema its own
|
|
549
|
+
* `unevaluatedProperties: false` and add one at this level too.
|
|
550
|
+
* @param {string} property - The discriminator property name.
|
|
551
|
+
* @param {Record<string, Object>} mapping - Discriminator value → the schema the rest of the
|
|
552
|
+
* object must then satisfy.
|
|
553
|
+
* @param {Object} [opts]
|
|
554
|
+
* @param {string} [opts.description]
|
|
555
|
+
* @returns {Object} `{ type: 'object', properties: { [property]: { enum } }, required, propertyDependencies }`.
|
|
556
|
+
*/
|
|
557
|
+
export function discriminated(property, mapping, opts = {}) {
|
|
245
558
|
const schema = {
|
|
246
|
-
type: '
|
|
559
|
+
type: 'object',
|
|
560
|
+
properties: { [property]: { enum: Object.keys(mapping) } },
|
|
561
|
+
required: [property],
|
|
562
|
+
propertyDependencies: { [property]: mapping },
|
|
247
563
|
}
|
|
248
|
-
if (defined(
|
|
564
|
+
if (defined(opts.description)) schema.description = opts.description
|
|
249
565
|
return schema
|
|
250
566
|
}
|
|
251
567
|
|
|
568
|
+
const stringFormat = (format, description) => ({ type: 'string', format, description })
|
|
569
|
+
|
|
570
|
+
/** JSON schema for an RFC 3339 date-time string (`format: 'date-time'`). */
|
|
571
|
+
export function isoDateTime() {
|
|
572
|
+
return stringFormat('date-time', 'ISO 8601 / RFC 3339 date-time')
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** JSON schema for an RFC 3339 full-date string (`format: 'date'`). */
|
|
576
|
+
export function isoDate() {
|
|
577
|
+
return stringFormat('date', 'ISO 8601 / RFC 3339 date')
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** JSON schema for an RFC 3339 full-time string (`format: 'time'`). */
|
|
581
|
+
export function isoTime() {
|
|
582
|
+
return stringFormat('time', 'ISO 8601 / RFC 3339 time')
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** JSON schema for an ISO 8601 duration string (`format: 'duration'`, e.g. `P1DT2H`). */
|
|
586
|
+
export function duration() {
|
|
587
|
+
return stringFormat('duration', 'ISO 8601 duration')
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** JSON schema for a URI string (`format: 'uri'`). */
|
|
591
|
+
export function uri() {
|
|
592
|
+
return stringFormat('uri', 'URI')
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** JSON schema for a UUID string (`format: 'uuid'`). */
|
|
596
|
+
export function uuid() {
|
|
597
|
+
return stringFormat('uuid', 'UUID')
|
|
598
|
+
}
|
|
599
|
+
|
|
252
600
|
/**
|
|
253
|
-
* JSON schema for an email-address string,
|
|
254
|
-
* @
|
|
601
|
+
* JSON schema for an email-address string, checked against the `email` format
|
|
602
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_EMAIL}).
|
|
603
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
255
604
|
*/
|
|
256
605
|
export function email() {
|
|
257
606
|
return {
|
|
258
607
|
type: 'string',
|
|
259
|
-
|
|
608
|
+
format: 'empyria-email',
|
|
260
609
|
description: 'Email address',
|
|
261
610
|
}
|
|
262
611
|
}
|
|
263
612
|
|
|
264
613
|
/**
|
|
265
|
-
* JSON schema for a password string,
|
|
266
|
-
* (
|
|
267
|
-
*
|
|
614
|
+
* JSON schema for a password string, checked against the `password` format
|
|
615
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_PASS}: lower-case, upper-case, a digit,
|
|
616
|
+
* and at least 8 characters).
|
|
617
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
268
618
|
*/
|
|
269
619
|
export function password() {
|
|
270
620
|
return {
|
|
271
621
|
type: 'string',
|
|
272
|
-
|
|
622
|
+
format: 'empyria-password',
|
|
273
623
|
description: 'password',
|
|
274
624
|
}
|
|
275
625
|
}
|
|
276
626
|
|
|
277
627
|
/**
|
|
278
|
-
* JSON schema for an IPv4-address string,
|
|
279
|
-
* @
|
|
628
|
+
* JSON schema for an IPv4-address string, checked against the `ipv4` format
|
|
629
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv4}).
|
|
630
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
280
631
|
*/
|
|
281
632
|
export function ip4() {
|
|
282
633
|
return {
|
|
283
634
|
type: 'string',
|
|
284
|
-
|
|
635
|
+
format: 'empyria-ipv4',
|
|
285
636
|
description: 'IP address v4',
|
|
286
637
|
}
|
|
287
638
|
}
|
|
288
639
|
|
|
289
640
|
/**
|
|
290
|
-
* JSON schema for an IPv6-address string,
|
|
291
|
-
* @
|
|
641
|
+
* JSON schema for an IPv6-address string, checked against the `ipv6` format
|
|
642
|
+
* ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv6}).
|
|
643
|
+
* @returns {Object} String schema with `format` and `description` set.
|
|
292
644
|
*/
|
|
293
645
|
export function ip6() {
|
|
294
646
|
return {
|
|
295
647
|
type: 'string',
|
|
296
|
-
|
|
648
|
+
format: 'empyria-ipv6',
|
|
297
649
|
description: 'IP address v6',
|
|
298
650
|
}
|
|
299
651
|
}
|
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.3.0",
|
|
4
4
|
"description": "Shared backend goodies for Empyria",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Imre Fazekas <imre.fazekas@icloud.com>",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@empyria/classification": "0.1.6",
|
|
23
|
-
"ata-validator": "1.
|
|
23
|
+
"ata-validator": "1.14.1",
|
|
24
24
|
"clerobee": "1.0.8",
|
|
25
25
|
"ms": "2.1.3"
|
|
26
26
|
},
|
|
@@ -29,7 +29,7 @@
|
|
|
29
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,18 @@ 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
|
+
setDefaultDialect,
|
|
19
|
+
getDefaultDialect,
|
|
20
|
+
DIALECTS,
|
|
21
|
+
EMPYRIA_FORMATS,
|
|
12
22
|
bool,
|
|
13
23
|
string,
|
|
14
24
|
enumType,
|
|
@@ -17,6 +27,17 @@ import {
|
|
|
17
27
|
logFile,
|
|
18
28
|
nodeEnv,
|
|
19
29
|
number,
|
|
30
|
+
integer,
|
|
31
|
+
constant,
|
|
32
|
+
nullable,
|
|
33
|
+
array,
|
|
34
|
+
discriminated,
|
|
35
|
+
isoDateTime,
|
|
36
|
+
isoDate,
|
|
37
|
+
isoTime,
|
|
38
|
+
duration,
|
|
39
|
+
uri,
|
|
40
|
+
uuid,
|
|
20
41
|
email,
|
|
21
42
|
password,
|
|
22
43
|
ip4,
|
|
@@ -89,6 +110,31 @@ describe('defineSchema', () => {
|
|
|
89
110
|
test('disallows extra properties when strict', () => {
|
|
90
111
|
expect(defineSchema({ a: string() }, { strict: true }).additionalProperties).toBe(false)
|
|
91
112
|
})
|
|
113
|
+
|
|
114
|
+
test('an explicit additionalProperties overrides strict', () => {
|
|
115
|
+
expect(
|
|
116
|
+
defineSchema({ a: string() }, { strict: true, additionalProperties: true })
|
|
117
|
+
.additionalProperties,
|
|
118
|
+
).toBe(true)
|
|
119
|
+
expect(
|
|
120
|
+
defineSchema({ a: string() }, { additionalProperties: false }).additionalProperties,
|
|
121
|
+
).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('binds validation options on a non-enumerable key, invisible to JSON/toEqual', () => {
|
|
125
|
+
const schema = defineSchema(
|
|
126
|
+
{ a: string() },
|
|
127
|
+
{ validation: { coerceTypes: true, removeAdditional: true } },
|
|
128
|
+
)
|
|
129
|
+
expect(schema).toEqual({
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: { a: { type: 'string' } },
|
|
132
|
+
required: ['a'],
|
|
133
|
+
additionalProperties: true,
|
|
134
|
+
})
|
|
135
|
+
expect(JSON.stringify(schema)).not.toContain('coerceTypes')
|
|
136
|
+
expect(schemaOptions(schema)).toEqual({ coerceTypes: true, removeAdditional: true })
|
|
137
|
+
})
|
|
92
138
|
})
|
|
93
139
|
|
|
94
140
|
describe('cloneBySchema', () => {
|
|
@@ -171,11 +217,11 @@ describe('schema builders', () => {
|
|
|
171
217
|
})
|
|
172
218
|
})
|
|
173
219
|
|
|
174
|
-
test('email/password/ip4/ip6 reference their
|
|
175
|
-
expect(email().
|
|
176
|
-
expect(password().
|
|
177
|
-
expect(ip4().
|
|
178
|
-
expect(ip6().
|
|
220
|
+
test('email/password/ip4/ip6 reference their named formats', () => {
|
|
221
|
+
expect(email().format).toBe('empyria-email')
|
|
222
|
+
expect(password().format).toBe('empyria-password')
|
|
223
|
+
expect(ip4().format).toBe('empyria-ipv4')
|
|
224
|
+
expect(ip6().format).toBe('empyria-ipv6')
|
|
179
225
|
})
|
|
180
226
|
})
|
|
181
227
|
|
|
@@ -189,8 +235,16 @@ describe('renderCompact / renderJSON re-exports', () => {
|
|
|
189
235
|
describe('createChecker / checkSchema', () => {
|
|
190
236
|
const schema = defineSchema({ name: string() })
|
|
191
237
|
|
|
192
|
-
test('checkSchema returns valid:true and no errors for good input', () => {
|
|
193
|
-
expect(checkSchema(schema, { name: 'Bob' })).toEqual({
|
|
238
|
+
test('checkSchema returns valid:true, the parsed value, and no errors for good input', () => {
|
|
239
|
+
expect(checkSchema(schema, { name: 'Bob' })).toEqual({
|
|
240
|
+
valid: true,
|
|
241
|
+
data: { name: 'Bob' },
|
|
242
|
+
errors: [],
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('checkSchema reports data:undefined for bad input', () => {
|
|
247
|
+
expect(checkSchema(schema, {}).data).toBeUndefined()
|
|
194
248
|
})
|
|
195
249
|
|
|
196
250
|
test('checkSchema returns valid:false and a non-empty error list for bad input, without throwing', () => {
|
|
@@ -205,8 +259,229 @@ describe('createChecker / checkSchema', () => {
|
|
|
205
259
|
expect(renderCompact(errors)).toContain('name')
|
|
206
260
|
})
|
|
207
261
|
|
|
208
|
-
test('createChecker builds a reusable checker
|
|
262
|
+
test('createChecker builds a reusable checker, forwards options, and returns the parsed value', () => {
|
|
209
263
|
const check = createChecker(defineSchema({ port: number() }), { coerceTypes: true })
|
|
210
|
-
expect(check({ port: '4040' })).toEqual({ valid: true, errors: [] })
|
|
264
|
+
expect(check({ port: '4040' })).toEqual({ valid: true, data: { port: 4040 }, errors: [] })
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('named formats', () => {
|
|
269
|
+
test('EMPYRIA_FORMATS enforce their patterns', () => {
|
|
270
|
+
expect(EMPYRIA_FORMATS['empyria-email']('user@example.com')).toBe(true)
|
|
271
|
+
expect(EMPYRIA_FORMATS['empyria-email']('user@example!com')).toBe(false)
|
|
272
|
+
expect(EMPYRIA_FORMATS['empyria-password']('Abcdefg1')).toBe(true)
|
|
273
|
+
expect(EMPYRIA_FORMATS['empyria-password']('Abcdefgh')).toBe(false)
|
|
274
|
+
expect(EMPYRIA_FORMATS['empyria-ipv4']('192.168.0.1')).toBe(true)
|
|
275
|
+
expect(EMPYRIA_FORMATS['empyria-ipv4']('999.0.0.1')).toBe(false)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('a schema from email() is enforced through this module without per-call wiring', () => {
|
|
279
|
+
const schema = defineSchema({ addr: email() })
|
|
280
|
+
expect(validate(schema, { addr: 'user@example.com' })).toEqual({ addr: 'user@example.com' })
|
|
281
|
+
expect(() => validate(schema, { addr: 'nope' })).toThrow(EmpyriaError)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('a schema from password() is enforced (no ata-validator built-in for it)', () => {
|
|
285
|
+
const schema = defineSchema({ pw: password() })
|
|
286
|
+
expect(() => validate(schema, { pw: 'weak' })).toThrow(EmpyriaError)
|
|
287
|
+
expect(validate(schema, { pw: 'Abcdefg1' })).toEqual({ pw: 'Abcdefg1' })
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('a per-call formats entry overrides the Empyria default', () => {
|
|
291
|
+
const schema = defineSchema({ addr: email() })
|
|
292
|
+
const only = { formats: { 'empyria-email': (s) => s === 'ok@ok.io' } }
|
|
293
|
+
expect(validate(schema, { addr: 'ok@ok.io' }, only)).toEqual({ addr: 'ok@ok.io' })
|
|
294
|
+
expect(() => validate(schema, { addr: 'user@example.com' }, only)).toThrow(EmpyriaError)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe('option layering: setAtaDefaults / schema-bound / per-call', () => {
|
|
299
|
+
test('getAtaDefaults starts empty', () => {
|
|
300
|
+
expect(getAtaDefaults()).toEqual({})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('a library default is applied when no per-call option is given', () => {
|
|
304
|
+
const schema = defineSchema({ port: number() })
|
|
305
|
+
expect(() => validate(schema, { port: '8080' })).toThrow(EmpyriaError)
|
|
306
|
+
setAtaDefaults({ coerceTypes: true })
|
|
307
|
+
try {
|
|
308
|
+
expect(validate(schema, { port: '8080' })).toEqual({ port: 8080 })
|
|
309
|
+
} finally {
|
|
310
|
+
setAtaDefaults()
|
|
311
|
+
}
|
|
312
|
+
expect(getAtaDefaults()).toEqual({})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('schema-bound validation options beat library defaults, per-call beats both', () => {
|
|
316
|
+
setAtaDefaults({ coerceTypes: false })
|
|
317
|
+
try {
|
|
318
|
+
const bound = defineSchema({ port: number() }, { validation: { coerceTypes: true } })
|
|
319
|
+
// schema-bound coerceTypes:true wins over the library default
|
|
320
|
+
expect(validate(bound, { port: '80' })).toEqual({ port: 80 })
|
|
321
|
+
// per-call coerceTypes:false wins over the schema-bound value
|
|
322
|
+
expect(() => validate(bound, { port: '80' }, { coerceTypes: false })).toThrow(
|
|
323
|
+
EmpyriaError,
|
|
324
|
+
)
|
|
325
|
+
} finally {
|
|
326
|
+
setAtaDefaults()
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe('parse / createParser', () => {
|
|
332
|
+
test('parse coerces and strips against a closed schema', () => {
|
|
333
|
+
const schema = defineSchema(
|
|
334
|
+
{ port: number() },
|
|
335
|
+
{ strict: true, validation: { coerceTypes: true, removeAdditional: true } },
|
|
336
|
+
)
|
|
337
|
+
expect(parse(schema, { port: '3000', stray: 'x' })).toEqual({ port: 3000 })
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('createParser is a reusable createValidator under an intent-revealing name', () => {
|
|
341
|
+
const p = createParser(defineSchema({ n: number() }), { coerceTypes: true })
|
|
342
|
+
expect(p({ n: '7' })).toEqual({ n: 7 })
|
|
343
|
+
expect(typeof createValidator).toBe('function')
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('scalar builders — constraints', () => {
|
|
348
|
+
test('string accepts a number shorthand or an options object', () => {
|
|
349
|
+
expect(string('x', 3)).toEqual({ type: 'string', default: 'x', minLength: 3 })
|
|
350
|
+
expect(
|
|
351
|
+
string(undefined, { minLength: 0, maxLength: 8, pattern: '^a', format: 'uri' }),
|
|
352
|
+
).toEqual({
|
|
353
|
+
type: 'string',
|
|
354
|
+
minLength: 0,
|
|
355
|
+
maxLength: 8,
|
|
356
|
+
pattern: '^a',
|
|
357
|
+
format: 'uri',
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('number/integer carry bounds; integer types as integer', () => {
|
|
362
|
+
expect(number(5)).toEqual({ type: 'number', default: 5 })
|
|
363
|
+
expect(integer(8100, { min: 1, max: 65535 })).toEqual({
|
|
364
|
+
type: 'integer',
|
|
365
|
+
default: 8100,
|
|
366
|
+
minimum: 1,
|
|
367
|
+
maximum: 65535,
|
|
368
|
+
})
|
|
369
|
+
expect(integer(undefined, { exclusiveMin: 0, multipleOf: 2 })).toEqual({
|
|
370
|
+
type: 'integer',
|
|
371
|
+
exclusiveMinimum: 0,
|
|
372
|
+
multipleOf: 2,
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('an integer schema rejects a real (and, coerced, a non-integer string)', () => {
|
|
377
|
+
const schema = defineSchema({ port: integer() })
|
|
378
|
+
expect(() => validate(schema, { port: 3.5 })).toThrow(EmpyriaError)
|
|
379
|
+
expect(() => validate(schema, { port: '3.5' }, { coerceTypes: true })).toThrow(EmpyriaError)
|
|
380
|
+
expect(validate(schema, { port: '3000' }, { coerceTypes: true })).toEqual({ port: 3000 })
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('enumType can carry a type alongside enum', () => {
|
|
384
|
+
expect(enumType(['a', 'b'], 'a')).toEqual({ enum: ['a', 'b'], default: 'a' })
|
|
385
|
+
expect(enumType(['a', 'b'], undefined, { type: 'string' })).toEqual({
|
|
386
|
+
type: 'string',
|
|
387
|
+
enum: ['a', 'b'],
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test('constant / nullable', () => {
|
|
392
|
+
expect(constant('circle')).toEqual({ const: 'circle' })
|
|
393
|
+
expect(nullable(string('x'))).toEqual({ type: ['string', 'null'], default: 'x' })
|
|
394
|
+
expect(nullable(enumType(['a']))).toEqual({ anyOf: [{ enum: ['a'] }, { type: 'null' }] })
|
|
395
|
+
const schema = defineSchema({ note: nullable(string()) })
|
|
396
|
+
expect(validate(schema, { note: null })).toEqual({ note: null })
|
|
397
|
+
expect(validate(schema, { note: 'hi' })).toEqual({ note: 'hi' })
|
|
398
|
+
expect(() => validate(schema, { note: 3 })).toThrow(EmpyriaError)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('array builder', () => {
|
|
402
|
+
expect(array(string(), { minItems: 1, uniqueItems: true })).toEqual({
|
|
403
|
+
type: 'array',
|
|
404
|
+
items: { type: 'string' },
|
|
405
|
+
minItems: 1,
|
|
406
|
+
uniqueItems: true,
|
|
407
|
+
})
|
|
408
|
+
const schema = defineSchema({ tags: array(string()) })
|
|
409
|
+
expect(validate(schema, { tags: ['a', 'b'] })).toEqual({ tags: ['a', 'b'] })
|
|
410
|
+
expect(() => validate(schema, { tags: ['a', 2] })).toThrow(EmpyriaError)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('format wrappers', () => {
|
|
414
|
+
expect(isoDateTime()).toEqual({
|
|
415
|
+
type: 'string',
|
|
416
|
+
format: 'date-time',
|
|
417
|
+
description: expect.any(String),
|
|
418
|
+
})
|
|
419
|
+
expect([isoDate(), isoTime(), duration(), uri(), uuid()].map((s) => s.format)).toEqual([
|
|
420
|
+
'date',
|
|
421
|
+
'time',
|
|
422
|
+
'duration',
|
|
423
|
+
'uri',
|
|
424
|
+
'uuid',
|
|
425
|
+
])
|
|
426
|
+
const schema = defineSchema({ at: isoDateTime() })
|
|
427
|
+
expect(validate(schema, { at: '2026-09-10T09:28:09Z' })).toEqual({
|
|
428
|
+
at: '2026-09-10T09:28:09Z',
|
|
429
|
+
})
|
|
430
|
+
expect(() => validate(schema, { at: 'not-a-date' })).toThrow(EmpyriaError)
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
describe('defineSchema — dialect & unevaluated strict', () => {
|
|
435
|
+
test('dialect stamps $schema from a short name or a raw URI', () => {
|
|
436
|
+
expect(defineSchema({ a: string() }, { dialect: 'v1' }).$schema).toBe(
|
|
437
|
+
'https://json-schema.org/v1',
|
|
438
|
+
)
|
|
439
|
+
expect(defineSchema({ a: string() }, { dialect: 'https://example.test/s' }).$schema).toBe(
|
|
440
|
+
'https://example.test/s',
|
|
441
|
+
)
|
|
442
|
+
expect(defineSchema({ a: string() }).$schema).toBeUndefined()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('setDefaultDialect applies when a call passes no dialect', () => {
|
|
446
|
+
expect(getDefaultDialect()).toBeNull()
|
|
447
|
+
setDefaultDialect('v1')
|
|
448
|
+
try {
|
|
449
|
+
expect(defineSchema({ a: string() }).$schema).toBe('https://json-schema.org/v1')
|
|
450
|
+
expect(defineSchema({ a: string() }, { dialect: '2020-12' }).$schema).toBe(
|
|
451
|
+
'https://json-schema.org/draft/2020-12/schema',
|
|
452
|
+
)
|
|
453
|
+
} finally {
|
|
454
|
+
setDefaultDialect()
|
|
455
|
+
}
|
|
456
|
+
expect(getDefaultDialect()).toBeNull()
|
|
457
|
+
expect(defineSchema({ a: string() }).$schema).toBeUndefined()
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test("strict: 'unevaluated' keeps additionalProperties open but sets unevaluatedProperties:false", () => {
|
|
461
|
+
const schema = defineSchema({ a: string() }, { strict: 'unevaluated' })
|
|
462
|
+
expect(schema.additionalProperties).toBe(true)
|
|
463
|
+
expect(schema.unevaluatedProperties).toBe(false)
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
describe('discriminated (propertyDependencies)', () => {
|
|
468
|
+
const shape = discriminated('kind', {
|
|
469
|
+
circle: defineSchema({ radius: number() }),
|
|
470
|
+
square: defineSchema({ side: number() }),
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test('selects the branch by the discriminator value', () => {
|
|
474
|
+
expect(validate(shape, { kind: 'circle', radius: 2 })).toEqual({
|
|
475
|
+
kind: 'circle',
|
|
476
|
+
radius: 2,
|
|
477
|
+
})
|
|
478
|
+
expect(validate(shape, { kind: 'square', side: 3 })).toEqual({ kind: 'square', side: 3 })
|
|
479
|
+
expect(() => validate(shape, { kind: 'circle', side: 3 })).toThrow(EmpyriaError)
|
|
480
|
+
expect(() => validate(shape, { kind: 'triangle' })).toThrow(EmpyriaError)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('pairs with a v1 $schema via dialect', () => {
|
|
484
|
+
const v1shape = { ...shape, $schema: DIALECTS.v1 }
|
|
485
|
+
expect(validate(v1shape, { kind: 'square', side: 3 })).toEqual({ kind: 'square', side: 3 })
|
|
211
486
|
})
|
|
212
487
|
})
|
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
|
})
|