From d388765b778ba9b77ac0a0897e75780a9c3c9388 Mon Sep 17 00:00:00 2001 From: Kitson Kelly Date: Wed, 17 Jul 2024 22:49:54 +1000 Subject: [PATCH] feat: improve context API --- _examples/server.ts | 135 +++++++++++++++++++++++++-------- constants.ts | 1 + context.ts | 179 +++++++++++++++++++++++++++++++++++++++++++- deno.json | 8 +- logger.ts | 23 +++++- mod.ts | 91 ++++++++++++++++++++++ route.ts | 53 +++++++------ router.ts | 165 ++++++++++++++++++++++++++++++---------- schema.ts | 21 ++++-- types.ts | 5 +- 10 files changed, 570 insertions(+), 111 deletions(-) diff --git a/_examples/server.ts b/_examples/server.ts index d3103ee..c779696 100644 --- a/_examples/server.ts +++ b/_examples/server.ts @@ -1,46 +1,119 @@ // Copyright 2018-2024 the oak authors. All rights reserved. -import * as colors from "jsr:@std/fmt@0.225/colors"; -import { KeyStack } from "jsr:@std/crypto@0.224/unstable-keystack"; -import * as v from "@valibot/valibot"; +import { Router, Status, v } from "../mod.ts"; +import { assert } from "@oak/commons/assert"; -import { Router } from "../router.ts"; -import { Status, STATUS_TEXT } from "jsr:@oak/commons@^0.12/status"; +const router = new Router({ logger: { console: true } }); -const keys = new KeyStack(["super secret"]); +const db = await Deno.openKv(); -const router = new Router({ - keys, - logger: { console: { level: "DEBUG" } }, +const book = v.object({ + author: v.string(), + title: v.string(), +}); + +const bookList = v.array(book); + +type Book = v.InferOutput; + +const bookPatch = v.object({ + author: v.optional(v.string()), + title: v.optional(v.string()), }); router.get("/", async (ctx) => { - let count = parseInt((await ctx.cookies.get("count")) ?? "0"); - await ctx.cookies.set("count", String(++count)); + const count = parseInt(await ctx.cookies.get("count") ?? "0", 10) + 1; + await ctx.cookies.set("count", String(count)); return { hello: "world", count }; -}, { - schema: { - querystring: v.object({ - a: v.optional(v.string()), - b: v.optional(v.string()), - }), - response: v.object({ hello: v.number(), count: v.number() }), - }, -}); -router.get("/error", () => { - throw new Error("test"); }); -router.on(Status.NotFound, () => { - return Response.json({ message: "Not Found" }, { - status: Status.NotFound, - statusText: STATUS_TEXT[Status.NotFound], +router.get("/redirect", (ctx) => { + return ctx.redirect("/book/:id", { + params: { id: "1" }, + status: Status.TemporaryRedirect, }); }); -router.listen({ - port: 3000, - onListen({ hostname, port }) { - console.log(`Listening on ${colors.yellow(`http://${hostname}:${port}/`)}`); - }, +router.get("/book", async () => { + const books: Book[] = []; + const bookEntries = db.list({ prefix: ["books"] }); + for await (const { key, value } of bookEntries) { + if (key[1] === "id") { + continue; + } + console.log(key, value); + books.push(value); + } + return books; +}, { schema: { response: bookList } }); + +router.get("/book/:id", async (ctx) => { + const id = parseInt(ctx.params.id, 10); + const maybeBook = await db + .get(["books", id]); + if (!maybeBook.value) { + ctx.throw(Status.NotFound, "Book not found"); + } + return maybeBook.value; +}, { schema: { response: book } }); + +router.post("/book", async (ctx) => { + const body = await ctx.body(); + assert(body, "Body required."); + const idEntry = await db.get(["books", "id"]); + const id = (idEntry.value ?? 0) + 1; + const result = await db.atomic() + .check({ key: ["books", "id"], versionstamp: idEntry.versionstamp }) + .set(["books", "id"], id) + .set(["books", id], body) + .commit(); + if (!result.ok) { + ctx.throw(Status.InternalServerError, "Conflict updating the book id"); + } + return ctx.created(body, { + location: `/book/:id`, + params: { id: String(id) }, + }); +}, { schema: { body: book, response: book } }); + +router.put("/book/:id", async (ctx) => { + const body = await ctx.body(); + const id = parseInt(ctx.params.id, 10); + const bookEntry = await db.get(["books", id]); + if (!bookEntry.value) { + ctx.throw(Status.NotFound, "Book not found"); + } + const result = await db.atomic() + .check({ key: ["books", id], versionstamp: bookEntry.versionstamp }) + .set(["books", id], body) + .commit(); + if (!result.ok) { + ctx.throw(Status.InternalServerError, "Conflict updating the book"); + } + return book; +}, { schema: { body: book, response: book } }); + +router.patch("/book/:id", async (ctx) => { + const body = await ctx.body(); + const id = parseInt(ctx.params.id, 10); + const bookEntry = await db.get(["books", id]); + if (!bookEntry.value) { + ctx.throw(Status.NotFound, "Book not found"); + } + const book = { ...bookEntry.value, ...body }; + const result = await db.atomic() + .check({ key: ["books", id], versionstamp: bookEntry.versionstamp }) + .set(["books", id], book) + .commit(); + if (!result.ok) { + ctx.throw(Status.InternalServerError, "Conflict updating the book"); + } + return book; +}, { schema: { body: bookPatch, response: book } }); + +router.delete("/book/:id", async (ctx) => { + const id = parseInt(ctx.params.id, 10); + await db.delete(["books", id]); }); + +router.listen({ port: 3000 }); diff --git a/constants.ts b/constants.ts index 112d054..7a26e9c 100644 --- a/constants.ts +++ b/constants.ts @@ -6,3 +6,4 @@ export const BODYLESS_METHODS = ["GET", "HEAD"]; export const CONTENT_TYPE_HTML = contentType("html")!; export const CONTENT_TYPE_JSON = contentType("json")!; export const CONTENT_TYPE_TEXT = contentType("text/plain")!; +export const NOT_ALLOWED = Symbol.for("acorn.NotAllowed"); diff --git a/context.ts b/context.ts index f0ea64f..0ffb3ef 100644 --- a/context.ts +++ b/context.ts @@ -11,13 +11,21 @@ */ import { type KeyRing, SecureCookieMap } from "@oak/commons/cookie_map"; -import { createHttpError } from "@oak/commons/http_errors"; +import { + createHttpError, + type HttpErrorOptions, +} from "@oak/commons/http_errors"; import { ServerSentEventStreamTarget, type ServerSentEventTarget, type ServerSentEventTargetOptions, } from "@oak/commons/server_sent_event"; -import { Status } from "@oak/commons/status"; +import { + type ErrorStatus, + type RedirectStatus, + Status, + STATUS_TEXT, +} from "@oak/commons/status"; import { UserAgent } from "@std/http/user-agent"; import type { InferOutput } from "@valibot/valibot"; @@ -27,9 +35,46 @@ import type { Addr, ParamsDictionary, RequestEvent, + RouteParameters, UpgradeWebSocketOptions, } from "./types.ts"; import { appendHeaders } from "./utils.ts"; +import { compile } from "path-to-regexp"; + +export interface RedirectInit { + /** + * The parameters to interpolate into the `location` path. + */ + params?: LocationParams; + /** + * The status to use for the redirect. Defaults to `302 Found`. + */ + status?: RedirectStatus; +} + +/** + * Initiation options when responding to a request with a `201 Created` status. + */ +export interface RespondInit< + Location extends string, + LocationParams extends ParamsDictionary, +> { + /** + * Additional headers to include in the response. + */ + headers?: HeadersInit; + /** + * The location to include in the `Location` header of the response. + * + * If the path includes parameters, the `params` should be provided to + * interpolate the values into the path. + */ + location?: Location; + /** + * The parameters to interpolate into the `location` path. + */ + params?: LocationParams; +} /** * Provides an API for understanding information about the request being @@ -59,6 +104,7 @@ export class Context< #body?: RequestBody; #bodySet = false; #cookies: SecureCookieMap; + #expose: boolean; #logger: Logger; #params: Params; #queryParams?: QueryParams; @@ -103,6 +149,9 @@ export class Context< * A globally unique identifier for the request event. * * This can be used for logging and debugging purposes. + * + * For automatically generated error responses, this identifier will be added + * to the response as the `X-Request-ID` header. */ get id(): string { return this.#requestEvent.id; @@ -146,6 +195,14 @@ export class Context< return this.#requestEvent.request; } + /** + * The {@linkcode Headers} object which will be used to set headers on the + * response. + */ + get responseHeaders(): Headers { + return this.#responseHeaders; + } + /** * The parsed form of the {@linkcode Request}'s URL. */ @@ -175,6 +232,7 @@ export class Context< params: Params, schema: Schema, keys: KeyRing | undefined, + expose: boolean, ) { this.#requestEvent = requestEvent; this.#responseHeaders = responseHeaders; @@ -187,6 +245,7 @@ export class Context< secure, }); this.#logger = getLogger("acorn.context"); + this.#expose = expose; } /** @@ -217,6 +276,75 @@ export class Context< return this.#body; } + /** + * Will throw an HTTP error with the status of `409 Conflict` and the message + * provided. If a `cause` is provided, it will be included in the error as the + * `cause` property. + * + * This is an appropriate response when a `PUT` request is made to a resource + * that cannot be updated because it is in a state that conflicts with the + * request. + */ + conflict(message = "Resource conflict", cause?: unknown): never { + throw createHttpError(Status.Conflict, message, { + cause, + expose: this.#expose, + }); + } + + /** + * Returns a {@linkcode Response} with the status of `201 Created` and the + * body provided. If a `location` is provided in the respond init, the + * response will include a `Location` header with the value of the `location`. + * + * If `locationParams` is provided, the `location` will be compiled with the + * `params` and the resulting value will be used as the value of the + * `Location` header. For example, if the `location` is `/book/:id` and the + * `params` is `{ id: "123" }`, the `Location` header will be set to + * `/book/123`. + * + * This is an appropriate response when a `POST` request is made to create a + * new resource. + */ + created< + Location extends string, + LocationParams extends ParamsDictionary = RouteParameters, + >( + body: InferOutput, + init: RespondInit = {}, + ): Response { + const { headers, location, params } = init; + const response = Response.json(body, { + status: Status.Created, + statusText: STATUS_TEXT[Status.Created], + headers, + }); + if (location) { + if (params) { + const toPath = compile(location); + response.headers.set("location", toPath(params)); + } else { + response.headers.set("location", location); + } + } + return response; + } + + /** + * Will throw an HTTP error with the status of `404 Not Found` and the message + * provided. If a `cause` is provided, it will be included in the error as the + * `cause` property. + * + * This is an appropriate response when a resource is requested that does not + * exist. + */ + notFound(message = "Resource not found", cause?: unknown): never { + throw createHttpError(Status.NotFound, message, { + cause, + expose: this.#expose, + }); + } + /** * In addition to the value of `.url.searchParams`, acorn can parse and * validate the search part of the requesting URL with the @@ -239,6 +367,35 @@ export class Context< return this.#queryParams; } + /** + * Redirect the client to a new location. The `location` can be a relative + * path or an absolute URL. If the `location` string includes parameters, the + * `params` should be provided in the init to interpolate the values into the + * path. + * + * For example if the `location` is `/book/:id` and the `params` is `{ id: + * "123" }`, the resulting URL will be `/book/123`. + * + * The status defaults to `302 Found`, but can be set to any of the redirect + * statuses via passing it in the `init`. + */ + redirect< + Location extends string, + LocationParams extends ParamsDictionary = RouteParameters, + >( + location: Location, + init: RedirectInit = {}, + // status: RedirectStatus = Status.Found, + // params?: LocationParams, + ): Response { + const { status, params } = init; + if (params) { + const toPath = compile(location); + location = toPath(params) as Location; + } + return Response.redirect(location, status); + } + /** * Initiate server sent events, returning a {@linkcode ServerSentEventTarget} * which can be used to dispatch events to the client. @@ -261,6 +418,19 @@ export class Context< return sse; } + /** + * Throw an HTTP error with the specified status and message, along with any + * options. If the status is not provided, it will default to `500 Internal + * Server Error`. + */ + throw( + status?: ErrorStatus, + message?: string, + options?: HttpErrorOptions, + ): never { + throw createHttpError(status, message, options); + } + /** * Upgrade the current connection to a web socket and return the * {@linkcode WebSocket} object to be able to communicate with the remote @@ -276,6 +446,7 @@ export class Context< throw createHttpError( Status.ServiceUnavailable, "Web sockets not currently supported.", + { expose: this.#expose }, ); } if (this.#requestEvent.responded) { @@ -285,6 +456,7 @@ export class Context< return this.#requestEvent.upgrade(options); } + /** Custom inspect method under Deno. */ [Symbol.for("Deno.customInspect")]( inspect: (value: unknown) => string, ): string { @@ -296,12 +468,14 @@ export class Context< params: this.#params, cookies: this.#cookies, request: this.#requestEvent.request, + responseHeaders: this.#responseHeaders, userAgent: this.userAgent, url: this.#url, }) }`; } + /** Custom inspect method under Node.js. */ [Symbol.for("nodejs.util.inspect.custom")]( depth: number, // deno-lint-ignore no-explicit-any @@ -324,6 +498,7 @@ export class Context< params: this.#params, cookies: this.#cookies, request: this.#requestEvent.request, + responseHeaders: this.#responseHeaders, userAgent: this.userAgent, url: this.#url, }, newOptions) diff --git a/deno.json b/deno.json index 239fb38..4fde084 100644 --- a/deno.json +++ b/deno.json @@ -19,12 +19,12 @@ "test": "deno test --allow-net --allow-env --allow-hrtime" }, "imports": { - "@oak/commons": "jsr:@oak/commons@^0.12", - "@std/assert": "jsr:@std/assert@^0.226", + "@oak/commons": "jsr:@oak/commons@^0.13", + "@std/assert": "jsr:@std/assert@^1.0", "@std/http": "jsr:@std/http@^0.224", - "@std/log": "jsr:@std/log@^0.224.3", + "@std/log": "jsr:@std/log@^0.224", "@std/media-types": "jsr:@std/media-types@^1.0", - "@valibot/valibot": "jsr:@valibot/valibot@^0.35", + "@valibot/valibot": "jsr:@valibot/valibot@^0.36", "hyperid": "npm:hyperid@^3.2", "path-to-regexp": "npm:path-to-regexp@^7.0", "qs": "npm:qs@^6.12" diff --git a/logger.ts b/logger.ts index 01c46a5..d9f895f 100644 --- a/logger.ts +++ b/logger.ts @@ -11,6 +11,7 @@ import { RotatingFileHandler, setup, } from "@std/log"; +import { isBun, isNode } from "./utils.ts"; export { type Logger } from "@std/log"; @@ -61,9 +62,25 @@ export interface LoggerOptions { stream?: { level?: LevelName; stream: WritableStream }; } -export const formatter: FormatterFunction = ( - { datetime, levelName, loggerName, msg }, -) => `${datetime.toISOString()} [${levelName}] ${loggerName}: ${msg}`; +let inspect: (value: unknown) => string; + +if (typeof globalThis?.Deno?.inspect === "function") { + inspect = Deno.inspect; +} else { + inspect = (value) => JSON.stringify(value); + if (isNode() || isBun()) { + import("node:util").then(({ inspect: nodeInspect }) => { + inspect = nodeInspect; + }); + } +} + +const formatter: FormatterFunction = ( + { datetime, levelName, loggerName, msg, args }, +) => + `${datetime.toISOString()} [${levelName}] ${loggerName}: ${msg} ${ + args.map((arg) => inspect(arg)).join(" ") + }`; const mods = [ "acorn.context", diff --git a/mod.ts b/mod.ts index c7e2deb..a56efde 100644 --- a/mod.ts +++ b/mod.ts @@ -161,6 +161,34 @@ * the request. If a query parameter schema is provided to the route, the query * parameters will be validated against that schema before being returned. * + * ### `throw()` + * + * A method which can be used to throw an HTTP error which will be caught by the + * router and handled appropriately. The method takes a status code and an + * optional message which will be sent to the client. + * + * ### `created()` + * + * A method which returns a {@linkcode Response} with a `201 Created` status + * code. The method takes the body of the response and an optional object with + * options for the response. + * + * This is an appropriate response when a `POST` request is made to a resource + * collection and the resource is created successfully. The options should be + * included with a `location` property set to the URL of the created resource. + * The `params` property can be used to provide parameters to the URL. + * For example if `location` is `/books/:id` and `params` is `{ id: 1 }` + * the URL will be `/books/1`. + * + * ### `conflict()` + * + * A method which throws a `409 Conflict` error and takes an optional message + * and optional cause. + * + * This is an appropriate response when a `PUT` request is made to a resource + * that cannot be updated because it is in a state that conflicts with the + * request. + * * ### `sendEvents()` * * A method which starts sending server-sent events to the client. This method @@ -351,6 +379,60 @@ * called when the schema validation fails. This allows you to provide a custom * response to the client when the request does not match the schema. * + * ## RESTful JSON Services + * + * acorn is designed to make it easy to create RESTful JSON services. The router + * provides a simple and expressive way to define routes and has several + * features which make it easy to create production ready services. + * + * ### HTTP Errors + * + * acorn provides a mechanism for throwing HTTP errors from route handlers. The + * `throw()` method on the context object can be used to throw an HTTP error. + * HTTP errors are caught by the router and handled appropriately. The router + * will send a response to the client with the status code and message provided + * to the `throw()` method with the body of the response respecting the content + * negotiation headers provided by the client. + * + * ### No Content Responses + * + * If a handler returns `undefined`, the router will send a `204 No Content` + * response to the client. This is useful when a request is successful but there + * is no content to return to the client. + * + * No content responses are appropriate for `PUT` or `PATCH` requests that are + * successful but you do not want to return the updated resource to the client. + * + * ### Created Responses + * + * The `created()` method on the context object can be used to send a `201 + * Created` response to the client. This is appropriate when a `POST` request is + * made to a resource collection and the resource is created successfully. The + * method takes the body of the response and an optional object with options for + * the response. + * + * The options should be included with a `location` property set to the URL of + * the created resource. The `params` property can be used to provide parameters + * to the URL. For example if `location` is `/books/:id` and `params` is + * `{ id: 1 }` the URL will be `/books/1`. + * + * ### Conflict Responses + * + * The `conflict()` method on the context object can be used to throw a `409 + * Conflict` error. This is appropriate when a `PUT` request is made to a + * resource that cannot be updated because it is in a state that conflicts with + * the request. + * + * ### Redirect Responses + * + * If you need to redirect the client to a different URL, you can use the + * `redirect()` method on the context object. This method takes a URL and an + * optional status code and will send a redirect response to the client. + * + * In addition, if the `location` is a path with parameters, you can provide the + * `params` object to the `redirect()` method which will be used to populate the + * parameters in the URL. + * * ## Logging * * acorn integrates the [LogTape](https://jsr.io/@logtape/logtape) library to @@ -385,6 +467,15 @@ */ export * as v from "@valibot/valibot"; export type { ServerSentEventTarget } from "@oak/commons/server_sent_event"; +export { + type ClientErrorStatus, + type ErrorStatus, + type InformationalStatus, + type RedirectStatus, + type ServerErrorStatus, + Status, + type SuccessfulStatus, +} from "@oak/commons/status"; export type { Context } from "./context.ts"; export type { LoggerOptions } from "./logger.ts"; diff --git a/route.ts b/route.ts index 44a6bfe..4627fdd 100644 --- a/route.ts +++ b/route.ts @@ -7,21 +7,23 @@ import { Status, STATUS_TEXT } from "@oak/commons/status"; import { type Key, pathToRegexp } from "path-to-regexp"; import type { InferOutput } from "@valibot/valibot"; +import { NOT_ALLOWED } from "./constants.ts"; import { Context } from "./context.ts"; import { getLogger, type Logger } from "./logger.ts"; +import { + type BodySchema, + type QueryStringSchema, + Schema, + type SchemaDescriptor, +} from "./schema.ts"; import type { + NotAllowed, ParamsDictionary, RequestEvent, Route, RouteParameters, } from "./types.ts"; import { appendHeaders, decodeComponent } from "./utils.ts"; -import { - type BodySchema, - type QueryStringSchema, - Schema, - type SchemaDescriptor, -} from "./schema.ts"; /** * A function that handles a route. The handler is provided a @@ -98,6 +100,7 @@ export class PathRoute< ResSchema extends BodySchema = BodySchema, ResponseBody extends InferOutput = InferOutput, > implements Route { + #expose: boolean; #handler: RouteHandler< Env, Params, @@ -171,13 +174,15 @@ export class PathRoute< ResponseBody >, keys: KeyRing | undefined, + expose: boolean, options?: RouteOptions, ) { this.#path = path; this.#methods = methods; - this.#schema = new Schema(schemaDescriptor); + this.#schema = new Schema(schemaDescriptor, expose); this.#handler = handler; this.#keys = keys; + this.#expose = expose; this.#regex = pathToRegexp(path, options); this.#paramKeys = this.#regex.keys; this.#logger = getLogger("acorn.route"); @@ -216,6 +221,7 @@ export class PathRoute< this.#params, this.#schema, this.#keys, + this.#expose, ); this.#logger.debug(`[${this.#path}] ${requestEvent.id} calling handler`); const result = await this.#handler(context); @@ -255,24 +261,25 @@ export class PathRoute< /** * Determines if the request should be handled by the route. */ - matches(method: HttpMethod, pathname: string): boolean { - if (this.#methods.includes(method)) { - const match = pathname.match(this.#regex); - if (match) { - this.#logger - .debug(`[${this.#path}] route matched: ${method} ${pathname}`); - const params = {} as Params; - const captures = match.slice(1); - for (let i = 0; i < captures.length; i++) { - if (this.#paramKeys[i]) { - const capture = captures[i]; - (params as Record)[this.#paramKeys[i].name] = - decodeComponent(capture); - } + matches(method: HttpMethod, pathname: string): boolean | NotAllowed { + const match = pathname.match(this.#regex); + if (match) { + if (!this.#methods.includes(method)) { + return NOT_ALLOWED; + } + this.#logger + .debug(`[${this.#path}] route matched: ${method} ${pathname}`); + const params = {} as Params; + const captures = match.slice(1); + for (let i = 0; i < captures.length; i++) { + if (this.#paramKeys[i]) { + const capture = captures[i]; + (params as Record)[this.#paramKeys[i].name] = + decodeComponent(capture); } - this.#params = params; - return true; } + this.#params = params; + return true; } return false; } diff --git a/router.ts b/router.ts index 0feedc6..50f1def 100644 --- a/router.ts +++ b/router.ts @@ -10,7 +10,7 @@ import type { KeyRing } from "@oak/commons/cookie_map"; import { createHttpError, isHttpError } from "@oak/commons/http_errors"; import type { HttpMethod } from "@oak/commons/method"; -import { Status } from "@oak/commons/status"; +import { isClientErrorStatus, Status, STATUS_TEXT } from "@oak/commons/status"; import { assert } from "@std/assert/assert"; import type { InferOutput } from "@valibot/valibot"; @@ -48,6 +48,7 @@ import type { QueryStringSchema, SchemaDescriptor, } from "./schema.ts"; +import { NOT_ALLOWED } from "./constants.ts"; export interface RouteDescriptor< Path extends string, @@ -286,6 +287,14 @@ export interface NotFoundDetails< export interface RouterOptions< Env extends Record = Record, > extends RouteOptions { + /** + * Determines if when returning a router generated client error, like a `400 + * Bad Request` or `404 Not Found`, the error stack should be exposed in the + * response. + * + * @default true + */ + expose?: boolean; /** * An optional key ring which is used for signing and verifying cookies, which * helps ensure that the cookie values are resistent to client side tampering. @@ -435,6 +444,7 @@ let CFWRequestEventCtor: typeof CloudflareWorkerRequestEvent | undefined; export class Router< Env extends Record = Record, > { + #expose: boolean; #handling = new Set>(); #logger: Logger; #keys?: KeyRing; @@ -549,6 +559,7 @@ export class Router< schemaDescriptor, handler, this.#keys, + this.#expose, this.#routeOptions, ); this.#logger.debug(`adding route for ${methods.join(", ")} ${path}`); @@ -563,14 +574,19 @@ export class Router< } async #error( + id: string, message: string, cause: unknown, + start: number, requestEvent?: RequestEvent, route?: Route, - ) { - this.#logger.error( - `${requestEvent?.id} error handling request: ${message}`, - ); + ): Promise { + if (!(isHttpError(cause) && isClientErrorStatus(cause.status))) { + this.#logger.error( + `${requestEvent?.id} error handling request: ${message}`, + ); + } + const duration = performance.now() - start; if (this.#onError) { this.#logger.debug(`${requestEvent?.id} calling onError for request`); const maybeResponse = await this.#onError({ @@ -585,6 +601,13 @@ export class Router< .debug( `${requestEvent?.id} responding with onError response for request`, ); + this.#logResponse( + id, + requestEvent.request.method, + route?.path ?? requestEvent.url.pathname, + maybeResponse.status, + duration, + ); return requestEvent.respond(maybeResponse); } } @@ -594,6 +617,13 @@ export class Router< `${requestEvent?.id} responding with default response for request`, ); if (isHttpError(cause)) { + this.#logResponse( + id, + requestEvent.request.method, + route?.path ?? requestEvent.url.pathname, + cause.status, + duration, + ); return requestEvent.respond( cause.asResponse({ request: requestEvent.request, @@ -604,6 +634,13 @@ export class Router< }), ); } else { + this.#logResponse( + id, + requestEvent.request.method, + route?.path ?? requestEvent.url.pathname, + Status.InternalServerError, + duration, + ); return requestEvent.respond( createHttpError( Status.InternalServerError, @@ -624,7 +661,9 @@ export class Router< secure: boolean, ): Promise { const id = requestEvent.id; - this.#logger.info(`${id} handling request: ${requestEvent.url.toString()}`); + this.#logger.debug( + `${id} handling request: ${requestEvent.url.toString()}`, + ); const start = performance.now(); let response: Response | undefined | void; let route: Route | undefined; @@ -633,19 +672,25 @@ export class Router< this.#handling.delete(requestEvent.response) ).catch((cause) => { this.#logger.error(`${id} error deleting handling handle for request`); - this.#error("Error deleting handling handle.", cause, requestEvent); + this.#error( + id, + "Error deleting handling handle.", + cause, + start, + requestEvent, + ); }); this.#onRequest?.(requestEvent); const responseHeaders = new Headers(); + const allowed: HttpMethod[] = []; if (!requestEvent.responded) { for (route of this.#routes) { - if ( - route.matches( - requestEvent.request.method as HttpMethod, - requestEvent.url.pathname, - ) - ) { - this.#logger.info(`${id} request matched`); + const matches = route.matches( + requestEvent.request.method as HttpMethod, + requestEvent.url.pathname, + ); + if (matches === true) { + this.#logger.debug(`${id} request matched`); try { response = await route.handle( requestEvent, @@ -668,10 +713,11 @@ export class Router< break; } } catch (cause) { - this.#logger.error(`${id} error during handling request`); await this.#error( + id, "Error during handling.", cause, + start, requestEvent, route, ); @@ -680,29 +726,53 @@ export class Router< } } } else { + if (matches === NOT_ALLOWED) { + allowed.push(...route.methods); + } route = undefined; } } } if (!requestEvent.responded) { - this.#logger.debug(`${id} not found`); - response = response ?? - createHttpError(Status.NotFound, "Not Found").asResponse({ - prefer: this.#preferJson ? "json" : "html", - headers: { "x-request-id": id }, - }), - response = await this.#handleStatus( - requestEvent, - responseHeaders, - response, - secure, - ); + if (allowed.length) { + this.#logger.debug(`${id} method not allowed`); + response = createHttpError( + Status.MethodNotAllowed, + "Method Not Allowed", + { expose: this.#expose }, + ) + .asResponse({ + prefer: this.#preferJson ? "json" : "html", + headers: { "x-request-id": id, "allowed": allowed.join(", ") }, + }); + } else { + this.#logger.debug(`${id} not found`); + response = response ?? + createHttpError( + Status.NotFound, + "Not Found", + { expose: this.#expose }, + ).asResponse({ + prefer: this.#preferJson ? "json" : "html", + headers: { "x-request-id": id }, + }); + } + response = await this.#handleStatus( + requestEvent, + responseHeaders, + response, + secure, + ); requestEvent.respond(response); } const duration = performance.now() - start; if (response) { - this.#logger.info( - `${id} handled in ${parseFloat(duration.toFixed(2))}ms`, + this.#logResponse( + id, + requestEvent.request.method, + route?.path ?? requestEvent.url.pathname, + response.status, + duration, ); this.#onHandled?.({ duration, requestEvent, response, route }); } else { @@ -743,8 +813,23 @@ export class Router< return result; } + #logResponse( + id: string, + method: string, + pathname: string, + status: Status, + duration: number, + ) { + this.#logger.info( + `${method} ${pathname} ${status} ${STATUS_TEXT[status]} [${id}] (${ + duration.toFixed(2) + }ms)`, + ); + } + constructor(options: RouterOptions = {}) { const { + expose = true, keys, onError, onHandled, @@ -755,6 +840,7 @@ export class Router< logger, ...routerOptions } = options; + this.#expose = expose; this.#keys = keys; this.#onError = onError; this.#onHandled = onHandled; @@ -1194,7 +1280,7 @@ export class Router< ResSchema, ResponseBody >, - init?: RouteInit, + init?: RouteInit, ): Removeable; options( pathOrDescriptor: @@ -1303,7 +1389,7 @@ export class Router< ResSchema, ResponseBody >, - init?: RouteInit, + init?: RouteInit, ): Removeable; post( pathOrDescriptor: @@ -1412,7 +1498,7 @@ export class Router< ResSchema, ResponseBody >, - init?: RouteInit, + init?: RouteInit, ): Removeable; put( pathOrDescriptor: @@ -1521,7 +1607,7 @@ export class Router< ResSchema, ResponseBody >, - init?: RouteInit, + init?: RouteInit, ): Removeable; patch( pathOrDescriptor: @@ -1630,7 +1716,7 @@ export class Router< ResSchema, ResponseBody >, - init?: RouteInit, + init?: RouteInit, ): Removeable; delete( pathOrDescriptor: @@ -1656,7 +1742,7 @@ export class Router< | undefined, init?: RouteInit, ): Removeable { - return this.#addRoute(["PATCH"], pathOrDescriptor, handlerOrInit, init); + return this.#addRoute(["DELETE"], pathOrDescriptor, handlerOrInit, init); } /** @@ -1760,7 +1846,7 @@ export class Router< */ match(method: HttpMethod, path: string): Route | undefined { for (const route of this.#routes) { - if (route.matches(method, path)) { + if (route.matches(method, path) === true) { return route; } } @@ -1798,7 +1884,7 @@ export class Router< signal: abortController.signal, }); const addr = await server.listen(); - this.#logger.info(`listening on: ${addr}`); + this.#logger.info("listening on:", addr); onListen?.(addr); try { for await (const requestEvent of server) { @@ -1806,10 +1892,7 @@ export class Router< } await Promise.all(this.#handling); } catch (cause) { - this.#error( - cause instanceof Error ? cause.message : "Internal error", - cause, - ); + this.#logger.error("server when attempting to listen error:", cause); } } diff --git a/schema.ts b/schema.ts index e81657b..afd89c3 100644 --- a/schema.ts +++ b/schema.ts @@ -137,18 +137,23 @@ export class Schema< ResSchema extends BodySchema, > { #body?: BSchema; + #expose: boolean; #invalidHandler?: InvalidHandler; #logger = getLogger("acorn.schema"); #options?: ValidationOptions; #querystring?: QSSchema; #response?: ResSchema; - constructor(descriptor: SchemaDescriptor = {}) { + constructor( + descriptor: SchemaDescriptor = {}, + expose: boolean, + ) { this.#querystring = descriptor.querystring; this.#body = descriptor.body; this.#response = descriptor.response; this.#options = descriptor.options; this.#invalidHandler = descriptor.invalidHandler; + this.#expose = expose; } /** @@ -181,7 +186,7 @@ export class Schema< } else { try { this.#logger - .info(`${id} querystring is invalid, calling invalid handler.`); + .debug(`${id} querystring is invalid, calling invalid handler.`); return { invalidResponse: await this.#invalidHandler( "querystring", @@ -204,9 +209,10 @@ export class Schema< output: await parseAsync(this.#querystring, input, this.#options), }; } catch (cause) { - this.#logger.info(`${id} querystring is invalid.`); + this.#logger.debug(`${id} querystring is invalid.`); throw createHttpError(Status.BadRequest, "Invalid querystring", { cause, + expose: this.#expose, }); } } @@ -245,7 +251,7 @@ export class Schema< } else { try { this.#logger - .info( + .debug( `${requestEvent.id} body is invalid, calling invalid handler.`, ); return { @@ -270,8 +276,11 @@ export class Schema< output: await parseAsync(this.#body, input, this.#options), }; } catch (cause) { - this.#logger.info(`${requestEvent.id} body is invalid.`); - throw createHttpError(Status.BadRequest, "Invalid body", { cause }); + this.#logger.debug(`${requestEvent.id} body is invalid.`); + throw createHttpError(Status.BadRequest, "Invalid body", { + cause, + expose: this.#expose, + }); } } } diff --git a/types.ts b/types.ts index 6c049c4..2038c39 100644 --- a/types.ts +++ b/types.ts @@ -1,6 +1,7 @@ // Copyright 2018-2024 the oak authors. All rights reserved. import type { HttpMethod } from "@oak/commons/method"; +import type { NOT_ALLOWED } from "./constants.ts"; /** * The interface that defines the Cloudflare Worker fetch handler. @@ -268,6 +269,8 @@ export type RouteParameters = string extends Route // deno-lint-ignore ban-types : {}; +export type NotAllowed = typeof NOT_ALLOWED; + /** The abstract interface that needs to be implemented for a route. */ export interface Route< Env extends Record = Record, @@ -284,5 +287,5 @@ export interface Route< secure: boolean, ): Promise; /** Determines if the pathname and method are a match. */ - matches(method: HttpMethod, pathname: string): boolean; + matches(method: HttpMethod, pathname: string): boolean | NotAllowed; }