~bigbes/lethe

ref: f0f651bfc74681988f56bd610074e1dce6dbee1c lethe/internal/domain/search/handler.go -rw-r--r-- 4.1 KiB
f0f651bf — Eugene Blikh feat: add search data layer — adapter, highlight helper, hook, and tests 24 days 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
146
147
148
149
150
151
152
153
154
package search

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"
)

const (
	defaultLimit = 50
	maxLimit     = 200
)

const allOwnersSentinel = "*"

// Handler is the steward-managed HTTP boundary for the search read 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 read route under r. Server.Init mounts this inside
// the /api/v1 group, so the effective path is /api/v1/search.
func (h *Handler) Mount(r chi.Router) {
	r.Get("/search", h.List)
}

// List handles GET /search. It resolves the owner scope, parses query
// parameters, clamps pagination, and writes a search.Result. 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 := Filter{Owner: scope}

	filter.Query = strings.TrimSpace(q.Get("q"))
	if filter.Query == "" {
		apierror.Render(w, r, culpa.WithCode(
			culpa.WithPublic(culpa.New("search query is empty"), "q must not be empty"),
			"INVALID",
		))
		return
	}

	if v := q.Get("include_tool_outputs"); v == "1" {
		filter.IncludeToolOutputs = true
	}
	if v := q.Get("tool"); v != "" {
		filter.Tool = &v
	}
	if v := q.Get("host"); v != "" {
		filter.Host = &v
	}
	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
	}
	if v := q.Get("until"); v != "" {
		n, perr := strconv.ParseInt(v, 10, 64)
		if perr != nil {
			apierror.Render(w, r, culpa.WithCode(
				culpa.WithPublic(culpa.Wrap(perr, "parse until"), "until must be an integer (unix epoch seconds)"),
				"INVALID",
			))
			return
		}
		filter.Until = &n
	}
	if filter.Since != nil && filter.Until != nil && *filter.Since > *filter.Until {
		apierror.Render(w, r, culpa.WithCode(
			culpa.WithPublic(culpa.New("since > until"), "since must be <= until"),
			"INVALID",
		))
		return
	}

	filter.Limit = clampLimit(q.Get("limit"))
	filter.Cursor = q.Get("cursor")

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

	if writeErr := httputil.WriteJSON(w, http.StatusOK, result); writeErr != nil {
		slog.Default().ErrorContext(r.Context(), "write search 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
}