From e2e03ffeb35f00627335dbfd3e128e2cf9dcfdee Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Sun, 16 Oct 2022 10:19:31 -0700 Subject: [PATCH] feat!: switch decoder API to zod like schema API (#108) * fix: optional caveats backport #105 into 0.9 * stash: current status of schema code * chore: increase coverage * fix: get 100 coverage * chore: optimize typings * chore: rename decoder to schema * feat: update decoders to new API * feat: increase coverage to 100% * fix: regressions in other packages * fix: bundler issue * Update packages/validator/src/schema/schema.js * fix: fixes for #108 (#116) * fix: coverage for Link.optional Co-authored-by: Hugo Dias --- packages/interface/src/capability.ts | 17 +- packages/interface/src/lib.ts | 2 +- packages/principal/package.json | 8 +- packages/principal/src/ed25519/signer.js | 4 + packages/principal/src/ed25519/verifier.js | 2 +- packages/server/test/server.spec.js | 4 +- packages/server/test/service/store.js | 4 +- packages/validator/package.json | 4 +- packages/validator/src/capability.js | 36 +- packages/validator/src/decoder/did.js | 48 - packages/validator/src/decoder/link.js | 79 - packages/validator/src/decoder/text.js | 46 - packages/validator/src/decoder/uri.js | 60 - packages/validator/src/decoder/view.js | 53 - packages/validator/src/error.js | 17 +- packages/validator/src/lib.js | 11 +- packages/validator/src/schema.js | 5 + packages/validator/src/schema/did.js | 39 + packages/validator/src/schema/link.js | 80 ++ packages/validator/src/schema/schema.js | 1280 +++++++++++++++++ packages/validator/src/schema/text.js | 34 + packages/validator/src/schema/type.js | 0 packages/validator/src/schema/type.ts | 142 ++ packages/validator/src/schema/uri.js | 61 + packages/validator/test/capability.spec.js | 541 ++++++- packages/validator/test/error.spec.js | 42 + .../{decoder.spec.js => extra-schema.spec.js} | 135 +- packages/validator/test/lib.spec.js | 17 +- packages/validator/test/schema.spec.js | 647 +++++++++ packages/validator/test/schema/fixtures.js | 878 +++++++++++ packages/validator/test/schema/util.js | 77 + 31 files changed, 3989 insertions(+), 384 deletions(-) delete mode 100644 packages/validator/src/decoder/did.js delete mode 100644 packages/validator/src/decoder/link.js delete mode 100644 packages/validator/src/decoder/text.js delete mode 100644 packages/validator/src/decoder/uri.js delete mode 100644 packages/validator/src/decoder/view.js create mode 100644 packages/validator/src/schema.js create mode 100644 packages/validator/src/schema/did.js create mode 100644 packages/validator/src/schema/link.js create mode 100644 packages/validator/src/schema/schema.js create mode 100644 packages/validator/src/schema/text.js create mode 100644 packages/validator/src/schema/type.js create mode 100644 packages/validator/src/schema/type.ts create mode 100644 packages/validator/src/schema/uri.js create mode 100644 packages/validator/test/error.spec.js rename packages/validator/test/{decoder.spec.js => extra-schema.spec.js} (61%) create mode 100644 packages/validator/test/schema.spec.js create mode 100644 packages/validator/test/schema/fixtures.js create mode 100644 packages/validator/test/schema/util.js diff --git a/packages/interface/src/capability.ts b/packages/interface/src/capability.ts index 74926d97..833e85cd 100644 --- a/packages/interface/src/capability.ts +++ b/packages/interface/src/capability.ts @@ -53,16 +53,17 @@ export interface MatchSelector export interface DirectMatch extends Match> {} -export interface Decoder< - I extends unknown, - O extends unknown, +export interface Reader< + O = unknown, + I = unknown, X extends { error: true } = Failure > { - decode: (input: I) => Result + read: (input: I) => Result } -export interface Caveats - extends Record> {} +export interface Caveats { + [key: string]: Reader +} export type MatchResult = Result @@ -260,7 +261,7 @@ export type ParsedCapability< : { can: Can; with: Resource; nb: C } export type InferCaveats = Optionalize<{ - [K in keyof C]: C[K] extends Decoder ? T : never + [K in keyof C]: C[K] extends Reader ? T : never }> export interface Descriptor< @@ -269,7 +270,7 @@ export interface Descriptor< C extends Caveats > { can: A - with: Decoder + with: Reader nb?: C diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index bd5cabd2..54403961 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -265,7 +265,7 @@ export type ExecuteInvocation< ? Out : never -export type Result = +export type Result = | (T extends null | undefined ? T : never) | (T & { error?: never }) | X diff --git a/packages/principal/package.json b/packages/principal/package.json index 71d5da96..a1b32aff 100644 --- a/packages/principal/package.json +++ b/packages/principal/package.json @@ -48,8 +48,8 @@ "types": "./dist/src/lib.d.ts", "typesVersions": { "*": { - "*": [ - "dist/*" + ".": [ + "dist/src/lib.d.ts" ], "ed25519": [ "dist/src/ed25519.d.ts" @@ -60,6 +60,10 @@ } }, "exports": { + ".": { + "types": "./dist/src/lib.d.ts", + "import": "./src/lib.js" + }, "./ed25519": { "types": "./dist/src/ed25519.d.ts", "import": "./src/ed25519.js" diff --git a/packages/principal/src/ed25519/signer.js b/packages/principal/src/ed25519/signer.js index f5311fb2..a6076192 100644 --- a/packages/principal/src/ed25519/signer.js +++ b/packages/principal/src/ed25519/signer.js @@ -17,6 +17,10 @@ const SIZE = PRIVATE_TAG_SIZE + KEY_SIZE + PUBLIC_TAG_SIZE + KEY_SIZE export const PUB_KEY_OFFSET = PRIVATE_TAG_SIZE + KEY_SIZE +/** + * @typedef {API.EdSigner } EdSigner + */ + /** * Generates new issuer by generating underlying ED25519 keypair. * @returns {Promise} diff --git a/packages/principal/src/ed25519/verifier.js b/packages/principal/src/ed25519/verifier.js index 776bb186..d31ed0de 100644 --- a/packages/principal/src/ed25519/verifier.js +++ b/packages/principal/src/ed25519/verifier.js @@ -13,7 +13,7 @@ const PUBLIC_TAG_SIZE = varint.encodingLength(code) const SIZE = 32 + PUBLIC_TAG_SIZE /** - * @typedef {API.Verifier<"key", Signature.EdDSA> & Uint8Array} Verifier + * @typedef {API.EdVerifier} EdVerifier */ /** diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index 9def2523..c2c581db 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -10,7 +10,7 @@ const storeAdd = Server.capability({ can: 'store/add', with: Server.URI.match({ protocol: 'did:' }), nb: { - link: Server.Link.optional(), + link: Server.Link.match().optional(), }, derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { @@ -35,7 +35,7 @@ const storeRemove = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), nb: { - link: Server.Link.optional(), + link: Server.Link.match().optional(), }, derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { diff --git a/packages/server/test/service/store.js b/packages/server/test/service/store.js index 9e70464d..75f8c5d4 100644 --- a/packages/server/test/service/store.js +++ b/packages/server/test/service/store.js @@ -9,7 +9,7 @@ const addCapability = Server.capability({ can: 'store/add', with: Server.URI.match({ protocol: 'did:' }), nb: { - link: Server.Link.optional(), + link: Server.Link.match().optional(), }, derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { @@ -35,7 +35,7 @@ const removeCapability = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), nb: { - link: Server.Link.optional(), + link: Server.Link.match().optional(), }, derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { diff --git a/packages/validator/package.json b/packages/validator/package.json index da0e1f83..5f60f0a9 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -20,9 +20,9 @@ "homepage": "https://github.com/web3-storage/ucanto", "scripts": { "test:web": "playwright-test test/**/*.spec.js --cov && nyc report", - "test:node": "c8 --check-coverage --branches 96 --functions 85 --lines 93 mocha test/**/*.spec.js", + "test:node": "c8 --check-coverage --branches 100 --functions 100 --lines 100 mocha test/**/*.spec.js", "test": "npm run test:node", - "coverage": "c8 --reporter=html mocha test/**/*.spec.js && npm_config_yes=true npx st -d coverage -p 8080", + "coverage": "c8 --reporter=html mocha test/**/*.spec.js", "typecheck": "tsc --build", "build": "tsc --build" }, diff --git a/packages/validator/src/capability.js b/packages/validator/src/capability.js index c158e449..7bdb70d6 100644 --- a/packages/validator/src/capability.js +++ b/packages/validator/src/capability.js @@ -7,7 +7,7 @@ import { DelegationError as MatchError, Failure, } from './error.js' -import { invoke, delegate } from '@ucanto/core' +import { invoke } from '@ucanto/core' /** * @template {API.Ability} A @@ -51,6 +51,7 @@ class View { * @param {API.Source} source * @returns {API.MatchResult} */ + /* c8 ignore next 3 */ match(source) { return new UnknownCapability(source.capability) } @@ -121,7 +122,7 @@ class Capability extends Unit { const decoders = descriptor.nb const data = /** @type {API.InferCaveats} */ (options.nb || {}) - const resource = descriptor.with.decode(options.with) + const resource = descriptor.with.read(options.with) if (resource.error) { throw Object.assign(new Error(`Invalid 'with' - ${resource.message}`), { cause: resource, @@ -134,7 +135,7 @@ class Capability extends Unit { for (const [name, decoder] of Object.entries(decoders || {})) { const key = /** @type {keyof data & string} */ (name) - const value = decoder.decode(data[key]) + const value = decoder.read(data[key]) if (value?.error) { throw Object.assign( new Error(`Invalid 'nb.${key}' - ${value.message}`), @@ -209,7 +210,11 @@ class Or extends Unit { if (left.error) { const right = this.right.match(capability) if (right.error) { - return right.name === 'MalformedCapability' ? right : left + return right.name === 'MalformedCapability' + ? // + right + : // + left } else { return right } @@ -404,11 +409,11 @@ class Match { return { matches, unknown, errors } } toString() { + const { nb } = this.value return JSON.stringify({ can: this.descriptor.can, with: this.value.with, - nb: - Object.keys(this.value.nb || {}).length > 0 ? this.value.nb : undefined, + nb: nb && Object.keys(nb).length > 0 ? nb : undefined, }) } } @@ -552,7 +557,7 @@ class AndMatch { proofs.push(delegation) } - Object.defineProperties(this, { source: { value: proofs } }) + Object.defineProperties(this, { proofs: { value: proofs } }) return proofs } /** @@ -588,7 +593,7 @@ class AndMatch { */ const parse = (self, source) => { - const { can, with: withDecoder, nb: decoders } = self.descriptor + const { can, with: withReader, nb: readers } = self.descriptor const { delegation } = source const capability = /** @type {API.Capability>} */ ( source.capability @@ -598,19 +603,19 @@ const parse = (self, source) => { return new UnknownCapability(capability) } - const uri = withDecoder.decode(capability.with) + const uri = withReader.read(capability.with) if (uri.error) { return new MalformedCapability(capability, uri) } const nb = /** @type {API.InferCaveats} */ ({}) - if (decoders) { + if (readers) { /** @type {Partial>} */ const caveats = capability.nb || {} - for (const [name, decoder] of entries(decoders)) { + for (const [name, reader] of entries(readers)) { const key = /** @type {keyof caveats & keyof nb} */ (name) - const result = decoder.decode(caveats[key]) + const result = reader.read(caveats[key]) if (result?.error) { return new MalformedCapability(capability, result) } else if (result != null) { @@ -697,7 +702,9 @@ const selectGroup = (self, capabilities) => { const matches = combine(data).map(group => new AndMatch(group)) return { - unknown: unknown || [], + unknown: + /* c8 ignore next */ + unknown || [], errors, matches, } @@ -720,10 +727,11 @@ const derives = (claimed, delegated) => { } } else if (delegated.with !== claimed.with) { return new Failure( - `Resource ${claimed.with} does not contain ${delegated.with}` + `Resource ${claimed.with} is not contained by ${delegated.with}` ) } + /* c8 ignore next 2 */ const caveats = delegated.nb || {} const nb = claimed.nb || {} const kv = entries(caveats) diff --git a/packages/validator/src/decoder/did.js b/packages/validator/src/decoder/did.js deleted file mode 100644 index 909a1ffb..00000000 --- a/packages/validator/src/decoder/did.js +++ /dev/null @@ -1,48 +0,0 @@ -import * as API from '@ucanto/interface' -import { Failure } from '../error.js' - -/** - * @template {string} M - * @param {unknown} source - * @param {{method?: M}} options - * @return {API.Result & API.URI<"did:">, API.Failure>} - */ -export const decode = (source, { method } = {}) => { - const prefix = method ? `did:${method}:` : `did:` - if (typeof source != 'string') { - return new Failure( - `Expected a string but got ${ - source === null ? null : typeof source - } instead` - ) - } else if (!source.startsWith(prefix)) { - return new Failure(`Expected a ${prefix} but got "${source}" instead`) - } else { - return /** @type {API.DID} */ (source) - } -} - -/** - * @template {string} M - * @param {{method: M}} options - * @returns {API.Decoder & API.URI<"did:">, API.Failure>} - */ -export const match = options => ({ - decode: input => decode(input, options), -}) - -/** - * @template {string} M - * @param {{method?: M}} options - * @returns {API.Decoder & API.URI<"did:">), API.Failure>} - */ - -export const optional = options => ({ - decode: input => { - if (input === undefined) { - return undefined - } else { - return decode(input, options) - } - }, -}) diff --git a/packages/validator/src/decoder/link.js b/packages/validator/src/decoder/link.js deleted file mode 100644 index 8faa2ef1..00000000 --- a/packages/validator/src/decoder/link.js +++ /dev/null @@ -1,79 +0,0 @@ -import * as API from '@ucanto/interface' -import { Failure } from '../error.js' -import { create, createV0, isLink, asLink, parse } from '@ucanto/core/link' - -export { create, createV0, isLink, asLink, parse } - -/** - * @template {number} Code - * @template {number} Alg - * @template {1|0} Version - * @param {unknown} input - * @param {{code?:Code, algorithm?:Alg, version?:Version}} [options] - * @returns {API.Result, API.Failure>} - */ -export const decode = (input, options = {}) => { - if (input == null) { - return new Failure(`Expected link but got ${input} instead`) - } else { - const cid = asLink(input) - if (cid == null) { - return new Failure(`Expected link to be a CID instead of ${input}`) - } else { - if (options.code != null && cid.code !== options.code) { - return new Failure( - `Expected link to be CID with 0x${options.code.toString(16)} codec` - ) - } - if ( - options.algorithm != null && - cid.multihash.code !== options.algorithm - ) { - return new Failure( - `Expected link to be CID with 0x${options.algorithm.toString( - 16 - )} hashing algorithm` - ) - } - - if (options.version != null && cid.version !== options.version) { - return new Failure( - `Expected link to be CID version ${options.version} instead of ${cid.version}` - ) - } - - const link = /** @type {API.Link} */ (cid) - - return link - } - } -} - -/** - * @template {number} Code - * @template {number} Alg - * @template {1|0} Version - * @param {{code?:Code, algorithm?:Alg, version?:Version}} options - * @returns {API.Decoder, API.Failure>} - */ - -export const match = options => ({ - decode: input => decode(input, options), -}) - -/** - * @template {number} Code - * @template {number} Alg - * @template {1|0} Version - * @param {{code?:Code, algorithm?:Alg, version?:Version}} options - * @returns {API.Decoder, API.Failure>} - */ -export const optional = (options = {}) => ({ - decode: input => { - if (input === undefined) { - return undefined - } else { - return decode(input, options) - } - }, -}) diff --git a/packages/validator/src/decoder/text.js b/packages/validator/src/decoder/text.js deleted file mode 100644 index 20659a1c..00000000 --- a/packages/validator/src/decoder/text.js +++ /dev/null @@ -1,46 +0,0 @@ -import * as API from '@ucanto/interface' -import { Failure } from '../error.js' - -/** - * @param {unknown} input - * @param {{pattern?: RegExp}} options - * @return {API.Result} - */ -export const decode = (input, { pattern } = {}) => { - if (typeof input != 'string') { - return new Failure( - `Expected a string but got ${ - input === null ? null : typeof input - } instead` - ) - } else if (pattern && !pattern.test(input)) { - return new Failure( - `Expected to match ${pattern} but got "${input}" instead` - ) - } else { - return input - } -} - -/** - * @param {{pattern?: RegExp}} options - * @returns {API.Decoder} - */ -export const match = (options = {}) => ({ - decode: input => decode(input, options), -}) - -/** - * @param {{pattern?: RegExp}} options - * @returns {API.Decoder} - */ - -export const optional = (options = {}) => ({ - decode: input => { - if (input === undefined) { - return undefined - } else { - return decode(input, options) - } - }, -}) diff --git a/packages/validator/src/decoder/uri.js b/packages/validator/src/decoder/uri.js deleted file mode 100644 index 9c24a546..00000000 --- a/packages/validator/src/decoder/uri.js +++ /dev/null @@ -1,60 +0,0 @@ -import * as API from '@ucanto/interface' -import { Failure } from '../error.js' - -/** - * @template {API.Protocol} P - * @param {unknown} input - * @param {{protocol?: P}} options - * @return {API.Result, API.Failure>} - */ -export const decode = (input, { protocol } = {}) => { - if (typeof input !== 'string' && !(input instanceof URL)) { - return new Failure( - `Expected URI but got ${input === null ? 'null' : typeof input}` - ) - } - - try { - const url = new URL(String(input)) - if (protocol != null && url.protocol !== protocol) { - return new Failure(`Expected ${protocol} URI instead got ${url.href}`) - } else { - return /** @type {API.URI

} */ (url.href) - } - } catch (_) { - return new Failure(`Invalid URI`) - } -} - -/** - * @template {{protocol: API.Protocol}} Options - * @param {Options} options - * @returns {API.Decoder, API.Failure>} - */ -export const match = options => ({ - decode: input => decode(input, options), -}) - -/** - * @template {{protocol: API.Protocol}} Options - * @param {Options} options - * @returns {API.Decoder, API.Failure>} - */ - -export const optional = options => ({ - decode: input => { - if (input === undefined) { - return undefined - } else { - return decode(input, options) - } - }, -}) - -/** - * @template {API.Protocol} P - * @template {API.URI

} T - * @param {T} uri - * @return {T} - */ -export const from = uri => uri diff --git a/packages/validator/src/decoder/view.js b/packages/validator/src/decoder/view.js deleted file mode 100644 index 60d9d0b4..00000000 --- a/packages/validator/src/decoder/view.js +++ /dev/null @@ -1,53 +0,0 @@ -import * as API from '@ucanto/interface' -import { Failure } from '../error.js' - -/** - * @template T - * @template Options - * @implements {API.Decoder} - */ - -class Decoder { - /** - * @param {(input:unknown, options:Options) => API.Result} decodeWith - * @param {Options} options - * @param {boolean} optional - */ - constructor(decodeWith, options, optional = false) { - this.decodeWith = decodeWith - this.options = options - } - /** - * @param {unknown} input - */ - decode(input) { - return this.decodeWith(input, this.options) - } - /** - * @returns {API.Decoder} - */ - get optional() { - const optional = new OptionalDecoder(this.decodeWith, this.options) - Object.defineProperties(this, { optional: { value: optional } }) - return optional - } -} - -/** - * @template Options - * @template T - * @implements {API.Decoder} - * @extends {Decoder} - */ -class OptionalDecoder extends Decoder { - /** - * @param {unknown} input - */ - decode(input) { - if (input === undefined) { - return undefined - } else { - return this.decodeWith(input, this.options) - } - } -} diff --git a/packages/validator/src/error.js b/packages/validator/src/error.js index 65251f57..c5d847d6 100644 --- a/packages/validator/src/error.js +++ b/packages/validator/src/error.js @@ -9,6 +9,7 @@ export class Failure extends Error { get error() { return true } + /* c8 ignore next 3 */ describe() { return this.name } @@ -17,8 +18,8 @@ export class Failure extends Error { } toJSON() { - const { error, name, message } = this - return { error, name, message } + const { error, name, message, stack } = this + return { error, name, message, stack } } } @@ -57,7 +58,7 @@ export class DelegationError extends Failure { describe() { return [ `Can not derive ${this.context} from delegated capabilities:`, - ...this.causes.map((cause) => li(cause.message)), + ...this.causes.map(cause => li(cause.message)), ].join('\n') } @@ -65,6 +66,7 @@ export class DelegationError extends Failure { * @type {API.InvalidCapability | API.EscalatedDelegation | API.DelegationError} */ get cause() { + /* c8 ignore next 9 */ if (this.causes.length !== 1) { return this } else { @@ -141,13 +143,14 @@ export class InvalidAudience extends Failure { return `Delegates to '${this.delegation.audience.did()}' instead of '${this.audience.did()}'` } toJSON() { - const { error, name, audience, message } = this + const { error, name, audience, message, stack } = this return { error, name, audience: audience.did(), delegation: { audience: this.delegation.audience.did() }, message, + stack, } } } @@ -206,12 +209,13 @@ export class Expired extends Failure { return this.delegation.expiration } toJSON() { - const { error, name, expiredAt, message } = this + const { error, name, expiredAt, message, stack } = this return { error, name, message, expiredAt, + stack, } } } @@ -242,6 +246,7 @@ const format = (capability, space) => JSON.stringify( capability, (key, value) => { + /* c8 ignore next 2 */ if (value && value.asCID === value) { return value.toString() } else { @@ -260,4 +265,4 @@ export const indent = (message, indent = ' ') => /** * @param {string} message */ -export const li = (message) => indent(`- ${message}`) +export const li = message => indent(`- ${message}`) diff --git a/packages/validator/src/lib.js b/packages/validator/src/lib.js index e275eae7..ba06a6eb 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -15,11 +15,8 @@ import { export { Failure, UnavailableProof, MalformedCapability } export { capability } from './capability.js' - -export * as URI from './decoder/uri.js' -export * as Link from './decoder/link.js' -export * as Text from './decoder/text.js' -export * as DID from './decoder/did.js' +export * from './schema.js' +export * as Schema from './schema.js' const empty = () => [] @@ -333,8 +330,8 @@ class Unauthorized extends Failure { return this.cause.message } toJSON() { - const { error, name, message, cause } = this - return { error, name, message, cause } + const { error, name, message, cause, stack } = this + return { error, name, message, cause, stack } } } const ALL = '*' diff --git a/packages/validator/src/schema.js b/packages/validator/src/schema.js new file mode 100644 index 00000000..e2a5e547 --- /dev/null +++ b/packages/validator/src/schema.js @@ -0,0 +1,5 @@ +export * as URI from './schema/uri.js' +export * as Link from './schema/link.js' +export * as DID from './schema/did.js' +export * as Text from './schema/text.js' +export * from './schema/schema.js' diff --git a/packages/validator/src/schema/did.js b/packages/validator/src/schema/did.js new file mode 100644 index 00000000..69277c58 --- /dev/null +++ b/packages/validator/src/schema/did.js @@ -0,0 +1,39 @@ +import * as API from '@ucanto/interface' +import * as Schema from './schema.js' + +/** + * @template {string} Method + * @extends {Schema.API & API.URI<"did:">, string, void|Method>} + */ +class DIDSchema extends Schema.API { + /** + * @param {string} source + * @param {void|Method} method + */ + readWith(source, method) { + const prefix = method ? `did:${method}:` : `did:` + if (!source.startsWith(prefix)) { + return Schema.error(`Expected a ${prefix} but got "${source}" instead`) + } else { + return /** @type {API.DID} */ (source) + } + } +} + +const schema = Schema.string().refine(new DIDSchema()) + +export const did = () => schema +/** + * + * @param {unknown} input + */ +export const read = input => schema.read(input) + +/** + * @template {string} Method + * @param {{method?: Method}} options + */ +export const match = options => + /** @type {Schema.Schema & API.URI<"did:">>} */ ( + Schema.string().refine(new DIDSchema(options.method)) + ) diff --git a/packages/validator/src/schema/link.js b/packages/validator/src/schema/link.js new file mode 100644 index 00000000..9c74d3fd --- /dev/null +++ b/packages/validator/src/schema/link.js @@ -0,0 +1,80 @@ +import * as API from '@ucanto/interface' +import { create, createV0, isLink, asLink, parse } from '@ucanto/core/link' +import * as Schema from './schema.js' + +export { create, createV0, isLink, asLink, parse } + +/** + * @template {number} [Code=number] + * @template {number} [Alg=number] + * @template {1|0} [Version=0|1] + * @typedef {{code?:Code, algorithm?:Alg, version?:Version}} Settings + */ + +/** + * @template {number} Code + * @template {number} Alg + * @template {1|0} Version + * @extends {Schema.API, unknown, Settings>} + */ +class LinkSchema extends Schema.API { + /** + * + * @param {unknown} input + * @param {Settings} settings + */ + readWith(input, { code, algorithm, version }) { + if (input == null) { + return Schema.error(`Expected link but got ${input} instead`) + } else { + const cid = asLink(input) + if (cid == null) { + return Schema.error(`Expected link to be a CID instead of ${input}`) + } else { + if (code != null && cid.code !== code) { + return Schema.error( + `Expected link to be CID with 0x${code.toString(16)} codec` + ) + } + if (algorithm != null && cid.multihash.code !== algorithm) { + return Schema.error( + `Expected link to be CID with 0x${algorithm.toString( + 16 + )} hashing algorithm` + ) + } + + if (version != null && cid.version !== version) { + return Schema.error( + `Expected link to be CID version ${version} instead of ${cid.version}` + ) + } + + const link = /** @type {API.Link} */ (cid) + + return link + } + } + } +} + +/** @type {Schema.Schema, unknown>} */ +export const schema = new LinkSchema({}) + +export const link = () => schema + +/** + * @template {number} Code + * @template {number} Alg + * @template {1|0} Version + * @param {Settings} options + * @returns {Schema.Schema>} + */ +export const match = (options = {}) => new LinkSchema(options) + +/** + * @param {unknown} input + */ +export const read = input => schema.read(input) + +export const optional = () => schema.optional() diff --git a/packages/validator/src/schema/schema.js b/packages/validator/src/schema/schema.js new file mode 100644 index 00000000..c952fde8 --- /dev/null +++ b/packages/validator/src/schema/schema.js @@ -0,0 +1,1280 @@ +import * as Schema from './type.js' + +export * from './type.js' + +/** + * @abstract + * @template [T=unknown] + * @template [I=unknown] + * @template [Settings=void] + * @extends {Schema.Base} + * @implements {Schema.Schema} + */ +export class API { + /** + * @param {Settings} settings + */ + constructor(settings) { + /** @protected */ + this.settings = settings + } + + toString() { + return `new ${this.constructor.name}()` + } + /** + * @abstract + * @param {I} input + * @param {Settings} settings + * @returns {Schema.ReadResult} + */ + /* c8 ignore next 3 */ + readWith(input, settings) { + throw new Error(`Abstract method readWith must be implemented by subclass`) + } + /** + * @param {I} input + * @returns {Schema.ReadResult} + */ + read(input) { + return this.readWith(input, this.settings) + } + + /** + * @param {unknown} value + * @returns {value is T} + */ + is(value) { + return !this.read(/** @type {I} */ (value))?.error + } + + /** + * @param {unknown} value + * @return {T} + */ + from(value) { + const result = this.read(/** @type {I} */ (value)) + if (result?.error) { + throw result + } else { + return result + } + } + + /** + * @returns {Schema.Schema} + */ + optional() { + return optional(this) + } + + /** + * @returns {Schema.Schema} + */ + nullable() { + return nullable(this) + } + + /** + * @returns {Schema.Schema} + */ + array() { + return array(this) + } + /** + * @template U + * @param {Schema.Reader} schema + * @returns {Schema.Schema} + */ + + or(schema) { + return or(this, schema) + } + + /** + * @template U + * @param {Schema.Reader} schema + * @returns {Schema.Schema} + */ + and(schema) { + return and(this, schema) + } + + /** + * @template {T} U + * @param {Schema.Reader} schema + * @returns {Schema.Schema} + */ + refine(schema) { + return refine(this, schema) + } + + /** + * @template {string} Kind + * @param {Kind} [kind] + * @returns {Schema.Schema, I>} + */ + brand(kind) { + return /** @type {Schema.Schema, I>} */ (this) + } + + /** + * @param {Schema.NotUndefined} value + * @returns {Schema.DefaultSchema, I>} + */ + default(value) { + // ⚠️ this.from will throw if wrong default is provided + const fallback = this.from(value) + // we also check that fallback is not undefined because that is the point + // of having a fallback + if (fallback === undefined) { + throw new Error(`Value of type undefined is not a vaild default`) + } + + const schema = new Default({ + reader: /** @type {Schema.Reader} */ (this), + value: /** @type {Schema.NotUndefined} */ (fallback), + }) + + return /** @type {Schema.DefaultSchema, I>} */ ( + schema + ) + } +} + +/** + * @template [I=unknown] + * @extends {API} + * @implements {Schema.Schema} + */ +class Never extends API { + toString() { + return 'never()' + } + /** + * @param {I} input + * @returns {Schema.ReadResult} + */ + read(input) { + return typeError({ expect: 'never', actual: input }) + } +} + +/** + * @template [I=unknown] + * @returns {Schema.Schema} + */ +export const never = () => new Never() + +/** + * @template [I=unknown] + * @extends API + * @implements {Schema.Schema} + */ +class Unknown extends API { + /** + * @param {I} input + */ + read(input) { + return /** @type {Schema.ReadResult}*/ (input) + } + toString() { + return 'unknown()' + } +} + +/** + * @template [I=unknown] + * @returns {Schema.Schema} + */ +export const unknown = () => new Unknown() + +/** + * @template O + * @template [I=unknown] + * @extends {API>} + * @implements {Schema.Schema} + */ +class Nullable extends API { + /** + * @param {I} input + * @param {Schema.Reader} reader + */ + readWith(input, reader) { + const result = reader.read(input) + if (result?.error) { + return input === null + ? null + : new UnionError({ + causes: [result, typeError({ expect: 'null', actual: input })], + }) + } else { + return result + } + } + toString() { + return `${this.settings}.nullable()` + } +} + +/** + * @template O + * @template [I=unknown] + * @param {Schema.Reader} schema + * @returns {Schema.Schema} + */ +export const nullable = schema => new Nullable(schema) + +/** + * @template O + * @template [I=unknown] + * @extends {API>} + * @implements {Schema.Schema} + */ +class Optional extends API { + optional() { + return this + } + /** + * @param {I} input + * @param {Schema.Reader} reader + * @returns {Schema.ReadResult} + */ + readWith(input, reader) { + const result = reader.read(input) + return result?.error && input === undefined ? undefined : result + } + toString() { + return `${this.settings}.optional()` + } +} + +/** + * @template {unknown} O + * @template [I=unknown] + * @extends {API, value:O & Schema.NotUndefined}>} + * @implements {Schema.DefaultSchema} + */ +class Default extends API { + /** + * @returns {Schema.DefaultSchema, I>} + */ + optional() { + // Short circuit here as we there is no point in wrapping this in optional. + return /** @type {Schema.DefaultSchema, I>} */ ( + this + ) + } + /** + * @param {I} input + * @param {object} options + * @param {Schema.Reader} options.reader + * @param {O} options.value + * @returns {Schema.ReadResult} + */ + readWith(input, { reader, value }) { + if (input === undefined) { + return /** @type {Schema.ReadResult} */ (value) + } else { + const result = reader.read(input) + + return /** @type {Schema.ReadResult} */ ( + result === undefined ? value : result + ) + } + } + toString() { + return `${this.settings.reader}.default(${JSON.stringify( + this.settings.value + )})` + } + + get value() { + return this.settings.value + } +} + +/** + * @template O + * @template [I=unknown] + * @param {Schema.Reader} schema + * @returns {Schema.Schema} + */ +export const optional = schema => new Optional(schema) + +/** + * @template O + * @template [I=unknown] + * @extends {API>} + * @implements {Schema.ArraySchema} + */ +class ArrayOf extends API { + /** + * @param {I} input + * @param {Schema.Reader} schema + */ + readWith(input, schema) { + if (!Array.isArray(input)) { + return typeError({ expect: 'array', actual: input }) + } + /** @type {O[]} */ + const results = [] + for (const [index, value] of input.entries()) { + const result = schema.read(value) + if (result?.error) { + return memberError({ at: index, cause: result }) + } else { + results.push(result) + } + } + return results + } + get element() { + return this.settings + } + toString() { + return `array(${this.element})` + } +} + +/** + * @template O + * @template [I=unknown] + * @param {Schema.Reader} schema + * @returns {Schema.ArraySchema} + */ +export const array = schema => new ArrayOf(schema) + +/** + * @template {Schema.Reader} T + * @template {[T, ...T[]]} U + * @template [I=unknown] + * @extends {API, I, U>} + * @implements {Schema.Schema, I>} + */ +class Tuple extends API { + /** + * @param {I} input + * @param {U} shape + * @returns {Schema.ReadResult>} + */ + readWith(input, shape) { + if (!Array.isArray(input)) { + return typeError({ expect: 'array', actual: input }) + } + if (input.length !== this.shape.length) { + return new SchemaError( + `Array must contain exactly ${this.shape.length} elements` + ) + } + + const results = [] + for (const [index, reader] of shape.entries()) { + const result = reader.read(input[index]) + if (result?.error) { + return memberError({ at: index, cause: result }) + } else { + results[index] = result + } + } + + return /** @type {Schema.InferTuple} */ (results) + } + + /** @type {U} */ + get shape() { + return this.settings + } + + toString() { + return `tuple([${this.shape.map(reader => reader.toString()).join(', ')}])` + } +} + +/** + * @template {Schema.Reader} T + * @template {[T, ...T[]]} U + * @template [I=unknown] + * @param {U} shape + * @returns {Schema.Schema, I>} + */ +export const tuple = shape => new Tuple(shape) + +/** + * @template {[unknown, ...unknown[]]} T + * @template [I=unknown] + * @extends {API}>} + * @implements {Schema.Schema} + */ +class Enum extends API { + /** + * @param {I} input + * @param {{type:string, variants:Set}} settings + * @returns {Schema.ReadResult} + */ + readWith(input, { variants, type }) { + if (variants.has(input)) { + return /** @type {Schema.ReadResult} */ (input) + } else { + return typeError({ expect: type, actual: input }) + } + } + toString() { + return this.settings.type + } +} + +/** + * @template {string} T + * @template {[T, ...T[]]} U + * @template [I=unknown] + * @param {U} variants + * @returns {Schema.Schema} + */ +const createEnum = variants => + new Enum({ + type: variants.join('|'), + variants: new Set(variants), + }) +export { createEnum as enum } + +/** + * @template {Schema.Reader} T + * @template {[T, ...T[]]} U + * @template [I=unknown] + * @extends {API, I, U>} + * @implements {Schema.Schema, I>} + */ +class Union extends API { + /** + * @param {I} input + * @param {U} variants + */ + readWith(input, variants) { + const causes = [] + for (const reader of variants) { + const result = reader.read(input) + if (result?.error) { + causes.push(result) + } else { + return result + } + } + return new UnionError({ causes }) + } + + get variants() { + return this.settings + } + toString() { + return `union([${this.variants.map(type => type.toString()).join(', ')}])` + } +} + +/** + * @template {Schema.Reader} T + * @template {[T, ...T[]]} U + * @template [I=unknown] + * @param {U} variants + * @returns {Schema.Schema, I>} + */ +const union = variants => new Union(variants) + +/** + * @template T, U + * @template [I=unknown] + * @param {Schema.Reader} left + * @param {Schema.Reader} right + * @returns {Schema.Schema} + */ +export const or = (left, right) => union([left, right]) + +/** + * @template {Schema.Reader} T + * @template {[T, ...T[]]} U + * @template [I=unknown] + * @extends {API, I, U>} + * @implements {Schema.Schema, I>} + */ +class Intersection extends API { + /** + * @param {I} input + * @param {U} schemas + * @returns {Schema.ReadResult>} + */ + readWith(input, schemas) { + const causes = [] + for (const schema of schemas) { + const result = schema.read(input) + if (result?.error) { + causes.push(result) + } + } + + return causes.length > 0 + ? new IntersectionError({ causes }) + : /** @type {Schema.ReadResult>} */ (input) + } + toString() { + return `intersection([${this.settings + .map(type => type.toString()) + .join(',')}])` + } +} + +/** + * @template {Schema.Reader} T + * @template {[T, ...T[]]} U + * @template [I=unknown] + * @param {U} variants + * @returns {Schema.Schema, I>} + */ +export const intersection = variants => new Intersection(variants) + +/** + * @template T, U + * @template [I=unknown] + * @param {Schema.Reader} left + * @param {Schema.Reader} right + * @returns {Schema.Schema} + */ +export const and = (left, right) => intersection([left, right]) + +/** + * @template [I=unknown] + * @extends {API} + */ +class Boolean extends API { + /** + * @param {I} input + */ + readWith(input) { + switch (input) { + case true: + case false: + return /** @type {boolean} */ (input) + default: + return typeError({ + expect: 'boolean', + actual: input, + }) + } + } + + toString() { + return `boolean()` + } +} + +/** @type {Schema.Schema} */ +const anyBoolean = new Boolean() + +export const boolean = () => anyBoolean + +/** + * @template {number} [O=number] + * @template [I=unknown] + * @template [Settings=void] + * @extends {API} + * @implements {Schema.NumberSchema} + */ +class UnknownNumber extends API { + /** + * @param {number} n + */ + greaterThan(n) { + return this.refine(greaterThan(n)) + } + /** + * @param {number} n + */ + lessThan(n) { + return this.refine(lessThan(n)) + } + + /** + * @template {O} U + * @param {Schema.Reader} schema + * @returns {Schema.NumberSchema} + */ + refine(schema) { + return new RefinedNumber({ base: this, schema }) + } +} + +/** + * @template [I=unknown] + * @extends {UnknownNumber} + * @implements {Schema.NumberSchema} + */ +class AnyNumber extends UnknownNumber { + /** + * @param {I} input + * @returns {Schema.ReadResult} + */ + readWith(input) { + return typeof input === 'number' + ? input + : typeError({ expect: 'number', actual: input }) + } + toString() { + return `number()` + } +} + +/** @type {Schema.NumberSchema} */ +const anyNumber = new AnyNumber() +export const number = () => anyNumber + +/** + * @template {number} [T=number] + * @template {T} [O=T] + * @template [I=unknown] + * @extends {UnknownNumber, schema:Schema.Reader}>} + * @implements {Schema.NumberSchema} + */ +class RefinedNumber extends UnknownNumber { + /** + * @param {I} input + * @param {{base:Schema.Reader, schema:Schema.Reader}} settings + * @returns {Schema.ReadResult} + */ + readWith(input, { base, schema }) { + const result = base.read(input) + return result?.error ? result : schema.read(result) + } + toString() { + return `${this.settings.base}.refine(${this.settings.schema})` + } +} + +/** + * @template {number} T + * @extends {API} + */ +class LessThan extends API { + /** + * @param {T} input + * @param {number} number + * @returns {Schema.ReadResult} + */ + readWith(input, number) { + if (input < number) { + return input + } else { + return error(`Expected ${input} < ${number}`) + } + } + toString() { + return `lessThan(${this.settings})` + } +} + +/** + * @template {number} T + * @param {number} n + * @returns {Schema.Schema} + */ +export const lessThan = n => new LessThan(n) + +/** + * @template {number} T + * @extends {API} + */ +class GreaterThan extends API { + /** + * @param {T} input + * @param {number} number + * @returns {Schema.ReadResult} + */ + readWith(input, number) { + if (input > number) { + return input + } else { + return error(`Expected ${input} > ${number}`) + } + } + toString() { + return `greaterThan(${this.settings})` + } +} + +/** + * @template {number} T + * @param {number} n + * @returns {Schema.Schema} + */ +export const greaterThan = n => new GreaterThan(n) + +const Integer = { + /** + * @param {number} input + * @returns {Schema.ReadResult} + */ + read(input) { + return Number.isInteger(input) + ? /** @type {Schema.Integer} */ (input) + : typeError({ + expect: 'integer', + actual: input, + }) + }, + toString() { + return `Integer` + }, +} + +const anyInteger = anyNumber.refine(Integer) +export const integer = () => anyInteger + +const Float = { + /** + * @param {number} number + * @returns {Schema.ReadResult} + */ + read(number) { + return Number.isFinite(number) + ? /** @type {Schema.Float} */ (number) + : typeError({ + expect: 'Float', + actual: number, + }) + }, + toString() { + return 'Float' + }, +} + +const anyFloat = anyNumber.refine(Float) +export const float = () => anyFloat + +/** + * @template {string} [O=string] + * @template [I=unknown] + * @template [Settings=void] + * @extends {API} + */ +class UnknownString extends API { + /** + * @template {O|unknown} U + * @param {Schema.Reader} schema + * @returns {Schema.StringSchema} + */ + refine(schema) { + const other = /** @type {Schema.Reader} */ (schema) + const rest = new RefinedString({ + base: this, + schema: other, + }) + + return /** @type {Schema.StringSchema} */ (rest) + } + /** + * @template {string} Prefix + * @param {Prefix} prefix + */ + startsWith(prefix) { + return this.refine(startsWith(prefix)) + } + /** + * @template {string} Suffix + * @param {Suffix} suffix + */ + endsWith(suffix) { + return this.refine(endsWith(suffix)) + } + toString() { + return `string()` + } +} + +/** + * @template O + * @template {string} [T=string] + * @template [I=unknown] + * @extends {UnknownString, schema:Schema.Reader}>} + * @implements {Schema.StringSchema} + */ +class RefinedString extends UnknownString { + /** + * @param {I} input + * @param {{base:Schema.Reader, schema:Schema.Reader}} settings + * @returns {Schema.ReadResult} + */ + readWith(input, { base, schema }) { + const result = base.read(input) + return result?.error + ? result + : /** @type {Schema.ReadResult} */ (schema.read(result)) + } + toString() { + return `${this.settings.base}.refine(${this.settings.schema})` + } +} + +/** + * @template [I=unknown] + * @extends {UnknownString} + * @implements {Schema.StringSchema} + */ +class AnyString extends UnknownString { + /** + * @param {I} input + * @returns {Schema.ReadResult} + */ + readWith(input) { + return typeof input === 'string' + ? input + : typeError({ expect: 'string', actual: input }) + } +} + +/** @type {Schema.StringSchema} */ +const anyString = new AnyString() +export const string = () => anyString + +/** + * @template {string} Prefix + * @template {string} Body + * @extends {API} + * @implements {Schema.Schema} + */ +class StartsWith extends API { + /** + * @param {Body} input + * @param {Prefix} prefix + */ + readWith(input, prefix) { + return input.startsWith(prefix) + ? /** @type {Schema.ReadResult} */ (input) + : error(`Expect string to start with "${prefix}" instead got "${input}"`) + } + get prefix() { + return this.settings + } + toString() { + return `startsWith("${this.prefix}")` + } +} + +/** + * @template {string} Prefix + * @template {string} Body + * @param {Prefix} prefix + * @returns {Schema.Schema<`${Prefix}${string}`, string>} + */ +export const startsWith = prefix => new StartsWith(prefix) + +/** + * @template {string} Suffix + * @template {string} Body + * @extends {API} + */ +class EndsWith extends API { + /** + * @param {Body} input + * @param {Suffix} suffix + */ + readWith(input, suffix) { + return input.endsWith(suffix) + ? /** @type {Schema.ReadResult} */ (input) + : error(`Expect string to end with "${suffix}" instead got "${input}"`) + } + get suffix() { + return this.settings + } + toString() { + return `endsWith("${this.suffix}")` + } +} + +/** + * @template {string} Suffix + * @param {Suffix} suffix + * @returns {Schema.Schema<`${string}${Suffix}`, string>} + */ +export const endsWith = suffix => new EndsWith(suffix) + +/** + * @template T + * @template {T} U + * @template [I=unknown] + * @extends {API, schema: Schema.Reader }>} + * @implements {Schema.Schema} + */ + +class Refine extends API { + /** + * @param {I} input + * @param {{ base: Schema.Reader, schema: Schema.Reader }} settings + */ + readWith(input, { base, schema }) { + const result = base.read(input) + return result?.error ? result : schema.read(result) + } + toString() { + return `${this.settings.base}.refine(${this.settings.schema})` + } +} + +/** + * @template T + * @template {T} U + * @template [I=unknown] + * @param {Schema.Reader} base + * @param {Schema.Reader} schema + * @returns {Schema.Schema} + */ +export const refine = (base, schema) => new Refine({ base, schema }) + +/** + * @template {null|boolean|string|number} T + * @template [I=unknown] + * @extends {API} + * @implements {Schema.LiteralSchema} + */ +class Literal extends API { + /** + * @param {I} input + * @param {T} expect + * @returns {Schema.ReadResult} + */ + readWith(input, expect) { + return input !== /** @type {unknown} */ (expect) + ? new LiteralError({ expect, actual: input }) + : expect + } + get value() { + return /** @type {Exclude} */ (this.settings) + } + /** + * @template {Schema.NotUndefined} U + * @param {U} value + */ + default(value = /** @type {U} */ (this.value)) { + return super.default(value) + } + toString() { + return `literal(${displayTypeName(this.value)})` + } +} + +/** + * @template {null|boolean|string|number} T + * @template [I=unknown] + * @param {T} value + * @returns {Schema.LiteralSchema} + */ +export const literal = value => new Literal(value) + +/** + * @template {{[key:string]: Schema.Reader}} U + * @template [I=unknown] + * @extends {API, I, U>} + */ +class Struct extends API { + /** + * @param {I} input + * @param {U} shape + * @returns {Schema.ReadResult>} + */ + readWith(input, shape) { + if (typeof input != 'object' || input === null || Array.isArray(input)) { + return typeError({ + expect: 'object', + actual: input, + }) + } + + const source = /** @type {{[K in keyof U]: unknown}} */ (input) + + const struct = /** @type {{[K in keyof U]: Schema.Infer}} */ ({}) + const entries = + /** @type {{[K in keyof U]: [K & string, U[K]]}[keyof U][]} */ ( + Object.entries(shape) + ) + + for (const [at, reader] of entries) { + const result = reader.read(source[at]) + if (result?.error) { + return memberError({ at, cause: result }) + } + // skip undefined because they mess up CBOR and are generally useless. + else if (result !== undefined) { + struct[at] = /** @type {Schema.Infer} */ (result) + } + } + + return struct + } + + /** @type {U} */ + get shape() { + // @ts-ignore - We declared `settings` private but we access it here + return this.settings + } + + toString() { + return [ + `struct({ `, + ...Object.entries(this.shape).map( + ([key, schema]) => `${key}: ${schema}, ` + ), + `})`, + ].join('') + } + + /** + * @param {Schema.InferStructSource} data + */ + create(data) { + return this.from(data || {}) + } + + /** + * @template {{[key:string]: Schema.Reader}} E + * @param {E} extension + * @returns {Schema.StructSchema} + */ + extend(extension) { + return new Struct({ ...this.shape, ...extension }) + } +} + +/** + * @template {null|boolean|string|number} T + * @template {{[key:string]: T|Schema.Reader}} U + * @template {{[K in keyof U]: U[K] extends Schema.Reader ? U[K] : Schema.LiteralSchema}} V + * @template [I=unknown] + * @param {U} fields + * @returns {Schema.StructSchema} + */ +export const struct = fields => { + const shape = + /** @type {{[K in keyof U]: Schema.Reader}} */ ({}) + /** @type {[keyof U & string, T|Schema.Reader][]} */ + const entries = Object.entries(fields) + + for (const [key, field] of entries) { + switch (typeof field) { + case 'number': + case 'string': + case 'boolean': + shape[key] = literal(field) + break + case 'object': + shape[key] = field === null ? literal(null) : field + break + default: + throw new Error( + `Invalid struct field "${key}", expected schema or literal, instead got ${typeof field}` + ) + } + } + + return new Struct(/** @type {V} */ (shape)) +} + +/** + * @param {string} message + * @returns {Schema.Error} + */ +export const error = message => new SchemaError(message) + +class SchemaError extends Error { + get name() { + return 'SchemaError' + } + /** @type {true} */ + get error() { + return true + } + /* c8 ignore next 3 */ + describe() { + return this.name + } + get message() { + return this.describe() + } + + toJSON() { + const { error, name, message, stack } = this + return { error, name, message, stack } + } +} + +class TypeError extends SchemaError { + /** + * @param {{expect:string, actual:unknown}} data + */ + constructor({ expect, actual }) { + super() + this.expect = expect + this.actual = actual + } + get name() { + return 'TypeError' + } + describe() { + return `Expected value of type ${this.expect} instead got ${displayTypeName( + this.actual + )}` + } +} + +/** + * @param {object} data + * @param {string} data.expect + * @param {unknown} data.actual + * @returns {Schema.Error} + */ +export const typeError = data => new TypeError(data) + +/** + * + * @param {unknown} value + */ +const displayTypeName = value => { + const type = typeof value + switch (type) { + case 'boolean': + case 'string': + return JSON.stringify(value) + // if these types we do not want JSON.stringify as it may mess things up + // eg turn NaN and Infinity to null + case 'bigint': + return `${value}n` + case 'number': + case 'symbol': + case 'undefined': + return String(value) + case 'object': + return value === null ? 'null' : Array.isArray(value) ? 'array' : 'object' + default: + return type + } +} + +class LiteralError extends SchemaError { + /** + * @param {{ + * expect:string|number|boolean|null + * actual:unknown + * }} data + */ + constructor({ expect, actual }) { + super() + this.expect = expect + this.actual = actual + } + get name() { + return 'LiteralError' + } + describe() { + return `Expected literal ${displayTypeName( + this.expect + )} instead got ${displayTypeName(this.actual)}` + } +} + +class ElementError extends SchemaError { + /** + * @param {{at:number, cause:Schema.Error}} data + */ + constructor({ at, cause }) { + super() + this.at = at + this.cause = cause + } + get name() { + return 'ElementError' + } + describe() { + return [ + `Array contains invalid element at ${this.at}:`, + li(this.cause.message), + ].join('\n') + } +} + +class FieldError extends SchemaError { + /** + * @param {{at:string, cause:Schema.Error}} data + */ + constructor({ at, cause }) { + super() + this.at = at + this.cause = cause + } + get name() { + return 'FieldError' + } + describe() { + return [ + `Object contains invalid field "${this.at}":`, + li(this.cause.message), + ].join('\n') + } +} + +/** + * @param {object} options + * @param {string|number} options.at + * @param {Schema.Error} options.cause + * @returns {Schema.Error} + */ +export const memberError = ({ at, cause }) => + typeof at === 'string' + ? new FieldError({ at, cause }) + : new ElementError({ at, cause }) + +class UnionError extends SchemaError { + /** + * @param {{causes: Schema.Error[]}} data + */ + constructor({ causes }) { + super() + this.causes = causes + } + get name() { + return 'UnionError' + } + describe() { + const { causes } = this + return [ + `Value does not match any type of the union:`, + ...causes.map(cause => li(cause.message)), + ].join('\n') + } +} + +class IntersectionError extends SchemaError { + /** + * @param {{causes: Schema.Error[]}} data + */ + constructor({ causes }) { + super() + this.causes = causes + } + get name() { + return 'IntersectionError' + } + describe() { + const { causes } = this + return [ + `Value does not match following types of the intersection:`, + ...causes.map(cause => li(cause.message)), + ].join('\n') + } +} + +/** + * @param {string} message + */ +const indent = (message, indent = ' ') => + `${indent}${message.split('\n').join(`\n${indent}`)}` + +/** + * @param {string} message + */ +const li = message => indent(`- ${message}`) diff --git a/packages/validator/src/schema/text.js b/packages/validator/src/schema/text.js new file mode 100644 index 00000000..546950bb --- /dev/null +++ b/packages/validator/src/schema/text.js @@ -0,0 +1,34 @@ +import * as Schema from './schema.js' + +const schema = Schema.string() + +export const text = () => schema + +/** + * @param {{pattern: RegExp}} options + */ +export const match = ({ pattern }) => schema.refine(new Match(pattern)) + +/** + * @param {unknown} input + */ +export const read = input => schema.read(input) + +/** + * @extends {Schema.API} + */ +class Match extends Schema.API { + /** + * @param {string} source + * @param {RegExp} pattern + */ + readWith(source, pattern) { + if (!pattern.test(source)) { + return Schema.error( + `Expected to match ${pattern} but got "${source}" instead` + ) + } else { + return source + } + } +} diff --git a/packages/validator/src/schema/type.js b/packages/validator/src/schema/type.js new file mode 100644 index 00000000..e69de29b diff --git a/packages/validator/src/schema/type.ts b/packages/validator/src/schema/type.ts new file mode 100644 index 00000000..0bce1e55 --- /dev/null +++ b/packages/validator/src/schema/type.ts @@ -0,0 +1,142 @@ +import { Failure as Error, Result, Phantom } from '@ucanto/interface' + +export interface Reader< + O = unknown, + I = unknown, + X extends { error: true } = Error +> { + read(input: I): Result +} + +export type { Error } + +export type ReadResult = Result + +export interface Schema< + O extends unknown = unknown, + I extends unknown = unknown +> extends Reader { + optional(): Schema + nullable(): Schema + array(): Schema + default(value: NotUndefined): DefaultSchema, I> + or(other: Reader): Schema + and(other: Reader): Schema + refine(schema: Reader): Schema + + brand(kind?: K): Schema, I> + + is(value: unknown): value is O + from(value: I): O +} + +export interface DefaultSchema< + O extends unknown = unknown, + I extends unknown = unknown +> extends Schema { + readonly value: O & NotUndefined + optional(): DefaultSchema, I> +} + +export type NotUndefined = Exclude + +export interface ArraySchema extends Schema { + element: Reader +} + +export interface LiteralSchema< + T extends string | number | boolean | null, + I = unknown +> extends Schema { + default(value?: T): DefaultSchema, I> + readonly value: T +} + +export interface NumberSchema< + N extends number = number, + I extends unknown = unknown +> extends Schema { + greaterThan(n: number): NumberSchema + lessThan(n: number): NumberSchema + + refine(schema: Reader): NumberSchema +} + +export interface StructSchema< + U extends { [key: string]: Reader } = {}, + I extends unknown = unknown +> extends Schema, I> { + shape: U + + create(input: MarkEmptyOptional>): InferStruct + extend( + extension: E + ): StructSchema +} + +export interface StringSchema + extends Schema { + startsWith( + prefix: Prefix + ): StringSchema + endsWith( + suffix: Suffix + ): StringSchema + refine(schema: Reader): StringSchema +} + +declare const Marker: unique symbol +export type Branded = T & { + [Marker]: T +} + +export type Integer = number & Phantom<{ typeof: 'integer' }> +export type Float = number & Phantom<{ typeof: 'float' }> + +export type Infer = T extends Reader ? T : never + +export type InferIntesection = { + [K in keyof U]: (input: Infer) => void +}[number] extends (input: infer T) => void + ? T + : never + +export type InferUnion = Infer + +export type InferTuple = { + [K in keyof U]: Infer +} + +export type InferStruct = MarkOptionals<{ + [K in keyof U]: Infer +}> + +export type InferStructSource = + // MarkEmptyOptional< + MarkOptionals<{ + [K in keyof U]: InferSource + }> +// > + +export type InferSource = U extends DefaultSchema + ? T | undefined + : U extends StructSchema + ? InferStructSource + : U extends Reader + ? T + : never + +export type MarkEmptyOptional = RequiredKeys extends never + ? T | void + : T + +type MarkOptionals = Pick> & + Partial>> + +type RequiredKeys = { + [k in keyof T]: undefined extends T[k] ? never : k +}[keyof T] & {} + +type OptionalKeys = { + [k in keyof T]: undefined extends T[k] ? k : never +}[keyof T] & {} diff --git a/packages/validator/src/schema/uri.js b/packages/validator/src/schema/uri.js new file mode 100644 index 00000000..e6563740 --- /dev/null +++ b/packages/validator/src/schema/uri.js @@ -0,0 +1,61 @@ +import * as API from '@ucanto/interface' +import * as Schema from './schema.js' + +/** + * @template {API.Protocol} [P=API.Protocol] + * @typedef {{protocol: P}} Options + */ + +/** + * @template {Options} O + * @extends {Schema.API, unknown, Partial>} + */ +class URISchema extends Schema.API { + /** + * @param {unknown} input + * @param {Partial} options + * @returns {Schema.ReadResult>} + */ + readWith(input, { protocol } = {}) { + if (typeof input !== 'string' && !(input instanceof URL)) { + return Schema.error( + `Expected URI but got ${input === null ? 'null' : typeof input}` + ) + } + + try { + const url = new URL(String(input)) + if (protocol != null && url.protocol !== protocol) { + return Schema.error(`Expected ${protocol} URI instead got ${url.href}`) + } else { + return /** @type {API.URI} */ (url.href) + } + } catch (_) { + return Schema.error(`Invalid URI`) + } + } +} + +const schema = new URISchema({}) + +export const uri = () => schema + +/** + * @param {unknown} input + */ +export const read = input => schema.read(input) + +/** + * @template {API.Protocol} P + * @template {Options

} O + * @param {O} options + * @returns {Schema.Schema, unknown>} + */ +export const match = options => new URISchema(options) + +/** + * @template {string} [Scheme=string] + * @param {`${Scheme}:${string}`} input + */ +export const from = input => + /** @type {API.URI<`${Scheme}:`>} */ (schema.from(input)) diff --git a/packages/validator/test/capability.spec.js b/packages/validator/test/capability.spec.js index 920637a7..8e418b1e 100644 --- a/packages/validator/test/capability.spec.js +++ b/packages/validator/test/capability.spec.js @@ -1,11 +1,12 @@ -import { capability, URI, Link, access } from '../src/lib.js' -import { invoke, parseLink, Delegation } from '@ucanto/core' +import { capability, URI, Link, unknown, Schema } from '../src/lib.js' +import { invoke, parseLink } from '@ucanto/core' import * as API from '@ucanto/interface' import { Failure } from '../src/error.js' import { the } from '../src/util.js' import { CID } from 'multiformats' import { test, assert } from './test.js' import { alice, bob, mallory, service as w3 } from './fixtures.js' +import { derive } from '../src/capability.js' /** * @template {API.Capabilities} C @@ -21,6 +22,13 @@ const delegate = (capabilities, delegation = {}) => index, })) +/** + * @param {API.Capability} capability + * @param {object} delegation + */ +const source = (capability, delegation = {}) => + delegate([capability], delegation)[0] + test('capability selects matches', () => { const read = capability({ can: 'file/read', @@ -792,7 +800,7 @@ test('parse with nb', () => { can: 'store/add', with: URI.match({ protocol: 'did:' }), nb: { - link: Link.optional(), + link: Link.match().optional(), }, derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { @@ -1202,6 +1210,7 @@ test('toString methods', () => { }) test('capability create with nb', () => { + const data = URI.match({ protocol: 'data:' }) const echo = capability({ can: 'test/echo', with: URI.match({ protocol: 'did:' }), @@ -1509,7 +1518,7 @@ test('capability with optional caveats', async () => { with: URI.match({ protocol: 'did:' }), nb: { message: URI.match({ protocol: 'data:' }), - meta: Link.optional(), + meta: Link.match().optional(), }, }) @@ -1554,3 +1563,527 @@ test('capability with optional caveats', async () => { }, ]) }) + +test('and chain', () => { + const A = capability({ + can: 'test/a', + with: URI, + }) + + const B = capability({ + can: 'test/b', + with: URI, + }) + + const C = capability({ + can: 'test/c', + with: URI, + }) + + const ABC = A.and(B).and(C) + + const cap = ABC.derive({ + to: capability({ + can: 'test/abc', + with: URI, + }), + derives: (abc, [a, b, c]) => { + return abc.with !== a.with + ? new Failure(`${abc.with} != ${a.with}`) + : abc.with !== b.with + ? new Failure(`${abc.with} != ${b.with}`) + : abc.with !== c.with + ? new Failure(`${abc.with} != ${c.with}`) + : true + }, + }) + + const d1 = delegate([{ can: 'test/abc', with: 'file:///test' }]) + + const d2 = delegate([ + { can: 'test/c', with: 'file:///test' }, + { can: 'test/a', with: 'file:///test' }, + { can: 'test/b', with: 'file:///test' }, + ]) + + assert.containSubset( + ABC.match(source({ can: 'test/c', with: 'file:///test' })), + { + error: true, + } + ) + + assert.containSubset(ABC.select(d2), { + unknown: [], + errors: [], + matches: [ + { + matches: [ + { value: { can: 'test/c', with: 'file:///test' } }, + { value: { can: 'test/a', with: 'file:///test' } }, + { value: { can: 'test/b', with: 'file:///test' } }, + ], + }, + ], + }) +}) + +test('.and(...).match', () => { + const A = capability({ + can: 'test/ab', + with: URI, + nb: { + a: Schema.Text, + }, + }) + + const B = capability({ + can: 'test/ab', + with: URI, + nb: { + b: Schema.Text, + }, + }) + + const AB = A.and(B) + const m = AB.match( + source({ + can: 'test/ab', + with: 'data:1', + nb: { + a: 'a', + b: 'b', + }, + }) + ) + + if (m.error) { + return assert.fail(m.message) + } + + assert.containSubset(AB.select([]), { + unknown: [], + errors: [], + matches: [], + }) + + const src = delegate([ + { can: 'test/ab', with: 'data:1', nb: { a: 'a' } }, + { can: 'test/ab', with: 'data:1', nb: { a: 'A', b: 'b' } }, + ]) + + assert.containSubset(m.select(src), { + unknown: [], + errors: [ + { + name: 'InvalidClaim', + cause: { + name: 'EscalatedCapability', + message: 'Constraint violation: a: a violates A', + }, + }, + { + name: 'InvalidClaim', + cause: { + name: 'MalformedCapability', + cause: { + name: 'TypeError', + message: 'Expected value of type string instead got undefined', + }, + }, + }, + ], + matches: [ + { + matches: [ + { + value: { + can: 'test/ab', + with: 'data:1', + nb: { a: 'a' }, + }, + }, + { + value: { + can: 'test/ab', + with: 'data:1', + nb: { b: 'b' }, + }, + }, + ], + }, + ], + }) +}) + +test('A.or(B).match', () => { + const A = capability({ + can: 'test/a', + with: URI, + }) + + const B = capability({ + can: 'test/b', + with: URI, + }) + + const AB = A.or(B) + + const ab = AB.match(source({ can: 'test/c', with: 'data:0' })) + assert.containSubset(ab, { + error: true, + name: 'UnknownCapability', + }) + + assert.containSubset( + AB.match( + source({ + can: 'test/b', + // @ts-expect-error - not a URI + with: 'hello', + }) + ), + { + error: true, + name: 'MalformedCapability', + } + ) +}) + +test('and with diff nb', () => { + const A = capability({ + can: 'test/me', + with: URI, + nb: { + a: Schema.Text, + }, + }) + + const B = capability({ + can: 'test/me', + with: URI, + nb: { + b: Schema.Text, + }, + }) + + const AB = A.and(B) + + assert.containSubset(AB.match(source({ can: 'test/me', with: 'data:1' })), { + error: true, + }) + assert.containSubset( + AB.match(source({ can: 'test/me', with: 'data:1', nb: { a: 'a' } })), + { + error: true, + } + ) + assert.containSubset( + AB.match(source({ can: 'test/me', with: 'data:1', nb: { b: 'b' } })), + { + error: true, + } + ) + + const proof = { this: { is: 'proof' } } + + assert.containSubset( + AB.match( + source({ can: 'test/me', with: 'data:1', nb: { a: 'a', b: 'b' } }, proof) + ), + { + proofs: [proof], + matches: [ + { value: { can: 'test/me', with: 'data:1', nb: { a: 'a' } } }, + { value: { can: 'test/me', with: 'data:1', nb: { b: 'b' } } }, + ], + } + ) +}) + +test('derived capability DSL', () => { + const A = capability({ + can: 'invoke/a', + with: Schema.URI, + }) + + const AA = A.derive({ + to: capability({ + can: 'derive/a', + with: Schema.URI, + }), + derives: (b, a) => + b.with === a.with ? true : new Failure(`with don't match`), + }) + + assert.equal(AA.can, 'derive/a') + + assert.deepEqual( + AA.create({ + with: 'data:a', + }), + { + can: 'derive/a', + with: 'data:a', + } + ) + + assert.deepEqual( + AA.invoke({ + audience: w3, + issuer: alice, + with: alice.did(), + }), + invoke({ + issuer: alice, + audience: w3, + capability: { + can: 'derive/a', + with: alice.did(), + }, + }) + ) +}) + +test('capability match', () => { + const a = capability({ can: 'test/a', with: Schema.URI }) + + const proof = { + fake: { thing: 'thing' }, + } + + const m = a.match(source({ can: 'test/a', with: 'data:a' }, proof)) + assert.containSubset(m, { + can: 'test/a', + proofs: [proof], + }) + + assert.equal( + m.toString(), + JSON.stringify({ + can: 'test/a', + with: 'data:a', + }) + ) + + const m2 = a.match(source({ can: 'test/a', with: 'data:a', nb: {} }, proof)) + assert.equal( + m2.toString(), + JSON.stringify({ + can: 'test/a', + with: 'data:a', + }) + ) + + const echo = capability({ + can: 'test/echo', + with: URI.match({ protocol: 'did:' }), + nb: { + message: URI.match({ protocol: 'data:' }), + }, + }) + + const m3 = echo.match( + source( + { + can: 'test/echo', + with: alice.did(), + nb: { message: 'data:hello' }, + }, + proof + ) + ) + + assert.containSubset(m3, { + can: 'test/echo', + value: { + can: 'test/echo', + with: alice.did(), + nb: { message: 'data:hello' }, + }, + proofs: [proof], + }) + + assert.equal( + m3.toString(), + JSON.stringify({ + can: 'test/echo', + with: alice.did(), + nb: { message: 'data:hello' }, + }) + ) +}) + +test('derived capability match & select', () => { + const A = capability({ + can: 'invoke/a', + with: Schema.URI, + }) + + const AA = A.derive({ + to: capability({ + can: 'derive/a', + with: Schema.URI, + }), + derives: (b, a) => + b.with === a.with ? true : new Failure(`with don't match`), + }) + + const proof = { + issuer: alice, + fake: { thing: 'thing' }, + } + const src = source({ can: 'derive/a', with: 'data:a' }, proof) + const m = AA.match(src) + + assert.containSubset(m, { + can: 'derive/a', + proofs: [proof], + value: { can: 'derive/a', with: 'data:a' }, + source: [src], + }) + + if (m.error) { + return assert.fail(m.message) + } + + assert.notEqual(m.prune({ canIssue: () => false }), null) + assert.equal(m.prune({ canIssue: () => true }), null) +}) + +test('default derive', () => { + const a = capability({ + can: 'test/a', + with: Schema.URI.match({ protocol: 'file:' }), + }) + + const home = a.match( + source({ can: 'test/a', with: 'file:///home/bob/photo' }) + ) + if (home.error) { + return assert.fail(home.message) + } + + assert.containSubset( + home.select( + delegate([ + { + can: 'test/a', + with: 'file:///home/alice/*', + }, + ]) + ), + { + matches: [], + unknown: [], + errors: [ + { + name: 'InvalidClaim', + cause: { + name: 'EscalatedCapability', + message: + 'Constraint violation: Resource file:///home/bob/photo does not match delegated file:///home/alice/* ', + }, + }, + ], + } + ) + + assert.containSubset( + home.select( + delegate([ + { + can: 'test/a', + with: 'file:///home/bob/*', + }, + ]) + ), + { + matches: [ + { + can: 'test/a', + value: { + can: 'test/a', + with: 'file:///home/bob/*', + nb: {}, + }, + }, + ], + unknown: [], + errors: [], + } + ) + + assert.containSubset( + home.select( + delegate([ + { + can: 'test/a', + with: 'file:///home/alice/', + }, + ]) + ), + { + matches: [], + unknown: [], + errors: [ + { + name: 'InvalidClaim', + cause: { + name: 'EscalatedCapability', + message: + 'Constraint violation: Resource file:///home/bob/photo is not contained by file:///home/alice/', + }, + }, + ], + } + ) +}) + +test('default derive with nb', () => { + const Profile = capability({ + can: 'profile/set', + with: Schema.URI.match({ protocol: 'file:' }), + nb: { + mime: Schema.Text, + }, + }) + + const pic = Profile.match( + source({ + can: 'profile/set', + with: 'file:///home/alice/photo', + nb: { mime: 'image/png' }, + }) + ) + + if (pic.error) { + return assert.fail(pic.message) + } + + assert.containSubset( + pic.select( + delegate([ + { + can: 'profile/set', + with: 'file:///home/alice/photo', + nb: { mime: 'image/jpeg' }, + }, + ]) + ), + { + matches: [], + unknown: [], + errors: [ + { + name: 'InvalidClaim', + cause: { + name: 'EscalatedCapability', + message: + 'Constraint violation: mime: image/png violates image/jpeg', + }, + }, + ], + } + ) +}) diff --git a/packages/validator/test/error.spec.js b/packages/validator/test/error.spec.js new file mode 100644 index 00000000..ae176b4b --- /dev/null +++ b/packages/validator/test/error.spec.js @@ -0,0 +1,42 @@ +import { test, assert } from './test.js' +import { Failure, InvalidAudience } from '../src/error.js' +import { alice, bob, mallory, service as w3 } from './fixtures.js' +import { delegate } from '@ucanto/core' + +test('Failure', () => { + const error = new Failure('boom!') + const json = JSON.parse(JSON.stringify(error)) + assert.deepInclude(json, { + name: 'Error', + message: 'boom!', + error: true, + stack: error.stack, + }) + + assert.equal(error instanceof Error, true) +}) + +test('InvalidAudience', async () => { + const delegation = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'store/write', + with: alice.did(), + }, + ], + proofs: [], + }) + + const error = new InvalidAudience(bob, delegation) + + assert.deepEqual(error.toJSON(), { + error: true, + name: 'InvalidAudience', + audience: bob.did(), + delegation: { audience: w3.did() }, + message: `Delegates to '${w3.did()}' instead of '${bob.did()}'`, + stack: error.stack, + }) +}) diff --git a/packages/validator/test/decoder.spec.js b/packages/validator/test/extra-schema.spec.js similarity index 61% rename from packages/validator/test/decoder.spec.js rename to packages/validator/test/extra-schema.spec.js index 924b2ebd..af6fcdf2 100644 --- a/packages/validator/test/decoder.spec.js +++ b/packages/validator/test/extra-schema.spec.js @@ -1,6 +1,7 @@ -import { URI, Link, Text, DID } from '../src/lib.js' +import { URI, Link, Text, DID } from '../src/schema.js' import { test, assert } from './test.js' import { CID } from 'multiformats' +import * as API from '@ucanto/interface' { /** @type {[string, string|{message:string}][]} */ @@ -12,11 +13,23 @@ import { CID } from 'multiformats' for (const [input, expect] of dataset) { test(`URI.decode(${JSON.stringify(input)}}`, () => { - assert.containSubset(URI.decode(input), expect) + assert.containSubset(URI.read(input), expect) + assert.containSubset(URI.uri().read(input), expect) }) } } +test('URI.from', () => { + /** @type {API.URI<`did:`>} */ + // @ts-expect-error - URI<"data:"> not assignable to URI<"did:"> + const data = URI.from('data:text/html,1') + assert.equal(data, 'data:text/html,1') + + /** @type {API.URI<`did:`>} */ + const key = URI.from('did:key:zAlice') + assert.equal(key, 'did:key:zAlice') +}) + { /** @type {[unknown, `${string}:`, {message:string}|string][]} */ const dataset = [ @@ -40,8 +53,8 @@ import { CID } from 'multiformats' for (const [input, protocol, expect] of dataset) { test(`URI.match(${JSON.stringify({ protocol, - })}).decode(${JSON.stringify(input)})}}`, () => { - assert.containSubset(URI.match({ protocol }).decode(input), expect) + })}).read(${JSON.stringify(input)})}}`, () => { + assert.containSubset(URI.match({ protocol }).read(input), expect) }) } } @@ -67,10 +80,13 @@ import { CID } from 'multiformats' ] for (const [input, protocol, expect] of dataset) { - test(`URI.optional(${JSON.stringify({ + test(`URI.match(${JSON.stringify({ protocol, - })}).decode(${JSON.stringify(input)})}}`, () => { - assert.containSubset(URI.optional({ protocol }).decode(input), expect) + })}).optional().decode(${JSON.stringify(input)})}}`, () => { + assert.containSubset( + URI.match({ protocol }).optional().read(input), + expect + ) }) } } @@ -128,28 +144,33 @@ import { CID } from 'multiformats' ] for (const [input, out1, out2, out3, out4, out5] of dataset) { - test(`Link.decode(${input})`, () => { - assert.containSubset(Link.decode(input), out1 || input) + test(`Link.read(${input})`, () => { + assert.containSubset(Link.read(input), out1 || input) }) - test(`Link.match({ code: 0x70 }).decode(${input})`, () => { + test('Link.link()', () => { + const schema = Link.link() + assert.containSubset(schema.read(input), out1 || input) + }) + + test(`Link.match({ code: 0x70 }).read(${input})`, () => { const link = Link.match({ code: 0x70 }) - assert.containSubset(link.decode(input), out2 || input) + assert.containSubset(link.read(input), out2 || input) }) - test(`Link.match({ algorithm: 0x12 }).decode(${input})`, () => { + test(`Link.match({ algorithm: 0x12 }).read(${input})`, () => { const link = Link.match({ algorithm: 0x12 }) - assert.containSubset(link.decode(input), out3 || input) + assert.containSubset(link.read(input), out3 || input) }) - test(`Link.match({ version: 1 }).decode(${input})`, () => { + test(`Link.match({ version: 1 }).read(${input})`, () => { const link = Link.match({ version: 1 }) - assert.containSubset(link.decode(input), out4 || input) + assert.containSubset(link.read(input), out4 || input) }) - test(`Link.optional().decode(${input})`, () => { + test(`Link.optional().read(${input})`, () => { const link = Link.optional() - assert.containSubset(link.decode(input), out5 || input) + assert.containSubset(link.read(input), out5 || input) }) } } @@ -157,18 +178,21 @@ import { CID } from 'multiformats' { /** @type {unknown[][]} */ const dataset = [ - [undefined, { message: 'Expected a string but got undefined instead' }], - [null, { message: 'Expected a string but got null instead' }], + [ + undefined, + { message: 'Expected value of type string instead got undefined' }, + ], + [null, { message: 'Expected value of type string instead got null' }], ['hello', 'hello'], [ new String('hello'), - { message: 'Expected a string but got object instead' }, + { message: 'Expected value of type string instead got object' }, ], ] for (const [input, out] of dataset) { - test(`Text.decode(${input})`, () => { - assert.containSubset(Text.decode(input), out) + test(`Text.read(${input})`, () => { + assert.containSubset(Text.read(input), out) }) } } @@ -179,12 +203,14 @@ import { CID } from 'multiformats' [ { pattern: /hello .*/ }, undefined, - { message: 'Expected a string but got undefined instead' }, + { + message: 'Expected value of type string instead got undefined', + }, ], [ { pattern: /hello .*/ }, null, - { message: 'Expected a string but got null instead' }, + { message: 'Expected value of type string instead got null' }, ], [ { pattern: /hello .*/ }, @@ -195,13 +221,13 @@ import { CID } from 'multiformats' [ { pattern: /hello .*/ }, new String('hello'), - { message: 'Expected a string but got object instead' }, + { message: 'Expected value of type string instead got object' }, ], ] for (const [options, input, out] of dataset) { - test(`Text.match({ pattern: ${options.pattern} }).decode(${input})`, () => { - assert.containSubset(Text.match(options).decode(input), out) + test(`Text.match({ pattern: ${options.pattern} }).read(${input})`, () => { + assert.containSubset(Text.match(options).read(input), out) }) } } @@ -210,19 +236,21 @@ import { CID } from 'multiformats' /** @type {[{pattern?:RegExp}, unknown, unknown][]} */ const dataset = [ [{}, undefined, undefined], - [{}, null, { message: 'Expected a string but got null instead' }], + [{}, null, { message: 'Expected value of type string instead got null' }], [{}, 'hello', 'hello'], [ {}, new String('hello'), - { message: 'Expected a string but got object instead' }, + { message: 'Expected value of type string instead got object' }, ], [{ pattern: /hello .*/ }, undefined, undefined], [ { pattern: /hello .*/ }, null, - { message: 'Expected a string but got null instead' }, + { + message: 'Expected value of type string instead got null', + }, ], [ { pattern: /hello .*/ }, @@ -233,13 +261,18 @@ import { CID } from 'multiformats' [ { pattern: /hello .*/ }, new String('hello'), - { message: 'Expected a string but got object instead' }, + { + message: 'Expected value of type string instead got object', + }, ], ] for (const [options, input, out] of dataset) { - test(`Text.match({ pattern: ${options.pattern} }).decode(${input})`, () => { - assert.containSubset(Text.optional(options).decode(input), out) + test(`Text.match({ pattern: ${options.pattern} }).read(${input})`, () => { + const schema = options.pattern + ? Text.match({ pattern: options.pattern }) + : Text.text() + assert.containSubset(schema.optional().read(input), out) }) } } @@ -247,19 +280,24 @@ import { CID } from 'multiformats' { /** @type {unknown[][]} */ const dataset = [ - [undefined, { message: 'Expected a string but got undefined instead' }], - [null, { message: 'Expected a string but got null instead' }], + [ + undefined, + { message: 'Expected value of type string instead got undefined' }, + ], + [null, { message: 'Expected value of type string instead got null' }], ['hello', { message: 'Expected a did: but got "hello" instead' }], [ new String('hello'), - { message: 'Expected a string but got object instead' }, + { + message: 'Expected value of type string instead got object', + }, ], ['did:echo:1', 'did:echo:1'], ] for (const [input, out] of dataset) { - test(`DID.decode(${input})`, () => { - assert.containSubset(DID.decode(input), out) + test(`DID.read(${input})`, () => { + assert.containSubset(DID.read(input), out) }) } } @@ -270,12 +308,12 @@ import { CID } from 'multiformats' [ { method: 'echo' }, undefined, - { message: 'Expected a string but got undefined instead' }, + { message: 'Expected value of type string instead got undefined' }, ], [ { method: 'echo' }, null, - { message: 'Expected a string but got null instead' }, + { message: 'Expected value of type string instead got null' }, ], [ { method: 'echo' }, @@ -291,13 +329,13 @@ import { CID } from 'multiformats' [ { method: 'echo' }, new String('hello'), - { message: 'Expected a string but got object instead' }, + { message: 'Expected value of type string instead got object' }, ], ] for (const [options, input, out] of dataset) { test(`DID.match({ method: ${options.method} }).decode(${input})`, () => { - assert.containSubset(DID.match(options).decode(input), out) + assert.containSubset(DID.match(options).read(input), out) }) } } @@ -306,19 +344,19 @@ import { CID } from 'multiformats' /** @type {[{method?:string}, unknown, unknown][]} */ const dataset = [ [{}, undefined, undefined], - [{}, null, { message: 'Expected a string but got null instead' }], + [{}, null, { message: 'Expected value of type string instead got null' }], [{}, 'did:echo:bar', 'did:echo:bar'], [ {}, new String('hello'), - { message: 'Expected a string but got object instead' }, + { message: 'Expected value of type string instead got object' }, ], [{ method: 'echo' }, undefined, undefined], [ { method: 'echo' }, null, - { message: 'Expected a string but got null instead' }, + { message: 'Expected value of type string instead got null' }, ], [ { method: 'echo' }, @@ -333,13 +371,14 @@ import { CID } from 'multiformats' [ { method: 'echo' }, new String('hello'), - { message: 'Expected a string but got object instead' }, + { message: 'Expected value of type string instead got object' }, ], ] for (const [options, input, out] of dataset) { - test(`DID.optional({ method: "${options.method}" }).decode(${input})`, () => { - assert.containSubset(DID.optional(options).decode(input), out) + test(`DID.match({ method: "${options.method}" }).optional().decode(${input})`, () => { + const schema = options.method ? DID.match(options) : DID.did() + assert.containSubset(schema.optional().read(input), out) }) } } diff --git a/packages/validator/test/lib.spec.js b/packages/validator/test/lib.spec.js index 9dca5aaa..0ea54ae0 100644 --- a/packages/validator/test/lib.spec.js +++ b/packages/validator/test/lib.spec.js @@ -14,7 +14,7 @@ const storeAdd = capability({ can: 'store/add', with: URI.match({ protocol: 'did:' }), nb: { - link: Link.optional(), + link: Link.match().optional(), }, derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { @@ -111,7 +111,9 @@ test('expired invocation', async () => { name: 'Expired', message: `Expired on ${new Date(expiration * 1000)}`, expiredAt: expiration, + stack: result.error ? result.cause.stack : undefined, }, + stack: result.error ? result.stack : undefined, }) ) }) @@ -177,6 +179,8 @@ test('invalid signature', async () => { cause: { name: 'InvalidSignature', message: `Signature is invalid`, + issuer: invocation.issuer, + audience: invocation.audience, }, }) }) @@ -835,6 +839,17 @@ test('delegate with my:*', async () => { }, ], }) + + assert.containSubset( + await access(invocation, { + principal: ed25519.Verifier, + canIssue: (claim, issuer) => claim.with === issuer, + capability: storeAdd, + }), + { + error: true, + } + ) }) test('delegate with my:did', async () => { diff --git a/packages/validator/test/schema.spec.js b/packages/validator/test/schema.spec.js new file mode 100644 index 00000000..669ab01e --- /dev/null +++ b/packages/validator/test/schema.spec.js @@ -0,0 +1,647 @@ +import { test, assert } from './test.js' +import * as Schema from '../src/schema.js' +import fixtures from './schema/fixtures.js' + +for (const { input, schema, expect, inputLabel, skip, only } of fixtures()) { + const unit = skip ? test.skip : only ? test.only : test + unit(`${schema}.read(${inputLabel})`, () => { + const result = schema.read(input) + + if (expect.error) { + assert.match(String(result), expect.error) + } else { + assert.deepEqual( + result, + // if expcted value is set to undefined use input + expect.value === undefined ? input : expect.value + ) + } + }) + + unit(`${schema}.from(${inputLabel})`, () => { + if (expect.error) { + assert.throws(() => schema.from(input), expect.error) + } else { + assert.deepEqual( + schema.from(input), + // if expcted value is set to undefined use input + expect.value === undefined ? input : expect.value + ) + } + }) + + unit(`${schema}.is(${inputLabel})`, () => { + assert.equal(schema.is(input), !expect.error) + }) +} + +test('string startsWith & endsWith', () => { + const impossible = Schema.string().startsWith('hello').startsWith('hi') + /** @type {Schema.StringSchema<`hello${string}` & `hi${string}`>} */ + const typeofImpossible = impossible + + assert.equal( + impossible.toString(), + 'string().refine(startsWith("hello")).refine(startsWith("hi"))' + ) + + assert.deepInclude(impossible.read('hello world'), { + error: true, + message: `Expect string to start with "hi" instead got "hello world"`, + }) + + assert.deepInclude(impossible.read('hello world'), { + error: true, + message: `Expect string to start with "hi" instead got "hello world"`, + }) + + const hello = Schema.string().startsWith('hello').startsWith('hello ') + /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}`>} */ + const typeofHello = hello + + assert.equal(hello.read('hello world'), 'hello world') +}) + +test('string startsWtih', () => { + /** @type {Schema.StringSchema<`hello${string}`>} */ + // @ts-expect-error - catches invalid type + const bad = Schema.string() + + /** @type {Schema.StringSchema<`hello${string}`>} */ + const hello = Schema.string().startsWith('hello') + + assert.equal(hello.read('hello world!'), 'hello world!') + assert.deepInclude(hello.read('hi world'), { + error: true, + name: 'SchemaError', + message: `Expect string to start with "hello" instead got "hi world"`, + }) +}) + +test('string endsWith', () => { + /** @type {Schema.StringSchema<`${string} world`>} */ + // @ts-expect-error - catches invalid type + const bad = Schema.string() + + /** @type {Schema.StringSchema<`${string} world`>} */ + const greet = Schema.string().endsWith(' world') + + assert.equal(greet.read('hello world'), 'hello world') + assert.equal(greet.read('hi world'), 'hi world') + assert.deepInclude(greet.read('hello world!'), { + error: true, + name: 'SchemaError', + message: `Expect string to end with " world" instead got "hello world!"`, + }) +}) + +test('string startsWith/endsWith', () => { + /** @type {Schema.StringSchema<`hello${string}!`>} */ + // @ts-expect-error - catches invalid type + const bad = Schema.string() + + /** @type {Schema.StringSchema<`hello${string}` & `${string}!`>} */ + const hello1 = Schema.string().startsWith('hello').endsWith('!') + /** @type {Schema.StringSchema<`hello${string}` & `${string}!`>} */ + const hello2 = Schema.string().endsWith('!').startsWith('hello') + + assert.equal( + hello1.toString(), + `string().refine(startsWith("hello")).refine(endsWith("!"))` + ) + assert.equal( + hello2.toString(), + `string().refine(endsWith("!")).refine(startsWith("hello"))` + ) + + assert.equal(hello1.read('hello world!'), 'hello world!') + assert.equal(hello2.read('hello world!'), 'hello world!') + assert.deepInclude(hello1.read('hello world'), { + error: true, + name: 'SchemaError', + message: `Expect string to end with "!" instead got "hello world"`, + }) + assert.deepInclude(hello2.read('hello world'), { + error: true, + name: 'SchemaError', + message: `Expect string to end with "!" instead got "hello world"`, + }) + assert.deepInclude(hello1.read('hi world!'), { + error: true, + name: 'SchemaError', + message: `Expect string to start with "hello" instead got "hi world!"`, + }) + assert.deepInclude(hello2.read('hi world!'), { + error: true, + name: 'SchemaError', + message: `Expect string to start with "hello" instead got "hi world!"`, + }) +}) + +test('string startsWith & endsWith', () => { + const impossible = Schema.string().startsWith('hello').startsWith('hi') + /** @type {Schema.StringSchema<`hello${string}` & `hi${string}`>} */ + const typeofImpossible = impossible + + assert.deepInclude(impossible.read('hello world'), { + error: true, + message: `Expect string to start with "hi" instead got "hello world"`, + }) + + assert.deepInclude(impossible.read('hello world'), { + error: true, + message: `Expect string to start with "hi" instead got "hello world"`, + }) + + const hello = Schema.string().startsWith('hello').startsWith('hello ') + /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}`>} */ + const typeofHello = hello + + assert.equal(hello.read('hello world'), 'hello world') +}) + +test('string().refine', () => { + const impossible = Schema.string() + .refine(Schema.startsWith('hello')) + .refine(Schema.startsWith('hi')) + + /** @type {Schema.StringSchema<`hello${string}` & `hi${string}`>} */ + const typeofImpossible = impossible + + assert.deepInclude(impossible.read('hello world'), { + error: true, + message: `Expect string to start with "hi" instead got "hello world"`, + }) + + assert.deepInclude(impossible.read('hello world'), { + error: true, + message: `Expect string to start with "hi" instead got "hello world"`, + }) + + const hello = Schema.string() + .refine(Schema.startsWith('hello')) + .refine(Schema.startsWith('hello ')) + + /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}`>} */ + const typeofHello = hello + + assert.equal(hello.read('hello world'), 'hello world') + + const greet = hello.refine({ + /** + * @template {string} In + * @param {In} hello + */ + read(hello) { + if (hello.length === 11) { + return /** @type {In & {length: 11}} */ (hello) + } else { + return Schema.error(`Expected string with 11 chars`) + } + }, + }) + /** @type {Schema.StringSchema<`hello${string}` & `hello ${string}` & { length: 11 }>} */ + const typeofGreet = greet + + assert.equal( + greet.read('hello world'), + /** @type {unknown} */ ('hello world') + ) + assert.equal( + greet.read('hello Julia'), + /** @type {unknown} */ ('hello Julia') + ) + + assert.deepInclude(greet.read('hello Jack'), { + error: true, + message: 'Expected string with 11 chars', + }) +}) + +test('never().default()', () => { + assert.throws( + () => + Schema.never() + // @ts-expect-error - no value satisfies default + .default('hello'), + /Expected value of type never instead got "hello"/ + ) +}) + +test('literal("foo").default("bar") throws', () => { + assert.throws( + () => + Schema.literal('foo') + // @ts-expect-error - no value satisfies default + .default('bar'), + /Expected literal "foo" instead got "bar"/ + ) +}) + +test('default on litral has default', () => { + const schema = Schema.literal('foo').default() + assert.equal(schema.read(undefined), 'foo') +}) + +test('literal has value field', () => { + assert.equal(Schema.literal('foo').value, 'foo') +}) + +test('.default().optional() is noop', () => { + const schema = Schema.string().default('hello') + assert.equal(schema.optional(), schema) +}) + +test('optional().optional() is noop', () => { + const schema = Schema.string().optional() + assert.equal(schema.optional(), schema) +}) + +test('.element of array', () => { + const schema = Schema.string() + assert.equal(Schema.array(schema).element, schema) +}) + +test('struct', () => { + const Point = Schema.struct({ + type: 'Point', + x: Schema.integer(), + y: Schema.integer(), + }) + + const p1 = Point.read({ + x: 1, + y: 2, + }) + assert.equal(p1.error, true) + + assert.match(String(p1), /field "type".*expect.*"Point".*got undefined/is) + + const p2 = Point.read({ + type: 'Point', + x: 1, + y: 1, + }) + assert.deepEqual(p2, { + type: 'Point', + x: Schema.integer().from(1), + y: Schema.integer().from(1), + }) + + const p3 = Point.read({ + type: 'Point', + x: 1, + y: 1.1, + }) + + assert.equal(p3.error, true) + assert.match(String(p3), /field "y".*expect.*integer.*got 1.1/is) + + assert.match( + String(Point.read(['h', 'e', 'l', null, 'l', 'o'])), + /Expected value of type object instead got array/ + ) +}) + +test('struct with defaults', () => { + const Point = Schema.struct({ + x: Schema.number().default(0), + y: Schema.number().default(0), + }) + + assert.deepEqual(Point.read({}), { x: 0, y: 0 }) + assert.deepEqual(Point.read({ x: 2 }), { x: 2, y: 0 }) + assert.deepEqual(Point.read({ x: 2, y: 7 }), { x: 2, y: 7 }) + assert.deepEqual(Point.read({ y: 7 }), { x: 0, y: 7 }) +}) + +test('struct with literals', () => { + const Point = Schema.struct({ + z: 0, + x: Schema.number(), + y: Schema.number(), + }) + + assert.deepEqual(Point.read({ x: 0, y: 0, z: 0 }), { x: 0, y: 0, z: 0 }) + assert.match( + String(Point.read({ x: 1, y: 1, z: 1 })), + /"z".*expect.* 0 .* got 1/is + ) +}) + +test('bad struct def', () => { + assert.throws( + () => + Schema.struct({ + name: Schema.string(), + // @ts-expect-error + toString: () => 'hello', + }), + /Invalid struct field "toString", expected schema or literal, instead got function/ + ) +}) + +test('struct with null literal', () => { + const schema = Schema.struct({ a: null, b: true, c: Schema.string() }) + + assert.deepEqual(schema.read({ a: null, b: true, c: 'hi' }), { + a: null, + b: true, + c: 'hi', + }) + + assert.match( + String(schema.read({ a: null, b: false, c: '' })), + /"b".*expect.* true .* got false/is + ) + + assert.match( + String(schema.read({ b: true, c: '' })), + /"a".*expect.* null .* got undefined/is + ) +}) + +test('lessThan', () => { + const schema = Schema.number().lessThan(100) + + assert.deepEqual(schema.read(10), 10) + assert.match(String(schema.read(127)), /127 < 100/) + assert.match(String(schema.read(Infinity)), /Infinity < 100/) + assert.match(String(schema.read(NaN)), /NaN < 100/) +}) + +test('greaterThan', () => { + const schema = Schema.number().greaterThan(100) + + assert.deepEqual(schema.read(127), 127) + assert.match(String(schema.read(12)), /12 > 100/) + assert.equal(schema.read(Infinity), Infinity) + assert.match(String(schema.read(NaN)), /NaN > 100/) +}) + +test('number().greaterThan().lessThan()', () => { + const schema = Schema.number().greaterThan(3).lessThan(117) + + assert.equal(schema.read(4), 4) + assert.equal(schema.read(116), 116) + assert.match(String(schema.read(117)), /117 < 117/) + assert.match(String(schema.read(3)), /3 > 3/) + assert.match(String(schema.read(127)), /127 < 117/) + assert.match(String(schema.read(0)), /0 > 3/) + assert.match(String(schema.read(Infinity)), /Infinity < 117/) + assert.match(String(schema.read(NaN)), /NaN > 3/) +}) + +test('enum', () => { + const schema = Schema.enum(['Red', 'Green', 'Blue']) + assert.equal(schema.toString(), 'Red|Green|Blue') + assert.equal(schema.read('Red'), 'Red') + assert.equal(schema.read('Blue'), 'Blue') + assert.equal(schema.read('Green'), 'Green') + + assert.match( + String(schema.read('red')), + /expect.* Red\|Green\|Blue .* got "red"/is + ) + assert.match(String(schema.read(5)), /expect.* Red\|Green\|Blue .* got 5/is) +}) + +test('tuple', () => { + const schema = Schema.tuple([Schema.string(), Schema.integer()]) + assert.match( + String(schema.read([, undefined])), + /invalid element at 0.*expect.*string.*got undefined/is + ) + assert.match( + String(schema.read([0, 'hello'])), + /invalid element at 0.*expect.*string.*got 0/is + ) + assert.match( + String(schema.read(['0', '1'])), + /invalid element at 1.*expect.*number.*got "1"/is + ) + assert.match( + String(schema.read(['0', Infinity])), + /invalid element at 1.*expect.*integer.*got Infinity/is + ) + assert.match( + String(schema.read(['0', NaN])), + /invalid element at 1.*expect.*integer.*got NaN/is + ) + assert.match( + String(schema.read(['0', 0.2])), + /invalid element at 1.*expect.*integer.*got 0.2/is + ) + + assert.deepEqual(schema.read(['x', 0]), ['x', 0]) +}) + +test('extend API', () => { + { + /** + * @template {string} M + * @implements {Schema.Schema<`did:${M}:${string}`, string>} + * @extends {Schema.API<`did:${M}:${string}`, string, M>} + */ + class DIDString extends Schema.API { + /** + * @param {string} source + * @param {M} method + */ + readWith(source, method) { + const string = String(source) + if (string.startsWith(`did:${method}:`)) { + return /** @type {`did:${M}:${string}`} */ (method) + } else { + return Schema.error( + `Expected did:${method} URI instead got ${string}` + ) + } + } + } + + const schema = new DIDString('key') + assert.equal(schema.toString(), 'new DIDString()') + assert.match( + String( + // @ts-expect-error + schema.read(54) + ), + /Expected did:key URI/ + ) + + assert.match( + String(schema.read('did:echo:foo')), + /Expected did:key URI instead got did:echo:foo/ + ) + + const didKey = Schema.string().refine(new DIDString('key')) + assert.match(String(didKey.read(54)), /Expect.* string instead got 54/is) + } +}) + +test('errors', () => { + const error = Schema.error('boom!') + const json = JSON.parse(JSON.stringify(error)) + assert.deepInclude(json, { + name: 'SchemaError', + message: 'boom!', + error: true, + stack: error.stack, + }) + + assert.equal(error instanceof Error, true) +}) + +test('refine', () => { + /** + * @template T + */ + class NonEmpty extends Schema.API { + /** + * @param {T[]} array + */ + read(array) { + return array.length > 0 + ? array + : Schema.error('Array expected to have elements') + } + } + + const schema = Schema.array(Schema.string()).refine(new NonEmpty()) + + assert.equal(schema.toString(), 'array(string()).refine(new NonEmpty())') + assert.match(String(schema.read([])), /Array expected to have elements/) + assert.deepEqual(schema.read(['hello', 'world']), ['hello', 'world']) + assert.match(String(schema.read(null)), /expect.* array .*got null/is) +}) + +test('brand', () => { + const digit = Schema.integer() + .refine({ + read(n) { + return n >= 0 && n <= 9 + ? n + : Schema.error(`Expected digit but got ${n}`) + }, + }) + .brand('digit') + + assert.match(String(digit.read(10)), /Expected digit but got 10/) + assert.match(String(digit.read(2.7)), /Expected value of type integer/) + assert.equal(digit.from(2), 2) + + /** @param {Schema.Infer} n */ + const fromDigit = n => n + + const three = digit.from(3) + + // @ts-expect-error - 3 is not known to be digit + fromDigit(3) + fromDigit(three) + + /** @type {Schema.Integer} */ + const is_int = three + /** @type {Schema.Branded} */ + const is_digit = three + /** @type {Schema.Branded} */ + const is_int_digit = three +}) + +test('optional.default removes undefined from type', () => { + const schema1 = Schema.string().optional() + + /** @type {Schema.Schema} */ + // @ts-expect-error - Schema is not assignable + const castError = schema1 + + const schema2 = schema1.default('') + /** @type {Schema.Schema} */ + const castOk = schema2 + + assert.equal(schema1.read(undefined), undefined) + assert.equal(schema2.read(undefined), '') +}) + +test('.default("one").default("two")', () => { + const schema = Schema.string().default('one').default('two') + + assert.equal(schema.value, 'two') + assert.deepEqual(schema.read(undefined), 'two') + assert.deepEqual(schema.read('three'), 'three') +}) + +test('default throws on invalid default', () => { + assert.throws( + () => + Schema.string().default( + // @ts-expect-error - number is not assignable to string + 101 + ), + /expect.* string .* got 101/is + ) +}) + +test('unknown with default', () => { + assert.throws( + () => Schema.unknown().default(undefined), + /undefined is not a vaild default/ + ) +}) + +test('default swaps undefined even if decodes to undefined', () => { + /** @type {Schema.Schema} */ + const schema = Schema.unknown().refine({ + read(value) { + return value === null ? undefined : value + }, + }) + + assert.equal(schema.default('X').read(null), 'X') +}) + +test('record defaults', () => { + const Point = Schema.struct({ + x: Schema.integer().default(1), + y: Schema.integer().optional(), + }) + + const Point3D = Point.extend({ + z: Schema.integer(), + }) + + assert.match( + String(Point.read(undefined)), + /expect.* object .* got undefined/is + ) + assert.deepEqual(Point.create(), { + x: 1, + }) + assert.deepEqual(Point.create(undefined), { + x: 1, + }) + + assert.deepEqual(Point.read({}), { + x: 1, + }) + + assert.deepEqual(Point.read({ y: 2 }), { + x: 1, + y: 2, + }) + + assert.deepEqual(Point.read({ x: 2, y: 2 }), { + x: 2, + y: 2, + }) + + const Line = Schema.struct({ + start: Point.default({ x: 0 }), + end: Point.default({ x: 1, y: 3 }), + }) + + assert.deepEqual(Line.create(), { + start: { x: 0 }, + end: { x: 1, y: 3 }, + }) +}) diff --git a/packages/validator/test/schema/fixtures.js b/packages/validator/test/schema/fixtures.js new file mode 100644 index 00000000..9f3b911c --- /dev/null +++ b/packages/validator/test/schema/fixtures.js @@ -0,0 +1,878 @@ +import { pass, fail, display } from './util.js' +import * as Schema from '../../src/schema.js' + +/** + * @typedef {import('./util.js').Expect} Expect + * + * + * @typedef {{ + * any?: Expect + * nullable?: Expect + * optional?: Expect + * default?: (input:unknown) => Expect + * }} ExpectGroup + * @typedef {{ + * skip?: boolean + * only?: boolean + * in:any + * got: unknown + * any: Expect + * unknown: ExpectGroup, + * never: ExpectGroup + * string: ExpectGroup, + * boolean: ExpectGroup + * strartsWithHello: ExpectGroup + * endsWithWorld: ExpectGroup + * startsWithHelloEndsWithWorld: ExpectGroup + * number: ExpectGroup + * ['n > 100']?: ExpectGroup, + * ['n < 100']?: ExpectGroup, + * ['3 < n < 17']?: ExpectGroup, + * integer: ExpectGroup, + * float: ExpectGroup, + * literal: { + * any?: ExpectGroup, + * [key:string]: ExpectGroup|undefined + * }, + * array: { + * any?: Expect + * string?: ExpectGroup + * number?: ExpectGroup + * unknown?: ExpectGroup + * never?: ExpectGroup + * } + * tuple: { + * any?: Expect + * strNstr?: ExpectGroup + * strNfloat?: ExpectGroup + * } + * struct: ExpectGroup + * enum: ExpectGroup + * stringOrNumber?: ExpectGroup + * point2d?: ExpectGroup + * ['Red|Green|Blue']?: ExpectGroup + * xyz?: ExpectGroup + * }} Fixture + * + * @param {Partial} source + * @returns {Fixture} + */ + +export const fixture = ({ in: input, got = input, array, ...expect }) => ({ + ...expect, + in: input, + got, + any: fail({ got }), + unknown: { any: fail({ expect: 'unknown', got }), ...expect.unknown }, + never: { any: fail({ expect: 'never', got }), ...expect.never }, + string: { any: fail({ expect: 'string', got }), ...expect.string }, + boolean: { any: fail({ expect: 'boolean', got }), ...expect.boolean }, + strartsWithHello: { + any: fail({ expect: 'string', got }), + ...expect.strartsWithHello, + }, + endsWithWorld: { + any: fail({ expect: 'string', got }), + ...expect.endsWithWorld, + }, + startsWithHelloEndsWithWorld: { + any: fail({ expect: 'string', got }), + ...expect.startsWithHelloEndsWithWorld, + }, + number: { any: fail({ expect: 'number', got }), ...expect.number }, + integer: { any: fail({ expect: 'number', got }), ...expect.integer }, + float: { any: fail({ expect: 'number', got }), ...expect.float }, + literal: { + any: { any: fail({ expect: 'literal', got }) }, + ...Object.fromEntries( + Object.entries(expect.literal || {}).map(([key, value]) => { + return [ + key, + { + any: fail({ expect: 'literal', got }), + ...value, + }, + ] + }) + ), + }, + array: { + any: array?.any || fail({ expect: 'array', got }), + string: { + ...array?.string, + }, + number: { + ...array?.number, + }, + never: { + ...array?.never, + }, + unknown: { + ...array?.unknown, + }, + }, + tuple: { + any: fail({ expect: 'array', got }), + ...expect.tuple, + }, + stringOrNumber: { + any: + expect.stringOrNumber?.any || fail({ expect: 'string .* number', got }), + ...expect.stringOrNumber, + }, + struct: { + any: fail({ expect: 'object', got }), + ...expect.struct, + }, + enum: { + any: fail({ got }), + ...expect.enum, + }, +}) + +/** @type {Partial[]} */ +export const source = [ + { + in: 'hello', + got: '"hello"', + string: { any: pass() }, + unknown: { any: pass() }, + literal: { hello: { any: pass() } }, + stringOrNumber: { any: pass() }, + strartsWithHello: { any: fail.as(`expect .* "Hello" .* got "hello"`) }, + endsWithWorld: { any: fail.as(`expect .* "world" .* got "hello"`) }, + startsWithHelloEndsWithWorld: { + any: fail.as(`expect .* "Hello" .* got "hello"`), + }, + }, + { + in: 'Green', + got: '"Green"', + string: { any: pass() }, + unknown: { any: pass() }, + stringOrNumber: { any: pass() }, + strartsWithHello: { any: fail.as(`expect .* "Hello" .* got "Green"`) }, + endsWithWorld: { any: fail.as(`expect .* "world" .* got "Green"`) }, + startsWithHelloEndsWithWorld: { + any: fail.as(`expect .* "Hello" .* got "Green"`), + }, + ['Red|Green|Blue']: { + any: pass(), + }, + }, + { + in: 'Hello world', + got: '"Hello world"', + string: { any: pass() }, + unknown: { any: pass() }, + stringOrNumber: { any: pass() }, + strartsWithHello: { any: pass() }, + endsWithWorld: { any: pass() }, + startsWithHelloEndsWithWorld: { + any: pass(), + }, + }, + { + in: new String('hello'), + got: 'object', + unknown: { any: pass() }, + point2d: { + any: fail.at('"name"', { expect: '"Point2d"', got: 'undefined' }), + }, + xyz: { + any: fail.at('"x"', { expect: 'number', got: 'undefined' }), + }, + }, + { + in: null, + got: 'null', + string: { + nullable: pass(null), + }, + boolean: { + nullable: pass(null), + }, + number: { + nullable: pass(), + }, + stringOrNumber: { + nullable: pass(), + }, + integer: { + nullable: pass(), + }, + float: { + nullable: pass(), + }, + unknown: { any: pass() }, + never: { + nullable: pass(), + }, + literal: { + hello: { + nullable: pass(), + }, + }, + }, + { + in: undefined, + string: { + optional: pass(), + default: value => pass(value), + }, + number: { + optional: pass(), + default: value => pass(value), + }, + stringOrNumber: { + optional: pass(), + default: value => pass(value), + }, + integer: { + optional: pass(), + default: value => pass(value), + }, + boolean: { + optional: pass(), + default: value => pass(value), + }, + float: { + optional: pass(), + default: value => pass(value), + }, + unknown: { any: pass(), default: value => pass(value) }, + never: { + optional: pass(), + }, + literal: { + hello: { + optional: pass(), + default: pass, + }, + }, + }, + { + in: Infinity, + got: 'Infinity', + number: { any: pass() }, + stringOrNumber: { any: pass() }, + integer: { any: fail({ expect: 'integer', got: 'Infinity' }) }, + float: { any: fail({ expect: 'float', got: 'Infinity' }) }, + ['3 < n < 17']: { any: fail.as('Infinity < 17') }, + ['n < 100']: { any: fail.as('Infinity < 100') }, + unknown: { any: pass() }, + }, + { + in: NaN, + got: 'NaN', + number: { any: pass() }, + ['3 < n < 17']: { any: fail.as('NaN > 3') }, + ['n < 100']: { any: fail.as('NaN < 100') }, + ['n > 100']: { any: fail.as('NaN > 100') }, + stringOrNumber: { any: pass() }, + integer: { any: fail({ expect: 'integer', got: 'NaN' }) }, + float: { any: fail({ expect: 'float', got: 'NaN' }) }, + unknown: { any: pass() }, + }, + { + in: 101, + number: { any: pass() }, + ['3 < n < 17']: { any: fail.as('101 < 17') }, + ['n < 100']: { any: fail.as('101 < 100') }, + ['n > 100']: { any: pass() }, + stringOrNumber: { any: pass() }, + integer: { any: pass() }, + float: { any: pass() }, + unknown: { any: pass() }, + }, + { + in: 9.8, + number: { any: pass() }, + ['3 < n < 17']: { any: pass() }, + ['n < 100']: { any: pass() }, + ['n > 100']: { any: fail.as('9.8 > 100') }, + stringOrNumber: { any: pass() }, + integer: { any: fail({ expect: 'integer', got: '9.8' }) }, + float: { any: pass() }, + unknown: { any: pass() }, + }, + { + in: BigInt(1000), + got: '1000n', + unknown: { any: pass() }, + }, + { + in: true, + unknown: { any: pass() }, + boolean: { any: pass() }, + }, + { + in: false, + unknown: { any: pass() }, + boolean: { any: pass() }, + }, + { + in: Symbol.for('bye'), + got: 'Symbol\\(bye\\)', + unknown: { any: pass() }, + }, + { + in: () => 'hello', + got: 'function', + unknown: { any: pass() }, + }, + { + in: {}, + got: 'object', + unknown: { any: pass() }, + point2d: { + any: fail.at('"name"', { expect: '"Point2d"', got: 'undefined' }), + }, + xyz: { + any: fail.at('"x"', { expect: 'number', got: 'undefined' }), + }, + }, + { + in: [], + got: 'array', + array: { any: pass() }, + unknown: { any: pass() }, + tuple: { + any: fail.as('Array must contain exactly 2 elements'), + }, + }, + { + in: [, undefined], + got: 'array', + array: { + any: fail.at(0, { got: undefined }), + string: { + optional: pass(), + nullable: fail.at(0, { got: undefined, expect: 'null' }), + default: v => pass([v, v]), + }, + }, + unknown: { any: pass() }, + tuple: { + strNstr: { + any: fail.at(0, { got: 'undefined', expect: 'string' }), + }, + strNfloat: { + any: fail.at(0, { got: 'undefined', expect: 'string' }), + }, + }, + }, + { + in: ['hello', 'world', 1, '?'], + got: 'array', + array: { + any: fail.at(0, { got: '"hello"' }), + string: { + any: fail.at(2, { got: 1, expect: 'string' }), + nullable: fail.at(2, { + expect: ['string', 'null'], + got: 1, + }), + }, + }, + unknown: { any: pass() }, + tuple: { + any: fail.as('Array must contain exactly 2 elements'), + }, + }, + { + in: ['hello', , 'world'], + got: 'array', + array: { + any: fail.at(0, { got: '"hello"' }), + string: { + any: fail.at(1, { expect: 'string', got: undefined }), + nullable: fail.at(1, { + expect: ['string', 'null'], + got: undefined, + }), + default: v => pass(['hello', v, 'world']), + optional: pass(), + }, + }, + unknown: { any: pass() }, + tuple: { + any: fail.as('Array must contain exactly 2 elements'), + }, + }, + { + in: ['hello', 'world'], + got: 'array', + array: { + any: fail.at(0, { got: '"hello"' }), + string: { any: pass() }, + }, + unknown: { any: pass() }, + tuple: { + strNfloat: { + any: fail.at(1, { expect: 'number', got: '"world"' }), + }, + strNstr: { + any: pass(), + }, + }, + }, + { + in: ['h', 'e', 'l', null, 'l', 'o'], + got: 'array', + array: { + any: fail.at(0, { got: '"h"' }), + string: { + any: fail.at(3, { expect: 'string', got: 'null' }), + nullable: pass(), + }, + }, + unknown: { any: pass() }, + tuple: { + any: fail.as('Array must contain exactly 2 elements'), + }, + }, + { + in: ['hello', new String('world')], + got: 'array', + array: { + any: fail.at(0, { got: '"hello"' }), + string: { + any: fail.at(1, { expect: 'string', got: 'object' }), + }, + }, + unknown: { any: pass() }, + tuple: { + strNstr: { + any: fail.at(1, { got: 'object' }), + }, + strNfloat: { + any: fail.at(1, { got: 'object', expect: 'number' }), + }, + }, + }, + { + in: ['1', 2.1], + got: 'array', + array: { + any: fail.at(0, { got: 1 }), + string: { + any: fail.at(1, { expect: 'string', got: 2.1 }), + }, + }, + unknown: { any: pass() }, + tuple: { + strNstr: { + any: fail.at(1, { got: 2.1 }), + }, + strNfloat: { + any: pass(), + }, + }, + }, + { + in: ['true', 'false', true], + got: 'array', + array: { + any: fail.at(0, { got: '"true"' }), + string: { + any: fail.at(2, { expect: 'string', got: true }), + }, + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + any: fail.as('Array must contain exactly 2 elements'), + }, + }, + { + in: ['hello', Symbol.for('world')], + got: 'array', + array: { + any: fail.at(0, { got: '"hello"' }), + string: { + any: fail.at(1, { expect: 'string', got: 'Symbol\\(world\\)' }), + }, + }, + unknown: { any: pass() }, + tuple: { + strNstr: { + any: fail.at(1, { got: 'Symbol\\(world\\)' }), + }, + strNfloat: { + any: fail.at(1, { got: 'Symbol\\(world\\)' }), + }, + }, + }, + { + in: ['hello', () => 'world'], + got: 'array', + array: { + any: fail.at(0, { got: '"hello"' }), + string: { + any: fail.at(1, { got: 'function' }), + }, + }, + unknown: { any: pass() }, + tuple: { + strNstr: { + any: fail.at(1, { got: 'function' }), + }, + strNfloat: { + any: fail.at(1, { got: 'function' }), + }, + }, + }, + { + in: { name: 'Point2d', x: 0, y: 0 }, + got: 'object', + point2d: { + any: pass(), + }, + unknown: { + any: pass(), + }, + xyz: { + any: fail.at('"z"', { expect: 'number', got: 'undefined' }), + }, + }, + { + in: { name: 'Point2d', x: 0, z: 0 }, + got: 'object', + point2d: { + any: fail.at('"y"', { expect: 'number', got: 'undefined' }), + }, + unknown: { + any: pass(), + }, + xyz: { + any: fail.at('"y"', { expect: 'number', got: 'undefined' }), + }, + }, + { + in: { name: 'Point2d', x: 0, y: 0.1 }, + got: 'object', + point2d: { + any: fail.at('"y"', { expect: 'integer', got: '0.1' }), + }, + xyz: { + any: fail.at('"y"', { expect: 'integer', got: '0.1' }), + }, + unknown: { + any: pass(), + }, + }, +] + +/** + * + * @param {Fixture} fixture + * @returns {{schema: Schema.Schema, expect: Expect, skip?: boolean, only?:boolean}[]} + */ +export const scenarios = fixture => [ + { + schema: Schema.never(), + expect: fixture.never.any || fixture.any, + }, + { + schema: Schema.never().nullable(), + expect: fixture.never.nullable || fixture.never.any || fixture.any, + }, + { + schema: Schema.never().optional(), + expect: fixture.never.optional || fixture.never.any || fixture.any, + }, + { + schema: Schema.unknown(), + expect: fixture.unknown.any || fixture.any, + }, + { + schema: Schema.unknown().optional(), + expect: fixture.unknown.any || fixture.any, + }, + { + schema: Schema.unknown().nullable(), + expect: fixture.unknown.any || fixture.any, + }, + { + schema: Schema.unknown().default('DEFAULT'), + expect: + (fixture.unknown.default && fixture.unknown.default('DEFAULT')) || + fixture.unknown.any || + fixture.any, + }, + { + schema: Schema.string(), + expect: fixture.string.any || fixture.any, + }, + { + schema: Schema.string().optional(), + expect: fixture.string.optional || fixture.string.any || fixture.any, + }, + { + schema: Schema.string().nullable(), + expect: fixture.string.nullable || fixture.string.any || fixture.any, + }, + { + schema: Schema.string().default('DEFAULT'), + expect: + (fixture.string.default && fixture.string.default('DEFAULT')) || + fixture.string.any || + fixture.any, + }, + { + schema: Schema.boolean(), + expect: fixture.boolean.any || fixture.any, + }, + { + schema: Schema.boolean().optional(), + expect: fixture.boolean.optional || fixture.boolean.any || fixture.any, + }, + { + schema: Schema.boolean().nullable(), + expect: fixture.boolean.nullable || fixture.boolean.any || fixture.any, + }, + { + schema: Schema.boolean().default(false), + expect: + (fixture.boolean.default && fixture.boolean.default(false)) || + fixture.boolean.any || + fixture.any, + }, + { + schema: Schema.number(), + expect: fixture.number.any || fixture.any, + }, + { + schema: Schema.number().optional(), + expect: fixture.number.optional || fixture.number.any || fixture.any, + }, + { + schema: Schema.number().nullable(), + expect: fixture.number.nullable || fixture.number.any || fixture.any, + }, + { + schema: Schema.number().default(17), + expect: + (fixture.number.default && fixture.number.default(17)) || + fixture.number.any || + fixture.any, + }, + { + schema: Schema.integer(), + expect: fixture.integer.any || fixture.any, + }, + { + schema: Schema.integer().optional(), + expect: fixture.integer.optional || fixture.integer.any || fixture.any, + }, + { + schema: Schema.integer().nullable(), + expect: fixture.integer.nullable || fixture.integer.any || fixture.any, + }, + { + schema: Schema.integer().default(17), + expect: + (fixture.integer.default && fixture.integer.default(17)) || + fixture.integer.any || + fixture.any, + }, + { + schema: Schema.float(), + expect: fixture.float.any || fixture.any, + }, + { + schema: Schema.float().optional(), + expect: fixture.float.optional || fixture.float.any || fixture.any, + }, + { + schema: Schema.float().nullable(), + expect: fixture.float.nullable || fixture.float.any || fixture.any, + }, + { + schema: Schema.float().default(1.7), + expect: + (fixture.float.default && fixture.float.default(1.7)) || + fixture.float.any || + fixture.any, + }, + { + schema: Schema.array(Schema.string()), + expect: fixture.array.string?.any || fixture.array.any || fixture.any, + }, + { + schema: Schema.string().array(), + expect: fixture.array.string?.any || fixture.array.any || fixture.any, + }, + { + schema: Schema.array(Schema.string().optional()), + expect: + fixture.array.string?.optional || + fixture.array.string?.any || + fixture.array.any || + fixture.any, + }, + { + schema: Schema.array(Schema.string().nullable()), + expect: + fixture.array.string?.nullable || + fixture.array.string?.any || + fixture.array.any || + fixture.any, + }, + { + schema: Schema.array(Schema.string().default('DEFAULT')), + expect: + (fixture.array.string?.default && + fixture.array.string?.default('DEFAULT')) || + fixture.array.string?.any || + fixture.array.any || + fixture.any, + }, + { + schema: Schema.literal('foo'), + expect: + fixture.literal?.foo?.any || fixture.literal.any?.any || fixture.any, + }, + { + schema: Schema.literal('hello'), + expect: + fixture.literal?.hello?.any || fixture.literal.any?.any || fixture.any, + }, + { + schema: Schema.literal('hello').optional(), + expect: + fixture.literal?.hello?.optional || + fixture.literal?.hello?.any || + fixture.literal.any?.any || + fixture.any, + }, + { + schema: Schema.literal('hello').nullable(), + expect: + fixture.literal?.hello?.nullable || + fixture.literal?.hello?.any || + fixture.literal.any?.any || + fixture.any, + }, + { + schema: Schema.literal('hello').default('hello'), + expect: + (fixture.literal?.hello?.default && + fixture.literal?.hello?.default('hello')) || + fixture.literal?.hello?.any || + fixture.literal.any?.any || + fixture.any, + }, + { + schema: Schema.tuple([Schema.string(), Schema.string()]), + expect: fixture.tuple.strNstr?.any || fixture.tuple.any || fixture.any, + }, + { + schema: Schema.tuple([Schema.string(), Schema.float()]), + expect: fixture.tuple.strNfloat?.any || fixture.tuple.any || fixture.any, + }, + { + schema: Schema.string().or(Schema.number()), + expect: fixture.stringOrNumber?.any || fixture.any, + }, + { + schema: Schema.string().or(Schema.number()), + expect: fixture.stringOrNumber?.any || fixture.string.any || fixture.any, + }, + { + schema: Schema.string().or(Schema.number()).optional(), + expect: + fixture.stringOrNumber?.optional || + fixture.stringOrNumber?.any || + fixture.string.any || + fixture.any, + }, + { + schema: Schema.string().or(Schema.number()).nullable(), + expect: + fixture.stringOrNumber?.nullable || + fixture.stringOrNumber?.any || + fixture.string.any || + fixture.any, + }, + { + schema: Schema.string().or(Schema.number()).default(10), + expect: + (fixture.stringOrNumber?.default && + fixture.stringOrNumber?.default(10)) || + fixture.stringOrNumber?.any || + fixture.string.any || + fixture.any, + }, + { + schema: Schema.string().or(Schema.number()).default('test'), + expect: + (fixture.stringOrNumber?.default && + fixture.stringOrNumber?.default('test')) || + fixture.stringOrNumber?.any || + fixture.string.any || + fixture.any, + }, + { + schema: Schema.struct({ + name: 'Point2d', + x: Schema.integer(), + y: Schema.integer(), + }), + expect: fixture.point2d?.any || fixture.struct.any || fixture.any, + }, + { + schema: Schema.string().startsWith('Hello'), + expect: fixture.strartsWithHello.any || fixture.string.any || fixture.any, + }, + { + schema: Schema.string().endsWith('world'), + expect: fixture.endsWithWorld.any || fixture.string.any || fixture.any, + }, + { + schema: Schema.string().startsWith('Hello').endsWith('world'), + expect: + fixture.startsWithHelloEndsWithWorld.any || + fixture.string.any || + fixture.any, + }, + { + schema: Schema.number().greaterThan(100), + expect: fixture['n > 100']?.any || fixture.number.any || fixture.any, + }, + { + schema: Schema.number().lessThan(100), + expect: fixture['n < 100']?.any || fixture.number.any || fixture.any, + }, + { + schema: Schema.number().greaterThan(3).lessThan(17), + expect: fixture['3 < n < 17']?.any || fixture.number.any || fixture.any, + }, + { + schema: Schema.enum(['Red', 'Green', 'Blue']), + expect: fixture['Red|Green|Blue']?.any || fixture.enum.any || fixture.any, + }, + { + schema: Schema.struct({ x: Schema.integer() }) + .and(Schema.struct({ y: Schema.integer() })) + .and(Schema.struct({ z: Schema.integer() })), + expect: fixture.xyz?.any || fixture.struct.any || fixture.any, + }, +] + +export default function* () { + for (const each of source.map(fixture)) { + for (const { skip, only, schema, expect } of scenarios(each)) { + yield { + skip: skip || each.skip, + only: only || each.only, + expect, + schema, + inputLabel: display(each.in), + input: each.in, + } + } + } +} diff --git a/packages/validator/test/schema/util.js b/packages/validator/test/schema/util.js new file mode 100644 index 00000000..b4388a7a --- /dev/null +++ b/packages/validator/test/schema/util.js @@ -0,0 +1,77 @@ +/** + * @typedef {{error?:undefined, value:unknown}|{error:RegExp}} Expect + * @param {unknown} [value] + * @return {Expect} + */ +export const pass = value => ({ value }) + +export const fail = Object.assign( + /** + * @param {object} options + * @param {unknown} [options.got] + * @param {string} [options.expect] + */ + ({ got = '.*', expect = '.*' }) => ({ + error: new RegExp(`expect.*${expect}.* got ${got}`, 'is'), + }), + { + /** + * @param {string} pattern + */ + as: pattern => ({ + error: new RegExp(pattern, 'is'), + }), + /** + * @param {number|string} at + * @param {object} options + * @param {unknown} [options.got] + * @param {unknown} [options.expect] + */ + at: (at, { got = '.*', expect = [] }) => { + const variants = Array.isArray(expect) + ? expect.join(`.* expect.*`) + : expect + return { + error: new RegExp( + `invalid .* ${at}.* expect.*${variants} .* got ${got}`, + 'is' + ), + } + }, + } +) + +/** + * @param {unknown} source + * @returns {string} + */ +export const display = source => { + const type = typeof source + switch (type) { + case 'boolean': + case 'string': + return JSON.stringify(source) + // if these types we do not want JSON.stringify as it may mess things up + // eg turn NaN and Infinity to null + case 'bigint': + case 'number': + case 'symbol': + case 'undefined': + return String(source) + case 'object': { + if (source === null) { + return 'null' + } + + if (Array.isArray(source)) { + return `[${source.map(display).join(', ')}]` + } + + return `{${Object.entries(Object(source)).map( + ([key, value]) => `${key}:${display(value)}` + )}}` + } + default: + return String(source) + } +}