Skip to content

Commit b3a6f37

Browse files
committed
feat: add kanban example using chi and govalidator
1 parent c459205 commit b3a6f37

7 files changed

Lines changed: 453 additions & 0 deletions

File tree

example/kanban/domain.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"time"
6+
)
7+
8+
// Task represents a task in the Kanban board.
9+
type Task struct {
10+
ID string `json:"id"`
11+
Title string `json:"title"`
12+
Description string `json:"description,omitempty"`
13+
Status string `json:"status"` // E.g., "TODO", "DOING", "DONE"
14+
CreatedAt time.Time `json:"created_at"`
15+
UpdatedAt time.Time `json:"updated_at"`
16+
}
17+
18+
// ==== Standardized Domain Errors ====
19+
// These are errors that originate purely from the domain/business layer.
20+
// They know nothing about HTTP, Status Codes, or RFC 7807.
21+
22+
var (
23+
// ErrTaskNotFound is returned when a task cannot be found in the store.
24+
ErrTaskNotFound = errors.New("task not found in the kanban board")
25+
26+
// ErrInvalidStatus is returned when a task is moved to an invalid board column.
27+
ErrInvalidStatus = errors.New("invalid task status transition")
28+
29+
// ErrTitleCannotBeEmpty is a domain rule.
30+
ErrTitleCannotBeEmpty = errors.New("a task title must have at least 3 characters")
31+
)
32+
33+
// Repository defines how the application interacts with the data store.
34+
// In Clean Architecture, the usecase/service layer interacts with this.
35+
type Repository interface {
36+
Create(task Task) error
37+
GetByID(id string) (Task, error)
38+
Update(task Task) error
39+
Delete(id string) error
40+
List() ([]Task, error)
41+
}
42+
43+
// Service is our primary use-case layer for business logic.
44+
type Service interface {
45+
CreateTask(title, description string) (Task, error)
46+
GetTask(id string) (Task, error)
47+
MoveTask(id string, newStatus string) (Task, error)
48+
ListTasks() ([]Task, error)
49+
}

example/kanban/go.mod

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module github.com/semmidev/problem/example/kanban
2+
3+
go 1.25.0
4+
5+
replace github.com/semmidev/problem => ../../
6+
7+
require (
8+
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
9+
github.com/go-chi/chi/v5 v5.2.5
10+
github.com/google/uuid v1.6.0
11+
github.com/semmidev/problem v0.0.0-00010101000000-000000000000
12+
)

example/kanban/go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
2+
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
3+
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
4+
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
5+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

example/kanban/handler.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"net/http"
7+
8+
"github.com/asaskevich/govalidator"
9+
"github.com/go-chi/chi/v5"
10+
"github.com/semmidev/problem"
11+
)
12+
13+
type KanbanHandler struct {
14+
service Service
15+
}
16+
17+
func NewHandler(service Service) *KanbanHandler {
18+
return &KanbanHandler{service: service}
19+
}
20+
21+
// mapErrorToProblem is the central place where domain errors and infrastructure errors
22+
// are translated into RFC 7807 Problem Details.
23+
func mapErrorToProblem(err error) *problem.Problem {
24+
// 1. Not Found Domain Error
25+
if errors.Is(err, ErrTaskNotFound) {
26+
return problem.Wrap(err, problem.NotFound, problem.WithDetail(err.Error()))
27+
}
28+
// 2. Business Logic Validation Errors
29+
if errors.Is(err, ErrInvalidStatus) || errors.Is(err, ErrTitleCannotBeEmpty) {
30+
return problem.Wrap(err, problem.UnprocessableEntity, problem.WithDetail(err.Error()))
31+
}
32+
33+
// 3. Fallback to 500 Internal Server error for anything unhandled
34+
// In a real app we'd log the original err securely here and mask details to the client
35+
return problem.Wrap(err, problem.InternalServerError, problem.WithDetail("An unexpected internal error occurred."))
36+
}
37+
38+
// writeError Helper
39+
func writeError(w http.ResponseWriter, r *http.Request, err error) {
40+
p := mapErrorToProblem(err)
41+
// Optionally attach instance URI
42+
p.Instance = r.URL.Path
43+
p.Write(w)
44+
}
45+
46+
// ==== Request Models ====
47+
48+
type CreateTaskRequest struct {
49+
Title string `json:"title" valid:"required,stringlength(3|100)"`
50+
Description string `json:"description" valid:"type(string)"`
51+
}
52+
53+
type MoveTaskRequest struct {
54+
Status string `json:"status" valid:"in(TODO|DOING|DONE),required"`
55+
}
56+
57+
// ==== HTTP Handlers ====
58+
59+
func (h *KanbanHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
60+
var req CreateTaskRequest
61+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
62+
problem.Wrap(err, problem.BadRequest, problem.WithDetail("Invalid or malformed JSON payload")).Write(w)
63+
return
64+
}
65+
66+
if _, err := govalidator.ValidateStruct(req); err != nil {
67+
var validationErrors []map[string]string
68+
if errs, ok := err.(govalidator.Errors); ok {
69+
for _, e := range errs {
70+
if valErr, isValErr := e.(govalidator.Error); isValErr {
71+
validationErrors = append(validationErrors, map[string]string{
72+
"field": valErr.Name,
73+
"message": valErr.Err.Error(),
74+
})
75+
} else {
76+
validationErrors = append(validationErrors, map[string]string{"message": e.Error()})
77+
}
78+
}
79+
} else {
80+
validationErrors = append(validationErrors, map[string]string{"message": err.Error()})
81+
}
82+
83+
p := problem.New(
84+
problem.UnprocessableEntity,
85+
problem.WithDetail("request parameters failed validation"),
86+
problem.WithInstance(r.URL.Path),
87+
problem.WithExtension("invalid_params", validationErrors),
88+
)
89+
p.Write(w)
90+
return
91+
}
92+
93+
task, err := h.service.CreateTask(req.Title, req.Description)
94+
if err != nil {
95+
writeError(w, r, err)
96+
return
97+
}
98+
99+
w.Header().Set("Content-Type", "application/json")
100+
w.WriteHeader(http.StatusCreated)
101+
json.NewEncoder(w).Encode(task)
102+
}
103+
104+
func (h *KanbanHandler) GetTask(w http.ResponseWriter, r *http.Request) {
105+
id := chi.URLParam(r, "id")
106+
task, err := h.service.GetTask(id)
107+
if err != nil {
108+
writeError(w, r, err)
109+
return
110+
}
111+
112+
w.Header().Set("Content-Type", "application/json")
113+
json.NewEncoder(w).Encode(task)
114+
}
115+
116+
func (h *KanbanHandler) MoveTask(w http.ResponseWriter, r *http.Request) {
117+
id := chi.URLParam(r, "id")
118+
119+
var req MoveTaskRequest
120+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
121+
problem.Wrap(err, problem.BadRequest, problem.WithDetail("Invalid JSON")).Write(w)
122+
return
123+
}
124+
125+
if _, err := govalidator.ValidateStruct(req); err != nil {
126+
var validationErrors []map[string]string
127+
if errs, ok := err.(govalidator.Errors); ok {
128+
for _, e := range errs {
129+
if valErr, isValErr := e.(govalidator.Error); isValErr {
130+
validationErrors = append(validationErrors, map[string]string{
131+
"field": valErr.Name,
132+
"message": valErr.Err.Error(),
133+
})
134+
} else {
135+
validationErrors = append(validationErrors, map[string]string{"message": e.Error()})
136+
}
137+
}
138+
} else {
139+
validationErrors = append(validationErrors, map[string]string{"message": err.Error()})
140+
}
141+
142+
p := problem.New(
143+
problem.UnprocessableEntity,
144+
problem.WithDetail("validation failed for status update"),
145+
problem.WithExtension("invalid_params", validationErrors),
146+
)
147+
p.Write(w)
148+
return
149+
}
150+
151+
task, err := h.service.MoveTask(id, req.Status)
152+
if err != nil {
153+
writeError(w, r, err)
154+
return
155+
}
156+
157+
w.Header().Set("Content-Type", "application/json")
158+
json.NewEncoder(w).Encode(task)
159+
}
160+
161+
func (h *KanbanHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
162+
tasks, err := h.service.ListTasks()
163+
if err != nil {
164+
writeError(w, r, err)
165+
return
166+
}
167+
168+
w.Header().Set("Content-Type", "application/json")
169+
json.NewEncoder(w).Encode(tasks)
170+
}

example/kanban/main.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"net/http"
6+
7+
"github.com/go-chi/chi/v5"
8+
"github.com/go-chi/chi/v5/middleware"
9+
"github.com/semmidev/problem"
10+
)
11+
12+
func main() {
13+
// 1. Setup Data Store (In-Memory)
14+
repo := NewMemoryRepository()
15+
16+
// 2. Setup Business Logic Layer
17+
service := NewService(repo)
18+
19+
// 3. Setup HTTP Handler
20+
handler := NewHandler(service)
21+
22+
// 4. Setup Router using Chi
23+
r := chi.NewRouter()
24+
r.Use(middleware.RequestID)
25+
r.Use(middleware.Logger)
26+
r.Use(middleware.Recoverer)
27+
28+
// Injecting an application-level panic recovery middleware
29+
// returning an RFC 7807 500 error on panic
30+
r.Use(func(next http.Handler) http.Handler {
31+
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
32+
defer func() {
33+
if rvr := recover(); rvr != nil {
34+
p := problem.New(
35+
problem.InternalServerError,
36+
problem.WithDetail("A critical panic occurred internally."),
37+
problem.WithInstance(req.URL.Path),
38+
)
39+
p.Write(w)
40+
}
41+
}()
42+
next.ServeHTTP(w, req)
43+
})
44+
})
45+
46+
r.Route("/tasks", func(r chi.Router) {
47+
r.Get("/", handler.ListTasks)
48+
r.Post("/", handler.CreateTask)
49+
50+
r.Route("/{id}", func(r chi.Router) {
51+
r.Get("/", handler.GetTask)
52+
r.Put("/status", handler.MoveTask)
53+
})
54+
})
55+
56+
// Setup a standard 404 handler returning Problem details
57+
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
58+
problem.New(
59+
problem.NotFound,
60+
problem.WithDetail("The requested endpoint does not exist."),
61+
problem.WithInstance(req.URL.Path),
62+
).Write(w)
63+
})
64+
65+
// Setup a standard 405 Method Not Allowed
66+
r.MethodNotAllowed(func(w http.ResponseWriter, req *http.Request) {
67+
problem.New(
68+
problem.MethodNotAllowed,
69+
problem.WithDetail("The requested HTTP method is not allowed on this endpoint."),
70+
problem.WithInstance(req.URL.Path),
71+
).Write(w)
72+
})
73+
74+
log.Println("Starting server on :8080...")
75+
if err := http.ListenAndServe(":8080", r); err != nil {
76+
log.Fatal(err)
77+
}
78+
}

example/kanban/repository.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package main
2+
3+
import (
4+
"sync"
5+
)
6+
7+
// memoryRepo implements the domain Repository interface
8+
// using a concurrent-safe in-memory map.
9+
type memoryRepo struct {
10+
tasks map[string]Task
11+
mu sync.RWMutex
12+
}
13+
14+
func NewMemoryRepository() Repository {
15+
return &memoryRepo{
16+
tasks: make(map[string]Task),
17+
}
18+
}
19+
20+
func (r *memoryRepo) Create(task Task) error {
21+
r.mu.Lock()
22+
defer r.mu.Unlock()
23+
r.tasks[task.ID] = task
24+
return nil
25+
}
26+
27+
func (r *memoryRepo) GetByID(id string) (Task, error) {
28+
r.mu.RLock()
29+
defer r.mu.RUnlock()
30+
task, exists := r.tasks[id]
31+
if !exists {
32+
// Return our clear domain error.
33+
return Task{}, ErrTaskNotFound
34+
}
35+
return task, nil
36+
}
37+
38+
func (r *memoryRepo) Update(task Task) error {
39+
r.mu.Lock()
40+
defer r.mu.Unlock()
41+
42+
if _, exists := r.tasks[task.ID]; !exists {
43+
return ErrTaskNotFound
44+
}
45+
46+
r.tasks[task.ID] = task
47+
return nil
48+
}
49+
50+
func (r *memoryRepo) Delete(id string) error {
51+
r.mu.Lock()
52+
defer r.mu.Unlock()
53+
54+
if _, exists := r.tasks[id]; !exists {
55+
return ErrTaskNotFound
56+
}
57+
58+
delete(r.tasks, id)
59+
return nil
60+
}
61+
62+
func (r *memoryRepo) List() ([]Task, error) {
63+
r.mu.RLock()
64+
defer r.mu.RUnlock()
65+
66+
list := make([]Task, 0, len(r.tasks))
67+
for _, t := range r.tasks {
68+
list = append(list, t)
69+
}
70+
return list, nil
71+
}

0 commit comments

Comments
 (0)