You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
236 lines
7.4 KiB
236 lines
7.4 KiB
import {DateTime} from "luxon";
|
|
|
|
export interface Serializer<DeserializedT, SerializedT> {
|
|
checkType(data: unknown): data is SerializedT
|
|
hydrate(data: SerializedT): DeserializedT
|
|
dehydrate(data: DeserializedT): SerializedT
|
|
}
|
|
|
|
export interface LocatedError {
|
|
readonly location: string
|
|
readonly error: Error|LocatedError
|
|
}
|
|
|
|
export function isLocatedError(object: unknown): object is LocatedError {
|
|
return typeof object === "object" && object !== null && object.hasOwnProperty("location") && object.hasOwnProperty("error")
|
|
}
|
|
|
|
export function wrapErrorWithLocation(location: string, object: unknown): LocatedError {
|
|
if (typeof object === "string") {
|
|
return {
|
|
location,
|
|
error: Error(object)
|
|
}
|
|
} else if (object instanceof Error || isLocatedError(object)) {
|
|
return {
|
|
location,
|
|
error: object
|
|
}
|
|
} else {
|
|
return {
|
|
location,
|
|
error: Error(`Unknown error: ${object}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export class SelfSerializer<T> implements Serializer<T, T> {
|
|
readonly checkType: (data: unknown) => data is T;
|
|
|
|
constructor(checkType: (data: unknown) => data is T) {
|
|
this.checkType = checkType
|
|
}
|
|
|
|
dehydrate(data: T): T {
|
|
return data;
|
|
}
|
|
|
|
hydrate(data: T): T {
|
|
return data;
|
|
}
|
|
}
|
|
|
|
export const StringSerializer = new SelfSerializer((data): data is string => typeof data === "string")
|
|
export const DateTimeSerializer: Serializer<DateTime, string> = {
|
|
checkType(data: unknown): data is string {
|
|
return typeof data === "string"
|
|
},
|
|
dehydrate(data: DateTime): string {
|
|
return data.toISO();
|
|
},
|
|
hydrate(data: string): DateTime {
|
|
return DateTime.fromISO(data);
|
|
}
|
|
}
|
|
|
|
export class EnumSerializer<T extends string|number> implements Serializer<T, T> {
|
|
readonly values: readonly T[]
|
|
|
|
constructor(values: readonly T[]) {
|
|
this.values = values
|
|
}
|
|
|
|
checkType(data: unknown): data is T {
|
|
if (!(this.values as unknown[]).includes(data)) {
|
|
throw Error("not a valid value for the enum")
|
|
}
|
|
return true
|
|
}
|
|
|
|
dehydrate(data: T): T {
|
|
return data;
|
|
}
|
|
|
|
hydrate(data: T): T {
|
|
return data;
|
|
}
|
|
}
|
|
|
|
export class NullableSerializer<DeserializedT, SerializedT> implements Serializer<DeserializedT | null, SerializedT | null> {
|
|
readonly inner: Serializer<DeserializedT, SerializedT>
|
|
constructor(inner: Serializer<DeserializedT, SerializedT>) {
|
|
this.inner = inner
|
|
}
|
|
|
|
checkType(data: unknown): data is SerializedT | null {
|
|
return data === null || this.inner.checkType(data)
|
|
}
|
|
|
|
dehydrate(data: DeserializedT | null): SerializedT | null {
|
|
if (data === null) {
|
|
return null
|
|
}
|
|
return this.inner.dehydrate(data);
|
|
}
|
|
|
|
hydrate(data: SerializedT | null): DeserializedT | null {
|
|
if (data === null) {
|
|
return null
|
|
}
|
|
return this.inner.hydrate(data);
|
|
}
|
|
}
|
|
|
|
export class OptionalSerializer<DeserializedT, SerializedT> implements Serializer<DeserializedT | undefined, SerializedT | undefined> {
|
|
readonly inner: Serializer<DeserializedT, SerializedT>
|
|
constructor(inner: Serializer<DeserializedT, SerializedT>) {
|
|
this.inner = inner
|
|
}
|
|
|
|
checkType(data: unknown): data is SerializedT | undefined {
|
|
return typeof data === "undefined" || this.inner.checkType(data)
|
|
}
|
|
|
|
dehydrate(data: DeserializedT | undefined): SerializedT | undefined {
|
|
if (typeof data === "undefined") {
|
|
return data
|
|
}
|
|
return this.inner.dehydrate(data);
|
|
}
|
|
|
|
hydrate(data: SerializedT | undefined): DeserializedT | undefined {
|
|
if (typeof data === "undefined") {
|
|
return data
|
|
}
|
|
return this.inner.hydrate(data);
|
|
}
|
|
}
|
|
export const OptionalStringSerializer = new OptionalSerializer(StringSerializer)
|
|
export const OptionalDateTimeSerializer = new OptionalSerializer(DateTimeSerializer)
|
|
|
|
export type KeySerializers<DeserializedT extends {[key in KeysT]?: unknown}, SerializedT extends {[key in KeysT]?: unknown}, KeysT extends string|number> = {
|
|
[key in KeysT]: Serializer<DeserializedT[key], SerializedT[key]>
|
|
}
|
|
|
|
export class ObjectSerializer<DeserializedT extends {[key in KeysT]?: unknown}, SerializedT extends {[key in KeysT]?: unknown}, KeysT extends string|number> implements Serializer<DeserializedT, SerializedT> {
|
|
readonly inner: KeySerializers<DeserializedT, SerializedT, KeysT>
|
|
readonly keys: KeysT[]
|
|
constructor(inner: KeySerializers<DeserializedT, SerializedT, KeysT>, keys: KeysT[]) {
|
|
this.inner = inner
|
|
this.keys = keys
|
|
}
|
|
|
|
checkType(data: unknown): data is SerializedT {
|
|
if (typeof data !== "object" || data === null) {
|
|
throw Error("expected an object")
|
|
}
|
|
const map: {[key in KeysT]?: unknown} = data
|
|
for (const key of this.keys) {
|
|
try {
|
|
if (!this.inner[key].checkType(map[key])) {
|
|
// noinspection ExceptionCaughtLocallyJS
|
|
throw Error("checkType returned false")
|
|
}
|
|
} catch (e) {
|
|
throw wrapErrorWithLocation(`at key ${key}`, e)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
dehydrate(data: DeserializedT): SerializedT {
|
|
const result: Partial<SerializedT> = {}
|
|
for (const key of this.keys) {
|
|
try {
|
|
result[key] = this.inner[key].dehydrate(data[key])
|
|
} catch (e) {
|
|
throw wrapErrorWithLocation(`at key ${key}`, e)
|
|
}
|
|
}
|
|
return result as SerializedT
|
|
}
|
|
|
|
hydrate(data: SerializedT): DeserializedT {
|
|
const result: Partial<DeserializedT> = {}
|
|
for (const key of this.keys) {
|
|
try {
|
|
result[key] = this.inner[key].hydrate(data[key])
|
|
} catch (e) {
|
|
throw wrapErrorWithLocation(`at key ${key}`, e)
|
|
}
|
|
}
|
|
return result as DeserializedT
|
|
}
|
|
}
|
|
|
|
export class ArraySerializer<DeserializedT, SerializedT> implements Serializer<DeserializedT[], SerializedT[]> {
|
|
readonly inner: Serializer<DeserializedT, SerializedT>
|
|
constructor(inner: Serializer<DeserializedT, SerializedT>) {
|
|
this.inner = inner
|
|
}
|
|
|
|
checkType(data: unknown): data is SerializedT[] {
|
|
return Array.isArray(data) && data.every((value, index) => {
|
|
try {
|
|
if (!this.inner.checkType(value)) {
|
|
// noinspection ExceptionCaughtLocallyJS
|
|
throw Error("checkType returned false")
|
|
}
|
|
} catch (e) {
|
|
throw wrapErrorWithLocation(`at index ${index}`, e)
|
|
}
|
|
})
|
|
}
|
|
|
|
dehydrate(data: DeserializedT[]): SerializedT[] {
|
|
return data.map((value, index) => {
|
|
try {
|
|
return this.inner.dehydrate(value)
|
|
} catch (e) {
|
|
throw wrapErrorWithLocation(`at index ${index}`, e)
|
|
}
|
|
});
|
|
}
|
|
|
|
hydrate(data: SerializedT[]): DeserializedT[] {
|
|
return data.map((value, index) => {
|
|
try {
|
|
return this.inner.hydrate(value)
|
|
} catch (e) {
|
|
throw wrapErrorWithLocation(`at index ${index}`, e)
|
|
}
|
|
});
|
|
}
|
|
}
|
|
export const ArrayStringSerializer = new ArraySerializer(StringSerializer)
|
|
export const OptionalArrayStringSerializer = new OptionalSerializer(ArrayStringSerializer) |