Skip to content

Commit 16f65d3

Browse files
committed
feat: new hono package
1 parent a8209b7 commit 16f65d3

File tree

7 files changed

+285
-86
lines changed

7 files changed

+285
-86
lines changed

packages/hono/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Changelog

packages/hono/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# @stl-api/hono: Hono plugin for Stainless API
2+
3+
Use this plugin to serve a Stainless API in a Hono app.
4+
5+
# Getting started
6+
7+
> **Warning**
8+
>
9+
> This is alpha software, and we may make significant changes in the coming months.
10+
> We're eager for you to try it out and let us know what you think!
11+
12+
## Installation
13+
14+
```
15+
npm i --save stainless-api/stl-api#hono-0.1.0
16+
```
17+
18+
## Creating a Hono app
19+
20+
```ts
21+
import { apiRoute } from "@stl-api/hono";
22+
import { Hono } from "hono";
23+
import api from "./api";
24+
25+
const app = new Hono();
26+
app.route("/", apiRoute(api));
27+
```

packages/hono/package.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "@stl-api/hono",
3+
"version": "0.1.0",
4+
"license": "ISC",
5+
"description": "hono plugin for stainless api",
6+
"author": "[email protected]",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/stainless-api/stl-api.git",
10+
"directory": "packages/hono"
11+
},
12+
"homepage": "https://github.com/stainless-api/stl-api/tree/main/packages/hono",
13+
"bugs": {
14+
"url": "https://github.com/stainless-api/stl-api/issues"
15+
},
16+
"keywords": [
17+
"stainless",
18+
"api",
19+
"hono"
20+
],
21+
"source": "src/honoPlugin.ts",
22+
"main": "dist/honoPlugin.js",
23+
"types": "dist/honoPlugin.d.ts",
24+
"scripts": {
25+
"clean": "rimraf dist *.tsbuildinfo"
26+
},
27+
"devDependencies": {
28+
"@hono/node-server": "^1.13.7",
29+
"@types/node": "^20.10.3",
30+
"@types/qs": "^6.9.10",
31+
"typescript": "^5.3.2"
32+
},
33+
"dependencies": {
34+
"hono": "^4.0.0",
35+
"qs": "^6.11.2",
36+
"stainless": "github:stainless-api/stl-api#stainless-0.1.1"
37+
}
38+
}

packages/hono/src/honoPlugin.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Hono, HonoRequest } from "hono";
2+
import { StatusCode } from "hono/utils/http-status";
3+
import qs from "qs";
4+
import {
5+
allEndpoints,
6+
AnyAPIDescription,
7+
AnyEndpoint,
8+
isStlError,
9+
NotFoundError,
10+
} from "stainless";
11+
import { isValidRouteMatch, makeRouteMatcher } from "./routeMatcher";
12+
13+
export type HonoServerContext = {
14+
type: "hono";
15+
args: [HonoRequest, Response];
16+
};
17+
18+
declare module "stainless" {
19+
interface StlContext<EC extends AnyBaseEndpoint> {
20+
server: HonoServerContext;
21+
}
22+
}
23+
24+
const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
25+
26+
function makeApp(endpoints: AnyEndpoint[]) {
27+
const stl = endpoints[0]?.stl;
28+
if (!stl) {
29+
throw new Error(`endpoints[0].stl must be defined`);
30+
}
31+
32+
const app = new Hono();
33+
const routeMatcher = makeRouteMatcher(endpoints);
34+
35+
return app.all("*", async (c) => {
36+
try {
37+
const match = routeMatcher.match(c.req.method, c.req.path);
38+
const { search } = new URL(c.req.url);
39+
40+
if (!isValidRouteMatch(match)) {
41+
const enabledMethods = methods.filter((method) =>
42+
isValidRouteMatch(routeMatcher.match(method, c.req.path))
43+
);
44+
if (enabledMethods.length) {
45+
return c.json(
46+
{
47+
message: `No handler for ${c.req.method}; only ${enabledMethods
48+
.map((x) => x.toUpperCase())
49+
.join(", ")}.`,
50+
},
51+
{ status: 405 }
52+
);
53+
}
54+
throw new NotFoundError();
55+
}
56+
57+
const [endpoint, path] = match[0][0];
58+
const server: HonoServerContext = {
59+
type: "hono",
60+
args: [c.req, c.res],
61+
};
62+
63+
const context = stl.initContext({
64+
endpoint,
65+
headers: c.req.header(),
66+
server,
67+
});
68+
69+
const params = stl.initParams({
70+
path,
71+
query: search ? qs.parse(search.replace(/^\?/, "")) : {},
72+
body: await c.req.json().catch(() => undefined),
73+
headers: c.req.header(),
74+
});
75+
76+
const result = await stl.execute(params, context);
77+
78+
return c.json(result);
79+
} catch (error) {
80+
if (isStlError(error)) {
81+
return c.json(error.response, error.statusCode as StatusCode);
82+
}
83+
84+
console.error(
85+
`ERROR in ${c.req.method} ${c.req.url}:`,
86+
error instanceof Error ? error.stack : error
87+
);
88+
return c.json({ error, details: "Failed to handle the request." }, 500);
89+
}
90+
});
91+
}
92+
93+
export function apiRoute({ topLevel, resources }: AnyAPIDescription) {
94+
return makeApp(
95+
allEndpoints({
96+
actions: topLevel?.actions,
97+
namespacedResources: resources,
98+
})
99+
);
100+
}

packages/hono/src/routeMatcher.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Result } from "hono/router";
2+
import { TrieRouter } from "hono/router/trie-router";
3+
import {
4+
AnyEndpoint,
5+
HttpEndpoint,
6+
HttpMethod,
7+
parseEndpoint,
8+
} from "stainless";
9+
10+
/**
11+
* Converts an endpoint from a format like 'GET /users/{id}'
12+
* to ['GET', '/users/:id']
13+
*/
14+
function endpointToHono(endpoint: HttpEndpoint): [HttpMethod, string] {
15+
const [method, path] = parseEndpoint(endpoint);
16+
17+
const pathParts = path
18+
.split("/")
19+
.map((el) => el.replace(/^\{([^}]+)\}$/, ":$1"));
20+
21+
const unsupportedEl = pathParts.find((el) => el.includes("{"));
22+
if (unsupportedEl) {
23+
// TODO: hono routers don't support variables in the middle of a
24+
// path element, but they do support regexes, so we'd need to convert
25+
// this
26+
throw new Error(`path element isn't currently supported: ${unsupportedEl}`);
27+
}
28+
29+
return [method, pathParts.join("/")];
30+
}
31+
32+
export function makeRouteMatcher(endpoints: AnyEndpoint[]) {
33+
const routeMatcher: TrieRouter<AnyEndpoint> = new TrieRouter();
34+
for (const endpoint of endpoints) {
35+
const [method, path] = endpointToHono(endpoint.endpoint);
36+
routeMatcher.add(method, path, endpoint);
37+
}
38+
39+
return routeMatcher;
40+
}
41+
42+
export function isValidRouteMatch(m: Result<AnyEndpoint>) {
43+
if (!m) return false;
44+
45+
if (m[0].length === 0) return false;
46+
47+
return true;
48+
}

packages/hono/tsconfig.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig-common.json",
3+
"include": ["src"],
4+
"compilerOptions": {
5+
"outDir": "dist",
6+
"rootDir": "src"
7+
},
8+
"references": [{ "path": "../stainless" }]
9+
}

0 commit comments

Comments
 (0)