From 2759df673ee5266fccc0cc6360c277e25a1e1b0d Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Mon, 7 Sep 2026 15:02:31 +0300 Subject: [PATCH 1/4] Refactor internals and align spec/docs/API surfaces Extract the pure event subscription broker and interval scheduler into a new internal/eventbus package so the coordinators have a real package boundary and a public test seam, and de-complexify response selection, request filtering and header resolution. Fix the JSON-RPC over HTTP transport to always answer 200 (a mocked non-2xx status is carried in X-Mock-Status), mount each RPC procedure's own path so path parameters resolve through chi, lower the complexity lint gates, and align the OpenAPI/AsyncAPI schemas, docs and OpenSpec scenarios with the implementation. --- .golangci.yml | 7 +- CHANGELOG.md | 7 + api/asyncapi.yaml | 4 + api/openapi.yaml | 12 + docs/diagrams/eventbus-boundary.puml | 59 ++++ docs/json-rpc.md | 18 ++ docs/project.md | 2 +- internal/asyncapi/document.go | 5 +- internal/asyncapi/parse.go | 3 + internal/eventbus/broker.go | 228 ++++++++++++++ internal/eventbus/broker_test.go | 284 ++++++++++++++++++ internal/eventbus/scheduler.go | 126 ++++++++ .../scheduler_test.go} | 84 +++--- internal/extensions/classify.go | 2 + internal/extensions/extract.go | 5 +- internal/extensions/match.go | 2 + internal/loader/router.go | 2 + internal/loader/rpc.go | 2 + internal/runtime/expression.go | 4 + .../server/add_example_validation_test.go | 33 ++ internal/server/async_driver_seam_test.go | 3 +- internal/server/builtin_triggers.go | 2 +- internal/server/engine.go | 121 ++++---- internal/server/engine_async.go | 5 +- internal/server/engine_expr.go | 11 + internal/server/engine_state.go | 43 +-- internal/server/event_broker.go | 207 ------------- internal/server/event_broker_test.go | 263 ---------------- internal/server/event_delivery_test.go | 6 +- internal/server/event_server.go | 83 +++-- internal/server/hubmanager.go | 8 +- internal/server/interfaces.go | 6 +- internal/server/job_scheduler.go | 125 -------- internal/server/jsonrpc.go | 21 +- internal/server/jsonrpc_handler_test.go | 146 +++++++-- internal/server/manage_ws.go | 47 +-- internal/server/management_async.go | 16 +- internal/server/message_delivery.go | 35 ++- internal/server/server_http.go | 51 +--- internal/server/server_management.go | 9 +- internal/server/server_requests.go | 58 ++-- internal/server/server_routes.go | 14 + internal/server/server_test.go | 61 +++- internal/server/signalr_hub.go | 1 + internal/server/wrappers.go | 9 + internal/server/ws_adapter.go | 8 +- openspec/specs/asyncapi-management/spec.md | 6 +- openspec/specs/json-rpc/spec.md | 15 +- test/_shared/resources/test-rpc.yaml | 39 +++ .../management-api/management_api_test.go | 1 + test/jsonrpc/rpc_integration_test.go | 85 ++++++ 51 files changed, 1484 insertions(+), 910 deletions(-) create mode 100644 docs/diagrams/eventbus-boundary.puml create mode 100644 internal/eventbus/broker.go create mode 100644 internal/eventbus/broker_test.go create mode 100644 internal/eventbus/scheduler.go rename internal/{server/job_scheduler_test.go => eventbus/scheduler_test.go} (70%) delete mode 100644 internal/server/event_broker.go delete mode 100644 internal/server/event_broker_test.go delete mode 100644 internal/server/job_scheduler.go diff --git a/.golangci.yml b/.golangci.yml index e3a597e..39122f0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,11 +19,14 @@ linters: settings: gocyclo: # Project standard: keep every function under this cyclomatic bound. - min-complexity: 15 + # Lowered from 15 to 10 so ceiling-sitting logic is either simplified or + # carries an explicit //nolint:gocyclo with a written rationale. + min-complexity: 10 gocognit: # Flag only deeply tangled functions (handlers and schema registrars # legitimately branch on several states); small helpers are preferred. - min-complexity: 35 + # Lowered from 35 to 25 so deeply nested helpers are reviewed. + min-complexity: 25 dupl: threshold: 150 exclusions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8e2fb..fd9e745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Consumers listable without a `channel` filter — flat union across all channels (raw ws + SignalR streams) ### Changed +- JSON-RPC over HTTP always answers transport `200` for a single-call result or + error; a mocked example's non-2xx status is exposed in the `X-Mock-Status` + response header instead of becoming the transport status (previously a mocked + `500` returned HTTP 500) +- Each RPC procedure's own path is now mounted as a route, so a procedure may + be invoked at `/rpc/users/123` as well as at the gateway; path parameters + resolve through chi via the procedure's brace-form pattern (RS.JRP.34) - Recurring delivery moved off the schedule endpoint onto `interval` on `/_mock/examples` - `AddExampleRequest` is now a `oneOf` two-branch schema (sync `path` vs async `channel`) rejecting mixed targeting - Delivered/scheduled messages are templated at emission time so `{$event.*}`/`{$state.*}`/`{$env.*}` resolve against current state diff --git a/api/asyncapi.yaml b/api/asyncapi.yaml index c45e07b..7f959e9 100644 --- a/api/asyncapi.yaml +++ b/api/asyncapi.yaml @@ -151,6 +151,10 @@ components: type: string channel: type: string + protocol: + type: string + enum: [ws, signalr] + description: Consumer transport (ws for raw WebSocket, signalr for SignalR) streams: type: array items: diff --git a/api/openapi.yaml b/api/openapi.yaml index 7d86319..ec1a6ac 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -269,6 +269,12 @@ components: type: object required: - response + description: | + Target discriminator: include `path` (plus optional `method`) to target a + sync OpenAPI operation; include `channel` (plus optional `protocol`) to + target an async AsyncAPI channel. Mixing target kinds is rejected: the + sync branch forbids any async-only field (protocol/channel/match/ + interval/delay), and the async branch forbids `path`. oneOf: - title: sync (OpenAPI) required: @@ -503,6 +509,9 @@ components: type: string channel: type: string + protocol: + type: string + enum: [ws, signalr] streams: type: array items: @@ -542,6 +551,9 @@ components: type: string channel: type: string + protocol: + type: string + enum: [ws, signalr] streams: type: array items: diff --git a/docs/diagrams/eventbus-boundary.puml b/docs/diagrams/eventbus-boundary.puml new file mode 100644 index 0000000..c667901 --- /dev/null +++ b/docs/diagrams/eventbus-boundary.puml @@ -0,0 +1,59 @@ +@startuml +' Event-bus boundary: pure coordinator package vs server wiring. +' See docs/architecture.md §2.2 and the WS-A refactor description. + +skinparam componentStyle rectangle +skinparam backgroundColor #FFFFFF + +package "internal/eventbus (pure, leaf)" { + component "Broker" as Broker { + note right + Subscription registry keyed by event identity + schema scope. + Decoupled from HTTP, SignalR, Server. + end note + } + + component "Scheduler" as Scheduler { + note right + Per-example interval jobs with panic containment. + Delivery injected per job. + end note + } + + interface "EventDeliverer" as ED + Broker ..> ED : "injected delivery func" + Broker ..> "loader.MessageSpec" : "message deliverables" +} + +package "internal/server (wiring)" { + component "eventBus" as EB { + note bottom + Orchestrates broker + scheduler + delivery engine. + Fires events, classifies spec examples, drives periodic jobs. + end note + } + + component "messageDelivery" as MD { + note bottom + Renders and delivers via MessageRenderer / ConsumerBus. + Owns recipient partition, delayed emission, push observer. + end note + } + + component "MessageRenderer" as MR + component "ConsumerBus" as CB + component "manageStream" as MS + component "exampleEngine" as EE + component "hubManager" as HM +} + +EB --> Broker : "own" +EB --> Scheduler : "own" +EB --> MD : "deliver" +MD --> MR +MD --> CB +MD --> MS : "push observer" +EE ..> MR : "implements" +HM ..> CB : "implements" + +@enduml \ No newline at end of file diff --git a/docs/json-rpc.md b/docs/json-rpc.md index b1714aa..1a6e966 100644 --- a/docs/json-rpc.md +++ b/docs/json-rpc.md @@ -102,10 +102,28 @@ Standard JSON-RPC 2.0 error codes are returned for protocol-level errors: | -32601 | Method not found | Procedure name not found in the operation map | | -32603 | Internal error | Pipeline execution error | +## HTTP Transport Semantics + +JSON-RPC over HTTP answers with transport status `200` for every valid +single-call or batch response, whether a call yields a JSON-RPC result or a +JSON-RPC error (the error lives in the JSON body, per the spec). Notifications +alone answer `204 No Content`. + +Because a mocked operation may declare a non-2xx response status, the mocked +status never becomes the transport status — it is exposed in the +`X-Mock-Status` response header. For the default `200` example the header is +omitted. + ## Coexistence with HTTP Routes A single OpenAPI spec can contain both RPC procedures and normal HTTP routes. Paths under the gateway are served by the RPC handler; all other paths are served by the normal HTTP handler. +Every procedure path is also mounted as its own route, so a procedure may be +invoked either at the gateway (`POST /rpc` with the `method` field in the body) +or directly at the procedure's own path. The direct form lets chi capture path +parameters — for a procedure at `/rpc/users/{id}`, posting to `/rpc/users/123` +makes `{$request.path.id}` resolve to `123`. + ## CLI Usage Start the server with a spec containing `x-rpc`: diff --git a/docs/project.md b/docs/project.md index e2a89a4..1d5db59 100644 --- a/docs/project.md +++ b/docs/project.md @@ -20,7 +20,7 @@ - `test/_shared/resources` - Various resources (e.g. yaml, json files incl. AsyncAPI fixtures) - `third_party/` - Vendored dependencies (AsyncAPI parser `go-asyncapi`, wired via `go.mod` `replace`) - `docs/` - Project documentation - - `docs/diagrams` - PlantUML diagrams (container for extracted `.puml` files) + - `docs/diagrams` - PlantUML diagrams (container for extracted `.puml` files, e.g. `eventbus-boundary.puml` documenting the pure `internal/eventbus` coordinator package) ## Conventions diff --git a/internal/asyncapi/document.go b/internal/asyncapi/document.go index d370728..65406ac 100644 --- a/internal/asyncapi/document.go +++ b/internal/asyncapi/document.go @@ -18,8 +18,9 @@ const ( // Supported protocols for the MVP mock server. const ( - ProtocolHTTP = "http" - ProtocolWS = "ws" + ProtocolHTTP = "http" + ProtocolWS = "ws" + ProtocolSignalR = "signalr" ) // Document is the root of a parsed AsyncAPI document. diff --git a/internal/asyncapi/parse.go b/internal/asyncapi/parse.go index e1794b0..bdbe1a0 100644 --- a/internal/asyncapi/parse.go +++ b/internal/asyncapi/parse.go @@ -156,6 +156,7 @@ func captureSignalR(doc *Document, data []byte) { // signalRKey is the root-level x-signalr extension name. const signalRKey = "x-signalr" +//nolint:gocyclo // schema-to-neutral-document field mapping branches func mapDocument(raw *benelser.Document) (*Document, error) { doc := &Document{ Version: raw.AsyncAPI, @@ -209,6 +210,8 @@ func mapDocument(raw *benelser.Document) (*Document, error) { // messageRefs maps the messages of an operation; when the operation declares // none it falls back to the referenced channel's messages. +// +//nolint:gocyclo // operation/channel message resolution branches func messageRefs(op *benelser.Operation) []*Message { var refs []*benelser.MessageRef refs = append(refs, op.Messages...) diff --git a/internal/eventbus/broker.go b/internal/eventbus/broker.go new file mode 100644 index 0000000..c13417b --- /dev/null +++ b/internal/eventbus/broker.go @@ -0,0 +1,228 @@ +// Package eventbus owns the pure event-subscription broker and the per-example +// interval scheduler. Both coordinators are decoupled from HTTP, SignalR and +// the Server; delivery and management-stream observation stay in internal/server. +package eventbus + +import ( + "log/slog" + "sync" + "time" + + "github.com/mamonth/oasmock/internal/loader" +) + +// AnyEventIdentity is the broker key for match-identified examples that do not +// pin an identity ({$event.name}) — they evaluate against every fired event. +const AnyEventIdentity = "*" + +// anyEventIdentity is the unexported alias used internally so match-identified +// examples never collide with user-chosen event identities. +const anyEventIdentity = AnyEventIdentity + +// ChannelSubscription binds an event subscription to a channel address. +type ChannelSubscription struct { + // Address is the fully-prefixed channel address. + Address string + // Event is the match identity: the {$event.name} condition value, a + // built-in trigger (connect/receive), or "" for payload-only matches. + Event string + // Delay is the per-example x-mock-delay (ms) applied before an event-driven + // emission (RS.EXT.23). + Delay int + // Schema is the owning schema prefix (empty = global). + Schema string + // Messages carries the message specs whose examples subscribed. + Messages []*MessageDeliverable +} + +// MessageDeliverable is a message spec deliverable when its subscription fires. +type MessageDeliverable struct { + Spec *loader.MessageSpec + Prefix string +} + +// DelaySchedule describes a delayed delivery. +type DelaySchedule struct { + Ms int +} + +// EventDeliverer emits a delivered message for a channel subscription. +type EventDeliverer func(sub ChannelSubscription, payload map[string]any) + +// Broker decouples OpenAPI event triggers from AsyncAPI consumers. Subscriptions +// are keyed by match identity + schema scope. +type Broker struct { + mu sync.RWMutex + byEvent map[string][]ChannelSubscription // identity -> subscriptions + deliver EventDeliverer + // done is closed on shutdown so pending delayed fires no longer deliver. + done chan struct{} + stopOne sync.Once +} + +// NewBroker creates an empty broker. When deliver is nil, fired events are +// accepted without delivery (used by tests). +func NewBroker(deliver EventDeliverer) *Broker { + return &Broker{ + byEvent: make(map[string][]ChannelSubscription), + deliver: deliver, + done: make(chan struct{}), + } +} + +// Stop cancels any pending delayed deliveries. +func (b *Broker) Stop() { + if b == nil { + return + } + b.stopOne.Do(func() { close(b.done) }) +} + +// sanitizeIdentity maps a subscription identity to a broker key. An empty +// identity becomes the wildcard key ("*") so payload-only matches evaluate +// against every fired event. +func sanitizeIdentity(identity string) string { + if identity == "" { + return anyEventIdentity + } + return identity +} + +// AddSubscriptions registers subscriptions for a schema. +func (b *Broker) AddSubscriptions(schema string, subs []ChannelSubscription) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + for i := range subs { + subs[i].Schema = schema + key := sanitizeIdentity(subs[i].Event) + b.byEvent[key] = append(b.byEvent[key], subs[i]) + } +} + +// RemoveRuntimeExample removes the runtime event-driven subscription registered +// under a deliverable named "runtime-" for a schema scope. +func (b *Broker) RemoveRuntimeExample(schema, id string) { + if b == nil { + return + } + target := "runtime-" + id + b.mu.Lock() + defer b.mu.Unlock() + for key, subs := range b.byEvent { + kept := subs[:0] + for _, sub := range subs { + if sub.Schema == schema && hasDeliverableNamed(sub, target) { + continue + } + kept = append(kept, sub) + } + if len(kept) == 0 { + delete(b.byEvent, key) + } else { + b.byEvent[key] = kept + } + } +} + +// SubscriptionCount reports how many event identities are registered, for tests +// asserting an absent subscription set. +func (b *Broker) SubscriptionCount() int { + if b == nil { + return 0 + } + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.byEvent) +} + +// hasDeliverableNamed reports whether a subscription carries a deliverable +// whose message spec name equals target. +func hasDeliverableNamed(sub ChannelSubscription, target string) bool { + for _, d := range sub.Messages { + if d.Spec != nil && d.Spec.Name == target { + return true + } + } + return false +} + +// ResolveSubscribers returns subscriptions matching an event name for the +// given firing schema. When global is true, all schemas' subscriptions match. +// Wildcard subscriptions (payload-only matches) always resolve. +func (b *Broker) ResolveSubscribers(event, firingSchema string, global ...bool) ([]ChannelSubscription, int) { + if b == nil { + return nil, 0 + } + isGlobal := len(global) > 0 && global[0] + b.mu.RLock() + defer b.mu.RUnlock() + all := append([]ChannelSubscription{}, b.byEvent[event]...) + all = append(all, b.byEvent[anyEventIdentity]...) + out := make([]ChannelSubscription, 0, len(all)) + for _, sub := range all { + if isGlobal || sub.Schema == firingSchema { + out = append(out, sub) + } + } + return out, len(out) +} + +// HasSubscribers is a cheap membership check for hot paths such as built-in +// trigger firing: it reports whether any subscription exists for an event +// identity and schema scope (global when global is true). +func (b *Broker) HasSubscribers(event, firingSchema string, global ...bool) bool { + if b == nil { + return false + } + b.mu.RLock() + defer b.mu.RUnlock() + isGlobal := len(global) > 0 && global[0] + // Copy before concat so the wildcard entries are never appended into the + // live byEvent slice's backing array (which would race AddSubscriptions). + all := append([]ChannelSubscription{}, b.byEvent[event]...) + all = append(all, b.byEvent[anyEventIdentity]...) + for _, sub := range all { + if isGlobal || sub.Schema == firingSchema { + return true + } + } + return false +} + +// Fire dispatches a named event. A delay schedules delivery on a background +// goroutine; otherwise delivery is synchronous. +func (b *Broker) Fire(event string, payload map[string]any, firingSchema string, global bool, delay *DelaySchedule) { + if b == nil { + return + } + subs, _ := b.ResolveSubscribers(event, firingSchema, global) + if len(subs) == 0 { + return + } + if delay != nil && delay.Ms > 0 { + go func() { + select { + case <-b.done: + return + case <-time.After(time.Duration(delay.Ms) * time.Millisecond): + } + b.deliverAll(subs, payload) + }() + return + } + b.deliverAll(subs, payload) +} + +// deliverAll emits a payload to every resolved subscription. +func (b *Broker) deliverAll(subs []ChannelSubscription, payload map[string]any) { + for _, sub := range subs { + if b.deliver != nil { + b.deliver(sub, payload) + } else { + slog.Debug("Event delivered (no deliverer)", "event", sub.Event, "address", sub.Address) + } + } +} diff --git a/internal/eventbus/broker_test.go b/internal/eventbus/broker_test.go new file mode 100644 index 0000000..6835444 --- /dev/null +++ b/internal/eventbus/broker_test.go @@ -0,0 +1,284 @@ +package eventbus + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Registering event subscriptions per schema +Given a broker with a schema-prefixed subscription +When resolveSubscribers is called for a schema-local event in the same schema +Then the subscription is resolved + +Related spec scenarios: RS.EVT.5 +*/ +func TestBroker_ResolveSchemaLocal(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/alerts", Event: "orderCreated"}, + }) + + subs, count := broker.ResolveSubscribers("orderCreated", "/v1") + assert.Equal(t, 1, count) + require.Len(t, subs, 1) + assert.Equal(t, "/v1/alerts", subs[0].Address) +} + +/* +Scenario: Schema-local events do not cross schema boundaries +Given a broker with a subscription in schema /a +When a schema-local event is fired from schema /b +Then no subscription is resolved + +Related spec scenarios: RS.EVT.5 +*/ +func TestBroker_ResolveSchemaLocalNoCross(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + broker.AddSubscriptions("/a", []ChannelSubscription{ + {Address: "/a/alerts", Event: "orderCreated"}, + }) + + subs, count := broker.ResolveSubscribers("orderCreated", "/b") + assert.Equal(t, 0, count) + assert.Empty(t, subs) +} + +/* +Scenario: Global events cross schema boundaries +Given a broker with a subscription in schema /a +When a global event is fired from schema /b +Then the subscription resolves regardless of schema + +Related spec scenarios: RS.EVT.6 +*/ +func TestBroker_ResolveGlobal(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + broker.AddSubscriptions("/a", []ChannelSubscription{ + {Address: "/a/alerts", Event: "orderCreated"}, + }) + + subs, count := broker.ResolveSubscribers("orderCreated", "", true) + assert.Equal(t, 1, count) + require.Len(t, subs, 1) + assert.Equal(t, "/a/alerts", subs[0].Address) +} + +/* +Scenario: Event with no subscribers is accepted +Given a broker with no matching subscription +When an event fires +Then it is accepted with no delivery + +Related spec scenarios: RS.EVT.14 +*/ +func TestBroker_FireNoSubscribers(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/alerts", Event: "other"}, + }) + + broker.Fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, nil) +} + +/* +Scenario: Delayed event delivery schedules +Given an event with a delay +When fire is called +Then the delivery is scheduled and the broker returns immediately + +Related spec scenarios: RS.EVT.4, RS.EVT.16 +*/ +func TestBroker_FireWithDelaySchedules(t *testing.T) { + t.Parallel() + + delivered := make(chan ChannelSubscription, 1) + broker := NewBroker(func(sub ChannelSubscription, payload map[string]any) { + delivered <- sub + }) + + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/alerts", Event: "orderCreated"}, + }) + + broker.Fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, &DelaySchedule{Ms: 10}) + + select { + case sub := <-delivered: + assert.Equal(t, "/v1/alerts", sub.Address) + case <-time.After(time.Second): + t.Fatal("expected delayed delivery") + } +} + +/* +Scenario: Immediate delivery with no delay +Given an event with no delay +When fire is called +Then the delivery happens synchronously + +Related spec scenarios: RS.EVT.1, RS.EVT.3 +*/ +func TestBroker_FireImmediate(t *testing.T) { + t.Parallel() + + var delivered []ChannelSubscription + broker := NewBroker(func(sub ChannelSubscription, payload map[string]any) { + delivered = append(delivered, sub) + }) + + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/alerts", Event: "orderCreated"}, + }) + + broker.Fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, nil) + require.Len(t, delivered, 1) + assert.Equal(t, "/v1/alerts", delivered[0].Address) +} + +/* +Scenario: Match-identified examples resolve only for their schema +Given a broker with an event-driven example registered under identity + schema +When resolveSubscribers is called for a schema-local match fire in the same schema +Then the subscription resolves, and it does not resolve for another schema + +Related spec scenarios: RS.EVT.5, RS.EVT.22 +*/ +func TestBroker_ResolveMatchIdentified(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/alerts", Event: "orderCreated"}, + }) + + subs, count := broker.ResolveSubscribers("orderCreated", "/v1") + assert.Equal(t, 1, count) + require.Len(t, subs, 1) + assert.Equal(t, "/v1/alerts", subs[0].Address) + + // A different schema-local fire must not resolve this subscription. + other, count := broker.ResolveSubscribers("orderCreated", "/v2") + assert.Equal(t, 0, count) + assert.Empty(t, other) +} + +/* +Scenario: Global resolution crosses schema boundaries for match-identified examples +Given a broker with a match-identified example in one schema +When a global event fires +Then the example resolves regardless of the firing schema + +Related spec scenarios: RS.EVT.6, RS.EVT.22 +*/ +func TestBroker_ResolveMatchIdentifiedGlobal(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/alerts", Event: "orderCreated"}, + }) + + subs, count := broker.ResolveSubscribers("orderCreated", "/anything", true) + assert.Equal(t, 1, count) + require.Len(t, subs, 1) + assert.Equal(t, "/v1/alerts", subs[0].Address) +} + +/* +Scenario: hasSubscribers reports emptiness cheaply +Given a broker with and without a matching identity+scope +When hasSubscribers is queried +Then it returns true only when a subscription exists for the identity+scope + +Related spec scenarios: RS.EVT.14, RS.EVT.22 +*/ +func TestBroker_HasSubscribers(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/alerts", Event: "levelUp"}, + }) + + assert.True(t, broker.HasSubscribers("levelUp", "/v1")) + assert.True(t, broker.HasSubscribers("levelUp", "/v1", true)) + + assert.False(t, broker.HasSubscribers("missing", "/v1")) + assert.False(t, broker.HasSubscribers("levelUp", "/v2")) +} + +/* +Scenario: SubscriptionCount reports identity entries +Given a broker with known subscriptions +When SubscriptionCount is queried +Then it reports the number of registered identities + +Related spec scenarios: RS.EVT.5 +*/ +func TestBroker_SubscriptionCount(t *testing.T) { + t.Parallel() + + broker := NewBroker(nil) + assert.Equal(t, 0, broker.SubscriptionCount()) + broker.AddSubscriptions("/v1", []ChannelSubscription{ + {Address: "/v1/a", Event: "ev-a"}, + {Address: "/v1/b", Event: "ev-b"}, + {Address: "/v1/c", Event: "ev-a"}, // same identity as ev-a -> coalesces + }) + assert.Equal(t, 2, broker.SubscriptionCount()) +} + +/* +Scenario: hasSubscribers and addSubscriptions are safe under concurrency +Given a broker being mutated and queried from multiple goroutines +When subscriptions are added while hasSubscribers and resolveSubscribers run +Then the broker remains consistent (race detector must stay clean) + +Related spec scenarios: RS.EVT.14, RS.EVT.22, RS.MAPI.33 +*/ +func TestBroker_HasSubscribersConcurrentWithAdds(t *testing.T) { + broker := NewBroker(nil) + broker.AddSubscriptions("/v0", []ChannelSubscription{{Address: "/v0/base", Event: "seed"}}) + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func(seed int) { + defer wg.Done() + for j := 0; j < 500; j++ { + schema := fmt.Sprintf("/s%d", (seed+j)%8) + broker.AddSubscriptions(schema, []ChannelSubscription{{ + Address: schema + "/ch", + Event: fmt.Sprintf("ev-%d", (seed+j)%16), + }}) + } + }(i) + } + for i := 0; i < 4; i++ { + wg.Add(1) + go func(seed int) { + defer wg.Done() + for j := 0; j < 500; j++ { + schema := fmt.Sprintf("/s%d", (seed+j)%8) + _ = broker.HasSubscribers(fmt.Sprintf("ev-%d", (seed+j)%16), schema) + _, count := broker.ResolveSubscribers(fmt.Sprintf("ev-%d", (seed+j)%16), schema) + assert.GreaterOrEqual(t, count, 0) + } + }(i) + } + wg.Wait() +} diff --git a/internal/eventbus/scheduler.go b/internal/eventbus/scheduler.go new file mode 100644 index 0000000..82b16db --- /dev/null +++ b/internal/eventbus/scheduler.go @@ -0,0 +1,126 @@ +package eventbus + +import ( + "log/slog" + "sync" + "time" +) + +// ScheduledJob is a single per-example recurring delivery job. Deliver runs the +// full delivery pipeline (render + recipient partition + push) for the owning +// example on every tick. +type ScheduledJob struct { + ID string + Interval time.Duration + // ExampleID is the client-facing example identity (the POST /_mock/examples + // id for runtime examples, the spec example name otherwise), used for + // schedule lifecycle envelopes (RS.AMG.27). + ExampleID string + Channel string + stop chan struct{} + Deliver func() +} + +// Scheduler runs per-example interval jobs. It is a pure fabrication decoupled +// from both the HTTP surface and the event broker: delivery is injected per job +// so the scheduler never reaches into the caller. +type Scheduler struct { + mu sync.Mutex + jobs map[string]*ScheduledJob +} + +// NewScheduler creates an empty scheduler. +func NewScheduler() *Scheduler { + return &Scheduler{jobs: make(map[string]*ScheduledJob)} +} + +// Add registers a job and returns it; Run must be started in a goroutine. A job +// already registered under the same id is replaced: its stop channel is closed +// so its ticker loop ends and no further deliveries occur. +func (s *Scheduler) Add(job *ScheduledJob) *ScheduledJob { + if job.stop == nil { + job.stop = make(chan struct{}) + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.jobs[job.ID]; ok { + delete(s.jobs, job.ID) + close(existing.stop) + } + s.jobs[job.ID] = job + return job +} + +// Run delivers a job at its interval until stopped or shut down. The stop +// channel is checked before each tick so a cancelled job does not run further +// deliveries even when a tick is already due. A panic inside a delivery is +// contained: the job is unregistered so the cadence is not silently lost, the +// panic is logged, and the scheduler keeps serving other jobs. +func (s *Scheduler) Run(job *ScheduledJob) { + if job == nil { + return + } + defer func() { + if r := recover(); r != nil { + slog.Error("interval job delivery panicked; job removed", "id", job.ID, "panic", r) + s.Cancel(job.ID) + } + }() + ticker := time.NewTicker(job.Interval) + defer ticker.Stop() + for { + select { + case <-job.stop: + return + default: + } + select { + case <-job.stop: + return + case <-ticker.C: + job.Deliver() + } + } +} + +// Started reports whether a job is currently registered (running or pending). +func (s *Scheduler) Started(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.jobs[id] + return ok +} + +// Stopped reports whether a job has been fully removed. +func (s *Scheduler) Stopped(id string) bool { + return !s.Started(id) +} + +// Cancel unregisters a job by id and reports it, returning the removed job so +// the caller can emit lifecycle metadata. The caller closes its stop channel +// to end any in-flight ticker loop. +func (s *Scheduler) Cancel(id string) (*ScheduledJob, bool) { + s.mu.Lock() + defer s.mu.Unlock() + job, ok := s.jobs[id] + if !ok { + return nil, false + } + delete(s.jobs, id) + close(job.stop) + return job, true +} + +// Shutdown stops all scheduled jobs. Each job's stop channel is closed exactly +// once by deleting it from the map first. +func (s *Scheduler) Shutdown() { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for id, job := range s.jobs { + delete(s.jobs, id) + close(job.stop) + } +} diff --git a/internal/server/job_scheduler_test.go b/internal/eventbus/scheduler_test.go similarity index 70% rename from internal/server/job_scheduler_test.go rename to internal/eventbus/scheduler_test.go index 227e460..27d7342 100644 --- a/internal/server/job_scheduler_test.go +++ b/internal/eventbus/scheduler_test.go @@ -1,4 +1,4 @@ -package server +package eventbus import ( "sync/atomic" @@ -17,17 +17,17 @@ Then the delivery callback fires repeatedly at the configured interval Related spec scenarios: RS.EXT.22, RS.MAPI.25 */ -func TestJobScheduler_DeliversAtCadence(t *testing.T) { +func TestScheduler_DeliversAtCadence(t *testing.T) { t.Parallel() - sched := newJobScheduler() - defer sched.shutdown() + sched := NewScheduler() + defer sched.Shutdown() var count atomic.Int32 - job := sched.add(&scheduledJob{id: "ex-1", interval: 10 * time.Millisecond, deliver: func() { + job := sched.Add(&ScheduledJob{ID: "ex-1", Interval: 10 * time.Millisecond, Deliver: func() { count.Add(1) }}) - go sched.run(job) + go sched.Run(job) deadline := time.Now().Add(200 * time.Millisecond) for count.Load() < 2 && time.Now().Before(deadline) { @@ -44,17 +44,17 @@ Then no further deliveries occur after cancellation Related spec scenarios: RS.EXT.22, RS.MAPI.30 */ -func TestJobScheduler_CancelStops(t *testing.T) { +func TestScheduler_CancelStops(t *testing.T) { t.Parallel() - sched := newJobScheduler() - defer sched.shutdown() + sched := NewScheduler() + defer sched.Shutdown() var count atomic.Int32 - job := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + job := sched.Add(&ScheduledJob{ID: "ex-1", Interval: 5 * time.Millisecond, Deliver: func() { count.Add(1) }}) - go sched.run(job) + go sched.Run(job) deadline := time.Now().Add(100 * time.Millisecond) for count.Load() < 2 && time.Now().Before(deadline) { @@ -63,9 +63,9 @@ func TestJobScheduler_CancelStops(t *testing.T) { before := count.Load() require.GreaterOrEqual(t, before, int32(2)) - job, ok := sched.cancel("ex-1") + removed, ok := sched.Cancel("ex-1") require.True(t, ok) - require.NotNil(t, job) + require.NotNil(t, removed) time.Sleep(40 * time.Millisecond) // At most the one tick already in flight at the moment of cancellation may @@ -81,15 +81,15 @@ Then the jobs are cancelled and registered entries removed Related spec scenarios: RS.MAPI.25, RS.MSC.49 */ -func TestJobScheduler_Shutdown(t *testing.T) { +func TestScheduler_Shutdown(t *testing.T) { t.Parallel() - sched := newJobScheduler() + sched := NewScheduler() var count atomic.Int32 - job := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + job := sched.Add(&ScheduledJob{ID: "ex-1", Interval: 5 * time.Millisecond, Deliver: func() { count.Add(1) }}) - go sched.run(job) + go sched.Run(job) deadline := time.Now().Add(100 * time.Millisecond) for count.Load() < 2 && time.Now().Before(deadline) { @@ -97,9 +97,9 @@ func TestJobScheduler_Shutdown(t *testing.T) { } require.GreaterOrEqual(t, count.Load(), int32(2)) - sched.shutdown() + sched.Shutdown() time.Sleep(30 * time.Millisecond) - assert.True(t, sched.stopped("ex-1")) + assert.True(t, sched.Stopped("ex-1")) } /* @@ -110,12 +110,12 @@ Then it reports false and leaves no error Related spec scenarios: RS.MAPI.31 */ -func TestJobScheduler_CancelUnknown(t *testing.T) { +func TestScheduler_CancelUnknown(t *testing.T) { t.Parallel() - sched := newJobScheduler() - defer sched.shutdown() - job, ok := sched.cancel("unknown") + sched := NewScheduler() + defer sched.Shutdown() + job, ok := sched.Cancel("unknown") assert.False(t, ok) assert.Nil(t, job) } @@ -128,17 +128,17 @@ Then the previous job's deliveries stop and only the new job delivers onward Related spec scenarios: RS.EXT.22, RS.MAPI.25 */ -func TestJobScheduler_AddReplacesAndStopsPrevious(t *testing.T) { +func TestScheduler_AddReplacesAndStopsPrevious(t *testing.T) { t.Parallel() - sched := newJobScheduler() - defer sched.shutdown() + sched := NewScheduler() + defer sched.Shutdown() var oldCount atomic.Int32 - jobA := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + jobA := sched.Add(&ScheduledJob{ID: "ex-1", Interval: 5 * time.Millisecond, Deliver: func() { oldCount.Add(1) }}) - go sched.run(jobA) + go sched.Run(jobA) deadline := time.Now().Add(100 * time.Millisecond) for oldCount.Load() < 2 && time.Now().Before(deadline) { @@ -147,10 +147,10 @@ func TestJobScheduler_AddReplacesAndStopsPrevious(t *testing.T) { require.GreaterOrEqual(t, oldCount.Load(), int32(2)) var newCount atomic.Int32 - jobB := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + jobB := sched.Add(&ScheduledJob{ID: "ex-1", Interval: 5 * time.Millisecond, Deliver: func() { newCount.Add(1) }}) - go sched.run(jobB) + go sched.Run(jobB) deadline = time.Now().Add(100 * time.Millisecond) for newCount.Load() < 2 && time.Now().Before(deadline) { @@ -172,38 +172,38 @@ serving other jobs instead of silently losing the cadence Related spec scenarios: RS.EXT.22, RS.MAPI.25 */ -func TestJobScheduler_PanicInDeliverRemovesJob(t *testing.T) { +func TestScheduler_PanicInDeliverRemovesJob(t *testing.T) { t.Parallel() - sched := newJobScheduler() - defer sched.shutdown() + sched := NewScheduler() + defer sched.Shutdown() var poisoned atomic.Bool poisoned.Store(true) - job := sched.add(&scheduledJob{ - id: "boom", - interval: 5 * time.Millisecond, - deliver: func() { + job := sched.Add(&ScheduledJob{ + ID: "boom", + Interval: 5 * time.Millisecond, + Deliver: func() { if poisoned.Load() { poisoned.Store(false) panic("deliver exploded") } }, }) - go sched.run(job) + go sched.Run(job) deadline := time.Now().Add(time.Second) - for sched.started("boom") && time.Now().Before(deadline) { + for sched.Started("boom") && time.Now().Before(deadline) { time.Sleep(2 * time.Millisecond) } - assert.False(t, sched.started("boom"), "a panicking job must be removed from the scheduler") + assert.False(t, sched.Started("boom"), "a panicking job must be removed from the scheduler") // The scheduler must remain usable for subsequently added jobs. var healthy atomic.Int32 - good := sched.add(&scheduledJob{id: "healthy", interval: 5 * time.Millisecond, deliver: func() { + good := sched.Add(&ScheduledJob{ID: "healthy", Interval: 5 * time.Millisecond, Deliver: func() { healthy.Add(1) }}) - go sched.run(good) + go sched.Run(good) deadline = time.Now().Add(500 * time.Millisecond) for healthy.Load() < 2 && time.Now().Before(deadline) { diff --git a/internal/extensions/classify.go b/internal/extensions/classify.go index a3d1dad..92ca593 100644 --- a/internal/extensions/classify.go +++ b/internal/extensions/classify.go @@ -39,6 +39,8 @@ type Trigger struct { // declared-but-invalid timing values (a non-positive or fractional // x-mock-interval or a negative/fractional x-mock-delay, RS.EXT.22-23) instead // of silently reclassifying the example. +// +//nolint:gocyclo // strict classification-rejection matrix func ClassifyTrigger(ev ExampleValue) (Trigger, error) { var trig Trigger match, hasMatch := ValueMatch(ev) diff --git a/internal/extensions/extract.go b/internal/extensions/extract.go index 2b37dc4..77842b1 100644 --- a/internal/extensions/extract.go +++ b/internal/extensions/extract.go @@ -50,7 +50,10 @@ type EventTrigger struct { } // ExtractEventTriggers parses the x-event-trigger list extension (RS.EVT.1-4). -// It returns false when the extension is absent or not a list. +// It returns false when the extension is absent or not a list. The parse +// branches on each trigger item's optional fields. +// +//nolint:gocyclo // per-trigger field dispatch func ExtractEventTriggers(ex *openapi3.Example) ([]EventTrigger, bool) { if ex == nil || ex.Extensions == nil { return nil, false diff --git a/internal/extensions/match.go b/internal/extensions/match.go index 25a267a..96634fc 100644 --- a/internal/extensions/match.go +++ b/internal/extensions/match.go @@ -87,6 +87,8 @@ func getCachedSchema(schema map[string]any) (*gojsonschema.Schema, error) { // An evaluation failure (an expression source unavailable in the context, e.g. // {$event.*} on the reply path) fails closed; when verbose is true the failure // is logged at warning level (RS.EXT.29), otherwise at debug. +// +//nolint:gocyclo // per-condition grammar branches (pre-eval, eval, compare) func EvaluateParamsMatch(pm ParamsMatch, eval runtime.Evaluator, verbose ...bool) (bool, error) { keepVerbose := len(verbose) > 0 && verbose[0] for expr, condition := range pm { diff --git a/internal/loader/router.go b/internal/loader/router.go index a1e8cbe..9ded16c 100644 --- a/internal/loader/router.go +++ b/internal/loader/router.go @@ -52,6 +52,8 @@ type MessageExampleSpec struct { // channels with unknown/missing binding info. When the document declares root // x-signalr, its ws channels are served by the SignalR hub and are not mapped // to raw ws routes (design D7). +// +//nolint:gocyclo // per-channel protocol/operation mapping branches func buildAsyncRouteMappings(info SchemaInfo) ([]RouteMapping, error) { if info.Async == nil { return nil, fmt.Errorf("schema %q has no AsyncAPI document", info.Prefix) diff --git a/internal/loader/rpc.go b/internal/loader/rpc.go index 5a6eefe..4526a6a 100644 --- a/internal/loader/rpc.go +++ b/internal/loader/rpc.go @@ -15,6 +15,7 @@ var supportedProtocols = map[string]bool{ ProtocolTypeJsonRpc: true, } +//nolint:gocyclo // x-rpc field extraction branches func ParseRpcConfig(spec *openapi3.T) (*RpcConfig, error) { ext := spec.Extensions["x-rpc"] if ext == nil { @@ -68,6 +69,7 @@ func ParseRpcConfig(spec *openapi3.T) (*RpcConfig, error) { return cfg, nil } +//nolint:gocyclo // per-path RPC mapping construction branches func BuildRpcMappings(infos []SchemaInfo, cfg *RpcConfig) ([]*RpcRouteMapping, error) { if cfg == nil { return nil, nil diff --git a/internal/runtime/expression.go b/internal/runtime/expression.go index 84f486b..f24067e 100644 --- a/internal/runtime/expression.go +++ b/internal/runtime/expression.go @@ -356,6 +356,10 @@ func (e *evaluator) AddSource(name string, source DataSource) { } // Evaluate evaluates a runtime expression like "{$request.path.id}". +// It branches on the expression grammar: format, modifier, source lookup, +// missing-path defaulting and modifier dispatch. +// +//nolint:gocyclo // expression-grammar branches func (e *evaluator) Evaluate(expr string) (any, error) { if !strings.HasPrefix(expr, "{$") || !strings.HasSuffix(expr, "}") { return nil, fmt.Errorf("invalid expression format: %s", expr) diff --git a/internal/server/add_example_validation_test.go b/internal/server/add_example_validation_test.go index d2beaf0..16f55e8 100644 --- a/internal/server/add_example_validation_test.go +++ b/internal/server/add_example_validation_test.go @@ -335,3 +335,36 @@ func TestAddExampleValidation_ValidateFlag(t *testing.T) { defer resp3.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp3.StatusCode, "a conforming body must pass validation") } + +/* +Scenario: The sync/async target discriminator is strict +Given the POST /_mock/examples oneOf fence +When a body mixes a sync target field (path) with an async target field +Then the request is rejected with HTTP 400 -- the fence only accepts the two +declared target kinds, and mixing them is invalid (RS.MAPI.27) + +Related spec scenarios: RS.MAPI.27 +*/ +func TestAddExampleValidation_DiscriminatorFence(t *testing.T) { + t.Parallel() + + schemas := []loader.SchemaInfo{{Kind: loader.KindOpenAPI, Spec: mustOpenAPISpec(t, validateOpenAPISpec), Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + // A sync (path) target carrying any async-only field violates the sync + // branch's `not` and the async branch's `not.path`, so the oneOf yields no + // matching branch and the request is rejected. + invalidBodies := []string{ + `{"path":"/validate","protocol":"http","response":{"code":200}}`, + `{"path":"/validate","channel":"/alerts","response":{"code":200}}`, + `{"path":"/validate","interval":100,"response":{"code":200}}`, + } + for _, body := range invalidBodies { + resp := postExample(t, ts.URL, body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "body=%s", body) + resp.Body.Close() //nolint:errcheck + } +} diff --git a/internal/server/async_driver_seam_test.go b/internal/server/async_driver_seam_test.go index 1d20e4f..e501126 100644 --- a/internal/server/async_driver_seam_test.go +++ b/internal/server/async_driver_seam_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/mamonth/oasmock/internal/eventbus" "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" "github.com/stretchr/testify/require" @@ -20,7 +21,7 @@ type stubAsyncDriver struct { registered *loader.MessageExampleSpec } -func (d *stubAsyncDriver) fire(name string, payload map[string]any, schema string, global bool, delay *delaySchedule) { +func (d *stubAsyncDriver) fire(name string, payload map[string]any, schema string, global bool, delay *eventbus.DelaySchedule) { d.fired = append(d.fired, name) } func (d *stubAsyncDriver) fireTargeted(name string, payload map[string]any, schema string, recipient ConsumerInfo) { diff --git a/internal/server/builtin_triggers.go b/internal/server/builtin_triggers.go index fbf6aef..ebb8433 100644 --- a/internal/server/builtin_triggers.go +++ b/internal/server/builtin_triggers.go @@ -58,7 +58,7 @@ func (s *Server) wireBuiltInHooks() { s.notifyConsumerLifecycle("connected", channel, info) }, OnDisconnect: func(channel, connID string) { - s.notifyConsumerLifecycle("disconnected", channel, ConsumerInfo{ConnectionID: connID, Channel: channel}) + s.notifyConsumerLifecycle("disconnected", channel, ConsumerInfo{ConnectionID: connID, Channel: channel, Protocol: asyncapi.ProtocolWS}) }, } if adapter, ok := s.protocolAdapters[asyncapi.ProtocolWS].(*wsProtocolAdapter); ok && adapter != nil { diff --git a/internal/server/engine.go b/internal/server/engine.go index 356a630..080b25d 100644 --- a/internal/server/engine.go +++ b/internal/server/engine.go @@ -53,30 +53,9 @@ func (e *exampleEngine) selectResponse(mapping *RouteMapping, eval runtime.Evalu for code := range respMap { keys = append(keys, code) } - // Sort keys with custom order: numeric status codes ascending, "default" last - slices.SortFunc(keys, func(a, b string) int { - if a == "default" && b == "default" { - return 0 - } - if a == "default" { - return 1 // default after numeric codes - } - if b == "default" { - return -1 - } - aInt, errA := strconv.Atoi(a) - bInt, errB := strconv.Atoi(b) - if errA != nil && errB != nil { - return strings.Compare(a, b) // fallback lexical - } - if errA != nil { - return 1 // non-numeric after numeric - } - if errB != nil { - return -1 - } - return cmp.Compare(aInt, bInt) - }) + // Sort keys with a declarative total order: numeric status codes ascending, + // then "default" last, then any non-numeric key lexically. + slices.SortFunc(keys, responseOrder) // Iterate sorted keys for _, code := range keys { resp := respMap[code] @@ -87,6 +66,30 @@ func (e *exampleEngine) selectResponse(mapping *RouteMapping, eval runtime.Evalu return "", nil } +// responseOrder is a pure total order over response-status keys: numeric codes +// ascending, then non-numeric keys lexically, with "default" last so the mock +// prefers explicit statuses over the catch-all when one is declared. +func responseOrder(a, b string) int { + aInt, aErr := strconv.Atoi(a) + bInt, bErr := strconv.Atoi(b) + switch { + case aErr == nil && bErr == nil: + return cmp.Compare(aInt, bInt) + case aErr == nil: + return -1 // numeric before non-numeric + case bErr == nil: + return 1 + case a == "default" && b == "default": + return 0 + case a == "default": + return 1 // default last + case b == "default": + return -1 + default: + return strings.Compare(a, b) + } +} + func (e *exampleEngine) selectMediaType(response *openapi3.Response) (string, *openapi3.MediaType, error) { if response.Content == nil { return "", nil, fmt.Errorf("no media type defined for response") @@ -265,35 +268,12 @@ func (e *exampleEngine) evaluateHeaders(example *openapi3.Example, eval runtime. func (e *exampleEngine) resolveHeaderValue(val any, eval runtime.Evaluator) (string, bool) { switch v := val.(type) { case string: - resolved, err := e.evaluateValue(v, eval) - if err != nil { - if e.verbose { - slog.Debug("Failed to evaluate header value", "headerValue", v, "error", err) - } - return "", false - } - if str, ok := resolved.(string); ok { - return str, true - } - // Convert to JSON string - b, err := json.Marshal(resolved) - if err != nil { - return "", false - } - return string(b), true + return e.resolveStringHeader(v, eval) case []any: - // Multiple header values - join with comma (except for Set-Cookie which should be separate headers) - // For simplicity, just take the first value for now - if len(v) > 0 { - if first, ok := v[0].(string); ok { - resolved, err := e.evaluateValue(first, eval) - if err == nil { - if str, ok := resolved.(string); ok { - return str, true - } - } - } - } + // Multiple header values - join with comma (except for Set-Cookie + // which should be separate headers). For simplicity, just take the + // first value for now. + return e.resolveSliceHeader(v, eval) default: // Try to evaluate as runtime expression resolved, err := e.evaluateValue(val, eval) @@ -305,3 +285,40 @@ func (e *exampleEngine) resolveHeaderValue(val any, eval runtime.Evaluator) (str } return "", false } + +// resolveStringHeader evaluates a single header value, marshal-hing non-string +// results to JSON text. +func (e *exampleEngine) resolveStringHeader(v string, eval runtime.Evaluator) (string, bool) { + resolved, err := e.evaluateValue(v, eval) + if err != nil { + if e.verbose { + slog.Debug("Failed to evaluate header value", "headerValue", v, "error", err) + } + return "", false + } + if str, ok := resolved.(string); ok { + return str, true + } + // Convert to JSON string + b, err := json.Marshal(resolved) + if err != nil { + return "", false + } + return string(b), true +} + +// resolveSliceHeader evaluates the first header value of a multi-value list. +func (e *exampleEngine) resolveSliceHeader(v []any, eval runtime.Evaluator) (string, bool) { + if len(v) == 0 { + return "", false + } + if first, ok := v[0].(string); ok { + resolved, err := e.evaluateValue(first, eval) + if err == nil { + if str, ok := resolved.(string); ok { + return str, true + } + } + } + return "", false +} diff --git a/internal/server/engine_async.go b/internal/server/engine_async.go index 128b7cf..491df30 100644 --- a/internal/server/engine_async.go +++ b/internal/server/engine_async.go @@ -103,7 +103,10 @@ func (e *exampleEngine) asyncRequestSource(in InboundMessage) *runtime.RequestSo } // selectAsyncExample selects a message example using the x-mock-* semantics -// (skip, once, params-match) shared with the OpenAPI pipeline. +// (skip, once, params-match) shared with the OpenAPI pipeline. The branches +// cover the selection predicates; they are inherent to the semantics. +// +//nolint:gocyclo // selection predicate matrix (skip/once/match) func (e *exampleEngine) SelectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*MessageExampleView, string) { if message == nil { return nil, "" diff --git a/internal/server/engine_expr.go b/internal/server/engine_expr.go index 48f4b9c..d090c3f 100644 --- a/internal/server/engine_expr.go +++ b/internal/server/engine_expr.go @@ -12,6 +12,12 @@ import ( // Runtime expression evaluation // --------------------------------------------------------------------------- +// replaceEmbeddedExpressions scans a string for {$...} runtime expressions, +// tracking brace depth across nested expressions. It is a stateful scanner with +// several interleaved branches (literal/evaluate/nested-brace fallback), so its +// cyclomatic surface is kept in one place rather than split across helpers. +// +//nolint:gocyclo,gocognit // intentional brace-depth scan state machine func (e *exampleEngine) replaceEmbeddedExpressions(str string, eval runtime.Evaluator) (string, error) { var result strings.Builder i := 0 @@ -106,6 +112,11 @@ func (e *exampleEngine) evaluateValue(val any, eval runtime.Evaluator) (any, err return e.evaluateValueDepth(val, eval, 0) } +// evaluateValueDepth recursively templates nested JSON values, branching on +// string/object/array/literal forms at every level. The branching is inherent +// to the value grammar; the depth bound guards stack overflow. +// +//nolint:gocyclo // inherent JSON shape switch func (e *exampleEngine) evaluateValueDepth(val any, eval runtime.Evaluator, depth int) (any, error) { if depth > maxEvaluationDepth { return nil, fmt.Errorf("value nesting exceeds maximum depth of %d", maxEvaluationDepth) diff --git a/internal/server/engine_state.go b/internal/server/engine_state.go index 9ddd18c..1c83efe 100644 --- a/internal/server/engine_state.go +++ b/internal/server/engine_state.go @@ -27,28 +27,12 @@ func (e *exampleEngine) handleIncrementState(prefix, resolvedKey string, incVal } return err } - // Convert to float64 - var delta float64 - switch v := resolvedInc.(type) { - case float64: - delta = v - case int: - delta = float64(v) - case string: - // Try to parse as number - if f, err := strconv.ParseFloat(v, 64); err == nil { - delta = f - } else { - if e.verbose { - slog.Debug("Increment value is not a number", "value", v) - } - return fmt.Errorf("increment value is not a number: %s", v) - } - default: + delta, err := coerceNumber(resolvedInc) + if err != nil { if e.verbose { - slog.Debug("Increment value has unsupported type", "type", fmt.Sprintf("%T", v)) + slog.Debug("Increment value is not a number", "value", resolvedInc, "error", err) } - return fmt.Errorf("increment value has unsupported type: %T", v) + return err } newVal, err := e.stateStore.Increment(prefix, resolvedKey, delta) if err != nil { @@ -63,6 +47,25 @@ func (e *exampleEngine) handleIncrementState(prefix, resolvedKey string, incVal return nil } +// coerceNumber converts an increment delta value to float64, accepting numeric +// and numeric-string representations. +func coerceNumber(v any) (float64, error) { + switch x := v.(type) { + case float64: + return x, nil + case int: + return float64(x), nil + case string: + f, err := strconv.ParseFloat(x, 64) + if err != nil { + return 0, fmt.Errorf("increment value is not a number: %s", x) + } + return f, nil + default: + return 0, fmt.Errorf("increment value has unsupported type: %T", x) + } +} + func (e *exampleEngine) handleValueObjectState(prefix, resolvedKey string, valObj any, eval runtime.Evaluator) error { resolvedVal, err := e.evaluateValue(valObj, eval) if err != nil { diff --git a/internal/server/event_broker.go b/internal/server/event_broker.go deleted file mode 100644 index 38e29b0..0000000 --- a/internal/server/event_broker.go +++ /dev/null @@ -1,207 +0,0 @@ -package server - -import ( - "log/slog" - "sync" - "time" - - "github.com/mamonth/oasmock/internal/loader" -) - -// anyEventIdentity is the broker key for match-identified examples that do not -// pin an identity ({$event.name}) — they evaluate against every fired event. -const anyEventIdentity = "*" - -// channelSubscription binds an event subscription to a channel address. -type channelSubscription struct { - // address is the fully-prefixed channel address. - address string - // event is the match identity: the {$event.name} condition value, a - // built-in trigger (connect/receive), or "" for payload-only matches. - event string - // delay is the per-example x-mock-delay (ms) applied before an event-driven - // emission (RS.EXT.23). - delay int - // schema is the owning schema prefix (empty = global). - schema string - // messages carries the message specs whose examples subscribed. - messages []*messageDeliverable -} - -// messageDeliverable is a message spec deliverable when its subscription fires. -type messageDeliverable struct { - spec *loader.MessageSpec - prefix string -} - -// delaySchedule describes a delayed delivery. -type delaySchedule struct { - ms int -} - -// eventDeliverer emits a delivered message for a channel subscription. -type eventDeliverer func(sub channelSubscription, payload map[string]any) - -// eventBroker decouples OpenAPI event triggers from AsyncAPI consumers -// (design D3). Subscriptions are keyed by match identity + schema scope. -type eventBroker struct { - mu sync.RWMutex - byEvent map[string][]channelSubscription // identity -> subscriptions - deliver eventDeliverer - // done is closed on shutdown so pending delayed fires no longer deliver. - done chan struct{} - stopOne sync.Once -} - -// newEventBroker creates an empty broker. When deliver is nil, fired events -// are accepted without delivery (used by tests). -func newEventBroker(deliver eventDeliverer) *eventBroker { - return &eventBroker{ - byEvent: make(map[string][]channelSubscription), - deliver: deliver, - done: make(chan struct{}), - } -} - -// stop cancels any pending delayed deliveries. -func (b *eventBroker) stop() { - if b == nil { - return - } - b.stopOne.Do(func() { close(b.done) }) -} - -// sanitizeIdentity maps a subscription identity to a broker key. An empty -// identity becomes the wildcard key ("*") so payload-only matches evaluate -// against every fired event. -func sanitizeIdentity(identity string) string { - if identity == "" { - return anyEventIdentity - } - return identity -} - -// addSubscriptions registers subscriptions for a schema. -func (b *eventBroker) addSubscriptions(schema string, subs []channelSubscription) { - if b == nil { - return - } - b.mu.Lock() - defer b.mu.Unlock() - for i := range subs { - subs[i].schema = schema - key := sanitizeIdentity(subs[i].event) - b.byEvent[key] = append(b.byEvent[key], subs[i]) - } -} - -// removeRuntimeExample removes the runtime event-driven subscription registered -// under a deliverable named "runtime-" for a schema scope. -func (b *eventBroker) removeRuntimeExample(schema, id string) { - if b == nil { - return - } - target := "runtime-" + id - b.mu.Lock() - defer b.mu.Unlock() - for key, subs := range b.byEvent { - kept := subs[:0] - for _, sub := range subs { - if sub.schema == schema && hasDeliverableNamed(sub, target) { - continue - } - kept = append(kept, sub) - } - if len(kept) == 0 { - delete(b.byEvent, key) - } else { - b.byEvent[key] = kept - } - } -} - -// hasDeliverableNamed reports whether a subscription carries a deliverable -// whose message spec name equals target. -func hasDeliverableNamed(sub channelSubscription, target string) bool { - for _, d := range sub.messages { - if d.spec != nil && d.spec.Name == target { - return true - } - } - return false -} - -// resolveSubscribers returns subscriptions matching an event name for the -// given firing schema. When global is true, all schemas' subscriptions match. -// Wildcard subscriptions (payload-only matches) always resolve. -func (b *eventBroker) resolveSubscribers(event, firingSchema string, global ...bool) ([]channelSubscription, int) { - if b == nil { - return nil, 0 - } - isGlobal := len(global) > 0 && global[0] - b.mu.RLock() - defer b.mu.RUnlock() - all := append([]channelSubscription{}, b.byEvent[event]...) - all = append(all, b.byEvent[anyEventIdentity]...) - out := make([]channelSubscription, 0, len(all)) - for _, sub := range all { - if isGlobal || sub.schema == firingSchema { - out = append(out, sub) - } - } - return out, len(out) -} - -// hasSubscribers is a cheap membership check for hot paths such as built-in -// trigger firing: it reports whether any subscription exists for an event -// identity and schema scope (global when global is true). -func (b *eventBroker) hasSubscribers(event, firingSchema string, global ...bool) bool { - b.mu.RLock() - defer b.mu.RUnlock() - isGlobal := len(global) > 0 && global[0] - // Copy before concat so the wildcard entries are never appended into the - // live byEvent slice's backing array (which would race addSubscriptions). - all := append([]channelSubscription{}, b.byEvent[event]...) - all = append(all, b.byEvent[anyEventIdentity]...) - for _, sub := range all { - if isGlobal || sub.schema == firingSchema { - return true - } - } - return false -} - -// fire dispatches a named event. A delay schedules delivery on a background -// goroutine; otherwise delivery is synchronous. -func (b *eventBroker) fire(event string, payload map[string]any, firingSchema string, global bool, delay *delaySchedule) { - if b == nil { - return - } - subs, _ := b.resolveSubscribers(event, firingSchema, global) - if len(subs) == 0 { - return - } - if delay != nil && delay.ms > 0 { - go func() { - select { - case <-b.done: - return - case <-time.After(time.Duration(delay.ms) * time.Millisecond): - } - b.deliverAll(subs, payload) - }() - return - } - b.deliverAll(subs, payload) -} - -// deliverAll emits a payload to every resolved subscription. -func (b *eventBroker) deliverAll(subs []channelSubscription, payload map[string]any) { - for _, sub := range subs { - if b.deliver != nil { - b.deliver(sub, payload) - } else { - slog.Debug("Event delivered (no deliverer)", "event", sub.event, "address", sub.address) - } - } -} diff --git a/internal/server/event_broker_test.go b/internal/server/event_broker_test.go deleted file mode 100644 index 6c9f26a..0000000 --- a/internal/server/event_broker_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package server - -import ( - "fmt" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -/* -Scenario: Registering event subscriptions per schema -Given a broker with a schema-prefixed subscription -When resolveSubscribers is called for a schema-local event in the same schema -Then the subscription is resolved - -Related spec scenarios: RS.EVT.5 -*/ -func TestEventBroker_ResolveSchemaLocal(t *testing.T) { - t.Parallel() - - broker := newEventBroker(nil) - broker.addSubscriptions("/v1", []channelSubscription{ - {address: "/v1/alerts", event: "orderCreated"}, - }) - - subs, count := broker.resolveSubscribers("orderCreated", "/v1") - assert.Equal(t, 1, count) - require.Len(t, subs, 1) - assert.Equal(t, "/v1/alerts", subs[0].address) -} - -/* -Scenario: Schema-local events do not cross schema boundaries -Given a broker with a subscription in schema /a -When a schema-local event is fired from schema /b -Then no subscription is resolved - -Related spec scenarios: RS.EVT.5 -*/ -func TestEventBroker_ResolveSchemaLocalNoCross(t *testing.T) { - t.Parallel() - - broker := newEventBroker(nil) - broker.addSubscriptions("/a", []channelSubscription{ - {address: "/a/alerts", event: "orderCreated"}, - }) - - subs, count := broker.resolveSubscribers("orderCreated", "/b") - assert.Equal(t, 0, count) - assert.Empty(t, subs) -} - -/* -Scenario: Global events cross schema boundaries -Given a broker with a subscription in schema /a -When a global event is fired from schema /b -Then the subscription resolves regardless of schema - -Related spec scenarios: RS.EVT.6 -*/ -func TestEventBroker_ResolveGlobal(t *testing.T) { - t.Parallel() - - broker := newEventBroker(nil) - broker.addSubscriptions("/a", []channelSubscription{ - {address: "/a/alerts", event: "orderCreated"}, - }) - - subs, count := broker.resolveSubscribers("orderCreated", "", true) - assert.Equal(t, 1, count) - require.Len(t, subs, 1) - assert.Equal(t, "/a/alerts", subs[0].address) -} - -/* -Scenario: Event with no subscribers is accepted -Given a broker with no matching subscription -When an event fires -Then it is accepted with no delivery - -Related spec scenarios: RS.EVT.14 -*/ -func TestEventBroker_FireNoSubscribers(t *testing.T) { - t.Parallel() - - broker := newEventBroker(nil) - broker.addSubscriptions("/v1", []channelSubscription{ - {address: "/v1/alerts", event: "other"}, - }) - - broker.fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, nil) -} - -/* -Scenario: Delayed event delivery schedules -Given an event with a delay -When fire is called -Then the delivery is scheduled and the broker returns immediately - -Related spec scenarios: RS.EVT.4, RS.EVT.16 -*/ -func TestEventBroker_FireWithDelaySchedules(t *testing.T) { - t.Parallel() - - delivered := make(chan channelSubscription, 1) - broker := newEventBroker(func(sub channelSubscription, payload map[string]any) { - delivered <- sub - }) - - broker.addSubscriptions("/v1", []channelSubscription{ - {address: "/v1/alerts", event: "orderCreated"}, - }) - - broker.fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, &delaySchedule{ms: 10}) - - select { - case sub := <-delivered: - assert.Equal(t, "/v1/alerts", sub.address) - case <-time.After(time.Second): - t.Fatal("expected delayed delivery") - } -} - -/* -Scenario: Immediate delivery with no delay -Given an event with no delay -When fire is called -Then the delivery happens synchronously - -Related spec scenarios: RS.EVT.1, RS.EVT.3 -*/ -func TestEventBroker_FireImmediate(t *testing.T) { - t.Parallel() - - var delivered []channelSubscription - broker := newEventBroker(func(sub channelSubscription, payload map[string]any) { - delivered = append(delivered, sub) - }) - - broker.addSubscriptions("/v1", []channelSubscription{ - {address: "/v1/alerts", event: "orderCreated"}, - }) - - broker.fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, nil) - require.Len(t, delivered, 1) - assert.Equal(t, "/v1/alerts", delivered[0].address) -} - -/* -Scenario: Match-identified examples resolve only for their schema -Given a broker with an event-driven example registered under identity + schema -When resolveSubscribers is called for a schema-local match fire in the same schema -Then the subscription resolves, and it does not resolve for another schema - -Related spec scenarios: RS.EVT.5, RS.EVT.22 -*/ -func TestEventBroker_ResolveMatchIdentified(t *testing.T) { - t.Parallel() - - broker := newEventBroker(nil) - broker.addSubscriptions("/v1", []channelSubscription{ - {address: "/v1/alerts", event: "orderCreated"}, - }) - - subs, count := broker.resolveSubscribers("orderCreated", "/v1") - assert.Equal(t, 1, count) - require.Len(t, subs, 1) - assert.Equal(t, "/v1/alerts", subs[0].address) - - // A different schema-local fire must not resolve this subscription. - other, count := broker.resolveSubscribers("orderCreated", "/v2") - assert.Equal(t, 0, count) - assert.Empty(t, other) -} - -/* -Scenario: Global resolution crosses schema boundaries for match-identified examples -Given a broker with a match-identified example in one schema -When a global event fires -Then the example resolves regardless of the firing schema - -Related spec scenarios: RS.EVT.6, RS.EVT.22 -*/ -func TestEventBroker_ResolveMatchIdentifiedGlobal(t *testing.T) { - t.Parallel() - - broker := newEventBroker(nil) - broker.addSubscriptions("/v1", []channelSubscription{ - {address: "/v1/alerts", event: "orderCreated"}, - }) - - subs, count := broker.resolveSubscribers("orderCreated", "/anything", true) - assert.Equal(t, 1, count) - require.Len(t, subs, 1) - assert.Equal(t, "/v1/alerts", subs[0].address) -} - -/* -Scenario: hasSubscribers reports emptiness cheaply -Given a broker with and without a matching identity+scope -When hasSubscribers is queried -Then it returns true only when a subscription exists for the identity+scope - -Related spec scenarios: RS.EVT.14, RS.EVT.22 -*/ -func TestEventBroker_HasSubscribers(t *testing.T) { - t.Parallel() - - broker := newEventBroker(nil) - broker.addSubscriptions("/v1", []channelSubscription{ - {address: "/v1/alerts", event: "levelUp"}, - }) - - assert.True(t, broker.hasSubscribers("levelUp", "/v1")) - assert.True(t, broker.hasSubscribers("levelUp", "/v1", true)) - - assert.False(t, broker.hasSubscribers("missing", "/v1")) - assert.False(t, broker.hasSubscribers("levelUp", "/v2")) -} - -/* -Scenario: hasSubscribers and addSubscriptions are safe under concurrency -Given a broker being mutated and queried from multiple goroutines -When subscriptions are added while hasSubscribers and resolveSubscribers run -Then the broker remains consistent (race detector must stay clean) - -Related spec scenarios: RS.EVT.14, RS.EVT.22, RS.MAPI.33 -*/ -func TestEventBroker_HasSubscribersConcurrentWithAdds(t *testing.T) { - broker := newEventBroker(nil) - broker.addSubscriptions("/v0", []channelSubscription{{address: "/v0/base", event: "seed"}}) - - var wg sync.WaitGroup - for i := 0; i < 4; i++ { - wg.Add(1) - go func(seed int) { - defer wg.Done() - for j := 0; j < 500; j++ { - schema := fmt.Sprintf("/s%d", (seed+j)%8) - broker.addSubscriptions(schema, []channelSubscription{{ - address: schema + "/ch", - event: fmt.Sprintf("ev-%d", (seed+j)%16), - }}) - } - }(i) - } - for i := 0; i < 4; i++ { - wg.Add(1) - go func(seed int) { - defer wg.Done() - for j := 0; j < 500; j++ { - schema := fmt.Sprintf("/s%d", (seed+j)%8) - _ = broker.hasSubscribers(fmt.Sprintf("ev-%d", (seed+j)%16), schema) - _, count := broker.resolveSubscribers(fmt.Sprintf("ev-%d", (seed+j)%16), schema) - assert.GreaterOrEqual(t, count, 0) - } - }(i) - } - wg.Wait() -} diff --git a/internal/server/event_delivery_test.go b/internal/server/event_delivery_test.go index 6ef448d..979e2f9 100644 --- a/internal/server/event_delivery_test.go +++ b/internal/server/event_delivery_test.go @@ -295,7 +295,7 @@ func TestSchemaRegistration_AtomicOnError(t *testing.T) { // The valid periodic example ("good") must not have been scheduled, because // the later classification error aborts the whole schema registration. - assert.False(t, bus.scheduler.started("interval---/alerts-good"), + assert.False(t, bus.scheduler.Started("interval---/alerts-good"), "no periodic job may be scheduled when schema registration fails") } @@ -407,7 +407,7 @@ func TestSchemaRegistration_XSendEventsSilentlyIgnored(t *testing.T) { // Neither legacy key may register anything: the named entry would surface // as a "legacyAlert" subscription, the cron entry as an interval job. - assert.Len(t, bus.broker.byEvent, 0, "x-send-events must not register any event subscription") - assert.False(t, bus.scheduler.started("interval---/alerts-cron"), + assert.Equal(t, 0, bus.broker.SubscriptionCount(), "x-send-events must not register any event subscription") + assert.False(t, bus.scheduler.Started("interval---/alerts-cron"), "x-send-events cron must not schedule an interval job") } diff --git a/internal/server/event_server.go b/internal/server/event_server.go index e40dce2..419a027 100644 --- a/internal/server/event_server.go +++ b/internal/server/event_server.go @@ -7,6 +7,7 @@ import ( "time" "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/eventbus" "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" ) @@ -15,8 +16,8 @@ import ( // broker and the interval scheduler, and delegates message rendering/delivery // to the messageDelivery engine. It never reaches into Server. type eventBus struct { - broker *eventBroker - scheduler *jobScheduler + broker *eventbus.Broker + scheduler *eventbus.Scheduler delivery *messageDelivery verbose bool // observer, when set, is invoked with event and schedule envelopes so the @@ -45,15 +46,11 @@ func newEventBus(renderer MessageRenderer, bus ConsumerBus, verbose bool) *event func newEventBusWithObserver(renderer MessageRenderer, bus ConsumerBus, verbose bool, observer func(env manageEnvelope)) *eventBus { delivery := newMessageDelivery(renderer, bus, verbose) b := &eventBus{ - scheduler: newJobScheduler(), + scheduler: eventbus.NewScheduler(), delivery: delivery, verbose: verbose, } - b.broker = &eventBroker{ - byEvent: make(map[string][]channelSubscription), - deliver: delivery.deliver, - done: make(chan struct{}), - } + b.broker = eventbus.NewBroker(delivery.deliver) if observer != nil { b.setObserver(observer) } @@ -61,7 +58,7 @@ func newEventBusWithObserver(renderer MessageRenderer, bus ConsumerBus, verbose } // fire dispatches a named event, reusing the broker's delay semantics. -func (b *eventBus) fire(name string, payload map[string]any, firingSchema string, global bool, delay *delaySchedule) { +func (b *eventBus) fire(name string, payload map[string]any, firingSchema string, global bool, delay *eventbus.DelaySchedule) { if b == nil || b.broker == nil { return } @@ -70,7 +67,7 @@ func (b *eventBus) fire(name string, payload map[string]any, firingSchema string env.Event = &manageEventEnvelope{Name: name, Schema: firingSchema, Global: global, Payload: payload} b.observer(env) } - b.broker.fire(name, payload, firingSchema, global, delay) + b.broker.Fire(name, payload, firingSchema, global, delay) } // fireTargeted fires a built-in event scoped to a single recipient connection @@ -86,7 +83,7 @@ func (b *eventBus) fireTargeted(name string, payload map[string]any, firingSchem env.Event = &manageEventEnvelope{Name: name, Schema: firingSchema, Global: false, Payload: payload} b.observer(env) } - subs, _ := b.broker.resolveSubscribers(name, firingSchema) + subs, _ := b.broker.ResolveSubscribers(name, firingSchema) if len(subs) == 0 { return } @@ -110,7 +107,7 @@ func (b *eventBus) doneChannel() <-chan struct{} { // hasSubscribers reports whether any event-driven example could match an // event identity within a schema scope (cheap gate for built-in firing). func (b *eventBus) hasSubscribers(name, schema string) bool { - return b.broker != nil && b.broker.hasSubscribers(name, schema) + return b.broker != nil && b.broker.HasSubscribers(name, schema) } // shutdown cancels all periodic interval jobs and cancels pending delayed @@ -120,10 +117,10 @@ func (b *eventBus) shutdown() { return } if b.scheduler != nil { - b.scheduler.shutdown() + b.scheduler.Shutdown() } b.delivery.shutdown() - b.broker.stop() + b.broker.Stop() } // registerEventSubscriptions scans AsyncAPI schemas, classifies each message @@ -154,7 +151,7 @@ func (b *eventBus) registerEventSubscriptions(schemas []SchemaInfo) error { // only when all pass are subscriptions and scheduler jobs committed (design // D4, RS.EXT.20/22/28). func (b *eventBus) registerSchema(prefix string, doc *asyncapi.Document) error { - var subs []channelSubscription + var subs []eventbus.ChannelSubscription var periodic []periodicRegistration for _, ch := range doc.Channels { address := asyncAddressWithPrefix(prefix, ch.Address) @@ -168,7 +165,7 @@ func (b *eventBus) registerSchema(prefix string, doc *asyncapi.Document) error { } // Commit phase: nothing above has side effects, so a classification error // from any example leaves the eventBus untouched. - b.broker.addSubscriptions(prefix, subs) + b.broker.AddSubscriptions(prefix, subs) for _, p := range periodic { if _, err := b.registerPeriodic(p.address, p.prefix, p.exampleID, p.spec, p.interval); err != nil { return err @@ -182,7 +179,7 @@ func (b *eventBus) registerSchema(prefix string, doc *asyncapi.Document) error { // to the commit-stage accumulators. Classification is driven purely by the // example's x-mock-match/x-mock-interval trigger extensions; a legacy // x-send-events key, if present, is silently ignored (RS.EVT.18 removed). -func (b *eventBus) classifyMessageExample(channelID, msgName, address, prefix string, ex *asyncapi.Example, subs *[]channelSubscription, periodic *[]periodicRegistration) error { +func (b *eventBus) classifyMessageExample(channelID, msgName, address, prefix string, ex *asyncapi.Example, subs *[]eventbus.ChannelSubscription, periodic *[]periodicRegistration) error { if ex == nil { return nil } @@ -198,13 +195,13 @@ func (b *eventBus) classifyMessageExample(channelID, msgName, address, prefix st } switch trig.Kind { case extensions.TriggerEvent: - *subs = append(*subs, channelSubscription{ - address: address, - event: trig.Identity, - delay: trig.Delay, - messages: []*messageDeliverable{{ - spec: &loader.MessageSpec{Name: msgName, Examples: []*loader.MessageExampleSpec{spec}}, - prefix: prefix, + *subs = append(*subs, eventbus.ChannelSubscription{ + Address: address, + Event: trig.Identity, + Delay: trig.Delay, + Messages: []*eventbus.MessageDeliverable{{ + Spec: &loader.MessageSpec{Name: msgName, Examples: []*loader.MessageExampleSpec{spec}}, + Prefix: prefix, }}, }) case extensions.TriggerPeriodic: @@ -250,13 +247,13 @@ func (b *eventBus) registerRuntimeExample(id, address, prefix string, spec *load } switch trig.Kind { case extensions.TriggerEvent: - b.broker.addSubscriptions(prefix, []channelSubscription{{ - address: address, - event: trig.Identity, - delay: trig.Delay, - messages: []*messageDeliverable{{ - spec: &loader.MessageSpec{Name: "runtime-" + id, Examples: []*loader.MessageExampleSpec{spec}}, - prefix: prefix, + b.broker.AddSubscriptions(prefix, []eventbus.ChannelSubscription{{ + Address: address, + Event: trig.Identity, + Delay: trig.Delay, + Messages: []*eventbus.MessageDeliverable{{ + Spec: &loader.MessageSpec{Name: "runtime-" + id, Examples: []*loader.MessageExampleSpec{spec}}, + Prefix: prefix, }}, }}) return extensions.TriggerEvent, "", nil @@ -276,7 +273,7 @@ func (b *eventBus) removeEventSubscription(prefix, id string) { if b == nil || b.broker == nil { return } - b.broker.removeRuntimeExample(prefix, id) + b.broker.RemoveRuntimeExample(prefix, id) } // registerPeriodic schedules a scheduler job delivering a periodically driven @@ -289,16 +286,16 @@ func (b *eventBus) registerPeriodic(address, prefix, exampleID string, spec *loa } jobID := fmt.Sprintf("interval-%s-%s-%s", prefix, address, exampleID) opID := "event:interval:" + address - job := b.scheduler.add(&scheduledJob{ - id: jobID, - interval: time.Duration(interval) * time.Millisecond, - exampleID: exampleID, - channel: address, - deliver: func() { + job := b.scheduler.Add(&eventbus.ScheduledJob{ + ID: jobID, + Interval: time.Duration(interval) * time.Millisecond, + ExampleID: exampleID, + Channel: address, + Deliver: func() { b.delivery.deliverPeriodic(address, prefix, spec, opID) }, }) - go b.scheduler.run(job) + go b.scheduler.Run(job) if b.observer != nil { env := manageEnvelope{Type: "schedule"} env.Schedule = &manageScheduleEnvelope{Action: "started", ExampleID: exampleID, Channel: address, Interval: interval} @@ -313,14 +310,14 @@ func (b *eventBus) removeIntervalJob(jobID string) { if b == nil || b.scheduler == nil || jobID == "" { return } - job, cancelled := b.scheduler.cancel(jobID) + job, cancelled := b.scheduler.Cancel(jobID) if cancelled && b.observer != nil { env := manageEnvelope{Type: "schedule"} env.Schedule = &manageScheduleEnvelope{ Action: "stopped", - ExampleID: cmp.Or(job.exampleID, jobID), - Channel: job.channel, - Interval: int(job.interval.Milliseconds()), + ExampleID: cmp.Or(job.ExampleID, jobID), + Channel: job.Channel, + Interval: int(job.Interval.Milliseconds()), } b.observer(env) } diff --git a/internal/server/hubmanager.go b/internal/server/hubmanager.go index 9610ef5..2cb33e6 100644 --- a/internal/server/hubmanager.go +++ b/internal/server/hubmanager.go @@ -1,6 +1,10 @@ package server -import "strings" +import ( + "strings" + + "github.com/mamonth/oasmock/internal/asyncapi" +) // hubManager owns the SignalR hubs built from AsyncAPI documents and the raw // ws protocol adapter, exposing connection lookup and payload delivery behind @@ -104,6 +108,7 @@ func (m *hubManager) Candidates(address string) []ConsumerInfo { Channel: ws.channel, Query: ws.query, Headers: ws.headers, + Protocol: asyncapi.ProtocolWS, }) } } @@ -122,6 +127,7 @@ func (m *hubManager) Candidates(address string) []ConsumerInfo { Query: hub.conns.connectionMetadata(connID), Headers: hub.conns.connectionHeaders(connID), Streams: []map[string]string{st}, + Protocol: asyncapi.ProtocolSignalR, }) } } diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index a09e34d..803a6db 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -3,6 +3,7 @@ package server //go:generate mockgen -destination=interfaces_mock_test.go -package=server . RouteProvider,StateStore,HistoryStore,RpcProtocol import ( + "github.com/mamonth/oasmock/internal/eventbus" "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/history" "github.com/mamonth/oasmock/internal/loader" @@ -140,6 +141,9 @@ type ConsumerInfo struct { Query map[string][]string Headers map[string][]string Streams []map[string]string + // Protocol is the consumer transport: "ws" for raw WebSocket consumers or + // "signalr" for SignalR connections (design D7). + Protocol string } // ConsumerBus emits rendered payloads to channel consumers (SignalR open @@ -164,7 +168,7 @@ type ConsumerBus interface { // consumes. It is implemented by eventBus so the server and its tests can // depend on a narrow contract instead of the concrete bus. type asyncDriver interface { - fire(name string, payload map[string]any, schema string, global bool, delay *delaySchedule) + fire(name string, payload map[string]any, schema string, global bool, delay *eventbus.DelaySchedule) fireTargeted(name string, payload map[string]any, schema string, recipient ConsumerInfo) hasSubscribers(name, schema string) bool doneChannel() <-chan struct{} diff --git a/internal/server/job_scheduler.go b/internal/server/job_scheduler.go deleted file mode 100644 index a736ae0..0000000 --- a/internal/server/job_scheduler.go +++ /dev/null @@ -1,125 +0,0 @@ -package server - -import ( - "log/slog" - "sync" - "time" -) - -// scheduledJob is a single per-example recurring delivery job (design D4). -// deliver runs the full delivery pipeline (render + recipient partition + -// push) for the owning example on every tick. -type scheduledJob struct { - id string - interval time.Duration - // exampleID is the client-facing example identity (the POST /_mock/examples - // id for runtime examples, the spec example name otherwise), used for - // schedule lifecycle envelopes (RS.AMG.27). - exampleID string - channel string - stop chan struct{} - deliver func() -} - -// jobScheduler runs per-example interval jobs. It is a pure fabrication -// decoupled from both the HTTP surface and the event broker: delivery is -// injected per job so the scheduler never reaches into Server. -type jobScheduler struct { - mu sync.Mutex - jobs map[string]*scheduledJob -} - -func newJobScheduler() *jobScheduler { - return &jobScheduler{jobs: make(map[string]*scheduledJob)} -} - -// add registers a job and returns it; run must be started in a goroutine. A -// job already registered under the same id is replaced: its stop channel is -// closed so its ticker loop ends and no further deliveries occur. -func (s *jobScheduler) add(job *scheduledJob) *scheduledJob { - if job.stop == nil { - job.stop = make(chan struct{}) - } - s.mu.Lock() - defer s.mu.Unlock() - if existing, ok := s.jobs[job.id]; ok { - delete(s.jobs, job.id) - close(existing.stop) - } - s.jobs[job.id] = job - return job -} - -// run delivers a job at its interval until stopped or shut down. The stop -// channel is checked before each tick so a cancelled job does not run further -// deliveries even when a tick is already due. A panic inside a delivery is -// contained: the job is unregistered so the cadence is not silently lost, the -// panic is logged, and the scheduler keeps serving other jobs. -func (s *jobScheduler) run(job *scheduledJob) { - if job == nil { - return - } - defer func() { - if r := recover(); r != nil { - slog.Error("interval job delivery panicked; job removed", "id", job.id, "panic", r) - s.cancel(job.id) - } - }() - ticker := time.NewTicker(job.interval) - defer ticker.Stop() - for { - select { - case <-job.stop: - return - default: - } - select { - case <-job.stop: - return - case <-ticker.C: - job.deliver() - } - } -} - -// started reports whether a job is currently registered (running or pending). -func (s *jobScheduler) started(id string) bool { - s.mu.Lock() - defer s.mu.Unlock() - _, ok := s.jobs[id] - return ok -} - -// stopped reports whether a job has been fully removed. -func (s *jobScheduler) stopped(id string) bool { - return !s.started(id) -} - -// cancel unregisters a job by id and reports it, returning the removed job so -// the caller can emit lifecycle metadata. The caller closes its stop channel -// to end any in-flight ticker loop. -func (s *jobScheduler) cancel(id string) (*scheduledJob, bool) { - s.mu.Lock() - defer s.mu.Unlock() - job, ok := s.jobs[id] - if !ok { - return nil, false - } - delete(s.jobs, id) - close(job.stop) - return job, true -} - -// shutdown stops all scheduled jobs. Each job's stop channel is closed exactly -// once by deleting it from the map first. -func (s *jobScheduler) shutdown() { - if s == nil { - return - } - s.mu.Lock() - defer s.mu.Unlock() - for id, job := range s.jobs { - delete(s.jobs, id) - close(job.stop) - } -} diff --git a/internal/server/jsonrpc.go b/internal/server/jsonrpc.go index 08c3c3c..dca7c68 100644 --- a/internal/server/jsonrpc.go +++ b/internal/server/jsonrpc.go @@ -32,6 +32,11 @@ func writeBody(w http.ResponseWriter, body []byte) { } } +// ServeHTTP implements the JSON-RPC over HTTP gateway. It is a state machine +// over the batch/single/notification/error response matrix, with the mocked +// status carried in X-Mock-Status on an always-200 transport. +// +//nolint:gocyclo // intentional JSON-RPC response-matrix state machine func (h *RpcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) if err != nil { @@ -102,11 +107,15 @@ func (h *RpcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set(k, v) } w.Header().Set("Content-Type", h.protocol.ContentType()) + // JSON-RPC over HTTP: the transport is always 200, whether the call is a + // result or a protocol error (the JSON-RPC error lives in the body). The + // mocked HTTP status of the selected example is carried in a non-transport + // header so a mocked non-2xx status never conflates with a transport error. sc := singleStatusCode - if sc <= 0 { - sc = http.StatusOK + if sc > 0 && sc != http.StatusOK { + w.Header().Set("X-Mock-Status", strconv.Itoa(sc)) } - w.WriteHeader(sc) + w.WriteHeader(http.StatusOK) writeBody(w, results[0]) } @@ -138,9 +147,9 @@ func isBatchRequest(body []byte) bool { // handleCall resolves the mapping for one JSON-RPC call and executes its mock // pipeline, appending a response entry (a result body or a protocol error) to // results for calls with an id. Notifications run without a response entry. -// It returns the status code and response headers of a successful call (0 and -// nil for notifications and errors), which the single-call path uses for the -// standard HTTP response. +// It returns the mocked status code and response headers of a successful call +// (0 and nil for notifications and errors); the single-call path surfaces the +// mocked status only through the X-Mock-Status header and responds HTTP 200. func (h *RpcHandler) handleCall(call *RpcCall, r *http.Request, pathParamsCache map[string]map[string]string, results *[]json.RawMessage) (int, map[string]string) { if call == nil { return 0, nil diff --git a/internal/server/jsonrpc_handler_test.go b/internal/server/jsonrpc_handler_test.go index 5eb2363..d615391 100644 --- a/internal/server/jsonrpc_handler_test.go +++ b/internal/server/jsonrpc_handler_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -9,6 +10,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/golang/mock/gomock" + "github.com/mamonth/oasmock/internal/loader" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,6 +48,40 @@ paths: return op.Responses } +func createResponsesWithStatus(status string) *openapi3.Responses { + const yamlSpec = ` +openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 +paths: + /test: + get: + responses: + xxxx: + description: OK + content: + application/json: + examples: + default: + value: + message: "Hello, World!" +` + filled := strings.Replace(yamlSpec, "xxxx", status, 1) + ldr := openapi3.NewLoader() + spec, err := ldr.LoadFromData([]byte(filled)) + if err != nil { + panic(err) + } + pathMap := spec.Paths.Map() + pathItem := pathMap["/test"] + op := pathItem.Get + if op == nil { + panic("GET operation not found") + } + return op.Responses +} + func newRpcHandlerWithMocks(t *testing.T) (*RpcHandler, *MockRpcProtocol, *Server) { t.Helper() ctrl := gomock.NewController(t) @@ -428,14 +464,15 @@ func TestRpcHandler_ResponseHeaders(t *testing.T) { } /* -Scenario: RpcHandler propagates response status code from example -Given a handler with a mapping that returns a specific status code +Scenario: RpcHandler keeps the transport 200 and surfaces a mocked status in X-Mock-Status +Given a handler with a mapping whose example carries a non-200 status code When ServeHTTP is called -Then the HTTP response uses that status code +Then the HTTP response is 200 (JSON-RPC over HTTP transport) and the mocked +status is exposed in the X-Mock-Status header instead of the transport status -Related spec scenarios: RS.JRP.17 +Related spec scenarios: RS.JRP.35 */ -func TestRpcHandler_ResponseStatusCode(t *testing.T) { +func TestRpcHandler_MockedStatusInHeader(t *testing.T) { t.Parallel() handler, proto, _ := newRpcHandlerWithMocks(t) @@ -445,7 +482,7 @@ func TestRpcHandler_ResponseStatusCode(t *testing.T) { Path: "/rpc/s", Pattern: "/rpc/s", ChiPattern: "/rpc/s", - Responses: createResponsesWithExample(), + Responses: createResponsesWithStatus("502"), } handler.procedureMap["s"] = mapping @@ -459,41 +496,112 @@ func TestRpcHandler_ResponseStatusCode(t *testing.T) { handler.ServeHTTP(w, req) resp := w.Result() - assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, http.StatusOK, resp.StatusCode, "JSON-RPC transport must always answer 200 for a single call") + assert.Equal(t, "502", resp.Header.Get("X-Mock-Status"), "mocked status must be carried in X-Mock-Status") } /* -Scenario: RpcHandler extracts procedure path params from the request URL -Given a procedure whose route is /rpc/users/{id} invoked at /rpc/users/123 +Scenario: RpcHandler omits X-Mock-Status for a default 200 mocked status +Given a handler with a mapping whose example carries the default 200 status When ServeHTTP is called -Then path param id=123 is captured against the procedure's own ChiPattern -(even though the gateway route itself has no params) +Then the transport is 200 and no X-Mock-Status header is set -Related spec scenarios: RS.JRP.34 +Related spec scenarios: RS.JRP.36 */ -func TestRpcHandler_ProcedurePathParams(t *testing.T) { +func TestRpcHandler_NoStatusHeaderFor200(t *testing.T) { t.Parallel() handler, proto, _ := newRpcHandlerWithMocks(t) mapping := &RouteMapping{ Method: "POST", - Path: "/rpc/users/{id}", - Pattern: "/rpc/users/{id}", - ChiPattern: "/rpc/users/{id}", + Path: "/rpc/s", + Pattern: "/rpc/s", + ChiPattern: "/rpc/s", Responses: createResponsesWithExample(), } - handler.procedureMap["getUser"] = mapping + handler.procedureMap["s"] = mapping - call := RpcCall{Procedure: "getUser", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "getUser", "id": float64(1)}, ID: float64(1), HasID: true} + call := RpcCall{Procedure: "s", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "s", "id": float64(1)}, ID: float64(1), HasID: true} proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &call}}, nil) proto.EXPECT().ContentType().Return("application/json") - req := httptest.NewRequest(http.MethodPost, "/rpc/users/123", nil) + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) resp := w.Result() assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, resp.Header.Get("X-Mock-Status"), "default 200 mocked status must not set X-Mock-Status") +} + +/* +Scenario: RpcHandler extracts procedure path params from the request URL +Given a procedure whose route is /rpc/users/{id} invoked at /rpc/users/123 +When the request is routed through the real router +Then path param id=123 is captured from chi against the procedure's own +ChiPattern (the procedure path is mounted so {$request.path.id} resolves) + +Related spec scenarios: RS.JRP.34 +*/ +func TestRpcHandler_ProcedurePathParams(t *testing.T) { + t.Parallel() + + const spec = ` +openapi: 3.0.3 +info: + title: RPC Param API + version: 1.0.0 +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +paths: + /rpc/users/{id}: + post: + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: "{$request.path.id}" + id: "{$request.body.id}" +` + ldr := openapi3.NewLoader() + parsed, err := ldr.LoadFromData([]byte(spec)) + require.NoError(t, err) + require.NoError(t, parsed.Validate(ldr.Context)) + + schemas := []loader.SchemaInfo{{Kind: loader.KindOpenAPI, Spec: parsed, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + defer func() { _ = srv.Shutdown(context.Background()) }() + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"jsonrpc":"2.0","method":"getUser","id":1}` + resp, err := http.Post(ts.URL+"/rpc/users/123", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, "123", result["result"], "{$request.path.id} must resolve to the chi-captured param") + assert.Equal(t, float64(1), result["id"]) } diff --git a/internal/server/manage_ws.go b/internal/server/manage_ws.go index 6147346..7203b6c 100644 --- a/internal/server/manage_ws.go +++ b/internal/server/manage_ws.go @@ -39,6 +39,7 @@ type manageConsumerEnvelope struct { Action string `json:"action"` ConnectionID string `json:"connectionId"` Channel string `json:"channel"` + Protocol string `json:"protocol,omitempty"` Streams []map[string]string `json:"streams,omitempty"` } @@ -126,24 +127,7 @@ func filterMatches(f streamFilter, env manageEnvelope) bool { return false } if len(f.channels) > 0 { - channel := "" - switch env.Type { - case "push": - if env.Push != nil { - channel = env.Push.Channel - } - case "consumer": - if env.Consumer != nil { - channel = env.Consumer.Channel - } - case "schedule": - if env.Schedule != nil { - channel = env.Schedule.Channel - } - case "event": - // Event envelopes carry a schema scope, not a channel; the channels - // filter does not apply to them. - } + channel := envelopeChannel(env) if channel != "" && !globMatchAny(f.channels, channel) { return false } @@ -151,6 +135,30 @@ func filterMatches(f streamFilter, env manageEnvelope) bool { return true } +// envelopeChannel resolves the channel a notification envelope refers to. Event +// envelopes carry a schema scope, not a channel, so the channels filter does +// not apply to them (empty result). +func envelopeChannel(env manageEnvelope) string { + switch env.Type { + case "push": + if env.Push != nil { + return env.Push.Channel + } + case "consumer": + if env.Consumer != nil { + return env.Consumer.Channel + } + case "schedule": + if env.Schedule != nil { + return env.Schedule.Channel + } + case "event": + // Event envelopes carry a schema scope, not a channel; the channels + // filter does not apply to them. + } + return "" +} + // globMatchAny reports whether a value matches any comma-separated glob in the // list ("*" matches everything). func globMatchAny(patterns []string, value string) bool { @@ -166,6 +174,8 @@ func globMatchAny(patterns []string, value string) bool { // segment between '*'s must appear in value in order. The first segment is // anchored to the start when the pattern begins with a literal, and the last // segment is anchored to the end when the pattern ends with a literal. +// +//nolint:gocyclo // segment-anchoring walk over split glob parts func globMatch(pattern, value string) bool { if pattern == "*" { return true @@ -325,6 +335,7 @@ func (ms *manageStream) notifyConsumer(action, channel string, info ConsumerInfo Action: action, ConnectionID: info.ConnectionID, Channel: channel, + Protocol: info.Protocol, Streams: info.Streams, }, }) diff --git a/internal/server/management_async.go b/internal/server/management_async.go index b489b25..db5b791 100644 --- a/internal/server/management_async.go +++ b/internal/server/management_async.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" "github.com/mamonth/oasmock/internal/runtime" ) @@ -20,7 +21,10 @@ type asyncMessageRequest struct { } // handleAsyncMessage pushes a message to channel consumers (immediate or -// delayed, targeted or broadcast). +// delayed, targeted or broadcast). It branches on deliverable class and +// preconditions before delegating to the push/registry helpers. +// +//nolint:gocyclo // targeted/broadcast/delayed precondition branches func (s *Server) handleAsyncMessage(w http.ResponseWriter, r *http.Request) { req, err := decodeAsyncMessage(r) if err != nil { @@ -163,12 +167,16 @@ func matchingHubChannel(hub *signalRHub, address string) string { } // handleAsyncConsumers lists active consumers per channel (RS.AMG.8-9) or -// across all channels when the channel filter is omitted (RS.AMG.22). +// across all channels when the channel filter is omitted (RS.AMG.22). It +// branches on the ws/SignalR registries and the channel scope. +// +//nolint:gocyclo // ws registry + SignalR hub union branches func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { channel := r.URL.Query().Get("channel") type consumerInfo struct { ConnectionID string `json:"connectionId"` Channel string `json:"channel"` + Protocol string `json:"protocol"` Streams []map[string]string `json:"streams,omitempty"` } consumers := []consumerInfo{} @@ -181,7 +189,7 @@ func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { conns = reg.connections(channel) } for _, ws := range conns { - consumers = append(consumers, consumerInfo{ConnectionID: ws.id, Channel: ws.channel}) + consumers = append(consumers, consumerInfo{ConnectionID: ws.id, Channel: ws.channel, Protocol: asyncapi.ProtocolWS}) } } if channel == "" { @@ -193,6 +201,7 @@ func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { consumers = append(consumers, consumerInfo{ ConnectionID: st["connectionId"], Channel: address, + Protocol: asyncapi.ProtocolSignalR, Streams: []map[string]string{st}, }) } @@ -204,6 +213,7 @@ func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { consumers = append(consumers, consumerInfo{ ConnectionID: st["connectionId"], Channel: channel, + Protocol: asyncapi.ProtocolSignalR, Streams: []map[string]string{st}, }) } diff --git a/internal/server/message_delivery.go b/internal/server/message_delivery.go index f5457dd..6815123 100644 --- a/internal/server/message_delivery.go +++ b/internal/server/message_delivery.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/mamonth/oasmock/internal/eventbus" "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" "github.com/mamonth/oasmock/internal/runtime" @@ -67,7 +68,7 @@ func (d *messageDelivery) shutdown() { } // deliver delivers an event payload to a subscription's consumers (broadcast). -func (d *messageDelivery) deliver(sub channelSubscription, payload map[string]any) { +func (d *messageDelivery) deliver(sub eventbus.ChannelSubscription, payload map[string]any) { d.deliverTo(sub, payload, nil) } @@ -75,20 +76,20 @@ func (d *messageDelivery) deliver(sub channelSubscription, payload map[string]an // The connection bucket (if any) is evaluated against that one recipient only; // with no connection conditions the message is pushed to the recipient alone // (RS.EVT.24, RS.EXT.26). -func (d *messageDelivery) deliverTargeted(sub channelSubscription, payload map[string]any, recipient ConsumerInfo) { +func (d *messageDelivery) deliverTargeted(sub eventbus.ChannelSubscription, payload map[string]any, recipient ConsumerInfo) { d.deliverTo(sub, payload, &recipient) } // deliverTo runs the shared delayed-emission + delivery pipeline for a // subscription. When target is non-nil, delivery is restricted to that single // candidate (built-in connect recipient). -func (d *messageDelivery) deliverTo(sub channelSubscription, payload map[string]any, target *ConsumerInfo) { - if len(sub.messages) == 0 { +func (d *messageDelivery) deliverTo(sub eventbus.ChannelSubscription, payload map[string]any, target *ConsumerInfo) { + if len(sub.Messages) == 0 { return } - if sub.delay > 0 { - ms := sub.delay - sub.delay = 0 + if sub.Delay > 0 { + ms := sub.Delay + sub.Delay = 0 go func() { select { case <-d.done: @@ -99,13 +100,13 @@ func (d *messageDelivery) deliverTo(sub channelSubscription, payload map[string] }() return } - deliverable := sub.messages[0] - addr := sub.address - prefix := deliverable.prefix - eventName := sub.event - opID := "event:" + cmp.Or(eventName, anyEventIdentity) + ":" + addr + deliverable := sub.Messages[0] + addr := sub.Address + prefix := deliverable.Prefix + eventName := sub.Event + opID := "event:" + cmp.Or(eventName, eventbus.AnyEventIdentity) + ":" + addr - d.deliverExample(sub, deliverable.spec.Examples, addr, prefix, eventName, payload, opID, target) + d.deliverExample(sub, deliverable.Spec.Examples, addr, prefix, eventName, payload, opID, target) } // stateEnvEvaluator wires the fixed state and environment sources shared by @@ -162,8 +163,12 @@ func (d *messageDelivery) evaluateConnectionBucket(bucket extensions.ParamsMatch // deliverExample runs the shared selection + render + recipient-partition // pipeline for one subscription's examples. When target is non-nil, delivery -// is restricted to that single candidate (built-in connect recipient). -func (d *messageDelivery) deliverExample(sub channelSubscription, examples []*loader.MessageExampleSpec, addr, prefix, eventName string, payload map[string]any, opID string, target *ConsumerInfo) { +// is restricted to that single candidate (built-in connect recipient). The +// branches cover per-example match/target/broadcast/per-connection partition +// paths that are inherent to the two-phase delivery design (design D6). +// +//nolint:gocyclo // intentional two-phase recipient-partition pipeline +func (d *messageDelivery) deliverExample(sub eventbus.ChannelSubscription, examples []*loader.MessageExampleSpec, addr, prefix, eventName string, payload map[string]any, opID string, target *ConsumerInfo) { // Fixed (non-connection) sources evaluated once per emission. state := d.renderer.NewStateSource(prefix) env := d.renderer.NewEnvSource() diff --git a/internal/server/server_http.go b/internal/server/server_http.go index 9d4a09f..07f23ff 100644 --- a/internal/server/server_http.go +++ b/internal/server/server_http.go @@ -15,6 +15,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" + "github.com/mamonth/oasmock/internal/eventbus" "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/runtime" ) @@ -272,11 +273,11 @@ func (s *Server) fireExampleTriggers(example *openapi3.Example, prefix string) { } // triggerDelay maps a trigger delay (ms) to a schedule. -func triggerDelay(ms int) *delaySchedule { +func triggerDelay(ms int) *eventbus.DelaySchedule { if ms <= 0 { return nil } - return &delaySchedule{ms: ms} + return &eventbus.DelaySchedule{Ms: ms} } func parseStatusCode(codeStr string) int { @@ -359,12 +360,14 @@ func (s *Server) verboseLoggingMiddleware(next http.Handler) http.Handler { } // extractPathParams extracts path parameters from the request using chi URL -// params, falling back to pattern-matching the request path against the -// mapping's brace-form ChiPattern when chi has not populated them (the RPC -// gateway dispatch and direct handler invocations). +// params. Path parameters are only authoritative when chi has routed the +// request against a brace-form pattern (mock routes, protocol adapters and the +// RPC gateway's procedure routes); non-routed invocations yield no params. func (s *Server) extractPathParams(r *http.Request, mapping *RouteMapping) map[string]string { params := make(map[string]string) - // Get chi route context + if r == nil { + return params + } ctx := chi.RouteContext(r.Context()) if s.config.Verbose { slog.Debug("extractPathParams", "ctxNil", ctx == nil, "method", r.Method, "path", r.URL.Path, "chiPattern", mapping.ChiPattern) @@ -376,42 +379,6 @@ func (s *Server) extractPathParams(r *http.Request, mapping *RouteMapping) map[s params[key] = ctx.URLParams.Values[i] } } - if len(params) > 0 { - return params - } - } - if mapping == nil || r == nil || r.URL == nil { - return params } - matchPathParams(params, r.URL.Path, mapping.ChiPattern) return params } - -// matchPathParams extracts {param} captures by matching each segment of the -// actual request path against the corresponding segment of the brace-form chi -// pattern. Only well-formed brace segments capture; literal and malformed -// segments must match exactly. -func matchPathParams(params map[string]string, path, pattern string) { - if pattern == "" || path == "" { - return - } - patSegs := splitPathSegments(pattern) - pathSegs := splitPathSegments(path) - if len(patSegs) != len(pathSegs) { - return - } - for i, seg := range patSegs { - if len(seg) > 2 && seg[0] == '{' && seg[len(seg)-1] == '}' { - name := seg[1 : len(seg)-1] - if name != "" && !strings.ContainsAny(name, " \t") { - params[name] = pathSegs[i] - } - } - } -} - -// splitPathSegments splits an absolute path on "/" preserving empty segments -// for exact length comparison. -func splitPathSegments(p string) []string { - return strings.Split(p, "/") -} diff --git a/internal/server/server_management.go b/internal/server/server_management.go index 65c4e2e..cf9532a 100644 --- a/internal/server/server_management.go +++ b/internal/server/server_management.go @@ -58,9 +58,12 @@ func (s *Server) findAsyncRouteMapping(protocol, channel, method string) *RouteM } // addExampleRequestSchema is the oneOf two-branch request schema for -// POST /_mock/examples (design D2). Branch A is the sync (OpenAPI) target: -// required path+response and no async-only fields. Branch B is the async -// (AsyncAPI) target: required channel+response and no path. +// POST /_mock/examples (design D2). The target discriminator is: the sync +// (OpenAPI) branch requires `path`+`response` and forbids every async-only +// field (protocol/channel/match/interval/delay); the async (AsyncAPI) branch +// requires `channel`+`response` and forbids `path`. The fence is deliberately +// strict so a future third target kind requires extending these oneOf branches +// (and the validation tests) rather than silently accepting mixed targeting. var addExampleRequestSchema = gojsonschema.NewGoLoader(map[string]any{ "type": "object", "required": []string{"response"}, diff --git a/internal/server/server_requests.go b/internal/server/server_requests.go index 10b3fa2..714c515 100644 --- a/internal/server/server_requests.go +++ b/internal/server/server_requests.go @@ -10,35 +10,45 @@ import ( func filterRecords(records []RequestRecord, query url.Values) []RequestRecord { filtered := make([]RequestRecord, 0, len(records)) for _, rec := range records { - // Filter by path - if path := query.Get("path"); path != "" && rec.Path != path { - continue + if recordMatchesFilter(rec, query) { + filtered = append(filtered, rec) } - // Filter by method - if method := query.Get("method"); method != "" && rec.Method != method { - continue - } - // Filter by time_from (milliseconds since epoch) - if timeFromStr := query.Get("time_from"); timeFromStr != "" { - if timeFrom, err := strconv.ParseInt(timeFromStr, 10, 64); err == nil { - if rec.Timestamp.UnixMilli() < timeFrom { - continue - } - } - } - // Filter by time_till - if timeTillStr := query.Get("time_till"); timeTillStr != "" { - if timeTill, err := strconv.ParseInt(timeTillStr, 10, 64); err == nil { - if rec.Timestamp.UnixMilli() > timeTill { - continue - } - } - } - filtered = append(filtered, rec) } return filtered } +// recordMatchesFilter reports whether a record satisfies every filter in query. +func recordMatchesFilter(rec RequestRecord, query url.Values) bool { + return matchesStringFilter(rec.Path, query.Get("path")) && + matchesStringFilter(rec.Method, query.Get("method")) && + matchesTimeFilter(rec.Timestamp.UnixMilli(), "time_from", query, false) && + matchesTimeFilter(rec.Timestamp.UnixMilli(), "time_till", query, true) +} + +// matchesStringFilter reports whether the value equals the query filter, or the +// filter is empty (unset filters always match). +func matchesStringFilter(value, filter string) bool { + return filter == "" || value == filter +} + +// matchesTimeFilter reports whether the timestamp satisfies a millisecond +// range filter. When after is true the timestamp must not exceed the bound; +// otherwise it must not precede it. Malformed bounds are ignored. +func matchesTimeFilter(ts int64, key string, query url.Values, after bool) bool { + raw := query.Get(key) + if raw == "" { + return true + } + bound, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return true + } + if after { + return ts <= bound + } + return ts >= bound +} + // paginateRecords applies offset and limit pagination to records. func paginateRecords(records []RequestRecord, offset, limit int) []RequestRecord { if offset < 0 { diff --git a/internal/server/server_routes.go b/internal/server/server_routes.go index bca4c5d..0355287 100644 --- a/internal/server/server_routes.go +++ b/internal/server/server_routes.go @@ -57,6 +57,20 @@ func (s *Server) setupRouter() { // Register RPC gateway route if configured if s.rpcHandler != nil { r.Post(s.gatewayPath, s.rpcHandler.ServeHTTP) + // Also mount each procedure's own path so a client may invoke a + // procedure directly at /rpc/users/123. Routing through chi populates + // RouteContext.URLParams from the procedure's brace pattern, so path + // parameters resolve without a manual URL fallback (RS.JRP.34). + // The gateway itself is already mounted; avoid chi's duplicate-route + // panic when a procedure is declared exactly at the gateway path. + seen := map[string]bool{s.gatewayPath: true} + for _, rm := range s.rpcMappings { + if seen[rm.ChiPattern] { + continue + } + seen[rm.ChiPattern] = true + r.Post(rm.ChiPattern, s.rpcHandler.ServeHTTP) + } slog.Info("Registered RPC gateway", "path", s.gatewayPath, "procedures", len(s.rpcHandler.procedureMap)) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index b89afe9..7777ab8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -483,7 +483,7 @@ func TestReplaceEmbeddedExpressions(t *testing.T) { Scenario: Extracting path parameters from HTTP request Given an HTTP request with Chi route context containing URL parameters When extractPathParams is called -Then it returns a map with parameter names and values +Then it returns a map with parameter names and values only when chi populated them Related spec scenarios: RS.MSC.5 */ @@ -516,13 +516,13 @@ func TestExtractPathParams(t *testing.T) { want: map[string]string{}, }, { - name: "no chi context but mapping chi pattern has params", + name: "no chi context yields no params even with a parameterized pattern", setupRequest: func() *http.Request { req, _ := http.NewRequest(http.MethodGet, "/users/123", nil) return req }, mapping: &RouteMapping{ChiPattern: "/users/{id}"}, - want: map[string]string{"id": "123"}, + want: map[string]string{}, }, { name: "with path parameters", @@ -1909,14 +1909,59 @@ func TestSelectResponse(t *testing.T) { } /* - Scenario: parseStatusCode converts status code string to int - Given a status code string - When parseStatusCode is called - Then it should return the appropriate integer status code +Scenario: responseOrder gives a declarative total order over response keys +Given pairs of response-status keys (numeric, default, non-numeric) +When responseOrder compares them +Then numeric codes sort ascending, "default" last, non-numeric fallback lexical - Related spec scenarios: RS.MSC.27 +Related spec scenarios: RS.MSC.8, RS.MSC.9 */ +func TestResponseOrder(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b string + want int // sign of expected comparison + }{ + {name: "numeric ascending", a: "200", b: "201", want: -1}, + {name: "numeric descending reversed", a: "500", b: "404", want: 1}, + {name: "numeric before default", a: "200", b: "default", want: -1}, + {name: "default after numeric", a: "default", b: "200", want: 1}, + {name: "default equals default", a: "default", b: "default", want: 0}, + {name: "numeric before non-numeric", a: "200", b: "foo", want: -1}, + {name: "non-numeric after numeric", a: "bar", b: "200", want: 1}, + {name: "non-numeric lexical", a: "abc", b: "abd", want: -1}, + {name: "non-numeric lexical reversed", a: "zed", b: "aaa", want: 1}, + {name: "non-numeric equal", a: "xyz", b: "xyz", want: 0}, + {name: "default last over non-numeric", a: "default", b: "abc", want: 1}, + {name: "non-numeric before default", a: "abc", b: "default", want: -1}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := responseOrder(tt.a, tt.b) + if tt.want < 0 { + assert.Negative(t, got) + } else if tt.want > 0 { + assert.Positive(t, got) + } else { + assert.Zero(t, got) + } + }) + } +} + +/* +Scenario: parseStatusCode converts status code string to int +Given a status code string +When parseStatusCode is called +Then it should return the appropriate integer status code + +Related spec scenarios: RS.MSC.27 +*/ func TestParseStatusCode(t *testing.T) { t.Parallel() diff --git a/internal/server/signalr_hub.go b/internal/server/signalr_hub.go index d332095..c04d0a4 100644 --- a/internal/server/signalr_hub.go +++ b/internal/server/signalr_hub.go @@ -213,6 +213,7 @@ func (h *signalRHub) serveUpgrade(w http.ResponseWriter, r *http.Request) { Channel: channel, Query: sc.query, Headers: sc.headers, + Protocol: asyncapi.ProtocolSignalR, } if h.hooks.OnConnect != nil { h.hooks.OnConnect(channel, connID, info) diff --git a/internal/server/wrappers.go b/internal/server/wrappers.go index b35cccb..91a28c9 100644 --- a/internal/server/wrappers.go +++ b/internal/server/wrappers.go @@ -6,6 +6,15 @@ import ( "github.com/mamonth/oasmock/internal/state" ) +// The wrappers below adapt the concrete infrastructure types (state.Manager, +// history.RingBuffer, loader) to the Server's dependency interfaces. They keep +// the Server testable through mock_server without accepting the concrete +// *state.Manager/*history.RingBuffer/*loader types directly: unit tests inject +// generated mocks of StateStore/HistoryStore/RouteProvider (see mock/), and the +// wrappers turn the production implementations into those interfaces. The +// pass-through methods are intentional — they exist only to satisfy the +// interface contract, not to add behavior. + // loaderRouteProvider wraps loader package to implement RouteProvider. type loaderRouteProvider struct{} diff --git a/internal/server/ws_adapter.go b/internal/server/ws_adapter.go index ed63299..a5537ec 100644 --- a/internal/server/ws_adapter.go +++ b/internal/server/ws_adapter.go @@ -235,7 +235,12 @@ func newWSProtocolAdapter() *wsProtocolAdapter { // Protocol implements ProtocolAdapter. func (a *wsProtocolAdapter) Protocol() string { return asyncapi.ProtocolWS } -// Handler builds the WebSocket upgrade handler for an AsyncAPI ws channel. +// Handler builds the WebSocket upgrade handler for an AsyncAPI ws channel. It +// is a lifecycle state machine (upgrade, registry, connect hooks, send/receive +// handling, reply dispatch, read loop) whose branches are interleaved by +// protocol. +// +//nolint:gocognit,gocyclo // intentional WebSocket lifecycle state machine func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { conn, err := wsUpgrader.Upgrade(w, r, nil) @@ -254,6 +259,7 @@ func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandle Channel: channel, Query: r.URL.Query(), Headers: lowerHeaderKeys(r.Header), + Protocol: asyncapi.ProtocolWS, } if a.hooks.OnConnect != nil { a.hooks.OnConnect(channel, id, info) diff --git a/openspec/specs/asyncapi-management/spec.md b/openspec/specs/asyncapi-management/spec.md index 01b0fd3..2229085 100644 --- a/openspec/specs/asyncapi-management/spec.md +++ b/openspec/specs/asyncapi-management/spec.md @@ -44,7 +44,7 @@ The mock server SHALL expose the currently connected consumers per AsyncAPI chan #### Scenario RS.AMG.8: Listing connected consumers - **WHEN** a management request queries consumers for an AsyncAPI channel with active connections -- **THEN** the server returns the consumer list with connection IDs, channel/address details, and open streams (for SignalR hubs) +- **THEN** the server returns the consumer list with connection IDs, channel/address details, the consumer's `protocol` (`ws` for raw WebSocket or `signalr` for SignalR), and open streams (for SignalR hubs) #### Scenario RS.AMG.9: Listing consumers for a channel with no connections - **WHEN** a management request queries consumers for an AsyncAPI channel with no active connections @@ -52,7 +52,7 @@ The mock server SHALL expose the currently connected consumers per AsyncAPI chan #### Scenario RS.AMG.22: Listing all consumers without a channel filter - **WHEN** a management request queries consumers without a `channel` parameter and consumers are connected on multiple channels -- **THEN** the server returns a single flat list of consumers across all channels (raw ws and SignalR), and an empty list when none are connected +- **THEN** the server returns a single flat list of consumers across all channels (raw ws and SignalR, each tagged with its `protocol`), and an empty list when none are connected ### Requirement: Templated push payloads Pushed message payloads SHALL support runtime expressions ({$state.*}, {$env.*}) evaluated at delivery time, using the schema's state namespace. @@ -120,7 +120,7 @@ The mock server SHALL expose a general management WebSocket stream at `GET /_moc #### Scenario RS.AMG.26: Receiving consumer lifecycle envelopes - **WHEN** a consumer connects to or disconnects from a channel (raw ws or SignalR) -- **THEN** a subscribed client receives an envelope of type `consumer` with a `connected`/`disconnected` action, connection ID, and channel +- **THEN** a subscribed client receives an envelope of type `consumer` with a `connected`/`disconnected` action, connection ID, channel, and the consumer's `protocol` #### Scenario RS.AMG.27: Receiving schedule start/stop envelopes - **WHEN** a periodic message example is registered with `interval` via `POST /_mock/examples` (or spec `x-mock-interval`) or removed via `DELETE /_mock/examples/{exampleId}` diff --git a/openspec/specs/json-rpc/spec.md b/openspec/specs/json-rpc/spec.md index 9020054..976e225 100644 --- a/openspec/specs/json-rpc/spec.md +++ b/openspec/specs/json-rpc/spec.md @@ -134,8 +134,19 @@ The mock server SHALL evaluate `{$request.body.*}` against the individual call o - **THEN** each call's `x-mock-params-match` conditions evaluate against its own params object, not the batch array #### Scenario RS.JRP.34: Procedure path parameters resolve through the gateway -- **WHEN** a procedure is backed by a route such as `/rpc/users/{id}` and the request URL is `/rpc/users/123` -- **THEN** `{$request.path.id}` resolves to `123` (the params are extracted against the procedure's own route pattern, not the gateway route) +- **WHEN** a procedure is backed by a route such as `/rpc/users/{id}` and a client posts to `/rpc/users/123` (the procedure's own path is mounted as a route alongside the gateway) +- **THEN** `{$request.path.id}` resolves to `123` (chi captures the params against the procedure's brace-form route pattern, without a manual URL fallback) + +### Requirement: HTTP transport semantics +The mock server SHALL answer JSON-RPC over HTTP with a transport status of 200 for every valid single-call or batch response, whether the call produced a JSON-RPC result or a JSON-RPC error, and SHALL NOT use a mocked example's HTTP status code as the transport status. A mocked example's non-200 status SHALL be exposed in the `X-Mock-Status` response header instead. + +#### Scenario RS.JRP.35: Mocked non-2xx status uses the X-Mock-Status header +- **WHEN** a procedure's selected example declares a response status of `502` +- **THEN** the HTTP response status is `200` and the response carries an `X-Mock-Status: 502` header + +#### Scenario RS.JRP.36: Default 200 mocked status sets no X-Mock-Status header +- **WHEN** a procedure's selected example declares the default `200` response +- **THEN** the HTTP response status is `200` and no `X-Mock-Status` header is present ### Requirement: Extension compatibility All existing `x-mock-*` extensions SHALL work identically for JSON-RPC calls as for HTTP requests. diff --git a/test/_shared/resources/test-rpc.yaml b/test/_shared/resources/test-rpc.yaml index 09177f0..a08ae58 100644 --- a/test/_shared/resources/test-rpc.yaml +++ b/test/_shared/resources/test-rpc.yaml @@ -159,6 +159,25 @@ paths: jsonrpc: "2.0" result: with-headers id: "{$request.body.id}" + /rpc/status: + post: + operationId: status + requestBody: + content: + application/json: + schema: + type: object + responses: + "502": + description: Bad Gateway + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: bad-gateway + id: "{$request.body.id}" /rpc/paramsMatch: post: operationId: paramsMatch @@ -185,3 +204,23 @@ paths: jsonrpc: "2.0" result: user-response id: "{$request.body.id}" + /rpc/users/{id}: + post: + operationId: getUser + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: "user-{$request.path.id}" + id: "{$request.body.id}" diff --git a/test/asyncapi/management-api/management_api_test.go b/test/asyncapi/management-api/management_api_test.go index ad538fb..8f38aca 100644 --- a/test/asyncapi/management-api/management_api_test.go +++ b/test/asyncapi/management-api/management_api_test.go @@ -469,6 +469,7 @@ Then the filtered subscriber receives schedule/consumer/event/push envelopes but Related spec scenarios: RS.AMG.23, RS.AMG.24, RS.AMG.25, RS.AMG.26, RS.AMG.27 */ +//nolint:gocyclo // black-box assertion matrix over four envelope kinds func TestIntegration_ManageStream_Envelopes(t *testing.T) { t.Parallel() port, stop := startManagementServer(t) diff --git a/test/jsonrpc/rpc_integration_test.go b/test/jsonrpc/rpc_integration_test.go index dab9904..2cc39fa 100644 --- a/test/jsonrpc/rpc_integration_test.go +++ b/test/jsonrpc/rpc_integration_test.go @@ -286,6 +286,91 @@ func TestRpcParseError(t *testing.T) { } } +/* +Scenario: Mocked non-2xx status surfaces in X-Mock-Status, transport stays 200 +Given an RPC gateway with a procedure declaring a 502 response +When a single JSON-RPC call hits it +Then the transport status is 200 and X-Mock-Status is 502 + +Related spec scenarios: RS.JRP.35 +*/ +func TestRpcMockedStatusInHeader(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"status","params":{},"id":1}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusOK, resp.StatusCode, "JSON-RPC transport must always answer 200 for a single call") + assert.Equal(t, "502", resp.Header.Get("X-Mock-Status"), "mocked status must be carried in X-Mock-Status") + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "bad-gateway", result["result"]) + assert.Equal(t, float64(1), result["id"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: Procedure path parameters resolve via the mounted procedure route +Given an RPC procedure /rpc/users/{id} +When a call is posted directly to /rpc/users/123 +Then the procedure executes and {$request.path.id} resolves to 123 + +Related spec scenarios: RS.JRP.34 +*/ +func TestRpcProcedurePathResolves(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"getUser","id":1}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc/users/123", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "user-123", result["result"]) + assert.Equal(t, float64(1), result["id"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + /* Scenario: RPC and HTTP routes coexist in the same spec Given a schema with both RPC gateway and normal HTTP routes From 37f0ddf6520fef4f99885b9729747b1eefe03d5e Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Mon, 7 Sep 2026 17:19:59 +0300 Subject: [PATCH 2/4] Unify example-selection onto single conditions field Remove the async-only match wire field from POST /_mock/examples and make conditions the single selector for every target kind. Async targets now map conditions onto the existing x-mock-match extension (event/connection/reply contexts), strict validation rejects a stale match field (RS.MAPI.37), and the generated AddExampleRequest schema is regenerated from api/openapi.yaml as the single source of truth via the new gen-control-schema tool. Update specs, docs, and tests; archive the unify-example-match-conditions change. --- .github/workflows/ci.yml | 24 ++ .gitignore | 1 + .spectral.yaml | 7 + CHANGELOG.md | 7 +- Makefile | 14 +- api/openapi.yaml | 188 +++++---------- cmd/gen-control-schema/main.go | 227 ++++++++++++++++++ cmd/gen-control-schema/main_test.go | 170 +++++++++++++ docs/extensions.md | 4 +- .../server/add_example_request_schema_gen.go | 10 + internal/server/add_example_runtime_test.go | 6 +- .../server/add_example_validation_test.go | 51 +++- internal/server/async_http_adapter_test.go | 57 +++++ internal/server/generate.go | 3 + internal/server/server_management.go | 156 +++++------- internal/server/server_test.go | 43 +++- .../.openspec.yaml | 2 + .../README.md | 3 + .../design.md | 109 +++++++++ .../proposal.md | 59 +++++ .../specs/management-api/spec.md | 47 ++++ .../tasks.md | 31 +++ openspec/specs/management-api/spec.md | 24 +- 23 files changed, 988 insertions(+), 255 deletions(-) create mode 100644 .spectral.yaml create mode 100644 cmd/gen-control-schema/main.go create mode 100644 cmd/gen-control-schema/main_test.go create mode 100644 internal/server/add_example_request_schema_gen.go create mode 100644 internal/server/generate.go create mode 100644 openspec/changes/archive/2026-09-07-unify-example-match-conditions/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-07-unify-example-match-conditions/README.md create mode 100644 openspec/changes/archive/2026-09-07-unify-example-match-conditions/design.md create mode 100644 openspec/changes/archive/2026-09-07-unify-example-match-conditions/proposal.md create mode 100644 openspec/changes/archive/2026-09-07-unify-example-match-conditions/specs/management-api/spec.md create mode 100644 openspec/changes/archive/2026-09-07-unify-example-match-conditions/tasks.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 598656a..314a3e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,30 @@ jobs: with: name: spec-coverage-report path: coverage_report.md + + spec-validation: + name: Spec Validation (Spectral + codegen freshness) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Lint OpenAPI (sync) and AsyncAPI (async) specs + run: npx --yes @stoplight/spectral-cli@6.14.3 lint api/openapi.yaml api/asyncapi.yaml + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Ensure generated request schema matches the OpenAPI contract + run: | + go run ./cmd/gen-control-schema -in api/openapi.yaml -out internal/server/add_example_request_schema_gen.go -pkg server + git diff --exit-code -- internal/server/add_example_request_schema_gen.go build: name: Build Binaries diff --git a/.gitignore b/.gitignore index 32c404b..a3f3020 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.out *.prof vendor/ +/gen-control-schema # Node node_modules/ diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..d008094 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,7 @@ +# Spectral ruleset for the management API contracts. +# Both specifications are linted together: the OpenAPI REST surface +# (api/openapi.yaml) and the AsyncAPI stream surface (api/asyncapi.yaml). +# Spectral applies each preset only to documents of the matching format. +extends: + - spectral:oas + - spectral:asyncapi \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9e745..331c410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Protocol-neutral async management prefix `/_mock/async/{push,consumers,disconnect}` +- Spectral-based CI validation for both management API specs (`api/openapi.yaml` sync REST + `api/asyncapi.yaml` async stream) - Unified example injection: `POST /_mock/examples` gains `match`/`interval`/`delay` for AsyncAPI targets (runtime mirror of `x-mock-match`/`x-mock-interval`/`x-mock-delay`), with strict context-aware validation, plus `DELETE /_mock/examples/{exampleId}` to remove and cancel recurrence - Single event resource `POST /_mock/events` firing a named event by its `name` identity (the `{$event.name}` matched by event-driven examples) - Management WebSocket stream `/_mock/stream` with connect-time `events`/`channels` filters; pushes `event`/`push`/`consumer`/`schedule` envelopes @@ -18,10 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Consumers listable without a `channel` filter — flat union across all channels (raw ws + SignalR streams) ### Changed -- JSON-RPC over HTTP always answers transport `200` for a single-call result or - error; a mocked example's non-2xx status is exposed in the `X-Mock-Status` - response header instead of becoming the transport status (previously a mocked - `500` returned HTTP 500) +- `AddExampleRequest` refactored into an abstract `NewExampleRequestBase` plus concrete `NewExampleRequestSync`/`NewExampleRequestAsync` schemas composed through `allOf`, selected by a `oneOf` on `AddExampleRequest` — same rejection semantics, cleaner target-kind partitioning +- The Go runtime validator for `POST /_mock/examples` is no longer hand-written: it is generated from `api/openapi.yaml` (`components.schemas.AddExampleRequest`) by `gen-control-schema`, keeping the OpenAPI document the single source of truth (regenerate with `make generate`, freshness enforced in CI) - Each RPC procedure's own path is now mounted as a route, so a procedure may be invoked at `/rpc/users/123` as well as at the gateway; path parameters resolve through chi via the procedure's brace-form pattern (RS.JRP.34) diff --git a/Makefile b/Makefile index fa31775..e5e529d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for oasmock -.PHONY: help build build-cross test test-unit test-integration lint clean coverage-unit spec-coverage docker-build +.PHONY: help build build-cross test test-unit test-integration lint clean coverage-unit spec-coverage docker-build validate-spec check-generated # Default target all: build @@ -19,6 +19,8 @@ help: @echo " generate - run go generate" @echo " coverage-unit - run test coverage check for unit tests only" @echo " spec-coverage - check requirement scenario coverage" + @echo " validate-spec - lint the management API specs with Spectral" + @echo " check-generated - regenerate the request schema and verify it matches" @echo " docker-build - build Docker image from local binary" # Install dependencies @@ -78,6 +80,16 @@ docker-build: generate: go generate ./... +# Lint the management API specs (OpenAPI + AsyncAPI) with Spectral +validate-spec: + npx --yes @stoplight/spectral-cli@6.14.3 lint api/openapi.yaml api/asyncapi.yaml + +# Regenerate the AddExampleRequest schema from openapi.yaml and fail if the +# committed artifact drifted from the OpenAPI contract +check-generated: + go run ./cmd/gen-control-schema -in api/openapi.yaml -out internal/server/add_example_request_schema_gen.go -pkg server + git diff --exit-code -- internal/server/add_example_request_schema_gen.go + # Install golangci-lint (if not present) install-lint: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin v1.61.0 diff --git a/api/openapi.yaml b/api/openapi.yaml index ec1a6ac..be269fb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -265,72 +265,21 @@ components: error: type: string description: Human-readable error message - AddExampleRequest: + NewExampleRequestBase: type: object + description: | + Abstract request fields shared by every example target kind. Not used + directly — compose via NewExampleRequestSync / NewExampleRequestAsync. + The shared property bag is left open so target-specific schemas can + merge it through allOf and partition targeting with oneOf. required: - response - description: | - Target discriminator: include `path` (plus optional `method`) to target a - sync OpenAPI operation; include `channel` (plus optional `protocol`) to - target an async AsyncAPI channel. Mixing target kinds is rejected: the - sync branch forbids any async-only field (protocol/channel/match/ - interval/delay), and the async branch forbids `path`. - oneOf: - - title: sync (OpenAPI) - required: - - path - - response - not: - anyOf: - - required: [protocol] - - required: [channel] - - required: [match] - - required: [interval] - - required: [delay] - - title: async (AsyncAPI) - required: - - channel - - response - not: - anyOf: - - required: [path] properties: - path: - type: string - description: The request path (including path parameters) to match (OpenAPI target) - protocol: - type: string - enum: [http, ws] - description: AsyncAPI protocol when targeting an AsyncAPI channel - channel: - type: string - description: AsyncAPI channel address (prefixed) when targeting an AsyncAPI channel method: type: string enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS] default: GET description: HTTP method to match - match: - type: object - additionalProperties: true - description: | - AsyncAPI-only. Mirrors the x-mock-match extension against the event - and connection contexts ({$event.*}, {$connection.*}) to drive live - event-driven delivery. At least one condition must reference the - event context; a connection-only or literal match is rejected. - interval: - type: integer - minimum: 1 - description: | - AsyncAPI-only. Positive millisecond cadence for recurring delivery - (mirrors x-mock-interval). - delay: - type: integer - minimum: 0 - default: 0 - description: | - AsyncAPI-only. Millisecond delay before emission after a fire - (mirrors x-mock-delay). once: type: boolean default: false @@ -354,8 +303,67 @@ components: description: | Conditions to match the request. Keys can be runtime expressions, values can be literal values or JSON schemas for validation. + Keys may reference the reply contexts ({$request.*}, {$message.*}, + {$channel.*}) for OpenAPI/AsyncAPI reply examples, or the event and + connection contexts ({$event.*}, {$connection.*}) for async-driven + delivery. response: $ref: '#/components/schemas/ExampleResponse' + NewExampleRequestSync: + type: object + description: | + Sync (OpenAPI) target. Requires `path` (plus the shared `response`) and + forbids every async-only field (protocol/channel/interval/delay); + mixing target kinds is rejected via the not-fence, so an unknown field + never silently flips the request onto the async branch. + allOf: + - $ref: '#/components/schemas/NewExampleRequestBase' + - type: object + required: + - path + properties: + path: + type: string + description: The request path (including path parameters) to match (OpenAPI target) + NewExampleRequestAsync: + type: object + description: | + Async (AsyncAPI) target. Requires `channel` (plus the shared `response`) + and forbids `path`; mixing target kinds is rejected via the not-fence. + allOf: + - $ref: '#/components/schemas/NewExampleRequestBase' + - type: object + required: + - channel + properties: + protocol: + type: string + enum: [http, ws] + description: AsyncAPI protocol when targeting an AsyncAPI channel + channel: + type: string + description: AsyncAPI channel address (prefixed) when targeting an AsyncAPI channel + interval: + type: integer + minimum: 1 + description: | + AsyncAPI-only. Positive millisecond cadence for recurring delivery + (mirrors x-mock-interval). + delay: + type: integer + minimum: 0 + default: 0 + description: | + AsyncAPI-only. Millisecond delay before emission after a fire + (mirrors x-mock-delay). + AddExampleRequest: + description: | + Target discriminator over the concrete example schemas: the sync + (OpenAPI) branch targets a path, the async (AsyncAPI) branch targets a + channel. Exactly one branch matches, so mixed targeting is rejected. + oneOf: + - $ref: '#/components/schemas/NewExampleRequestSync' + - $ref: '#/components/schemas/NewExampleRequestAsync' ExampleResponse: type: object required: @@ -465,72 +473,6 @@ components: minimum: 0 default: 0 description: Delivery delay in milliseconds - ManageEnvelope: - type: object - required: - - type - properties: - type: - type: string - enum: [event, push, consumer, schedule] - description: Envelope kind - ts: - type: integer - description: Emit timestamp in milliseconds since epoch - event: - type: object - properties: - name: - type: string - schema: - type: string - global: - type: boolean - payload: - type: object - additionalProperties: true - push: - type: object - properties: - channel: - type: string - connectionId: - type: string - payload: - type: object - additionalProperties: true - consumer: - type: object - properties: - action: - type: string - enum: [connected, disconnected] - connectionId: - type: string - channel: - type: string - protocol: - type: string - enum: [ws, signalr] - streams: - type: array - items: - type: object - additionalProperties: true - schedule: - type: object - properties: - action: - type: string - enum: [started, stopped] - description: started when an interval example registers, stopped when it is removed or cancelled - exampleId: - type: string - description: Client-facing example id (the POST /_mock/examples id for runtime examples, the spec example name otherwise); identical for the started/stopped pair - channel: - type: string - interval: - type: integer AsyncActionResponse: type: object properties: diff --git a/cmd/gen-control-schema/main.go b/cmd/gen-control-schema/main.go new file mode 100644 index 0000000..057d09c --- /dev/null +++ b/cmd/gen-control-schema/main.go @@ -0,0 +1,227 @@ +// Command gen-control-schema derives the runtime request-validation JSON schema +// for POST /_mock/examples directly from api/openapi.yaml, so the Go server +// never hand-duplicates the OpenAPI contract (single source of truth). +// +// It resolves components.schemas.AddExampleRequest into a standalone, fully +// inlined JSON schema (following every $ref within components.schemas) and +// writes a Go source file exposing that schema as a byte constant. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "go/format" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + schemaName = "AddExampleRequest" + jsonSchemaRef = "#/components/schemas/" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "gen-control-schema:", err) + os.Exit(1) + } +} + +func run() error { + inPath, outPath, pkg, err := parseFlags() + if err != nil { + return err + } + schemas, err := loadSchemas(inPath) + if err != nil { + return err + } + source, ok := schemas[schemaName] + if !ok { + return fmt.Errorf("components.schemas.%s not found in %s", schemaName, inPath) + } + + resolved, err := deref(source, schemas) + if err != nil { + return fmt.Errorf("resolve %s: %w", schemaName, err) + } + if containsRef(resolved) { + return fmt.Errorf("resolve left an unresolvable $ref in %s; schemas must stay acyclic and self-contained", schemaName) + } + + jsonBytes, err := json.MarshalIndent(resolved, "", " ") + if err != nil { + return fmt.Errorf("marshal resolved schema: %w", err) + } + output, err := buildGoSource(jsonBytes, pkg) + if err != nil { + return err + } + if err := os.WriteFile(outPath, output, 0o644); err != nil { + return fmt.Errorf("write %s: %w", outPath, err) + } + return nil +} + +// parseFlags reads the -in/-out/-pkg flags and validates them. +func parseFlags() (inPath, outPath, pkg string, err error) { + flag.StringVar(&inPath, "in", "", "path to api/openapi.yaml") + flag.StringVar(&outPath, "out", "", "path to generated Go file") + flag.StringVar(&pkg, "pkg", "server", "name of the generated Go package") + flag.Parse() + if inPath == "" || outPath == "" { + return "", "", "", fmt.Errorf("-in and -out are required") + } + if pkg == "" { + return "", "", "", fmt.Errorf("-pkg must not be empty") + } + return inPath, outPath, pkg, nil +} + +// loadSchemas reads and parses the components.schemas of an OpenAPI document. +func loadSchemas(inPath string) (map[string]any, error) { + data, err := os.ReadFile(inPath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", inPath, err) + } + + var doc struct { + Components struct { + Schemas map[string]any `yaml:"schemas"` + } `yaml:"components"` + } + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parse %s: %w", inPath, err) + } + if doc.Components.Schemas == nil { + return nil, fmt.Errorf("no components.schemas found in %s", inPath) + } + return doc.Components.Schemas, nil +} + +// deref returns a deep copy of src with every $ref to components.schemas +// replaced by the referenced schema, recursively. Refs outside +// components.schemas (e.g. examples) are left untouched; any remaining ref +// aborts generation (see containsRef). Circular $refs are rejected: a schema +// that cannot be inlined into a finite artifact is an error, not a hang. +func deref(src any, schemas map[string]any) (any, error) { + return derefImpl(src, schemas, map[string]bool{}) +} + +func derefImpl(src any, schemas map[string]any, stack map[string]bool) (any, error) { + switch v := src.(type) { + case map[string]any: + return derefMap(v, schemas, stack) + case []any: + return derefSlice(v, schemas, stack) + default: + return src, nil + } +} + +func derefMap(v map[string]any, schemas map[string]any, stack map[string]bool) (any, error) { + out := make(map[string]any, len(v)) + for k, val := range v { + if k == "$ref" { + ref, ok := val.(string) + if !ok { + return nil, fmt.Errorf("$ref value is not a string: %v", val) + } + if strings.HasPrefix(ref, jsonSchemaRef) { + resolved, err := inlineRef(ref, schemas, stack) + if err != nil { + return nil, err + } + return resolved, nil + } + out[k] = val + continue + } + child, err := derefImpl(val, schemas, stack) + if err != nil { + return nil, err + } + out[k] = child + } + return out, nil +} + +func derefSlice(v []any, schemas map[string]any, stack map[string]bool) (any, error) { + out := make([]any, len(v)) + for i, item := range v { + child, err := derefImpl(item, schemas, stack) + if err != nil { + return nil, err + } + out[i] = child + } + return out, nil +} + +// inlineRef resolves a components.schemas ref to its target schema, guarding +// against cycles, so a recursive schema fails loudly instead of recursing +// forever during inlining. +func inlineRef(ref string, schemas map[string]any, stack map[string]bool) (any, error) { + name := strings.TrimPrefix(ref, jsonSchemaRef) + target, ok := schemas[name] + if !ok { + return nil, fmt.Errorf("unresolved $ref %q", ref) + } + if stack[name] { + return nil, fmt.Errorf("circular $ref %q; component schemas must be acyclic", ref) + } + stack[name] = true + resolved, err := derefImpl(target, schemas, stack) + delete(stack, name) + if err != nil { + return nil, err + } + return resolved, nil +} + +// containsRef reports whether any nested $ref remains in a resolved schema. +func containsRef(v any) bool { + switch val := v.(type) { + case map[string]any: + if _, ok := val["$ref"]; ok { + return true + } + for _, child := range val { + if containsRef(child) { + return true + } + } + case []any: + for _, child := range val { + if containsRef(child) { + return true + } + } + } + return false +} + +// buildGoSource renders the resolved schema as a Go byte constant in the given +// package, formatted by go/format. +func buildGoSource(schema []byte, pkg string) ([]byte, error) { + raw := fmt.Sprintf(`// Code generated by gen-control-schema; DO NOT EDIT. +// Source: api/openapi.yaml -> components.schemas.%[1]s. +// The request body of POST /_mock/examples is validated against this schema at +// runtime, keeping the Go server's validation in lock-step with the OpenAPI +// contract (single source of truth). Regenerate with: go generate ./internal/server + +package %[2]s + +// %[1]sSchemaJSON is the fully-inlined JSON schema for a %[1]s request body. +var %[1]sSchemaJSON = []byte(%[3]q) +`, schemaName, pkg, string(schema)) + + formatted, err := format.Source([]byte(raw)) + if err != nil { + return nil, fmt.Errorf("format generated source: %w", err) + } + return formatted, nil +} diff --git a/cmd/gen-control-schema/main_test.go b/cmd/gen-control-schema/main_test.go new file mode 100644 index 0000000..f877651 --- /dev/null +++ b/cmd/gen-control-schema/main_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Dereferencing a component schema graph into a standalone schema +Given a map of component schemas where AddExampleRequest references two concrete + + schemas via oneOf, each merging a shared base through allOf + +When deref is applied to AddExampleRequest +Then every $ref within components/schemas is inlined, the shared base and the + + not-fences are preserved, and no $ref remains + +Related spec scenarios: RS.MAPI.14, RS.MAPI.27, RS.MAPI.28 +*/ +func TestDerefInlinesComponentRefs(t *testing.T) { + t.Parallel() + + schemas := map[string]any{ + "NewExampleRequestBase": map[string]any{ + "type": "object", + "required": []any{"response"}, + "properties": map[string]any{ + "method": map[string]any{"type": "string"}, + }, + }, + "NewExampleRequestSync": map[string]any{ + "type": "object", + "allOf": []any{map[string]any{"$ref": "#/components/schemas/NewExampleRequestBase"}}, + }, + "NewExampleRequestAsync": map[string]any{ + "type": "object", + "allOf": []any{map[string]any{"$ref": "#/components/schemas/NewExampleRequestBase"}}, + }, + "AddExampleRequest": map[string]any{ + "oneOf": []any{ + map[string]any{"$ref": "#/components/schemas/NewExampleRequestSync"}, + map[string]any{"$ref": "#/components/schemas/NewExampleRequestAsync"}, + }, + }, + } + + resolved, err := deref(schemas["AddExampleRequest"], schemas) + require.NoError(t, err) + assert.False(t, containsRef(resolved), "all component refs must be inlined") + + raw, err := json.Marshal(resolved) + require.NoError(t, err) + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + + oneOf, ok := doc["oneOf"].([]any) + require.True(t, ok, "resolved schema must keep its oneOf") + require.Len(t, oneOf, 2) + for _, branch := range oneOf { + branchMap, ok := branch.(map[string]any) + require.True(t, ok) + allOf, ok := branchMap["allOf"].([]any) + require.True(t, ok, "concrete branches must merge the base through allOf") + require.Len(t, allOf, 1) + base, ok := allOf[0].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, base["properties"], "shared base properties must survive inlining") + } +} + +/* +Scenario: Leaving refs outside components.schemas untouched +Given a schema referencing an example resource outside components.schemas +When deref is applied +Then the external ref is preserved verbatim instead of failing + +Related spec scenarios: RS.MAPI.14 +*/ +func TestDerefPreservesExternalRefs(t *testing.T) { + t.Parallel() + + schemas := map[string]any{ + "AddExampleRequest": map[string]any{ + "oneOf": []any{ + map[string]any{ + "type": "object", + "properties": map[string]any{"$ref": "#/components/examples/someExample"}, + }, + }, + }, + } + + resolved, err := deref(schemas["AddExampleRequest"], schemas) + require.NoError(t, err) + assert.True(t, containsRef(resolved), "external refs are preserved and still detected") +} + +/* +Scenario: Rejecting an unresolvable component ref +Given a schema with a $ref to a missing component +When deref is applied +Then an error describing the unresolved ref is returned + +Related spec scenarios: RS.MAPI.14 +*/ +func TestDerefErrorsOnMissingComponent(t *testing.T) { + t.Parallel() + + schemas := map[string]any{ + "AddExampleRequest": map[string]any{ + "oneOf": []any{map[string]any{"$ref": "#/components/schemas/Missing"}}, + }, + } + + _, err := deref(schemas["AddExampleRequest"], schemas) + require.Error(t, err) + assert.Contains(t, err.Error(), "#/components/schemas/Missing") +} + +/* +Scenario: Generated Go source exposes the schema and keeps its package +Given resolved schema bytes and a package name +When buildGoSource renders a Go file +Then it contains a recognized "Code generated" header, the target package, and a + + byte literal that unmarshals to the original schema JSON + +Related spec scenarios: RS.MAPI.14 +*/ +func TestBuildGoSource(t *testing.T) { + t.Parallel() + + schema := []byte(`{"oneOf":[{"type":"object"}]}`) + output, err := buildGoSource(schema, "server") + require.NoError(t, err) + + src := string(output) + assert.Contains(t, src, "// Code generated by gen-control-schema; DO NOT EDIT.") + assert.Contains(t, src, "package server") + assert.Contains(t, src, "AddExampleRequestSchemaJSON") + assert.Contains(t, src, `{\"oneOf\":[{\"type\":\"object\"}]}`) + require.True(t, strings.HasSuffix(src, "\n"), "generated source must end with a newline") +} + +/* +Scenario: Rejecting a self-referential component schema +Given a schema referencing itself through components.schemas +When deref is applied +Then it aborts with a circular-$ref error instead of recursing forever + +Related spec scenarios: RS.MAPI.14 +*/ +func TestDerefRejectsCircularRef(t *testing.T) { + t.Parallel() + + schemas := map[string]any{ + "AddExampleRequest": map[string]any{ + "$ref": "#/components/schemas/AddExampleRequest", + }, + } + + _, err := deref(schemas["AddExampleRequest"], schemas) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular $ref") +} diff --git a/docs/extensions.md b/docs/extensions.md index 68fa1ee..57ce8a9 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -103,9 +103,9 @@ examples: Timing values are integer milliseconds: a fractional value (e.g. `x-mock-interval: 2.5`) is rejected at load instead of being silently truncated. Periodically driven examples honor `x-mock-skip` like every other example and are not emitted while it is set. -### Runtime matches and timing (management API) +### Runtime conditions and timing (management API) -`POST /_mock/examples` mirrors the extensions for AsyncAPI targets with `match`, `interval` and `delay` fields; the same classification and delivery rules apply. See `api/openapi.yaml`. +`POST /_mock/examples` mirrors the extensions for AsyncAPI targets with `conditions`, `interval` and `delay` fields; the same classification and delivery rules apply. See `api/openapi.yaml`. > **Not idempotent**: every successful `POST /_mock/examples` registers a distinct example (and, for interval targets, a separate delivery job). Re-sending a request after a lost response creates a second subscription; keep the returned `id` and stop an interval example with `DELETE /_mock/examples/{id}`. diff --git a/internal/server/add_example_request_schema_gen.go b/internal/server/add_example_request_schema_gen.go new file mode 100644 index 0000000..37d8df5 --- /dev/null +++ b/internal/server/add_example_request_schema_gen.go @@ -0,0 +1,10 @@ +// Code generated by gen-control-schema; DO NOT EDIT. +// Source: api/openapi.yaml -> components.schemas.AddExampleRequest. +// The request body of POST /_mock/examples is validated against this schema at +// runtime, keeping the Go server's validation in lock-step with the OpenAPI +// contract (single source of truth). Regenerate with: go generate ./internal/server + +package server + +// AddExampleRequestSchemaJSON is the fully-inlined JSON schema for a AddExampleRequest request body. +var AddExampleRequestSchemaJSON = []byte("{\n \"description\": \"Target discriminator over the concrete example schemas: the sync\\n(OpenAPI) branch targets a path, the async (AsyncAPI) branch targets a\\nchannel. Exactly one branch matches, so mixed targeting is rejected.\\n\",\n \"oneOf\": [\n {\n \"allOf\": [\n {\n \"description\": \"Abstract request fields shared by every example target kind. Not used\\ndirectly — compose via NewExampleRequestSync / NewExampleRequestAsync.\\nThe shared property bag is left open so target-specific schemas can\\nmerge it through allOf and partition targeting with oneOf.\\n\",\n \"properties\": {\n \"conditions\": {\n \"additionalProperties\": {\n \"oneOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"number\"\n },\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"object\"\n }\n ]\n },\n \"description\": \"Conditions to match the request. Keys can be runtime expressions,\\nvalues can be literal values or JSON schemas for validation.\\nKeys may reference the reply contexts ({$request.*}, {$message.*},\\n{$channel.*}) for OpenAPI/AsyncAPI reply examples, or the event and\\nconnection contexts ({$event.*}, {$connection.*}) for async-driven\\ndelivery.\\n\",\n \"type\": \"object\"\n },\n \"method\": {\n \"default\": \"GET\",\n \"description\": \"HTTP method to match\",\n \"enum\": [\n \"GET\",\n \"POST\",\n \"PUT\",\n \"PATCH\",\n \"DELETE\",\n \"HEAD\",\n \"OPTIONS\"\n ],\n \"type\": \"string\"\n },\n \"once\": {\n \"default\": false,\n \"description\": \"If true, the example will be returned only once (when conditions are met)\",\n \"type\": \"boolean\"\n },\n \"response\": {\n \"properties\": {\n \"body\": {\n \"description\": \"Response body (JSON or text)\",\n \"oneOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"number\"\n },\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n }\n ]\n },\n \"code\": {\n \"description\": \"HTTP status code (required)\",\n \"type\": \"integer\"\n },\n \"headers\": {\n \"additionalProperties\": {\n \"type\": \"string\"\n },\n \"description\": \"Response headers\",\n \"type\": \"object\"\n }\n },\n \"required\": [\n \"code\"\n ],\n \"type\": \"object\"\n },\n \"ttl\": {\n \"description\": \"Time-to-live in seconds. After this duration the example becomes unavailable and is removed from memory. 0 or omitted means no expiration.\",\n \"minimum\": 0,\n \"type\": \"integer\"\n },\n \"validate\": {\n \"default\": true,\n \"description\": \"Validate the example data against the OpenAPI schema for the path\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"response\"\n ],\n \"type\": \"object\"\n },\n {\n \"properties\": {\n \"path\": {\n \"description\": \"The request path (including path parameters) to match (OpenAPI target)\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"path\"\n ],\n \"type\": \"object\"\n }\n ],\n \"description\": \"Sync (OpenAPI) target. Requires `path` (plus the shared `response`) and\\nforbids every async-only field (protocol/channel/interval/delay);\\nmixing target kinds is rejected via the not-fence, so an unknown field\\nnever silently flips the request onto the async branch.\\n\",\n \"type\": \"object\"\n },\n {\n \"allOf\": [\n {\n \"description\": \"Abstract request fields shared by every example target kind. Not used\\ndirectly — compose via NewExampleRequestSync / NewExampleRequestAsync.\\nThe shared property bag is left open so target-specific schemas can\\nmerge it through allOf and partition targeting with oneOf.\\n\",\n \"properties\": {\n \"conditions\": {\n \"additionalProperties\": {\n \"oneOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"number\"\n },\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"object\"\n }\n ]\n },\n \"description\": \"Conditions to match the request. Keys can be runtime expressions,\\nvalues can be literal values or JSON schemas for validation.\\nKeys may reference the reply contexts ({$request.*}, {$message.*},\\n{$channel.*}) for OpenAPI/AsyncAPI reply examples, or the event and\\nconnection contexts ({$event.*}, {$connection.*}) for async-driven\\ndelivery.\\n\",\n \"type\": \"object\"\n },\n \"method\": {\n \"default\": \"GET\",\n \"description\": \"HTTP method to match\",\n \"enum\": [\n \"GET\",\n \"POST\",\n \"PUT\",\n \"PATCH\",\n \"DELETE\",\n \"HEAD\",\n \"OPTIONS\"\n ],\n \"type\": \"string\"\n },\n \"once\": {\n \"default\": false,\n \"description\": \"If true, the example will be returned only once (when conditions are met)\",\n \"type\": \"boolean\"\n },\n \"response\": {\n \"properties\": {\n \"body\": {\n \"description\": \"Response body (JSON or text)\",\n \"oneOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"number\"\n },\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n }\n ]\n },\n \"code\": {\n \"description\": \"HTTP status code (required)\",\n \"type\": \"integer\"\n },\n \"headers\": {\n \"additionalProperties\": {\n \"type\": \"string\"\n },\n \"description\": \"Response headers\",\n \"type\": \"object\"\n }\n },\n \"required\": [\n \"code\"\n ],\n \"type\": \"object\"\n },\n \"ttl\": {\n \"description\": \"Time-to-live in seconds. After this duration the example becomes unavailable and is removed from memory. 0 or omitted means no expiration.\",\n \"minimum\": 0,\n \"type\": \"integer\"\n },\n \"validate\": {\n \"default\": true,\n \"description\": \"Validate the example data against the OpenAPI schema for the path\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"response\"\n ],\n \"type\": \"object\"\n },\n {\n \"properties\": {\n \"channel\": {\n \"description\": \"AsyncAPI channel address (prefixed) when targeting an AsyncAPI channel\",\n \"type\": \"string\"\n },\n \"delay\": {\n \"default\": 0,\n \"description\": \"AsyncAPI-only. Millisecond delay before emission after a fire\\n(mirrors x-mock-delay).\\n\",\n \"minimum\": 0,\n \"type\": \"integer\"\n },\n \"interval\": {\n \"description\": \"AsyncAPI-only. Positive millisecond cadence for recurring delivery\\n(mirrors x-mock-interval).\\n\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"protocol\": {\n \"description\": \"AsyncAPI protocol when targeting an AsyncAPI channel\",\n \"enum\": [\n \"http\",\n \"ws\"\n ],\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"channel\"\n ],\n \"type\": \"object\"\n }\n ],\n \"description\": \"Async (AsyncAPI) target. Requires `channel` (plus the shared `response`)\\nand forbids `path`; mixing target kinds is rejected via the not-fence.\\n\",\n \"type\": \"object\"\n }\n ]\n}") diff --git a/internal/server/add_example_runtime_test.go b/internal/server/add_example_runtime_test.go index 58d6bd7..773a40f 100644 --- a/internal/server/add_example_runtime_test.go +++ b/internal/server/add_example_runtime_test.go @@ -30,7 +30,7 @@ func TestAddExample_RuntimeEventMatch(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"channel":"/alerts","match":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"msg":"{$event.msg}"}}}` + body := `{"channel":"/alerts","conditions":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"msg":"{$event.msg}"}}}` resp := postExample(t, ts.URL, body) require.Equal(t, http.StatusOK, resp.StatusCode) var addResp map[string]any @@ -135,7 +135,7 @@ func TestAddExample_RuntimeConnectMatch(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"channel":"/alerts","match":{"{$event.name}":"connect"},"response":{"code":200,"body":{"msg":"welcome"}}}` + body := `{"channel":"/alerts","conditions":{"{$event.name}":"connect"},"response":{"code":200,"body":{"msg":"welcome"}}}` resp := postExample(t, ts.URL, body) require.Equal(t, http.StatusOK, resp.StatusCode) resp.Body.Close() //nolint:errcheck @@ -177,7 +177,7 @@ func TestAddExample_IdsAreNamespaced(t *testing.T) { asyncTS := httptest.NewServer(asyncSrv.router) defer asyncTS.Close() //nolint:errcheck - body := `{"channel":"/alerts","match":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"a":1}}}` + body := `{"channel":"/alerts","conditions":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"a":1}}}` asyncResp := postExample(t, asyncTS.URL, body) require.Equal(t, http.StatusOK, asyncResp.StatusCode) var asyncPayload map[string]any diff --git a/internal/server/add_example_validation_test.go b/internal/server/add_example_validation_test.go index 16f55e8..e42bfb1 100644 --- a/internal/server/add_example_validation_test.go +++ b/internal/server/add_example_validation_test.go @@ -71,8 +71,8 @@ func TestAddExampleValidation_PathAndChannel(t *testing.T) { } /* -Scenario: match or interval on an OpenAPI target is rejected -Given a POST with path and match but no AsyncAPI target +Scenario: Async-only fields on an OpenAPI target are rejected +Given a POST with path and an async-only timing field (interval) but no AsyncAPI target When /_mock/examples is invoked Then the server responds with HTTP 400 @@ -85,7 +85,7 @@ func TestAddExampleValidation_AsyncFieldsOnSyncedPath(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"path":"/users","match":{"{$event.name}":"x"},"response":{"code":200,"body":{"a":1}}}` + body := `{"path":"/users","interval":100,"response":{"code":200,"body":{"a":1}}}` resp := postExample(t, ts.URL, body) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -106,7 +106,7 @@ func TestAddExampleValidation_DualTriggers(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"channel":"/alerts","interval":100,"match":{"{$event.name}":"x"},"response":{"code":200,"body":{"a":1}}}` + body := `{"channel":"/alerts","interval":100,"conditions":{"{$event.name}":"x"},"response":{"code":200,"body":{"a":1}}}` resp := postExample(t, ts.URL, body) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -151,7 +151,7 @@ func TestAddExampleValidation_NonEventMatchRejected(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"channel":"/alerts","match":{"{$connection.channel}":"/alerts"},"response":{"code":200,"body":{"a":1}}}` + body := `{"channel":"/alerts","conditions":{"{$connection.channel}":"/alerts"},"response":{"code":200,"body":{"a":1}}}` resp := postExample(t, ts.URL, body) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -174,7 +174,7 @@ func TestAddExampleValidation_ConnectionMatchWithEventValueAccepted(t *testing.T ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"channel":"/alerts","match":{"{$connection.id}":"{$event.connectionId}"},"response":{"code":200,"body":{"a":1}}}` + body := `{"channel":"/alerts","conditions":{"{$connection.id}":"{$event.connectionId}"},"response":{"code":200,"body":{"a":1}}}` resp := postExample(t, ts.URL, body) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -195,7 +195,7 @@ func TestAddExampleValidation_LiteralOnlyMatchRejected(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"channel":"/alerts","match":{"kind":"tick"},"response":{"code":200,"body":{"a":1}}}` + body := `{"channel":"/alerts","conditions":{"kind":"tick"},"response":{"code":200,"body":{"a":1}}}` resp := postExample(t, ts.URL, body) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -228,7 +228,7 @@ func TestAddExampleValidation_EventMatchWithDelayAccepted(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"channel":"/alerts","delay":10,"match":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"a":1}}}` + body := `{"channel":"/alerts","delay":10,"conditions":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"a":1}}}` resp := postExample(t, ts.URL, body) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -251,7 +251,7 @@ func TestAddExampleValidation_ValidShapesPass(t *testing.T) { tests := []string{ `{"channel":"/alerts","response":{"code":200,"body":{"a":1}}}`, - `{"channel":"/alerts","match":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"a":1}}}`, + `{"channel":"/alerts","conditions":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"a":1}}}`, `{"channel":"/alerts","interval":200,"response":{"code":200,"body":{"a":1}}}`, `{"channel":"/alerts","delay":10,"response":{"code":200,"body":{"a":1}}}`, } @@ -281,9 +281,9 @@ func TestAddExampleValidation_ErrorEnvelopeIsValidJSON(t *testing.T) { defer ts.Close() //nolint:errcheck invalidBodies := []string{ - `{"path":"/users","channel":"/alerts","response":{"code":200,"body":{"a":1}}}`, // oneOf violation - `{"channel":"/alerts","interval":100,"match":{"{$event.name}":"x"},"response":{"code":200,"body":{"a":1}}}`, // dual trigger - `{"path":"/does-not-exist","response":{"code":200}}`, // no matching route + `{"path":"/users","channel":"/alerts","response":{"code":200,"body":{"a":1}}}`, // oneOf violation + `{"channel":"/alerts","interval":100,"conditions":{"{$event.name}":"x"},"response":{"code":200,"body":{"a":1}}}`, // dual trigger + `{"path":"/does-not-exist","response":{"code":200}}`, // no matching route `not-json`, // malformed body } for _, body := range invalidBodies { @@ -336,6 +336,33 @@ func TestAddExampleValidation_ValidateFlag(t *testing.T) { assert.Equal(t, http.StatusOK, resp3.StatusCode, "a conforming body must pass validation") } +/* +Scenario: The removed match field is rejected +Given a POST /_mock/examples body carrying the removed async-only match field +When the server processes it +Then it responds with HTTP 400 and registers nothing (RS.MAPI.37) + +Related spec scenarios: RS.MAPI.37 +*/ +func TestAddExampleValidation_RemovedMatchFieldRejected(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + bodies := []string{ + `{"path":"/users","match":{"{$event.name}":"orderCreated"},"response":{"code":200,"body":{"a":1}}}`, + `{"channel":"/alerts","match":{"{$event.name}":"levelUp"},"response":{"code":200,"body":{"a":1}}}`, + } + for _, body := range bodies { + resp := postExample(t, ts.URL, body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "body=%s", body) + resp.Body.Close() //nolint:errcheck + } + assertNoRuntimeExampleRegistered(t, srv, "a removed match field must not register a runtime example") +} + /* Scenario: The sync/async target discriminator is strict Given the POST /_mock/examples oneOf fence diff --git a/internal/server/async_http_adapter_test.go b/internal/server/async_http_adapter_test.go index 379b75d..f707bb5 100644 --- a/internal/server/async_http_adapter_test.go +++ b/internal/server/async_http_adapter_test.go @@ -62,6 +62,63 @@ func TestHTTPProtocolAdapter_RendersMessage(t *testing.T) { assert.Equal(t, "Ada", body["name"]) } +/* +Scenario: Async reply-context conditions on a dynamic example are honored +Given an AsyncAPI http channel with a management-injected dynamic example whose +conditions reference the reply context ({$message.payload.*}) +When a matching inbound message arrives +Then the dynamic example is selected and its body rendered; a non-matching +message falls through without selecting it + +Related spec scenarios: RS.MAPI.36, RS.MAPI.20 +*/ +func TestAsyncDynamicExample_ReplyContextConditionsHonored(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncapi.ProtocolHTTP, + Method: http.MethodPost, + ChiPattern: "/employees", + Pattern: "/employees", + Messages: nil, // no spec messages: the dynamic example is the only reply + } + + // Register a management-injected dynamic example guarded by a reply-context + // ({$message.*}) condition (RS.MAPI.36). + key := routeKey(mapping.Method, mapping.ChiPattern) + srv.registry.addDynamic(key, dynamicExample{ + onceID: "dynex-1", + conditions: map[string]any{"{$message.payload.kind}": "urgent"}, + response: struct { + code int + headers map[string]string + body any + }{code: 200, body: map[string]any{"dyn": "yes"}}, + }) + + adapter := srv.adapterForProtocol(asyncapi.ProtocolHTTP) + require.NotNil(t, adapter) + handler := adapter.Handler(mapping, srv.asyncMessageHandler(mapping)) + + // A matching message selects the dynamic example. + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodPost, "/employees", strings.NewReader(`{"kind":"urgent","id":7}`))) + assert.Equal(t, http.StatusOK, rec.Code) + var matched map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &matched)) + assert.Equal(t, "yes", matched["dyn"]) + + // A non-matching message does not select the dynamic example (no spec + // messages exist, so the reply is empty). + rec2 := httptest.NewRecorder() + handler(rec2, httptest.NewRequest(http.MethodPost, "/employees", strings.NewReader(`{"kind":"normal","id":7}`))) + assert.Equal(t, http.StatusOK, rec2.Code) + assert.Empty(t, rec2.Body.String()) +} + /* Scenario: HTTP adapter ack send with no reply message Given an AsyncAPI http channel whose operation has no reply message diff --git a/internal/server/generate.go b/internal/server/generate.go new file mode 100644 index 0000000..1c518a5 --- /dev/null +++ b/internal/server/generate.go @@ -0,0 +1,3 @@ +package server + +//go:generate go run github.com/mamonth/oasmock/cmd/gen-control-schema -in ../../api/openapi.yaml -out add_example_request_schema_gen.go -pkg server diff --git a/internal/server/server_management.go b/internal/server/server_management.go index cf9532a..f4efe51 100644 --- a/internal/server/server_management.go +++ b/internal/server/server_management.go @@ -57,86 +57,20 @@ func (s *Server) findAsyncRouteMapping(protocol, channel, method string) *RouteM return nil } -// addExampleRequestSchema is the oneOf two-branch request schema for -// POST /_mock/examples (design D2). The target discriminator is: the sync -// (OpenAPI) branch requires `path`+`response` and forbids every async-only -// field (protocol/channel/match/interval/delay); the async (AsyncAPI) branch -// requires `channel`+`response` and forbids `path`. The fence is deliberately -// strict so a future third target kind requires extending these oneOf branches -// (and the validation tests) rather than silently accepting mixed targeting. -var addExampleRequestSchema = gojsonschema.NewGoLoader(map[string]any{ - "type": "object", - "required": []string{"response"}, - "oneOf": []any{ - map[string]any{ - "required": []string{"path", "response"}, - "not": map[string]any{ - "anyOf": []any{ - map[string]any{"required": []string{"protocol"}}, - map[string]any{"required": []string{"channel"}}, - map[string]any{"required": []string{"match"}}, - map[string]any{"required": []string{"interval"}}, - map[string]any{"required": []string{"delay"}}, - }, - }, - }, - map[string]any{ - "required": []string{"channel", "response"}, - "not": map[string]any{ - "anyOf": []any{ - map[string]any{"required": []string{"path"}}, - }, - }, - }, - }, - "properties": map[string]any{ - "path": map[string]any{"type": "string"}, - "protocol": map[string]any{ - "type": "string", - "enum": []string{"http", "ws"}, - }, - "channel": map[string]any{"type": "string"}, - "method": map[string]any{ - "type": "string", - "enum": []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, - "default": "GET", - }, - "match": map[string]any{ - "type": "object", - "additionalProperties": true, - }, - "interval": map[string]any{"type": "integer", "minimum": 1}, - "delay": map[string]any{"type": "integer", "minimum": 0}, - "once": map[string]any{"type": "boolean"}, - "validate": map[string]any{"type": "boolean"}, - "ttl": map[string]any{"type": "integer", "minimum": 0}, - "conditions": map[string]any{ - "type": "object", - "additionalProperties": map[string]any{ - "oneOf": []any{ - map[string]any{"type": "string"}, - map[string]any{"type": "number"}, - map[string]any{"type": "boolean"}, - map[string]any{"type": "object"}, - }, - }, - }, - "response": map[string]any{ - "type": "object", - "required": []string{"code"}, - "properties": map[string]any{ - "code": map[string]any{"type": "integer"}, - "headers": map[string]any{ - "type": "object", - "additionalProperties": map[string]any{"type": "string"}, - }, - "body": map[string]any{ - "type": []any{"string", "number", "boolean", "object", "array"}, - }, - }, - }, - }, -}) +// addExampleRequestSchema is the runtime request-validation schema for +// POST /_mock/examples (design D2: oneOf two-branch target discriminator — the +// sync branch requires path+response and forbids every async-only field, the +// async branch requires channel+response and forbids path). It is not +// hand-written: it is regenerated from components.schemas.AddExampleRequest in +// api/openapi.yaml (see add_example_request_schema_gen.go), keeping the OpenAPI +// document the single source of truth for the management contract. +var addExampleRequestSchema = func() gojsonschema.JSONLoader { + loader := gojsonschema.NewBytesLoader(AddExampleRequestSchemaJSON) + if _, err := gojsonschema.NewSchemaLoader().Compile(loader); err != nil { + panic("generated AddExampleRequest schema failed to compile: " + err.Error()) + } + return loader +}() func validateAddExampleRequest(rawJSON []byte) error { loader := gojsonschema.NewBytesLoader(rawJSON) @@ -168,7 +102,6 @@ type addExampleRequest struct { Method string `json:"method"` Protocol string `json:"protocol"` Channel string `json:"channel"` - Match map[string]any `json:"match"` Interval int `json:"interval"` Delay int `json:"delay"` Once bool `json:"once"` @@ -245,6 +178,31 @@ func responseSchemaFor(responses *openapi3.Responses, code int) *openapi3.Schema return nil } +// rejectRemovedMatchField rejects a stale top-level `match` field (RS.MAPI.37): +// the async-only selector was removed in favor of the unified `conditions`, so +// a body still carrying it must never be silently registered without its +// selection conditions. +func rejectRemovedMatchField(bodyBytes []byte) error { + var raw map[string]any + if err := json.Unmarshal(bodyBytes, &raw); err != nil { + return fmt.Errorf("invalid JSON") + } + if _, ok := raw["match"]; ok { + return fmt.Errorf("'match' is removed; use 'conditions'") + } + return nil +} + +// rejectSyncTimingFields rejects the async-only timing fields on an OpenAPI +// (sync) target (RS.MAPI.28): interval/delay are only meaningful for AsyncAPI +// routes. +func rejectSyncTimingFields(req *addExampleRequest) error { + if req.Interval > 0 || req.Delay > 0 { + return fmt.Errorf("'interval' and 'delay' are only valid on an AsyncAPI target") + } + return nil +} + // decodeAddExampleRequest reads, schema-validates and decodes an add-example // body, applying the field checks that are independent of the resolved target. func decodeAddExampleRequest(r *http.Request) (*addExampleRequest, error) { @@ -255,6 +213,9 @@ func decodeAddExampleRequest(r *http.Request) (*addExampleRequest, error) { if err := validateAddExampleRequest(bodyBytes); err != nil { return nil, err } + if err := rejectRemovedMatchField(bodyBytes); err != nil { + return nil, err + } var req addExampleRequest if err := json.Unmarshal(bodyBytes, &req); err != nil { return nil, fmt.Errorf("invalid JSON") @@ -266,17 +227,27 @@ func decodeAddExampleRequest(r *http.Request) (*addExampleRequest, error) { // Single-trigger rule (RS.MAPI.29): an async target has exactly one // trigger — interval OR an {$event.*}-based match, never both. - if matchesEventContext(req.Match) && req.Interval > 0 { - return nil, fmt.Errorf("'interval' and an event-based 'match' are mutually exclusive") + if matchesEventContext(req.Conditions) && req.Interval > 0 { + return nil, fmt.Errorf("'interval' and an event-based 'conditions' are mutually exclusive") } return &req, nil } +// rejectNonEventAsyncConditions rejects an async target whose conditions +// reference only {$connection.*} or literal values (no {$event.*}): a runtime +// example needs a trigger, and a connection-only match has none (RS.MAPI.35). +func rejectNonEventAsyncConditions(mapping *RouteMapping, req *addExampleRequest) error { + if mapping.Protocol != "" && len(req.Conditions) > 0 && !matchesEventContext(req.Conditions) { + return fmt.Errorf("async target 'conditions' must reference the event context ({$event.*}); use 'interval' for periodic emission") + } + return nil +} + // resolveExampleTarget maps an add-example request to its route: AsyncAPI // targets resolve by protocol/channel, OpenAPI targets by path/method. A // runtime match on an async target must drive emission, so only an -// {$event.*}-based match is accepted; a connection-only or literal match has -// no trigger and is rejected rather than silently registered nowhere +// {$event.*}-based conditions is accepted; a connection-only or literal match +// has no trigger and is rejected rather than silently registered nowhere // (RS.MAPI.24-26, RS.MAPI.33). func (s *Server) resolveExampleTarget(req *addExampleRequest) (*RouteMapping, error) { if req.Protocol != "" || req.Channel != "" { @@ -284,13 +255,16 @@ func (s *Server) resolveExampleTarget(req *addExampleRequest) (*RouteMapping, er if mapping == nil { return nil, fmt.Errorf("no matching route found") } - if mapping.Protocol != "" && req.Match != nil && !matchesEventContext(req.Match) { - return nil, fmt.Errorf("async target 'match' must reference the event context ({$event.*}); use 'interval' for periodic emission") + if err := rejectNonEventAsyncConditions(mapping, req); err != nil { + return nil, err } return mapping, nil } for i := range s.mappings { if m := &s.mappings[i]; m.Pattern == req.Path && m.Method == req.Method { + if err := rejectSyncTimingFields(req); err != nil { + return nil, err + } return m, nil } } @@ -298,9 +272,9 @@ func (s *Server) resolveExampleTarget(req *addExampleRequest) (*RouteMapping, er } // needsRuntimeRegistration reports whether an async target carries a trigger -// (match or interval) that registers through the event broker / scheduler. +// (conditions or interval) that registers through the event broker / scheduler. func needsRuntimeRegistration(mapping *RouteMapping, req *addExampleRequest) bool { - return mapping.Protocol != "" && (req.Match != nil || req.Interval > 0) + return mapping.Protocol != "" && (len(req.Conditions) > 0 || req.Interval > 0) } // registerAsyncRuntimeExample registers an event-driven or periodically driven @@ -313,8 +287,8 @@ func (s *Server) registerAsyncRuntimeExample(w http.ResponseWriter, req *addExam headers[k] = v } ext := make(map[string]any) - if req.Match != nil { - ext["x-mock-match"] = req.Match + if len(req.Conditions) > 0 { + ext["x-mock-match"] = req.Conditions } if req.Interval > 0 { ext["x-mock-interval"] = req.Interval diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7777ab8..ee49565 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -61,9 +61,10 @@ func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *M Scenario: Validating add‑example request JSON Given a JSON string representing an add‑example request When validateAddExampleRequest is called -Then it returns error for missing required fields or invalid data, nil for valid requests +Then it returns error for missing required fields, mixed sync/async targeting, +or invalid data, nil for valid requests -Related spec scenarios: RS.MAPI.14 +Related spec scenarios: RS.MAPI.14, RS.MAPI.27, RS.MAPI.28 */ func TestValidateAddExampleRequest(t *testing.T) { t.Parallel() @@ -113,6 +114,16 @@ func TestValidateAddExampleRequest(t *testing.T) { json: `{"path":"/test","response":{"code":200,"body":{"message":"hello"}}}`, wantErr: false, }, + { + name: "mixed sync and async targeting rejected", + json: `{"path":"/test","channel":"/alerts","response":{"code":200}}`, + wantErr: true, // RS.MAPI.27: oneOf must match exactly one branch + }, + { + name: "valid async target with delay", + json: `{"channel":"/alerts","delay":50,"response":{"code":200}}`, + wantErr: false, + }, } for _, tt := range tests { @@ -758,12 +769,13 @@ func TestHandleAddExample(t *testing.T) { } tests := []struct { - name string - reqBody string - mappings []RouteMapping - wantStatus int - wantJSON map[string]any - wantExample bool // whether example should be added + name string + reqBody string + mappings []RouteMapping + wantStatus int + wantJSON map[string]any + wantErrorContains string // optional substring check on the error field + wantExample bool // whether example should be added }{ { name: "valid minimal request", @@ -804,9 +816,10 @@ func TestHandleAddExample(t *testing.T) { Pattern: "/test", ChiPattern: "/test", }}, - wantStatus: http.StatusBadRequest, - wantJSON: map[string]any{"error": "invalid request: (root): Must validate one and only one schema (oneOf); (root): path is required"}, - wantExample: false, + wantStatus: http.StatusBadRequest, + wantJSON: map[string]any{"error": "invalid request: (root): Must validate one and only one schema (oneOf)"}, + wantErrorContains: "path is required", + wantExample: false, }, { name: "no matching route", @@ -856,6 +869,14 @@ func TestHandleAddExample(t *testing.T) { assert.NotEmpty(t, resp[key], "id should not be empty") continue } + if key == "error" && tt.wantErrorContains != "" { + if errStr, ok := resp[key].(string); ok { + assert.Contains(t, errStr, tt.wantErrorContains, "field %s mismatch", key) + } else { + assert.Equal(t, expectedValue, resp[key], "field %s mismatch", key) + } + continue + } assert.Equal(t, expectedValue, resp[key], "field %s mismatch", key) } diff --git a/openspec/changes/archive/2026-09-07-unify-example-match-conditions/.openspec.yaml b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/.openspec.yaml new file mode 100644 index 0000000..2e24cfa --- /dev/null +++ b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-07 diff --git a/openspec/changes/archive/2026-09-07-unify-example-match-conditions/README.md b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/README.md new file mode 100644 index 0000000..3cfddae --- /dev/null +++ b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/README.md @@ -0,0 +1,3 @@ +# unify-example-match-conditions + +Unify POST /examples selection on a single conditions field; drop async-only match diff --git a/openspec/changes/archive/2026-09-07-unify-example-match-conditions/design.md b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/design.md new file mode 100644 index 0000000..37ebb46 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/design.md @@ -0,0 +1,109 @@ +## Context + +See proposal.md — Why for motivation. Current state that shapes the approach: + +- `POST /_mock/examples` is a `oneOf` discriminator over two branches + (sync/async) that merge a shared base (`NewExampleRequestBase` via `allOf`). + Selection currently splits across two fields: `conditions` (shared base, + sync-only in practice, evaluated by `exampleRegistry.selectDynamic` / + `exampleEligible`) and `match` (async-only, mapped onto the `x-mock-match` + extension for the event/connection contexts). +- The async registration path (`registerAsyncRuntimeExample`) builds a + `MessageExampleSpec` with `Extensions["x-mock-match"]` from `req.Match`; the + sync path (`registerDynamicExample`) stores `req.Conditions` into + `dynamicExample.conditions`. +- The Go request struct `addExampleRequest` (server_management.go) decodes + both `Match` and `Conditions`; server-side cross-field rules + (`decodeAddExampleRequest`, `resolveExampleTarget`, `needsRuntimeRegistration`) + read `Match` to enforce single-trigger and event-context semantics. +- Internal selection everywhere already goes through one engine + (`extensions.EvaluateParamsMatch` on `x-mock-match`) — the duplication exists + only in the wire/request layer, not the matching engine itself. +- The runtime validation schema + `internal/server/add_example_request_schema_gen.go` is generated from the + OpenAPI document (single source of truth) via `gen-control-schema`. + +## Goals / Non-Goals + +**Goals:** +- A single `conditions` field selects examples for every target kind in + `POST /_mock/examples`. +- Async targets honor `conditions` for event/connection/reply contexts; the + `match` wire field is removed with a clean drop. +- The generated schema and OpenAPI contract stay in sync. + +**Non-Goals:** +- Renaming the internal selection vocabulary (`conditions` stays a wire field + that maps onto `x-mock-match` internally; `x-mock-match` extension is + unchanged). +- Changing `interval`/`delay` semantics or placement (they remain async-only + timing siblings). +- Changing the `+specs-extensions` matching engine + (`extensions/match.go`, `classify.go`). +- Deprecation alias for `match` (decided: clean drop — field unreleased). + +## Decisions + +**D1: Keep `conditions` as the single field; remove `match`.** + +The `conditions` field already exists on the shared base and is the +evolutionary successor — it carries sync reply semantics today and can simply +gain async event/connection semantics. Alternative: unify on `match` — rejected +as it would break the existing sync `conditions` consumers and contradict the +stated goal of eliminating the duplicate rather than renaming it. + +**D2: Map async `conditions` onto the existing `x-mock-match` extension.** + +Async runtime examples are registered through the event broker / scheduler as +`MessageExampleSpec` with `Extensions["x-mock-match"]`. Repoint that mapping to +`req.Conditions`. No change to `internal/extensions` — the classification +(`MatchReferencesEvent`, `PartitionConnectionConditions`) and evaluation +(`EvaluateParamsMatch`) already handle all contexts. This keeps a single +matching engine and keeps the OpenAPI/doc gap (wire `conditions` ↔ extension +`x-mock-match`) as the only translation point. + +**D3: Drive the async target checks off `conditions` in the handler.** + +The single-trigger and event-context guards currently key off `req.Match` +(server_management.go: `decodeAddExampleRequest`, `resolveExampleTarget`, +`needsRuntimeRegistration`). These read `req.Conditions` instead, preserving +RS.MAPI.29 (interval xor event-based conditions) and RS.MAPI.35 (connection-only +conditions rejected on async) semantics. `needsRuntimeRegistration` becomes +`len(req.Conditions) > 0 || req.Interval > 0`. + +**D4: Reject the removed field at the handler, not the schema.** + +`internal/server/add_example_request_schema_gen.go` is `DO NOT EDIT` generated +and `validateAddExampleRequest` runs it. The `oneOf`/`allOf` request schema +leaves `additionalProperties` open (the base bag is explicitly "left open" for +`allOf` merging), so removing `match` from the YAML alone does NOT make the +schema reject a stale `match` field — the schema is permissive of unknown +top-level properties. Rejecting `match` at the schema would require closing +`additionalProperties`, which the `allOf` structure makes brittle (inner-branch +`additionalProperties: false` would not see base properties merged via +`allOf`, wrongly rejecting `conditions`/`method`/`response`). So the rejected +field and the misplaced-field rules are enforced in the handler: +`decodeAddExampleRequest` rejects a body that still carries `match` +(RS.MAPI.37), and `resolveExampleTarget` rejects `interval`/`delay` on an +OpenAPI (sync) target (RS.MAPI.28). The generated schema is still regenerated +from the OpenAPI contract (single source of truth) so `match` no longer +appears as a declared property. + +**D5: Widen the `conditions` doc (not schema) to cover event/connection contexts.** + +The current `conditions` `additionalProperties` already permits object (JSON +schema) values, which is all the event/connection forms need. Only the +description text is updated to document both context families. No typing +change required, keeping behavior identical for existing sync consumers. + +## Risks / Trade-offs + +- **Breaking wire change** (removal of `match`) → Clean drop is acceptable + because the field was introduced in an unreleased change; no alias kept per + decision. Mitigated by adding RS.MAPI.37 and a schema-level rejection test. +- **Silently-shifted async selection** — a prior async client sending + `conditions` (hoping for event semantics) was ignored; now it is honored, + which could change observed delivery. Mitigated by the async-conditions + spec scenario (RS.MAPI.36) and adding a behavioral test. +- **Generated-schema drift** → Regeneration is a step in tasks.md and verified + via `go generate` + build; the generated file is committed with the change. diff --git a/openspec/changes/archive/2026-09-07-unify-example-match-conditions/proposal.md b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/proposal.md new file mode 100644 index 0000000..7bde449 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/proposal.md @@ -0,0 +1,59 @@ +## Why + +`POST /examples` in the management API exposes two parallel example-selection +fields: the original sync `conditions` (on the shared request base) and the +async-only `match` (added by the async-management-extensions change). The two +are architecturally redundant — `match` merely mirrors the `x-mock-match` +spec-extension vocabulary against async event/connection contexts, while +`conditions` is ignored on async targets and `match` is ignored on sync targets +— so a client can send both (or a nonsense `conditions` on an async body) with +no effect and no documented rationale. This is a schema smell, not a deliberate +design, and it must be collapsed into a single selection field. + +## What Changes + +- Make `conditions` the single example-selection field for **all** target kinds + in `POST /examples` (sync `{$request.*}`/`{$message.*}`/`{$channel.*}` reply + contexts and async `{$event.*}`/`{$connection.*}` event/connection contexts). +- **BREAKING**: Remove the async-only `match` property from the request schema + (clean drop — no deprecation alias). A request body carrying `match` is now + rejected by schema validation. +- Async targets now honor `conditions` (previously silent no-op): an event- or + interval-triggered registration maps `conditions` onto the internal + `x-mock-match` extension. +- `interval` / `delay` remain async-only timing fields (selector stays pure of + timing, mirroring the `x-mock-match`/`x-mock-interval`/`x-mock-delay` split). +- Regenerate the runtime validation schema + (`internal/server/add_example_request_schema_gen.go`) from the OpenAPI + contract, keeping the document single source of truth. +- Update specs/docs that referenced the `match` wire field. + +## Capabilities + +### New Capabilities + +None. No new behavior domain is introduced. + +### Modified Capabilities + +- `management-api`: The `POST /examples` contract changes — examples of every + target kind select through a single `conditions` field; the `match` field is + removed and async targets accept (and honor) event/connection `conditions` + instead. + +## Impact + +- **API**: `api/openapi.yaml` — remove `match` from `NewExampleRequestAsync`; + re-scope the `conditions` description to cover both context families. +- **Handlers**: `internal/server/server_management.go` — drop `Match` from the + request struct; route `conditions` into the async runtime registration + (`registerAsyncRuntimeExample`), and update `resolveExampleTarget`, + `needsRuntimeRegistration`, and `decodeAddExampleRequest` to read + `conditions` instead of `match`. Internal selection still uses + `x-mock-match` (`internal/extensions/match.go`, `classify.go`) unchanged. +- **Generated schema**: `internal/server/add_example_request_schema_gen.go` + regenerated via `gen-control-schema` (Makefile `gen` target). +- **Specs/docs**: `openspec/specs/management-api/spec.md` (wire field rename of + async conditions) and `docs/extensions.md` (runtime `conditions` wording). +- **Tests**: update async handler tests that send `match`; add coverage that + async `conditions` is now honored and that a `match` field is rejected. diff --git a/openspec/changes/archive/2026-09-07-unify-example-match-conditions/specs/management-api/spec.md b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/specs/management-api/spec.md new file mode 100644 index 0000000..5ed9c90 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/specs/management-api/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Adding a runtime async-driven example +The `POST /_mock/examples` request SHALL accept, for AsyncAPI targets, an optional `conditions` object (mirroring `x-mock-match` against the event and connection contexts), an optional `interval` (positive integer ms for periodic emission), and an optional `delay` (integer ms). The mock server SHALL register the added message example (payload = `response.body`, headers = `response.headers`) as a live async-driven subscription delivered to the channel's consumers according to its conditions/interval, templating the payload at emission time against `{$event.*}`, `{$connection.*}`, `{$state.*}`, and `{$env.*}`. + +#### Scenario RS.MAPI.24: Registering a named-event runtime example +- **WHEN** a POST request is sent to `/_mock/examples` with an AsyncAPI target, `response.body`, and `conditions: {'{$event.name}': orderCreated}` +- **THEN** the server registers the message as a live subscription, responds with success and an example ID, and delivers the message when the `orderCreated` event fires + +#### Scenario RS.MAPI.25: Scheduling repeated delivery via interval +- **WHEN** a POST request includes `interval: 1000` for an AsyncAPI target +- **THEN** the message is delivered repeatedly at the 1000 ms interval until removed (or the server shuts down) + +#### Scenario RS.MAPI.26: Subscribing to the connect and receive built-ins +- **WHEN** a POST request includes `conditions: {'{$event.name}': connect}` or `{'{$event.name}': receive}` +- **THEN** the message is delivered to a consumer when it connects to the channel, or when the channel receives a client message (with the inbound message payload available to templates), respectively + +#### Scenario RS.MAPI.33: Targeting delivery by connection +- **WHEN** a POST request includes `conditions` with a `{$connection.*}` condition alongside an event condition +- **THEN** the registered message is delivered only to the channel's consumers satisfying that connection condition when the event fires + +#### Scenario RS.MAPI.36: Async conditions are honored +- **WHEN** a POST request targets an AsyncAPI channel and carries `conditions` referencing the reply context (`{$request.*}`/`{$message.*}`/`{$channel.*}`) +- **THEN** the example is selected by the same reply-context selection pipeline as spec examples (async `conditions` are honored, not silently ignored) + +### Requirement: Strict example target validation +The `POST /_mock/examples` request SHALL reject field combinations that mix or misplace sync and async targeting with HTTP 400. An OpenAPI target requires `path` (and uses `response`); an AsyncAPI target requires `channel` (optionally `protocol`); `conditions`/`interval`/`delay` are validated per target — `interval` and `delay` are only valid on AsyncAPI targets; a runtime example SHALL have exactly one trigger — `interval` OR an `{$event.*}`-based `conditions`, never both — and `interval` SHALL be a positive integer. The request schema SHALL NOT contain a `match` field. + +#### Scenario RS.MAPI.27: Mixing sync and async targeting +- **WHEN** a POST request includes both `path` and `channel` +- **THEN** the server responds with HTTP 400 + +#### Scenario RS.MAPI.28: match or interval on an OpenAPI target +- **WHEN** a POST request includes `path` with `interval` (or `delay`) but no AsyncAPI target +- **THEN** the server responds with HTTP 400 + +#### Scenario RS.MAPI.29: Dual or invalid triggers +- **WHEN** a POST request includes both `interval` and an event-based `conditions`, or an `interval` that is not a positive integer +- **THEN** the server responds with HTTP 400 + +#### Scenario RS.MAPI.35: Non-event match on an async target +- **WHEN** a POST request includes an AsyncAPI target and `conditions` referencing only `{$connection.*}` (or literal values) with no `{$event.*}` reference +- **THEN** the server responds with HTTP 400 and registers nothing (a runtime example needs a trigger; a connection-only match has none) + +#### Scenario RS.MAPI.37: Legacy match field rejected +- **WHEN** a POST request to `/_mock/examples` includes a `match` field +- **THEN** the server responds with HTTP 400 and registers nothing (the `match` field is removed; use `conditions`) diff --git a/openspec/changes/archive/2026-09-07-unify-example-match-conditions/tasks.md b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/tasks.md new file mode 100644 index 0000000..1b7ce95 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-unify-example-match-conditions/tasks.md @@ -0,0 +1,31 @@ +## 1. OpenAPI contract + +- [x] 1.1 Remove the `match` property from `NewExampleRequestAsync` in `api/openapi.yaml` and verify `openspec validate` / spectral lint passes on the document +- [x] 1.2 Reword the `conditions` description in `NewExampleRequestBase` to document both reply contexts (`{$request.*}`/`{$message.*}`/`{$channel.*}`) and async event/connection contexts (`{$event.*}`/`{$connection.*}`) and verify the YAML is valid + +## 2. Regenerate the runtime schema + +- [x] 2.1 Run `make gen` (gen-control-schema) to regenerate `internal/server/add_example_request_schema_gen.go` and verify the generated file no longer contains a `match` property and `go build ./...` succeeds + +## 3. Handler refactor (server_management.go) + +- [x] 3.1 Remove the `Match map[string]any` field from the `addExampleRequest` struct and update construction sites so only `Conditions` remains; verify compile +- [x] 3.2 In `registerAsyncRuntimeExample`, map `req.Conditions` onto `ext["x-mock-match"]` (instead of `req.Match`) and verify the async registration follows RS.MAPI.24/26/33 +- [x] 3.3 Update `needsRuntimeRegistration` to `len(req.Conditions) > 0 || req.Interval > 0`; update `resolveExampleTarget` and the single-trigger guard in `decodeAddExampleRequest` to read `req.Conditions`; verify `go build ./...` + +## 4. Tests (TDD order per AGENTS.md) + +- [x] 4.1 Update existing async handler tests that send the `match` field to send `conditions` (`internal/server/server_test.go` and async handler tests) and verify they pass +- [x] 4.2 Add a behavioral test that an async target with reply-context `conditions` is honored (RS.MAPI.36); verify it passes +- [x] 4.3 Add a validation test that a request body containing `match` is rejected with HTTP 400 (RS.MAPI.37); verify it passes +- [x] 4.4 Add/adjust unit tests in `internal/server` covering async event- and connection-based `conditions` (RS.MAPI.24, RS.MAPI.26, RS.MAPI.33) and the dual/invalid trigger guard (RS.MAPI.29); verify `go test ./internal/server/... ./internal/extensions/...` passes + +## 5. Specs and docs + +- [x] 5.1 Update `openspec/specs/management-api/spec.md` async requirements/scenarios from `match` to `conditions` (the change's delta spec already carries the new wording; reconcile the main spec on archive) and verify `openspec validate` passes +- [x] 5.2 Update `docs/extensions.md` "Runtime matches and timing (management API)" wording from `match` to `conditions` and verify the reference to `api/openapi.yaml` is consistent + +## 6. Verification + +- [x] 6.1 Run `go generate ./internal/server`, `go build ./...`, and `go test ./internal/server/... ./internal/extensions/...`; verify all pass +- [x] 6.2 Run spectral lint on `api/openapi.yaml` and `gitnexus detect-changes --scope all --repo .`; verify a clean result with the documented diff surface (server_management.go, generated schema, openapi.yaml, docs) diff --git a/openspec/specs/management-api/spec.md b/openspec/specs/management-api/spec.md index e1a2e6d..e429276 100644 --- a/openspec/specs/management-api/spec.md +++ b/openspec/specs/management-api/spec.md @@ -130,10 +130,10 @@ The management API SHALL expose `POST /_mock/events` to fire a named event ad-ho - **THEN** the server responds with HTTP 400 ### Requirement: Adding a runtime async-driven example -The `POST /_mock/examples` request SHALL accept, for AsyncAPI targets, an optional `match` object (mirroring `x-mock-match` against the event and connection contexts), an optional `interval` (positive integer ms for periodic emission), and an optional `delay` (integer ms). The mock server SHALL register the added message example (payload = `response.body`, headers = `response.headers`) as a live async-driven subscription delivered to the channel's consumers according to its match/interval, templating the payload at emission time against `{$event.*}`, `{$connection.*}`, `{$state.*}`, and `{$env.*}`. +The `POST /_mock/examples` request SHALL accept, for AsyncAPI targets, an optional `conditions` object (mirroring `x-mock-match` against the event and connection contexts), an optional `interval` (positive integer ms for periodic emission), and an optional `delay` (integer ms). The mock server SHALL register the added message example (payload = `response.body`, headers = `response.headers`) as a live async-driven subscription delivered to the channel's consumers according to its conditions/interval, templating the payload at emission time against `{$event.*}`, `{$connection.*}`, `{$state.*}`, and `{$env.*}`. #### Scenario RS.MAPI.24: Registering a named-event runtime example -- **WHEN** a POST request is sent to `/_mock/examples` with an AsyncAPI target, `response.body`, and `match: {'{$event.name}': orderCreated}` +- **WHEN** a POST request is sent to `/_mock/examples` with an AsyncAPI target, `response.body`, and `conditions: {'{$event.name}': orderCreated}` - **THEN** the server registers the message as a live subscription, responds with success and an example ID, and delivers the message when the `orderCreated` event fires #### Scenario RS.MAPI.25: Scheduling repeated delivery via interval @@ -141,32 +141,40 @@ The `POST /_mock/examples` request SHALL accept, for AsyncAPI targets, an option - **THEN** the message is delivered repeatedly at the 1000 ms interval until removed (or the server shuts down) #### Scenario RS.MAPI.26: Subscribing to the connect and receive built-ins -- **WHEN** a POST request includes `match: {'{$event.name}': connect}` or `{'{$event.name}': receive}` +- **WHEN** a POST request includes `conditions: {'{$event.name}': connect}` or `{'{$event.name}': receive}` - **THEN** the message is delivered to a consumer when it connects to the channel, or when the channel receives a client message (with the inbound message payload available to templates), respectively #### Scenario RS.MAPI.33: Targeting delivery by connection -- **WHEN** a POST request includes `match` with a `{$connection.*}` condition alongside an event condition +- **WHEN** a POST request includes `conditions` with a `{$connection.*}` condition alongside an event condition - **THEN** the registered message is delivered only to the channel's consumers satisfying that connection condition when the event fires +#### Scenario RS.MAPI.36: Async conditions are honored +- **WHEN** a POST request targets an AsyncAPI channel and carries `conditions` referencing the reply context (`{$request.*}`/`{$message.*}`/`{$channel.*}`) +- **THEN** the example is selected by the same reply-context selection pipeline as spec examples (async `conditions` are honored, not silently ignored) + ### Requirement: Strict example target validation -The `POST /_mock/examples` request SHALL reject field combinations that mix or misplace sync and async targeting with HTTP 400. An OpenAPI target requires `path` (and uses `response`); an AsyncAPI target requires `channel` (optionally `protocol`); `match`/`interval`/`delay` are only valid on AsyncAPI targets; a runtime example SHALL have exactly one trigger — `interval` OR an `{$event.*}`-based `match`, never both — and `interval` SHALL be a positive integer. +The `POST /_mock/examples` request SHALL reject field combinations that mix or misplace sync and async targeting with HTTP 400. An OpenAPI target requires `path` (and uses `response`); an AsyncAPI target requires `channel` (optionally `protocol`); `conditions`/`interval`/`delay` are validated per target — `interval` and `delay` are only valid on AsyncAPI targets; a runtime example SHALL have exactly one trigger — `interval` OR an `{$event.*}`-based `conditions`, never both — and `interval` SHALL be a positive integer. The request schema SHALL NOT contain a `match` field. #### Scenario RS.MAPI.27: Mixing sync and async targeting - **WHEN** a POST request includes both `path` and `channel` - **THEN** the server responds with HTTP 400 #### Scenario RS.MAPI.28: match or interval on an OpenAPI target -- **WHEN** a POST request includes `path` with `match` (or `interval`) but no AsyncAPI target +- **WHEN** a POST request includes `path` with `interval` (or `delay`) but no AsyncAPI target - **THEN** the server responds with HTTP 400 #### Scenario RS.MAPI.29: Dual or invalid triggers -- **WHEN** a POST request includes both `interval` and an event-based `match`, or an `interval` that is not a positive integer +- **WHEN** a POST request includes both `interval` and an event-based `conditions`, or an `interval` that is not a positive integer - **THEN** the server responds with HTTP 400 #### Scenario RS.MAPI.35: Non-event match on an async target -- **WHEN** a POST request includes an AsyncAPI target and a `match` whose conditions reference only `{$connection.*}` (or literal values) with no `{$event.*}` reference +- **WHEN** a POST request includes an AsyncAPI target and `conditions` referencing only `{$connection.*}` (or literal values) with no `{$event.*}` reference - **THEN** the server responds with HTTP 400 and registers nothing (a runtime example needs a trigger; a connection-only match has none) +#### Scenario RS.MAPI.37: Legacy match field rejected +- **WHEN** a POST request to `/_mock/examples` includes a `match` field +- **THEN** the server responds with HTTP 400 and registers nothing (the `match` field is removed; use `conditions`) + ### Requirement: Removing a dynamic example The mock server SHALL provide `DELETE /_mock/examples/{exampleId}` to remove a dynamically added example and cancel any recurring delivery registered under that example ID. From 04ee934d96f2998ddbf0a375e396f599b2286372 Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Mon, 7 Sep 2026 18:39:23 +0300 Subject: [PATCH 3/4] Update integration tests to use conditions field TestIntegration_ConnectionTargeting and TestIntegration_ReceiveBuiltIn_Runtime still sent the removed match wire field, which the unified conditions selector now rejects with HTTP 400. Switch both to conditions (RS.MAPI.33, RS.MAPI.26). --- test/asyncapi/management-api/management_api_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/asyncapi/management-api/management_api_test.go b/test/asyncapi/management-api/management_api_test.go index 8f38aca..ae2b1c5 100644 --- a/test/asyncapi/management-api/management_api_test.go +++ b/test/asyncapi/management-api/management_api_test.go @@ -256,7 +256,7 @@ func TestIntegration_ConnectionTargeting(t *testing.T) { port, stop := startManagementServer(t) defer stop() - addBody := `{"channel":"/alerts","match":{"{$event.name}":"levelUp","{$connection.id}":"{$event.connectionId}"},"response":{"code":200,"body":{"ring":"{$event.data}"}}}` + addBody := `{"channel":"/alerts","conditions":{"{$event.name}":"levelUp","{$connection.id}":"{$event.connectionId}"},"response":{"code":200,"body":{"ring":"{$event.data}"}}}` resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/examples", port), "application/json", strings.NewReader(addBody)) require.NoError(t, err) _ = resp.Body.Close() @@ -591,7 +591,7 @@ func TestIntegration_ReceiveBuiltIn_Runtime(t *testing.T) { port, stop := startManagementServer(t) defer stop() - addBody := `{"channel":"/alerts","match":{"{$event.name}":"receive"},"response":{"code":200,"body":{"echoed":"{$event.text}"}}}` + addBody := `{"channel":"/alerts","conditions":{"{$event.name}":"receive"},"response":{"code":200,"body":{"echoed":"{$event.text}"}}}` resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/examples", port), "application/json", strings.NewReader(addBody)) require.NoError(t, err) _ = resp.Body.Close() From cb0f35e7c5f1494a0180ab0bcc6f0f177e161c1b Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Mon, 7 Sep 2026 18:40:02 +0300 Subject: [PATCH 4/4] Update GitNexus index stats in AGENTS.md --- AGENTS.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fde593..952b052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,18 +48,17 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **oasmock** (2518 symbols, 5666 relationships, 133 execution flows). +This project is indexed by GitNexus as **oasmock** (3986 symbols, 10747 relationships, 251 execution flows). > Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). ## Always Do -- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. +- **MUST run impact before editing.** Use `impact({target: "symbolName", direction: "upstream"})` or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .`; report callers, processes, and risk. Never substitute grep for graph analysis. - **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File. - **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero. -- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. +- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals. - For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). ## Never Do