Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ga-npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

- name: 🟢 Update node
uses: actions/setup-node@v6
with: { node-version: 24, registry-url: "https://registry.npmjs.org" }
with: { node-version: 24, registry-url: 'https://registry.npmjs.org' }

- name: 🦖 Set up Deno
uses: denoland/setup-deno@v2
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ classes (`ResponseError`). Adds default JSON headers and bodyless handling
### [`api/env`](./api/env.ts)

`ENV()` getter with fallback and required enforcement; typed `APP_ENV` guard
(dev/test/prod) with validation; `CI_COMMIT_SHA`, `DEVTOOL_TOKEN`, `DEVTOOL_URL`
accessors.
(dev/test/prod) with validation; `CI_COMMIT_SHA`, `DEVTOOL_REPORT_TOKEN`, `DEVTOOL_ACCESS_TOKEN`,
`DEVTOOL_URL` accessors.

### [`api/validator`](./api/validator.ts)

Expand Down
60 changes: 60 additions & 0 deletions api/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { APP_ENV, DEVTOOL_ACCESS_TOKEN } from '@01edu/api/env'
import { respond } from '@01edu/api/response'
import type { RequestContext } from '@01edu/types/context'
import { route } from '@01edu/api/router'
import { ARR, OBJ, optional, STR } from '@01edu/api/validator'
import type { Sql } from '@01edu/db'

/**
* Authorizes access to developer routes.
* Checks for `DEVTOOL_ACCESS_TOKEN` in the Authorization header.
* In non-prod environments, access is allowed if no token is configured.
*
* @param ctx - The request context.
* @throws {respond.UnauthorizedError} If access is denied.
*/
export const authorizeDevAccess = ({ req }: RequestContext) => {
if (APP_ENV !== 'prod') return // always open for dev env
const auth = req.headers.get('Authorization') || ''
const bearer = auth.toLowerCase().startsWith('bearer ')
? auth.slice(7).trim()
: ''
if (bearer && bearer === DEVTOOL_ACCESS_TOKEN) return
throw new respond.UnauthorizedError({ message: 'Unauthorized access' })
}

/**
* Creates a route handler for executing arbitrary SQL queries.
* Useful for debugging and development tools.
*
* @param sql - The SQL tag function to use for execution.
* @returns A route handler configuration.
*/
export const createSqlDevRoute = (sql?: Sql) => {
return route({
authorize: authorizeDevAccess,
fn: (_, { query, params }) => {
try {
if (!sql) {
return respond.NotImplemented({
message: 'Database not configured',
})
}
return sql`${query}`.all(params)
} catch (error) {
throw new respond.BadRequestError({
message: error instanceof Error ? error.message : 'Unexpected Error',
})
}
},
input: OBJ({
query: STR('The SQL query to execute'),
params: optional(OBJ({}, 'The parameters to bind to the query')),
}),
output: ARR(
optional(OBJ({}, 'A single result row')),
'List of results',
),
description: 'Execute an SQL query',
})
}
140 changes: 140 additions & 0 deletions api/doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { Def, DefBase } from '@01edu/types/validator'
import type { GenericRoutes } from '@01edu/types/router'
import { route } from '@01edu/api/router'
import { ARR, BOOL, LIST, OBJ, optional, STR } from '@01edu/api/validator'

/**
* Recursive type representing the structure of input/output documentation.
* It mirrors the structure of the validator definitions but simplified for documentation purposes.
*/
export type Documentation =
& (
| { type: Exclude<DefBase['type'], 'object' | 'array' | 'list' | 'union'> }
| { type: 'object'; properties: Record<string, Documentation> }
| { type: 'array'; items: Documentation }
| { type: 'list'; options: (string | number)[] }
| { type: 'union'; options: Documentation[] }
)
& { description?: string; optional?: boolean }

/**
* Represents the documentation for a single API endpoint.
*/
export type EndpointDoc = {
method: string
path: string
requiresAuth: boolean
authFunction: string
description?: string
input?: Documentation
output?: Documentation
}

/**
* Extracts documentation from a validator definition.
* Recursively processes objects and arrays to build a `Documentation` structure.
*
* @param def - The validator definition to extract documentation from.
* @returns The extracted documentation or undefined if no definition is provided.
*/
function extractDocs(def?: Def): Documentation | undefined {
if (!def) return undefined
const base = {
type: def.type,
description: def.description,
optional: def.optional,
}

switch (def.type) {
case 'object': {
const properties: Record<string, Documentation> = {}
for (const [key, value] of Object.entries(def.properties)) {
const doc = extractDocs(value)
if (doc) {
properties[key] = doc
}
}
return { ...base, properties, type: 'object' }
}
case 'array': {
const items = extractDocs(def.of) as Documentation
return { ...base, items, type: 'array' }
}
case 'list':
return { ...base, options: def.of as (string | number)[], type: 'list' }
case 'union':
return {
...base,
options: def.of.map((d: Def) => extractDocs(d) as Documentation),
type: 'union',
}
case 'boolean':
return { ...base, type: 'boolean' }
case 'number':
return { ...base, type: 'number' }
case 'string':
return { ...base, type: 'string' }
}
}

/**
* Generates API documentation for a set of routes.
* Iterates through the route definitions and extracts metadata, input, and output documentation.
*
* @param defs - The route definitions to generate documentation for.
* @returns An array of `EndpointDoc` objects describing the API.
*/
export const generateApiDocs = (defs: GenericRoutes) => {
return Object.entries<typeof defs[keyof typeof defs]>(defs).map(
([key, handler]) => {
const slashIndex = key.indexOf('/')
const method = key.slice(0, slashIndex).toUpperCase()
const path = key.slice(slashIndex)
const requiresAuth = handler.authorize ? true : false

return {
method,
path,
requiresAuth,
authFunction: handler.authorize?.name || '',
description: 'description' in handler ? handler.description : undefined,
input: 'input' in handler ? extractDocs(handler.input) : undefined,
output: 'output' in handler ? extractDocs(handler.output) : undefined,
}
},
)
}

const encoder = new TextEncoder()
const apiDocOutputDef: Def = ARR(
OBJ({
method: LIST(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], 'HTTP method'),
path: STR('API endpoint path'),
requiresAuth: BOOL('whether authentication is required'),
authFunction: STR('name of the authorization function'),
description: STR('Endpoint description'),
input: optional(OBJ({}, 'Input documentation structure')),
output: optional(OBJ({}, 'Output documentation structure')),
}, 'API documentation object structure'),
'API documentation array',
)

/**
* Creates a route handler that serves the generated API documentation.
* The documentation is served as a JSON array of `EndpointDoc` objects.
*
* @param defs - The route definitions to generate documentation for.
* @returns A route handler that serves the API documentation.
*/
export const createDocRoute = (defs: GenericRoutes) => {
const docStr = JSON.stringify(generateApiDocs(defs))
const docBuffer = encoder.encode(docStr)
return route({
fn: () =>
new Response(docBuffer, {
headers: { 'content-type': 'application/json' },
}),
output: apiDocOutputDef,
description: 'Get the API documentation',
})
}
20 changes: 17 additions & 3 deletions api/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ export const CI_COMMIT_SHA: string = ENV('CI_COMMIT_SHA', '')
*
* @example
* ```ts
* import { DEVTOOL_TOKEN } from '@01edu/api/env';
* import { DEVTOOL_REPORT_TOKEN } from '@01edu/api/env';
*
* const headers = {
* 'Authorization': `Bearer ${DEVTOOL_TOKEN}`,
* 'Authorization': `Bearer ${DEVTOOL_REPORT_TOKEN}`,
* };
* ```
*/
export const DEVTOOL_TOKEN: string = ENV('DEVTOOL_TOKEN', '')
export const DEVTOOL_REPORT_TOKEN: string = ENV('DEVTOOL_REPORT_TOKEN', '')
/**
* The URL for a developer tool service.
*
Expand All @@ -86,6 +86,20 @@ export const DEVTOOL_TOKEN: string = ENV('DEVTOOL_TOKEN', '')
*/
export const DEVTOOL_URL: string = ENV('DEVTOOL_URL', '')

/**
* Internal token for dev access in production.
*
* @example
* ```ts
* import { DEVTOOL_ACCESS_TOKEN } from '@01edu/api/env';
*
* if (req.headers.get('Authorization') === `Bearer ${DEVTOOL_ACCESS_TOKEN}`) {
* // Allow access
* }
* ```
*/
export const DEVTOOL_ACCESS_TOKEN: string = ENV('DEVTOOL_ACCESS_TOKEN', '')

const forAppEnv =
(env: AppEnvironments) => (key: string, fallback?: string): string => {
const value = Deno.env.get(key)
Expand Down
40 changes: 24 additions & 16 deletions api/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,31 @@ import {
} from '@std/fmt/colors'
import { now, startTime } from '@01edu/time'
import { getContext } from './context.ts'
import { APP_ENV, CI_COMMIT_SHA, DEVTOOL_TOKEN, DEVTOOL_URL } from './env.ts'
import {
APP_ENV,
CI_COMMIT_SHA,
DEVTOOL_REPORT_TOKEN,
DEVTOOL_URL,
} from './env.ts'

// Types
type LogLevel = 'info' | 'error' | 'warn' | 'debug'
type LoggerOptions =
{
/** The URL of the devtool service to send logs to (prod only). Defaults to `DEVTOOL_URL` env var. */
logUrl?: string
/** The authentication token for the devtool service (prod only). Defaults to `DEVTOOL_REPORT_TOKEN` env var. */
logToken?: string
/** The version of the application, typically a git commit SHA. Defaults to `CI_COMMIT_SHA` env var or `git rev-parse HEAD`. */
version?: string
/** The interval in milliseconds to batch and send logs (prod only). */
batchInterval?: number
/** The maximum number of logs to batch before sending (prod only). */
maxBatchSize?: number
/** A set of event names to filter out and not log. */
filters?: Set<string>
}

type LogFunction = (
level: LogLevel,
event: string,
Expand Down Expand Up @@ -129,22 +150,9 @@ export const logger = async ({
batchInterval = 5000,
maxBatchSize = 50,
logUrl = DEVTOOL_URL,
logToken = DEVTOOL_TOKEN,
logToken = DEVTOOL_REPORT_TOKEN,
version = CI_COMMIT_SHA,
}: {
/** The URL of the devtool service to send logs to (prod only). Defaults to `DEVTOOL_URL` env var. */
logUrl?: string
/** The authentication token for the devtool service (prod only). Defaults to `DEVTOOL_TOKEN` env var. */
logToken?: string
/** The version of the application, typically a git commit SHA. Defaults to `CI_COMMIT_SHA` env var or `git rev-parse HEAD`. */
version?: string
/** The interval in milliseconds to batch and send logs (prod only). */
batchInterval?: number
/** The maximum number of logs to batch before sending (prod only). */
maxBatchSize?: number
/** A set of event names to filter out and not log. */
filters?: Set<string>
}): Promise<Log> => {
}: LoggerOptions): Promise<Log> => {
let logBatch: unknown[] = []
if (APP_ENV === 'prod' && (!logToken || !logUrl)) {
throw Error('DEVTOOLS configuration is required in production')
Expand Down
39 changes: 31 additions & 8 deletions api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ import type {
} from '@01edu/types/router'
import type { Log } from './log.ts'
import { respond, ResponseError } from './response.ts'
import type { Sql } from '@01edu/db'
import { createSqlDevRoute } from './dev.ts'
import { createDocRoute } from './doc.ts'

/**
* Options for configuring the router.
*/
export type RouterOptions = {
log: Log
sql?: Sql
sensitiveKeys?: string[]
}

/**
* A declaration function for creating a route handler.
Expand Down Expand Up @@ -96,21 +108,32 @@ const sensitiveData = (
* }),
* };
*
* const router = makeRouter(log, routes);
* const router = makeRouter(routes, { log });
* ```
*/
export const makeRouter = <T extends GenericRoutes>(
log: Log,
defs: T,
sensitiveKeys = [
'password',
'confPassword',
'currentPassword',
'newPassword',
],
{
log,
sql,
sensitiveKeys = [
'password',
'confPassword',
'currentPassword',
'newPassword',
],
}: RouterOptions,
): (ctx: RequestContext) => Awaitable<Response> => {
const routeMaps: Record<string, Route> = Object.create(null)

if (!defs['POST/api/execute-sql']) {
defs['POST/api/execute-sql'] = createSqlDevRoute(sql)
}

if (!defs['GET/api/doc']) {
defs['GET/api/doc'] = createDocRoute(defs)
}

for (const key in defs) {
const slashIndex = key.indexOf('/')
const method = key.slice(0, slashIndex) as HttpMethod
Expand Down
Loading