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 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