import { InferType, Parser, inferFromSampleValue, object } from 'cast.ts' import { NextFunction, Request, Response, Router } from 'express' import { readFileSync, writeFileSync } from 'fs' import debug from 'debug' import { join } from 'path' import { HttpError } from './error' import { proxy } from './proxy' import { JWTPayload, checkAdmin, getJWT } from './jwt' import { env } from './env' let clientDir = join('..', 'client', 'src', 'api') let configFile = join(clientDir, 'config.ts') let clientWsTypesFile = join(clientDir, 'ws-types.ts') let serverWsTypesFile = join('src', 'ws', 'types.ts') export type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' export function canMethodHasBody(method: Method): boolean { switch (method) { case 'GET': case 'DELETE': return false case 'POST': case 'PATCH': case 'PUT': return true } } const emptyInputParser = object({ headers: object({}), params: object({}), query: object({}), body: object({}), }) const emptyOutputParser = object({}) type Result = T | Promise export function defModule(options: { name: string; apiPrefix?: string }) { let moduleName = options.name let log = debug(moduleName) log.enabled = true let clientFile = join(clientDir, `${moduleName}.ts`) let router = Router() let apiPrefix = options.apiPrefix ?? '/api' let code = ` // This file is generated automatically // Don't edit this file directly import { call, toParams } from './utils' export let api_origin = '${apiPrefix}' ` function defAPI< Path extends string, Input extends { headers?: object params?: object query?: object body?: object }, Output, >( method: Method, url: Path, api?: { name?: string sampleInput?: Input sampleOutput?: Output inputParser?: Parser> outputParser?: Parser> transformInputForLog?: (input: Partial>) => unknown transformOutputForLog?: (output: Partial>) => unknown } & ( | { jwt: true role?: 'admin' fn?: ( input: InferType, jwt: JWTPayload, ) => Result>> } | { jwt?: false fn?: ( input: InferType, jwt: JWTPayload | null, ) => Result>> } ), ) { let name = api?.name ? api.name : method.toLowerCase() + url .split('/') .filter(s => s && s[0] != ':') .map(s => s[0].toUpperCase() + s.substring(1)) .join('') let Name = name[0].toUpperCase() + name.substring(1) const inputParser = (api?.inputParser || (api?.sampleInput ? inferFromSampleValue(api.sampleInput) : emptyInputParser)) as Parser> const outputParser = (api?.outputParser || (api?.sampleOutput ? inferFromSampleValue(api.sampleOutput) : emptyOutputParser)) as Parser> const InputType = inputParser.type const OutputType = outputParser.type let href: string = url let params = url .split('/') .filter(s => s[0] == ':') .map(s => s.substring(1)) let bodyCode = `` if (params.length > 0) { bodyCode += ` let { params } = input` for (let param of params) { href = href.replace(':' + param, '${params.' + param + '}') } } let hasQuery = InputType.includes('\n query: {\n') if (hasQuery) { href = '`' + href + '?` + toParams(input.query)' } else { href = '`' + href + '`' } let hasBody = InputType.includes('body:') if (hasBody && !canMethodHasBody(method)) { console.warn( `Warning: HTTP method ${method} cannot have body but used in module: ${moduleName}, api: ${name}`, ) } if (hasBody) { bodyCode += ` return call('${method}', api_origin + ${href}, input.body)` } else { bodyCode += ` return call('${method}', api_origin + ${href})` } code += ` // ${method} ${apiPrefix}${url} export function ${name}(input: ${Name}Input): Promise<${Name}Output & { error?: string }> { ${bodyCode.trim()} } export type ${Name}Input = ${InputType} export type ${Name}Output = ${OutputType} ` function getSampleInput() { return ( api?.sampleInput ?? api?.inputParser?.sampleValue ?? api?.inputParser?.randomSample() ) } function getSampleOutput() { return ( api?.sampleOutput ?? api?.outputParser?.sampleValue ?? api?.outputParser?.randomSample() ) } let requestHandler = async ( req: Request, res: Response, next: NextFunction, ) => { let startTime = Date.now() let input: InferType | undefined let output: InferType | { error: string } let jwt: JWTPayload | null = null try { input = inputParser.parse(req) log( name, api?.transformInputForLog ? api.transformInputForLog(input) : input, ) if (!api?.fn) { res.status(501) res.json(getSampleOutput()) return } if (api.jwt) { jwt = getJWT(req) if (api.role == 'admin') checkAdmin(jwt) output = await api.fn(input, jwt) } else { try { jwt = getJWT(req) } catch (error) { // api from guest } output = await api.fn(input, jwt) } output = outputParser.parse(output) } catch (e: any) { let err = e as HttpError if (!err.statusCode) log(err) res.status(err.statusCode || 500) let error = HttpError.toString(err) output = { error } } let endTime = Date.now() res.json(output) if (!input) { input = { headers: req.headers, params: req.params, query: req.query, body: req.body, } as InferType } proxy.log.push({ method, url, input: JSON.stringify( api?.transformInputForLog ? api.transformInputForLog(input) : input, ), output: JSON.stringify( api?.transformOutputForLog && output && typeof output == 'object' && !('error' in output) ? api.transformOutputForLog(output) : output, ), time_used: endTime - startTime, user_id: jwt ? jwt.id : null, user_agent: req.headers['user-agent'] || null, }) } router[method.toLowerCase() as 'post'](url, requestHandler) return { method, url, ...api, name, requestHandler, inputParser, outputParser, getSampleInput, getSampleOutput, } } function saveClient() { if (env.NODE_ENV != 'development') return saveConfig({ file: configFile, }) saveFile({ file: clientFile, code, }) } return { defAPI, saveClient, apiPrefix, router, } } function saveConfig(options: { file: string }) { let code = ` // This file is generated automatically // Don't edit this file directly export let server_origin = '${env.ORIGIN}' export function setServerOrigin(origin: string) { server_origin = origin } ` saveFile({ file: options.file, code }) } function saveFile(options: { file: string; code: string }) { let { file, code } = options code = code.trim() + '\n' try { let content = readFileSync(file).toString() if (content == code) return } catch (error) { // e.g. file not exist } writeFileSync(file, code) console.log('saved to', file) } export function saveWsTypes(args: { wsClientMessageParser: Parser wsServerMessageParser: Parser }) { let code = ` // This file is generated automatically // Don't edit this file directly export const Ping = '1' export const Pong = '2' export const Send = '3' export type WSClientMessage = ${args.wsClientMessageParser.type} export type WSServerMessage = ${args.wsServerMessageParser.type} ` saveFile({ file: clientWsTypesFile, code }) saveFile({ file: serverWsTypesFile, code }) }