Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ jobs:
- run: go mod download && go mod verify
- run: go test -race ./... -count=1 -timeout=5m
- run: go vet ./...
- run: test -z "$(gofmt -l ./*.go)"
- run: test -z "$(gofmt -l .)"
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ the gateway; endpoint, port and secret resolution to `codefly-dev/sdk-go`;
workspace and manifest types to `codefly-dev/core`; running a composition and
provisioning its secrets to `codefly-dev/cli`. Everything here is a library —
one Go package at the repository root, consumed as the Go module
`github.com/codefly-dev/solution-runtime-go` at a `vX.Y.Z` tag.
`github.com/codefly-dev/solution-runtime-go` at a `vX.Y.Z` tag, plus
`passthroughtest`, the test seam a consumer runs the root package's
passthrough under against a fake host. It holds no runtime behaviour of its
own: it builds on `Server.PassthroughHandler`, the handler `Serve` mounts.

## Boundaries

Expand Down Expand Up @@ -117,17 +120,16 @@ gate. It is four commands, and they are the whole local loop:
go mod download && go mod verify
go test -race ./... -count=1 -timeout=5m
go vet ./...
test -z "$(gofmt -l ./*.go)"
test -z "$(gofmt -l .)"
```

Go comes from `go.mod` (1.27). Nothing else is needed: no Docker, no
credentials, no running composition.

- The suite takes ~20s, most of it two heartbeat tests that wait on real wall
time (10s and 5s). That is not a hang.
- The formatting gate globs `./*.go`, so it covers the root package and only
the root package. A package added in a subdirectory is vetted and tested by
the commands above but not format-checked; widen the glob in the same PR.
- The formatting gate runs over the whole tree (`gofmt -l .`), so a package
added in a subdirectory is format-checked as well as vetted and tested.
- Dependency pins are how fixes from `core` and `sdk-go` reach consumers:
`go get github.com/codefly-dev/core@vX.Y.Z && go mod tidy`, then the four
commands above. `go mod tidy` is a no-op on a clean tree, so a diff it
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,37 @@ In the interface artifact a streamed operation says so in its behaviour and
carries `x-streaming: server` (Swagger 2 has no stream; its 200 schema is one
message of it).

#### Testing the passthrough: `passthroughtest`

`Serve` resolves the gateway and api.consumes from the composition and
registers with the host, so a solution cannot run its own passthrough in a
test through it. Package
`github.com/codefly-dev/solution-runtime-go/passthroughtest` serves the real
passthrough — `Server.PassthroughHandler`, the handler `Serve` mounts, behind
the same boot check — over an `httptest` server, against a fake host:

```go
upstream := httptest.NewServer(fakeWidgets) // the test's stand-in for the module's binding
host := passthroughtest.NewHost(t).Module("widgets", upstream.URL)
page := passthroughtest.Start(t, host, declaration...) // the solution's Consumes
client := widgetsv1connect.NewWidgetsClient(page.Client(), page.BaseURL("widgets"))
```

| API | What it is |
| --- | --- |
| `NewHost(t)` | The fake host gateway: it mints the viewer's Work Context (the accounts `StartTask` procedure) and forwards `/v1/<as>/*` to each routed module, streams included, flushed as they arrive. |
| `Host.Module(as, upstream)` | Routes a consumed module to the test's own server, path unchanged. Every routed module is also in the solution's api.consumes; one the declaration names but the host does not route is refused as `Serve` refuses it. |
| `Host.RefuseMints(func(Mint) *Refusal)` | Decides each mint: a non-nil `Refusal` refuses it as accounts would (default status 403), so the page sees `permission_denied` with its message. |
| `Host.Mints()`, `Host.Calls()` | Every mint asked for (audience, scopes, org, session, bearer, the token answered), and every call forwarded to a module, each with the `Mint` whose capability it presented — how a test asserts the authority each method was called under. |
| `Start(t, host, consumes...)` / `Handler(host, consumes...)` | The passthrough served for the declaration, or the bare handler and the error `Serve` would refuse the declaration with. |
| `Solution.Client()`, `ClientAs(Viewer)`, `BaseURL(as)` | A client that calls as `DefaultViewer` (or the viewer given), setting the bearer and the `x-org-id` / `x-session-id` headers the gateway stamps; and the base URL a module's generated client takes. |

What a green test there proves is the solution's side: the declaration, the
authority each method mints, the fields that reach the page, stream bounds and
cancellation. It never proves that a real host admits the solution, that
accounts grants the scopes, or that the real module answers its binding the way
the test's stand-in does.

### Generated messages in a response

A handler's value is encoded with `encoding/json`, which reads a generated
Expand Down
61 changes: 61 additions & 0 deletions passthrough.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,67 @@ func (s *Server) validatePassthrough() (map[string]passthroughRoute, error) {
return routes, checkConsumed(s.consumed, consumed)
}

// mountPassthrough mounts the declared routes on mux at PassthroughPathPrefix,
// as serve does and as PassthroughHandler does: the one place the passthrough
// is wired, so a test through PassthroughHandler serves what Serve serves.
// Serve has already resolved and checked the routes; a test that calls serve
// directly has not, and resolves them here.
func (s *Server) mountPassthrough(mux *http.ServeMux) error {
if len(s.consumed) == 0 {
return nil
}
routes := s.passthrough
if routes == nil {
var err error
if routes, err = resolvePassthrough(s.consumed); err != nil {
return err
}
}
mux.Handle(PassthroughPathPrefix, s.passthroughHandler(routes))
return nil
}

// PassthroughEnvironment is what Serve resolves from the composition for the
// passthrough, supplied instead by a caller of PassthroughHandler.
type PassthroughEnvironment struct {
// GatewayURL is the host gateway every call goes through: the Work Context
// mint and the consumed modules' /v1/<as> prefixes.
GatewayURL string
// Consumes is the solution's api.consumes projection, which Serve reads
// from CODEFLY__API_CONSUMES. Every declared module must be in it.
Consumes []manifest.ConsumedAPI
}

// PassthroughHandler is the consumed-module passthrough exactly as Serve
// serves it — the declaration checked by the same boot check, mounted by the
// same code at PassthroughPathPrefix — against an environment the caller
// supplies instead of the one Serve resolves. Nothing else of the solution is
// served, and nothing registers anywhere.
//
// It exists for tests: package passthroughtest builds on it, with a fake host
// standing in for the gateway. A solution serves with Serve, which overwrites
// the environment set here when it resolves its own.
func (s *Server) PassthroughHandler(env PassthroughEnvironment) (http.Handler, error) {
if len(s.consumed) == 0 {
return nil, fmt.Errorf("solution %q declares no consumed modules (Consumes)", s.manifest.ID)
}
projection, err := json.Marshal(env.Consumes)
if err != nil {
return nil, err
}
s.cfg.gatewayURL, s.cfg.apiConsumes = env.GatewayURL, string(projection)
routes, err := s.validatePassthrough()
if err != nil {
return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err)
}
s.passthrough = routes
mux := http.NewServeMux()
if err := s.mountPassthrough(mux); err != nil {
return nil, err
}
return mux, nil
}

// passthroughHandler serves every route under PassthroughPathPrefix. Each
// route is a Connect unary handler over the method's own descriptor, so every
// protocol the module's generated clients speak is accepted and every refusal
Expand Down
Loading
Loading