tenseleyflow/shithub / c35c88a

Browse files

api/actions_variables: repo+org CRUD

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c35c88af95c202755982043eca5fb2289dc6cdbe
Parents
e458660
Tree
4dcab6c

1 changed file

StatusFile+-
A internal/web/handlers/api/actions_variables.go 279 0
internal/web/handlers/api/actions_variables.goadded
@@ -0,0 +1,279 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"encoding/json"
7
+	"errors"
8
+	"net/http"
9
+	"time"
10
+
11
+	"github.com/go-chi/chi/v5"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/actions/variables"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+// mountActionsVariables registers the S50 §13 part 3 actions
20
+// variables REST surface (repo + org). Variables are plaintext —
21
+// `${{ vars.NAME }}` substitution in workflows — and unlike secrets
22
+// have no encryption wrapper.
23
+//
24
+//	GET    /api/v1/repos/{o}/{r}/actions/variables
25
+//	POST   /api/v1/repos/{o}/{r}/actions/variables
26
+//	GET    /api/v1/repos/{o}/{r}/actions/variables/{name}
27
+//	PATCH  /api/v1/repos/{o}/{r}/actions/variables/{name}
28
+//	DELETE /api/v1/repos/{o}/{r}/actions/variables/{name}
29
+//	GET    /api/v1/orgs/{org}/actions/variables
30
+//	POST   /api/v1/orgs/{org}/actions/variables
31
+//	GET    /api/v1/orgs/{org}/actions/variables/{name}
32
+//	PATCH  /api/v1/orgs/{org}/actions/variables/{name}
33
+//	DELETE /api/v1/orgs/{org}/actions/variables/{name}
34
+//
35
+// Scopes: `repo:read` on GETs, `repo:write` on mutations.
36
+func (h *Handlers) mountActionsVariables(r chi.Router) {
37
+	r.Group(func(r chi.Router) {
38
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
39
+		r.Get("/api/v1/repos/{owner}/{repo}/actions/variables", h.actionsVariablesListRepo)
40
+		r.Get("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", h.actionsVariablesGetRepo)
41
+		r.Get("/api/v1/orgs/{org}/actions/variables", h.actionsVariablesListOrg)
42
+		r.Get("/api/v1/orgs/{org}/actions/variables/{name}", h.actionsVariablesGetOrg)
43
+	})
44
+	r.Group(func(r chi.Router) {
45
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
46
+		r.Post("/api/v1/repos/{owner}/{repo}/actions/variables", h.actionsVariablesCreateRepo)
47
+		r.Patch("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", h.actionsVariablesUpdateRepo)
48
+		r.Delete("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", h.actionsVariablesDeleteRepo)
49
+		r.Post("/api/v1/orgs/{org}/actions/variables", h.actionsVariablesCreateOrg)
50
+		r.Patch("/api/v1/orgs/{org}/actions/variables/{name}", h.actionsVariablesUpdateOrg)
51
+		r.Delete("/api/v1/orgs/{org}/actions/variables/{name}", h.actionsVariablesDeleteOrg)
52
+	})
53
+}
54
+
55
+type variableResponse struct {
56
+	Name      string `json:"name"`
57
+	Value     string `json:"value"`
58
+	CreatedAt string `json:"created_at"`
59
+	UpdatedAt string `json:"updated_at"`
60
+}
61
+
62
+type variableCreateRequest struct {
63
+	Name  string `json:"name"`
64
+	Value string `json:"value"`
65
+}
66
+
67
+type variableUpdateRequest struct {
68
+	Value string `json:"value"`
69
+}
70
+
71
+func presentVariable(v variables.Variable) variableResponse {
72
+	return variableResponse{
73
+		Name:      v.Name,
74
+		Value:     v.Value,
75
+		CreatedAt: v.CreatedAt.Time.UTC().Format(time.RFC3339),
76
+		UpdatedAt: v.UpdatedAt.Time.UTC().Format(time.RFC3339),
77
+	}
78
+}
79
+
80
+// ─── repo scope ─────────────────────────────────────────────────────
81
+
82
+func (h *Handlers) actionsVariablesListRepo(w http.ResponseWriter, r *http.Request) {
83
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
84
+	if !ok {
85
+		return
86
+	}
87
+	rows, err := h.variablesDeps().List(r.Context(), variables.RepoScope(repo.ID))
88
+	if err != nil {
89
+		h.d.Logger.ErrorContext(r.Context(), "api: list repo variables", "error", err)
90
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
91
+		return
92
+	}
93
+	out := make([]variableResponse, 0, len(rows))
94
+	for _, v := range rows {
95
+		out = append(out, presentVariable(v))
96
+	}
97
+	writeJSON(w, http.StatusOK, out)
98
+}
99
+
100
+func (h *Handlers) actionsVariablesGetRepo(w http.ResponseWriter, r *http.Request) {
101
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
102
+	if !ok {
103
+		return
104
+	}
105
+	v, err := h.variablesDeps().Get(r.Context(), variables.RepoScope(repo.ID), chi.URLParam(r, "name"))
106
+	if err != nil {
107
+		writeVariablesError(w, err)
108
+		return
109
+	}
110
+	writeJSON(w, http.StatusOK, presentVariable(v))
111
+}
112
+
113
+func (h *Handlers) actionsVariablesCreateRepo(w http.ResponseWriter, r *http.Request) {
114
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
115
+	if !ok {
116
+		return
117
+	}
118
+	var body variableCreateRequest
119
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil {
120
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
121
+		return
122
+	}
123
+	auth := middleware.PATAuthFromContext(r.Context())
124
+	if err := h.variablesDeps().Set(r.Context(), variables.RepoScope(repo.ID), body.Name, body.Value, auth.UserID); err != nil {
125
+		writeVariablesError(w, err)
126
+		return
127
+	}
128
+	v, err := h.variablesDeps().Get(r.Context(), variables.RepoScope(repo.ID), body.Name)
129
+	if err != nil {
130
+		writeAPIError(w, http.StatusInternalServerError, "reload failed")
131
+		return
132
+	}
133
+	writeJSON(w, http.StatusCreated, presentVariable(v))
134
+}
135
+
136
+func (h *Handlers) actionsVariablesUpdateRepo(w http.ResponseWriter, r *http.Request) {
137
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
138
+	if !ok {
139
+		return
140
+	}
141
+	var body variableUpdateRequest
142
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil {
143
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
144
+		return
145
+	}
146
+	auth := middleware.PATAuthFromContext(r.Context())
147
+	if err := h.variablesDeps().Set(r.Context(), variables.RepoScope(repo.ID), chi.URLParam(r, "name"), body.Value, auth.UserID); err != nil {
148
+		writeVariablesError(w, err)
149
+		return
150
+	}
151
+	v, err := h.variablesDeps().Get(r.Context(), variables.RepoScope(repo.ID), chi.URLParam(r, "name"))
152
+	if err != nil {
153
+		writeAPIError(w, http.StatusInternalServerError, "reload failed")
154
+		return
155
+	}
156
+	writeJSON(w, http.StatusOK, presentVariable(v))
157
+}
158
+
159
+func (h *Handlers) actionsVariablesDeleteRepo(w http.ResponseWriter, r *http.Request) {
160
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
161
+	if !ok {
162
+		return
163
+	}
164
+	if err := h.variablesDeps().Delete(r.Context(), variables.RepoScope(repo.ID), chi.URLParam(r, "name")); err != nil {
165
+		writeVariablesError(w, err)
166
+		return
167
+	}
168
+	w.WriteHeader(http.StatusNoContent)
169
+}
170
+
171
+// ─── org scope ──────────────────────────────────────────────────────
172
+
173
+func (h *Handlers) actionsVariablesListOrg(w http.ResponseWriter, r *http.Request) {
174
+	org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
175
+	if !ok {
176
+		return
177
+	}
178
+	rows, err := h.variablesDeps().List(r.Context(), variables.OrgScope(org.ID))
179
+	if err != nil {
180
+		h.d.Logger.ErrorContext(r.Context(), "api: list org variables", "error", err)
181
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
182
+		return
183
+	}
184
+	out := make([]variableResponse, 0, len(rows))
185
+	for _, v := range rows {
186
+		out = append(out, presentVariable(v))
187
+	}
188
+	writeJSON(w, http.StatusOK, out)
189
+}
190
+
191
+func (h *Handlers) actionsVariablesGetOrg(w http.ResponseWriter, r *http.Request) {
192
+	org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
193
+	if !ok {
194
+		return
195
+	}
196
+	v, err := h.variablesDeps().Get(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name"))
197
+	if err != nil {
198
+		writeVariablesError(w, err)
199
+		return
200
+	}
201
+	writeJSON(w, http.StatusOK, presentVariable(v))
202
+}
203
+
204
+func (h *Handlers) actionsVariablesCreateOrg(w http.ResponseWriter, r *http.Request) {
205
+	org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
206
+	if !ok {
207
+		return
208
+	}
209
+	var body variableCreateRequest
210
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil {
211
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
212
+		return
213
+	}
214
+	auth := middleware.PATAuthFromContext(r.Context())
215
+	if err := h.variablesDeps().Set(r.Context(), variables.OrgScope(org.ID), body.Name, body.Value, auth.UserID); err != nil {
216
+		writeVariablesError(w, err)
217
+		return
218
+	}
219
+	v, err := h.variablesDeps().Get(r.Context(), variables.OrgScope(org.ID), body.Name)
220
+	if err != nil {
221
+		writeAPIError(w, http.StatusInternalServerError, "reload failed")
222
+		return
223
+	}
224
+	writeJSON(w, http.StatusCreated, presentVariable(v))
225
+}
226
+
227
+func (h *Handlers) actionsVariablesUpdateOrg(w http.ResponseWriter, r *http.Request) {
228
+	org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
229
+	if !ok {
230
+		return
231
+	}
232
+	var body variableUpdateRequest
233
+	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&body); err != nil {
234
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
235
+		return
236
+	}
237
+	auth := middleware.PATAuthFromContext(r.Context())
238
+	if err := h.variablesDeps().Set(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name"), body.Value, auth.UserID); err != nil {
239
+		writeVariablesError(w, err)
240
+		return
241
+	}
242
+	v, err := h.variablesDeps().Get(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name"))
243
+	if err != nil {
244
+		writeAPIError(w, http.StatusInternalServerError, "reload failed")
245
+		return
246
+	}
247
+	writeJSON(w, http.StatusOK, presentVariable(v))
248
+}
249
+
250
+func (h *Handlers) actionsVariablesDeleteOrg(w http.ResponseWriter, r *http.Request) {
251
+	org, ok := h.resolveAPIOrg(w, r, chi.URLParam(r, "org"))
252
+	if !ok {
253
+		return
254
+	}
255
+	if err := h.variablesDeps().Delete(r.Context(), variables.OrgScope(org.ID), chi.URLParam(r, "name")); err != nil {
256
+		writeVariablesError(w, err)
257
+		return
258
+	}
259
+	w.WriteHeader(http.StatusNoContent)
260
+}
261
+
262
+// ─── helpers ────────────────────────────────────────────────────────
263
+
264
+func (h *Handlers) variablesDeps() variables.Deps {
265
+	return variables.Deps{Pool: h.d.Pool}
266
+}
267
+
268
+func writeVariablesError(w http.ResponseWriter, err error) {
269
+	switch {
270
+	case errors.Is(err, variables.ErrNotFound):
271
+		writeAPIError(w, http.StatusNotFound, "variable not found")
272
+	case errors.Is(err, variables.ErrInvalidName):
273
+		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
274
+	case errors.Is(err, variables.ErrValueTooLong):
275
+		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
276
+	default:
277
+		writeAPIError(w, http.StatusInternalServerError, "internal error")
278
+	}
279
+}