Sign in
Sign up

    Live Loading

    Global Posts

    View on GitHub
DenoKV + OpenAPIHono

@shimehituzi - 3M

main.ts

1import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
2import { swaggerUI } from '@hono/swagger-ui'
3
4const Todo = z.object({
5 id: z.number().int().positive(),
6 title: z.string().min(1),
7 completed: z.boolean(),
8})
9
10type Todo = z.infer<typeof Todo>
11
12const kv = await Deno.openKv(':memory:')
13
14const CreateTodoSchema = z.object({
15 title: z.string().min(1),
16})
17
18const UpdateTodoSchema = z.object({
19 title: z.string().min(1).optional(),
20 completed: z.boolean().optional(),
21})
22
23const ParamsSchema = z.object({
24 id: z.string().regex(/^\d+$/).transform(Number).pipe(
25 z.number().int().positive(),
26 ),
27})
28
29const ErrorSchema = z.object({
30 error: z.string(),
31})
32
33async function createTodo(title: string): Promise<Todo> {
34 while (true) {
35 const counterKey = ['todo_counter']
36 const { value: currentId, versionstamp } = await kv.get<number>(counterKey)
37 const nextId = (currentId ?? 0) + 1
38
39 const newTodo: Todo = {
40 id: nextId,
41 title,
42 completed: false,
43 }
44
45 const res = await kv.atomic()
46 .check({ key: counterKey, versionstamp })
47 .set(counterKey, nextId)
48 .set(['todo', nextId], newTodo)
49 .commit()
50
51 if (res.ok) {
52 return newTodo
53 }
54 // If the transaction fails, the loop will retry
55 }
56}
57
58async function getAllTodos(): Promise<Todo[]> {
59 const todos: Todo[] = []
60 const iter = kv.list<Todo>({ prefix: ['todo'] })
61 for await (const entry of iter) {
62 if (typeof entry.key[1] === 'number') { // Ensure we're only getting todo entries
63 todos.push(entry.value)
64 }
65 }
66 return todos
67}
68
69async function updateTodo(
70 id: number,
71 updates: Partial<Todo>,
72): Promise<Todo | null> {
73 while (true) {
74 const { value: existingTodo, versionstamp } = await kv.get<Todo>([
75 'todo',
76 id,
77 ])
78 if (!existingTodo) return null
79
80 const updatedTodo: Todo = {
81 ...existingTodo,
82 ...updates,
83 }
84
85 const res = await kv.atomic()
86 .check({ key: ['todo', id], versionstamp })
87 .set(['todo', id], updatedTodo)
88 .commit()
89
90 if (res.ok) {
91 return updatedTodo
92 }
93 // If the transaction fails, the loop will retry
94 }
95}
96
97async function deleteTodo(id: number): Promise<boolean> {
98 while (true) {
99 const { versionstamp } = await kv.get(['todo', id])
100
101 if (!versionstamp) {
102 // Todo doesn't exist
103 return false
104 }
105
106 const res = await kv.atomic()
107 .check({ key: ['todo', id], versionstamp })
108 .delete(['todo', id])
109 .commit()
110
111 if (res.ok) {
112 return true
113 }
114 // If the transaction fails, the loop will retry
115 }
116}
117
118export const app = new OpenAPIHono()
119 .openapi(
120 createRoute({
121 method: 'get',
122 path: '/',
123 responses: {
124 200: {
125 content: {
126 'text/plain': {
127 schema: z.string(),
128 },
129 },
130 description: 'Welcome message',
131 },
132 },
133 }),
134 (c) => {
135 return c.text('Hono + Deno TODO API')
136 },
137 )
138 .openapi(
139 createRoute({
140 method: 'get',
141 path: '/todos',
142 responses: {
143 200: {
144 content: {
145 'application/json': {
146 schema: z.array(Todo),
147 },
148 },
149 description: 'List of todos',
150 },
151 },
152 }),
153 async (c) => {
154 const todos = await getAllTodos()
155 return c.json(todos, 200)
156 },
157 )
158 .openapi(
159 createRoute({
160 method: 'post',
161 path: '/todos',
162 request: {
163 body: {
164 content: {
165 'application/json': {
166 schema: CreateTodoSchema,
167 },
168 },
169 },
170 },
171 responses: {
172 201: {
173 content: {
174 'application/json': {
175 schema: Todo,
176 },
177 },
178 description: 'Create a new todo',
179 },
180 },
181 }),
182 async (c) => {
183 const { title } = c.req.valid('json')
184 const newTodo = await createTodo(title)
185 return c.json(newTodo, 201)
186 },
187 )
188 .openapi(
189 createRoute({
190 method: 'put',
191 path: '/todos/{id}',
192 request: {
193 params: ParamsSchema,
194 body: {
195 content: {
196 'application/json': {
197 schema: UpdateTodoSchema,
198 },
199 },
200 },
201 },
202 responses: {
203 200: {
204 content: {
205 'application/json': {
206 schema: Todo,
207 },
208 },
209 description: 'Update a todo',
210 },
211 404: {
212 content: {
213 'application/json': {
214 schema: ErrorSchema,
215 },
216 },
217 description: 'Todo not found',
218 },
219 },
220 }),
221 async (c) => {
222 const { id } = c.req.valid('param')
223 const updates = c.req.valid('json')
224 const updatedTodo = await updateTodo(id, updates)
225 if (!updatedTodo) {
226 return c.json({ error: 'Todo not found' }, 404)
227 }
228 return c.json(updatedTodo, 200)
229 },
230 )
231 .openapi(
232 createRoute({
233 method: 'delete',
234 path: '/todos/{id}',
235 request: {
236 params: ParamsSchema,
237 },
238 responses: {
239 200: {
240 content: {
241 'application/json': {
242 schema: z.object({
243 message: z.string(),
244 }),
245 },
246 },
247 description: 'Delete a todo',
248 },
249 404: {
250 content: {
251 'application/json': {
252 schema: ErrorSchema,
253 },
254 },
255 description: 'Todo not found',
256 },
257 },
258 }),
259 async (c) => {
260 const { id } = c.req.valid('param')
261 const deleted = await deleteTodo(id)
262 if (!deleted) {
263 return c.json({ error: 'Todo not found' }, 404)
264 }
265 return c.json({ message: 'Todo deleted successfully' }, 200)
266 },
267 )
268 .doc('/doc', {
269 openapi: '3.0.0',
270 info: {
271 version: '1.0.0',
272 title: 'My API',
273 },
274 })
275 .get('/ui', swaggerUI({ url: '/doc' }))
276
277export type AppType = typeof app
278
279Deno.serve(app.fetch)

main_test.ts

1import { assert, assertEquals, assertNotEquals } from '@std/assert'
2import { app } from './main.ts'
3import { testClient } from 'hono/testing'
4
5const client = testClient(app)
6
7const createTodo = async (title: string) => {
8 const res = await client.todos.$post({
9 json: {
10 title: title,
11 },
12 })
13 return await res.json()
14}
15
16const resetTodos = async () => {
17 const res = await client.todos.$get()
18 const todos = await res.json()
19 const deletePromises = todos.map(({ id }) => {
20 return client.todos[':id'].$delete({
21 param: {
22 id: `${id}`,
23 },
24 })
25 })
26 await Promise.all(deletePromises)
27}
28
29Deno.test('GET / returns welcome message', async () => {
30 const res = await client.index.$get()
31 assertEquals(res.status, 200)
32 assertEquals(await res.text(), 'Hono + Deno TODO API')
33})
34
35Deno.test('Post /todos creates a new todo', async () => {
36 await resetTodos()
37 const res = await client.todos.$post({
38 json: {
39 title: 'Test Todo',
40 },
41 })
42 assertEquals(res.status, 201)
43 const todo = await res.json()
44 assertEquals(todo.title, 'Test Todo')
45 assertEquals(todo.completed, false)
46 assertEquals(typeof todo.id, 'number')
47})
48
49Deno.test('GET /todos returns all todos', async () => {
50 await resetTodos()
51 await createTodo('Todo 1')
52 await createTodo('Todo 2')
53
54 const res = await client.todos.$get()
55 assertEquals(res.status, 200)
56 const todos = await res.json()
57 assertEquals(todos.length, 2)
58 assertEquals(todos[0].title, 'Todo 1')
59 assertEquals(todos[1].title, 'Todo 2')
60})
61
62Deno.test('PUT /todos/:id updates a todo', async () => {
63 await resetTodos()
64 const todo = await createTodo('Update Me')
65
66 const updateRes = await client.todos[':id'].$put({
67 param: {
68 id: `${todo.id}`,
69 },
70 json: {
71 title: 'Updated',
72 completed: true,
73 },
74 })
75 assertEquals(updateRes.status, 200)
76
77 const updatedTodo = await updateRes.json()
78 assert(!('error' in updatedTodo), 'Unexpected error response')
79 assertEquals(updatedTodo.id, todo.id)
80 assertEquals(updatedTodo.title, 'Updated')
81 assertEquals(updatedTodo.completed, true)
82
83 const getRes = await client.todos.$get()
84 assertEquals(getRes.status, 200)
85 const fetchedTodos = await getRes.json()
86
87 const fetchedTodo = fetchedTodos.find((t) => t.id === todo.id)
88 assert(fetchedTodo, 'Todo not found')
89
90 assertEquals(fetchedTodo.title, 'Updated')
91 assertEquals(fetchedTodo.completed, true)
92})
93
94Deno.test('DELETE /todos/:id deletes a todo', async () => {
95 await resetTodos()
96 const todo = await createTodo('Delete Me')
97
98 const res = await client.todos[':id'].$delete({
99 param: {
100 id: `${todo.id}`,
101 },
102 })
103
104 assertEquals(res.status, 200)
105 const result = await res.json()
106 assert(!(('error') in result), 'Unexpected error response')
107
108 assertEquals(result.message, 'Todo deleted successfully')
109 const getRes = await client.todos.$get()
110 const todos = await getRes.json()
111 assertEquals(todos.length, 0)
112})
113
114Deno.test('POST /todos with invalid data returns 400', async () => {
115 await resetTodos()
116
117 const res = await client.todos.$post({
118 json: {
119 title: '',
120 },
121 })
122
123 assertEquals(res.status, 400)
124})
125
126Deno.test('PUT /todos/:id with invalid id returns 404', async () => {
127 await resetTodos()
128
129 const res = await client.todos[':id'].$put({
130 param: {
131 id: '999',
132 },
133 json: {
134 title: 'Updated',
135 },
136 })
137
138 assertEquals(res.status, 404)
139})
140
141Deno.test('DELETE /todos/:id with invalid id returns 404', async () => {
142 await resetTodos()
143
144 const res = await client.todos[':id'].$delete({
145 param: {
146 id: '999',
147 },
148 })
149
150 assertEquals(res.status, 404)
151})
152
153Deno.test('PUT /todos/:id with no changes returns the same todo', async () => {
154 await resetTodos()
155 const todo = await createTodo('No Change')
156
157 const res = await client.todos[':id'].$put({
158 param: {
159 id: `${todo.id}`,
160 },
161 json: {},
162 })
163
164 assertEquals(res.status, 200)
165 const updatedTodo = await res.json()
166 assertEquals(updatedTodo, todo)
167})
168
169Deno.test('Todos maintain order after updates and deletions', async () => {
170 await resetTodos()
171 const todo1 = await createTodo('First')
172 const todo2 = await createTodo('Second')
173 await createTodo('Third')
174
175 await client.todos[':id'].$put({
176 param: {
177 id: `${todo2.id}`,
178 },
179 json: {
180 title: 'Updated Second',
181 },
182 })
183 await client.todos[':id'].$delete({
184 param: {
185 id: `${todo1.id}`,
186 },
187 })
188
189 const res = await app.request('/todos')
190 const todos = await res.json()
191
192 assertEquals(todos.length, 2)
193 assertEquals(todos[0].title, 'Updated Second')
194 assertEquals(todos[1].title, 'Third')
195})
196
197Deno.test('Todo IDs are unique and incremental', async () => {
198 await resetTodos()
199 const todo1 = await createTodo('First')
200 const todo2 = await createTodo('Second')
201
202 assertNotEquals(todo1.id, todo2.id)
203 assertEquals(todo2.id, todo1.id + 1)
204})
Comment