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
}
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, `
UPDATE budgets
SET version=?, amount=?, notes=?,
updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id=?`,
req.Version, req.Amount, req.Notes, id,
req.Version, req.Amount, req.Notes, req.ID,
)
if err != nil {
return nil, fmt.Errorf("update budget: %w", err)
}
row := r.db.QueryRowContext(ctx,
`SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id)
`SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, req.ID)
b, err := scanBudget(row)
if err != nil {
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
}
func (r *BudgetRepo) Delete(ctx context.Context, id int) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id = ?`, id)
func (r *BudgetRepo) Delete(ctx context.Context, req model.DeleteBudgetRequest) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id = ?`, req.ID)
if err != nil {
return fmt.Errorf("delete budget: %w", err)
}

View File

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

View File

@@ -20,7 +20,7 @@ func NewReferenceHandler(repo *database.ReferenceRepo) *ReferenceHandler {
// ── Departments ───────────────────────────────────────────────────────────────
// POST /api/v1/departments
// POST /api/v1/department/create
// PUT /api/v1/departments/{id} (same body, id from path)
func (h *ReferenceHandler) CreateDepartment(w http.ResponseWriter, r *http.Request) {
var req database.Department
@@ -47,7 +47,7 @@ func (h *ReferenceHandler) CreateDepartment(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusCreated, dept)
}
// GET /api/v1/departments
// GET /api/v1/department/list
func (h *ReferenceHandler) ListDepartments(w http.ResponseWriter, r *http.Request) {
depts, err := h.repo.ListDepartments(r.Context())
if err != nil {
@@ -60,8 +60,16 @@ func (h *ReferenceHandler) ListDepartments(w http.ResponseWriter, r *http.Reques
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) {
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")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")

View File

@@ -27,6 +27,14 @@ type Department struct {
CostCenter string `json:"cost_center"`
}
type GetDepartmentBudget struct {
Code string `json:"code"`
}
type GetDepartmentActual struct {
Code string `json:"code"`
}
type GLAccount struct {
ID int `json:"id"`
Code string `json:"code"`
@@ -35,6 +43,14 @@ type GLAccount struct {
FavourHigh bool `json:"favour_high"`
}
type GetGLAccountBudget struct {
Code string `json:"code"`
}
type GetGLAccountActual struct {
Code string `json:"code"`
}
type Budget struct {
ID int `json:"id"`
FiscalYear int `json:"fiscal_year"`
@@ -94,44 +110,64 @@ func (cBudget *CreateBudgetRequest) Valid() []string {
}
type UpdateBudgetRequest struct {
ID int `json:"id"`
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
Version BudgetVersion `json:"version"`
DepartmentID int `json:"department_id"`
GLAccountID int `json:"gl_account_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Notes string `json:"notes"`
ChangedBy string `json:"created_by"`
ID int // required, not a pointer
ChangedBy string // required, not a pointer
FiscalYear *int
FiscalPeriod *int
Version *string
DepartmentID *int
GLAccountID *int
Amount *float64
Currency *string
Notes *string
}
type DeleteBudgetRequest struct {
ID int `json:"id"`
}
func (uBudget *UpdateBudgetRequest) Valid() []string {
var errs []string
if uBudget.FiscalYear < 1 || uBudget.FiscalYear > 2200 {
errs = append(errs, "fiscal_year must be between 1 and 2200")
}
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")
// Always required: identity + audit
if uBudget.ID == 0 {
errs = append(errs, "id is required")
}
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

View File

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

22
main.go
View File

@@ -49,18 +49,22 @@ func main() {
mux := http.NewServeMux()
// Reference endpoints
mux.HandleFunc("POST /api/v1/departments", referenceH.CreateDepartment)
mux.HandleFunc("GET /api/v1/departments", referenceH.ListDepartments)
mux.HandleFunc("DELETE /api/v1/departments/{id}", referenceH.DeleteDepartment)
mux.HandleFunc("POST /api/v1/department/create", referenceH.CreateDepartment)
mux.HandleFunc("DELETE /api/v1/department/delete", 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("GET /api/v1/gl-accounts", referenceH.ListGLAccounts)
mux.HandleFunc("DELETE /api/v1/gl-accounts/{id}", referenceH.DeleteGLAccount)
mux.HandleFunc("POST /api/v1/gl-account/create", referenceH.CreateGLAccount)
mux.HandleFunc("DELETE /api/v1/gl-accounts/delete", 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
mux.HandleFunc("POST /api/v1/budgets", budgetH.Create)
mux.HandleFunc("PUT /api/v1/budgets/{id}", budgetH.Update)
mux.HandleFunc("DELETE /api/v1/budgets/{id}", budgetH.Delete)
mux.HandleFunc("POST /api/v1/budget/create", budgetH.Create)
mux.HandleFunc("PUT /api/v1/budgets/update", budgetH.Update)
mux.HandleFunc("DELETE /api/v1/budgets/delete", budgetH.Delete)
// Actuals + variance
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))
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/budgets", h.Create)
mux.HandleFunc("PUT /api/v1/budgets/{id}", h.Update)
mux.HandleFunc("DELETE /api/v1/budgets/{id}", h.Delete)
mux.HandleFunc("POST /api/v1/budget/create", h.Create)
mux.HandleFunc("PUT /api/v1/budgets/update", h.Update)
mux.HandleFunc("DELETE /api/v1/budgets/delete", h.Delete)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
@@ -108,40 +108,76 @@ func TestUpdateBudget_OK(t *testing.T) {
srv := newBudgetServer(t)
client := srv.Client()
// Create first
resp, err := client.Post(srv.URL+"/api/v1/budgets", "application/json",
mustJSON(t, validBudget()))
// Create a budget to update
resp, err := client.Post(
srv.URL+"/api/v1/budget/create",
"application/json",
mustJSON(t, validBudget()),
)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var created model.Budget
json.NewDecoder(resp.Body).Decode(&created)
resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
t.Fatalf("decode created budget: %v", err)
}
if created.ID == 0 {
t.Fatal("expected a non-zero ID from create")
}
// Update amount
updated := validBudget()
updated["amount"] = 9999.99
// Update only the fields the endpoint is meant to change
wantAmount := 9999.99
wantNotes := "updated in test"
updateReq := model.UpdateBudgetRequest{
ID: created.ID,
Amount: &wantAmount,
Notes: &wantNotes,
ChangedBy: created.CreatedBy,
}
req, _ := http.NewRequest(http.MethodPut,
fmt.Sprintf("%s/api/v1/budgets/%d", srv.URL, created.ID),
mustJSON(t, updated))
req, err := http.NewRequest(
http.MethodPut,
srv.URL+"/api/v1/budget/update",
mustJSON(t, updateReq),
)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp2, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK && resp2.StatusCode != http.StatusNoContent {
t.Errorf("update: got %d, want 200 or 204", resp2.StatusCode)
if resp2.StatusCode != http.StatusOK {
t.Fatalf("update: got %d, want 200", resp2.StatusCode)
}
if resp2.StatusCode == http.StatusOK {
var got model.Budget
json.NewDecoder(resp2.Body).Decode(&got)
if got.Amount != 9999.99 {
t.Errorf("updated amount: got %v, want 9999.99", got.Amount)
}
var got model.Budget
if err := json.NewDecoder(resp2.Body).Decode(&got); err != nil {
t.Fatalf("decode updated budget: %v", err)
}
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)
}
}