A CRUD API for posts
CRUD is the four basic actions on data: Create, Read, Update, Delete. Let us build them for blog posts in one file, using everything from the previous lessons: a route group, zod, HTTPException and the SQLite database.
Routes
| Request | Action | Status code |
|---|---|---|
GET /posts | list of posts | 200 |
GET /posts/:id | one post | 200 or 404 |
POST /posts | create | 201 |
PUT /posts/:id | update | 200 or 404 |
DELETE /posts/:id | delete | 204 or 404 |
The routes file
Replace the contents of src/routes/posts.ts:
// src/routes/posts.ts
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { db, type Post } from '../db.js'
import { postSchema } from '../schemas.js'
const idParam = z.object({ id: z.coerce.number().int().positive() })
function findPost(id: number): Post {
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefined
if (!post) {
throw new HTTPException(404, { message: 'Post not found' })
}
return post
}
const posts = new Hono()
posts.get('/', (c) => {
const rows = db.prepare('SELECT * FROM posts ORDER BY id DESC').all() as Post[]
return c.json(rows)
})
posts.get('/:id', zValidator('param', idParam), (c) => {
const { id } = c.req.valid('param')
return c.json(findPost(id))
})
posts.post('/', zValidator('json', postSchema), (c) => {
const { title, body } = c.req.valid('json')
const result = db
.prepare('INSERT INTO posts (title, body) VALUES (?, ?)')
.run(title, body)
return c.json(findPost(Number(result.lastInsertRowid)), 201)
})
posts.put('/:id', zValidator('param', idParam), zValidator('json', postSchema), (c) => {
const { id } = c.req.valid('param')
const { title, body } = c.req.valid('json')
findPost(id)
db.prepare('UPDATE posts SET title = ?, body = ? WHERE id = ?').run(title, body, id)
return c.json(findPost(id))
})
posts.delete('/:id', zValidator('param', idParam), (c) => {
const { id } = c.req.valid('param')
findPost(id)
db.prepare('DELETE FROM posts WHERE id = ?').run(id)
return c.body(null, 204)
})
export default postsThe findPost() function looks up a post and throws an HTTPException if it does not exist. Thanks to it the 404 check is written once, and app.onError() from the error lesson turns the exception into JSON. The put route has two validators: one for the number in the address and one for the body.
Checking
Create a post:
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title": "Hono and SQLite", "body": "A post from the database"}'The response is the post with its number, date and code 201. Next:
curl http://localhost:3000/posts/1
curl -X PUT http://localhost:3000/posts/1 \
-H "Content-Type: application/json" \
-d '{"title": "New title", "body": "New text"}'
curl -i -X DELETE http://localhost:3000/posts/1The first command returns the post, the second its updated version, the third an empty response with code 204. After deletion GET /posts/1 returns 404. The data survives a server restart.
Comments
The comment routes in src/routes/comments.ts are built the same way: GET /posts/:postId/comments selects rows WHERE post_id = ?, and POST inserts a comment with the post number from the address. Before inserting, check that the post exists — otherwise you get a comment on a post that does not exist.
Common mistake
Sending a 204 response with c.json(null, 204) or with a message body. Code 204 means "no content", and such a response must have an empty body. Use c.body(null, 204), and if you want to return a message, choose code 200.