// Values checked at run time and typed at compile time. A descriptor states its property values, directive payloads // and declaration options with these; parse() returns the value or throws a SchemaError that names where it went // wrong. Written here rather than taken from a package: mqtt stays the only runtime dependency. /** A value that does not fit its schema: path is where ("payload.targetSetpoint.scale"), problem is what. */ export class SchemaError extends Error { constructor( readonly path: string, readonly problem: string ) { super(path ? `${path}: ${problem}` : problem); this.name = "SchemaError"; } } export interface Schema { /** What the schema accepts, in the words of an error message: "an integer from 0 to 100", "ON | OFF". */ readonly expects: string; /** The checked value. path names it in the error message. */ parse(input: unknown, path?: string): T; } /** A schema that accepts a missing value; s.object() makes its key optional in the inferred type. */ export interface OptionalSchema extends Schema { readonly optional: true; } export interface EnumSchema extends Schema { readonly values: readonly V[]; } /** The type a schema produces. */ export type Infer = S extends Schema ? T : never; export type Shape = Record>; type OptionalKeys = { [K in keyof S]: S[K] extends OptionalSchema ? K : never }[keyof S]; export type InferShape = { [K in Exclude>]: Infer } & { [K in OptionalKeys]?: Infer; }; export interface NumberRules { min?: number; max?: number; /** Greater than, the bound itself excluded. */ gt?: number; integer?: boolean; } export interface Temperature { value: number; scale: "CELSIUS" | "FAHRENHEIT" | "KELVIN"; } export interface TimeInterval { start?: string; end?: string; duration?: string; } // The input as an error message shows it: short, and quoted when it is text. function shown(input: unknown): string { if (input === undefined) return "nothing"; if (typeof input === "function") return "a function"; const text = JSON.stringify(input) ?? String(input); return text.length > 60 ? `${text.slice(0, 57)}...` : text; } /** The error of a value that is not what a schema expects. */ export function mismatch(path: string, expects: string, input: unknown): SchemaError { return new SchemaError(path, `expected ${expects}, got ${shown(input)}`); } /** The path of a key of the object at path. */ export const at = (path: string, key: string): string => (path ? `${path}.${key}` : key); function isRecord(input: unknown): input is Record { return typeof input === "object" && input !== null && !Array.isArray(input); } function schema(expects: string, accepts: (input: unknown) => boolean): Schema { return { expects, parse(input, path = "") { if (!accepts(input)) throw mismatch(path, expects, input); return input as T; }, }; } function numberText({ min, max, gt, integer }: NumberRules): string { const kind = integer ? "an integer" : "a number"; if (min !== undefined && max !== undefined) return `${kind} from ${min} to ${max}`; if (min !== undefined) return `${kind} of ${min} or more`; if (max !== undefined) return `${kind} of ${max} or less`; if (gt !== undefined) return `${kind} greater than ${gt}`; return kind; } function number(rules: NumberRules = {}): Schema { const { min = -Infinity, max = Infinity, gt = -Infinity, integer = false } = rules; return schema(numberText(rules), (input) => typeof input === "number" && Number.isFinite(input) && input >= min && input <= max && input > gt && (!integer || Number.isInteger(input))); } function string(rules: { min?: number; max?: number; pattern?: RegExp; expects?: string } = {}): Schema { const { min = 0, max = Infinity, pattern } = rules; const length = max === Infinity ? (min > 0 ? ` of ${min} or more characters` : "") : ` of ${min} to ${max} characters`; return schema(rules.expects ?? `a string${length}`, (input) => typeof input === "string" && input.length >= min && input.length <= max && (!pattern || pattern.test(input))); } function boolean(): Schema { return schema("true or false", (input) => typeof input === "boolean"); } function literal(value: V): Schema { return schema(JSON.stringify(value), (input) => input === value); } function enumeration(...values: V): EnumSchema { return oneOf(values, values.join(" | ")); } /** One of a list too long to print in an error message: expects says what the list is. */ function oneOf(values: readonly V[], expects: string): EnumSchema { return { ...schema(expects, (input) => values.includes(input as V)), values }; } function unknown(): Schema { return schema("any value", () => true); } function optional(inner: Schema): OptionalSchema { return { expects: inner.expects, optional: true, parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)), }; } /** A value that is made when none is given: the time of an event, which is now unless the caller says when. */ function defaulted(inner: Schema, make: () => T): Schema { return { expects: inner.expects, parse: (input, path = "") => (input === undefined ? make() : inner.parse(input, path)), }; } function nullable(inner: Schema): Schema { const expects = `${inner.expects} or null`; return { expects, parse(input, path = "") { if (input === null) return null; try { return inner.parse(input, path); } catch (err) { // The value itself is of the wrong kind: null was a choice too. An error further in keeps its own path. if (err instanceof SchemaError && err.path === path) throw mismatch(path, expects, input); throw err; } }, }; } function array(item: Schema, rules: { min?: number; max?: number } = {}): Schema { const { min = 0, max = Infinity } = rules; const count = max === Infinity ? (min > 0 ? ` with ${min} or more entries` : "") : ` with ${min} to ${max} entries`; const expects = `a list${count}`; return { expects, parse(input, path = "") { if (!Array.isArray(input) || input.length < min || input.length > max) throw mismatch(path, expects, input); return input.map((entry, i) => item.parse(entry, `${path}[${i}]`)); }, }; } /** * An object with the keys of shape. Keys the shape does not name are kept as they are: a field Alexa adds to a * directive reaches the handler. unknownKeys "reject" is for what a developer writes, where such a key is a typo. */ function object(shape: S, rules: { unknownKeys?: "keep" | "reject" } = {}): Schema> { const known = Object.keys(shape); const expects = known.length > 0 ? `an object with ${known.join(", ")}` : "an object"; return { expects, parse(input, path = "") { if (!isRecord(input)) throw mismatch(path, expects, input); const others = Object.keys(input).filter((key) => !known.includes(key)); if (others.length > 0 && rules.unknownKeys === "reject") { throw new SchemaError(at(path, others[0]), `unknown key, the known ones are ${known.join(", ") || "none"}`); } const parsed: Record = {}; for (const key of known) { const value = shape[key].parse(input[key], at(path, key)); if (value !== undefined) parsed[key] = value; } for (const key of others) parsed[key] = input[key]; return parsed as InferShape; }, }; } // alexa-property-schemas.html "Temperature", "Temperature scales" function temperature(): Schema { return object({ value: number(), scale: enumeration("CELSIUS", "FAHRENHEIT", "KELVIN") }); } // alexa-property-schemas.html "DateTime": UTC, no offsets. The seconds are optional here because the TimeInterval // examples on the same page leave them out ("2017-10-04T14:00Z"). const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/; function dateTime(): Schema { return schema("a UTC time like 2017-08-30T01:18:21Z", (input) => typeof input === "string" && DATE_TIME.test(input) && !Number.isNaN(Date.parse(input))); } // alexa-property-schemas.html "Duration": the time portion of ISO 8601, negative for a delta ("PT-30S") const DURATION = /^PT(?=.)(-?\d+H)?(-?\d+M)?(-?\d+S)?$/; function duration(): Schema { return string({ pattern: DURATION, expects: "a duration like PT3M15S" }); } // alexa-property-schemas.html "TimeInterval": "Specify one or two of the time interval fields. If you specify all // three fields, an error occurs." function timeInterval(): Schema { const fields = object({ start: optional(dateTime()), end: optional(dateTime()), duration: optional(duration()) }); const expects = "a time interval with one or two of start, end, duration"; return { expects, parse(input, path = "") { const interval = fields.parse(input, path); const given = [interval.start, interval.end, interval.duration].filter((field) => field !== undefined).length; if (given < 1 || given > 2) throw mismatch(path, expects, input); return interval; }, }; } export const s = { string, number, boolean, literal, enum: enumeration, oneOf, unknown, optional, defaulted, nullable, array, object, temperature, dateTime, duration, timeInterval, };