diff --git a/base/openapi/api.swagger.json b/base/openapi/api.swagger.json index cea613b..c554c0d 100644 --- a/base/openapi/api.swagger.json +++ b/base/openapi/api.swagger.json @@ -11,6 +11,21 @@ } }, "type": "object" + }, + "OverviewResponse": { + "additionalProperties": false, + "properties": { + "consumes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "solution": { + "type": "string" + } + }, + "type": "object" } }, "info": { @@ -18,7 +33,73 @@ "title": "codefly-base solution backend", "version": "0.0.0" }, - "paths": {}, + "paths": { + "/v1/codefly-base/overview": { + "get": { + "description": "This solution's own page, to show that it is installed and reachable as the viewer\n\nAnswers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + "operationId": "CodeflyBase_Get_V1_Codefly-base_Overview", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/OverviewResponse" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "report what this solution is and which modules it can reach", + "tags": [ + "codefly-base" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "codefly-base.page", + "actions": [ + "read" + ] + } + ] + }, + "x-codefly-operation": { + "attempt_timeout": "30s", + "audience": "codefly-base", + "backoff": "1s", + "completion": "COMPLETION_CALL", + "invoke_scopes": [ + { + "actions": [ + "read" + ], + "resource_kind": "codefly-base.page" + } + ], + "max_attempts": 3, + "recovery": "RECOVERY_RECOMPUTE", + "retryable_codes": [ + "503" + ], + "tool": { + "description": "report what this solution is and which modules it can reach", + "effect": "EFFECT_READ_ONLY", + "name": "CodeflyBase_Get_V1_Codefly-base_Overview" + }, + "total_timeout": "90s" + } + } + } + }, "produces": [ "application/json" ], diff --git a/base/operations.go b/base/operations.go index 123a5dd..60bac9b 100644 --- a/base/operations.go +++ b/base/operations.go @@ -1,6 +1,11 @@ package main -import solution "github.com/codefly-dev/solution-runtime-go" +import ( + "context" + "net/http" + + solution "github.com/codefly-dev/solution-runtime-go" +) // route is where the authority's edge routes this solution and forwards its // requests unchanged: /v1/, the solution's alias. EVERY operation carries @@ -36,5 +41,62 @@ const route = "/v1/" + solutionID // contract does not declare is a scope the authority never issues, so the // route would refuse every call. func operations() []solution.Operation { - return nil + return []solution.Operation{overviewOperation()} +} + +// overviewOperation is the one route a scaffolded solution serves, and the +// one its page reads. +// +// It exists so a freshly scaffolded solution is RUNNABLE AND OBSERVABLE +// rather than correct-looking and inert. Everything between a host and this +// backend has to work for its answer to appear on the page: the host mounts +// the remote, the edge routes /v1/, the authority checks the viewer's +// Work Context against the policy below, and the runtime dispatches. A page +// that renders this has proven that path; a page that renders a fixed string +// has proven nothing, which is why the answer is derived rather than typed. +// +// Replace it with the solution's own first route. Keep the shape: a policy, +// the three prose fields, and a typed Response. +func overviewOperation() solution.Operation { + return solution.Operation{ + Path: route + "/overview", + Method: http.MethodGet, + Summary: "report what this solution is and which modules it can reach", + Callers: "This solution's own page, to show that it is installed and reachable as the viewer", + Behavior: "Answers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + Response: overviewResponse{}, + // The viewer must hold a read on this solution's own page. The kind is + // the solution's OWN vocabulary, which module.contract.codefly.yaml + // declares under scope_ceilings and grants through the installer role + // — both written by `codefly add solution` for exactly this name + // (cli#1216). A kind the contract does not declare is a scope the + // authority never issues, so the route would refuse every call. + Policy: solution.Requires(solutionID+".page", "read"), + Handler: overview, + } +} + +// overviewResponse is what the page reads to show that this solution is +// reachable, and what it can reach. +type overviewResponse struct { + // Solution is this solution's id, as the host lists it and the authority + // issues to. + Solution string `json:"solution"` + // Consumes is the `as` route name of every module this backend relays, + // which is what consumes.go declares and the runtime opened a relay for. + // Empty on a freshly scaffolded solution, which composes none yet. + Consumes []string `json:"consumes"` +} + +// overview answers from the DECLARATIONS the runtime booted with, not from +// constants: the list is the one consumed() built, so a module that failed to +// declare a method is absent here too, and the page shows what is actually +// reachable. +func overview(_ context.Context, _ *solution.Gateway) (any, error) { + relayed := consumed() + names := make([]string, 0, len(relayed)) + for index := range relayed { + names = append(names, relayed[index].As) + } + return overviewResponse{Solution: solutionID, Consumes: names}, nil } diff --git a/base_test.go b/base_test.go index 54cf0a3..81c74d2 100644 --- a/base_test.go +++ b/base_test.go @@ -141,7 +141,8 @@ func TestBaseConsumptionIsWhatThisAgentGenerates(t *testing.T) { // and it must come from the same render — otherwise base/'s suite passes on // bytes a scaffolded tree would never hold. func TestBaseInterfaceDocumentIsTheRuntimesRender(t *testing.T) { - server := solution.New(solution.Manifest{ID: baseSolutionID, Contract: baseSolutionID}) + server := solution.New(solution.Manifest{ID: baseSolutionID, Contract: baseSolutionID}). + Operations(scaffoldedOperations(baseSolutionID)...) document, err := server.InterfaceArtifact("", solution.InterfaceInfo{ Title: baseSolutionID + " solution backend", Version: "0.0.0", diff --git a/scaffold.go b/scaffold.go index 4dbf640..7101f52 100644 --- a/scaffold.go +++ b/scaffold.go @@ -214,7 +214,8 @@ func (s *Builder) writeGeneratedConsumption(ctx context.Context) error { // service.codefly.yaml, which `CreateResponse` has not written yet when this // runs. func (s *Builder) writeInterfaceArtifact(ctx context.Context, context scaffold, version string) error { - server := solution.New(solution.Manifest{ID: context.SolutionID, Contract: context.SolutionID}) + server := solution.New(solution.Manifest{ID: context.SolutionID, Contract: context.SolutionID}). + Operations(scaffoldedOperations(context.SolutionID)...) document, err := server.InterfaceArtifact("", solution.InterfaceInfo{ Title: context.SolutionID + " solution backend", Version: version, diff --git a/scaffolded_operations.go b/scaffolded_operations.go new file mode 100644 index 0000000..8829384 --- /dev/null +++ b/scaffolded_operations.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "net/http" + + solution "github.com/codefly-dev/solution-runtime-go" +) + +// scaffoldedOperations is what a solution this agent scaffolds SERVES, as a +// declaration this module can render without compiling the scaffolded tree. +// +// It mirrors base/operations.go, and it has to exist because the two live in +// different Go modules: base/ is the scaffolded backend's source and cannot +// be imported from here, but the interface document this agent writes at +// Create must list exactly the routes that source serves. Render it from an +// empty server and the scaffolded tree ships a document saying it serves +// nothing while its code serves a route — the authority then derives no check +// row for a route that is being served, and the scaffolded +// TestInterfaceArtifactIsCurrent fails on a tree nobody has touched. +// +// THE DRIFT IS GUARDED, which is the only reason a second declaration is +// tolerable here: TestBaseInterfaceDocumentIsTheRuntimesRender renders this +// and compares it byte for byte against base/openapi/api.swagger.json, which +// base's own `go generate` produces from base/operations.go. Change one +// without the other and that test fails. +func scaffoldedOperations(solutionID string) []solution.Operation { + route := "/v1/" + solutionID + return []solution.Operation{{ + Path: route + "/overview", + Method: http.MethodGet, + Summary: "report what this solution is and which modules it can reach", + Callers: "This solution's own page, to show that it is installed and reachable as the viewer", + Behavior: "Answers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + Response: overviewResponse{}, + Policy: solution.Requires(solutionID+".page", "read"), + Handler: func(context.Context, *solution.Gateway) (any, error) { + // Never called: this declaration exists to be RENDERED. The + // scaffolded tree's own handler is base/operations.go's. + return overviewResponse{}, nil + }, + }} +} + +// overviewResponse mirrors base/operations.go's, and must stay identical: the +// document's schema is derived from this type, and the comparison against +// base's committed render is what catches a field added to one and not the +// other. +type overviewResponse struct { + Solution string `json:"solution"` + Consumes []string `json:"consumes"` +} diff --git a/templates/factory/operations.go.tmpl b/templates/factory/operations.go.tmpl index baafa3a..50ecd15 100644 --- a/templates/factory/operations.go.tmpl +++ b/templates/factory/operations.go.tmpl @@ -1,6 +1,11 @@ package main -import solution "github.com/codefly-dev/solution-runtime-go" +import ( + "context" + "net/http" + + solution "github.com/codefly-dev/solution-runtime-go" +) // route is where the authority's edge routes this solution and forwards its // requests unchanged: /v1/, the solution's alias. EVERY operation carries @@ -36,5 +41,62 @@ const route = "/v1/" + solutionID // contract does not declare is a scope the authority never issues, so the // route would refuse every call. func operations() []solution.Operation { - return nil + return []solution.Operation{overviewOperation()} +} + +// overviewOperation is the one route a scaffolded solution serves, and the +// one its page reads. +// +// It exists so a freshly scaffolded solution is RUNNABLE AND OBSERVABLE +// rather than correct-looking and inert. Everything between a host and this +// backend has to work for its answer to appear on the page: the host mounts +// the remote, the edge routes /v1/, the authority checks the viewer's +// Work Context against the policy below, and the runtime dispatches. A page +// that renders this has proven that path; a page that renders a fixed string +// has proven nothing, which is why the answer is derived rather than typed. +// +// Replace it with the solution's own first route. Keep the shape: a policy, +// the three prose fields, and a typed Response. +func overviewOperation() solution.Operation { + return solution.Operation{ + Path: route + "/overview", + Method: http.MethodGet, + Summary: "report what this solution is and which modules it can reach", + Callers: "This solution's own page, to show that it is installed and reachable as the viewer", + Behavior: "Answers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + Response: overviewResponse{}, + // The viewer must hold a read on this solution's own page. The kind is + // the solution's OWN vocabulary, which module.contract.codefly.yaml + // declares under scope_ceilings and grants through the installer role + // — both written by `codefly add solution` for exactly this name + // (cli#1216). A kind the contract does not declare is a scope the + // authority never issues, so the route would refuse every call. + Policy: solution.Requires(solutionID+".page", "read"), + Handler: overview, + } +} + +// overviewResponse is what the page reads to show that this solution is +// reachable, and what it can reach. +type overviewResponse struct { + // Solution is this solution's id, as the host lists it and the authority + // issues to. + Solution string `json:"solution"` + // Consumes is the `as` route name of every module this backend relays, + // which is what consumes.go declares and the runtime opened a relay for. + // Empty on a freshly scaffolded solution, which composes none yet. + Consumes []string `json:"consumes"` +} + +// overview answers from the DECLARATIONS the runtime booted with, not from +// constants: the list is the one consumed() built, so a module that failed to +// declare a method is absent here too, and the page shows what is actually +// reachable. +func overview(_ context.Context, _ *solution.Gateway) (any, error) { + relayed := consumed() + names := make([]string, 0, len(relayed)) + for index := range relayed { + names = append(names, relayed[index].As) + } + return overviewResponse{Solution: solutionID, Consumes: names}, nil }