feat(api): init hono with rest and trpc
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# dev
|
||||
.yarn/
|
||||
!.yarn/releases
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
# Basango API
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@basango/db": "workspace:*",
|
||||
"@basango/logger": "workspace:*",
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/zod-openapi": "^1.1.4",
|
||||
"@scalar/hono-api-reference": "^0.9.24",
|
||||
"@trpc/server": "^11.7.1",
|
||||
"ai": "^5.0.89",
|
||||
"camelcase-keys": "^10.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"hono": "^4.10.4",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
"jose": "^6.1.0",
|
||||
"zod": "^4.1.12",
|
||||
"zod-openapi": "^5.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.17",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"name": "@basango/api",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import { cors } from "hono/cors";
|
||||
import { secureHeaders } from "hono/secure-headers";
|
||||
|
||||
import { checkHealth } from "@/utils/health";
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
|
||||
app.use(secureHeaders());
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
allowHeaders: [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"accept-language",
|
||||
"x-trpc-source",
|
||||
"x-user-locale",
|
||||
"x-user-timezone",
|
||||
"x-user-country",
|
||||
],
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
exposeHeaders: ["Content-Length"],
|
||||
maxAge: 86400,
|
||||
origin: process.env.BASANGO_API_ALLOWED_ORIGINS?.split(",") ?? [],
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/health", async (c) => {
|
||||
try {
|
||||
await checkHealth();
|
||||
|
||||
return c.json({ status: "ok" }, 200);
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
status: "error",
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.doc("/openapi", {
|
||||
info: {
|
||||
contact: {
|
||||
email: "engineer@basango.io",
|
||||
name: "Basango",
|
||||
url: "https://basango.io",
|
||||
},
|
||||
description: "Basango is a platform that leverages AI to revolutionize news curation.",
|
||||
license: {
|
||||
name: "AGPL-3.0 license",
|
||||
url: "https://github.com/midday-ai/midday/blob/main/LICENSE",
|
||||
},
|
||||
title: "Basango API",
|
||||
version: "0.0.1",
|
||||
},
|
||||
openapi: "3.1.0",
|
||||
security: [
|
||||
{
|
||||
oauth2: [],
|
||||
},
|
||||
{ token: [] },
|
||||
],
|
||||
servers: [
|
||||
{
|
||||
description: "Production API",
|
||||
url: "https://api.basango.io",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Register security scheme
|
||||
app.openAPIRegistry.registerComponent("securitySchemes", "token", {
|
||||
description: "Default authentication mechanism",
|
||||
scheme: "bearer",
|
||||
type: "http",
|
||||
"x-speakeasy-example": "BASANGO_API_KEY",
|
||||
});
|
||||
|
||||
app.get("/", Scalar({ pageTitle: "Basango API", theme: "saturn", url: "/openapi" }));
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { HonoRequest } from "hono";
|
||||
|
||||
export function getGeoContext(req: HonoRequest) {
|
||||
const headers = req.header();
|
||||
|
||||
const country = headers["x-user-country"]?.toUpperCase() ?? null;
|
||||
const locale = headers["x-user-locale"] ?? null;
|
||||
const timezone = headers["x-user-timezone"] ?? null;
|
||||
const ip = headers["x-forwarded-for"] ?? null;
|
||||
|
||||
return {
|
||||
country,
|
||||
ip,
|
||||
locale,
|
||||
timezone,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { checkHealth as checkDbHealth } from "@basango/db/utils/health";
|
||||
|
||||
export async function checkHealth(): Promise<void> {
|
||||
await checkDbHealth();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function parseInputValue(value?: string | null) {
|
||||
if (value === null) return null;
|
||||
return value ? JSON.parse(value) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function buildSearchQuery(input: string) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((term) => {
|
||||
// Escape special characters for PostgreSQL full-text search
|
||||
// Special characters: & | ! ( ) : * ' " + - ~
|
||||
const escaped = term.toLowerCase().replace(/[&|!():*'"+~-]/g, "\\$&");
|
||||
return `${escaped}:*`;
|
||||
})
|
||||
.join(" & ");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { logger } from "@basango/logger";
|
||||
import { z } from "zod";
|
||||
|
||||
export function validateResponse(data: unknown, schema: z.ZodSchema) {
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
const cause = z.treeifyError(result.error);
|
||||
|
||||
logger.error(cause);
|
||||
|
||||
return {
|
||||
data: null,
|
||||
details: cause,
|
||||
error: "Response validation failed",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"extends": "@basango/tsconfig/base.json",
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
Reference in New Issue
Block a user