github.com/xeger/goa-vcr provides:
runtime/: transport-agnostic VCR primitives (policy, stub store, route matching, stub doer, recording transport).plugin/vcr/: a Goa v3 codegen plugin that generates per-service glue intogen/http/<service>/vcr.
goa-vcr currently targets Goa v3.23.x; v3.25+ compatibility is deferred.
The Goa plugin model is: your plugin must be linked into the generator binary that runs during goa gen (plugin guide).
- Add a blank import in your design module (any file in the design package):
import _ "github.com/xeger/goa-vcr/plugin/vcr"- Run generation:
# IMPORTANT: your `goa` CLI must be compatible with the `goa.design/goa/v3` module
# version used by your repo. If you see a compile error like:
# "not enough arguments in call to generator.Generate"
# it means your installed `goa` binary is older/newer than the module.
#
# This repo pins goa to v3.23.4, so this is a safe invocation:
go run goa.design/goa/v3/cmd/goa@v3.23.4 gen <design-import-path> -o .The generated per-service Scenario implements your Goa Service interface. A scenario holds a per-method handler queue plus a reference to the Service "below" it (another Scenario, or the stub-backed background). Calls to a method dispatch to the queued handler if one is set; otherwise they delegate to the layer below. Handlers for unary methods receive the layer below as a next argument so they can call through and post-process results.
import (
toy "<your-module>/gen/toy"
toyvcr "<your-module>/gen/http/toy/vcr"
vcrruntime "github.com/xeger/goa-vcr/runtime"
)
// Background: stub-backed Service.
bg := toyvcr.NewBackground(store)
// Single scenario: override one method, delegate the rest to bg.
sc := toyvcr.NewScenario(bg)
sc.SetGetThing(func(ctx context.Context, p *toy.GetThingPayload, next toy.Service) (*toy.Thing, error) {
t, err := next.GetThing(ctx, p)
if err != nil { return nil, err }
t.Name = "patched"
return t, nil
})
handler, _ := toyvcr.NewPlaybackHandler(sc)Stack(bg, outer, middle, inner) composes layers with the first argument outermost (outer(middle(inner(bg)))). Layers are ordinary middleware-style functions func(next Service) Service:
func addLogging(next toy.Service) toy.Service {
s := toyvcr.NewScenario(next)
s.SetListThings(func(ctx context.Context, p *toy.ListThingsPayload, next toy.Service) (*toy.ThingList, error) {
log.Printf("ListThings called")
return next.ListThings(ctx, p)
})
return s
}
func patchSingle(next toy.Service) toy.Service {
s := toyvcr.NewScenario(next)
s.SetGetThing(func(ctx context.Context, p *toy.GetThingPayload, next toy.Service) (*toy.Thing, error) {
t, err := next.GetThing(ctx, p)
if err != nil { return nil, err }
t.Name = "patched"
return t, nil
})
return s
}
svc := toyvcr.Stack(bg, addLogging, patchSingle)
handler, _ := toyvcr.NewPlaybackHandler(svc)Streaming handlers are terminal: they receive (ctx, payload, stream) and do not get a next argument. If a layer has a handler registered for a streaming method, it runs; otherwise the call delegates to the next layer. If no layer handles it, the background returns an explicit error — there are no recorded streams to fall back to.
Stream wrapping (intercepting Send/Recv) is planned for a future release.
The generated CLI accepts repeatable --scenario <name> flags to define an
ordered active stack at startup. Scenario objects are applied outer-first:
--scenario Happy --scenario Sad means Stack(bg, Happy, Sad).
If --scenario is omitted, DefaultScenarios is used. DefaultScenario
remains as a single-entry compatibility fallback.
Registry entries are layer constructors:
cfg := toyvcr.CLIConfig{
AppName: "toy-vcr",
ScenarioRegistry: map[string]func(toy.Service) toy.Service{
"happy": func(next toy.Service) toy.Service {
s := toyvcr.NewScenario(next)
s.SetGetThing(func(ctx context.Context, p *toy.GetThingPayload, next toy.Service) (*toy.Thing, error) {
t, err := next.GetThing(ctx, p)
if err != nil { return nil, err }
t.Name = "happy-" + t.Name
return t, nil
})
return s
},
"sad": patchSingle,
"noop": func(next toy.Service) toy.Service { return next },
},
DefaultScenarios: []string{"happy", "sad"},
}
os.Exit(toyvcr.RunCLI(os.Args[1:], cfg))Playback servers expose an admin API for runtime stack changes:
GET /__vcr__/scenariosreturns:registered: known scenario namesdefault: startup default stackactive: current active stack
PUT /__vcr__/scenarios/activereplaces the active stack.DELETE /__vcr__/scenarios/activerestores startup defaults.
Request body for PUT is an ordered array of objects:
[
{"name": "Happy"},
{"name": "Sad"}
]Semantics:
PUTfully replaces the current active stack.PUT []is valid and means background-only playback.DELETE /__vcr__/scenarios/activerestoresDefaultScenarios.
Each VCR stub directory contains a vcr.json policy file that configures recording behavior:
{
"upstream": "https://example.com",
"authorization": {
"claims": {
"sub": "deadbeef"
}
},
"endpoints": {
"GetThing": {
"variant": {
"query": false
}
}
}
}Policy fields:
upstream(required): Base URL of the upstream server to proxy to during recording.authorization.claims(optional): Map of required JWT claim names to their required values. When recording, if anAuthorization: Bearer <token>header is present, the decoded JWT payload (without signature verification) must contain matching claims. If noAuthorizationheader is present, recording proceeds normally. Claim values must be JSON scalars (string, number, bool, null).endpoints.<name>.variant.query(optional): Controls whether query strings participate in stub variants. Defaults totrueif not specified.endpoints.<name>.variant.path(optional): Controls whether route params participate in stub variants. Defaults totrueif not specified.
Authorization gate: the authorization.claims policy only affects recording (via RecordingTransport). Playback does not enforce authorization claims; it serves stubs based on endpoint matching and diversifiers only.