diff --git a/packages/hono/CHANGELOG.md b/packages/hono/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/packages/hono/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/packages/hono/README.md b/packages/hono/README.md new file mode 100644 index 00000000..669209ef --- /dev/null +++ b/packages/hono/README.md @@ -0,0 +1,27 @@ +# @stl-api/hono: Hono plugin for Stainless API + +Use this plugin to serve a Stainless API in a Hono app. + +# Getting started + +> **Warning** +> +> This is alpha software, and we may make significant changes in the coming months. +> We're eager for you to try it out and let us know what you think! + +## Installation + +``` +npm i --save stainless-api/stl-api#hono-0.1.0 +``` + +## Creating a Hono app + +```ts +import { stlApi } from "@stl-api/hono"; +import { Hono } from "hono"; +import api from "./api"; + +const app = new Hono(); +app.use("*", stlApi(api)); +``` diff --git a/packages/hono/package.json b/packages/hono/package.json new file mode 100644 index 00000000..8788447e --- /dev/null +++ b/packages/hono/package.json @@ -0,0 +1,38 @@ +{ + "name": "@stl-api/hono", + "version": "0.1.0", + "license": "ISC", + "description": "hono plugin for stainless api", + "author": "dev@stainlessapi.com", + "repository": { + "type": "git", + "url": "https://github.com/stainless-api/stl-api.git", + "directory": "packages/hono" + }, + "homepage": "https://github.com/stainless-api/stl-api/tree/main/packages/hono", + "bugs": { + "url": "https://github.com/stainless-api/stl-api/issues" + }, + "keywords": [ + "stainless", + "api", + "hono" + ], + "source": "src/honoPlugin.ts", + "main": "dist/honoPlugin.js", + "types": "dist/honoPlugin.d.ts", + "scripts": { + "clean": "rimraf dist *.tsbuildinfo" + }, + "devDependencies": { + "@types/node": "^20.10.3", + "@types/qs": "^6.9.10", + "typescript": "^5.3.2", + "vitest": "^1.3.1" + }, + "dependencies": { + "hono": "^4.0.0", + "qs": "^6.11.2", + "stainless": "github:stainless-api/stl-api#stainless-0.1.1" + } +} diff --git a/packages/hono/src/honoPlugin.test.ts b/packages/hono/src/honoPlugin.test.ts new file mode 100644 index 00000000..bfcda194 --- /dev/null +++ b/packages/hono/src/honoPlugin.test.ts @@ -0,0 +1,219 @@ +import { Hono } from "hono"; +import { Stl, UnauthorizedError, z } from "stainless"; +import { describe, expect, test } from "vitest"; +import { stlApi } from "./honoPlugin"; + +const stl = new Stl({ plugins: {} }); + +describe("basic routing", () => { + const api = stl.api({ + basePath: "/api", + resources: { + posts: stl.resource({ + summary: "posts", + actions: { + retrieve: stl.endpoint({ + endpoint: "GET /api/posts/:postId", + path: z.object({ postId: z.coerce.number() }), + query: z.object({ expand: z.string().array().optional() }), + response: z.object({ postId: z.coerce.number() }), + handler: (params) => params, + }), + update: stl.endpoint({ + endpoint: "POST /api/posts/:postId", + path: z.object({ postId: z.coerce.number() }), + body: z.object({ content: z.string() }), + response: z.object({ + postId: z.coerce.number(), + content: z.string(), + }), + handler: (params) => params, + }), + list: stl.endpoint({ + endpoint: "GET /api/posts", + response: z.any().array(), + handler: () => [], + }), + }, + }), + comments: stl.resource({ + summary: "comments", + actions: { + retrieve: stl.endpoint({ + endpoint: "GET /api/comments/:commentId", + path: z.object({ commentId: z.coerce.number() }), + response: z.object({ commentId: z.coerce.number() }), + handler: (params) => params, + }), + update: stl.endpoint({ + endpoint: "POST /api/comments/:commentId", + path: z.object({ commentId: z.coerce.number() }), + handler: () => { + throw new UnauthorizedError(); + }, + }), + }, + }), + }, + }); + + const app = new Hono(); + app.use("*", stlApi(api)); + + test("list posts", async () => { + const response = await app.request("/api/posts"); + expect(response).toHaveProperty("status", 200); + expect(await response.json()).toMatchInlineSnapshot(` + [] + `); + }); + + test("retrieve posts", async () => { + const response = await app.request("/api/posts/5"); + expect(response).toHaveProperty("status", 200); + expect(await response.json()).toMatchInlineSnapshot(` + { + "postId": 5, + } + `); + }); + + test("retrieve posts, wrong method", async () => { + const response = await app.request("/api/posts/5", { + method: "PUT", + }); + expect(response).toHaveProperty("status", 405); + expect(await response.json()).toMatchInlineSnapshot(` + { + "message": "No handler for PUT; only GET, POST.", + } + `); + }); + + test("update posts", async () => { + const response = await app.request("/api/posts/5", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ content: "hello" }), + }); + expect(response).toHaveProperty("status", 200); + expect(await response.json()).toMatchInlineSnapshot(` + { + "content": "hello", + "postId": 5, + } + `); + }); + + test("update posts, wrong content type", async () => { + const response = await app.request("/api/posts/5", { + method: "POST", + headers: { + "content-type": "text/plain", + }, + body: "hello", + }); + expect(response).toHaveProperty("status", 400); + expect(await response.json()).toMatchInlineSnapshot(` + { + "error": "bad request", + "issues": [ + { + "code": "invalid_type", + "expected": "object", + "message": "Required", + "path": [ + "", + ], + "received": "undefined", + }, + ], + } + `); + }); + + test("retrieve comments", async () => { + const response = await app.request("/api/comments/3"); + expect(response).toHaveProperty("status", 200); + expect(await response.json()).toMatchInlineSnapshot(` + { + "commentId": 3, + } + `); + }); + + test("not found", async () => { + const response = await app.request("/api/not-found"); + expect(response).toHaveProperty("status", 404); + expect(await response.json()).toMatchInlineSnapshot(` + { + "error": "not found", + } + `); + }); + + test("throwing inside handler", async () => { + const response = await app.request("/api/comments/3", { + method: "POST", + }); + expect(response).toHaveProperty("status", 401); + expect(await response.json()).toMatchInlineSnapshot(` + { + "error": "unauthorized", + } + `); + }); +}); + +describe("hono passthrough", () => { + const baseApi = stl.api({ + basePath: "/api", + resources: { + posts: stl.resource({ + summary: "posts", + actions: { + retrieve: stl.endpoint({ + endpoint: "GET /api/posts", + handler: () => { + throw new Error("arbitrary error"); + }, + }), + }, + }), + }, + }); + + const app = new Hono(); + app.use("*", stlApi(baseApi, { handleErrors: false })); + app.all("/public/*", (c) => { + return c.text("public content", 200); + }); + app.notFound((c) => { + return c.text("custom not found", 404); + }); + app.onError((err, c) => { + return c.text(`custom error: ${err.message}`, 500); + }); + + test("public passthrough", async () => { + const response = await app.request("/public/foo/bar"); + expect(response).toHaveProperty("status", 200); + expect(await response.text()).toMatchInlineSnapshot(`"public content"`); + }); + + test("not found passthrough", async () => { + const response = await app.request("/api/comments"); + expect(response).toHaveProperty("status", 404); + expect(await response.text()).toMatchInlineSnapshot(`"custom not found"`); + }); + + test("error passthrough", async () => { + const response = await app.request("/api/posts"); + expect(response).toHaveProperty("status", 500); + expect(await response.text()).toMatchInlineSnapshot( + `"custom error: arbitrary error"` + ); + }); +}); diff --git a/packages/hono/src/honoPlugin.ts b/packages/hono/src/honoPlugin.ts new file mode 100644 index 00000000..bc1e2316 --- /dev/null +++ b/packages/hono/src/honoPlugin.ts @@ -0,0 +1,116 @@ +import { HonoRequest } from "hono"; +import { createMiddleware } from "hono/factory"; +import { StatusCode } from "hono/utils/http-status"; +import qs from "qs"; +import { + allEndpoints, + AnyAPIDescription, + AnyEndpoint, + isStlError, + NotFoundError, +} from "stainless"; +import { isValidRouteMatch, makeRouteMatcher } from "./routeMatcher"; + +export type HonoServerContext = { + type: "hono"; + args: [HonoRequest, Response]; +}; + +declare module "stainless" { + interface StlContext { + server: HonoServerContext; + } +} + +export type StlAppOptions = { + handleErrors?: boolean; +}; + +const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + +function makeHandler(endpoints: AnyEndpoint[], options?: StlAppOptions) { + const stl = endpoints[0]?.stl; + if (!stl) { + throw new Error(`endpoints[0].stl must be defined`); + } + + const routeMatcher = makeRouteMatcher(endpoints); + + return createMiddleware(async (c, next) => { + try { + const match = routeMatcher.match(c.req.method, c.req.path); + const { search } = new URL(c.req.url); + + if (!isValidRouteMatch(match)) { + const enabledMethods = methods.filter((method) => + isValidRouteMatch(routeMatcher.match(method, c.req.path)) + ); + if (enabledMethods.length) { + return c.json( + { + message: `No handler for ${c.req.method}; only ${enabledMethods + .map((x) => x.toUpperCase()) + .join(", ")}.`, + }, + { status: 405 } + ); + } + if (options?.handleErrors !== false) { + throw new NotFoundError(); + } + await next(); + return; + } + + const [endpoint, path] = match[0][0]; + const server: HonoServerContext = { + type: "hono", + args: [c.req, c.res], + }; + + const context = stl.initContext({ + endpoint, + headers: c.req.header(), + server, + }); + + const params = stl.initParams({ + path, + query: search ? qs.parse(search.replace(/^\?/, "")) : {}, + body: await c.req.json().catch(() => undefined), + headers: c.req.header(), + }); + + const result = await stl.execute(params, context); + + return c.json(result); + } catch (error) { + if (options?.handleErrors === false) { + throw error; + } + + if (isStlError(error)) { + return c.json(error.response, error.statusCode as StatusCode); + } + + console.error( + `ERROR in ${c.req.method} ${c.req.url}:`, + error instanceof Error ? error.stack : error + ); + return c.json({ error, details: "Failed to handle the request." }, 500); + } + }); +} + +export function stlApi( + { topLevel, resources }: AnyAPIDescription, + options?: StlAppOptions +) { + return makeHandler( + allEndpoints({ + actions: topLevel?.actions, + namespacedResources: resources, + }), + options + ); +} diff --git a/packages/hono/src/routeMatcher.ts b/packages/hono/src/routeMatcher.ts new file mode 100644 index 00000000..701ff87b --- /dev/null +++ b/packages/hono/src/routeMatcher.ts @@ -0,0 +1,48 @@ +import { Result } from "hono/router"; +import { TrieRouter } from "hono/router/trie-router"; +import { + AnyEndpoint, + HttpEndpoint, + HttpMethod, + parseEndpoint, +} from "stainless"; + +/** + * Converts an endpoint from a format like 'GET /users/{id}' + * to ['GET', '/users/:id'] + */ +function endpointToHono(endpoint: HttpEndpoint): [HttpMethod, string] { + const [method, path] = parseEndpoint(endpoint); + + const pathParts = path + .split("/") + .map((el) => el.replace(/^\{([^}]+)\}$/, ":$1")); + + const unsupportedEl = pathParts.find((el) => el.includes("{")); + if (unsupportedEl) { + // TODO: hono routers don't support variables in the middle of a + // path element, but they do support regexes, so we'd need to convert + // this + throw new Error(`path element isn't currently supported: ${unsupportedEl}`); + } + + return [method, pathParts.join("/")]; +} + +export function makeRouteMatcher(endpoints: AnyEndpoint[]) { + const routeMatcher: TrieRouter = new TrieRouter(); + for (const endpoint of endpoints) { + const [method, path] = endpointToHono(endpoint.endpoint); + routeMatcher.add(method, path, endpoint); + } + + return routeMatcher; +} + +export function isValidRouteMatch(m: Result) { + if (!m) return false; + + if (m[0].length === 0) return false; + + return true; +} diff --git a/packages/hono/tsconfig.json b/packages/hono/tsconfig.json new file mode 100644 index 00000000..8d2f9088 --- /dev/null +++ b/packages/hono/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig-common.json", + "include": ["src"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "references": [{ "path": "../stainless" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c98a1fa..440a9242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,31 @@ importers: specifier: ^5.3.2 version: 5.3.2 + packages/hono: + dependencies: + hono: + specifier: ^4.0.0 + version: 4.6.1 + qs: + specifier: ^6.11.2 + version: 6.13.0 + stainless: + specifier: github:stainless-api/stl-api#stainless-0.1.1 + version: https://codeload.github.com/stainless-api/stl-api/tar.gz/7c730d0e83d046d06ca3cfed4c0ecceb94cbd72e(@swc/core@1.3.100(@swc/helpers@0.5.5))(@types/node@20.16.5)(typescript@5.3.2) + devDependencies: + '@types/node': + specifier: ^20.10.3 + version: 20.16.5 + '@types/qs': + specifier: ^6.9.10 + version: 6.9.15 + typescript: + specifier: ^5.3.2 + version: 5.3.2 + vitest: + specifier: ^1.3.1 + version: 1.6.0(@types/node@20.16.5)(jsdom@20.0.3)(terser@5.32.0) + packages/next: dependencies: hono: