diff --git a/cbor.test.ts b/cbor.test.ts new file mode 100644 index 0000000..a23575e --- /dev/null +++ b/cbor.test.ts @@ -0,0 +1,27 @@ +import { cborDecode, cborEncode } from './cbor.ts' +import { asserts } from './deps.ts' + +Deno.test('cborDecode decodes what cborEncode encoded', () => { + const value = { + foo: { + bar: true, + bin: 'baz', + fatty: 8123, + acid: .3, + }, + data: null, + } + + asserts.assertEquals(cborDecode(cborEncode(value)), value) + + // Can also encode/decode primitives + asserts.assertEquals(cborDecode(cborEncode(true)), true) + asserts.assertEquals(cborDecode(cborEncode('asd')), 'asd') + asserts.assertEquals(cborDecode(cborEncode(123)), 123) + asserts.assertEquals(cborDecode(cborEncode(.123)), .123) + asserts.assertEquals(cborDecode(cborEncode(null)), null) +}) + +Deno.test('cborDecode throws if it can\'t decode', () => { + asserts.assertThrows(() => cborDecode(new Uint8Array([12, 2, 55, 90, 123]))) +}) diff --git a/cbor.ts b/cbor.ts new file mode 100644 index 0000000..2ee07d7 --- /dev/null +++ b/cbor.ts @@ -0,0 +1,9 @@ +import { cbor } from './deps.ts' + +export function cborEncode(value: unknown): Uint8Array { + return cbor.encode(value) +} + +export function cborDecode(bytes: Uint8Array): unknown { + return cbor.decode(bytes) +} diff --git a/deps.ts b/deps.ts index cd781dd..84511bd 100644 --- a/deps.ts +++ b/deps.ts @@ -5,3 +5,5 @@ export * as base64 from 'https://deno.land/std@0.201.0/encoding/base64.ts' export * as jwtCore from 'https://deno.land/x/djwt@v2.8/mod.ts' export * as streamUtils from 'https://deno.land/std@0.201.0/streams/mod.ts' export * as hexEncodingUtils from 'https://deno.land/std@0.201.0/encoding/hex.ts' + +export * as cbor from 'https://deno.land/x/cbor@v1.5.4/index.js' diff --git a/http.ts b/http.ts index 7a18f3c..6e222c3 100644 --- a/http.ts +++ b/http.ts @@ -32,6 +32,7 @@ export class ExpectantQuery { } } +/** @deprecated Use `SafeUnknown` instead */ export class JsonBody { body: unknown diff --git a/json.test.ts b/json.test.ts new file mode 100644 index 0000000..208de45 --- /dev/null +++ b/json.test.ts @@ -0,0 +1,29 @@ +import { asserts } from './deps.ts' +import { jsonDecode, jsonEncode } from './json.ts' + +Deno.test('jsonDecode decodes what jsonEncode encodes', () => { + const value = { + foo: { + bar: true, + bin: 'baz', + fatty: 8123, + acid: .3, + }, + data: null, + } + + asserts.assertEquals(jsonDecode(jsonEncode(value)), value) + + // Can also encode/decode primitives + asserts.assertEquals(jsonDecode(jsonEncode(true)), true) + asserts.assertEquals(jsonDecode(jsonEncode('asd')), 'asd') + asserts.assertEquals(jsonDecode(jsonEncode(123)), 123) + asserts.assertEquals(jsonDecode(jsonEncode(.123)), .123) + asserts.assertEquals(jsonDecode(jsonEncode(null)), null) +}) + +Deno.test('jsonDecode throws if it can\'t decode', () => { + asserts.assertThrows(() => jsonDecode('duh')) + asserts.assertThrows(() => jsonDecode('')) + asserts.assertThrows(() => jsonDecode('{ new: }')) +}) diff --git a/json.ts b/json.ts index cb3d189..841cf55 100644 --- a/json.ts +++ b/json.ts @@ -1,11 +1,13 @@ // deno-lint-ignore no-explicit-any export type Json = any +/** @deprecated Use `SafeUnknown` instead of validators */ export interface StringDescriptor { type: 'string' values?: string[] } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface NumberDescriptor { type: 'number' min?: number @@ -16,14 +18,17 @@ export interface NumberDescriptor { canBeNaN?: boolean } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface NullDescriptor { type: 'null' } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface BooleanDescriptor { type: 'boolean' } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface ArrayDescriptor { type: 'array' keyType: JsonDescriptor @@ -31,6 +36,7 @@ export interface ArrayDescriptor { minLength?: number } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface ObjectDescriptor { type: 'object' /** Either `keys` or `valueType` must be specified */ @@ -44,15 +50,18 @@ export interface ObjectDescriptor { valueType?: JsonDescriptor } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface AnyDescriptor { type: 'any' } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface TypeChoiceDescriptor { type: 'choice' options: JsonDescriptor[] } +/** @deprecated Use `SafeUnknown` instead of validators */ export type JsonDescriptor = | NullDescriptor | StringDescriptor @@ -63,21 +72,26 @@ export type JsonDescriptor = | AnyDescriptor | TypeChoiceDescriptor +/** @deprecated Use `SafeUnknown` instead of validators */ export interface ValidatorError { message: string path: string } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface ValidatorResultOk { ok: true } +/** @deprecated Use `SafeUnknown` instead of validators */ export interface ValidatorResultNotOk { ok: false errors: ValidatorError[] } +/** @deprecated Use `SafeUnknown` instead of validators */ export type ValidatorResult = ValidatorResultOk | ValidatorResultNotOk +/** @deprecated Use `SafeUnknown` instead of validators */ export function validateJson(descriptor: JsonDescriptor, json: Json): ValidatorResult { interface ValidatorResultOk { ok: true @@ -270,32 +284,66 @@ export function validateJson(descriptor: JsonDescriptor, json: Json): ValidatorR } } +/** @deprecated Will be removed in next major release. Use `jsonDecode` instead */ // deno-lint-ignore ban-types -export function jsonParse(string: string, fallback: {} | [] | null = null): Json { - if (!string.length) return string - if (string.startsWith('"') && string.endsWith('"')) return string.slice(1, -1) - if (string === 'true') return true - if (string === 'false') return false - if (string === 'null' || string === 'undefined') return null - if ((string.startsWith('{') && string.endsWith('}')) || (string.startsWith('[') && string.endsWith(']'))) { +export function jsonParse(json: string, fallback: {} | [] | null = null): Json { + if (!json.length) return json + if (json.startsWith('"') && json.endsWith('"')) return json.slice(1, -1) + if (json === 'true') return true + if (json === 'false') return false + if (json === 'null' || json === 'undefined') return null + if ((json.startsWith('{') && json.endsWith('}')) || (json.startsWith('[') && json.endsWith(']'))) { try { - return JSON.parse(string) + return JSON.parse(json) } catch (e) { if (!fallback) { - e.raw = string + e.raw = json throw e } - console.warn(`Failed to parse JSON. Resorting to fallback. DUMP:`, e, string) + console.warn(`Failed to parse JSON. Resorting to fallback. DUMP:`, e, json) return fallback } } - const numTry = Number(string) + const numTry = Number(json) if (!isNaN(numTry)) return numTry - return string + return json } -export function jsonStringify(json: Json, spacer = ''): string { - return JSON.stringify(json, undefined, spacer) +/** @deprecated Will be removed in next major release. Use `jsonEncode` instead */ +export function jsonStringify(data: Json, spacer = ''): string { + return JSON.stringify(data, undefined, spacer) +} + +/** + * Parse json. + * + * Wraps the native implementation, the difference being that primitives are parsed at the top level + * (i.e. `jsonParse("null")`) is valid */ +export function jsonDecode(json: string): unknown { + json = json.trim() + + if (!json.length) throw new Error('Expected a json primitive, object, or array, but found nothing') + if (json.startsWith('"') && json.endsWith('"')) return json.slice(1, -1) + if (json === 'true') return true + if (json === 'false') return false + if (json === 'null' || json === 'undefined') return null + if ((json.startsWith('{') && json.endsWith('}')) || (json.startsWith('[') && json.endsWith(']'))) { + try { + return JSON.parse(json) + } catch (error) { + throw new Error(`Failed to parse json. ${error.message} ... Dump: "${json}"`) + } + } + + const numTry = Number(json) + if (!isNaN(numTry)) return numTry + + throw new Error(`Expected a json primitive, object, or array, but found: "${json}"`) +} + +/** Stringify json with an optional spacer. Wraps the native implementation */ +export function jsonEncode(data: unknown, spacer = ''): string { + return JSON.stringify(data, undefined, spacer) } diff --git a/safe_unknown.test.ts b/safe_unknown.test.ts new file mode 100644 index 0000000..d416a01 --- /dev/null +++ b/safe_unknown.test.ts @@ -0,0 +1,167 @@ +import { asserts } from './deps.ts' +import { SafeUnknown, SafeUnknownArray, SafeUnknownObject } from './safe_unknown.ts' +import { concatenate } from './string.ts' + +Deno.test('SafeUnknown gets the correct type, and throws if it\'s the wrong type', () => { + interface TypeTest { + value: unknown + fn(safe: SafeUnknown): void + } + + const inputs: TypeTest[] = [ + { + value: 'hello', + fn(safe) { + safe.asString() + }, + }, + { + value: true, + fn(safe) { + safe.asBoolean() + }, + }, + { + value: 12341, + fn(safe) { + safe.asNumber() + }, + }, + { + value: null, + fn(safe) { + safe.asNull() + }, + }, + { + value: { foo: 'bar' }, + fn(safe) { + safe.asObject() + }, + }, + { + value: [20, 1], + fn(safe) { + safe.asArray() + }, + }, + ] + + for (const targetInput of inputs) { + targetInput.fn(new SafeUnknown(targetInput.value)) + + for (const comparativeInput of inputs) { + if (comparativeInput === targetInput) continue + + asserts.assertThrows(() => targetInput.fn(new SafeUnknown(comparativeInput.value))) + } + } +}) + +Deno.test('SafeUnknownObject.get can get a single value', () => { + asserts.assertEquals(new SafeUnknownObject({ foo: 12 }).get('foo').asNumber(), 12) + asserts.assertEquals(new SafeUnknownObject({ foo: { bar: 12 } }).get('foo').isNumber(), false) +}) + +Deno.test('SafeUnknownObject.get recursively gets values', () => { + // Check the valid cases + asserts.assertEquals( + new SafeUnknownObject({ foo: null }).get('foo', 'bar', 'bin', 'baz').isNull(), + true, + ) + asserts.assertEquals( + new SafeUnknownObject({}).get('foo').isNull(), + true, + ) + asserts.assertEquals( + new SafeUnknownObject({}).get('foo', 'bar', 'bin', 'baz').isNull(), + true, + ) + asserts.assertEquals( + new SafeUnknownObject({ + foo: { + bar: { + bin: { + baz: 'Hello', + }, + }, + }, + }).get('foo', 'bar', 'bin', 'baz').asString(), + 'Hello', + ) + + // Check the invalid cases + asserts.assertThrows(() => new SafeUnknownObject({ foo: 'hello' }).get('foo', 'bar', 'bin', 'baz')) +}) + +Deno.test('SafeUnknownObject.sureGet properly gets', () => { + // Tests invalid cases + asserts.assertThrows(() => new SafeUnknownObject({ foo: null }).sureGet('foo', 'bar', 'bin', 'baz')) + asserts.assertThrows(() => new SafeUnknownObject({}).sureGet('foo')) + asserts.assertThrows(() => new SafeUnknownObject({}).sureGet('foo', 'bar', 'bin', 'baz').isNull()) + asserts.assertThrows(() => new SafeUnknownObject({ foo: 'hello' }).sureGet('foo', 'bar', 'bin', 'baz')) + + // Test valid cases + asserts.assertEquals( + new SafeUnknownObject({ foo: { bar: { bin: { baz: 'Hello' } } } }).sureGet('foo', 'bar', 'bin', 'baz').asString(), + 'Hello', + ) +}) + +Deno.test('SafeUnknownObject.sureGet can get single values', () => { + asserts.assertEquals(new SafeUnknownObject({ foo: 12 }).sureGet('foo').asNumber(), 12) + asserts.assertEquals(new SafeUnknownObject({ foo: { bar: 12 } }).sureGet('foo').isNumber(), false) +}) + +const ARRAY_TEST = ['foo', 'bar', 'bin', 'baz'] +const OBJECT_TEST = { foo: 'whatever, man', bar: 'sleep tight', bin: 'never rest', baz: 'be happy' } + +Deno.test('SafeUnknownArray.map maps', () => { + asserts.assertEquals(new SafeUnknownArray(ARRAY_TEST).map((v) => v.asString()), ARRAY_TEST) +}) + +Deno.test('SafeUnknownArray.forEach loops over each in order', () => { + let trueIndex = 0 + new SafeUnknownArray(ARRAY_TEST).forEach((item, index) => { + asserts.assertEquals(index, trueIndex) + asserts.assertEquals(item.asString(), ARRAY_TEST[trueIndex]) + + trueIndex++ + }) + + asserts.assertEquals(trueIndex, ARRAY_TEST.length) +}) + +Deno.test('SafeUnknownArray.values just gets some values', () => { + asserts.assertEquals(new SafeUnknownArray(ARRAY_TEST).values().map((v) => v.asString()), ARRAY_TEST) +}) + +Deno.test('SafeUnknownObject.keys just gets the object keys', () => { + asserts.assertEquals(new SafeUnknownObject(OBJECT_TEST).keys(), Object.keys(OBJECT_TEST)) +}) + +Deno.test('SafeUnknownObject.values just gets the object values', () => { + asserts.assertEquals(new SafeUnknownObject(OBJECT_TEST).values().map((v) => v.asString()), Object.values(OBJECT_TEST)) +}) + +Deno.test('SafeUnknownObject.forEach loops through object', () => { + const trueKeys = Object.keys(OBJECT_TEST) + const loopedKeys: string[] = [] + + new SafeUnknownObject(OBJECT_TEST).forEach((value, key) => { + // @ts-ignore an undefined is ok in this case + const trueValue = OBJECT_TEST[key] + asserts.assertEquals(value.asString(), trueValue) + + loopedKeys.push(key) + }) + + asserts.assertEquals(loopedKeys, trueKeys) +}) + +Deno.test('SafeUnknownObject.map maps', () => { + asserts.assertEquals( + Object.values(new SafeUnknownObject(OBJECT_TEST).map((value, key) => concatenate([key, value.asString()]))), + Object.entries(OBJECT_TEST).map(([key, value]) => concatenate([key, value])), + ) +}) diff --git a/safe_unknown.ts b/safe_unknown.ts new file mode 100644 index 0000000..925f13f --- /dev/null +++ b/safe_unknown.ts @@ -0,0 +1,257 @@ +import { BadParamsError } from './errors.ts' + +export type SafeUnknownType = 'null' | 'string' | 'number' | 'bigint' | 'boolean' | 'array' | 'object' | 'function' + +export class SafeUnknown { + data: unknown + #contextPath: string + + constructor(data: unknown, contextPath = '$') { + this.data = data + this.#contextPath = contextPath + } + + getType(): SafeUnknownType { + if (this.data === null) return 'null' + if (typeof this.data === 'string') return 'string' + if (typeof this.data === 'number') return 'number' + if (typeof this.data === 'bigint') return 'bigint' + if (typeof this.data === 'boolean') return 'boolean' + if (Array.isArray(this.data)) return 'array' + if (typeof this.data === 'object') return 'object' + + throw new Error(`Encountered undefined type in value "${this.data}"`) + } + + isString(): boolean { + return this.getType() === 'string' + } + + asString(): string { + if (!this.isString()) { + throw new BadParamsError(`Expected data to be of type string, but found type ${this.getType()} at ${this.#contextPath}`) + } + + // @ts-ignore check is above + return this.data + } + + isBoolean(): boolean { + return this.getType() === 'boolean' + } + + asBoolean(): boolean { + if (!this.isBoolean()) { + throw new BadParamsError(`Expected data to be of type boolean, but found type ${this.getType()} at ${this.#contextPath}`) + } + + // @ts-ignore check is above + return this.data + } + + isNumber(): boolean { + return this.getType() === 'number' + } + + asNumber(): number { + if (!this.isNumber()) { + throw new BadParamsError(`Expected data to be of type number, but found type ${this.getType()} at ${this.#contextPath}`) + } + + // @ts-ignore check is above + return this.data + } + + isNull(): boolean { + return this.getType() === 'null' + } + + asNull(): null { + if (!this.isNull()) { + throw new BadParamsError(`Expected data to be null, but found type ${typeof this.data} at ${this.#contextPath}`) + } + + // @ts-ignore check is above + return this.data + } + + isArray(): boolean { + return this.getType() === 'array' + } + + asArray(): SafeUnknownArray { + if (!this.isArray()) throw new Error(`Expected data to be an array, but found type ${this.getType()}`) + + // @ts-ignore check is above + return new SafeUnknownArray(this.data) + } + + isObject(): boolean { + return this.getType() === 'object' + } + + asObject(): SafeUnknownObject { + if (!this.isObject()) throw new Error(`Expected data to be an object, but found type ${typeof this.data}`) + + // @ts-ignore check is above + return new SafeUnknownObject(this.data) + } +} + +export class SafeUnknownObject { + data: Record + #contextPath: string + + constructor(data: Record, contextPath = '$') { + this.data = data + this.#contextPath = contextPath + } + + /** Gets an array of all the keys in the object */ + keys(): string[] { + const indexes: string[] = [] + + for (const index in this.data) indexes.push(index) + + return indexes + } + + /** Gets an array of all the values in the array as safe unknowns */ + values(): SafeUnknown[] { + const values: SafeUnknown[] = [] + + for (const key in this.data) values.push(new SafeUnknown(this.data[key])) + + return values + } + + /** Calls `fn` for every item in the array */ + forEach(fn: (value: SafeUnknown, key: string) => unknown): void { + for (const key in this.data) fn(new SafeUnknown(this.data[key]), key) + } + + /** Calls `fn` on each item in the array, returning a new array made up of the results of `fn` */ + map(fn: (value: SafeUnknown, key: string) => T): Record { + const newRecord: Record = {} + + for (const key in this.data) newRecord[key] = fn(new SafeUnknown(this.data[key]), key) + + return newRecord + } + + /** Gets the value of a single key in the object. If the key doesn't exist, a `SafeUnknown` with `null` will be returned */ + getSingle(key: string): SafeUnknown { + const value = this.data[key] ?? null + + return new SafeUnknown(value, `${this.#contextPath}.${key}`) + } + + /** Gets the value of a single key in the object. If the key doesn't exist, an error is thrown */ + sureGetSingle(key: string): SafeUnknown { + const value = this.data[key] + if (value === undefined) throw new BadParamsError(`Expected to find a value for key "${key}" at ${this.#contextPath}`) + + return new SafeUnknown(value, `${this.#contextPath}.${key}`) + } + + /** + * Gets the value of an object path in the object. If a key doesn't exist, a `SafeUnknown` with `null` will be returned. If a key gives null, + * either by it not existing, or it actually being null, a `SafeUnknown` with `null` is immediately returned. + * + * Examples: + * + * ```ts + * new SafeUnknownObject({ foo: null }).get('foo', 'bar', 'bin', 'baz').isNull() // true + * new SafeUnknownObject({}).get('foo').isNull() // true + * new SafeUnknownObject({}).get('foo', 'bar', 'bin', 'baz').isNull() // true + * new SafeUnknownObject({ foo: "hello" }).get('foo', 'bar', 'bin', 'baz') // Error: Expected data to be an object, but found type string at $.foo + * new SafeUnknownObject({ foo: { bar: { bin: { baz: "Hello" }}}}).asString() // "Hello" + * ``` + */ + get(...keys: string[]): SafeUnknown { + if (!keys.length) throw new Error('Expected at least 1 key to be provided') + + const firstKey = keys[0] + const firstValue = this.getSingle(firstKey) + + if (firstValue.isNull() || keys.length === 1) return firstValue + + return firstValue.asObject().get(...keys.slice(1)) + } + + /** + * Gets the value of an object path in the object. If a key doesn't exist, an error is thrown. Unlike `get`, `null` is not + * immediately returned. + * + * Examples: + * + * ```ts + * new SafeUnknownObject({ foo: null }).sureGet('foo', 'bar', 'bin', 'baz') // Error: Expected data to be an object, but found type null at $.foo + * new SafeUnknownObject({}).sureGet('foo') // Error: Expected to find a value for key "foo" at $ + * new SafeUnknownObject({}).sureGet('foo', 'bar', 'bin', 'baz').isNull() // Error: Expected to find a value for key "foo" at $ + * new SafeUnknownObject({ foo: "hello" }).sureGet('foo', 'bar', 'bin', 'baz') // Error: Expected data to be an object, but found type string at $.foo + * new SafeUnknownObject({ foo: { bar: { bin: { baz: "Hello" }}}}).sureGet('foo', 'bar', 'bin', 'baz').asString() // "Hello" + * ``` + */ + sureGet(...keys: string[]): SafeUnknown { + if (!keys.length) throw new Error('Expected at least 1 key to be provided') + + const firstKey = keys[0] + const firstValue = this.sureGetSingle(firstKey) + + if (keys.length === 1) return firstValue + + return firstValue.asObject().get(...keys.slice(1)) + } +} + +export class SafeUnknownArray { + data: unknown[] + length: number + #contextPath: string + + constructor(data: unknown[], contextPath = '$') { + this.data = data + this.length = data.length + + this.#contextPath = contextPath + } + + /** Gets an array of all the values in the array as safe unknowns */ + values(): SafeUnknown[] { + const values: SafeUnknown[] = [] + + for (const value of this.data) values.push(new SafeUnknown(value)) + + return values + } + + /** Calls `fn` for every item in the array */ + forEach(fn: (value: SafeUnknown, index: number) => unknown): void { + for (const index in this.data) fn(new SafeUnknown(this.data[index]), parseInt(index)) + } + + /** Calls `fn` on each item in the array, returning a new array made up of the results of `fn` */ + map(fn: (value: SafeUnknown, index: number) => T): T[] { + const newArray: T[] = [] + + for (const index in this.data) newArray.push(fn(new SafeUnknown(this.data[index]), parseInt(index))) + + return newArray + } + + /** Gets the value of a single index in the array. If the index doesn't exist, a `SafeUnknown` with `null` will be returned */ + get(index: number): SafeUnknown { + const value = this.data[index] ?? null + + return new SafeUnknown(value, `${this.#contextPath}[${index}]`) + } + + /** Gets the value of a single index in the array. If the index doesn't exist, an error is thrown */ + sureGet(index: number): SafeUnknown { + const value = this.data[index] + if (value === undefined) throw new BadParamsError(`Expected to find an item for index "${index}" at ${this.#contextPath}`) + + return new SafeUnknown(value, `${this.#contextPath}[${index}]`) + } +}