From feda7c168e28ffade9911c09edeb3c820cbc4a12 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 30 Sep 2022 11:54:08 -0700 Subject: [PATCH 01/13] fix: optional caveats backport #105 into 0.9 --- packages/validator/src/capability.js | 2 +- packages/validator/test/capability.spec.js | 56 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/validator/src/capability.js b/packages/validator/src/capability.js index 97b8e1d0..c158e449 100644 --- a/packages/validator/src/capability.js +++ b/packages/validator/src/capability.js @@ -140,7 +140,7 @@ class Capability extends Unit { new Error(`Invalid 'nb.${key}' - ${value.message}`), { cause: value } ) - } else { + } else if (value !== undefined) { const nb = capabality.nb || (capabality.nb = /** @type {API.InferCaveats} */ ({})) diff --git a/packages/validator/test/capability.spec.js b/packages/validator/test/capability.spec.js index f6a0c013..920637a7 100644 --- a/packages/validator/test/capability.spec.js +++ b/packages/validator/test/capability.spec.js @@ -1,5 +1,5 @@ -import { capability, URI, Link } from '../src/lib.js' -import { invoke } from '@ucanto/core' +import { capability, URI, Link, access } from '../src/lib.js' +import { invoke, parseLink, Delegation } from '@ucanto/core' import * as API from '@ucanto/interface' import { Failure } from '../src/error.js' import { the } from '../src/util.js' @@ -1502,3 +1502,55 @@ test('invoke capability (with nb)', () => { }) ) }) + +test('capability with optional caveats', async () => { + const Echo = capability({ + can: 'test/echo', + with: URI.match({ protocol: 'did:' }), + nb: { + message: URI.match({ protocol: 'data:' }), + meta: Link.optional(), + }, + }) + + const echo = await Echo.invoke({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + message: 'data:hello', + }, + }).delegate() + + assert.deepEqual(echo.capabilities, [ + { + can: 'test/echo', + with: alice.did(), + nb: { + message: 'data:hello', + }, + }, + ]) + + const link = parseLink('bafkqaaa') + const out = await Echo.invoke({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + message: 'data:hello', + meta: link, + }, + }).delegate() + + assert.deepEqual(out.capabilities, [ + { + can: 'test/echo', + with: alice.did(), + nb: { + message: 'data:hello', + meta: link, + }, + }, + ]) +}) From 4ce4ace4a6343485918f466732223bbe26dc7c44 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 6 Oct 2022 12:16:54 -0700 Subject: [PATCH 02/13] stash: current status of schema code --- packages/validator/src/decoder/core.js | 1220 ++++++++++++++++++++++++ packages/validator/src/decoder/type.js | 0 packages/validator/src/decoder/type.ts | 72 ++ packages/validator/test/schema.spec.js | 600 ++++++++++++ 4 files changed, 1892 insertions(+) create mode 100644 packages/validator/src/decoder/core.js create mode 100644 packages/validator/src/decoder/type.js create mode 100644 packages/validator/src/decoder/type.ts create mode 100644 packages/validator/test/schema.spec.js diff --git a/packages/validator/src/decoder/core.js b/packages/validator/src/decoder/core.js new file mode 100644 index 00000000..00eddce8 --- /dev/null +++ b/packages/validator/src/decoder/core.js @@ -0,0 +1,1220 @@ +import * as Schema from './type.js' + +export * from './type.js' + +/** + * @template [T=any] + * @template [I=unknown] + * @template [Settings=void] + * @implements {Schema.Schema} + */ +export class API { + /** + * @param {Settings} settings + */ + constructor(settings) { + /** @protected */ + this.settings = settings + } + + toString() { + return `new ${this.constructor.name}()` + } + /** + * @param {I} _input + * @param {Settings} _settings + * @returns {Schema.Read} + * @abstract + */ + readWith(_input, _settings) { + throw new Error(`Abstract method readWith must be implemented by subclass`) + } + /** + * @param {I} input + * @returns {Schema.Read} + */ + 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 + */ + 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 {T} value + * @returns {Schema.Schema} + */ + default(value) { + return new Default({ + reader: /** @type {Schema.Reader} */ (this), + value, + }) + } +} + +/** + * @template [I=unknown] + * @extends {API} + * @implements {Schema.Schema} + */ +class Never extends API { + toString() { + return 'never()' + } + /** + * @param {I} input + * @returns {Schema.Read} + */ + read(input) { + return new TypeError({ expect: 'never', actual: input }) + } + /** + * @param {never} value + * @returns {never} + */ + default(value) { + throw new Error( + `Can not call never().default(value) because no default will satisify never type` + ) + } +} + +/** + * @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 + */ + readWith(input) { + return /** @type {Schema.Read}*/ (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, new 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.Read} + */ + readWith(input, reader) { + const result = reader.read(input) + return result?.error && input === undefined ? undefined : result + } + toString() { + return `${this.settings}.optional()` + } +} + +/** + * @template O + * @template [I=unknown] + * @extends {API, value:O}>} + * @implements {Schema.Schema} + */ +class Default extends API { + /** + * @returns {Schema.Schema} + */ + optional() { + // Short circuit here as we there is no point in wrapping this in optional. + return /** @type {Schema.Schema} */ (this) + } + /** + * @param {I} input + * @param {object} options + * @param {Schema.Reader} options.reader + * @param {O} options.value + * @returns {Schema.Read} + */ + readWith(input, { reader, value }) { + const result = reader.read(input) + return result?.error && input === undefined + ? /** @type {Schema.Read} */ (value) + : result + } + toString() { + return `${this.settings.reader}.default(${JSON.stringify( + 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.Schema} + */ +class ArrayOf extends API { + /** + * @param {I} input + * @param {Schema.Reader} schema + */ + readWith(input, schema) { + if (!Array.isArray(input)) { + return new 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 new ElementError({ 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.Schema} + */ +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.Read>} + */ + readWith(input, shape) { + if (!Array.isArray(input)) { + return new TypeError({ expect: 'array', actual: input }) + } + + const results = [] + for (const [index, reader] of shape.entries()) { + const result = reader.read(input[index]) + if (result?.error) { + return new ElementError({ 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.Read} + */ + readWith(input, { variants, type }) { + if (variants.has(input)) { + return /** @type {Schema.Read} */ (input) + } else { + return new 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.Read>} + */ + 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.Read>} */ (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 new TypeError({ + expect: 'boolean', + actual: input, + }) + } + } + toString() { + return `boolean()` + } +} + +/** + * @template {unknown} I + * @returns {Schema.Schema} + */ +export const boolean = () => new Boolean() + +/** + * @template {number} [O=number] + * @template [I=unknown] + * @template [Settings=void] + * @extends {API} + * @implements {Schema.Schema} + */ +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 + */ + refine(schema) { + return new RefinedNumber({ base: this, schema }) + } +} + +/** + * @template [I=unknown] + * @extends {UnknownNumber} + * @implements {Schema.Schema} + */ +class AnyNumber extends UnknownNumber { + /** + * @param {I} input + * @returns {Schema.Read} + */ + readWith(input) { + return typeof input === 'number' + ? input + : new TypeError({ expect: 'number', actual: input }) + } + toString() { + return `number()` + } +} + +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.Schema} + */ +class RefinedNumber extends UnknownNumber { + /** + * @param {I} input + * @param {{base:Schema.Reader, schema:Schema.Reader}} settings + * @returns {Schema.Read} + */ + 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.Read} + */ + 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.Read} + */ + 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.Read} + */ + read(input) { + return Number.isInteger(input) + ? input + : new TypeError({ + expect: 'Integer', + actual: input, + }) + }, + toString() { + return `Integer` + }, +} + +export const integer = () => anyNumber.refine(Integer) + +const Float = { + /** + * @param {number} number + * @returns {Schema.Read} + */ + read(number) { + return Number.isFinite(number) + ? number + : new TypeError({ + expect: 'Float', + actual: number, + }) + }, + toString() { + return 'Float' + }, +} +export const float = () => anyNumber.refine(Float) + +/** + * @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 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.Read} + */ + readWith(input, { base, schema }) { + const result = base.read(input) + return result?.error + ? result + : /** @type {Schema.Read} */ (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.Read} + */ + readWith(input) { + return typeof input === 'string' + ? input + : new 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 StratsWith extends API { + /** + * @param {Body} input + * @param {Prefix} prefix + */ + readWith(input, prefix) { + return input.startsWith(prefix) + ? /** @type {Schema.Read} */ (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 StratsWith(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.Read} */ (input) + : error(`Expect string to end with "${suffix}" instead got "${input}"`) + } + get suffix() { + return this.settings + } + toString() { + return `endsWith("${this.suffix}")` + } +} + +/** + * @template {string} Suffix + * @template {string} Body + * @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.Schema} + */ +class Literal extends API { + /** + * @param {I} input + * @param {T} expect + * @returns {Schema.Read} + */ + readWith(input, expect) { + return input !== /** @type {unknown} */ (expect) + ? new LiteralError({ expect, actual: input }) + : expect + } + get value() { + return this.settings + } + toString() { + return `literal(${displayTypeName(this.value)})` + } +} + +/** + * @template {null|boolean|string|number} T + * @template [I=unknown] + * @param {T} value + * @returns {Schema.Schema} + */ +export const literal = value => new Literal(value) + +/** + * @template {Schema.Reader} T + * @template {{[key:string]: T}} U + * @template [I=unknown] + * @extends {API, I, U>} + */ +class Struct extends API { + /** + * @param {I} input + * @param {U} shape + * @returns {Schema.Read>} + */ + readWith(input, shape) { + if (typeof input === 'object' && input !== null) { + return new 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 new FieldError({ 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]) => + indent(`${key}: ${schema}`) + ), + `})`, + ].join('\n') + } +} + +/** + * @template {Schema.Reader|null|boolean|string|number} T + * @template {{[key:string]: T}} U + * @template {{[K in keyof U]: U[K] extends Schema.Reader ? U[K] : Schema.Reader}} V + * @template [I=unknown] + * @param {U} fields + * @returns {Schema.Schema, I>} + */ +export const struct = fields => { + const shape = /** @type {{[K in keyof U]: unknown}} */ ({}) + /** @type {[keyof U, T][]} */ + 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 + default: + shape[key] = field === null ? literal(field) : field + break + } + } + + return new Struct(/** @type {V} */ (shape)) +} + +/** + * @param {string} message + * @returns {Schema.ReadError} + */ +export const error = message => new SchemaError(message) + +class SchemaError extends Error { + get name() { + return 'SchemaError' + } + /** @type {true} */ + get error() { + return true + } + describe() { + return this.name + } + get message() { + return this.describe() + } + + toJSON() { + const { error, name, message } = this + return { error, name, message } + } +} + +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 {string} expect + * @param {unknown} actual + * @returns {Schema.ReadError} + */ +export const typeError = (expect, actual) => new TypeError({ expect, actual }) + +/** + * + * @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': + 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 value ${JSON.stringify( + this.expect + )} instead got ${JSON.stringify(this.actual)}` + } +} + +class ElementError extends SchemaError { + /** + * @param {{at:number, cause:Schema.ReadError}} 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.ReadError}} data + */ + constructor({ at, cause }) { + super() + this.at = at + this.cause = cause + } + get name() { + return 'FieldError' + } + describe() { + return `Object contains invalid field ${this.at}\n - ${this.cause.message}` + } +} + +/** + * @param {string|number} at + * @param {Schema.ReadError} cause + * @returns {Schema.ReadError} + */ +export const memberError = (at, cause) => + typeof at === 'string' + ? new FieldError({ at, cause }) + : new ElementError({ at, cause }) + +class UnionError extends SchemaError { + /** + * @param {{causes: Schema.ReadError[]}} 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.ReadError[]}} 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 + */ +export const indent = (message, indent = ' ') => + `${indent}${message.split('\n').join(`\n${indent}`)}` + +/** + * @param {string} message + */ +export const li = message => indent(`- ${message}`) diff --git a/packages/validator/src/decoder/type.js b/packages/validator/src/decoder/type.js new file mode 100644 index 00000000..e69de29b diff --git a/packages/validator/src/decoder/type.ts b/packages/validator/src/decoder/type.ts new file mode 100644 index 00000000..7f838dd5 --- /dev/null +++ b/packages/validator/src/decoder/type.ts @@ -0,0 +1,72 @@ +import { Failure as ReadError, Result, Phantom } from '@ucanto/interface' + +export interface Reader< + O = unknown, + I = unknown, + Context = void, + X extends { error: true } = ReadError +> { + read(input: I, context: Context): Read +} + +export { ReadError } + +export type Read = Result + +export interface Schema extends Reader { + optional(): Schema + nullable(): Schema + array(): Schema + default(value: O): Schema + 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 StringSchema + extends Schema { + startsWith( + prefix: Prefix + ): StringSchema + endsWith( + suffix: Suffix + ): StringSchema + refine(schema: Reader): StringSchema +} + +export type Branded = T & Phantom<{ brand: Brand }> + +export type Integer = Branded +export type Float = Branded + +export type Infer> = T extends Reader< + infer T, + any +> + ? T + : never + +export type InferIntesection< + U extends [Reader, ...Reader[]] +> = { [K in keyof U]: (input: Infer) => void }[number] extends ( + input: infer T +) => void + ? T + : never + +export type InferUnion< + U extends [Reader, ...Reader[]] +> = Infer + +export type InferTuple< + U extends [Reader, ...Reader[]] +> = { [K in keyof U]: Infer } + +export type InferStruct = { + [K in keyof U]: Infer +} diff --git a/packages/validator/test/schema.spec.js b/packages/validator/test/schema.spec.js new file mode 100644 index 00000000..3d623657 --- /dev/null +++ b/packages/validator/test/schema.spec.js @@ -0,0 +1,600 @@ +import { test, assert } from './test.js' +import * as Schema from '../src/decoder/core.js' + +/** + * @param {unknown} [value] + * @return {Expect} + */ +export const pass = value => ({ value }) + +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 {number} 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(`at ${at}.* expect.*${variants} .* got ${got}`, 'is'), + } + }, + } +) + +/** + * @param {unknown} source + * @returns {string} + */ +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) + } +} + +/** + * @typedef {{error?:undefined, value:unknown}|{error:RegExp}} Expect + * @typedef {{ + * in:unknown + * unknown: { + * any: Expect + * nullable?: Expect + * optional?: Expect + * default?: (input:unknown) => Expect + * }, + * never: { + * any: Expect + * nullable?: Expect + * optional?: Expect + * default?: (input:unknown) => Expect + * } + * string: { + * any: Expect, + * optional?: Expect, + * nullable?: Expect, + * default?: (input:unknown) => Expect + * }, + * array: { + * any: Expect + * string?:Expect + * optionalString?:Expect + * nullableString?:Expect + * defaultString?: (input:unknown) => Expect + * number?:Expect + * } + * }} Fixture + * @type {Fixture[]} + */ +const fixtures = [ + { + in: 'hello', + array: { any: fail({ expect: 'array', got: '"hello"' }) }, + string: { any: pass() }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: '"hello"' }) }, + }, + { + in: new String('hello'), + array: { any: fail({ expect: 'array', got: 'object' }) }, + string: { any: fail({ expect: 'string', got: 'object' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'object' }) }, + }, + { + in: null, + array: { any: fail({ expect: 'array', got: 'null' }) }, + string: { + any: fail({ expect: 'string', got: 'null' }), + nullable: pass(null), + }, + unknown: { any: pass() }, + never: { + any: fail({ expect: 'never', got: 'null' }), + nullable: pass(), + }, + }, + { + in: undefined, + array: { any: fail({ expect: 'array', got: undefined }) }, + string: { + any: fail({ expect: 'string', got: undefined }), + optional: pass(), + default: value => pass(value), + }, + unknown: { any: pass() }, + never: { + any: fail({ expect: 'never', got: undefined }), + optional: pass(), + }, + }, + { + in: 101, + array: { any: fail({ expect: 'array', got: 101 }) }, + string: { any: fail({ expect: 'string', got: 101 }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 101 }) }, + }, + { + in: 9.8, + array: { any: fail({ expect: 'array', got: 9.8 }) }, + string: { any: fail({ expect: 'string', got: 9.8 }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 9.8 }) }, + }, + { + in: true, + array: { any: fail({ expect: 'array', got: true }) }, + string: { any: fail({ expect: 'string', got: true }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: true }) }, + }, + { + in: false, + array: { any: fail({ expect: 'array', got: false }) }, + string: { any: fail({ expect: 'string', got: false }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: false }) }, + }, + { + in: Symbol.for('bye'), + array: { any: fail({ expect: 'array', got: 'Symbol\\(bye\\)' }) }, + string: { any: fail({ expect: 'string', got: 'Symbol\\(bye\\)' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'Symbol\\(bye\\)' }) }, + }, + { + in: () => 'hello', + array: { any: fail({ expect: 'array', got: 'function' }) }, + string: { any: fail({ expect: 'string', got: 'function' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'function' }) }, + }, + { + in: 'hello', + array: { any: fail({ expect: 'array', got: '"hello"' }) }, + string: { any: pass() }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: '"hello"' }) }, + }, + { + in: {}, + array: { any: fail({ expect: 'array', got: 'object' }) }, + string: { any: fail({ expect: 'string', got: 'object' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'object' }) }, + }, + { + in: [], + array: { + any: pass(), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: [, undefined], + array: { + any: fail.at(0, { got: undefined }), + optionalString: pass(), + nullableString: fail.at(0, { got: undefined, expect: 'null' }), + defaultString: v => pass([v, v]), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['hello', 'world', 1, '?'], + array: { + any: fail.at(0, { got: '"hello"' }), + string: fail.at(2, { got: 1, expect: 'string' }), + nullableString: fail.at(2, { + expect: ['string', 'null'], + got: 1, + }), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['hello', , 'world'], + array: { + any: fail.at(0, { got: '"hello"' }), + string: fail.at(1, { expect: 'string', got: undefined }), + nullableString: fail.at(1, { + expect: ['string', 'null'], + got: undefined, + }), + defaultString: v => pass(['hello', v, 'world']), + optionalString: pass(), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['h', 'e', 'l', null, 'l', 'o'], + array: { + any: fail.at(0, { got: '"h"' }), + string: fail.at(3, { expect: 'string', got: 'null' }), + nullableString: pass(), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['hello', new String('world')], + array: { + any: fail.at(0, { got: '"hello"' }), + string: fail.at(1, { expect: 'string', got: 'object' }), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['1', 2.1], + array: { + any: fail.at(0, { got: 1 }), + string: fail.at(1, { expect: 'string', got: 2.1 }), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['true', 'false', true], + array: { + any: fail.at(0, { got: '"true"' }), + string: fail.at(2, { expect: 'string', got: true }), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['hello', Symbol.for('world')], + array: { + any: fail.at(0, { got: '"hello"' }), + string: fail.at(1, { expect: 'string', got: 'Symbol\\(world\\)' }), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, + { + in: ['hello', () => 'world'], + array: { + any: fail.at(0, { got: '"hello"' }), + string: fail.at(1, { got: 'function' }), + }, + string: { any: fail({ expect: 'string', got: 'array' }) }, + unknown: { any: pass() }, + never: { any: fail({ expect: 'never', got: 'array' }) }, + }, +] + +for (const fixture of fixtures) { + const label = `${fixture.in === null ? 'null' : typeof fixture.in}` + + for (const { schema, expect } of [ + { + schema: Schema.never(), + expect: fixture.never.any, + }, + { + schema: Schema.never().nullable(), + expect: fixture.never.nullable || fixture.never.any, + }, + { + schema: Schema.never().optional(), + expect: fixture.never.optional || fixture.never.any, + }, + { + schema: Schema.unknown(), + expect: fixture.unknown.any, + }, + { + schema: Schema.unknown().optional(), + expect: fixture.unknown.any, + }, + { + schema: Schema.unknown().nullable(), + expect: fixture.unknown.any, + }, + { + schema: Schema.unknown().default('DEFAULT'), + expect: fixture.unknown.any, + }, + { + schema: Schema.string(), + expect: fixture.string.any, + }, + { + schema: Schema.string().optional(), + expect: fixture.string.optional || fixture.string.any, + }, + { + schema: Schema.string().nullable(), + expect: fixture.string.nullable || fixture.string.any, + }, + { + schema: Schema.string().default('DEFAULT'), + expect: + (fixture.string.default && fixture.string.default('DEFAULT')) || + fixture.string.any, + }, + { + schema: Schema.array(Schema.string()), + expect: fixture.array.string || fixture.array.any, + }, + { + schema: Schema.array(Schema.string().optional()), + expect: + fixture.array.optionalString || + fixture.array.string || + fixture.array.any, + }, + { + schema: Schema.array(Schema.string().nullable()), + expect: + fixture.array.nullableString || + fixture.array.string || + fixture.array.any, + }, + { + schema: Schema.array(Schema.string().default('DEFAULT')), + expect: + (fixture.array.defaultString && + fixture.array.defaultString('DEFAULT')) || + fixture.array.string || + fixture.array.any, + }, + // ['number', Schema.number()], + ]) { + test(`${schema}.read(${display(fixture.in)})`, () => { + const result = schema.read(fixture.in) + + 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 ? fixture.in : expect.value + ) + } + }) + + test(`${schema}.from(${display(fixture.in)})`, () => { + if (expect.error) { + assert.throws(() => schema.from(fixture.in), expect.error) + } else { + assert.deepEqual( + schema.from(fixture.in), + // if expcted value is set to undefined use input + expect.value === undefined ? fixture.in : expect.value + ) + } + }) + + test(`${schema}.is(${display(fixture.in)})`, () => { + assert.equal(schema.is(fixture.in), !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.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.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({ + read(hello) { + if (hello.length === 11) { + return /** @type {string & {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'), + /Can not call never\(\).default\(value\)/ + ) +}) From 9518e295dd9099108d2d3600ba0a3f3c873fa1e1 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Thu, 6 Oct 2022 17:28:37 -0700 Subject: [PATCH 03/13] chore: increase coverage --- packages/validator/src/decoder/core.js | 103 +++-- packages/validator/src/decoder/type.ts | 23 +- packages/validator/test/schema.spec.js | 548 +++++++++++++++++++------ 3 files changed, 493 insertions(+), 181 deletions(-) diff --git a/packages/validator/src/decoder/core.js b/packages/validator/src/decoder/core.js index 00eddce8..5e0e270c 100644 --- a/packages/validator/src/decoder/core.js +++ b/packages/validator/src/decoder/core.js @@ -3,9 +3,11 @@ import * as Schema from './type.js' export * from './type.js' /** + * @abstract * @template [T=any] * @template [I=unknown] * @template [Settings=void] + * @extends {Schema.Base} * @implements {Schema.Schema} */ export class API { @@ -21,17 +23,17 @@ export class API { return `new ${this.constructor.name}()` } /** - * @param {I} _input - * @param {Settings} _settings - * @returns {Schema.Read} * @abstract + * @param {I} input + * @param {Settings} settings + * @returns {Schema.ReadResult} */ - readWith(_input, _settings) { + readWith(input, settings) { throw new Error(`Abstract method readWith must be implemented by subclass`) } /** * @param {I} input - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ read(input) { return this.readWith(input, this.settings) @@ -137,7 +139,7 @@ class Never extends API { } /** * @param {I} input - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ read(input) { return new TypeError({ expect: 'never', actual: input }) @@ -168,8 +170,8 @@ class Unknown extends API { /** * @param {I} input */ - readWith(input) { - return /** @type {Schema.Read}*/ (input) + read(input) { + return /** @type {Schema.ReadResult}*/ (input) } toString() { return 'unknown()' @@ -231,7 +233,7 @@ class Optional extends API { /** * @param {I} input * @param {Schema.Reader} reader - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, reader) { const result = reader.read(input) @@ -261,12 +263,12 @@ class Default extends API { * @param {object} options * @param {Schema.Reader} options.reader * @param {O} options.value - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, { reader, value }) { const result = reader.read(input) return result?.error && input === undefined - ? /** @type {Schema.Read} */ (value) + ? /** @type {Schema.ReadResult} */ (value) : result } toString() { @@ -288,7 +290,7 @@ export const optional = schema => new Optional(schema) * @template O * @template [I=unknown] * @extends {API>} - * @implements {Schema.Schema} + * @implements {Schema.ArraySchema} */ class ArrayOf extends API { /** @@ -323,7 +325,7 @@ class ArrayOf extends API { * @template O * @template [I=unknown] * @param {Schema.Reader} schema - * @returns {Schema.Schema} + * @returns {Schema.ArraySchema} */ export const array = schema => new ArrayOf(schema) @@ -338,12 +340,17 @@ class Tuple extends API { /** * @param {I} input * @param {U} shape - * @returns {Schema.Read>} + * @returns {Schema.ReadResult>} */ readWith(input, shape) { if (!Array.isArray(input)) { return new 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()) { @@ -387,11 +394,11 @@ class Enum extends API { /** * @param {I} input * @param {{type:string, variants:Set}} settings - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, { variants, type }) { if (variants.has(input)) { - return /** @type {Schema.Read} */ (input) + return /** @type {Schema.ReadResult} */ (input) } else { return new TypeError({ expect: type, actual: input }) } @@ -477,7 +484,7 @@ class Intersection extends API { /** * @param {I} input * @param {U} schemas - * @returns {Schema.Read>} + * @returns {Schema.ReadResult>} */ readWith(input, schemas) { const causes = [] @@ -490,7 +497,7 @@ class Intersection extends API { return causes.length > 0 ? new IntersectionError({ causes }) - : /** @type {Schema.Read>} */ (input) + : /** @type {Schema.ReadResult>} */ (input) } toString() { return `intersection([${this.settings @@ -586,7 +593,7 @@ class UnknownNumber extends API { class AnyNumber extends UnknownNumber { /** * @param {I} input - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input) { return typeof input === 'number' @@ -612,7 +619,7 @@ class RefinedNumber extends UnknownNumber { /** * @param {I} input * @param {{base:Schema.Reader, schema:Schema.Reader}} settings - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, { base, schema }) { const result = base.read(input) @@ -631,7 +638,7 @@ class LessThan extends API { /** * @param {T} input * @param {number} number - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, number) { if (input < number) { @@ -660,7 +667,7 @@ class GreaterThan extends API { /** * @param {T} input * @param {number} number - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, number) { if (input > number) { @@ -684,7 +691,7 @@ export const greaterThan = n => new GreaterThan(n) const Integer = { /** * @param {number} input - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ read(input) { return Number.isInteger(input) @@ -704,7 +711,7 @@ export const integer = () => anyNumber.refine(Integer) const Float = { /** * @param {number} number - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ read(number) { return Number.isFinite(number) @@ -771,13 +778,13 @@ class RefinedString extends UnknownString { /** * @param {I} input * @param {{base:Schema.Reader, schema:Schema.Reader}} settings - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, { base, schema }) { const result = base.read(input) return result?.error ? result - : /** @type {Schema.Read} */ (schema.read(result)) + : /** @type {Schema.ReadResult} */ (schema.read(result)) } toString() { return `${this.settings.base}.refine(${this.settings.schema})` @@ -792,7 +799,7 @@ class RefinedString extends UnknownString { class AnyString extends UnknownString { /** * @param {I} input - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input) { return typeof input === 'string' @@ -819,7 +826,7 @@ class StratsWith extends API { */ readWith(input, prefix) { return input.startsWith(prefix) - ? /** @type {Schema.Read} */ (input) + ? /** @type {Schema.ReadResult} */ (input) : error(`Expect string to start with "${prefix}" instead got "${input}"`) } get prefix() { @@ -850,7 +857,7 @@ class EndsWith extends API { */ readWith(input, suffix) { return input.endsWith(suffix) - ? /** @type {Schema.Read} */ (input) + ? /** @type {Schema.ReadResult} */ (input) : error(`Expect string to end with "${suffix}" instead got "${input}"`) } get suffix() { @@ -905,13 +912,13 @@ export const refine = (base, schema) => new Refine({ base, schema }) * @template {null|boolean|string|number} T * @template [I=unknown] * @extends {API} - * @implements {Schema.Schema} + * @implements {Schema.LiteralSchema} */ class Literal extends API { /** * @param {I} input * @param {T} expect - * @returns {Schema.Read} + * @returns {Schema.ReadResult} */ readWith(input, expect) { return input !== /** @type {unknown} */ (expect) @@ -921,6 +928,16 @@ class Literal extends API { get value() { return this.settings } + /** + * @param {T} value + */ + default(value = this.value) { + if (value === this.value) { + return new Default({ reader: this, value }) + } else { + throw new Error(`Provided default does not match this literal`) + } + } toString() { return `literal(${displayTypeName(this.value)})` } @@ -930,7 +947,7 @@ class Literal extends API { * @template {null|boolean|string|number} T * @template [I=unknown] * @param {T} value - * @returns {Schema.Schema} + * @returns {Schema.LiteralSchema} */ export const literal = value => new Literal(value) @@ -944,7 +961,7 @@ class Struct extends API { /** * @param {I} input * @param {U} shape - * @returns {Schema.Read>} + * @returns {Schema.ReadResult>} */ readWith(input, shape) { if (typeof input === 'object' && input !== null) { @@ -1024,7 +1041,7 @@ export const struct = fields => { /** * @param {string} message - * @returns {Schema.ReadError} + * @returns {Schema.Error} */ export const error = message => new SchemaError(message) @@ -1071,7 +1088,7 @@ class TypeError extends SchemaError { /** * @param {string} expect * @param {unknown} actual - * @returns {Schema.ReadError} + * @returns {Schema.Error} */ export const typeError = (expect, actual) => new TypeError({ expect, actual }) @@ -1115,15 +1132,15 @@ class LiteralError extends SchemaError { return 'LiteralError' } describe() { - return `Expected value ${JSON.stringify( + return `Expected literal ${displayTypeName( this.expect - )} instead got ${JSON.stringify(this.actual)}` + )} instead got ${displayTypeName(this.actual)}` } } class ElementError extends SchemaError { /** - * @param {{at:number, cause:Schema.ReadError}} data + * @param {{at:number, cause:Schema.Error}} data */ constructor({ at, cause }) { super() @@ -1143,7 +1160,7 @@ class ElementError extends SchemaError { class FieldError extends SchemaError { /** - * @param {{at:string, cause:Schema.ReadError}} data + * @param {{at:string, cause:Schema.Error}} data */ constructor({ at, cause }) { super() @@ -1160,8 +1177,8 @@ class FieldError extends SchemaError { /** * @param {string|number} at - * @param {Schema.ReadError} cause - * @returns {Schema.ReadError} + * @param {Schema.Error} cause + * @returns {Schema.Error} */ export const memberError = (at, cause) => typeof at === 'string' @@ -1170,7 +1187,7 @@ export const memberError = (at, cause) => class UnionError extends SchemaError { /** - * @param {{causes: Schema.ReadError[]}} data + * @param {{causes: Schema.Error[]}} data */ constructor({ causes }) { super() @@ -1190,7 +1207,7 @@ class UnionError extends SchemaError { class IntersectionError extends SchemaError { /** - * @param {{causes: Schema.ReadError[]}} data + * @param {{causes: Schema.Error[]}} data */ constructor({ causes }) { super() diff --git a/packages/validator/src/decoder/type.ts b/packages/validator/src/decoder/type.ts index 7f838dd5..7e7f02b8 100644 --- a/packages/validator/src/decoder/type.ts +++ b/packages/validator/src/decoder/type.ts @@ -1,17 +1,16 @@ -import { Failure as ReadError, Result, Phantom } from '@ucanto/interface' +import { Failure as Error, Result, Phantom } from '@ucanto/interface' export interface Reader< O = unknown, I = unknown, - Context = void, - X extends { error: true } = ReadError + X extends { error: true } = Error > { - read(input: I, context: Context): Read + read(input: I): ReadResult } -export { ReadError } +export type { Error } -export type Read = Result +export type ReadResult = Result export interface Schema extends Reader { optional(): Schema @@ -28,6 +27,18 @@ export interface Schema extends Reader { from(value: I): O } +export interface ArraySchema extends Schema { + element: Reader +} + +export interface LiteralSchema< + T extends string | number | boolean | null, + I = unknown +> extends Schema { + default(value?: T): Schema + readonly value: T +} + export interface StringSchema extends Schema { startsWith( diff --git a/packages/validator/test/schema.spec.js b/packages/validator/test/schema.spec.js index 3d623657..bf67c169 100644 --- a/packages/validator/test/schema.spec.js +++ b/packages/validator/test/schema.spec.js @@ -10,20 +10,26 @@ export const pass = value => ({ value }) const fail = Object.assign( /** * @param {object} options - * @param {unknown} options.got + * @param {unknown} [options.got] * @param {string} [options.expect] */ - ({ got, expect = '.*' }) => ({ + ({ got = '.*', expect = '.*' }) => ({ error: new RegExp(`expect.*${expect}.* got ${got}`, 'is'), }), { + /** + * @param {string} pattern + */ + as: pattern => ({ + error: new RegExp(pattern, 'is'), + }), /** * @param {number} at * @param {object} options - * @param {unknown} options.got + * @param {unknown} [options.got] * @param {unknown} [options.expect] */ - at: (at, { got, expect = [] }) => { + at: (at, { got = '.*', expect = [] }) => { const variants = Array.isArray(expect) ? expect.join(`.* expect.*`) : expect @@ -69,250 +75,391 @@ const display = source => { } } +/** + * @param {Partial} source + * @returns {Fixture} + */ + +const fixture = ({ in: input, got = input, array, ...expect }) => { + return { + 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 }, + 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: expect.tuple?.any || fail({ expect: 'array', got }), + strNstr: { + any: fail({ expect: 'array', got }), + ...expect.tuple?.strNstr, + }, + strNfloat: { + any: fail({ expect: 'array', got }), + ...expect.tuple?.strNstr, + }, + }, + } +} /** * @typedef {{error?:undefined, value:unknown}|{error:RegExp}} Expect * @typedef {{ - * in:unknown - * unknown: { - * any: Expect + * any?: Expect * nullable?: Expect * optional?: Expect * default?: (input:unknown) => Expect - * }, - * never: { - * any: Expect - * nullable?: Expect - * optional?: Expect - * default?: (input:unknown) => Expect - * } - * string: { - * any: Expect, - * optional?: Expect, - * nullable?: Expect, - * default?: (input:unknown) => Expect + * }} ExpectGroup + * @typedef {{ + * in:any + * got: unknown + * any: Expect + * unknown: ExpectGroup, + * never: ExpectGroup + * string: ExpectGroup, + * number: ExpectGroup, + * integer: ExpectGroup, + * float: ExpectGroup, + * literal: { + * any?: ExpectGroup, + * [key:string]: ExpectGroup|undefined * }, * array: { - * any: Expect - * string?:Expect - * optionalString?:Expect - * nullableString?:Expect - * defaultString?: (input:unknown) => Expect - * number?:Expect + * any?: Expect + * string?: ExpectGroup + * number?: ExpectGroup + * unknown?: ExpectGroup + * never?: ExpectGroup + * } + * tuple: { + * any?: Expect + * strNstr?: ExpectGroup + * strNfloat?: ExpectGroup * } * }} Fixture - * @type {Fixture[]} + * + * @type {Partial[]} */ -const fixtures = [ +const source = [ { in: 'hello', - array: { any: fail({ expect: 'array', got: '"hello"' }) }, + got: '"hello"', string: { any: pass() }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: '"hello"' }) }, + literal: { hello: { any: pass() } }, }, { in: new String('hello'), - array: { any: fail({ expect: 'array', got: 'object' }) }, - string: { any: fail({ expect: 'string', got: 'object' }) }, + got: 'object', unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'object' }) }, }, { in: null, - array: { any: fail({ expect: 'array', got: 'null' }) }, + got: 'null', string: { - any: fail({ expect: 'string', got: 'null' }), nullable: pass(null), }, + number: { + nullable: pass(), + }, + integer: { + nullable: pass(), + }, + float: { + nullable: pass(), + }, unknown: { any: pass() }, never: { - any: fail({ expect: 'never', got: 'null' }), nullable: pass(), }, + literal: { + hello: { + nullable: pass(), + }, + }, }, { in: undefined, - array: { any: fail({ expect: 'array', got: undefined }) }, string: { - any: fail({ expect: 'string', got: undefined }), + optional: pass(), + default: value => pass(value), + }, + number: { + optional: pass(), + default: value => pass(value), + }, + integer: { + optional: pass(), + default: value => pass(value), + }, + float: { optional: pass(), default: value => pass(value), }, unknown: { any: pass() }, never: { - any: fail({ expect: 'never', got: undefined }), optional: pass(), }, + literal: { + hello: { + optional: pass(), + default: pass, + }, + }, + }, + { + in: Infinity, + got: 'Infinity', + number: { any: pass() }, + integer: { any: fail({ expect: 'integer', got: 'Infinity' }) }, + float: { any: fail({ expect: 'float', got: 'Infinity' }) }, + unknown: { any: pass() }, + }, + { + in: NaN, + got: 'NaN', + number: { any: pass() }, + integer: { any: fail({ expect: 'integer', got: 'NaN' }) }, + float: { any: fail({ expect: 'float', got: 'NaN' }) }, + unknown: { any: pass() }, }, { in: 101, - array: { any: fail({ expect: 'array', got: 101 }) }, - string: { any: fail({ expect: 'string', got: 101 }) }, + number: { any: pass() }, + integer: { any: pass() }, + float: { any: pass() }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 101 }) }, }, { in: 9.8, - array: { any: fail({ expect: 'array', got: 9.8 }) }, - string: { any: fail({ expect: 'string', got: 9.8 }) }, + number: { any: pass() }, + integer: { any: fail({ expect: 'integer', got: '9.8' }) }, + float: { any: pass() }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 9.8 }) }, }, { in: true, - array: { any: fail({ expect: 'array', got: true }) }, - string: { any: fail({ expect: 'string', got: true }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: true }) }, }, { in: false, - array: { any: fail({ expect: 'array', got: false }) }, - string: { any: fail({ expect: 'string', got: false }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: false }) }, }, { in: Symbol.for('bye'), - array: { any: fail({ expect: 'array', got: 'Symbol\\(bye\\)' }) }, - string: { any: fail({ expect: 'string', got: 'Symbol\\(bye\\)' }) }, + got: 'Symbol\\(bye\\)', unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'Symbol\\(bye\\)' }) }, }, { in: () => 'hello', - array: { any: fail({ expect: 'array', got: 'function' }) }, - string: { any: fail({ expect: 'string', got: 'function' }) }, - unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'function' }) }, - }, - { - in: 'hello', - array: { any: fail({ expect: 'array', got: '"hello"' }) }, - string: { any: pass() }, + got: 'function', unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: '"hello"' }) }, }, { in: {}, - array: { any: fail({ expect: 'array', got: 'object' }) }, - string: { any: fail({ expect: 'string', got: 'object' }) }, + got: 'object', unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'object' }) }, }, { in: [], - array: { - any: pass(), - }, - string: { any: fail({ expect: 'string', got: 'array' }) }, + got: 'array', + array: { any: pass() }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { + any: fail.as('Array must contain exactly 2 elements'), + }, + }, }, { in: [, undefined], + got: 'array', array: { any: fail.at(0, { got: undefined }), - optionalString: pass(), - nullableString: fail.at(0, { got: undefined, expect: 'null' }), - defaultString: v => pass([v, v]), + string: { + optional: pass(), + nullable: fail.at(0, { got: undefined, expect: 'null' }), + default: v => pass([v, v]), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { + any: fail.at(0, { got: undefined, expect: 'string' }), + }, + }, }, { in: ['hello', 'world', 1, '?'], + got: 'array', array: { any: fail.at(0, { got: '"hello"' }), - string: fail.at(2, { got: 1, expect: 'string' }), - nullableString: fail.at(2, { - expect: ['string', 'null'], - got: 1, - }), + string: { + any: fail.at(2, { got: 1, expect: 'string' }), + nullable: fail.at(2, { + expect: ['string', 'null'], + got: 1, + }), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { any: fail.as('Array must contain exactly 2 elements') }, + }, }, { in: ['hello', , 'world'], + got: 'array', array: { any: fail.at(0, { got: '"hello"' }), - string: fail.at(1, { expect: 'string', got: undefined }), - nullableString: fail.at(1, { - expect: ['string', 'null'], - got: undefined, - }), - defaultString: v => pass(['hello', v, 'world']), - optionalString: pass(), + 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(), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { + any: fail.as('Array must contain exactly 2 elements'), + }, + }, }, { in: ['h', 'e', 'l', null, 'l', 'o'], + got: 'array', array: { any: fail.at(0, { got: '"h"' }), - string: fail.at(3, { expect: 'string', got: 'null' }), - nullableString: pass(), + string: { + any: fail.at(3, { expect: 'string', got: 'null' }), + nullable: pass(), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { any: fail.as('Array must contain exactly 2 elements') }, + }, }, { in: ['hello', new String('world')], + got: 'array', array: { any: fail.at(0, { got: '"hello"' }), - string: fail.at(1, { expect: 'string', got: 'object' }), + string: { + any: fail.at(1, { expect: 'string', got: 'object' }), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { + any: fail.at(1, { got: 'object' }), + }, + }, }, { in: ['1', 2.1], + got: 'array', array: { any: fail.at(0, { got: 1 }), - string: fail.at(1, { expect: 'string', got: 2.1 }), + string: { + any: fail.at(1, { expect: 'string', got: 2.1 }), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + 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: fail.at(2, { expect: 'string', 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: { + strNstr: { any: fail.as('Array must contain exactly 2 elements') }, + }, }, { in: ['hello', Symbol.for('world')], + got: 'array', array: { any: fail.at(0, { got: '"hello"' }), - string: fail.at(1, { expect: 'string', got: 'Symbol\\(world\\)' }), + string: { + any: fail.at(1, { expect: 'string', got: 'Symbol\\(world\\)' }), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { + any: fail.at(1, { got: 'Symbol\\(world\\)' }), + }, + }, }, { in: ['hello', () => 'world'], + got: 'array', array: { any: fail.at(0, { got: '"hello"' }), - string: fail.at(1, { got: 'function' }), + string: { + any: fail.at(1, { got: 'function' }), + }, }, - string: { any: fail({ expect: 'string', got: 'array' }) }, unknown: { any: pass() }, - never: { any: fail({ expect: 'never', got: 'array' }) }, + tuple: { + strNstr: { + any: fail.at(1, { got: 'function' }), + }, + }, }, ] +const fixtures = source.map(fixture) for (const fixture of fixtures) { const label = `${fixture.in === null ? 'null' : typeof fixture.in}` @@ -320,77 +467,180 @@ for (const fixture of fixtures) { for (const { schema, expect } of [ { schema: Schema.never(), - expect: fixture.never.any, + expect: fixture.never.any || fixture.any, }, { schema: Schema.never().nullable(), - expect: fixture.never.nullable || fixture.never.any, + expect: fixture.never.nullable || fixture.never.any || fixture.any, }, { schema: Schema.never().optional(), - expect: fixture.never.optional || fixture.never.any, + expect: fixture.never.optional || fixture.never.any || fixture.any, }, { schema: Schema.unknown(), - expect: fixture.unknown.any, + expect: fixture.unknown.any || fixture.any, }, { schema: Schema.unknown().optional(), - expect: fixture.unknown.any, + expect: fixture.unknown.any || fixture.any, }, { schema: Schema.unknown().nullable(), - expect: fixture.unknown.any, + expect: fixture.unknown.any || fixture.any, }, { schema: Schema.unknown().default('DEFAULT'), - expect: fixture.unknown.any, + expect: fixture.unknown.any || fixture.any, }, { schema: Schema.string(), - expect: fixture.string.any, + expect: fixture.string.any || fixture.any, }, { schema: Schema.string().optional(), - expect: fixture.string.optional || fixture.string.any, + expect: fixture.string.optional || fixture.string.any || fixture.any, }, { schema: Schema.string().nullable(), - expect: fixture.string.nullable || fixture.string.any, + 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.string.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 || fixture.array.any, + expect: fixture.array.string?.any || fixture.array.any || fixture.any, }, { schema: Schema.array(Schema.string().optional()), expect: - fixture.array.optionalString || - fixture.array.string || - fixture.array.any, + fixture.array.string?.optional || + fixture.array.string?.any || + fixture.array.any || + fixture.any, }, { schema: Schema.array(Schema.string().nullable()), expect: - fixture.array.nullableString || - fixture.array.string || - fixture.array.any, + fixture.array.string?.nullable || + fixture.array.string?.any || + fixture.array.any || + fixture.any, }, { schema: Schema.array(Schema.string().default('DEFAULT')), expect: - (fixture.array.defaultString && - fixture.array.defaultString('DEFAULT')) || - fixture.array.string || - fixture.array.any, + (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.integer()]), + expect: fixture.tuple.strNfloat?.any || fixture.tuple.any || fixture.any, }, - // ['number', Schema.number()], ]) { test(`${schema}.read(${display(fixture.in)})`, () => { const result = schema.read(fixture.in) @@ -598,3 +848,37 @@ test('never().default()', () => { /Can not call never\(\).default\(value\)/ ) }) + +test('literal("foo").default("bar") throws', () => { + assert.throws( + () => + Schema.literal('foo') + // @ts-expect-error - no value satisfies default + .default('bar'), + /Provided default does not match this literal/ + ) +}) + +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) +}) From b7cb92d7bf20a8f21e28395d53cad2e586d48b3f Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 10 Oct 2022 14:26:43 -0700 Subject: [PATCH 04/13] fix: get 100 coverage --- packages/interface/src/lib.ts | 2 +- packages/validator/src/decoder/core.js | 208 ++-- packages/validator/src/decoder/type.ts | 115 +- packages/validator/test/schema.spec.js | 1103 ++++++++------------ packages/validator/test/schema/fixtures.js | 878 ++++++++++++++++ packages/validator/test/schema/util.js | 77 ++ 6 files changed, 1602 insertions(+), 781 deletions(-) create mode 100644 packages/validator/test/schema/fixtures.js create mode 100644 packages/validator/test/schema/util.js diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 0fd7aea4..3cccb32f 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -267,7 +267,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/validator/src/decoder/core.js b/packages/validator/src/decoder/core.js index 5e0e270c..648d0dc8 100644 --- a/packages/validator/src/decoder/core.js +++ b/packages/validator/src/decoder/core.js @@ -4,7 +4,7 @@ export * from './type.js' /** * @abstract - * @template [T=any] + * @template [T=unknown] * @template [I=unknown] * @template [Settings=void] * @extends {Schema.Base} @@ -28,6 +28,7 @@ export class API { * @param {Settings} settings * @returns {Schema.ReadResult} */ + /* c8 ignore next 3 */ readWith(input, settings) { throw new Error(`Abstract method readWith must be implemented by subclass`) } @@ -117,14 +118,26 @@ export class API { } /** - * @param {T} value - * @returns {Schema.Schema} + * @param {Schema.NotUndefined} value + * @returns {Schema.DefaultSchema, I>} */ default(value) { - return new Default({ + // ⚠️ 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, + value: /** @type {Schema.NotUndefined} */ (fallback), }) + + return /** @type {Schema.DefaultSchema, I>} */ ( + schema + ) } } @@ -142,16 +155,7 @@ class Never extends API { * @returns {Schema.ReadResult} */ read(input) { - return new TypeError({ expect: 'never', actual: input }) - } - /** - * @param {never} value - * @returns {never} - */ - default(value) { - throw new Error( - `Can not call never().default(value) because no default will satisify never type` - ) + return typeError({ expect: 'never', actual: input }) } } @@ -201,7 +205,7 @@ class Nullable extends API { return input === null ? null : new UnionError({ - causes: [result, new TypeError({ expect: 'null', actual: input })], + causes: [result, typeError({ expect: 'null', actual: input })], }) } else { return result @@ -245,37 +249,48 @@ class Optional extends API { } /** - * @template O + * @template {unknown} O * @template [I=unknown] - * @extends {API, value:O}>} - * @implements {Schema.Schema} + * @extends {API, value:O & Schema.NotUndefined}>} + * @implements {Schema.DefaultSchema} */ class Default extends API { /** - * @returns {Schema.Schema} + * @returns {Schema.DefaultSchema, I>} */ optional() { // Short circuit here as we there is no point in wrapping this in optional. - return /** @type {Schema.Schema} */ (this) + return /** @type {Schema.DefaultSchema, I>} */ ( + this + ) } /** * @param {I} input * @param {object} options - * @param {Schema.Reader} options.reader + * @param {Schema.Reader} options.reader * @param {O} options.value * @returns {Schema.ReadResult} */ readWith(input, { reader, value }) { - const result = reader.read(input) - return result?.error && input === undefined - ? /** @type {Schema.ReadResult} */ (value) - : result + 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 + } } /** @@ -299,14 +314,14 @@ class ArrayOf extends API { */ readWith(input, schema) { if (!Array.isArray(input)) { - return new TypeError({ expect: 'array', actual: 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 new ElementError({ at: index, cause: result }) + return memberError({ at: index, cause: result }) } else { results.push(result) } @@ -344,7 +359,7 @@ class Tuple extends API { */ readWith(input, shape) { if (!Array.isArray(input)) { - return new TypeError({ expect: 'array', actual: input }) + return typeError({ expect: 'array', actual: input }) } if (input.length !== this.shape.length) { return new SchemaError( @@ -356,7 +371,7 @@ class Tuple extends API { for (const [index, reader] of shape.entries()) { const result = reader.read(input[index]) if (result?.error) { - return new ElementError({ at: index, cause: result }) + return memberError({ at: index, cause: result }) } else { results[index] = result } @@ -400,7 +415,7 @@ class Enum extends API { if (variants.has(input)) { return /** @type {Schema.ReadResult} */ (input) } else { - return new TypeError({ expect: type, actual: input }) + return typeError({ expect: type, actual: input }) } } toString() { @@ -451,7 +466,7 @@ class Union extends API { return this.settings } toString() { - return `union([${this.variants.map(type => type.toString()).join(',')}])` + return `union([${this.variants.map(type => type.toString()).join(', ')}])` } } @@ -538,12 +553,13 @@ class Boolean extends API { case false: return /** @type {boolean} */ (input) default: - return new TypeError({ + return typeError({ expect: 'boolean', actual: input, }) } } + toString() { return `boolean()` } @@ -560,7 +576,7 @@ export const boolean = () => new Boolean() * @template [I=unknown] * @template [Settings=void] * @extends {API} - * @implements {Schema.Schema} + * @implements {Schema.NumberSchema} */ class UnknownNumber extends API { /** @@ -579,6 +595,7 @@ class UnknownNumber extends API { /** * @template {O} U * @param {Schema.Reader} schema + * @returns {Schema.NumberSchema} */ refine(schema) { return new RefinedNumber({ base: this, schema }) @@ -588,7 +605,7 @@ class UnknownNumber extends API { /** * @template [I=unknown] * @extends {UnknownNumber} - * @implements {Schema.Schema} + * @implements {Schema.NumberSchema} */ class AnyNumber extends UnknownNumber { /** @@ -598,7 +615,7 @@ class AnyNumber extends UnknownNumber { readWith(input) { return typeof input === 'number' ? input - : new TypeError({ expect: 'number', actual: input }) + : typeError({ expect: 'number', actual: input }) } toString() { return `number()` @@ -606,6 +623,10 @@ class AnyNumber extends UnknownNumber { } const anyNumber = new AnyNumber() + +/** + * @returns {Schema.NumberSchema} + */ export const number = () => anyNumber /** @@ -613,7 +634,7 @@ export const number = () => anyNumber * @template {T} [O=T] * @template [I=unknown] * @extends {UnknownNumber, schema:Schema.Reader}>} - * @implements {Schema.Schema} + * @implements {Schema.NumberSchema} */ class RefinedNumber extends UnknownNumber { /** @@ -673,7 +694,7 @@ class GreaterThan extends API { if (input > number) { return input } else { - return error(`Expected ${input} < ${number}`) + return error(`Expected ${input} > ${number}`) } } toString() { @@ -695,9 +716,9 @@ const Integer = { */ read(input) { return Number.isInteger(input) - ? input - : new TypeError({ - expect: 'Integer', + ? /** @type {Schema.Integer} */ (input) + : typeError({ + expect: 'integer', actual: input, }) }, @@ -715,8 +736,8 @@ const Float = { */ read(number) { return Number.isFinite(number) - ? number - : new TypeError({ + ? /** @type {Schema.Float} */ (number) + : typeError({ expect: 'Float', actual: number, }) @@ -746,7 +767,7 @@ class UnknownString extends API { schema: other, }) - return rest + return /** @type {Schema.StringSchema} */ (rest) } /** * @template {string} Prefix @@ -804,7 +825,7 @@ class AnyString extends UnknownString { readWith(input) { return typeof input === 'string' ? input - : new TypeError({ expect: 'string', actual: input }) + : typeError({ expect: 'string', actual: input }) } } @@ -870,7 +891,6 @@ class EndsWith extends API { /** * @template {string} Suffix - * @template {string} Body * @param {Suffix} suffix * @returns {Schema.Schema<`${string}${Suffix}`, string>} */ @@ -926,17 +946,14 @@ class Literal extends API { : expect } get value() { - return this.settings + return /** @type {Exclude} */ (this.settings) } /** - * @param {T} value + * @template {Schema.NotUndefined} U + * @param {U} value */ - default(value = this.value) { - if (value === this.value) { - return new Default({ reader: this, value }) - } else { - throw new Error(`Provided default does not match this literal`) - } + default(value = /** @type {U} */ (this.value)) { + return super.default(value) } toString() { return `literal(${displayTypeName(this.value)})` @@ -952,8 +969,7 @@ class Literal extends API { export const literal = value => new Literal(value) /** - * @template {Schema.Reader} T - * @template {{[key:string]: T}} U + * @template {{[key:string]: Schema.Reader}} U * @template [I=unknown] * @extends {API, I, U>} */ @@ -964,8 +980,8 @@ class Struct extends API { * @returns {Schema.ReadResult>} */ readWith(input, shape) { - if (typeof input === 'object' && input !== null) { - return new TypeError({ + if (typeof input != 'object' || input === null || Array.isArray(input)) { + return typeError({ expect: 'object', actual: input, }) @@ -982,7 +998,7 @@ class Struct extends API { for (const [at, reader] of entries) { const result = reader.read(source[at]) if (result?.error) { - return new FieldError({ at, cause: result }) + return memberError({ at, cause: result }) } // skip undefined because they mess up CBOR and are generally useless. else if (result !== undefined) { @@ -1001,26 +1017,43 @@ class Struct extends API { toString() { return [ - `struct({`, - ...Object.entries(this.shape).map(([key, schema]) => - indent(`${key}: ${schema}`) + `struct({ `, + ...Object.entries(this.shape).map( + ([key, schema]) => `${key}: ${schema}, ` ), `})`, - ].join('\n') + ].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 {Schema.Reader|null|boolean|string|number} T - * @template {{[key:string]: T}} U - * @template {{[K in keyof U]: U[K] extends Schema.Reader ? U[K] : Schema.Reader}} V + * @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.Schema, I>} + * @returns {Schema.StructSchema} */ export const struct = fields => { - const shape = /** @type {{[K in keyof U]: unknown}} */ ({}) - /** @type {[keyof U, T][]} */ + 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) { @@ -1030,9 +1063,13 @@ export const struct = fields => { case 'boolean': shape[key] = literal(field) break - default: - shape[key] = field === null ? literal(field) : field + 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}` + ) } } @@ -1053,6 +1090,7 @@ class SchemaError extends Error { get error() { return true } + /* c8 ignore next 3 */ describe() { return this.name } @@ -1061,8 +1099,8 @@ class SchemaError extends Error { } toJSON() { - const { error, name, message } = this - return { error, name, message } + const { error, name, message, stack } = this + return { error, name, message, stack } } } @@ -1086,11 +1124,12 @@ class TypeError extends SchemaError { } /** - * @param {string} expect - * @param {unknown} actual + * @param {object} data + * @param {string} data.expect + * @param {unknown} data.actual * @returns {Schema.Error} */ -export const typeError = (expect, actual) => new TypeError({ expect, actual }) +export const typeError = data => new TypeError(data) /** * @@ -1105,6 +1144,7 @@ const displayTypeName = 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': @@ -1171,16 +1211,20 @@ class FieldError extends SchemaError { return 'FieldError' } describe() { - return `Object contains invalid field ${this.at}\n - ${this.cause.message}` + return [ + `Object contains invalid field "${this.at}":`, + li(this.cause.message), + ].join('\n') } } /** - * @param {string|number} at - * @param {Schema.Error} cause + * @param {object} options + * @param {string|number} options.at + * @param {Schema.Error} options.cause * @returns {Schema.Error} */ -export const memberError = (at, cause) => +export const memberError = ({ at, cause }) => typeof at === 'string' ? new FieldError({ at, cause }) : new ElementError({ at, cause }) @@ -1228,10 +1272,10 @@ class IntersectionError extends SchemaError { /** * @param {string} message */ -export const indent = (message, indent = ' ') => +const indent = (message, indent = ' ') => `${indent}${message.split('\n').join(`\n${indent}`)}` /** * @param {string} message */ -export const li = message => indent(`- ${message}`) +const li = message => indent(`- ${message}`) diff --git a/packages/validator/src/decoder/type.ts b/packages/validator/src/decoder/type.ts index 7e7f02b8..629e0df3 100644 --- a/packages/validator/src/decoder/type.ts +++ b/packages/validator/src/decoder/type.ts @@ -5,18 +5,21 @@ export interface Reader< I = unknown, X extends { error: true } = Error > { - read(input: I): ReadResult + read(input: I): Result } export type { Error } export type ReadResult = Result -export interface Schema extends Reader { +export interface Schema< + O extends unknown = unknown, + I extends unknown = unknown +> extends Reader { optional(): Schema nullable(): Schema array(): Schema - default(value: O): Schema + default(value: NotUndefined): DefaultSchema, I> or(other: Reader): Schema and(other: Reader): Schema refine(schema: Reader): Schema @@ -27,6 +30,16 @@ export interface Schema extends Reader { 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 } @@ -35,10 +48,32 @@ export interface LiteralSchema< T extends string | number | boolean | null, I = unknown > extends Schema { - default(value?: T): 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( @@ -47,37 +82,65 @@ export interface StringSchema endsWith( suffix: Suffix ): StringSchema - refine(schema: Reader): StringSchema + refine(schema: Reader): StringSchema +} + +declare const Marker: unique symbol +export type Branded = T & { + [Marker]: T } -export type Branded = T & Phantom<{ brand: Brand }> +export type Integer = number & Phantom<{ typeof: 'integer' }> +export type Float = number & Phantom<{ typeof: 'float' }> -export type Integer = Branded -export type Float = Branded +export type Infer = T extends Reader ? T : never -export type Infer> = T extends Reader< - infer T, - any -> +export type InferIntesection = { + [K in keyof U]: (input: Infer) => void +}[number] extends (input: infer T) => void ? T : never -export type InferIntesection< - U extends [Reader, ...Reader[]] -> = { [K in keyof U]: (input: Infer) => void }[number] extends ( - input: infer T -) => void +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 InferUnion< - U extends [Reader, ...Reader[]] -> = Infer +export type MarkEmptyOptional = RequiredKeys extends never + ? T | void + : T -export type InferTuple< - U extends [Reader, ...Reader[]] -> = { [K in keyof U]: Infer } +type MarkOptionals = Pick> & + Partial>> -export type InferStruct = { - [K in keyof U]: Infer -} +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] & {} + +type RequiredKeys2 = { + [k in keyof T]: undefined extends T[k] ? never : k +}[keyof T] & {} diff --git a/packages/validator/test/schema.spec.js b/packages/validator/test/schema.spec.js index bf67c169..6c8ba269 100644 --- a/packages/validator/test/schema.spec.js +++ b/packages/validator/test/schema.spec.js @@ -1,677 +1,38 @@ import { test, assert } from './test.js' import * as Schema from '../src/decoder/core.js' - -/** - * @param {unknown} [value] - * @return {Expect} - */ -export const pass = value => ({ value }) - -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} 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(`at ${at}.* expect.*${variants} .* got ${got}`, 'is'), - } - }, - } -) - -/** - * @param {unknown} source - * @returns {string} - */ -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)}` - )}}` +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 + ) } - default: - return String(source) - } -} - -/** - * @param {Partial} source - * @returns {Fixture} - */ - -const fixture = ({ in: input, got = input, array, ...expect }) => { - return { - 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 }, - 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: expect.tuple?.any || fail({ expect: 'array', got }), - strNstr: { - any: fail({ expect: 'array', got }), - ...expect.tuple?.strNstr, - }, - strNfloat: { - any: fail({ expect: 'array', got }), - ...expect.tuple?.strNstr, - }, - }, - } -} -/** - * @typedef {{error?:undefined, value:unknown}|{error:RegExp}} Expect - * @typedef {{ - * any?: Expect - * nullable?: Expect - * optional?: Expect - * default?: (input:unknown) => Expect - * }} ExpectGroup - * @typedef {{ - * in:any - * got: unknown - * any: Expect - * unknown: ExpectGroup, - * never: ExpectGroup - * string: ExpectGroup, - * number: 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 - * } - * }} Fixture - * - * @type {Partial[]} - */ -const source = [ - { - in: 'hello', - got: '"hello"', - string: { any: pass() }, - unknown: { any: pass() }, - literal: { hello: { any: pass() } }, - }, - { - in: new String('hello'), - got: 'object', - unknown: { any: pass() }, - }, - { - in: null, - got: 'null', - string: { - nullable: pass(null), - }, - number: { - 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), - }, - integer: { - optional: pass(), - default: value => pass(value), - }, - float: { - optional: pass(), - default: value => pass(value), - }, - unknown: { any: pass() }, - never: { - optional: pass(), - }, - literal: { - hello: { - optional: pass(), - default: pass, - }, - }, - }, - { - in: Infinity, - got: 'Infinity', - number: { any: pass() }, - integer: { any: fail({ expect: 'integer', got: 'Infinity' }) }, - float: { any: fail({ expect: 'float', got: 'Infinity' }) }, - unknown: { any: pass() }, - }, - { - in: NaN, - got: 'NaN', - number: { any: pass() }, - integer: { any: fail({ expect: 'integer', got: 'NaN' }) }, - float: { any: fail({ expect: 'float', got: 'NaN' }) }, - unknown: { any: pass() }, - }, - { - in: 101, - number: { any: pass() }, - integer: { any: pass() }, - float: { any: pass() }, - unknown: { any: pass() }, - }, - { - in: 9.8, - number: { any: pass() }, - integer: { any: fail({ expect: 'integer', got: '9.8' }) }, - float: { any: pass() }, - unknown: { any: pass() }, - }, - { - in: true, - unknown: { any: pass() }, - }, - { - in: false, - unknown: { 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() }, - }, - { - in: [], - got: 'array', - array: { any: pass() }, - unknown: { any: pass() }, - tuple: { - strNstr: { - 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' }), - }, - }, - }, - { - 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: { - strNstr: { 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: { - strNstr: { - any: fail.as('Array must contain exactly 2 elements'), - }, - }, - }, - { - 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: { - strNstr: { 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' }), - }, - }, - }, - { - 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: { - strNstr: { 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\\)' }), - }, - }, - }, - { - 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' }), - }, - }, - }, -] -const fixtures = source.map(fixture) - -for (const fixture of fixtures) { - const label = `${fixture.in === null ? 'null' : typeof fixture.in}` - - for (const { schema, expect } of [ - { - 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.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.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.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.integer()]), - expect: fixture.tuple.strNfloat?.any || fixture.tuple.any || fixture.any, - }, - ]) { - test(`${schema}.read(${display(fixture.in)})`, () => { - const result = schema.read(fixture.in) - - 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 ? fixture.in : expect.value - ) - } - }) + }) - test(`${schema}.from(${display(fixture.in)})`, () => { - if (expect.error) { - assert.throws(() => schema.from(fixture.in), expect.error) - } else { - assert.deepEqual( - schema.from(fixture.in), - // if expcted value is set to undefined use input - expect.value === undefined ? fixture.in : 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 + ) + } + }) - test(`${schema}.is(${display(fixture.in)})`, () => { - assert.equal(schema.is(fixture.in), !expect.error) - }) - } + unit(`${schema}.is(${inputLabel})`, () => { + assert.equal(schema.is(input), !expect.error) + }) } test('string startsWith & endsWith', () => { @@ -679,6 +40,11 @@ test('string startsWith & endsWith', () => { /** @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"`, @@ -739,6 +105,15 @@ test('string startsWith/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'), { @@ -813,9 +188,13 @@ test('string().refine', () => { 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 {string & {length: 11}} */ (hello) + return /** @type {In & {length: 11}} */ (hello) } else { return Schema.error(`Expected string with 11 chars`) } @@ -845,7 +224,7 @@ test('never().default()', () => { Schema.never() // @ts-expect-error - no value satisfies default .default('hello'), - /Can not call never\(\).default\(value\)/ + /Expected value of type never instead got "hello"/ ) }) @@ -855,7 +234,7 @@ test('literal("foo").default("bar") throws', () => { Schema.literal('foo') // @ts-expect-error - no value satisfies default .default('bar'), - /Provided default does not match this literal/ + /Expected literal "foo" instead got "bar"/ ) }) @@ -882,3 +261,383 @@ 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 DID 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 DID('key') + assert.equal(schema.toString(), 'new DID()') + 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 DID('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..da945eac --- /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/decoder/core.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) + } +} From 091b805d7de1d5174dd3091ef2ecc545ff885472 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 10 Oct 2022 15:43:41 -0700 Subject: [PATCH 05/13] chore: optimize typings --- packages/validator/src/decoder/core.js | 22 ++++++++++------------ packages/validator/src/decoder/type.ts | 6 +----- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/validator/src/decoder/core.js b/packages/validator/src/decoder/core.js index 648d0dc8..31b81558 100644 --- a/packages/validator/src/decoder/core.js +++ b/packages/validator/src/decoder/core.js @@ -565,11 +565,10 @@ class Boolean extends API { } } -/** - * @template {unknown} I - * @returns {Schema.Schema} - */ -export const boolean = () => new Boolean() +/** @type {Schema.Schema} */ +const anyBoolean = new Boolean() + +export const boolean = () => anyBoolean /** * @template {number} [O=number] @@ -622,11 +621,8 @@ class AnyNumber extends UnknownNumber { } } +/** @type {Schema.NumberSchema} */ const anyNumber = new AnyNumber() - -/** - * @returns {Schema.NumberSchema} - */ export const number = () => anyNumber /** @@ -727,7 +723,8 @@ const Integer = { }, } -export const integer = () => anyNumber.refine(Integer) +const anyInteger = anyNumber.refine(Integer) +export const integer = () => anyInteger const Float = { /** @@ -746,7 +743,9 @@ const Float = { return 'Float' }, } -export const float = () => anyNumber.refine(Float) + +const anyFloat = anyNumber.refine(Float) +export const float = () => anyFloat /** * @template {string} [O=string] @@ -831,7 +830,6 @@ class AnyString extends UnknownString { /** @type {Schema.StringSchema} */ const anyString = new AnyString() - export const string = () => anyString /** diff --git a/packages/validator/src/decoder/type.ts b/packages/validator/src/decoder/type.ts index 629e0df3..71286797 100644 --- a/packages/validator/src/decoder/type.ts +++ b/packages/validator/src/decoder/type.ts @@ -59,7 +59,7 @@ export interface NumberSchema< greaterThan(n: number): NumberSchema lessThan(n: number): NumberSchema - refine(schema: Reader): NumberSchema + refine(schema: Reader): NumberSchema } export interface StructSchema< @@ -140,7 +140,3 @@ type RequiredKeys = { type OptionalKeys = { [k in keyof T]: undefined extends T[k] ? k : never }[keyof T] & {} - -type RequiredKeys2 = { - [k in keyof T]: undefined extends T[k] ? never : k -}[keyof T] & {} From 3e00f56eb1606a4f044ec91009ff336a2e0693ab Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 10 Oct 2022 17:00:51 -0700 Subject: [PATCH 06/13] chore: rename decoder to schema --- 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/lib.js | 6 +- packages/validator/src/schema.js | 5 + packages/validator/src/schema/did.js | 37 +++++ packages/validator/src/schema/link.js | 77 +++++++++++ .../src/{decoder/core.js => schema/schema.js} | 1 + packages/validator/src/schema/text.js | 34 +++++ .../validator/src/{decoder => schema}/type.js | 0 .../validator/src/{decoder => schema}/type.ts | 2 +- packages/validator/src/schema/uri.js | 51 +++++++ .../{decoder.spec.js => extra-schema.spec.js} | 127 ++++++++++-------- packages/validator/test/schema.spec.js | 2 +- packages/validator/test/schema/fixtures.js | 2 +- 17 files changed, 283 insertions(+), 347 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 rename packages/validator/src/{decoder/core.js => schema/schema.js} (99%) create mode 100644 packages/validator/src/schema/text.js rename packages/validator/src/{decoder => schema}/type.js (100%) rename packages/validator/src/{decoder => schema}/type.ts (98%) create mode 100644 packages/validator/src/schema/uri.js rename packages/validator/test/{decoder.spec.js => extra-schema.spec.js} (63%) 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/lib.js b/packages/validator/src/lib.js index e275eae7..7af3c4bc 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -15,11 +15,7 @@ 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' const empty = () => [] 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..4e32fafa --- /dev/null +++ b/packages/validator/src/schema/did.js @@ -0,0 +1,37 @@ +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 = {}) => + 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..ac58506b --- /dev/null +++ b/packages/validator/src/schema/link.js @@ -0,0 +1,77 @@ +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 + } + } + } +} + +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) diff --git a/packages/validator/src/decoder/core.js b/packages/validator/src/schema/schema.js similarity index 99% rename from packages/validator/src/decoder/core.js rename to packages/validator/src/schema/schema.js index 31b81558..7518e383 100644 --- a/packages/validator/src/decoder/core.js +++ b/packages/validator/src/schema/schema.js @@ -50,6 +50,7 @@ export class API { /** * @param {unknown} value + * @return {T} */ from(value) { const result = this.read(/** @type {I} */ (value)) diff --git a/packages/validator/src/schema/text.js b/packages/validator/src/schema/text.js new file mode 100644 index 00000000..05837e6c --- /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 = () => Text + +/** + * @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/decoder/type.js b/packages/validator/src/schema/type.js similarity index 100% rename from packages/validator/src/decoder/type.js rename to packages/validator/src/schema/type.js diff --git a/packages/validator/src/decoder/type.ts b/packages/validator/src/schema/type.ts similarity index 98% rename from packages/validator/src/decoder/type.ts rename to packages/validator/src/schema/type.ts index 71286797..0bce1e55 100644 --- a/packages/validator/src/decoder/type.ts +++ b/packages/validator/src/schema/type.ts @@ -82,7 +82,7 @@ export interface StringSchema endsWith( suffix: Suffix ): StringSchema - refine(schema: Reader): StringSchema + refine(schema: Reader): StringSchema } declare const Marker: unique symbol diff --git a/packages/validator/src/schema/uri.js b/packages/validator/src/schema/uri.js new file mode 100644 index 00000000..fe71b41b --- /dev/null +++ b/packages/validator/src/schema/uri.js @@ -0,0 +1,51 @@ +import * as API from '@ucanto/interface' +import * as Schema from './schema.js' + +/** + * @template {API.Protocol} P + * @typedef {{protocol?: P}} Options + */ + +/** + * @template {API.Protocol} P + * @extends {Schema.API, unknown, Options

>} + */ +class URISchema extends Schema.API { + /** + * @param {unknown} input + * @param {{protocol?: P}} options + */ + 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} U + * @param {Options} options + */ +export const match = (options = {}) => new URISchema(options) diff --git a/packages/validator/test/decoder.spec.js b/packages/validator/test/extra-schema.spec.js similarity index 63% rename from packages/validator/test/decoder.spec.js rename to packages/validator/test/extra-schema.spec.js index 924b2ebd..d1653274 100644 --- a/packages/validator/test/decoder.spec.js +++ b/packages/validator/test/extra-schema.spec.js @@ -1,4 +1,4 @@ -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' @@ -12,7 +12,7 @@ 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) }) } } @@ -40,8 +40,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 +67,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 +131,28 @@ 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.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})`, () => { - const link = Link.optional() - assert.containSubset(link.decode(input), out5 || input) + test(`Link.optional().read(${input})`, () => { + const link = Link.match().optional() + assert.containSubset(link.read(input), out5 || input) }) } } @@ -157,18 +160,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 +185,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,34 +203,40 @@ 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) }) } } { - /** @type {[{pattern?:RegExp}, unknown, unknown][]} */ + /** @type {[{pattern:RegExp}, unknown, unknown][]} */ const dataset = [ - [{}, undefined, undefined], - [{}, null, { message: 'Expected a string but got null instead' }], - [{}, 'hello', 'hello'], + [{ pattern: /./ }, undefined, undefined], [ - {}, + { pattern: /./ }, + null, + { message: 'Expected value of type string instead got null' }, + ], + [{ pattern: /./ }, 'hello', 'hello'], + [ + { pattern: /./ }, 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 +247,15 @@ 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})`, () => { + assert.containSubset(Text.match(options).optional().read(input), out) }) } } @@ -247,19 +263,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 +291,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 +312,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 +327,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 +354,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.optional({ method: "${options.method}" }).decode(${input})`, () => { - assert.containSubset(DID.optional(options).decode(input), out) + test(`DID.match({ method: "${options.method}" }).optional().decode(${input})`, () => { + assert.containSubset(DID.match(options).optional().read(input), out) }) } } diff --git a/packages/validator/test/schema.spec.js b/packages/validator/test/schema.spec.js index 6c8ba269..cc43ddac 100644 --- a/packages/validator/test/schema.spec.js +++ b/packages/validator/test/schema.spec.js @@ -1,5 +1,5 @@ import { test, assert } from './test.js' -import * as Schema from '../src/decoder/core.js' +import * as Schema from '../src/schema.js' import fixtures from './schema/fixtures.js' for (const { input, schema, expect, inputLabel, skip, only } of fixtures()) { diff --git a/packages/validator/test/schema/fixtures.js b/packages/validator/test/schema/fixtures.js index da945eac..9f3b911c 100644 --- a/packages/validator/test/schema/fixtures.js +++ b/packages/validator/test/schema/fixtures.js @@ -1,5 +1,5 @@ import { pass, fail, display } from './util.js' -import * as Schema from '../../src/decoder/core.js' +import * as Schema from '../../src/schema.js' /** * @typedef {import('./util.js').Expect} Expect From 26bfe6cb2d6fecb76314074889f5a6e42b8b9e14 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 11 Oct 2022 01:00:34 -0700 Subject: [PATCH 07/13] feat: update decoders to new API --- packages/interface/src/capability.ts | 17 +- packages/validator/src/capability.js | 36 +- packages/validator/src/lib.js | 1 + packages/validator/src/schema/did.js | 6 +- packages/validator/src/schema/link.js | 3 +- packages/validator/src/schema/text.js | 2 +- packages/validator/src/schema/uri.js | 28 +- packages/validator/test/capability.spec.js | 541 ++++++++++++++++++- packages/validator/test/extra-schema.spec.js | 37 +- 9 files changed, 620 insertions(+), 51 deletions(-) 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/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/lib.js b/packages/validator/src/lib.js index 7af3c4bc..c0c4f81a 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -16,6 +16,7 @@ export { Failure, UnavailableProof, MalformedCapability } export { capability } from './capability.js' export * from './schema.js' +export * as Schema from './schema.js' const empty = () => [] diff --git a/packages/validator/src/schema/did.js b/packages/validator/src/schema/did.js index 4e32fafa..69277c58 100644 --- a/packages/validator/src/schema/did.js +++ b/packages/validator/src/schema/did.js @@ -33,5 +33,7 @@ export const read = input => schema.read(input) * @template {string} Method * @param {{method?: Method}} options */ -export const match = (options = {}) => - Schema.string().refine(new DIDSchema(options.method)) +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 index ac58506b..7a91ac0e 100644 --- a/packages/validator/src/schema/link.js +++ b/packages/validator/src/schema/link.js @@ -58,7 +58,8 @@ class LinkSchema extends Schema.API { } } -const schema = new LinkSchema({}) +/** @type {Schema.Schema, unknown>} */ +export const schema = new LinkSchema({}) export const link = () => schema diff --git a/packages/validator/src/schema/text.js b/packages/validator/src/schema/text.js index 05837e6c..546950bb 100644 --- a/packages/validator/src/schema/text.js +++ b/packages/validator/src/schema/text.js @@ -2,7 +2,7 @@ import * as Schema from './schema.js' const schema = Schema.string() -export const text = () => Text +export const text = () => schema /** * @param {{pattern: RegExp}} options diff --git a/packages/validator/src/schema/uri.js b/packages/validator/src/schema/uri.js index fe71b41b..e6563740 100644 --- a/packages/validator/src/schema/uri.js +++ b/packages/validator/src/schema/uri.js @@ -2,18 +2,19 @@ import * as API from '@ucanto/interface' import * as Schema from './schema.js' /** - * @template {API.Protocol} P - * @typedef {{protocol?: P}} Options + * @template {API.Protocol} [P=API.Protocol] + * @typedef {{protocol: P}} Options */ /** - * @template {API.Protocol} P - * @extends {Schema.API, unknown, Options

>} + * @template {Options} O + * @extends {Schema.API, unknown, Partial>} */ class URISchema extends Schema.API { /** * @param {unknown} input - * @param {{protocol?: P}} options + * @param {Partial} options + * @returns {Schema.ReadResult>} */ readWith(input, { protocol } = {}) { if (typeof input !== 'string' && !(input instanceof URL)) { @@ -27,7 +28,7 @@ class URISchema extends Schema.API { if (protocol != null && url.protocol !== protocol) { return Schema.error(`Expected ${protocol} URI instead got ${url.href}`) } else { - return /** @type {API.URI

} */ (url.href) + return /** @type {API.URI} */ (url.href) } } catch (_) { return Schema.error(`Invalid URI`) @@ -45,7 +46,16 @@ export const uri = () => schema export const read = input => schema.read(input) /** - * @template {API.Protocol} U - * @param {Options} options + * @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 match = (options = {}) => new URISchema(options) +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/extra-schema.spec.js b/packages/validator/test/extra-schema.spec.js index d1653274..fa45288a 100644 --- a/packages/validator/test/extra-schema.spec.js +++ b/packages/validator/test/extra-schema.spec.js @@ -1,6 +1,7 @@ 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}][]} */ @@ -13,10 +14,22 @@ import { CID } from 'multiformats' for (const [input, expect] of dataset) { test(`URI.decode(${JSON.stringify(input)}}`, () => { 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 = [ @@ -151,7 +164,7 @@ import { CID } from 'multiformats' }) test(`Link.optional().read(${input})`, () => { - const link = Link.match().optional() + const link = Link.link().optional() assert.containSubset(link.read(input), out5 || input) }) } @@ -215,17 +228,13 @@ import { CID } from 'multiformats' } { - /** @type {[{pattern:RegExp}, unknown, unknown][]} */ + /** @type {[{pattern?:RegExp}, unknown, unknown][]} */ const dataset = [ - [{ pattern: /./ }, undefined, undefined], - [ - { pattern: /./ }, - null, - { message: 'Expected value of type string instead got null' }, - ], - [{ pattern: /./ }, 'hello', 'hello'], + [{}, undefined, undefined], + [{}, null, { message: 'Expected value of type string instead got null' }], + [{}, 'hello', 'hello'], [ - { pattern: /./ }, + {}, new String('hello'), { message: 'Expected value of type string instead got object' }, ], @@ -255,7 +264,10 @@ import { CID } from 'multiformats' for (const [options, input, out] of dataset) { test(`Text.match({ pattern: ${options.pattern} }).read(${input})`, () => { - assert.containSubset(Text.match(options).optional().read(input), out) + const schema = options.pattern + ? Text.match({ pattern: options.pattern }) + : Text.text() + assert.containSubset(schema.optional().read(input), out) }) } } @@ -360,7 +372,8 @@ import { CID } from 'multiformats' for (const [options, input, out] of dataset) { test(`DID.match({ method: "${options.method}" }).optional().decode(${input})`, () => { - assert.containSubset(DID.match(options).optional().read(input), out) + const schema = options.method ? DID.match(options) : DID.did() + assert.containSubset(schema.optional().read(input), out) }) } } From 7e165a83faa134c545175312d62cf3a45a03ed6e Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 11 Oct 2022 01:01:22 -0700 Subject: [PATCH 08/13] feat: increase coverage to 100% --- packages/validator/package.json | 4 +-- packages/validator/src/error.js | 17 +++++++---- packages/validator/src/lib.js | 4 +-- packages/validator/test/error.spec.js | 42 +++++++++++++++++++++++++++ packages/validator/test/lib.spec.js | 17 ++++++++++- 5 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 packages/validator/test/error.spec.js 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/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 c0c4f81a..ba06a6eb 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -330,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/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/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 () => { From a1d70d8713e67508dd58807ab8e45dc058de074d Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 11 Oct 2022 01:10:10 -0700 Subject: [PATCH 09/13] fix: regressions in other packages --- packages/server/test/server.spec.js | 4 ++-- packages/server/test/service/store.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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) { From c848a4b1b855fb678469ccd4eabb95da97d6ffa8 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 11 Oct 2022 01:18:08 -0700 Subject: [PATCH 10/13] fix: bundler issue --- packages/validator/test/schema.spec.js | 64 ++++++++++++++------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/packages/validator/test/schema.spec.js b/packages/validator/test/schema.spec.js index cc43ddac..669ab01e 100644 --- a/packages/validator/test/schema.spec.js +++ b/packages/validator/test/schema.spec.js @@ -437,43 +437,47 @@ test('tuple', () => { }) test('extend API', () => { - /** - * @template {string} M - * @implements {Schema.Schema<`did:${M}:${string}`, string>} - * @extends {Schema.API<`did:${M}:${string}`, string, M>} - */ - class DID extends Schema.API { + { /** - * @param {string} source - * @param {M} method + * @template {string} M + * @implements {Schema.Schema<`did:${M}:${string}`, string>} + * @extends {Schema.API<`did:${M}:${string}`, string, M>} */ - 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}`) + 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 DID('key') - assert.equal(schema.toString(), 'new DID()') - assert.match( - String( - // @ts-expect-error - schema.read(54) - ), - /Expected did:key URI/ - ) + 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/ - ) + assert.match( + String(schema.read('did:echo:foo')), + /Expected did:key URI instead got did:echo:foo/ + ) - const didKey = Schema.string().refine(new DID('key')) - assert.match(String(didKey.read(54)), /Expect.* string instead got 54/is) + const didKey = Schema.string().refine(new DIDString('key')) + assert.match(String(didKey.read(54)), /Expect.* string instead got 54/is) + } }) test('errors', () => { From 62ed88e9ecedef7f6777dac1c5b218af5b2c8b37 Mon Sep 17 00:00:00 2001 From: Hugo Dias Date: Fri, 14 Oct 2022 16:09:37 +0100 Subject: [PATCH 11/13] Update packages/validator/src/schema/schema.js --- packages/validator/src/schema/schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validator/src/schema/schema.js b/packages/validator/src/schema/schema.js index 7518e383..88eafa59 100644 --- a/packages/validator/src/schema/schema.js +++ b/packages/validator/src/schema/schema.js @@ -839,7 +839,7 @@ export const string = () => anyString * @extends {API} * @implements {Schema.Schema} */ -class StratsWith extends API { +class StartsWith extends API { /** * @param {Body} input * @param {Prefix} prefix From 207c9de0e2fa744b19e5c4a293835bd37c86d1be Mon Sep 17 00:00:00 2001 From: Hugo Dias Date: Fri, 14 Oct 2022 19:39:25 +0100 Subject: [PATCH 12/13] fix: fixes for #108 (#116) --- packages/principal/package.json | 8 ++++++-- packages/principal/src/ed25519/signer.js | 4 ++++ packages/principal/src/ed25519/verifier.js | 2 +- packages/validator/src/schema/link.js | 2 ++ packages/validator/src/schema/schema.js | 2 +- 5 files changed, 14 insertions(+), 4 deletions(-) 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/validator/src/schema/link.js b/packages/validator/src/schema/link.js index 7a91ac0e..9c74d3fd 100644 --- a/packages/validator/src/schema/link.js +++ b/packages/validator/src/schema/link.js @@ -76,3 +76,5 @@ 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 index 88eafa59..c952fde8 100644 --- a/packages/validator/src/schema/schema.js +++ b/packages/validator/src/schema/schema.js @@ -863,7 +863,7 @@ class StartsWith extends API { * @param {Prefix} prefix * @returns {Schema.Schema<`${Prefix}${string}`, string>} */ -export const startsWith = prefix => new StratsWith(prefix) +export const startsWith = prefix => new StartsWith(prefix) /** * @template {string} Suffix From daf8c7bff80484b5d64674cd1707a719859f8e77 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Sat, 15 Oct 2022 14:47:05 -0700 Subject: [PATCH 13/13] fix: coverage for Link.optional --- packages/validator/test/extra-schema.spec.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/validator/test/extra-schema.spec.js b/packages/validator/test/extra-schema.spec.js index fa45288a..af6fcdf2 100644 --- a/packages/validator/test/extra-schema.spec.js +++ b/packages/validator/test/extra-schema.spec.js @@ -148,6 +148,11 @@ test('URI.from', () => { assert.containSubset(Link.read(input), out1 || 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.read(input), out2 || input) @@ -164,7 +169,7 @@ test('URI.from', () => { }) test(`Link.optional().read(${input})`, () => { - const link = Link.link().optional() + const link = Link.optional() assert.containSubset(link.read(input), out5 || input) }) }