浏览代码

add core providers, hooks, lib, api

Aneurin Barker Snook 1 年之前
父节点
当前提交
abcc400948

+ 2 - 1
src/auth.ts

@@ -2,6 +2,7 @@ import type { Account } from './account/types'
 import type { Context } from './types'
 import { ObjectId } from 'mongodb'
 import type { WithId } from 'mongodb'
+import { http } from '@edge/misc-utils'
 import jwt from 'jsonwebtoken'
 import type { NextFunction, Request, Response } from 'express'
 
@@ -91,7 +92,7 @@ function createAuth(ctx: Context) {
 
       // Load account
       const account = await ctx.model.account.collection.findOne({ _id })
-      if (!account) throw new Error(`account ${_id.toString()} not found`)
+      if (!account) return http.unauthorized(res, next)
       req.account = account
       next()
     } catch (err) {

+ 1 - 1
web/src/App.tsx

@@ -1,5 +1,5 @@
 import './App.css'
-import reactLogo from './assets/react.svg'
+import reactLogo from '@/assets/react.svg'
 import { useState } from 'react'
 import viteLogo from '/vite.svg'
 

+ 84 - 0
web/src/api/account.ts

@@ -0,0 +1,84 @@
+import { request } from './lib'
+import type { Options, SomeRequired, WithId } from './lib'
+
+/** Account data. */
+export interface Account {
+  /** Email address. Used for authentication. */
+  email: string
+  /** Password. Used for authentication. */
+  password: string
+  /** Password salt. Used for authentication. */
+  passwordSalt: string
+}
+
+/** Create account request data. */
+export interface CreateAccountRequest {
+  account: SomeRequired<Account, 'email' | 'password'>
+}
+
+/** Create account response data. */
+export interface CreateAccountResponse {
+  account: WithId<Account>
+}
+
+/** Delete account response data. */
+export interface DeleteAccountResponse {
+  account: WithId<Account>
+  herds: {
+    deletedCount: number
+  }
+  tasks: {
+    deletedCount: number
+  }
+}
+
+/** Get account response data. */
+export interface GetAccountResponse {
+  account: WithId<Account>
+}
+
+/** Account login request data. */
+export interface LoginAccountRequest {
+  account: Pick<Account, 'email' | 'password'>
+}
+
+/** Account login response data. */
+export interface LoginAccountResponse {
+  token: string
+  account: Account
+}
+
+/** Update account request data. */
+export interface UpdateAccountRequest {
+  account: Partial<Account>
+}
+
+/** Update account response data. */
+export interface UpdateAccountResponse {
+  account: WithId<Account>
+}
+
+/** Create an account. */
+export async function createAccount(opt: Options, data: CreateAccountRequest): Promise<CreateAccountResponse> {
+  return request(opt, 'POST', '/account', undefined, data)
+}
+
+/** Delete an account. */
+export async function deleteAccount(opt: Options, id?: string): Promise<DeleteAccountResponse> {
+  return request(opt, 'DELETE', id ? `/account/${id}` : '/account')
+}
+
+/** Get an account. */
+export async function getAccount(opt: Options, id?: string): Promise<GetAccountResponse> {
+  return request(opt, 'GET', id ? `/account/${id}` : '/account')
+}
+
+/** Log in to an account. */
+export async function loginAccount(opt: Options, data: LoginAccountRequest): Promise<LoginAccountResponse> {
+  return request(opt, 'POST', '/login/account', undefined, data)
+}
+
+/** Update an account. */
+export async function updateAccount(opt: Options, id: string | undefined, data: UpdateAccountRequest): Promise<UpdateAccountResponse> {
+  return request(opt, 'PUT', id ? `/account/${id}` : '/account', undefined, data)
+}

+ 68 - 0
web/src/api/herd.ts

@@ -0,0 +1,68 @@
+import type { Options, SearchParams, SearchResponse, SomeRequired, WithId } from './lib'
+import { request, writeSearchParams } from './lib'
+
+/** Herd data. */
+export interface Herd {
+  /** Account ID. */
+  _account: string
+  /** Name. */
+  name: string
+}
+
+/** Create herd request data. */
+export interface CreateHerdRequest {
+  herd: SomeRequired<Herd, '_account' | 'name'>
+}
+
+/** Create herd response data. */
+export interface CreateHerdResponse {
+  herd: WithId<Herd>
+}
+
+/** Delete herd response data. */
+export interface DeleteHerdResponse {
+  herd: WithId<Herd>
+  tasks: {
+    deletedCount: number
+  }
+}
+
+/** Get herd response data. */
+export interface GetHerdResponse {
+  herd: WithId<Herd>
+}
+
+/** Update herd request data. */
+export interface UpdateHerdRequest {
+  herd: Partial<Herd>
+}
+
+/** Update herd response data. */
+export interface UpdateHerdResponse {
+  herd: WithId<Herd>
+}
+
+/** Create a herd. */
+export async function createHerd(opt: Options, data: CreateHerdRequest): Promise<CreateHerdResponse> {
+  return request(opt, 'POST', '/herd', undefined, data)
+}
+
+/** Delete a herd. */
+export async function deleteHerd(opt: Options, id: string): Promise<DeleteHerdResponse> {
+  return request(opt, 'DELETE', `/herd/${id}`)
+}
+
+/** Get a herd. */
+export async function getHerd(opt: Options, id: string): Promise<GetHerdResponse> {
+  return request(opt, 'GET', `/herd/${id}`)
+}
+
+/** Search herds. */
+export async function searchHerds(opt: Options, params?: SearchParams): Promise<SearchResponse<GetHerdResponse>> {
+  return request(opt, 'GET', '/herds', params && writeSearchParams(params))
+}
+
+/** Update a herd. */
+export async function updateHerd(opt: Options, id: string, data: UpdateHerdRequest): Promise<UpdateHerdResponse> {
+  return request(opt, 'PUT', `/herd/${id}`, undefined, data)
+}

+ 4 - 0
web/src/api/index.ts

@@ -0,0 +1,4 @@
+export * from './lib'
+
+import * as api from './lib'
+export default api

+ 153 - 0
web/src/api/lib.ts

@@ -0,0 +1,153 @@
+export * from './account'
+export * from './herd'
+export * from './misc'
+export * from './task'
+
+/** Document with an unique ID. */
+export type WithId<T> = T & {
+  /** Document ID. */
+  _id: string
+}
+
+/** HTTP request method. */
+export type Method = 'DELETE' | 'GET' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT'
+
+/** Request options. */
+export interface Options {
+  /** HTTP base URL for Herda Server API. */
+  host: string
+  /** Bearer token for authentication. */
+  token?: string
+  /** HTTP request timeout. */
+  timeout?: number
+}
+
+/**
+ * Request API error.
+ * Corresponds to https://github.com/edge/misc-utils/blob/master/lib/http.ts
+ */
+export class RequestError extends Error {
+  data
+  xhr
+
+  /** Create a request API error. */
+  constructor(message: string, data?: Record<string, unknown>, xhr?: XMLHttpRequest) {
+    super(message)
+    this.name = 'RequestError'
+    this.data = data
+    this.xhr = xhr
+  }
+
+  /**
+   * Create a request API error by parsing a XMLHTTPRequest (which is presumed to have completed).
+   *
+   * If the response is a standard REST error, its message and any additional data will be attached automatically to the RequestError.
+   * See `error` in <https://github.com/edge/misc-utils/blob/master/lib/http.ts> for more detail.
+   *
+   * If the response is not a standard REST error then YMMV.
+   */
+  static parse(xhr: XMLHttpRequest) {
+    let message = xhr.status.toString()
+    let data = undefined
+
+    if (xhr.getResponseHeader('Content-Type')?.startsWith('application/json')) {
+      const res = JSON.parse(xhr.response)
+      if (isObject(res)) {
+        if (res.message && typeof res.message === 'string') message = res.message
+        if (isObject(res.data)) data = res.data
+      }
+    }
+
+    return new this(message, data, xhr)
+  }
+}
+
+export interface SearchParams {
+  limit?: number
+  page?: number
+  search?: string
+  sort?: string[]
+}
+
+export interface SearchResponse<T> {
+  results: T
+  metadata: {
+    limit: number
+    page: number
+    totalCount: number
+  }
+}
+
+/** Make properties of T in the union K required, while making other properties optional. */
+export type SomeRequired<T, K extends keyof T> = Partial<T> & Required<Pick<T, K>>
+
+/** Simple object check for internal use only. */
+function isObject(data: unknown) {
+  return typeof data === 'object' && !(data instanceof Array) && data !== null
+}
+
+/** Convert URLSearch params to named search parameters. */
+export function readSearchParams(up: URLSearchParams): SearchParams {
+  const params: SearchParams = {}
+
+  if (up.has('limit')) params.limit = parseInt(up.get('limit') as string)
+  if (up.has('page')) params.page = parseInt(up.get('page') as string)
+  if (up.has('search')) params.search = up.get('search') as string
+
+  if (up.has('sort')) params.sort = up.getAll('sort')
+
+  return params
+}
+
+/**
+ * Perform an HTTP request to Herda Server REST API.
+ * This method should not normally be used directly outside of this package.
+ */
+export function request<T>(opt: Options, method: Method, path: string, params?: URLSearchParams, body?: unknown): Promise<T> {
+  let url = `${opt.host}${path}`
+  if (params) url = `${url}?${params.toString()}`
+
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest()
+
+    xhr.addEventListener('load', () => {
+      if (xhr.status >= 200 && xhr.status < 300) {
+        if (xhr.getResponseHeader('Content-Type')?.startsWith('application/json')) {
+          resolve(JSON.parse(xhr.response))
+        } else resolve(xhr.response)
+      } else reject(RequestError.parse(xhr))
+    })
+
+    xhr.addEventListener('error', reject)
+
+    if (opt.timeout) xhr.timeout = opt.timeout
+
+    xhr.open(method, url)
+
+    xhr.setRequestHeader('Accept', 'application/json')
+    if (opt.token) xhr.setRequestHeader('Authorization', `Bearer ${opt.token}`)
+
+    if (typeof body === 'string') xhr.send(body)
+    else if (typeof body === 'object') {
+      xhr.setRequestHeader('Content-Type', 'application/json')
+      xhr.send(JSON.stringify(body))
+    } else if (body !== undefined) {
+      reject(new Error('invalid body'))
+    } else xhr.send()
+  })
+}
+
+/** Convert named search parameters object to URLSearchParams. */
+export function writeSearchParams(params: SearchParams): URLSearchParams {
+  const up = new URLSearchParams()
+
+  if (params.limit !== undefined) up.append('limit', `${params.limit}`)
+  if (params.page !== undefined) up.append('page', `${params.page}`)
+  if (params.search !== undefined) up.append('search', params.search)
+
+  if (params.sort !== undefined) {
+    for (const sort of params.sort) up.append('sort', sort)
+  }
+
+  return up
+}

+ 18 - 0
web/src/api/misc.ts

@@ -0,0 +1,18 @@
+import type { Options } from './lib'
+import { request } from './lib'
+
+/** Herda Server information. */
+export interface ServerInfo {
+  /** Server name. */
+  product: string
+  /** Server version. */
+  version: string
+}
+
+/**
+ * Get information about Herda Server.
+ * The API is static, so this function can be used to check whether the server is reachable.
+ */
+export function getServerInfo(opt: Options): Promise<ServerInfo> {
+  return request(opt, 'GET', '/')
+}

+ 71 - 0
web/src/api/task.ts

@@ -0,0 +1,71 @@
+import type { Options, SearchParams, SearchResponse, SomeRequired, WithId } from './lib'
+import { request, writeSearchParams } from './lib'
+
+/** Task data. */
+export interface Task {
+  /** Herd ID. */
+  _herd: string
+  /** Account ID reflecting the task assignee. */
+  _account: string
+  /** Description. */
+  description: string
+  /** Position in herd. */
+  position: number
+  /** Flag signifying whether the task is done. */
+  done: boolean
+}
+
+/** Create task request data. */
+export interface CreateTaskRequest {
+  task: SomeRequired<Task, '_herd' | '_account' | 'description'>
+}
+
+/** Create task response data. */
+export interface CreateTaskResponse {
+  task: WithId<Task>
+}
+
+/** Delete task response data. */
+export interface DeleteTaskResponse {
+  task: WithId<Task>
+}
+
+/** Get task response data. */
+export interface GetTaskResponse {
+  task: WithId<Task>
+}
+
+/** Update task request data. */
+export interface UpdateTaskRequest {
+  task: Partial<Task>
+}
+
+/** Update task response data. */
+export interface UpdateTaskResponse {
+  task: WithId<Task>
+}
+
+/** Create a task. */
+export async function createTask(opt: Options, data: CreateTaskRequest): Promise<CreateTaskResponse> {
+  return request(opt, 'POST', '/task', undefined, data)
+}
+
+/** Delete a task. */
+export async function deleteTask(opt: Options, id: string): Promise<DeleteTaskResponse> {
+  return request(opt, 'DELETE', `/task/${id}`)
+}
+
+/** Get a task. */
+export async function getTask(opt: Options, id: string): Promise<GetTaskResponse> {
+  return request(opt, 'GET', `/task/${id}`)
+}
+
+/** Search tasks. */
+export async function searchTasks(opt: Options, herd?: string, params?: SearchParams): Promise<SearchResponse<GetTaskResponse>> {
+  return request(opt, 'GET', herd ? `/herd/${herd}/tasks` : '/tasks', params && writeSearchParams(params))
+}
+
+/** Update a task. */
+export async function updateTask(opt: Options, id: string, data: UpdateTaskRequest): Promise<UpdateTaskResponse> {
+  return request(opt, 'PUT', `/task/${id}`, undefined, data)
+}

+ 14 - 0
web/src/build.ts

@@ -0,0 +1,14 @@
+const build = {
+  api: {
+    host: import.meta.env.VITE_API_HOST || 'http://localhost:5001/api',
+    timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '10000'),
+  },
+  document: {
+    titleSuffix: import.meta.env.VITE_DOCUMENT_TITLE_SUFFIX || 'Herda',
+  },
+  localStorage: {
+    prefix: import.meta.env.VITE_LOCAL_STORAGE_PREFIX || 'herda-',
+  },
+}
+
+export default build

+ 16 - 0
web/src/hooks/index.ts

@@ -0,0 +1,16 @@
+import { ConnectionContext } from '@/providers/connection'
+import { DocumentContext } from '@/providers/document'
+import { SessionContext } from '@/providers/session'
+import { useContext } from 'react'
+
+export function useConnection() {
+  return useContext(ConnectionContext)
+}
+
+export function useDocument() {
+  return useContext(DocumentContext)
+}
+
+export function useSession() {
+  return useContext(SessionContext)
+}

+ 30 - 0
web/src/lib/valueStorage.ts

@@ -0,0 +1,30 @@
+export interface ValueStorage<T> {
+  del(): void
+  exists(): boolean
+  get(): T | undefined
+  set(value: T): void
+}
+
+export function localValueStorage(key: string) {
+  function del() {
+    localStorage.removeItem(key)
+  }
+
+  function exists() {
+    return localStorage.getItem(key) !== null
+  }
+
+  function get() {
+    const data = localStorage.getItem(key)
+    if (data === null) return undefined
+    const value = JSON.parse(data)
+    return value
+  }
+
+  function set<T>(value: T) {
+    const data = JSON.stringify(value)
+    localStorage.setItem(key, data)
+  }
+
+  return { del, exists, get, set }
+}

+ 26 - 2
web/src/main.tsx

@@ -1,10 +1,34 @@
 import './index.css'
-import App from './App.tsx'
+import App from '@/App.tsx'
+import { ConnectionProvider } from './providers/connection'
+import { DocumentProvider } from './providers/document'
 import React from 'react'
 import ReactDOM from 'react-dom/client'
+import { SessionProvider } from './providers/session'
+import build from './build'
+import { localValueStorage } from './lib/valueStorage'
+
+const connectionProps = {
+  host: build.api.host,
+  timeout: build.api.timeout,
+}
+
+const documentProps = {
+  titleSuffix: build.document.titleSuffix,
+}
+
+const sessionProps = {
+  authStorage: localValueStorage(`${build.localStorage.prefix}-auth`),
+}
 
 ReactDOM.createRoot(document.getElementById('root')!).render(
   <React.StrictMode>
-    <App />
+    <DocumentProvider value={documentProps}>
+      <ConnectionProvider value={connectionProps}>
+        <SessionProvider value={sessionProps}>
+          <App />
+        </SessionProvider>
+      </ConnectionProvider>
+    </DocumentProvider>
   </React.StrictMode>,
 )

+ 76 - 0
web/src/providers/connection.ts

@@ -0,0 +1,76 @@
+import type { ProviderProps } from 'react'
+import api from '@/api'
+import { createContext, createElement, useEffect, useReducer, useState } from 'react'
+
+/**
+ * API connection state.
+ */
+export interface ConnectionState {
+  /** API server availability. */
+  available: boolean
+  /** API connection error. */
+  error: Error | undefined
+  /** API server information. */
+  info: api.ServerInfo | undefined
+  /** API client options. Should be used in all API requests. */
+  options: api.Options
+
+  /** Connect to the server and store product information. */
+  connect(): Promise<void>
+  /** Set the bearer token for use in API requests. Pass undefined to clear the bearer token. */
+  setToken(token: string | undefined): void
+}
+
+/**
+ * Action to mutate API client options.
+ */
+export type OptionsAction = ['setToken', string | undefined]
+
+/** API client options context. */
+export const ConnectionContext = createContext({} as ConnectionState)
+
+/**
+ * API connection provider.
+ * This should be one of the root components of the application.
+ */
+export function ConnectionProvider({ children, value: props }: ProviderProps<api.Options>) {
+  const [available, setAvailable] = useState(false)
+  const [error, setError] = useState<Error>()
+  const [info, setInfo] = useState<api.ServerInfo>()
+  const [options, dispatchOptions] = useReducer(OptionsReducer, props)
+
+  async function connect() {
+    try {
+      const res = await api.getServerInfo(options)
+      setInfo(res)
+      setAvailable(true)
+    } catch (err) {
+      setError(err as Error)
+      setAvailable(false)
+    }
+  }
+
+  function setToken(token: string | undefined) {
+    return dispatchOptions(['setToken', token])
+  }
+
+  useEffect(() => {
+    connect()
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const value = {
+    available, error, info, options,
+    connect, setToken,
+  }
+  return createElement(ConnectionContext.Provider, { value }, children)
+}
+
+/** API client options reducer. */
+function OptionsReducer(state: api.Options, [task, data]: OptionsAction) {
+  if (task === 'setToken') {
+    state.token = data
+    return state
+  }
+  throw new Error('Invalid OptionsAction')
+}

+ 33 - 0
web/src/providers/document.ts

@@ -0,0 +1,33 @@
+import type { ProviderProps} from 'react'
+import { createContext, createElement, useEffect, useState } from 'react'
+
+export interface DocumentProps {
+  titleSuffix: string
+}
+
+export interface DocumentState {
+  clearTitle(): void
+  setTitle(title: string): void
+}
+
+export const DocumentContext = createContext({} as DocumentState)
+
+export function DocumentProvider({ children, value: params }: ProviderProps<DocumentProps>) {
+  const [title, setTitle] = useState<string>()
+
+  function clearTitle() {
+    setTitle(undefined)
+  }
+
+  useEffect(() => {
+    if (title) {
+      document.title = `${title} - ${params.titleSuffix}`
+    } else {
+      document.title = params.titleSuffix
+    }
+  }, [params.titleSuffix, title])
+
+  const value = { clearTitle, setTitle }
+
+  return createElement(DocumentContext.Provider, { value }, children)
+}

+ 95 - 0
web/src/providers/session.ts

@@ -0,0 +1,95 @@
+import type { ProviderProps } from 'react'
+import type { ValueStorage } from '@/lib/valueStorage'
+import api from '@/api'
+import { useConnection } from '@/hooks'
+import { createContext, createElement, useLayoutEffect, useState } from 'react'
+
+export interface SessionData {
+  account?: api.Account
+  loggedIn?: boolean
+  ready?: boolean
+}
+
+export interface SessionProps {
+  authStorage: ValueStorage<api.LoginAccountResponse>
+}
+
+export interface SessionState extends SessionData {
+  heartbeat(): Promise<void>
+  login(email: string, password: string): Promise<void>
+  logout(): void
+}
+
+export function SessionProvider({ children, value: { authStorage } }: ProviderProps<SessionProps>) {
+  const { options, setToken } = useConnection()
+
+  const [account, setAccount] = useState<api.Account>()
+  const [loggedIn, setLoggedIn] = useState(false)
+  const [ready, setReady] = useState(false)
+
+  function reset() {
+    authStorage.del()
+    setAccount(undefined)
+    setToken(undefined)
+    setLoggedIn(false)
+  }
+
+  async function heartbeat(token?: string) {
+    try {
+      const res = await api.getAccount({ ...options, token })
+      setAccount(res.account)
+      setToken(token)
+      setLoggedIn(true)
+    } catch (err) {
+      const status = (err as api.RequestError).xhr?.status || 500
+      // If the response code indicates a client-side issue, reset session state
+      if (status >= 400 && status < 500) {
+        reset()
+      }
+      throw err
+    }
+  }
+
+  async function login(email: string, password: string) {
+    const res = await api.loginAccount(options, {
+      account: { email, password },
+    })
+
+    authStorage.set(res)
+
+    setAccount(res.account)
+    setToken(res.token)
+    setLoggedIn(true)
+  }
+
+  function logout() {
+    reset()
+  }
+
+  // Check whether a session exists in storage, and if so, send a heartbeat.
+  // This effect triggers once only when the component is mounted.
+  useLayoutEffect(() => {
+    if (!ready) {
+      const auth = authStorage.get()
+      if (auth?.token) {
+        heartbeat(auth.token)
+          .catch(err => {
+            console.log(err)
+          })
+          .finally(() => {
+            setReady(true)
+          })
+      } else setReady(true)
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const value = {
+    account, loggedIn, ready,
+    heartbeat, login, logout,
+  }
+
+  return createElement(SessionContext.Provider, { value }, children)
+}
+
+export const SessionContext = createContext({} as SessionState)