Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
2.8 KiB
Go
97 lines
2.8 KiB
Go
// Package handlers implements the TravelHub JSON API.
|
|
//
|
|
// Handlers never read a tenant id from the URL, query string, or body. They
|
|
// take it from the authenticated identity, which the Basic auth middleware
|
|
// resolved from the credentials. That keeps "which tenant am I acting as"
|
|
// impossible for a client to influence.
|
|
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/travelswitch/travelhub/backend/internal/auth"
|
|
"github.com/travelswitch/travelhub/backend/internal/repository"
|
|
)
|
|
|
|
// Handler carries the dependencies shared by every endpoint.
|
|
type Handler struct {
|
|
Repo *repository.Repository
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// New builds a Handler.
|
|
func New(repo *repository.Repository, logger *slog.Logger) *Handler {
|
|
return &Handler{Repo: repo, Logger: logger}
|
|
}
|
|
|
|
// page is the envelope for every list endpoint.
|
|
type page[T any] struct {
|
|
Items []T `json:"items"`
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if body != nil {
|
|
json.NewEncoder(w).Encode(body)
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
|
writeJSON(w, status, map[string]string{"error": message})
|
|
}
|
|
|
|
// fail maps a repository error onto a status code, logging only the ones that
|
|
// represent a server fault.
|
|
func (h *Handler) fail(w http.ResponseWriter, err error, context string) {
|
|
switch {
|
|
case errors.Is(err, repository.ErrNotFound):
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
case errors.Is(err, repository.ErrNoSeats):
|
|
writeError(w, http.StatusConflict, "not enough seats available on this flight")
|
|
default:
|
|
h.Logger.Error(context, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
}
|
|
}
|
|
|
|
// identity pulls the authenticated caller out of the request, replying 401 if
|
|
// the route was somehow mounted without the auth middleware.
|
|
func (h *Handler) identity(w http.ResponseWriter, r *http.Request) (auth.Identity, bool) {
|
|
id, err := auth.FromContext(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "authentication required")
|
|
return auth.Identity{}, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// intParam reads a bounded integer query parameter.
|
|
func intParam(r *http.Request, name string, fallback, min, max int) int {
|
|
raw := r.URL.Query().Get(name)
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil || v < min {
|
|
return fallback
|
|
}
|
|
if v > max {
|
|
return max
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Health reports liveness. It is the only endpoint that needs neither
|
|
// credentials nor a tenant.
|
|
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|