From 21dd7a28ec736f11761383e15bec34a930476573 Mon Sep 17 00:00:00 2001 From: Lars Hampe Date: Wed, 9 Oct 2024 13:11:00 +0200 Subject: [PATCH] feat(api): add changelog version routes --- apps/api/package.json | 6 +- apps/api/src/changelog/version/byId.ts | 66 ++++++ apps/api/src/changelog/version/create.ts | 88 ++++++++ apps/api/src/changelog/version/delete.ts | 60 ++++++ apps/api/src/changelog/version/index.ts | 101 +++++++++ apps/api/src/changelog/version/update.ts | 72 +++++++ .../api/src/changelog/version/version.test.ts | 200 ++++++++++++++++++ apps/api/src/index.ts | 2 + bun.lockb | Bin 425600 -> 426000 bytes 9 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/changelog/version/byId.ts create mode 100644 apps/api/src/changelog/version/create.ts create mode 100644 apps/api/src/changelog/version/delete.ts create mode 100644 apps/api/src/changelog/version/index.ts create mode 100644 apps/api/src/changelog/version/update.ts create mode 100644 apps/api/src/changelog/version/version.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index c2a024f..4bc7f35 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,15 +7,17 @@ }, "dependencies": { "@boring.tools/database": "workspace:*", - "@boring.tools/schema": "workspace:*", "@boring.tools/logger": "workspace:*", + "@boring.tools/schema": "workspace:*", "@hono/clerk-auth": "^2.0.0", "@hono/zod-openapi": "^0.16.2", "@scalar/hono-api-reference": "^0.5.149", "hono": "^4.6.3", + "semver": "^7.6.3", "svix": "^1.36.0" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/semver": "^7.5.8" } } diff --git a/apps/api/src/changelog/version/byId.ts b/apps/api/src/changelog/version/byId.ts new file mode 100644 index 0000000..ef8351f --- /dev/null +++ b/apps/api/src/changelog/version/byId.ts @@ -0,0 +1,66 @@ +import { changelog, changelog_version, db } from '@boring.tools/database' +import { VersionByIdParams, VersionOutput } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' + +export const byId = createRoute({ + method: 'get', + path: '/:id', + request: { + params: VersionByIdParams, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: VersionOutput, + }, + }, + description: 'Return version by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const byIdFunc = async ({ + userId, + id, +}: { + userId: string + id: string +}) => { + const versionResult = await db.query.changelog_version.findFirst({ + where: eq(changelog_version.id, id), + with: { + commits: true, + }, + }) + + if (!versionResult) { + return null + } + + if (!versionResult.changelogId) { + return null + } + + const changelogResult = await db.query.changelog.findMany({ + where: and(eq(changelog.userId, userId)), + columns: { + id: true, + }, + }) + + const changelogIds = changelogResult.map((cl) => cl.id) + + if (!changelogIds.includes(versionResult.changelogId)) { + return null + } + + return versionResult +} diff --git a/apps/api/src/changelog/version/create.ts b/apps/api/src/changelog/version/create.ts new file mode 100644 index 0000000..f873f66 --- /dev/null +++ b/apps/api/src/changelog/version/create.ts @@ -0,0 +1,88 @@ +import { changelog, changelog_version, db } from '@boring.tools/database' +import { VersionCreateInput, VersionCreateOutput } from '@boring.tools/schema' +import { createRoute, type z } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' +import semver from 'semver' + +export const create = createRoute({ + method: 'post', + path: '/', + request: { + body: { + content: { + 'application/json': { schema: VersionCreateInput }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/json': { schema: VersionCreateOutput }, + }, + description: 'Version created', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const createFunc = async ({ + userId, + payload, +}: { + userId: string + payload: z.infer +}) => { + const formattedVersion = semver.coerce(payload.version) + const validVersion = semver.valid(formattedVersion) + + const changelogResult = await db.query.changelog.findFirst({ + where: and( + eq(changelog.userId, userId), + eq(changelog.id, payload.changelogId), + ), + }) + + if (!changelogResult) { + throw new HTTPException(404, { + message: 'changelog not found', + }) + } + + if (validVersion === null) { + throw new HTTPException(409, { + message: 'Version is not semver compatible', + }) + } + + // Check if a version with the same version already exists + const versionResult = await db.query.version.findFirst({ + where: and( + eq(changelog_version.changelogId, payload.changelogId), + eq(changelog_version.version, validVersion), + ), + }) + + if (versionResult) { + throw new HTTPException(409, { + message: 'Version exists already', + }) + } + + const [versionCreateResult] = await db + .insert(changelog_version) + .values({ + changelogId: payload.changelogId, + version: validVersion, + status: payload.status, + markdown: payload.markdown, + }) + .returning() + + return versionCreateResult +} diff --git a/apps/api/src/changelog/version/delete.ts b/apps/api/src/changelog/version/delete.ts new file mode 100644 index 0000000..d3af99f --- /dev/null +++ b/apps/api/src/changelog/version/delete.ts @@ -0,0 +1,60 @@ +import { changelog, changelog_version, 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 remove = createRoute({ + method: 'delete', + path: '/:id', + responses: { + 200: { + content: { + 'application/json': { + schema: GeneralOutput, + }, + }, + description: 'Removes a version by id', + }, + 404: { + content: { + 'application/json': { + schema: GeneralOutput, + }, + }, + description: 'Version not found', + }, + }, +}) + +export const removeFunc = async ({ + userId, + id, +}: { + userId: string + id: string +}) => { + const changelogResult = await db.query.changelog.findMany({ + where: and(eq(changelog.userId, userId)), + with: { + versions: { + where: eq(changelog_version.id, id), + }, + }, + }) + + const findChangelog = changelogResult.find((change) => + change.versions.find((ver) => ver.id === id), + ) + + if (!findChangelog?.versions.length) { + throw new HTTPException(404, { + message: 'Version not found', + }) + } + + return db + .delete(changelog_version) + .where(and(eq(changelog_version.id, id))) + .returning() +} diff --git a/apps/api/src/changelog/version/index.ts b/apps/api/src/changelog/version/index.ts new file mode 100644 index 0000000..f807be3 --- /dev/null +++ b/apps/api/src/changelog/version/index.ts @@ -0,0 +1,101 @@ +import { OpenAPIHono } from '@hono/zod-openapi' + +import { HTTPException } from 'hono/http-exception' +import type { Variables } from '../..' +import { verifyAuthentication } from '../../utils/authentication' +import { byId, byIdFunc } from './byId' +import { create, createFunc } from './create' +import { remove, removeFunc } from './delete' +import { update, updateFunc } from './update' + +const app = new OpenAPIHono<{ Variables: Variables }>() + +app.openapi(create, async (c) => { + const userId = verifyAuthentication(c) + try { + const payload = await c.req.json() + const result = await createFunc({ userId, payload }) + + if (!result) { + return c.json({ message: 'Version not created' }, 400) + } + + 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(byId, async (c) => { + const userId = verifyAuthentication(c) + try { + const id = c.req.param('id') + const result = await byIdFunc({ userId, id }) + + if (!result) { + return c.json({ message: 'Version not found' }, 404) + } + + // Ensure all required properties are present and non-null + return c.json( + { + ...result, + changelogId: result.changelogId || '', + version: result.version || '', + status: result.status || 'draft', + releasedAt: result.releasedAt + ? result.releasedAt.toISOString() + : new Date().toISOString(), + commits: result.commits || [], + markdown: result.markdown || '', + }, + 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(update, async (c) => { + const userId = verifyAuthentication(c) + try { + const id = c.req.param('id') + + if (!id) { + return c.json({ message: 'Version not found' }, 404) + } + + const result = await updateFunc({ + userId, + payload: await c.req.json(), + id, + }) + + 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) + } +}) + +app.openapi(remove, async (c) => { + const userId = verifyAuthentication(c) + const id = c.req.param('id') + const result = await removeFunc({ userId, id }) + + if (result.length === 0) { + return c.json({ message: 'Version not found' }, 404) + } + + return c.json({ message: 'Version removed' }) +}) + +export default app diff --git a/apps/api/src/changelog/version/update.ts b/apps/api/src/changelog/version/update.ts new file mode 100644 index 0000000..5822c1a --- /dev/null +++ b/apps/api/src/changelog/version/update.ts @@ -0,0 +1,72 @@ +import { changelog, changelog_version, db } from '@boring.tools/database' +import { VersionUpdateInput, VersionUpdateOutput } 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 update = createRoute({ + method: 'put', + path: '/:id', + request: { + body: { + content: { + 'application/json': { schema: VersionUpdateInput }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { schema: VersionUpdateOutput }, + }, + description: 'Return updated version', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const updateFunc = async ({ + userId, + payload, + id, +}: { + userId: string + payload: z.infer + id: string +}) => { + const changelogResult = await db.query.changelog.findMany({ + where: and(eq(changelog.userId, userId)), + with: { + versions: { + where: eq(changelog_version.id, id), + }, + }, + }) + + const findChangelog = changelogResult.find((change) => + change.versions.find((ver) => ver.id === id), + ) + + if (!findChangelog?.versions.length) { + throw new HTTPException(404, { + message: 'Version not found', + }) + } + + const [versionUpdateResult] = await db + .update(changelog_version) + .set({ + status: payload.status, + markdown: payload.markdown, + releasedAt: payload.status === 'published' ? new Date() : null, + }) + .where(and(eq(changelog_version.id, id))) + .returning() + + return versionUpdateResult +} diff --git a/apps/api/src/changelog/version/version.test.ts b/apps/api/src/changelog/version/version.test.ts new file mode 100644 index 0000000..f6e9fae --- /dev/null +++ b/apps/api/src/changelog/version/version.test.ts @@ -0,0 +1,200 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type ChangelogSelect, + type CommitSelect, + type UserSelect, + type VersionSelect, + changelog, + commit, + db, + user, +} from '@changelog/database' +import type { + CommitCreateInput, + VersionCreateInput, + VersionOutput, + VersionUpdateInput, +} from '@changelog/schemas' +import type { z } from '@hono/zod-openapi' +import { eq } from 'drizzle-orm' +import { fetch } from '../../utils/testing/fetch' + +describe('Version', () => { + let testUser: UserSelect + let testChangelog: ChangelogSelect + let testCommits: CommitSelect[] + let testVersion: VersionSelect + + beforeAll(async () => { + const tUser = await db + .insert(user) + .values({ email: 'version@test.local' }) + .returning() + const tChangelog = await db + .insert(changelog) + .values({ + title: 'test', + description: 'some description', + userId: tUser[0].id, + }) + .returning() + + const payload: z.infer = [ + { + changelogId: tChangelog[0].id, + date: new Date(), + message: 'Some commit', + shortHash: '1234567', + body: 'some body', + }, + { + changelogId: tChangelog[0].id, + date: new Date(), + message: 'Some other commit', + shortHash: '1234568', + body: 'some body', + }, + ] + + await fetch( + { + path: '/api/commit', + method: 'POST', + body: payload, + }, + tUser[0], + ) + + testCommits = await db.query.commit.findMany({ + where: eq(commit.changelogId, tChangelog[0].id), + }) + testUser = tUser[0] + testChangelog = tChangelog[0] + }) + + afterAll(async () => { + await db.delete(user).where(eq(user.email, 'version@test.local')) + }) + + describe('Create', () => { + test('Success', async () => { + const payload: z.infer = { + changelogId: testChangelog.id, + releasedAt: new Date(), + commits: testCommits.map((c) => c.shortHash), + status: 'draft', + version: '1.0.0', + markdown: '', + } + + const res = await fetch( + { + path: '/api/version', + method: 'POST', + body: payload, + }, + testUser, + ) + + const json = await res.json() + testVersion = json + + expect(res.status).toBe(201) + }) + + test('Duplicate', async () => { + const payload: z.infer = { + changelogId: testChangelog.id, + releasedAt: new Date(), + commits: testCommits.map((c) => c.shortHash), + status: 'draft', + version: '1.0.0', + markdown: '', + } + + const res = await fetch( + { + path: '/api/version', + method: 'POST', + body: payload, + }, + testUser, + ) + expect(res.status).toBe(409) + }) + }) + + describe('By Id', () => { + test('Success', async () => { + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'GET', + }, + testUser, + ) + + expect(res.status).toBe(200) + + const json: z.infer = await res.json() + expect(json.commits).toHaveLength(2) + }) + + test('Not found', async () => { + const res = await fetch( + { + path: '/api/version/a7d2a68b-0696-4424-96c9-3629ae37978c', + method: 'GET', + }, + testUser, + ) + + expect(res.status).toBe(404) + }) + }) + + describe('Update', () => { + test('Success', async () => { + const payload: z.infer = { + status: 'published', + markdown: '', + } + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'PUT', + body: payload, + }, + testUser, + ) + + expect(res.status).toBe(200) + }) + }) + + describe('Remove', () => { + test('Success', async () => { + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'DELETE', + }, + testUser, + ) + + expect(res.status).toBe(200) + }) + + test('Not Found', async () => { + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'DELETE', + }, + testUser, + ) + + expect(res.status).toBe(404) + }) + }) +}) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3825cfc..dad3a54 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,7 @@ import { apiReference } from '@scalar/hono-api-reference' import { cors } from 'hono/cors' import changelog from './changelog' +import version from './changelog/version' import user from './user' import { authentication } from './utils/authentication' @@ -22,6 +23,7 @@ app.use('/v1/*', authentication) app.route('/v1/user', user) app.route('/v1/changelog', changelog) +app.route('/v1/changelog/version', version) app.doc('/openapi.json', { openapi: '3.0.0', diff --git a/bun.lockb b/bun.lockb index b1ce076159d8998e8e6d8f8a2dee60082b78949c..c99871e445c82014d64aec0d96e50046516e0a86 100755 GIT binary patch delta 4399 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 delta 3889 zcmdUydvMHI7RT@H{C=Hu6GVbUkhHwoBb_Im2zenik}z1d#v;fh)|$j?nGi`wGU72L z+PH}E+EPrZEo_*d$IKGUV#auk-Hq`WGHaEwc9&8$#f-4ulRlZh_W!NDRkzPMpL2ev z@A=*PyZ862tL$0X8dB%zciD0MZ^u^4#zT8|pPZBKTeRz+$Ew}Im7DThhpOg%ywmlx z+$^Ydub&cTO1B9A9+u`uW zr&%{oC~c6md!FW5JTHIh>=`lL?upO#YfO1{X1GjIFEeC@{GCe8lo|RdBBHixS+-2K`~tTm4ODoVt~xD8VU6)xqn)PLd1}aT#H8VFB!Zee@{-ds zA65!1-QynTl>5}n;WEP#iW@cBd!fsz&xSS0(>f~6au%9*Kuu$u7J=KA?M*bzDaWhS zk+^gR;0SNNF;4kdogf&6TS>M0cqFbXfJJ}lwYI?;{Im59N18ltm($X1qSvQ+nqZBE zm5fN?alaL}DO)p+j*`d2Y*=DqiJuTUWErFhas?!{HmPE3WtduDB>jH=>0+U%DvDC& zwX%ncf~#R_khPp-Ez9+g68r*Rq0!mO=VEQhf`i#>YxyGJvoH0{nYhLvo0#hFd}Tu;_zG^`AHSli)6=Kk7Gl8+Cr$}k44F)c6h zJkJWkv!NN`&rH`Q81Y~G=oJxlG$k8p{-^{X74tFWviZ1 zinZ+W1*R2CZ5=Z*`!QS3j2@pjW*?}mj!3bAUDzwdLCS-+kr_5@afn$RGi>f6iAPqi z>M6x0c6ElUmcu>Fu=R@0U{wERupW@u@5Lh&OpoP3)j}x_aX5Bxaf$E6VP-*KOPC#D zh9f~tX7&e_;ExoI?4o~I!vj3ZEEuf9J3-kHk(v(&^a z0_+8i4eczmNa*Ptch1W?i73EHT>g2rpcBgaoa5=g_w)SxiJ2WNkl6)h{lL!f&Aq6a zD8(gq4S*|vU0*O8NGX_o$t)Huh}mTo&>1PdVpkkoRXi70z^Eu3ZlW5D=II*8B|tw9 zo!WI(Kq3L<0eBlpi_|pR6usQIm=S6T%%(vP!f0c5B5wxQc2y(R6=p_2L<@lo1Qn|hR^^-_MLv;)>_ zIbQ#rXyfE4?RK>HQp*pVpusErYNvbr0ghlI9&Gu=s@xFO=>?3W(H0WQ`gC z5!(LkRn~Lz4(bR;;i^FC3YNIfaE{j2R? zw>CUHY`i|+mLZt}T&jfv+CR%q7u{;OdScDMwxOgt@W}5{aZ!3MJ_^dCFd{#tqI9=3 zR7f;nwknF&8{`J%6Qd`|`^p&u>#DjDqvu-fF<8%NcDw3t*R%JR+I4B2YWKqC3&$=RUobp)1@;8A_!Y`sZqBeSW81G>eNk?{VoBj|+)`CpZjMnGSDI6-$C*7X KH>?$Av-}SUJHDX+