443 lines
10 KiB
TypeScript
443 lines
10 KiB
TypeScript
/*
|
|
This file is part of GNU Taler
|
|
(C) 2018-2019 GNUnet e.V.
|
|
|
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
|
terms of the GNU General Public License as published by the Free Software
|
|
Foundation; either version 3, or (at your option) any later version.
|
|
|
|
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License along with
|
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
|
*/
|
|
|
|
/**
|
|
* Type-safe codecs for converting from/to JSON.
|
|
*/
|
|
|
|
/* eslint-disable @typescript-eslint/ban-types */
|
|
|
|
/**
|
|
* Error thrown when decoding fails.
|
|
*/
|
|
export class DecodingError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
Object.setPrototypeOf(this, DecodingError.prototype);
|
|
this.name = "DecodingError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Context information to show nicer error messages when decoding fails.
|
|
*/
|
|
export interface Context {
|
|
readonly path?: string[];
|
|
}
|
|
|
|
export function renderContext(c?: Context): string {
|
|
const p = c?.path;
|
|
if (p) {
|
|
return p.join(".");
|
|
} else {
|
|
return "(unknown)";
|
|
}
|
|
}
|
|
|
|
function joinContext(c: Context | undefined, part: string): Context {
|
|
const path = c?.path ?? [];
|
|
return {
|
|
path: path.concat([part]),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A codec converts untyped JSON to a typed object.
|
|
*/
|
|
export interface Codec<V> {
|
|
/**
|
|
* Decode untyped JSON to an object of type [[V]].
|
|
*/
|
|
readonly decode: (x: any, c?: Context) => V;
|
|
}
|
|
|
|
type SingletonRecord<K extends keyof any, V> = { [Y in K]: V };
|
|
|
|
interface Prop {
|
|
name: string;
|
|
codec: Codec<any>;
|
|
}
|
|
|
|
interface Alternative {
|
|
tagValue: any;
|
|
codec: Codec<any>;
|
|
}
|
|
|
|
class ObjectCodecBuilder<OutputType, PartialOutputType> {
|
|
private propList: Prop[] = [];
|
|
|
|
/**
|
|
* Define a property for the object.
|
|
*/
|
|
property<K extends keyof OutputType & string, V extends OutputType[K]>(
|
|
x: K,
|
|
codec: Codec<V>,
|
|
): ObjectCodecBuilder<OutputType, PartialOutputType & SingletonRecord<K, V>> {
|
|
if (!codec) {
|
|
throw Error("inner codec must be defined");
|
|
}
|
|
this.propList.push({ name: x, codec: codec });
|
|
return this as any;
|
|
}
|
|
|
|
/**
|
|
* Return the built codec.
|
|
*
|
|
* @param objectDisplayName name of the object that this codec operates on,
|
|
* used in error messages.
|
|
*/
|
|
build(objectDisplayName: string): Codec<PartialOutputType> {
|
|
const propList = this.propList;
|
|
return {
|
|
decode(x: any, c?: Context): PartialOutputType {
|
|
if (!c) {
|
|
c = {
|
|
path: [`(${objectDisplayName})`],
|
|
};
|
|
}
|
|
if (typeof x !== "object") {
|
|
throw new DecodingError(
|
|
`expected object for ${objectDisplayName} at ${renderContext(
|
|
c,
|
|
)} but got ${typeof x}`,
|
|
);
|
|
}
|
|
const obj: any = {};
|
|
for (const prop of propList) {
|
|
const propRawVal = x[prop.name];
|
|
const propVal = prop.codec.decode(
|
|
propRawVal,
|
|
joinContext(c, prop.name),
|
|
);
|
|
obj[prop.name] = propVal;
|
|
}
|
|
return obj as PartialOutputType;
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
class UnionCodecBuilder<
|
|
TargetType,
|
|
TagPropertyLabel extends keyof TargetType,
|
|
CommonBaseType,
|
|
PartialTargetType,
|
|
> {
|
|
private alternatives = new Map<any, Alternative>();
|
|
|
|
constructor(
|
|
private discriminator: TagPropertyLabel,
|
|
private baseCodec?: Codec<CommonBaseType>,
|
|
) {}
|
|
|
|
/**
|
|
* Define a property for the object.
|
|
*/
|
|
alternative<V>(
|
|
tagValue: TargetType[TagPropertyLabel],
|
|
codec: Codec<V>,
|
|
): UnionCodecBuilder<
|
|
TargetType,
|
|
TagPropertyLabel,
|
|
CommonBaseType,
|
|
PartialTargetType | V
|
|
> {
|
|
if (!codec) {
|
|
throw Error("inner codec must be defined");
|
|
}
|
|
this.alternatives.set(tagValue, { codec, tagValue });
|
|
return this as any;
|
|
}
|
|
|
|
/**
|
|
* Return the built codec.
|
|
*
|
|
* @param objectDisplayName name of the object that this codec operates on,
|
|
* used in error messages.
|
|
*/
|
|
build<R extends PartialTargetType & CommonBaseType = never>(
|
|
objectDisplayName: string,
|
|
): Codec<R> {
|
|
const alternatives = this.alternatives;
|
|
const discriminator = this.discriminator;
|
|
const baseCodec = this.baseCodec;
|
|
return {
|
|
decode(x: any, c?: Context): R {
|
|
if (!c) {
|
|
c = {
|
|
path: [`(${objectDisplayName})`],
|
|
};
|
|
}
|
|
const d = x[discriminator];
|
|
if (d === undefined) {
|
|
throw new DecodingError(
|
|
`expected tag for ${objectDisplayName} at ${renderContext(
|
|
c,
|
|
)}.${String(discriminator)}`,
|
|
);
|
|
}
|
|
const alt = alternatives.get(d);
|
|
if (!alt) {
|
|
throw new DecodingError(
|
|
`unknown tag for ${objectDisplayName} ${d} at ${renderContext(
|
|
c,
|
|
)}.${String(discriminator)}`,
|
|
);
|
|
}
|
|
const altDecoded = alt.codec.decode(x);
|
|
if (baseCodec) {
|
|
const baseDecoded = baseCodec.decode(x, c);
|
|
return { ...baseDecoded, ...altDecoded };
|
|
} else {
|
|
return altDecoded;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export class UnionCodecPreBuilder<T> {
|
|
discriminateOn<D extends keyof T, B = {}>(
|
|
discriminator: D,
|
|
baseCodec?: Codec<B>,
|
|
): UnionCodecBuilder<T, D, B, never> {
|
|
return new UnionCodecBuilder<T, D, B, never>(discriminator, baseCodec);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a builder for a codec that decodes an object with properties.
|
|
*/
|
|
export function buildCodecForObject<T>(): ObjectCodecBuilder<T, {}> {
|
|
return new ObjectCodecBuilder<T, {}>();
|
|
}
|
|
|
|
export function buildCodecForUnion<T>(): UnionCodecPreBuilder<T> {
|
|
return new UnionCodecPreBuilder<T>();
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a mapping from a string to values described by the inner codec.
|
|
*/
|
|
export function codecForMap<T>(
|
|
innerCodec: Codec<T>,
|
|
): Codec<{ [x: string]: T }> {
|
|
if (!innerCodec) {
|
|
throw Error("inner codec must be defined");
|
|
}
|
|
return {
|
|
decode(x: any, c?: Context): { [x: string]: T } {
|
|
const map: { [x: string]: T } = {};
|
|
if (typeof x !== "object") {
|
|
throw new DecodingError(`expected object at ${renderContext(c)}`);
|
|
}
|
|
for (const i in x) {
|
|
map[i] = innerCodec.decode(x[i], joinContext(c, `[${i}]`));
|
|
}
|
|
return map;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a list, containing values described by the inner codec.
|
|
*/
|
|
export function codecForList<T>(innerCodec: Codec<T>): Codec<T[]> {
|
|
if (!innerCodec) {
|
|
throw Error("inner codec must be defined");
|
|
}
|
|
return {
|
|
decode(x: any, c?: Context): T[] {
|
|
const arr: T[] = [];
|
|
if (!Array.isArray(x)) {
|
|
throw new DecodingError(`expected array at ${renderContext(c)}`);
|
|
}
|
|
for (const i in x) {
|
|
arr.push(innerCodec.decode(x[i], joinContext(c, `[${i}]`)));
|
|
}
|
|
return arr;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a value that must be a number.
|
|
*/
|
|
export function codecForNumber(): Codec<number> {
|
|
return {
|
|
decode(x: any, c?: Context): number {
|
|
if (typeof x === "number") {
|
|
return x;
|
|
}
|
|
throw new DecodingError(
|
|
`expected number at ${renderContext(c)} but got ${typeof x}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a value that must be a number.
|
|
*/
|
|
export function codecForBoolean(): Codec<boolean> {
|
|
return {
|
|
decode(x: any, c?: Context): boolean {
|
|
if (typeof x === "boolean") {
|
|
return x;
|
|
}
|
|
throw new DecodingError(
|
|
`expected boolean at ${renderContext(c)} but got ${typeof x}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a value that must be a string.
|
|
*/
|
|
export function codecForString(): Codec<string> {
|
|
return {
|
|
decode(x: any, c?: Context): string {
|
|
if (typeof x === "string") {
|
|
return x;
|
|
}
|
|
throw new DecodingError(
|
|
`expected string at ${renderContext(c)} but got ${typeof x}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Codec that allows any value.
|
|
*/
|
|
export function codecForAny(): Codec<any> {
|
|
return {
|
|
decode(x: any, c?: Context): any {
|
|
return x;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a value that must be a string.
|
|
*/
|
|
export function codecForConstString<V extends string>(s: V): Codec<V> {
|
|
return {
|
|
decode(x: any, c?: Context): V {
|
|
if (x === s) {
|
|
return x;
|
|
}
|
|
if (typeof x !== "string") {
|
|
throw new DecodingError(
|
|
`expected string constant "${s}" at ${renderContext(
|
|
c,
|
|
)} but got ${typeof x}`,
|
|
);
|
|
}
|
|
throw new DecodingError(
|
|
`expected string constant "${s}" at ${renderContext(
|
|
c,
|
|
)} but got string value "${x}"`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a boolean true constant.
|
|
*/
|
|
export function codecForConstTrue(): Codec<true> {
|
|
return {
|
|
decode(x: any, c?: Context): true {
|
|
if (x === true) {
|
|
return x;
|
|
}
|
|
throw new DecodingError(
|
|
`expected boolean true at ${renderContext(c)} but got ${typeof x}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a boolean true constant.
|
|
*/
|
|
export function codecForConstFalse(): Codec<false> {
|
|
return {
|
|
decode(x: any, c?: Context): false {
|
|
if (x === false) {
|
|
return x;
|
|
}
|
|
throw new DecodingError(
|
|
`expected boolean false at ${renderContext(c)} but got ${typeof x}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a codec for a value that must be a constant number.
|
|
*/
|
|
export function codecForConstNumber<V extends number>(n: V): Codec<V> {
|
|
return {
|
|
decode(x: any, c?: Context): V {
|
|
if (x === n) {
|
|
return x;
|
|
}
|
|
throw new DecodingError(
|
|
`expected number constant "${n}" at ${renderContext(
|
|
c,
|
|
)} but got ${typeof x}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function codecOptional<V>(innerCodec: Codec<V>): Codec<V | undefined> {
|
|
return {
|
|
decode(x: any, c?: Context): V | undefined {
|
|
if (x === undefined || x === null) {
|
|
return undefined;
|
|
}
|
|
return innerCodec.decode(x, c);
|
|
},
|
|
};
|
|
}
|
|
|
|
export type CodecType<T> = T extends Codec<infer X> ? X : any;
|
|
|
|
export function codecForEither<T extends Array<Codec<unknown>>>(
|
|
...alts: [...T]
|
|
): Codec<CodecType<T[number]>> {
|
|
return {
|
|
decode(x: any, c?: Context): any {
|
|
for (const alt of alts) {
|
|
try {
|
|
return alt.decode(x, c);
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
}
|
|
throw new DecodingError(
|
|
`No alternative matched at at ${renderContext(c)}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
const x = codecForEither(codecForString(), codecForNumber());
|