-
-
Save ryanflorence/859e39736a77465f9f2da2f8d3c9d584 to your computer and use it in GitHub Desktop.
export let action: ActionFunction = async ({ request, params }) => { | |
let session = await requireAuthSession(request); | |
await ensureUserAccount(session.get("auth")); | |
let data = Object.fromEntries(await request.formData()); | |
invariant(typeof data._action === "string", "_action should be string"); | |
switch (data._action) { | |
case Actions.CREATE_TASK: | |
case Actions.UPDATE_TASK_NAME: { | |
invariant(typeof data.id === "string", "expected taskId"); | |
invariant(typeof data.name === "string", "expected name"); | |
invariant( | |
typeof data.date === "string" || data.date === undefined, | |
"expected name" | |
); | |
return createTask(data.id, data.date) | |
} | |
case Actions.MARK_COMPLETE: { | |
invariant(typeof data.id === "string", "expected task id"); | |
return markComplete(data, id) | |
} | |
case Actions.MARK_INCOMPLETE: { | |
invariant(typeof data.id === "string", "expected task id"); | |
return markIncomplete(data.id) | |
} | |
case Actions.MOVE_TASK_TO_DAY: { | |
invariant(typeof data.id === "string", "expected taskId"); | |
invariant(params.day, "expcted params.day"); | |
return moveTaskToDay(data.id, params.day) | |
} | |
case Actions.MOVE_TASK_TO_BACKLOG: { | |
invariant(typeof data.id === "string", "expected taskId"); | |
return moveTaskToBacklog(data.id, params.day) | |
} | |
case Actions.DELETE_TASK: { | |
invariant(typeof data.id === "string", "expected taskId"); | |
return deleteTask(data.id) | |
} | |
default: { | |
throw new Response("Bad Request", { status: 400 }); | |
} | |
} | |
}; |
This is unfinished but is a starting point of where I would handle the logic of validation separately from the place where it's executed in the controller.
That way I would be able to write tests for each validation rule, probably one "name" per need instead of per field name and type.
Also, I'd leverage a bit more user-defined assertion functions.
I would start with something like the following
/**
* Somewhere else, probably global to the web app
*/
export interface IBaseViewControllerData<TData, TActions = string> {
readonly _action: TActions;
// Actually, this is how I would do, separate the "data" entity shape from the rest
readonly data: TData
}
export type IAssertator<T> = (input: unknown | T) => asserts input is T
export type IFieldAssertator = <T>(fieldName: string, input: T | unknown) => asserts input is T
export type IFieldValidatorReadonlyMap<T> = ReadonlyMap<string, (input: T | unknown) => asserts input is T>
The controller file
/**
* This controller has those possibilities
*/
const CONTROLLER_ACTIONS = ['create','update'] as const
/**
* Create a type based on the CONTROLLER_ACTIONS
*/
type IControllerActions = typeof CONTROLLER_ACTIONS[number]
/**
* The data this controller will work with
*/
interface IDataEntity {
readonly id: string
readonly name?: string
readonly date?: string
}
interface ISomeViewController extends IBaseViewControllerData<IDataEntity, IControllerActions> {
readonly data: IDataEntity
}
// This should be tested.
export const fieldMap = new Map([
['id', (dto: IDataEntity) => Reflect.has(dto, 'id') && typeof dto.id === 'string'],
['name', (dto: IDataEntity) => Reflect.has(dto, 'name') && typeof dto.name === 'string'],
['date', (dto: IDataEntity) => Reflect.has(dto, 'date') && typeof dto.date === 'string'],
]) as IFieldValidatorReadonlyMap<IDataEntity>
/**
* Simple implementation of "invariant"
* This should be tested too.
*/
export const assertsMustHave: IFieldAssertator = (fieldName, input: unknown | IDataEntity) => {
const maybe = fieldMap.get(fieldName as string)
if (maybe && input && Reflect.has(input as IDataEntity, fieldName)) {
const assertator: IAssertator<IDataEntity> = maybe
try {
assertator(input)
} catch {
const message = `Input object does not have property ${fieldName} or is not of the expected type`
throw new TypeError(message)
}
return
}
// In case trying to call a field that is not supported
const message = `There is no field named ${fieldName}`
throw new Error(message)
}
Then, in the switch map, you can do
const controllerState: ISomeViewController = { /* ... */ }
const { _action, data = {} } = controllerState
// ... then, inside the switch you can call
assertsMustHave('id', data)
Refer to:
- TypeScript 3.7’s Assertion Functions
- User-defined type guards and TypeScript handbook about user-defined type guards
- Indexable type
Note that this is just a quick draft, I've litterally spent less than an hour just to see how I would write validation in a way where I'd be able to write tests without testing the controller and the data passing into it. This was just a kata to flex my muscles, I would probably do more but that was just for illustration. Clearly there has to have more validator because data types, while it is best to have flat structure, are rarely just strings numbers, boolean etc.
Joi validation integration could be nice