// Package controllers holds the HTTP-facing controllers for the /api/v1 // surface. Each controller groups one resource's routes, exposes a Register // method, and depends on exactly one resource-level Manager interface — never // directly on stores, lifecycle reducers, or adapters. package controllers import ( "encoding/json" "net/http" "github.com/go-chi/chi/v5" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" ) // ProjectsController owns the /projects routes. The controller depends only on // projectsvc.Manager; nil keeps routes registered but returns OpenAPI-backed 501s. type ProjectsController struct { Mgr projectsvc.Manager } // Register mounts the project routes on the supplied router. func (c *ProjectsController) Register(r chi.Router) { r.Get("/projects", c.list) r.Post("/projects", c.add) r.Get("/projects/{id}", c.get) r.Put("/projects/{id}/config", c.setConfig) r.Delete("/projects/{id}", c.remove) } func (c *ProjectsController) list(w http.ResponseWriter, r *http.Request) { if c.Mgr == nil { apispec.NotImplemented(w, r, "GET", "/api/v1/projects") return } projects, err := c.Mgr.List(r.Context()) if err != nil { envelope.WriteError(w, r, err) return } if projects == nil { projects = []projectsvc.Summary{} } envelope.WriteJSON(w, http.StatusOK, ListProjectsResponse{Projects: projects}) } func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) { if c.Mgr == nil { apispec.NotImplemented(w, r, "POST", "/api/v1/projects") return } var in projectsvc.AddInput if err := decodeJSONStrict(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return } p, err := c.Mgr.Add(r.Context(), in) if err != nil { envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusCreated, ProjectResponse{Project: p}) } func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { if c.Mgr == nil { apispec.NotImplemented(w, r, "GET", "/api/v1/projects/{id}") return } got, err := c.Mgr.Get(r.Context(), projectID(r)) if err != nil { envelope.WriteError(w, r, err) return } resp, err := newGetProjectResponse(got) if err != nil { envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil) return } envelope.WriteJSON(w, http.StatusOK, resp) } func (c *ProjectsController) setConfig(w http.ResponseWriter, r *http.Request) { if c.Mgr == nil { apispec.NotImplemented(w, r, "PUT", "/api/v1/projects/{id}/config") return } var in projectsvc.SetConfigInput if err := decodeJSONStrict(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return } p, err := c.Mgr.SetConfig(r.Context(), projectID(r), in) if err != nil { envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, ProjectResponse{Project: p}) } func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) { if c.Mgr == nil { apispec.NotImplemented(w, r, "DELETE", "/api/v1/projects/{id}") return } result, err := c.Mgr.Remove(r.Context(), projectID(r)) if err != nil { envelope.WriteError(w, r, err) return } envelope.WriteJSON(w, http.StatusOK, result) } func projectID(r *http.Request) domain.ProjectID { return domain.ProjectID(chi.URLParam(r, "id")) } func decodeJSON(r *http.Request, out any) error { return json.NewDecoder(r.Body).Decode(out) } // decodeJSONStrict rejects request bodies that include keys outside the target // type. Used on project add/set-config so a misspelled or removed config field // surfaces as a 400 instead of being silently dropped. func decodeJSONStrict(r *http.Request, out any) error { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() return dec.Decode(out) }