From 1f49790cb4d5309507eeb6e71b89f4c0945d6292 Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Sat, 5 Sep 2026 11:34:46 +0300 Subject: [PATCH 1/2] Add async management API extensions Unify event-driven example selection on x-mock-match with an event context and a per-connection recipient partition, add the timing extensions (x-mock-interval/x-mock-delay), a general /_mock/stream management WebSocket, a type-discriminated /_mock/events endpoint, unified POST/DELETE /_mock/examples with strict oneOf validation, and actually fire the connect/receive built-ins. Retire the schedule endpoint (410) and keep the legacy /ws/* and /events/fire aliases. Includes review-driven hardening: typed runtime trigger kinds, a shared delivery render path, consolidated test helpers, and strengthened deletion/validation/disconnect assertions. --- CHANGELOG.md | 33 ++ README.md | 13 +- api/asyncapi.yaml | 185 ++++++ api/openapi.yaml | 318 +++++++++- docs/architecture.md | 12 +- docs/extensions.md | 99 ++++ internal/extensions/classify.go | 108 ++++ internal/extensions/classify_test.go | 335 +++++++++++ internal/extensions/condition_value_test.go | 75 +++ internal/extensions/event_match_test.go | 145 +++++ internal/extensions/example_value.go | 71 +++ internal/extensions/match.go | 148 +++++ internal/extensions/match_test.go | 72 ++- internal/extensions/partition_test.go | 99 ++++ internal/extensions/testhelpers_test.go | 24 + internal/extensions/timing_test.go | 125 ++++ internal/loader/schema_test.go | 6 + internal/runtime/connection_source_test.go | 75 +++ internal/runtime/event_source_test.go | 62 ++ internal/runtime/expression.go | 66 ++- internal/server/add_example_runtime_test.go | 206 +++++++ .../server/add_example_validation_test.go | 260 +++++++++ .../server/async_consumers_getall_test.go | 178 ++++++ internal/server/async_push_regression_test.go | 166 ++++++ internal/server/async_state_test.go | 44 +- internal/server/builtin_triggers.go | 88 +++ internal/server/builtin_triggers_test.go | 174 ++++++ internal/server/control_api_spec_sync_test.go | 280 +++++++++ internal/server/delete_example_test.go | 147 +++++ internal/server/engine.go | 25 - internal/server/event_broker.go | 89 ++- internal/server/event_broker_test.go | 115 ++++ internal/server/event_delay_test.go | 101 ++++ internal/server/event_delivery_test.go | 357 ++++++++++++ internal/server/event_server.go | 548 +++++++++++++++--- internal/server/events_endpoint_test.go | 184 ++++++ internal/server/fire_event.go | 75 ++- internal/server/fire_event_endpoint_test.go | 18 +- internal/server/hubmanager.go | 93 +++ internal/server/interfaces.go | 23 +- internal/server/job_scheduler.go | 125 ++++ internal/server/job_scheduler_test.go | 213 +++++++ .../server/manage_stream_lifecycle_test.go | 153 +++++ .../server/manage_stream_observer_test.go | 155 +++++ internal/server/manage_stream_test.go | 215 +++++++ internal/server/manage_ws.go | 318 ++++++++++ internal/server/management_async.go | 93 +-- .../server/management_async_aliases_test.go | 252 ++++++++ .../server/management_async_lifecycle_test.go | 71 +-- internal/server/management_async_test.go | 51 +- internal/server/registry.go | 27 + internal/server/scheduler.go | 78 --- internal/server/send_events.go | 17 +- internal/server/server.go | 40 +- internal/server/server_management.go | 136 ++++- internal/server/server_runtime_example.go | 102 ++++ internal/server/server_test.go | 8 +- internal/server/signalr_builtin_test.go | 233 ++++++++ internal/server/signalr_hub.go | 75 +++ internal/server/testhelpers_test.go | 170 ++++++ internal/server/ws_adapter.go | 101 +++- internal/server/ws_adapter_test.go | 2 +- internal/server/x_send_events_shim_test.go | 59 ++ .../.openspec.yaml | 2 + .../async-management-api-extensions/design.md | 114 ++++ .../proposal.md | 48 ++ .../specs/asyncapi-management/spec.md | 68 +++ .../specs/event-driver/spec.md | 96 +++ .../specs/extensions/spec.md | 95 +++ .../specs/management-api/spec.md | 73 +++ .../async-management-api-extensions/tasks.md | 76 +++ test/_shared/clihelper/clihelper.go | 35 +- .../resources/asyncapi-management.yaml | 34 ++ .../management-api/management_api_test.go | 282 +++++++++ 74 files changed, 8339 insertions(+), 520 deletions(-) create mode 100644 api/asyncapi.yaml create mode 100644 internal/extensions/classify.go create mode 100644 internal/extensions/classify_test.go create mode 100644 internal/extensions/condition_value_test.go create mode 100644 internal/extensions/event_match_test.go create mode 100644 internal/extensions/partition_test.go create mode 100644 internal/extensions/testhelpers_test.go create mode 100644 internal/extensions/timing_test.go create mode 100644 internal/runtime/connection_source_test.go create mode 100644 internal/server/add_example_runtime_test.go create mode 100644 internal/server/add_example_validation_test.go create mode 100644 internal/server/async_consumers_getall_test.go create mode 100644 internal/server/async_push_regression_test.go create mode 100644 internal/server/builtin_triggers.go create mode 100644 internal/server/builtin_triggers_test.go create mode 100644 internal/server/control_api_spec_sync_test.go create mode 100644 internal/server/delete_example_test.go create mode 100644 internal/server/event_delay_test.go create mode 100644 internal/server/event_delivery_test.go create mode 100644 internal/server/events_endpoint_test.go create mode 100644 internal/server/job_scheduler.go create mode 100644 internal/server/job_scheduler_test.go create mode 100644 internal/server/manage_stream_lifecycle_test.go create mode 100644 internal/server/manage_stream_observer_test.go create mode 100644 internal/server/manage_stream_test.go create mode 100644 internal/server/manage_ws.go create mode 100644 internal/server/management_async_aliases_test.go delete mode 100644 internal/server/scheduler.go create mode 100644 internal/server/server_runtime_example.go create mode 100644 internal/server/signalr_builtin_test.go create mode 100644 internal/server/testhelpers_test.go create mode 100644 internal/server/x_send_events_shim_test.go create mode 100644 openspec/changes/async-management-api-extensions/.openspec.yaml create mode 100644 openspec/changes/async-management-api-extensions/design.md create mode 100644 openspec/changes/async-management-api-extensions/proposal.md create mode 100644 openspec/changes/async-management-api-extensions/specs/asyncapi-management/spec.md create mode 100644 openspec/changes/async-management-api-extensions/specs/event-driver/spec.md create mode 100644 openspec/changes/async-management-api-extensions/specs/extensions/spec.md create mode 100644 openspec/changes/async-management-api-extensions/specs/management-api/spec.md create mode 100644 openspec/changes/async-management-api-extensions/tasks.md create mode 100644 test/_shared/resources/asyncapi-management.yaml create mode 100644 test/asyncapi/management-api/management_api_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcff7f..fe6940d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Protocol-neutral async management prefix `/_mock/async/{push,consumers,disconnect}`; legacy `/_mock/ws/*` kept as deprecated aliases +- 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` with a `type` discriminator (V1: `fire`), replacing `/_mock/events/fire` (deprecated alias kept) +- Management WebSocket stream `/_mock/stream` with connect-time `events`/`channels` filters; pushes `event`/`push`/`consumer`/`schedule` envelopes +- Event-context matching: `{$event.name}` (identity), `{$event.data}` (whole payload) alongside `{$event.}`; `{$connection.*}` per-connection recipient partition (id/channel/query/header) with broadcast fast path +- Timing extensions `x-mock-interval` (periodic emission) and `x-mock-delay` (delayed emission); `cron` is no longer an event +- Actually-fired built-in triggers `connect` (on consumer connection) and `receive` (on inbound traffic), gated by a cheap `hasSubscribers` check +- Consumers listable without a `channel` filter — flat union across all channels (raw ws + SignalR streams) +- `x-send-events` is deprecated: a loader mapping shim translates `{on, wait}` to the match/interval form with a verbose deprecation note; removal deferred one release + +### Changed +- Recurring delivery moved off the schedule endpoint onto `interval` on `/_mock/examples`; `/_mock/ws/schedule{,/{pushId}}` now answer `410 Gone` pointing at the examples endpoint +- `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 + ### Fixed +- `x-mock-delay` now actually delays an async emission (it was parsed but never applied); a `connect` welcome honors it too +- The deprecated `/_mock/events/fire` alias again accepts the legacy type-less body shape (defaulting to `fire`) instead of requiring the new `type` discriminator +- A runtime async `match` without an `{$event.*}` reference is rejected with 400 instead of silently registering nothing +- `DELETE /_mock/examples/{exampleId}` now also removes sync (OpenAPI) dynamic examples, not only async-driven ones +- The `/_mock/stream` ping keepalive goroutine no longer leaks past the connection's lifetime +- Schema registration is atomic: a load/classification error from any example aborts the whole schema without leaking already-started interval jobs +- A periodically driven example is single-trigger — declaring `x-mock-interval` together with any `x-mock-match` is rejected at load (previously the match was silently dropped at delivery), and period examples honor `x-mock-skip` +- An `{$event.name}` identity whose value is itself a runtime expression is rejected at load instead of registering a subscription key that could never match (matches without an identity pin stay wildcard) +- Timing extensions require integer milliseconds: fractional `x-mock-interval`/`x-mock-delay` values are load errors rather than silently truncated +- A panicking interval delivery is recovered: the job is unregistered and logged instead of silently losing its cadence +- SignalR built-in `connect`/`receive` use a deterministic default channel address instead of map-iteration order for multi-channel hubs +- Periodic deliveries now emit `push` envelopes and built-in `connect` fires emit `event` envelopes to `/_mock/stream` subscribers; `schedule` `started`/`stopped` envelopes carry the same example identity, channel and interval so clients can correlate them +- SignalR upgrades now capture query/headers so `{$connection.query.*}`/`{$connection.header.*}` resolve for hub connections too +- `{$event.*}`/`{$connection.*}` condition values pre-resolve at delivery; reply-path condition values stay literal (sync matching unchanged) +- Legacy `x-send-events {on: cron}` without a positive `wait` is a load error instead of a silent no-op - Docker image `/app/oasmock` is now marked executable — GitHub artifact downloads strip exec bits, breaking `ENTRYPOINT` in the published image - CI-built binaries are now statically linked (`CGO_ENABLED=0`) — previously `linux/amd64` was dynamically linked against glibc, causing `exec /app/oasmock: no such file or directory` in the `distroless/static` image - Release Docker image is now smoke-tested (starts and serves the control API) before it is pushed to Docker Hub, via a shared `smoke-test-image` action also used by the PR `docker-build` check - `api/openapi.yaml` was an invalid OpenAPI document (array schemas missing `items`), causing the server to exit at startup and the Docker smoke test to fail with connection refused; now fixed and covered by a loader test - Docker smoke test now waits for server readiness with a retry loop and dumps container logs on failure for diagnosis +## [0.1.0] - Initial Release + ### Added - Initial release of OASMock - OpenAPI mock server - Support for OpenAPI 3.0 schemas with custom extensions diff --git a/README.md b/README.md index e2ad0ea..d70299a 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Route calls by body field instead of URL path. See [json-rpc.md](./docs/json-rpc ## Runtime Expressions -Runtime expressions are enclosed in `{$...}` and resolved at request time. Data sources: `{$request.path.param}`, `{$request.query.param}`, `{$request.header.name}`, `{$request.body.field}`, `{$request.cookie.name}`, `{$state.key}`, `{$env.VARIABLE}`. +Runtime expressions are enclosed in `{$...}` and resolved at request time. Data sources: `{$request.path.param}`, `{$request.query.param}`, `{$request.header.name}`, `{$request.body.field}`, `{$request.cookie.name}`, `{$state.key}`, `{$env.VARIABLE}`, and for async-driven examples `{$event.name}`/`{$event.data}`/`{$event.}` plus per-connection `{$connection.id}`/`{$connection.channel}`/`{$connection.query.}`/`{$connection.header.}`. Modifiers: `\|default:value` (fallback), `\|getByPath:path` (traverse nested objects), `\|toJWT` (stub). @@ -126,10 +126,19 @@ Expressions can appear in extension keys, values, and response bodies. Full refe ## Management API -The server exposes a control HTTP API under the `/_mock` prefix. Full schema: [api/openapi.yaml](./api/openapi.yaml). +The server exposes a control HTTP API under the `/_mock` prefix. Full schema: [api/openapi.yaml](./api/openapi.yaml). The asynchronous control surface (the management WebSocket stream `/_mock/stream` and its envelopes) is described in [api/asyncapi.yaml](./api/asyncapi.yaml). Both specs are kept in sync with the implementation by contract tests in `internal/server/control_api_spec_sync_test.go`. - `GET /_mock/requests` — request history (filterable by path, method, time range, pagination) - `POST /_mock/examples` — add a dynamic example to an existing route + - sync (OpenAPI) targets use `path`; AsyncAPI targets use `channel` with optional `match`/`interval`/`delay` mirroring `x-mock-match`/`x-mock-interval`/`x-mock-delay` for live event-driven or recurring delivery +- `DELETE /_mock/examples/{exampleId}` — remove a dynamic example and cancel any recurring interval delivery +- `POST /_mock/events` — fire a named event ad-hoc with a `type` discriminator (`fire` for V1) +- `POST /_mock/async/push` — push a message to channel consumers (immediate/delayed, targeted/broadcast) +- `GET /_mock/async/consumers` — list connected consumers (`channel` optional, all channels when omitted) +- `POST /_mock/async/disconnect` — force-disconnect a consumer +- `GET /_mock/stream` — management WebSocket stream of runtime notifications (event/push/consumer/schedule envelopes, filtered at connect time) + +The legacy `/_mock/ws/*` aliases and `/_mock/events/fire` are deprecated but still work; the removed `/_mock/ws/schedule*` answers `410 Gone` pointing at the examples endpoint. ## Command‑Line Interface diff --git a/api/asyncapi.yaml b/api/asyncapi.yaml new file mode 100644 index 0000000..c45e07b --- /dev/null +++ b/api/asyncapi.yaml @@ -0,0 +1,185 @@ +asyncapi: 3.0.0 +info: + title: OASMock control AsyncAPI + description: | + Asynchronous surface of the OASMock control plane. The management WebSocket + stream (GET /_mock/stream) is the general cross-cutting control channel for + mock-runner test harnesses: it pushes runtime notifications (fired events, + message pushes, consumer lifecycle and schedule start/stop) as JSON + envelopes. V1 is notifications-only — filters are set at connect time via + the events and channels query parameters and the server pushes envelopes; + the client does not send commands on this channel. + version: 0.1.0 +defaultContentType: application/json +channels: + stream: + address: /_mock/stream + title: Management notification stream + description: | + A single consumer connects at upgrade time (WebSocket). The server pushes + zero or more JSON envelopes; each envelope's type discriminates its + payload. An omitted filter parameter matches everything; '*' is a glob. + parameters: + events: + description: Comma-separated event-name globs to subscribe to (omit for all) + channels: + description: Comma-separated channel-address globs to subscribe to (omit for all) + bindings: + ws: + method: GET + messages: + EventNotification: + $ref: '#/components/messages/EventNotification' + PushNotification: + $ref: '#/components/messages/PushNotification' + ConsumerNotification: + $ref: '#/components/messages/ConsumerNotification' + ScheduleNotification: + $ref: '#/components/messages/ScheduleNotification' +operations: + receiveNotifications: + action: receive + channel: + $ref: '#/channels/stream' + messages: + - $ref: '#/channels/stream/messages/EventNotification' + - $ref: '#/channels/stream/messages/PushNotification' + - $ref: '#/channels/stream/messages/ConsumerNotification' + - $ref: '#/channels/stream/messages/ScheduleNotification' +components: + messages: + EventNotification: + name: event + summary: Event notification — fired when a named event fires (spec-triggered or via POST /_mock/events) + contentType: application/json + payload: + $ref: '#/components/schemas/EventEnvelope' + PushNotification: + name: push + summary: Push notification — fired when a management push delivers a message to a channel + contentType: application/json + payload: + $ref: '#/components/schemas/PushEnvelope' + ConsumerNotification: + name: consumer + summary: Consumer lifecycle notification — fired when a consumer connects to or disconnects from a channel + contentType: application/json + payload: + $ref: '#/components/schemas/ConsumerEnvelope' + ScheduleNotification: + name: schedule + summary: Schedule notification — fired when a periodic message example starts or stops + contentType: application/json + payload: + $ref: '#/components/schemas/ScheduleEnvelope' + schemas: + EventEnvelope: + type: object + required: + - type + properties: + type: + type: string + const: event + ts: + type: integer + format: int64 + event: + $ref: '#/components/schemas/EventEnvelopeBody' + EventEnvelopeBody: + type: object + properties: + name: + type: string + description: The named event (or built-in kind) that fired + schema: + type: string + description: Schema scope of the fire (empty for global) + global: + type: boolean + default: false + payload: + type: object + additionalProperties: true + description: Event payload exposed to templates via {$event.*} + PushEnvelope: + type: object + required: + - type + properties: + type: + type: string + const: push + ts: + type: integer + format: int64 + push: + $ref: '#/components/schemas/PushEnvelopeBody' + PushEnvelopeBody: + type: object + properties: + channel: + type: string + description: Channel address the message was pushed to + connectionId: + type: string + description: Target connection when the push was targeted + payload: + type: object + additionalProperties: true + description: The delivered message payload + ConsumerEnvelope: + type: object + required: + - type + properties: + type: + type: string + const: consumer + ts: + type: integer + format: int64 + consumer: + $ref: '#/components/schemas/ConsumerEnvelopeBody' + ConsumerEnvelopeBody: + type: object + properties: + action: + type: string + enum: [connected, disconnected] + connectionId: + type: string + channel: + type: string + streams: + type: array + items: + type: object + additionalProperties: true + description: Open SignalR streams for the connection + ScheduleEnvelope: + type: object + required: + - type + properties: + type: + type: string + const: schedule + ts: + type: integer + format: int64 + schedule: + $ref: '#/components/schemas/ScheduleEnvelopeBody' + ScheduleEnvelopeBody: + type: object + properties: + action: + type: string + enum: [started, stopped] + exampleId: + type: string + channel: + type: string + interval: + type: integer + description: Delivery interval in milliseconds \ No newline at end of file diff --git a/api/openapi.yaml b/api/openapi.yaml index 41f7f4c..ee4fb07 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -14,6 +14,12 @@ paths: description: | Adds a custom example to the mock server for a given path and method. The example can be conditional based on request parameters. + + Note: the operation is not idempotent. Each successful call registers a + distinct example (and, for async interval examples, a separate delivery + job). Re-sending a request after a lost response creates a second + subscription; capture the returned `id` and use `DELETE + /_mock/examples/{id}` to stop recurring delivery. requestBody: required: true content: @@ -31,6 +37,29 @@ paths: description: Invalid request '500': description: Internal server error + /examples/{exampleId}: + delete: + operationId: deleteExample + summary: Remove a dynamic example + description: | + Removes a dynamically added example, cancelling any recurring interval + delivery registered under that example id. + parameters: + - name: exampleId + in: path + required: true + description: Example id returned when the example was added + schema: + type: string + responses: + '200': + description: Example removed + content: + application/json: + schema: + $ref: '#/components/schemas/AsyncActionResponse' + '404': + description: Unknown exampleId /requests: get: summary: Retrieve request history @@ -81,13 +110,14 @@ paths: application/json: schema: $ref: '#/components/schemas/RequestHistoryResponse' - /events/fire: + /events: post: operationId: fireEvent summary: Fire a named event on the event bus description: | Fires a named event ad-hoc, delivering it (immediately or after a delay) - to x-send-events consumers across the loaded schemas. + to event-driven message examples across the loaded schemas. The request + carries a required type discriminator — V1 supports "fire" only. requestBody: required: true content: @@ -101,9 +131,29 @@ paths: application/json: schema: $ref: '#/components/schemas/AsyncActionResponse' + '400': + description: Invalid request (missing/unknown type or event) + /events/fire: + post: + operationId: fireEventLegacy + summary: Fire a named event (deprecated alias) + description: | + Deprecated alias of POST /_mock/events. Accepts the legacy body without + the `type` discriminator (defaults to "fire") alongside the new shape. + Kept for backward compatibility; use /_mock/events. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FireEventRequest' + deprecated: true + responses: + '200': + description: Event accepted '400': description: Invalid request - /ws/push: + /async/push: post: operationId: pushToChannel summary: Push a message to channel consumers @@ -128,18 +178,19 @@ paths: description: Invalid request '404': description: Unknown connectionId - /ws/consumers: + /async/consumers: get: operationId: listConsumers summary: List connected consumers per channel description: | Returns the currently connected consumers for an AsyncAPI channel, - including open SignalR streams when applicable. + including open SignalR streams when applicable. The channel parameter is + optional: when omitted, consumers across all channels are returned. parameters: - name: channel in: query - required: true - description: Channel address + required: false + description: Channel address (omit for consumers across all channels) schema: type: string responses: @@ -149,51 +200,137 @@ paths: application/json: schema: $ref: '#/components/schemas/ConsumersResponse' - /ws/schedule: + /async/disconnect: post: - operationId: scheduleRecurringPush - summary: Schedule a recurring push + operationId: disconnectConsumer + summary: Force-disconnect a consumer description: | - Schedules a message to be pushed to a channel at a fixed interval until - cancelled by its push ID. + Terminates a connected consumer's WebSocket connection, with an optional + close reason/code, or simulates an abrupt drop. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/ScheduleRequest' + $ref: '#/components/schemas/DisconnectRequest' responses: '200': - description: Schedule created + description: Consumer disconnected content: application/json: schema: $ref: '#/components/schemas/AsyncActionResponse' '400': description: Invalid request + '404': + description: Unknown connectionId + /stream: + get: + operationId: managementStream + summary: Management WebSocket stream (notifications) + description: | + Upgrades to a WebSocket stream of runtime notifications. V1 is + notifications-only: filters are set at connect time via the events and + channels query parameters (comma-separated, * wildcard) and the server + pushes JSON envelopes of type event | push | consumer | schedule. + A non-upgrade request is rejected with 405. + parameters: + - name: events + in: query + description: Comma-separated event-name globs to subscribe to (omit for all) + schema: + type: string + - name: channels + in: query + description: Comma-separated channel-address globs to subscribe to (omit for all) + schema: + type: string + responses: + '101': + description: | + Switching Protocols — the connection upgrades to a WebSocket and the + server pushes ManageEnvelope frames (per the envelope schemas; not an + HTTP response body) + '405': + description: Non-upgrade request rejected + /ws/push: + post: + operationId: pushToChannelLegacy + summary: Push a message (deprecated alias) + deprecated: true + description: Deprecated alias of POST /_mock/async/push. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushRequest' + responses: + '200': + description: Push accepted + '400': + description: Invalid request + '404': + description: Unknown connectionId + /ws/consumers: + get: + operationId: listConsumersLegacy + summary: List connected consumers (deprecated alias) + deprecated: true + description: Deprecated alias of GET /_mock/async/consumers. + parameters: + - name: channel + in: query + required: false + description: Channel address (omit for all channels) + schema: + type: string + responses: + '200': + description: Consumers listed + content: + application/json: + schema: + $ref: '#/components/schemas/ConsumersResponse' + /ws/schedule: + post: + operationId: scheduleRecurringPush + summary: Schedule a recurring push (removed) + description: | + Removed. Recurring delivery is expressed via the interval field on + POST /_mock/examples for an AsyncAPI target. This path answers 410 Gone. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleRequest' + responses: + '410': + description: Gone — use POST /_mock/examples with interval /ws/schedule/{pushId}: delete: operationId: stopRecurringPush - summary: Stop a recurring push + summary: Stop a recurring push (removed) + description: | + Removed. Recurring delivery is stopped with DELETE /_mock/examples/{id}. + This path answers 410 Gone. parameters: - name: pushId in: path required: true - description: Push ID returned by the schedule endpoint + description: Push ID returned by the former schedule endpoint schema: type: string responses: - '200': - description: Schedule stopped - '404': - description: Unknown pushId + '410': + description: Gone — use DELETE /_mock/examples/{exampleId} /ws/disconnect: post: - operationId: disconnectConsumer - summary: Force-disconnect a consumer - description: | - Terminates a connected consumer's WebSocket connection, with an optional - close reason/code, or simulates an abrupt drop. + operationId: disconnectConsumerLegacy + summary: Force-disconnect a consumer (deprecated alias) + deprecated: true + description: Deprecated alias of POST /_mock/async/disconnect. requestBody: required: true content: @@ -203,6 +340,10 @@ paths: responses: '200': description: Consumer disconnected + content: + application/json: + schema: + $ref: '#/components/schemas/AsyncActionResponse' '400': description: Invalid request '404': @@ -213,10 +354,29 @@ components: type: object required: - response + 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 + description: The request path (including path parameters) to match (OpenAPI target) protocol: type: string enum: [http, ws] @@ -229,6 +389,27 @@ components: 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 @@ -256,11 +437,12 @@ components: $ref: '#/components/schemas/ExampleResponse' ExampleResponse: type: object + required: + - code properties: code: type: integer - description: HTTP status code - default: 200 + description: HTTP status code (required) headers: type: object additionalProperties: @@ -285,6 +467,13 @@ components: id: type: string description: Unique identifier for the added example + kind: + type: string + enum: [event, interval] + description: Trigger kind for async-driven examples (match/interval) + jobID: + type: string + description: Scheduler job id for interval-driven examples RequestHistoryItem: type: object properties: @@ -319,8 +508,13 @@ components: FireEventRequest: type: object required: + - type - event properties: + type: + type: string + enum: [fire] + description: Action discriminator. V1 supports "fire" only. event: type: string description: The named event to fire @@ -371,6 +565,69 @@ components: payload: type: object description: Message payload pushed at each interval + 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 + 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 DisconnectRequest: type: object required: @@ -396,10 +653,7 @@ components: type: boolean event: type: string - description: Fired event name (fire-event endpoint only) - pushId: - type: string - description: Scheduled push id (schedule endpoint only) + description: Fired event name (fire-event endpoints only) ConsumersResponse: type: object properties: diff --git a/docs/architecture.md b/docs/architecture.md index 967ccfe..1727831 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,9 +156,11 @@ flowchart LR - `registry.go` - `exampleRegistry`: x-mock-once markers, dynamic examples, TTL sweep - `convert.go` - Single loader↔server route/type conversion points - `hubmanager.go` - `hubManager` (implements `ConsumerBus`): SignalR hubs + ws broadcast - - `event_server.go` - `eventBus`: event broker coordination on `MessageRenderer` + `ConsumerBus` + - `event_server.go` - `eventBus`: event broker coordination on `MessageRenderer` + `ConsumerBus`; classification, partition delivery, management-stream observer - `event_broker.go` - `eventBroker`: subscription registry + dispatch (pure) - - `scheduler.go` - `pushScheduler`: recurring push jobs (pure) + - `job_scheduler.go` - `jobScheduler`: per-example interval jobs (pure) + - `manage_ws.go` - management WebSocket stream (`/_mock/stream`) with envelope broadcasting + - `builtin_triggers.go` - connect/receive built-in firing + consumer lifecycle notifications - `server_management.go` - Management API endpoints - `server_example.go` / `server_eval.go` / `server_state.go` - Thin Server forwarders (selection/templating/state) - `jsonrpc.go` - JSON-RPC handler (gateway requests) @@ -340,8 +342,10 @@ OASMock autodetects AsyncAPI 3.0.0/3.1.0 files (root key `asyncapi`, version maj - **Neutral document view** (`internal/asyncapi/`): channels, operations, messages, bindings, examples with `x-mock-*` extensions; root `x-signalr` capture. The vendored `benelser/go-asyncapi` parser is isolated behind this package. - **Protocol adapters** (`internal/server/protocol.go`): `ProtocolAdapter` strategies registered keyed by protocol. `httpAdapter` reuses the HTTP pipeline; `wsAdapter` upgrades WebSockets and tracks a connection registry. - **SignalR overlay** (`internal/server/signalr_hub.go`): a document with root `x-signalr` is served as one SignalR hub — `POST {hubPath}/negotiate`, token-correlated ws upgrade, `\x1e` framing, handshake. Streams map to channels (`StreamInvocation` → held-open `StreamItem`); one-shot invocations map to operations (`Invocation` → `Completion`); event-driven items append into open streams via the open-stream registry. -- **Event broker** (`internal/server/event_broker.go`): OpenAPI examples fire named events via `x-event-trigger`; AsyncAPI message examples subscribe via `x-send-events` (named + `connect`/`cron`/`receive`). Delivery is broadcast with client-side filtering (`{$event.*}` templating). `POST /_mock/events/fire` fires events ad-hoc. -- **Async mocking management API**: `/_mock/ws/push` (delayed/targeted/broadcast), `/_mock/ws/consumers` (discovery incl. SignalR streams), `/_mock/ws/schedule` (recurring push, cancellable), `/_mock/ws/disconnect` (lifecycle control). `/_mock/examples` accepts AsyncAPI route identifiers (protocol + channel). +- **Event broker** (`internal/server/event_broker.go`): OpenAPI examples fire named events via `x-event-trigger`; AsyncAPI message examples are classified at load (event-driven via `{$event.*}` `x-mock-match`, periodically driven via `x-mock-interval`, or reply) and registered keyed by match identity + schema scope. Classification is strict and atomic: mixed match contexts, an interval alongside any `x-mock-match`, a non-literal event identity, or fractional timing values are load errors, and a failed schema never partially registers. Legacy `x-send-events` entries map through a deprecation shim. Delivery runs the shared selection pipeline against the event context and narrows to per-connection recipients via a two-phase `{$connection.*}` partition (broadcast fast path otherwise). `POST /_mock/events` (with a `type` discriminator) fires events ad-hoc; built-ins `connect`/`receive` are actually fired from ws/SignalR lifecycle and inbound hooks gated by a cheap `hasSubscribers` check. +- **Scheduler** (`internal/server/job_scheduler.go`): per-example `{id, interval, deliver func()}` interval jobs drive periodically driven examples, with per-delivery templating against current state/env; shutdown cancels all jobs, `DELETE /_mock/examples/{id}` cancels individual ones. +- **Async mocking management API**: `/_mock/async/{push,consumers,disconnect}` (canonical, protocol-neutral prefix; legacy `/_mock/ws/*` kept as deprecated aliases), `/_mock/events` (type-discriminated fire), `/_mock/examples` (unified sync/async injection with strict oneOf validation and runtime `match`/`interval`/`delay`, plus `DELETE /_mock/examples/{id}`), `/_mock/stream` (management WebSocket notifications). The removed `/_mock/ws/schedule*` surface answers `410 Gone` pointing at the examples endpoint. +- **Management stream** (`internal/server/manage_ws.go`): `/_mock/stream` upgrade handler with connect-time `events`/`channels` filters; pushes `event`/`push`/`consumer`/`schedule` envelopes from the eventBus observer, consumer lifecycle hooks and scheduler start/stop. V1 is notifications-only (pings/pongs keep the socket alive). - **Templating parity**: `{$message.*}`, `{$channel.*}`, `{$event.*}` data sources; `ExampleValue` wrapper unifies selection across OpenAPI/AsyncAPI; state/history integration uses each schema's isolated namespace. - **Unsupported protocols** (`amqp`, `kafka`, ...) fail startup with exit code 3. diff --git a/docs/extensions.md b/docs/extensions.md index f5b6ce2..60e9568 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -23,6 +23,103 @@ examples: **Alias**: x-mock-params-match — deprecated, kept for backward compatibility +### Event-context matching (async-driven examples) + +On an AsyncAPI message example, `x-mock-match` selects the example against the event context instead of a request context. The event context exposes: + +| Expression | Description | +|--------------------|-------------------------------------------------------------------| +| `{$event.name}` | Event identity: the named-event name or the built-in kind (`connect`/`receive`) | +| `{$event.data}` | The whole event payload | +| `{$event.}` | A single event payload field | + +`name` and `data` are reserved metadata names; payload fields with those names are shadowed (reachable via `{$event.data.}`). + +```yaml +examples: + orderAlert: + payload: + severity: '{$event.priority}' + x-mock-match: + '{$event.name}': orderCreated +``` + +An example whose `x-mock-match` references `{$event.*}` is classified at load as event-driven. Mixing `{$event.*}` with `{$request.*}`/`{$message.*}`/`{$channel.*}` in the same match, or declaring both `x-mock-interval` and `x-mock-match`, is rejected at load. + +When a match pins the identity (`{$event.name}`), the condition value **must be a literal string**: an expression value (e.g. `{$state.envName}`) is rejected at load, because the produced subscription key could never match a fired identity. A match that references the event context without an `{$event.name}` condition (e.g. `'{$connection.id}': '{$event.connectionId'}`) still registers as an event-driven example with a **wildcard identity** that evaluates against every fired event. + +Condition **values** follow the context of their key: on an event/connection key a full-expression value (e.g. `'{$connection.id}': '{$event.connectionId}'`) is resolved before comparison, while values on the reply-path contexts (`{$request.*}`/`{$message.*}`/`{$channel.*}`) are always compared literally. + +### Per-connection recipient matching (`{$connection.*}`) + +For event-driven examples, conditions referencing the connection context become a **per-connection recipient filter** evaluated at delivery time in two phases: non-connection conditions are evaluated once per emission, and `{$connection.*}` conditions are evaluated against each candidate consumer. Only consumers satisfying both phases receive the message. When no condition references `{$connection.*}`, delivery broadcasts to all consumers of the channel. + +| Expression | Description | +|------------------------------------|------------------------------------------------------| +| `{$connection.id}` | Consumer connection id | +| `{$connection.channel}` | Channel address the consumer connected to | +| `{$connection.query.}` | Query parameter captured at upgrade | +| `{$connection.header.}` | Request header captured at upgrade (lower-cased) | + +```yaml +examples: + targeted: + payload: + ring: '{$event.data}' + x-mock-match: + '{$event.name}': orderCreated + '{$connection.id}': '{$event.connectionId}' +``` + +A condition referencing `{$connection.*}` on a reply-path example (no connection context available) never matches, and in verbose mode a warning is logged. + +### x-mock-interval / x-mock-delay + +**Location**: AsyncAPI message example object + +Timing-only sibling extensions that keep `x-mock-match` pure. Neither is an event identity. A periodically driven example is single-trigger: declaring `x-mock-interval` together with any `x-mock-match` is **rejected at load** (a periodic emission has no match context to honor — periodicity and selection are mutually exclusive). + +- `x-mock-interval`: positive integer milliseconds marking a **periodically driven** example — the message is emitted repeatedly at that cadence until removed or the server shuts down. + +```yaml +examples: + ticker: + payload: + seq: '{$state.counter}' + x-mock-interval: 1000 +``` + +- `x-mock-delay`: integer milliseconds (default 0) delaying emission after an event fire. + +```yaml +examples: + welcome: + payload: + msg: hello + x-mock-match: + '{$event.name}': connect + x-mock-delay: 150 +``` + +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. + +### x-send-events (deprecated) + +**Location**: AsyncAPI message example object + +**Deprecated**: kept for one release with a loader mapping shim. Each entry is translated to the unified form during loading with a verbose-mode deprecation note: + +- `{on: }` → `x-mock-match: {'{$event.name}': }` +- `{on: connect, wait: N}` → `x-mock-match: {'{$event.name}': connect}` + `x-mock-delay: N` +- `{on: receive}` → `x-mock-match: {'{$event.name}': receive}` +- `{on: cron, wait: N}` → `x-mock-interval: N` + +### Runtime matches 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`. + +> **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}`. + ## x-mock-skip **Location**: OAS example object @@ -133,3 +230,5 @@ Value modifiers can be specified after a `|` sign. Example: `{$request.path.para | `{$request.cookie.cookieName}` | Parsed request cookies | | `{$state.someSavedParam}` | State data (set previously with `x-mock-set-state`) | | `{$env.ENV_VAR}` | Runtime environment variables | +| `{$event.name}` / `{$event.data}` / `{$event.}` | Event identity, whole payload, and payload fields (async-driven examples) | +| `{$connection.id}` / `{$connection.channel}` / `{$connection.query.}` / `{$connection.header.}` | Per-connection recipient matching (event delivery) | diff --git a/internal/extensions/classify.go b/internal/extensions/classify.go new file mode 100644 index 0000000..a3d1dad --- /dev/null +++ b/internal/extensions/classify.go @@ -0,0 +1,108 @@ +package extensions + +import "fmt" + +// TriggerKind classifies how an AsyncAPI message example is driven at load. +type TriggerKind int + +const ( + // TriggerReply is a plain sync/async reply example (no event match, no + // interval; may still carry a request/message/channel match). + TriggerReply TriggerKind = iota + // TriggerEvent is event-driven: its x-mock-match references {$event.*}. + TriggerEvent + // TriggerPeriodic declares x-mock-interval for recurring emission. + TriggerPeriodic +) + +// Trigger is the classification result for one message example. +type Trigger struct { + // Kind is the driving mechanism. + Kind TriggerKind + // Identity is the event identity for event triggers (the {$event.name} + // condition value, or the built-in connect/receive kind). + Identity string + // Interval is the millisecond cadence for periodic triggers. + Interval int + // Delay is the millisecond emission delay for event triggers. + Delay int + // Match is the example's x-mock-match conditions (any kind). + Match map[string]any +} + +// ClassifyTrigger classifies a message example from its x-mock-match timing +// extensions (design D3). It rejects mixed match contexts ({$event.*} with +// {$request.*}/{$message.*}/{$channel.*}, RS.EXT.20), dual triggers (interval +// alongside any x-mock-match, RS.EXT.28), non-literal event identities +// (a {$event.name} condition whose value is itself an expression, since the +// produced subscription key could never match a fired identity) and +// 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. +func ClassifyTrigger(ev ExampleValue) (Trigger, error) { + var trig Trigger + match, hasMatch := ValueMatch(ev) + trig.Match = match + + if hasMatch && MatchMixedContext(match) { + return trig, fmt.Errorf("x-mock-match mixes event conditions with request/message/channel conditions; an example may target a single context") + } + + if interval, hasInterval := ValueInterval(ev); hasInterval { + if hasMatch { + return trig, fmt.Errorf("example declares both x-mock-interval and x-mock-match; a periodically driven example cannot carry a match — use one trigger") + } + trig.Kind = TriggerPeriodic + trig.Interval = interval + return trig, nil + } + // A declared-but-invalid interval is a configuration error, not a silent + // reclassification to a reply (RS.EXT.22). + if declaredInterval(ev) { + return trig, fmt.Errorf("x-mock-interval must be a positive integer") + } + + if hasDelay(ev) { + delay, ok := ValueDelay(ev) + if !ok { + return trig, fmt.Errorf("x-mock-delay must be an integer number of milliseconds") + } + if delay < 0 { + return trig, fmt.Errorf("x-mock-delay cannot be negative") + } + trig.Delay = delay + } + + if hasMatch && MatchReferencesEvent(match) { + trig.Kind = TriggerEvent + if identity, ok := EventIdentity(match); ok { + if isFullExpression(identity) { + return trig, fmt.Errorf("{$event.name} identity must be a literal string, not a runtime expression (got %q)", identity) + } + trig.Identity = identity + } + return trig, nil + } + + trig.Kind = TriggerReply + return trig, nil +} + +// declaredInterval reports whether the example declares an x-mock-interval +// extension (regardless of value validity). +func declaredInterval(ev ExampleValue) bool { + if ev == nil { + return false + } + _, ok := ev.Get("x-mock-interval") + return ok +} + +// hasDelay reports whether the example declares an x-mock-delay extension. +func hasDelay(ev ExampleValue) bool { + if ev == nil { + return false + } + _, ok := ev.Get("x-mock-delay") + return ok +} diff --git a/internal/extensions/classify_test.go b/internal/extensions/classify_test.go new file mode 100644 index 0000000..78d2de3 --- /dev/null +++ b/internal/extensions/classify_test.go @@ -0,0 +1,335 @@ +package extensions + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Classifying an example driven by an event match +Given a message example whose x-mock-match references {$event.*} +When ClassifyTrigger is called +Then the example is classified as event-driven (TriggerEvent) + +Related spec scenarios: RS.EXT.20, RS.EXT.28 +*/ +func TestClassifyTrigger_EventDriven(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{"level": "warn"}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$event.name}": "orderCreated", + }, + }) + + trig, err := ClassifyTrigger(ev) + require.NoError(t, err) + assert.Equal(t, TriggerEvent, trig.Kind) + assert.Equal(t, "orderCreated", trig.Identity) +} + +/* +Scenario: Classifying an example driven by the interval timing extension +Given a message example declaring x-mock-interval without an event match +When ClassifyTrigger is called +Then the example is classified as periodically driven (TriggerPeriodic) + +Related spec scenarios: RS.EXT.22 +*/ +func TestClassifyTrigger_Periodic(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{"tick": true}, nil, map[string]any{ + "x-mock-interval": float64(1000), + }) + + trig, err := ClassifyTrigger(ev) + require.NoError(t, err) + assert.Equal(t, TriggerPeriodic, trig.Kind) + assert.Equal(t, 1000, trig.Interval) +} + +/* +Scenario: Classifying a plain reply example +Given a message example with no event match and no interval +When ClassifyTrigger is called +Then the example is classified as a reply (TriggerReply) + +Related spec scenarios: RS.EXT.20 +*/ +func TestClassifyTrigger_Reply(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{"level": "info"}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$request.query.kind}": "alerts", + }, + }) + + trig, err := ClassifyTrigger(ev) + require.NoError(t, err) + assert.Equal(t, TriggerReply, trig.Kind) +} + +/* +Scenario: Rejecting an example mixing event and reply match contexts +Given a message example whose x-mock-match references both {$event.*} and +{$message.*} +When ClassifyTrigger is called +Then it returns a clear load error + +Related spec scenarios: RS.EXT.20 +*/ +func TestClassifyTrigger_MixedContextRejected(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$event.name}": "orderCreated", + "{$message.payload.kind}": "order", + }, + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "event") +} + +/* +Scenario: Rejecting an example declaring both interval and an event match +Given a message example declaring both x-mock-interval and an event-based match +When ClassifyTrigger is called +Then it returns a clear load error + +Related spec scenarios: RS.EXT.28 +*/ +func TestClassifyTrigger_DualTriggerRejected(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-interval": float64(500), + "x-mock-match": map[string]any{ + "{$event.name}": "orderCreated", + }, + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "interval") +} + +/* +Scenario: Rejecting an example declaring both interval and a non-event match +Given a message example declaring x-mock-interval and an x-mock-match that is +not event-driven (a reply/connection/literal match) +When ClassifyTrigger is called +Then it returns a clear load error: a periodically driven example has exactly +one trigger and cannot carry a match, which would otherwise be silently dropped + +Related spec scenarios: RS.EXT.22, RS.EXT.28 +*/ +func TestClassifyTrigger_PeriodicWithMatchRejected(t *testing.T) { + t.Parallel() + + for _, match := range []map[string]any{ + {"{$request.query.kind}": "alerts"}, + {"{$connection.id}": "conn-1"}, + {"fixed": "value"}, + } { + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-interval": float64(500), + "x-mock-match": match, + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "interval") + assert.Contains(t, err.Error(), "match") + } +} + +/* +Scenario: Rejecting a non-literal event identity condition value +Given a message example whose {$event.name} condition value is itself a runtime +expression +When ClassifyTrigger is called +Then it returns a clear load error instead of registering a subscription keyed +by the literal expression string (which could never match a fired identity) + +Related spec scenarios: RS.EXT.20 +*/ +func TestClassifyTrigger_NonLiteralIdentityRejected(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$event.name}": "{$state.envName}", + }, + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "identity") +} + +/* +Scenario: An event-driven match without an {$event.name} pin is a wildcard +Given a match referencing the event context only through a condition value +(no {$event.name} identity condition) +When ClassifyTrigger is called +Then the example is event-driven with an empty (wildcard) identity that +evaluates against every fired event + +Related spec scenarios: RS.EXT.20, RS.EXT.24 +*/ +func TestClassifyTrigger_EventWithoutIdentityIsWildcard(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$connection.id}": "{$event.connectionId}", + }, + }) + + trig, err := ClassifyTrigger(ev) + require.NoError(t, err) + assert.Equal(t, TriggerEvent, trig.Kind) + assert.Equal(t, "", trig.Identity, "no {$event.name} condition means a wildcard identity") +} + +/* +Scenario: A declared fractional x-mock-interval is a load error +Given a message example declaring x-mock-interval with a fractional millisecond +value +When ClassifyTrigger is called +Then it returns a clear load error instead of silently truncating to an integer + +Related spec scenarios: RS.EXT.22 +*/ +func TestClassifyTrigger_FractionalIntervalRejected(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-interval": float64(1.5), + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "positive") +} + +/* +Scenario: A declared fractional x-mock-delay is a load error +Given a message example declaring x-mock-delay with a fractional millisecond +value +When ClassifyTrigger is called +Then it returns a clear load error instead of silently ignoring the delay + +Related spec scenarios: RS.EXT.23 +*/ +func TestClassifyTrigger_FractionalDelayRejected(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$event.name}": "connect", + }, + "x-mock-delay": float64(1.5), + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "delay") +} + +/* +Scenario: Delayed event-driven example carries the delay +Given an event-driven message example declaring x-mock-delay +When ClassifyTrigger is called +Then the delay is captured on the trigger + +Related spec scenarios: RS.EXT.23 +*/ +func TestClassifyTrigger_EventWithDelay(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$event.name}": "connect", + }, + "x-mock-delay": float64(150), + }) + + trig, err := ClassifyTrigger(ev) + require.NoError(t, err) + assert.Equal(t, TriggerEvent, trig.Kind) + assert.Equal(t, 150, trig.Delay) +} + +/* +Scenario: A declared but non-positive x-mock-interval is a load error +Given a message example declaring x-mock-interval with a zero or negative value +When ClassifyTrigger is called +Then it returns a clear load error instead of silently reclassifying the example +as a reply + +Related spec scenarios: RS.EXT.22 +*/ +func TestClassifyTrigger_InvalidIntervalRejected(t *testing.T) { + t.Parallel() + + for _, interval := range []any{0, -10} { + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-interval": interval, + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "positive") + } +} + +/* +Scenario: A declared but non-numeric x-mock-interval is a load error +Given a message example declaring x-mock-interval as a non-numeric value +When ClassifyTrigger is called +Then it returns a clear load error + +Related spec scenarios: RS.EXT.22 +*/ +func TestClassifyTrigger_NonNumericIntervalRejected(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-interval": "soon", + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "positive") +} + +/* +Scenario: A negative x-mock-delay is a load error +Given a message example declaring a negative x-mock-delay +When ClassifyTrigger is called +Then it returns a clear load error + +Related spec scenarios: RS.EXT.23 +*/ +func TestClassifyTrigger_NegativeDelayRejected(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{}, nil, map[string]any{ + "x-mock-match": map[string]any{ + "{$event.name}": "connect", + }, + "x-mock-delay": float64(-5), + }) + + _, err := ClassifyTrigger(ev) + require.Error(t, err) + assert.Contains(t, err.Error(), "delay") +} diff --git a/internal/extensions/condition_value_test.go b/internal/extensions/condition_value_test.go new file mode 100644 index 0000000..6959aa5 --- /dev/null +++ b/internal/extensions/condition_value_test.go @@ -0,0 +1,75 @@ +package extensions + +import ( + "testing" + + "github.com/mamonth/oasmock/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Reply-path condition values are compared literally +Given an x-mock-match whose key references the reply context and whose value is +a full runtime expression string +When EvaluateParamsMatch runs on the reply path +Then the value is compared as a literal string, not pre-resolved + +Related spec scenarios: RS.EXT.30 +*/ +func TestEvaluateParamsMatch_ReplyConditionValueStaysLiteral(t *testing.T) { + t.Parallel() + + eval := newMatchEvaluator(&runtime.RequestSource{ + QueryParams: map[string][]string{"id": {"abc"}}, + }, nil, nil, map[string]any{"token": "abc"}) + + pm := ParamsMatch{"{$request.query.id}": "{$state.token}"} + ok, err := EvaluateParamsMatch(pm, eval) + require.NoError(t, err) + assert.False(t, ok, "a reply-path condition value must be compared literally, never resolved") +} + +/* +Scenario: Event-context condition values are pre-resolved +Given an x-mock-match whose key and value both reference the event context +When EvaluateParamsMatch runs against the event context +Then the value expression is resolved before comparison + +Related spec scenarios: RS.EXT.18, RS.EXT.19 +*/ +func TestEvaluateParamsMatch_EventContextValueResolves(t *testing.T) { + t.Parallel() + + eval := newMatchEvaluator(nil, &runtime.EventSource{ + Name: "orderCreated", + Data: map[string]any{"accountId": "acc-1", "expectedAccount": "acc-1"}, + }, nil, nil) + + pm := ParamsMatch{"{$event.accountId}": "{$event.expectedAccount}"} + ok, err := EvaluateParamsMatch(pm, eval) + require.NoError(t, err) + assert.True(t, ok) +} + +/* +Scenario: Connection-context condition values are pre-resolved +Given an x-mock-match with '{$connection.id}': '{$event.connectionId}' +When EvaluateParamsMatch runs with the matching connection and event contexts +Then the condition matches + +Related spec scenarios: RS.EXT.24, RS.EXT.27 +*/ +func TestEvaluateParamsMatch_ConnectionValueResolves(t *testing.T) { + t.Parallel() + + eval := newMatchEvaluator(nil, &runtime.EventSource{ + Name: "orderCreated", + Data: map[string]any{"connectionId": "c1"}, + }, &runtime.ConnectionSource{ID: "c1"}, nil) + + pm := ParamsMatch{"{$connection.id}": "{$event.connectionId}"} + ok, err := EvaluateParamsMatch(pm, eval) + require.NoError(t, err) + assert.True(t, ok) +} diff --git a/internal/extensions/event_match_test.go b/internal/extensions/event_match_test.go new file mode 100644 index 0000000..2ddd7c7 --- /dev/null +++ b/internal/extensions/event_match_test.go @@ -0,0 +1,145 @@ +package extensions + +import ( + "testing" + + "github.com/mamonth/oasmock/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Matching the event identity via x-mock-match +Given an x-mock-match condition on {$event.name} and a fired event +When the match is evaluated against the event context +Then it matches only when the event identity equals the condition value + +Related spec scenarios: RS.EXT.18 +*/ +func TestEvaluateParamsMatch_EventName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pm ParamsMatch + ev string + want bool + }{ + {name: "identity matches", pm: ParamsMatch{"{$event.name}": "orderCreated"}, ev: "orderCreated", want: true}, + {name: "identity differs", pm: ParamsMatch{"{$event.name}": "orderCreated"}, ev: "orderShipped", want: false}, + {name: "built-in connect", pm: ParamsMatch{"{$event.name}": "connect"}, ev: "connect", want: true}, + {name: "built-in receive", pm: ParamsMatch{"{$event.name}": "receive"}, ev: "receive", want: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + eval := newMatchEvaluator(nil, &runtime.EventSource{Name: tt.ev, Data: map[string]any{}}, nil, nil) + got, err := EvaluateParamsMatch(tt.pm, eval) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +/* +Scenario: Matching the event payload via x-mock-match +Given an x-mock-match condition on {$event.} or a JSON-schema on +{$event.data} and a fired event payload +When the match is evaluated against the event context +Then it matches only when the payload satisfies the condition + +Related spec scenarios: RS.EXT.19 +*/ +func TestEvaluateParamsMatch_EventPayload(t *testing.T) { + t.Parallel() + + payload := map[string]any{"accountId": "acc-1", "amount": 100} + + tests := []struct { + name string + pm ParamsMatch + want bool + }{ + {name: "literal field match", pm: ParamsMatch{"{$event.accountId}": "acc-1"}, want: true}, + {name: "literal field mismatch", pm: ParamsMatch{"{$event.accountId}": "acc-9"}, want: false}, + {name: "json schema on data", pm: ParamsMatch{ + "{$event.data}": map[string]any{ + "type": "object", + "required": []string{"accountId"}, + "properties": map[string]any{ + "accountId": map[string]any{"type": "string"}, + "amount": map[string]any{"type": "number", "minimum": 50}, + }, + }, + }, want: true}, + {name: "json schema on data no match", pm: ParamsMatch{ + "{$event.data}": map[string]any{ + "type": "object", + "required": []string{"accountId"}, + "properties": map[string]any{ + "accountId": map[string]any{"type": "string"}, + "amount": map[string]any{"type": "number", "minimum": 1000}, + }, + }, + }, want: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + eval := newMatchEvaluator(nil, &runtime.EventSource{Name: "orderCreated", Data: payload}, nil, nil) + got, err := EvaluateParamsMatch(tt.pm, eval) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +/* +Scenario: Connecting a connection context to event matching +Given an x-mock-match condition combining event and connection contexts +When the match is evaluated against both contexts +Then it matches only when both conditions hold + +Related spec scenarios: RS.EXT.24, RS.EXT.27 +*/ +func TestEvaluateParamsMatch_EventConnection(t *testing.T) { + t.Parallel() + + payload := map[string]any{"connectionId": "conn-7"} + conn := &runtime.ConnectionSource{ID: "conn-7", Channel: "/alerts"} + + pm := ParamsMatch{ + "{$event.name}": "orderCreated", + "{$connection.id}": "{$event.connectionId}", + "{$connection.channel}": "/alerts", + } + + eval := newMatchEvaluator(nil, &runtime.EventSource{Name: "orderCreated", Data: payload}, conn, nil) + got, err := EvaluateParamsMatch(pm, eval) + require.NoError(t, err) + assert.True(t, got) +} + +/* +Scenario: Failing closed when the event context is unavailable +Given an x-mock-match referencing {$event.*} evaluated without an event context +When the match is evaluated +Then it fails closed (never matches) without a hard error + +Related spec scenarios: RS.EXT.29 +*/ +func TestEvaluateParamsMatch_EventContextUnavailable(t *testing.T) { + t.Parallel() + + eval := runtime.NewEvaluator() + eval.AddSource("state", &runtime.StateSource{Data: map[string]any{"k": "v"}}) + + pm := ParamsMatch{"{$event.name}": "orderCreated"} + got, err := EvaluateParamsMatch(pm, eval) + require.NoError(t, err) + assert.False(t, got) +} diff --git a/internal/extensions/example_value.go b/internal/extensions/example_value.go index 7ffe27f..e9004de 100644 --- a/internal/extensions/example_value.go +++ b/internal/extensions/example_value.go @@ -1,7 +1,9 @@ package extensions import ( + "encoding/json" "log/slog" + "math" "github.com/getkin/kin-openapi/openapi3" ) @@ -142,6 +144,75 @@ func ValueHeaders(ev ExampleValue) (map[string]any, bool) { return h, h != nil } +// ValueInterval parses the x-mock-interval timing extension (positive +// millisecond cadence marking a periodically driven example, RS.EXT.22). +// Absent, zero, negative or non-numeric values report "not present". +func ValueInterval(ev ExampleValue) (int, bool) { + ms, ok := asPositiveMs(ev, "x-mock-interval") + if !ok || ms <= 0 { + return 0, false + } + return ms, true +} + +// ValueDelay parses the x-mock-delay timing extension (integer millisecond +// delay before emission, defaulting to zero when absent, RS.EXT.23). A +// declared but non-integer value reports "not present" and is rejected as a +// load error by the classifier. +func ValueDelay(ev ExampleValue) (int, bool) { + if ev == nil { + return 0, false + } + v, ok := ev.Get("x-mock-delay") + if !ok { + return 0, false + } + ms, ok := AsMilliseconds(v) + return ms, ok +} + +// asPositiveMs extracts a numeric millisecond extension that must be positive. +func asPositiveMs(ev ExampleValue, key string) (int, bool) { + if ev == nil { + return 0, false + } + v, ok := ev.Get(key) + if !ok { + return 0, false + } + return AsMilliseconds(v) +} + +// AsMilliseconds converts a JSON number to an integer millisecond value. A +// fractional number is rejected rather than truncated, so every caller treats +// a value like 2.5ms as an error (RS.EXT.22-23). +func AsMilliseconds(v any) (int, bool) { + switch n := v.(type) { + case float64: + if n != math.Trunc(n) { + return 0, false + } + return int(n), true + case float32: + if float64(n) != math.Trunc(float64(n)) { + return 0, false + } + return int(n), true + case int: + return n, true + case int64: + return int(n), true + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, false + } + return int(i), true + default: + return 0, false + } +} + func asMap(ev ExampleValue, key string) (map[string]any, bool) { v, ok := ev.Get(key) if !ok { diff --git a/internal/extensions/match.go b/internal/extensions/match.go index 8aed2bc..28dbc23 100644 --- a/internal/extensions/match.go +++ b/internal/extensions/match.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "log/slog" + "regexp" "strconv" + "strings" "sync" "github.com/mamonth/oasmock/internal/runtime" @@ -47,6 +49,21 @@ func getCachedSchema(schema map[string]any) (*gojsonschema.Schema, error) { // EvaluateParamsMatch evaluates whether the given params match the conditions. func EvaluateParamsMatch(pm ParamsMatch, eval runtime.Evaluator) (bool, error) { for expr, condition := range pm { + // Pre-evaluate the condition value when it is itself a runtime + // expression AND the key references the new event/connection contexts + // (design D6): a condition like + // '{$connection.id}': '{$event.connectionId}' compares resolved values. + // Reply-path conditions keep literal value semantics so sync matching + // is unchanged. + if str, ok := condition.(string); ok && isFullExpression(str) && referencesNewMatchContext(expr) { + resolved, err := eval.Evaluate(str) + if err != nil { + slog.Debug("EvaluateParamsMatch: condition expression evaluation failed", "expr", expr, "condition", str, "err", err) + return false, nil + } + condition = resolved + } + // Evaluate the runtime expression value, err := eval.Evaluate(expr) if err != nil { @@ -77,6 +94,137 @@ func EvaluateParamsMatch(pm ParamsMatch, eval runtime.Evaluator) (bool, error) { return true, nil } +// referencesNewMatchContext reports whether a condition key references the +// event or connection context, the two contexts added by this change. Only +// those conditions pre-resolve full-expression values (design D6); reply-path +// conditions keep literal value semantics. +func referencesNewMatchContext(expr string) bool { + return eventRefPattern.MatchString(expr) || connectionRefPattern.MatchString(expr) +} + +// ReferencesEvent reports whether a condition key or value references the +// event context ({$event.*}). +func ReferencesEvent(expr string, condition any) bool { + return referencesContext(expr, condition, eventRefPattern) +} + +// ReferencesNonEventContext reports whether a condition key or value references +// a reply-path context ({$request.*}, {$message.*}, {$channel.*}). +func ReferencesNonEventContext(expr string, condition any) bool { + return referencesContext(expr, condition, nonEventContextPattern) +} + +// referencesConnection reports whether a condition key or value references the +// connection context ({$connection.*}). +func referencesConnection(expr string, condition any) bool { + return referencesContext(expr, condition, connectionRefPattern) +} + +// referencesContext reports whether a condition key or its string value matches +// any of the given context-reference patterns. +func referencesContext(expr string, condition any, patterns ...*regexp.Regexp) bool { + s, _ := condition.(string) + for _, p := range patterns { + if p.MatchString(expr) || p.MatchString(s) { + return true + } + } + return false +} + +// isFullExpression reports whether a string is a single complete runtime +// expression (e.g. "{$event.connectionId}"). +func isFullExpression(s string) bool { + return strings.HasPrefix(s, "{$") && strings.HasSuffix(s, "}") && strings.Count(s, "{$") == 1 +} + +// connectionRefPattern matches a {$connection.*} reference anywhere in a +// condition key or value string. +var connectionRefPattern = regexp.MustCompile(`\{\$connection\.`) + +// eventRefPattern matches a {$event.*} reference. +var eventRefPattern = regexp.MustCompile(`\{\$event\.`) + +// nonEventContextPattern matches the HTTP/message/channel reply contexts +// ({$request.*}, {$message.*}, {$channel.*}). +var nonEventContextPattern = regexp.MustCompile(`\{\$(request|message|channel)\.`) + +// MatchReferencesEvent reports whether any condition in a match references the +// event context. A match with no and no event reference is a plain reply match. +func MatchReferencesEvent(match map[string]any) bool { + for expr, condition := range match { + if ReferencesEvent(expr, condition) { + return true + } + } + return false +} + +// MatchMixedContext reports whether a match mixes event conditions with +// reply-path ({$request.*}/{$message.*}/{$channel.*}) conditions, which is +// rejected at load (RS.EXT.20). +func MatchMixedContext(match map[string]any) bool { + hasEvent := false + hasNonEvent := false + for expr, condition := range match { + if ReferencesEvent(expr, condition) { + hasEvent = true + } + if ReferencesNonEventContext(expr, condition) { + hasNonEvent = true + } + } + return hasEvent && hasNonEvent +} + +// EventIdentity extracts the identity condition from a match: the literal value +// of a "{$event.name}" condition. It returns ("", false) when the match does +// not pin an identity. +func EventIdentity(match map[string]any) (string, bool) { + for expr, condition := range match { + if !eventRefPattern.MatchString(expr) { + continue + } + if expr != "{$event.name}" { + continue + } + if s, ok := condition.(string); ok && s != "" { + return s, true + } + } + return "", false +} + +// PartitionConnectionConditions splits an x-mock-match into the conditions +// that reference {$connection.*} (the per-connection recipient filter) and the +// conditions that do not (evaluated once per emission). An empty connection +// bucket means delivery broadcasts to all consumers of the channel (design D6, +// RS.EXT.24-25). +func PartitionConnectionConditions(pm ParamsMatch) (common, connection ParamsMatch) { + common = make(ParamsMatch) + connection = make(ParamsMatch) + for expr, condition := range pm { + if referencesConnection(expr, condition) { + connection[expr] = condition + } else { + common[expr] = condition + } + } + return common, connection +} + +// MatchReferencesConnection reports whether any condition in a match references +// the connection context (so the example needs a per-connection recipient +// decision at delivery). +func MatchReferencesConnection(match map[string]any) bool { + for expr, condition := range match { + if referencesConnection(expr, condition) { + return true + } + } + return false +} + // equalJSON compares two JSON values for equality. func equalJSON(a, b any) bool { // Handle numeric string vs number equality diff --git a/internal/extensions/match_test.go b/internal/extensions/match_test.go index e504984..dcf13c7 100644 --- a/internal/extensions/match_test.go +++ b/internal/extensions/match_test.go @@ -3,40 +3,23 @@ package extensions import ( "testing" - "github.com/golang/mock/gomock" "github.com/mamonth/oasmock/internal/runtime" - mock_runtime "github.com/mamonth/oasmock/mock/runtime" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// newMockRuntimeEvaluatorFromSources creates a mock runtime.Evaluator using gomock -// that simulates evaluation of expressions based on the provided data sources. -// Currently supports only request source with query params. -func newMockRuntimeEvaluatorFromSources(t *testing.T, sources map[string]runtime.DataSource) *mock_runtime.MockEvaluator { +// newRequestEvaluatorFromSources builds an evaluator from real data sources. +// Using the production runtime.Evaluator (rather than a mock that mirrors the +// expression string construction) keeps these tests honest: a change to how +// expressions resolve surfaces as a failed assertion, not a silently +// re-matched mock expectation. +func newRequestEvaluatorFromSources(t *testing.T, sources map[string]runtime.DataSource) runtime.Evaluator { t.Helper() - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - mockEval := mock_runtime.NewMockEvaluator(ctrl) - // Allow AddSource to be called any number of times - mockEval.EXPECT().AddSource(gomock.Any(), gomock.Any()).AnyTimes() - - // Build expected evaluations from request sources + eval := runtime.NewEvaluator() for name, source := range sources { - if req, ok := source.(*runtime.RequestSource); ok { - for param, values := range req.QueryParams { - if len(values) > 0 { - expr := "{$" + name + ".query." + param + "}" - mockEval.EXPECT().Evaluate(expr).Return(values[0], nil).AnyTimes() - } - } - } + eval.AddSource(name, source) } - // For any other expression, return nil, nil (simulate not found) - mockEval.EXPECT().Evaluate(gomock.Any()).Return(nil, nil).AnyTimes() - - return mockEval + return eval } /* @@ -270,7 +253,7 @@ func TestEvaluateParamsMatchLiteral(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - eval := newMockRuntimeEvaluatorFromSources(t, tt.sources) + eval := newRequestEvaluatorFromSources(t, tt.sources) got, err := EvaluateParamsMatch(tt.pm, eval) if tt.wantErr { @@ -370,7 +353,7 @@ func TestEvaluateParamsMatchSchema(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - eval := newMockRuntimeEvaluatorFromSources(t, tt.sources) + eval := newRequestEvaluatorFromSources(t, tt.sources) got, err := EvaluateParamsMatch(tt.pm, eval) if tt.wantErr { @@ -472,3 +455,36 @@ func TestMatchesJSONSchema(t *testing.T) { }) } } + +/* +Scenario: Detecting connection references in a match +Given a match whose condition key or string value references {$connection.*} +When MatchReferencesConnection runs +Then it reports true for key-side and value-side references and false otherwise + +Related spec scenarios: RS.EXT.24, RS.EXT.27 +*/ +func TestMatchReferencesConnection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + match map[string]any + want bool + }{ + {name: "connection id key", match: map[string]any{"{$connection.id}": "conn-1"}, want: true}, + {name: "connection header key", match: map[string]any{"{$connection.header.x-tid}": "abc"}, want: true}, + {name: "connection ref as value", match: map[string]any{"{$event.connectionId}": "{$connection.id}"}, want: true}, + {name: "empty match", match: map[string]any{}, want: false}, + {name: "event only", match: map[string]any{"{$event.name}": "orderCreated"}, want: false}, + {name: "reply path only", match: map[string]any{"{$request.query.kind}": "alerts"}, want: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, MatchReferencesConnection(tt.match)) + }) + } +} diff --git a/internal/extensions/partition_test.go b/internal/extensions/partition_test.go new file mode 100644 index 0000000..c28b3fc --- /dev/null +++ b/internal/extensions/partition_test.go @@ -0,0 +1,99 @@ +package extensions + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Partitioning match conditions by connection reference +Given an x-mock-match whose conditions reference {$connection.*} on either side +When the match is partitioned +Then conditions referencing {$connection.*} land in the connection bucket and +all other conditions land in the common bucket + +Related spec scenarios: RS.EXT.24, RS.EXT.25 +*/ +func TestPartitionConnectionConditions(t *testing.T) { + t.Parallel() + + pm := ParamsMatch{ + "{$event.name}": "orderCreated", + "{$connection.id}": "{$event.connectionId}", + "{$request.query.role}": "admin", + "{$connection.channel}": "/alerts", + } + + common, conn := PartitionConnectionConditions(pm) + assert.Equal(t, ParamsMatch{ + "{$event.name}": "orderCreated", + "{$request.query.role}": "admin", + }, common) + assert.Equal(t, ParamsMatch{ + "{$connection.id}": "{$event.connectionId}", + "{$connection.channel}": "/alerts", + }, conn) +} + +/* +Scenario: Partitioning without connection conditions yields an empty bucket +Given an x-mock-match with no {$connection.*} references +When the match is partitioned +Then the connection bucket is empty, enabling the broadcast fast path + +Related spec scenarios: RS.EXT.25 +*/ +func TestPartitionConnectionConditions_EmptyConnectionBucket(t *testing.T) { + t.Parallel() + + pm := ParamsMatch{ + "{$event.name}": "orderCreated", + } + + common, conn := PartitionConnectionConditions(pm) + assert.Equal(t, pm, common) + assert.Empty(t, conn) +} + +/* +Scenario: A condition value referencing connection context partitions that side +Given a condition whose value references {$connection.*} while the key does not +When the match is partitioned +Then the condition lands in the connection bucket because a side references it + +Related spec scenarios: RS.EXT.24, RS.EXT.27 +*/ +func TestPartitionConnectionConditions_ValueSideReference(t *testing.T) { + t.Parallel() + + pm := ParamsMatch{ + "{$event.name}": "orderCreated", + "{$event.target}": "{$connection.id}", + } + + common, conn := PartitionConnectionConditions(pm) + assert.Equal(t, ParamsMatch{"{$event.name}": "orderCreated"}, common) + assert.Equal(t, ParamsMatch{"{$event.target}": "{$connection.id}"}, conn) +} + +/* +Scenario: Partition helper is deterministic for empty input +Given an empty x-mock-match +When the match is partitioned +Then both buckets are empty + +Related spec scenarios: RS.EXT.25 +*/ +func TestPartitionConnectionConditions_Empty(t *testing.T) { + t.Parallel() + + common, conn := PartitionConnectionConditions(ParamsMatch{}) + assert.Empty(t, common) + assert.Empty(t, conn) + + require.NotPanics(t, func() { + PartitionConnectionConditions(nil) + }) +} diff --git a/internal/extensions/testhelpers_test.go b/internal/extensions/testhelpers_test.go new file mode 100644 index 0000000..9b949ff --- /dev/null +++ b/internal/extensions/testhelpers_test.go @@ -0,0 +1,24 @@ +package extensions + +import ( + "github.com/mamonth/oasmock/internal/runtime" +) + +// newMatchEvaluator builds a real runtime evaluator exposing the request, +// event, connection and state contexts for match-condition tests. Nil sources +// are simply not registered, so the same builder serves reply-path, event-path +// and connection-path cases. +func newMatchEvaluator(request *runtime.RequestSource, event *runtime.EventSource, conn *runtime.ConnectionSource, state map[string]any) runtime.Evaluator { + eval := runtime.NewEvaluator() + if request != nil { + eval.AddSource("request", request) + } + eval.AddSource("event", event) + if conn != nil { + eval.AddSource("connection", conn) + } + if state != nil { + eval.AddSource("state", &runtime.StateSource{Data: state}) + } + return eval +} diff --git a/internal/extensions/timing_test.go b/internal/extensions/timing_test.go new file mode 100644 index 0000000..6f61ed7 --- /dev/null +++ b/internal/extensions/timing_test.go @@ -0,0 +1,125 @@ +package extensions + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Extracting the x-mock-interval timing extension +Given a message example declaring x-mock-interval as a positive millisecond count +When the interval is extracted +Then it resolves to the declared millisecond value and marks the example periodic + +Related spec scenarios: RS.EXT.22 +*/ +func TestValueInterval(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ext map[string]any + want int + wantOK bool + }{ + {name: "positive interval", ext: map[string]any{"x-mock-interval": float64(1000)}, want: 1000, wantOK: true}, + {name: "integer interval", ext: map[string]any{"x-mock-interval": 500}, want: 500, wantOK: true}, + {name: "absent interval", ext: map[string]any{}, want: 0, wantOK: false}, + {name: "zero interval invalid", ext: map[string]any{"x-mock-interval": 0}, want: 0, wantOK: false}, + {name: "negative interval invalid", ext: map[string]any{"x-mock-interval": -10}, want: 0, wantOK: false}, + {name: "fractional interval invalid", ext: map[string]any{"x-mock-interval": 1.5}, want: 0, wantOK: false}, + {name: "non-numeric invalid", ext: map[string]any{"x-mock-interval": "soon"}, want: 0, wantOK: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ev := NewExampleValue(map[string]any{}, nil, tt.ext) + got, ok := ValueInterval(ev) + assert.Equal(t, tt.wantOK, ok) + if tt.wantOK { + assert.Equal(t, tt.want, got) + } + }) + } +} + +/* +Scenario: Extracting the x-mock-delay timing extension +Given a message example declaring x-mock-delay as a millisecond count +When the delay is extracted +Then it resolves to the declared value (defaulting to zero when absent) + +Related spec scenarios: RS.EXT.23 +*/ +func TestValueDelay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ext map[string]any + want int + wantOK bool + }{ + {name: "declared delay", ext: map[string]any{"x-mock-delay": float64(150)}, want: 150, wantOK: true}, + {name: "integer delay", ext: map[string]any{"x-mock-delay": 200}, want: 200, wantOK: true}, + {name: "zero delay", ext: map[string]any{"x-mock-delay": 0}, want: 0, wantOK: true}, + {name: "absent delay", ext: map[string]any{}, want: 0, wantOK: false}, + {name: "fractional delay invalid", ext: map[string]any{"x-mock-delay": 1.5}, want: 0, wantOK: false}, + {name: "non-numeric invalid", ext: map[string]any{"x-mock-delay": "soon"}, want: 0, wantOK: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ev := NewExampleValue(map[string]any{}, nil, tt.ext) + got, ok := ValueDelay(ev) + assert.Equal(t, tt.wantOK, ok) + if tt.wantOK { + assert.Equal(t, tt.want, got) + } + }) + } +} + +/* +Scenario: Interval and delay parse from OpenAPI examples too +Given an OpenAPI example carrying x-mock-interval and x-mock-delay +When the generic extractors run +Then the values resolve identically to the map-backed accessors + +Related spec scenarios: RS.EXT.22, RS.EXT.23 +*/ +func TestValueIntervalDelay_OpenAPI(t *testing.T) { + t.Parallel() + + ev := exampleSource{ + ext: map[string]any{"x-mock-interval": 1000, "x-mock-delay": 150}, + } + + interval, ok := ValueInterval(ev) + require.True(t, ok) + assert.Equal(t, 1000, interval) + + delay, ok := ValueDelay(ev) + require.True(t, ok) + assert.Equal(t, 150, delay) +} + +// exampleSource is a minimal ExampleValue implementation exercising the +// extractors through the interface without importing openapi3. +type exampleSource struct { + ext map[string]any +} + +func (e exampleSource) Get(key string) (any, bool) { + v, ok := e.ext[key] + return v, ok +} + +func (e exampleSource) Payload() any { return nil } +func (e exampleSource) Headers() map[string]any { return nil } diff --git a/internal/loader/schema_test.go b/internal/loader/schema_test.go index e63f1c6..735b03f 100644 --- a/internal/loader/schema_test.go +++ b/internal/loader/schema_test.go @@ -37,6 +37,12 @@ func TestLoadSingleSchema(t *testing.T) { wantErr: false, wantKind: KindOpenAPI, }, + { + name: "control API AsyncAPI YAML", + path: "../../api/asyncapi.yaml", + wantErr: false, + wantKind: KindAsyncAPI, + }, { name: "valid AsyncAPI 3.0.0 YAML", path: "../../test/_shared/resources/asyncapi-30.yaml", diff --git a/internal/runtime/connection_source_test.go b/internal/runtime/connection_source_test.go new file mode 100644 index 0000000..56abb46 --- /dev/null +++ b/internal/runtime/connection_source_test.go @@ -0,0 +1,75 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Connection context exposure for per-connection matching +Given a ConnectionSource with id, channel and upgrade-time metadata +When the source is queried by path +Then {$connection.id}, {$connection.channel}, {$connection.query.} and +{$connection.header.} resolve from the connection context + +Related spec scenarios: RS.EXT.27 +*/ +func TestConnectionSource_Get(t *testing.T) { + t.Parallel() + + src := &ConnectionSource{ + ID: "conn-1", + Channel: "/alerts", + Query: map[string][]string{"region": {"eu"}, "mode": {"a", "b"}}, + Headers: map[string][]string{"x-tenant": {"acme"}, "x-org": {"acme"}, "x-echo": {"one", "two"}}, + } + + tests := []struct { + name string + path string + want any + ok bool + }{ + {name: "connection id", path: "id", want: "conn-1", ok: true}, + {name: "connection channel", path: "channel", want: "/alerts", ok: true}, + {name: "query single value", path: "query.region", want: "eu", ok: true}, + {name: "query multiple values", path: "query.mode", want: []string{"a", "b"}, ok: true}, + {name: "header single value", path: "header.x-tenant", want: "acme", ok: true}, + {name: "header multiple values", path: "header.x-echo", want: []string{"one", "two"}, ok: true}, + {name: "missing query key", path: "query.missing", ok: false}, + {name: "missing header key", path: "header.missing", ok: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + v, ok := src.Get(tt.path) + assert.Equal(t, tt.ok, ok) + if tt.ok { + assert.Equal(t, tt.want, v) + } + }) + } +} + +/* +Scenario: Connection expressions evaluate through the evaluator +Given an evaluator with a connection source registered as "connection" +When an expression is evaluated +Then the connection value is returned + +Related spec scenarios: RS.EXT.27 +*/ +func TestEvaluator_ConnectionExpression(t *testing.T) { + t.Parallel() + + eval := NewEvaluator() + eval.AddSource("connection", &ConnectionSource{ID: "conn-1", Channel: "/alerts"}) + + val, err := eval.Evaluate("{$connection.id}") + require.NoError(t, err) + assert.Equal(t, "conn-1", val) +} diff --git a/internal/runtime/event_source_test.go b/internal/runtime/event_source_test.go index 09931dd..497dba8 100644 --- a/internal/runtime/event_source_test.go +++ b/internal/runtime/event_source_test.go @@ -53,3 +53,65 @@ func TestEvaluator_EventExpression(t *testing.T) { require.NoError(t, err) assert.Equal(t, "info", val) } + +/* +Scenario: Event identity is exposed via the reserved name accessor +Given an EventSource carrying an event name and a payload +When the name accessor is queried +Then {$event.name} resolves to the event identity (registered name or built-in kind) + +Related spec scenarios: RS.EXT.18 +*/ +func TestEventSource_GetName(t *testing.T) { + t.Parallel() + + src := &EventSource{Name: "orderCreated", Data: map[string]any{"accountId": "acc-1"}} + + v, ok := src.Get("name") + require.True(t, ok) + assert.Equal(t, "orderCreated", v) +} + +/* +Scenario: Whole-payload access via the reserved data accessor +Given an EventSource carrying a payload +When the data accessor is queried +Then {$event.data} resolves to the whole payload object + +Related spec scenarios: RS.EXT.19 +*/ +func TestEventSource_GetData(t *testing.T) { + t.Parallel() + + payload := map[string]any{"accountId": "acc-1", "amount": 10} + src := &EventSource{Name: "orderCreated", Data: payload} + + v, ok := src.Get("data") + require.True(t, ok) + assert.Equal(t, payload, v) +} + +/* +Scenario: Payload field access is unchanged alongside reserved accessors +Given an EventSource with payload fields named and data +When the payload field accessors are queried +Then field names shadowed by reserved accessors are reachable only via data + +Related spec scenarios: RS.EXT.18, RS.EXT.19 +*/ +func TestEventSource_ReservedFieldsShadowed(t *testing.T) { + t.Parallel() + + src := &EventSource{Name: "orderCreated", Data: map[string]any{ + "name": "shadowed", + "data": "shadowed", + }} + + name, ok := src.Get("name") + require.True(t, ok) + assert.Equal(t, "orderCreated", name) + + data, ok := src.Get("data") + require.True(t, ok) + assert.Equal(t, map[string]any{"name": "shadowed", "data": "shadowed"}, data) +} diff --git a/internal/runtime/expression.go b/internal/runtime/expression.go index d25605f..5b5046d 100644 --- a/internal/runtime/expression.go +++ b/internal/runtime/expression.go @@ -158,19 +158,81 @@ func (s *StateSource) Get(path string) (any, bool) { return getNested(val, parts[1:]) } +// ConnectionSource provides access to a consumer connection's context via +// {$connection.*} for per-connection recipient matching (RS.EXT.27). Metadata +// is captured at upgrade time. +type ConnectionSource struct { + ID string + Channel string + Query map[string][]string + Headers map[string][]string +} + +func (c *ConnectionSource) Get(path string) (any, bool) { + parts := splitEscapedPath(path) + if len(parts) == 0 { + return nil, false + } + switch parts[0] { + case "id": + if c.ID == "" { + return nil, false + } + return c.ID, true + case "channel": + if c.Channel == "" { + return nil, false + } + return c.Channel, true + case "query": + return multiValueLookup(c.Query, parts[1:]) + case "header": + return multiValueLookup(c.Headers, parts[1:]) + } + return nil, false +} + +// multiValueLookup returns a single value when a key has one entry, the +// full slice when it has several, and false when the key or category is +// absent. +func multiValueLookup(m map[string][]string, keys []string) (any, bool) { + if len(keys) != 1 { + return nil, false + } + vals, ok := m[keys[0]] + if !ok || len(vals) == 0 { + return nil, false + } + if len(vals) == 1 { + return vals[0], true + } + return vals, true +} + // EnvSource provides access to environment variables. type EnvSource struct { Env map[string]string } // EventSource provides access to the payload of the currently fired event via -// {$event.*} (design D8). +// {$event.*} (design D8). Name is the event identity (named-event name or +// built-in kind); Data is the event payload. name and data are reserved +// accessor names: {$event.name} returns the identity, {$event.data} the whole +// payload, and any other path resolves within the payload fields. type EventSource struct { + Name string Data map[string]any } func (e *EventSource) Get(path string) (any, bool) { - return getMapNested(e.Data, path) + switch path { + case "name": + return e.Name, e.Name != "" + case "data": + return e.Data, e.Data != nil + default: + return getMapNested(e.Data, path) + } } // MessageSource provides access to an AsyncAPI message via {$message.*} diff --git a/internal/server/add_example_runtime_test.go b/internal/server/add_example_runtime_test.go new file mode 100644 index 0000000..54f5cef --- /dev/null +++ b/internal/server/add_example_runtime_test.go @@ -0,0 +1,206 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Registering a named-event runtime example via /_mock/examples +Given a POST to /_mock/examples with a channel and an event match +When the event fires via /_mock/events +Then the registered message is delivered to the channel's consumers + +Related spec scenarios: RS.MAPI.24, RS.EXT.18 +*/ +func TestAddExample_RuntimeEventMatch(t *testing.T) { + t.Parallel() + + srv := newPushServer(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}"}}}` + resp := postExample(t, ts.URL, body) + require.Equal(t, http.StatusOK, resp.StatusCode) + var addResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&addResp)) + resp.Body.Close() //nolint:errcheck + require.NotEmpty(t, addResp["id"]) + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + waitForConnections(srv, "/alerts", 1) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // consume the connect snapshot + + fire, err := http.Post(ts.URL+"/_mock/events", "application/json", + strings.NewReader(`{"type":"fire","event":"levelUp","payload":{"msg":"boom"}}`)) + require.NoError(t, err) + _ = fire.Body.Close() + assert.Equal(t, http.StatusOK, fire.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + deadline := time.Now().Add(3 * time.Second) + got := "" + for time.Now().Before(deadline) { + _, msg, rerr := conn.ReadMessage() + if rerr != nil { + break + } + got = string(msg) + if strings.Contains(got, `"boom"`) { + break + } + } + assert.Contains(t, got, `"msg":"boom"`) +} + +/* +Scenario: Registering an interval runtime example via /_mock/examples +Given a POST to /_mock/examples with a channel and an interval +When the server runs +Then the message is delivered repeatedly at the interval until removed + +Related spec scenarios: RS.MAPI.25, RS.EVT.26 +*/ +func TestAddExample_RuntimeInterval(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"channel":"/alerts","interval":40,"response":{"code":200,"body":{"tick":"{$state.counter}"}}}` + resp := postExample(t, ts.URL, body) + require.Equal(t, http.StatusOK, resp.StatusCode) + var addResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&addResp)) + resp.Body.Close() //nolint:errcheck + exampleID, _ := addResp["id"].(string) + require.NotEmpty(t, exampleID) + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + waitForConnections(srv, "/alerts", 1) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // consume the connect snapshot + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"tick"`) + + // Stop by removing the example. + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/examples/"+exampleID, nil) + require.NoError(t, err) + delResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer delResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, delResp.StatusCode) + + // The interval job must be cancelled: no further deliveries after a quiet + // window (5x the cadence), proving DELETE stops recurring delivery. + _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + _, _, err = conn.ReadMessage() + require.Error(t, err, "expected no interval deliveries after DELETE") +} + +/* +Scenario: A runtime connect match delivers to the connecting consumer +Given a POST to /_mock/examples with a connect match +When a consumer connects +Then the registered message is delivered to that consumer + +Related spec scenarios: RS.MAPI.26 +*/ +func TestAddExample_RuntimeConnectMatch(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"channel":"/alerts","match":{"{$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 + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + deadline := time.Now().Add(3 * time.Second) + got := "" + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(deadline) + _, msg, rerr := conn.ReadMessage() + if rerr != nil { + break + } + got = string(msg) + if strings.Contains(got, `"msg":"welcome"`) { + break + } + } + assert.Contains(t, got, `"msg":"welcome"`) +} + +/* +Scenario: Example ids are namespaced by registry kind +Given a valid sync and async add-example request +When /_mock/examples is invoked +Then the sync example id carries the "dynex-" prefix and the async runtime +example id carries the "rtex-" prefix, keeping the two registries disjoint + +Related spec scenarios: RS.MAPI.30-31 +*/ +func TestAddExample_IdsAreNamespaced(t *testing.T) { + t.Parallel() + + asyncSrv := newPushServer(t) + asyncTS := httptest.NewServer(asyncSrv.router) + defer asyncTS.Close() //nolint:errcheck + + body := `{"channel":"/alerts","match":{"{$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 + require.NoError(t, json.NewDecoder(asyncResp.Body).Decode(&asyncPayload)) + asyncResp.Body.Close() //nolint:errcheck + asyncID, _ := asyncPayload["id"].(string) + assert.True(t, strings.HasPrefix(asyncID, "rtex-"), "async runtime id prefix: %s", asyncID) + + spec, err := openapi3.NewLoader().LoadFromData([]byte(syncDeleteOpenAPI)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindOpenAPI, Spec: spec, Prefix: ""}} + syncSrv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + syncTS := httptest.NewServer(syncSrv.router) + defer syncTS.Close() //nolint:errcheck + + syncResp := postExample(t, syncTS.URL, `{"path":"/ping","method":"GET","response":{"code":200,"body":{"a":1}}}`) + require.Equal(t, http.StatusOK, syncResp.StatusCode) + var syncPayload map[string]any + require.NoError(t, json.NewDecoder(syncResp.Body).Decode(&syncPayload)) + syncResp.Body.Close() //nolint:errcheck + syncID, _ := syncPayload["id"].(string) + assert.True(t, strings.HasPrefix(syncID, "dynex-"), "sync dynamic id prefix: %s", syncID) + + assert.NotEqual(t, asyncID, syncID) +} diff --git a/internal/server/add_example_validation_test.go b/internal/server/add_example_validation_test.go new file mode 100644 index 0000000..3751b37 --- /dev/null +++ b/internal/server/add_example_validation_test.go @@ -0,0 +1,260 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Mixing sync and async targeting is rejected +Given a POST with both path and channel +When /_mock/examples is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.27 +*/ +func TestAddExampleValidation_PathAndChannel(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"path":"/users","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) +} + +/* +Scenario: match or interval on an OpenAPI target is rejected +Given a POST with path and match but no AsyncAPI target +When /_mock/examples is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.28 +*/ +func TestAddExampleValidation_AsyncFieldsOnSyncedPath(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"path":"/users","match":{"{$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) +} + +/* +Scenario: Dual triggers are rejected +Given a POST with interval alongside an event-based match +When /_mock/examples is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.29, RS.EXT.28 +*/ +func TestAddExampleValidation_DualTriggers(t *testing.T) { + t.Parallel() + + srv := newPushServer(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}}}` + resp := postExample(t, ts.URL, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +/* +Scenario: A non-positive interval is rejected +Given a POST with a non-positive interval on an AsyncAPI target +When /_mock/examples is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.29 +*/ +func TestAddExampleValidation_NonPositiveInterval(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + for _, interval := range []string{"0", "-5"} { + body := `{"channel":"/alerts","interval":` + interval + `,"response":{"code":200,"body":{"a":1}}}` + resp := postExample(t, ts.URL, body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + resp.Body.Close() //nolint:errcheck + } +} + +/* +Scenario: A non-event match on an AsyncAPI target is rejected +Given a POST with an async target and a connection-only match whose values do +not reference the event context +When /_mock/examples is invoked +Then the server responds with HTTP 400 and registers nothing + +Related spec scenarios: RS.MAPI.29, RS.EXT.28 +*/ +func TestAddExampleValidation_NonEventMatchRejected(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"channel":"/alerts","match":{"{$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) + + assertNoRuntimeExampleRegistered(t, srv, "a connection-only match must not register a runtime example") +} + +/* +Scenario: A connection match whose value references an event is accepted +Given a POST with an async target and '{$connection.id}': '{$event.connectionId}' +When /_mock/examples is invoked +Then the server accepts the event-driven example with HTTP 200 + +Related spec scenarios: RS.MAPI.33, RS.EVT.19 +*/ +func TestAddExampleValidation_ConnectionMatchWithEventValueAccepted(t *testing.T) { + t.Parallel() + + srv := newPushServer(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}}}` + resp := postExample(t, ts.URL, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +/* +Scenario: A match with no event or connection references is rejected +Given a POST with an async target and a literal-only match +When /_mock/examples is invoked +Then the server responds with HTTP 400 and registers nothing + +Related spec scenarios: RS.MAPI.29 +*/ +func TestAddExampleValidation_LiteralOnlyMatchRejected(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"channel":"/alerts","match":{"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) + + assertNoRuntimeExampleRegistered(t, srv, "a literal-only match must not register a runtime example") +} + +// assertNoRuntimeExampleRegistered proves a rejected request left no live +// async-driven example behind (no broker subscription, no interval job), +// pinning the "registers nothing" part of the validation scenarios. +func assertNoRuntimeExampleRegistered(t *testing.T, srv *Server, msg string) { + t.Helper() + srv.runtimeExamples.mu.RLock() + defer srv.runtimeExamples.mu.RUnlock() + assert.Empty(t, srv.runtimeExamples.byID, msg) +} + +/* +Scenario: An event match alongside a delay is accepted +Given a POST with an async target, an event match and a delay +When /_mock/examples is invoked +Then the server accepts it with HTTP 200 + +Related spec scenarios: RS.MAPI.24, RS.EXT.23 +*/ +func TestAddExampleValidation_EventMatchWithDelayAccepted(t *testing.T) { + t.Parallel() + + srv := newPushServer(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}}}` + resp := postExample(t, ts.URL, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +/* +Scenario: Existing valid request shapes still pass +Given valid sync and async add-example requests +When /_mock/examples is invoked +Then they are accepted with HTTP 200 + +Related spec scenarios: RS.MAPI.19, RS.MAPI.20 +*/ +func TestAddExampleValidation_ValidShapesPass(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + tests := []string{ + `{"channel":"/alerts","response":{"code":200,"body":{"a":1}}}`, + `{"channel":"/alerts","match":{"{$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}}}`, + } + for _, body := range tests { + resp := postExample(t, ts.URL, body) + assert.Equal(t, http.StatusOK, resp.StatusCode, "body=%s", body) + var payload map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + resp.Body.Close() //nolint:errcheck + require.NotEmpty(t, payload["id"]) + } +} + +/* +Scenario: Validation failures return a valid JSON error envelope +Given a POST /_mock/examples body rejected by schema validation or handler rules +When the server responds with HTTP 400 +Then the body parses as JSON with an "error" key and a JSON content type + +Related spec scenarios: RS.MAPI.19, RS.MAPI.27 +*/ +func TestAddExampleValidation_ErrorEnvelopeIsValidJSON(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + 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 + `not-json`, // malformed body + } + for _, body := range invalidBodies { + resp := postExample(t, ts.URL, body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "body=%s", body) + + assert.Equal(t, "application/json", resp.Header.Get("Content-Type"), "body=%s", body) + var envelope map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope), "invalid JSON error envelope for body=%s", body) + resp.Body.Close() //nolint:errcheck + require.NotEmpty(t, envelope["error"]) + } +} diff --git a/internal/server/async_consumers_getall_test.go b/internal/server/async_consumers_getall_test.go new file mode 100644 index 0000000..279565a --- /dev/null +++ b/internal/server/async_consumers_getall_test.go @@ -0,0 +1,178 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const twoChannelDoc = `asyncapi: 3.0.0 +info: + title: Alerts + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + alertMsg: + examples: + - name: ex1 + payload: + level: info + watches: + address: /watches + bindings: + ws: + method: GET + messages: + watchMsg: + examples: + - name: ex1 + payload: + kind: watch +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' + receiveWatches: + action: receive + channel: + $ref: '#/channels/watches' +` + +func newTwoChannelServer(t *testing.T) *Server { + t.Helper() + doc, err := asyncapi.Parse([]byte(twoChannelDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + return srv +} + +func consumeJSON(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + var payload map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + return payload +} + +/* +Scenario: Listing all consumers without a channel filter +Given raw ws consumers connected on multiple channels +When the consumers endpoint is queried without a channel parameter +Then the server returns a single flat list across all channels + +Related spec scenarios: RS.AMG.22, RS.AMG.8, RS.AMG.9 +*/ +func TestAsyncConsumers_GetAll(t *testing.T) { + t.Parallel() + + srv := newTwoChannelServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + base := "ws" + strings.TrimPrefix(ts.URL, "http") + conn1, _, err := websocket.DefaultDialer.Dial(base+"/alerts", nil) + require.NoError(t, err) + defer conn1.Close() //nolint:errcheck + conn2, _, err := websocket.DefaultDialer.Dial(base+"/watches", nil) + require.NoError(t, err) + defer conn2.Close() //nolint:errcheck + + waitForConnections(srv, "/alerts", 1) + waitForConnections(srv, "/watches", 1) + + resp, err := http.Get(ts.URL + "/_mock/async/consumers") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + payload := consumeJSON(t, resp) + items, ok := payload["consumers"].([]any) + require.True(t, ok) + assert.Len(t, items, 2) + + channels := map[string]bool{} + for _, item := range items { + m, ok := item.(map[string]any) + require.True(t, ok) + channels[m["channel"].(string)] = true + } + assert.True(t, channels["/alerts"]) + assert.True(t, channels["/watches"]) +} + +/* +Scenario: Listing consumers with no connections returns empty +Given no connected consumers +When the consumers endpoint is queried without a channel filter +Then the server returns an empty list + +Related spec scenarios: RS.AMG.22, RS.AMG.9 +*/ +func TestAsyncConsumers_GetAllEmpty(t *testing.T) { + t.Parallel() + + srv := newTwoChannelServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + resp, err := http.Get(ts.URL + "/_mock/async/consumers") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + payload := consumeJSON(t, resp) + items, ok := payload["consumers"].([]any) + require.True(t, ok) + assert.Empty(t, items) +} + +/* +Scenario: Listing signalr stream consumers without a channel filter +Given open SignalR streams across hub channels +When the consumers endpoint is queried without a channel +Then the stream consumers are included in the flat union + +Related spec scenarios: RS.AMG.22, RS.AMG.8 +*/ +func TestAsyncConsumers_GetAllSignalR(t *testing.T) { + t.Parallel() + + srv := newSignalRPushMgmtServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + hubURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/hub" + + conn, _, err := websocket.DefaultDialer.Dial(hubURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + _, _, err = conn.ReadMessage() + require.NoError(t, err) + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":4,"invocationId":"s1","target":"priceFeed"}`+"\x1e"))) + _, _, err = conn.ReadMessage() + require.NoError(t, err) // snapshot + + resp, err := http.Get(ts.URL + "/_mock/async/consumers") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + payload := consumeJSON(t, resp) + items, ok := payload["consumers"].([]any) + require.True(t, ok) + require.NotEmpty(t, items) + first, ok := items[0].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, first["streams"]) +} diff --git a/internal/server/async_push_regression_test.go b/internal/server/async_push_regression_test.go new file mode 100644 index 0000000..a096568 --- /dev/null +++ b/internal/server/async_push_regression_test.go @@ -0,0 +1,166 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: One-shot push on the canonical async path is unchanged +Given a connected ws consumer and a management push targeting the canonical path +When the push is invoked (immediate, delayed, targeted, broadcast) +Then the consumer receives the message exactly as before the rename + +Related spec scenarios: RS.AMG.1, RS.AMG.5, RS.AMG.6 +*/ +func TestPushCanonicalPath_Unchanged(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // snapshot + + body := `{"channel":"/alerts","payload":{"seq":1}}` + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"seq":1`) +} + +/* +Scenario: Delayed push on the canonical path is unchanged +Given a push with a positive delay to the canonical path +When the response returns and the consumer reads +Then the message arrives after the delay + +Related spec scenarios: RS.AMG.6 +*/ +func TestPushCanonicalPath_Delayed(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // snapshot + + body := `{"channel":"/alerts","payload":{"delayed":true},"delay":10}` + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"delayed":true`) +} + +/* +Scenario: Targeted push on the canonical path is unchanged +Given two consumers and a push carrying one connectionId +When the push targets the canonical path +Then only the targeted consumer receives the message + +Related spec scenarios: RS.AMG.5 +*/ +func TestPushCanonicalPath_Targeted(t *testing.T) { + t.Parallel() + + srv := newAsyncMgmtServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + + conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn1.Close() //nolint:errcheck + _ = conn1.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn1.ReadMessage() // snapshot + + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn2.Close() //nolint:errcheck + _ = conn2.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn2.ReadMessage() // snapshot + + body := `{"channel":"/alerts","connectionId":"conn-1","payload":{"targeted":true}}` + post, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer post.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, post.StatusCode) + + _ = conn1.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg1, err := conn1.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg1), `"targeted":true`) + + _ = conn2.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + _, _, err2 := conn2.ReadMessage() + require.Error(t, err2) +} + +/* +Scenario: Broadcast push on the canonical path reaches both consumers +Given two consumers on the same channel +When a broadcast push targets the canonical path +Then both consumers receive the message + +Related spec scenarios: RS.AMG.1 +*/ +func TestPushCanonicalPath_Broadcast(t *testing.T) { + t.Parallel() + + srv := newAsyncMgmtServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + + conns := make([]*websocket.Conn, 0, 2) + for i := 0; i < 2; i++ { + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // snapshot + conns = append(conns, conn) + } + + body := `{"channel":"/alerts","payload":{"broadcast":true}}` + post, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer post.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, post.StatusCode) + + for i, conn := range conns { + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, rerr := conn.ReadMessage() + require.NoError(t, rerr, "conn %d", i) + var payload map[string]any + require.NoError(t, json.Unmarshal(msg, &payload)) + assert.True(t, payload["broadcast"].(bool)) + } +} diff --git a/internal/server/async_state_test.go b/internal/server/async_state_test.go index a0a1378..4c74513 100644 --- a/internal/server/async_state_test.go +++ b/internal/server/async_state_test.go @@ -1,11 +1,11 @@ package server import ( - "encoding/json" "testing" "github.com/golang/mock/gomock" "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -105,40 +105,28 @@ func TestRenderMessageSpecs_Delete(t *testing.T) { } /* -Scenario: Cron subscriptions render an incrementing state counter per delivery -Given a message example subscribing to the cron built-in with an increment -When collectSchemaSubscriptions captures it and the spec is rendered -Then the subscription is retained and each delivery applies the increment +Scenario: Cron subscriptions map to the periodic x-mock-interval shim +Given a message example subscribing to the cron built-in with a wait +When derivedExamples maps its x-send-events entry +Then the example becomes a periodically driven example with the wait interval -Related spec scenarios: RS.ATM.18, RS.EVT.10 +Related spec scenarios: RS.EVT.18, RS.EXT.22 */ -func TestCollectSchemaSubscriptions_CronIncrement(t *testing.T) { +func TestDerivedExamples_CronToPeriodic(t *testing.T) { t.Parallel() doc, err := asyncapi.Parse([]byte(incrementCronDoc)) require.NoError(t, err) - subs := collectSchemaSubscriptions("", doc) - require.Len(t, subs, 1) - assert.Equal(t, "cron", subs[0].event) - require.Len(t, subs[0].messages, 1) - require.NotNil(t, subs[0].messages[0].spec) - require.Len(t, subs[0].messages[0].spec.Examples, 1) - - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) - stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() - stateStore.EXPECT().Increment("/ns", "counter", 1.0).Return(1.0, nil) - - count, out, err := srv.renderMessageSpecsWithEvent( - []*loader.MessageSpec{subs[0].messages[0].spec}, - "/ns", - "op", - map[string]any{}, - ) + bus := newEventBus(nil, nil, false) + ex := doc.Channels[0].Messages[0].Examples[0] + derived, err := bus.derivedExamples(ex) require.NoError(t, err) - require.Equal(t, 1, count) + require.Len(t, derived, 1) - var body map[string]any - require.NoError(t, json.Unmarshal(out, &body)) - assert.Contains(t, body, "seq") + view := &MessageExampleView{spec: derived[0]} + trig, err := extensions.ClassifyTrigger(view) + require.NoError(t, err) + assert.Equal(t, extensions.TriggerPeriodic, trig.Kind) + assert.Equal(t, 1000, trig.Interval) } diff --git a/internal/server/builtin_triggers.go b/internal/server/builtin_triggers.go new file mode 100644 index 0000000..8197343 --- /dev/null +++ b/internal/server/builtin_triggers.go @@ -0,0 +1,88 @@ +package server + +import ( + "encoding/json" + + "github.com/mamonth/oasmock/internal/runtime" +) + +// fireConnectBuiltIn fires the connect built-in schema-local for a freshly +// connected consumer. The recipient set is the single connecting connection +// (RS.EVT.24, RS.EXT.26); it is a no-op when nothing subscribes to connect on +// the channel's schema. +func (s *Server) fireConnectBuiltIn(channel string, info ConsumerInfo) { + prefix := s.prefixForChannel(channel) + if !s.eventBus.hasSubscribers("connect", prefix) { + return + } + payload := map[string]any{ + "connectionId": info.ConnectionID, + } + s.eventBus.fireTargeted("connect", payload, prefix, info) +} + +// fireReceiveBuiltIn fires the receive built-in schema-local with the inbound +// client message exposed in the event context (RS.EVT.25). It is a no-op when +// nothing subscribes to receive on the channel's schema. +func (s *Server) fireReceiveBuiltIn(channel string, in InboundMessage, prefix string) { + if !s.eventBus.hasSubscribers("receive", prefix) { + return + } + payload := map[string]any{} + var parsed any + if err := json.Unmarshal(in.Payload, &parsed); err == nil && parsed != nil { + if obj, ok := parsed.(map[string]any); ok { + payload = obj + } else { + payload["data"] = parsed + } + } else { + payload["data"] = string(in.Payload) + } + s.eventBus.fire("receive", payload, prefix, false, nil) +} + +// wireBuiltInHooks connects the ws adapter and SignalR hub lifecycle/inbound +// hooks to the built-in trigger firings (design D5). +func (s *Server) wireBuiltInHooks() { + hookSet := builtInHooks{ + Connect: func(channel, connID string, info ConsumerInfo) { + s.fireConnectBuiltIn(channel, info) + }, + Receive: func(channel string, in InboundMessage) { + prefix := s.prefixForChannel(channel) + s.fireReceiveBuiltIn(channel, in, prefix) + }, + OnConnect: func(channel, connID string, info ConsumerInfo) { + s.notifyConsumerLifecycle("connected", channel, info) + }, + OnDisconnect: func(channel, connID string) { + s.notifyConsumerLifecycle("disconnected", channel, ConsumerInfo{ConnectionID: connID, Channel: channel}) + }, + } + if adapter, ok := s.protocolAdapters[asyncWSProtocol].(*wsProtocolAdapter); ok && adapter != nil { + adapter.hooks = hookSet + } + for _, hub := range s.hubMgr.hubs { + hub.setHooks(hookSet) + } +} + +// notifyConsumerLifecycle forwards consumer lifecycle events to the management +// stream subscribers (RS.AMG.26). +func (s *Server) notifyConsumerLifecycle(action, channel string, info ConsumerInfo) { + if s.manageStream != nil { + s.manageStream.notifyConsumer(action, channel, info) + } +} + +// connectionSourceFromInfo builds a runtime connection data source for an +// upgrade-captured consumer. +func connectionSourceFromInfo(info ConsumerInfo) *runtime.ConnectionSource { + return &runtime.ConnectionSource{ + ID: info.ConnectionID, + Channel: info.Channel, + Query: info.Query, + Headers: info.Headers, + } +} diff --git a/internal/server/builtin_triggers_test.go b/internal/server/builtin_triggers_test.go new file mode 100644 index 0000000..d0261e9 --- /dev/null +++ b/internal/server/builtin_triggers_test.go @@ -0,0 +1,174 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const connectBuiltInDoc = `asyncapi: 3.0.0 +info: + title: Connect + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + welcome: + examples: + - name: welcome1 + payload: + msg: "hello {$connection.id}" + x-mock-match: + '{$event.name}': "connect" +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: connect built-in fires on consumer connection +Given a message example matching the connect built-in +When a ws consumer connects +Then the templated message is delivered to the just-connected consumer + +Related spec scenarios: RS.EVT.24, RS.EXT.21, RS.EXT.26 +*/ +func TestBuiltInConnect_FiresOnConnect(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(connectBuiltInDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"msg":"hello conn-1"`) +} + +/* +Scenario: connect built-in is a no-op without subscribers +Given a server without any connect subscription +When a ws consumer connects +Then no connect-driven message is delivered (only the receive snapshot) + +Related spec scenarios: RS.EVT.24, RS.EXT.26 +*/ +func TestBuiltInConnect_NoSubscriberNoOp(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(pushChannelDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Equal(t, `{"level":"info","msg":"default"}`, strings.TrimSpace(string(msg))) +} + +const receiveBuiltInDoc = `asyncapi: 3.0.0 +info: + title: Receive + version: 1.0.0 +channels: + chat: + address: /chat + bindings: + ws: + method: GET + messages: + reply: + examples: + - name: echo1 + payload: + echoed: "{$event.text}" + x-mock-match: + '{$event.name}': "receive" +operations: + sendChat: + action: send + channel: + $ref: '#/channels/chat' +` + +/* +Scenario: receive built-in fires on inbound client traffic +Given a message example matching the receive built-in +When a client sends a message on the channel +Then the templated message is emitted with the inbound message in the context + +Related spec scenarios: RS.EVT.25, RS.EVT.23, RS.EXT.21 +*/ +func TestBuiltInReceive_FiresOnInbound(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(receiveBuiltInDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/chat" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"text":"hi"}`))) + + // The receive-built-in delivery is broadcast to channel consumers; read + // the first frame carrying the echoed payload. + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + deadline := time.Now().Add(3 * time.Second) + var got string + for time.Now().Before(deadline) { + _, msg, rerr := conn.ReadMessage() + if rerr != nil { + break + } + got = string(msg) + if strings.Contains(got, `"echoed"`) { + break + } + } + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(got), &payload)) + assert.Equal(t, "hi", payload["echoed"]) +} diff --git a/internal/server/control_api_spec_sync_test.go b/internal/server/control_api_spec_sync_test.go new file mode 100644 index 0000000..66ac1e2 --- /dev/null +++ b/internal/server/control_api_spec_sync_test.go @@ -0,0 +1,280 @@ +package server + +import ( + "net/url" + "os" + "reflect" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// managementRoutesOf registers a server's management routes onto a fresh chi +// router and returns the registered method+path pairs under /_mock. +func managementRoutesOf(t *testing.T) map[string]bool { + t.Helper() + srv := newPushServer(t) + r := chi.NewRouter() + srv.registerManagementRoutes(r) + return walkRoutes(r, map[string]bool{}) +} + +// walkRoutes enumerates "METHOD /path" keys from a chi route tree. +func walkRoutes(r chi.Routes, out map[string]bool) map[string]bool { + for _, route := range r.Routes() { + if route.SubRoutes != nil { + walkRoutes(route.SubRoutes, out) + continue + } + for method := range route.Handlers { + full := strings.ToUpper(method) + " " + route.Pattern + out[full] = true + } + } + return out +} + +// loadOpenAPISpec loads api/openapi.yaml into an OpenAPI document. +func loadOpenAPISpec(t *testing.T) *openapi3.T { + t.Helper() + data, err := os.ReadFile("../../api/openapi.yaml") + require.NoError(t, err) + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(data) + require.NoError(t, err) + require.NoError(t, doc.Validate(loader.Context)) + return doc +} + +// openAPIPaths returns "METHOD /path" keys from an OpenAPI document, excluding +// the path placeholder braces discrepancy: chi and OpenAPI both use {param}. +func openAPIPaths(t *testing.T, doc *openapi3.T) map[string]bool { + t.Helper() + out := map[string]bool{} + for path, item := range doc.Paths.Map() { + if item == nil { + continue + } + if op := item.Post; op != nil { + out["POST "+path] = true + } + if op := item.Get; op != nil { + out["GET "+path] = true + } + if op := item.Delete; op != nil { + out["DELETE "+path] = true + } + if op := item.Patch; op != nil { + out["PATCH "+path] = true + } + if op := item.Put; op != nil { + out["PUT "+path] = true + } + } + return out +} + +// specBasePath returns the path portion of the document's first server URL +// (e.g. "/_mock" for "http://localhost:19191/_mock"). OpenAPI paths are +// relative to this base; the router serves fully-prefixed paths. +func specBasePath(doc *openapi3.T) string { + if len(doc.Servers) == 0 { + return "" + } + u, err := url.Parse(doc.Servers[0].URL) + if err != nil { + return "" + } + return strings.TrimSuffix(u.Path, "/") +} + +/* +Scenario: The OpenAPI control spec documents the registered management routes +Given the code registers the canonical /_mock/async|events|stream|examples routes, + + the deprecated /_mock/ws aliases and the removed /_mock/ws/schedule 410s + +When the OpenAPI control spec and the router are both enumerated +Then every registered route is documented and every documented route is real + +Related spec scenarios: RS.AMG.1, RS.MAPI.19, RS.AMG.22, RS.AMG.28 +*/ +func TestControlAPISpecSync_OpenAPI(t *testing.T) { + t.Parallel() + + doc := loadOpenAPISpec(t) + base := specBasePath(doc) + require.NotEmpty(t, base, "openapi spec must declare a servers base URL") + + // Resolve documented paths against the servers base so both sides use the + // fully-prefixed route shape the router registers. + documented := map[string]bool{} + for route := range openAPIPaths(t, doc) { + method, path, _ := strings.Cut(route, " ") + documented[method+" "+base+path] = true + } + registered := managementRoutesOf(t) + + // Every route the server registers must appear in the resolved spec. + for route := range registered { + assert.True(t, documented[route], "registered route not documented in api/openapi.yaml: %s", route) + } + + // Every documented management path must be a real route. + for route := range documented { + if isControlPath(route) { + assert.True(t, registered[route], "documented route not registered by the server: %s", route) + } + } +} + +/* +Scenario: The AsyncAPI control spec envelope fields match the Go structs +Given the manageEnvelope payload structs emitted by the code +When the asyncapi.yaml component schemas are decoded +Then the documented JSON properties for each envelope type match the Go struct +JSON tags exactly (no drift between documentation and realization) + +Related spec scenarios: RS.AMG.24, RS.AMG.25, RS.AMG.26, RS.AMG.27 +*/ +func TestControlAPISpecSync_EnvelopeFields(t *testing.T) { + t.Parallel() + + schemas := loadAsyncAPISchemas(t) + + expected := map[string][]string{ + "EventEnvelopeBody": jsonFields(manageEventEnvelope{}), + "PushEnvelopeBody": jsonFields(managePushEnvelope{}), + "ConsumerEnvelopeBody": jsonFields(manageConsumerEnvelope{}), + "ScheduleEnvelopeBody": jsonFields(manageScheduleEnvelope{}), + } + for schemaName, structFields := range expected { + schema, ok := schemas[schemaName] + require.True(t, ok, "schema %q missing from api/asyncapi.yaml", schemaName) + props, ok := schema.properties() + require.True(t, ok, "schema %q must declare properties", schemaName) + var documented []string + for name := range props { + documented = append(documented, name) + } + assert.ElementsMatch(t, structFields, documented, + "schema %q JSON properties drift from the Go struct", schemaName) + } +} + +// loadAsyncAPISchemas decodes the components.schemas of api/asyncapi.yaml. +func loadAsyncAPISchemas(t *testing.T) map[string]jsonSchema { + t.Helper() + data, err := os.ReadFile("../../api/asyncapi.yaml") + require.NoError(t, err) + var raw struct { + Components struct { + Schemas map[string]jsonSchema `yaml:"schemas"` + } `yaml:"components"` + } + require.NoError(t, yaml.Unmarshal(data, &raw)) + return raw.Components.Schemas +} + +// jsonSchema is a minimal view of an AsyncAPI/JSON schema node. +type jsonSchema struct { + Type string `yaml:"type"` + PropertySet map[string]jsonSchema `yaml:"properties"` +} + +// properties returns the declared property names. +func (s jsonSchema) properties() (map[string]jsonSchema, bool) { + return s.PropertySet, s.PropertySet != nil +} + +// jsonFields returns the JSON field names of a struct (implementation dtypex of +// the envelope payloads emitted by /_mock/stream). +func jsonFields(v any) []string { + tv := reflect.TypeOf(v) + var out []string + for i := 0; i < tv.NumField(); i++ { + tag := tv.Field(i).Tag.Get("json") + name, _, _ := strings.Cut(tag, ",") + if name != "" && name != "-" { + out = append(out, name) + } + } + return out +} + +// isControlPath reports whether a management path belongs to the async-mocking +// or event surface (all of them are under /_mock with a real handler). +func isControlPath(route string) bool { + path := route[strings.Index(route, " ")+1:] + return strings.HasPrefix(path, "/_mock/") +} + +/* +Scenario: The AsyncAPI control spec documents the management stream channel +Given the code serves GET /_mock/stream with event/push/consumer/schedule envelopes +When the AsyncAPI control spec is parsed +Then its stream channel address is a real route and its message names match the +implemented envelope types + +Related spec scenarios: RS.AMG.23, RS.AMG.24, RS.AMG.25, RS.AMG.26, RS.AMG.27 +*/ +func TestControlAPISpecSync_AsyncAPI(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile("../../api/asyncapi.yaml") + require.NoError(t, err) + doc, err := asyncapi.Parse(data) + require.NoError(t, err) + + ch := doc.Channel("stream") + require.NotNil(t, ch, "stream channel missing from api/asyncapi.yaml") + assert.Equal(t, "/_mock/stream", ch.Address) + + // The stream channel must correspond to a real GET /_mock/stream route. + registered := managementRoutesOf(t) + require.True(t, registered["GET /_mock/stream"], "GET /_mock/stream must be a registered route") + + // Message names match the envelope types emitted by the code. + names := map[string]bool{} + for _, m := range ch.Messages { + names[m.Name] = true + } + for _, expected := range []string{"event", "push", "consumer", "schedule"} { + assert.True(t, names[expected], "stream channel must document a %q envelope message", expected) + } +} + +/* +Scenario: OpenAPI /stream and AsyncAPI stream channel agree on the surface +Given the /stream path in api/openapi.yaml and the stream channel in api/asyncapi.yaml +When both are parsed +Then they reference the same address and the same envelope kinds + +Related spec scenarios: RS.AMG.23 +*/ +func TestControlAPISpecSync_CrossFormat(t *testing.T) { + t.Parallel() + + docs := loadOpenAPISpec(t) + require.Contains(t, docs.Paths.Map(), "/stream") + streamOp := docs.Paths.Map()["/stream"].Get + require.NotNil(t, streamOp, "/stream must be a GET operation") + + asyncData, err := os.ReadFile("../../api/asyncapi.yaml") + require.NoError(t, err) + asyncDoc, err := asyncapi.Parse(asyncData) + require.NoError(t, err) + ch := asyncDoc.Channel("stream") + require.NotNil(t, ch) + + // The OpenAPI servers block is "http://localhost:19191/_mock", so the + // documented path /stream is served at /_mock/stream which matches the + // AsyncAPI channel address. + assert.Equal(t, "_mock/stream", strings.TrimPrefix(ch.Address, "/")) +} diff --git a/internal/server/delete_example_test.go b/internal/server/delete_example_test.go new file mode 100644 index 0000000..40a583e --- /dev/null +++ b/internal/server/delete_example_test.go @@ -0,0 +1,147 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Removing a dynamic async example +Given a registered interval example and a connected consumer +When DELETE /_mock/examples/{exampleId} cancels it +Then the example is removed and no further deliveries occur + +Related spec scenarios: RS.MAPI.30, RS.MAPI.25 +*/ +func TestDeleteExample_RemovesAsync(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + exampleID := addExample(t, ts.URL, `{"channel":"/alerts","interval":20,"response":{"code":200,"body":{"tick":true}}}`) + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + waitForConnections(srv, "/alerts", 1) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // consume snapshot + + // First interval delivery. + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"tick":true`) + + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/examples/"+exampleID, nil) + require.NoError(t, err) + delResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer delResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, delResp.StatusCode) + + // No further deliveries after a quiet window. + _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + _, _, err = conn.ReadMessage() + require.Error(t, err) +} + +/* +Scenario: Removing an unknown example returns 404 +Given a DELETE for an unknown exampleId +When /_mock/examples/{exampleId} is invoked +Then the server responds with HTTP 404 + +Related spec scenarios: RS.MAPI.31 +*/ +func TestDeleteExample_Unknown404(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/examples/nope", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// syncDeleteOpenAPI is a minimal OpenAPI doc with one GET path for the sync +// dynamic-example deletion test. +const syncDeleteOpenAPI = `openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 +paths: + /ping: + get: + responses: + '200': + description: OK + content: + application/json: + examples: + default: + value: + message: pong +` + +/* +Scenario: Removing a sync dynamic example +Given a registered sync example on an OpenAPI path +When DELETE /_mock/examples/{exampleId} removes it +Then the delete succeeds and the dynamic example no longer matches the path + +Related spec scenarios: RS.MAPI.30, RS.MAPI.27 +*/ +func TestDeleteExample_RemovesSync(t *testing.T) { + t.Parallel() + + spec, err := openapi3.NewLoader().LoadFromData([]byte(syncDeleteOpenAPI)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindOpenAPI, Spec: spec, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + exampleID := addExample(t, ts.URL, `{"path":"/ping","method":"GET","response":{"code":200,"body":{"injected":true}}}`) + + resp, err := http.Get(ts.URL + "/ping") + require.NoError(t, err) + var injected map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&injected)) + resp.Body.Close() //nolint:errcheck + assert.Equal(t, true, injected["injected"]) + + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/examples/"+exampleID, nil) + require.NoError(t, err) + delResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer delResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, delResp.StatusCode) + + resp2, err := http.Get(ts.URL + "/ping") + require.NoError(t, err) + defer resp2.Body.Close() //nolint:errcheck + var after map[string]any + require.NoError(t, json.NewDecoder(resp2.Body).Decode(&after)) + assert.NotEqual(t, true, after["injected"], "the deleted dynamic example must no longer match") +} diff --git a/internal/server/engine.go b/internal/server/engine.go index afff554..6cc5be7 100644 --- a/internal/server/engine.go +++ b/internal/server/engine.go @@ -658,31 +658,6 @@ func (e *exampleEngine) RenderMessageSpecs(messages []*loader.MessageSpec, prefi return 0, nil, nil } -// renderMessageSpecsWithEvent evaluates message specs with an event payload -// registered in the evaluator as {$event.*}. -func (e *exampleEngine) RenderMessageSpecsWithEvent(messages []*loader.MessageSpec, prefix, opID string, payload map[string]any) (int, []byte, error) { - evaluator := runtime.NewEvaluator() - evaluator.AddSource("state", e.NewStateSource(prefix)) - evaluator.AddSource("env", e.NewEnvSource()) - evaluator.AddSource("event", &runtime.EventSource{Data: payload}) - - for _, msg := range messages { - example, _ := e.SelectAsyncExample(msg, evaluator, opID) - if example == nil { - continue - } - if stateMap, ok := extensions.ValueSetState(example); ok { - e.ApplySetState(stateMap, evaluator, prefix) - } - body, err := e.RenderAsyncPayload(example, evaluator) - if err != nil { - return 0, nil, err - } - return 1, body, nil - } - return 0, nil, nil -} - // asyncRequestSource adapts an InboundMessage to a runtime data source. func (e *exampleEngine) asyncRequestSource(in InboundMessage) *runtime.RequestSource { headers := make(map[string][]string, len(in.Headers)) diff --git a/internal/server/event_broker.go b/internal/server/event_broker.go index 768cca0..f2d88be 100644 --- a/internal/server/event_broker.go +++ b/internal/server/event_broker.go @@ -8,12 +8,20 @@ import ( "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 named event, or a built-in trigger ("" when built-in). + // 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. @@ -35,10 +43,10 @@ type delaySchedule struct { type eventDeliverer func(sub channelSubscription, payload map[string]any) // eventBroker decouples OpenAPI event triggers from AsyncAPI consumers -// (design D8). Subscriptions are keyed by event name + schema scope. +// (design D8). Subscriptions are keyed by match identity + schema scope. type eventBroker struct { mu sync.RWMutex - byEvent map[string][]channelSubscription // event name -> subscriptions + byEvent map[string][]channelSubscription // identity -> subscriptions deliver eventDeliverer } @@ -51,6 +59,16 @@ func newEventBroker(deliver eventDeliverer) *eventBroker { } } +// 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 { @@ -59,16 +77,51 @@ func (b *eventBroker) addSubscriptions(schema string, subs []channelSubscription b.mu.Lock() defer b.mu.Unlock() for i := range subs { - if subs[i].event == "" { - continue - } subs[i].schema = schema - b.byEvent[subs[i].event] = append(b.byEvent[subs[i].event], subs[i]) + 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 @@ -76,7 +129,8 @@ func (b *eventBroker) resolveSubscribers(event, firingSchema string, global ...b isGlobal := len(global) > 0 && global[0] b.mu.RLock() defer b.mu.RUnlock() - all := b.byEvent[event] + 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 { @@ -86,6 +140,25 @@ func (b *eventBroker) resolveSubscribers(event, firingSchema string, global ...b 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) { diff --git a/internal/server/event_broker_test.go b/internal/server/event_broker_test.go index 47a31c6..6c9f26a 100644 --- a/internal/server/event_broker_test.go +++ b/internal/server/event_broker_test.go @@ -1,6 +1,8 @@ package server import ( + "fmt" + "sync" "testing" "time" @@ -146,3 +148,116 @@ func TestEventBroker_FireImmediate(t *testing.T) { 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_delay_test.go b/internal/server/event_delay_test.go new file mode 100644 index 0000000..c842043 --- /dev/null +++ b/internal/server/event_delay_test.go @@ -0,0 +1,101 @@ +package server + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/mamonth/oasmock/internal/extensions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// delayWindow is the tolerated band around a declared x-mock-delay: the +// delivery should land not before the declared delay and well within the wait +// deadline (never sooner, but also not effectively never). +type delayWindow struct { + min time.Duration + max time.Duration +} + +func assertWithinDelayWindow(t *testing.T, elapsed time.Duration, window delayWindow) { + t.Helper() + assert.GreaterOrEqual(t, elapsed, window.min, "x-mock-delay must delay emission") + assert.Less(t, elapsed, window.max, "x-mock-delay emission must not be excessively late") +} + +/* +Scenario: Delayed event emission for an x-mock-delay example +Given an event-driven example declaring x-mock-delay 60 +When the event fires +Then the message is emitted at least the declared delay after the fire + +Related spec scenarios: RS.EXT.23 +*/ +func TestEventBus_DelayedEmissionDelaysDelivery(t *testing.T) { + t.Parallel() + + var pushed atomic.Int64 + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{ + wsPush: func(ConsumerInfo, []byte) { pushed.Add(1) }, + }, false) + bus.setObserver(func(env manageEnvelope) { + if env.Type == "push" { + pushed.Add(1) + } + }) + + spec := loaderExampleSpecForTest(map[string]any{"ring": "{$event.tag}"}, map[string]any{ + "x-mock-match": map[string]any{"{$event.name}": "orderCreated"}, + "x-mock-delay": float64(60), + }) + trigger, _, err := bus.registerRuntimeExample("ex-1", "/alerts", "", spec) + require.NoError(t, err) + assert.Equal(t, extensions.TriggerEvent, trigger) + + start := time.Now() + bus.fire("orderCreated", map[string]any{"tag": "hi"}, "", true, nil) + elapsed, delivered := waitForPush(t, &pushed, start) + require.True(t, delivered, "expected a delayed delivery") + assertWithinDelayWindow(t, elapsed, delayWindow{ + min: 30 * time.Millisecond, + max: 2 * time.Second, + }) +} + +/* +Scenario: Delayed connect built-in emission +Given a connect example declaring x-mock-delay 60 on an event-driven match +When the connect built-in fires for a recipient +Then the message is delivered to the recipient at least the delay after the fire + +Related spec scenarios: RS.EVT.24 +*/ +func TestEventBus_ConnectBuiltInDelayed(t *testing.T) { + t.Parallel() + + var pushed atomic.Int64 + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{ + wsPush: func(ConsumerInfo, []byte) { pushed.Add(1) }, + }, false) + + spec := loaderExampleSpecForTest(map[string]any{"msg": "welcome"}, map[string]any{ + "x-mock-match": map[string]any{"{$event.name}": "connect"}, + "x-mock-delay": float64(60), + }) + trigger, _, err := bus.registerRuntimeExample("ex-c", "/alerts", "", spec) + require.NoError(t, err) + assert.Equal(t, extensions.TriggerEvent, trigger) + + start := time.Now() + bus.fireTargeted("connect", map[string]any{"connectionId": "c1"}, "", ConsumerInfo{ + ConnectionID: "c1", + Channel: "/alerts", + }) + elapsed, delivered := waitForPush(t, &pushed, start) + require.True(t, delivered, "expected a delayed delivery") + assertWithinDelayWindow(t, elapsed, delayWindow{ + min: 30 * time.Millisecond, + max: 2 * time.Second, + }) +} diff --git a/internal/server/event_delivery_test.go b/internal/server/event_delivery_test.go new file mode 100644 index 0000000..0413311 --- /dev/null +++ b/internal/server/event_delivery_test.go @@ -0,0 +1,357 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const targetedDeliveryDoc = `asyncapi: 3.0.0 +info: + title: Targeting + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + ring: + examples: + - name: targeted + payload: + ring: "{$event.data}" + x-mock-match: + '{$event.name}': "orderCreated" + '{$connection.id}': '{$event.connectionId}' +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: Targeted event delivery by connection id +Given an event-driven example with a {$connection.id} = {$event.connectionId} +condition and two connected consumers +When an event fires with a connectionId payload for one consumer +Then only the matching consumer receives the message + +Related spec scenarios: RS.EVT.19, RS.EXT.24 +*/ +func TestEventDelivery_TargetedByConnection(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(targetedDeliveryDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + + conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn1.Close() //nolint:errcheck + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn2.Close() //nolint:errcheck + waitForConnections(srv, "/alerts", 2) + + // Target conn-1 via the fired event's connectionId payload. + body := `{"type":"fire","event":"orderCreated","payload":{"connectionId":"conn-1"}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn1.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg1, err := conn1.ReadMessage() + require.NoError(t, err) + var payload1 map[string]any + require.NoError(t, json.Unmarshal(msg1, &payload1)) + assert.Equal(t, map[string]any{"connectionId": "conn-1"}, payload1["ring"]) + + // conn-2 must not receive the targeted message. + _ = conn2.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + _, _, err2 := conn2.ReadMessage() + require.Error(t, err2) +} + +const broadcastFastPathDoc = `asyncapi: 3.0.0 +info: + title: Broadcast + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + ring: + examples: + - name: broad + payload: + ring: "yes" + x-mock-match: + '{$event.name}': "orderCreated" +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: Broadcast fast path with no connection conditions +Given an event-driven example without {$connection.*} conditions +When an event fires with two connected consumers +Then the message is broadcast to all consumers of the channel + +Related spec scenarios: RS.EXT.25 +*/ +func TestEventDelivery_BroadcastFastPath(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(broadcastFastPathDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + + conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn1.Close() //nolint:errcheck + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn2.Close() //nolint:errcheck + waitForConnections(srv, "/alerts", 2) + + body := `{"type":"fire","event":"orderCreated","payload":{"x":1}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + for i, conn := range []*websocket.Conn{conn1, conn2} { + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, rerr := conn.ReadMessage() + require.NoError(t, rerr, "conn %d", i+1) + assert.Contains(t, string(msg), `"ring":"yes"`) + } +} + +const connectTargetedDoc = `asyncapi: 3.0.0 +info: + title: Welcome + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + welcome: + examples: + - name: welcome1 + payload: + msg: "welcome" + x-mock-match: + '{$event.name}': "connect" + '{$connection.channel}': "/alerts" +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: Single-recipient connect built-in with a connection condition +Given a connect example with a {$connection.channel} condition +When a consumer connects to the matching channel +Then the message is delivered to that single consumer only + +Related spec scenarios: RS.EXT.26 +*/ +func TestEventDelivery_ConnectTargeted(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(connectTargetedDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + waitForConnections(srv, "/alerts", 1) + + // The connect-built-in welcomes the connecting consumer. + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + deadline := time.Now().Add(3 * time.Second) + got := "" + for time.Now().Before(deadline) { + _, msg, rerr := conn.ReadMessage() + if rerr != nil { + break + } + got = string(msg) + if strings.Contains(got, `"msg":"welcome"`) { + break + } + } + assert.Contains(t, got, `"msg":"welcome"`) +} + +/* +Scenario: registerRuntimeExample on an uninitialized bus reports an error +Given a nil eventBus +When registerRuntimeExample is called +Then it returns an error instead of a silent empty success + +Related spec scenarios: RS.MAPI.24-26 +*/ +func TestEventBus_RegisterRuntimeExampleNilBusErrors(t *testing.T) { + t.Parallel() + + var b *eventBus + _, _, err := b.registerRuntimeExample("ex-1", "/alerts", "", &loader.MessageExampleSpec{ + Extensions: map[string]any{"x-mock-interval": 100}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not initialized") +} + +const partiallyInvalidSchemaDoc = `asyncapi: 3.0.0 +info: + title: Atomic + version: 1.0.0 +channels: + alerts: + address: /alerts + messages: + tick: + examples: + - name: good + payload: + seq: 1 + x-mock-interval: 500 + - name: bad + payload: + seq: 2 + x-mock-match: + '{$event.name}': orderCreated + '{$message.payload.kind}': order +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: Schema registration is atomic under a late classification error +Given a schema whose first message example is periodically driven and whose +second example is invalid (mixed match contexts) +When registerSchema runs +Then it returns the load error and has scheduled no periodic job for the valid +example (no partial registration leaks a running interval goroutine) + +Related spec scenarios: RS.EXT.20, RS.EXT.22 +*/ +func TestSchemaRegistration_AtomicOnError(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(partiallyInvalidSchemaDoc)) + require.NoError(t, err) + + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{}, false) + err = bus.registerSchema("", doc) + require.Error(t, err) + + // 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"), + "no periodic job may be scheduled when schema registration fails") +} + +const periodicSkipDoc = `asyncapi: 3.0.0 +info: + title: Skip + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + tick: + examples: + - name: skipme + payload: + seq: 1 + x-mock-interval: 20 + x-mock-skip: true +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: A periodically driven example honoring x-mock-skip is never emitted +Given a periodic message example declaring x-mock-skip +When the server runs the interval job with a connected consumer +Then no message is delivered to the channel + +Related spec scenarios: RS.EXT.22 +*/ +func TestEventDelivery_PeriodicSkipsSkippedExample(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(periodicSkipDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + waitForConnections(srv, "/alerts", 1) + + // Give several 20ms cadences time to fire; the skipped example must stay + // silent on the wire. + _ = conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)) + _, _, rerr := conn.ReadMessage() + require.Error(t, rerr, "a skipped periodic example must not be emitted") +} diff --git a/internal/server/event_server.go b/internal/server/event_server.go index dc56b64..1e9943a 100644 --- a/internal/server/event_server.go +++ b/internal/server/event_server.go @@ -1,11 +1,16 @@ package server import ( + "cmp" + "encoding/json" + "fmt" "log/slog" - "strings" + "time" "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" ) // eventBus is the pure-fabrication coordinator behind the event driver @@ -13,19 +18,33 @@ import ( // messages through the MessageRenderer and ConsumerBus contracts, so it never // reaches into Server. type eventBus struct { - broker *eventBroker - renderer MessageRenderer - bus ConsumerBus - verbose bool + broker *eventBroker + renderer MessageRenderer + bus ConsumerBus + scheduler *jobScheduler + verbose bool + // wait sleeps for a delayed emission (design D4/D5); injectable so tests + // stay hermetic. + wait func(time.Duration) + // observer, when set, is invoked with every emitted envelope so the + // management stream can mirror fired events and deliveries (RS.AMG.24-25). + observer func(env manageEnvelope) +} + +// setObserver installs the management-stream observer. +func (b *eventBus) setObserver(observer func(env manageEnvelope)) { + b.observer = observer } // newEventBus wires a broker whose delivery goes through the renderer and // consumer bus. func newEventBus(renderer MessageRenderer, bus ConsumerBus, verbose bool) *eventBus { b := &eventBus{ - renderer: renderer, - bus: bus, - verbose: verbose, + renderer: renderer, + bus: bus, + scheduler: newJobScheduler(), + verbose: verbose, + wait: time.Sleep, } b.broker = &eventBroker{ byEvent: make(map[string][]channelSubscription), @@ -39,127 +58,486 @@ func (b *eventBus) fire(name string, payload map[string]any, firingSchema string if b == nil || b.broker == nil { return } + if b.observer != nil { + env := manageEnvelope{Type: "event"} + env.Event = &manageEventEnvelope{Name: name, Schema: firingSchema, Global: global, Payload: payload} + b.observer(env) + } b.broker.fire(name, payload, firingSchema, global, delay) } -// registerEventSubscriptions scans AsyncAPI schemas and registers x-send-events -// subscriptions for every message example, keyed by event name and schema. -func (b *eventBus) registerEventSubscriptions(schemas []SchemaInfo) { +// fireTargeted fires a built-in event scoped to a single recipient connection +// (the connecting consumer for the connect built-in, RS.EVT.24). Delivery is +// narrow: the common match is evaluated once, the connection match (if any) +// against the single candidate, and a match delivers to that candidate alone. +func (b *eventBus) fireTargeted(name string, payload map[string]any, firingSchema string, recipient ConsumerInfo) { if b == nil || b.broker == nil { return } - for _, schema := range schemas { - if schema.Kind != loader.KindAsyncAPI || schema.Async == nil { - continue - } - subs := collectSchemaSubscriptions(schema.Prefix, schema.Async) - b.broker.addSubscriptions(schema.Prefix, subs) + if b.observer != nil { + env := manageEnvelope{Type: "event"} + env.Event = &manageEventEnvelope{Name: name, Schema: firingSchema, Global: false, Payload: payload} + b.observer(env) } -} - -// deliver renders the subscribed message with the event payload and emits it -// to the channel's consumers (ws broadcast or SignalR open streams). -func (b *eventBus) deliver(sub channelSubscription, payload map[string]any) { - if len(sub.messages) == 0 { + subs, _ := b.broker.resolveSubscribers(name, firingSchema) + if len(subs) == 0 { return } - deliverable := sub.messages[0] - opID := "event:" + sub.event + ":" + sub.address - - count, body, err := b.renderer.RenderMessageSpecsWithEvent([]*loader.MessageSpec{deliverable.spec}, deliverable.prefix, opID, payload) - if err != nil { - if b.verbose { - slog.Debug("Event delivery render failed", "event", sub.event, "err", err) - } - return + for _, sub := range subs { + b.deliverTargeted(sub, payload, recipient) } - if count == 0 { +} + +// 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) +} + +// shutdown cancels all periodic interval jobs. +func (b *eventBus) shutdown() { + if b == nil || b.scheduler == nil { return } - - // Broadcast to SignalR open streams first (RS.EVT.13), then raw ws. - b.bus.SignalRPush(sub.address, body) - b.bus.WSBroadcast(sub.address, body) + b.scheduler.shutdown() } -// hubForAddress finds the SignalR hub owning a channel address. -func (s *Server) hubForAddress(address string) *signalRHub { - return s.hubMgr.hubForAddress(address) +// registerEventSubscriptions scans AsyncAPI schemas, classifies each message +// example by trigger kind (event-driven via {$event.*} match, periodic via +// x-mock-interval, or reply), and registers event-driven subscriptions keyed by +// identity + schema. Legacy x-send-events entries are mapped to the unified +// form with a verbose deprecation note (RS.EVT.18). Load errors (mixed match +// contexts, dual triggers) abort schema setup (RS.EXT.20, RS.EXT.28). +func (b *eventBus) registerEventSubscriptions(schemas []SchemaInfo) error { + if b == nil || b.broker == nil { + return nil + } + for _, schema := range schemas { + if schema.Kind != loader.KindAsyncAPI || schema.Async == nil { + continue + } + if err := b.registerSchema(schema.Prefix, schema.Async); err != nil { + return err + } + } + return nil } -// collectSchemaSubscriptions extracts channel subscriptions declared via -// x-send-events on the schema's message examples. Each subscription carries a -// message spec restricted to the examples that subscribed to that event, so -// delivery renders exactly the templated subscribed message. -func collectSchemaSubscriptions(prefix string, doc *asyncapi.Document) []channelSubscription { +// registerSchema classifies and registers every event-driven example of one +// AsyncAPI schema, and schedules its periodically driven examples. Classifying +// is two-phase so a late classification error aborts the whole schema without +// leaking already-started interval jobs: every example is validated first, and +// 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 periodic []periodicRegistration for _, ch := range doc.Channels { address := asyncAddressWithPrefix(prefix, ch.Address) for _, msg := range ch.Messages { - byEvent := groupSubscribedExamples(msg) - for event, examples := range byEvent { - spec := loader.NewMessageSpec(msg) - if spec == nil { + for _, ex := range msg.Examples { + if ex == nil { continue } - spec.Examples = examples - subs = append(subs, channelSubscription{ - address: address, - event: event, - messages: []*messageDeliverable{{spec: spec, prefix: prefix}}, - }) + derived, err := b.derivedExamples(ex) + if err != nil { + return err + } + for _, spec := range derived { + trig, err := extensions.ClassifyTrigger(&MessageExampleView{spec: spec}) + if err != nil { + return fmt.Errorf("channel %q example %q: %w", ch.ID, ex.Name, err) + } + switch trig.Kind { + case extensions.TriggerEvent: + subs = append(subs, channelSubscription{ + address: address, + event: trig.Identity, + delay: trig.Delay, + messages: []*messageDeliverable{{ + spec: &loader.MessageSpec{Name: msg.Name, Examples: []*loader.MessageExampleSpec{spec}}, + prefix: prefix, + }}, + }) + case extensions.TriggerPeriodic: + periodic = append(periodic, periodicRegistration{ + address: address, prefix: prefix, exampleID: ex.Name, spec: spec, interval: trig.Interval, + }) + case extensions.TriggerReply: + // Reply examples are served by the channel's normal + // reply path; nothing to register here. A match that + // still references {$connection.*} can never evaluate + // (no connection context in the reply path), so point it + // out in verbose mode instead of failing silently. + if b.verbose && extensions.MatchReferencesConnection(trig.Match) { + slog.Warn("reply example references {$connection.*} which never matches without an event context; remove the connection condition or make the example event-driven", + "channel", ch.ID, "example", ex.Name) + } + } + } } } } - return subs + // Commit phase: nothing above has side effects, so a classification error + // from any example leaves the eventBus untouched. + 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 + } + } + return nil } -// groupSubscribedExamples returns, per event, the message examples carrying a -// subscription to that event (as loader example specs). -func groupSubscribedExamples(msg *asyncapi.Message) map[string][]*loader.MessageExampleSpec { - out := make(map[string][]*loader.MessageExampleSpec) - for _, ex := range msg.Examples { - if ex == nil { - continue +// periodicRegistration is a validated periodically driven example awaiting +// scheduler registration after the classification pass of registerSchema. +type periodicRegistration struct { + address string + prefix string + exampleID string + spec *loader.MessageExampleSpec + interval int +} + +// registerRuntimeExample registers a dynamically added async example (POST +// /_mock/examples with match or interval, RS.MAPI.24-26). Event-driven +// examples subscribe by identity; periodic examples schedule a delivery job. +// It returns the example trigger kind (TriggerEvent or TriggerPeriodic) and +// the scheduler job id ("" when none). +func (b *eventBus) registerRuntimeExample(id, address, prefix string, spec *loader.MessageExampleSpec) (extensions.TriggerKind, string, error) { + if b == nil || b.broker == nil { + return 0, "", fmt.Errorf("event broker not initialized") + } + trig, err := extensions.ClassifyTrigger(&MessageExampleView{spec: spec}) + if err != nil { + return 0, "", err + } + 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, + }}, + }}) + return extensions.TriggerEvent, "", nil + case extensions.TriggerPeriodic: + if _, err := b.registerPeriodic(address, prefix, id, spec, trig.Interval); err != nil { + return 0, "", err } - events, err := parseSendEvents(ex.Extensions) - if err != nil { - continue + return extensions.TriggerPeriodic, fmt.Sprintf("interval-%s-%s-%s", prefix, address, id), nil + default: + return 0, "", fmt.Errorf("unsupported runtime trigger: async examples require an {$event.*} match or an interval") + } +} + +// removeEventSubscription unregisters a runtime event-driven subscription by +// its example id (deliverable spec name "runtime-"). +func (b *eventBus) removeEventSubscription(prefix, id string) { + if b == nil || b.broker == nil { + return + } + b.broker.removeRuntimeExample(prefix, id) +} + +// registerPeriodic schedules a scheduler job delivering a periodically driven +// example at its cadence, notifying management observers (RS.AMG.27). The job +// id is scoped by prefix, address and example identity so distinct examples +// never collide. +func (b *eventBus) registerPeriodic(address, prefix, exampleID string, spec *loader.MessageExampleSpec, interval int) (string, error) { + if interval <= 0 { + return "", fmt.Errorf("x-mock-interval must be a positive integer") + } + 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() { + b.deliverPeriodic(address, prefix, spec, opID) + }, + }) + go b.scheduler.run(job) + if b.observer != nil { + env := manageEnvelope{Type: "schedule"} + env.Schedule = &manageScheduleEnvelope{Action: "started", ExampleID: exampleID, Channel: address, Interval: interval} + b.observer(env) + } + return jobID, nil +} + +// removeIntervalJob cancels a runtime interval job by its scheduler id, +// emitting a stopped envelope carrying the same identity as the started one. +func (b *eventBus) removeIntervalJob(jobID string) { + if b == nil || b.scheduler == nil || jobID == "" { + return + } + 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()), } - spec := &loader.MessageExampleSpec{ + b.observer(env) + } +} + +// deliverPeriodic renders a periodically driven example against current state +// and environment and broadcasts it to the channel's consumers. +func (b *eventBus) deliverPeriodic(address, prefix string, spec *loader.MessageExampleSpec, opID string) { + view := &MessageExampleView{spec: spec} + body := b.renderExample(view, b.stateEnvEvaluator(prefix), prefix, opID) + if body == nil { + return + } + b.notifyPush(address, "", body) + b.bus.SignalRPush(address, body) + b.bus.WSBroadcast(address, body) +} + +// derivedExamples maps one spec example into the example specs to register. +// An example without x-send-events maps to itself. A legacy x-send-events +// example maps through the deprecation shim: each entry becomes the unified +// form ({on} → {$event.name} match, {on: cron, wait} → x-mock-interval) with a +// verbose-mode deprecation note (RS.EVT.18). +func (b *eventBus) derivedExamples(ex *asyncapi.Example) ([]*loader.MessageExampleSpec, error) { + events, err := parseSendEvents(ex.Extensions) + if err != nil { + return nil, fmt.Errorf("example %q: %w", ex.Name, err) + } + if len(events) == 0 { + return []*loader.MessageExampleSpec{{ Name: ex.Name, Headers: ex.Headers, Payload: ex.Payload, Extensions: ex.Extensions, + }}, nil + } + out := make([]*loader.MessageExampleSpec, 0, len(events)) + for _, ev := range events { + ext := cloneExtensions(ex.Extensions) + delete(ext, xSendEventsKey) + if ev.On == "cron" { + if ev.Wait <= 0 { + return nil, fmt.Errorf("example %q: x-send-events {on: cron} requires a positive wait interval (use x-mock-interval)", ex.Name) + } + if b.verbose { + slog.Warn("x-send-events is deprecated; use x-mock-interval", "example", ex.Name) + } + ext["x-mock-interval"] = ev.Wait + } else { + if b.verbose { + slog.Warn("x-send-events is deprecated; use x-mock-match: {'{$event.name}': }", "example", ex.Name) + } + match, _ := ext["x-mock-match"].(map[string]any) + if match == nil { + match = make(map[string]any) + } + match["{$event.name}"] = ev.On + ext["x-mock-match"] = match + if ev.On == "connect" || ev.On == "receive" { + if ev.On == "connect" && ev.Wait > 0 { + ext["x-mock-delay"] = ev.Wait + } + } } - for _, ev := range events { - out[ev.On] = append(out[ev.On], spec) - } + out = append(out, &loader.MessageExampleSpec{ + Name: ex.Name, + Headers: ex.Headers, + Payload: ex.Payload, + Extensions: ext, + }) + } + return out, nil +} + +// cloneExtensions deep-copies an example's extension map. +func cloneExtensions(ext map[string]any) map[string]any { + out := make(map[string]any, len(ext)) + for k, v := range ext { + out[k] = v } return out } -// asyncAddressWithPrefix applies a schema prefix to a channel address. -func asyncAddressWithPrefix(prefix, address string) string { - addr := "/" + strings.Trim(address, "/") - if prefix == "" { - return addr +// deliver renders the subscribed message with the event payload and emits it +// to the channel's consumers, narrowing to per-connection recipients when the +// example's match references {$connection.*} (design D6, RS.EXT.24-25). An +// example-level x-mock-delay schedules the emission that far after the fire +// (RS.EXT.23). +func (b *eventBus) deliver(sub channelSubscription, payload map[string]any) { + b.deliverTo(sub, payload, nil) +} + +// deliverTargeted delivers a built-in event to a single candidate connection. +// 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 (b *eventBus) deliverTargeted(sub channelSubscription, payload map[string]any, recipient ConsumerInfo) { + b.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 (b *eventBus) deliverTo(sub channelSubscription, payload map[string]any, target *ConsumerInfo) { + if len(sub.messages) == 0 { + return + } + if sub.delay > 0 { + ms := sub.delay + sub.delay = 0 + go func() { + b.wait(time.Duration(ms) * time.Millisecond) + b.deliverTo(sub, payload, target) + }() + return + } + deliverable := sub.messages[0] + addr := sub.address + prefix := deliverable.prefix + eventName := sub.event + opID := "event:" + cmp.Or(eventName, anyEventIdentity) + ":" + addr + + b.deliverExample(sub, deliverable.spec.Examples, addr, prefix, eventName, payload, opID, target) +} + +// stateEnvEvaluator wires the fixed state and environment sources shared by +// every emission path (periodic deliveries have no event/connection context). +func (b *eventBus) stateEnvEvaluator(prefix string) runtime.Evaluator { + eval := runtime.NewEvaluator() + eval.AddSource("state", b.renderer.NewStateSource(prefix)) + eval.AddSource("env", b.renderer.NewEnvSource()) + return eval +} + +// eventEvaluator wires the fixed emission sources (state, env, event) plus an +// optional per-connection source into a fresh evaluator. +func (b *eventBus) eventEvaluator(state, env runtime.DataSource, eventName string, payload map[string]any, connection *runtime.ConnectionSource) runtime.Evaluator { + eval := runtime.NewEvaluator() + eval.AddSource("state", state) + eval.AddSource("env", env) + eval.AddSource("event", &runtime.EventSource{Name: eventName, Data: payload}) + if connection != nil { + eval.AddSource("connection", connection) + } + return eval +} + +// renderExample renders a single example's payload against an evaluator, +// honoring x-mock-skip and x-mock-set-state. It returns the rendered body, or +// nil when the example is skipped or rendering fails (verbose-logged). +func (b *eventBus) renderExample(view *MessageExampleView, eval runtime.Evaluator, prefix, opID string) []byte { + if extensions.ValueSkip(view) { + return nil + } + if stateMap, ok := extensions.ValueSetState(view); ok { + b.renderer.ApplySetState(stateMap, eval, prefix) + } + body, err := b.renderer.RenderAsyncPayload(view, eval) + if err != nil { + if b.verbose { + slog.Debug("Example delivery render failed", "opID", opID, "err", err) + } + return nil + } + return body +} + +// evaluateConnectionBucket evaluates an example's connection conditions +// against one candidate recipient. An empty bucket matches every candidate. +func (b *eventBus) evaluateConnectionBucket(bucket extensions.ParamsMatch, state, env runtime.DataSource, eventName string, payload map[string]any, candidate ConsumerInfo) (bool, error) { + if len(bucket) == 0 { + return true, nil + } + eval := b.eventEvaluator(state, env, eventName, payload, connectionSourceFromInfo(candidate)) + return extensions.EvaluateParamsMatch(bucket, eval) +} + +// 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 (b *eventBus) deliverExample(sub 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 := b.renderer.NewStateSource(prefix) + env := b.renderer.NewEnvSource() + + for _, example := range examples { + view := &MessageExampleView{spec: example} + common, connection := b.partitionedMatch(view) + var connSource *runtime.ConnectionSource + if target != nil { + connSource = connectionSourceFromInfo(*target) + } + evaluator := b.eventEvaluator(state, env, eventName, payload, connSource) + if len(common) > 0 { + ok, cErr := extensions.EvaluateParamsMatch(common, evaluator) + if cErr != nil || !ok { + continue + } + } + body := b.renderExample(view, evaluator, prefix, opID) + if body == nil { + continue + } + if target != nil { + // Built-in recipient: evaluate the connection bucket against the + // single candidate and deliver on match (or immediately when there + // is no connection bucket). + ok, okErr := b.evaluateConnectionBucket(connection, state, env, eventName, payload, *target) + if okErr != nil || !ok { + continue + } + b.notifyPush(addr, target.ConnectionID, body) + b.bus.PushTo(*target, addr, body) + continue + } + if len(connection) == 0 { + // Broadcast fast path (RS.EXT.25). + b.notifyPush(addr, "", body) + b.bus.SignalRPush(addr, body) + b.bus.WSBroadcast(addr, body) + continue + } + // Per-connection partition: evaluate the connection bucket against each + // candidate with its connection context (design D6). + for _, candidate := range b.bus.Candidates(addr) { + ok, okErr := b.evaluateConnectionBucket(connection, state, env, eventName, payload, candidate) + if okErr != nil || !ok { + continue + } + b.notifyPush(addr, candidate.ConnectionID, body) + b.bus.PushTo(candidate, addr, body) + } } - return "/" + strings.Trim(prefix, "/") + addr } -// broadcast sends a payload to every connected consumer of a channel address. -func (a *wsProtocolAdapter) broadcast(address string, payload []byte) { - if a == nil || a.registry == nil { +// notifyPush emits a push envelope to the management observer (RS.AMG.25). +func (b *eventBus) notifyPush(channel, connectionID string, body []byte) { + if b.observer == nil { return } - for _, ws := range a.registry.connections(address) { - ws.writer.write(payload) + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + payload = map[string]any{"raw": string(body)} } + env := manageEnvelope{Type: "push"} + env.Push = &managePushEnvelope{Channel: channel, ConnectionID: connectionID, Payload: payload} + b.observer(env) } -// renderMessageSpecsWithEvent evaluates message specs with an event payload -// registered in the evaluator as {$event.*}. -func (s *Server) renderMessageSpecsWithEvent(messages []*loader.MessageSpec, prefix, opID string, payload map[string]any) (int, []byte, error) { - return s.engine.RenderMessageSpecsWithEvent(messages, prefix, opID, payload) +// partitionedMatch splits an example's x-mock-match into common conditions +// (evaluated once per emission) and connection conditions (evaluated per +// candidate recipient). A nil/absent match yields empty buckets. +func (b *eventBus) partitionedMatch(view *MessageExampleView) (extensions.ParamsMatch, extensions.ParamsMatch) { + match, _ := extensions.ValueMatch(view) + return extensions.PartitionConnectionConditions(extensions.ParamsMatch(match)) } diff --git a/internal/server/events_endpoint_test.go b/internal/server/events_endpoint_test.go new file mode 100644 index 0000000..6849066 --- /dev/null +++ b/internal/server/events_endpoint_test.go @@ -0,0 +1,184 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Events endpoint requires the type discriminator +Given a management request to /_mock/events without a type field +When the events endpoint is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.32, RS.MAPI.22 +*/ +func TestEventsEndpoint_MissingType(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + body := `{"event":"levelUp","payload":{"level":"warn"}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +/* +Scenario: Events endpoint rejects an unknown type +Given a management request to /_mock/events with an unsupported type +When the events endpoint is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.32, RS.MAPI.22 +*/ +func TestEventsEndpoint_UnknownType(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + body := `{"type":"explode","event":"levelUp","payload":{"level":"warn"}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +/* +Scenario: Firing an event with type fire reproduces the fire behavior +Given a management request to /_mock/events with type fire and a payload +When the events endpoint is invoked with a connected consumer +Then the consumer receives the templated message + +Related spec scenarios: RS.MAPI.22, RS.AMG.20 +*/ +func TestEventsEndpoint_TypeFireDelivers(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + body := `{"type":"fire","event":"levelUp","payload":{"level":"warn","message":"high load"}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(msg, &payload)) + assert.Equal(t, "warn", payload["level"]) + assert.Equal(t, "high load", payload["msg"]) +} + +/* +Scenario: Fired-event payload expressions are templated against state and env +Given a management fired event whose payload references {$env.*} and {$state.*} +When the events endpoint is invoked with a connected consumer +Then the expressions are resolved before delivery (RS.MAPI.23) + +Related spec scenarios: RS.MAPI.23, RS.AMG.20 +*/ +func TestEventsEndpoint_PayloadTemplating(t *testing.T) { + t.Setenv("OASMOCK_EVENTS_TEST", "warn") + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + body := `{"type":"fire","event":"levelUp","payload":{"level":"{$env.OASMOCK_EVENTS_TEST}","message":"high load"}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(msg, &payload)) + assert.Equal(t, "warn", payload["level"]) + assert.Equal(t, "high load", payload["msg"]) +} + +/* +Scenario: The legacy /events/fire alias defaults the type discriminator +Given a legacy management request to /_mock/events/fire without a type field +When the alias is invoked with a connected consumer +Then the event fires as type "fire" and the consumer receives the message + +Related spec scenarios: RS.MAPI.22, RS.MAPI.32 +*/ +func TestEventsEndpoint_LegacyAliasDefaultsType(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + body := `{"event":"levelUp","payload":{"level":"warn","message":"legacy"}}` + resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(msg, &payload)) + assert.Equal(t, "warn", payload["level"]) + assert.Equal(t, "legacy", payload["msg"]) +} diff --git a/internal/server/fire_event.go b/internal/server/fire_event.go index 579dfde..c33cce3 100644 --- a/internal/server/fire_event.go +++ b/internal/server/fire_event.go @@ -4,22 +4,43 @@ import ( "encoding/json" "io" "net/http" + + "github.com/mamonth/oasmock/internal/runtime" ) -// fireEventRequest is the payload of POST /_mock/events/fire (RS.EVT.16-17). +// fireEventRequest is the payload of POST /_mock/events (RS.MAPI.22-23, +// RS.MAPI.32). Type discriminates the action; V1 supports "fire" only. type fireEventRequest struct { + Type string `json:"type"` Event string `json:"event"` Payload map[string]any `json:"payload"` Delay int `json:"delay"` Global bool `json:"global"` } -// handleFireEvent fires a named event ad-hoc through the event broker. -func (s *Server) handleFireEvent(w http.ResponseWriter, r *http.Request) { - if s.eventBus == nil { - writeJSONError(w, http.StatusInternalServerError, "event broker not initialized") +// handleFireEventLegacy serves the deprecated /_mock/events/fire alias, +// accepting the pre-discriminator body (no "type" field). The alias keeps the +// old contract so pre-change clients keep working (design D1); the canonical +// /_mock/events endpoint still requires the type discriminator. +func (s *Server) handleFireEventLegacy(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "failed to read request body") return } + var req fireEventRequest + if err := json.Unmarshal(body, &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid JSON body") + return + } + req.Type = "fire" + s.dispatchFireEvent(w, req) +} + +// handleEvents dispatches a discriminated event action through the event +// broker. The type discriminator is required and only "fire" is accepted +// (RS.MAPI.32); fire reuses the existing ad-hoc fire semantics. +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) if err != nil { writeJSONError(w, http.StatusBadRequest, "failed to read request body") @@ -30,6 +51,25 @@ func (s *Server) handleFireEvent(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "invalid JSON body") return } + s.dispatchFireEvent(w, req) +} + +// dispatchFireEvent validates and executes a fired event. Both the canonical +// and the legacy alias decode into the shared request, so the discriminator +// default lives next to the type checks instead of a body re-encode round trip. +func (s *Server) dispatchFireEvent(w http.ResponseWriter, req fireEventRequest) { + if s.eventBus == nil { + writeJSONError(w, http.StatusInternalServerError, "event broker not initialized") + return + } + if req.Type == "" { + writeJSONError(w, http.StatusBadRequest, "missing required field 'type'") + return + } + if req.Type != "fire" { + writeJSONErrorf(w, http.StatusBadRequest, "unsupported event type %q (supported: fire)", req.Type) + return + } if req.Event == "" { writeJSONError(w, http.StatusBadRequest, "missing required field 'event'") return @@ -38,9 +78,30 @@ func (s *Server) handleFireEvent(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "delay cannot be negative") return } + + // Fired-event payload expressions {$state.*}/{$env.*} are evaluated against + // the schema's state namespace and the environment before delivery + // (RS.MAPI.23). + if len(req.Payload) > 0 { + eval := runtime.NewEvaluator() + eval.AddSource("state", s.newStateSource("")) + eval.AddSource("env", s.newEnvSource()) + resolved, err := s.evaluateValue(req.Payload, eval) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + var ok bool + req.Payload, ok = resolved.(map[string]any) + if !ok { + writeJSONError(w, http.StatusBadRequest, "payload must be a JSON object") + return + } + } // Global events apply to all schemas; otherwise they are schema-local. The - // management endpoint has no single schema context, so global: true is - // honored and local events are delivered to subscribed schemas. + // management endpoint has no schema context of its own, so schema-local + // fires only reach empty-prefix subscriptions (use global: true for + // prefixed channels). s.eventBus.fire(req.Event, req.Payload, "", req.Global, triggerDelay(req.Delay)) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/internal/server/fire_event_endpoint_test.go b/internal/server/fire_event_endpoint_test.go index 98768e1..a5c8da4 100644 --- a/internal/server/fire_event_endpoint_test.go +++ b/internal/server/fire_event_endpoint_test.go @@ -47,7 +47,7 @@ Given a management request firing a named event with a payload When the fire-event endpoint is invoked with a connected consumer Then the consumer receives the templated message -Related spec scenarios: RS.EVT.16, RS.EVT.17, RS.AMG.20, RS.AMG.21, RS.MAPI.22, RS.MAPI.23 +Related spec scenarios: RS.EVT.16, RS.EVT.17, RS.AMG.20, RS.AMG.21, RS.MAPI.22 */ func TestFireEventEndpoint(t *testing.T) { t.Parallel() @@ -65,8 +65,8 @@ func TestFireEventEndpoint(t *testing.T) { require.NoError(t, err) defer conn.Close() //nolint:errcheck - body := `{"event":"levelUp","payload":{"level":"warn","message":"high load"}}` - resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + body := `{"type":"fire","event":"levelUp","payload":{"level":"warn","message":"high load"}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -99,8 +99,8 @@ func TestFireEventEndpoint_Delayed(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"event":"levelUp","payload":{"level":"warn"},"delay":10}` - resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + body := `{"type":"fire","event":"levelUp","payload":{"level":"warn"},"delay":10}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -125,8 +125,8 @@ func TestFireEventEndpoint_NegativeDelay(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"event":"levelUp","payload":{},"delay":-5}` - resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + body := `{"type":"fire","event":"levelUp","payload":{},"delay":-5}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -151,8 +151,8 @@ func TestFireEventEndpoint_NoConsumers(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck - body := `{"event":"levelUp","payload":{"level":"warn","message":"high load"}}` - resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + body := `{"type":"fire","event":"levelUp","payload":{"level":"warn","message":"high load"}}` + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/internal/server/hubmanager.go b/internal/server/hubmanager.go index 94b4521..b856092 100644 --- a/internal/server/hubmanager.go +++ b/internal/server/hubmanager.go @@ -1,5 +1,7 @@ package server +import "strings" + // hubManager owns the SignalR hubs built from AsyncAPI documents and the raw // ws protocol adapter, exposing connection lookup and payload delivery behind // the ConsumerBus contract. It depends only on the MessageRenderer surface. @@ -8,6 +10,20 @@ type hubManager struct { ws *wsProtocolAdapter } +// hubForAddress finds the SignalR hub owning a channel address. +func (s *Server) hubForAddress(address string) *signalRHub { + return s.hubMgr.hubForAddress(address) +} + +// asyncAddressWithPrefix applies a schema prefix to a channel address. +func asyncAddressWithPrefix(prefix, address string) string { + addr := "/" + strings.Trim(address, "/") + if prefix == "" { + return addr + } + return "/" + strings.Trim(prefix, "/") + addr +} + // newHubManager builds a hub per AsyncAPI document declaring root x-signalr and // keeps the ws adapter used for raw consumer broadcast. func newHubManager(renderer MessageRenderer, ws *wsProtocolAdapter, schemas []SchemaInfo) *hubManager { @@ -64,3 +80,80 @@ func (m *hubManager) WSBroadcast(address string, payload []byte) { } m.ws.broadcast(address, payload) } + +// Candidates returns every consumer of a channel address (raw ws and SignalR) +// with the connection context captured at upgrade. +func (m *hubManager) Candidates(address string) []ConsumerInfo { + var out []ConsumerInfo + if m.ws != nil { + for _, ws := range m.ws.registry.connections(address) { + out = append(out, ConsumerInfo{ + ConnectionID: ws.id, + Channel: ws.channel, + Query: ws.query, + Headers: ws.headers, + }) + } + } + if hub := m.hubForAddress(address); hub != nil { + for channelID, ch := range hub.channels { + if asyncAddressWithPrefix(hub.prefix, ch.Address) != address { + continue + } + for _, st := range hub.openStreamsForChannel(channelID) { + out = append(out, ConsumerInfo{ + ConnectionID: st["connectionId"], + Channel: address, + Query: hub.connectionMetadata(st["connectionId"]), + Headers: hub.connectionHeaders(st["connectionId"]), + Streams: []map[string]string{st}, + }) + } + } + } + return out +} + +// connectionMetadata returns the upgrade-time query metadata of a connection +// (for {$connection.query.*} evaluation); nil when unknown. +func (h *signalRHub) connectionMetadata(connID string) map[string][]string { + h.mu.Lock() + defer h.mu.Unlock() + if sc, ok := h.conns[connID]; ok { + return sc.query + } + return nil +} + +// connectionHeaders returns the upgrade-time header metadata of a connection; +// nil when unknown. Header keys are lower-cased at capture time. +func (h *signalRHub) connectionHeaders(connID string) map[string][]string { + h.mu.Lock() + defer h.mu.Unlock() + if sc, ok := h.conns[connID]; ok { + return sc.headers + } + return nil +} + +// PushTo delivers a payload to one candidate consumer: its raw ws connection, +// or its SignalR stream network (falling back to a server invocation). +func (m *hubManager) PushTo(consumer ConsumerInfo, address string, payload []byte) { + if m.ws != nil { + if ws, ok := m.ws.registry.connection(consumer.ConnectionID); ok { + ws.writer.write(payload) + return + } + } + hub := m.hubForAddress(address) + if hub == nil { + return + } + for channelID, ch := range hub.channels { + if asyncAddressWithPrefix(hub.prefix, ch.Address) != address { + continue + } + hub.pushToConnection(consumer.ConnectionID, channelID, payload, channelID) + return + } +} diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index 1d704a6..7bdec12 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -184,9 +184,6 @@ type MessageRenderer interface { // RenderMessageSpecs renders the first selectable example across the given // message specs. RenderMessageSpecs(messages []*loader.MessageSpec, prefix, opID string, in InboundMessage) (int, []byte, error) - // RenderMessageSpecsWithEvent renders message specs with an {$event.*} data - // source. - RenderMessageSpecsWithEvent(messages []*loader.MessageSpec, prefix, opID string, payload map[string]any) (int, []byte, error) // RenderAsyncPayload evaluates runtime expressions in an example payload. RenderAsyncPayload(example *MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) // ApplySetState applies x-mock-set-state against a schema namespace. @@ -197,8 +194,20 @@ type MessageRenderer interface { NewEnvSource() *runtime.EnvSource } +// ConsumerInfo is a delivery candidate: a raw ws consumer or a SignalR +// connection with open streams, carrying the connection context captured at +// upgrade for {$connection.*} evaluation. +type ConsumerInfo struct { + ConnectionID string + Channel string + Query map[string][]string + Headers map[string][]string + Streams []map[string]string +} + // ConsumerBus emits rendered payloads to channel consumers (SignalR open -// streams and/or raw ws broadcast). hubManager implements it. +// streams and/or raw ws broadcast) and enumerates delivery candidates for +// per-connection recipient partitioning. hubManager implements it. type ConsumerBus interface { // SignalRPush emits a payload into a SignalR hub channel's open streams or // as a server invocation when none are open (RS.SHR.18-19). @@ -206,4 +215,10 @@ type ConsumerBus interface { // WSBroadcast sends a payload to every connected raw ws consumer of a // channel address. WSBroadcast(address string, payload []byte) + // Candidates returns every consumer of a channel address (raw ws and + // SignalR) with the connection context captured at upgrade. + Candidates(address string) []ConsumerInfo + // PushTo delivers a payload to one candidate consumer (its open streams, + // falling back to a server invocation on the same connection). + PushTo(consumer ConsumerInfo, address string, payload []byte) } diff --git a/internal/server/job_scheduler.go b/internal/server/job_scheduler.go new file mode 100644 index 0000000..a736ae0 --- /dev/null +++ b/internal/server/job_scheduler.go @@ -0,0 +1,125 @@ +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/job_scheduler_test.go b/internal/server/job_scheduler_test.go new file mode 100644 index 0000000..227e460 --- /dev/null +++ b/internal/server/job_scheduler_test.go @@ -0,0 +1,213 @@ +package server + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: An interval job delivers at its cadence +Given a per-example interval job registered in the scheduler +When the job runs +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) { + t.Parallel() + + sched := newJobScheduler() + defer sched.shutdown() + + var count atomic.Int32 + job := sched.add(&scheduledJob{id: "ex-1", interval: 10 * time.Millisecond, deliver: func() { + count.Add(1) + }}) + go sched.run(job) + + deadline := time.Now().Add(200 * time.Millisecond) + for count.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + assert.GreaterOrEqual(t, count.Load(), int32(2)) +} + +/* +Scenario: Cancelling an interval job stops further deliveries +Given a running interval job +When the job is cancelled by id +Then no further deliveries occur after cancellation + +Related spec scenarios: RS.EXT.22, RS.MAPI.30 +*/ +func TestJobScheduler_CancelStops(t *testing.T) { + t.Parallel() + + sched := newJobScheduler() + defer sched.shutdown() + + var count atomic.Int32 + job := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + count.Add(1) + }}) + go sched.run(job) + + deadline := time.Now().Add(100 * time.Millisecond) + for count.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + before := count.Load() + require.GreaterOrEqual(t, before, int32(2)) + + job, ok := sched.cancel("ex-1") + require.True(t, ok) + require.NotNil(t, job) + + time.Sleep(40 * time.Millisecond) + // At most the one tick already in flight at the moment of cancellation may + // land; any sustained cadence (5ms here would add ~8) means cancel failed. + assert.LessOrEqual(t, count.Load()-before, int32(1), "no further deliveries may occur after cancellation") +} + +/* +Scenario: Shutting down the scheduler stops all interval jobs +Given running interval jobs +When the scheduler shuts down +Then the jobs are cancelled and registered entries removed + +Related spec scenarios: RS.MAPI.25, RS.MSC.49 +*/ +func TestJobScheduler_Shutdown(t *testing.T) { + t.Parallel() + + sched := newJobScheduler() + var count atomic.Int32 + job := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + count.Add(1) + }}) + go sched.run(job) + + deadline := time.Now().Add(100 * time.Millisecond) + for count.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + require.GreaterOrEqual(t, count.Load(), int32(2)) + + sched.shutdown() + time.Sleep(30 * time.Millisecond) + assert.True(t, sched.stopped("ex-1")) +} + +/* +Scenario: Cancelling an unknown job reports false +Given a scheduler without the named job +When cancel is called +Then it reports false and leaves no error + +Related spec scenarios: RS.MAPI.31 +*/ +func TestJobScheduler_CancelUnknown(t *testing.T) { + t.Parallel() + + sched := newJobScheduler() + defer sched.shutdown() + job, ok := sched.cancel("unknown") + assert.False(t, ok) + assert.Nil(t, job) +} + +/* +Scenario: Re-adding a job id stops the previous job +Given a running job and a new job registered under the same id +When the second job is added +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) { + t.Parallel() + + sched := newJobScheduler() + defer sched.shutdown() + + var oldCount atomic.Int32 + jobA := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + oldCount.Add(1) + }}) + go sched.run(jobA) + + deadline := time.Now().Add(100 * time.Millisecond) + for oldCount.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + require.GreaterOrEqual(t, oldCount.Load(), int32(2)) + + var newCount atomic.Int32 + jobB := sched.add(&scheduledJob{id: "ex-1", interval: 5 * time.Millisecond, deliver: func() { + newCount.Add(1) + }}) + go sched.run(jobB) + + deadline = time.Now().Add(100 * time.Millisecond) + for newCount.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + require.GreaterOrEqual(t, newCount.Load(), int32(2)) + + frozen := oldCount.Load() + time.Sleep(30 * time.Millisecond) + assert.Equal(t, frozen, oldCount.Load(), "the replaced job must be stopped") +} + +/* +Scenario: A panicking interval job is contained and removed +Given an interval job whose delivery callback panics +When the job runs +Then the panic is recovered, the job is unregistered, and the scheduler keeps +serving other jobs instead of silently losing the cadence + +Related spec scenarios: RS.EXT.22, RS.MAPI.25 +*/ +func TestJobScheduler_PanicInDeliverRemovesJob(t *testing.T) { + t.Parallel() + + sched := newJobScheduler() + defer sched.shutdown() + + var poisoned atomic.Bool + poisoned.Store(true) + 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) + + deadline := time.Now().Add(time.Second) + 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") + + // 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() { + healthy.Add(1) + }}) + go sched.run(good) + + deadline = time.Now().Add(500 * time.Millisecond) + for healthy.Load() < 2 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + assert.GreaterOrEqual(t, healthy.Load(), int32(2), "healthy jobs must keep delivering after a panic") +} diff --git a/internal/server/manage_stream_lifecycle_test.go b/internal/server/manage_stream_lifecycle_test.go new file mode 100644 index 0000000..053dc6c --- /dev/null +++ b/internal/server/manage_stream_lifecycle_test.go @@ -0,0 +1,153 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const streamLifecycleDoc = `asyncapi: 3.0.0 +info: + title: Alerts + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + alertMsg: + examples: + - name: ex1 + payload: + level: info +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +// readManageEnvelopes reads up to n envelopes, collecting matches for predicate. +func readManageEnvelopes(t *testing.T, conn *websocket.Conn, n int, match func(manageEnvelope) bool) []manageEnvelope { + t.Helper() + var out []manageEnvelope + deadline := time.Now().Add(3 * time.Second) + for len(out) < n && time.Now().Before(deadline) { + _ = conn.SetReadDeadline(deadline) + _, raw, rerr := conn.ReadMessage() + if rerr != nil { + break + } + var env manageEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + continue + } + if match(env) { + out = append(out, env) + } + } + return out +} + +/* +Scenario: Consumer lifecycle envelopes are emitted on connect/disconnect +Given a stream subscriber and a raw ws consumer on a channel +When the consumer connects and disconnects +Then the subscriber receives consumer envelopes for both lifecycle events + +Related spec scenarios: RS.AMG.26 +*/ +func TestManageStream_ConsumerLifecycleEnvelopes(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(streamLifecycleDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + stream := dialManageStream(t, ts.URL, "channels=/alerts") + defer stream.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + + envs := readManageEnvelopes(t, stream, 1, func(e manageEnvelope) bool { + return e.Type == "consumer" && e.Consumer != nil && e.Consumer.Action == "connected" + }) + require.Len(t, envs, 1) + assert.Equal(t, "/alerts", envs[0].Consumer.Channel) + assert.NotEmpty(t, envs[0].Consumer.ConnectionID) + + // Disconnect the consumer and expect a disconnected envelope. + conn.Close() //nolint:errcheck + disc := readManageEnvelopes(t, stream, 1, func(e manageEnvelope) bool { + return e.Type == "consumer" && e.Consumer != nil && e.Consumer.Action == "disconnected" + }) + assert.Len(t, disc, 1) +} + +/* +Scenario: Schedule envelopes are emitted on interval start and stop +Given a stream subscriber and a POST to /_mock/examples with an interval +When the interval example is added then removed +Then the subscriber receives schedule started/stopped envelopes + +Related spec scenarios: RS.AMG.27 +*/ +func TestManageStream_ScheduleEnvelopes(t *testing.T) { + t.Parallel() + + srv := newAsyncMgmtServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + stream := dialManageStream(t, ts.URL, "channels=/alerts") + defer stream.Close() //nolint:errcheck + + body := `{"channel":"/alerts","interval":50,"response":{"code":200,"body":{"tick":true}}}` + resp := postExample(t, ts.URL, body) + var addResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&addResp)) + resp.Body.Close() //nolint:errcheck + exampleID, _ := addResp["id"].(string) + require.NotEmpty(t, exampleID) + + started := readManageEnvelopes(t, stream, 1, func(e manageEnvelope) bool { + return e.Type == "schedule" && e.Schedule != nil && e.Schedule.Action == "started" + }) + require.Len(t, started, 1) + assert.Equal(t, exampleID, started[0].Schedule.ExampleID) + assert.Equal(t, "/alerts", started[0].Schedule.Channel) + assert.Equal(t, 50, started[0].Schedule.Interval) + + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/examples/"+exampleID, nil) + require.NoError(t, err) + delResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = delResp.Body.Close() + assert.Equal(t, http.StatusOK, delResp.StatusCode) + + stopped := readManageEnvelopes(t, stream, 1, func(e manageEnvelope) bool { + return e.Type == "schedule" && e.Schedule != nil && e.Schedule.Action == "stopped" + }) + require.Len(t, stopped, 1) + // The stopped envelope carries the same example identity, channel and + // interval as the started one so stream clients can correlate the pair. + assert.Equal(t, started[0].Schedule.ExampleID, stopped[0].Schedule.ExampleID) + assert.Equal(t, "/alerts", stopped[0].Schedule.Channel) + assert.Equal(t, 50, stopped[0].Schedule.Interval) +} diff --git a/internal/server/manage_stream_observer_test.go b/internal/server/manage_stream_observer_test.go new file mode 100644 index 0000000..7f113b2 --- /dev/null +++ b/internal/server/manage_stream_observer_test.go @@ -0,0 +1,155 @@ +package server + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/mamonth/oasmock/internal/extensions" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: The event bus observer emits an event envelope on fire +Given an eventBus with an observer and a text-only deliverer +When fire is called +Then the observer receives an event envelope + +Related spec scenarios: RS.AMG.24 +*/ +func TestEventBusObserver_EmitsEventOnFire(t *testing.T) { + t.Parallel() + + var seen atomic.Value + bus := newEventBus(nil, nil, false) + bus.setObserver(func(env manageEnvelope) { seen.Store(env) }) + + bus.fire("orderCreated", map[string]any{"id": "1"}, "/v1", true, nil) + + env, ok := seen.Load().(manageEnvelope) + require.True(t, ok) + assert.Equal(t, "event", env.Type) + require.NotNil(t, env.Event) + assert.Equal(t, "orderCreated", env.Event.Name) + assert.Equal(t, "/v1", env.Event.Schema) + assert.True(t, env.Event.Global) +} + +/* +Scenario: The event bus observer emits a push envelope on a broadcast delivery +Given an eventBus with an observer, a registered event example and a consumer bus +When the event fires and the example delivers +Then the observer receives a push envelope for the channel + +Related spec scenarios: RS.AMG.25 +*/ +func TestEventBusObserver_EmitsPushOnDeliver(t *testing.T) { + t.Parallel() + + var pushed atomic.Bool + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{ + candidates: []ConsumerInfo{{ConnectionID: "c1", Channel: "/alerts"}}, + wsPush: func(consumer ConsumerInfo, payload []byte) { + pushed.Store(true) + }, + }, false) + bus.setObserver(func(env manageEnvelope) { + if env.Type == "push" { + pushed.Store(true) + } + }) + + spec := loaderExampleSpecForTest(map[string]any{ + "level": "info", + "msg": "hi", + }, map[string]any{ + "x-mock-match": map[string]any{ + "{$event.name}": "orderCreated", + }, + }) + trigger, _, err := bus.registerRuntimeExample("ex-1", "/alerts", "/v1", spec) + require.NoError(t, err) + assert.Equal(t, extensions.TriggerEvent, trigger) + + bus.fire("orderCreated", map[string]any{"id": "1"}, "/v1", true, nil) + assert.True(t, pushed.Load()) +} + +/* +Scenario: The observer emits a push envelope on a periodic delivery +Given an interval-driven example registered in the event bus +When its job delivers at a tick +Then the observer receives a push envelope for the channel + +Related spec scenarios: RS.AMG.25, RS.AMG.27 +*/ +func TestEventBusObserver_EmitsPushOnPeriodicDelivery(t *testing.T) { + t.Parallel() + + var pushEnvelopes atomic.Int64 + var delivered atomic.Int64 + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{ + wsPush: func(ConsumerInfo, []byte) { delivered.Add(1) }, + }, false) + bus.setObserver(func(env manageEnvelope) { + if env.Type == "push" { + pushEnvelopes.Add(1) + } + }) + + spec := loaderExampleSpecForTest(map[string]any{"tick": true}, map[string]any{ + "x-mock-interval": 25, + }) + trigger, _, err := bus.registerRuntimeExample("ex-p", "/alerts", "", spec) + require.NoError(t, err) + assert.Equal(t, extensions.TriggerPeriodic, trigger) + + deadline := time.Now().Add(2 * time.Second) + for pushEnvelopes.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + time.Sleep(5 * time.Millisecond) + assert.Greater(t, delivered.Load(), int64(0)) + assert.Greater(t, pushEnvelopes.Load(), int64(0), "periodic deliveries must emit a push envelope") +} + +/* +Scenario: The observer emits an event envelope for a built-in targeted fire +Given a connect example registered in the event bus +When the connect built-in fires for a recipient +Then the observer receives an event envelope naming the built-in + +Related spec scenarios: RS.AMG.24 +*/ +func TestEventBusObserver_EmitsEventOnFireTargeted(t *testing.T) { + t.Parallel() + + var seen atomic.Value + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{ + wsPush: func(ConsumerInfo, []byte) {}, + }, false) + bus.setObserver(func(env manageEnvelope) { + if env.Type == "event" { + seen.Store(env) + } + }) + + spec := loaderExampleSpecForTest(map[string]any{"msg": "welcome"}, map[string]any{ + "x-mock-match": map[string]any{"{$event.name}": "connect"}, + }) + trigger, _, err := bus.registerRuntimeExample("ex-c", "/alerts", "", spec) + require.NoError(t, err) + assert.Equal(t, extensions.TriggerEvent, trigger) + + bus.fireTargeted("connect", map[string]any{"connectionId": "c1"}, "", ConsumerInfo{ + ConnectionID: "c1", + Channel: "/alerts", + }) + + env, ok := seen.Load().(manageEnvelope) + require.True(t, ok) + assert.Equal(t, "event", env.Type) + require.NotNil(t, env.Event) + assert.Equal(t, "connect", env.Event.Name) +} diff --git a/internal/server/manage_stream_test.go b/internal/server/manage_stream_test.go new file mode 100644 index 0000000..b9f0eab --- /dev/null +++ b/internal/server/manage_stream_test.go @@ -0,0 +1,215 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dialManageStream connects to /_mock/stream with optional filters. +func dialManageStream(t *testing.T, tsURL, query string) *websocket.Conn { + t.Helper() + url := "ws" + strings.TrimPrefix(tsURL, "http") + "/_mock/stream" + if query != "" { + url += "?" + query + } + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + require.NoError(t, err) + return conn +} + +/* +Scenario: A plain HTTP GET to the stream endpoint is rejected +Given a non-upgrade request to /_mock/stream +When the request is served +Then the server responds with HTTP 405 + +Related spec scenarios: RS.AMG.28 +*/ +func TestManageStream_PlainHTTPRejected(t *testing.T) { + t.Parallel() + + srv := newAsyncMgmtServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + resp, err := http.Get(ts.URL + "/_mock/stream") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) +} + +/* +Scenario: A ws client connects and receives envelopes +Given a management stream subscriber and a fired event +When an event fires with a connected consumer +Then the subscriber receives an event envelope + +Related spec scenarios: RS.AMG.23, RS.AMG.24 +*/ +func TestManageStream_ReceivesEventEnvelope(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + stream := dialManageStream(t, ts.URL, "events=levelUp") + defer stream.Close() //nolint:errcheck + + // Connect a consumer on the /alerts channel. + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + resp, err := http.Post(ts.URL+"/_mock/events", "application/json", + strings.NewReader(`{"type":"fire","event":"levelUp","payload":{"level":"warn"}}`)) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + deadline := time.Now().Add(3 * time.Second) + var gotEnv manageEnvelope + for time.Now().Before(deadline) { + _ = stream.SetReadDeadline(deadline) + _, raw, rerr := stream.ReadMessage() + if rerr != nil { + break + } + var env manageEnvelope + require.NoError(t, json.Unmarshal(raw, &env)) + if env.Type == "event" && env.Event != nil && env.Event.Name == "levelUp" { + gotEnv = env + break + } + } + assert.Equal(t, "event", gotEnv.Type) + require.NotNil(t, gotEnv.Event) + assert.Equal(t, "levelUp", gotEnv.Event.Name) +} + +/* +Scenario: Parsing connect-time event and channel filters +Given a stream URL with comma-separated events and channels globs +When parseStreamFilters runs +Then the filters are parsed into the subscriber's filter + +Related spec scenarios: RS.AMG.23 +*/ +func TestManageStream_FiltersParsed(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/_mock/stream?events=orderCreated,levelUp&channels=/*,x", nil) + f := parseStreamFilters(req) + assert.ElementsMatch(t, []string{"orderCreated", "levelUp"}, f.events) + assert.ElementsMatch(t, []string{"/*", "x"}, f.channels) +} + +/* +Scenario: Ping keepalive stops after the stream connection closes +Given a management stream subscriber with a ping loop +When the connection drops and the stop signal closes +Then the ping goroutine exits and issues no further pings + +Related spec scenarios: RS.AMG.23 +*/ +func TestServePings_StopsAfterStop(t *testing.T) { + t.Parallel() + + rec := &pingRecorder{} + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + servePings(rec, 5*time.Millisecond, stop) + close(done) + }() + + deadline := time.Now().Add(time.Second) + for rec.count() < 3 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + require.GreaterOrEqual(t, rec.count(), 3) + + close(stop) + select { + case <-done: + case <-time.After(200 * time.Millisecond): + t.Fatal("servePings did not exit after the stop signal") + } + + after := rec.count() + time.Sleep(15 * time.Millisecond) + assert.Equal(t, after, rec.count(), "no ping may be issued after the stop signal") +} + +// pingRecorder counts ping frames written through a pingWriter. +type pingRecorder struct { + mu sync.Mutex + sends int +} + +func (p *pingRecorder) writeMessage(_ int, _ []byte) { + p.mu.Lock() + defer p.mu.Unlock() + p.sends++ +} + +func (p *pingRecorder) count() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.sends +} + +/* +Scenario: Glob matching covers leading, middle and trailing wildcards +Given a glob pattern with '*' in any position +When globMatch compares it to a value +Then the value matches when the literal segments appear in order + +Related spec scenarios: RS.AMG.23 +*/ +func TestGlobMatch_WildcardPositions(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + pattern string + value string + want bool + }{ + {name: "leading star", pattern: "*lerts", value: "alerts", want: true}, + {name: "leading segment", pattern: "*level", value: "orderCreated, levelUp.level", want: true}, + {name: "mid star", pattern: "alert*up", value: "alerts-up", want: true}, + {name: "trailing star", pattern: "/alerts/*", value: "/alerts/x", want: true}, + {name: "prefix literal", pattern: "orderCr*", value: "orderCreated", want: true}, + {name: "bare star", pattern: "*", value: "anything", want: true}, + {name: "exact", pattern: "orderCreated", value: "orderCreated", want: true}, + {name: "miss", pattern: "orderOther", value: "orderCreated", want: false}, + {name: "multi segment order", pattern: "*a*b", value: "xa-b", want: true}, + {name: "out of order segments", pattern: "*b*a", value: "a-b", want: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, globMatch(tc.pattern, tc.value)) + }) + } +} diff --git a/internal/server/manage_ws.go b/internal/server/manage_ws.go new file mode 100644 index 0000000..d44245b --- /dev/null +++ b/internal/server/manage_ws.go @@ -0,0 +1,318 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// manageEnvelope is a single notification pushed to _mock/stream subscribers +// (RS.AMG.24-27). Type is one of event|push|consumer|schedule. +type manageEnvelope struct { + Type string `json:"type"` + TS int64 `json:"ts"` + // per-type payloads (omitempty; present only for the matching type) + Event *manageEventEnvelope `json:"event,omitempty"` + Push *managePushEnvelope `json:"push,omitempty"` + Consumer *manageConsumerEnvelope `json:"consumer,omitempty"` + Schedule *manageScheduleEnvelope `json:"schedule,omitempty"` +} + +type manageEventEnvelope struct { + Name string `json:"name"` + Schema string `json:"schema,omitempty"` + Global bool `json:"global,omitempty"` + Payload map[string]any `json:"payload"` +} + +type managePushEnvelope struct { + Channel string `json:"channel"` + ConnectionID string `json:"connectionId,omitempty"` + Payload map[string]any `json:"payload"` +} + +type manageConsumerEnvelope struct { + Action string `json:"action"` + ConnectionID string `json:"connectionId"` + Channel string `json:"channel"` + Streams []map[string]string `json:"streams,omitempty"` +} + +type manageScheduleEnvelope struct { + Action string `json:"action"` + ExampleID string `json:"exampleId"` + Channel string `json:"channel"` + Interval int `json:"interval"` +} + +// streamFilter holds a subscriber's connect-time filters (RS.AMG.23). +type streamFilter struct { + events []string + channels []string +} + +// manageStreamSub is a single connected _mock/stream subscriber. +type manageStreamSub struct { + writer *wsWriter + filter streamFilter +} + +// manageStream is the management WebSocket stream (GET /_mock/stream). The +// registry is deliberately separate from the channel connectionRegistry so +// management sockets never appear under /_mock/async/consumers (D7). +type manageStream struct { + mu sync.RWMutex + subs map[*wsWriter]*manageStreamSub + verbose bool +} + +// newManageStream creates the management stream registry. +func newManageStream(verbose bool) *manageStream { + return &manageStream{ + subs: make(map[*wsWriter]*manageStreamSub), + verbose: verbose, + } +} + +// add registers a subscriber connection with its filters. +func (ms *manageStream) add(w *wsWriter, filter streamFilter) { + ms.mu.Lock() + defer ms.mu.Unlock() + ms.subs[w] = &manageStreamSub{writer: w, filter: filter} +} + +// remove unregisters a subscriber connection. +func (ms *manageStream) remove(w *wsWriter) { + ms.mu.Lock() + defer ms.mu.Unlock() + delete(ms.subs, w) +} + +// broadcast encodes and sends an envelope to matching subscribers. +func (ms *manageStream) broadcast(env manageEnvelope) { + ms.mu.RLock() + subs := make([]*manageStreamSub, 0, len(ms.subs)) + for _, sub := range ms.subs { + subs = append(subs, sub) + } + ms.mu.RUnlock() + + env.TS = time.Now().UnixMilli() + payload, err := json.Marshal(env) + if err != nil { + return + } + for _, sub := range subs { + if !filterMatches(sub.filter, env) { + continue + } + sub.writer.write(payload) + } +} + +// filterMatches applies a subscriber's event/channel filters to an envelope. +// Filtering is done per type; the envelope passes when it matches all active +// filters (an omitted filter matches everything, RS.AMG.23). +func filterMatches(f streamFilter, env manageEnvelope) bool { + if len(f.events) > 0 && env.Type == "event" && env.Event != nil && !globMatchAny(f.events, env.Event.Name) { + 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. + } + if channel != "" && !globMatchAny(f.channels, channel) { + return false + } + } + return true +} + +// globMatchAny reports whether a value matches any comma-separated glob in the +// list ("*" matches everything). +func globMatchAny(patterns []string, value string) bool { + for _, p := range patterns { + if globMatch(p, value) { + return true + } + } + return false +} + +// globMatch implements single-* glob matching (RS.AMG.23): each literal +// 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. +func globMatch(pattern, value string) bool { + if pattern == "*" { + return true + } + if !strings.Contains(pattern, "*") { + return pattern == value + } + segments := splitGlob(pattern) + + pos := 0 + for i, seg := range segments { + if i == 0 && !strings.HasPrefix(pattern, "*") { + if !strings.HasPrefix(value, seg) { + return false + } + pos = len(seg) + continue + } + remaining := value[pos:] + if i == len(segments)-1 && !strings.HasSuffix(pattern, "*") { + return len(remaining) >= len(seg) && remaining[len(remaining)-len(seg):] == seg + } + idx := strings.Index(remaining, seg) + if idx < 0 { + return false + } + pos += idx + len(seg) + } + return true +} + +// splitGlob splits a glob pattern on '*' dropping the empty segments. +func splitGlob(pattern string) []string { + var parts []string + for _, seg := range strings.Split(pattern, "*") { + if seg != "" { + parts = append(parts, seg) + } + } + return parts +} + +// parseStreamFilters parses the connect-time events/channels query parameters +// (comma-separated globs). +func parseStreamFilters(r *http.Request) streamFilter { + return streamFilter{ + events: splitComma(r.URL.Query().Get("events")), + channels: splitComma(r.URL.Query().Get("channels")), + } +} + +func splitComma(s string) []string { + if s == "" { + return nil + } + var out []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == ',' { + if part := s[start:i]; part != "" { + out = append(out, part) + } + start = i + 1 + } + } + return out +} + +// pingWriter is the subset of wsWriter the keepalive loop needs, so the loop is +// testable without a live socket. +type pingWriter interface { + writeMessage(messageType int, data []byte) +} + +// manageStreamPingInterval is the management stream keepalive cadence. +const manageStreamPingInterval = 30 * time.Second + +// servePings writes WebSocket pings on a management stream connection until the +// stop signal fires. It returns when stop closes, so the goroutine never leaks +// past the connection's lifetime (the handler closes stop in its defer). +func servePings(wr pingWriter, interval time.Duration, stop <-chan struct{}) { + if interval <= 0 { + interval = manageStreamPingInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + wr.writeMessage(websocket.PingMessage, nil) + } + } +} + +// handleManageStream upgrades GET /_mock/stream or rejects a non-upgrade +// request with 405 (RS.AMG.28). A connected subscriber's filters are parsed at +// connect time; V1 is notifications-only (pings/pongs keep the socket alive). +func (s *Server) handleManageStream(w http.ResponseWriter, r *http.Request) { + if s.manageStream == nil { + writeJSONError(w, http.StatusInternalServerError, "management stream not initialized") + return + } + // Non-WebSocket requests must be rejected (RS.AMG.28). + if !strings.EqualFold(r.Header.Get("Connection"), "Upgrade") || + !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + writeJSONError(w, http.StatusMethodNotAllowed, "management stream requires a WebSocket upgrade") + return + } + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + wr := newWSWriter(conn) + s.manageStream.add(wr, parseStreamFilters(r)) + defer s.manageStream.remove(wr) + + // Ping/pong keepalive; V1 has no client→server commands. The pinger is + // bound to this connection's lifetime so it cannot outlive the socket. + stopPings := make(chan struct{}) + defer close(stopPings) + go servePings(wr, manageStreamPingInterval, stopPings) + for { + messageType, payload, rerr := conn.ReadMessage() + if rerr != nil { + return + } + if messageType == websocket.PingMessage { + wr.writeMessage(websocket.PongMessage, payload) + continue + } + if messageType == websocket.PongMessage { + continue + } + // Notifications-only: client frames are ignored. + } +} + +// notifyConsumer pushes a consumer lifecycle envelope (RS.AMG.26). +func (ms *manageStream) notifyConsumer(action, channel string, info ConsumerInfo) { + if ms == nil { + return + } + ms.broadcast(manageEnvelope{ + Type: "consumer", + Consumer: &manageConsumerEnvelope{ + Action: action, + ConnectionID: info.ConnectionID, + Channel: channel, + Streams: info.Streams, + }, + }) +} diff --git a/internal/server/management_async.go b/internal/server/management_async.go index b62a56e..58bc7aa 100644 --- a/internal/server/management_async.go +++ b/internal/server/management_async.go @@ -4,10 +4,8 @@ import ( "encoding/json" "io" "net/http" - "strconv" "time" - "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" "github.com/mamonth/oasmock/internal/runtime" ) @@ -158,7 +156,8 @@ func matchingHubChannel(hub *signalRHub, address string) string { return "" } -// handleAsyncConsumers lists active consumers per channel (RS.AMG.8-9). +// handleAsyncConsumers lists active consumers per channel (RS.AMG.8-9) or +// across all channels when the channel filter is omitted (RS.AMG.22). func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { channel := r.URL.Query().Get("channel") type consumerInfo struct { @@ -169,12 +168,31 @@ func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { consumers := []consumerInfo{} if reg := s.wsRegistry(); reg != nil { - conns := reg.connections(channel) + var conns []*wsConnection + if channel == "" { + conns = reg.allConnections() + } else { + conns = reg.connections(channel) + } for _, ws := range conns { - consumers = append(consumers, consumerInfo{ConnectionID: ws.id, Channel: channel}) + consumers = append(consumers, consumerInfo{ConnectionID: ws.id, Channel: ws.channel}) } } - if hub := s.hubForAddress(channel); hub != nil { + if channel == "" { + // Flat union across every hub channel's open streams (RS.AMG.22). + for _, hub := range s.hubMgr.hubs { + for channelID := range hub.channels { + address := asyncAddressWithPrefix(hub.prefix, hub.channels[channelID].Address) + for _, st := range hub.openStreamsForChannel(channelID) { + consumers = append(consumers, consumerInfo{ + ConnectionID: st["connectionId"], + Channel: address, + Streams: []map[string]string{st}, + }) + } + } + } + } else if hub := s.hubForAddress(channel); hub != nil { if id := matchingHubChannel(hub, channel); id != "" { for _, st := range hub.openStreamsForChannel(id) { consumers = append(consumers, consumerInfo{ @@ -196,63 +214,12 @@ func (h *signalRHub) pushPayload(channelID string, payload []byte) { h.pushToStreams(channelID, payload, channelID) } -// handleAsyncSchedule schedules a recurring push (RS.AMG.12). -func (s *Server) handleAsyncSchedule(w http.ResponseWriter, r *http.Request) { - var req struct { - Channel string `json:"channel"` - Interval int `json:"interval"` - Payload map[string]any `json:"payload"` - } - body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) - if err != nil { - writeJSONError(w, http.StatusBadRequest, err.Error()) - return - } - if err := json.Unmarshal(body, &req); err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid JSON body") - return - } - if req.Channel == "" || req.Interval <= 0 { - writeJSONError(w, http.StatusBadRequest, "channel and a positive interval are required") - return - } - payload, err := json.Marshal(req.Payload) - if err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid payload") - return - } - - id := "push-" + strconv.FormatInt(time.Now().UnixNano(), 10) - s.scheduler.add(&recurringPush{ - id: id, - channel: req.Channel, - interval: time.Duration(req.Interval) * time.Millisecond, - payload: payload, - stop: make(chan struct{}), - }) - - go s.scheduler.run(id) - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "pushId": id}) -} - -// shutdownSchedules stops all recurring push jobs (RS.AMG.12). -func (s *Server) shutdownSchedules() { - s.scheduler.shutdown() -} - -// handleAsyncScheduleStop cancels a recurring push (RS.AMG.13). -func (s *Server) handleAsyncScheduleStop(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "pushId") - job, ok := s.scheduler.stop(id) - if !ok { - writeJSONError(w, http.StatusNotFound, "unknown pushId") - return - } - close(job.stop) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) +// handleGoneSchedule answers the removed /_mock/ws/schedule surface with HTTP +// 410 Gone pointing at POST /_mock/examples (design D1). The recurring-delivery +// capability now lives on unified example injection with a runtime interval. +func (s *Server) handleGoneSchedule(w http.ResponseWriter, r *http.Request) { + writeJSONError(w, http.StatusGone, + "the async schedule endpoint is removed; use POST /_mock/examples with an AsyncAPI target, response.body and interval (and DELETE /_mock/examples/{exampleId} to stop)") } // handleAsyncDisconnect force-disconnects a consumer (RS.AMG.14-17). diff --git a/internal/server/management_async_aliases_test.go b/internal/server/management_async_aliases_test.go new file mode 100644 index 0000000..cb9a94c --- /dev/null +++ b/internal/server/management_async_aliases_test.go @@ -0,0 +1,252 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Deprecated ws alias for push still works +Given a server with the canonical async management routes +When a management push is sent to the deprecated /_mock/ws/push path +Then the request is accepted (200) and delivered to consumers + +Related spec scenarios: RS.AMG.1, RS.AMG.6 +*/ +func TestDeprecatedAlias_Push(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + _, _, _ = conn.ReadMessage() // consume snapshot + + body := `{"channel":"/alerts","payload":{"msg":"alias"}}` + resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"alias"`) +} + +/* +Scenario: Deprecated ws alias for consumers still works +Given a server with the canonical async management routes and a connected consumer +When consumers are listed via the deprecated /_mock/ws/consumers path +Then the consumer list includes the connection id + +Related spec scenarios: RS.AMG.8 +*/ +func TestDeprecatedAlias_Consumers(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var payload map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + items, ok := payload["consumers"].([]any) + require.True(t, ok) + assert.NotEmpty(t, items) +} + +/* +Scenario: Deprecated ws alias for disconnect still works +Given a server with the canonical async management routes and an active consumer +When the consumer is disconnected via the deprecated /_mock/ws/disconnect path +Then the connection is closed and the request is accepted + +Related spec scenarios: RS.AMG.14 +*/ +func TestDeprecatedAlias_Disconnect(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // consume snapshot + + resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + _ = resp.Body.Close() + items, ok := payload["consumers"].([]any) + require.True(t, ok) + require.NotEmpty(t, items) + first, ok := items[0].(map[string]any) + require.True(t, ok) + connID, ok := first["connectionId"].(string) + require.True(t, ok) + + disc := `{"connectionId":"` + connID + `","reason":"alias"}` + discResp, err := http.Post(ts.URL+"/_mock/ws/disconnect", "application/json", strings.NewReader(disc)) + require.NoError(t, err) + defer discResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, discResp.StatusCode) + + // The disconnect must actually close the socket: a read on the consumer + // should observe the close (EOF) rather than keep waiting for frames. + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, err = conn.ReadMessage() + require.Error(t, err, "expected the connection to close after the alias disconnect") +} + +/* +Scenario: Deprecated events/fire alias still works +Given a server with the canonical events endpoint and a fired-event subscription +When an event is fired via the deprecated /_mock/events/fire path +Then the templated message reaches a connected consumer + +Related spec scenarios: RS.MAPI.22, RS.AMG.20 +*/ +func TestDeprecatedAlias_EventsFire(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + body := `{"type":"fire","event":"levelUp","payload":{"level":"warn","message":"high load"}}` + resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"high load"`) +} + +/* +Scenario: The deprecated events/fire alias accepts a legacy type-less body +Given a server with the canonical events endpoint and a fired-event subscription +When an event is fired via the deprecated /_mock/events/fire path without a +'type' field +Then the legacy body is still honored and the message reaches a consumer + +Related spec scenarios: RS.MAPI.22, RS.AMG.20 +*/ +func TestDeprecatedAlias_EventsFireLegacyBody(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(fireEventWsDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + body := `{"event":"levelUp","payload":{"level":"warn","message":"legacy high load"}}` + resp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"legacy high load"`) +} + +/* +Scenario: Removed schedule push answers 410 Gone +Given a server with the canonical examples endpoint +When a recurring push is scheduled via the removed /_mock/ws/schedule path +Then the server responds with HTTP 410 Gone pointing at POST /_mock/examples + +Related spec scenarios: RS.AMG.12 +*/ +func TestRemovedScheduleRet_410(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + body := `{"channel":"/alerts","interval":50,"payload":{"tick":true}}` + resp, err := http.Post(ts.URL+"/_mock/ws/schedule", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusGone, resp.StatusCode) + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, strings.ToLower(string(bodyBytes)), "/_mock/examples") +} + +/* +Scenario: Removed schedule stop answers 410 Gone +Given a server with the canonical examples endpoint +When a schedule is stopped via the removed /_mock/ws/schedule/{pushId} path +Then the server responds with HTTP 410 Gone pointing at POST /_mock/examples + +Related spec scenarios: RS.AMG.13 +*/ +func TestRemovedScheduleStop_410(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/ws/schedule/push-123", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusGone, resp.StatusCode) +} diff --git a/internal/server/management_async_lifecycle_test.go b/internal/server/management_async_lifecycle_test.go index a738942..01dc01f 100644 --- a/internal/server/management_async_lifecycle_test.go +++ b/internal/server/management_async_lifecycle_test.go @@ -15,12 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -// newAsyncMgmtServer builds a server with control API enabled and one ws channel. -func newAsyncMgmtServer(t *testing.T) *Server { - t.Helper() - return newPushServer(t) -} - /* Scenario: Templated push payloads evaluate against schema state Given a push request whose payload uses the schema's state namespace @@ -42,7 +36,7 @@ func TestPushEndpoint_TemplatedPayload(t *testing.T) { _, _, _ = conn.ReadMessage() // consume snapshot body := `{"channel":"/alerts","payload":{"msg":"{$env.OASMOCK_TEST_VAL}"}}` - resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -68,21 +62,21 @@ func TestPushEndpoint_UnresolvableExpression(t *testing.T) { ts := httptest.NewServer(srv.router) defer ts.Close() //nolint:errcheck body := `{"channel":"/alerts","payload":{"msg":"{$event.nonexistent}"}}` - resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } /* -Scenario: Scheduling a recurring push delivers at the interval -Given a schedule request with an interval -When the schedule is created and the server runs -Then the message is delivered repeatedly until stopped +Scenario: Removed schedule endpoint answers 410 Gone +Given a management schedule request against the removed /_mock/ws/schedule path +When the schedule endpoint is invoked +Then the server responds 410 Gone pointing at POST /_mock/examples -Related spec scenarios: RS.AMG.12, RS.AMG.13 +Related spec scenarios: RS.AMG.12 */ -func TestSchedulePush_Recurring(t *testing.T) { +func TestSchedulePush_Removed(t *testing.T) { t.Parallel() srv := newAsyncMgmtServer(t) @@ -98,26 +92,7 @@ func TestSchedulePush_Recurring(t *testing.T) { resp, err := http.Post(ts.URL+"/_mock/ws/schedule", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck - require.Equal(t, http.StatusOK, resp.StatusCode) - - var scheduleResp map[string]any - require.NoError(t, json.NewDecoder(resp.Body).Decode(&scheduleResp)) - pushID, ok := scheduleResp["pushId"].(string) - require.True(t, ok) - - // First recurring delivery. - _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - _, msg, err := conn.ReadMessage() - require.NoError(t, err) - assert.Contains(t, string(msg), `"tick":true`) - - // Stop the schedule. - req, err := http.NewRequest(http.MethodDelete, ts.URL+"/_mock/ws/schedule/"+pushID, nil) - require.NoError(t, err) - stopResp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer stopResp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusOK, stopResp.StatusCode) + assert.Equal(t, http.StatusGone, resp.StatusCode) } /* @@ -138,9 +113,11 @@ func TestDisconnectEndpoint(t *testing.T) { conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) require.NoError(t, err) defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, _ = conn.ReadMessage() // consume snapshot // Identify the connection id via consumers. - resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") + resp, err := http.Get(ts.URL + "/_mock/async/consumers?channel=/alerts") require.NoError(t, err) var payload map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) @@ -155,13 +132,19 @@ func TestDisconnectEndpoint(t *testing.T) { // Disconnect it. disc := `{"connectionId":"` + connID + `","reason":"busy","code":4001}` - discResp, err := http.Post(ts.URL+"/_mock/ws/disconnect", "application/json", strings.NewReader(disc)) + discResp, err := http.Post(ts.URL+"/_mock/async/disconnect", "application/json", strings.NewReader(disc)) require.NoError(t, err) defer discResp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, discResp.StatusCode) + // The disconnect must actually close the socket: a read on the consumer + // observes the close handshake (RS.AMG.15). + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, readErr := conn.ReadMessage() + require.Error(t, readErr, "expected the connection to close after the disconnect") + // Unknown consumer 404. - unknownResp, err := http.Post(ts.URL+"/_mock/ws/disconnect", "application/json", strings.NewReader(`{"connectionId":"nope"}`)) + unknownResp, err := http.Post(ts.URL+"/_mock/async/disconnect", "application/json", strings.NewReader(`{"connectionId":"nope"}`)) require.NoError(t, err) defer unknownResp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, unknownResp.StatusCode) @@ -188,14 +171,14 @@ func TestAsyncManagement_LiveConnections(t *testing.T) { _, _, _ = conn.ReadMessage() // snapshot // Fire an event (accepted, possibly no matching subscriber on /alerts). - evResp, err := http.Post(ts.URL+"/_mock/events/fire", "application/json", - strings.NewReader(`{"event":"any","payload":{"x":1}}`)) + evResp, err := http.Post(ts.URL+"/_mock/events", "application/json", + strings.NewReader(`{"type":"fire","event":"any","payload":{"x":1}}`)) require.NoError(t, err) _ = evResp.Body.Close() assert.Equal(t, http.StatusOK, evResp.StatusCode) // Push a message to the connected consumer. - pushResp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", + pushResp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(`{"channel":"/alerts","payload":{"seq":1}}`)) require.NoError(t, err) _ = pushResp.Body.Close() @@ -270,7 +253,7 @@ func TestPushEndpoint_TargetedWS(t *testing.T) { // The registry hands out sequential ids (conn-1, conn-2) in dial order. body := `{"channel":"/alerts","connectionId":"conn-1","payload":{"targeted":true}}` - post, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + post, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer post.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, post.StatusCode) @@ -332,7 +315,7 @@ func TestPushEndpoint_TargetedSignalR(t *testing.T) { require.NotEmpty(t, streamConnID) body := `{"channel":"/priceFeed","connectionId":"` + streamConnID + `","payload":{"seq":1}}` - post, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + post, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer post.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, post.StatusCode) @@ -373,7 +356,7 @@ func TestDisconnectEndpoint_Abrupt(t *testing.T) { _, _, _ = conn.ReadMessage() // consume snapshot // Identify the connection id via consumers. - resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") + resp, err := http.Get(ts.URL + "/_mock/async/consumers?channel=/alerts") require.NoError(t, err) var payload map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) @@ -387,7 +370,7 @@ func TestDisconnectEndpoint_Abrupt(t *testing.T) { require.True(t, ok) disc := `{"connectionId":"` + connID + `","abrupt":true}` - discResp, err := http.Post(ts.URL+"/_mock/ws/disconnect", "application/json", strings.NewReader(disc)) + discResp, err := http.Post(ts.URL+"/_mock/async/disconnect", "application/json", strings.NewReader(disc)) require.NoError(t, err) defer discResp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, discResp.StatusCode) diff --git a/internal/server/management_async_test.go b/internal/server/management_async_test.go index 0a06aab..1098671 100644 --- a/internal/server/management_async_test.go +++ b/internal/server/management_async_test.go @@ -9,51 +9,10 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/mamonth/oasmock/internal/asyncapi" - "github.com/mamonth/oasmock/internal/loader" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -const pushChannelDoc = `asyncapi: 3.0.0 -info: - title: Alerts - version: 1.0.0 -channels: - alerts: - address: /alerts - bindings: - ws: - method: GET - messages: - alertMsg: - examples: - - name: ex1 - payload: - level: info - msg: default -operations: - receiveAlerts: - action: receive - channel: - $ref: '#/channels/alerts' -` - -func newPushServer(t *testing.T) *Server { - t.Helper() - schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: parsePushDoc(t), Prefix: ""}} - srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) - require.NoError(t, err) - return srv -} - -func parsePushDoc(t *testing.T) *asyncapi.Document { - t.Helper() - doc, err := asyncapi.Parse([]byte(pushChannelDoc)) - require.NoError(t, err) - return doc -} - /* Scenario: Pushing a message to channel consumers immediately Given a management push request without a delay and a connected consumer @@ -80,7 +39,7 @@ func TestPushEndpoint_Immediate(t *testing.T) { require.NoError(t, err) body := `{"channel":"/alerts","payload":{"msg":"hello"}}` - resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -107,7 +66,7 @@ func TestPushEndpoint_NegativeDelay(t *testing.T) { defer ts.Close() body := `{"channel":"/alerts","payload":{},"delay":-10}` - resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -129,7 +88,7 @@ func TestPushEndpoint_NoConsumers(t *testing.T) { defer ts.Close() body := `{"channel":"/alerts","payload":{"msg":"none"}}` - resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -151,7 +110,7 @@ func TestPushEndpoint_UnknownConsumer(t *testing.T) { defer ts.Close() body := `{"channel":"/alerts","payload":{},"connectionId":"missing"}` - resp, err := http.Post(ts.URL+"/_mock/ws/push", "application/json", strings.NewReader(body)) + resp, err := http.Post(ts.URL+"/_mock/async/push", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp.StatusCode) @@ -177,7 +136,7 @@ func TestConsumersEndpoint(t *testing.T) { require.NoError(t, err) defer conn.Close() //nolint:errcheck - resp, err := http.Get(ts.URL + "/_mock/ws/consumers?channel=/alerts") + resp, err := http.Get(ts.URL + "/_mock/async/consumers?channel=/alerts") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/internal/server/registry.go b/internal/server/registry.go index cf02444..9e2820f 100644 --- a/internal/server/registry.go +++ b/internal/server/registry.go @@ -78,6 +78,33 @@ func (r *exampleRegistry) addDynamic(key string, ex dynamicExample) { r.dynamicExamples[key] = append(r.dynamicExamples[key], ex) } +// removeDynamic removes every dynamic example with the given id (onceID) from +// all route keys and its once marker, reporting whether any was removed. +func (r *exampleRegistry) removeDynamic(id string) bool { + r.dyMu.Lock() + defer r.dyMu.Unlock() + removed := false + for key, examples := range r.dynamicExamples { + kept := examples[:0] + for _, ex := range examples { + if ex.onceID == id { + removed = true + r.onceMu.Lock() + delete(r.onceExamples, id) + r.onceMu.Unlock() + continue + } + kept = append(kept, ex) + } + if len(kept) == 0 { + delete(r.dynamicExamples, key) + } else { + r.dynamicExamples[key] = kept + } + } + return removed +} + // selectDynamic returns the first dynamic example matching a route key that is // not once-used, not expired and whose conditions evaluate, along with its // index key. It returns nil when none matches. diff --git a/internal/server/scheduler.go b/internal/server/scheduler.go deleted file mode 100644 index 9fd0304..0000000 --- a/internal/server/scheduler.go +++ /dev/null @@ -1,78 +0,0 @@ -package server - -import ( - "sync" - "time" -) - -// recurringPush is a scheduled recurring push (RS.AMG.12-13). -type recurringPush struct { - id string - channel string - interval time.Duration - payload []byte - stop chan struct{} -} - -// pushScheduler runs recurring push jobs (RS.AMG.12-13). It is a pure -// fabrication decoupled from the HTTP surface; delivery is injected as a -// callback so the scheduler never reaches into Server. -type pushScheduler struct { - mu sync.Mutex - jobs map[string]*recurringPush - push func(channel string, payload []byte) -} - -func newPushScheduler(push func(channel string, payload []byte)) *pushScheduler { - return &pushScheduler{jobs: make(map[string]*recurringPush), push: push} -} - -// add registers a job and returns it; run must be started in a goroutine. -func (s *pushScheduler) add(job *recurringPush) { - s.mu.Lock() - defer s.mu.Unlock() - s.jobs[job.id] = job -} - -// run delivers a scheduled push at its interval until stopped. -func (s *pushScheduler) run(id string) { - s.mu.Lock() - job := s.jobs[id] - s.mu.Unlock() - if job == nil { - return - } - ticker := time.NewTicker(job.interval) - defer ticker.Stop() - for { - select { - case <-job.stop: - return - case <-ticker.C: - s.push(job.channel, job.payload) - } - } -} - -// stop unregisters a job and reports it. The caller closes its stop channel. -func (s *pushScheduler) stop(id string) (*recurringPush, bool) { - s.mu.Lock() - defer s.mu.Unlock() - job := s.jobs[id] - delete(s.jobs, id) - return job, job != nil -} - -// shutdown stops all recurring push jobs. Each job's stop channel is closed -// exactly once by deleting it from the map first (RS.AMG.12, RS.MSC.49). -func (s *pushScheduler) 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/send_events.go b/internal/server/send_events.go index 78eb0d1..37f8b38 100644 --- a/internal/server/send_events.go +++ b/internal/server/send_events.go @@ -2,6 +2,8 @@ package server import ( "fmt" + + "github.com/mamonth/oasmock/internal/extensions" ) // xSendEventsKey is the extension key carrying event subscriptions on an @@ -39,7 +41,7 @@ func parseSendEvents(ext map[string]any) ([]SendEvent, error) { return nil, fmt.Errorf("x-send-events entry must have an 'on' field") } ev := SendEvent{On: on} - if wait, ok := asInt(v["wait"]); ok { + if wait, ok := extensions.AsMilliseconds(v["wait"]); ok { ev.Wait = wait } out = append(out, ev) @@ -49,16 +51,3 @@ func parseSendEvents(ext map[string]any) ([]SendEvent, error) { } return out, nil } - -// asInt converts a JSON number to an int. -func asInt(v any) (int, bool) { - switch n := v.(type) { - case float64: - return int(n), true - case int: - return n, true - case int64: - return int(n), true - } - return 0, false -} diff --git a/internal/server/server.go b/internal/server/server.go index 6d584bc..01d5730 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -118,7 +118,8 @@ type Server struct { routerSetupErr error hubMgr *hubManager eventBus *eventBus - scheduler *pushScheduler + manageStream *manageStream + runtimeExamples *runtimeExampleRegistry } // New creates a new mock server with the given configuration and loaded schemas. @@ -205,9 +206,18 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, protocolAdapters: defaultProtocolAdapters(), } s.hubMgr = newHubManager(s.engine, s.protocolAdapters[asyncWSProtocol].(*wsProtocolAdapter), schemas) + s.manageStream = newManageStream(config.Verbose) + s.runtimeExamples = newRuntimeExampleRegistry() s.eventBus = newEventBus(s.engine, s.hubMgr, config.Verbose) - s.eventBus.registerEventSubscriptions(schemas) - s.scheduler = newPushScheduler(func(channel string, payload []byte) { s.pushToChannel(channel, "", payload) }) + s.eventBus.setObserver(func(env manageEnvelope) { + if s.manageStream != nil { + s.manageStream.broadcast(env) + } + }) + if err := s.eventBus.registerEventSubscriptions(schemas); err != nil { + return nil, err + } + s.wireBuiltInHooks() if rpcConfig != nil { proto, err := newRpcProtocol(rpcConfig) @@ -360,13 +370,27 @@ func (s *Server) buildRouteHandler(mapping *RouteMapping) (http.HandlerFunc, err func (s *Server) registerManagementRoutes(r chi.Router) { r.Post("/_mock/examples", s.handleAddExample) + r.Delete("/_mock/examples/{exampleId}", s.handleDeleteExample) r.Get("/_mock/requests", s.handleGetRequests) - r.Post("/_mock/events/fire", s.handleFireEvent) + + // Canonical protocol-neutral async surface (design D1). + r.Post("/_mock/events", s.handleEvents) + r.Post("/_mock/async/push", s.handleAsyncPush) + r.Get("/_mock/async/consumers", s.handleAsyncConsumers) + r.Post("/_mock/async/disconnect", s.handleAsyncDisconnect) + r.Get("/_mock/stream", s.handleManageStream) + + // Deprecated aliases kept for one release (design D1). The events/fire + // alias serves the legacy type-less contract (handleFireEventLegacy); the + // ws aliases share the canonical handlers. + r.Post("/_mock/events/fire", s.handleFireEventLegacy) r.Post("/_mock/ws/push", s.handleAsyncPush) r.Get("/_mock/ws/consumers", s.handleAsyncConsumers) - r.Post("/_mock/ws/schedule", s.handleAsyncSchedule) - r.Delete("/_mock/ws/schedule/{pushId}", s.handleAsyncScheduleStop) r.Post("/_mock/ws/disconnect", s.handleAsyncDisconnect) + + // Removed schedule surface answers 410 Gone pointing at /_mock/examples. + r.Post("/_mock/ws/schedule", s.handleGoneSchedule) + r.Delete("/_mock/ws/schedule/{pushId}", s.handleGoneSchedule) } func (s *Server) newRequestSource(r *http.Request, pathParams map[string]string) *runtime.RequestSource { @@ -720,7 +744,9 @@ func (s *Server) BoundPort() int { // calls are no-ops that return the result of the first shutdown. func (s *Server) Shutdown(ctx context.Context) error { s.registry.stopSweep() - s.shutdownSchedules() + if s.eventBus != nil { + s.eventBus.shutdown() + } s.httpMu.Lock() hs := s.httpServer s.httpMu.Unlock() diff --git a/internal/server/server_management.go b/internal/server/server_management.go index 1e15deb..b7d985c 100644 --- a/internal/server/server_management.go +++ b/internal/server/server_management.go @@ -12,9 +12,30 @@ import ( "strings" "time" + "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/loader" "github.com/xeipuuv/gojsonschema" ) +// matchesEventContext reports whether a match references the event context +// ({$event.*}), which makes it an event-driven runtime trigger. +func matchesEventContext(match map[string]any) bool { + return extensions.MatchReferencesEvent(match) +} + +// triggerKindString maps an extensions.TriggerKind to the wire value used in +// the POST /_mock/examples response "kind" field (OpenAPI enum: event|interval). +func triggerKindString(kind extensions.TriggerKind) string { + switch kind { + case extensions.TriggerEvent: + return "event" + case extensions.TriggerPeriodic: + return "interval" + default: + return "" + } +} + // findAsyncRouteMapping resolves an AsyncAPI route mapping by protocol and // channel address (RS.MAPI.19, RS.MAPI.21). func (s *Server) findAsyncRouteMapping(protocol, channel, method string) *RouteMapping { @@ -37,9 +58,35 @@ 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). 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. 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{ @@ -48,9 +95,16 @@ var addExampleRequestSchema = gojsonschema.NewGoLoader(map[string]any{ }, "channel": map[string]any{"type": "string"}, "method": map[string]any{ - "type": "string", - "enum": []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, + "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}, @@ -202,6 +256,14 @@ func (s *Server) handleGetRequests(w http.ResponseWriter, r *http.Request) { } } +// newExampleID returns a time-unique example id in the given namespace. The +// namespace prefix keeps runtime-async ids ("rtex-") disjoint from sync +// dynamic-example ids ("dynex-"), so DELETE /_mock/examples/{id} never has to +// disambiguate a collision between the two registries. +func newExampleID(namespace string) string { + return fmt.Sprintf("%s-%d", namespace, time.Now().UnixNano()) +} + func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { // Read the raw body for validation bodyBytes, err := io.ReadAll(r.Body) @@ -209,12 +271,12 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { if s.config.Verbose { slog.Debug("Failed to read request body", "err", err) } - http.Error(w, `{"error":"Failed to read request body"}`, http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "failed to read request body") return } // Validate against OpenAPI schema if err := validateAddExampleRequest(bodyBytes); err != nil { - http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, err.Error()) return } // Decode into struct @@ -223,6 +285,9 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { 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"` Validate bool `json:"validate"` TTL int `json:"ttl"` @@ -238,17 +303,24 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { return } if req.Response.Code == 0 || (req.Path == "" && req.Channel == "") { - http.Error(w, `{"error":"Missing required fields"}`, http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "missing required fields") return } req.Method = cmp.Or(req.Method, DefaultMethod) + // 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 { + writeJSONError(w, http.StatusBadRequest, "'interval' and an event-based 'match' are mutually exclusive") + return + } + // Resolve the target route: OpenAPI path/method or AsyncAPI channel. var targetMapping *RouteMapping if req.Protocol != "" || req.Channel != "" { targetMapping = s.findAsyncRouteMapping(req.Protocol, req.Channel, req.Method) if targetMapping == nil { - http.Error(w, `{"error":"No matching route found"}`, http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "no matching route found") return } } else { @@ -260,14 +332,62 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { } } if targetMapping == nil { - http.Error(w, `{"error":"No matching route found"}`, http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "no matching route found") return } } // TODO: validate response body against OpenAPI schema if req.Validate is true // (skipped for now) + + // Runtime async-driven examples (match/interval) register through the + // event broker / scheduler (RS.MAPI.24-26, RS.MAPI.33). 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. + if targetMapping.Protocol != "" && req.Match != nil && !matchesEventContext(req.Match) { + writeJSONError(w, http.StatusBadRequest, "async target 'match' must reference the event context ({$event.*}); use 'interval' for periodic emission") + return + } + if targetMapping.Protocol != "" && (req.Match != nil || req.Interval > 0) { + id := newExampleID("rtex") + headers := make(map[string]any, len(req.Response.Headers)) + for k, v := range req.Response.Headers { + headers[k] = v + } + ext := make(map[string]any) + if req.Match != nil { + ext["x-mock-match"] = req.Match + } + if req.Interval > 0 { + ext["x-mock-interval"] = req.Interval + } + if req.Delay > 0 { + ext["x-mock-delay"] = req.Delay + } + example := &loader.MessageExampleSpec{ + Name: "runtime-" + id, + Headers: headers, + Payload: req.Response.Body, + Extensions: ext, + } + kind, jobID, err := s.registerRuntimeExample(id, targetMapping, example) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "message": "Example added", + "id": id, + "kind": triggerKindString(kind), + "jobID": jobID, + }) + return + } + // Create dynamic example - id := fmt.Sprintf("dynex-%d", time.Now().UnixNano()) + id := newExampleID("dynex") example := dynamicExample{ onceID: id, once: req.Once, diff --git a/internal/server/server_runtime_example.go b/internal/server/server_runtime_example.go new file mode 100644 index 0000000..063c11a --- /dev/null +++ b/internal/server/server_runtime_example.go @@ -0,0 +1,102 @@ +package server + +import ( + "encoding/json" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/loader" +) + +// runtimeExampleInfo tracks a dynamically added async example so DELETE can +// unregister the broker subscription or cancel the interval job. +type runtimeExampleInfo struct { + id string + address string + prefix string + trigger extensions.TriggerKind + jobID string // scheduler job id (interval triggers) +} + +// runtimeExampleRegistry owns the live async-driven examples added via +// POST /_mock/examples (RS.MAPI.24-26, RS.MAPI.30). +type runtimeExampleRegistry struct { + mu sync.RWMutex + byID map[string]runtimeExampleInfo +} + +func newRuntimeExampleRegistry() *runtimeExampleRegistry { + return &runtimeExampleRegistry{byID: make(map[string]runtimeExampleInfo)} +} + +func (r *runtimeExampleRegistry) add(info runtimeExampleInfo) { + r.mu.Lock() + defer r.mu.Unlock() + r.byID[info.id] = info +} + +// remove deletes an example by id and reports whether it existed. +func (r *runtimeExampleRegistry) remove(id string) (runtimeExampleInfo, bool) { + r.mu.Lock() + defer r.mu.Unlock() + info, ok := r.byID[id] + if ok { + delete(r.byID, id) + } + return info, ok +} + +// registerRuntimeExample routes a runtime async example through the event +// broker's classification path and records its registration for DELETE. +func (s *Server) registerRuntimeExample(id string, mapping *RouteMapping, spec *loader.MessageExampleSpec) (extensions.TriggerKind, string, error) { + address := mapping.Path + prefix := mapping.Prefix + + trigger, jobID, err := s.eventBus.registerRuntimeExample(id, address, prefix, spec) + if err != nil { + return 0, "", err + } + s.runtimeExamples.add(runtimeExampleInfo{ + id: id, + address: address, + prefix: prefix, + trigger: trigger, + jobID: jobID, + }) + return trigger, jobID, nil +} + +// deleteExample removes a runtime async example (cancelling its interval job +// where one exists) or a sync dynamic example by id (RS.MAPI.30-31). +func (s *Server) deleteExample(id string) bool { + if info, ok := s.runtimeExamples.remove(id); ok { + switch info.trigger { + case extensions.TriggerPeriodic: + if s.eventBus != nil { + s.eventBus.removeIntervalJob(info.jobID) + } + case extensions.TriggerEvent: + if s.eventBus != nil { + s.eventBus.removeEventSubscription(info.prefix, id) + } + } + return true + } + if s.registry != nil && s.registry.removeDynamic(id) { + return true + } + return false +} + +// handleDeleteExample implements DELETE /_mock/examples/{exampleId}. +func (s *Server) handleDeleteExample(w http.ResponseWriter, r *http.Request) { + exampleID := chi.URLParam(r, "exampleId") + if !s.deleteExample(exampleID) { + writeJSONError(w, http.StatusNotFound, "unknown exampleId") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f018fbf..97b3f59 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -90,9 +90,9 @@ func TestValidateAddExampleRequest(t *testing.T) { wantErr: false, }, { - name: "missing path", + name: "missing path and channel rejected", json: `{"response":{"code":200}}`, - wantErr: false, // path is optional; channel may be supplied instead + wantErr: true, // oneOf requires an OpenAPI path or an AsyncAPI channel target }, { name: "valid async channel request", @@ -805,7 +805,7 @@ func TestHandleAddExample(t *testing.T) { ChiPattern: "/test", }}, wantStatus: http.StatusBadRequest, - wantJSON: map[string]any{"error": "Missing required fields"}, + wantJSON: map[string]any{"error": "invalid request: (root): Must validate one and only one schema (oneOf); (root): path is required"}, wantExample: false, }, { @@ -818,7 +818,7 @@ func TestHandleAddExample(t *testing.T) { ChiPattern: "/test", }}, wantStatus: http.StatusBadRequest, - wantJSON: map[string]any{"error": "No matching route found"}, + wantJSON: map[string]any{"error": "no matching route found"}, wantExample: false, }, } diff --git a/internal/server/signalr_builtin_test.go b/internal/server/signalr_builtin_test.go new file mode 100644 index 0000000..7060e42 --- /dev/null +++ b/internal/server/signalr_builtin_test.go @@ -0,0 +1,233 @@ +package server + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const signalrReceiveBuiltInDoc = `asyncapi: 3.0.0 +info: + title: Hub + version: 1.0.0 +x-signalr: + path: /hub +channels: + chat: + address: chat + bindings: + ws: + method: GET + messages: + echo: + examples: + - name: snap + payload: + ok: true + - name: e1 + payload: + echoed: "{$event.text}" + x-mock-match: + '{$event.name}': "receive" +operations: + sendChat: + action: send + channel: + $ref: '#/channels/chat' +` + +/* +Scenario: receive built-in fires on a SignalR inbound invocation +Given a message example matching the receive built-in on a SignalR hub channel +When a SignalR client sends an invocation carrying a payload +Then the templated message is broadcast to the channel (open stream) + +Related spec scenarios: RS.EVT.25 +*/ +func TestSignalRReceiveBuiltIn_FiresOnInbound(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(signalrReceiveBuiltInDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + hubURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/hub" + + conn, _, err := websocket.DefaultDialer.Dial(hubURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + _, _, err = conn.ReadMessage() + require.NoError(t, err) // handshake reply + + // Open a stream on the chat channel so delivery has a target. + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":4,"invocationId":"s1","target":"chat"}`+"\x1e"))) + _, _, err = conn.ReadMessage() + require.NoError(t, err) // snapshot/completion + + // Send an invocation carrying a payload → receive built-in fires. + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":1,"invocationId":"c1","target":"sendChat","arguments":[{"text":"hi"}]}`+"\x1e"))) + + deadline := time.Now().Add(3 * time.Second) + got := "" + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(deadline) + _, msg, rerr := conn.ReadMessage() + if rerr != nil { + break + } + for _, frame := range splitSignalRFrames(msg) { + var env signalREnvelope + if err := json.Unmarshal(frame, &env); err != nil { + continue + } + if env.Type != signalRTypeStreamItem { + continue + } + raw, err := json.Marshal(env.Item) + if err == nil { + got = string(raw) + } + } + if strings.Contains(got, `"echoed"`) { + break + } + } + assert.Contains(t, got, `"echoed":"hi"`) +} + +const signalrConnectQueryBuiltInDoc = `asyncapi: 3.0.0 +info: + title: Hub + version: 1.0.0 +x-signalr: + path: /hub +channels: + chat: + address: chat + bindings: + ws: + method: GET + messages: + welcome: + examples: + - name: w + payload: + msg: "welcome" + x-mock-match: + '{$event.name}': "connect" + '{$connection.query.tid}': "abc" +operations: + sendChat: + action: send + channel: + $ref: '#/channels/chat' +` + +/* +Scenario: connect built-in resolves {$connection.query.*} on a SignalR upgrade +Given a connect example matching {$connection.query.tid} = abc +When a SignalR client connects through the hub with tid=abc +Then the welcome message is delivered to that single connection + +Related spec scenarios: RS.EXT.27, RS.EVT.24 +*/ +func TestSignalRConnectBuiltIn_ResolvesConnectionQuery(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(signalrConnectQueryBuiltInDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + hubURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/hub?tid=abc" + + conn, _, err := websocket.DefaultDialer.Dial(hubURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + // consume any handshake reply and the connect-built-in invitation. + deadline := time.Now().Add(3 * time.Second) + got := "" + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(deadline) + _, msg, rerr := conn.ReadMessage() + if rerr != nil { + break + } + for _, frame := range splitSignalRFrames(msg) { + var env signalREnvelope + if err := json.Unmarshal(frame, &env); err != nil { + continue + } + if env.Type != signalRTypeInvocation { + continue + } + raw, err := json.Marshal(env.Arguments) + if err == nil { + got = string(raw) + } + } + if strings.Contains(got, `"welcome"`) { + break + } + } + assert.Contains(t, got, `"welcome"`) +} + +/* +Scenario: Default hub channel selection is deterministic across map order +Given a SignalR hub serving several channels with a schema prefix +When the default channel address is computed +Then it resolves to the lexicographically smallest prefixed address regardless +of map iteration order (built-ins and recipient metadata become deterministic) + +Related spec scenarios: RS.EVT.24, RS.EXT.27 +*/ +func TestHubDefaultChannel_Deterministic(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + channels map[string]*asyncapi.Channel + wantAddr string + }{ + {name: "two channels picks lexicographic min", prefix: "/v1", channels: map[string]*asyncapi.Channel{ + "zeta": {Address: "/zeta"}, + "alpha": {Address: "/alpha"}, + }, wantAddr: "/v1/alpha"}, + {name: "no prefix", prefix: "", channels: map[string]*asyncapi.Channel{ + "alerts": {Address: "alerts"}, + }, wantAddr: "/alerts"}, + {name: "empty address ignored", prefix: "/v1", channels: map[string]*asyncapi.Channel{ + "noaddr": {Address: ""}, + "b": {Address: "/b"}, + }, wantAddr: "/v1/b"}, + {name: "no channels yields empty", prefix: "/v1", channels: map[string]*asyncapi.Channel{}, wantAddr: ""}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + hub := &signalRHub{prefix: tt.prefix, channels: tt.channels} + assert.Equal(t, tt.wantAddr, hubDefaultChannel(hub)) + }) + } +} diff --git a/internal/server/signalr_hub.go b/internal/server/signalr_hub.go index 8d45a60..95481c4 100644 --- a/internal/server/signalr_hub.go +++ b/internal/server/signalr_hub.go @@ -37,12 +37,21 @@ type signalRHub struct { channels map[string]*asyncapi.Channel ops map[string]*asyncapi.Operation + // hooks is wired by the Server for built-in triggers and consumer + // lifecycle notifications (D5). + hooks builtInHooks + mu sync.Mutex tokens map[string]string // connection token -> connection id conns map[string]*signalRConnection idSeq int } +// setHooks wires built-in trigger and lifecycle callbacks into the hub. +func (h *signalRHub) setHooks(hooks builtInHooks) { + h.hooks = hooks +} + // signalRConnection is a single SignalR client connection. type signalRConnection struct { id string @@ -51,6 +60,10 @@ type signalRConnection struct { writer *wsWriter streams map[string]*signalRStream // invocationId -> open stream server *signalRHub + // query/headers capture the upgrade-time metadata for {$connection.*} + // evaluation (RS.EXT.27). + query map[string][]string + headers map[string][]string } // signalRStream is an open client-initiated stream over a channel. @@ -232,17 +245,36 @@ func (h *signalRHub) serveUpgrade(w http.ResponseWriter, r *http.Request) { writer: wr, streams: make(map[string]*signalRStream), server: h, + query: r.URL.Query(), + headers: lowerHeaderKeys(r.Header), } h.mu.Lock() h.conns[connID] = sc h.mu.Unlock() + channel := hubDefaultChannel(h) + info := ConsumerInfo{ + ConnectionID: connID, + Channel: channel, + Query: sc.query, + Headers: sc.headers, + } + if h.hooks.OnConnect != nil { + h.hooks.OnConnect(channel, connID, info) + } + if h.hooks.Connect != nil { + h.hooks.Connect(channel, connID, info) + } + defer func() { h.mu.Lock() delete(h.conns, connID) h.mu.Unlock() h.removeConnectionStreams(connID) wr.close() + if h.hooks.OnDisconnect != nil { + h.hooks.OnDisconnect(channel, connID) + } }() h.runConnection(sc) @@ -314,12 +346,55 @@ func (h *signalRHub) dispatch(sc *signalRConnection, env signalREnvelope) { case signalRTypeStreamInvocation: h.handleStreamInvocation(sc, env) case signalRTypeInvocation: + // An inbound client invocation carries a payload; fire the receive + // built-in (RS.EVT.25) before answering. A single argument is exposed + // directly as the event payload (the common case for message mocks). + if h.hooks.Receive != nil { + payload := json.RawMessage("{}") + if len(env.Arguments) == 1 { + payload, _ = json.Marshal(env.Arguments[0]) + } else if len(env.Arguments) > 1 { + payload, _ = json.Marshal(env.Arguments) + } + ch := hubChannelAddress(h) + if ch != "" { + h.hooks.Receive(ch, InboundMessage{ + Payload: payload, + ConnectionID: sc.id, + }) + } + } h.handleInvocation(sc, env) case signalRTypeCancelInvocation: h.handleCancelInvocation(sc, env) } } +// hubDefaultChannel returns the channel address a SignalR hub uses for +// connection-level built-ins (connect/receive) and recipient metadata. A hub +// may serve several channels, so selection is deterministic (the +// lexicographically smallest prefixed address) rather than relying on map +// iteration order. It is empty when the hub has no addressable channel. +func hubDefaultChannel(h *signalRHub) string { + best := "" + for _, ch := range h.channels { + if ch.Address == "" { + continue + } + addr := asyncAddressWithPrefix(h.prefix, ch.Address) + if best == "" || addr < best { + best = addr + } + } + return best +} + +// hubChannelAddress returns the default channel address of a hub (used by the +// receive built-in dispatch). +func hubChannelAddress(h *signalRHub) string { + return hubDefaultChannel(h) +} + // handleStreamInvocation answers a StreamInvocation by channel ID with the // channel's snapshot message and holds the stream open (RS.SHR.3-5, RS.SHR.17). func (h *signalRHub) handleStreamInvocation(sc *signalRConnection, env signalREnvelope) { diff --git a/internal/server/testhelpers_test.go b/internal/server/testhelpers_test.go new file mode 100644 index 0000000..5d8ae78 --- /dev/null +++ b/internal/server/testhelpers_test.go @@ -0,0 +1,170 @@ +package server + +// Shared test helpers and fixtures for the async-management server tests. +// Keeping them in one file gives package-level helpers a canonical home so a +// fixture or stub edited here does not silently drift from a near-twin in +// another _test.go file. + +import ( + "encoding/json" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" + "github.com/stretchr/testify/require" +) + +// pushChannelDoc is the minimal AsyncAPI ws fixture used by most management +// control-API tests: one /alerts receive channel with a default example. +const pushChannelDoc = `asyncapi: 3.0.0 +info: + title: Alerts + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + alertMsg: + examples: + - name: ex1 + payload: + level: info + msg: default +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +func parsePushDoc(t *testing.T) *asyncapi.Document { + t.Helper() + doc, err := asyncapi.Parse([]byte(pushChannelDoc)) + require.NoError(t, err) + return doc +} + +// newPushServer builds a server with the control API enabled and one ws +// channel (/alerts). It is the de-facto default test server for the +// async-management surface. +func newPushServer(t *testing.T) *Server { + t.Helper() + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: parsePushDoc(t), Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + return srv +} + +// newAsyncMgmtServer is a discoverability alias of newPushServer for tests that +// conceptually drive the async management surface. +func newAsyncMgmtServer(t *testing.T) *Server { + t.Helper() + return newPushServer(t) +} + +// postExample POSTs a body to /_mock/examples and returns the response. +func postExample(t *testing.T, tsURL, body string) *http.Response { + t.Helper() + resp, err := http.Post(tsURL+"/_mock/examples", "application/json", strings.NewReader(body)) + require.NoError(t, err) + return resp +} + +// addExample POSTs a body to /_mock/examples, asserts a 200 and returns the +// returned example id. +func addExample(t *testing.T, tsURL, body string) string { + t.Helper() + resp := postExample(t, tsURL, body) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + var payload map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload)) + id, _ := payload["id"].(string) + require.NotEmpty(t, id) + return id +} + +// waitForConnections blocks until the ws registry holds at least n connections +// for the channel (avoiding the gorilla sticky read-timeout on empty pre-reads). +func waitForConnections(srv *Server, channel string, n int) { + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if len(srv.wsRegistry().connections(channel)) >= n { + return + } + time.Sleep(5 * time.Millisecond) + } +} + +// waitForPush blocks until the observer records a push envelope or the +// deadline elapses, returning the elapsed time from start. It reports whether +// the push arrived within the deadline so callers can distinguish a missing +// delivery from a merely late one. +func waitForPush(t *testing.T, got *atomic.Int64, start time.Time) (time.Duration, bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for got.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + return time.Since(start), got.Load() > 0 +} + +// stubConsumerBus is an in-memory ConsumerBus capturing delivery via wsPush. +type stubConsumerBus struct { + candidates []ConsumerInfo + wsPush func(consumer ConsumerInfo, payload []byte) +} + +func (s *stubConsumerBus) SignalRPush(address string, payload []byte) {} +func (s *stubConsumerBus) WSBroadcast(address string, payload []byte) { + if s.wsPush != nil { + s.wsPush(ConsumerInfo{Channel: address}, payload) + } +} +func (s *stubConsumerBus) Candidates(address string) []ConsumerInfo { return s.candidates } +func (s *stubConsumerBus) PushTo(consumer ConsumerInfo, address string, payload []byte) { + if s.wsPush != nil { + s.wsPush(consumer, payload) + } +} + +// stubMessageRenderer is an in-memory MessageRenderer; render overrides the +// default JSON-marshal of the example payload. +type stubMessageRenderer struct { + render func(example *MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) +} + +func (s *stubMessageRenderer) SelectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*MessageExampleView, string) { + return nil, "" +} +func (s *stubMessageRenderer) RenderMessageSpecs(messages []*loader.MessageSpec, prefix, opID string, in InboundMessage) (int, []byte, error) { + return 0, nil, nil +} +func (s *stubMessageRenderer) RenderAsyncPayload(example *MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) { + if s.render != nil { + return s.render(example, evaluator) + } + b, _ := json.Marshal(example.Payload()) + return b, nil +} +func (s *stubMessageRenderer) ApplySetState(stateMap map[string]any, eval runtime.Evaluator, prefix string) { +} +func (s *stubMessageRenderer) NewStateSource(prefix string) *runtime.StateSource { + return &runtime.StateSource{Data: map[string]any{}} +} +func (s *stubMessageRenderer) NewEnvSource() *runtime.EnvSource { + return &runtime.EnvSource{Env: map[string]string{}} +} + +// loaderExampleSpecForTest builds a MessageExampleSpec for event-bus tests. +func loaderExampleSpecForTest(payload, ext map[string]any) *loader.MessageExampleSpec { + return &loader.MessageExampleSpec{Payload: payload, Extensions: ext} +} diff --git a/internal/server/ws_adapter.go b/internal/server/ws_adapter.go index b3aff13..c291872 100644 --- a/internal/server/ws_adapter.go +++ b/internal/server/ws_adapter.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "sync" "time" @@ -86,11 +87,14 @@ func (w *wsWriter) close() { _ = w.conn.Close() } -// wsConnection is a single registered WebSocket consumer connection. +// wsConnection is a single registered WebSocket consumer connection. Metadata +// (query, headers) is captured at upgrade for {$connection.*} evaluation. type wsConnection struct { id string channel string writer *wsWriter + query map[string][]string + headers map[string][]string } // connectionRegistry tracks active WebSocket consumer connections per channel, @@ -111,17 +115,19 @@ func newConnectionRegistry() *connectionRegistry { } // register adds a connection and returns a fresh connection id. -func (r *connectionRegistry) register(channel string, writer *wsWriter) string { +func (r *connectionRegistry) register(channel, id string, wr *wsWriter, query, headers map[string][]string) string { r.mu.Lock() defer r.mu.Unlock() - r.autoID++ - id := "conn-" + strconv.Itoa(r.autoID) - conn := &wsConnection{id: id, channel: channel, writer: writer} - r.byID[id] = conn + if id == "" { + r.autoID++ + id = "conn-" + strconv.Itoa(r.autoID) + } + c := &wsConnection{id: id, channel: channel, writer: wr, query: query, headers: headers} + r.byID[id] = c if r.byChan[channel] == nil { r.byChan[channel] = make(map[string]*wsConnection) } - r.byChan[channel][id] = conn + r.byChan[channel][id] = c return id } @@ -151,11 +157,65 @@ func (r *connectionRegistry) connections(channel string) []*wsConnection { return out } +// allConnections returns every registered connection across all channels. +func (r *connectionRegistry) allConnections() []*wsConnection { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*wsConnection, 0, len(r.byID)) + for _, ws := range r.byID { + out = append(out, ws) + } + return out +} + +// connection returns a single connection by id. +func (r *connectionRegistry) connection(id string) (*wsConnection, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + ws, ok := r.byID[id] + return ws, ok +} + +// lowerHeaderKeys lowercases header keys so {$connection.header.} lookups +// are case-insensitive. +func lowerHeaderKeys(h http.Header) map[string][]string { + out := make(map[string][]string, len(h)) + for k, v := range h { + out[strings.ToLower(k)] = v + } + return out +} + +// broadcast sends a payload to every connected consumer of a channel address. +func (a *wsProtocolAdapter) broadcast(address string, payload []byte) { + if a == nil || a.registry == nil { + return + } + for _, ws := range a.registry.connections(address) { + ws.writer.write(payload) + } +} + +// builtInHooks carries the optional built-in trigger and lifecycle callbacks +// the Server wires in so the adapter never reaches into Server (D5, RS.EVT.24-25). +type builtInHooks struct { + // Connect fires the connect built-in schema-local for a just-connected + // consumer with its connection context as the recipient. + Connect func(channel string, connID string, info ConsumerInfo) + // Receive fires the receive built-in schema-local with the inbound message + // exposed in the event context. + Receive func(channel string, in InboundMessage) + // OnConnect/OnDisconnect notify consumer lifecycle observers. + OnConnect func(channel string, connID string, info ConsumerInfo) + OnDisconnect func(channel string, connID string) +} + // wsProtocolAdapter serves AsyncAPI ws channels as raw WebSockets (RS.ASP.2, // RS.ASP.6-7, RS.ASP.9). When the document declares root x-signalr the session // is handed to the SignalR overlay instead (design D7). type wsProtocolAdapter struct { registry *connectionRegistry + hooks builtInHooks } func newWSProtocolAdapter() *wsProtocolAdapter { @@ -174,11 +234,24 @@ func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandle } wr := newWSWriter(conn) channel := mapping.Path - id := a.registry.register(channel, wr) + id := a.registry.register(channel, "", wr, r.URL.Query(), lowerHeaderKeys(r.Header)) defer a.registry.unregister(id) slog.Debug("WebSocket consumer connected", "connectionId", id, "channel", channel) + info := ConsumerInfo{ + ConnectionID: id, + Channel: channel, + Query: r.URL.Query(), + Headers: lowerHeaderKeys(r.Header), + } + if a.hooks.OnConnect != nil { + a.hooks.OnConnect(channel, id, info) + } + if a.hooks.Connect != nil { + a.hooks.Connect(channel, id, info) + } + // Receive-operation emission on connect (RS.ASP.7). if mapping.Action == "receive" { out, herr := handler.HandleMessage(r.Context(), InboundMessage{ConnectionID: id, PathParams: addressParams(r)}) @@ -197,11 +270,15 @@ func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandle wr.writeMessage(websocket.PongMessage, payload) continue } - out, herr := handler.HandleMessage(r.Context(), InboundMessage{ + in := InboundMessage{ Payload: payload, ConnectionID: id, PathParams: addressParams(r), - }) + } + if a.hooks.Receive != nil { + a.hooks.Receive(channel, in) + } + out, herr := handler.HandleMessage(r.Context(), in) if herr != nil { wr.writeError(herr) continue @@ -212,5 +289,9 @@ func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandle } wr.write(out) } + + if a.hooks.OnDisconnect != nil { + a.hooks.OnDisconnect(channel, id) + } } } diff --git a/internal/server/ws_adapter_test.go b/internal/server/ws_adapter_test.go index dfb0253..c8a674b 100644 --- a/internal/server/ws_adapter_test.go +++ b/internal/server/ws_adapter_test.go @@ -107,7 +107,7 @@ func TestConnectionRegistry_Lifecycle(t *testing.T) { t.Parallel() registry := newConnectionRegistry() - id := registry.register("/chan", nil) + id := registry.register("/chan", "", nil, nil, nil) assert.Equal(t, "/chan", registry.connections("/chan")[0].channel) registry.unregister(id) diff --git a/internal/server/x_send_events_shim_test.go b/internal/server/x_send_events_shim_test.go new file mode 100644 index 0000000..b556aad --- /dev/null +++ b/internal/server/x_send_events_shim_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "testing" + + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Rejecting a legacy cron entry without a wait interval +Given an example whose x-send-events contains {on: cron} without a wait +When derivedExamples maps the entry +Then loading fails loudly with an error naming the missing interval + +Related spec scenarios: RS.EVT.18 +*/ +func TestDerivedExamples_CronWithoutWaitRejected(t *testing.T) { + t.Parallel() + + bus := newEventBus(nil, nil, false) + ex := &asyncapi.Example{ + Name: "bad", + Payload: map[string]any{"x": 1}, + Extensions: map[string]any{ + "x-send-events": []any{map[string]any{"on": "cron"}}, + }, + } + _, err := bus.derivedExamples(ex) + require.Error(t, err) + assert.Contains(t, err.Error(), "wait") +} + +/* +Scenario: Mapping legacy {on: cron, wait: N} to the interval shim +Given an example whose x-send-events contains {on: cron, wait: 1000} +When derivedExamples maps the entry +Then the example becomes periodically driven at the given interval + +Related spec scenarios: RS.EVT.18, RS.EXT.22 +*/ +func TestDerivedExamples_CronWithWaitMapsToInterval(t *testing.T) { + t.Parallel() + + bus := newEventBus(nil, nil, false) + ex := &asyncapi.Example{ + Name: "tick", + Payload: map[string]any{"seq": 1}, + Extensions: map[string]any{ + "x-send-events": []any{map[string]any{"on": "cron", "wait": float64(1000)}}, + }, + } + derived, err := bus.derivedExamples(ex) + require.NoError(t, err) + require.Len(t, derived, 1) + require.NotNil(t, derived[0].Extensions["x-mock-interval"]) + assert.Equal(t, 1000, derived[0].Extensions["x-mock-interval"]) +} diff --git a/openspec/changes/async-management-api-extensions/.openspec.yaml b/openspec/changes/async-management-api-extensions/.openspec.yaml new file mode 100644 index 0000000..1d9aeef --- /dev/null +++ b/openspec/changes/async-management-api-extensions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/async-management-api-extensions/design.md b/openspec/changes/async-management-api-extensions/design.md new file mode 100644 index 0000000..bb69c71 --- /dev/null +++ b/openspec/changes/async-management-api-extensions/design.md @@ -0,0 +1,114 @@ +# Design: Async management API extensions + +## Context + +The management surface today (see `api/openapi.yaml` and `internal/server/management_async.go`, `server_management.go`, `event_broker.go`, `scheduler.go`) is split under `/_mock/ws/*` even though its operations drive both `ws` and `http` AsyncAPI channels. Example selection is split across two vocabularies: `x-mock-match` (`extensions/match.go`, `example_value.go`) selects examples against HTTP/message contexts, while `x-send-events on:` (`send_events.go`) is a coarse event-name equality on top of the same matcher — a genuine duplicate. The built-ins `cron`/`connect`/`receive` are parsed and registered in the broker but never fired — the only `fire` callers are `x-event-trigger` (`server.go:533`) and `/_mock/events/fire`. The schedule endpoint is a one-variant bound duplicate of the `cron` built-in and marshals its payload once at registration (no per-delivery templating). Delivery is broadcast to all consumers of a channel with no server-side recipient decision. There is no reactive channel for test harnesses. + +See `proposal.md` for the why; this document covers how. Requirements are in `specs/` (deltas against `extensions`, `event-driver`, `management-api`, `asyncapi-management`). + +## Goals / Non-Goals + +**Goals:** +- One selection extension — `x-mock-match` — across sync (request) and async (event) contexts, plus a per-connection recipient filter (`{$connection.*}`), so sync and async examples share selection semantics. +- Event-driven emission expressed declaratively (`{$event.name}` match) rather than via a parallel `x-send-events` subscription key; recurrence/delay are timing-only sibling extensions (`x-mock-interval`, `x-mock-delay`) that keep the matcher pure. +- One unified `POST /_mock/examples` surface for sync and async injection with runtime `match`/`interval`/`delay` mirroring the extensions. +- Protocol-neutral async management prefix (`/_mock/async/*`) with legacy `/_mock/ws/*` aliases kept. +- `POST /_mock/events` with a `type` discriminator replacing `/_mock/events/fire`. +- Consumers listable across all channels (`channel` optional). +- A general management WebSocket stream at `WS /_mock/stream` (V1 notifications-only). +- Actually-fired built-ins (`connect`, `receive`) and scheduler-driven periodic emission. + +**Non-Goals:** +- Client→server commands on the management stream (V2, on the same socket). +- `once`/`ttl`/`conditions` selection semantics on runtime examples beyond the synchronous set already supported (payload/headers + match/interval/delay only). +- New AsyncAPI protocols or changes to channel serving. +- Auth on the management surface (mock tool premise, unchanged). + +## Decisions + +### D1: Path surface — protocol-neutral `/_mock/async/*`, general `/_mock/stream` +Management endpoints that act on both `ws` and `http` channels move under `/_mock/async/*`; `ws//` is a protocol artifact of the original MVP. The management WebSocket is deliberately *not* under `/async` — it is a general cross-cutting control channel — so it lives at `WS /_mock/stream`. Legacy `/_mock/ws/{push,consumers,disconnect}` and `/_mock/events/fire` are kept as deprecated aliases (identical handler registrations), and `/_mock/ws/schedule{,/{pushId}}` become `410 Gone` bodies pointing at `POST /_mock/examples` / `DELETE /_mock/examples/{id}`. +- **Alternative considered**: naming the stream `/_mock/ws/events` or `/_mock/async/ws` — rejected: locks a general control channel to the async domain and re-couples it to the ws protocol. + +### D2: Unified `AddExampleRequest` — `oneOf` JSON Schema branches + single-trigger validation +Replace the single loose `addExampleRequestSchema` (`server_management.go:40`) with a `oneOf` two-branch schema so a request is rejected in a declarative way when targeting is mixed: +- branch A (sync): `required: [path, response]`, `not: {anyOf: [{required:[protocol]},{required:[channel]},{required:[match]},{required:[interval]},{required:[delay]}]}`; +- branch B (async): `required: [channel, response]`, `not: {anyOf: [{required:[path]}]}`. +`match` mirrors `x-mock-match` (object), `interval` (positive integer ms), `delay` (integer ms). A Go-side check enforces the single-trigger rule (`interval` xor event-based `match`), since cross-field-dependency is not expressable in draft-07. Route resolution reuses `findAsyncRouteMapping` (`server_management.go:20`) so unknown channels still 400 (keeps RS.MAPI.21). +- **Alternative considered**: a Go-only checker over the raw JSON — rejected: drops the data-driven declarative validation style and duplicates the field rules in two places. + +### D3: Unified match model — event context, trigger classification, `x-send-events` shim +Emit decisions reuse the shared selection pipeline instead of bespoke subscription grouping: + +- **Event context**: `EventSource` (`runtime/expression.go`) gains identity `{$event.name}` (named-event name or built-in `connect`/`receive`) and whole-payload `{$event.data}`, alongside unchanged `{$event.}` payload access. `name`/`data` are reserved metadata. +- **Trigger classification at load**: an AsyncAPI message example is *event-driven* iff its `x-mock-match` references `{$event.*}`; *periodically driven* iff it declares `x-mock-interval`; otherwise it is a sync/async reply. Mixed match contexts (`{$event.*}` with `{$request|$message|$channel.*}`) or both triggers are load errors (RS.EXT.20, RS.EXT.28). The old subscription grouping (`event_server.go:collectSchemaSubscriptions`, `messageDeliverable`) is replaced by registration of each event-driven example keyed by its match identity + schema scope. +- **`x-send-events` shim**: on load, translate `{on:, wait:N}` → `{$event.name}` match (or `x-mock-interval` for `cron`) with a verbose-mode deprecation note (RS.EVT.18). +- **Runtime path**: `handleAddExample` with `match`/`interval` registers the example through the same machinery (RS.MAPI.24-26, RS.MAPI.33); `Server` keeps `map[exampleID]→{trigger, jobID}` so `DELETE /_mock/examples/{id}` unregisters and cancels. +- **Alternative considered**: keeping `x-send-events` alongside `x-mock-match` as an explicit subscription key — rejected: duplicates the matcher (the spray of this change) and forces reconciling two "which example" systems. + +### D4: Scheduler — `x-mock-interval` jobs with per-delivery templating +Retire the raw `pushScheduler.push(channel,payload)` model. `scheduler.go` becomes a job runner: job = `{id, interval, deliver func()}`; each tick calls the delivery pipeline (render + recipient partition + push) for that example. Spec `x-mock-interval` and runtime `interval` create such jobs per example (per-example cadence, so differing intervals don't intermix), and start/stop emits `schedule` envelopes. The legacy schedule endpoint is gone; one-shot `push` with `delay` keeps its existing `time.Sleep` path (`management_async.go:61`). +- **Alternative considered**: firing a synthetic named event per interval — rejected: keeps an event concept where none is needed and would merge same-schema intervals. + +### D5: Built-in trigger firing (`connect`, `receive`) +`cron` is no longer an event (D4 covers periodicity). The two remaining built-ins are fired from lifecycle/inbound hooks, gated by a cheap `broker.hasSubscribers(name, schema)` check to keep hot loops cheap: +- `connect`: fired schema-local in the ws adapter after `registry.register` (`ws_adapter.go:177`) and in `signalr_hub.go` on connection; recipient set = the single connecting connection. +- `receive`: fired schema-local in the ws adapter read loop on an inbound message and in the SignalR `dispatch`/stream path, with the inbound message exposed via the event context. +Both are no-ops when nothing matches. The existing receive-operation snapshot emission (`ws_adapter.go:183`) is independent and unchanged. +- **Alternative considered**: no gating (always fire) — rejected for per-frame overhead; **Alternative**: deriving `receive` from the operation snapshot — rejected: snapshot is spec-shaped, built-in needs the inbound message payload. + +### D6: Per-connection recipient partition (two-phase evaluation) +Server-side recipient selection replaces "broadcast and let the client filter" when an example asks for it. At delivery time `x-mock-match` is partitioned (either side referencing `{$connection.*}`): + +``` +0. fire → candidate set for the channel: all consumers (byChan[address] / hub + streams); connect → just the connecting connection. +1. split conditions: connectionBucket (any {$connection.*} ref on either side) + vs commonBucket (everything else). +2. evaluate commonBucket once (event/state/env context). Pre-evaluate + {$event.*}/{$state.*} subexpressions inside connectionBucket values once. + Fail → no delivery. +3. per candidate connection: evaluate only connectionBucket ({$connection.id}, + {$connection.channel}, {$connection.query.*}, {$connection.header.*}). +4. deliver the rendered-once payload to satisfied connections. + connectionBucket empty → broadcast to all candidates (today's behavior). +``` + +This needs a `{$connection.*}` data source and connection metadata: `wsConnection` (`ws_adapter.go:90`) captures `r.URL.Query()`/headers at upgrade; SignalR connections mirror it. No new extension key and no load-time classification for connection references — the partition is a runtime concern. Condition evaluation reuses `evaluateParamsMatch`, so literal equality and JSON-schema conditions both work per connection; the schema cache (`match.go:getCachedSchema`) keeps the only heavier case bounded. +- **Alternatives considered**: (a) dedicated `x-mock-target` expression targeting one connection (O(1)) — rejected as a special case of the partition (`'{$connection.id}':'{$event.connectionId}'`); (b) client-side filtering only — rejected: the mock server must be able to assert per-consumer delivery in tests. + +### D7: Management stream `/_mock/stream` +A dedicated `manageWSRegistry` (separate from `connectionRegistry` so management sockets never appear under `/_mock/async/consumers`). Upgrade only: check `Connection: Upgrade` first, else `405` (RS.AMG.28). Filters parsed at connect from `?events=` and `?channels=` (comma-separated, `*` glob). Envelopes: +`{type: event|push|consumer|schedule, ts, …}` with per-type payloads (event: name/payload/schema/global; push: channel/connectionId/payload; consumer: action/connectionId/channel/streams; schedule: action/exampleId/channel/interval). +Sources: `eventBus` observer hook (`addObserver(func(EventObservation))`, invoked in `fire` and `deliver`), ws-adapter/hub lifecycle hooks, scheduler start/stop. Reads serve pings/pongs only (V1 notifications-only; RS.AMG.23-28). +- **Alternative considered**: reusing the channel `connectionRegistry` — rejected: would pollute consumer discovery. + +### D8: Consumers get-all +`handleAsyncConsumers` treats `channel == ""` as "all": iterate `connectionRegistry.byChan` (each `wsConnection` already carries its channel) plus every hub channel's `openStreamsForChannel`. Response shape unchanged (`{consumers:[{connectionId, channel, streams?}]}`), so the get-all list is a flat union (RS.AMG.22). + +### D9: `POST /_mock/events` with `type` discriminator +`handleFireEvent` becomes `handleEvents` over a `type`-discriminated request. V1 enum `["fire"]`; `fire` reuses the existing `eventBus.fire(event, payload, "", global, triggerDelay(delay))` semantics (RS.MAPI.22-23). Missing/unknown `type` → 400 (RS.MAPI.32). The `/events/fire` route registers the same handler as a deprecated alias. + +## Risks / Trade-offs + +- **Management fire is effectively global-only for prefixed schemas**: `_mock/events` fires with `firingSchema == ""`; non-`global` delivery matches only empty-prefix subscriptions. Runtime examples on a `/v1` channel (prefix `/v1`) won't receive a schema-local management fire → mitigate with openapi/docs guidance (“use `global: true` from the management endpoint”) and noted as an open question (schema-universal fire later). +- **Per-connection evaluation cost** → bounded: common conditions evaluate once, connection conditions are map lookups, JSON-schema per connection cached and scoped to the candidate set; fast path skips entirely when no `{$connection.*}` refs (identical to today's broadcast). +- **Backward compatibility of `x-send-events`** → loader mapping shim with verbose deprecation note; removal deferred one release. +- **Event context reserved keys** (`{$event.name}`/`{$event.data}`) shadow payload fields of the same name → documented; aliased access via `{$event.data}` keeps the whole payload reachable. +- **Breaking removal of schedule** → mitigated by `410 Gone` bodies pointing at `/examples` + deprecated aliases; no client is silently broken (explicit error). +- **Built-in firing on hot loops** → gated by `broker.hasSubscribers`; negligible overhead when no examples match. +- **Recurring jobs leak on shutdown** → scheduler shutdown (existing `shutdownSchedules`) now also cancels interval jobs; `DELETE /_mock/examples/{id}` cancels individual ones. + +## Migration Plan + +1. Add all new endpoints (`/_mock/async/*`, `/_mock/events`, `/_mock/stream`, examples `match`/`interval`/`delay` + `DELETE /examples/{id}`) and the match/timing extension pipeline alongside the existing surface; register deprecated aliases and the `x-send-events` mapping shim. +2. Update `api/openapi.yaml`, `docs/extensions.md`, `docs/architecture.md`, `CHANGELOG.md`. +3. Switch internal tests to the new paths/model; add new unit + integration coverage. +4. Ship aliases for one release; schedule paths answer `410` only after their replacement is live. +Rollback = revert the change commit; legacy aliases + the `x-send-events` shim keep pre-change clients functional. + +## Open Questions + +- Whether management `fire` (no auto-schema) should become schema-universal by default in a later change (affects prefixed-schema consumption of runtime examples). +- Whether the management stream should gain client→server commands (bidirectional) in a follow-up. +- Whether connection metadata beyond id/channel/query/headers (e.g., negotiated protocol) is needed by real mocks (add later without spec changes). \ No newline at end of file diff --git a/openspec/changes/async-management-api-extensions/proposal.md b/openspec/changes/async-management-api-extensions/proposal.md new file mode 100644 index 0000000..b8f8693 --- /dev/null +++ b/openspec/changes/async-management-api-extensions/proposal.md @@ -0,0 +1,48 @@ +## Why + +The runtime control plane for async mocking is fragmented and protocol-locked: management endpoints live under `/_mock/ws/*` even though they drive both `ws` and `http` AsyncAPI channels, the only way to make a message fire in response to an event is to edit the AsyncAPI spec (`x-send-events`), the recurring-schedule endpoint is a one-variant bound duplicate of the `cron` built-in, and the spec-side built-in triggers (`cron`/`connect`/`receive`) are parsed but never actually fired. There is also no reactive channel for tests running mock clients — they can only poll HTTP. + +Compounding this, example selection is fragmented across two vocabulary systems: `x-mock-match` selects an example against the HTTP/message context, while `x-send-events on:` is effectively a coarse `{$event.name}` equality on top of the same matcher (`x-send-events` duplicates `x-mock-match`). Unifying them into match-driven selection with an event context makes sync and async examples behave identically and lets payload- and per-connection conditions drive emission. + +This change makes the async control surface protocol-neutral and complete: one unified `/_mock/examples` endpoint handles sync and async message injection, example selection uses one `x-mock-match` extension across request/event/connection contexts, recurrence is a timing extension instead of a parallel endpoint, and a general management WebSocket stream lets tests subscribe to runtime events. + +## What Changes + +- **Protocol-neutral async prefix**: `/_mock/ws/*` → `/_mock/async/*` for `push`, `consumers`, `disconnect` (these operate on both `ws` and `http` AsyncAPI channels). Old `/_mock/ws/*` paths stay as deprecated aliases. +- **Unified example selection (`x-mock-match`)**: the existing matcher is extended to select *and* target async examples. An event context exposes `{$event.name}` (event identity: named-event name or built-in `connect`/`receive`), `{$event.data}` (whole payload) alongside the unchanged `{$event.*}` payload fields, and `{$event.*}`-based conditions mark an example as event-driven. Conditions referencing `{$connection.*}` are a **per-connection recipient filter** evaluated in a two-phase partition (common conditions once per fire, connection conditions per candidate; absent connection conditions → broadcast as today). +- **Timing siblings**: recurrence and delay leave `x-mock-match` pure — new `x-mock-interval` (ms, periodic emission cadence) and `x-mock-delay` (ms, delayed emission after a fire). `cron` is **no longer an event**: periodic emission is expressed with `x-mock-interval`. +- **`x-send-events` deprecated**: a loader-time mapping shim translates `{on, wait}` into the match-identity + timing equivalent with a verbose-mode warning; removal deferred one release. +- **Unified example injection (`POST /_mock/examples`)**: `AddExampleRequest` gains `match` (runtime `x-mock-match`), `interval`, and `delay` for AsyncAPI targets — the runtime mirror of the extensions — in place of a dedicated `sendEvents` field. Strict, context-aware validation rejects wrong combinations (`path` vs `channel`, `match`/`interval` on a non-AsyncAPI target, `interval` alongside an event `match`, non-positive `interval`). +- **Example removal (`DELETE /_mock/examples/{exampleId}`)**: removes a dynamic example and cancels its recurring delivery (replaces the old schedule-stop). +- **Single event resource (`POST /_mock/events`)**: replaces the ambiguous `/_mock/events/fire` action path with a `type` discriminator (`"fire"` for now, extensible later); `/events/fire` stays as a deprecated alias. +- **Consumers get-all**: `GET /_mock/async/consumers` no longer requires `channel`; omitting it returns consumers across all channels. +- **Built-in trigger wiring**: the remaining built-ins `connect` and `receive` (today parsed but inert) are actually fired by the server from WebSocket/SignalR lifecycle and inbound-message hooks; periodic emission is driven by the generalized scheduler from `x-mock-interval`. The recurring schedule endpoint (`/_mock/ws/schedule*`) is **BREAKING**: removed, `410 Gone` pointing to `POST /_mock/examples` with `match`/`interval`. +- **Management WebSocket stream (`GET /_mock/stream`, `ws://host/_mock/stream`)**: connect-time event/channel filters; server pushes envelopes for fired events, message pushes, consumer connection lifecycle, and schedule start/stop. V1 is notifications-only. +- **Per-delivery templating**: delivered/scheduled messages (spec or runtime) are templated at emission time, so `{$event.*}`/`{$state.*}`/`{$env.*}` resolve against current state. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `extensions`: `x-mock-match` gains an event context (`{$event.name}`, `{$event.data}`, payload), per-connection recipient matching (`{$connection.*}`, two-phase partition), and timing sibling extensions `x-mock-interval`/`x-mock-delay`; event-driven examples are classified by `{$event.*}` match presence. +- `event-driver`: AsyncAPI message examples emit through `x-mock-match` against the event context (identity `{$event.name}`, built-ins `connect`/`receive`, periodic `x-mock-interval`); `x-send-events` is deprecated with a mapping shim; built-ins are actually fired. +- `management-api`: `POST /_mock/examples` gains `match`/`interval`/`delay` runtime async examples, `DELETE /_mock/examples/{exampleId}`, strict single-trigger field validation, and the fire-event endpoint becomes `POST /_mock/events` with a `type` discriminator. +- `asyncapi-management`: consumers can be listed without a channel filter; recurring delivery is expressed via `interval` on `/_mock/examples` (schedule endpoint removed); a general management WebSocket stream (`/_mock/stream`) exposes runtime event/consumer/schedule notifications. + +## Impact + +- `internal/extensions/` — `match.go`/`example_value.go`: event-context conditions, per-connection partition helper, `{$event.name}`/`{$event.data}` exposure contract; `extract.go`: deprecation-read path for `x-send-events`. +- `internal/runtime/` — `EventSource` gains identity (`name`) and whole-payload (`data`) access; a `ConnectionSource` (`{$connection.*}`) for per-connection matching. +- `internal/loader/` + `internal/server/event_server.go` — trigger classification at load (event-driven via `{$event.*}` match vs `x-mock-interval` vs reply), `x-send-events` mapping shim, drop of the subscription-key grouping (`groupSubscribedExamples`/`messageDeliverable`). +- `internal/server/engine.go` — fire-time selection runs the shared `SelectAsyncExample` pipeline against the event context; two-phase recipient partition at delivery. +- `internal/server/server.go` — management route registration (renames, removed schedule, new `/events`, `/stream`, `/examples/{exampleId}`), build-time wiring. +- `internal/server/server_management.go` — unified `AddExampleRequest` (`match`/`interval`/`delay`) + context-aware single-trigger validation; runtime registration path; example removal. +- `internal/server/scheduler.go` — generalize `pushScheduler` to per-example `x-mock-interval` jobs with per-delivery templating; emits schedule start/stop notifications. +- `internal/server/ws_adapter.go` / `internal/server/signalr_hub.go` — connect/receive trigger hooks; consumer lifecycle notifications; connection metadata capture at upgrade (query/headers) for `{$connection.*}`. +- `internal/server/manage_ws.go` (new) — `/_mock/stream` upgrade handler, filters, envelope encoder. +- `internal/server/fire_event.go` → `handleEvents` with `type` discriminator. +- `api/openapi.yaml` — `/async/*` paths, `/events` (`type`), `/examples` `match`/`interval`/`delay` + `DELETE`, `/stream` contract + envelope schemas, deprecated aliases + `x-send-events` note, `410` on schedule, `consumers.channel` optional. +- Tests — extensions pipeline unit tests (event context, partition, timing), trigger classification, runtime registration, built-in firing, scheduler intervals, consumers get-all, stream filters/envelopes; integration under `test/asyncapi/management-api/`. +- Docs — `docs/extensions.md` (event context + timing + `{$connection.*}` sections), `docs/architecture.md` async-management rewrite, `CHANGELOG.md`. \ No newline at end of file diff --git a/openspec/changes/async-management-api-extensions/specs/asyncapi-management/spec.md b/openspec/changes/async-management-api-extensions/specs/asyncapi-management/spec.md new file mode 100644 index 0000000..c430eb1 --- /dev/null +++ b/openspec/changes/async-management-api-extensions/specs/asyncapi-management/spec.md @@ -0,0 +1,68 @@ +# asyncapi-management Delta + +## Purpose + +Runtime driving of async mocking is made protocol-neutral and reactive: consumers can be listed globally, recurring delivery is expressed through the runtime `interval` field (`x-mock-interval` extension) instead of a dedicated schedule endpoint, and a general management WebSocket stream exposes runtime events. + +## ADDED Requirements + +### Requirement: Management WebSocket event stream +The mock server SHALL expose a general management WebSocket stream at `GET /_mock/stream` (upgrade) through which a client subscribes to runtime notifications. V1 SHALL be notifications-only: the client sets filters at connection time via `events` and `channels` query parameters (comma-separated, `*` wildcard supported), and the server pushes JSON envelopes. A non-upgrade request to `/_mock/stream` SHALL be rejected. + +#### Scenario RS.AMG.23: Subscribing with event and channel filters +- **WHEN** a client connects to `/_mock/stream` with `?events=orderCreated&channels=/alerts` +- **THEN** the client receives envelopes only for matching events and channels; an omitted filter matches everything + +#### Scenario RS.AMG.24: Receiving an event-fired envelope +- **WHEN** a named event fires (spec-triggered or via the management API) and a subscribed client is connected +- **THEN** the client receives an envelope of type `event` with the event name, payload, schema scope, and global flag + +#### Scenario RS.AMG.25: Receiving a push envelope +- **WHEN** a management push delivers a message to a channel +- **THEN** a subscribed client receives an envelope of type `push` with the channel, target connection (when targeted), and payload + +#### 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 + +#### 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}` +- **THEN** a subscribed client receives an envelope of type `schedule` with a `started`/`stopped` action, example ID, channel, and interval + +#### Scenario RS.AMG.28: Non-upgrade request to the stream endpoint +- **WHEN** a plain HTTP (non-WebSocket) request is sent to `/_mock/stream` +- **THEN** the server responds with HTTP 405 + +## MODIFIED Requirements + +### Requirement: Connected consumer discovery +The mock server SHALL expose the currently connected consumers per AsyncAPI channel, including open SignalR streams. The `channel` query parameter SHALL be optional: when omitted, the server SHALL return consumers across all channels; when present, it SHALL return only consumers of that channel. + +#### 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) + +#### 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 +- **THEN** the server returns an empty list + +#### 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 + +## REMOVED Requirements + +### Requirement: Recurring scheduled push +The mock server SHALL support scheduling repeated pushes of a message example to a channel at a fixed interval. + +#### Scenario RS.AMG.12: Scheduling a recurring push +- **WHEN** a management request schedules a push with an `interval` in milliseconds +- **THEN** the message is delivered repeatedly at that interval until stopped (or the server shuts down) + +#### Scenario RS.AMG.13: Stopping a recurring push +- **WHEN** a management request stops a previously scheduled recurring push (by its push ID) +- **THEN** no further deliveries occur for that schedule + +**Reason**: The dedicated schedule surface was a single-variant duplicate of the `cron` built-in. Recurring delivery is now expressed uniformly through the runtime `interval` field / `x-mock-interval` extension on message examples, removing the parallel endpoint and its push-ID lifecycle. + +**Migration**: Use `POST /_mock/examples` with an AsyncAPI target, `response.body`, and `interval: ` to start recurrence, and `DELETE /_mock/examples/{exampleId}` to stop it. The legacy `/_mock/ws/schedule*` paths respond with HTTP 410 Gone pointing to `POST /_mock/examples`. \ No newline at end of file diff --git a/openspec/changes/async-management-api-extensions/specs/event-driver/spec.md b/openspec/changes/async-management-api-extensions/specs/event-driver/spec.md new file mode 100644 index 0000000..8bcff5d --- /dev/null +++ b/openspec/changes/async-management-api-extensions/specs/event-driver/spec.md @@ -0,0 +1,96 @@ +# event-driver Delta + +## Purpose + +Event-driven emission on AsyncAPI message examples moves from the `x-send-events` subscription key to `x-mock-match` evaluated against an event context; recurrence moves to the `x-mock-interval` timing extension and built-ins are actually fired. + +## MODIFIED Requirements + +### Requirement: Broadcast delivery with client-side filtering +Event-driven messages SHALL be broadcast to the consuming channel's connected consumers by default; consumers may filter by payload. When the example's `x-mock-match` additionally references `{$connection.*}`, delivery SHALL be narrowed to the consumers satisfying those per-connection conditions (two-phase recipient partition); the mock does not otherwise route by session or account. + +#### Scenario RS.EVT.12: Broadcasting an event-driven message +- **WHEN** an event fires and a message example matching it exists on a channel with active consumers and no `{$connection.*}` conditions +- **THEN** the templated message is emitted to all consumers connected to that channel + +#### Scenario RS.EVT.13: Emitting into an open SignalR stream +- **WHEN** an event fires and the matching channel is a SignalR hub stream with open invocation handles +- **THEN** the templated message is pushed as a `StreamItem` into the channel's open streams (per `signalr-hub-runtime`) + +#### Scenario RS.EVT.14: Event with no subscribers +- **WHEN** a named event fires but no message example matches it +- **THEN** the event is accepted with no delivery (no error) + +#### Scenario RS.EVT.15: Event with no consumers +- **WHEN** an event fires, a matching example exists, but the channel has no connected consumers +- **THEN** the event is accepted without error and no message is delivered + +## ADDED Requirements + +### Requirement: Event-driven emission on an AsyncAPI message example +The mock server SHALL emit an AsyncAPI message example in response to events when its `x-mock-match` references the event context. The event identity is matched via `{$event.name}` (named-event name or built-in kind `connect`/`receive`); payload conditions use `{$event.}` and the whole payload via `{$event.data}`. The example's payload SHALL be templated at emission time against `{$event.*}`, `{$state.*}`, and `{$env.*}`. Periodic emission SHALL be declared with `x-mock-interval` rather than an event condition. + +#### Scenario RS.EVT.22: Emitting on a named event +- **WHEN** a message example has `x-mock-match: {'{$event.name}': }` +- **THEN** the message is emitted to the channel's consumers whenever that event fires + +#### Scenario RS.EVT.23: Event payload in consumer template +- **WHEN** an event fires with a payload and the message example references `{$event.}` +- **THEN** the expression resolves to the event payload value at emission time + +#### Scenario RS.EVT.24: Built-in connect trigger +- **WHEN** a message example has `x-mock-match: {'{$event.name}': connect}` +- **THEN** the message is emitted to the (just-connected) consumer when it connects, subject to an optional `x-mock-delay` + +#### Scenario RS.EVT.25: Built-in receive trigger +- **WHEN** a message example has `x-mock-match: {'{$event.name}': receive}` and the channel receives a client message +- **THEN** the message is emitted with the inbound client message exposed in the event context + +#### Scenario RS.EVT.26: Periodic emission via x-mock-interval +- **WHEN** a message example declares `x-mock-interval: ` instead of any event condition or match +- **THEN** the message is emitted repeatedly to the channel's consumers at the given interval until removed or the server shuts down +- **AND** the example SHALL NOT carry an `x-mock-match` (a periodically driven example has exactly one trigger); a spec declaring both is rejected at load + +### Requirement: Per-connection event delivery +The mock server SHALL narrow event-driven delivery to consumers whose connection context satisfies the example's `{$connection.*}` conditions (e.g., `'{$connection.id}': '{$event.connectionId}'`), evaluating non-connection conditions once per emission and connection conditions per candidate. + +#### Scenario RS.EVT.19: Targeted event delivery by connection id +- **WHEN** an example has `x-mock-match: {'{$event.name}': orderCreated, '{$connection.id}': '{$event.connectionId}'}` and the event fires with a `connectionId` payload +- **THEN** only the consumer whose connection id equals the payload value receives the message + +### Requirement: x-send-events deprecation mapping +The mock server SHALL accept legacy `x-send-events` entries by mapping each `{on, wait}` to the unified form during loading, writing a deprecation note in verbose mode: `on` → `x-mock-match: {'{$event.name}': on}` for named/`connect`/`receive`, and `{on: cron, wait: N}` → `x-mock-interval: N`. + +#### Scenario RS.EVT.18: Mapping legacy x-send-events to match +- **WHEN** a spec still uses `x-send-events: [{on: orderCreated}]` or `[{on: cron, wait: 1000}]` +- **THEN** the server behaves as if the example declared `x-mock-match: {'{$event.name}': orderCreated}` (respectively `x-mock-interval: 1000`) and logs a deprecation note in verbose mode +- **AND** a `{on: cron}` entry without a positive `wait` SHALL be rejected at load with an error naming the missing interval (an interval of 0 would otherwise silently register a dead reply example) + +## REMOVED Requirements + +### Requirement: Event subscription on an AsyncAPI message example +The mock server SHALL support subscribing an AsyncAPI message example to events via the `x-send-events` extension. Each entry references a named event or a built-in trigger (`receive`, `connect`, `cron`). + +#### Scenario RS.EVT.7: Subscribing to a named event +- **WHEN** a message example has `x-send-events` containing `{on: }` +- **THEN** the message is emitted to the channel's consumers whenever that event fires + +#### Scenario RS.EVT.8: Event payload in consumer template +- **WHEN** an event fires with a payload and the subscribed message example references `{$event.}` +- **THEN** the expression resolves to the event payload value at emission time + +#### Scenario RS.EVT.9: Built-in connect trigger +- **WHEN** a message example's `x-send-events` contains `{on: connect}` +- **THEN** the message is emitted to a consumer when it connects (with an optional `wait` delay) + +#### Scenario RS.EVT.10: Built-in cron trigger +- **WHEN** a message example's `x-send-events` contains `{on: cron, wait: }` +- **THEN** the message is emitted repeatedly to the channel's consumers at the given interval + +#### Scenario RS.EVT.11: Built-in receive trigger +- **WHEN** a message example's `x-send-events` contains a flat `receive` entry +- **THEN** the message is emitted when the channel receives a matching client message + +**Reason**: `x-send-events on:` duplicated `x-mock-match` (coarse event-name equality on top of the existing matcher) and was the only reject — event-driven emission now uses the unified matcher against an event context, and recurrence is the `x-mock-interval` timing extension rather than a `cron` event. + +**Migration**: Replace `x-send-events: [{on: }]` with `x-mock-match: {'{$event.name}': }`; `{on: connect, wait: N}` with `x-mock-match: {'{$event.name}': connect}` + `x-mock-delay: N`; `{on: receive}` with `x-mock-match: {'{$event.name}': receive}`; `{on: cron, wait: N}` with `x-mock-interval: N`. Until the next release the old form keeps working through the loading shim. \ No newline at end of file diff --git a/openspec/changes/async-management-api-extensions/specs/extensions/spec.md b/openspec/changes/async-management-api-extensions/specs/extensions/spec.md new file mode 100644 index 0000000..1b57e7f --- /dev/null +++ b/openspec/changes/async-management-api-extensions/specs/extensions/spec.md @@ -0,0 +1,95 @@ +# extensions Delta + +## Purpose + +`x-mock-match` becomes the single selection extension across sync and async examples: it selects against an HTTP/request context for OpenAPI and against an event context (`{$event.*}`) for async-driven examples, filters recipients per connection (`{$connection.*}`), and is complemented by timing-only sibling extensions. + +## ADDED Requirements + +### Requirement: Event-context example matching +The mock server SHALL evaluate `x-mock-match` against an event context for async-driven examples. The event context SHALL expose the event identity as `{$event.name}` (the named-event name or the built-in kind), the whole event payload as `{$event.data}`, and each payload field as `{$event.}`. `{$event.name}`/`{$event.data}` are reserved metadata names; payload fields with those names are shadowed. + +#### Scenario RS.EXT.18: Matching the event identity +- **WHEN** an async-driven example has `x-mock-match: {'{$event.name}': orderCreated}` and the `orderCreated` event fires +- **THEN** the server emits the example to the channel's consumers + +#### Scenario RS.EXT.19: Matching the event payload +- **WHEN** an async-driven example has `x-mock-match: {'{$event.accountId}': 'acc-1'}` (or a JSON-schema condition on `{$event.data}`) +- **AND** the fired event's payload satisfies the condition +- **THEN** the server emits the example; otherwise it does not + +#### Scenario RS.EXT.21: Matching built-in events +- **WHEN** an async-driven example has `x-mock-match: {'{$event.name}': connect}` or `{'{$event.name}': receive}` +- **THEN** the example is emitted when a consumer connects to the channel, or when the channel receives a client message, respectively + +### Requirement: Event-driven example classification +The mock server SHALL classify a spec example as event-driven when its `x-mock-match` references `{$event.*}`, as periodically driven when it declares `x-mock-interval`, and otherwise as a sync/async reply. An example SHALL be rejected at load when it mixes `{$event.*}` match conditions with `{$request.*}`/`{$message.*}`/`{$channel.*}` conditions, or when it declares both `x-mock-interval` and any `x-mock-match` conditions (a periodic emission is single-trigger and has no match context to honor). An event-driven example whose `{$event.name}` condition value is itself a runtime expression SHALL be rejected at load: the identity must be a literal string so the subscription key can always match a fired identity. + +#### Scenario RS.EXT.20: Rejecting mixed match contexts +- **WHEN** a spec example's `x-mock-match` contains both `{$event.name}` and `{$message.payload.kind}` conditions in the same map +- **THEN** the server rejects the spec at load with a clear error + +#### Scenario RS.EXT.28: Rejecting dual triggers +- **WHEN** a spec example declares both `x-mock-interval` and an `{$event.name}` match condition +- **THEN** the server rejects the spec at load with a clear error (any `x-mock-match` alongside `x-mock-interval`, event-driven or not, is a load error) + +#### Scenario RS.EXT.33: Rejecting a non-literal event identity +- **WHEN** a spec example has `x-mock-match: {'{$event.name}': '{$state.envName}'}` +- **THEN** the server rejects the spec at load with a clear error instead of registering a subscription keyed by the literal expression string + +#### Scenario RS.EXT.34: Wildcard identity without an {$event.name} pin +- **WHEN** an event-driven match references the event context only through condition values (no `{$event.name}` condition), e.g. `'{$connection.id}': '{$event.connectionId}'` +- **THEN** the example is registered as event-driven with a wildcard identity that evaluates against every fired event + +### Requirement: Per-connection recipient matching +The mock server SHALL partition `x-mock-match` at delivery time: conditions referencing `{$connection.*}` on either side form the recipient filter, evaluated per candidate connection; all other conditions are evaluated once per emission. Only candidates that satisfy both phases receive the message. When no condition references `{$connection.*}`, the server SHALL broadcast to all consumers of the channel as today. + +#### Scenario RS.EXT.24: Two-phase recipient partition +- **WHEN** an async-driven example has `x-mock-match` with a common condition and `'{$connection.id}': '{$event.connectionId}'`, and the event fires +- **THEN** the server evaluates the common condition once, then evaluates only the `{$connection.*}` condition against each candidate connection and delivers the payload to the connections whose id matches the event's `connectionId` + +#### Scenario RS.EXT.25: Broadcast fast path +- **WHEN** an async-driven example has no `{$connection.*}` conditions and no `{$connection.*}` references +- **THEN** the server broadcasts the emitted message to all consumers of the channel (unchanged behavior, no per-connection evaluation) + +#### Scenario RS.EXT.26: Single-recipient connect built-in +- **WHEN** a `connect` event fires and the connected consumer satisfies the example's `{$connection.*}` conditions +- **THEN** the server delivers the message to that single consumer only + +#### Scenario RS.EXT.27: Connection context exposure +- **WHEN** a condition references `{$connection.id}`, `{$connection.channel}`, `{$connection.query.}`, or `{$connection.header.}` +- **THEN** the values resolve from the consumer's connection id, channel address, and metadata captured at upgrade + +### Requirement: Timing extensions x-mock-interval and x-mock-delay +The mock server SHALL support `x-mock-interval` (positive integer milliseconds) on an async example to emit it repeatedly at that cadence, and `x-mock-delay` (integer milliseconds, default 0) to delay emission after an event fire. Neither is an event identity; `x-mock-interval` marks a periodically driven example. Timing values SHALL be integral milliseconds: a fractional value SHALL be rejected at load rather than silently truncated, and a periodically driven example SHALL honor `x-mock-skip` like every other example. + +#### Scenario RS.EXT.22: Interval-driven periodic emission +- **WHEN** an async example declares `x-mock-interval: 1000` +- **THEN** the server emits the message to the channel's consumers roughly every 1000 ms until the example is removed or the server shuts down + +#### Scenario RS.EXT.35: Rejecting a fractional x-mock-interval +- **WHEN** an async example declares `x-mock-interval: 2.5` (a fractional millisecond value) +- **THEN** the server rejects the spec at load with a clear error instead of truncating to 2 ms + +#### Scenario RS.EXT.36: Rejecting a fractional x-mock-delay +- **WHEN** an async example declares `x-mock-delay: 2.5` +- **THEN** the server rejects the spec at load with a clear error instead of silently dropping the delay + +#### Scenario RS.EXT.37: Skipping a periodically driven example +- **WHEN** a periodically driven example declares `x-mock-skip` and a consumer is connected to its channel +- **THEN** the server never emits the example's message while the skip flag is set + +#### Scenario RS.EXT.23: Delayed event emission +- **WHEN** an async-driven example declares `x-mock-delay: 150` and its event fires +- **THEN** the server emits the message 150 ms after the fire + +#### Scenario RS.EXT.30: Reply-path condition values stay literal +- **WHEN** an `x-mock-match` condition key references the reply context (`{$request.*}`/`{$message.*}`/`{$channel.*}`) and its value is a full runtime-expression string +- **THEN** the value is compared as a literal string, never pre-resolved (only conditions whose key references `{$event.*}` or `{$connection.*}` pre-resolve full-expression values) + +### Requirement: Fail-closed match evaluation +An `x-mock-match` condition that references an expression source unavailable in the evaluation context SHALL fail closed (the example does not match) and, in verbose mode, SHALL log a warning. + +#### Scenario RS.EXT.29: Event context unavailable in reply path +- **WHEN** a sync example references `{$event.*}` or a reply-path async example references `{$connection.*}` +- **THEN** the condition never matches and the server logs a verbose-mode warning rather than erroring \ No newline at end of file diff --git a/openspec/changes/async-management-api-extensions/specs/management-api/spec.md b/openspec/changes/async-management-api-extensions/specs/management-api/spec.md new file mode 100644 index 0000000..d4699fe --- /dev/null +++ b/openspec/changes/async-management-api-extensions/specs/management-api/spec.md @@ -0,0 +1,73 @@ +# Management API Delta + +## Purpose + +Unified runtime example injection for sync (OpenAPI) and async (AsyncAPI) mocking: async targets take `match`/`interval`/`delay` mirroring the `x-mock-match`/`x-mock-interval`/`x-mock-delay` extensions, with strict single-trigger validation and a `type`-discriminated event resource. + +## ADDED Requirements + +### 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.*}`. + +#### 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}` +- **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 `match: {'{$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 +- **THEN** the registered message is delivered only to the channel's consumers satisfying that connection condition when the event fires + +### 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. + +#### 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 +- **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 +- **THEN** the server responds with HTTP 400 + +#### Scenario RS.MAPI.34: 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 +- **THEN** the server responds with HTTP 400 and registers nothing (a runtime example needs a trigger; a connection-only match has none) + +### 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. + +#### Scenario RS.MAPI.30: Removing a dynamic example +- **WHEN** a DELETE request is sent to `/_mock/examples/{exampleId}` for an existing example +- **THEN** the server removes the example, stops any recurring delivery, and responds with success + +#### Scenario RS.MAPI.31: Removing an unknown example +- **WHEN** a DELETE request is sent to `/_mock/examples/{unknownId}` that does not exist +- **THEN** the server responds with HTTP 404 + +## MODIFIED Requirements + +### Requirement: Fire an event on the event bus +The management API SHALL expose `POST /_mock/events` to fire a named event ad-hoc. The request SHALL carry a required `type` discriminator (`"fire"` for V1, extensible), along with `event`, `payload`, `delay`, and `global` fields, reusing the event broker and its delay semantics (per `event-driver`). + +#### Scenario RS.MAPI.22: Firing an event via management API +- **WHEN** a `POST /_mock/events` request fires a named event with `type: fire`, a payload, and an optional delay +- **THEN** the server delivers it like a spec-triggered event (immediately or after the delay) to matching event-driven message examples + +#### Scenario RS.MAPI.23: Fire-event payload templating +- **WHEN** an ad-hoc fired event payload contains `{$state.*}` or `{$env.*}` expressions +- **THEN** they are evaluated against the schema's isolated state namespace and environment before delivery + +#### Scenario RS.MAPI.32: Invalid event type +- **WHEN** a `POST /_mock/events` request omits `type` or uses a type other than `fire` +- **THEN** the server responds with HTTP 400 \ No newline at end of file diff --git a/openspec/changes/async-management-api-extensions/tasks.md b/openspec/changes/async-management-api-extensions/tasks.md new file mode 100644 index 0000000..ae2e58b --- /dev/null +++ b/openspec/changes/async-management-api-extensions/tasks.md @@ -0,0 +1,76 @@ +# Tasks: Async management API extensions + +TDD workflow for every item below (per AGENTS.md design-first/TDD rule): +1. **Red** — write or edit the test for the parent/consumer first (mocking the new/edited interface), prove it fails. +2. **Red** — add the interface-level unit test (mock its dependencies), prove it fails. +3. **Green** — implement the interface until all tests pass (`go test ./...`). +4. **Refactor** — keep cognitive complexity low, cohesion high; re-run tests + lint. + +## 1. Route re-organization (renames, `/events`, schedule removal) + +- [x] 1.1 **Red**: update path references in `internal/server/management_async_test.go`, `internal/server/management_async_lifecycle_test.go`, and `internal/server/fire_event_endpoint_test.go` from `/_mock/ws/*` to `/_mock/async/*` and from `/_mock/events/fire` to `/_mock/events`; verify `go test ./internal/server/` fails (routes 404/405) +- [x] 1.2 **Red**: add failing tests that pin the deprecated aliases (`/_mock/ws/push`, `/_mock/ws/consumers`, `/_mock/ws/disconnect`, `/_mock/events/fire` still work) and that `/_mock/ws/schedule` + `/_mock/ws/schedule/{pushId}` answer `410 Gone` with the `POST /_mock/examples` guidance body; verify they fail +- [x] 1.3 **Green**: in `internal/server/server.go` `registerManagementRoutes`, register the canonical `/_mock/async/*` paths plus the deprecated alias routes and the `410` answers for schedule in `internal/server/management_async.go`; verify 1.1 and 1.2 tests pass +- [x] 1.4 **Red**: write unit tests for `/events` that `type` is required and `"fire"` is the only accepted value (missing/unknown → 400, `type:"fire"` reproduces previous fire behavior), asserting payload templating and delay semantics are unchanged (RS.MAPI.22-23, RS.MAPI.32); verify they fail +- [x] 1.5 **Green**: replace `handleFireEvent` with `handleEvents` in `internal/server/fire_event.go` adding the `type` discriminator and reusing the existing fire path; verify 1.4 tests and the retained alias test pass, then run lint/typecheck + +## 2. Unified match pipeline (event context, timing, recipient partition) + +- [x] 2.1 **Red**: write failing `internal/runtime` tests that `EventSource` exposes `{$event.name}` (identity) and `{$event.data}` (whole payload) while `{$event.}` payload access is unchanged (RS.EXT.18-19); verify they fail +- [x] 2.2 **Green**: extend `EventSource` (`internal/runtime/expression.go`) with reserved `name`/`data` accessors without mutating the payload; verify 2.1 passes and existing event-expression tests still pass +- [x] 2.3 **Red**: write failing `internal/runtime` tests for a new `ConnectionSource` resolving `{$connection.id}`, `{$connection.channel}`, `{$connection.query.}`, `{$connection.header.}` (RS.EXT.27); verify they fail +- [x] 2.4 **Green**: add `ConnectionSource` and register it in the async-driven evaluators; verify 2.3 passes +- [x] 2.5 **Red**: write failing `internal/extensions` tests — matching against an event context (identity + payload, literal and JSON-schema; RS.EXT.18-19, RS.EXT.21), and fail-closed with a verbose warning when the context is unavailable (RS.EXT.29); verify they fail +- [x] 2.6 **Green**: extend the match evaluation (`internal/extensions/match.go` / evaluator construction in `internal/server/engine.go`) to accept event/connection contexts; verify 2.5 passes +- [x] 2.7 **Red**: write failing `internal/extensions` tests for the per-connection condition partition helper — conditions referencing `{$connection.*}` on either side land in the connection bucket, others in the common bucket; empty connection bucket → broadcast fast path (RS.EXT.24-25); verify they fail +- [x] 2.8 **Green**: implement the partition helper; verify 2.7 passes and existing `match_test.go`/`parity_test.go` stay green +- [x] 2.9 **Red**: write failing tests parsing/decorating `x-mock-match` timing siblings — `x-mock-interval` (positive ms) marks a periodically driven example, `x-mock-delay` (ms) delays event emission (RS.EXT.22-23); verify they fail +- [x] 2.10 **Green**: add `x-mock-interval`/`x-mock-delay` as example extensions consumed by the classification path; verify 2.9 passes + +## 3. Trigger classification, broker, scheduler, built-ins + +- [x] 3.1 **Red**: write failing unit tests that classify a spec example at load — event-driven iff `x-mock-match` references `{$event.*}`, periodically driven iff `x-mock-interval`, mixed match contexts or dual triggers rejected with a clear load error (RS.EXT.20, RS.EXT.28); verify they fail +- [x] 3.2 **Green**: implement trigger classification in the AsyncAPI load/registration path (`internal/server/event_server.go`, replacing `collectSchemaSubscriptions`/`messageDeliverable` grouping) plus the `x-send-events` mapping shim with a verbose deprecation note (RS.EVT.18); verify 3.1 and existing loader tests pass +- [x] 3.3 **Red**: write failing broker tests — event-driven examples registered by identity + schema scope resolve only for their schema (and globally), and a cheap `hasSubscribers(name, schema)` returns false when none; verify they fail +- [x] 3.4 **Green**: refit `eventBroker` (`internal/server/event_broker.go`) to register/reoslve match-identified examples; verify 3.3 passes +- [x] 3.5 **Red**: write failing scheduler tests — a per-example interval job delivers at its cadence and stops on cancel/shutdown; verify they fail +- [x] 3.6 **Green**: generalize `internal/server/scheduler.go` into per-example `{id, interval, deliver func()}` jobs wired from classification and runtime `interval`, with shutdown coverage (design D4); verify 3.5 passes +- [x] 3.7 **Red**: write failing tests for built-in firing — `connect` fires schema-local on consumer connection with the single connecting connection as recipient, `receive` fires schema-local on inbound traffic carrying the inbound message in the event context, both gated on `hasSubscribers` (RS.EVT.9, RS.EVT.11, RS.EXT.26); verify they fail +- [x] 3.8 **Green**: wire `connect` (ws adapter + `signalr_hub.go` connect) and `receive` (ws adapter read loop + SignalR dispatch) hooks; verify 3.7 passes +- [x] 3.9 **Red**: write failing integration-style unit tests for fire-time selection — the shared `SelectAsyncExample` pipeline runs against the event context and delivers through the two-phase partition (targeted `'{$connection.id}': '{$event.connectionId}'`, broadcast fast path) (RS.EVT.19, RS.EXT.24-25); verify they fail +- [x] 3.10 **Green**: run selection + delivery through the partition (design D6) with connection metadata captured at upgrade (`wsConnection`, SignalR connection); verify 3.9 passes + +## 4. Unified example injection (`/examples`) + +- [x] 4.1 **Red**: write failing validation tests asserting `POST /_mock/examples` rejects `path`+`channel`, `path` with `match`/`interval` but no async target, `interval` alongside an event `match`, and a non-positive `interval`, with 400 (RS.MAPI.27-29), while existing valid request shapes still pass; verify they fail +- [x] 4.2 **Green**: in `internal/server/server_management.go`, replace `addExampleRequestSchema` with the `oneOf` sync/async two-branch schema and extend the decoded struct with `match`/`interval`/`delay` plus the single-trigger check; verify 4.1 tests pass and existing add-example tests still pass +- [x] 4.3 **Red**: write failing HTTP tests — `handleAddExample` with `match` (event) or `interval` (recurring) registers a live async-driven example returning `{success, id}` and delivers on fire/at cadence with per-connection targeting (RS.MAPI.24-26, RS.MAPI.33), while the no-`match`/`interval` path keeps the inbound-reply registry behavior; verify they fail +- [x] 4.4 **Green**: route `handleAddExample` through the match/interval classification path from section 3; verify 4.3 tests pass +- [x] 4.5 **Red**: write failing tests for `DELETE /_mock/examples/{exampleId}` — removes a registry example, cancels an interval example (no further deliveries), returns 404 for an unknown id (RS.MAPI.30-31, RS.MAPI.25); verify they fail +- [x] 4.6 **Green**: implement `DELETE /_mock/examples/{exampleId}` via the `Server` registry map from 3.2/3.6; verify 4.5 tests pass + +## 5. Consumers get-all and push regression + +- [x] 5.1 **Red**: write failing unit tests for `handleAsyncConsumers` — `channel` omitted returns the flat union of all raw-ws connections (across channels) and all hub channels' open streams, empty when none (RS.AMG.22, RS.AMG.8-9); verify they fail +- [x] 5.2 **Green**: make `channel` optional in `handleAsyncConsumers` (`internal/server/management_async.go`); verify 5.1 tests pass and the existing `GET /_mock/async/consumers?channel=/alerts` test still passes +- [x] 5.3 **Red**: add an HTTP regression test asserting one-shot `POST /_mock/async/push` (immediate, delayed, targeted, broadcast) is unchanged after the rename; pin it to the new canonical path and verify it fails only on the old path there +- [x] 5.4 **Green**: confirm push handlers are intact on the canonical path; verify 5.3 passes + +## 6. Management WebSocket stream (`/_mock/stream`) + +- [x] 6.1 **Red**: write failing tests for `manage_ws.go` — a ws client connects and receives envelopes, a plain HTTP GET returns 405 (RS.AMG.28), and connect-time `events`/`channels` filters (comma-separated, `*` glob) are parsed; verify they fail +- [x] 6.2 **Green**: implement `internal/server/manage_ws.go` (upgrade with `Connection: Upgrade` check, filter parsing, ping/pong loop, dedicated `manageWSRegistry` excluded from `/_mock/async/consumers`); verify 6.1 passes +- [x] 6.3 **Red**: write failing tests for an `eventBus` observer hook emitting `event` and `push` envelopes filtered per-subscriber (name/payload/schema/global) on `fire` and `deliver` (RS.AMG.24-25); verify they fail +- [x] 6.4 **Green**: add the observer hook to `eventBus` (`event_broker.go`/`event_server.go`) and subscribe `/_mock/stream` connections to it; verify 6.3 passes +- [x] 6.5 **Red**: write failing tests that consumer connect/disconnect (ws adapter + SignalR) and interval start/stop (from scheduler jobs) emit `consumer`/`schedule` envelopes (RS.AMG.26-27); verify they fail +- [x] 6.6 **Green**: wire lifecycle and scheduler start/stop hooks into envelope emission; verify 6.5 passes + +## 7. OpenAPI contract and docs + +- [x] 7.1 Update `api/openapi.yaml` — `/_mock/async/{push,consumers,disconnect}`, `/events` with `EventRequest.type` enum, `/examples` `match`/`interval`/`delay` + `DELETE /examples/{exampleId}`, `/stream` prose + envelope schemas in `components`, `consumers.channel` optional, deprecated markers on `/ws/*` and `/events/fire`, `410` description on `/ws/schedule*`; verify the file parses and any project openapi lint/validation used in CI passes +- [x] 7.2 Update `docs/extensions.md` (event-context matching, `{$connection.*}` partition, `x-mock-interval`/`x-mock-delay`, `x-send-events` deprecation), `docs/architecture.md` (async-management + event sections), and add a `CHANGELOG.md` entry; verify docs build/lint passes + +## 8. Integration verification (black-box, TDD as one red→green cycle) + +- [x] 8.1 **Red**: add integration tests under `test/asyncapi/management-api/` (skip on `testing.Short()`) covering — runtime `match` on `{$event.name}` fired via `POST /_mock/events`; runtime `interval` recurrence then stop via `DELETE /examples/{id}`; per-connection targeting (`{$connection.id}`); `connect`/`receive` built-ins (spec + runtime); `x-send-events` shim still emitting; a `/_mock/stream` subscriber with filters receiving event/push/consumer/schedule envelopes; schedule alias `410`; and the deprecated alias still working; verify they fail against the pre-change surface +- [x] 8.2 **Green**: run the full suite against the implemented server so 8.1 integration tests pass along with all earlier unit tests — `go test ./...`, project lint/typecheck targets from the `Makefile`, coverage threshold (≥70%) maintained, and `openspec validate async-management-api-extensions` still passes \ No newline at end of file diff --git a/test/_shared/clihelper/clihelper.go b/test/_shared/clihelper/clihelper.go index 644c739..6da1540 100644 --- a/test/_shared/clihelper/clihelper.go +++ b/test/_shared/clihelper/clihelper.go @@ -311,21 +311,34 @@ func StopServer(t *testing.T, cmd *exec.Cmd) { } } - // Wait a bit for process to exit - done := make(chan struct{}) - go func() { - cmd.Wait() - close(done) - }() - - select { - case <-done: - // Process exited - case <-time.After(2 * time.Second): + // Wait a bit for the process to exit without racing the Wait goroutine + // spawned by Run. A second cmd.Wait() here is a data race; probing + // liveness with signal 0 is atomic and never consumes the reaping Wait. + if err := waitForExit(cmd, 2*time.Second); err != nil { t.Logf("Timeout waiting for process to exit") } } +// waitForExit polls a process's liveness until it exits or the timeout elapses. +// It probes the PID with signal 0 (atomic, race-free) instead of calling Wait, +// which must only ever be invoked once per exec.Cmd. +func waitForExit(cmd *exec.Cmd, timeout time.Duration) error { + deadline := time.After(timeout) + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-deadline: + return fmt.Errorf("process did not exit within %s", timeout) + case <-ticker.C: + if err := cmd.Process.Signal(syscall.Signal(0)); err != nil { + // ESRCH ("no such process") or "os: process already finished". + return nil + } + } + } +} + // RunCmd executes a CLI command with optional environment variables. // Returns the combined output (stdout+stderr) and a cleanup function to kill the process. func RunCmd(t *testing.T, args []string, env []string) (string, func()) { diff --git a/test/_shared/resources/asyncapi-management.yaml b/test/_shared/resources/asyncapi-management.yaml new file mode 100644 index 0000000..4e80e3c --- /dev/null +++ b/test/_shared/resources/asyncapi-management.yaml @@ -0,0 +1,34 @@ +asyncapi: 3.0.0 +info: + title: Alerts + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + alertMsg: + examples: + - name: connectWelcome + payload: + msg: "welcome" + x-mock-match: + '{$event.name}': "connect" + - name: levelUp + payload: + level: "{$event.level}" + msg: "{$event.msg}" + x-mock-match: + '{$event.name}': "levelUp" + - name: legacyAlert + payload: + legacy: "{$event.level}" + x-send-events: + - on: legacyAlert +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' \ No newline at end of file diff --git a/test/asyncapi/management-api/management_api_test.go b/test/asyncapi/management-api/management_api_test.go new file mode 100644 index 0000000..243f336 --- /dev/null +++ b/test/asyncapi/management-api/management_api_test.go @@ -0,0 +1,282 @@ +package managapi_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/test/_shared/clihelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func startManagementServer(t *testing.T) (int, func()) { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../../_shared/resources/asyncapi-management.yaml", "").Run() + cleanup := func() { clihelper.StopServer(t, cmd) } + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + _ = errCh + return port, cleanup +} + +func wsConnect(t *testing.T, port int, path string) *websocket.Conn { + t.Helper() + url := fmt.Sprintf("ws://localhost:%d%s", port, path) + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + require.NoError(t, err) + return conn +} + +/* +Scenario: Runtime match on {$event.name} fired via POST /_mock/events +Given a running server with a match on {$event.name} and a connected consumer +When POST /_mock/events fires the named event +Then the templated message reaches the consumer + +Related spec scenarios: RS.MAPI.24, RS.EXT.18, RS.MAPI.22 +*/ +func TestIntegration_EventMatchFired(t *testing.T) { + t.Parallel() + port, stop := startManagementServer(t) + defer stop() + + conn := wsConnect(t, port, "/alerts") + defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, _ = conn.ReadMessage() // consume welcome snapshot + + req, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/events", port), "application/json", + strings.NewReader(`{"type":"fire","event":"levelUp","payload":{"level":"warn","msg":"boom"}}`)) + require.NoError(t, err) + _ = req.Body.Close() + assert.Equal(t, 200, req.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + deadline := time.Now().Add(3 * time.Second) + got := "" + for time.Now().Before(deadline) { + _, raw, rerr := conn.ReadMessage() + if rerr != nil { + break + } + got = string(raw) + if strings.Contains(got, `"boom"`) { + break + } + } + assert.Contains(t, got, `"level":"warn"`) + assert.Contains(t, got, `"msg":"boom"`) +} + +/* +Scenario: A runtime interval example recurs then stops via DELETE +Given a registered interval example and a connected consumer +When DELETE /_mock/examples/{id} cancels it +Then the recurring delivery stops + +Related spec scenarios: RS.MAPI.25, RS.MAPI.30, RS.EVT.26 +*/ +func TestIntegration_RuntimeIntervalThenStop(t *testing.T) { + t.Parallel() + port, stop := startManagementServer(t) + defer stop() + + conn := wsConnect(t, port, "/alerts") + defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, _ = conn.ReadMessage() // welcome + + addBody := `{"channel":"/alerts","interval":200,"response":{"code":200,"body":{"mytick":true}}}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/examples", port), "application/json", strings.NewReader(addBody)) + require.NoError(t, err) + var add map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&add)) + resp.Body.Close() //nolint:errcheck + require.Equal(t, 200, resp.StatusCode) + exampleID, _ := add["id"].(string) + require.NotEmpty(t, exampleID) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + deadline := time.Now().Add(3 * time.Second) + found := false + for time.Now().Before(deadline) { + _, raw, rerr := conn.ReadMessage() + if rerr != nil { + break + } + if strings.Contains(string(raw), `"mytick"`) { + found = true + break + } + } + require.True(t, found, "expected a first mytick delivery") + + // Delete the example; assert success and then no further mytick deliveries + // at the cadence. A single in-flight tick may already be in the pipe, so + // drain it first, then require a quiet window. + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("http://localhost:%d/_mock/examples/%s", port, exampleID), nil) + require.NoError(t, err) + delResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = delResp.Body.Close() + assert.Equal(t, 200, delResp.StatusCode) + + wait := time.Now().Add(300 * time.Millisecond) + for time.Now().Before(wait) { + _ = conn.SetReadDeadline(wait) + _, raw, rerr := conn.ReadMessage() + if rerr != nil { + break + } + t.Logf("drained %s", string(raw)) + } + _ = conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)) + _, _, rerr := conn.ReadMessage() + require.Error(t, rerr, "no mytick delivery should occur after DELETE") +} + +/* +Scenario: Per-connection targeting via {$connection.id} +Given an event match that also narrows by {$connection.id} +When the event fires with a connectionId payload +Then only the matching consumer receives the message + +Related spec scenarios: RS.EVT.19, RS.EXT.24, RS.MAPI.33 +*/ +func TestIntegration_ConnectionTargeting(t *testing.T) { + t.Parallel() + port, stop := startManagementServer(t) + defer stop() + + addBody := `{"channel":"/alerts","match":{"{$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() + require.Equal(t, 200, resp.StatusCode) + + conn1 := wsConnect(t, port, "/alerts") + defer conn1.Close() //nolint:errcheck + conn2 := wsConnect(t, port, "/alerts") + defer conn2.Close() //nolint:errcheck + _ = conn1.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, _ = conn1.ReadMessage() // welcome + _ = conn2.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, _ = conn2.ReadMessage() // welcome + + // Find the first connection's id via the consumers endpoint. + creq, err := http.Get(fmt.Sprintf("http://localhost:%d/_mock/async/consumers?channel=/alerts", port)) + require.NoError(t, err) + var cpayload map[string]any + require.NoError(t, json.NewDecoder(creq.Body).Decode(&cpayload)) + creq.Body.Close() //nolint:errcheck + items, ok := cpayload["consumers"].([]any) + require.True(t, ok) + require.NotEmpty(t, items) + first, ok := items[0].(map[string]any) + require.True(t, ok) + connID, ok := first["connectionId"].(string) + require.True(t, ok) + + body := `{"type":"fire","event":"levelUp","payload":{"connectionId":"` + connID + `"}}` + _, err = http.Post(fmt.Sprintf("http://localhost:%d/_mock/events", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + + // Exactly one of the two connections receives the ring with the targeted + // connection id; the other receives nothing. + readWith := func(conn *websocket.Conn) string { + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + _, raw, rerr := conn.ReadMessage() + if rerr != nil { + return "" + } + if strings.Contains(string(raw), `"ring"`) { + return string(raw) + } + } + return "" + } + r1 := readWith(conn1) + r2 := readWith(conn2) + + ringCount := 0 + if r1 != "" { + assert.Contains(t, r1, fmt.Sprintf(`"connectionId":"%s"`, connID)) + ringCount++ + } + if r2 != "" { + assert.Contains(t, r2, fmt.Sprintf(`"connectionId":"%s"`, connID)) + ringCount++ + } + assert.Equal(t, 1, ringCount, "exactly one consumer should receive the targeted ring") +} + +/* +Scenario: The deprecated /_mock/ws/push alias still works +Given a connected consumer and a push to the deprecated path +When the push is invoked +Then the message reaches the consumer + +Related spec scenarios: RS.AMG.1, RS.AMG.6 +*/ +func TestIntegration_DeprecatedAliasStillWorks(t *testing.T) { + t.Parallel() + port, stop := startManagementServer(t) + defer stop() + + conn := wsConnect(t, port, "/alerts") + defer conn.Close() //nolint:errcheck + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, _ = conn.ReadMessage() // welcome + + body := `{"channel":"/alerts","payload":{"alias":true}}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/ws/push", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + deadline := time.Now().Add(3 * time.Second) + got := "" + for time.Now().Before(deadline) { + _, raw, rerr := conn.ReadMessage() + if rerr != nil { + break + } + got = string(raw) + if strings.Contains(got, `"alias":true`) { + break + } + } + assert.Contains(t, got, `"alias":true`) +} + +/* +Scenario: The removed schedule endpoint answers 410 +Given a request to the removed /_mock/ws/schedule path +When the schedule endpoint is invoked +Then the server responds with 410 Gone + +Related spec scenarios: RS.AMG.12, RS.AMG.13 +*/ +func TestIntegration_ScheduleGone410(t *testing.T) { + t.Parallel() + port, stop := startManagementServer(t) + defer stop() + + body := `{"channel":"/alerts","interval":50,"payload":{"tick":true}}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/ws/schedule", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, 410, resp.StatusCode) +} From 6c251343f2507aec007d3eea22492cb80ab939fe Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Sat, 5 Sep 2026 12:09:04 +0300 Subject: [PATCH 2/2] Fix spec-coverage mapping to reach 100% scenario coverage Replace range-style scenario citations (RS.MAPI.24-26, RS.MAPI.30-31) with the individual codes, and correct headers that cited the wrong requirement scenario (EXT.33/34/35/36/37, MAPI.34, ATM.18). The coverage gate previously failed at 98.3% with two unmapped-range warnings that also leaked into the --coverage-only output and broke the CI bc comparison; warnings now go to stderr so the computed value is a clean decimal. --- internal/extensions/classify_test.go | 8 ++++---- internal/server/add_example_runtime_test.go | 2 +- internal/server/add_example_validation_test.go | 4 ++-- internal/server/async_state_test.go | 8 +++++--- internal/server/event_delivery_test.go | 4 ++-- scripts/analyze_scenario_coverage.py | 3 ++- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/internal/extensions/classify_test.go b/internal/extensions/classify_test.go index 78d2de3..f17b376 100644 --- a/internal/extensions/classify_test.go +++ b/internal/extensions/classify_test.go @@ -158,7 +158,7 @@ When ClassifyTrigger is called Then it returns a clear load error instead of registering a subscription keyed by the literal expression string (which could never match a fired identity) -Related spec scenarios: RS.EXT.20 +Related spec scenarios: RS.EXT.33 */ func TestClassifyTrigger_NonLiteralIdentityRejected(t *testing.T) { t.Parallel() @@ -182,7 +182,7 @@ When ClassifyTrigger is called Then the example is event-driven with an empty (wildcard) identity that evaluates against every fired event -Related spec scenarios: RS.EXT.20, RS.EXT.24 +Related spec scenarios: RS.EXT.34 */ func TestClassifyTrigger_EventWithoutIdentityIsWildcard(t *testing.T) { t.Parallel() @@ -206,7 +206,7 @@ value When ClassifyTrigger is called Then it returns a clear load error instead of silently truncating to an integer -Related spec scenarios: RS.EXT.22 +Related spec scenarios: RS.EXT.35 */ func TestClassifyTrigger_FractionalIntervalRejected(t *testing.T) { t.Parallel() @@ -227,7 +227,7 @@ value When ClassifyTrigger is called Then it returns a clear load error instead of silently ignoring the delay -Related spec scenarios: RS.EXT.23 +Related spec scenarios: RS.EXT.36 */ func TestClassifyTrigger_FractionalDelayRejected(t *testing.T) { t.Parallel() diff --git a/internal/server/add_example_runtime_test.go b/internal/server/add_example_runtime_test.go index 54f5cef..67c52f1 100644 --- a/internal/server/add_example_runtime_test.go +++ b/internal/server/add_example_runtime_test.go @@ -168,7 +168,7 @@ When /_mock/examples is invoked Then the sync example id carries the "dynex-" prefix and the async runtime example id carries the "rtex-" prefix, keeping the two registries disjoint -Related spec scenarios: RS.MAPI.30-31 +Related spec scenarios: RS.MAPI.30, RS.MAPI.31 */ func TestAddExample_IdsAreNamespaced(t *testing.T) { t.Parallel() diff --git a/internal/server/add_example_validation_test.go b/internal/server/add_example_validation_test.go index 3751b37..f02e2fc 100644 --- a/internal/server/add_example_validation_test.go +++ b/internal/server/add_example_validation_test.go @@ -103,7 +103,7 @@ not reference the event context When /_mock/examples is invoked Then the server responds with HTTP 400 and registers nothing -Related spec scenarios: RS.MAPI.29, RS.EXT.28 +Related spec scenarios: RS.MAPI.34 */ func TestAddExampleValidation_NonEventMatchRejected(t *testing.T) { t.Parallel() @@ -147,7 +147,7 @@ Given a POST with an async target and a literal-only match When /_mock/examples is invoked Then the server responds with HTTP 400 and registers nothing -Related spec scenarios: RS.MAPI.29 +Related spec scenarios: RS.MAPI.34 */ func TestAddExampleValidation_LiteralOnlyMatchRejected(t *testing.T) { t.Parallel() diff --git a/internal/server/async_state_test.go b/internal/server/async_state_test.go index 4c74513..73f7f65 100644 --- a/internal/server/async_state_test.go +++ b/internal/server/async_state_test.go @@ -106,11 +106,13 @@ func TestRenderMessageSpecs_Delete(t *testing.T) { /* Scenario: Cron subscriptions map to the periodic x-mock-interval shim -Given a message example subscribing to the cron built-in with a wait +Given a message example subscribing to the cron built-in with a wait and a +state-backed sequence counter When derivedExamples maps its x-send-events entry -Then the example becomes a periodically driven example with the wait interval +Then the example becomes a periodically driven example with the wait interval, +keeping the pace-by-state-and-cron behavior of the templating spec -Related spec scenarios: RS.EVT.18, RS.EXT.22 +Related spec scenarios: RS.ATM.18, RS.EVT.18, RS.EXT.22 */ func TestDerivedExamples_CronToPeriodic(t *testing.T) { t.Parallel() diff --git a/internal/server/event_delivery_test.go b/internal/server/event_delivery_test.go index 0413311..1fa0b0b 100644 --- a/internal/server/event_delivery_test.go +++ b/internal/server/event_delivery_test.go @@ -233,7 +233,7 @@ Given a nil eventBus When registerRuntimeExample is called Then it returns an error instead of a silent empty success -Related spec scenarios: RS.MAPI.24-26 +Related spec scenarios: RS.MAPI.24, RS.MAPI.25, RS.MAPI.26 */ func TestEventBus_RegisterRuntimeExampleNilBusErrors(t *testing.T) { t.Parallel() @@ -330,7 +330,7 @@ Given a periodic message example declaring x-mock-skip When the server runs the interval job with a connected consumer Then no message is delivered to the channel -Related spec scenarios: RS.EXT.22 +Related spec scenarios: RS.EXT.37 */ func TestEventDelivery_PeriodicSkipsSkippedExample(t *testing.T) { t.Parallel() diff --git a/scripts/analyze_scenario_coverage.py b/scripts/analyze_scenario_coverage.py index 0d1678f..4dcf438 100644 --- a/scripts/analyze_scenario_coverage.py +++ b/scripts/analyze_scenario_coverage.py @@ -122,7 +122,8 @@ def analyze_test_files(self) -> None: else: # Scenario mentioned in test but not found in any spec print( - f"Warning: Scenario {scenario} in {test_rel_path} not found in baseline or active change specs" + f"Warning: Scenario {scenario} in {test_rel_path} not found in baseline or active change specs", + file=sys.stderr, ) self.scenario_to_tests[scenario] = { "unit": [],