/*
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
*/
import { j2s } from "./helpers.js";
import { Logger } from "./logging.js";
/**
* Type-safe codecs for converting from/to JSON.
*/
/* eslint-disable @typescript-eslint/ban-types */
const logger = new Logger("codec.ts");
/**
* 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 {
/**
* Decode untyped JSON to an object of type [[V]].
*/
readonly decode: (x: any, c?: Context) => V;
}
type SingletonRecord = { [Y in K]: V };
interface Prop {
name: string;
codec: Codec;
}
interface Alternative {
tagValue: any;
codec: Codec;
}
class ObjectCodecBuilder {
private propList: Prop[] = [];
/**
* Define a property for the object.
*/
property(
x: K,
codec: Codec,
): ObjectCodecBuilder> {
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 {
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();
constructor(
private discriminator: TagPropertyLabel,
private baseCodec?: Codec,
) { }
/**
* Define a property for the object.
*/
alternative(
tagValue: TargetType[TagPropertyLabel],
codec: Codec,
): 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(
objectDisplayName: string,
): Codec {
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 {
discriminateOn(
discriminator: D,
baseCodec?: Codec,
): UnionCodecBuilder {
return new UnionCodecBuilder(discriminator, baseCodec);
}
}
/**
* Return a builder for a codec that decodes an object with properties.
*/
export function buildCodecForObject(): ObjectCodecBuilder {
return new ObjectCodecBuilder();
}
export function buildCodecForUnion(): UnionCodecPreBuilder {
return new UnionCodecPreBuilder();
}
/**
* Return a codec for a mapping from a string to values described by the inner codec.
*/
export function codecForMap(
innerCodec: Codec,
): 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(innerCodec: Codec): Codec {
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 {
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 {
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 {
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}`,
);
},
};
}
/**
* Return a codec for a value that must be a string.
*/
export function codecForStringURL(shouldEndWithSlash?: boolean): Codec {
return {
decode(x: any, c?: Context): string {
if (typeof x !== "string") {
throw new DecodingError(
`expected string at ${renderContext(c)} but got ${typeof x}`,
);
}
if (shouldEndWithSlash && !x.endsWith("/")) {
throw new DecodingError(
`expected URL string that ends with slash at ${renderContext(
c,
)} but got ${x}`,
);
}
try {
const url = new URL(x);
return x;
} catch (e) {
if (e instanceof Error) {
throw new DecodingError(e.message);
} else {
throw new DecodingError(
`expected an URL string at ${renderContext(c)} but got "${x}"`,
);
}
}
},
};
}
/**
* Return a codec for a value that must be a string.
*/
export function codecForURL(shouldEndWithSlash?: boolean): Codec {
return {
decode(x: any, c?: Context): URL {
if (typeof x !== "string") {
throw new DecodingError(
`expected string at ${renderContext(c)} but got ${typeof x}`,
);
}
if (shouldEndWithSlash && !x.endsWith("/")) {
throw new DecodingError(
`expected URL string that ends with slash at ${renderContext(
c,
)} but got ${x}`,
);
}
try {
const url = new URL(x);
return url;
} catch (e) {
if (e instanceof Error) {
throw new DecodingError(e.message);
} else {
throw new DecodingError(
`expected an URL string at ${renderContext(c)} but got "${x}"`,
);
}
}
},
};
}
/**
* Codec that allows any value.
*/
export function codecForAny(): Codec {
return {
decode(x: any, c?: Context): any {
return x;
},
};
}
/**
* Return a codec for a value that must be a string.
*/
export function codecForConstString(s: V): Codec {
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 {
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 {
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(n: V): Codec {
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(innerCodec: Codec): Codec {
return {
decode(x: any, c?: Context): V | undefined {
if (x === undefined || x === null) {
return undefined;
}
return innerCodec.decode(x, c);
},
};
}
export function codecOptionalDefault(innerCodec: Codec, def: V): Codec {
return {
decode(x: any, c?: Context): V {
if (x === undefined || x === null) {
return def;
}
return innerCodec.decode(x, c);
},
};
}
export function codecForLazy(innerCodec: () => Codec): Codec {
let instance: Codec | undefined = undefined
return {
decode(x: any, c?: Context): V {
if (instance === undefined) {
instance = innerCodec()
}
return instance.decode(x, c);
},
};
}
export type CodecType = T extends Codec ? X : any;
export function codecForEither>>(
...alts: [...T]
): Codec> {
return {
decode(x: any, c?: Context): any {
for (const alt of alts) {
try {
return alt.decode(x, c);
} catch (e) {
continue;
}
}
if (logger.shouldLogTrace()) {
logger.trace(`offending value: ${j2s(x)}`);
}
throw new DecodingError(
`No alternative matched at at ${renderContext(c)}`,
);
},
};
}