registry: describe an Alexa interface as data
src/registry/ holds what the library knows about an interface: namespace, version, the page it was read from, properties with their value schemas and directives with their payload schemas. Five interfaces are described (Alexa, PowerController, BrightnessController, TemperatureSensor, EndpointHealth); the other 65 names of AlexaInterfaceType are stubs with the version and property names of 1.5.2. schema.ts is the run-time check behind it (241 lines, no new dependency), catalog.ts the vocabularies of the pages: 103 assets (23 units), 6 actions, 9 states, 56 display categories, 22 reserved words, 73 error types under 11 namespaces. AlexaInterface.getVersion() and getProps() read the registry; the two switch statements are gone (-167 lines). On the wire: Alexa.EndpointHealth is announced at 3.1 (was 3.3; the page is titled 3.1 and no page mentions 3.3), and TimeHoldController and Camera.LiveViewController at 3 and 1.7 (1.5.2 sent the string "UNKNOWN"). DisplayCategory gains VACUUM. New exports: registry, DeclarationError, SchemaError, Assets, Units, Actions, States, DisplayCategories and the descriptor types. Tests: 20 JSON examples of the five pages under test/fixtures/alexa-docs; every directive payload and property value in them parses with its descriptor. npm test: 57 pass (was 30) in 10.8 s, also on Node 18.20.8 and 20.20.2. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
128ca35c2a
commit
aa0ffd64ea
93 changed files with 4058 additions and 490 deletions
241
src/registry/schema.ts
Normal file
241
src/registry/schema.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// 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<T> {
|
||||
/** 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<T> extends Schema<T | undefined> {
|
||||
readonly optional: true;
|
||||
}
|
||||
|
||||
export interface EnumSchema<V> extends Schema<V> {
|
||||
readonly values: readonly V[];
|
||||
}
|
||||
|
||||
/** The type a schema produces. */
|
||||
export type Infer<S> = S extends Schema<infer T> ? T : never;
|
||||
|
||||
export type Shape = Record<string, Schema<any>>;
|
||||
type OptionalKeys<S extends Shape> = { [K in keyof S]: S[K] extends OptionalSchema<any> ? K : never }[keyof S];
|
||||
export type InferShape<S extends Shape> = { [K in Exclude<keyof S, OptionalKeys<S>>]: Infer<S[K]> } & {
|
||||
[K in OptionalKeys<S>]?: Infer<S[K]>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function mismatch(path: string, expects: string, input: unknown): SchemaError {
|
||||
return new SchemaError(path, `expected ${expects}, got ${shown(input)}`);
|
||||
}
|
||||
|
||||
const at = (path: string, key: string): string => (path ? `${path}.${key}` : key);
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input);
|
||||
}
|
||||
|
||||
function schema<T>(expects: string, accepts: (input: unknown) => boolean): Schema<T> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
return schema("true or false", (input) => typeof input === "boolean");
|
||||
}
|
||||
|
||||
function literal<V extends string | number | boolean | null>(value: V): Schema<V> {
|
||||
return schema(JSON.stringify(value), (input) => input === value);
|
||||
}
|
||||
|
||||
function enumeration<V extends readonly string[]>(...values: V): EnumSchema<V[number]> {
|
||||
return { ...schema<V[number]>(values.join(" | "), (input) => values.includes(input as string)), values };
|
||||
}
|
||||
|
||||
function unknown(): Schema<unknown> {
|
||||
return schema("any value", () => true);
|
||||
}
|
||||
|
||||
function optional<T>(inner: Schema<T>): OptionalSchema<T> {
|
||||
return {
|
||||
expects: inner.expects,
|
||||
optional: true,
|
||||
parse: (input, path = "") => (input === undefined ? undefined : inner.parse(input, path)),
|
||||
};
|
||||
}
|
||||
|
||||
function nullable<T>(inner: Schema<T>): Schema<T | null> {
|
||||
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<T>(item: Schema<T>, rules: { min?: number; max?: number } = {}): Schema<T[]> {
|
||||
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<S extends Shape>(shape: S, rules: { unknownKeys?: "keep" | "reject" } = {}): Schema<InferShape<S>> {
|
||||
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<string, unknown> = {};
|
||||
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<S>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// alexa-property-schemas.html "Temperature", "Temperature scales"
|
||||
function temperature(): Schema<Temperature> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<TimeInterval> {
|
||||
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,
|
||||
unknown,
|
||||
optional,
|
||||
nullable,
|
||||
array,
|
||||
object,
|
||||
temperature,
|
||||
dateTime,
|
||||
duration,
|
||||
timeInterval,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue