Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Created December 15, 2021 15:19
Show Gist options
  • Save ryanflorence/859e39736a77465f9f2da2f8d3c9d584 to your computer and use it in GitHub Desktop.
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 });
}
}
};
@revskill10
Copy link

Joi validation integration could be nice

@renoirb
Copy link

renoirb commented Dec 17, 2021

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:

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment