diff --git a/apps/api/src/changelog/byId.ts b/apps/api/src/changelog/byId.ts new file mode 100644 index 0000000..fd4d364 --- /dev/null +++ b/apps/api/src/changelog/byId.ts @@ -0,0 +1,53 @@ +import { changelog, db } from '@boring.tools/database' +import { ChangelogByIdParams, ChangelogOutput } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' + +export const route = createRoute({ + method: 'get', + path: '/:id', + request: { + params: ChangelogByIdParams, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: ChangelogOutput, + }, + }, + description: 'Return changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const func = async ({ userId, id }: { userId: string; id: string }) => { + const result = await db.query.changelog.findFirst({ + where: and(eq(changelog.userId, userId), eq(changelog.id, id)), + with: { + versions: { + orderBy: (changelog_version, { desc }) => [ + desc(changelog_version.releasedAt), + ], + }, + }, + }) + + if (!result) { + throw new HTTPException(404, { message: 'Not found' }) + } + + return result +} + +export default { + route, + func, +} diff --git a/apps/api/src/changelog/changelog.test.ts b/apps/api/src/changelog/changelog.test.ts new file mode 100644 index 0000000..713bccf --- /dev/null +++ b/apps/api/src/changelog/changelog.test.ts @@ -0,0 +1,140 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { access_token, db, user } from '@boring.tools/database' +import type { + AccessTokenOutput, + ChangelogCreateInput, + ChangelogCreateOutput, + ChangelogListOutput, + ChangelogOutput, +} from '@boring.tools/schema' +import type { z } from '@hono/zod-openapi' +import { eq } from 'drizzle-orm' +import { fetch } from '../utils/testing/fetch' + +describe('Changelog', () => { + let testAccessToken: AccessTokenOutput + let testChangelog: z.infer + + beforeAll(async () => { + await db + .insert(user) + .values({ email: 'changelog@test.local', id: 'test_000' }) + const tAccessToken = await db + .insert(access_token) + .values({ token: 'test123', userId: 'test_000', name: 'testtoken' }) + .returning() + testAccessToken = tAccessToken[0] + }) + + afterAll(async () => { + await db.delete(user).where(eq(user.email, 'changelog@test.local')) + }) + + describe('Create', () => { + test('Success', async () => { + const payload: z.infer = { + title: 'changelog', + description: 'description', + } + + const res = await fetch( + { + path: '/v1/changelog', + method: 'POST', + body: payload, + }, + testAccessToken.token, + ) + + const json: z.infer = await res.json() + testChangelog = json + + expect(res.status).toBe(201) + expect(json.title).toBe(payload.title) + }) + }) + + describe('By Id', () => { + test('Success', async () => { + const res = await fetch( + { + path: `/v1/changelog/${testChangelog.id}`, + method: 'GET', + }, + testAccessToken.token, + ) + + expect(res.status).toBe(200) + }) + + test('Not Found', async () => { + const res = await fetch( + { + path: '/v1/changelog/635f4aa7-79fc-4d6b-af7d-6731999cc8bb', + method: 'GET', + }, + testAccessToken.token, + ) + + expect(res.status).toBe(404) + }) + + test('Invalid Id', async () => { + const res = await fetch( + { + path: '/v1/changelog/some', + method: 'GET', + }, + testAccessToken.token, + ) + + expect(res.status).toBe(400) + + const json = await res.json() + expect(json.success).toBeFalse() + }) + }) + + describe('List', () => { + test('Success', async () => { + const res = await fetch( + { + path: '/v1/changelog', + method: 'GET', + }, + testAccessToken.token, + ) + + expect(res.status).toBe(200) + + const json: z.infer = await res.json() + expect(json).toHaveLength(1) + }) + }) + + describe('Remove', () => { + test('Success', async () => { + const res = await fetch( + { + path: `/v1/changelog/${testChangelog.id}`, + method: 'DELETE', + }, + testAccessToken.token, + ) + + expect(res.status).toBe(200) + }) + + test('Not found', async () => { + const res = await fetch( + { + path: `/v1/changelog/${testChangelog.id}`, + method: 'DELETE', + }, + testAccessToken.token, + ) + + expect(res.status).toBe(404) + }) + }) +}) diff --git a/apps/api/src/changelog/create.ts b/apps/api/src/changelog/create.ts new file mode 100644 index 0000000..2975ba9 --- /dev/null +++ b/apps/api/src/changelog/create.ts @@ -0,0 +1,53 @@ +import { changelog, db } from '@boring.tools/database' +import { + ChangelogCreateInput, + ChangelogCreateOutput, +} from '@boring.tools/schema' +import { createRoute, type z } from '@hono/zod-openapi' + +export const route = createRoute({ + method: 'post', + path: '/', + request: { + body: { + content: { + 'application/json': { schema: ChangelogCreateInput }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/json': { schema: ChangelogCreateOutput }, + }, + description: 'Return created changelog', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const func = async ({ + userId, + payload, +}: { + userId: string + payload: z.infer +}) => { + return await db + .insert(changelog) + .values({ + ...payload, + userId: userId, + }) + .returning() +} + +export default { + route, + func, +} diff --git a/apps/api/src/changelog/delete.ts b/apps/api/src/changelog/delete.ts new file mode 100644 index 0000000..c219815 --- /dev/null +++ b/apps/api/src/changelog/delete.ts @@ -0,0 +1,44 @@ +import { changelog, db } from '@boring.tools/database' +import { GeneralOutput } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' + +export const route = createRoute({ + method: 'delete', + path: '/:id', + responses: { + 200: { + content: { + 'application/json': { + schema: GeneralOutput, + }, + }, + description: 'Removes a changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const func = async ({ userId, id }: { userId: string; id: string }) => { + const result = await db + .delete(changelog) + .where(and(eq(changelog.userId, userId), eq(changelog.id, id))) + .returning() + + if (!result) { + throw new HTTPException(404, { message: 'Not found' }) + } + + return result +} + +export default { + route, + func, +} diff --git a/apps/api/src/changelog/index.ts b/apps/api/src/changelog/index.ts new file mode 100644 index 0000000..1e11070 --- /dev/null +++ b/apps/api/src/changelog/index.ts @@ -0,0 +1,107 @@ +import { OpenAPIHono } from '@hono/zod-openapi' + +import { HTTPException } from 'hono/http-exception' +import type { Variables } from '..' +import { verifyAuthentication } from '../utils/authentication' +import ById from './byId' +import Create from './create' +import Delete from './delete' +import List from './list' +import Update from './update' + +const app = new OpenAPIHono<{ Variables: Variables }>() + +app.openapi(ById.route, async (c) => { + const userId = verifyAuthentication(c) + try { + const id = c.req.param('id') + const result = await ById.func({ userId, id }) + 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) + } +}) + +app.openapi(List.route, async (c) => { + const userId = verifyAuthentication(c) + try { + const result = await List.func({ userId }) + 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) + } +}) + +app.openapi(Create.route, async (c) => { + const userId = verifyAuthentication(c) + + try { + const [result] = await Create.func({ + userId, + payload: await c.req.json(), + }) + 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(Delete.route, async (c) => { + const userId = verifyAuthentication(c) + + try { + const id = c.req.param('id') + const result = await Delete.func({ userId, id }) + + if (result.length === 0) { + return c.json({ message: 'Changelog not found' }, 404) + } + + return c.json({ message: 'Changelog removed' }) + } 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(Update.route, async (c) => { + const userId = verifyAuthentication(c) + + try { + const id = c.req.param('id') + + if (!id) { + return c.json({ message: 'Changelog not found' }, 404) + } + + const result = await Update.func({ + userId, + payload: await c.req.json(), + id, + }) + + if (result.length === 0) { + return c.json({ message: 'Changelog not found' }, 404) + } + + return c.json(result) + } 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/changelog/list.ts b/apps/api/src/changelog/list.ts new file mode 100644 index 0000000..f744b08 --- /dev/null +++ b/apps/api/src/changelog/list.ts @@ -0,0 +1,54 @@ +import { changelog, db } from '@boring.tools/database' +import { ChangelogListOutput } 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: ChangelogListOutput, + }, + }, + description: 'Return changelogs for current user', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const func = async ({ userId }: { userId: string }) => { + const result = await db.query.changelog.findMany({ + where: eq(changelog.userId, userId), + with: { + versions: true, + commits: { + columns: { id: true }, + }, + }, + orderBy: (changelog, { asc }) => [asc(changelog.createdAt)], + }) + + return result.map((changelog) => { + const { versions, commits, ...rest } = changelog + return { + ...rest, + computed: { + versionCount: versions.length, + commitCount: commits.length, + }, + } + }) +} + +export default { + route, + func, +} diff --git a/apps/api/src/changelog/update.ts b/apps/api/src/changelog/update.ts new file mode 100644 index 0000000..c4b0fa6 --- /dev/null +++ b/apps/api/src/changelog/update.ts @@ -0,0 +1,63 @@ +import { changelog, db } from '@boring.tools/database' +import { + ChangelogUpdateInput, + ChangelogUpdateOutput, +} from '@boring.tools/schema' +import { createRoute, type z } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' + +export const route = createRoute({ + method: 'put', + path: '/:id', + request: { + body: { + content: { + 'application/json': { schema: ChangelogUpdateInput }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { schema: ChangelogUpdateOutput }, + }, + description: 'Return updated changelog', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const func = async ({ + userId, + payload, + id, +}: { + userId: string + payload: z.infer + id: string +}) => { + const result = await db + .update(changelog) + .set({ + ...payload, + }) + .where(and(eq(changelog.id, id), eq(changelog.userId, userId))) + .returning() + + if (!result) { + throw new HTTPException(404, { message: 'Not found' }) + } + + return result +} + +export default { + route, + func, +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 45b7fb4..32806df 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import { OpenAPIHono, type z } from '@hono/zod-openapi' import { apiReference } from '@scalar/hono-api-reference' import { cors } from 'hono/cors' +import changelog from './changelog' import user from './user' import { authentication } from './utils/authentication' @@ -17,9 +18,10 @@ export type Variables = { export const app = new OpenAPIHono<{ Variables: Variables }>() app.use('*', cors()) -app.use('/api/*', authentication) +app.use('/v1/*', authentication) -app.route('/api/user', user) +app.route('/v1/user', user) +app.route('/v1/changelog', changelog) app.doc('/openapi.json', { openapi: '3.0.0', diff --git a/apps/api/src/utils/authentication.ts b/apps/api/src/utils/authentication.ts index 0cbd6a8..23ac344 100644 --- a/apps/api/src/utils/authentication.ts +++ b/apps/api/src/utils/authentication.ts @@ -13,7 +13,7 @@ const generatedToken = async (c: Context, next: Next) => { const token = authHeader.replace('Bearer ', '') - const accessTokenResult = await db.query.accessToken.findFirst({ + const accessTokenResult = await db.query.access_token.findFirst({ where: eq(access_token.token, token), with: { user: true, diff --git a/apps/api/src/utils/testing/fetch.ts b/apps/api/src/utils/testing/fetch.ts new file mode 100644 index 0000000..e86878d --- /dev/null +++ b/apps/api/src/utils/testing/fetch.ts @@ -0,0 +1,30 @@ +import { app } from '../..' + +export const fetch = async ( + { + method, + body, + path, + }: { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' + body?: Record | Record[] + path: string + }, + token: string, +) => { + try { + // const token = await authorization(user) + const headers = new Headers({ + Authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }) + + return app.request(path, { + method, + body: JSON.stringify(body), + headers, + }) + } catch (error) { + throw new Error(error) + } +} diff --git a/bun.lockb b/bun.lockb index 90126cc..1b83a05 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/schema/src/changelog/base.ts b/packages/schema/src/changelog/base.ts new file mode 100644 index 0000000..23a19ba --- /dev/null +++ b/packages/schema/src/changelog/base.ts @@ -0,0 +1,24 @@ +import { z } from '@hono/zod-openapi' + +export const ChangelogOutput = z + .object({ + id: z.string().uuid().openapi({ + example: '9f00f912-f687-42ef-84d7-efde48ca11ef', + }), + title: z.string().min(3).openapi({ + example: 'My Changelog', + }), + description: z.string().openapi({ + example: 'This is a changelog', + }), + versions: z.array(z.object({})).optional(), + computed: z.object({ + versionCount: z.number().openapi({ + example: 5, + }), + commitCount: z.number().openapi({ + example: 10, + }), + }), + }) + .openapi('Changelog') diff --git a/packages/schema/src/changelog/byId.ts b/packages/schema/src/changelog/byId.ts new file mode 100644 index 0000000..7c0e0e2 --- /dev/null +++ b/packages/schema/src/changelog/byId.ts @@ -0,0 +1,4 @@ +import { z } from '@hono/zod-openapi' +import { ChangelogOutput } from './base' + +export const ChangelogListOutput = z.array(ChangelogOutput) diff --git a/packages/schema/src/changelog/create.ts b/packages/schema/src/changelog/create.ts new file mode 100644 index 0000000..2f805b8 --- /dev/null +++ b/packages/schema/src/changelog/create.ts @@ -0,0 +1,12 @@ +import { z } from '@hono/zod-openapi' +import { ChangelogOutput } from './base' + +export const ChangelogCreateOutput = ChangelogOutput +export const ChangelogCreateInput = z + .object({ + title: z.string().min(3, 'Title must contain at least 3 charachters.'), + description: z.string(), + }) + .openapi({ + required: ['title', 'userId'], + }) diff --git a/packages/schema/src/changelog/index.ts b/packages/schema/src/changelog/index.ts new file mode 100644 index 0000000..1a2fa72 --- /dev/null +++ b/packages/schema/src/changelog/index.ts @@ -0,0 +1,5 @@ +export * from './base' +export * from './list' +export * from './create' +export * from './byId' +export * from './update' diff --git a/packages/schema/src/changelog/list.ts b/packages/schema/src/changelog/list.ts new file mode 100644 index 0000000..428b97f --- /dev/null +++ b/packages/schema/src/changelog/list.ts @@ -0,0 +1,14 @@ +import { z } from '@hono/zod-openapi' + +export const ChangelogByIdParams = z.object({ + id: z + .string() + .uuid() + .openapi({ + param: { + name: 'id', + in: 'path', + }, + example: 'a5ed5965-0506-44e6-aaec-0465ff9fe092', + }), +}) diff --git a/packages/schema/src/changelog/update.ts b/packages/schema/src/changelog/update.ts new file mode 100644 index 0000000..5a8e229 --- /dev/null +++ b/packages/schema/src/changelog/update.ts @@ -0,0 +1,13 @@ +import { z } from '@hono/zod-openapi' +import { ChangelogOutput } from './base' +import { ChangelogCreateInput } from './create' + +export const ChangelogUpdateOutput = ChangelogOutput +export const ChangelogUpdateInput = ChangelogCreateInput +export const ChangelogUpdateParams = z + .object({ + id: z.string().uuid(), + }) + .openapi({ + required: ['id'], + }) diff --git a/packages/schema/src/general/base.ts b/packages/schema/src/general/base.ts new file mode 100644 index 0000000..1bdd7a2 --- /dev/null +++ b/packages/schema/src/general/base.ts @@ -0,0 +1,9 @@ +import { z } from '@hono/zod-openapi' + +export const GeneralOutput = z + .object({ + message: z.string().optional().openapi({ + example: 'Something', + }), + }) + .openapi('General') diff --git a/packages/schema/src/general/index.ts b/packages/schema/src/general/index.ts new file mode 100644 index 0000000..85e5652 --- /dev/null +++ b/packages/schema/src/general/index.ts @@ -0,0 +1 @@ +export * from './base' diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index c3a9c65..fdae3b0 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1 +1,3 @@ +export * from './general' export * from './user' +export * from './changelog'