Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add formData schema #539

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
545 changes: 545 additions & 0 deletions library/src/schemas/formData/formData.test.ts

Large diffs are not rendered by default.

267 changes: 267 additions & 0 deletions library/src/schemas/formData/formData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import type {
BaseSchema,
ErrorMessage,
Pipe,
SchemaConfig,
SchemaIssues,
} from '../../types/index.ts';
import {
defaultArgs,
pipeResult,
schemaIssue,
schemaResult,
} from '../../utils/index.ts';
import type { ArraySchema } from '../array/array.ts';
import {
object,
type ObjectEntries,
type ObjectSchema,
} from '../object/object.ts';
import type { ObjectOutput } from '../object/types.ts';
import type { FormDataPathItem } from './types.ts';

/**
* FormData schema type.
*/
export interface FormDataSchema<
TEntries extends ObjectEntries,
TOutput = ObjectOutput<TEntries, undefined>,
> extends BaseSchema<FormData, TOutput> {
/**
* The schema type.
*/
type: 'formData';
/**
* The object entries.
*/
entries: TEntries | ObjectSchema<TEntries, undefined>;
/**
* The error message.
*/
message: ErrorMessage | undefined;
/**
* The validation and transformation pipeline.
*/
pipe: Pipe<TOutput> | undefined;
}

function decodeEntry(schema: BaseSchema, value: FormDataEntryValue | null) {
if (value === null) return undefined;
if (value === '') return null;
switch (schema.type) {
case 'boolean': {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
}
case 'date': {
const number = Number(value);
if (!Number.isNaN(number)) return new Date(number);
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? value : date;
}
case 'number': {
const number = Number(value);
return Number.isNaN(number) ? value : number;
}
default: {
return value;
}
}
}

function decode(
schema: BaseSchema,
input: FormData,
key: string,
config?: SchemaConfig
) {
let typed = true;
let issues: SchemaIssues | undefined;
let output: any;
switch (schema.type) {
case 'array': {
let arrayOutput: any[] | undefined;
const arraySchema = schema as ArraySchema<BaseSchema>;
const itemSchema = arraySchema.item;
const items = input.getAll(key);
if (items.length > 0) {
arrayOutput = [];
for (const item of items) {
const value = decodeEntry(itemSchema, item);
const result = itemSchema._parse(value, config);
if (result.issues) {
if (!issues) issues = result.issues;
if (config?.abortEarly) {
typed = false;
break;
}
}
if (!result.typed) typed = false;
if (result.output !== undefined) arrayOutput.push(result.output);
}
} else {
let index = 0;
let result;
do {
const arrayKey = `${key}.${index}`;
result = decode(itemSchema, input, arrayKey, config);
if (result.issues) {
if (!issues) issues = result.issues;
if (config?.abortEarly) {
typed = false;
break;
}
}
if (!result.typed) typed = false;
if (result.output !== undefined) {
arrayOutput ??= [];
arrayOutput.push(result.output);
}
index++;
} while (result.output !== undefined && !result.issues);
}
if (!arrayOutput) {
issues = schemaIssue(arraySchema, formData, arrayOutput, config, {
issues,
}).issues;
}
output = arrayOutput;
break;
}
case 'object': {
let objectOutput: Record<string, any> | undefined;
const objectSchema = schema as ObjectSchema<
Record<string, BaseSchema>,
undefined
>;
for (const [objectKey, entrySchema] of Object.entries(
objectSchema.entries
)) {
const decodeKey = key ? `${key}.${objectKey}` : objectKey;
const result = decode(entrySchema, input, decodeKey, config);
if (result.output !== undefined) {
objectOutput ??= {};
objectOutput[objectKey] = result.output;
}
if (result.issues) {
const pathItem: FormDataPathItem = {
type: 'formData',
origin: 'value',
input,
key: decodeKey,
value: result.output ?? input.get(decodeKey),
};
for (const issue of result.issues) {
if (issue.path) issue.path.unshift(pathItem);
else issue.path = [pathItem];
issues?.push(issue);
}
if (!issues) issues = result.issues;
if (config?.abortEarly) {
typed = false;
break;
}
}
if (!result.typed) typed = false;
}
if (!objectOutput) {
if (key) {
issues = schemaIssue(objectSchema, formData, objectOutput, config, {
issues,
}).issues;
} else {
objectOutput = {};
}
}
output = objectOutput;
break;
}
default: {
const value = decodeEntry(schema, input.get(key));
const result = schema._parse(value, config);
if (result.issues) issues = result.issues;
if (!result.typed) typed = false;
output = result.output;
}
}
return { typed, output, issues };
}

/**
* Creates a formData schema.
*
* @param entries The object entries.
* @param pipe A validation and transformation pipe.
*
* @returns A formData schema.
*/
export function formData<TEntries extends ObjectEntries>(
entries: TEntries | ObjectSchema<TEntries, undefined>,
pipe?: Pipe<ObjectOutput<TEntries, undefined>>
): FormDataSchema<TEntries>;

/**
* Creates a formData schema.
*
* @param entries The object entries.
* @param message The error message.
* @param pipe A validation and transformation pipe.
*
* @returns A formData schema.
*/
export function formData<TEntries extends ObjectEntries>(
entries: TEntries | ObjectSchema<TEntries, undefined>,
message?: ErrorMessage,
pipe?: Pipe<ObjectOutput<TEntries, undefined>>
): FormDataSchema<TEntries>;

export function formData<TEntries extends ObjectEntries>(
entries: TEntries | ObjectSchema<TEntries, undefined>,
arg2?: ErrorMessage | Pipe<ObjectOutput<TEntries, undefined>>,
arg3?: Pipe<ObjectOutput<TEntries, undefined>>
): FormDataSchema<TEntries> {
// Get message and pipe argument
const [message, pipe] = defaultArgs(arg2, arg3);

// Create and return array schema
return {
type: 'formData',
expects: 'FormData',
async: false,
entries,
message,
pipe,
_parse(input, config) {
// If root type is valid, check nested types
if (input instanceof FormData) {
// Parse nested schema, decode and validate FormData against it
const schema =
entries.type === 'object'
? (entries as ObjectSchema<TEntries, undefined>)
: object(entries as TEntries);
const result = decode(schema, input, '', config);

// If output is typed, return pipe result
if (result.typed) {
return pipeResult(
this,
result.output as ObjectOutput<TEntries, undefined>,
config,
result.issues
);
}

// Otherwise, return untyped schema result
return schemaResult(
false,
result.output,
result.issues as SchemaIssues
);
}

// Otherwise, return schema issue
return schemaIssue(this, formData, input, config);
},
};
}