feat(api): add changelog routes and tests
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m35s

This commit is contained in:
Lars Hampe 2024-10-01 13:37:26 +02:00
parent 060bb71b0e
commit 0d0e241e68
20 changed files with 633 additions and 3 deletions

View File

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

View File

@ -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<typeof ChangelogOutput>
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<typeof ChangelogCreateInput> = {
title: 'changelog',
description: 'description',
}
const res = await fetch(
{
path: '/v1/changelog',
method: 'POST',
body: payload,
},
testAccessToken.token,
)
const json: z.infer<typeof ChangelogCreateOutput> = 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<typeof ChangelogListOutput> = 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)
})
})
})

View File

@ -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<typeof ChangelogCreateInput>
}) => {
return await db
.insert(changelog)
.values({
...payload,
userId: userId,
})
.returning()
}
export default {
route,
func,
}

View File

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

View File

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

View File

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

View File

@ -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<typeof ChangelogUpdateInput>
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,
}

View File

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

View File

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

View File

@ -0,0 +1,30 @@
import { app } from '../..'
export const fetch = async (
{
method,
body,
path,
}: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
body?: Record<string, unknown> | Record<string, unknown>[]
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)
}
}

BIN
bun.lockb

Binary file not shown.

View File

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

View File

@ -0,0 +1,4 @@
import { z } from '@hono/zod-openapi'
import { ChangelogOutput } from './base'
export const ChangelogListOutput = z.array(ChangelogOutput)

View File

@ -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'],
})

View File

@ -0,0 +1,5 @@
export * from './base'
export * from './list'
export * from './create'
export * from './byId'
export * from './update'

View File

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

View File

@ -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'],
})

View File

@ -0,0 +1,9 @@
import { z } from '@hono/zod-openapi'
export const GeneralOutput = z
.object({
message: z.string().optional().openapi({
example: 'Something',
}),
})
.openapi('General')

View File

@ -0,0 +1 @@
export * from './base'

View File

@ -1 +1,3 @@
export * from './general'
export * from './user'
export * from './changelog'