-
Notifications
You must be signed in to change notification settings - Fork 1
feat(api): implement project routes with mock manager/store #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
a86ae08
feat(backend): HTTP daemon skeleton — config, health, runfile, gracef…
neversettle17-101 61c5b8a
refactor(backend): drop Env config field — not needed yet (#10)
neversettle17-101 1d4d71b
docs: add backend run + config quick-start to README (#10)
neversettle17-101 a1fda8c
fix(backend): address Phase 1a review comments (#10)
neversettle17-101 1fc4efe
fix(backend): strip trailing blank line from runfile.go (#10)
neversettle17-101 5bca1a2
fix(backend): cross-platform run-file replace + AO_HOST rationale (#10)
neversettle17-101 24f57fd
fix(backend): route chi access logs through slog/stderr (#10)
neversettle17-101 afee896
feat(api): projects route shell (7 routes, REST-corrected) — #20
neversettle17-101 7319ab8
refactor(api): collapse ProjectService → ProjectManager — #20
neversettle17-101 3906d56
refactor(api): replace stubs/ with OpenAPI-as-source-of-truth — #20
neversettle17-101 01a26a9
refactor(api): move projects contract to internal/project package — #20
neversettle17-101 9a0547e
refactor(api): consolidate project types into internal/project — #20
neversettle17-101 5373e62
feat(api): implement project routes with mock manager/store
7877f85
Potential fix for pull request finding
Vaibhaav-Tiwari 427676a
Merge remote-tracking branch 'origin/main' into lenovo/phase3-project…
Copilot d14d41f
Potential fix for pull request finding
Vaibhaav-Tiwari 15aa33f
merge: resolve conflicts with origin/main
Copilot ccc7f4f
Merge remote-tracking branch 'origin/lenovo/phase3-project-apis' into…
Copilot fb146ce
Potential fix for pull request finding
Vaibhaav-Tiwari 99a92a3
Potential fix for pull request finding
Vaibhaav-Tiwari b438925
Merge remote-tracking branch 'origin/lenovo/phase3-project-apis' into…
Copilot 9b3c677
refactor(httpd): share JSON/API error envelope helpers
Copilot 323adaf
fix(api): align project mock store with sqlite schema
d46bf77
fix(api): address project API review semantics
0b0b136
canonicalize both paths with filepath.EvalSymlinks before comparing
Vaibhaav-Tiwari ea728ab
Merge latest main into phase3 project APIs
fd6fb77
Merge remote-tracking branch 'origin/lenovo/phase3-project-apis' into…
5046ea8
style(project): gofmt git repo validation
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| package httpd | ||
|
|
||
| import ( | ||
| "net/http" | ||
|
|
||
| "github.com/go-chi/chi/v5" | ||
| "github.com/go-chi/chi/v5/middleware" | ||
|
|
||
| "github.com/aoagents/agent-orchestrator/backend/internal/config" | ||
| "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" | ||
| "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" | ||
| "github.com/aoagents/agent-orchestrator/backend/internal/project" | ||
| ) | ||
|
|
||
| // APIDeps bundles every Manager the API layer's controllers depend on. There | ||
| // is exactly one Manager per resource, defined in that resource's own package | ||
| // (project.Manager, later session.Manager, ...), and the controllers see ONLY | ||
| // that interface — they don't reach past it to the LCM, adapters, or stores. | ||
| // Whether a Manager impl talks to the registry, the LCM, or an outbound port | ||
| // is its own concern. | ||
| // | ||
| // The route-shell PR (#20) leaves every field nil — handlers answer via | ||
| // apispec.NotImplemented and don't dereference them yet. The handler-impl PR | ||
| // wires real Managers and flips stubs to real logic one route at a time. | ||
| type APIDeps struct { | ||
| Projects project.Manager | ||
| } | ||
|
|
||
| // API owns one controller per resource and is the single Register call the | ||
| // router invokes to mount the /api/v1 surface. Splitting per-resource means | ||
| // later PRs can land a controller's real handlers without touching the | ||
| // surrounding wiring. | ||
| type API struct { | ||
| cfg config.Config | ||
| projects *controllers.ProjectsController | ||
| } | ||
|
|
||
| // NewAPI constructs the API surface from its dependencies. cfg carries the | ||
| // per-request timeout so the REST group can apply it without re-reading the | ||
| // environment. | ||
| func NewAPI(cfg config.Config, deps APIDeps) *API { | ||
| return &API{ | ||
| cfg: cfg, | ||
| projects: &controllers.ProjectsController{ | ||
| Mgr: deps.Projects, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // Register mounts the API surface on root. /api/v1 hosts the REST group with | ||
| // the per-request Timeout that the skeleton router (router.go) deliberately | ||
| // kept off the global stack — REST routes are bounded, but long-lived surfaces | ||
| // (/events SSE, /mux WS) live outside this group when they land. | ||
| // | ||
| // /mux is mounted outside /api/v1 for parity with the legacy TS surface; it is | ||
| // a phase-4 placeholder and stays unregistered here until that lane starts. | ||
| func (a *API) Register(root chi.Router) { | ||
| timeout := a.cfg.RequestTimeout | ||
| if timeout <= 0 { | ||
| timeout = config.DefaultRequestTimeout | ||
| } | ||
|
|
||
| root.Route("/api/v1", func(r chi.Router) { | ||
| // The OpenAPI document is the source of truth for every contract on | ||
| // this surface; serve it so tooling (SDK generators, the OpenAPI | ||
| // validator in #19, the dashboard's developer tools) can fetch the | ||
| // whole spec from the same origin as the routes it describes. | ||
| apispec.RegisterServe(r, "/openapi.yaml") | ||
|
|
||
| r.Group(func(r chi.Router) { | ||
| r.Use(middleware.Timeout(timeout)) | ||
| a.projects.Register(r) | ||
| // Sibling controllers (sessions, issues, prs, ...) plug in here in | ||
| // follow-up PRs #21 / #22 without touching the timeout group. | ||
| }) | ||
| // Surfaces that intentionally bypass the REST timeout (SSE, future WS) | ||
| // register at this level — none exist in the route-shell PR. | ||
| }) | ||
| } | ||
|
|
||
| // notFoundJSON returns the locked envelope for unmatched routes. Chi's default | ||
| // 404 is a text/plain body; the API surface must answer JSON so consumers can | ||
| // parse it uniformly. | ||
| func notFoundJSON(w http.ResponseWriter, r *http.Request) { | ||
| writeAPIError(w, r, http.StatusNotFound, "not_found", "ROUTE_NOT_FOUND", | ||
| r.Method+" "+r.URL.Path+" has no handler", nil) | ||
| } | ||
|
|
||
| // methodNotAllowedJSON returns the locked envelope when a method probes a | ||
| // known path without a matching verb (e.g. PUT /projects/{id} after we drop | ||
| // the legacy PUT alias). | ||
| func methodNotAllowedJSON(w http.ResponseWriter, r *http.Request) { | ||
| writeAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED", | ||
| r.Method+" not allowed on "+r.URL.Path, nil) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| // Package apispec embeds the OpenAPI document, looks up per-operation | ||
| // slices, and writes the locked 501 envelope. The 501 body carries the | ||
| // operation's slice of the OpenAPI document so consumers discover the | ||
| // contract from the endpoint itself — no duplicate planned/contract | ||
| // metadata lives in code. | ||
| // | ||
| // The same document is served verbatim at /api/v1/openapi.yaml so | ||
| // tooling that wants the whole spec can fetch it once. | ||
| package apispec | ||
|
|
||
| import ( | ||
| _ "embed" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "github.com/go-chi/chi/v5" | ||
| "github.com/go-chi/chi/v5/middleware" | ||
| yaml "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| //go:embed openapi.yaml | ||
| var openapiYAML []byte | ||
|
|
||
| // Spec is the parsed, in-memory view of the embedded OpenAPI document. It | ||
| // preserves the YAML shape verbatim so the JSON we emit on 501 responses | ||
| // matches the on-disk source. | ||
| type Spec struct { | ||
| doc map[string]any | ||
| } | ||
|
|
||
| var ( | ||
| defaultOnce sync.Once | ||
| defaultSpec *Spec | ||
| defaultErr error | ||
| ) | ||
|
|
||
| // Default returns the process-wide spec parsed from the embedded YAML. It | ||
| // panics on a malformed embed — that is a build-time bug, not a runtime | ||
| // one, so failing fast at first use is the right call. | ||
| func Default() *Spec { | ||
| defaultOnce.Do(func() { | ||
| s, err := New(openapiYAML) | ||
| defaultSpec = s | ||
| defaultErr = err | ||
| }) | ||
| if defaultErr != nil { | ||
| panic(fmt.Sprintf("apispec: embedded openapi.yaml failed to parse: %v", defaultErr)) | ||
| } | ||
| return defaultSpec | ||
| } | ||
|
|
||
| // New parses the supplied YAML bytes. Exposed so tests can construct an | ||
| // independent spec without touching the embedded default. | ||
| func New(yamlBytes []byte) (*Spec, error) { | ||
| var doc map[string]any | ||
| if err := yaml.Unmarshal(yamlBytes, &doc); err != nil { | ||
| return nil, fmt.Errorf("parse openapi: %w", err) | ||
| } | ||
| if doc == nil { | ||
| return nil, fmt.Errorf("parse openapi: empty document") | ||
| } | ||
| return &Spec{doc: doc}, nil | ||
| } | ||
|
|
||
| // YAML returns the raw embedded document bytes. Used by the /openapi.yaml | ||
| // handler. | ||
| func (s *Spec) YAML() []byte { return openapiYAML } | ||
|
|
||
| // Operation returns the spec slice for a single (method, path) pair, ready | ||
| // to be JSON-serialised. The slice is the OpenAPI Operation object (the | ||
| // inner block under e.g. paths./projects.get), with parent path-level | ||
| // parameters merged in for completeness. | ||
| // | ||
| // Returns nil if the path or method is not in the spec; that is treated as | ||
| // a developer error (route registered without spec coverage) — callers | ||
| // log/fail loudly rather than silently writing a partial 501 body. | ||
| func (s *Spec) Operation(method, path string) map[string]any { | ||
| paths, _ := s.doc["paths"].(map[string]any) | ||
| if paths == nil { | ||
| return nil | ||
| } | ||
| pathItem, _ := paths[path].(map[string]any) | ||
| if pathItem == nil { | ||
| return nil | ||
| } | ||
| op, _ := pathItem[strings.ToLower(method)].(map[string]any) | ||
| if op == nil { | ||
| return nil | ||
| } | ||
|
|
||
| // Path-level parameters apply to every method on that path; merge them | ||
| // in so the slice is self-contained. | ||
| out := make(map[string]any, len(op)+1) | ||
| for k, v := range op { | ||
| out[k] = v | ||
| } | ||
| if params, ok := pathItem["parameters"]; ok { | ||
| // Prefer the operation's own parameters when both are present; | ||
| // otherwise inherit from the path level. | ||
| if _, exists := out["parameters"]; !exists { | ||
| out["parameters"] = params | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // notImplementedResponse is the wire shape for 501 — APIError envelope | ||
| // plus a `spec` field carrying the operation slice. Mirrors the | ||
| // NotImplementedResponse schema in openapi.yaml. | ||
| type notImplementedResponse struct { | ||
| Error string `json:"error"` | ||
| Code string `json:"code"` | ||
| Message string `json:"message"` | ||
| RequestID string `json:"requestId,omitempty"` | ||
| Spec map[string]any `json:"spec"` | ||
| } | ||
|
Vaibhaav-Tiwari marked this conversation as resolved.
|
||
|
|
||
| // NotImplemented writes the locked 501 envelope, embedding the OpenAPI | ||
| // Operation slice that documents what this route WILL do. Replaces the | ||
| // throwaway PlannedRoute literals that the first cut of the route shell | ||
| // duplicated in controller code. | ||
| func NotImplemented(w http.ResponseWriter, r *http.Request, method, path string) { | ||
| op := Default().Operation(method, path) | ||
| if op == nil { | ||
| panic(fmt.Sprintf("apispec: missing operation for %s %s", method, path)) | ||
| } | ||
| body := notImplementedResponse{ | ||
| Error: "not_implemented", | ||
| Code: "NOT_IMPLEMENTED", | ||
| Message: method + " " + path + " is registered but not yet implemented", | ||
| RequestID: middleware.GetReqID(r.Context()), | ||
| Spec: op, | ||
| } | ||
| w.Header().Set("Content-Type", "application/json; charset=utf-8") | ||
| w.WriteHeader(http.StatusNotImplemented) | ||
| // A write error here means the client went away mid-response. | ||
| _ = json.NewEncoder(w).Encode(body) | ||
| } | ||
|
|
||
| // ServeYAML serves the embedded openapi.yaml document. Mounted at | ||
| // /api/v1/openapi.yaml so spec-consuming tooling (#19's validator, | ||
| // SDK generators, the dashboard's developer tools) can fetch the | ||
| // whole document in one request. | ||
| func ServeYAML(w http.ResponseWriter, _ *http.Request) { | ||
| w.Header().Set("Content-Type", "application/yaml; charset=utf-8") | ||
| _, _ = w.Write(openapiYAML) | ||
| } | ||
|
|
||
| // RegisterServe mounts ServeYAML on the supplied router. Kept as a | ||
| // helper so the router code only references one symbol from apispec | ||
| // for the static serve path. | ||
| func RegisterServe(r chi.Router, path string) { | ||
| r.Get(path, ServeYAML) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package apispec_test | ||
|
|
||
| import ( | ||
| "net/http/httptest" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" | ||
| ) | ||
|
|
||
| // TestDefaultLoadsEmbeddedSpec is the smoke test for //go:embed wiring: | ||
| // the default Spec must parse the embedded YAML without panicking and | ||
| // recognise a known operation. | ||
| func TestDefaultLoadsEmbeddedSpec(t *testing.T) { | ||
| op := apispec.Default().Operation("GET", "/api/v1/projects") | ||
| if op == nil { | ||
| t.Fatal("Default().Operation(GET, /api/v1/projects) = nil; embed broken or path missing") | ||
| } | ||
| if got, _ := op["operationId"].(string); got != "listProjects" { | ||
| t.Errorf("operationId = %q, want listProjects", got) | ||
| } | ||
| } | ||
|
|
||
| // TestOperation_MissingPath returns nil for unknown paths — that's how the | ||
| // controller-side test catches "route registered without spec coverage". | ||
| func TestOperation_MissingPath(t *testing.T) { | ||
| if op := apispec.Default().Operation("GET", "/api/v1/no-such-route"); op != nil { | ||
| t.Errorf("unknown path returned %v, want nil", op) | ||
| } | ||
| } | ||
|
|
||
| // TestOperation_MissingMethod returns nil for known path / unknown method. | ||
| func TestOperation_MissingMethod(t *testing.T) { | ||
| if op := apispec.Default().Operation("HEAD", "/api/v1/projects"); op != nil { | ||
| t.Errorf("HEAD on a GET-only path returned %v, want nil", op) | ||
| } | ||
| } | ||
|
|
||
| // TestOperation_InheritsPathParameters covers the bit of behaviour that | ||
| // would silently rot otherwise: parameters declared at the path level | ||
| // (e.g. the {id} path param shared by GET/PATCH/DELETE) must show up on | ||
| // every operation's slice so the 501 response is self-contained. | ||
| func TestOperation_InheritsPathParameters(t *testing.T) { | ||
| op := apispec.Default().Operation("GET", "/api/v1/projects/{id}") | ||
| if op == nil { | ||
| t.Fatal("expected operation slice") | ||
| } | ||
| params, ok := op["parameters"].([]any) | ||
| if !ok || len(params) == 0 { | ||
| t.Fatalf("expected inherited path-level parameters, got %#v", op["parameters"]) | ||
| } | ||
| } | ||
|
|
||
| // TestServeYAML serves the raw embedded document; tooling fetches it | ||
| // whole rather than reconstructing it from per-operation slices. | ||
| func TestServeYAML(t *testing.T) { | ||
| rec := httptest.NewRecorder() | ||
| req := httptest.NewRequest("GET", "/api/v1/openapi.yaml", nil) | ||
| apispec.ServeYAML(rec, req) | ||
|
|
||
| if rec.Code != 200 { | ||
| t.Fatalf("status = %d, want 200", rec.Code) | ||
| } | ||
| if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { | ||
| t.Errorf("Content-Type = %q, want application/yaml*", ct) | ||
| } | ||
| if !strings.Contains(rec.Body.String(), "openapi: 3.1.0") { | ||
| t.Errorf("body did not begin with an OpenAPI 3.1 doc") | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.