Add Blog Posts CRUD with SQLite

- Add modernc.org/sqlite (pure Go, no CGO)
- Create models package with Post struct
- Implement SQLite connection and schema auto-creation
- Add CRUD methods to database package
- Create post handlers with JSON API
- Register API routes: GET/POST/PUT/DELETE /api/v1/posts
- Set default DBURL to file:./data.db with WAL mode
This commit is contained in:
2025-12-27 12:43:30 +07:00
parent fb347b96df
commit f7ab09c2c3
8 changed files with 474 additions and 10 deletions

View File

@@ -59,7 +59,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
viper.SetDefault("DEV_ADMIN_USERNAME", "")
viper.SetDefault("DEV_ADMIN_PASSWORD", "")
viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", "")
viper.SetDefault("DBURL", "file:./data.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")

View File

@@ -2,10 +2,13 @@ package database
import (
"context"
"database/sql"
"log/slog"
"time"
"git.eeqj.de/sneak/gohttpserver/internal/config"
"git.eeqj.de/sneak/gohttpserver/internal/logger"
"git.eeqj.de/sneak/gohttpserver/internal/models"
"go.uber.org/fx"
// spooky action at a distance!
@@ -16,6 +19,9 @@ import (
// `DBURL=postgres://user:pass@.../`
// (without the backticks, of course)
_ "github.com/joho/godotenv/autoload"
// pure Go SQLite driver
_ "modernc.org/sqlite"
)
type DatabaseParams struct {
@@ -25,7 +31,7 @@ type DatabaseParams struct {
}
type Database struct {
URL string
db *sql.DB
log *slog.Logger
params *DatabaseParams
}
@@ -40,13 +46,161 @@ func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s.log.Info("Database OnStart Hook")
// FIXME connect to db
return nil
return s.connect(ctx)
},
OnStop: func(ctx context.Context) error {
// FIXME disconnect from db
s.log.Info("Database OnStop Hook")
if s.db != nil {
return s.db.Close()
}
return nil
},
})
return s, nil
}
func (s *Database) connect(ctx context.Context) error {
dbURL := s.params.Config.DBURL
if dbURL == "" {
dbURL = "file:./data.db?_journal_mode=WAL"
}
s.log.Info("connecting to database", "url", dbURL)
db, err := sql.Open("sqlite", dbURL)
if err != nil {
s.log.Error("failed to open database", "error", err)
return err
}
if err := db.PingContext(ctx); err != nil {
s.log.Error("failed to ping database", "error", err)
return err
}
s.db = db
s.log.Info("database connected")
return s.createSchema(ctx)
}
func (s *Database) createSchema(ctx context.Context) error {
schema := `
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
author TEXT NOT NULL,
published INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`
_, err := s.db.ExecContext(ctx, schema)
if err != nil {
s.log.Error("failed to create schema", "error", err)
return err
}
s.log.Info("database schema initialized")
return nil
}
func (s *Database) CreatePost(ctx context.Context, req *models.CreatePostRequest) (*models.Post, error) {
now := time.Now()
result, err := s.db.ExecContext(ctx,
`INSERT INTO posts (title, body, author, published, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
req.Title, req.Body, req.Author, req.Published, now, now,
)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return s.GetPost(ctx, id)
}
func (s *Database) GetPost(ctx context.Context, id int64) (*models.Post, error) {
post := &models.Post{}
err := s.db.QueryRowContext(ctx,
`SELECT id, title, body, author, published, created_at, updated_at FROM posts WHERE id = ?`,
id,
).Scan(&post.ID, &post.Title, &post.Body, &post.Author, &post.Published, &post.CreatedAt, &post.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return post, nil
}
func (s *Database) ListPosts(ctx context.Context) ([]*models.Post, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, title, body, author, published, created_at, updated_at FROM posts ORDER BY created_at DESC`,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var posts []*models.Post
for rows.Next() {
post := &models.Post{}
if err := rows.Scan(&post.ID, &post.Title, &post.Body, &post.Author, &post.Published, &post.CreatedAt, &post.UpdatedAt); err != nil {
return nil, err
}
posts = append(posts, post)
}
if err := rows.Err(); err != nil {
return nil, err
}
return posts, nil
}
func (s *Database) UpdatePost(ctx context.Context, id int64, req *models.UpdatePostRequest) (*models.Post, error) {
post, err := s.GetPost(ctx, id)
if err != nil {
return nil, err
}
if post == nil {
return nil, nil
}
if req.Title != nil {
post.Title = *req.Title
}
if req.Body != nil {
post.Body = *req.Body
}
if req.Author != nil {
post.Author = *req.Author
}
if req.Published != nil {
post.Published = *req.Published
}
post.UpdatedAt = time.Now()
_, err = s.db.ExecContext(ctx,
`UPDATE posts SET title = ?, body = ?, author = ?, published = ?, updated_at = ? WHERE id = ?`,
post.Title, post.Body, post.Author, post.Published, post.UpdatedAt, id,
)
if err != nil {
return nil, err
}
return post, nil
}
func (s *Database) DeletePost(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM posts WHERE id = ?`, id)
return err
}

137
internal/handlers/posts.go Normal file
View File

@@ -0,0 +1,137 @@
package handlers
import (
"net/http"
"strconv"
"git.eeqj.de/sneak/gohttpserver/internal/models"
"github.com/go-chi/chi"
)
func (s *Handlers) HandleListPosts() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
posts, err := s.params.Database.ListPosts(r.Context())
if err != nil {
s.log.Error("failed to list posts", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
return
}
if posts == nil {
posts = []*models.Post{}
}
s.respondJSON(w, r, posts, http.StatusOK)
}
}
func (s *Handlers) HandleGetPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid id"}, http.StatusBadRequest)
return
}
post, err := s.params.Database.GetPost(r.Context(), id)
if err != nil {
s.log.Error("failed to get post", "error", err, "id", id)
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
return
}
if post == nil {
s.respondJSON(w, r, map[string]string{"error": "not found"}, http.StatusNotFound)
return
}
s.respondJSON(w, r, post, http.StatusOK)
}
}
func (s *Handlers) HandleCreatePost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req models.CreatePostRequest
if err := s.decodeJSON(w, r, &req); err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid request body"}, http.StatusBadRequest)
return
}
if req.Title == "" || req.Body == "" || req.Author == "" {
s.respondJSON(w, r, map[string]string{"error": "title, body, and author are required"}, http.StatusBadRequest)
return
}
post, err := s.params.Database.CreatePost(r.Context(), &req)
if err != nil {
s.log.Error("failed to create post", "error", err)
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, post, http.StatusCreated)
}
}
func (s *Handlers) HandleUpdatePost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid id"}, http.StatusBadRequest)
return
}
var req models.UpdatePostRequest
if err := s.decodeJSON(w, r, &req); err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid request body"}, http.StatusBadRequest)
return
}
post, err := s.params.Database.UpdatePost(r.Context(), id, &req)
if err != nil {
s.log.Error("failed to update post", "error", err, "id", id)
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
return
}
if post == nil {
s.respondJSON(w, r, map[string]string{"error": "not found"}, http.StatusNotFound)
return
}
s.respondJSON(w, r, post, http.StatusOK)
}
}
func (s *Handlers) HandleDeletePost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
s.respondJSON(w, r, map[string]string{"error": "invalid id"}, http.StatusBadRequest)
return
}
post, err := s.params.Database.GetPost(r.Context(), id)
if err != nil {
s.log.Error("failed to get post for delete", "error", err, "id", id)
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
return
}
if post == nil {
s.respondJSON(w, r, map[string]string{"error": "not found"}, http.StatusNotFound)
return
}
if err := s.params.Database.DeletePost(r.Context(), id); err != nil {
s.log.Error("failed to delete post", "error", err, "id", id)
s.respondJSON(w, r, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
return
}
s.respondJSON(w, r, nil, http.StatusNoContent)
}
}

29
internal/models/models.go Normal file
View File

@@ -0,0 +1,29 @@
package models
import (
"time"
)
type Post struct {
ID int64 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Author string `json:"author"`
Published bool `json:"published"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreatePostRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Author string `json:"author"`
Published bool `json:"published"`
}
type UpdatePostRequest struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
Author *string `json:"author,omitempty"`
Published *bool `json:"published,omitempty"`
}

View File

@@ -63,6 +63,13 @@ func (s *Server) SetupRoutes() {
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/now", s.h.HandleNow())
// Posts CRUD
r.Get("/posts", s.h.HandleListPosts())
r.Post("/posts", s.h.HandleCreatePost())
r.Get("/posts/{id}", s.h.HandleGetPost())
r.Put("/posts/{id}", s.h.HandleUpdatePost())
r.Delete("/posts/{id}", s.h.HandleDeletePost())
})
// if you want to use a general purpose middleware (http.Handler