initial boilerplate
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/yourname/deflated/internal/db"
|
||||
"github.com/yourname/deflated/internal/models"
|
||||
"github.com/yourname/deflated/internal/parser"
|
||||
)
|
||||
|
||||
// handlers holds shared dependencies (database, future: storage client, etc.)
|
||||
// All handler methods live on this struct — no global state.
|
||||
type handlers struct {
|
||||
q *db.Queries
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// respond encodes v as JSON and writes it with the given status code.
|
||||
// This is the only place we write JSON — keeps error handling consistent.
|
||||
func respond(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
// Encoding errors are rare (e.g. non-serializable types).
|
||||
// Log and move on — headers are already sent.
|
||||
http.Error(w, "encoding error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, status int, msg string, details ...string) {
|
||||
e := models.ErrorResponse{Error: msg}
|
||||
if len(details) > 0 {
|
||||
e.Details = details[0]
|
||||
}
|
||||
respond(w, status, e)
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handlers) health(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// POST /api/receipts
|
||||
// Accepts JSON body or multipart form with an optional image file.
|
||||
func (h *handlers) submitReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
// Limit request body to 15 MB (image uploads can be large)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 15<<20)
|
||||
|
||||
var req models.SubmitReceiptRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid JSON", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate the receipt date
|
||||
receiptDate, err := time.Parse("2006-01-02", req.ReceiptDate)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest,
|
||||
"invalid receipt_date, use YYYY-MM-DD format", err.Error())
|
||||
return
|
||||
}
|
||||
if receiptDate.After(time.Now()) {
|
||||
respondError(w, http.StatusBadRequest, "receipt_date cannot be in the future")
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
respondError(w, http.StatusBadRequest, "at least one item is required")
|
||||
return
|
||||
}
|
||||
if len(req.Items) > 200 {
|
||||
respondError(w, http.StatusBadRequest, "maximum 200 items per receipt")
|
||||
return
|
||||
}
|
||||
|
||||
// Build the receipt params
|
||||
params := models.InsertReceiptParams{ReceiptDate: receiptDate}
|
||||
if req.StoreName != "" {
|
||||
params.StoreName = &req.StoreName
|
||||
}
|
||||
if req.City != "" {
|
||||
params.City = &req.City
|
||||
}
|
||||
|
||||
receipt, err := h.q.InsertReceipt(r.Context(), params)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save receipt")
|
||||
return
|
||||
}
|
||||
|
||||
// Insert each line item, normalizing the name to a canonical form
|
||||
var inserted int
|
||||
for _, item := range req.Items {
|
||||
if item.Name == "" || item.Price <= 0 {
|
||||
continue // skip malformed items silently
|
||||
}
|
||||
qty := item.Quantity
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
|
||||
canonical, category := parser.Normalize(item.Name)
|
||||
|
||||
iparams := models.InsertLineItemParams{
|
||||
ReceiptID: receipt.ID,
|
||||
RawName: item.Name,
|
||||
CanonicalName: canonical,
|
||||
Category: category,
|
||||
PriceCents: int(math.Round(item.Price * 100)),
|
||||
Quantity: qty,
|
||||
}
|
||||
if _, err := h.q.InsertLineItem(r.Context(), iparams); err != nil {
|
||||
// Don't abort the whole receipt for one bad item — log and continue
|
||||
fmt.Printf("warn: failed to insert line item %q: %v\n", item.Name, err)
|
||||
continue
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
|
||||
respond(w, http.StatusCreated, models.SubmitReceiptResponse{
|
||||
ReceiptID: receipt.ID,
|
||||
ItemsAdded: inserted,
|
||||
Message: fmt.Sprintf("Receipt saved with %d items", inserted),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/receipts/{id}
|
||||
func (h *handlers) getReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid receipt ID")
|
||||
return
|
||||
}
|
||||
|
||||
receipt, err := h.q.GetReceipt(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "receipt not found")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.q.GetLineItemsByReceipt(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"receipt": receipt,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/items/{name}/history?months=24
|
||||
func (h *handlers) getPriceHistory(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if name == "" {
|
||||
respondError(w, http.StatusBadRequest, "item name is required")
|
||||
return
|
||||
}
|
||||
|
||||
months := 24
|
||||
if m := r.URL.Query().Get("months"); m != "" {
|
||||
if v, err := strconv.Atoi(m); err == nil && v > 0 && v <= 120 {
|
||||
months = v
|
||||
}
|
||||
}
|
||||
|
||||
snapshots, err := h.q.GetPriceHistory(r.Context(), name, months)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert DB rows to the API shape the frontend expects
|
||||
resp := models.PriceHistoryResponse{CanonicalName: name}
|
||||
for _, s := range snapshots {
|
||||
resp.DataPoints = append(resp.DataPoints, models.PriceDataPoint{
|
||||
Month: s.YearMonth.Format("2006-01"),
|
||||
AvgPrice: float64(s.AvgPriceCents) / 100,
|
||||
SampleCount: s.SampleCount,
|
||||
})
|
||||
}
|
||||
|
||||
respond(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/items/movers?limit=10
|
||||
func (h *handlers) getTopMovers(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 10
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 50 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
movers, err := h.q.GetTopMovers(r.Context(), limit)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusOK, map[string]any{"movers": movers})
|
||||
}
|
||||
|
||||
// GET /api/inflation/summary?from=2009-01&to=2025-01
|
||||
func (h *handlers) getInflationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
|
||||
// Default: from Jan 2009 to today
|
||||
from := time.Date(2009, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Now()
|
||||
|
||||
if fromStr != "" {
|
||||
if t, err := time.Parse("2006-01", fromStr); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if toStr != "" {
|
||||
if t, err := time.Parse("2006-01", toStr); err == nil {
|
||||
to = t
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := h.q.GetInflationSummary(r.Context(), from, to)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusOK, summary)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package api wires together the HTTP router and all handlers.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/yourname/deflated/internal/db"
|
||||
)
|
||||
|
||||
// NewRouter builds the full Chi router with middleware and all routes.
|
||||
// This is the only place routes are registered — easy to see the full API shape.
|
||||
func NewRouter(pool *pgxpool.Pool) http.Handler {
|
||||
queries := db.NewQueries(pool)
|
||||
h := &handlers{q: queries}
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// ── Middleware stack ────────────────────────────────────────────────────
|
||||
r.Use(middleware.RequestID) // adds X-Request-Id header
|
||||
r.Use(middleware.RealIP) // reads X-Forwarded-For
|
||||
r.Use(middleware.Logger) // logs every request: method, path, status, latency
|
||||
r.Use(middleware.Recoverer) // catches panics, returns 500 instead of crashing
|
||||
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3000", "https://deflated.fyi"},
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Content-Type"},
|
||||
}))
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────
|
||||
r.Get("/health", h.health)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
// Receipt submission
|
||||
r.Post("/receipts", h.submitReceipt)
|
||||
r.Get("/receipts/{id}", h.getReceipt)
|
||||
|
||||
// Price data for the dashboard
|
||||
r.Get("/items/{name}/history", h.getPriceHistory) // ?months=24
|
||||
r.Get("/items/movers", h.getTopMovers) // ?limit=10
|
||||
|
||||
// The torn dollar bill — purchasing power over time
|
||||
r.Get("/inflation/summary", h.getInflationSummary) // ?from=2009-01&to=2025-01
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user