feat(api): add changelog version routes

This commit is contained in:
Lars Hampe 2024-10-09 13:11:00 +02:00
parent 14f39f47c7
commit 21dd7a28ec
9 changed files with 593 additions and 2 deletions

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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<typeof VersionCreateInput>
}) => {
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
}

View File

@ -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()
}

View File

@ -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

View File

@ -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<typeof VersionUpdateInput>
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
}

View File

@ -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<typeof CommitCreateInput> = [
{
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<typeof VersionCreateInput> = {
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<typeof VersionCreateInput> = {
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<typeof VersionOutput> = 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<typeof VersionUpdateInput> = {
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)
})
})
})

View File

@ -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',

BIN
bun.lockb

Binary file not shown.