feat(db): migration and database setup
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
NODE_ENV=development
|
||||
BASANGO_API_HOST=localhost
|
||||
BASANGO_API_PORT=3000
|
||||
BASANGO_API_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
BASANGO_API_KEY=your_api_key_here
|
||||
BASANGO_CRAWLER_KEY=dev
|
||||
|
||||
@@ -27,6 +27,7 @@ export const { env, config } = defineConfig({
|
||||
"BASANGO_API_PORT",
|
||||
"BASANGO_API_ALLOWED_ORIGINS",
|
||||
"BASANGO_API_KEY",
|
||||
"BASANGO_CRAWLER_KEY",
|
||||
],
|
||||
path: path.join(PROJECT_DIR, ".env"),
|
||||
},
|
||||
|
||||
+3
-18
@@ -4,7 +4,7 @@ import { cors } from "hono/cors";
|
||||
import { secureHeaders } from "hono/secure-headers";
|
||||
|
||||
import { config, env } from "@/config";
|
||||
import { checkHealth } from "@/utils/health";
|
||||
import { routers } from "@/rest/routers";
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
|
||||
@@ -21,26 +21,10 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
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",
|
||||
email: "engineering@basango.io",
|
||||
name: "Basango",
|
||||
url: "https://basango.io",
|
||||
},
|
||||
@@ -76,6 +60,7 @@ app.openAPIRegistry.registerComponent("securitySchemes", "token", {
|
||||
});
|
||||
|
||||
app.get("/", Scalar({ pageTitle: "Basango API", theme: "saturn", url: "/openapi" }));
|
||||
app.route("/", routers);
|
||||
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
import { env } from "@/config";
|
||||
|
||||
export const withCrawlerAuth: MiddlewareHandler = async (c, next) => {
|
||||
const token = c.req.header("Authorization");
|
||||
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: "Authorization header required" });
|
||||
}
|
||||
|
||||
if (token !== env("BASANGO_CRAWLER_KEY")) {
|
||||
throw new HTTPException(403, { message: "Invalid token" });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { db } from "@basango/db/client";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
|
||||
export const withDatabase: MiddlewareHandler = async (c, next) => {
|
||||
c.set("db", db);
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
|
||||
import type { Scope } from "@/utils/scopes";
|
||||
|
||||
export const withRequiredScope = (...requiredScopes: Scope[]): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
const scopes = c.get("scopes") as Scope[] | undefined;
|
||||
|
||||
if (!scopes) {
|
||||
return c.json(
|
||||
{
|
||||
description: "No scopes found for the current user. Authentication is required.",
|
||||
error: "Unauthorized",
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has at least one of the required scopes
|
||||
const hasRequiredScope = requiredScopes.some((requiredScope) => scopes.includes(requiredScope));
|
||||
|
||||
if (!hasRequiredScope) {
|
||||
return c.json(
|
||||
{
|
||||
description: `Insufficient permissions. Required scopes: ${requiredScopes.join(
|
||||
", ",
|
||||
)}. Your scopes: ${scopes.join(", ")}`,
|
||||
error: "Forbidden",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createArticle } from "@basango/db/queries";
|
||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||
|
||||
import { withCrawlerAuth } from "@/rest/middlewares/crawler";
|
||||
import type { Context } from "@/rest/types";
|
||||
import { createArticleResponseSchema, createArticleSchema } from "@/schemas/articles";
|
||||
import { validateResponse } from "@/utils/response";
|
||||
|
||||
const app = new OpenAPIHono<Context>();
|
||||
|
||||
app.openapi(
|
||||
createRoute({
|
||||
description: "Store a new crawled article in the database.",
|
||||
method: "post",
|
||||
middleware: [withCrawlerAuth],
|
||||
operationId: "CreateArticle",
|
||||
path: "/",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createArticleSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createArticleResponseSchema,
|
||||
},
|
||||
},
|
||||
description: "Article created",
|
||||
},
|
||||
},
|
||||
summary: "Create Article",
|
||||
tags: ["Articles"],
|
||||
"x-speakeasy-name-override": "create",
|
||||
}),
|
||||
async (c) => {
|
||||
const db = c.get("db");
|
||||
const body = c.req.valid("json");
|
||||
const result = await createArticle(db, { ...body });
|
||||
|
||||
return c.json(
|
||||
validateResponse(result, createArticleResponseSchema) as { id: string; sourceId: string },
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const articlesRouter = app;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
|
||||
import { articlesRouter } from "@/rest/routers/articles";
|
||||
|
||||
const routers = new OpenAPIHono();
|
||||
|
||||
routers.route("/articles", articlesRouter);
|
||||
|
||||
export { routers };
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Database } from "@basango/db/client";
|
||||
|
||||
export type Context = {
|
||||
Variables: {
|
||||
db: Database;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { z } from "@hono/zod-openapi";
|
||||
|
||||
const sentimentSchema = z.enum(["positive", "neutral", "negative"]).openapi({
|
||||
default: "neutral",
|
||||
description: "The sentiment of the article content.",
|
||||
});
|
||||
|
||||
const readingTimeSchema = z.number().min(1).openapi({
|
||||
description: "The estimated reading time of the article in minutes.",
|
||||
example: 5,
|
||||
});
|
||||
|
||||
const tokenStatisticsSchema = z.object({
|
||||
body: z.number().min(0).openapi({
|
||||
description: "The number of tokens in the article body.",
|
||||
example: 350,
|
||||
}),
|
||||
categories: z.number().min(0).openapi({
|
||||
description: "The number of tokens in the article categories.",
|
||||
example: 25,
|
||||
}),
|
||||
excerpt: z.number().min(0).openapi({
|
||||
description: "The number of tokens in the article excerpt.",
|
||||
example: 50,
|
||||
}),
|
||||
title: z.number().min(0).openapi({
|
||||
description: "The number of tokens in the article title.",
|
||||
example: 15,
|
||||
}),
|
||||
total: z.number().min(0).openapi({
|
||||
description: "The total number of tokens in the article.",
|
||||
example: 440,
|
||||
}),
|
||||
});
|
||||
|
||||
const metadataSchema = z.object({
|
||||
description: z.string().optional().openapi({
|
||||
description: "A brief description or summary of the article.",
|
||||
example: "This article discusses the latest advancements in AI technology.",
|
||||
}),
|
||||
image: z.url().optional().openapi({
|
||||
description: "The URL of the main image associated with the article.",
|
||||
example: "https://example.com/image.jpg",
|
||||
}),
|
||||
title: z.string().optional().openapi({
|
||||
description: "The title of the article for metadata purposes.",
|
||||
example: "The Rise of AI",
|
||||
}),
|
||||
});
|
||||
|
||||
export const createArticleSchema = z
|
||||
.object({
|
||||
body: z.string().min(1).openapi({
|
||||
description: "The main content of the article.",
|
||||
example: "This is the body of the article...",
|
||||
}),
|
||||
categories: z.array(z.string()).openapi({
|
||||
description: "The categories or tags associated with the article.",
|
||||
example: ["Technology", "AI"],
|
||||
}),
|
||||
hash: z.string().min(1).openapi({
|
||||
description: "The unique hash of the article link.",
|
||||
example: "d41d8cd98f00b204e9800998ecf8427e",
|
||||
}),
|
||||
link: z.url().openapi({
|
||||
description: "The URL of the article.",
|
||||
example: "https://example.com/article",
|
||||
}),
|
||||
metadata: metadataSchema.optional(),
|
||||
publishedAt: z.date().openapi({
|
||||
description: "The publication date of the article.",
|
||||
example: "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
readingTime: readingTimeSchema.optional(),
|
||||
sentiment: sentimentSchema.optional().optional().default("neutral"),
|
||||
sourceId: z.string().openapi({
|
||||
description: "The unique identifier of the source from which the article was crawled.",
|
||||
example: "source-123",
|
||||
}),
|
||||
title: z.string().min(1).openapi({
|
||||
description: "The title of the article.",
|
||||
example: "The Rise of AI",
|
||||
}),
|
||||
tokenStatistics: tokenStatisticsSchema.optional(),
|
||||
})
|
||||
.openapi("CreateArticle");
|
||||
|
||||
export const createArticleResponseSchema = z
|
||||
.object({
|
||||
id: z.uuid().openapi({
|
||||
description: "The unique identifier of the article.",
|
||||
example: "b3b7c8e2-1f2a-4c3d-9e4f-5a6b7c8d9e0f",
|
||||
}),
|
||||
sourceId: z.uuid().openapi({
|
||||
description: "The unique identifier of the source associated with the article.",
|
||||
example: "a1a2b3c4-d5e6-7f8g-9h0i-j1k2l3m4n5o6",
|
||||
}),
|
||||
})
|
||||
.openapi("CreateArticleResponse");
|
||||
@@ -1,5 +0,0 @@
|
||||
import { checkHealth as checkDbHealth } from "@basango/db";
|
||||
|
||||
export async function checkHealth(): Promise<void> {
|
||||
await checkDbHealth();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export const SCOPES = [
|
||||
"articles.read",
|
||||
"articles.write",
|
||||
"apis.all", // All API scopes
|
||||
"apis.read", // All read scopes
|
||||
] as const;
|
||||
|
||||
export type Scope = (typeof SCOPES)[number];
|
||||
export type ScopePreset = "all_access" | "read_only" | "restricted";
|
||||
|
||||
export const scopePresets = [
|
||||
{
|
||||
description: "full access to all resources",
|
||||
label: "All",
|
||||
value: "all_access",
|
||||
},
|
||||
{
|
||||
description: "read-only access to all resources",
|
||||
label: "Read Only",
|
||||
value: "read_only",
|
||||
},
|
||||
{
|
||||
description: "restricted access to some resources",
|
||||
label: "Restricted",
|
||||
value: "restricted",
|
||||
},
|
||||
];
|
||||
|
||||
export const scopesToName = (scopes: string[]) => {
|
||||
if (scopes.includes("apis.all")) {
|
||||
return {
|
||||
description: "full access to all resources",
|
||||
name: "All access",
|
||||
preset: "all_access",
|
||||
};
|
||||
}
|
||||
|
||||
if (scopes.includes("apis.read")) {
|
||||
return {
|
||||
description: "read-only access to all resources",
|
||||
name: "Read-only",
|
||||
preset: "read_only",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
description: "restricted access to some resources",
|
||||
name: "Restricted",
|
||||
preset: "restricted",
|
||||
};
|
||||
};
|
||||
|
||||
export const expandScopes = (scopes: string[]): string[] => {
|
||||
if (scopes.includes("apis.all")) {
|
||||
// Return all scopes except any that start with "apis."
|
||||
return SCOPES.filter((scope) => !scope.startsWith("apis."));
|
||||
}
|
||||
|
||||
if (scopes.includes("apis.read")) {
|
||||
// Return all read scopes except any that start with "apis."
|
||||
return SCOPES.filter((scope) => scope.endsWith(".read") && !scope.startsWith("apis."));
|
||||
}
|
||||
|
||||
// For custom scopes, filter out any "apis." scopes
|
||||
return scopes.filter((scope) => !scope.startsWith("apis."));
|
||||
};
|
||||
Reference in New Issue
Block a user