1
0
Quellcode durchsuchen

add account crud with validation

Aneurin Barker Snook vor 1 Jahr
Ursprung
Commit
874d23c1e2
6 geänderte Dateien mit 387 neuen und 23 gelöschten Zeilen
  1. 122 16
      src/account/api.ts
  2. 21 3
      src/account/model.ts
  3. 3 0
      src/account/types.ts
  4. 1 1
      src/db.ts
  5. 41 3
      src/http.ts
  6. 199 0
      src/validate.ts

+ 122 - 16
src/account/api.ts

@@ -1,30 +1,136 @@
+import * as v from '../validate'
+import type { Account } from './types'
 import type { Context } from '../types'
+import { ObjectId } from 'mongodb'
 import type { RequestHandler } from 'express'
+import type { WithId } from 'mongodb'
+import { sendBadRequest, sendNotFound } from '../http'
 
-export function createAccount(ctx: Context): RequestHandler {
-  return function (req, res, next) {
-    res.send('Create account WIP')
-    next()
+export function createAccount({ model }: Context): RequestHandler {
+  interface RequestData {
+    account: Account
+  }
+
+  interface ResponseData {
+    account: WithId<Account>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    account: {
+      email: v.email,
+    },
+  })
+
+  return async function (req, res, next) {
+    try {
+      const input = readRequestData(req.body)
+
+      const account = await model.account.create(input.account)
+
+      const output: ResponseData = { account }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
   }
 }
 
-export function deleteAccount(ctx: Context): RequestHandler {
-  return function (req, res, next) {
-    res.send('Delete account WIP')
-    next()
+export function deleteAccount({ model }: Context): RequestHandler {
+  interface ResponseData {
+    account: WithId<Account>
+  }
+
+  return async function (req, res, next) {
+    /** @todo get ID from authentication */
+    const id = req.params.id
+    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
+
+    try {
+      /** @todo delete related data */
+      const account = await model.account.collection.findOneAndDelete({ _id: new ObjectId(id) })
+      if (!account) return sendNotFound(res, next)
+
+      const output: ResponseData = { account }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
   }
 }
 
-export function getAccount(ctx: Context): RequestHandler {
-  return function (req, res, next) {
-    res.send('Get account WIP')
-    next()
+export function getAccount({ model }: Context): RequestHandler {
+  interface ResponseData {
+    account: WithId<Account>
+  }
+
+  return async function (req, res, next) {
+    /** @todo get ID from authentication */
+    const id = req.params.id
+    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
+
+    try {
+      const account = await model.account.collection.findOne({ _id: new ObjectId(id) })
+      if (!account) return sendNotFound(res, next)
+
+      const output: ResponseData = { account }
+      res.send(output)
+      next()
+    } catch (err) {
+      return next(err)
+    }
   }
 }
 
-export function updateAccount(ctx: Context): RequestHandler {
-  return function (req, res, next) {
-    res.send('Update account WIP')
-    next()
+export function updateAccount({ model }: Context): RequestHandler {
+  interface RequestData {
+    account: Partial<Account>
+  }
+
+  interface ResponseData {
+    account: WithId<Account>
+  }
+
+  const readRequestData = v.validate<RequestData>({
+    account: {
+      email: v.seq(v.optional, v.email),
+    },
+  })
+
+  return async function (req, res, next) {
+    /** @todo get ID from authentication */
+    const id = req.params.id
+    if (!id) return sendBadRequest(res, next, { reason: 'no ID' })
+
+    try {
+      const input = readRequestData(req.body)
+      if (!input.account.email) {
+        return sendBadRequest(res, next, { reason: 'no data' })
+      }
+
+      const account = await model.account.collection.findOneAndUpdate(
+        { _id: new ObjectId(id) },
+        { $set: input.account },
+        { returnDocument: 'after' },
+      )
+      if (!account) return sendNotFound(res, next)
+
+      const output: ResponseData = { account }
+      res.send(output)
+      next()
+    } catch (err) {
+      const name = (err as Error).name
+      if (name === 'ValidateError') {
+        const ve = err as v.ValidateError
+        return sendBadRequest(res, next, { param: ve.param, reason: ve.message })
+      }
+      return next(err)
+    }
   }
 }

+ 21 - 3
src/account/model.ts

@@ -1,12 +1,30 @@
+import type { Account } from './types'
 import type { Context } from '../types'
+import type { WithId } from 'mongodb'
 
-export type AccountModel = ReturnType<typeof createAccountModel>
+export type AccountModel = Awaited<ReturnType<typeof createAccountModel>>
 
-function createAccountModel(ctx: Context) {
-  const collection = ctx.db.collection('account')
+async function createAccountModel(ctx: Context) {
+  const collection = ctx.db.collection<Account>('account')
+
+  async function create(input: Account): Promise<WithId<Account>> {
+    const result = await collection.insertOne(input)
+    return { ...input, _id: result.insertedId }
+  }
+
+  async function init() {
+    const exists = await collection.indexExists('unq_email')
+    if (!exists) {
+      await collection.createIndex({ email: 1 }, { unique: true })
+    }
+  }
+
+  await init()
 
   return {
     collection,
+    create,
+    init,
   }
 }
 

+ 3 - 0
src/account/types.ts

@@ -0,0 +1,3 @@
+export interface Account {
+  email: string
+}

+ 1 - 1
src/db.ts

@@ -13,7 +13,7 @@ async function createDatabase(ctx: Context) {
 
   const dbCtx = { ...ctx, mongo, db }
   const model = <Models>{
-    account: createAccountModel(dbCtx),
+    account: await createAccountModel(dbCtx),
   }
 
   return { mongo, db, model }

+ 41 - 3
src/http.ts

@@ -1,15 +1,28 @@
 import * as account from './account/api'
 import type { Context } from './types'
 import express from 'express'
+import type { ErrorRequestHandler, NextFunction, Response } from 'express'
 
 export function createExpress(ctx: Context) {
   const app = express()
+
+  app.use(express.json())
+
   const prefix = ctx.config.api.prefix
 
-  app.get(`${prefix}/account`, account.getAccount(ctx))
-  app.put(`${prefix}/account`, account.getAccount(ctx))
   app.post(`${prefix}/account`, account.createAccount(ctx))
-  app.delete(`${prefix}/account`, account.deleteAccount(ctx))
+  app.get(`${prefix}/account/:id?`, account.getAccount(ctx))
+  app.put(`${prefix}/account/:id?`, account.updateAccount(ctx))
+  app.delete(`${prefix}/account/:id?`, account.deleteAccount(ctx))
+
+  // Handle errors passed to next function
+  const catchError: ErrorRequestHandler = (err, req, res, next) => {
+    if (!res.headersSent) {
+      sendInternalServerError(res, next, { reason: (err as Error).message })
+    }
+    ctx.log.error(err)
+  }
+  app.use(catchError)
 
   // Log request after handling
   app.use((req, res, next) => {
@@ -19,3 +32,28 @@ export function createExpress(ctx: Context) {
 
   return app
 }
+
+export function sendBadRequest(res: Response, next: NextFunction, data?: Record<string, unknown>) {
+  res.status(400).send({ message: 'Bad Request', ...data })
+  next()
+}
+
+export function sendForbidden(res: Response, next: NextFunction, data?: Record<string, unknown>) {
+  res.status(403).send({ message: 'Forbidden', ...data })
+  next()
+}
+
+export function sendInternalServerError(res: Response, next: NextFunction, data?: Record<string, unknown>) {
+  res.status(500).send({ message: 'Internal Server Error', ...data })
+  next()
+}
+
+export function sendNotFound(res: Response, next: NextFunction, data?: Record<string, unknown>) {
+  res.status(404).send({ message: 'Not Found', ...data })
+  next()
+}
+
+export function sendUnauthorized(res: Response, next: NextFunction, data?: Record<string, unknown>) {
+  res.status(401).send({ message: 'Unauthorized', ...data })
+  next()
+}

+ 199 - 0
src/validate.ts

@@ -0,0 +1,199 @@
+/**
+ * IMPORTED EXTERNAL LIB - DO NOT MODIFY
+ */
+
+/**
+ * Validation specification.
+ * Given an object type, a specification outline is derived to ensure that validation is defined for all scalar
+ * properties.
+ *
+ * If a property is array-type, a single validator must be specified which receives the entire array.
+ * If a property is otherwise object-type, the validation spec extends deeply into it.
+ *
+ * Optional properties must still be specified; the `optional()` function can be used to allow them to be skipped if
+ * the value is undefined.
+ */
+export type Spec<T> =
+  T extends Array<unknown>
+    ? ValidateFn
+    : T extends object
+      ? { [P in keyof Required<T>]: Spec<T[P]> }
+      : ValidateFn
+
+/** Validation error thrown by `validate()`. Provides the failed parameter along with the error message. */
+export class ValidateError extends Error {
+  param: string
+
+  constructor(param: string, message: string) {
+    super(message)
+    this.name = 'ValidateError'
+    this.param = param
+  }
+}
+
+/**
+ * A validation function takes any input and throws an error if it does not satisfy the relevant condition.
+ *
+ * It may return `true` signifying that further validation is not required; see `seq()` for more detail.
+ *
+ * When used in conjunction with `validate()` to parse an object, an `origInput` is also passed giving the entire
+ * data object.
+ * This can be used to validate one property based on the value of another, though as the type of the parent input
+ * is `unknown` knowledge of the structure has to be asserted by the caller.
+ */
+export type ValidateFn = (input: unknown, origInput?: unknown) => true | void
+
+/** Validate input is Boolean. */
+export const bool: ValidateFn = input => {
+  if (typeof input !== 'boolean') throw new Error('must be boolean')
+}
+
+/**
+ * FQDN format expression.
+ *
+ * Based on https://stackoverflow.com/a/62917037/1717753
+ */
+const domainRegexp = /^((?=[a-z0-9-_]{1,63}\.)(xn--)?[a-z0-9_]+(-[a-z0-9_]+)*\.)+[a-z]{2,63}$/
+
+/**
+ * Validate input is a fully-qualified domain name.
+ * Implicitly validates `str()` for convenience.
+ */
+export const domain: ValidateFn = input => {
+  str(input)
+  if (!domainRegexp.test(input as string)) throw new Error('invalid domain')
+}
+
+/** Email format expression. */
+const emailRegexp = /^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+
+/**
+ * Validate input is an email address.
+ * Implicitly validates `str()` for convenience.
+ */
+export const email: ValidateFn = input => {
+  str(input)
+  if (!emailRegexp.test(input as string)) throw new Error('invalid email address')
+}
+
+/** Validate input is exactly equal to a comparison value. */
+export const eq = <T>(cmp: T): ValidateFn => input => {
+  if (input !== cmp) throw new Error(`must be ${cmp}`)
+}
+
+/** Validate exact length of string. */
+export const exactLength = (n: number): ValidateFn => input => {
+  if ((input as string).length !== n) throw new Error(`must contain exactly ${n} characters`)
+}
+
+/** Lowercase hexadecimal expression. */
+const hexRegexp = /^[a-f0-9]*$/
+
+/**
+ * Validate string is lowercase hexadecimal.
+ * Implicitly validates `str()` for convenience.
+ */
+export const hex: ValidateFn = input => {
+  str(input)
+  if (!hexRegexp.test(input as string)) throw new Error('invalid characters')
+}
+
+/**
+ * Validate input is an integer.
+ * Implicitly validates `numeric()` for convenience.
+ */
+export const integer: ValidateFn = input => {
+  numeric(input)
+  if ((input as number).toString().indexOf('.') > -1) throw new Error('must be an integer')
+}
+
+/** Validate maximum number. */
+export const max = (n: number): ValidateFn => input => {
+  if (input as number > n) throw new Error(`must be no more than ${n}`)
+}
+
+/** Validate maximum length of string. */
+export const maxLength = (n: number): ValidateFn => input => {
+  if ((input as string).length > n) throw new Error(`must be no longer than ${n} characters`)
+}
+
+/** Validate minimum number. */
+export const min = (n: number): ValidateFn => input => {
+  if (input as number < n) throw new Error(`must be no less than ${n}`)
+}
+
+/** Validate minimum length of string. */
+export const minLength = (n: number): ValidateFn => input => {
+  if ((input as string).length < n) throw new Error(`must be no shorter than ${n} characters`)
+}
+
+/** Validate input is numeric. */
+export const numeric: ValidateFn = input => {
+  if (typeof input !== 'number') throw new Error('must be a number')
+}
+
+/** Validate input is one of a range of options. */
+export const oneOf = <T>(range: T[]): ValidateFn => input => {
+  if (!range.includes(input as T)) throw new Error(`must be one of: ${range.join(', ')}`)
+}
+
+/**
+ * Special non-validator that returns true if the input is null or undefined, and never throws an error.
+ * Normally used with `seq()`.
+ */
+export const optional: ValidateFn = input => {
+  if (input === null || input === undefined) return true
+}
+
+/** Validate string against regular expression. */
+export const regexp = (re: RegExp): ValidateFn => input => {
+  if (!re.test(input as string)) throw new Error('invalid characters')
+}
+
+/**
+ * Sequentially runs multiple validation functions.
+ * If a function returns true, for example `optional()`, subsequent validation is ignored.
+ */
+export const seq = (...fs: ValidateFn[]): ValidateFn => (input, origInput) => {
+  for (let i = 0; i < fs.length; i++) {
+    if (fs[i](input, origInput)) return
+  }
+}
+
+/** Validate input is a string. */
+export const str: ValidateFn = input => {
+  if (typeof input !== 'string') throw new Error('must be a string')
+}
+
+/**
+ * Read an unknown input and assert it matches an object specification.
+ * This function immediately throws a ValidateError if any validation fails.
+ * Otherwise, a typed copy of the input is returned.
+ *
+ * This method does not support validation across multiple properties or asynchronous validation.
+ *
+ * @todo Remove usage of `any` type.
+ */
+export const validate = <T extends object>(spec: Spec<Required<T>>, parent = '') =>
+  (input: unknown, origInput?: unknown): T => {
+    type V = keyof Spec<Required<T>>
+    type I = keyof T
+    if (typeof input !== 'object' || input === null) throw new Error('no data')
+    return Object.keys(spec).reduce((v, k) => {
+      const f = spec[k as V]
+      const value = (input as T)[k as I]
+      if (typeof f === 'function') {
+        try {
+          f(value, origInput || input)
+          v[k as I] = value
+        } catch (err) {
+          const param = parent ? `${parent}.${k}` : k
+          throw new ValidateError(param, (err as Error).message)
+        }
+      } else {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        v[k as I] = validate(f as any, k as string)(value as any, origInput || input) as any
+      }
+      return v
+    }, <T>{})
+  }