Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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 { stlApi } from "@stl-api/hono";
import { Hono } from "hono";
import api from "./api";

const app = new Hono();
app.use("*", stlApi(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": {
"@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"
}
}
196 changes: 196 additions & 0 deletions packages/hono/src/honoPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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": [
"<stainless request body>",
],
"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: {},
});

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);
});

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"`);
});
});
121 changes: 121 additions & 0 deletions packages/hono/src/honoPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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<EC extends AnyBaseEndpoint> {
server: HonoServerContext;
}
}

export type StlAppOptions = {
handleErrors?: boolean;
};

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

function makeHandler(
basePath: string,
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(
{ basePath, topLevel, resources }: AnyAPIDescription,
options?: StlAppOptions
) {
return makeHandler(
basePath,
allEndpoints({
actions: topLevel?.actions,
namespacedResources: resources,
}),
options
);
}
Loading
Loading