Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new hono package #83

Merged
merged 6 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions packages/hono/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
27 changes: 27 additions & 0 deletions packages/hono/README.md
Original file line number Diff line number Diff line change
@@ -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 { apiRoute } from "@stl-api/hono";
import { Hono } from "hono";
import api from "./api";

const app = new Hono();
app.route("/", apiRoute(api));
```
38 changes: 38 additions & 0 deletions packages/hono/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@stl-api/hono",
"version": "0.1.0",
"license": "ISC",
"description": "hono plugin for stainless api",
"author": "[email protected]",
"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": {
"@hono/node-server": "^1.13.7",
"@types/node": "^20.10.3",
"@types/qs": "^6.9.10",
"typescript": "^5.3.2"
},
"dependencies": {
"hono": "^4.0.0",
"qs": "^6.11.2",
"stainless": "github:stainless-api/stl-api#stainless-0.1.1"
}
}
100 changes: 100 additions & 0 deletions packages/hono/src/honoPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Hono, HonoRequest } from "hono";
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<EC extends AnyBaseEndpoint> {
server: HonoServerContext;
}
}

const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];

function makeApp(endpoints: AnyEndpoint[]) {
const stl = endpoints[0]?.stl;
if (!stl) {
throw new Error(`endpoints[0].stl must be defined`);
}

const app = new Hono();
const routeMatcher = makeRouteMatcher(endpoints);

return app.all("*", async (c) => {
try {
const match = routeMatcher.match(c.req.method, c.req.path);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may need to check to see if the stl-api definition has any base paths defined

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 }
);
}
throw new NotFoundError();
}

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 (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 apiRoute({ topLevel, resources }: AnyAPIDescription) {
return makeApp(
allEndpoints({
actions: topLevel?.actions,
namespacedResources: resources,
})
);
}
48 changes: 48 additions & 0 deletions packages/hono/src/routeMatcher.ts
Original file line number Diff line number Diff line change
@@ -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<AnyEndpoint> = 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<AnyEndpoint>) {
if (!m) return false;

if (m[0].length === 0) return false;

return true;
}
9 changes: 9 additions & 0 deletions packages/hono/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig-common.json",
"include": ["src"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"references": [{ "path": "../stainless" }]
}
Loading
Loading