~bigbes/lethe

ref: c9442a6b8a6caf8bf82ba241ce2c464eb2efa586 lethe/internal/domain/project/handler.go -rw-r--r-- 3.9 KiB
c9442a6b — Eugene Blikh docs(lethe-web-ui-aggregates): record execute, verify, review a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
package project

import (
	"context"
	"log/slog"
	"net/http"
	"strconv"
	"strings"

	"github.com/go-chi/chi/v5"
	"go.bigb.es/auxilia/culpa"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// Pagination knobs mirrored from the session handler spec.
const (
	defaultLimit = 50
	maxLimit     = 200
)

// allOwnersSentinel is the ?owner=* value that an admin uses to read across
// all owners. Identical to the session handler's sentinel.
const allOwnersSentinel = "*"

// Handler is the steward-managed HTTP boundary for the project aggregation
// API. Repo is the injected SQL steward; the Handler holds no other state.
type Handler struct {
	Repo *Repository `inject:""`
}

// Init satisfies the steward Initer contract.
func (h *Handler) Init(_ context.Context) error { return nil }

// Mount registers the single read route under r. Server.Init mounts this
// inside the /api/v1 group, so the effective path is /api/v1/projects.
func (h *Handler) Mount(r chi.Router) {
	r.Get("/projects", h.List)
}

// listResponse is the JSON body returned by List.
type listResponse struct {
	Projects []Project `json:"projects"`
	Limit    int       `json:"limit"`
	Offset   int       `json:"offset"`
}

// List handles GET /projects. It resolves the owner scope (admin gating on
// ?owner=), parses optional filters, clamps pagination, and writes a
// listResponse. Errors surface through apierror.Render as RFC 7807.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
	scope, err := h.resolveScope(r)
	if err != nil {
		apierror.Render(w, r, err)
		return
	}

	q := r.URL.Query()
	filter := ListFilter{Owner: scope}

	if v := q.Get("since"); v != "" {
		n, perr := strconv.ParseInt(v, 10, 64)
		if perr != nil {
			apierror.Render(w, r, culpa.WithCode(
				culpa.WithPublic(culpa.Wrap(perr, "parse since"), "since must be an integer (unix epoch seconds)"),
				"INVALID",
			))
			return
		}
		filter.Since = &n
	}

	filter.Limit = clampLimit(q.Get("limit"))
	filter.Offset = clampOffset(q.Get("offset"))

	rows, err := h.Repo.List(r.Context(), filter)
	if err != nil {
		apierror.Render(w, r, err)
		return
	}

	if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{
		Projects: rows,
		Limit:    filter.Limit,
		Offset:   filter.Offset,
	}); writeErr != nil {
		slog.Default().ErrorContext(r.Context(), "write projects response", scribe.Err(writeErr))
	}
}

// resolveScope reads the authenticated identity off the context and the
// optional ?owner= query parameter, then returns the appropriate
// session.OwnerScope. Non-admin requests with ?owner= set are 403.
func (h *Handler) resolveScope(r *http.Request) (session.OwnerScope, error) {
	id := auth.MustIdentity(r.Context())
	param := r.URL.Query().Get("owner")
	if param == "" {
		return session.OwnerScope{User: id.User}, nil
	}
	if !id.IsAdmin {
		return session.OwnerScope{}, culpa.WithCode(
			culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"),
			"FORBIDDEN",
		)
	}
	if param == allOwnersSentinel {
		return session.OwnerScope{User: id.User, AllOwners: true}, nil
	}
	owner := strings.ToLower(param)
	return session.OwnerScope{User: id.User, SpecificOwner: &owner}, nil
}

// clampLimit returns the effective limit: defaultLimit when missing,
// non-numeric, or negative; capped at maxLimit when the parsed value
// exceeds it.
func clampLimit(raw string) int {
	if raw == "" {
		return defaultLimit
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n < 0 {
		return defaultLimit
	}
	if n > maxLimit {
		return maxLimit
	}
	return n
}

// clampOffset returns the effective offset: 0 when missing, non-numeric,
// or negative.
func clampOffset(raw string) int {
	if raw == "" {
		return 0
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n < 0 {
		return 0
	}
	return n
}