diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..b0ace5b --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1 @@ +POSTGRES_URL=postgres://postgres:postgres@localhost:5432/postgres \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index ab16798..fa39948 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,9 +1,15 @@ { "name": "@boring.tools/api", "scripts": { - "dev": "bun run --hot src/index.ts" + "dev": "bun run --hot src/index.ts", + "build": "bun build --entrypoints ./src/index.ts --outdir ../../build/api --target bun --splitting --sourcemap=linked", + "test": "bun test --preload ./src/index.ts" }, "dependencies": { + "@boring.tools/database": "workspace:*", + "@boring.tools/schema": "workspace:*", + "@hono/clerk-auth": "^2.0.0", + "@hono/zod-openapi": "^0.16.2", "hono": "^4.6.3" }, "devDependencies": { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3191383..6c81e46 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,9 +1,33 @@ -import { Hono } from 'hono' +import type { UserOutput } from '@boring.tools/schema' +import { OpenAPIHono, type z } from '@hono/zod-openapi' +import { cors } from 'hono/cors' -const app = new Hono() +import user from './user' -app.get('/', (c) => { - return c.text('Hello Hono!') +import { authentication } from './utils/authentication' + +type User = z.infer + +export type Variables = { + user: User +} + +export const app = new OpenAPIHono<{ Variables: Variables }>() + +app.use('*', cors()) +app.use('/api/*', authentication) + +app.route('/api/user', user) + +app.doc('/openapi.json', { + openapi: '3.0.0', + info: { + version: '0.0.0', + title: 'boring.tools', + }, }) -export default app +export default { + port: 3000, + fetch: app.fetch, +} diff --git a/apps/api/src/user/get.ts b/apps/api/src/user/get.ts new file mode 100644 index 0000000..4c6ad54 --- /dev/null +++ b/apps/api/src/user/get.ts @@ -0,0 +1,40 @@ +import { type UserSelect, db, user as userDb } from '@boring.tools/database' +import { UserOutput } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { eq } from 'drizzle-orm' + +export const route = createRoute({ + method: 'get', + path: '/', + responses: { + 200: { + content: { + 'application/json': { schema: UserOutput }, + }, + description: 'Return created commit', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const func = async ({ user }: { user: UserSelect }) => { + const result = await db.query.user.findFirst({ + where: eq(userDb.id, user.id), + }) + + if (!result) { + throw new Error('User not found') + } + + return result +} + +export default { + route, + func, +} diff --git a/apps/api/src/user/index.ts b/apps/api/src/user/index.ts new file mode 100644 index 0000000..c3ba76c --- /dev/null +++ b/apps/api/src/user/index.ts @@ -0,0 +1,34 @@ +import { OpenAPIHono, type z } from '@hono/zod-openapi' +import { HTTPException } from 'hono/http-exception' +import type { Variables } from '..' +import get from './get' +import webhook from './webhook' + +const app = new OpenAPIHono<{ Variables: Variables }>() + +app.openapi(get.route, async (c) => { + try { + const user = c.get('user') + const result = await get.func({ user }) + return c.json(result, 201) + } catch (error) { + if (error instanceof HTTPException) { + return c.json({ message: error.message }, error.status) + } + return c.json({ message: 'An unexpected error occurred' }, 500) + } +}) + +app.openapi(webhook.route, async (c) => { + try { + const result = await webhook.func({ payload: await c.req.json() }) + return c.json(result, 200) + } catch (error) { + if (error instanceof HTTPException) { + return c.json({ message: error.message }, error.status) + } + return c.json({ message: 'An unexpected error occurred' }, 500) + } +}) + +export default app diff --git a/apps/api/src/user/webhook.ts b/apps/api/src/user/webhook.ts new file mode 100644 index 0000000..4601ab6 --- /dev/null +++ b/apps/api/src/user/webhook.ts @@ -0,0 +1,75 @@ +import { db, user as userDb } from '@boring.tools/database' +import { UserOutput, UserWebhookInput } from '@boring.tools/schema' +import { createRoute, type z } from '@hono/zod-openapi' +import { HTTPException } from 'hono/http-exception' + +export const route = createRoute({ + method: 'post', + path: '/webhook', + request: { + body: { + content: { + 'application/json': { schema: UserWebhookInput }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { schema: UserOutput }, + }, + description: 'Return updated changelog', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +const userCreate = async ({ + payload, +}: { + payload: z.infer +}) => { + const data = { + id: payload.data.id, + name: `${payload.data.first_name} ${payload.data.last_name}`, + email: payload.data.email_addresses[0].email_address, + } + try { + await db + .insert(userDb) + .values({ + ...data, + }) + .onConflictDoUpdate({ + target: userDb.id, + set: data, + }) + + return true + } catch (error) { + return false + } +} + +export const func = async ({ + payload, +}: { + payload: z.infer +}) => { + switch (payload.type) { + case 'user.created': + return userCreate({ payload }) + default: + throw new HTTPException(404, { message: 'Webhook type not supported' }) + } +} + +export default { + route, + func, +} diff --git a/apps/api/src/utils/authentication.ts b/apps/api/src/utils/authentication.ts new file mode 100644 index 0000000..e59ac85 --- /dev/null +++ b/apps/api/src/utils/authentication.ts @@ -0,0 +1,44 @@ +import { accessToken, db } from '@boring.tools/database' +import { clerkMiddleware, getAuth } from '@hono/clerk-auth' +import { eq } from 'drizzle-orm' +import type { Context, Next } from 'hono' +import { some } from 'hono/combine' +import { HTTPException } from 'hono/http-exception' + +const generatedToken = async (c: Context, next: Next) => { + const authHeader = c.req.header('Authorization') + if (!authHeader) { + throw new HTTPException(401, { message: 'Unauthorized' }) + } + + const token = authHeader.replace('Bearer ', '') + + const accessTokenResult = await db.query.accessToken.findFirst({ + where: eq(accessToken.token, token), + with: { + user: true, + }, + }) + + if (!accessTokenResult) { + throw new HTTPException(401, { message: 'Unauthorized' }) + } + + c.set('user', accessTokenResult.user) + + await next() +} + +export const authentication = some(generatedToken, clerkMiddleware()) + +export const verifyAuthentication = (c: Context) => { + const auth = getAuth(c) + if (!auth?.userId) { + const accessTokenUser = c.get('user') + if (!accessTokenUser) { + throw new HTTPException(401, { message: 'Unauthorized' }) + } + return accessTokenUser.id + } + return auth.userId +}