feat(m1): backend scaffold - entries CRUD, start/stop, auth, migrations
This commit is contained in:
24
internal/handler/auth.go
Normal file
24
internal/handler/auth.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AuthMiddleware returns a middleware that validates the Bearer token.
|
||||
func AuthMiddleware(token string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
const prefix = "Bearer "
|
||||
auth := r.Header.Get("Authorization")
|
||||
if len(auth) < len(prefix) || auth[:len(prefix)] != prefix {
|
||||
writeError(w, http.StatusUnauthorized, "missing or malformed Authorization header")
|
||||
return
|
||||
}
|
||||
if auth[len(prefix):] != token {
|
||||
writeError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
129
internal/handler/entry_handler.go
Normal file
129
internal/handler/entry_handler.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
)
|
||||
|
||||
// EntryHandler serves /api/entries routes.
|
||||
type EntryHandler struct {
|
||||
svc *service.EntryService
|
||||
}
|
||||
|
||||
func NewEntryHandler(svc *service.EntryService) *EntryHandler {
|
||||
return &EntryHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *EntryHandler) Routes(r chi.Router) {
|
||||
r.Post("/entries/start", h.Start)
|
||||
r.Post("/entries/{id}/stop", h.StopByID)
|
||||
r.Get("/entries", h.List)
|
||||
r.Put("/entries/{id}", h.Update)
|
||||
r.Delete("/entries/{id}", h.Delete)
|
||||
}
|
||||
|
||||
// Start POST /api/entries/start
|
||||
func (h *EntryHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
_ = decodeJSON(r, &body) // note is optional
|
||||
|
||||
entry, err := h.svc.Start(r.Context(), body.Note)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrEntryRunning):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrDayAlreadyClosed):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, entry)
|
||||
}
|
||||
|
||||
// StopByID POST /api/entries/{id}/stop
|
||||
func (h *EntryHandler) StopByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
entry, err := h.svc.StopByID(r.Context(), id)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrEntryNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrEntryNotRunning):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
|
||||
// List GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
func (h *EntryHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
if from == "" {
|
||||
from = "0000-01-01"
|
||||
}
|
||||
if to == "" {
|
||||
to = "9999-12-31"
|
||||
}
|
||||
entries, err := h.svc.List(r.Context(), from, to)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if entries == nil {
|
||||
writeJSON(w, http.StatusOK, []struct{}{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// Update PUT /api/entries/{id}
|
||||
func (h *EntryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body struct {
|
||||
StartTime *int64 `json:"start_time"`
|
||||
EndTime *int64 `json:"end_time"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
entry, err := h.svc.Update(r.Context(), id, service.UpdateEntryInput{
|
||||
StartTime: body.StartTime,
|
||||
EndTime: body.EndTime,
|
||||
Note: body.Note,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrEntryNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrCrossesMidnight):
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
}
|
||||
|
||||
// Delete DELETE /api/entries/{id}
|
||||
func (h *EntryHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.Delete(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
20
internal/handler/helpers.go
Normal file
20
internal/handler/helpers.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
33
internal/handler/router.go
Normal file
33
internal/handler/router.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
)
|
||||
|
||||
// NewRouter builds the full HTTP router.
|
||||
func NewRouter(authToken string, entrySvc *service.EntryService) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// Unauthenticated
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Authenticated API
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(authToken))
|
||||
|
||||
entryH := NewEntryHandler(entrySvc)
|
||||
entryH.Routes(r)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user