better api paths

This commit is contained in:
samantha42
2026-03-21 09:25:02 +01:00
parent abc17d92dd
commit 3f203178b2
7 changed files with 166 additions and 84 deletions

View File

@@ -107,20 +107,20 @@ func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest)
return b, nil return b, nil
} }
func (r *BudgetRepo) Update(ctx context.Context, id int, req model.UpdateBudgetRequest) (*model.Budget, error) { func (r *BudgetRepo) Update(ctx context.Context, req model.UpdateBudgetRequest) (*model.Budget, error) {
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
UPDATE budgets UPDATE budgets
SET version=?, amount=?, notes=?, SET version=?, amount=?, notes=?,
updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id=?`, WHERE id=?`,
req.Version, req.Amount, req.Notes, id, req.Version, req.Amount, req.Notes, req.ID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("update budget: %w", err) return nil, fmt.Errorf("update budget: %w", err)
} }
row := r.db.QueryRowContext(ctx, row := r.db.QueryRowContext(ctx,
`SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id) `SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, req.ID)
b, err := scanBudget(row) b, err := scanBudget(row)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch updated budget: %w", err) return nil, fmt.Errorf("fetch updated budget: %w", err)
@@ -128,8 +128,8 @@ func (r *BudgetRepo) Update(ctx context.Context, id int, req model.UpdateBudgetR
return b, nil return b, nil
} }
func (r *BudgetRepo) Delete(ctx context.Context, id int) error { func (r *BudgetRepo) Delete(ctx context.Context, req model.DeleteBudgetRequest) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id = ?`, id) _, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id = ?`, req.ID)
if err != nil { if err != nil {
return fmt.Errorf("delete budget: %w", err) return fmt.Errorf("delete budget: %w", err)
} }

View File

@@ -3,7 +3,6 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv"
"strings" "strings"
"Engine/internal/model" "Engine/internal/model"
@@ -39,12 +38,8 @@ func (h *BudgetHandler) Create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, budget) writeJSON(w, http.StatusCreated, budget)
} }
// PUT /api/v1/budgets/update
func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) { func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req model.UpdateBudgetRequest var req model.UpdateBudgetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body") writeError(w, http.StatusBadRequest, "invalid request body")
@@ -57,7 +52,7 @@ func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) {
return return
} }
budget, err := h.svc.Update(r.Context(), id, req) budget, err := h.svc.Update(r.Context(), req)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -65,13 +60,16 @@ func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, budget) writeJSON(w, http.StatusOK, budget)
} }
// DELETE /api/v1/budgets/delete
func (h *BudgetHandler) Delete(w http.ResponseWriter, r *http.Request) { func (h *BudgetHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id")) var req struct {
if err != nil { ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid id") writeError(w, http.StatusBadRequest, "invalid id")
return return
} }
if err := h.svc.Delete(r.Context(), id); err != nil { if err := h.svc.Delete(r.Context(), req); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }

View File

@@ -20,7 +20,7 @@ func NewReferenceHandler(repo *database.ReferenceRepo) *ReferenceHandler {
// ── Departments ─────────────────────────────────────────────────────────────── // ── Departments ───────────────────────────────────────────────────────────────
// POST /api/v1/departments // POST /api/v1/department/create
// PUT /api/v1/departments/{id} (same body, id from path) // PUT /api/v1/departments/{id} (same body, id from path)
func (h *ReferenceHandler) CreateDepartment(w http.ResponseWriter, r *http.Request) { func (h *ReferenceHandler) CreateDepartment(w http.ResponseWriter, r *http.Request) {
var req database.Department var req database.Department
@@ -47,7 +47,7 @@ func (h *ReferenceHandler) CreateDepartment(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusCreated, dept) writeJSON(w, http.StatusCreated, dept)
} }
// GET /api/v1/departments // GET /api/v1/department/list
func (h *ReferenceHandler) ListDepartments(w http.ResponseWriter, r *http.Request) { func (h *ReferenceHandler) ListDepartments(w http.ResponseWriter, r *http.Request) {
depts, err := h.repo.ListDepartments(r.Context()) depts, err := h.repo.ListDepartments(r.Context())
if err != nil { if err != nil {
@@ -60,8 +60,16 @@ func (h *ReferenceHandler) ListDepartments(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, depts) writeJSON(w, http.StatusOK, depts)
} }
// DELETE /api/v1/departments/{id} // DELETE /api/v1/department/delete
func (h *ReferenceHandler) DeleteDepartment(w http.ResponseWriter, r *http.Request) { func (h *ReferenceHandler) DeleteDepartment(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid body: %v", err))
return
}
id, err := pathID(r, "id") id, err := pathID(r, "id")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid id") writeError(w, http.StatusBadRequest, "invalid id")

View File

@@ -27,6 +27,14 @@ type Department struct {
CostCenter string `json:"cost_center"` CostCenter string `json:"cost_center"`
} }
type GetDepartmentBudget struct {
Code string `json:"code"`
}
type GetDepartmentActual struct {
Code string `json:"code"`
}
type GLAccount struct { type GLAccount struct {
ID int `json:"id"` ID int `json:"id"`
Code string `json:"code"` Code string `json:"code"`
@@ -35,6 +43,14 @@ type GLAccount struct {
FavourHigh bool `json:"favour_high"` FavourHigh bool `json:"favour_high"`
} }
type GetGLAccountBudget struct {
Code string `json:"code"`
}
type GetGLAccountActual struct {
Code string `json:"code"`
}
type Budget struct { type Budget struct {
ID int `json:"id"` ID int `json:"id"`
FiscalYear int `json:"fiscal_year"` FiscalYear int `json:"fiscal_year"`
@@ -94,44 +110,64 @@ func (cBudget *CreateBudgetRequest) Valid() []string {
} }
type UpdateBudgetRequest struct { type UpdateBudgetRequest struct {
ID int `json:"id"` ID int // required, not a pointer
FiscalYear int `json:"fiscal_year"` ChangedBy string // required, not a pointer
FiscalPeriod int `json:"fiscal_period"` FiscalYear *int
Version BudgetVersion `json:"version"` FiscalPeriod *int
DepartmentID int `json:"department_id"` Version *string
GLAccountID int `json:"gl_account_id"` DepartmentID *int
Amount float64 `json:"amount"` GLAccountID *int
Currency string `json:"currency"` Amount *float64
Notes string `json:"notes"` Currency *string
ChangedBy string `json:"created_by"` Notes *string
}
type DeleteBudgetRequest struct {
ID int `json:"id"`
} }
func (uBudget *UpdateBudgetRequest) Valid() []string { func (uBudget *UpdateBudgetRequest) Valid() []string {
var errs []string var errs []string
if uBudget.FiscalYear < 1 || uBudget.FiscalYear > 2200 { // Always required: identity + audit
errs = append(errs, "fiscal_year must be between 1 and 2200") if uBudget.ID == 0 {
} errs = append(errs, "id is required")
if uBudget.FiscalPeriod < 1 || uBudget.FiscalPeriod > 12 {
errs = append(errs, "fiscal_period must be between 1 and 12")
}
if uBudget.Version == "" {
errs = append(errs, "version is required")
}
if uBudget.DepartmentID == 0 {
errs = append(errs, "department_id is required")
}
if uBudget.GLAccountID == 0 {
errs = append(errs, "gl_account_id is required")
}
if uBudget.Amount <= 0 {
errs = append(errs, "amount must be greater than 0")
}
if uBudget.Currency == "" {
errs = append(errs, "currency is required")
} }
if uBudget.ChangedBy == "" { if uBudget.ChangedBy == "" {
errs = append(errs, "created_by is required") errs = append(errs, "changed_by is required")
}
// Validate fields only if they were provided
if uBudget.FiscalYear != nil {
if *uBudget.FiscalYear < 1 || *uBudget.FiscalYear > 2200 {
errs = append(errs, "fiscal_year must be between 1 and 2200")
}
}
if uBudget.FiscalPeriod != nil {
if *uBudget.FiscalPeriod < 1 || *uBudget.FiscalPeriod > 12 {
errs = append(errs, "fiscal_period must be between 1 and 12")
}
}
if uBudget.Amount != nil && *uBudget.Amount <= 0 {
errs = append(errs, "amount must be greater than 0")
}
if uBudget.Version != nil && *uBudget.Version == "" {
errs = append(errs, "version cannot be empty")
}
if uBudget.Currency != nil && *uBudget.Currency == "" {
errs = append(errs, "currency cannot be empty")
}
// At least one field must be set — otherwise there's nothing to do
if uBudget.FiscalYear == nil &&
uBudget.FiscalPeriod == nil &&
uBudget.Version == nil &&
uBudget.DepartmentID == nil &&
uBudget.GLAccountID == nil &&
uBudget.Amount == nil &&
uBudget.Currency == nil &&
uBudget.Notes == nil {
errs = append(errs, "at least one field must be provided to update")
} }
return errs return errs

View File

@@ -19,10 +19,10 @@ func (s *BudgetService) Create(ctx context.Context, req model.CreateBudgetReques
return s.repo.Create(ctx, req) return s.repo.Create(ctx, req)
} }
func (s *BudgetService) Update(ctx context.Context, id int, req model.UpdateBudgetRequest) (*model.Budget, error) { func (s *BudgetService) Update(ctx context.Context, req model.UpdateBudgetRequest) (*model.Budget, error) {
return s.repo.Update(ctx, id, req) return s.repo.Update(ctx, req)
} }
func (s *BudgetService) Delete(ctx context.Context, id int) error { func (s *BudgetService) Delete(ctx context.Context, req model.DeleteBudgetRequest) error {
return s.repo.Delete(ctx, id) return s.repo.Delete(ctx, req)
} }

22
main.go
View File

@@ -49,18 +49,22 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
// Reference endpoints // Reference endpoints
mux.HandleFunc("POST /api/v1/departments", referenceH.CreateDepartment) mux.HandleFunc("POST /api/v1/department/create", referenceH.CreateDepartment)
mux.HandleFunc("GET /api/v1/departments", referenceH.ListDepartments) mux.HandleFunc("DELETE /api/v1/department/delete", referenceH.DeleteDepartment)
mux.HandleFunc("DELETE /api/v1/departments/{id}", referenceH.DeleteDepartment) mux.HandleFunc("GET /api/v1/department/list", referenceH.ListDepartments)
mux.HandleFunc("GET /api/v1/department/bugdet", referenceH.ListDepartments)
mux.HandleFunc("GET /api/v1/department/actual", referenceH.ListDepartments)
mux.HandleFunc("POST /api/v1/gl-accounts", referenceH.CreateGLAccount) mux.HandleFunc("POST /api/v1/gl-account/create", referenceH.CreateGLAccount)
mux.HandleFunc("GET /api/v1/gl-accounts", referenceH.ListGLAccounts) mux.HandleFunc("DELETE /api/v1/gl-accounts/delete", referenceH.DeleteGLAccount)
mux.HandleFunc("DELETE /api/v1/gl-accounts/{id}", referenceH.DeleteGLAccount) mux.HandleFunc("GET /api/v1/gl-account/list", referenceH.ListGLAccounts)
mux.HandleFunc("GET /api/v1/gl-accounts/bugdet", referenceH.ListDepartments)
mux.HandleFunc("GET /api/v1/gl-accounts/actual", referenceH.ListDepartments)
// Budget endpoints // Budget endpoints
mux.HandleFunc("POST /api/v1/budgets", budgetH.Create) mux.HandleFunc("POST /api/v1/budget/create", budgetH.Create)
mux.HandleFunc("PUT /api/v1/budgets/{id}", budgetH.Update) mux.HandleFunc("PUT /api/v1/budgets/update", budgetH.Update)
mux.HandleFunc("DELETE /api/v1/budgets/{id}", budgetH.Delete) mux.HandleFunc("DELETE /api/v1/budgets/delete", budgetH.Delete)
// Actuals + variance // Actuals + variance
mux.HandleFunc("POST /api/v1/actuals/ingest", actualsH.Ingest) mux.HandleFunc("POST /api/v1/actuals/ingest", actualsH.Ingest)

View File

@@ -25,9 +25,9 @@ func newBudgetServer(t *testing.T) *httptest.Server {
h := handler.NewBudgetHandler(service.NewBudgetService(repo)) h := handler.NewBudgetHandler(service.NewBudgetService(repo))
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/budgets", h.Create) mux.HandleFunc("POST /api/v1/budget/create", h.Create)
mux.HandleFunc("PUT /api/v1/budgets/{id}", h.Update) mux.HandleFunc("PUT /api/v1/budgets/update", h.Update)
mux.HandleFunc("DELETE /api/v1/budgets/{id}", h.Delete) mux.HandleFunc("DELETE /api/v1/budgets/delete", h.Delete)
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
@@ -108,40 +108,76 @@ func TestUpdateBudget_OK(t *testing.T) {
srv := newBudgetServer(t) srv := newBudgetServer(t)
client := srv.Client() client := srv.Client()
// Create first // Create a budget to update
resp, err := client.Post(srv.URL+"/api/v1/budgets", "application/json", resp, err := client.Post(
mustJSON(t, validBudget())) srv.URL+"/api/v1/budget/create",
"application/json",
mustJSON(t, validBudget()),
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close()
var created model.Budget var created model.Budget
json.NewDecoder(resp.Body).Decode(&created) if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
resp.Body.Close() t.Fatalf("decode created budget: %v", err)
}
if created.ID == 0 {
t.Fatal("expected a non-zero ID from create")
}
// Update amount // Update only the fields the endpoint is meant to change
updated := validBudget() wantAmount := 9999.99
updated["amount"] = 9999.99 wantNotes := "updated in test"
updateReq := model.UpdateBudgetRequest{
ID: created.ID,
Amount: &wantAmount,
Notes: &wantNotes,
ChangedBy: created.CreatedBy,
}
req, _ := http.NewRequest(http.MethodPut, req, err := http.NewRequest(
fmt.Sprintf("%s/api/v1/budgets/%d", srv.URL, created.ID), http.MethodPut,
mustJSON(t, updated)) srv.URL+"/api/v1/budget/update",
mustJSON(t, updateReq),
)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp2, err := client.Do(req) resp2, err := client.Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp2.Body.Close() defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK && resp2.StatusCode != http.StatusNoContent { if resp2.StatusCode != http.StatusOK {
t.Errorf("update: got %d, want 200 or 204", resp2.StatusCode) t.Fatalf("update: got %d, want 200", resp2.StatusCode)
} }
if resp2.StatusCode == http.StatusOK { var got model.Budget
var got model.Budget if err := json.NewDecoder(resp2.Body).Decode(&got); err != nil {
json.NewDecoder(resp2.Body).Decode(&got) t.Fatalf("decode updated budget: %v", err)
if got.Amount != 9999.99 { }
t.Errorf("updated amount: got %v, want 9999.99", got.Amount)
} if got.Amount != wantAmount {
t.Errorf("amount: got %v, want %v", got.Amount, wantAmount)
}
if got.Notes != wantNotes {
t.Errorf("notes: got %q, want %q", got.Notes, wantNotes)
}
if got.ID != created.ID {
t.Errorf("ID changed after update: got %v, want %v", got.ID, created.ID)
}
// Fields not included in the update should be unchanged
if got.FiscalYear != created.FiscalYear {
t.Errorf("fiscal_year changed unexpectedly: got %v, want %v", got.FiscalYear, created.FiscalYear)
}
if got.DepartmentID != created.DepartmentID {
t.Errorf("department_id changed unexpectedly: got %v, want %v", got.DepartmentID, created.DepartmentID)
} }
} }