400 lines
16 KiB
Go
400 lines
16 KiB
Go
// Package specgen builds the code-first OpenAPI document from the Go contract
|
|
// types. It lives outside apispec because it imports the controllers (to
|
|
// reflect their request/response shapes), and controllers import apispec (for
|
|
// the 501 stub) — keeping Build here breaks that cycle. apispec only embeds and
|
|
// serves the committed openapi.yaml; specgen produces it.
|
|
package specgen
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
|
|
jsonschema "github.com/swaggest/jsonschema-go"
|
|
openapi "github.com/swaggest/openapi-go"
|
|
"github.com/swaggest/openapi-go/openapi31"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
|
|
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
|
)
|
|
|
|
// Build reflects the Go contract types and the operation registry below into
|
|
// the OpenAPI document. It is the single source of truth for the /api/v1
|
|
// contract: `cmd/genspec` writes its output to apispec/openapi.yaml (the
|
|
// committed, embedded artifact) and TestBuild_MatchesEmbedded asserts the embed
|
|
// equals fresh Build() output so the two can never drift. Schema facets live as
|
|
// struct tags on the service.*/controllers.* types; operation metadata (path,
|
|
// status codes, summaries) lives here.
|
|
//
|
|
// Every wire shape is reflected straight from where it is used at runtime — the
|
|
// request bodies, path params, and response envelopes from controllers, the
|
|
// error envelope from httpd/envelope — so the served responses and the
|
|
// generated schema share one definition each.
|
|
func Build() ([]byte, error) {
|
|
r := openapi31.NewReflector()
|
|
// Derive `required` from the idiomatic Go convention: a JSON field without
|
|
// `omitempty` is required. swaggest does not infer this on its own, so the
|
|
// structs stay clean (only description/enum tags) and this hook adds the
|
|
// required array. nonNullableSlices drops the spurious "null" type swaggest
|
|
// stamps on every Go slice.
|
|
r.DefaultOptions = append(r.DefaultOptions,
|
|
jsonschema.InterceptProp(requiredFromJSONTag),
|
|
jsonschema.InterceptNullability(nonNullableSlices),
|
|
// Clean component schema names (which become the generated TS type names):
|
|
// swaggest defaults to PackageType, e.g. "ProjectProject", "EnvelopeAPIError".
|
|
jsonschema.InterceptDefName(schemaName),
|
|
)
|
|
|
|
r.Spec.SetTitle("Agent Orchestrator HTTP daemon")
|
|
r.Spec.SetVersion("0.1.0-route-shell")
|
|
r.Spec.SetDescription("Loopback-only HTTP surface served by the Go daemon. " +
|
|
"Generated from Go (code-first) — do not edit by hand; run `go generate ./...`.")
|
|
r.Spec.Servers = []openapi31.Server{
|
|
*(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"),
|
|
}
|
|
r.Spec.Tags = []openapi31.Tag{
|
|
*(&openapi31.Tag{Name: "projects"}).WithDescription(
|
|
"Project registry, configuration, and lifecycle administration"),
|
|
*(&openapi31.Tag{Name: "sessions"}).WithDescription(
|
|
"Agent session lifecycle and messaging"),
|
|
*(&openapi31.Tag{Name: "prs"}).WithDescription(
|
|
"Pull-request actions (SCM lane)"),
|
|
}
|
|
|
|
for _, op := range operations() {
|
|
oc, err := r.NewOperationContext(op.method, op.path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new operation %s %s: %w", op.method, op.path, err)
|
|
}
|
|
oc.SetID(op.id)
|
|
oc.SetSummary(op.summary)
|
|
oc.SetTags(op.tag)
|
|
for _, param := range op.pathParams {
|
|
oc.AddReqStructure(param)
|
|
}
|
|
if op.reqBody != nil {
|
|
// AddReqStructure leaves requestBody.required absent, which
|
|
// OpenAPI reads as optional. These bodies are mandatory, so force
|
|
// it — otherwise validators/generators treat the body as skippable.
|
|
oc.AddReqStructure(op.reqBody, openapi.WithCustomize(markRequestBodyRequired))
|
|
}
|
|
for _, resp := range op.resps {
|
|
oc.AddRespStructure(resp.body, openapi.WithHTTPStatus(resp.status))
|
|
}
|
|
if err := r.AddOperation(oc); err != nil {
|
|
return nil, fmt.Errorf("add operation %s %s: %w", op.method, op.path, err)
|
|
}
|
|
}
|
|
|
|
return r.Spec.MarshalYAML()
|
|
}
|
|
|
|
// schemaName maps swaggest's default PackageType component names (e.g.
|
|
// "ProjectProject", "EnvelopeAPIError") to the clean, stable schema names that
|
|
// become the generated TypeScript type names. Every reflected type is listed
|
|
// explicitly: an unrecognised default name is returned verbatim, so a new type
|
|
// surfaces as a visibly-wrong "PackageType" name in the diff (and the drift
|
|
// test) rather than silently colliding with an existing schema via a
|
|
// TrimPrefix catch-all.
|
|
func schemaName(_ reflect.Type, defaultName string) string {
|
|
if clean, ok := schemaNames[defaultName]; ok {
|
|
return clean
|
|
}
|
|
return defaultName
|
|
}
|
|
|
|
// schemaNames is the exhaustive default→clean mapping for every type reflected
|
|
// by projectOperations(). Add an entry when a new contract type is introduced;
|
|
// the drift test fails until the spec is regenerated, which flags the gap.
|
|
var schemaNames = map[string]string{
|
|
// httpd/envelope
|
|
"EnvelopeAPIError": "APIError",
|
|
// domain
|
|
"DomainProjectID": "ProjectID",
|
|
"DomainSessionID": "SessionID",
|
|
"DomainIssueID": "IssueID",
|
|
"DomainSession": "Session",
|
|
// httpd/controllers (wire envelopes)
|
|
"ControllersListProjectsResponse": "ListProjectsResponse",
|
|
"ControllersProjectResponse": "ProjectResponse",
|
|
"ControllersGetProjectResponse": "ProjectGetResponse",
|
|
"ControllersProjectOrDegraded": "ProjectOrDegraded",
|
|
"ControllersListSessionsQuery": "ListSessionsQuery",
|
|
"ControllersListSessionsResponse": "ListSessionsResponse",
|
|
"ControllersSpawnSessionRequest": "SpawnSessionRequest",
|
|
"ControllersSessionResponse": "SessionResponse",
|
|
"ControllersRenameSessionRequest": "RenameSessionRequest",
|
|
"ControllersRestoreSessionResponse": "RestoreSessionResponse",
|
|
"ControllersKillSessionResponse": "KillSessionResponse",
|
|
"ControllersSendSessionMessageRequest": "SendSessionMessageRequest",
|
|
"ControllersSendSessionMessageResponse": "SendSessionMessageResponse",
|
|
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
|
|
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
|
|
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
|
// httpd/controllers — PR wire envelopes
|
|
"ControllersMergePRResponse": "MergePRResponse",
|
|
"ControllersResolveCommentsRequest": "ResolveCommentsRequest",
|
|
"ControllersResolveCommentsResponse": "ResolveCommentsResponse",
|
|
// service/project entities + DTOs
|
|
"ProjectProject": "Project",
|
|
"ProjectSummary": "ProjectSummary",
|
|
"ProjectDegraded": "DegradedProject",
|
|
"ProjectAddInput": "AddProjectInput",
|
|
"ProjectRemoveResult": "RemoveProjectResult",
|
|
"ProjectTrackerConfig": "TrackerConfig",
|
|
"ProjectSCMConfig": "SCMConfig",
|
|
"ProjectSCMWebhookConfig": "SCMWebhookConfig",
|
|
}
|
|
|
|
// markRequestBodyRequired sets requestBody.required: true on the operation's
|
|
// JSON body. swaggest leaves it absent (== optional) for AddReqStructure bodies.
|
|
func markRequestBodyRequired(cor openapi.ContentOrReference) {
|
|
if rb, ok := cor.(*openapi31.RequestBodyOrReference); ok && rb.RequestBody != nil {
|
|
rb.RequestBody.WithRequired(true)
|
|
}
|
|
}
|
|
|
|
// nonNullableSlices drops the "null" that swaggest unions into every Go slice
|
|
// type (a nil slice marshals as JSON null). A required array field should be
|
|
// `T[]`, not `T[] | null`; the handlers normalise nil to an empty slice, so
|
|
// null never reaches the wire. Byte slices (base64 strings) are left alone.
|
|
func nonNullableSlices(p jsonschema.InterceptNullabilityParams) {
|
|
if !p.NullAdded || p.Type == nil || p.Type.Kind() != reflect.Slice {
|
|
return
|
|
}
|
|
if p.Type.Elem().Kind() == reflect.Uint8 {
|
|
return
|
|
}
|
|
p.Schema.TypeEns().WithSimpleTypes(jsonschema.Array)
|
|
p.Schema.Type.SliceOfSimpleTypeValues = nil
|
|
}
|
|
|
|
// requiredFromJSONTag marks a property required when its json tag lacks
|
|
// `omitempty` (the Go convention for "always present"). Runs after default
|
|
// processing so ParentSchema exists; skips fields without a json tag (e.g. path
|
|
// params, which swaggest marks required on their own).
|
|
func requiredFromJSONTag(p jsonschema.InterceptPropParams) error {
|
|
if !p.Processed || p.ParentSchema == nil {
|
|
return nil
|
|
}
|
|
jsonTag := p.Field.Tag.Get("json")
|
|
if jsonTag == "" || jsonTag == "-" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(jsonTag, ",")
|
|
name := parts[0]
|
|
if name == "" {
|
|
name = p.Name
|
|
}
|
|
for _, opt := range parts[1:] {
|
|
if opt == "omitempty" {
|
|
return nil
|
|
}
|
|
}
|
|
for _, existing := range p.ParentSchema.Required {
|
|
if existing == name {
|
|
return nil
|
|
}
|
|
}
|
|
p.ParentSchema.Required = append(p.ParentSchema.Required, name)
|
|
return nil
|
|
}
|
|
|
|
// --- operation registry -----------------------------------------------------
|
|
|
|
type respUnit struct {
|
|
status int
|
|
body any
|
|
}
|
|
|
|
type operation struct {
|
|
method, path, id, summary string
|
|
tag string
|
|
pathParams []any // path/query param containers (e.g. ProjectIDParam)
|
|
reqBody any // JSON request body struct, nil when the op takes none
|
|
resps []respUnit
|
|
}
|
|
|
|
func operations() []operation {
|
|
ops := append([]operation{}, projectOperations()...)
|
|
ops = append(ops, sessionOperations()...)
|
|
ops = append(ops, prOperations()...)
|
|
return ops
|
|
}
|
|
|
|
// projectOperations declares the 4 canonical /projects operations. The set must
|
|
// stay 1:1 with the routes ProjectsController.Register mounts —
|
|
// TestRouteSpecParity fails the build otherwise.
|
|
func projectOperations() []operation {
|
|
return []operation{
|
|
{
|
|
method: http.MethodGet, path: "/api/v1/projects", id: "listProjects", tag: "projects",
|
|
summary: "List all registered projects (active + degraded)",
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.ListProjectsResponse{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/projects", id: "addProject", tag: "projects",
|
|
summary: "Register a new project from a git repository path",
|
|
reqBody: projectsvc.AddInput{},
|
|
resps: []respUnit{
|
|
{http.StatusCreated, controllers.ProjectResponse{}},
|
|
{http.StatusBadRequest, envelope.APIError{}},
|
|
{http.StatusConflict, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodGet, path: "/api/v1/projects/{id}", id: "getProject", tag: "projects",
|
|
summary: "Fetch one project; discriminates ok vs degraded",
|
|
pathParams: []any{controllers.ProjectIDParam{}},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.GetProjectResponse{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", tag: "projects",
|
|
summary: "Remove a project; stops sessions, cleans workspaces, unregisters",
|
|
pathParams: []any{controllers.ProjectIDParam{}},
|
|
resps: []respUnit{
|
|
{http.StatusOK, projectsvc.RemoveResult{}},
|
|
{http.StatusBadRequest, envelope.APIError{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func sessionOperations() []operation {
|
|
return []operation{
|
|
{
|
|
method: http.MethodGet, path: "/api/v1/sessions", id: "listSessions", tag: "sessions",
|
|
summary: "List sessions",
|
|
pathParams: []any{controllers.ListSessionsQuery{}},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.ListSessionsResponse{}},
|
|
{http.StatusBadRequest, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/sessions", id: "spawnSession", tag: "sessions",
|
|
summary: "Spawn a new agent session",
|
|
reqBody: controllers.SpawnSessionRequest{},
|
|
resps: []respUnit{
|
|
{http.StatusCreated, controllers.SessionResponse{}},
|
|
{http.StatusBadRequest, envelope.APIError{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodGet, path: "/api/v1/sessions/{sessionId}", id: "getSession", tag: "sessions",
|
|
summary: "Fetch one session",
|
|
pathParams: []any{controllers.SessionIDParam{}},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.SessionResponse{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPatch, path: "/api/v1/sessions/{sessionId}", id: "renameSession", tag: "sessions",
|
|
summary: "Rename a session display name",
|
|
pathParams: []any{controllers.SessionIDParam{}},
|
|
reqBody: controllers.RenameSessionRequest{},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.SessionResponse{}},
|
|
{http.StatusBadRequest, envelope.APIError{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusNotImplemented, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/restore", id: "restoreSession", tag: "sessions",
|
|
summary: "Restore a terminated session",
|
|
pathParams: []any{controllers.SessionIDParam{}},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.RestoreSessionResponse{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusConflict, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/kill", id: "killSession", tag: "sessions",
|
|
summary: "Mark a session terminated and tear down runtime/workspace resources",
|
|
pathParams: []any{controllers.SessionIDParam{}},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.KillSessionResponse{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusConflict, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/send", id: "sendSessionMessage", tag: "sessions",
|
|
summary: "Send a message to a running session's agent",
|
|
pathParams: []any{controllers.SessionIDParam{}},
|
|
reqBody: controllers.SendSessionMessageRequest{},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.SendSessionMessageResponse{}},
|
|
{http.StatusBadRequest, envelope.APIError{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/orchestrators", id: "spawnOrchestrator", tag: "sessions",
|
|
summary: "Spawn an orchestrator session",
|
|
reqBody: controllers.SpawnOrchestratorRequest{},
|
|
resps: []respUnit{
|
|
{http.StatusCreated, controllers.SpawnOrchestratorResponse{}},
|
|
{http.StatusBadRequest, envelope.APIError{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusInternalServerError, envelope.APIError{}},
|
|
{http.StatusNotImplemented, envelope.APIError{}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// prOperations declares the PR action operations. These live in the SCM lane:
|
|
// the handler delegates to a PRService backed by the SCM provider. A nil
|
|
// PRService (SCM not configured) returns 501 for both routes.
|
|
func prOperations() []operation {
|
|
return []operation{
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/prs/{id}/merge", id: "mergePR", tag: "prs",
|
|
summary: "Squash-merge a pull request",
|
|
pathParams: []any{controllers.PRIDParam{}},
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.MergePRResponse{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusConflict, envelope.APIError{}},
|
|
{http.StatusUnprocessableEntity, envelope.APIError{}},
|
|
{http.StatusNotImplemented, envelope.APIError{}},
|
|
},
|
|
},
|
|
{
|
|
method: http.MethodPost, path: "/api/v1/prs/{id}/resolve-comments", id: "resolveComments", tag: "prs",
|
|
summary: "Resolve review threads on a pull request",
|
|
pathParams: []any{controllers.PRIDParam{}},
|
|
reqBody: nil, // body is optional: omitting it resolves all unresolved threads
|
|
resps: []respUnit{
|
|
{http.StatusOK, controllers.ResolveCommentsResponse{}},
|
|
{http.StatusNotFound, envelope.APIError{}},
|
|
{http.StatusUnprocessableEntity, envelope.APIError{}},
|
|
{http.StatusNotImplemented, envelope.APIError{}},
|
|
},
|
|
},
|
|
}
|
|
}
|