Răsfoiți Sursa

add herd search, paginate api

plus replace local validation with package, name indexes properly
Aneurin Barker Snook 1 an în urmă
părinte
comite
5b550b6006
10 a modificat fișierele cu 79 adăugiri și 206 ștergeri
  1. 6 0
      package-lock.json
  2. 1 0
      package.json
  3. 1 1
      src/account/api.ts
  4. 2 2
      src/account/model.ts
  5. 11 0
      src/api.ts
  6. 49 1
      src/herd/api.ts
  7. 7 2
      src/herd/model.ts
  8. 1 0
      src/http.ts
  9. 1 1
      src/task/api.ts
  10. 0 199
      src/validate.ts

+ 6 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.0.0",
       "license": "SEE LICENSE IN LICENSE.md",
       "dependencies": {
+        "@edge/misc-utils": "^1.0.4",
         "dotenv": "^16.3.1",
         "express": "^4.18.2",
         "jsonwebtoken": "^9.0.2",
@@ -47,6 +48,11 @@
         "node": ">=12"
       }
     },
+    "node_modules/@edge/misc-utils": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@edge/misc-utils/-/misc-utils-1.0.4.tgz",
+      "integrity": "sha512-MY6EsTLHYYXzg4j4YQxi2Vj76saWAFbhMIs3IZRqFIjKp+AmzIYR6j1dXi+B4nxXBAipfRMSmaIdB9i2f8Ka3g=="
+    },
     "node_modules/@eslint-community/eslint-utils": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "typescript": "^5.3.2"
   },
   "dependencies": {
+    "@edge/misc-utils": "^1.0.4",
     "dotenv": "^16.3.1",
     "express": "^4.18.2",
     "jsonwebtoken": "^9.0.2",

+ 1 - 1
src/account/api.ts

@@ -1,9 +1,9 @@
-import * as v from '../validate'
 import type { AuthRequestHandler } from '../auth'
 import type { Context } from '../types'
 import { ObjectId } from 'mongodb'
 import type { RequestHandler } from 'express'
 import type { WithId } from 'mongodb'
+import { validate as v } from '@edge/misc-utils'
 import type { Account, AccountCreate, AccountUpdate } from './types'
 import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
 

+ 2 - 2
src/account/model.ts

@@ -41,9 +41,9 @@ async function createAccountModel(ctx: Context) {
   async function init() {
     await ctx.db.createCollection('account')
 
-    const exists = await collection.indexExists('email_1')
+    const exists = await collection.indexExists('account_unq_email')
     if (!exists) {
-      await collection.createIndex({ email: 1 }, { unique: true })
+      await collection.createIndex({ email: 1 }, { name: 'account_unq_email', unique: true })
     }
   }
 

+ 11 - 0
src/api.ts

@@ -0,0 +1,11 @@
+/**
+ * Standard search result type.
+ */
+export interface SearchResult<T> {
+  results: T[]
+  metadata: {
+    limit: number
+    page: number
+    totalCount: number
+  }
+}

+ 49 - 1
src/herd/api.ts

@@ -1,9 +1,10 @@
-import * as v from '../validate'
 import type { AuthRequestHandler } from '../auth'
 import type { Context } from '../types'
 import { ObjectId } from 'mongodb'
+import type { SearchResult } from '../api'
 import type { WithId } from 'mongodb'
 import type { Herd, HerdCreate, HerdUpdate } from './types'
+import { query, validate as v } from '@edge/misc-utils'
 import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
 
 /** Create a herd. */
@@ -105,6 +106,53 @@ export function getHerd({ model }: Context): AuthRequestHandler {
   }
 }
 
+/** Search herds. */
+export function searchHerds({ model }: Context): AuthRequestHandler {
+  type ResponseData = SearchResult<{
+    herd: WithId<Herd>
+  }>
+
+  return async function (req, res, next) {
+    if (!req.account) return sendUnauthorized(res, next)
+
+    // Read parameters
+    const limit = query.integer(req.query.limit, 1, 100) || 10
+    const page = query.integer(req.query.page, 1) || 1
+    const search = query.str(req.query.search)
+    const sort = query.sorts(req.query.sort, ['name'], ['name', 'ASC'])
+
+    // Build filter and skip
+    const filter: Record<string, unknown> = {
+      _account: req.account._id,
+    }
+    if (search) filter.$text = { $search: search }
+    const skip = (page - 1) * limit
+
+    try {
+      // Get total documents count for filter
+      const totalCount = await model.herd.collection.countDocuments(filter)
+
+      // Build cursor
+      let cursor = model.herd.collection.find(filter)
+      for (const [prop, dir] of sort) {
+        cursor = cursor.sort(prop, dir === 'ASC' ? 1 : -1)
+      }
+      cursor = cursor.skip(skip).limit(limit)
+
+      // Get results and send output
+      const data = await cursor.toArray()
+      const output: ResponseData = {
+        results: data.map(herd => ({ herd })),
+        metadata: { limit, page, totalCount },
+      }
+      res.send(output)
+      next()
+    } catch (err) {
+      next(err)
+    }
+  }
+}
+
 /** Update a herd. */
 export function updateHerd({ model }: Context): AuthRequestHandler {
   interface RequestData {

+ 7 - 2
src/herd/model.ts

@@ -25,9 +25,14 @@ async function createHerdModel(ctx: Context) {
   async function init() {
     await ctx.db.createCollection('herd')
 
-    const exists = await collection.indexExists('_account_1_name_1')
+    let exists = await collection.indexExists('herd_unq_account_name')
     if (!exists) {
-      await collection.createIndex({ _account: 1, name: 1 }, { unique: true })
+      await collection.createIndex({ _account: 1, name: 1 }, { name: 'herd_unq_account_name', unique: true })
+    }
+
+    exists = await collection.indexExists('herd_text')
+    if (!exists) {
+      await collection.createIndex({ name: 'text' }, { name: 'herd_text' })
     }
   }
 

+ 1 - 0
src/http.ts

@@ -21,6 +21,7 @@ export function createExpress(ctx: Context) {
   app.delete(`${prefix}/account/:id?`, account.deleteAccount(ctx))
 
   // Herd APIs
+  app.get(`${prefix}/herds`, herd.searchHerds(ctx))
   app.post(`${prefix}/herd`, herd.createHerd(ctx))
   app.get(`${prefix}/herd/:id`, herd.getHerd(ctx))
   app.put(`${prefix}/herd/:id`, herd.updateHerd(ctx))

+ 1 - 1
src/task/api.ts

@@ -1,8 +1,8 @@
-import * as v from '../validate'
 import type { AuthRequestHandler } from '../auth'
 import type { Context } from '../types'
 import { ObjectId } from 'mongodb'
 import type { WithId } from 'mongodb'
+import { validate as v } from '@edge/misc-utils'
 import type { Task, TaskCreate, TaskUpdate } from './types'
 import { sendBadRequest, sendForbidden, sendNotFound, sendUnauthorized } from '../http'
 

+ 0 - 199
src/validate.ts

@@ -1,199 +0,0 @@
-/**
- * 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>{})
-  }