Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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"
}
}
219 changes: 219 additions & 0 deletions packages/hono/src/honoPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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": [
"<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: {
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"`
);
});
});
Loading
Loading