From b1fb75ebaad6701aff52db287179dcf3aa4f7624 Mon Sep 17 00:00:00 2001 From: lorefnon Date: Sat, 1 Apr 2023 16:22:14 +0530 Subject: [PATCH] Add support for type-safe codecs which can be plugged into the deserialization flow --- src/codecs.ts | 64 ++++++++++++++++++++++++++++++++++++++++ src/extract_form.spec.ts | 15 ++++++++++ src/extract_form.ts | 22 ++++++++++++-- src/fields.spec.ts | 21 +++++++++++++ src/fields.ts | 27 +++++++++++------ src/index.ts | 1 + src/set.ts | 1 + 7 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 src/codecs.ts diff --git a/src/codecs.ts b/src/codecs.ts new file mode 100644 index 0000000..2dd0d6f --- /dev/null +++ b/src/codecs.ts @@ -0,0 +1,64 @@ +import { FieldName } from "./fields"; + +export abstract class BaseCodec { + constructor(public suffix: string) { + this.encodeName = this.encodeName.bind(this) + } + encodeName(fieldName: FieldName) { + return fieldName.toString() + ':' + this.suffix; + } + abstract decodeValue(value: FormDataEntryValue): T; +} + +export interface Codec extends BaseCodec { } + +export class StrCodec extends BaseCodec { + constructor() { + super('string') + } + decodeValue(value: FormDataEntryValue) { + if (typeof value === 'string') { + return value; + } + throw new Error(`Expected value to be string but found ${value}`); + } +} + +export const strCodec = new StrCodec() +export const asStr = strCodec.encodeName + +export class NumCodec extends BaseCodec { + constructor() { + super('number') + } + decodeValue(value: FormDataEntryValue) { + if (typeof value === 'string') { + return Number(value) + } + throw new Error(`Expected value to be string but found ${value}`); + } +} + +export const numCodec = new NumCodec() +export const asNum = numCodec.encodeName + +export class BoolCodec extends BaseCodec { + constructor() { + super('boolean') + } + decodeValue(value: FormDataEntryValue) { + return value === 'true' || value === 'on' + } +} + +export const boolCodec = new BoolCodec() +export const asBool = boolCodec.encodeName + +export const mapCodecsBySuffix = (codecs: Codec[]) => + Object.fromEntries(codecs.map(it => [it.suffix, it])) + +export const defaultCodecsBySuffix = mapCodecsBySuffix([ + strCodec, + numCodec, + boolCodec +]) \ No newline at end of file diff --git a/src/extract_form.spec.ts b/src/extract_form.spec.ts index 2267760..64bdb1a 100644 --- a/src/extract_form.spec.ts +++ b/src/extract_form.spec.ts @@ -31,3 +31,18 @@ test('extractFormData', () => { expect(files.object).toBe(undefined); expect(files.array[0]).toBeInstanceOf(Blob); }); + +test('extractFormData with codecs', () => { + const formdata = new FormData(); + + formdata.set('name:string', 'name'); + formdata.set('age:number', '90'); + formdata.set('active:boolean', 'on'); + + const { data, fields, files } = extractFormData(formdata); + + expect(data.name).toBe('name'); + expect(data.age).toBe(90); + expect(data.active).toBe(true); + +}) \ No newline at end of file diff --git a/src/extract_form.ts b/src/extract_form.ts index 1723bc4..4e86a3f 100644 --- a/src/extract_form.ts +++ b/src/extract_form.ts @@ -1,3 +1,4 @@ +import { Codec, defaultCodecsBySuffix, mapCodecsBySuffix } from './codecs'; import { set } from './set'; type GetFormData = T extends Record @@ -38,15 +39,24 @@ type ExtractFormData = { export function extractFormData( formData: FormData, + codecs?: Codec[] ): ExtractFormData { + const codecsMapping = codecs + ? { + ...defaultCodecsBySuffix, + ...mapCodecsBySuffix(codecs) + } + : defaultCodecsBySuffix + const data: any = {}; const fields: any = {}; const files: any = {}; - for (const [key, value] of formData.entries()) { + for (const [entryKey, value] of formData.entries()) { const isFile = value instanceof Blob; const isField = typeof value === 'string'; - let val: FormDataEntryValue | undefined = value; + let val: any + const [key, codecSuffix] = entryKey.split(':') if ( // empty file @@ -55,6 +65,14 @@ export function extractFormData( (isField && value === '') ) { val = undefined; + } else if (codecSuffix) { + const codec = codecsMapping[codecSuffix] as Codec + if (!codec) { + throw new Error(`No codec found for suffix: ${codecSuffix}`) + } + val = codec.decodeValue(value) + } else { + val = value } set(data, key, val); diff --git a/src/fields.spec.ts b/src/fields.spec.ts index e8d1ae2..020e713 100644 --- a/src/fields.spec.ts +++ b/src/fields.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from 'vitest'; +import { asBool, asNum, asStr } from './codecs'; import { fields } from './fields'; test('fields', () => { @@ -22,3 +23,23 @@ test('fields', () => { 'arrayNested[2].date', ); }); + +test('fields with codec', () => { + type Data = { + object?: { + key: string; + }; + isActive: boolean; + arrayNested: Array<{ + id: number; + }>; + }; + + const f = fields(); + + expect(asBool(f.isActive), 'nested object').toBe('isActive:boolean'); + expect(asStr(f.object.key), 'nested object').toBe('object.key:string'); + expect(asNum(f.arrayNested(2).id), 'nested array').toBe( + 'arrayNested[2].id:number', + ); +}) \ No newline at end of file diff --git a/src/fields.ts b/src/fields.ts index 8b88a38..490d74c 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -1,24 +1,33 @@ -type ReplaceValue = T extends Record +type ReplaceValue = T extends Record ? { - [K in keyof T]: ReplaceValue; + [K in keyof T]: ReplaceValue; } : T extends Array - ? Array> - : V; + ? Array> + : T extends FieldName + ? FieldName + : FieldName; -type FieldName = string & { +const fieldTypeBrand: unique symbol = Symbol(); + +export interface FieldName { [Symbol.toPrimitive]: () => string; + [fieldTypeBrand]: T toString(): string; valueOf(): string; }; -type Field = { +export type Field = { [K in keyof T]: T[K] extends unknown[] ? T[K][number] extends FormDataEntryValue - ? (index?: number) => FieldName + ? (index?: number) => FieldName + : T[K][number] extends FieldName + ? (index?: number) => FieldName : (index: number) => Field : T[K] extends FormDataEntryValue - ? FieldName + ? FieldName + : T[K] extends FieldName + ? FieldName : Field; }; @@ -30,7 +39,7 @@ type DeepRequired = T extends Array } : Exclude; -type Fields = Field, FieldName>>; +type Fields = Field>>; export function fields(): Fields { return new Proxy( diff --git a/src/index.ts b/src/index.ts index f03caa3..23a6e9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { fields } from './fields'; +export { asNum, asBool, asStr, Codec } from "./codecs" export { extractFormData } from './extract_form'; diff --git a/src/set.ts b/src/set.ts index 359a17a..8862abd 100644 --- a/src/set.ts +++ b/src/set.ts @@ -25,6 +25,7 @@ export function set(obj: any, path: string, value: unknown) { type ParsedSegment = | { type: 'key'; key: string } | { type: 'index'; key: number }; + function parsePath(str: string) { const parsed: ParsedSegment[] = [];