agent-orchestrator/backend/internal/httpd/controllers/projects.go

222 lines
7.2 KiB
Go

// Package controllers holds the HTTP-facing controllers for the /api/v1
// surface. Each controller groups one resource's routes, exposes a Register
// method that wires them on a chi.Router, and depends on exactly one
// *Manager interface from ports/inbound.go — never on a store, the LCM, an
// adapter, or any other port. Whether the Manager impl reaches past that
// boundary is its own concern.
//
// In the route-shell PR (#20) every handler is a one-line apispec.NotImplemented
// call: the contract lives in the OpenAPI document (apispec/openapi.yaml), and
// the 501 body returns that document's slice for the route so consumers can
// discover the contract from the endpoint itself. When real handlers land,
// the stub one-liner is replaced with the impl; no per-route planned
// metadata in code ever has to be deleted.
package controllers
import (
"bytes"
"encoding/json"
"errors"
"io"
"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"
"github.com/aoagents/agent-orchestrator/backend/internal/project"
)
// ProjectsController owns the 7 canonical /projects routes. The controller
// depends ONLY on project.Manager — it doesn't know whether the impl reaches
// into the registry, the LCM, an adapter, or all three. Mgr is nil while
// handlers are stubs; the handler-impl PR supplies a real project.Manager.
type ProjectsController struct {
Mgr project.Manager
}
// Register mounts the project routes on the supplied router. Route order
// matters: /projects/reload must register before /projects/{id} for the POST
// verb, otherwise chi would treat "reload" as an {id} match for repair.
//
// Legacy paths that the REST audit dropped are deliberately NOT registered
// here. They surface as 405 (sibling method exists, e.g. PUT /projects/{id})
// or 404 (no sibling). The mapping lives in apispec/openapi.yaml as
// `x-replaces` on the canonical operation so consumers discover the
// migration without leaving the spec.
func (c *ProjectsController) Register(r chi.Router) {
r.Get("/projects", c.list)
r.Post("/projects", c.add)
r.Post("/projects/reload", c.reload) // BEFORE /projects/{id}
r.Get("/projects/{id}", c.get)
r.Patch("/projects/{id}", c.updateConfig)
r.Delete("/projects/{id}", c.remove)
r.Post("/projects/{id}/repair", c.repair)
}
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 {
writeProjectError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"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 project.AddInput
if err := decodeJSON(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 {
writeProjectError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusCreated, map[string]any{"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 {
writeProjectError(w, r, err)
return
}
if got.Status == "degraded" {
envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Degraded})
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"status": got.Status, "project": got.Project})
}
func (c *ProjectsController) updateConfig(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
apispec.NotImplemented(w, r, "PATCH", "/api/v1/projects/{id}")
return
}
if frozen, err := containsFrozenIdentityField(r); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
} else if len(frozen) > 0 {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "IDENTITY_FROZEN", "Identity fields cannot be patched", map[string]any{"fields": frozen})
return
}
var patch project.UpdateConfigInput
if err := decodeJSON(r, &patch); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
p, err := c.Mgr.UpdateConfig(r.Context(), projectID(r), patch)
if err != nil {
writeProjectError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"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 {
writeProjectError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, result)
}
func (c *ProjectsController) repair(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/projects/{id}/repair")
return
}
p, err := c.Mgr.Repair(r.Context(), projectID(r))
if err != nil {
writeProjectError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, map[string]any{"project": p})
}
func (c *ProjectsController) reload(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/projects/reload")
return
}
result, err := c.Mgr.Reload(r.Context())
if err != nil {
writeProjectError(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)
}
func containsFrozenIdentityField(r *http.Request) ([]string, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
r.Body = io.NopCloser(bytes.NewReader(body))
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
var frozen []string
for _, field := range []string{"projectId", "path", "repo", "defaultBranch"} {
if _, ok := raw[field]; ok {
frozen = append(frozen, field)
}
}
return frozen, nil
}
// writeProjectError maps a project.Error to its HTTP status, falling back to
// 500 for an unrecognized kind or a non-project.Error.
func writeProjectError(w http.ResponseWriter, r *http.Request, err error) {
var pe *project.Error
if errors.As(err, &pe) {
status := http.StatusInternalServerError
switch pe.Kind {
case "bad_request":
status = http.StatusBadRequest
case "not_found":
status = http.StatusNotFound
case "conflict":
status = http.StatusConflict
case "not_implemented":
status = http.StatusNotImplemented
case "internal":
status = http.StatusInternalServerError
}
envelope.WriteAPIError(w, r, status, pe.Kind, pe.Code, pe.Message, pe.Details)
return
}
envelope.WriteAPIError(w, r, http.StatusInternalServerError, "internal", "INTERNAL_ERROR", "Internal server error", nil)
}