From f0c18b1e000b3f09f1b212f4a6c3ff1eb866ce2b Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Thu, 3 Sep 2026 21:04:29 +0300 Subject: [PATCH 1/4] Add AsyncAPI support with SignalR overlay and server composition refactor - Loader: auto-detect AsyncAPI 3.x (3.0.0/3.1.0), neutral asyncapi view, channel->route mapping for http/ws bindings, single MessageSpec conversion - Server: extract exampleEngine + exampleRegistry (MessageRenderer), hubManager (ConsumerBus), eventBus, pushScheduler; remove dead adapters pkg; Server slims to a coordinator wired only through New/NewWithDependencies - Protocols: raw ws adapter, http adapter with {$channel.*} address-param capture, ASP.NET Core SignalR-compatible hub (negotiate, token upgrade, handshake, streams, invocations, targeted/broadcast pushes) - Events: x-event-trigger -> x-send-events event bus with {$event.*} templating - Management: delayed/targeted/broadcast push, consumer discovery, recurring schedules, fire-event, disconnect incl. abrupt drop - Specs: six new asyncapi/event-driver/signalr-hub-runtime specs (coverage 100%) - Tests: unit + integration suites green, race detector clean, scenario coverage 250/250 --- AGENTS.md | 31 +- api/openapi.yaml | 241 +- docs/architecture.md | 94 +- docs/cli.md | 6 +- docs/project.md | 5 +- go.mod | 9 +- go.sum | 7 +- internal/asyncapi/document.go | 127 + internal/asyncapi/document_test.go | 50 + internal/asyncapi/parse.go | 337 + internal/asyncapi/parse_test.go | 313 + internal/asyncapi/testdata/test-30.yaml | 34 + internal/asyncapi/testdata/test-31.yaml | 23 + internal/extensions/event_trigger_test.go | 85 + internal/extensions/example_value.go | 152 + internal/extensions/example_value_test.go | 124 + internal/extensions/extract.go | 68 +- internal/extensions/parity_test.go | 73 + internal/loader/async_router_test.go | 316 + internal/loader/decode.go | 26 + internal/loader/router.go | 262 +- internal/loader/rpc.go | 3 + internal/loader/schema.go | 97 +- internal/loader/schema_test.go | 111 +- internal/loader/signalr_router_test.go | 88 + internal/runtime/event_source_test.go | 55 + internal/runtime/expression.go | 77 + internal/runtime/message_source_test.go | 79 + internal/server/adapters/adapters.go | 321 - internal/server/add_example_async_test.go | 82 + internal/server/async_http_adapter_test.go | 225 + internal/server/async_message.go | 110 + internal/server/async_state_test.go | 144 + internal/server/channel_params_test.go | 75 + internal/server/convert.go | 26 + internal/server/engine.go | 802 ++ internal/server/event_broker.go | 118 + internal/server/event_broker_test.go | 148 + internal/server/event_integration_test.go | 232 + internal/server/event_server.go | 171 + internal/server/fire_event.go | 50 + internal/server/fire_event_endpoint_test.go | 159 + internal/server/history_test.go | 67 + internal/server/http_adapter.go | 55 + internal/server/hubmanager.go | 66 + internal/server/interfaces.go | 50 +- internal/server/management_async.go | 331 + .../server/management_async_lifecycle_test.go | 400 + internal/server/management_async_test.go | 190 + internal/server/pathparams.go | 28 + internal/server/protocol.go | 70 + internal/server/protocol_test.go | 106 + internal/server/registry.go | 199 + internal/server/scheduler.go | 78 + internal/server/send_events.go | 64 + internal/server/send_events_test.go | 98 + internal/server/server.go | 180 +- internal/server/server_eval.go | 131 +- internal/server/server_example.go | 421 +- internal/server/server_management.go | 68 +- internal/server/server_state.go | 125 +- internal/server/server_test.go | 32 +- internal/server/server_ttl_test.go | 52 +- internal/server/signalr_hub.go | 542 + internal/server/signalr_hub_test.go | 186 + internal/server/signalr_integration_test.go | 365 + internal/server/signalr_protocol.go | 91 + internal/server/signalr_protocol_test.go | 115 + internal/server/signalr_server_test.go | 119 + internal/server/templating_parity_test.go | 237 + internal/server/wrappers.go | 19 +- internal/server/ws_adapter.go | 216 + internal/server/ws_adapter_test.go | 116 + .../.openspec.yaml | 2 + .../2026-09-02-add-asyncapi-support/design.md | 152 + .../proposal.md | 44 + .../specs/asyncapi-loader/spec.md | 72 + .../specs/asyncapi-management/spec.md | 104 + .../specs/asyncapi-protocols/spec.md | 59 + .../specs/asyncapi-templating/spec.md | 107 + .../specs/cli/spec.md | 46 + .../specs/event-driver/spec.md | 90 + .../specs/management-api/spec.md | 33 + .../specs/mock-server-core/spec.md | 45 + .../specs/signalr-hub-runtime/spec.md | 121 + .../2026-09-02-add-asyncapi-support/tasks.md | 97 + openspec/specs/asyncapi-loader/spec.md | 70 + openspec/specs/asyncapi-management/spec.md | 102 + openspec/specs/asyncapi-protocols/spec.md | 57 + openspec/specs/asyncapi-templating/spec.md | 105 + openspec/specs/cli/spec.md | 19 +- openspec/specs/event-driver/spec.md | 88 + openspec/specs/management-api/spec.md | 29 +- openspec/specs/mock-server-core/spec.md | 25 +- openspec/specs/signalr-hub-runtime/spec.md | 119 + test/_shared/resources/asyncapi-26.yaml | 5 + test/_shared/resources/asyncapi-30.yaml | 17 + test/_shared/resources/asyncapi-31.yaml | 17 + test/_shared/resources/not-a-spec.yaml | 3 + test/cli/cli_integration_test.go | 120 + test/server-core/server_integration_test.go | 149 + third_party/go-asyncapi/.gitignore | 29 + third_party/go-asyncapi/LICENSE | 21 + third_party/go-asyncapi/asyncapi.go | 20 + third_party/go-asyncapi/bindings.go | 203 + third_party/go-asyncapi/channel.go | 24 + third_party/go-asyncapi/components.go | 24 + third_party/go-asyncapi/document.go | 100 + third_party/go-asyncapi/errors.go | 79 + third_party/go-asyncapi/go.mod | 8 + third_party/go-asyncapi/go.sum | 12 + third_party/go-asyncapi/info.go | 39 + .../internal/jsonschema/asyncapi-3.0.0.json | 8971 +++++++++++++++++ third_party/go-asyncapi/loader.go | 122 + third_party/go-asyncapi/message.go | 127 + third_party/go-asyncapi/operation.go | 57 + third_party/go-asyncapi/reference.go | 727 ++ third_party/go-asyncapi/resolver.go | 495 + third_party/go-asyncapi/runtime_expr.go | 58 + third_party/go-asyncapi/schema.go | 171 + third_party/go-asyncapi/security.go | 48 + third_party/go-asyncapi/server.go | 25 + third_party/go-asyncapi/traits.go | 126 + third_party/go-asyncapi/validate.go | 267 + third_party/go-asyncapi/walk.go | 286 + 125 files changed, 23114 insertions(+), 1195 deletions(-) create mode 100644 internal/asyncapi/document.go create mode 100644 internal/asyncapi/document_test.go create mode 100644 internal/asyncapi/parse.go create mode 100644 internal/asyncapi/parse_test.go create mode 100644 internal/asyncapi/testdata/test-30.yaml create mode 100644 internal/asyncapi/testdata/test-31.yaml create mode 100644 internal/extensions/event_trigger_test.go create mode 100644 internal/extensions/example_value.go create mode 100644 internal/extensions/example_value_test.go create mode 100644 internal/extensions/parity_test.go create mode 100644 internal/loader/async_router_test.go create mode 100644 internal/loader/decode.go create mode 100644 internal/loader/signalr_router_test.go create mode 100644 internal/runtime/event_source_test.go create mode 100644 internal/runtime/message_source_test.go delete mode 100644 internal/server/adapters/adapters.go create mode 100644 internal/server/add_example_async_test.go create mode 100644 internal/server/async_http_adapter_test.go create mode 100644 internal/server/async_message.go create mode 100644 internal/server/async_state_test.go create mode 100644 internal/server/channel_params_test.go create mode 100644 internal/server/convert.go create mode 100644 internal/server/engine.go create mode 100644 internal/server/event_broker.go create mode 100644 internal/server/event_broker_test.go create mode 100644 internal/server/event_integration_test.go create mode 100644 internal/server/event_server.go create mode 100644 internal/server/fire_event.go create mode 100644 internal/server/fire_event_endpoint_test.go create mode 100644 internal/server/history_test.go create mode 100644 internal/server/http_adapter.go create mode 100644 internal/server/hubmanager.go create mode 100644 internal/server/management_async.go create mode 100644 internal/server/management_async_lifecycle_test.go create mode 100644 internal/server/management_async_test.go create mode 100644 internal/server/pathparams.go create mode 100644 internal/server/protocol.go create mode 100644 internal/server/protocol_test.go create mode 100644 internal/server/registry.go create mode 100644 internal/server/scheduler.go create mode 100644 internal/server/send_events.go create mode 100644 internal/server/send_events_test.go create mode 100644 internal/server/signalr_hub.go create mode 100644 internal/server/signalr_hub_test.go create mode 100644 internal/server/signalr_integration_test.go create mode 100644 internal/server/signalr_protocol.go create mode 100644 internal/server/signalr_protocol_test.go create mode 100644 internal/server/signalr_server_test.go create mode 100644 internal/server/templating_parity_test.go create mode 100644 internal/server/ws_adapter.go create mode 100644 internal/server/ws_adapter_test.go create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/design.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/proposal.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-loader/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-management/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-protocols/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-templating/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/cli/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/event-driver/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/management-api/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/mock-server-core/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/signalr-hub-runtime/spec.md create mode 100644 openspec/changes/archive/2026-09-02-add-asyncapi-support/tasks.md create mode 100644 openspec/specs/asyncapi-loader/spec.md create mode 100644 openspec/specs/asyncapi-management/spec.md create mode 100644 openspec/specs/asyncapi-protocols/spec.md create mode 100644 openspec/specs/asyncapi-templating/spec.md create mode 100644 openspec/specs/event-driver/spec.md create mode 100644 openspec/specs/signalr-hub-runtime/spec.md create mode 100644 test/_shared/resources/asyncapi-26.yaml create mode 100644 test/_shared/resources/asyncapi-30.yaml create mode 100644 test/_shared/resources/asyncapi-31.yaml create mode 100644 test/_shared/resources/not-a-spec.yaml create mode 100644 third_party/go-asyncapi/.gitignore create mode 100644 third_party/go-asyncapi/LICENSE create mode 100644 third_party/go-asyncapi/asyncapi.go create mode 100644 third_party/go-asyncapi/bindings.go create mode 100644 third_party/go-asyncapi/channel.go create mode 100644 third_party/go-asyncapi/components.go create mode 100644 third_party/go-asyncapi/document.go create mode 100644 third_party/go-asyncapi/errors.go create mode 100644 third_party/go-asyncapi/go.mod create mode 100644 third_party/go-asyncapi/go.sum create mode 100644 third_party/go-asyncapi/info.go create mode 100644 third_party/go-asyncapi/internal/jsonschema/asyncapi-3.0.0.json create mode 100644 third_party/go-asyncapi/loader.go create mode 100644 third_party/go-asyncapi/message.go create mode 100644 third_party/go-asyncapi/operation.go create mode 100644 third_party/go-asyncapi/reference.go create mode 100644 third_party/go-asyncapi/resolver.go create mode 100644 third_party/go-asyncapi/runtime_expr.go create mode 100644 third_party/go-asyncapi/schema.go create mode 100644 third_party/go-asyncapi/security.go create mode 100644 third_party/go-asyncapi/server.go create mode 100644 third_party/go-asyncapi/traits.go create mode 100644 third_party/go-asyncapi/validate.go create mode 100644 third_party/go-asyncapi/walk.go diff --git a/AGENTS.md b/AGENTS.md index 72e439f..8fde593 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,30 +48,31 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **oasmock** (2049 symbols, 4322 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **oasmock** (2518 symbols, 5666 relationships, 133 execution flows). -> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). +> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. +- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. +- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero. - When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. - For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). ## Never Do -- NEVER edit a function, class, or method without first running `impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER edit a function, class, or method before MCP/CLI impact analysis. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means. - NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. -- NEVER commit changes without running `detect_changes()` to check affected scope. +- NEVER commit before MCP/CLI graph change analysis. ## Resources | Resource | Use for | -|----------|---------| +| --- | --- | | `gitnexus://repo/oasmock/context` | Codebase overview, check index freshness | | `gitnexus://repo/oasmock/clusters` | All functional areas | | `gitnexus://repo/oasmock/processes` | All execution flows | @@ -80,12 +81,12 @@ This project is indexed by GitNexus as **oasmock** (2049 symbols, 4322 relations ## CLI | Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| --- | --- | +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` | diff --git a/api/openapi.yaml b/api/openapi.yaml index eae33a4..41f7f4c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -81,17 +81,149 @@ paths: application/json: schema: $ref: '#/components/schemas/RequestHistoryResponse' + /events/fire: + 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. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FireEventRequest' + responses: + '200': + description: Event accepted + content: + application/json: + schema: + $ref: '#/components/schemas/AsyncActionResponse' + '400': + description: Invalid request + /ws/push: + post: + operationId: pushToChannel + summary: Push a message to channel consumers + description: | + Pushes a message to the connected consumers of an AsyncAPI channel. + Supports immediate or delayed delivery, targeted (by connectionId) or + broadcast push, and runtime-expression templating of the payload. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushRequest' + responses: + '200': + description: Push accepted + content: + application/json: + schema: + $ref: '#/components/schemas/AsyncActionResponse' + '400': + description: Invalid request + '404': + description: Unknown connectionId + /ws/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. + parameters: + - name: channel + in: query + required: true + description: Channel address + 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 + description: | + Schedules a message to be pushed to a channel at a fixed interval until + cancelled by its push ID. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleRequest' + responses: + '200': + description: Schedule created + content: + application/json: + schema: + $ref: '#/components/schemas/AsyncActionResponse' + '400': + description: Invalid request + /ws/schedule/{pushId}: + delete: + operationId: stopRecurringPush + summary: Stop a recurring push + parameters: + - name: pushId + in: path + required: true + description: Push ID returned by the schedule endpoint + schema: + type: string + responses: + '200': + description: Schedule stopped + '404': + description: Unknown pushId + /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. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DisconnectRequest' + responses: + '200': + description: Consumer disconnected + '400': + description: Invalid request + '404': + description: Unknown connectionId components: schemas: AddExampleRequest: type: object required: - - path - response properties: path: type: string description: The request path (including path parameters) to match + protocol: + type: string + enum: [http, ws] + description: AsyncAPI protocol when targeting an AsyncAPI channel + channel: + type: string + description: AsyncAPI channel address (prefixed) when targeting an AsyncAPI channel method: type: string enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS] @@ -184,3 +316,110 @@ components: type: array items: $ref: '#/components/schemas/RequestHistoryItem' + FireEventRequest: + type: object + required: + - event + properties: + event: + type: string + description: The named event to fire + payload: + type: object + description: Event payload exposed to consuming templates via {$event.*} + delay: + type: integer + minimum: 0 + default: 0 + description: Delivery delay in milliseconds + global: + type: boolean + default: false + description: When true, the event is broadcast over all loaded schemas + PushRequest: + type: object + required: + - channel + properties: + channel: + type: string + description: AsyncAPI channel address to push to + connectionId: + type: string + description: Target a specific consumer; omitted broadcasts to all + payload: + type: object + description: Message payload, templated with {$state.*}/{$env.*} + delay: + type: integer + minimum: 0 + default: 0 + description: Delivery delay in milliseconds + ScheduleRequest: + type: object + required: + - channel + - interval + properties: + channel: + type: string + description: AsyncAPI channel address to push to + interval: + type: integer + minimum: 1 + description: Delivery interval in milliseconds + payload: + type: object + description: Message payload pushed at each interval + DisconnectRequest: + type: object + required: + - connectionId + properties: + connectionId: + type: string + description: Active consumer connection id + reason: + type: string + description: Optional close reason + code: + type: integer + description: Optional WebSocket close code + abrupt: + type: boolean + default: false + description: Simulate an abrupt network drop (no close frame) + AsyncActionResponse: + type: object + properties: + success: + type: boolean + event: + type: string + description: Fired event name (fire-event endpoint only) + pushId: + type: string + description: Scheduled push id (schedule endpoint only) + ConsumersResponse: + type: object + properties: + consumers: + type: array + items: + type: object + properties: + connectionId: + type: string + channel: + type: string + streams: + type: array + items: + type: object + properties: + connectionId: + type: string + invocationId: + type: string + streamId: + type: string diff --git a/docs/architecture.md b/docs/architecture.md index cbbf51d..967ccfe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,34 +148,51 @@ flowchart LR - **Dependencies**: Server, Loader ### 2.2 Server Component (`internal/server/`) -**Purpose**: Core HTTP server and component coordination. +**Purpose**: Core HTTP/WebSocket server and component coordination. - **Key Files**: - - `server.go` - Main server implementation and HTTP handlers + - `server.go` - Main server implementation (Controller) + HTTP handlers - `interfaces.go` - All public interfaces and dependency definitions - - `server_management.go` - Management API endpoints - - `server_example.go` - Example selection and response generation - - `server_eval.go` - Runtime expression evaluation integration - - `server_state.go` - State management helpers - - `jsonrpc.go` - JSON-RPC handler (gateway requests) - - `jsonrpc_protocol.go` - JSON-RPC 2.0 protocol parsing and error responses - - `wrappers.go` - Adapter implementations - - `adapters/` - Formal adapter layer for external components + - `engine.go` - `exampleEngine`: selection, templating, state, async rendering (implements `MessageRenderer`) + - `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_broker.go` - `eventBroker`: subscription registry + dispatch (pure) + - `scheduler.go` - `pushScheduler`: recurring push jobs (pure) + - `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) + - `jsonrpc_protocol.go` - JSON-RPC 2.0 protocol parsing and error responses + - `wrappers.go` - dependency wrapper implementations (stores, factories, processors) + - `protocol.go` - Protocol adapter interface + registry (http/ws) + - `pathparams.go` - chi route param extraction for `{$channel.*}` + - `http_adapter.go` - AsyncAPI http channel serving + - `ws_adapter.go` - WebSocket serving + connection registry + - `signalr_hub.go` - SignalR hub overlay (negotiate, handshake, framing) + - `async_message.go` - AsyncAPI message selection/rendering - **Public Interfaces**: - - `RouteProvider` - Builds route mappings from OpenAPI schemas + - `RouteProvider` - Builds route mappings from OpenAPI/AsyncAPI schemas + - `ProtocolAdapter` - Per-protocol serving strategy (http/ws) + - `MessageHandler` - AsyncAPI message rendering via shared pipeline - `StateStore` - Manages namespaced state with CRUD operations - `HistoryStore` - Stores request/response history records - `DataSource` - Generic data source for runtime expressions - `ExpressionEvaluator` - Evaluates runtime expressions (`{$request.path.id}`) - `ExtensionProcessor` - Processes OpenAPI extensions (`x-mock-*`) + - `MessageRenderer` - Narrow rendering surface (engine) consumed by hub/event bus/adapters + - `ConsumerBus` - Payload emission to SignalR streams + ws consumers (hub manager) - **Responsibilities**: - HTTP request routing using Chi router + - WebSocket upgrades and connection lifecycle + - SignalR hub serving for documents with root `x-signalr` + - Event-driven push bus decoupling REST producers from ws consumers - Middleware stack (CORS, logging, delay, history) - - Response generation and example selection + - Response generation and example selection (OpenAPI + AsyncAPI) - Runtime expression evaluation coordination - Extension processing and state updates - - Management API endpoints (`/_mock/examples`, `/_mock/requests`) + - Management API endpoints (`/_mock/examples`, `/_mock/requests`, async-mocking surface) - RPC gateway dispatch (JSON-RPC to operation mapping via `x-rpc`) -- **Dependencies**: Loader, Runtime, Extensions, State, History +- **Dependencies**: Loader, Runtime, Extensions, State, History, AsyncAPI, Gorilla WebSocket ### 2.3 Runtime Component (`internal/runtime/`) **Purpose**: Runtime expression evaluation engine. @@ -188,6 +205,9 @@ flowchart LR - `RequestSource` - Access to HTTP request data (path params, query, headers, body, cookies) - `StateSource` - Access to namespaced server state - `EnvSource` - Access to environment variables + - `MessageSource` - AsyncAPI message payload/headers (`{$message.*}`) + - `ChannelSource` - AsyncAPI channel parameters (`{$channel.*}`) + - `EventSource` - Event bus payload (`{$event.*}`) - **Responsibilities**: - Parse dot-separated paths with escape support (`path.id`, `query.page`) - Evaluate runtime expressions (`{$request.path.id | default:0}`) @@ -195,44 +215,51 @@ flowchart LR - **Dependencies**: None (self-contained) ### 2.4 Extensions Component (`internal/extensions/`) -**Purpose**: OpenAPI extension processing for advanced mock behavior. +**Purpose**: OpenAPI/AsyncAPI extension processing for advanced mock behavior. - **Key Files**: - `extract.go` - Extension extraction utilities - `match.go` - Parameter matching with JSON schema validation + - `example_value.go` - `ExampleValue` wrapper abstracting both example sources - **Supported Extensions**: - `x-mock-set-state` - Set server state after response - `x-mock-skip` - Skip example from selection - `x-mock-once` - Use example only once - `x-mock-match` - Conditional example selection (legacy alias: `x-mock-params-match`) - `x-mock-headers` - Custom response headers + - `x-event-trigger` - Fire a named event from an OpenAPI example - **Functions**: - - `ExtractSetState()`, `ExtractParamsMatch()`, `ExtractHeaders()` + - `ExtractSetState()`, `ExtractParamsMatch()`, `ExtractHeaders()`, `ExtractEventTriggers()` - `EvaluateParamsMatch()` - Uses Runtime.Evaluator for expression evaluation - `ExtractSkip()`, `ExtractOnce()` + - `OpenAPIExampleValue()` / `NewExampleValue()` - source-agnostic wrappers - **Responsibilities**: - - Extract extension values from OpenAPI examples + - Extract extension values from OpenAPI and AsyncAPI examples - Validate JSON schemas for parameter matching - Evaluate runtime expressions in match conditions + - Uniform example selection behavior across spec kinds (parity) - **Dependencies**: Runtime (for expression evaluation) ### 2.5 Loader Component (`internal/loader/`) -**Purpose**: OpenAPI schema loading and route mapping. +**Purpose**: OpenAPI/AsyncAPI schema loading and route mapping. - **Key Files**: - - `schema.go` - Schema loading and validation + - `schema.go` - Schema loading, detection, and validation - `router.go` - Route mapping construction + - `decode.go` - YAML/JSON generic decoding for spec-type detection + - `asyncapi/` - Neutral AsyncAPI document view + structural validation - **Key Types**: - - `SchemaInfo` - Loaded OpenAPI spec with prefix - - `RouteMapping` - Route information for server routing + - `SchemaInfo` - Loaded spec (OpenAPI or AsyncAPI) with prefix and `Kind` + - `RouteMapping` - Route information for server routing (protocol/action/messages) - **Functions**: - `LoadSchemas(sources, prefixes) ([]SchemaInfo, error)` - Load multiple schemas - - `loadSingleSchema(path) (*openapi3.T, error)` - Load and validate single schema + - `detectKind(data)` - Dispatch on root key (`openapi` vs `asyncapi`) + - `BuildRouteMappings(infos)` - OpenAPI + AsyncAPI route construction - `OpenAPIPatternToChi(pattern) string` - Convert OpenAPI patterns to Chi format - **Responsibilities**: - - Load OpenAPI YAML/JSON files from disk - - Validate OpenAPI 3.0 schemas - - Build route mappings for server registration - - Handle path prefixing for multi-schema scenarios -- **Dependencies**: None (uses external `kin-openapi` library) + - Autodetect spec type by root version key (no extra flags) + - Load and validate OpenAPI 3.0 and AsyncAPI 3.0.0/3.1.0 files + - Map AsyncAPI channels to `http`/`ws` routes by protocol binding + - Handle path/address prefixing for multi-schema scenarios +- **Dependencies**: `kin-openapi` (OpenAPI), `internal/asyncapi` (AsyncAPI) ### 2.6 State Component (`internal/state/`) **Purpose**: Thread-safe, namespaced key-value state management. @@ -305,6 +332,19 @@ flowchart LR - Support test-driven development - **Dependencies**: All interface packages (generated from them) +### 2.10 AsyncAPI Subsystem + +OASMock autodetects AsyncAPI 3.0.0/3.1.0 files (root key `asyncapi`, version major 3) and serves them alongside OpenAPI. The AsyncAPI subsystem adds: + +- **Loader autodetect** (`internal/loader/schema.go`): `detectKind` dispatches on the root version key; no new CLI flags. +- **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). +- **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. + ## 3. Sequence Flows ### 3.1 CLI Initialization Flow diff --git a/docs/cli.md b/docs/cli.md index 0c40dea..ab868a9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,7 +14,7 @@ oasmock [options] | Option | Type | Default | Description | |--------------------|-----------|--------------------|--------------------------------------------------------------------------| -| `--from` | string | `src/openapi.yaml` | Source OpenAPI schema. Can be specified multiple times. | +| `--from` | string | `src/openapi.yaml` | Source OpenAPI or AsyncAPI schema (autodetected by root key). Can be specified multiple times. | | `--prefix` | string | `''` | URI prefix for the schema. Can be specified for each `--from` parameter. | | `--port` | number | `19191` | Port to listen on. | | `--delay` | number | `100` | Delay between request and response in milliseconds. | @@ -51,7 +51,7 @@ The CLI can read configuration from a `.oasmock.yaml` file in the current workin | Key | Type | Description | |-------------------|---------------------|--------------------------------------------------------------------------| -| `schemas` | list | Multiple schemas, each either a string (path) or object with `src` and optional `prefix`. | +| `schemas` | list | Multiple schemas (OpenAPI or AsyncAPI — autodetected), each either a string (path) or object with `src` and optional `prefix`. | | `port` | number | Port to listen on. | | `delay` | number | Delay between request and response in milliseconds. | | `verbose` | boolean | Enable verbose logging. | @@ -59,6 +59,8 @@ The CLI can read configuration from a `.oasmock.yaml` file in the current workin | `history_size` | number | Maximum number of requests to keep in history. | | `no_control_api` | boolean | Disable the management control API. | +**Schema types:** each `--from`/`schemas` entry may reference an OpenAPI (`openapi:` root key) or AsyncAPI (`asyncapi:` root key, 3.0.0/3.1.0) file. Spec type is detected automatically from the root version key — no flags change. Mixed OpenAPI + AsyncAPI sources can be served together. + **Examples:** **Multiple schemas with prefixes:** diff --git a/docs/project.md b/docs/project.md index c6caf32..e2a89a4 100644 --- a/docs/project.md +++ b/docs/project.md @@ -11,11 +11,14 @@ - `api/` - Management HTTP API docs (e.g. OpenAPI specs) - `cmd/` - CLI entrypoints codebase - `internal/` - Application codebase + - `internal/asyncapi/` - Neutral AsyncAPI 3.x document view + structural validation + - `internal/server/` - HTTP/WebSocket server (Controller), example engine + registry, SignalR hub manager, event bus, push scheduler, protocol adapters (http/ws), async-mocking management API - `mock/` - Mocks generated from interfaces, duplicates codebase structure - `scripts/` - Various automation scripts - `test/` - Integration tests and test related codebase and resources - `test/_shared` - Common files for tests codebase including fixtures, helper functions, resources etc - - `test/_shared/resources` - Various resources (e.g. yaml, json files) + - `test/_shared/resources` - Various resources (e.g. yaml, json files incl. AsyncAPI fixtures) +- `third_party/` - Vendored dependencies (AsyncAPI parser `go-asyncapi`, wired via `go.mod` `replace`) - `docs/` - Project documentation - `docs/diagrams` - PlantUML diagrams (container for extracted `.puml` files) diff --git a/go.mod b/go.mod index 60f0ed4..b75ebac 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,17 @@ module github.com/mamonth/oasmock go 1.23.0 require ( + github.com/benelser/go-asyncapi v0.0.0-00010101000000-000000000000 github.com/getkin/kin-openapi v0.133.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/golang/mock v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/xeipuuv/gojsonschema v1.2.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -29,16 +32,18 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.28.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/benelser/go-asyncapi => ./third_party/go-asyncapi diff --git a/go.sum b/go.sum index cfded5e..566d59d 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -51,6 +53,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -74,8 +78,9 @@ github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0 github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b h1:6cLsL+2FW6dRAdl5iMtHgRogVCff0QpRi9653YmdcJA= +github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= diff --git a/internal/asyncapi/document.go b/internal/asyncapi/document.go new file mode 100644 index 0000000..d370728 --- /dev/null +++ b/internal/asyncapi/document.go @@ -0,0 +1,127 @@ +// Package asyncapi provides a protocol-neutral view of AsyncAPI 3.x documents. +// +// It isolates the backing parser (currently a vendored copy of +// github.com/benelser/go-asyncapi) behind a small document model so the rest +// of the codebase never depends on a third-party AsyncAPI type. Swapping the +// parser later only changes this package and the go.mod replace directive. +package asyncapi + +// Action is the operation action type. +type Action string + +const ( + // ActionSend indicates the application sends messages to the channel. + ActionSend Action = "send" + // ActionReceive indicates the application receives messages from the channel. + ActionReceive Action = "receive" +) + +// Supported protocols for the MVP mock server. +const ( + ProtocolHTTP = "http" + ProtocolWS = "ws" +) + +// Document is the root of a parsed AsyncAPI document. +type Document struct { + // Version is the AsyncAPI spec version (e.g. "3.0.0", "3.1.0"). + Version string + // Channels indexed by channel ID, in stable order. + Channels []*Channel + // Operations indexed by operation ID, in stable order. + Operations []*Operation + // SignalR is the root-level x-signalr hub overlay, if declared. + SignalR *SignalRConfig +} + +// SignalRConfig is the root-level x-signalr hub overlay for a document. +type SignalRConfig struct { + // Path is the hub path the SignalR hub is served at. + Path string + // Raw holds the full x-signalr extension for later options. + Raw map[string]any +} + +// Channel returns the channel with the given ID, or nil when absent. +func (d *Document) Channel(id string) *Channel { + for _, ch := range d.Channels { + if ch.ID == id { + return ch + } + } + return nil +} + +// Operation returns the operation with the given ID, or nil when absent. +func (d *Document) Operation(id string) *Operation { + for _, op := range d.Operations { + if op.ID == id { + return op + } + } + return nil +} + +// Channel is a parsed AsyncAPI channel. +type Channel struct { + ID string + Title string + // Address is the raw channel address (may contain {param} placeholders). + Address string + // Parameters indexed by name. + Parameters []*Parameter + // Messages indexed by message ID. + Messages []*Message + // Bindings carries the channel-level protocol bindings. + Bindings Bindings +} + +// Parameter is a channel address parameter. +type Parameter struct { + Name string +} + +// Operation is a parsed AsyncAPI operation with its channel resolved. +type Operation struct { + ID string + Action Action + Channel *Channel + Messages []*Message + Bindings Bindings +} + +// Message is a parsed AsyncAPI message with its examples. +type Message struct { + ID string + Name string + ContentType string + // Examples in asyncapi order. + Examples []*Example +} + +// Example is a single message example including spec extensions (x-mock-*). +type Example struct { + Name string + Headers map[string]any + Payload any + Extensions map[string]any +} + +// Bindings carries protocol binding information used for routing. +// Protocols lists every protocol binding name declared on the object +// (http, ws, or unsupported ones such as amqp/kafka/mqtt/nats). +type Bindings struct { + Protocols []string + HTTP *HTTPBinding + WS *WSBinding +} + +// HTTPBinding is the http channel/operation binding. +type HTTPBinding struct { + Method string +} + +// WSBinding is the ws channel binding. +type WSBinding struct { + Method string +} diff --git a/internal/asyncapi/document_test.go b/internal/asyncapi/document_test.go new file mode 100644 index 0000000..f006e0c --- /dev/null +++ b/internal/asyncapi/document_test.go @@ -0,0 +1,50 @@ +package asyncapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Looking up a channel by ID +Given a parsed AsyncAPI document +When Channel is called with an existing and a missing ID +Then the existing channel is returned and the missing lookup yields nil + +Related spec scenarios: RS.SHR.3, RS.SHR.5 +*/ +func TestDocument_ChannelLookup(t *testing.T) { + t.Parallel() + + doc, err := Parse(readFixture(t, "test-30.yaml")) + require.NoError(t, err) + + ch := doc.Channel("userSignedUp") + require.NotNil(t, ch) + assert.Equal(t, "user/signedup", ch.Address) + + assert.Nil(t, doc.Channel("missing")) +} + +/* +Scenario: Looking up an operation by ID +Given a parsed AsyncAPI document +When Operation is called with an existing and a missing ID +Then the existing operation is returned and the missing lookup yields nil + +Related spec scenarios: RS.SHR.6, RS.SHR.7 +*/ +func TestDocument_OperationLookup(t *testing.T) { + t.Parallel() + + doc, err := Parse(readFixture(t, "test-30.yaml")) + require.NoError(t, err) + + op := doc.Operation("receiveUserSignedUp") + require.NotNil(t, op) + assert.Equal(t, ActionReceive, op.Action) + + assert.Nil(t, doc.Operation("missing")) +} diff --git a/internal/asyncapi/parse.go b/internal/asyncapi/parse.go new file mode 100644 index 0000000..e1794b0 --- /dev/null +++ b/internal/asyncapi/parse.go @@ -0,0 +1,337 @@ +package asyncapi + +import ( + "fmt" + "sort" + "strings" + + benelser "github.com/benelser/go-asyncapi" + "gopkg.in/yaml.v3" +) + +// Parse parses AsyncAPI 3.x YAML/JSON data into a neutral Document view. +// It loads and resolves references through the backing parser, applies +// structural validation (version, mandatory fields, supported protocols), +// and maps the result to the protocol-neutral model. +func Parse(data []byte) (*Document, error) { + raw, err := benelser.LoadFromData(data) + if err != nil { + return nil, fmt.Errorf("invalid AsyncAPI schema: %w", err) + } + + if err := raw.ResolveRefs(); err != nil { + return nil, fmt.Errorf("invalid AsyncAPI schema: %w", err) + } + + if err := validate(raw); err != nil { + return nil, err + } + + doc, err := mapDocument(raw) + if err != nil { + return nil, err + } + + captureSignalR(doc, data) + return doc, nil +} + +// validate performs structural validation against spec requirements. +func validate(raw *benelser.Document) error { + version := raw.AsyncAPI + if version == "" { + return fmt.Errorf("invalid AsyncAPI schema: missing asyncapi version") + } + if !strings.HasPrefix(version, "3.") { + return fmt.Errorf("invalid AsyncAPI schema: unsupported AsyncAPI version %q (only 3.x is supported)", version) + } + + // All 3.x documents require at least one channel. + if len(raw.Channels) == 0 { + return fmt.Errorf("invalid AsyncAPI schema: missing mandatory channels") + } + + // 3.0.0 requires operations; 3.1.0 allows operations to be replaced by webhooks. + if version == "3.0.0" && len(raw.Operations) == 0 { + return fmt.Errorf("invalid AsyncAPI schema: missing mandatory operations") + } + + return validateProtocols(raw) +} + +// supportedProtocols is the set of protocol bindings the MVP can serve. +// amqp (and any other protocol) is treated as unsupported for now. +var supportedProtocols = map[string]bool{ + ProtocolHTTP: true, + ProtocolWS: true, +} + +// validateProtocols reports an error when a channel only declares protocol +// bindings that the mock server cannot serve, naming the offending protocol. +func validateProtocols(raw *benelser.Document) error { + for chID, chRef := range raw.Channels { + ch := chRef.Value + if ch == nil { + continue + } + prots := channelProtocols(ch) + if len(prots) == 0 { + continue + } + supported := false + for _, p := range prots { + if supportedProtocols[p] { + supported = true + break + } + } + if !supported { + return fmt.Errorf("invalid AsyncAPI schema: channel %q declares unsupported protocol binding(s): %s", + chID, strings.Join(prots, ", ")) + } + } + return nil +} + +// channelProtocols returns the protocol binding names declared on a channel. +func channelProtocols(ch *benelser.Channel) []string { + if ch == nil || ch.Bindings == nil { + return nil + } + return iterBindings(ch.Bindings.Value) +} + +// iterBindings extracts the declared protocol names from channel bindings. +func iterBindings(b *benelser.ChannelBindings) []string { + if b == nil { + return nil + } + var out []string + if b.HTTP != nil { + out = append(out, ProtocolHTTP) + } + if b.WS != nil { + out = append(out, ProtocolWS) + } + if b.AMQP != nil { + out = append(out, "amqp") + } + if b.Kafka != nil { + out = append(out, "kafka") + } + if b.MQTT != nil { + out = append(out, "mqtt") + } + if b.NATS != nil { + out = append(out, "nats") + } + for k := range b.Raw { + out = append(out, k) + } + return out +} + +// captureSignalR surfaces the root-level x-signalr extension on the document. +// The backing parser's Document keeps no root extension map populated, so the +// raw document is decoded and the extension looked up directly. +func captureSignalR(doc *Document, data []byte) { + var m map[string]any + if err := yaml.Unmarshal(data, &m); err != nil { + return + } + v, ok := m[signalRKey] + if !ok { + return + } + raw, ok := v.(map[string]any) + if !ok { + return + } + doc.SignalR = &SignalRConfig{Raw: raw} + if p, ok := raw["path"].(string); ok { + doc.SignalR.Path = p + } +} + +// signalRKey is the root-level x-signalr extension name. +const signalRKey = "x-signalr" + +func mapDocument(raw *benelser.Document) (*Document, error) { + doc := &Document{ + Version: raw.AsyncAPI, + } + + // Map channels deterministically, recording pointer identity so that + // operations can be linked back to the same channel instances. + channelByID := make(map[string]*Channel) + channelByPtr := make(map[*benelser.Channel]*Channel) + for _, id := range sortedKeys(raw.Channels) { + ch, err := mapChannel(id, raw.Channels[id]) + if err != nil { + return nil, err + } + doc.Channels = append(doc.Channels, ch) + channelByID[id] = ch + if ref := raw.Channels[id]; ref != nil && ref.Value != nil { + channelByPtr[ref.Value] = ch + } + } + + // Map operations; an operation references its channel through a resolved + // *Channel node, matched by pointer identity against root channels. + for _, id := range sortedKeys(raw.Operations) { + opRef := raw.Operations[id] + op := opRef.Value + if op == nil { + continue + } + out := &Operation{ + ID: id, + Action: Action(op.Action), + } + if op.Channel != nil && op.Channel.Value != nil { + if ch, ok := channelByPtr[op.Channel.Value]; ok { + out.Channel = ch + } else { + // Inline channel declared only at the operation; map it directly. + if mapped, merr := mapChannel(id+"-channel", op.Channel); merr == nil { + out.Channel = mapped + } + } + } + out.Messages = messageRefs(op) + out.Bindings = mapOperationBindings(op.Bindings) + doc.Operations = append(doc.Operations, out) + } + + return doc, nil +} + +// messageRefs maps the messages of an operation; when the operation declares +// none it falls back to the referenced channel's messages. +func messageRefs(op *benelser.Operation) []*Message { + var refs []*benelser.MessageRef + refs = append(refs, op.Messages...) + if len(refs) == 0 && op.Channel != nil && op.Channel.Value != nil { + for _, id := range sortedKeys(op.Channel.Value.Messages) { + refs = append(refs, op.Channel.Value.Messages[id]) + } + } + // Names for channel-resident messages come from the channel's map keys. + namesByPtr := make(map[*benelser.Message]string) + if op.Channel != nil && op.Channel.Value != nil { + for id, ref := range op.Channel.Value.Messages { + if ref != nil && ref.Value != nil { + namesByPtr[ref.Value] = id + } + } + } + var out []*Message + for _, ref := range refs { + m := ref.Value + if m == nil { + continue + } + out = append(out, mapMessage(m, namesByPtr[m])) + } + return out +} + +func mapMessage(m *benelser.Message, nameHint string) *Message { + msg := &Message{ + Name: m.Name, + ContentType: m.ContentType, + } + switch { + case m.Title != "": + msg.Name = m.Title + case msg.Name == "" && nameHint != "": + msg.Name = nameHint + } + for _, ex := range m.Examples { + if ex == nil { + continue + } + msg.Examples = append(msg.Examples, &Example{ + Name: ex.Name, + Headers: ex.Headers, + Payload: ex.Payload, + Extensions: ex.Extensions(), + }) + } + return msg +} + +func mapChannel(id string, chRef *benelser.ChannelRef) (*Channel, error) { + ch := chRef.Value + if ch == nil { + return nil, fmt.Errorf("invalid AsyncAPI schema: channel %q has no definition", id) + } + out := &Channel{ + ID: id, + Title: ch.Title, + } + if ch.Address != nil { + out.Address = *ch.Address + } + for _, pid := range sortedKeys(ch.Parameters) { + p := ch.Parameters[pid] + if p == nil { + continue + } + out.Parameters = append(out.Parameters, &Parameter{Name: pid}) + } + for _, mid := range sortedKeys(ch.Messages) { + m := ch.Messages[mid].Value + if m == nil { + continue + } + out.Messages = append(out.Messages, mapMessage(m, mid)) + } + out.Bindings = mapChannelBindings(ch.Bindings) + return out, nil +} + +// mapChannelBindings extracts binding details from channel bindings. +func mapChannelBindings(bRef *benelser.ChannelBindingsRef) Bindings { + if bRef == nil || bRef.Value == nil { + return Bindings{} + } + b := bRef.Value + out := Bindings{Protocols: iterBindings(b)} + if b.HTTP != nil { + out.HTTP = &HTTPBinding{} + } + if b.WS != nil { + out.WS = &WSBinding{Method: b.WS.Method} + } + return out +} + +// mapOperationBindings extracts binding details from operation bindings. +func mapOperationBindings(bRef *benelser.OperationBindingsRef) Bindings { + if bRef == nil || bRef.Value == nil { + return Bindings{} + } + b := bRef.Value + out := Bindings{} + if b.HTTP != nil { + out.HTTP = &HTTPBinding{Method: strings.ToUpper(b.HTTP.Method)} + out.Protocols = append(out.Protocols, ProtocolHTTP) + } + if b.WS != nil { + out.WS = &WSBinding{} + out.Protocols = append(out.Protocols, ProtocolWS) + } + return out +} + +// sortedKeys returns map keys in ascending order for deterministic iteration. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/asyncapi/parse_test.go b/internal/asyncapi/parse_test.go new file mode 100644 index 0000000..81f8058 --- /dev/null +++ b/internal/asyncapi/parse_test.go @@ -0,0 +1,313 @@ +package asyncapi + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func readFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile("testdata/" + name) + require.NoError(t, err) + return data +} + +/* +Scenario: Parsing a valid AsyncAPI 3.0.0 spec +Given YAML data with asyncapi: 3.0.0, a channel and an operation +When Parse is called +Then it returns a document with version, channel, operation, message and extensions + +Related spec scenarios: RS.AAL.2, RS.AAL.5, RS.AAL.11 +*/ +func TestParse_Valid30(t *testing.T) { + t.Parallel() + + doc, err := Parse(readFixture(t, "test-30.yaml")) + require.NoError(t, err) + require.Equal(t, "3.0.0", doc.Version) + require.Len(t, doc.Channels, 1) + require.Len(t, doc.Operations, 1) + + ch := doc.Channels[0] + assert.Equal(t, "userSignedUp", ch.ID) + assert.Equal(t, "user/signedup", ch.Address) + require.Len(t, ch.Messages, 1) + assert.Len(t, ch.Messages[0].Examples, 2) + assert.NotEmpty(t, ch.Messages[0].Examples[0].Extensions["x-mock-match"]) + assert.Equal(t, true, ch.Messages[0].Examples[0].Extensions["x-mock-once"]) + assert.Equal(t, true, ch.Messages[0].Examples[1].Extensions["x-mock-set-state"] != nil) + + op := doc.Operations[0] + assert.Equal(t, "receiveUserSignedUp", op.ID) + assert.Equal(t, ActionReceive, op.Action) + assert.Equal(t, "userSignedUp", op.Channel.ID) + require.Len(t, op.Messages, 1) + assert.Equal(t, "auserSignedUp", op.Messages[0].Name) +} + +/* +Scenario: Parsing a valid AsyncAPI 3.1.0 spec with components +Given YAML data with asyncapi: 3.1.0 and a components section +When Parse is called +Then it returns a document without error + +Related spec scenarios: RS.AAL.3, RS.AAL.7 +*/ +func TestParse_Valid31(t *testing.T) { + t.Parallel() + + doc, err := Parse(readFixture(t, "test-31.yaml")) + require.NoError(t, err) + assert.Equal(t, "3.1.0", doc.Version) + require.Len(t, doc.Channels, 1) + assert.Equal(t, "user/signedup", doc.Channels[0].Address) +} + +/* +Scenario: Rejecting an AMQP binding as unsupported +Given YAML data with an amqp channel binding declaring an exchange +When Parse is called +Then it reports a validation error naming the unsupported protocol (amqp) + +Related spec scenarios: RS.AAL.8, RS.ASP.4 +*/ +func TestParse_AMQPBindingRejected(t *testing.T) { + t.Parallel() + + data := []byte(`asyncapi: 3.0.0 +info: + title: AMQP Events + version: 1.0.0 +channels: + userSignup: + address: 'user/signup' + bindings: + amqp: + is: routingKey + exchange: + name: myExchange + type: topic + messages: + msg: + examples: + - payload: + event: signup +operations: + receiveSignup: + action: receive + channel: + $ref: '#/channels/userSignup' +`) + _, err := Parse(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "amqp") + assert.Contains(t, err.Error(), "unsupported protocol") +} + +/* +Scenario: Rejecting an unsupported AsyncAPI major version +Given YAML data with asyncapi: 2.6.0 +When Parse is called +Then it returns an error stating the version is unsupported + +Related spec scenarios: RS.AAL.12 +*/ +func TestParse_UnsupportedVersion(t *testing.T) { + t.Parallel() + + data := []byte("asyncapi: 2.6.0\ninfo:\n title: x\n version: 1.0.0\nchannels: {}\n") + _, err := Parse(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported AsyncAPI version") +} + +/* +Scenario: Rejecting missing mandatory channels +Given YAML data with asyncapi: 3.0.0 but no channels +When Parse is called +Then it reports a schema validation error + +Related spec scenarios: RS.AAL.6 +*/ +func TestParse_MissingChannels(t *testing.T) { + t.Parallel() + + data := []byte("asyncapi: 3.0.0\ninfo:\n title: x\n version: 1.0.0\noperations: {}\n") + _, err := Parse(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "channels") +} + +/* +Scenario: Rejecting missing operations in a 3.0.0 spec +Given YAML data with asyncapi: 3.0.0, channels but no operations +When Parse is called +Then it reports a schema validation error + +Related spec scenarios: RS.AAL.6 +*/ +func TestParse_MissingOperations(t *testing.T) { + t.Parallel() + + data := []byte("asyncapi: 3.0.0\ninfo:\n title: x\n version: 1.0.0\nchannels:\n c:\n address: a\n") + _, err := Parse(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "operations") +} + +/* +Scenario: Detecting an unsupported protocol binding +Given YAML data with a channel declaring a kafka binding +When Parse is called +Then it reports a validation error naming the unsupported protocol + +Related spec scenarios: RS.AAL.8, RS.ASP.4 +*/ +func TestParse_UnsupportedProtocol(t *testing.T) { + t.Parallel() + + data := []byte(`asyncapi: 3.0.0 +info: + title: x + version: 1.0.0 +channels: + c: + address: topic + bindings: + kafka: + topic: events +operations: + o: + action: receive + channel: + $ref: '#/channels/c' +`) + _, err := Parse(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "kafka") + assert.Contains(t, err.Error(), "unsupported protocol") +} + +/* +Scenario: File with neither version key is not handled by the AsyncAPI parser +Given data that is not an AsyncAPI document +When Parse is called +Then it returns an error + +Related spec scenarios: RS.AAL.4 +*/ +func TestParse_NonAsyncAPI(t *testing.T) { + t.Parallel() + + _, err := Parse([]byte("openapi: 3.0.0\ninfo:\n title: x\n version: 1.0.0\n")) + require.Error(t, err) +} + +/* +Scenario: Capturing a root-level x-signalr hub declaration +Given an AsyncAPI document with a root x-signalr extension carrying a hub path +When Parse is called +Then the neutral Document view exposes the SignalR config with the hub path + +Related spec scenarios: RS.SHR.1, RS.SHR.2 +*/ +func TestParse_RootSignalR(t *testing.T) { + t.Parallel() + + data := []byte(` +asyncapi: 3.0.0 +info: + title: SignalR Hub + version: 1.0.0 +x-signalr: + path: /hub +channels: + priceFeed: + address: priceFeed + bindings: + ws: + method: GET + messages: + priceMsg: + examples: + - name: snap + payload: + symbol: ETH +operations: + receivePrice: + action: receive + channel: + $ref: '#/channels/priceFeed' +`) + doc, err := Parse(data) + require.NoError(t, err) + require.NotNil(t, doc.SignalR) + assert.Equal(t, "/hub", doc.SignalR.Path) +} + +/* +Scenario: Absence of root x-signalr yields a nil SignalR config +Given an AsyncAPI document without the x-signalr extension +When Parse is called +Then the neutral Document view has a nil SignalR config + +Related spec scenarios: RS.SHR.1, RS.SHR.2 +*/ +func TestParse_NoRootSignalR(t *testing.T) { + t.Parallel() + + doc, err := Parse(readFixture(t, "test-30.yaml")) + require.NoError(t, err) + assert.Nil(t, doc.SignalR) +} + +/* +Scenario: Capturing x-send-events on a message example +Given an AsyncAPI message example with an x-send-events extension +When Parse is called +Then the neutral example view surfaces the extension + +Related spec scenarios: RS.EVT.7, RS.EVT.9, RS.EVT.10 +*/ +func TestParse_MessageExampleSendEvents(t *testing.T) { + t.Parallel() + + data := []byte(` +asyncapi: 3.0.0 +info: + title: Events + version: 1.0.0 +channels: + alerts: + address: alerts + bindings: + ws: + method: GET + messages: + alertMsg: + examples: + - name: ex1 + payload: + level: info + x-send-events: + - on: orderCreated + wait: 50 + - on: connect +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +`) + doc, err := Parse(data) + require.NoError(t, err) + require.Len(t, doc.Channels, 1) + require.Len(t, doc.Channels[0].Messages, 1) + examples := doc.Channels[0].Messages[0].Examples + require.Len(t, examples, 1) + assert.Contains(t, examples[0].Extensions, "x-send-events") +} diff --git a/internal/asyncapi/testdata/test-30.yaml b/internal/asyncapi/testdata/test-30.yaml new file mode 100644 index 0000000..751a7d7 --- /dev/null +++ b/internal/asyncapi/testdata/test-30.yaml @@ -0,0 +1,34 @@ +asyncapi: 3.0.0 +info: + title: User Events + version: 1.0.0 +channels: + userSignedUp: + address: user/signedup + messages: + auserSignedUp: + payload: + type: object + properties: + id: + type: string + examples: + - name: ex1 + payload: + id: "{$request.path.id}" + x-mock-match: + "{$message.payload.id}": + type: integer + x-mock-once: true + - name: ex2 + payload: + id: hello + x-mock-set-state: + lastEvent: signedup +operations: + receiveUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/auserSignedUp' \ No newline at end of file diff --git a/internal/asyncapi/testdata/test-31.yaml b/internal/asyncapi/testdata/test-31.yaml new file mode 100644 index 0000000..2f7b205 --- /dev/null +++ b/internal/asyncapi/testdata/test-31.yaml @@ -0,0 +1,23 @@ +asyncapi: 3.1.0 +info: + title: Webhooks Events + version: 1.0.0 +channels: + signedUp: + address: user/signedup + messages: + signedUpMsg: + examples: + - payload: + id: 1 +operations: + receiveSignedUp: + action: receive + channel: + $ref: '#/channels/signedUp' +components: + messages: + signedUpMsg: + examples: + - payload: + id: 2 \ No newline at end of file diff --git a/internal/extensions/event_trigger_test.go b/internal/extensions/event_trigger_test.go new file mode 100644 index 0000000..abf0ba7 --- /dev/null +++ b/internal/extensions/event_trigger_test.go @@ -0,0 +1,85 @@ +package extensions + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Extracting event triggers from an OpenAPI example +Given an OpenAPI example with an x-event-trigger list +When ExtractEventTriggers is called +Then the parsed triggers carry name, payload, delay and global flags + +Related spec scenarios: RS.EVT.1, RS.EVT.2, RS.EVT.3, RS.EVT.4 +*/ +func TestExtractEventTriggers(t *testing.T) { + t.Parallel() + + ex := &openapi3.Example{ + Extensions: map[string]any{ + "x-event-trigger": []any{ + map[string]any{ + "name": "orderCreated", + "payload": map[string]any{"orderId": "1"}, + "delay": float64(500), + "global": true, + }, + map[string]any{ + "name": "notified", + }, + }, + }, + } + + triggers, ok := ExtractEventTriggers(ex) + require.True(t, ok) + require.Len(t, triggers, 2) + assert.Equal(t, "orderCreated", triggers[0].Name) + assert.Equal(t, 500, triggers[0].Delay) + assert.True(t, triggers[0].Global) + assert.Equal(t, "1", triggers[0].Payload["orderId"]) + assert.Equal(t, "notified", triggers[1].Name) + assert.False(t, triggers[1].Global) +} + +/* +Scenario: No event triggers present +Given an OpenAPI example without x-event-trigger +When ExtractEventTriggers is called +Then it returns nil and false + +Related spec scenarios: RS.EVT.1 +*/ +func TestExtractEventTriggers_Absent(t *testing.T) { + t.Parallel() + + ex := &openapi3.Example{Extensions: map[string]any{"x-mock-once": true}} + triggers, ok := ExtractEventTriggers(ex) + assert.False(t, ok) + assert.Nil(t, triggers) +} + +/* +Scenario: Event trigger in short form is rejected +Given an OpenAPI example with a non-list x-event-trigger +When ExtractEventTriggers is called +Then it returns false + +Related spec scenarios: RS.EVT.1 +*/ +func TestExtractEventTriggers_NonList(t *testing.T) { + t.Parallel() + + ex := &openapi3.Example{ + Extensions: map[string]any{ + "x-event-trigger": map[string]any{"name": "x"}, + }, + } + triggers, ok := ExtractEventTriggers(ex) + assert.False(t, ok) + assert.Nil(t, triggers) +} diff --git a/internal/extensions/example_value.go b/internal/extensions/example_value.go new file mode 100644 index 0000000..7ffe27f --- /dev/null +++ b/internal/extensions/example_value.go @@ -0,0 +1,152 @@ +package extensions + +import ( + "log/slog" + + "github.com/getkin/kin-openapi/openapi3" +) + +// ExampleValue abstracts a message example (OpenAPI or AsyncAPI) so extension +// extraction and selection behave identically across both sources (design D5). +type ExampleValue interface { + // Get retrieves a spec extension by name. + Get(key string) (any, bool) + // Payload returns the example's payload value. + Payload() any + // Headers returns the example's declared headers. + Headers() map[string]any +} + +// OpenAPIExampleValue adapts an *openapi3.Example to the ExampleValue contract. +func OpenAPIExampleValue(ex *openapi3.Example) ExampleValue { + if ex == nil { + return nil + } + return openAPIExampleValue{ex: ex} +} + +type openAPIExampleValue struct { + ex *openapi3.Example +} + +func (v openAPIExampleValue) Get(key string) (any, bool) { + if v.ex.Extensions == nil { + return nil, false + } + val, ok := v.ex.Extensions[key] + return val, ok +} + +func (v openAPIExampleValue) Payload() any { return v.ex.Value } + +func (v openAPIExampleValue) Headers() map[string]any { + if v.ex.Extensions == nil { + return nil + } + h, _ := v.ex.Extensions["x-mock-headers"].(map[string]any) + return h +} + +// NewExampleValue builds an ExampleValue from a payload, headers map and +// pre-captured extensions (used for AsyncAPI message examples). +func NewExampleValue(payload any, headers map[string]any, extensions map[string]any) ExampleValue { + return mapExampleValue{ + payload: payload, + hdrs: headers, + ext: extensions, + } +} + +// mapExampleValue is a map-backed ExampleValue for AsyncAPI message examples. +type mapExampleValue struct { + payload any + hdrs map[string]any + ext map[string]any +} + +func (m mapExampleValue) Get(key string) (any, bool) { + if m.ext == nil { + return nil, false + } + v, ok := m.ext[key] + return v, ok +} + +func (m mapExampleValue) Payload() any { return m.payload } + +func (m mapExampleValue) Headers() map[string]any { + if m.hdrs != nil { + return m.hdrs + } + h, _ := m.ext["x-mock-headers"].(map[string]any) + return h +} + +// ValueMatch returns the active params-match condition for an example value. +// When both x-mock-match and x-mock-params-match are present, x-mock-match +// wins and the legacy x-mock-params-match alias is deprecated; a warning is +// written to stderr mirroring ExtractParamsMatch. +func ValueMatch(ev ExampleValue) (map[string]any, bool) { + if ev == nil { + return nil, false + } + _, hasMatch := ev.Get("x-mock-match") + _, hasParamsMatch := ev.Get("x-mock-params-match") + var key string + switch { + case hasParamsMatch && hasMatch: + slog.Warn("Example has both x-mock-match and x-mock-params-match. Ignoring deprecated x-mock-params-match; using x-mock-match.") + key = "x-mock-match" + case hasParamsMatch: + key = "x-mock-params-match" + case hasMatch: + key = "x-mock-match" + default: + return nil, false + } + return asMap(ev, key) +} + +// ValueSkip reports whether the example is marked x-mock-skip. +func ValueSkip(ev ExampleValue) bool { + if ev == nil { + return false + } + v, ok := ev.Get("x-mock-skip") + return ok && v == true +} + +// ValueOnce reports whether the example is marked x-mock-once. +func ValueOnce(ev ExampleValue) bool { + if ev == nil { + return false + } + v, ok := ev.Get("x-mock-once") + return ok && v == true +} + +// ValueSetState extracts x-mock-set-state from an example value. +func ValueSetState(ev ExampleValue) (map[string]any, bool) { + if ev == nil { + return nil, false + } + return asMap(ev, "x-mock-set-state") +} + +// ValueHeaders extracts x-mock-headers from an example value. +func ValueHeaders(ev ExampleValue) (map[string]any, bool) { + if ev == nil { + return nil, false + } + h := ev.Headers() + return h, h != nil +} + +func asMap(ev ExampleValue, key string) (map[string]any, bool) { + v, ok := ev.Get(key) + if !ok { + return nil, false + } + m, ok := v.(map[string]any) + return m, ok +} diff --git a/internal/extensions/example_value_test.go b/internal/extensions/example_value_test.go new file mode 100644 index 0000000..e562a2f --- /dev/null +++ b/internal/extensions/example_value_test.go @@ -0,0 +1,124 @@ +package extensions + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: OpenAPI example and AsyncAPI message example share a wrapper +Given an OpenAPI example and a map-backed AsyncAPI example with equal extensions +When wrapped as ExampleValue +Then both expose the same extension values, payload and headers + +Related spec scenarios: RS.ATM.10, RS.ATM.11 +*/ +func TestExampleValue_UniformAccess(t *testing.T) { + t.Parallel() + + oe := &openapi3.Example{ + Value: map[string]any{"id": 1}, + Extensions: map[string]any{ + "x-mock-once": true, + "x-mock-set-state": map[string]any{"counter": 1}, + }, + } + ov := OpenAPIExampleValue(oe) + + once, _ := ov.Get("x-mock-once") + assert.Equal(t, true, once) + setState, _ := ov.Get("x-mock-set-state") + assert.Equal(t, map[string]any{"counter": 1}, setState) + assert.Equal(t, map[string]any{"id": 1}, ov.Payload()) + + // AsyncAPI message example backed by a plain map (as captured by the + // vendored parser's Example.Extensions). + ae := NewExampleValue(map[string]any{"id": 2}, nil, map[string]any{ + "x-mock-once": true, + "x-mock-set-state": map[string]any{"counter": 2}, + }) + once, _ = ae.Get("x-mock-once") + assert.Equal(t, true, once) + setState, _ = ae.Get("x-mock-set-state") + assert.Equal(t, map[string]any{"counter": 2}, setState) + assert.Equal(t, map[string]any{"id": 2}, ae.Payload()) +} + +/* +Scenario: ExampleValue exposes extension extraction uniformly +Given an ExampleValue with x-mock-skip and x-mock-headers +When the generic extractors run +Then skip and headers behave identically to the OpenAPI-specific helpers + +Related spec scenarios: RS.ATM.9, RS.ATM.14 +*/ +func TestExampleValue_ExtractUniform(t *testing.T) { + t.Parallel() + + ev := NewExampleValue(map[string]any{"x": 1}, nil, map[string]any{ + "x-mock-skip": true, + "x-mock-headers": map[string]any{"X-Trace": "abc"}, + }) + + assert.True(t, ValueSkip(ev)) + headers, ok := ValueHeaders(ev) + require.True(t, ok) + assert.Equal(t, "abc", headers["X-Trace"]) +} + +/* +Scenario: AsyncAPI message example wrapper resolves from loader spec +Given an AsyncAPI message example spec +When NewExampleValue is called +Then the wrapper exposes extensions and payload + +Related spec scenarios: RS.ATM.6, RS.ATM.8 +*/ +func TestExampleValue_FromAsyncSpec(t *testing.T) { + t.Parallel() + + ex := map[string]any{ + "x-mock-match": map[string]any{"{$message.payload.id}": map[string]any{"type": "integer"}}, + } + ev := NewExampleValue(map[string]any{"id": 1}, nil, ex) + + match, ok := ValueMatch(ev) + require.True(t, ok) + _, hasExpr := match["{$message.payload.id}"] + assert.True(t, hasExpr) +} + +/* +Scenario: x-mock-match wins over the deprecated x-mock-params-match alias +Given an example value carrying both extensions +When ValueMatch is called +Then the x-mock-match map is returned +And x-mock-params-match alone still resolves + +Related spec scenarios: RS.ATM.8 +*/ +func TestValueMatch_Precedence(t *testing.T) { + t.Parallel() + + both := NewExampleValue(map[string]any{"id": 1}, nil, map[string]any{ + "x-mock-match": map[string]any{"{$message.payload.id}": map[string]any{"type": "integer"}}, + "x-mock-params-match": map[string]any{"{$message.payload.id}": map[string]any{"type": "string"}}, + }) + match, ok := ValueMatch(both) + require.True(t, ok) + cond, hasCond := match["{$message.payload.id}"] + require.True(t, hasCond) + assert.Equal(t, map[string]any{"type": "integer"}, cond) + + legacyOnly := NewExampleValue(map[string]any{"id": 1}, nil, map[string]any{ + "x-mock-params-match": map[string]any{"{$message.payload.id}": map[string]any{"type": "string"}}, + }) + m, ok := ValueMatch(legacyOnly) + require.True(t, ok) + cond2, hasCond2 := m["{$message.payload.id}"] + require.True(t, hasCond2) + assert.Equal(t, map[string]any{"type": "string"}, cond2) +} diff --git a/internal/extensions/extract.go b/internal/extensions/extract.go index 672f7c4..9ac3f55 100644 --- a/internal/extensions/extract.go +++ b/internal/extensions/extract.go @@ -38,7 +38,7 @@ func ExtractParamsMatch(ex *openapi3.Example) (ParamsMatch, bool) { var key string switch { case hasParamsMatch && hasMatch: - slog.Warn("Example has both x-mock-match and x-mock-params-match. Using x-mock-match (deprecated).") + slog.Warn("Example has both x-mock-match and x-mock-params-match. Ignoring deprecated x-mock-params-match; using x-mock-match.") key = "x-mock-match" case hasParamsMatch: key = "x-mock-params-match" @@ -73,3 +73,69 @@ func ExtractSetState(ex *openapi3.Example) (map[string]any, bool) { func ExtractHeaders(ex *openapi3.Example) (map[string]any, bool) { return extractExtension[map[string]any](ex, "x-mock-headers") } + +// EventTrigger is a single x-event-trigger entry (design D8). +type EventTrigger struct { + // Name is the named event fired when the example is selected. + Name string + // Payload is the event payload exposed via {$event.*}. + Payload map[string]any + // Delay is the delivery delay in milliseconds. + Delay int + // Global makes the event server-wide instead of schema-local. + Global bool +} + +// ExtractEventTriggers parses the x-event-trigger list extension (RS.EVT.1-4). +// It returns false when the extension is absent or not a list. +func ExtractEventTriggers(ex *openapi3.Example) ([]EventTrigger, bool) { + if ex == nil || ex.Extensions == nil { + return nil, false + } + raw, ok := ex.Extensions["x-event-trigger"] + if !ok { + return nil, false + } + items, ok := raw.([]any) + if !ok { + return nil, false + } + var out []EventTrigger + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + t := EventTrigger{} + if name, ok := m["name"].(string); ok { + t.Name = name + } + if payload, ok := m["payload"].(map[string]any); ok { + t.Payload = payload + } + if delay, ok := asDelay(m["delay"]); ok { + t.Delay = delay + } + if global, ok := m["global"].(bool); ok { + t.Global = global + } + if t.Name == "" { + continue + } + out = append(out, t) + } + return out, len(out) > 0 +} + +// asDelay converts a JSON number to an int millisecond delay. +func asDelay(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/extensions/parity_test.go b/internal/extensions/parity_test.go new file mode 100644 index 0000000..8966e53 --- /dev/null +++ b/internal/extensions/parity_test.go @@ -0,0 +1,73 @@ +package extensions + +import ( + "encoding/json" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Identical selection metadata across OpenAPI and AsyncAPI examples +Given equivalent OpenAPI and AsyncAPI examples carrying the same x-mock-* set +When the wrapper-based extractors run on both +Then selection decisions (match/skip/once/state/headers) are identical + +Related spec scenarios: RS.ATM.6, RS.ATM.7, RS.ATM.9, RS.ATM.10, RS.ATM.11, RS.ATM.14 +*/ +func TestExampleValue_OpenAPIAsyncAPIParity(t *testing.T) { + t.Parallel() + + ext := map[string]any{ + "x-mock-match": map[string]any{ + "{$message.payload.id}": map[string]any{"type": "integer"}, + }, + "x-mock-once": true, + "x-mock-set-state": map[string]any{"counter": 1}, + "x-mock-headers": map[string]any{"X-Trace": "abc"}, + } + + asyncValue := NewExampleValue(map[string]any{"id": 1}, nil, ext) + openapiValue := OpenAPIExampleValue(&openapi3.Example{ + Value: map[string]any{"id": 1}, + Extensions: ext, + }) + + for _, tc := range []struct { + name string + got func(ExampleValue) any + }{ + {name: "match", got: func(v ExampleValue) any { + m, ok := ValueMatch(v) + return []any{m, ok} + }}, + {name: "skip", got: func(v ExampleValue) any { return ValueSkip(v) }}, + {name: "once", got: func(v ExampleValue) any { return ValueOnce(v) }}, + {name: "set-state", got: func(v ExampleValue) any { + m, ok := ValueSetState(v) + return []any{m, ok} + }}, + {name: "headers", got: func(v ExampleValue) any { + m, ok := ValueHeaders(v) + return []any{m, ok} + }}, + } { + a := tc.got(asyncValue) + b := tc.got(openapiValue) + require.NotNil(t, a) + require.NotNil(t, b) + aj := parityJSON(t, a) + bj := parityJSON(t, b) + assert.Equal(t, aj, bj, "parity mismatch for %s", tc.name) + } +} + +// parityJSON renders a value as deterministic JSON for comparison. +func parityJSON(t *testing.T, v any) string { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + return string(data) +} diff --git a/internal/loader/async_router_test.go b/internal/loader/async_router_test.go new file mode 100644 index 0000000..08bc321 --- /dev/null +++ b/internal/loader/async_router_test.go @@ -0,0 +1,316 @@ +package loader + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const httpChannelSpec = `asyncapi: 3.0.0 +info: + title: HTTP Events + version: 1.0.0 +channels: + employees: + address: /employees + messages: + emplMsg: + examples: + - payload: + id: 1 +operations: + getEmployees: + action: send + channel: + $ref: '#/channels/employees' + bindings: + http: + method: GET +` + +const wsChannelSpec = `asyncapi: 3.0.0 +info: + title: WS Events + version: 1.0.0 +channels: + socket: + address: /socket + bindings: + ws: + method: GET + messages: + msg: + examples: + - payload: + event: hello +operations: + receiveSocket: + action: receive + channel: + $ref: '#/channels/socket' +` + +const unsupportedChannelSpec = `asyncapi: 3.0.0 +info: + title: Kafka Events + version: 1.0.0 +channels: + k: + address: topic + bindings: + kafka: + topic: events + messages: + msg: + examples: + - payload: {} +operations: + receiveK: + action: receive + channel: + $ref: '#/channels/k' +` + +const noBindingChannelSpec = `asyncapi: 3.0.0 +info: + title: No Binding + version: 1.0.0 +channels: + n: + address: something + messages: + msg: + examples: + - payload: {} +operations: + receiveN: + action: receive + channel: + $ref: '#/channels/n' +` + +/* +Scenario: Mapping an AsyncAPI HTTP channel to a route +Given an asyncapi spec with an http channel binding and a GET operation binding +When BuildRouteMappings is called +Then it produces an http route with the address and method, and the message spec + +Related spec scenarios: RS.ASP.1, RS.ASP.10 +*/ +func TestBuildAsyncRouteMappings_HTTP(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, httpChannelSpec, "/v1") + mappings, err := BuildRouteMappings([]SchemaInfo{info}) + require.NoError(t, err) + require.Len(t, mappings, 1) + rm := mappings[0] + assert.Equal(t, "GET", rm.Method) + assert.Equal(t, "/v1/employees", rm.Path) + assert.Equal(t, "/employees", rm.Pattern) + assert.Equal(t, "/v1/employees", rm.ChiPattern) + assert.Equal(t, asyncapi.ProtocolHTTP, rm.Protocol) + assert.Equal(t, "send", rm.Action) + require.Len(t, rm.Messages, 1) + assert.Equal(t, "emplMsg", rm.Messages[0].Name) + require.Len(t, rm.Messages[0].Examples, 1) +} + +/* +Scenario: Mapping an AsyncAPI WebSocket channel to a route +Given an asyncapi spec with a ws channel binding +When BuildRouteMappings is called +Then it produces a ws route at the channel address + +Related spec scenarios: RS.ASP.2 +*/ +func TestBuildAsyncRouteMappings_WS(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, wsChannelSpec, "") + mappings, err := BuildRouteMappings([]SchemaInfo{info}) + require.NoError(t, err) + require.Len(t, mappings, 1) + rm := mappings[0] + assert.Equal(t, asyncapi.ProtocolWS, rm.Protocol) + assert.Equal(t, "/socket", rm.Path) + assert.Equal(t, "GET", rm.Method) + assert.Equal(t, "receive", rm.Action) +} + +/* +Scenario: Mapping an AsyncAPI AMQP channel is rejected as unsupported +Given an asyncapi spec with an amqp channel binding +When asyncapi.Parse is called +Then it reports a validation error naming the unsupported protocol + +Related spec scenarios: RS.AAL.8, RS.ASP.4 +*/ +func TestBuildAsyncRouteMappings_AMQPRejected(t *testing.T) { + t.Parallel() + + data := []byte(`asyncapi: 3.0.0 +info: + title: AMQP Events + version: 1.0.0 +channels: + signup: + address: 'user/signup' + bindings: + amqp: + is: routingKey + exchange: + name: userExchange + messages: + msg: + examples: + - payload: + event: signup +operations: + receiveSignup: + action: receive + channel: + $ref: '#/channels/signup' +`) + _, err := asyncapi.Parse(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "amqp") + assert.Contains(t, err.Error(), "unsupported protocol") +} + +/* +Scenario: Rejecting a channel with an unknown protocol binding +Given an asyncapi spec with a kafka channel binding +When the loader parses the spec +Then it reports a validation error naming the unsupported protocol + +Related spec scenarios: RS.AAL.8, RS.ASP.4 +*/ +func TestBuildAsyncRouteMappings_UnsupportedProtocol(t *testing.T) { + t.Parallel() + + _, err := asyncapi.Parse([]byte(unsupportedChannelSpec)) + require.Error(t, err) + assert.Contains(t, err.Error(), "kafka") + assert.Contains(t, err.Error(), "unsupported protocol") +} + +/* +Scenario: Rejecting a channel without binding information +Given an asyncapi spec with a channel that declares no protocol binding +When BuildRouteMappings is called +Then it reports the channel as invalid with a clear error + +Related spec scenarios: RS.ASP.5 +*/ +func TestBuildAsyncRouteMappings_NoBinding(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, noBindingChannelSpec, "") + _, err := BuildRouteMappings([]SchemaInfo{info}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no binding information") +} + +/* +Scenario: Applying schema prefix to AsyncAPI channel addresses +Given an asyncapi spec with prefix /v1 and a channel address user/signedup +When BuildRouteMappings is called +Then the route serves under the prefixed address + +Related spec scenarios: RS.ASP.8, RS.MSC.51 +*/ +func TestBuildAsyncRouteMappings_Prefix(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, wsChannelSpec, "/v1") + mappings, err := BuildRouteMappings([]SchemaInfo{info}) + require.NoError(t, err) + require.Len(t, mappings, 1) + assert.Equal(t, "/v1/socket", mappings[0].Path) +} + +/* +Scenario: Multiple AsyncAPI schemas each honor their own prefix +Given two AsyncAPI schemas loaded with prefixes /a1 and /a2 +When BuildRouteMappings is called over both +Then routes are produced under each prefixed address + +Related spec scenarios: RS.AAL.9 +*/ +func TestBuildAsyncRouteMappings_TwoSchemasPrefixed(t *testing.T) { + t.Parallel() + + info1 := mustAsyncInfo(t, wsChannelSpec, "/a1") + info2 := mustAsyncInfo(t, httpChannelSpec, "/a2") + mappings, err := BuildRouteMappings([]SchemaInfo{info1, info2}) + require.NoError(t, err) + require.Len(t, mappings, 2) + + pathSet := make(map[string]bool) + for _, rm := range mappings { + pathSet[rm.Path] = true + } + assert.True(t, pathSet["/a1/socket"], "expected /a1/socket among routes, got %v", pathSet) + assert.True(t, pathSet["/a2/employees"], "expected /a2/employees among routes, got %v", pathSet) +} + +const loaderOpenAPIDoc = `openapi: 3.0.0 +info: + title: REST + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK +` + +/* +Scenario: Mixing OpenAPI and AsyncAPI sources yields routes of both kinds +Given an OpenAPI and an AsyncAPI schema file +When LoadSchemas and BuildRouteMappings are called +Then routes from both kinds are produced + +Related spec scenarios: RS.AAL.10, RS.MSC.50 +*/ +func TestBuildRouteMappings_MixedOpenAPIAndAsyncAPI(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + openapiPath := filepath.Join(dir, "openapi.yaml") + asyncPath := filepath.Join(dir, "asyncapi.yaml") + require.NoError(t, os.WriteFile(openapiPath, []byte(loaderOpenAPIDoc), 0o644)) + require.NoError(t, os.WriteFile(asyncPath, []byte(wsChannelSpec), 0o644)) + + infos, err := LoadSchemas([]string{openapiPath, asyncPath}, []string{"/v1", "/v2"}) + require.NoError(t, err) + require.Len(t, infos, 2) + + mappings, err := BuildRouteMappings(infos) + require.NoError(t, err) + require.NotEmpty(t, mappings) + + var openapiMapped, asyncMapped bool + for _, rm := range mappings { + if rm.Protocol != "" { + asyncMapped = true + } else if rm.Operation != nil { + openapiMapped = true + } + } + assert.True(t, openapiMapped, "expected at least one OpenAPI mapping") + assert.True(t, asyncMapped, "expected at least one AsyncAPI mapping") +} + +func mustAsyncInfo(t *testing.T, spec, prefix string) SchemaInfo { + t.Helper() + doc, err := asyncapi.Parse([]byte(spec)) + require.NoError(t, err) + return SchemaInfo{Kind: KindAsyncAPI, Async: doc, Prefix: prefix} +} diff --git a/internal/loader/decode.go b/internal/loader/decode.go new file mode 100644 index 0000000..5fd8034 --- /dev/null +++ b/internal/loader/decode.go @@ -0,0 +1,26 @@ +package loader + +import ( + "bytes" + "encoding/json" + + "gopkg.in/yaml.v3" +) + +// decodeYAMLDocument unmarshals YAML bytes into a generic mapping. +func decodeYAMLDocument(data []byte) (map[string]any, error) { + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, err + } + return doc, nil +} + +// decodeJSONDocument unmarshals JSON bytes into a generic mapping. +func decodeJSONDocument(data []byte) (map[string]any, error) { + var doc map[string]any + if err := json.Unmarshal(bytes.TrimSpace(data), &doc); err != nil { + return nil, err + } + return doc, nil +} diff --git a/internal/loader/router.go b/internal/loader/router.go index 50795a6..55dcaf0 100644 --- a/internal/loader/router.go +++ b/internal/loader/router.go @@ -1,14 +1,17 @@ package loader import ( + "fmt" "net/http" "slices" "strings" "github.com/getkin/kin-openapi/openapi3" + "github.com/mamonth/oasmock/internal/asyncapi" ) -// RouteMapping holds a mapping from HTTP method and path pattern to an OpenAPI operation. +// RouteMapping holds a mapping from HTTP method and path pattern to an OpenAPI +// operation, or from a channel address to an AsyncAPI channel/operation. type RouteMapping struct { Method string Path string // The full path pattern with prefix (e.g., "/v1/users/{id}") @@ -18,42 +21,257 @@ type RouteMapping struct { Operation *openapi3.Operation Parameters openapi3.Parameters Responses *openapi3.Responses + + // AsyncAPI-specific route data. + Protocol string // "http" | "ws" + Action string // "send" | "receive" | "" (OpenAPI default) + Messages []*MessageSpec // AsyncAPI-backed message specs } -// BuildRouteMappings creates route mappings from loaded schemas. -// For each schema, each path in the schema is combined with the schema's prefix -// to produce the full path pattern used for routing. -func BuildRouteMappings(infos []SchemaInfo) ([]RouteMapping, error) { +// MessageSpec is a protocol-neutral message source for AsyncAPI routes, +// carrying examples that drive the shared selection pipeline. +type MessageSpec struct { + ID string + Name string + Headers map[string]any + Payload any + // Examples in AsyncAPI definition order. + Examples []*MessageExampleSpec +} + +// MessageExampleSpec is a single asyncapi message example with extensions. +type MessageExampleSpec struct { + Name string + Headers map[string]any + Payload any + Extensions map[string]any +} + +// buildAsyncRouteMappings converts an AsyncAPI schema's channels/operations +// into routes keyed by protocol binding. It reports startup errors for +// channels with unknown/missing binding info. When the document declares root +// x-signalr, its ws channels are served by the SignalR hub and are not mapped +// to raw ws routes (design D7). +func buildAsyncRouteMappings(info SchemaInfo) ([]RouteMapping, error) { + if info.Async == nil { + return nil, fmt.Errorf("schema %q has no AsyncAPI document", info.Prefix) + } + prefix := strings.TrimSuffix(info.Prefix, "/") + + // Group operations by the channel they reference so channel-level and + // operation-level bindings can be combined per protocol. + byChannel := make(map[string][]*asyncapi.Operation) + for _, op := range info.Async.Operations { + if op.Channel == nil { + continue + } + byChannel[op.Channel.ID] = append(byChannel[op.Channel.ID], op) + } + var mappings []RouteMapping + for _, ch := range info.Async.Channels { + ops := byChannel[ch.ID] + if len(ops) == 0 { + return nil, fmt.Errorf("channel %q has no operations referencing it", ch.ID) + } - for _, info := range infos { - spec := info.Spec - prefix := strings.TrimSuffix(info.Prefix, "/") - - // Collect and sort paths for deterministic iteration - pathMap := spec.Paths.Map() - paths := make([]string, 0, len(pathMap)) - for path := range pathMap { - paths = append(paths, path) + // A channel's protocol can be declared at channel level or at the + // operation level (e.g. an http method binding on the operation). + protocol, err := channelProtocolForOps(ch, ops) + if err != nil { + return nil, err + } + + // SignalR documents serve ws channels via the hub, not as raw routes. + if info.Async.SignalR != nil && protocol == asyncapi.ProtocolWS { + continue + } + + for _, op := range ops { + rm, err := asyncRoute(ch, prefix, protocol, op, ch.Bindings) + if err != nil { + return nil, err + } + mappings = append(mappings, rm) + } + } + return mappings, nil +} + +// channelProtocolForOps resolves the protocol for a channel by merging its own +// channel-level bindings with the bindings of the operations that reference it. +func channelProtocolForOps(ch *asyncapi.Channel, ops []*asyncapi.Operation) (string, error) { + prots := append([]string{}, ch.Bindings.Protocols...) + for _, op := range ops { + prots = append(prots, op.Bindings.Protocols...) + } + if len(prots) == 0 { + return "", fmt.Errorf("channel %q has no binding information usable to determine a server protocol", ch.ID) + } + protocol := routeProtocol(prots) + if protocol == "" { + return "", fmt.Errorf("channel %q declares unsupported protocol binding(s): %s", ch.ID, strings.Join(prots, ", ")) + } + return protocol, nil +} + +// routeProtocol picks the first supported protocol from the declared set. +func routeProtocol(prots []string) string { + for _, p := range prots { + switch p { + case asyncapi.ProtocolHTTP, asyncapi.ProtocolWS: + return p + } + } + return "" +} + +// asyncRoute builds a single RouteMapping for an asyncapi channel+operation. +func asyncRoute(ch *asyncapi.Channel, prefix, protocol string, op *asyncapi.Operation, bindings asyncapi.Bindings) (RouteMapping, error) { + address := ch.Address + if address == "" { + return RouteMapping{}, fmt.Errorf("channel %q has no address", ch.ID) + } + fullAddress := applyAddressPrefix(prefix, address) + + rm := RouteMapping{ + Prefix: prefix, + Protocol: protocol, + Action: string(op.Action), + Messages: messageSpecs(op), + } + + switch protocol { + case asyncapi.ProtocolHTTP: + method := "GET" + if op.Bindings.HTTP != nil && op.Bindings.HTTP.Method != "" { + method = op.Bindings.HTTP.Method } - slices.Sort(paths) - for _, path := range paths { - pathItem := pathMap[path] - if pathItem == nil { + rm.Method = method + rm.Path = fullAddress + rm.Pattern = address + rm.ChiPattern = fullAddress + case asyncapi.ProtocolWS: + rm.Method = http.MethodGet + rm.Path = fullAddress + rm.Pattern = address + rm.ChiPattern = fullAddress + default: + return RouteMapping{}, fmt.Errorf("channel %q: unsupported protocol %q", ch.ID, protocol) + } + + return rm, nil +} + +// messageSpecs converts an asyncapi operation's messages into MessageSpecs. +func messageSpecs(op *asyncapi.Operation) []*MessageSpec { + return MessageSpecsFromAsync(op.Messages) +} + +// NewMessageSpec creates a MessageSpec from a single neutral AsyncAPI message, +// leaving Examples to the caller. It returns nil for a nil message. +func NewMessageSpec(m *asyncapi.Message) *MessageSpec { + if m == nil { + return nil + } + return &MessageSpec{ID: m.ID, Name: m.Name} +} + +// MessageSpecsFromAsync converts neutral AsyncAPI messages into MessageSpecs +// preserving example order. Nil messages and examples are skipped. It is the +// single conversion point shared by the loader, signalr hub, event driver and +// tests. +func MessageSpecsFromAsync(messages []*asyncapi.Message) []*MessageSpec { + var specs []*MessageSpec + for _, m := range messages { + spec := NewMessageSpec(m) + if spec == nil { + continue + } + for _, ex := range m.Examples { + if ex == nil { continue } + spec.Examples = append(spec.Examples, &MessageExampleSpec{ + Name: ex.Name, + Headers: ex.Headers, + Payload: ex.Payload, + Extensions: ex.Extensions, + }) + } + specs = append(specs, spec) + } + return specs +} - // Apply prefix to the path - fullPath := applyPrefix(prefix, path) +// applyAddressPrefix applies the schema prefix to an AsyncAPI channel address. +func applyAddressPrefix(prefix, address string) string { + if prefix == "" { + return normalizeAddress(address) + } + prefix = "/" + strings.Trim(prefix, "/") + return prefix + normalizeAddress(address) +} - // Create mappings for each HTTP method defined in the path item - mappings = append(mappings, createMappingsForPath(path, fullPath, prefix, pathItem)...) +// normalizeAddress ensures a channel address is absolute (starts with "/"). +func normalizeAddress(address string) string { + addr := "/" + strings.Trim(address, "/") + if addr == "/" { + return addr + } + return addr +} + +// BuildRouteMappings creates route mappings from loaded schemas. +// OpenAPI schemas map HTTP method+path pairs; AsyncAPI schemas map channels +// to routes per their protocol binding. For each schema, paths/addresses are +// combined with the schema's prefix to produce the full routing pattern. +func BuildRouteMappings(infos []SchemaInfo) ([]RouteMapping, error) { + var mappings []RouteMapping + + for _, info := range infos { + switch info.Kind { + case KindAsyncAPI: + asyncMappings, err := buildAsyncRouteMappings(info) + if err != nil { + return nil, err + } + mappings = append(mappings, asyncMappings...) + default: + mappings = append(mappings, buildOpenAPIRouteMappings(info)...) } } return mappings, nil } +func buildOpenAPIRouteMappings(info SchemaInfo) []RouteMapping { + var mappings []RouteMapping + spec := info.Spec + prefix := strings.TrimSuffix(info.Prefix, "/") + + // Collect and sort paths for deterministic iteration + pathMap := spec.Paths.Map() + paths := make([]string, 0, len(pathMap)) + for path := range pathMap { + paths = append(paths, path) + } + slices.Sort(paths) + for _, path := range paths { + pathItem := pathMap[path] + if pathItem == nil { + continue + } + + // Apply prefix to the path + fullPath := applyPrefix(prefix, path) + + // Create mappings for each HTTP method defined in the path item + mappings = append(mappings, createMappingsForPath(path, fullPath, prefix, pathItem)...) + } + return mappings +} + func applyPrefix(prefix, path string) string { if prefix == "" { return path diff --git a/internal/loader/rpc.go b/internal/loader/rpc.go index bb84adc..e032221 100644 --- a/internal/loader/rpc.go +++ b/internal/loader/rpc.go @@ -77,6 +77,9 @@ func BuildRpcMappings(infos []SchemaInfo, cfg *RpcConfig) ([]*RpcRouteMapping, e var mappings []*RpcRouteMapping for _, info := range infos { + if info.Kind == KindAsyncAPI || info.Spec == nil { + continue + } spec := info.Spec prefix := info.Prefix diff --git a/internal/loader/schema.go b/internal/loader/schema.go index 33aace1..272915d 100644 --- a/internal/loader/schema.go +++ b/internal/loader/schema.go @@ -1,20 +1,35 @@ package loader import ( + "bytes" "fmt" "os" "path/filepath" + "strings" "github.com/getkin/kin-openapi/openapi3" + "github.com/mamonth/oasmock/internal/asyncapi" ) -// SchemaInfo holds a loaded OpenAPI spec and its path prefix. +// Kind is the type of a loaded specification. +type Kind string + +const ( + // KindOpenAPI is an OpenAPI 3.x specification. + KindOpenAPI Kind = "openapi" + // KindAsyncAPI is an AsyncAPI 3.x specification. + KindAsyncAPI Kind = "asyncapi" +) + +// SchemaInfo holds a loaded specification and its path prefix. type SchemaInfo struct { Spec *openapi3.T + Kind Kind + Async *asyncapi.Document Prefix string } -// LoadSchemas loads multiple OpenAPI schemas from file paths. +// LoadSchemas loads multiple schemas (OpenAPI or AsyncAPI) from file paths. // Each source path is paired with a prefix (empty string for no prefix). // Returns a slice of SchemaInfo in the same order as sources. func LoadSchemas(sources []string, prefixes []string) ([]SchemaInfo, error) { @@ -29,40 +44,98 @@ func LoadSchemas(sources []string, prefixes []string) ([]SchemaInfo, error) { prefix = prefixes[i] } - spec, err := loadSingleSchema(source) + info, err := loadSingleSchema(source) if err != nil { return nil, fmt.Errorf("failed to load schema %q: %w", source, err) } + info.Prefix = prefix - infos[i] = SchemaInfo{ - Spec: spec, - Prefix: prefix, - } + infos[i] = info } return infos, nil } -func loadSingleSchema(path string) (*openapi3.T, error) { +// detectKind inspects raw spec bytes and dispatches on the root version key. +// Returns an error when the file is neither an OpenAPI nor an AsyncAPI spec. +func detectKind(data []byte) (Kind, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return "", fmt.Errorf("empty schema file") + } + + // Open the document as a generic mapping to find the root version key. + doc, err := decodeDocument(trimmed) + if err != nil { + // Not parseable as YAML/JSON at all — not a valid spec file. + return "", fmt.Errorf("file is not a valid OpenAPI or AsyncAPI schema: %w", err) + } + + _, hasOpenAPI := doc["openapi"] + _, hasAsyncAPI := doc["asyncapi"] + switch { + case hasOpenAPI: + return KindOpenAPI, nil + case hasAsyncAPI: + return KindAsyncAPI, nil + default: + return "", fmt.Errorf("schema file has neither an 'openapi' nor an 'asyncapi' root key") + } +} + +func loadSingleSchema(path string) (SchemaInfo, error) { absPath, err := filepath.Abs(path) if err != nil { - return nil, fmt.Errorf("invalid path %q: %w", path, err) + return SchemaInfo{}, fmt.Errorf("invalid path %q: %w", path, err) } data, err := os.ReadFile(absPath) if err != nil { - return nil, fmt.Errorf("cannot read file %q: %w", path, err) + return SchemaInfo{}, fmt.Errorf("cannot read file %q: %w", path, err) } + kind, err := detectKind(data) + if err != nil { + return SchemaInfo{}, fmt.Errorf("invalid schema %q: %w", path, err) + } + + switch kind { + case KindOpenAPI: + spec, err := loadOpenAPI(absPath, data) + if err != nil { + return SchemaInfo{}, err + } + return SchemaInfo{Spec: spec, Kind: kind}, nil + case KindAsyncAPI: + doc, err := asyncapi.Parse(data) + if err != nil { + return SchemaInfo{}, fmt.Errorf("invalid AsyncAPI schema %q: %w", path, err) + } + return SchemaInfo{Async: doc, Kind: kind}, nil + default: + return SchemaInfo{}, fmt.Errorf("unsupported schema kind %q", kind) + } +} + +func loadOpenAPI(absPath string, data []byte) (*openapi3.T, error) { loader := openapi3.NewLoader() spec, err := loader.LoadFromData(data) if err != nil { - return nil, fmt.Errorf("invalid OpenAPI schema %q: %w", path, err) + return nil, fmt.Errorf("invalid OpenAPI schema %q: %w", absPath, err) } // Validate the spec if err := spec.Validate(loader.Context); err != nil { - return nil, fmt.Errorf("invalid OpenAPI schema %q: %w", path, err) + return nil, fmt.Errorf("invalid OpenAPI schema %q: %w", absPath, err) } return spec, nil } + +// decodeDocument parses YAML or JSON bytes into a generic mapping so the +// root version key can be inspected before choosing a loader. +func decodeDocument(data []byte) (map[string]any, error) { + if !strings.HasPrefix(strings.TrimLeft(string(data), " \t\r\n"), "{") { + return decodeYAMLDocument(data) + } + return decodeJSONDocument(data) +} diff --git a/internal/loader/schema_test.go b/internal/loader/schema_test.go index a0a8394..e63f1c6 100644 --- a/internal/loader/schema_test.go +++ b/internal/loader/schema_test.go @@ -8,12 +8,12 @@ import ( ) /* -Scenario: Loading single OpenAPI schema from file -Given a file path to an OpenAPI YAML +Scenario: Loading single schema from file +Given a file path to an OpenAPI or AsyncAPI YAML When loadSingleSchema is called -Then it returns parsed spec or error for missing/invalid files +Then it returns the parsed spec with the correct kind or error for missing/invalid files -Related spec scenarios: RS.MSC.1, RS.MSC.3 +Related spec scenarios: RS.MSC.1, RS.MSC.3, RS.AAL.1 */ func TestLoadSingleSchema(t *testing.T) { t.Parallel() @@ -22,19 +22,32 @@ func TestLoadSingleSchema(t *testing.T) { name string path string wantErr bool + wantKind Kind errContains string }{ { - name: "valid OpenAPI YAML", - path: "../../test/_shared/resources/test.yaml", - wantErr: false, - errContains: "", + name: "valid OpenAPI YAML", + path: "../../test/_shared/resources/test.yaml", + wantErr: false, + wantKind: KindOpenAPI, + }, + { + name: "control API OpenAPI YAML", + path: "../../api/openapi.yaml", + wantErr: false, + wantKind: KindOpenAPI, }, { - name: "control API OpenAPI YAML", - path: "../../api/openapi.yaml", - wantErr: false, - errContains: "", + name: "valid AsyncAPI 3.0.0 YAML", + path: "../../test/_shared/resources/asyncapi-30.yaml", + wantErr: false, + wantKind: KindAsyncAPI, + }, + { + name: "valid AsyncAPI 3.1.0 YAML", + path: "../../test/_shared/resources/asyncapi-31.yaml", + wantErr: false, + wantKind: KindAsyncAPI, }, { name: "non-existent file", @@ -46,7 +59,19 @@ func TestLoadSingleSchema(t *testing.T) { name: "invalid OpenAPI content", path: "../../test/_shared/resources/test-invalid.yaml", wantErr: true, - errContains: "invalid OpenAPI schema", + errContains: "not a valid OpenAPI or AsyncAPI schema", + }, + { + name: "non-spec file", + path: "../../test/_shared/resources/not-a-spec.yaml", + wantErr: true, + errContains: "neither an 'openapi' nor an 'asyncapi' root key", + }, + { + name: "unsupported asyncapi version", + path: "../../test/_shared/resources/asyncapi-26.yaml", + wantErr: true, + errContains: "unsupported AsyncAPI version", }, } @@ -54,7 +79,7 @@ func TestLoadSingleSchema(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - spec, err := loadSingleSchema(tt.path) + info, err := loadSingleSchema(tt.path) if tt.wantErr { require.Error(t, err) if tt.errContains != "" { @@ -63,7 +88,53 @@ func TestLoadSingleSchema(t *testing.T) { return } require.NoError(t, err) - assert.NotNil(t, spec) + assert.Equal(t, tt.wantKind, info.Kind) + if tt.wantKind == KindOpenAPI { + assert.NotNil(t, info.Spec) + } else { + assert.NotNil(t, info.Async) + } + }) + } +} + +/* +Scenario: Detecting the spec kind from raw bytes +Given raw YAML/JSON bytes with an openapi or asyncapi root key +When detectKind is called +Then it returns the matching kind, and an error for files with neither key + +Related spec scenarios: RS.AAL.1, RS.AAL.2, RS.AAL.4 +*/ +func TestDetectKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + want Kind + wantErr bool + }{ + {name: "openapi yaml", data: "openapi: 3.0.0\ninfo:\n title: x\n version: 1.0.0\n", want: KindOpenAPI}, + {name: "asyncapi yaml", data: "asyncapi: 3.0.0\ninfo:\n title: x\n version: 1.0.0\n", want: KindAsyncAPI}, + {name: "openapi json", data: `{"openapi":"3.0.0","info":{"title":"x","version":"1.0.0"}}`, want: KindOpenAPI}, + {name: "asyncapi json", data: `{"asyncapi":"3.0.0","info":{"title":"x","version":"1.0.0"}}`, want: KindAsyncAPI}, + {name: "neither key", data: "foo: bar\n", wantErr: true}, + {name: "empty", data: "", wantErr: true}, + {name: "garbage", data: "{{{", wantErr: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + kind, err := detectKind([]byte(tt.data)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, kind) }) } } @@ -122,6 +193,12 @@ func TestLoadSchemas(t *testing.T) { prefixes: []string{""}, wantErr: true, }, + { + name: "mixing openapi and asyncapi sources", + sources: []string{"../../test/_shared/resources/test.yaml", "../../test/_shared/resources/asyncapi-30.yaml"}, + prefixes: []string{"/v1", "/v2"}, + wantErr: false, + }, } for _, tt := range tests { @@ -139,6 +216,10 @@ func TestLoadSchemas(t *testing.T) { require.NoError(t, err) require.Len(t, infos, len(tt.sources)) for i, info := range infos { + if info.Kind == KindAsyncAPI { + assert.NotNil(t, info.Async, "info[%d].Async is nil", i) + continue + } assert.NotNil(t, info.Spec, "info[%d].Spec is nil", i) expectedPrefix := "" if i < len(tt.prefixes) { diff --git a/internal/loader/signalr_router_test.go b/internal/loader/signalr_router_test.go new file mode 100644 index 0000000..441d639 --- /dev/null +++ b/internal/loader/signalr_router_test.go @@ -0,0 +1,88 @@ +package loader + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const signalRDocSpec = `asyncapi: 3.0.0 +info: + title: SignalR Hub + version: 1.0.0 +x-signalr: + path: /hub +channels: + priceFeed: + address: priceFeed + bindings: + ws: + method: GET + messages: + priceMsg: + examples: + - name: snap + payload: + symbol: ETH +operations: + receivePrice: + action: receive + channel: + $ref: '#/channels/priceFeed' +` + +/* +Scenario: Declaring a SignalR hub document +Given an AsyncAPI document with root x-signalr and ws channels +When the loader parses it +Then the neutral document exposes the SignalR hub path + +Related spec scenarios: RS.SHR.1 +*/ +func TestSignalR_DocumentParsing(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, signalRDocSpec, "") + require.NotNil(t, info.Async) + require.NotNil(t, info.Async.SignalR) + assert.Equal(t, "/hub", info.Async.SignalR.Path) +} + +/* +Scenario: SignalR hub document keeps ws channels accessible by ID +Given an AsyncAPI document with root x-signalr +When the neutral document is inspected +Then ws channels remain addressable by ID for stream targets + +Related spec scenarios: RS.SHR.3, RS.SHR.4 +*/ +func TestSignalR_ChannelsAccessible(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, signalRDocSpec, "") + ch := info.Async.Channel("priceFeed") + require.NotNil(t, ch) + assert.Equal(t, "priceFeed", ch.ID) + require.Len(t, ch.Messages, 1) + assert.Len(t, ch.Messages[0].Examples, 1) +} + +/* +Scenario: SignalR hub document does not map ws channels to raw ws routes +Given an AsyncAPI document with root x-signalr and a ws channel +When BuildRouteMappings is called +Then no raw ws route mapping is produced (the hub serves ws channels) + +Related spec scenarios: RS.SHR.1, RS.ASP.3 +*/ +func TestSignalR_NoRawWSRouteMappings(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, signalRDocSpec, "") + mappings, err := BuildRouteMappings([]SchemaInfo{info}) + require.NoError(t, err) + for _, rm := range mappings { + assert.NotEqual(t, "ws", rm.Protocol, "signalR hub channels must not map to raw ws routes") + } +} diff --git a/internal/runtime/event_source_test.go b/internal/runtime/event_source_test.go new file mode 100644 index 0000000..09931dd --- /dev/null +++ b/internal/runtime/event_source_test.go @@ -0,0 +1,55 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Event payload is exposed via the event data source +Given an event payload map and a nested value +When the EventSource is queried +Then the payload fields resolve via {$event.*} + +Related spec scenarios: RS.EVT.8, RS.ATM.17 +*/ +func TestEventSource_Get(t *testing.T) { + t.Parallel() + + src := &EventSource{Data: map[string]any{ + "accountId": "acc-1", + "order": map[string]any{"price": 10}, + }} + + v, ok := src.Get("accountId") + require.True(t, ok) + assert.Equal(t, "acc-1", v) + + v, ok = src.Get("order.price") + require.True(t, ok) + assert.Equal(t, 10, v) + + _, ok = src.Get("missing") + assert.False(t, ok) +} + +/* +Scenario: Event expressions evaluate through the evaluator +Given an evaluator with an event source registered as "event" +When an expression is evaluated +Then the event payload value is returned + +Related spec scenarios: RS.EVT.8 +*/ +func TestEvaluator_EventExpression(t *testing.T) { + t.Parallel() + + eval := NewEvaluator() + eval.AddSource("event", &EventSource{Data: map[string]any{"level": "info"}}) + + val, err := eval.Evaluate("{$event.level}") + require.NoError(t, err) + assert.Equal(t, "info", val) +} diff --git a/internal/runtime/expression.go b/internal/runtime/expression.go index ed6926f..d25605f 100644 --- a/internal/runtime/expression.go +++ b/internal/runtime/expression.go @@ -163,6 +163,83 @@ type EnvSource struct { Env map[string]string } +// EventSource provides access to the payload of the currently fired event via +// {$event.*} (design D8). +type EventSource struct { + Data map[string]any +} + +func (e *EventSource) Get(path string) (any, bool) { + return getMapNested(e.Data, path) +} + +// MessageSource provides access to an AsyncAPI message via {$message.*} +// (payload and headers) (RS.ATM.1, RS.ATM.5). +type MessageSource struct { + Payload any + Headers map[string]string +} + +func (m *MessageSource) Get(path string) (any, bool) { + parts := splitEscapedPath(path) + if len(parts) < 2 { + return nil, false + } + switch parts[0] { + case "payload": + if len(parts) == 2 { + obj, ok := m.Payload.(map[string]any) + if !ok { + return nil, false + } + v, ok := obj[parts[1]] + return v, ok + } + return getNested(m.Payload, parts[1:]) + case "headers": + if len(parts) == 2 { + v, ok := m.Headers[parts[1]] + return v, ok + } + } + return nil, false +} + +// ChannelSource provides access to channel address parameters via +// {$channel.*} (RS.ATM.3). +type ChannelSource struct { + Params map[string]string +} + +func (c *ChannelSource) Get(path string) (any, bool) { + parts := splitEscapedPath(path) + if len(parts) != 1 { + return nil, false + } + v, ok := c.Params[parts[0]] + return v, ok +} + +// getMapNested retrieves a value from a flat or nested map by dot path. +func getMapNested(data map[string]any, path string) (any, bool) { + parts := splitEscapedPath(path) + if len(parts) == 0 { + return nil, false + } + if len(parts) == 1 { + if val, ok := data[parts[0]]; ok { + return val, true + } + return nil, false + } + topKey := parts[0] + val, ok := data[topKey] + if !ok { + return nil, false + } + return getNested(val, parts[1:]) +} + func (e *EnvSource) Get(path string) (any, bool) { // Environment variables are flat parts := splitEscapedPath(path) diff --git a/internal/runtime/message_source_test.go b/internal/runtime/message_source_test.go new file mode 100644 index 0000000..0d4405b --- /dev/null +++ b/internal/runtime/message_source_test.go @@ -0,0 +1,79 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Message payload/headers exposed via the message data source +Given a message payload and headers map +When the MessageSource is queried +Then payload and header fields resolve via {$message.*} + +Related spec scenarios: RS.ATM.1, RS.ATM.5 +*/ +func TestMessageSource_Get(t *testing.T) { + t.Parallel() + + src := &MessageSource{ + Payload: map[string]any{"id": "1", "nested": map[string]any{"k": "v"}}, + Headers: map[string]string{"x-request-id": "req-1"}, + } + + v, ok := src.Get("payload.id") + require.True(t, ok) + assert.Equal(t, "1", v) + + v, ok = src.Get("payload.nested.k") + require.True(t, ok) + assert.Equal(t, "v", v) + + v, ok = src.Get("headers.x-request-id") + require.True(t, ok) + assert.Equal(t, "req-1", v) +} + +/* +Scenario: Channel parameters exposed via the channel data source +Given a channel params map +When the ChannelSource is queried +Then params resolve via {$channel.*} + +Related spec scenarios: RS.ATM.3 +*/ +func TestChannelSource_Get(t *testing.T) { + t.Parallel() + + src := &ChannelSource{Params: map[string]string{"userId": "u-42"}} + + v, ok := src.Get("userId") + require.True(t, ok) + assert.Equal(t, "u-42", v) + + _, ok = src.Get("missing") + assert.False(t, ok) +} + +/* +Scenario: Message expressions evaluate through the evaluator +Given an evaluator with a message source registered as "message" +When an expression is evaluated +Then the message payload value is returned + +Related spec scenarios: RS.ATM.1, RS.ATM.5 +*/ +func TestEvaluator_MessageExpression(t *testing.T) { + t.Parallel() + + eval := NewEvaluator() + eval.AddSource("message", &MessageSource{ + Payload: map[string]any{"id": "42"}, + }) + + val, err := eval.Evaluate("{$message.payload.id}") + require.NoError(t, err) + assert.Equal(t, "42", val) +} diff --git a/internal/server/adapters/adapters.go b/internal/server/adapters/adapters.go deleted file mode 100644 index f9071f0..0000000 --- a/internal/server/adapters/adapters.go +++ /dev/null @@ -1,321 +0,0 @@ -package adapters - -import ( - "net/http" - "os" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/mamonth/oasmock/internal/extensions" - "github.com/mamonth/oasmock/internal/history" - "github.com/mamonth/oasmock/internal/loader" - "github.com/mamonth/oasmock/internal/runtime" - "github.com/mamonth/oasmock/internal/server" - "github.com/mamonth/oasmock/internal/state" -) - -// LoaderRouteAdapter adapts loader package to server.RouteProvider interface. -type LoaderRouteAdapter struct{} - -func (a *LoaderRouteAdapter) BuildRouteMappings(schemas []server.SchemaInfo) ([]server.RouteMapping, error) { - // Convert server.SchemaInfo to loader.SchemaInfo - loaderSchemas := make([]loader.SchemaInfo, len(schemas)) - for i, schema := range schemas { - loaderSchemas[i] = loader.SchemaInfo{ - Spec: schema.Spec, - Prefix: schema.Prefix, - } - } - - // Call original loader function - loaderMappings, err := loader.BuildRouteMappings(loaderSchemas) - if err != nil { - return nil, err - } - - // Convert loader.RouteMapping to server.RouteMapping - mappings := make([]server.RouteMapping, len(loaderMappings)) - for i, lm := range loaderMappings { - mappings[i] = server.RouteMapping{ - Method: lm.Method, - Path: lm.Path, - Pattern: lm.Pattern, - Prefix: lm.Prefix, - ChiPattern: lm.ChiPattern, - Operation: lm.Operation, - Parameters: lm.Parameters, - Responses: lm.Responses, - } - } - - return mappings, nil -} - -// StateManagerAdapter adapts state.Manager to server.StateStore interface. -type StateManagerAdapter struct { - manager *state.Manager -} - -func NewStateManagerAdapter(manager *state.Manager) *StateManagerAdapter { - return &StateManagerAdapter{manager: manager} -} - -func (a *StateManagerAdapter) Get(namespace, key string) (any, bool) { - return a.manager.Get(namespace, key) -} - -func (a *StateManagerAdapter) Set(namespace, key string, value any) { - a.manager.Set(namespace, key, value) -} - -func (a *StateManagerAdapter) Increment(namespace, key string, delta float64) (float64, error) { - return a.manager.Increment(namespace, key, delta) -} - -func (a *StateManagerAdapter) Delete(namespace, key string) { - a.manager.Delete(namespace, key) -} - -func (a *StateManagerAdapter) GetNamespace(namespace string) map[string]any { - return a.manager.GetNamespace(namespace) -} - -func (a *StateManagerAdapter) GetAll() map[string]map[string]any { - return a.manager.GetAll() -} - -// HistoryRingBufferAdapter adapts history.RingBuffer to server.HistoryStore interface. -type HistoryRingBufferAdapter struct { - buffer *history.RingBuffer -} - -func NewHistoryRingBufferAdapter(buffer *history.RingBuffer) *HistoryRingBufferAdapter { - return &HistoryRingBufferAdapter{buffer: buffer} -} - -func (a *HistoryRingBufferAdapter) Add(record server.RequestRecord) { - // Convert server.RequestRecord to history.RequestRecord - historyRecord := history.RequestRecord{ - ID: record.ID, - Timestamp: record.Timestamp, - Method: record.Method, - Path: record.Path, - Query: record.Query, - Headers: record.Headers, - Body: record.Body, - } - - if record.Response != nil { - historyRecord.Response = &history.ResponseRecord{ - StatusCode: record.Response.StatusCode, - Headers: record.Response.Headers, - Body: record.Response.Body, - Duration: record.Response.Duration, - } - } - - a.buffer.Add(historyRecord) -} - -func (a *HistoryRingBufferAdapter) GetAll() []server.RequestRecord { - historyRecords := a.buffer.GetAll() - records := make([]server.RequestRecord, len(historyRecords)) - - for i, hr := range historyRecords { - record := server.RequestRecord{ - ID: hr.ID, - Timestamp: hr.Timestamp, - Method: hr.Method, - Path: hr.Path, - Query: hr.Query, - Headers: hr.Headers, - Body: hr.Body, - } - - if hr.Response != nil { - record.Response = &server.ResponseRecord{ - StatusCode: hr.Response.StatusCode, - Headers: hr.Response.Headers, - Body: hr.Response.Body, - Duration: hr.Response.Duration, - } - } - - records[i] = record - } - - return records -} - -func (a *HistoryRingBufferAdapter) Count() int { - return a.buffer.Count() -} - -func (a *HistoryRingBufferAdapter) Capacity() int { - return a.buffer.Capacity() -} - -func (a *HistoryRingBufferAdapter) Clear() { - a.buffer.Clear() -} - -// RuntimeRequestSourceAdapter adapts runtime.RequestSource to server.DataSource. -type RuntimeRequestSourceAdapter struct { - source *runtime.RequestSource -} - -func NewRuntimeRequestSourceAdapter(source *runtime.RequestSource) *RuntimeRequestSourceAdapter { - return &RuntimeRequestSourceAdapter{source: source} -} - -func (a *RuntimeRequestSourceAdapter) Get(path string) (any, bool) { - return a.source.Get(path) -} - -// RuntimeStateSourceAdapter adapts runtime.StateSource to server.DataSource. -type RuntimeStateSourceAdapter struct { - source *runtime.StateSource -} - -func NewRuntimeStateSourceAdapter(source *runtime.StateSource) *RuntimeStateSourceAdapter { - return &RuntimeStateSourceAdapter{source: source} -} - -func (a *RuntimeStateSourceAdapter) Get(path string) (any, bool) { - return a.source.Get(path) -} - -// RuntimeEnvSourceAdapter adapts runtime.EnvSource to server.DataSource. -type RuntimeEnvSourceAdapter struct { - source *runtime.EnvSource -} - -func NewRuntimeEnvSourceAdapter(source *runtime.EnvSource) *RuntimeEnvSourceAdapter { - return &RuntimeEnvSourceAdapter{source: source} -} - -func (a *RuntimeEnvSourceAdapter) Get(path string) (any, bool) { - return a.source.Get(path) -} - -// RuntimeRequestSourceFactory implements server.RequestSourceFactory using runtime package. -type RuntimeRequestSourceFactory struct{} - -func (f *RuntimeRequestSourceFactory) NewRequestSource(r *http.Request, pathParams map[string]string) server.DataSource { - source := &runtime.RequestSource{ - PathParams: pathParams, - QueryParams: r.URL.Query(), - Headers: r.Header, - Cookies: make(map[string]string), - Body: nil, // Will be set later if needed - } - - // Parse cookies - for _, cookie := range r.Cookies() { - source.Cookies[cookie.Name] = cookie.Value - } - - // TODO: Parse request body if needed - // This is simplified - actual server.newRequestSource has more logic - - return NewRuntimeRequestSourceAdapter(source) -} - -// RuntimeStateSourceFactory implements server.StateSourceFactory using runtime package. -type RuntimeStateSourceFactory struct { - stateStore server.StateStore -} - -func NewRuntimeStateSourceFactory(stateStore server.StateStore) *RuntimeStateSourceFactory { - return &RuntimeStateSourceFactory{stateStore: stateStore} -} - -func (f *RuntimeStateSourceFactory) NewStateSource(namespace string) server.DataSource { - data := f.stateStore.GetNamespace(namespace) - source := &runtime.StateSource{Data: data} - return NewRuntimeStateSourceAdapter(source) -} - -// RuntimeEnvSourceFactory implements server.EnvSourceFactory using runtime package. -type RuntimeEnvSourceFactory struct{} - -func (f *RuntimeEnvSourceFactory) NewEnvSource() server.DataSource { - env := make(map[string]string) - for _, e := range os.Environ() { - if i := index(e, '='); i >= 0 { - env[e[:i]] = e[i+1:] - } - } - source := &runtime.EnvSource{Env: env} - return NewRuntimeEnvSourceAdapter(source) -} - -// RuntimeExpressionEvaluatorAdapter adapts runtime.Evaluator to server.ExpressionEvaluator. -type RuntimeExpressionEvaluatorAdapter struct { - eval runtime.Evaluator -} - -func NewRuntimeExpressionEvaluatorAdapter(eval runtime.Evaluator) *RuntimeExpressionEvaluatorAdapter { - return &RuntimeExpressionEvaluatorAdapter{eval: eval} -} - -func (a *RuntimeExpressionEvaluatorAdapter) AddSource(name string, source server.DataSource) { - // We need to adapt server.DataSource to runtime.DataSource - // This is tricky because we have different DataSource interfaces - // For now, we'll create a wrapper - wrapper := &dataSourceWrapper{source: source} - a.eval.AddSource(name, wrapper) -} - -func (a *RuntimeExpressionEvaluatorAdapter) Evaluate(expr string) (any, error) { - return a.eval.Evaluate(expr) -} - -// dataSourceWrapper wraps server.DataSource to implement runtime.DataSource. -type dataSourceWrapper struct { - source server.DataSource -} - -func (w *dataSourceWrapper) Get(path string) (any, bool) { - return w.source.Get(path) -} - -// ExtensionsAdapter adapts extensions package to server.ExtensionProcessor interface. -type ExtensionsAdapter struct{} - -func (a *ExtensionsAdapter) ExtractSetState(example *openapi3.Example) (map[string]any, bool) { - return extensions.ExtractSetState(example) -} - -func (a *ExtensionsAdapter) ExtractSkip(example *openapi3.Example) bool { - return extensions.ExtractSkip(example) -} - -func (a *ExtensionsAdapter) ExtractOnce(example *openapi3.Example) bool { - return extensions.ExtractOnce(example) -} - -func (a *ExtensionsAdapter) ExtractParamsMatch(example *openapi3.Example) (map[string]any, bool) { - return extensions.ExtractParamsMatch(example) -} - -func (a *ExtensionsAdapter) EvaluateParamsMatch(params map[string]any, eval server.ExpressionEvaluator) (bool, error) { - // We need to adapt server.ExpressionEvaluator to runtime.Evaluator - // This is complex - for now, we'll use a simplified approach - // In practice, we'd need a full adapter - // For MVP, we'll return false - return false, nil -} - -func (a *ExtensionsAdapter) ExtractHeaders(example *openapi3.Example) (map[string]any, bool) { - return extensions.ExtractHeaders(example) -} - -// Helper function -func index(s string, c byte) int { - for i := 0; i < len(s); i++ { - if s[i] == c { - return i - } - } - return -1 -} diff --git a/internal/server/add_example_async_test.go b/internal/server/add_example_async_test.go new file mode 100644 index 0000000..8d36f26 --- /dev/null +++ b/internal/server/add_example_async_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newDynamicServer(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 +} + +/* +Scenario: Adding a dynamic example for an AsyncAPI channel +Given an AsyncAPI ws channel and a POST to /_mock/examples with a channel identifier +When a matching message arrives +Then the dynamic example is selected by the shared pipeline + +Related spec scenarios: RS.MAPI.19, RS.MAPI.20 +*/ +func TestAddExample_AsyncChannel(t *testing.T) { + t.Parallel() + + srv := newDynamicServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + addBody := `{ + "protocol": "ws", + "channel": "/alerts", + "response": {"code": 200, "body": {"level": "dynamic", "msg": "injected"}} + }` + resp, err := http.Post(ts.URL+"/_mock/examples", "application/json", strings.NewReader(addBody)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + 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), `"injected"`) +} + +/* +Scenario: Adding a dynamic example for an unmatched AsyncAPI route is rejected +Given a POST to /_mock/examples with an unknown channel +When it does not match any loaded channel +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.21 +*/ +func TestAddExample_UnmatchedAsyncRoute(t *testing.T) { + t.Parallel() + + srv := newDynamicServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + addBody := `{ + "protocol": "ws", + "channel": "/missing", + "response": {"code": 200, "body": {"a": 1}} + }` + resp, err := http.Post(ts.URL+"/_mock/examples", "application/json", strings.NewReader(addBody)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} diff --git a/internal/server/async_http_adapter_test.go b/internal/server/async_http_adapter_test.go new file mode 100644 index 0000000..dbfb1e0 --- /dev/null +++ b/internal/server/async_http_adapter_test.go @@ -0,0 +1,225 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: HTTP adapter renders an AsyncAPI http channel message +Given a route mapping with a message example payload +When the adapter handler is invoked +Then it responds 200 with the rendered JSON payload + +Related spec scenarios: RS.ASP.1, RS.ASP.10 +*/ +func TestHTTPProtocolAdapter_RendersMessage(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncHTTPProtocol, + Method: http.MethodPost, + Prefix: "", + Pattern: "/employees", + Messages: []*loader.MessageSpec{ + { + Name: "emplMsg", + Examples: []*loader.MessageExampleSpec{ + {Payload: map[string]any{"id": 1, "name": "Ada"}}, + }, + }, + }, + } + + adapter := srv.adapterForProtocol(asyncHTTPProtocol) + require.NotNil(t, adapter) + + mh := srv.asyncMessageHandler(mapping) + handler := adapter.Handler(mapping, mh) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/employees", strings.NewReader(`{"name":"Ada"}`)) + handler(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, float64(1), body["id"]) + assert.Equal(t, "Ada", body["name"]) +} + +/* +Scenario: HTTP adapter ack send with no reply message +Given an AsyncAPI http channel whose operation has no reply message +When the adapter handler is invoked +Then it responds 200 with an empty body + +Related spec scenarios: RS.ASP.10 +*/ +func TestHTTPProtocolAdapter_SendNoReply(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncHTTPProtocol, + Method: http.MethodPost, + Pattern: "/events", + Messages: nil, + } + + adapter := srv.adapterForProtocol(asyncHTTPProtocol) + require.NotNil(t, adapter) + + handler := adapter.Handler(mapping, srv.asyncMessageHandler(mapping)) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(`{"event":"signup"}`)) + handler(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, rec.Body.String()) +} + +/* +Scenario: AsyncAPI message selection skips x-mock-skip examples +Given a message spec carrying one skipped and one active example +When selectAsyncExample is called +Then the active example is selected + +Related spec scenarios: RS.ATM.6 +*/ +func TestSelectAsyncExample_Skip(t *testing.T) { + t.Parallel() + + message := &loader.MessageSpec{ + Name: "m", + Examples: []*loader.MessageExampleSpec{ + {Name: "skip", Extensions: map[string]any{"x-mock-skip": true}, Payload: map[string]any{"id": 1}}, + {Name: "active", Payload: map[string]any{"id": 2}}, + }, + } + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + + view, key := srv.selectAsyncExample(message, runtime.NewEvaluator(), "op") + require.NotNil(t, view) + assert.Equal(t, "m-1", key) + payload, ok := view.Payload().(map[string]any) + require.True(t, ok) + assert.Equal(t, 2, payload["id"]) +} + +/* +Scenario: First example selected when no example has conditions +Given a message spec with two condition-free examples +When selectAsyncExample is called +Then the first example (by definition order) is selected + +Related spec scenarios: RS.ATM.7 +*/ +func TestSelectAsyncExample_FirstNoConditions(t *testing.T) { + t.Parallel() + + message := &loader.MessageSpec{ + Name: "m", + Examples: []*loader.MessageExampleSpec{ + {Name: "first", Payload: map[string]any{"id": 1}}, + {Name: "second", Payload: map[string]any{"id": 2}}, + }, + } + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + + view, key := srv.selectAsyncExample(message, runtime.NewEvaluator(), "op") + require.NotNil(t, view) + assert.Equal(t, "m-0", key) + payload, ok := view.Payload().(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, payload["id"]) +} + +/* +Scenario: One-time example is removed from future selection +Given a message spec with an x-mock-once example +When selectAsyncExample is called twice +Then the first call returns it and the second returns nil + +Related spec scenarios: RS.ATM.10 +*/ +func TestSelectAsyncExample_Once(t *testing.T) { + t.Parallel() + + message := &loader.MessageSpec{ + Name: "m", + Examples: []*loader.MessageExampleSpec{ + {Name: "once", Extensions: map[string]any{"x-mock-once": true}, Payload: map[string]any{"id": 1}}, + }, + } + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + + view, key := srv.selectAsyncExample(message, runtime.NewEvaluator(), "op") + require.NotNil(t, view) + assert.Equal(t, "m-0", key) + + second, _ := srv.selectAsyncExample(message, runtime.NewEvaluator(), "op") + assert.Nil(t, second) +} + +/* +Scenario: Server fails to build a route with an unsupported protocol +Given a route mapping declaring an unsupported protocol +When buildRouteHandler is called +Then it returns an error naming the unsupported protocol + +Related spec scenarios: RS.ASP.4 +*/ +func TestBuildRouteHandler_UnsupportedProtocol(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + + mapping := &RouteMapping{ + Protocol: "amqp", + Method: http.MethodGet, + Pattern: "/q", + } + _, err := srv.buildRouteHandler(mapping) + require.Error(t, err) + assert.Contains(t, err.Error(), "amqp") + assert.Contains(t, err.Error(), "not supported") +} + +/* +Scenario: Server builds a ws route via its protocol adapter +Given a route mapping declaring the ws protocol +When buildRouteHandler is called +Then it returns a non-nil handler +*/ +func TestBuildRouteHandler_WSAssignsAdapter(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncWSProtocol, + Method: http.MethodGet, + Pattern: "/socket", + } + handler, err := srv.buildRouteHandler(mapping) + require.NoError(t, err) + require.NotNil(t, handler) +} diff --git a/internal/server/async_message.go b/internal/server/async_message.go new file mode 100644 index 0000000..b5b38bd --- /dev/null +++ b/internal/server/async_message.go @@ -0,0 +1,110 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" +) + +// asyncMessageHandler returns a MessageHandler that renders an AsyncAPI route's +// message examples through the shared selection pipeline (design D5). +func (s *Server) asyncMessageHandler(mapping *RouteMapping) MessageHandler { + return MessageHandlerFunc(func(ctx context.Context, in InboundMessage) ([]byte, error) { + count, body, err := s.renderAsyncMessage(mapping, in) + if err != nil { + return nil, err + } + if count == 0 { + // No message/reply produced (e.g. send with no reply): nil body. + s.recordAsyncExchange(in, mapping.Path, http.StatusOK, nil) + return nil, nil + } + s.recordAsyncExchange(in, mapping.Path, http.StatusOK, body) + return body, nil + }) +} + +// Message rendering lives in exampleEngine; these forwarders keep the protocol +// adapters and tests working through Server. + +func (s *Server) renderAsyncMessage(mapping *RouteMapping, in InboundMessage) (int, []byte, error) { + return s.engine.renderAsyncMessage(mapping, in) +} + +func (s *Server) newAsyncEvaluator(mapping *RouteMapping, in InboundMessage) runtime.Evaluator { + return s.engine.newAsyncEvaluator(mapping, in) +} + +func (s *Server) renderMessageSpecs(messages []*loader.MessageSpec, prefix, opID string, in InboundMessage) (int, []byte, error) { + return s.engine.RenderMessageSpecs(messages, prefix, opID, in) +} + +func (s *Server) asyncRequestSource(in InboundMessage) *runtime.RequestSource { + return s.engine.asyncRequestSource(in) +} + +func (s *Server) selectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*MessageExampleView, string) { + return s.engine.SelectAsyncExample(message, evaluator, opID) +} + +func (s *Server) renderAsyncPayload(example *MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) { + return s.engine.RenderAsyncPayload(example, evaluator) +} + +func (s *Server) recordAsyncExchange(in InboundMessage, address string, status int, responseBody []byte) { + s.engine.recordAsyncExchange(in, address, status, responseBody) +} + +// MessageExampleView adapts an AsyncAPI message example to the ExampleValue +// contract so extension extraction and selection are source-agnostic (D5). +type MessageExampleView struct { + spec *loader.MessageExampleSpec +} + +// Get implements extensions.ExampleValue. +func (v *MessageExampleView) Get(key string) (any, bool) { + if v == nil || v.spec == nil || v.spec.Extensions == nil { + return nil, false + } + val, ok := v.spec.Extensions[key] + return val, ok +} + +// Payload implements extensions.ExampleValue. +func (v *MessageExampleView) Payload() any { + if v == nil || v.spec == nil { + return nil + } + return v.spec.Payload +} + +// Headers implements extensions.ExampleValue. +func (v *MessageExampleView) Headers() map[string]any { + if v == nil || v.spec == nil { + return nil + } + return v.spec.Headers +} + +func idxName(msgName string, idx int) string { + if msgName == "" { + return fmt.Sprintf("example-%d", idx) + } + return fmt.Sprintf("%s-%d", msgName, idx) +} + +// jsonPayload parses inbound bytes as JSON, falling back to a raw string. +func jsonPayload(data []byte) any { + if len(data) == 0 { + return nil + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + return string(data) + } + return v +} diff --git a/internal/server/async_state_test.go b/internal/server/async_state_test.go new file mode 100644 index 0000000..a0a1378 --- /dev/null +++ b/internal/server/async_state_test.go @@ -0,0 +1,144 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/golang/mock/gomock" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const incrementCronDoc = `asyncapi: 3.0.0 +info: + title: Cron + version: 1.0.0 +channels: + feed: + address: /feed + bindings: + ws: + method: GET + messages: + tick: + examples: + - name: paced + payload: + seq: "{$state.counter}" + x-mock-set-state: + counter: + increment: 1 + x-send-events: + - on: cron + wait: 1000 +operations: + receiveFeed: + action: receive + channel: + $ref: '#/channels/feed' +` + +/* +Scenario: Incrementing state from a message example +Given a message example whose x-mock-set-state increments a counter +When renderMessageSpecs runs +Then the state store Increment is applied with the delta + +Related spec scenarios: RS.ATM.12 +*/ +func TestRenderMessageSpecs_Increment(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + stateStore.EXPECT().Increment("/ns", "counter", 2.0).Return(9.0, nil) + + message := &loader.MessageSpec{ + Name: "m", + Examples: []*loader.MessageExampleSpec{ + { + Payload: map[string]any{"done": true}, + Extensions: map[string]any{ + "x-mock-set-state": map[string]any{"counter": map[string]any{"increment": 2}}, + }, + }, + }, + } + count, out, err := srv.renderMessageSpecs([]*loader.MessageSpec{message}, "/ns", "op", InboundMessage{}) + require.NoError(t, err) + require.Equal(t, 1, count) + assert.Contains(t, string(out), `"done":true`) +} + +/* +Scenario: Deleting a state key from a message example +Given a message example whose x-mock-set-state maps a key to null +When renderMessageSpecs runs +Then the state store Delete is applied for that key + +Related spec scenarios: RS.ATM.13 +*/ +func TestRenderMessageSpecs_Delete(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + stateStore.EXPECT().Delete("/ns", "key") + + message := &loader.MessageSpec{ + Name: "m", + Examples: []*loader.MessageExampleSpec{ + { + Payload: map[string]any{"done": true}, + Extensions: map[string]any{ + "x-mock-set-state": map[string]any{"key": nil}, + }, + }, + }, + } + count, out, err := srv.renderMessageSpecs([]*loader.MessageSpec{message}, "/ns", "op", InboundMessage{}) + require.NoError(t, err) + require.Equal(t, 1, count) + assert.Contains(t, string(out), `"done":true`) +} + +/* +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 + +Related spec scenarios: RS.ATM.18, RS.EVT.10 +*/ +func TestCollectSchemaSubscriptions_CronIncrement(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{}, + ) + require.NoError(t, err) + require.Equal(t, 1, count) + + var body map[string]any + require.NoError(t, json.Unmarshal(out, &body)) + assert.Contains(t, body, "seq") +} diff --git a/internal/server/channel_params_test.go b/internal/server/channel_params_test.go new file mode 100644 index 0000000..b09e172 --- /dev/null +++ b/internal/server/channel_params_test.go @@ -0,0 +1,75 @@ +package server + +import ( + "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 tenantChannelDoc = `asyncapi: 3.0.0 +info: + title: Tenant + version: 1.0.0 +channels: + tenant: + address: /tenant/{tenantId} + bindings: + ws: + method: GET + parameters: + tenantId: + description: The tenant identifier + messages: + tenantMsg: + examples: + - name: ex1 + payload: + tenant: "{$channel.tenantId}" +operations: + sendTenant: + action: send + channel: + $ref: '#/channels/tenant' +` + +/* +Scenario: Channel address parameters are captured end-to-end via the router +Given an AsyncAPI ws channel address /tenant/{tenantId} with a send example +referencing {$channel.tenantId} +When a client dials /tenant/abc and sends a message through the real router +Then the echoed payload contains the captured parameter value abc + +Related spec scenarios: RS.ATM.3 +*/ +func TestChannelParams_CapturedEndToEnd(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(tenantChannelDoc)) + require.NoError(t, err) + require.Len(t, doc.Channels[0].Parameters, 1) + assert.Equal(t, "tenantId", doc.Channels[0].Parameters[0].Name) + + 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") + "/tenant/abc" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"msg":"hello"}`))) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, reply, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(reply), `"tenant":"abc"`) +} diff --git a/internal/server/convert.go b/internal/server/convert.go new file mode 100644 index 0000000..233dc8a --- /dev/null +++ b/internal/server/convert.go @@ -0,0 +1,26 @@ +package server + +import "github.com/mamonth/oasmock/internal/loader" + +// ConvertRouteMappings converts loader route mappings into server route +// mappings. It is the single translation point between the loader's routing +// model and the server's, so both share one field mapping. +func ConvertRouteMappings(loaderMappings []loader.RouteMapping) []RouteMapping { + mappings := make([]RouteMapping, len(loaderMappings)) + for i, lm := range loaderMappings { + mappings[i] = RouteMapping{ + Method: lm.Method, + Path: lm.Path, + Pattern: lm.Pattern, + Prefix: lm.Prefix, + ChiPattern: lm.ChiPattern, + Operation: lm.Operation, + Parameters: lm.Parameters, + Responses: lm.Responses, + Protocol: lm.Protocol, + Action: lm.Action, + Messages: lm.Messages, + } + } + return mappings +} diff --git a/internal/server/engine.go b/internal/server/engine.go new file mode 100644 index 0000000..afff554 --- /dev/null +++ b/internal/server/engine.go @@ -0,0 +1,802 @@ +package server + +import ( + "cmp" + "encoding/json" + "fmt" + "log/slog" + "maps" + "net/http" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" +) + +// exampleEngine is the shared example-selection and templating core for both +// the OpenAPI and AsyncAPI pipelines. It owns runtime-expression evaluation, +// x-mock-* extension handling, state mutation and async message rendering. +// The event/hub/management subsystems depend on it through narrow interfaces +// instead of on the whole Server. +type exampleEngine struct { + verbose bool + stateStore StateStore + historyStore HistoryStore + registry *exampleRegistry +} + +func newExampleEngine(config Config, deps Dependencies, registry *exampleRegistry) *exampleEngine { + return &exampleEngine{ + verbose: config.Verbose, + stateStore: deps.StateStore, + historyStore: deps.HistoryStore, + registry: registry, + } +} + +// --------------------------------------------------------------------------- +// Runtime expression evaluation +// --------------------------------------------------------------------------- + +func (e *exampleEngine) replaceEmbeddedExpressions(str string, eval runtime.Evaluator) (string, error) { + var result strings.Builder + i := 0 + for i < len(str) { + // Find start of expression "{$" + start := strings.Index(str[i:], "{$") + if start == -1 { + result.WriteString(str[i:]) + break + } + start += i + // Write literal part before expression + result.WriteString(str[i:start]) + // Find matching '}' + braceDepth := 1 + j := start + 2 + for j < len(str) && braceDepth > 0 { + ch := str[j] + if ch == '{' && j+1 < len(str) && str[j+1] == '$' { + braceDepth++ + j += 2 + continue + } + if ch == '}' { + braceDepth-- + if braceDepth == 0 { + break + } + } + j++ + } + if braceDepth != 0 { + // Unmatched braces, treat as literal + result.WriteString(str[start:]) + break + } + // j now points at the closing '}' + end := j + expr := str[start : end+1] + // Evaluate expression + value, err := eval.Evaluate(expr) + if err != nil { + // If evaluation fails, keep the original expression + result.WriteString(expr) + } else { + // Convert value to string + switch v := value.(type) { + case string: + result.WriteString(v) + default: + b, err := json.Marshal(v) + if err != nil { + result.WriteString(expr) + } else { + result.Write(b) + } + } + } + i = end + 1 + } + return result.String(), nil +} + +func (e *exampleEngine) evaluateExpressionInString(str string, eval runtime.Evaluator) (string, error) { + // First, check if the whole string is a runtime expression (optimization) + if strings.HasPrefix(str, "{$") && strings.HasSuffix(str, "}") && !strings.Contains(str[2:], "{$") { + result, err := eval.Evaluate(str) + if err != nil { + return "", err + } + // Convert result to string + switch v := result.(type) { + case string: + return v, nil + default: + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil + } + } + // Otherwise replace embedded expressions + return e.replaceEmbeddedExpressions(str, eval) +} + +func (e *exampleEngine) evaluateValue(val any, eval runtime.Evaluator) (any, error) { + // Handle strings: they may contain embedded runtime expressions + if str, ok := val.(string); ok { + // Check if the whole string is a single runtime expression (no other characters) + if strings.HasPrefix(str, "{$") && strings.HasSuffix(str, "}") && strings.Count(str, "{$") == 1 { + return eval.Evaluate(str) + } + // Otherwise replace embedded expressions + return e.replaceEmbeddedExpressions(str, eval) + } + // Recursively handle maps and slices + switch v := val.(type) { + case map[string]any: + result := make(map[string]any) + for k, item := range v { + resolvedK, err := e.evaluateExpressionInString(k, eval) + if err != nil { + return nil, err + } + resolvedItem, err := e.evaluateValue(item, eval) + if err != nil { + return nil, err + } + result[resolvedK] = resolvedItem + } + return result, nil + case []any: + result := make([]any, len(v)) + for i, item := range v { + resolvedItem, err := e.evaluateValue(item, eval) + if err != nil { + return nil, err + } + result[i] = resolvedItem + } + return result, nil + default: + // Literal value + return val, nil + } +} + +// --------------------------------------------------------------------------- +// State mutation (x-mock-set-state) +// --------------------------------------------------------------------------- + +func (e *exampleEngine) handleDeleteState(prefix, resolvedKey string) { + e.stateStore.Delete(prefix, resolvedKey) + if e.verbose { + slog.Debug("Deleted state", "key", resolvedKey, "namespace", prefix) + } +} + +func (e *exampleEngine) handleIncrementState(prefix, resolvedKey string, incVal any, eval runtime.Evaluator) error { + resolvedInc, err := e.evaluateValue(incVal, eval) + if err != nil { + if e.verbose { + slog.Debug("Failed to evaluate increment value", "error", err) + } + return err + } + // Convert to float64 + var delta float64 + switch v := resolvedInc.(type) { + case float64: + delta = v + case int: + delta = float64(v) + case string: + // Try to parse as number + if f, err := strconv.ParseFloat(v, 64); err == nil { + delta = f + } else { + if e.verbose { + slog.Debug("Increment value is not a number", "value", v) + } + return fmt.Errorf("increment value is not a number: %s", v) + } + default: + if e.verbose { + slog.Debug("Increment value has unsupported type", "type", fmt.Sprintf("%T", v)) + } + return fmt.Errorf("increment value has unsupported type: %T", v) + } + newVal, err := e.stateStore.Increment(prefix, resolvedKey, delta) + if err != nil { + if e.verbose { + slog.Debug("Failed to increment state", "key", resolvedKey, "error", err) + } + return err + } + if e.verbose { + slog.Debug("Incremented state", "key", resolvedKey, "namespace", prefix, "delta", delta, "newValue", newVal) + } + return nil +} + +func (e *exampleEngine) handleValueObjectState(prefix, resolvedKey string, valObj any, eval runtime.Evaluator) error { + resolvedVal, err := e.evaluateValue(valObj, eval) + if err != nil { + if e.verbose { + slog.Debug("Failed to evaluate value object", "error", err) + } + return err + } + e.stateStore.Set(prefix, resolvedKey, resolvedVal) + if e.verbose { + slog.Debug("Set state", "key", resolvedKey, "namespace", prefix, "value", resolvedVal) + } + return nil +} + +func (e *exampleEngine) handleMapState(prefix, resolvedKey string, m map[string]any, eval runtime.Evaluator) (handled bool, err error) { + if incVal, hasInc := m["increment"]; hasInc { + err = e.handleIncrementState(prefix, resolvedKey, incVal, eval) + return true, err + } + if valObj, hasVal := m["value"]; hasVal { + err = e.handleValueObjectState(prefix, resolvedKey, valObj, eval) + return true, err + } + return false, nil +} + +func (e *exampleEngine) handleSimpleState(prefix, resolvedKey string, val any, eval runtime.Evaluator) error { + resolvedVal, err := e.evaluateValue(val, eval) + if err != nil { + if e.verbose { + slog.Debug("Failed to evaluate value for key", "key", resolvedKey, "error", err) + } + return err + } + e.stateStore.Set(prefix, resolvedKey, resolvedVal) + if e.verbose { + slog.Debug("Set state", "key", resolvedKey, "namespace", prefix, "value", resolvedVal) + } + return nil +} + +func (e *exampleEngine) ApplySetState(stateMap map[string]any, eval runtime.Evaluator, prefix string) { + for key, val := range stateMap { + // Evaluate runtime expressions in key + resolvedKey, err := e.evaluateExpressionInString(key, eval) + if err != nil { + if e.verbose { + slog.Debug("Failed to evaluate key", "key", key, "error", err) + } + continue + } + + // Handle null value (delete) + if val == nil { + e.handleDeleteState(prefix, resolvedKey) + continue + } + + // Handle map (increment or value object) + if m, ok := val.(map[string]any); ok { + handled, _ := e.handleMapState(prefix, resolvedKey, m, eval) + if handled { + // Error already logged inside helpers + continue + } + // Not a recognized map structure, fall through to simple value + } + + // Simple value (could be runtime expression) + if err := e.handleSimpleState(prefix, resolvedKey, val, eval); err != nil { + // Error already logged inside helper + continue + } + } +} + +// --------------------------------------------------------------------------- +// OpenAPI example selection & response generation +// --------------------------------------------------------------------------- + +func (e *exampleEngine) selectResponse(mapping *RouteMapping, eval runtime.Evaluator) (string, *openapi3.Response) { + if mapping.Responses == nil { + return "", nil + } + respMap := mapping.Responses.Map() + if len(respMap) == 0 { + return "", nil + } + // Collect and sort keys for deterministic selection + keys := make([]string, 0, len(respMap)) + for code := range respMap { + keys = append(keys, code) + } + // Sort keys with custom order: numeric status codes ascending, "default" last + slices.SortFunc(keys, func(a, b string) int { + if a == "default" && b == "default" { + return 0 + } + if a == "default" { + return 1 // default after numeric codes + } + if b == "default" { + return -1 + } + aInt, errA := strconv.Atoi(a) + bInt, errB := strconv.Atoi(b) + if errA != nil && errB != nil { + return strings.Compare(a, b) // fallback lexical + } + if errA != nil { + return 1 // non-numeric after numeric + } + if errB != nil { + return -1 + } + return cmp.Compare(aInt, bInt) + }) + // Iterate sorted keys + for _, code := range keys { + resp := respMap[code] + if resp != nil && resp.Value != nil { + return code, resp.Value + } + } + return "", nil +} + +func (e *exampleEngine) selectMediaType(response *openapi3.Response) (string, *openapi3.MediaType, error) { + if response.Content == nil { + return "", nil, fmt.Errorf("no media type defined for response") + } + // Collect keys for deterministic selection + keys := make([]string, 0, len(response.Content)) + for mt := range response.Content { + keys = append(keys, mt) + } + if len(keys) == 0 { + return "", nil, fmt.Errorf("no media type defined for response") + } + slices.Sort(keys) + // Select first media type after sorting + mt := keys[0] + obj := response.Content[mt] + return mt, obj, nil +} + +func (e *exampleEngine) generateResponse(example *openapi3.Example, dynExample *dynamicExample, eval runtime.Evaluator, currentStatusCode string) (body []byte, headers map[string]string, statusCode string, err error) { + if example != nil { + body, err = e.evaluateExample(example, eval) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to evaluate example: %w", err) + } + headers = e.evaluateHeaders(example, eval) + statusCode = currentStatusCode + return + } + // dynExample != nil + resolvedBody, err := e.evaluateValue(dynExample.response.body, eval) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to evaluate dynamic example body: %w", err) + } + body, err = json.Marshal(resolvedBody) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to marshal response body: %w", err) + } + headers = dynExample.response.headers + // Evaluate runtime expressions in header values + for k, v := range headers { + resolved, err := e.evaluateExpressionInString(v, eval) + if err == nil { + headers[k] = resolved + } + } + statusCode = strconv.Itoa(dynExample.response.code) + return +} + +func (e *exampleEngine) selectExample(mediaType *openapi3.MediaType, eval runtime.Evaluator, opID string) (*openapi3.Example, string) { + if mediaType.Examples == nil { + return nil, "" + } + keys := slices.Collect(maps.Keys(mediaType.Examples)) + slices.Sort(keys) + withParamsMatch, withoutParamsMatch := e.categorizeExamples(mediaType.Examples, keys, eval, opID) + + // First, try examples with params-match + for _, k := range keys { + ex, ok := withParamsMatch[k] + if !ok { + continue + } + pm, _ := extensions.ExtractParamsMatch(ex) + if e.verbose { + slog.Debug("Example has x-mock-params-match", "example", k, "params", pm) + } + matched, err := extensions.EvaluateParamsMatch(pm, eval) + if err != nil { + if e.verbose { + slog.Debug("Error evaluating params-match", "example", k, "error", err) + } + continue + } + if e.verbose { + slog.Debug("Example params-match result", "example", k, "matched", matched) + } + if matched { + if extensions.ExtractOnce(ex) { + exampleID := opID + ":" + k + e.registry.markOnceUsed(exampleID) + if e.verbose { + slog.Debug("Marked example as used (x-mock-once)", "example", k) + } + } + return ex, k + } + } + + // No matched params-match examples, try those without params-match + for _, k := range keys { + ex, ok := withoutParamsMatch[k] + if !ok { + continue + } + if extensions.ExtractOnce(ex) { + exampleID := opID + ":" + k + e.registry.markOnceUsed(exampleID) + if e.verbose { + slog.Debug("Marked example as used (x-mock-once)", "example", k) + } + } + if e.verbose { + slog.Debug("Selecting example (no params-match)", "example", k) + } + return ex, k + } + return nil, "" +} + +func (e *exampleEngine) applyExtensions(example *openapi3.Example, eval runtime.Evaluator, prefix string) { + // Apply x-mock-set-state + if stateMap, ok := extensions.ExtractSetState(example); ok { + e.ApplySetState(stateMap, eval, prefix) + } + // x-mock-headers handled separately in evaluateHeaders + // x-mock-once is handled in selectExample +} + +func (e *exampleEngine) shouldSkipExample(ex *openapi3.Example, exampleKey, opID string) bool { + if extensions.ExtractSkip(ex) { + if e.verbose { + slog.Debug("Example skipped via x-mock-skip", "example", exampleKey) + } + return true + } + if extensions.ExtractOnce(ex) { + exampleID := opID + ":" + exampleKey + if e.registry.isOnceUsed(exampleID) { + if e.verbose { + slog.Debug("Example skipped via x-mock-once (already used)", "example", exampleKey) + } + return true + } + } + return false +} + +func (e *exampleEngine) categorizeExamples(examples openapi3.Examples, keys []string, eval runtime.Evaluator, opID string) (withParamsMatch, withoutParamsMatch map[string]*openapi3.Example) { + withParamsMatch = make(map[string]*openapi3.Example) + withoutParamsMatch = make(map[string]*openapi3.Example) + for _, k := range keys { + exRef := examples[k] + if exRef == nil || exRef.Value == nil { + continue + } + ex := exRef.Value + if e.shouldSkipExample(ex, k, opID) { + continue + } + if _, ok := extensions.ExtractParamsMatch(ex); ok { + withParamsMatch[k] = ex + } else { + withoutParamsMatch[k] = ex + } + } + return +} + +func (e *exampleEngine) evaluateExample(example *openapi3.Example, eval runtime.Evaluator) ([]byte, error) { + if example.Value == nil { + return []byte{}, nil + } + // Evaluate runtime expressions in the value + resolved, err := e.evaluateValue(example.Value, eval) + if err != nil { + return nil, err + } + // Convert to JSON + return json.Marshal(resolved) +} + +func (e *exampleEngine) evaluateHeaders(example *openapi3.Example, eval runtime.Evaluator) map[string]string { + headers := make(map[string]string) + + if headersMap, ok := extensions.ExtractHeaders(example); ok { + for key, val := range headersMap { + if str, ok := e.resolveHeaderValue(val, eval); ok { + headers[key] = str + } + } + } + + return headers +} + +func (e *exampleEngine) resolveHeaderValue(val any, eval runtime.Evaluator) (string, bool) { + switch v := val.(type) { + case string: + resolved, err := e.evaluateValue(v, eval) + if err != nil { + if e.verbose { + slog.Debug("Failed to evaluate header value", "headerValue", v, "error", err) + } + return "", false + } + if str, ok := resolved.(string); ok { + return str, true + } + // Convert to JSON string + b, err := json.Marshal(resolved) + if err != nil { + return "", false + } + return string(b), true + case []any: + // Multiple header values - join with comma (except for Set-Cookie which should be separate headers) + // For simplicity, just take the first value for now + if len(v) > 0 { + if first, ok := v[0].(string); ok { + resolved, err := e.evaluateValue(first, eval) + if err == nil { + if str, ok := resolved.(string); ok { + return str, true + } + } + } + } + default: + // Try to evaluate as runtime expression + resolved, err := e.evaluateValue(val, eval) + if err == nil { + if str, ok := resolved.(string); ok { + return str, true + } + } + } + return "", false +} + +// --------------------------------------------------------------------------- +// AsyncAPI message rendering +// --------------------------------------------------------------------------- + +// renderAsyncMessage selects a message example from the route's AsyncAPI +// message specs (or an injected dynamic example), evaluates runtime +// expressions, applies x-mock-set-state, and returns the rendered payload +// bytes. It returns the number of produced messages (0 when the route has no +// reply message/examples). +func (e *exampleEngine) renderAsyncMessage(mapping *RouteMapping, in InboundMessage) (int, []byte, error) { + opID := "async:" + mapping.Protocol + ":" + mapping.Pattern + + // Dynamic examples injected via the management API take priority (8.2). + evaluator := e.newAsyncEvaluator(mapping, in) + if dyn, _ := e.registry.selectDynamic(routeKey(mapping.Method, mapping.ChiPattern), evaluator); dyn != nil { + body, err := e.evaluateValue(dyn.response.body, evaluator) + if err != nil { + return 0, nil, err + } + jsonBody, merr := json.Marshal(body) + if merr != nil { + return 0, nil, merr + } + return 1, jsonBody, nil + } + + return e.RenderMessageSpecs(mapping.Messages, mapping.Prefix, opID, in) +} + +// newAsyncEvaluator builds an evaluator for an async exchange with the +// protocol-relevant data sources. +func (e *exampleEngine) newAsyncEvaluator(mapping *RouteMapping, in InboundMessage) runtime.Evaluator { + eval := runtime.NewEvaluator() + eval.AddSource("request", e.asyncRequestSource(in)) + eval.AddSource("message", &runtime.MessageSource{Payload: jsonPayload(in.Payload), Headers: in.Headers}) + eval.AddSource("channel", &runtime.ChannelSource{Params: in.PathParams}) + eval.AddSource("state", e.NewStateSource(mapping.Prefix)) + eval.AddSource("env", e.NewEnvSource()) + return eval +} + +// renderMessageSpecs renders the first selectable example across the given +// message specs using the shared selection pipeline (design D5). The evaluator +// exposes {$request.*}, {$message.*}, {$channel.*}, {$state.*} and {$env.*}. +func (e *exampleEngine) RenderMessageSpecs(messages []*loader.MessageSpec, prefix, opID string, in InboundMessage) (int, []byte, error) { + evaluator := runtime.NewEvaluator() + evaluator.AddSource("request", e.asyncRequestSource(in)) + evaluator.AddSource("message", &runtime.MessageSource{ + Payload: jsonPayload(in.Payload), + Headers: in.Headers, + }) + evaluator.AddSource("channel", &runtime.ChannelSource{Params: in.PathParams}) + evaluator.AddSource("state", e.NewStateSource(prefix)) + evaluator.AddSource("env", e.NewEnvSource()) + + 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 +} + +// 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)) + for k, v := range in.Headers { + headers[k] = []string{v} + } + return &runtime.RequestSource{ + PathParams: in.PathParams, + QueryParams: nil, + Headers: headers, + Body: jsonPayload(in.Payload), + Cookies: nil, + } +} + +// selectAsyncExample selects a message example using the x-mock-* semantics +// (skip, once, params-match) shared with the OpenAPI pipeline. +func (e *exampleEngine) SelectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*MessageExampleView, string) { + if message == nil { + return nil, "" + } + indices := make([]int, 0, len(message.Examples)) + for i := range message.Examples { + indices = append(indices, i) + } + slices.Sort(indices) + + for _, idx := range indices { + example := message.Examples[idx] + exampleKey := idxName(message.Name, idx) + if example == nil { + continue + } + view := &MessageExampleView{spec: example} + if extensions.ValueSkip(view) { + continue + } + onceID := opID + ":" + exampleKey + if extensions.ValueOnce(view) && e.registry.isOnceUsed(onceID) { + continue + } + if match, ok := extensions.ValueMatch(view); ok { + matched, err := extensions.EvaluateParamsMatch(extensions.ParamsMatch(match), evaluator) + if err != nil || !matched { + continue + } + } + if extensions.ValueOnce(view) { + e.registry.markOnceUsed(onceID) + } + return view, exampleKey + } + return nil, "" +} + +// renderAsyncPayload evaluates runtime expressions in a message example's +// payload and returns the JSON bytes. +func (e *exampleEngine) RenderAsyncPayload(example *MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) { + payload := example.Payload() + if payload == nil { + payload = map[string]any{} + } + resolved, err := e.evaluateValue(payload, evaluator) + if err != nil { + return nil, fmt.Errorf("failed to evaluate message payload: %w", err) + } + if e.verbose { + slog.Debug("Rendered AsyncAPI message payload", "payload", resolved) + } + return json.Marshal(resolved) +} + +// recordAsyncExchange records an AsyncAPI message exchange in the request +// history store (RS.ATM.15). +func (e *exampleEngine) recordAsyncExchange(in InboundMessage, address string, status int, responseBody []byte) { + headers := make(http.Header, len(in.Headers)) + for k, v := range in.Headers { + headers.Set(k, v) + } + now := time.Now() + record := RequestRecord{ + ID: fmt.Sprintf("%d", now.UnixNano()), + Timestamp: now, + Method: "async", + Path: address, + Query: "", + Headers: headers, + Body: in.Payload, + Response: &ResponseRecord{ + StatusCode: status, + Headers: http.Header{}, + Body: responseBody, + Duration: 0, + }, + } + e.historyStore.Add(record) +} + +// newStateSource builds a runtime state source for a schema namespace. +func (e *exampleEngine) NewStateSource(prefix string) *runtime.StateSource { + data := e.stateStore.GetNamespace(prefix) + if data == nil { + data = make(map[string]any) + } + return &runtime.StateSource{Data: data} +} + +// newEnvSource builds a runtime environment-variable source. +func (e *exampleEngine) NewEnvSource() *runtime.EnvSource { + env := make(map[string]string) + for _, item := range os.Environ() { + if key, val, found := strings.Cut(item, "="); found { + env[key] = val + } + } + return &runtime.EnvSource{Env: env} +} diff --git a/internal/server/event_broker.go b/internal/server/event_broker.go new file mode 100644 index 0000000..768cca0 --- /dev/null +++ b/internal/server/event_broker.go @@ -0,0 +1,118 @@ +package server + +import ( + "log/slog" + "sync" + "time" + + "github.com/mamonth/oasmock/internal/loader" +) + +// 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 string + // schema is the owning schema prefix (empty = global). + schema string + // messages carries the message specs whose examples subscribed. + messages []*messageDeliverable +} + +// messageDeliverable is a message spec deliverable when its subscription fires. +type messageDeliverable struct { + spec *loader.MessageSpec + prefix string +} + +// delaySchedule describes a delayed delivery. +type delaySchedule struct { + ms int +} + +// eventDeliverer emits a delivered message for a channel subscription. +type eventDeliverer func(sub channelSubscription, payload map[string]any) + +// eventBroker decouples OpenAPI event triggers from AsyncAPI consumers +// (design D8). Subscriptions are keyed by event name + schema scope. +type eventBroker struct { + mu sync.RWMutex + byEvent map[string][]channelSubscription // event name -> subscriptions + deliver eventDeliverer +} + +// newEventBroker creates an empty broker. When deliver is nil, fired events +// are accepted without delivery (used by tests). +func newEventBroker(deliver eventDeliverer) *eventBroker { + return &eventBroker{ + byEvent: make(map[string][]channelSubscription), + deliver: deliver, + } +} + +// addSubscriptions registers subscriptions for a schema. +func (b *eventBroker) addSubscriptions(schema string, subs []channelSubscription) { + if b == nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + for i := range subs { + if subs[i].event == "" { + continue + } + subs[i].schema = schema + b.byEvent[subs[i].event] = append(b.byEvent[subs[i].event], subs[i]) + } +} + +// resolveSubscribers returns subscriptions matching an event name for the +// given firing schema. When global is true, all schemas' subscriptions match. +func (b *eventBroker) resolveSubscribers(event, firingSchema string, global ...bool) ([]channelSubscription, int) { + if b == nil { + return nil, 0 + } + isGlobal := len(global) > 0 && global[0] + b.mu.RLock() + defer b.mu.RUnlock() + all := b.byEvent[event] + out := make([]channelSubscription, 0, len(all)) + for _, sub := range all { + if isGlobal || sub.schema == firingSchema { + out = append(out, sub) + } + } + return out, len(out) +} + +// fire dispatches a named event. A delay schedules delivery on a background +// goroutine; otherwise delivery is synchronous. +func (b *eventBroker) fire(event string, payload map[string]any, firingSchema string, global bool, delay *delaySchedule) { + if b == nil { + return + } + subs, _ := b.resolveSubscribers(event, firingSchema, global) + if len(subs) == 0 { + return + } + if delay != nil && delay.ms > 0 { + go func() { + time.Sleep(time.Duration(delay.ms) * time.Millisecond) + b.deliverAll(subs, payload) + }() + return + } + b.deliverAll(subs, payload) +} + +// deliverAll emits a payload to every resolved subscription. +func (b *eventBroker) deliverAll(subs []channelSubscription, payload map[string]any) { + for _, sub := range subs { + if b.deliver != nil { + b.deliver(sub, payload) + } else { + slog.Debug("Event delivered (no deliverer)", "event", sub.event, "address", sub.address) + } + } +} diff --git a/internal/server/event_broker_test.go b/internal/server/event_broker_test.go new file mode 100644 index 0000000..47a31c6 --- /dev/null +++ b/internal/server/event_broker_test.go @@ -0,0 +1,148 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Registering event subscriptions per schema +Given a broker with a schema-prefixed subscription +When resolveSubscribers is called for a schema-local event in the same schema +Then the subscription is resolved + +Related spec scenarios: RS.EVT.5 +*/ +func TestEventBroker_ResolveSchemaLocal(t *testing.T) { + t.Parallel() + + broker := newEventBroker(nil) + broker.addSubscriptions("/v1", []channelSubscription{ + {address: "/v1/alerts", event: "orderCreated"}, + }) + + subs, count := broker.resolveSubscribers("orderCreated", "/v1") + assert.Equal(t, 1, count) + require.Len(t, subs, 1) + assert.Equal(t, "/v1/alerts", subs[0].address) +} + +/* +Scenario: Schema-local events do not cross schema boundaries +Given a broker with a subscription in schema /a +When a schema-local event is fired from schema /b +Then no subscription is resolved + +Related spec scenarios: RS.EVT.5 +*/ +func TestEventBroker_ResolveSchemaLocalNoCross(t *testing.T) { + t.Parallel() + + broker := newEventBroker(nil) + broker.addSubscriptions("/a", []channelSubscription{ + {address: "/a/alerts", event: "orderCreated"}, + }) + + subs, count := broker.resolveSubscribers("orderCreated", "/b") + assert.Equal(t, 0, count) + assert.Empty(t, subs) +} + +/* +Scenario: Global events cross schema boundaries +Given a broker with a subscription in schema /a +When a global event is fired from schema /b +Then the subscription resolves regardless of schema + +Related spec scenarios: RS.EVT.6 +*/ +func TestEventBroker_ResolveGlobal(t *testing.T) { + t.Parallel() + + broker := newEventBroker(nil) + broker.addSubscriptions("/a", []channelSubscription{ + {address: "/a/alerts", event: "orderCreated"}, + }) + + subs, count := broker.resolveSubscribers("orderCreated", "", true) + assert.Equal(t, 1, count) + require.Len(t, subs, 1) + assert.Equal(t, "/a/alerts", subs[0].address) +} + +/* +Scenario: Event with no subscribers is accepted +Given a broker with no matching subscription +When an event fires +Then it is accepted with no delivery + +Related spec scenarios: RS.EVT.14 +*/ +func TestEventBroker_FireNoSubscribers(t *testing.T) { + t.Parallel() + + broker := newEventBroker(nil) + broker.addSubscriptions("/v1", []channelSubscription{ + {address: "/v1/alerts", event: "other"}, + }) + + broker.fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, nil) +} + +/* +Scenario: Delayed event delivery schedules +Given an event with a delay +When fire is called +Then the delivery is scheduled and the broker returns immediately + +Related spec scenarios: RS.EVT.4, RS.EVT.16 +*/ +func TestEventBroker_FireWithDelaySchedules(t *testing.T) { + t.Parallel() + + delivered := make(chan channelSubscription, 1) + broker := newEventBroker(func(sub channelSubscription, payload map[string]any) { + delivered <- sub + }) + + broker.addSubscriptions("/v1", []channelSubscription{ + {address: "/v1/alerts", event: "orderCreated"}, + }) + + broker.fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, &delaySchedule{ms: 10}) + + select { + case sub := <-delivered: + assert.Equal(t, "/v1/alerts", sub.address) + case <-time.After(time.Second): + t.Fatal("expected delayed delivery") + } +} + +/* +Scenario: Immediate delivery with no delay +Given an event with no delay +When fire is called +Then the delivery happens synchronously + +Related spec scenarios: RS.EVT.1, RS.EVT.3 +*/ +func TestEventBroker_FireImmediate(t *testing.T) { + t.Parallel() + + var delivered []channelSubscription + broker := newEventBroker(func(sub channelSubscription, payload map[string]any) { + delivered = append(delivered, sub) + }) + + broker.addSubscriptions("/v1", []channelSubscription{ + {address: "/v1/alerts", event: "orderCreated"}, + }) + + broker.fire("orderCreated", map[string]any{"id": "1"}, "/v1", false, nil) + require.Len(t, delivered, 1) + assert.Equal(t, "/v1/alerts", delivered[0].address) +} diff --git a/internal/server/event_integration_test.go b/internal/server/event_integration_test.go new file mode 100644 index 0000000..31c5479 --- /dev/null +++ b/internal/server/event_integration_test.go @@ -0,0 +1,232 @@ +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/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func openAPIExampleTriggerDoc() *openapi3.T { + const yamlSpec = ` +openapi: 3.0.0 +info: + title: REST + version: 1.0.0 +paths: + /orders: + post: + responses: + '200': + description: OK + content: + application/json: + examples: + trigger: + value: + status: created + x-event-trigger: + - name: orderCreated + payload: + accountId: acc-1 +` + ldr := openapi3.NewLoader() + spec, err := ldr.LoadFromData([]byte(yamlSpec)) + if err != nil { + panic(err) + } + return spec +} + +func asyncAlertDoc() *asyncapi.Document { + raw := ` +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 + account: "{$event.accountId}" + x-send-events: + - on: orderCreated +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + doc, _ := asyncapi.Parse([]byte(raw)) + return doc +} + +/* +Scenario: REST fill triggers an event delivered to an open ws stream +Given a REST example with x-event-trigger and a subscribed AsyncAPI ws channel +When a REST request selects the trigger example with a connected consumer +Then the consumer receives the templated message with the event payload + +Related spec scenarios: RS.EVT.1, RS.EVT.7, RS.EVT.8, RS.EVT.12 +*/ +func TestEventDriver_RESTToWS(t *testing.T) { + t.Parallel() + + schemas := []loader.SchemaInfo{ + {Kind: loader.KindOpenAPI, Spec: openAPIExampleTriggerDoc(), Prefix: ""}, + {Kind: loader.KindAsyncAPI, Async: asyncAlertDoc(), Prefix: ""}, + } + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + // Connect a ws consumer to 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 + + // Fire the REST example → triggers "orderCreated" → alert delivered to ws. + resp, err := http.Post(ts.URL+"/orders", "application/json", strings.NewReader(`{}`)) + require.NoError(t, err) + _ = resp.Body.Close() + require.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, "acc-1", payload["account"]) + assert.Equal(t, "info", payload["level"]) +} + +const signalRTriggerRestDoc = ` +openapi: 3.0.0 +info: + title: REST + version: 1.0.0 +paths: + /orders: + post: + responses: + '200': + description: OK + content: + application/json: + examples: + trigger: + value: + status: created + x-event-trigger: + - name: orderCreated + payload: + symbol: ETH +` + +const signalRTriggeredHubDoc = `asyncapi: 3.0.0 +info: + title: Prices + version: 1.0.0 +x-signalr: + path: /hub +channels: + priceFeed: + address: priceFeed + bindings: + ws: + method: GET + messages: + priceMsg: + examples: + - name: snap + payload: + symbol: ETH + price: 3000 + - name: evented + payload: + symbol: "{$event.symbol}" + price: 3100 + x-send-events: + - on: orderCreated +operations: + receivePrice: + action: receive + channel: + $ref: '#/channels/priceFeed' +` + +/* +Scenario: REST fill triggers an event delivered into an open SignalR stream +Given a REST example with x-event-trigger and a subscribed SignalR hub channel +When a REST request selects the trigger example with an open stream on the hub +Then the consumer receives the templated message as a StreamItem + +Related spec scenarios: RS.EVT.1, RS.EVT.7, RS.EVT.13, RS.SHR.18 +*/ +func TestEventDriver_RESTToSignalRStream(t *testing.T) { + t.Parallel() + + restLoader := openapi3.NewLoader() + restDoc, err := restLoader.LoadFromData([]byte(signalRTriggerRestDoc)) + require.NoError(t, err) + hubDoc, err := asyncapi.Parse([]byte(signalRTriggeredHubDoc)) + require.NoError(t, err) + + schemas := []loader.SchemaInfo{ + {Kind: loader.KindOpenAPI, Spec: restDoc, Prefix: ""}, + {Kind: loader.KindAsyncAPI, Async: hubDoc, Prefix: ""}, + } + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + // Connect a SignalR client and open a stream on the priceFeed channel. + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/hub" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, 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() // snapshot + require.NoError(t, err) + + // Fire the REST trigger; the event yields a StreamItem on the open stream. + resp, err := http.Post(ts.URL+"/orders", "application/json", strings.NewReader(`{}`)) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(msg)[0], &env)) + assert.Equal(t, signalRTypeStreamItem, env.Type) + assert.Equal(t, "s1", env.InvocationID) + raw, _ := json.Marshal(env.Item) + assert.Contains(t, string(raw), `"symbol":"ETH"`) +} diff --git a/internal/server/event_server.go b/internal/server/event_server.go new file mode 100644 index 0000000..4c93c51 --- /dev/null +++ b/internal/server/event_server.go @@ -0,0 +1,171 @@ +package server + +import ( + "log/slog" + "strings" + + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" +) + +// eventBus is the pure-fabrication coordinator behind the event driver +// (design D8). It owns the event broker and renders + delivers subscribed +// messages through the MessageRenderer and ConsumerBus contracts, so it never +// reaches into Server. +type eventBus struct { + broker *eventBroker + renderer MessageRenderer + bus ConsumerBus + verbose bool +} + +// 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, + } + b.broker = &eventBroker{ + byEvent: make(map[string][]channelSubscription), + deliver: b.deliver, + } + return b +} + +// fire dispatches a named event, reusing the broker's delay semantics. +func (b *eventBus) fire(name string, payload map[string]any, firingSchema string, global bool, delay *delaySchedule) { + if b == nil || b.broker == nil { + return + } + 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) { + 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) + } +} + +// 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 { + 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 + } + if count == 0 { + 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) +} + +// 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). +func (s *Server) signalRPush(address string, payload []byte) { + s.hubMgr.SignalRPush(address, payload) +} + +// hubForAddress finds the SignalR hub owning a channel address. +func (s *Server) hubForAddress(address string) *signalRHub { + return s.hubMgr.hubForAddress(address) +} + +// 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 { + var subs []channelSubscription + 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 { + continue + } + spec.Examples = examples + subs = append(subs, channelSubscription{ + address: address, + event: event, + messages: []*messageDeliverable{{spec: spec, prefix: prefix}}, + }) + } + } + } + return subs +} + +// 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 + } + events, err := parseSendEvents(ex.Extensions) + if err != nil { + continue + } + spec := &loader.MessageExampleSpec{ + Name: ex.Name, + Headers: ex.Headers, + Payload: ex.Payload, + Extensions: ex.Extensions, + } + for _, ev := range events { + out[ev.On] = append(out[ev.On], spec) + } + } + 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 + } + 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 { + return + } + for _, ws := range a.registry.connections(address) { + ws.writer.write(payload) + } +} + +// 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) +} diff --git a/internal/server/fire_event.go b/internal/server/fire_event.go new file mode 100644 index 0000000..579dfde --- /dev/null +++ b/internal/server/fire_event.go @@ -0,0 +1,50 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" +) + +// fireEventRequest is the payload of POST /_mock/events/fire (RS.EVT.16-17). +type fireEventRequest struct { + 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") + return + } + 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 + } + if req.Event == "" { + writeJSONError(w, http.StatusBadRequest, "missing required field 'event'") + return + } + if req.Delay < 0 { + writeJSONError(w, http.StatusBadRequest, "delay cannot be negative") + 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. + 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{ + "success": true, + "event": req.Event, + }) +} diff --git a/internal/server/fire_event_endpoint_test.go b/internal/server/fire_event_endpoint_test.go new file mode 100644 index 0000000..98768e1 --- /dev/null +++ b/internal/server/fire_event_endpoint_test.go @@ -0,0 +1,159 @@ +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 fireEventWsDoc = `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: "{$event.level}" + msg: "{$event.message}" + x-send-events: + - on: levelUp +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +` + +/* +Scenario: Firing an event via the management endpoint delivers to consumers +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 +*/ +func TestFireEventEndpoint(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":"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) + 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: Fire-event endpoint accepts a delay +Given a management request firing an event with a positive delay +When the fire-event endpoint is invoked +Then the request is accepted with 200 (delivery scheduled asynchronously) + +Related spec scenarios: RS.EVT.16, RS.AMG.20, RS.MAPI.22 +*/ +func TestFireEventEndpoint_Delayed(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"},"delay":10}` + 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) +} + +/* +Scenario: Fire-event endpoint rejects a negative delay +Given a management request firing an event with a negative delay +When the fire-event endpoint is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.AMG.3 +*/ +func TestFireEventEndpoint_NegativeDelay(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":{},"delay":-5}` + 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.StatusBadRequest, resp.StatusCode) +} + +/* +Scenario: Firing an event with a subscription but no connected consumers +Given a server with a subscribed channel and no connected consumers +When the fire-event endpoint is invoked +Then the request is accepted with 200 and no message is delivered + +Related spec scenarios: RS.EVT.15, RS.MAPI.22 +*/ +func TestFireEventEndpoint_NoConsumers(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","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) +} diff --git a/internal/server/history_test.go b/internal/server/history_test.go new file mode 100644 index 0000000..a87d9c2 --- /dev/null +++ b/internal/server/history_test.go @@ -0,0 +1,67 @@ +package server + +import ( + "testing" + + "github.com/mamonth/oasmock/internal/history" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Recording an AsyncAPI exchange in request history +Given a server with a real history store +When recordAsyncExchange is called with an address, payload and response +Then a RequestRecord with the async method marker is added to the history store + +Related spec scenarios: RS.ATM.15 +*/ +func TestServer_RecordAsyncExchange(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + + // Use a real ring buffer behind the mock to observe the Add. + realStore := newHistoryRingBufferStore(history.NewRingBuffer(32)) + srv.historyStore = realStore + srv.engine.historyStore = realStore + + srv.recordAsyncExchange(InboundMessage{ + Payload: []byte(`{"id":"1"}`), + Headers: map[string]string{"content-type": "application/json"}, + ConnectionID: "conn-1", + }, "/socket", 200, []byte(`{"id":1}`)) + + records := realStore.GetAll() + require.Len(t, records, 1) + require.NotNil(t, records[0].Response) + assert.Equal(t, "async", records[0].Method) + assert.Contains(t, records[0].Path, "/socket") + assert.Equal(t, `{"id":"1"}`, string(records[0].Body)) + assert.Equal(t, 200, records[0].Response.StatusCode) +} + +/* +Scenario: Recording an AsyncAPI exchange without a response body +Given a server with a real history store +When recordAsyncExchange is called without a response payload +Then the record is still added with the async method marker + +Related spec scenarios: RS.ATM.15 +*/ +func TestServer_RecordAsyncExchange_NoResponse(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + realStore := newHistoryRingBufferStore(history.NewRingBuffer(32)) + srv.historyStore = realStore + srv.engine.historyStore = realStore + + srv.recordAsyncExchange(InboundMessage{Payload: []byte("{}")}, "/alerts", 200, nil) + + records := realStore.GetAll() + require.Len(t, records, 1) + require.NotNil(t, records[0].Response) + assert.Equal(t, "async", records[0].Method) + assert.Nil(t, records[0].Response.Body) +} diff --git a/internal/server/http_adapter.go b/internal/server/http_adapter.go new file mode 100644 index 0000000..a2479de --- /dev/null +++ b/internal/server/http_adapter.go @@ -0,0 +1,55 @@ +package server + +import ( + "io" + "net/http" + "strings" +) + +// httpProtocolAdapter serves AsyncAPI http channels by reusing the HTTP mock +// response pipeline through the shared MessageHandler (RS.ASP.1, RS.ASP.10). +type httpProtocolAdapter struct{} + +// Protocol implements ProtocolAdapter. +func (a *httpProtocolAdapter) Protocol() string { return asyncHTTPProtocol } + +// Handler builds the HTTP handler that renders an AsyncAPI http channel route. +func (a *httpProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandler) http.HandlerFunc { + return func(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 + } + + headers := make(map[string]string) + for k := range r.Header { + value := r.Header.Get(k) + if value != "" { + headers[strings.ToLower(k)] = value + } + } + + out, err := handler.HandleMessage(r.Context(), InboundMessage{ + Payload: body, + Headers: headers, + PathParams: addressParams(r), + }) + if err != nil { + writeJSONErrorf(w, http.StatusInternalServerError, "failed to render message: %v", err) + return + } + + w.Header().Set("Content-Type", "application/json") + if out == nil { + // AsyncAPI http send with no reply message (RS.ASP.10). + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + if _, werr := w.Write(out); werr != nil { + // Best effort; connection may be gone. + return + } + } +} diff --git a/internal/server/hubmanager.go b/internal/server/hubmanager.go new file mode 100644 index 0000000..94b4521 --- /dev/null +++ b/internal/server/hubmanager.go @@ -0,0 +1,66 @@ +package server + +// 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. +type hubManager struct { + hubs []*signalRHub + ws *wsProtocolAdapter +} + +// 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 { + return &hubManager{ + hubs: buildSignalRHubs(renderer, schemas), + ws: ws, + } +} + +// hubForAddress finds the SignalR hub owning a channel address. +func (m *hubManager) hubForAddress(address string) *signalRHub { + for _, hub := range m.hubs { + for _, ch := range hub.channels { + if asyncAddressWithPrefix(hub.prefix, ch.Address) == address { + return hub + } + } + } + return nil +} + +// hasConnection reports whether a connection id is active on any hub. +func (m *hubManager) hasConnection(id string) bool { + for _, hub := range m.hubs { + hub.mu.Lock() + _, ok := hub.conns[id] + hub.mu.Unlock() + if ok { + return true + } + } + return false +} + +// SignalRPush emits a payload into a SignalR hub channel's open streams or as +// a server invocation when none are open (ConsumerBus, RS.SHR.18-19). +func (m *hubManager) SignalRPush(address string, payload []byte) { + hub := m.hubForAddress(address) + if hub == nil { + return + } + for channelID, ch := range hub.channels { + if asyncAddressWithPrefix(hub.prefix, ch.Address) == address { + hub.pushToStreams(channelID, payload, channelID) + } + } +} + +// WSBroadcast sends a payload to every connected raw ws consumer of a channel +// address (ConsumerBus). +func (m *hubManager) WSBroadcast(address string, payload []byte) { + if m.ws == nil { + return + } + m.ws.broadcast(address, payload) +} diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index 9bd1279..1d704a6 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -7,6 +7,9 @@ import ( "time" "github.com/getkin/kin-openapi/openapi3" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" ) // RouteProvider builds route mappings from OpenAPI schemas. @@ -15,7 +18,8 @@ type RouteProvider interface { BuildRouteMappings(schemas []SchemaInfo) ([]RouteMapping, error) } -// RouteMapping represents a route mapping for a single OpenAPI operation. +// RouteMapping represents a route mapping for a single OpenAPI operation or +// AsyncAPI channel/operation. type RouteMapping struct { Method string Path string // The full path pattern with prefix (e.g., "/v1/users/{id}") @@ -25,11 +29,18 @@ type RouteMapping struct { Operation *openapi3.Operation Parameters openapi3.Parameters Responses *openapi3.Responses + + // AsyncAPI-specific route data. + Protocol string // "http" | "ws" | "" + Action string // "send" | "receive" | "" + Messages []*loader.MessageSpec // AsyncAPI-backed message specs } -// SchemaInfo holds a loaded OpenAPI spec and its path prefix. +// SchemaInfo holds a loaded spec (OpenAPI or AsyncAPI) and its path prefix. type SchemaInfo struct { Spec *openapi3.T + Kind loader.Kind + Async *asyncapi.Document Prefix string } @@ -161,3 +172,38 @@ type Dependencies struct { ExpressionEvaluator ExpressionEvaluator ExtensionProcessor ExtensionProcessor } + +// MessageRenderer is the message-rendering surface consumed by the SignalR +// hub, the event bus and the async protocol adapters. It narrows the +// dependencies of those subsystems to the example-selection/templating core +// instead of the whole Server. exampleEngine implements it. +type MessageRenderer interface { + // SelectAsyncExample selects a message example using the shared x-mock-* + // semantics. + SelectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*MessageExampleView, string) + // 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. + ApplySetState(stateMap map[string]any, eval runtime.Evaluator, prefix string) + // NewStateSource builds the runtime state source for a schema namespace. + NewStateSource(prefix string) *runtime.StateSource + // NewEnvSource builds the runtime environment-variable source. + NewEnvSource() *runtime.EnvSource +} + +// ConsumerBus emits rendered payloads to channel consumers (SignalR open +// streams and/or raw ws broadcast). 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). + SignalRPush(address string, payload []byte) + // WSBroadcast sends a payload to every connected raw ws consumer of a + // channel address. + WSBroadcast(address string, payload []byte) +} diff --git a/internal/server/management_async.go b/internal/server/management_async.go new file mode 100644 index 0000000..b62a56e --- /dev/null +++ b/internal/server/management_async.go @@ -0,0 +1,331 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/runtime" +) + +// asyncPushRequest is the payload of POST /_mock/ws/push (RS.AMG.1-7, RS.AMG.10-11). +type asyncPushRequest struct { + Channel string `json:"channel"` + ConnectionID string `json:"connectionId"` + Payload map[string]any `json:"payload"` + Delay int `json:"delay"` +} + +// handleAsyncPush pushes a message to channel consumers (immediate or delayed, +// targeted or broadcast). +func (s *Server) handleAsyncPush(w http.ResponseWriter, r *http.Request) { + req, err := decodeAsyncPush(r) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if req.Channel == "" { + writeJSONError(w, http.StatusBadRequest, "missing required field 'channel'") + return + } + if req.Delay < 0 { + writeJSONError(w, http.StatusBadRequest, "delay cannot be negative") + return + } + + registry := s.wsRegistry() + if req.ConnectionID != "" { + if registry == nil || !s.hasConnection(req.ConnectionID) { + writeJSONError(w, http.StatusNotFound, "unknown connectionId") + return + } + } + + // Evaluate runtime expressions in the payload against the schema's state + // and environment at delivery time (RS.AMG.10-11). + resolved, err := s.evaluatePushPayload(req.Payload, req.Channel) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + payload, err := json.Marshal(resolved) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid payload") + return + } + + if req.Delay > 0 { + go func() { + time.Sleep(time.Duration(req.Delay) * time.Millisecond) + s.pushToChannel(req.Channel, req.ConnectionID, payload) + }() + } else { + s.pushToChannel(req.Channel, req.ConnectionID, payload) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) +} + +// evaluatePushPayload evaluates runtime expressions in a pushed payload using +// the channel's schema namespace and the environment (RS.AMG.10-11). +func (s *Server) evaluatePushPayload(payload map[string]any, channel string) (any, error) { + prefix := s.prefixForChannel(channel) + evaluator := runtime.NewEvaluator() + evaluator.AddSource("state", s.newStateSource(prefix)) + evaluator.AddSource("env", s.newEnvSource()) + return s.evaluateValue(payload, evaluator) +} + +// prefixForChannel finds the schema prefix owning a channel address. +func (s *Server) prefixForChannel(channel string) string { + for _, m := range s.mappings { + if m.Protocol != "" && m.Path == channel { + return m.Prefix + } + } + for _, hub := range s.hubMgr.hubs { + for _, ch := range hub.channels { + if asyncAddressWithPrefix(hub.prefix, ch.Address) == channel { + return hub.prefix + } + } + } + return "" +} + +// decodeAsyncPush parses and validates a push request body. +func decodeAsyncPush(r *http.Request) (asyncPushRequest, error) { + var req asyncPushRequest + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + if err != nil { + return req, err + } + if err := json.Unmarshal(body, &req); err != nil { + return req, err + } + return req, nil +} + +// hasConnection reports whether a ws connection id is active (registry or hub). +func (s *Server) hasConnection(id string) bool { + if reg := s.wsRegistry(); reg != nil { + reg.mu.RLock() + _, ok := reg.byID[id] + reg.mu.RUnlock() + if ok { + return true + } + } + return s.hubMgr.hasConnection(id) +} + +// pushToChannel delivers a payload to a channel's consumers. A connection id +// targets one consumer; otherwise it broadcasts. Both raw ws consumers and +// SignalR hub connections are targeted. +func (s *Server) pushToChannel(channel, connectionID string, payload []byte) { + hub := s.hubForAddress(channel) + if hub != nil { + if hubChannelID := matchingHubChannel(hub, channel); hubChannelID != "" { + if connectionID != "" { + hub.pushToConnection(connectionID, hubChannelID, payload, hubChannelID) + } else { + hub.pushPayload(hubChannelID, payload) + } + } + } + if reg := s.wsRegistry(); reg != nil { + targets := reg.connections(channel) + for _, ws := range targets { + if connectionID == "" || ws.id == connectionID { + ws.writer.write(payload) + } + } + } +} + +// matchingHubChannel finds the channel ID within a hub serving the address. +func matchingHubChannel(hub *signalRHub, address string) string { + for id, ch := range hub.channels { + if asyncAddressWithPrefix(hub.prefix, ch.Address) == address { + return id + } + } + return "" +} + +// handleAsyncConsumers lists active consumers per channel (RS.AMG.8-9). +func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { + channel := r.URL.Query().Get("channel") + type consumerInfo struct { + ConnectionID string `json:"connectionId"` + Channel string `json:"channel"` + Streams []map[string]string `json:"streams,omitempty"` + } + consumers := []consumerInfo{} + + if reg := s.wsRegistry(); reg != nil { + conns := reg.connections(channel) + for _, ws := range conns { + consumers = append(consumers, consumerInfo{ConnectionID: ws.id, Channel: channel}) + } + } + if hub := s.hubForAddress(channel); hub != nil { + if id := matchingHubChannel(hub, channel); id != "" { + for _, st := range hub.openStreamsForChannel(id) { + consumers = append(consumers, consumerInfo{ + ConnectionID: st["connectionId"], + Channel: channel, + Streams: []map[string]string{st}, + }) + } + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"consumers": consumers}) +} + +// pushPayload emits a payload into a hub channel's open streams or as a server +// invocation when no stream is open. +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}) +} + +// handleAsyncDisconnect force-disconnects a consumer (RS.AMG.14-17). +func (s *Server) handleAsyncDisconnect(w http.ResponseWriter, r *http.Request) { + var req struct { + ConnectionID string `json:"connectionId"` + Reason string `json:"reason"` + Code int `json:"code"` + Abrupt bool `json:"abrupt"` + } + 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.ConnectionID == "" { + writeJSONError(w, http.StatusBadRequest, "missing required field 'connectionId'") + return + } + + if !s.hasConnection(req.ConnectionID) { + writeJSONError(w, http.StatusNotFound, "unknown connectionId") + return + } + + // SignalR hub connection (RS.AMG.14-15). + for _, hub := range s.hubMgr.hubs { + hub.mu.Lock() + if sc, ok := hub.conns[req.ConnectionID]; ok { + hub.mu.Unlock() + s.disconnectWS(sc.writer, req) + return + } + hub.mu.Unlock() + } + + // Raw ws connection. + if reg := s.wsRegistry(); reg != nil { + reg.mu.RLock() + ws, ok := reg.byID[req.ConnectionID] + reg.mu.RUnlock() + if ok { + s.disconnectWS(ws.writer, req) + reg.unregister(req.ConnectionID) + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) +} + +// disconnectWS closes a WebSocket connection with a close frame or abruptly. +func (s *Server) disconnectWS(w *wsWriter, req struct { + ConnectionID string `json:"connectionId"` + Reason string `json:"reason"` + Code int `json:"code"` + Abrupt bool `json:"abrupt"` +}) { + if w == nil { + return + } + if req.Abrupt { + // Abrupt drop: abort without a close frame (RS.AMG.17). + w.abort() + return + } + code := websocket.CloseNormalClosure + if req.Code != 0 { + code = req.Code + } + w.writeClose(code, req.Reason) +} diff --git a/internal/server/management_async_lifecycle_test.go b/internal/server/management_async_lifecycle_test.go new file mode 100644 index 0000000..a738942 --- /dev/null +++ b/internal/server/management_async_lifecycle_test.go @@ -0,0 +1,400 @@ +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" +) + +// 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 +When the push is delivered +Then the expression is evaluated before delivery + +Related spec scenarios: RS.AMG.10 +*/ +func TestPushEndpoint_TemplatedPayload(t *testing.T) { + t.Setenv("OASMOCK_TEST_VAL", "resolved") + + srv := newAsyncMgmtServer(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":"{$env.OASMOCK_TEST_VAL}"}}` + 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), `"resolved"`) +} + +/* +Scenario: Pushing a malformed payload expression is rejected +Given a push request with an unresolvable expression +When the push is delivered +Then it is rejected with HTTP 400 + +Related spec scenarios: RS.AMG.11 +*/ +func TestPushEndpoint_UnresolvableExpression(t *testing.T) { + t.Parallel() + + srv := newAsyncMgmtServer(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)) + 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 + +Related spec scenarios: RS.AMG.12, RS.AMG.13 +*/ +func TestSchedulePush_Recurring(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" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + _, _, _ = conn.ReadMessage() // consume snapshot + + 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 + 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) +} + +/* +Scenario: Force-disconnecting a consumer closes the connection +Given an active consumer and a disconnect request +When the disconnect endpoint is invoked +Then the connection is closed + +Related spec scenarios: RS.AMG.14, RS.AMG.15, RS.AMG.16 +*/ +func TestDisconnectEndpoint(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" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + // Identify the connection id via consumers. + 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) + + // Disconnect it. + disc := `{"connectionId":"` + connID + `","reason":"busy","code":4001}` + 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) + + // Unknown consumer 404. + unknownResp, err := http.Post(ts.URL+"/_mock/ws/disconnect", "application/json", strings.NewReader(`{"connectionId":"nope"}`)) + require.NoError(t, err) + defer unknownResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNotFound, unknownResp.StatusCode) +} + +/* +Scenario: Fire-event and push drive live ws connections together +Given a server with an AsyncAPI ws channel and a fire-event subscription +When an event fires and a push occurs on the same channel +Then both deliveries reach a connected consumer + +Related spec scenarios: RS.AMG.1, RS.AMG.6, RS.AMG.20, RS.EVT.16 +*/ +func TestAsyncManagement_LiveConnections(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" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + _, _, _ = 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}}`)) + 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", + strings.NewReader(`{"channel":"/alerts","payload":{"seq":1}}`)) + require.NoError(t, err) + _ = pushResp.Body.Close() + assert.Equal(t, http.StatusOK, pushResp.StatusCode) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(msg), `"seq":1`) +} + +const signalRPushDoc = `asyncapi: 3.0.0 +info: + title: SignalR Hub + version: 1.0.0 +x-signalr: + path: /hub +channels: + priceFeed: + address: priceFeed + bindings: + ws: + method: GET + messages: + priceMsg: + examples: + - name: snap + payload: + symbol: ETH +operations: + receivePrice: + action: receive + channel: + $ref: '#/channels/priceFeed' +` + +func newSignalRPushMgmtServer(t *testing.T) *Server { + t.Helper() + doc, err := asyncapi.Parse([]byte(signalRPushDoc)) + 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 +} + +/* +Scenario: Targeted push delivers only to the named consumer +Given two consumers connected to the same ws channel +When a push request carries one consumer's connectionId +Then only that consumer receives the message + +Related spec scenarios: RS.AMG.5 +*/ +func TestPushEndpoint_TargetedWS(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.ReadMessage() // consume snapshot + + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn2.Close() //nolint:errcheck + _, _, _ = conn2.ReadMessage() // consume snapshot + + // 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)) + 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`) + + // The other consumer must not receive the targeted message. + _ = conn2.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + _, _, err2 := conn2.ReadMessage() + require.Error(t, err2) +} + +/* +Scenario: Targeted push reaches an open SignalR stream only +Given two SignalR consumers where one holds an open stream on a hub channel +When a push carries the streaming consumer's connectionId +Then only that consumer's stream receives the payload + +Related spec scenarios: RS.AMG.5, RS.SHR.18 +*/ +func TestPushEndpoint_TargetedSignalR(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" + + connA, _, err := websocket.DefaultDialer.Dial(hubURL, nil) + require.NoError(t, err) + defer connA.Close() //nolint:errcheck + require.NoError(t, connA.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + _, _, err = connA.ReadMessage() + require.NoError(t, err) // handshake reply + require.NoError(t, connA.WriteMessage(websocket.TextMessage, []byte(`{"type":4,"invocationId":"s1","target":"priceFeed"}`+"\x1e"))) + _, _, err = connA.ReadMessage() + require.NoError(t, err) // snapshot + + connB, _, err := websocket.DefaultDialer.Dial(hubURL, nil) + require.NoError(t, err) + defer connB.Close() //nolint:errcheck + require.NoError(t, connB.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + _, _, err = connB.ReadMessage() + require.NoError(t, err) // handshake reply + + hub := srv.hubMgr.hubs[0] + hub.mu.Lock() + var streamConnID string + for id, sc := range hub.conns { + if len(sc.streams) > 0 { + streamConnID = id + break + } + } + hub.mu.Unlock() + 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)) + require.NoError(t, err) + defer post.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, post.StatusCode) + + _ = connA.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := connA.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(msg)[0], &env)) + assert.Equal(t, signalRTypeStreamItem, env.Type) + assert.Equal(t, "s1", env.InvocationID) + + // The second connection has no open stream and must not receive it. + _ = connB.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + _, _, errB := connB.ReadMessage() + require.Error(t, errB) +} + +/* +Scenario: Abrupt disconnect aborts without a close frame +Given an active consumer and a disconnect request with abrupt=true +When the disconnect endpoint is invoked +Then the client read fails without a normal close frame + +Related spec scenarios: RS.AMG.17 +*/ +func TestDisconnectEndpoint_Abrupt(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" + 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") + 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 + `","abrupt":true}` + 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) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, readErr := conn.ReadMessage() + require.Error(t, readErr) + assert.False(t, websocket.IsCloseError(readErr, websocket.CloseNormalClosure), + "abrupt drop must not surface as a normal close frame") +} diff --git a/internal/server/management_async_test.go b/internal/server/management_async_test.go new file mode 100644 index 0000000..0a06aab --- /dev/null +++ b/internal/server/management_async_test.go @@ -0,0 +1,190 @@ +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 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 +When the push endpoint is invoked +Then the consumer receives the message + +Related spec scenarios: RS.AMG.1, RS.AMG.2, RS.AMG.6 +*/ +func TestPushEndpoint_Immediate(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/alerts" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + // Consume the receive-operation snapshot emitted on connect. + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, err = conn.ReadMessage() + require.NoError(t, err) + + body := `{"channel":"/alerts","payload":{"msg":"hello"}}` + 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.JSONEq(t, `{"msg":"hello"}`, string(msg)) +} + +/* +Scenario: Pushing with a negative delay is rejected +Given a management push request with a negative delay +When the push endpoint is invoked +Then the server responds with HTTP 400 + +Related spec scenarios: RS.AMG.3 +*/ +func TestPushEndpoint_NegativeDelay(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() + + body := `{"channel":"/alerts","payload":{},"delay":-10}` + 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.StatusBadRequest, resp.StatusCode) +} + +/* +Scenario: Pushing to a channel with no consumers is accepted +Given a management push request to a valid channel with no consumers +When the push endpoint is invoked +Then the server accepts the request without error + +Related spec scenarios: RS.AMG.4 +*/ +func TestPushEndpoint_NoConsumers(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() + + body := `{"channel":"/alerts","payload":{"msg":"none"}}` + 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) +} + +/* +Scenario: Unknown consumer reference returns 404 +Given a management push request targeting an unknown connection id +When the push endpoint is invoked +Then the server responds with HTTP 404 + +Related spec scenarios: RS.AMG.7 +*/ +func TestPushEndpoint_UnknownConsumer(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() + + body := `{"channel":"/alerts","payload":{},"connectionId":"missing"}` + 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.StatusNotFound, resp.StatusCode) +} + +/* +Scenario: Listing connected consumers per channel +Given a connected ws consumer +When the consumers endpoint is queried +Then the consumer list includes the connection id + +Related spec scenarios: RS.AMG.8, RS.AMG.9 +*/ +func TestConsumersEndpoint(t *testing.T) { + t.Parallel() + + srv := newPushServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() + + 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) +} diff --git a/internal/server/pathparams.go b/internal/server/pathparams.go new file mode 100644 index 0000000..f2eb515 --- /dev/null +++ b/internal/server/pathparams.go @@ -0,0 +1,28 @@ +package server + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +// addressParams returns the chi route URL parameters captured when the route +// pattern contains {param} placeholders (the channel address params). The chi +// route context is populated only when the request flowed through the router; +// direct handler invocations in tests get an empty map. +func addressParams(r *http.Request) map[string]string { + params := make(map[string]string) + if r == nil || r.URL == nil { + return params + } + ctx := chi.RouteContext(r.Context()) + if ctx == nil { + return params + } + for i, key := range ctx.URLParams.Keys { + if i < len(ctx.URLParams.Values) { + params[key] = ctx.URLParams.Values[i] + } + } + return params +} diff --git a/internal/server/protocol.go b/internal/server/protocol.go new file mode 100644 index 0000000..f518bfa --- /dev/null +++ b/internal/server/protocol.go @@ -0,0 +1,70 @@ +package server + +import ( + "context" + "net/http" +) + +// InboundMessage is a message received from a client on an AsyncAPI channel. +type InboundMessage struct { + // Payload is the raw message bytes received from the client. + Payload []byte + // Headers carries request headers (HTTP) or message headers (ws). + Headers map[string]string + // PathParams carries resolved channel address parameters. + PathParams map[string]string + // ConnectionID identifies the ws consumer connection. + ConnectionID string +} + +// MessageHandler renders an AsyncAPI message example for a route through the +// shared selection pipeline. Implementations return the outbound payload bytes +// to write back to the client, or nil to emit nothing. +type MessageHandler interface { + HandleMessage(ctx context.Context, in InboundMessage) ([]byte, error) +} + +// MessageHandlerFunc adapts a function to the MessageHandler interface. +type MessageHandlerFunc func(ctx context.Context, in InboundMessage) ([]byte, error) + +// HandleMessage implements MessageHandler. +func (f MessageHandlerFunc) HandleMessage(ctx context.Context, in InboundMessage) ([]byte, error) { + return f(ctx, in) +} + +// ProtocolAdapter serves AsyncAPI routes for one protocol binding (design D4). +// The adapter owns protocol-specific transport concerns; message rendering is +// delegated to the shared MessageHandler pipeline. +type ProtocolAdapter interface { + // Protocol returns the binding name this adapter serves (e.g. "http", "ws"). + Protocol() string + // Handler builds the HTTP handler that serves an AsyncAPI route mapping. + Handler(mapping *RouteMapping, handler MessageHandler) http.HandlerFunc +} + +// defaultProtocolAdapters is the set of adapters seeded for a new server. +func defaultProtocolAdapters() map[string]ProtocolAdapter { + return map[string]ProtocolAdapter{ + asyncHTTPProtocol: &httpProtocolAdapter{}, + asyncWSProtocol: newWSProtocolAdapter(), + } +} + +// wsRegistry returns the ws protocol adapter's connection registry, or nil +// when the ws adapter is not registered. +func (s *Server) wsRegistry() *connectionRegistry { + if a, ok := s.protocolAdapters[asyncWSProtocol].(*wsProtocolAdapter); ok && a != nil { + return a.registry + } + return nil +} + +// adapterForProtocol returns the registered protocol adapter for a binding. +func (s *Server) adapterForProtocol(protocol string) ProtocolAdapter { + return s.protocolAdapters[protocol] +} + +const ( + asyncHTTPProtocol = "http" + asyncWSProtocol = "ws" +) diff --git a/internal/server/protocol_test.go b/internal/server/protocol_test.go new file mode 100644 index 0000000..529d49f --- /dev/null +++ b/internal/server/protocol_test.go @@ -0,0 +1,106 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestRecorder() *httptest.ResponseRecorder { + return httptest.NewRecorder() +} + +func newTestRequest() *http.Request { + return httptest.NewRequest("GET", "/", nil) +} + +/* +Scenario: Registering protocol adapters keyed by protocol +Given a server with default dependencies +When the adapter registry is queried for the http and ws protocols +Then both adapters are registered and expose their protocol name + +Related spec scenarios: RS.ASP.1, RS.ASP.2, RS.ASP.4 +*/ +func TestServer_ProtocolAdaptersRegistered(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + + httpAdapter := srv.adapterForProtocol("http") + require.NotNil(t, httpAdapter) + assert.Equal(t, defaultProtocol("http"), httpAdapter.Protocol()) + + wsAdapter := srv.adapterForProtocol("ws") + require.NotNil(t, wsAdapter) + assert.Equal(t, defaultProtocol("ws"), wsAdapter.Protocol()) +} + +func defaultProtocol(p string) string { return p } + +/* +Scenario: No adapter for an unsupported protocol +Given an AsyncAPI route with a protocol binding no adapter serves +When the adapter registry is queried +Then no adapter is returned + +Related spec scenarios: RS.ASP.4 +*/ +func TestServer_NoAdapterForUnsupportedProtocol(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + + assert.Nil(t, srv.adapterForProtocol("amqp")) + assert.Nil(t, srv.adapterForProtocol("kafka")) +} + +/* +Scenario: ProtocolAdapter contract via a stub adapter +Given a stub ProtocolAdapter registered under a protocol +When a route handler is built +Then the adapter is invoked with the message handler and returns an HTTP handler + +Related spec scenarios: RS.ASP.1, RS.ASP.2 +*/ +func TestProtocolAdapter_HandlerBuilderContract(t *testing.T) { + t.Parallel() + + var gotHandler MessageHandler + adapter := &stubAdapter{protocol: "stub", onHandler: func(h MessageHandler) { + gotHandler = h + }} + + handler := MessageHandlerFunc(func(ctx context.Context, in InboundMessage) ([]byte, error) { + return []byte("ok"), nil + }) + built := adapter.Handler(&RouteMapping{}, handler) + require.NotNil(t, built) + + rec := newTestRecorder() + built(rec, newTestRequest()) + assert.Equal(t, http.StatusOK, rec.Code) + + assert.NotNil(t, gotHandler) +} + +type stubAdapter struct { + protocol string + onHandler func(h MessageHandler) +} + +func (a *stubAdapter) Protocol() string { return a.protocol } + +func (a *stubAdapter) Handler(_ *RouteMapping, h MessageHandler) http.HandlerFunc { + if a.onHandler != nil { + a.onHandler(h) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("served")) + }) +} diff --git a/internal/server/registry.go b/internal/server/registry.go new file mode 100644 index 0000000..cf02444 --- /dev/null +++ b/internal/server/registry.go @@ -0,0 +1,199 @@ +package server + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/runtime" +) + +// dynamicExample is a management-injected mock response with optional +// conditions, one-time use and TTL (RS.MAPI.2-9, RS.MSC.25-32). +type dynamicExample struct { + onceID string + addedAt time.Time + ttl int + once bool + conditions map[string]any + response struct { + code int + headers map[string]string + body any + } +} + +// isExpired reports whether the example's TTL has elapsed. +// Examples without a TTL (ttl <= 0) never expire. +func isExpired(ex dynamicExample) bool { + if ex.ttl <= 0 { + return false + } + return !ex.addedAt.Add(time.Duration(ex.ttl) * time.Second).After(time.Now()) +} + +const ttlSweepInterval = time.Second + +// exampleRegistry owns the x-mock-once marker set, the management-injected +// dynamic example store and the TTL sweep goroutine (RS.MSC.48-49). +type exampleRegistry struct { + onceMu sync.RWMutex + onceExamples map[string]bool + dyMu sync.RWMutex + dynamicExamples map[string][]dynamicExample + sweepCtx context.Context + sweepCancel context.CancelFunc + verbose bool +} + +func newExampleRegistry(verbose bool) *exampleRegistry { + return &exampleRegistry{ + onceExamples: make(map[string]bool), + dynamicExamples: make(map[string][]dynamicExample), + verbose: verbose, + } +} + +// markOnceUsed marks an example as used (for x-mock-once). +func (r *exampleRegistry) markOnceUsed(id string) { + r.onceMu.Lock() + defer r.onceMu.Unlock() + r.onceExamples[id] = true +} + +// isOnceUsed checks if an example has been used. +func (r *exampleRegistry) isOnceUsed(id string) bool { + r.onceMu.RLock() + defer r.onceMu.RUnlock() + return r.onceExamples[id] +} + +// addDynamic stores a management-injected dynamic example under a route key. +func (r *exampleRegistry) addDynamic(key string, ex dynamicExample) { + r.dyMu.Lock() + defer r.dyMu.Unlock() + r.dynamicExamples[key] = append(r.dynamicExamples[key], ex) +} + +// 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. +func (r *exampleRegistry) selectDynamic(key string, eval runtime.Evaluator) (*dynamicExample, string) { + if r.verbose { + slog.Debug("selectDynamicExample", "key", key, "numExamples", len(r.dynamicExamples[key])) + } + r.dyMu.RLock() + examples := r.dynamicExamples[key] + r.dyMu.RUnlock() + for idx, ex := range examples { + if r.verbose { + slog.Debug("selectDynamicExample: checking example", + "idx", idx, + "once", ex.once, + "conditions", len(ex.conditions)) + } + // Check once flag + if ex.once { + if r.isOnceUsed(ex.onceID) { + if r.verbose { + slog.Debug("selectDynamicExample: example already used", "onceID", ex.onceID) + } + continue + } + } + // Check TTL expiry + if isExpired(ex) { + if r.verbose { + slog.Debug("selectDynamicExample: example expired", + "idx", idx, + "ttl", ex.ttl, + "addedAt", ex.addedAt) + } + continue + } + // Evaluate conditions + if len(ex.conditions) > 0 { + // Convert to ParamsMatch + pm := extensions.ParamsMatch(ex.conditions) + matched, err := extensions.EvaluateParamsMatch(pm, eval) + if r.verbose { + slog.Debug("selectDynamicExample: condition evaluation result", + "matched", matched, "err", err, "conditions", ex.conditions) + } + if err != nil || !matched { + continue + } + } else if r.verbose { + slog.Debug("selectDynamicExample: no conditions, matching") + } + // Matched + if ex.once { + r.markOnceUsed(ex.onceID) + } + if r.verbose { + slog.Debug("selectDynamicExample: returning matched example", "idx", idx) + } + return &ex, fmt.Sprintf("dynamic:%d", idx) + } + if r.verbose { + slog.Debug("selectDynamicExample: no matching examples found", "key", key) + } + return nil, "" +} + +// sweepExpired removes expired dynamic examples and their once markers. +func (r *exampleRegistry) sweepExpired() { + r.dyMu.Lock() + defer r.dyMu.Unlock() + + for key, examples := range r.dynamicExamples { + kept := make([]dynamicExample, 0, len(examples)) + for idx, ex := range examples { + if !isExpired(ex) { + kept = append(kept, ex) + continue + } + r.onceMu.Lock() + delete(r.onceExamples, ex.onceID) + r.onceMu.Unlock() + if r.verbose { + slog.Debug("Removed expired dynamic example", "key", key, "idx", idx, "ttl", ex.ttl) + } + } + if len(kept) == 0 { + delete(r.dynamicExamples, key) + } else { + r.dynamicExamples[key] = kept + } + } +} + +// startSweep launches the background goroutine that periodically removes +// expired dynamic examples from memory. +func (r *exampleRegistry) startSweep() { + ctx, cancel := context.WithCancel(context.Background()) + r.sweepCtx = ctx + r.sweepCancel = cancel + go func() { + ticker := time.NewTicker(ttlSweepInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.sweepExpired() + } + } + }() +} + +// stopSweep cancels the TTL sweep goroutine. +func (r *exampleRegistry) stopSweep() { + if r.sweepCancel != nil { + r.sweepCancel() + } +} diff --git a/internal/server/scheduler.go b/internal/server/scheduler.go new file mode 100644 index 0000000..9fd0304 --- /dev/null +++ b/internal/server/scheduler.go @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000..78eb0d1 --- /dev/null +++ b/internal/server/send_events.go @@ -0,0 +1,64 @@ +package server + +import ( + "fmt" +) + +// xSendEventsKey is the extension key carrying event subscriptions on an +// AsyncAPI message example. +const xSendEventsKey = "x-send-events" + +// SendEvent is a single x-send-events subscription entry. +type SendEvent struct { + // On is a named event or a built-in trigger (connect, receive, cron). + On string + // Wait is the optional delay/interval in milliseconds for built-ins. + Wait int +} + +// parseSendEvents parses the x-send-events extension on an AsyncAPI message +// example. Each entry is {on: , wait?: ms} or a bare built-in string +// (RS.EVT.7-11). +func parseSendEvents(ext map[string]any) ([]SendEvent, error) { + raw, ok := ext[xSendEventsKey] + if !ok { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("x-send-events must be a list") + } + var out []SendEvent + for _, item := range items { + switch v := item.(type) { + case string: + out = append(out, SendEvent{On: v}) + case map[string]any: + on, ok := v["on"].(string) + if !ok || on == "" { + return nil, fmt.Errorf("x-send-events entry must have an 'on' field") + } + ev := SendEvent{On: on} + if wait, ok := asInt(v["wait"]); ok { + ev.Wait = wait + } + out = append(out, ev) + default: + return nil, fmt.Errorf("x-send-events entry must be a string or an object") + } + } + 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/send_events_test.go b/internal/server/send_events_test.go new file mode 100644 index 0000000..f63e012 --- /dev/null +++ b/internal/server/send_events_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Parsing x-send-events named-event subscriptions +Given an AsyncAPI message example with x-send-events entries +When parseSendEvents is called +Then each named subscription is parsed with its wait + +Related spec scenarios: RS.EVT.7, RS.EVT.9, RS.EVT.10 +*/ +func TestParseSendEvents_Named(t *testing.T) { + t.Parallel() + + ext := map[string]any{ + "x-send-events": []any{ + map[string]any{"on": "orderCreated", "wait": 50}, + map[string]any{"on": "connect"}, + }, + } + + events, err := parseSendEvents(ext) + require.NoError(t, err) + require.Len(t, events, 2) + assert.Equal(t, "orderCreated", events[0].On) + assert.Equal(t, 50, events[0].Wait) + assert.Equal(t, "connect", events[1].On) + assert.Equal(t, 0, events[1].Wait) +} + +/* +Scenario: Parsing x-send-events built-in receive subscription +Given a message example with a flat receive entry +When parseSendEvents is called +Then a receive subscription is parsed + +Related spec scenarios: RS.EVT.11 +*/ +func TestParseSendEvents_FlatReceive(t *testing.T) { + t.Parallel() + + ext := map[string]any{ + "x-send-events": []any{"receive"}, + } + + events, err := parseSendEvents(ext) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "receive", events[0].On) +} + +/* +Scenario: Parsing x-send-events cron built-in +Given a message example with an object cron entry +When parseSendEvents is called +Then the cron subscription carries its wait interval + +Related spec scenarios: RS.EVT.10 +*/ +func TestParseSendEvents_Cron(t *testing.T) { + t.Parallel() + + ext := map[string]any{ + "x-send-events": []any{ + map[string]any{"on": "cron", "wait": 1000}, + }, + } + + events, err := parseSendEvents(ext) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "cron", events[0].On) + assert.Equal(t, 1000, events[0].Wait) +} + +/* +Scenario: Handling an invalid x-send-events entry +Given a message example with a malformed x-send-events entry +When parseSendEvents is called +Then it returns an error + +Related spec scenarios: RS.EVT.7 +*/ +func TestParseSendEvents_Invalid(t *testing.T) { + t.Parallel() + + ext := map[string]any{ + "x-send-events": []any{42}, + } + _, err := parseSendEvents(ext) + require.Error(t, err) +} diff --git a/internal/server/server.go b/internal/server/server.go index b9b984a..3b174c2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,14 +1,15 @@ package server import ( + "bufio" "bytes" "context" "encoding/json" "fmt" "io" "log/slog" + "net" "net/http" - "os" "strconv" "strings" "sync" @@ -19,6 +20,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/history" "github.com/mamonth/oasmock/internal/loader" "github.com/mamonth/oasmock/internal/runtime" @@ -68,6 +70,22 @@ func (r *responseRecorder) Write(b []byte) (int, error) { return r.ResponseWriter.Write(b) } +// Hijack preserves the underlying connection so WebSocket upgrades work even +// when request history recording is active. +func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := r.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking") +} + +// Flush forwards flush calls to the underlying writer when supported. +func (r *responseRecorder) Flush() { + if f, ok := r.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + // Config holds server configuration. type Config struct { Port int @@ -80,26 +98,27 @@ type Config struct { // Server represents the mock HTTP server. type Server struct { - config Config - router *chi.Mux - httpServer *http.Server - httpMu sync.Mutex - shutdownOnce sync.Once - shutdownResult error - mappings []RouteMapping - stateStore StateStore - historyStore HistoryStore - routeMap map[string]*RouteMapping - onceExamples map[string]bool - onceMu sync.RWMutex - dynamicExamples map[string][]dynamicExample - dyMu sync.RWMutex - sweepCtx context.Context - sweepCancel context.CancelFunc - deps Dependencies - rpcHandler *RpcHandler - rpcMappings []*loader.RpcRouteMapping - gatewayPath string + config Config + router *chi.Mux + httpServer *http.Server + httpMu sync.Mutex + shutdownOnce sync.Once + shutdownResult error + mappings []RouteMapping + stateStore StateStore + historyStore HistoryStore + routeMap map[string]*RouteMapping + registry *exampleRegistry + engine *exampleEngine + deps Dependencies + rpcHandler *RpcHandler + rpcMappings []*loader.RpcRouteMapping + gatewayPath string + protocolAdapters map[string]ProtocolAdapter + routerSetupErr error + hubMgr *hubManager + eventBus *eventBus + scheduler *pushScheduler } // New creates a new mock server with the given configuration and loaded schemas. @@ -109,9 +128,14 @@ func New(config Config, schemas []loader.SchemaInfo) (*Server, error) { for i, schema := range schemas { serverSchemas[i] = SchemaInfo{ Spec: schema.Spec, + Kind: schema.Kind, + Async: schema.Async, Prefix: schema.Prefix, } + if schema.Kind == loader.KindAsyncAPI { + continue + } if rpcConfig == nil { var err error rpcConfig, err = loader.ParseRpcConfig(schema.Spec) @@ -167,17 +191,23 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, config.HistorySize = DefaultHistorySize } + registry := newExampleRegistry(config.Verbose) s := &Server{ - config: config, - mappings: mappings, - stateStore: deps.StateStore, - historyStore: deps.HistoryStore, - deps: deps, - routeMap: make(map[string]*RouteMapping), - onceExamples: make(map[string]bool), - dynamicExamples: make(map[string][]dynamicExample), - rpcMappings: rpcMappings, - } + config: config, + mappings: mappings, + stateStore: deps.StateStore, + historyStore: deps.HistoryStore, + deps: deps, + routeMap: make(map[string]*RouteMapping), + registry: registry, + engine: newExampleEngine(config, deps, registry), + rpcMappings: rpcMappings, + protocolAdapters: defaultProtocolAdapters(), + } + s.hubMgr = newHubManager(s.engine, s.protocolAdapters[asyncWSProtocol].(*wsProtocolAdapter), schemas) + 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) }) if rpcConfig != nil { proto, err := newRpcProtocol(rpcConfig) @@ -211,8 +241,11 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, s.setupRouter() - s.sweepCtx, s.sweepCancel = context.WithCancel(context.Background()) - s.startTTLSweep() + if err := s.routerSetupErr; err != nil { + return nil, err + } + + s.registry.startSweep() return s, nil } @@ -256,6 +289,9 @@ func (s *Server) setupRouter() { // Register mock routes s.registerMockRoutes(r) + // Register SignalR hubs (negotiate + upgrade) + s.registerSignalRHubs(r) + // Register RPC gateway route if configured if s.rpcHandler != nil { r.Post(s.gatewayPath, s.rpcHandler.ServeHTTP) @@ -288,21 +324,49 @@ func (s *Server) registerMockRoutes(r chi.Router) { key := routeKey(mapping.Method, mapping.ChiPattern) s.routeMap[key] = mapping + handler, err := s.buildRouteHandler(mapping) + if err != nil { + s.routerSetupErr = err + slog.Error("Failed to register route", "pattern", mapping.ChiPattern, "err", err) + return + } + if s.config.Verbose { slog.Info("XXXRegistering route", "method", mapping.Method, "chiPattern", mapping.ChiPattern, "fullPath", mapping.Path, "prefix", mapping.Prefix, "pattern", mapping.Pattern, "responses", mapping.Responses != nil) } - r.Method(mapping.Method, mapping.ChiPattern, s.makeMockHandler(mapping)) + r.Method(mapping.Method, mapping.ChiPattern, handler) if s.config.Verbose { slog.Debug("Registered route", "method", mapping.Method, "pattern", mapping.ChiPattern) } } +} +// buildRouteHandler dispatches AsyncAPI routes to their protocol adapter and +// falls back to the OpenAPI pipeline for regular routes (design D4). +func (s *Server) buildRouteHandler(mapping *RouteMapping) (http.HandlerFunc, error) { + if mapping.Protocol == "" { + return s.makeMockHandler(mapping), nil + } + adapter := s.adapterForProtocol(mapping.Protocol) + if adapter == nil { + return nil, fmt.Errorf("channel protocol %q is not supported (supported: http, ws)", mapping.Protocol) + } + if s.config.Verbose { + slog.Debug("Using protocol adapter", "protocol", mapping.Protocol, "pattern", mapping.ChiPattern) + } + return adapter.Handler(mapping, s.asyncMessageHandler(mapping)), nil } func (s *Server) registerManagementRoutes(r chi.Router) { r.Post("/_mock/examples", s.handleAddExample) r.Get("/_mock/requests", s.handleGetRequests) + r.Post("/_mock/events/fire", s.handleFireEvent) + 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) } func (s *Server) newRequestSource(r *http.Request, pathParams map[string]string) *runtime.RequestSource { @@ -345,21 +409,11 @@ func (s *Server) newRequestSource(r *http.Request, pathParams map[string]string) } func (s *Server) newStateSource(prefix string) *runtime.StateSource { - data := s.stateStore.GetNamespace(prefix) - if data == nil { - data = make(map[string]any) - } - return &runtime.StateSource{Data: data} + return s.engine.NewStateSource(prefix) } func (s *Server) newEnvSource() *runtime.EnvSource { - env := make(map[string]string) - for _, e := range os.Environ() { - if key, val, found := strings.Cut(e, "="); found { - env[key] = val - } - } - return &runtime.EnvSource{Env: env} + return s.engine.NewEnvSource() } func (s *Server) makeMockHandler(mapping *RouteMapping) http.HandlerFunc { @@ -456,9 +510,38 @@ func (s *Server) selectAndGenerateResponse(r *http.Request, mapping *RouteMappin return nil, nil, "", "", genErr } + // Fire x-event-trigger events after the response is produced (RS.EVT.1-4). + if example != nil { + s.fireExampleTriggers(example, mapping.Prefix) + } + return body, headers, statusCode, mediaType, nil } +// fireExampleTriggers dispatches the x-event-trigger events declared on an +// OpenAPI response example against the schema's event broker (design D8). +func (s *Server) fireExampleTriggers(example *openapi3.Example, prefix string) { + if s.eventBus == nil || example == nil { + return + } + triggers, ok := extensions.ExtractEventTriggers(example) + if !ok { + return + } + for _, trigger := range triggers { + delay := triggerDelay(trigger.Delay) + s.eventBus.fire(trigger.Name, trigger.Payload, prefix, trigger.Global, delay) + } +} + +// triggerDelay maps a trigger delay (ms) to a schedule. +func triggerDelay(ms int) *delaySchedule { + if ms <= 0 { + return nil + } + return &delaySchedule{ms: ms} +} + func (s *Server) newRpcRequestSource(r *http.Request, pathParams map[string]string, callBody any) *runtime.RequestSource { query := r.URL.Query() queryMap := make(map[string][]string) @@ -597,9 +680,8 @@ func (s *Server) Start() error { // Shutdown gracefully shuts down the server. It is idempotent: subsequent // calls are no-ops that return the result of the first shutdown. func (s *Server) Shutdown(ctx context.Context) error { - if s.sweepCancel != nil { - s.sweepCancel() - } + s.registry.stopSweep() + s.shutdownSchedules() s.httpMu.Lock() hs := s.httpServer s.httpMu.Unlock() diff --git a/internal/server/server_eval.go b/internal/server/server_eval.go index b365367..affe111 100644 --- a/internal/server/server_eval.go +++ b/internal/server/server_eval.go @@ -1,139 +1,20 @@ package server import ( - "encoding/json" - "strings" - "github.com/mamonth/oasmock/internal/runtime" ) +// Runtime-expression evaluation lives in exampleEngine; these forwarders keep +// the HTTP pipeline and tests working through Server. + func (s *Server) replaceEmbeddedExpressions(str string, eval runtime.Evaluator) (string, error) { - var result strings.Builder - i := 0 - for i < len(str) { - // Find start of expression "{$" - start := strings.Index(str[i:], "{$") - if start == -1 { - result.WriteString(str[i:]) - break - } - start += i - // Write literal part before expression - result.WriteString(str[i:start]) - // Find matching '}' - braceDepth := 1 - j := start + 2 - for j < len(str) && braceDepth > 0 { - ch := str[j] - if ch == '{' && j+1 < len(str) && str[j+1] == '$' { - braceDepth++ - j += 2 - continue - } - if ch == '}' { - braceDepth-- - if braceDepth == 0 { - break - } - } - j++ - } - if braceDepth != 0 { - // Unmatched braces, treat as literal - result.WriteString(str[start:]) - break - } - // j now points at the closing '}' - end := j - expr := str[start : end+1] - // Evaluate expression - value, err := eval.Evaluate(expr) - if err != nil { - // If evaluation fails, keep the original expression - result.WriteString(expr) - } else { - // Convert value to string - switch v := value.(type) { - case string: - result.WriteString(v) - default: - b, err := json.Marshal(v) - if err != nil { - result.WriteString(expr) - } else { - result.Write(b) - } - } - } - i = end + 1 - } - return result.String(), nil + return s.engine.replaceEmbeddedExpressions(str, eval) } -// evaluateExpressionInString evaluates runtime expressions embedded in a string. -// Example: "state-{$request.query.id}" -> "state-123" func (s *Server) evaluateExpressionInString(str string, eval runtime.Evaluator) (string, error) { - // First, check if the whole string is a runtime expression (optimization) - if strings.HasPrefix(str, "{$") && strings.HasSuffix(str, "}") && !strings.Contains(str[2:], "{$") { - result, err := eval.Evaluate(str) - if err != nil { - return "", err - } - // Convert result to string - switch v := result.(type) { - case string: - return v, nil - default: - b, err := json.Marshal(v) - if err != nil { - return "", err - } - return string(b), nil - } - } - // Otherwise replace embedded expressions - return s.replaceEmbeddedExpressions(str, eval) + return s.engine.evaluateExpressionInString(str, eval) } -// evaluateValue evaluates a value that could be a runtime expression or literal. func (s *Server) evaluateValue(val any, eval runtime.Evaluator) (any, error) { - // Handle strings: they may contain embedded runtime expressions - if str, ok := val.(string); ok { - // Check if the whole string is a single runtime expression (no other characters) - if strings.HasPrefix(str, "{$") && strings.HasSuffix(str, "}") && strings.Count(str, "{$") == 1 { - return eval.Evaluate(str) - } - // Otherwise replace embedded expressions - return s.replaceEmbeddedExpressions(str, eval) - } - // Recursively handle maps and slices - switch v := val.(type) { - case map[string]any: - result := make(map[string]any) - for k, item := range v { - resolvedK, err := s.evaluateExpressionInString(k, eval) - if err != nil { - return nil, err - } - resolvedItem, err := s.evaluateValue(item, eval) - if err != nil { - return nil, err - } - result[resolvedK] = resolvedItem - } - return result, nil - case []any: - result := make([]any, len(v)) - for i, item := range v { - resolvedItem, err := s.evaluateValue(item, eval) - if err != nil { - return nil, err - } - result[i] = resolvedItem - } - return result, nil - default: - // Literal value - return val, nil - } + return s.engine.evaluateValue(val, eval) } diff --git a/internal/server/server_example.go b/internal/server/server_example.go index d15dd89..37da2a8 100644 --- a/internal/server/server_example.go +++ b/internal/server/server_example.go @@ -1,449 +1,68 @@ package server import ( - "cmp" - "encoding/json" - "fmt" - "log/slog" - "maps" - "slices" - "strconv" - "strings" - "time" - "github.com/getkin/kin-openapi/openapi3" - "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" "github.com/mamonth/oasmock/internal/runtime" ) -type dynamicExample struct { - onceID string - addedAt time.Time - ttl int - once bool - conditions map[string]any - response struct { - code int - headers map[string]string - body any - } -} - -// isExpired reports whether the example's TTL has elapsed. -// Examples without a TTL (ttl <= 0) never expire. -func isExpired(ex dynamicExample) bool { - if ex.ttl <= 0 { - return false - } - return !ex.addedAt.Add(time.Duration(ex.ttl) * time.Second).After(time.Now()) -} - -const ttlSweepInterval = time.Second +// The once/dynamic example stores and the TTL sweep live in exampleRegistry; +// the selection/templating/state core lives in exampleEngine. The Server +// methods below are thin forwarders preserved for the HTTP pipeline and the +// test surface. -// startTTLSweep launches the background goroutine that periodically removes -// expired dynamic examples from memory. -func (s *Server) startTTLSweep() { - go func() { - ticker := time.NewTicker(ttlSweepInterval) - defer ticker.Stop() - for { - select { - case <-s.sweepCtx.Done(): - return - case <-ticker.C: - s.sweepExpiredExamples() - } - } - }() -} +func (s *Server) startTTLSweep() { s.registry.startSweep() } -// sweepExpiredExamples removes expired dynamic examples from storage and -// cleans up their onceExamples entries. -func (s *Server) sweepExpiredExamples() { - s.dyMu.Lock() - defer s.dyMu.Unlock() +func (s *Server) sweepExpiredExamples() { s.registry.sweepExpired() } - for key, examples := range s.dynamicExamples { - kept := make([]dynamicExample, 0, len(examples)) - for idx, ex := range examples { - if !isExpired(ex) { - kept = append(kept, ex) - continue - } - s.onceMu.Lock() - delete(s.onceExamples, ex.onceID) - s.onceMu.Unlock() - if s.config.Verbose { - slog.Debug("Removed expired dynamic example", "key", key, "idx", idx, "ttl", ex.ttl) - } - } - if len(kept) == 0 { - delete(s.dynamicExamples, key) - } else { - s.dynamicExamples[key] = kept - } - } +func (s *Server) selectDynamicExample(mapping *RouteMapping, eval runtime.Evaluator) (*dynamicExample, string) { + return s.registry.selectDynamic(routeKey(mapping.Method, mapping.ChiPattern), eval) } func (s *Server) selectResponse(mapping *RouteMapping, eval runtime.Evaluator) (string, *openapi3.Response) { - if mapping.Responses == nil { - return "", nil - } - respMap := mapping.Responses.Map() - if len(respMap) == 0 { - return "", nil - } - // Collect and sort keys for deterministic selection - keys := make([]string, 0, len(respMap)) - for code := range respMap { - keys = append(keys, code) - } - // Sort keys with custom order: numeric status codes ascending, "default" last - slices.SortFunc(keys, func(a, b string) int { - if a == "default" && b == "default" { - return 0 - } - if a == "default" { - return 1 // default after numeric codes - } - if b == "default" { - return -1 - } - aInt, errA := strconv.Atoi(a) - bInt, errB := strconv.Atoi(b) - if errA != nil && errB != nil { - return strings.Compare(a, b) // fallback lexical - } - if errA != nil { - return 1 // non-numeric after numeric - } - if errB != nil { - return -1 - } - return cmp.Compare(aInt, bInt) - }) - // Iterate sorted keys - for _, code := range keys { - resp := respMap[code] - if resp != nil && resp.Value != nil { - return code, resp.Value - } - } - return "", nil + return s.engine.selectResponse(mapping, eval) } func (s *Server) selectMediaType(response *openapi3.Response) (string, *openapi3.MediaType, error) { - if response.Content == nil { - return "", nil, fmt.Errorf("no media type defined for response") - } - // Collect keys for deterministic selection - keys := make([]string, 0, len(response.Content)) - for mt := range response.Content { - keys = append(keys, mt) - } - if len(keys) == 0 { - return "", nil, fmt.Errorf("no media type defined for response") - } - slices.Sort(keys) - // Select first media type after sorting - mt := keys[0] - obj := response.Content[mt] - return mt, obj, nil + return s.engine.selectMediaType(response) } func (s *Server) generateResponse(example *openapi3.Example, dynExample *dynamicExample, eval runtime.Evaluator, currentStatusCode string) (body []byte, headers map[string]string, statusCode string, err error) { - if example != nil { - body, err = s.evaluateExample(example, eval) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to evaluate example: %w", err) - } - headers = s.evaluateHeaders(example, eval) - statusCode = currentStatusCode - return - } - // dynExample != nil - resolvedBody, err := s.evaluateValue(dynExample.response.body, eval) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to evaluate dynamic example body: %w", err) - } - body, err = json.Marshal(resolvedBody) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to marshal response body: %w", err) - } - headers = dynExample.response.headers - // Evaluate runtime expressions in header values - for k, v := range headers { - resolved, err := s.evaluateExpressionInString(v, eval) - if err == nil { - headers[k] = resolved - } - } - statusCode = strconv.Itoa(dynExample.response.code) - return + return s.engine.generateResponse(example, dynExample, eval, currentStatusCode) } func (s *Server) selectExample(mediaType *openapi3.MediaType, eval runtime.Evaluator, opID string) (*openapi3.Example, string) { - if mediaType.Examples == nil { - return nil, "" - } - keys := slices.Collect(maps.Keys(mediaType.Examples)) - slices.Sort(keys) - withParamsMatch, withoutParamsMatch := s.categorizeExamples(mediaType.Examples, keys, eval, opID) - - // First, try examples with params-match - for _, k := range keys { - ex, ok := withParamsMatch[k] - if !ok { - continue - } - pm, _ := extensions.ExtractParamsMatch(ex) - if s.config.Verbose { - slog.Debug("Example has x-mock-params-match", "example", k, "params", pm) - } - matched, err := extensions.EvaluateParamsMatch(pm, eval) - if err != nil { - if s.config.Verbose { - slog.Debug("Error evaluating params-match", "example", k, "error", err) - } - continue - } - if s.config.Verbose { - slog.Debug("Example params-match result", "example", k, "matched", matched) - } - if matched { - if extensions.ExtractOnce(ex) { - exampleID := opID + ":" + k - s.markOnceUsed(exampleID) - if s.config.Verbose { - slog.Debug("Marked example as used (x-mock-once)", "example", k) - } - } - return ex, k - } - } - - // No matched params-match examples, try those without params-match - for _, k := range keys { - ex, ok := withoutParamsMatch[k] - if !ok { - continue - } - if extensions.ExtractOnce(ex) { - exampleID := opID + ":" + k - s.markOnceUsed(exampleID) - if s.config.Verbose { - slog.Debug("Marked example as used (x-mock-once)", "example", k) - } - } - if s.config.Verbose { - slog.Debug("Selecting example (no params-match)", "example", k) - } - return ex, k - } - return nil, "" -} - -func (s *Server) selectDynamicExample(mapping *RouteMapping, eval runtime.Evaluator) (*dynamicExample, string) { - key := routeKey(mapping.Method, mapping.ChiPattern) - if s.config.Verbose { - slog.Debug("selectDynamicExample", "key", key, "numExamples", len(s.dynamicExamples[key])) - } - s.dyMu.RLock() - examples := s.dynamicExamples[key] - s.dyMu.RUnlock() - for idx, ex := range examples { - if s.config.Verbose { - slog.Debug("selectDynamicExample: checking example", - "idx", idx, - "once", ex.once, - "conditions", len(ex.conditions)) - } - // Check once flag - if ex.once { - if s.isOnceUsed(ex.onceID) { - if s.config.Verbose { - slog.Debug("selectDynamicExample: example already used", "onceID", ex.onceID) - } - continue - } - } - // Check TTL expiry - if isExpired(ex) { - if s.config.Verbose { - slog.Debug("selectDynamicExample: example expired", - "idx", idx, - "ttl", ex.ttl, - "addedAt", ex.addedAt) - } - continue - } - // Evaluate conditions - if len(ex.conditions) > 0 { - // Convert to ParamsMatch - pm := extensions.ParamsMatch(ex.conditions) - matched, err := extensions.EvaluateParamsMatch(pm, eval) - if s.config.Verbose { - slog.Debug("selectDynamicExample: condition evaluation result", - "matched", matched, "err", err, "conditions", ex.conditions) - } - if err != nil || !matched { - continue - } - } else if s.config.Verbose { - slog.Debug("selectDynamicExample: no conditions, matching") - } - // Matched - if ex.once { - s.markOnceUsed(ex.onceID) - } - if s.config.Verbose { - slog.Debug("selectDynamicExample: returning matched example", "idx", idx) - } - return &ex, fmt.Sprintf("dynamic:%d", idx) - } - if s.config.Verbose { - slog.Debug("selectDynamicExample: no matching examples found", "key", key) - } - return nil, "" + return s.engine.selectExample(mediaType, eval, opID) } func (s *Server) applyExtensions(example *openapi3.Example, eval runtime.Evaluator, prefix string) { - // Apply x-mock-set-state - if stateMap, ok := extensions.ExtractSetState(example); ok { - s.applySetState(stateMap, eval, prefix) - } - // x-mock-headers handled separately in evaluateHeaders - // x-mock-once is handled in selectExample + s.engine.applyExtensions(example, eval, prefix) } // markOnceUsed marks an example as used (for x-mock-once). -// The ID should uniquely identify the example (e.g., operation path + method + example key). -func (s *Server) markOnceUsed(id string) { - s.onceMu.Lock() - defer s.onceMu.Unlock() - s.onceExamples[id] = true -} +func (s *Server) markOnceUsed(id string) { s.registry.markOnceUsed(id) } // isOnceUsed checks if an example has been used. -func (s *Server) isOnceUsed(id string) bool { - s.onceMu.RLock() - defer s.onceMu.RUnlock() - return s.onceExamples[id] -} +func (s *Server) isOnceUsed(id string) bool { return s.registry.isOnceUsed(id) } func (s *Server) shouldSkipExample(ex *openapi3.Example, exampleKey, opID string) bool { - if extensions.ExtractSkip(ex) { - if s.config.Verbose { - slog.Debug("Example skipped via x-mock-skip", "example", exampleKey) - } - return true - } - if extensions.ExtractOnce(ex) { - exampleID := opID + ":" + exampleKey - if s.isOnceUsed(exampleID) { - if s.config.Verbose { - slog.Debug("Example skipped via x-mock-once (already used)", "example", exampleKey) - } - return true - } - } - return false + return s.engine.shouldSkipExample(ex, exampleKey, opID) } -// categorizeExamples iterates over keys and categorizes examples into those with and without params-match. -// Returns maps from key to example for each category. func (s *Server) categorizeExamples(examples openapi3.Examples, keys []string, eval runtime.Evaluator, opID string) (withParamsMatch, withoutParamsMatch map[string]*openapi3.Example) { - withParamsMatch = make(map[string]*openapi3.Example) - withoutParamsMatch = make(map[string]*openapi3.Example) - for _, k := range keys { - exRef := examples[k] - if exRef == nil || exRef.Value == nil { - continue - } - ex := exRef.Value - if s.shouldSkipExample(ex, k, opID) { - continue - } - if _, ok := extensions.ExtractParamsMatch(ex); ok { - withParamsMatch[k] = ex - } else { - withoutParamsMatch[k] = ex - } - } - return + return s.engine.categorizeExamples(examples, keys, eval, opID) } func (s *Server) evaluateExample(example *openapi3.Example, eval runtime.Evaluator) ([]byte, error) { - if example.Value == nil { - return []byte{}, nil - } - // Evaluate runtime expressions in the value - resolved, err := s.evaluateValue(example.Value, eval) - if err != nil { - return nil, err - } - // Convert to JSON - return json.Marshal(resolved) + return s.engine.evaluateExample(example, eval) } func (s *Server) evaluateHeaders(example *openapi3.Example, eval runtime.Evaluator) map[string]string { - headers := make(map[string]string) - - if headersMap, ok := extensions.ExtractHeaders(example); ok { - for key, val := range headersMap { - if str, ok := s.resolveHeaderValue(val, eval); ok { - headers[key] = str - } - } - } - - return headers + return s.engine.evaluateHeaders(example, eval) } -// resolveHeaderValue converts a header value (string, []any, or any) to a resolved string. func (s *Server) resolveHeaderValue(val any, eval runtime.Evaluator) (string, bool) { - switch v := val.(type) { - case string: - resolved, err := s.evaluateValue(v, eval) - if err != nil { - if s.config.Verbose { - slog.Debug("Failed to evaluate header value", "headerValue", v, "error", err) - } - return "", false - } - if str, ok := resolved.(string); ok { - return str, true - } - // Convert to JSON string - b, err := json.Marshal(resolved) - if err != nil { - return "", false - } - return string(b), true - case []any: - // Multiple header values - join with comma (except for Set-Cookie which should be separate headers) - // For simplicity, just take the first value for now - if len(v) > 0 { - if first, ok := v[0].(string); ok { - resolved, err := s.evaluateValue(first, eval) - if err == nil { - if str, ok := resolved.(string); ok { - return str, true - } - } - } - } - default: - // Try to evaluate as runtime expression - resolved, err := s.evaluateValue(val, eval) - if err == nil { - if str, ok := resolved.(string); ok { - return str, true - } - } - } - return "", false + return s.engine.resolveHeaderValue(val, eval) } func getStatusCode(mapping *loader.RouteMapping, response *openapi3.Response) int { diff --git a/internal/server/server_management.go b/internal/server/server_management.go index 68bfc52..1e15deb 100644 --- a/internal/server/server_management.go +++ b/internal/server/server_management.go @@ -15,11 +15,38 @@ import ( "github.com/xeipuuv/gojsonschema" ) +// 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 { + for i := range s.mappings { + mapping := &s.mappings[i] + if mapping.Protocol == "" { + continue + } + if protocol != "" && mapping.Protocol != protocol { + continue + } + if mapping.Path != channel { + continue + } + if method != "" && mapping.Method != method && method != DefaultMethod { + continue + } + return mapping + } + return nil +} + var addExampleRequestSchema = gojsonschema.NewGoLoader(map[string]any{ "type": "object", - "required": []string{"path", "response"}, + "required": []string{"response"}, "properties": map[string]any{ "path": map[string]any{"type": "string"}, + "protocol": map[string]any{ + "type": "string", + "enum": []string{"http", "ws"}, + }, + "channel": map[string]any{"type": "string"}, "method": map[string]any{ "type": "string", "enum": []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, @@ -194,6 +221,8 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` Method string `json:"method"` + Protocol string `json:"protocol"` + Channel string `json:"channel"` Once bool `json:"once"` Validate bool `json:"validate"` TTL int `json:"ttl"` @@ -208,23 +237,32 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest) return } - if req.Path == "" || req.Response.Code == 0 { + if req.Response.Code == 0 || (req.Path == "" && req.Channel == "") { http.Error(w, `{"error":"Missing required fields"}`, http.StatusBadRequest) return } req.Method = cmp.Or(req.Method, DefaultMethod) - // Find a route mapping that matches the path + + // Resolve the target route: OpenAPI path/method or AsyncAPI channel. var targetMapping *RouteMapping - for i := range s.mappings { - mapping := &s.mappings[i] - if mapping.Pattern == req.Path && mapping.Method == req.Method { - targetMapping = mapping - break + 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) + return + } + } else { + for i := range s.mappings { + mapping := &s.mappings[i] + if mapping.Pattern == req.Path && mapping.Method == req.Method { + targetMapping = mapping + break + } + } + if targetMapping == nil { + http.Error(w, `{"error":"No matching route found"}`, http.StatusBadRequest) + return } - } - if targetMapping == nil { - http.Error(w, `{"error":"No matching route found"}`, http.StatusBadRequest) - return } // TODO: validate response body against OpenAPI schema if req.Validate is true // (skipped for now) @@ -251,11 +289,9 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { "method", req.Method, "chiPattern", targetMapping.ChiPattern, "pattern", targetMapping.Pattern, - "numExamples", len(s.dynamicExamples[key])+1) + "numExamples", len(s.registry.dynamicExamples[key])+1) } - s.dyMu.Lock() - s.dynamicExamples[key] = append(s.dynamicExamples[key], example) - s.dyMu.Unlock() + s.registry.addDynamic(key, example) // Respond with success w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]any{ diff --git a/internal/server/server_state.go b/internal/server/server_state.go index 064c67e..5b4a7e1 100644 --- a/internal/server/server_state.go +++ b/internal/server/server_state.go @@ -1,137 +1,30 @@ package server -import ( - "fmt" - "log/slog" - "strconv" +import "github.com/mamonth/oasmock/internal/runtime" - "github.com/mamonth/oasmock/internal/runtime" -) +// State mutation (x-mock-set-state) lives in exampleEngine; these forwarders +// keep the HTTP pipeline and tests working through Server. func (s *Server) handleDeleteState(prefix, resolvedKey string) { - s.stateStore.Delete(prefix, resolvedKey) - if s.config.Verbose { - slog.Debug("Deleted state", "key", resolvedKey, "namespace", prefix) - } + s.engine.handleDeleteState(prefix, resolvedKey) } func (s *Server) handleIncrementState(prefix, resolvedKey string, incVal any, eval runtime.Evaluator) error { - resolvedInc, err := s.evaluateValue(incVal, eval) - if err != nil { - if s.config.Verbose { - slog.Debug("Failed to evaluate increment value", "error", err) - } - return err - } - // Convert to float64 - var delta float64 - switch v := resolvedInc.(type) { - case float64: - delta = v - case int: - delta = float64(v) - case string: - // Try to parse as number - if f, err := strconv.ParseFloat(v, 64); err == nil { - delta = f - } else { - if s.config.Verbose { - slog.Debug("Increment value is not a number", "value", v) - } - return fmt.Errorf("increment value is not a number: %s", v) - } - default: - if s.config.Verbose { - slog.Debug("Increment value has unsupported type", "type", fmt.Sprintf("%T", v)) - } - return fmt.Errorf("increment value has unsupported type: %T", v) - } - newVal, err := s.stateStore.Increment(prefix, resolvedKey, delta) - if err != nil { - if s.config.Verbose { - slog.Debug("Failed to increment state", "key", resolvedKey, "error", err) - } - return err - } - if s.config.Verbose { - slog.Debug("Incremented state", "key", resolvedKey, "namespace", prefix, "delta", delta, "newValue", newVal) - } - return nil + return s.engine.handleIncrementState(prefix, resolvedKey, incVal, eval) } func (s *Server) handleValueObjectState(prefix, resolvedKey string, valObj any, eval runtime.Evaluator) error { - resolvedVal, err := s.evaluateValue(valObj, eval) - if err != nil { - if s.config.Verbose { - slog.Debug("Failed to evaluate value object", "error", err) - } - return err - } - s.stateStore.Set(prefix, resolvedKey, resolvedVal) - if s.config.Verbose { - slog.Debug("Set state", "key", resolvedKey, "namespace", prefix, "value", resolvedVal) - } - return nil + return s.engine.handleValueObjectState(prefix, resolvedKey, valObj, eval) } func (s *Server) handleMapState(prefix, resolvedKey string, m map[string]any, eval runtime.Evaluator) (handled bool, err error) { - if incVal, hasInc := m["increment"]; hasInc { - err = s.handleIncrementState(prefix, resolvedKey, incVal, eval) - return true, err - } - if valObj, hasVal := m["value"]; hasVal { - err = s.handleValueObjectState(prefix, resolvedKey, valObj, eval) - return true, err - } - return false, nil + return s.engine.handleMapState(prefix, resolvedKey, m, eval) } func (s *Server) handleSimpleState(prefix, resolvedKey string, val any, eval runtime.Evaluator) error { - resolvedVal, err := s.evaluateValue(val, eval) - if err != nil { - if s.config.Verbose { - slog.Debug("Failed to evaluate value for key", "key", resolvedKey, "error", err) - } - return err - } - s.stateStore.Set(prefix, resolvedKey, resolvedVal) - if s.config.Verbose { - slog.Debug("Set state", "key", resolvedKey, "namespace", prefix, "value", resolvedVal) - } - return nil + return s.engine.handleSimpleState(prefix, resolvedKey, val, eval) } func (s *Server) applySetState(stateMap map[string]any, eval runtime.Evaluator, prefix string) { - for key, val := range stateMap { - // Evaluate runtime expressions in key - resolvedKey, err := s.evaluateExpressionInString(key, eval) - if err != nil { - if s.config.Verbose { - slog.Debug("Failed to evaluate key", "key", key, "error", err) - } - continue - } - - // Handle null value (delete) - if val == nil { - s.handleDeleteState(prefix, resolvedKey) - continue - } - - // Handle map (increment or value object) - if m, ok := val.(map[string]any); ok { - handled, _ := s.handleMapState(prefix, resolvedKey, m, eval) - if handled { - // Error already logged inside helpers - continue - } - // Not a recognized map structure, fall through to simple value - } - - // Simple value (could be runtime expression) - if err := s.handleSimpleState(prefix, resolvedKey, val, eval); err != nil { - // Error already logged inside helper - continue - } - } + s.engine.ApplySetState(stateMap, eval, prefix) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index bbd8eb7..f018fbf 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -20,15 +20,12 @@ import ( "github.com/mamonth/oasmock/internal/history" "github.com/mamonth/oasmock/internal/loader" - "github.com/mamonth/oasmock/internal/runtime" "github.com/mamonth/oasmock/internal/state" mock_runtime "github.com/mamonth/oasmock/mock/runtime" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var _ = runtime.RequestSource{} - // newMockedServerWithGeneratedMocks creates a server with generated mock dependencies for testing. func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *MockRouteProvider, *MockStateStore, *MockHistoryStore, *MockExpressionEvaluator, *MockRequestSourceFactory, *MockStateSourceFactory, *MockEnvSourceFactory, *MockExtensionProcessor) { t.Helper() @@ -40,6 +37,7 @@ func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *M routeProvider.EXPECT().BuildRouteMappings(gomock.Any()).Return([]RouteMapping{}, nil) stateStore := NewMockStateStore(ctrl) historyStore := NewMockHistoryStore(ctrl) + historyStore.EXPECT().Add(gomock.Any()).AnyTimes() expressionEvaluator := NewMockExpressionEvaluator(ctrl) requestSourceFactory := NewMockRequestSourceFactory(ctrl) stateSourceFactory := NewMockStateSourceFactory(ctrl) @@ -94,7 +92,12 @@ func TestValidateAddExampleRequest(t *testing.T) { { name: "missing path", json: `{"response":{"code":200}}`, - wantErr: true, + wantErr: false, // path is optional; channel may be supplied instead + }, + { + name: "valid async channel request", + json: `{"protocol":"ws","channel":"/alerts","response":{"code":200,"body":{"a":1}}}`, + wantErr: false, }, { name: "missing response", @@ -727,6 +730,7 @@ func TestApplySetState(t *testing.T) { store, eval, callsPtr := tt.setup(ctrl) server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) server.stateStore = store + server.engine.stateStore = store server.applySetState(tt.stateMap, eval, "") assert.Equal(t, tt.wantCalls, *callsPtr, "store calls mismatch") }) @@ -801,7 +805,7 @@ func TestHandleAddExample(t *testing.T) { ChiPattern: "/test", }}, wantStatus: http.StatusBadRequest, - wantJSON: map[string]any{"error": "invalid request: (root): path is required"}, + wantJSON: map[string]any{"error": "Missing required fields"}, wantExample: false, }, { @@ -829,7 +833,7 @@ func TestHandleAddExample(t *testing.T) { // Set up mappings server.mappings = tt.mappings // Initialize dynamic examples map - server.dynamicExamples = make(map[string][]dynamicExample) + server.registry.dynamicExamples = make(map[string][]dynamicExample) // Create request req := httptest.NewRequest("POST", "/api/examples", strings.NewReader(tt.reqBody)) @@ -858,13 +862,13 @@ func TestHandleAddExample(t *testing.T) { // Check if example was added if tt.wantExample { key := "GET /test" - server.dyMu.RLock() - defer server.dyMu.RUnlock() - examples, exists := server.dynamicExamples[key] + server.registry.dyMu.RLock() + examples, exists := server.registry.dynamicExamples[key] + server.registry.dyMu.RUnlock() assert.True(t, exists, "dynamic example should be added for key %s", key) assert.Len(t, examples, 1, "should have one example") } else { - assert.Empty(t, server.dynamicExamples, "no examples should be added") + assert.Empty(t, server.registry.dynamicExamples, "no examples should be added") } }) } @@ -1350,7 +1354,7 @@ paths: name: "successful request with built-in example", setupServer: func(s *Server, reqFact *MockRequestSourceFactory, stateFact *MockStateSourceFactory, envFact *MockEnvSourceFactory) { // No dynamic examples - s.dynamicExamples = make(map[string][]dynamicExample) + s.registry.dynamicExamples = make(map[string][]dynamicExample) // Setup mock factories (not needed for this test) // With generated mocks, we need to set expectations // Since factories aren't used in this test, we allow any calls returning nil @@ -1375,7 +1379,7 @@ paths: { name: "no response defined", setupServer: func(s *Server, reqFact *MockRequestSourceFactory, stateFact *MockStateSourceFactory, envFact *MockEnvSourceFactory) { - s.dynamicExamples = make(map[string][]dynamicExample) + s.registry.dynamicExamples = make(map[string][]dynamicExample) // Allow any factory calls returning nil reqFact.EXPECT().NewRequestSource(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() stateFact.EXPECT().NewStateSource(gomock.Any()).Return(nil).AnyTimes() @@ -1399,9 +1403,9 @@ paths: name: "dynamic example selected", setupServer: func(s *Server, reqFact *MockRequestSourceFactory, stateFact *MockStateSourceFactory, envFact *MockEnvSourceFactory) { // Add a dynamic example - s.dynamicExamples = make(map[string][]dynamicExample) + s.registry.dynamicExamples = make(map[string][]dynamicExample) key := "GET /test" - s.dynamicExamples[key] = []dynamicExample{{ + s.registry.dynamicExamples[key] = []dynamicExample{{ once: false, conditions: nil, response: struct { diff --git a/internal/server/server_ttl_test.go b/internal/server/server_ttl_test.go index 2f4fac4..7ae2f30 100644 --- a/internal/server/server_ttl_test.go +++ b/internal/server/server_ttl_test.go @@ -43,7 +43,7 @@ func TestSelectDynamicExampleExpiry(t *testing.T) { server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" - server.dynamicExamples = map[string][]dynamicExample{ + server.registry.dynamicExamples = map[string][]dynamicExample{ key: { dynExample(1, time.Now().Add(-2*time.Second), "expired"), dynExample(3600, time.Now(), "alive"), @@ -70,7 +70,7 @@ func TestSelectDynamicExampleZeroTTLNeverExpires(t *testing.T) { server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" - server.dynamicExamples = map[string][]dynamicExample{ + server.registry.dynamicExamples = map[string][]dynamicExample{ key: { dynExample(0, time.Now().Add(-10*time.Hour), "no-ttl"), }, @@ -99,7 +99,7 @@ func TestSelectDynamicExampleOnceWithTTL(t *testing.T) { ex := dynExample(3600, time.Now(), "once-ttl") ex.once = true ex.onceID = "once-ttl" - server.dynamicExamples = map[string][]dynamicExample{key: {ex}} + server.registry.dynamicExamples = map[string][]dynamicExample{key: {ex}} mapping := &RouteMapping{Method: "GET", ChiPattern: "/test"} @@ -123,7 +123,7 @@ func TestSweepExpiredExamples(t *testing.T) { t.Parallel() server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) - server.dynamicExamples = map[string][]dynamicExample{ + server.registry.dynamicExamples = map[string][]dynamicExample{ "GET /a": { dynExample(1, time.Now().Add(-2*time.Second), "expired"), dynExample(3600, time.Now(), "alive"), @@ -136,9 +136,9 @@ func TestSweepExpiredExamples(t *testing.T) { server.sweepExpiredExamples() - server.dyMu.RLock() - defer server.dyMu.RUnlock() - got := server.dynamicExamples + server.registry.dyMu.RLock() + defer server.registry.dyMu.RUnlock() + got := server.registry.dynamicExamples require.Len(t, got["GET /a"], 2, "only the expired example should be removed") assert.Equal(t, "alive", got["GET /a"][0].response.body) @@ -162,14 +162,14 @@ func TestSweepExpiredExamplesCleansOnceExamples(t *testing.T) { ex := dynExample(1, time.Now().Add(-2*time.Second), "once-expired") ex.once = true ex.onceID = "once-expired" - server.dynamicExamples = map[string][]dynamicExample{key: {ex}} - server.onceExamples = map[string]bool{ex.onceID: true} + server.registry.dynamicExamples = map[string][]dynamicExample{key: {ex}} + server.registry.onceExamples = map[string]bool{ex.onceID: true} server.sweepExpiredExamples() - server.onceMu.RLock() - _, ok := server.onceExamples[ex.onceID] - server.onceMu.RUnlock() + server.registry.onceMu.RLock() + _, ok := server.registry.onceExamples[ex.onceID] + server.registry.onceMu.RUnlock() assert.False(t, ok, "onceExamples entry should be removed for swept example") } @@ -195,7 +195,7 @@ func TestSweepDoesNotReuseConsumedOnceExample(t *testing.T) { alive.once = true alive.onceID = "once-B" - server.dynamicExamples = map[string][]dynamicExample{key: {expired, alive}} + server.registry.dynamicExamples = map[string][]dynamicExample{key: {expired, alive}} // Both examples are consumed before the expired one is swept. server.markOnceUsed(expired.onceID) @@ -256,7 +256,7 @@ func TestHandleAddExampleWithTTL(t *testing.T) { Pattern: "/test", ChiPattern: "/test", }} - server.dynamicExamples = make(map[string][]dynamicExample) + server.registry.dynamicExamples = make(map[string][]dynamicExample) req := httptest.NewRequest("POST", "/_mock/examples", strings.NewReader(tt.reqBody)) w := httptest.NewRecorder() @@ -266,10 +266,10 @@ func TestHandleAddExampleWithTTL(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "expected success response") key := "GET /test" - server.dyMu.RLock() - defer server.dyMu.RUnlock() - require.Len(t, server.dynamicExamples[key], 1, "example should be stored") - stored := server.dynamicExamples[key][0] + server.registry.dyMu.RLock() + defer server.registry.dyMu.RUnlock() + require.Len(t, server.registry.dynamicExamples[key], 1, "example should be stored") + stored := server.registry.dynamicExamples[key][0] assert.Equal(t, tt.wantTTL, stored.ttl, "ttl should be stored on example") if tt.wantAddedAtSet { assert.False(t, stored.addedAt.IsZero(), "addedAt should be set for ttl > 0") @@ -298,7 +298,7 @@ func TestHandleAddExampleRejectsNegativeTTL(t *testing.T) { Pattern: "/test", ChiPattern: "/test", }} - server.dynamicExamples = make(map[string][]dynamicExample) + server.registry.dynamicExamples = make(map[string][]dynamicExample) req := httptest.NewRequest("POST", "/_mock/examples", strings.NewReader(`{"path":"/test","response":{"code":200},"ttl":-1}`)) w := httptest.NewRecorder() @@ -321,25 +321,25 @@ func TestTTLSweepStartsAndStops(t *testing.T) { t.Parallel() server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) - require.NotNil(t, server.sweepCancel, "sweep should be initialized on server creation") + require.NotNil(t, server.registry.sweepCancel, "sweep should be initialized on server creation") // Verify the background sweep goroutine runs: add an expired example and // expect it to be removed by the background sweep. key := "GET /test" - server.dynamicExamples = map[string][]dynamicExample{ + server.registry.dynamicExamples = map[string][]dynamicExample{ key: {dynExample(1, time.Now().Add(-2*time.Second), "expired")}, } require.Eventually(t, func() bool { - server.dyMu.RLock() - defer server.dyMu.RUnlock() - _, ok := server.dynamicExamples[key] + server.registry.dyMu.RLock() + defer server.registry.dyMu.RUnlock() + _, ok := server.registry.dynamicExamples[key] return !ok }, 3*time.Second, 50*time.Millisecond, "sweep goroutine should remove the expired example") // Shutdown cancels the sweep. require.NoError(t, server.Shutdown(context.Background())) - assert.Equal(t, context.Canceled, server.sweepCtx.Err(), "sweep context should be cancelled on shutdown") + assert.Equal(t, context.Canceled, server.registry.sweepCtx.Err(), "sweep context should be cancelled on shutdown") } /* @@ -369,7 +369,7 @@ func TestConcurrentSelectAndSweepNoDataRace(t *testing.T) { examples = append(examples, dynExample(3600, time.Now(), i)) } } - server.dynamicExamples = map[string][]dynamicExample{key: examples} + server.registry.dynamicExamples = map[string][]dynamicExample{key: examples} mapping := &RouteMapping{Method: "GET", ChiPattern: "/test"} diff --git a/internal/server/signalr_hub.go b/internal/server/signalr_hub.go new file mode 100644 index 0000000..8d45a60 --- /dev/null +++ b/internal/server/signalr_hub.go @@ -0,0 +1,542 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" +) + +// signalRNegotiate is the negotiate endpoint response (RS.SHR.8-9). +type signalRNegotiate struct { + ConnectionToken string `json:"connectionToken"` + ConnectionID string `json:"connectionId"` + NegotiateVersion int `json:"negotiateVersion"` + AvailableTransports []signalRAvailableTransport `json:"availableTransports"` +} + +type signalRAvailableTransport struct { + Transport string `json:"transport"` + TransferFormats []string `json:"transferFormats"` +} + +// signalRHub is the SignalR overlay serving a single AsyncAPI document +// declared with root-level x-signalr (design D7). It depends only on the +// MessageRenderer surface, never on the whole Server. +type signalRHub struct { + renderer MessageRenderer + path string // hub path, e.g. "/hub" + doc *asyncapi.Document + prefix string + channels map[string]*asyncapi.Channel + ops map[string]*asyncapi.Operation + + mu sync.Mutex + tokens map[string]string // connection token -> connection id + conns map[string]*signalRConnection + idSeq int +} + +// signalRConnection is a single SignalR client connection. +type signalRConnection struct { + id string + token string + conn *websocket.Conn + writer *wsWriter + streams map[string]*signalRStream // invocationId -> open stream + server *signalRHub +} + +// signalRStream is an open client-initiated stream over a channel. +type signalRStream struct { + invocationID string + channelID string + connID string +} + +// newSignalRHub creates a hub for a document with root x-signalr. +func newSignalRHub(renderer MessageRenderer, doc *asyncapi.Document, prefix string) *signalRHub { + hub := &signalRHub{ + renderer: renderer, + doc: doc, + prefix: prefix, + channels: make(map[string]*asyncapi.Channel), + ops: make(map[string]*asyncapi.Operation), + tokens: make(map[string]string), + conns: make(map[string]*signalRConnection), + } + if doc != nil { + hub.path = signalRPath(doc) + for _, ch := range doc.Channels { + hub.channels[ch.ID] = ch + } + for _, op := range doc.Operations { + hub.ops[op.ID] = op + } + } + return hub +} + +// newSignalRHubAtPath creates a hub at an explicit path (used by tests and +// when the caller controls the hub path directly). +func newSignalRHubAtPath(s *Server, path, prefix string, doc *asyncapi.Document) *signalRHub { + hub := newSignalRHub(s.engine, doc, prefix) + hub.path = normalizeHubPath(path) + return hub +} + +// signalRPath extracts the hub path from the root x-signalr extension. +func signalRPath(doc *asyncapi.Document) string { + if doc == nil || doc.SignalR == nil { + return "" + } + if p, ok := doc.SignalR.Raw["path"].(string); ok && p != "" { + return normalizeHubPath(p) + } + return "" +} + +// normalizeHubPath ensures the hub path starts with "/" and has no trailing "/". +func normalizeHubPath(path string) string { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return strings.TrimRight(path, "/") +} + +// prefixPath applies the schema prefix to a hub-relative path. +func (h *signalRHub) prefixPath(rel string) string { + prefix := strings.TrimSuffix(h.prefix, "/") + if prefix == "" { + return rel + } + return prefix + rel +} + +// negotiatePath is the full negotiate endpoint URL. +func (h *signalRHub) negotiatePath() string { + return h.prefixPath(h.path + "/negotiate") +} + +// upgradePath is the full WebSocket upgrade URL. +func (h *signalRHub) upgradePath() string { + return h.prefixPath(h.path) +} + +// negotiate handles POST {hubPath}/negotiate (RS.SHR.8-10). +func (h *signalRHub) negotiate(w http.ResponseWriter, r *http.Request) { + // Reject negotiate requests for a transport this server cannot serve + // (RS.SHR.10): only WebSockets is offered, anything else is HTTP 400. + if transport := r.URL.Query().Get("transport"); transport != "" && !isSignalRWebSockets(transport) { + writeJSONError(w, http.StatusBadRequest, "unsupported transport "+transport) + return + } + token, connID := h.issueToken() + resp := signalRNegotiate{ + ConnectionToken: token, + ConnectionID: connID, + NegotiateVersion: 1, + AvailableTransports: []signalRAvailableTransport{ + {Transport: "WebSockets", TransferFormats: []string{"Text", "Binary"}}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// isSignalRWebSockets reports whether the transport name is the WebSockets +// transport (case-insensitive), which is the only one this server offers. +func isSignalRWebSockets(transport string) bool { + return strings.EqualFold(transport, "webSockets") || strings.EqualFold(transport, "websockets") +} + +// issueToken creates and records a connection token. +func (h *signalRHub) issueToken() (token, connID string) { + h.mu.Lock() + defer h.mu.Unlock() + h.idSeq++ + connID = "signalr-" + strconv.Itoa(h.idSeq) + token = connID + "-t" + h.tokens[token] = connID + return token, connID +} + +// checkToken validates a connection token. +func (h *signalRHub) checkToken(token string) bool { + h.mu.Lock() + defer h.mu.Unlock() + _, ok := h.tokens[token] + return ok +} + +// consumeToken validates and consumes a token, binding the connection. +func (h *signalRHub) consumeToken(token string) (string, bool) { + h.mu.Lock() + defer h.mu.Unlock() + connID, ok := h.tokens[token] + if ok { + delete(h.tokens, token) + } + return connID, ok +} + +// freshToken returns a token for a connection id (no pre-correlation). +func (h *signalRHub) freshToken() (token, connID string) { + h.mu.Lock() + defer h.mu.Unlock() + h.idSeq++ + connID = "signalr-fresh-" + strconv.Itoa(h.idSeq) + return connID + "-t", connID +} + +// serveUpgrade handles a WebSocket upgrade to the hub path (RS.SHR.11-13). +func (h *signalRHub) serveUpgrade(w http.ResponseWriter, r *http.Request) { + // Only the WebSockets transport can upgrade (RS.SHR.10). + if transport := r.URL.Query().Get("transport"); transport != "" && !isSignalRWebSockets(transport) { + writeJSONError(w, http.StatusBadRequest, "unsupported transport "+transport) + return + } + idParam := r.URL.Query().Get("id") + connID := "" + token := idParam + if idParam != "" { + var ok bool + connID, ok = h.consumeToken(idParam) + if !ok { + writeJSONError(w, http.StatusNotFound, "unknown connection token") + return + } + } else { + // Fresh internally generated connection id (RS.SHR.13). + _, connID = h.freshToken() + token = connID + "-t" + } + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + wr := newWSWriter(conn) + + sc := &signalRConnection{ + id: connID, + token: token, + conn: conn, + writer: wr, + streams: make(map[string]*signalRStream), + server: h, + } + h.mu.Lock() + h.conns[connID] = sc + h.mu.Unlock() + + defer func() { + h.mu.Lock() + delete(h.conns, connID) + h.mu.Unlock() + h.removeConnectionStreams(connID) + wr.close() + }() + + h.runConnection(sc) +} + +// removeConnectionStreams drops all open streams for a connection. +func (h *signalRHub) removeConnectionStreams(connID string) { + h.mu.Lock() + defer h.mu.Unlock() + if sc, ok := h.conns[connID]; ok { + for _, st := range sc.streams { + h.unregisterStream(st) + } + } +} + +// runConnection drives the SignalR message loop for one connection. +func (h *signalRHub) runConnection(sc *signalRConnection) { + handshaken := false + for { + messageType, payload, err := sc.conn.ReadMessage() + if err != nil { + return + } + if messageType == websocket.PingMessage { + sc.writer.writeMessage(websocket.PongMessage, payload) + continue + } + // Handshake: the first frame must be the protocol handshake (RS.SHR.14-15). + if !handshaken { + if messageType != websocket.TextMessage { + h.writeHandshakeError(sc, "binary frames are not supported") + return + } + proto, version, hserr := parseSignalRHandshake(payload) + if hserr != nil { + h.writeHandshakeError(sc, hserr.Error()) + return + } + _, _ = proto, version + sc.writer.write([]byte("{}" + string(recordSeparator))) + handshaken = true + continue + } + if messageType != websocket.TextMessage { + continue + } + for _, chunk := range splitSignalRFrames(payload) { + env, err := parseSignalREnvelope(chunk) + if err != nil { + continue + } + h.dispatch(sc, env) + } + } +} + +// writeHandshakeError sends a handshake response then closes (RS.SHR.15). +func (h *signalRHub) writeHandshakeError(sc *signalRConnection, msg string) { + sc.writer.write([]byte(`{"error":"` + msg + `"}` + string(recordSeparator))) + sc.writer.close() +} + +// dispatch routes a single parsed envelope. +func (h *signalRHub) dispatch(sc *signalRConnection, env signalREnvelope) { + switch env.Type { + case signalRTypePing: + sc.writer.write(encodeSignalRMessage(signalREnvelope{Type: signalRTypePing})) + case signalRTypeStreamInvocation: + h.handleStreamInvocation(sc, env) + case signalRTypeInvocation: + h.handleInvocation(sc, env) + case signalRTypeCancelInvocation: + h.handleCancelInvocation(sc, env) + } +} + +// 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) { + channelID := env.Target + ch := h.channels[channelID] + if ch == nil { + h.writeCompletion(sc, env.InvocationID, fmt.Sprintf("unknown channel target %q", channelID)) + return + } + + // Snapshot stream item(s). + count, body, err := h.renderChannel(channelID) + if err != nil { + h.writeCompletion(sc, env.InvocationID, fmt.Sprintf("failed to render channel: %v", err)) + return + } + if count == 0 { + h.writeCompletion(sc, env.InvocationID, "channel has no message examples") + return + } + sc.writer.write(encodeSignalRMessage(signalREnvelope{ + Type: signalRTypeStreamItem, + InvocationID: env.InvocationID, + Item: json.RawMessage(body), + })) + + // Hold the stream open and register it (RS.SHR.4, RS.SHR.21). + h.mu.Lock() + sc.streams[env.InvocationID] = &signalRStream{ + invocationID: env.InvocationID, + channelID: channelID, + connID: sc.id, + } + h.mu.Unlock() +} + +// handleInvocation answers a one-shot Invocation by operation ID with a +// Completion carrying the operation's message example (RS.SHR.6-7). +func (h *signalRHub) handleInvocation(sc *signalRConnection, env signalREnvelope) { + opID := env.Target + op := h.ops[opID] + if op == nil { + h.writeCompletion(sc, env.InvocationID, fmt.Sprintf("unknown operation target %q", opID)) + return + } + count, body, err := h.renderOperation(opID) + if err != nil { + h.writeCompletion(sc, env.InvocationID, fmt.Sprintf("failed to render operation: %v", err)) + return + } + if count == 0 { + h.writeCompletion(sc, env.InvocationID, "operation has no message examples") + return + } + sc.writer.write(encodeSignalRMessage(signalREnvelope{ + Type: signalRTypeCompletion, + InvocationID: env.InvocationID, + Result: json.RawMessage(body), + })) +} + +// handleCancelInvocation closes an open stream (RS.SHR.17). +func (h *signalRHub) handleCancelInvocation(sc *signalRConnection, env signalREnvelope) { + h.mu.Lock() + if st, ok := sc.streams[env.InvocationID]; ok { + h.unregisterStream(st) + } + h.mu.Unlock() + h.writeCompletion(sc, env.InvocationID, "") +} + +// unregisterStream removes a stream from its connection registry. +// The caller must hold h.mu. +func (h *signalRHub) unregisterStream(st *signalRStream) { + if sc, ok := h.conns[st.connID]; ok { + delete(sc.streams, st.invocationID) + } +} + +// writeCompletion sends a completion envelope. +func (h *signalRHub) writeCompletion(sc *signalRConnection, invocationID, errMsg string) { + env := signalREnvelope{Type: signalRTypeCompletion, InvocationID: invocationID} + if errMsg != "" { + env.Error = errMsg + } + sc.writer.write(encodeSignalRMessage(env)) +} + +// renderChannel renders the snapshot message for a channel as JSON bytes. +func (h *signalRHub) renderChannel(channelID string) (int, []byte, error) { + ch := h.channels[channelID] + if ch == nil { + return 0, nil, nil + } + specs := loader.MessageSpecsFromAsync(ch.Messages) + opID := "signalr:channel:" + channelID + return h.renderer.RenderMessageSpecs(specs, h.prefix, opID, InboundMessage{}) +} + +// renderOperation renders the result message for a one-shot operation. +func (h *signalRHub) renderOperation(opID string) (int, []byte, error) { + op := h.ops[opID] + if op == nil { + return 0, nil, nil + } + specs := loader.MessageSpecsFromAsync(op.Messages) + opKey := "signalr:operation:" + opID + return h.renderer.RenderMessageSpecs(specs, h.prefix, opKey, InboundMessage{}) +} + +// pushToStreams emits a templated payload into all open streams of a channel; +// when no stream is open it sends a server Invocation (RS.SHR.18-19). +func (h *signalRHub) pushToStreams(channelID string, payload []byte, target string) { + h.mu.Lock() + defer h.mu.Unlock() + var matched bool + for _, sc := range h.conns { + for invocationID, st := range sc.streams { + if st.channelID != channelID { + continue + } + matched = true + sc.writer.write(encodeSignalRMessage(signalREnvelope{ + Type: signalRTypeStreamItem, + InvocationID: invocationID, + Item: json.RawMessage(payload), + })) + } + } + if !matched { + for _, sc := range h.conns { + // Server-to-client Invocation with a server-assigned id (RS.SHR.19). + sc.writer.write(encodeSignalRMessage(signalREnvelope{ + Type: signalRTypeInvocation, + InvocationID: "srv-" + target + "-" + strconv.Itoa(h.idSeq), + Target: target, + Arguments: []any{json.RawMessage(payload)}, + })) + } + } +} + +// pushToConnection pushes a payload to one connection's open streams for the +// channel, falling back to a server Invocation on that connection when no +// stream is open (RS.AMG.5, RS.SHR.18-19). +func (h *signalRHub) pushToConnection(connectionID, channelID string, payload []byte, target string) { + h.mu.Lock() + defer h.mu.Unlock() + sc, ok := h.conns[connectionID] + if !ok { + return + } + var matched bool + for invocationID, st := range sc.streams { + if st.channelID != channelID { + continue + } + matched = true + sc.writer.write(encodeSignalRMessage(signalREnvelope{ + Type: signalRTypeStreamItem, + InvocationID: invocationID, + Item: json.RawMessage(payload), + })) + } + if !matched { + sc.writer.write(encodeSignalRMessage(signalREnvelope{ + Type: signalRTypeInvocation, + InvocationID: "srv-" + target + "-" + strconv.Itoa(h.idSeq), + Target: target, + Arguments: []any{json.RawMessage(payload)}, + })) + } +} + +// buildSignalRHubs constructs a SignalR hub for each AsyncAPI document that +// declares root x-signalr (design D7). +func buildSignalRHubs(renderer MessageRenderer, schemas []SchemaInfo) []*signalRHub { + var hubs []*signalRHub + for _, schema := range schemas { + if schema.Kind != loader.KindAsyncAPI || schema.Async == nil || schema.Async.SignalR == nil { + continue + } + hub := newSignalRHub(renderer, schema.Async, schema.Prefix) + if hub.path == "" { + continue + } + hubs = append(hubs, hub) + } + return hubs +} + +// registerSignalRHubs registers negotiate + upgrade endpoints for all hubs. +func (s *Server) registerSignalRHubs(r interface { + Post(string, http.HandlerFunc) + Get(string, http.HandlerFunc) +}) { + for _, hub := range s.hubMgr.hubs { + r.Post(hub.negotiatePath(), hub.negotiate) + r.Get(hub.upgradePath(), hub.serveUpgrade) + } +} + +// openStreamsForChannel returns open-stream descriptions for a channel. +func (h *signalRHub) openStreamsForChannel(channelID string) []map[string]string { + h.mu.Lock() + defer h.mu.Unlock() + var out []map[string]string + for _, sc := range h.conns { + for invocationID, st := range sc.streams { + if st.channelID == channelID { + out = append(out, map[string]string{ + "connectionId": sc.id, + "invocationId": invocationID, + "streamId": st.channelID, + }) + } + } + } + return out +} diff --git a/internal/server/signalr_hub_test.go b/internal/server/signalr_hub_test.go new file mode 100644 index 0000000..aa35462 --- /dev/null +++ b/internal/server/signalr_hub_test.go @@ -0,0 +1,186 @@ +package server + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Successful negotiation returns connection token and transports +Given a negotiate request with negotiateVersion=1 +When negotiateSignalR is called +Then it returns a 200 with connectionToken, connectionId, negotiateVersion 1 and WebSockets transport + +Related spec scenarios: RS.SHR.8 +*/ +func TestNegotiateSignalR_Success(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/hub/negotiate?negotiateVersion=1", nil) + + hub.negotiate(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `"negotiateVersion":1`) + assert.Contains(t, body, `"connectionToken"`) + assert.Contains(t, body, `"connectionId"`) + assert.Contains(t, body, `"WebSockets"`) +} + +/* +Scenario: Negotiate without negotiateVersion is treated as version 0 request but answers 1 +Given a negotiate request without negotiateVersion +When negotiateSignalR is called +Then the response reports negotiateVersion 1 and includes connectionToken and connectionId + +Related spec scenarios: RS.SHR.9 +*/ +func TestNegotiateSignalR_DefaultVersion(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/hub/negotiate", nil) + + hub.negotiate(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `"negotiateVersion":1`) + assert.Contains(t, body, `"connectionToken"`) + assert.Contains(t, body, `"connectionId"`) +} + +/* +Scenario: Issued connection tokens correlate with upgrades +Given a negotiated token +When checkToken is called with it and with an unknown token +Then the issued token is valid and the unknown token is not + +Related spec scenarios: RS.SHR.11, RS.SHR.12 +*/ +func TestSignalRHub_TokenCorrelation(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + + token, connID := hub.issueToken() + require.NotEmpty(t, token) + require.NotEmpty(t, connID) + + assert.True(t, hub.checkToken(token)) + assert.False(t, hub.checkToken("unknown-token")) + _ = fmt.Sprintf("%s-%s", token, connID) +} + +/* +Scenario: Fresh token issued when upgrade has no id +Given a hub with no issued tokens +When a fresh token is requested +Then a new token and connection id are returned + +Related spec scenarios: RS.SHR.13 +*/ +func TestSignalRHub_FreshToken(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + + token, connID := hub.freshToken() + require.NotEmpty(t, token) + require.NotEmpty(t, connID) + assert.False(t, hub.checkToken(token)) // fresh token not yet correlated until upgrade +} + +/* +Scenario: Negotiate for an unsupported transport is rejected +Given a negotiate request requesting a non-WebSockets transport +When negotiateSignalR is called +Then it responds with HTTP 400 + +Related spec scenarios: RS.SHR.10 +*/ +func TestNegotiateSignalR_UnsupportedTransport(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/hub/negotiate?transport=ServerSentEvents", nil) + + hub.negotiate(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "unsupported transport") +} + +/* +Scenario: Negotiate for the WebSockets transport is accepted +Given a negotiate request explicitly requesting the WebSockets transport +When negotiateSignalR is called +Then it responds with 200 listing only WebSockets + +Related spec scenarios: RS.SHR.8, RS.SHR.10 +*/ +func TestNegotiateSignalR_WebSocketsTransport(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/hub/negotiate?transport=webSockets", nil) + + hub.negotiate(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"WebSockets"`) +} + +/* +Scenario: Open-stream registry tracks per-channel streams +Given a hub connection holding an open stream on a channel +When openStreamsForChannel is called +Then the stream is returned with connection id, invocation id and channel id + +Related spec scenarios: RS.SHR.21 +*/ +func TestSignalRHub_OpenStreamsForChannel(t *testing.T) { + t.Parallel() + + srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + + sc := &signalRConnection{ + id: "signalr-1", + writer: newWSWriter(nil), + streams: make(map[string]*signalRStream), + } + sc.streams["inv-1"] = &signalRStream{invocationID: "inv-1", channelID: "priceFeed", connID: "signalr-1"} + hub.mu.Lock() + hub.conns["signalr-1"] = sc + hub.mu.Unlock() + + streams := hub.openStreamsForChannel("priceFeed") + require.Len(t, streams, 1) + assert.Equal(t, "signalr-1", streams[0]["connectionId"]) + assert.Equal(t, "inv-1", streams[0]["invocationId"]) + assert.Equal(t, "priceFeed", streams[0]["streamId"]) + + assert.Empty(t, hub.openStreamsForChannel("otherChannel")) +} diff --git a/internal/server/signalr_integration_test.go b/internal/server/signalr_integration_test.go new file mode 100644 index 0000000..a259b53 --- /dev/null +++ b/internal/server/signalr_integration_test.go @@ -0,0 +1,365 @@ +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 signalRIntegrationDoc = `asyncapi: 3.0.0 +info: + title: SignalR Hub + version: 1.0.0 +x-signalr: + path: /hub +channels: + priceFeed: + address: priceFeed + bindings: + ws: + method: GET + messages: + priceMsg: + examples: + - name: snap + payload: + symbol: ETH + price: 3000 + - name: item2 + payload: + symbol: BTC + price: 60000 +operations: + getStatus: + action: send + channel: + $ref: '#/channels/priceFeed' + messages: + - $ref: '#/channels/priceFeed/messages/priceMsg' +` + +func newSignalRServer(t *testing.T) *Server { + t.Helper() + doc, err := asyncapi.Parse([]byte(signalRIntegrationDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + return srv +} + +// dialSignalR wires a raw-framed SignalR client over the hub's ws upgrade. +func dialSignalR(t *testing.T, srv *Server) *websocket.Conn { + t.Helper() + ts := httptest.NewServer(srv.router) + t.Cleanup(ts.Close) + + httpURL := ts.URL + wsURL := "ws" + strings.TrimPrefix(httpURL, "http") + "/hub" + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + if resp != nil { + defer resp.Body.Close() //nolint:errcheck + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +/* +Scenario: Full SignalR stream lifecycle with a raw-framed client +Given a connected SignalR client over the hub +When the client handshakes, opens a stream by channel ID, and cancels +Then the server responds to the handshake, streams the snapshot, keeps the +stream open, and completes on cancellation + +Related spec scenarios: RS.SHR.14, RS.SHR.16, RS.SHR.3, RS.SHR.4, RS.SHR.17 +*/ +func TestSignalR_StreamLifecycle(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + + // Handshake (RS.SHR.14). + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, hs, err := conn.ReadMessage() + require.NoError(t, err) + assert.Equal(t, "{}\x1e", string(hs)) + + // StreamInvocation by channel ID (RS.SHR.3, RS.SHR.16). + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":4,"invocationId":"1","target":"priceFeed"}`+"\x1e"))) + _, snap, err := conn.ReadMessage() + require.NoError(t, err) + assert.True(t, strings.HasSuffix(string(snap), "\x1e")) + var item map[string]any + require.NoError(t, json.Unmarshal(splitSignalRFrames(snap)[0], &item)) + assert.Equal(t, float64(signalRTypeStreamItem), item["type"]) + raw, _ := json.Marshal(item["item"]) + assert.Contains(t, string(raw), `"symbol":"ETH"`) + + // Stream stays open: next message is not a completion (RS.SHR.4). We send + // a ping and expect a ping back, proving no completion was emitted. + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":6}`+"\x1e"))) + _, pong, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(pong)[0], &env)) + assert.Equal(t, signalRTypePing, env.Type) + + // Cancel closes the stream with a completion (RS.SHR.17). + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":5,"invocationId":"1"}`+"\x1e"))) + _, comp, err := conn.ReadMessage() + require.NoError(t, err) + var env2 signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(comp)[0], &env2)) + assert.Equal(t, signalRTypeCompletion, env2.Type) +} + +/* +Scenario: StreamInvocation with an unknown channel target +Given a SignalR client with a frame targeting a missing channel +When the server processes it +Then it replies with a Completion carrying an error + +Related spec scenarios: RS.SHR.5 +*/ +func TestSignalR_UnknownChannelTarget(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + handshakeSignalR(t, conn) + + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":4,"invocationId":"1","target":"nope"}`+"\x1e"))) + _, comp, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(comp)[0], &env)) + assert.Equal(t, signalRTypeCompletion, env.Type) + assert.Contains(t, env.Error, "unknown channel target") +} + +/* +Scenario: One-shot Invocation by operation ID returns a Completion result +Given a SignalR client invoking an operation target +When the server processes the Invocation +Then it replies with a Completion carrying the operation's message example + +Related spec scenarios: RS.SHR.6, RS.SHR.7 +*/ +func TestSignalR_OperationInvocation(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + handshakeSignalR(t, conn) + + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":1,"invocationId":"2","target":"getStatus"}`+"\x1e"))) + _, comp, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(comp)[0], &env)) + assert.Equal(t, signalRTypeCompletion, env.Type) + raw, _ := json.Marshal(env.Result) + assert.Contains(t, string(raw), `"symbol":"ETH"`) +} + +/* +Scenario: Invocation with an unknown operation target returns an error completion +Given a SignalR client invoking a target matching no operation +When the server processes the Invocation +Then it replies with a Completion carrying an error + +Related spec scenarios: RS.SHR.7 +*/ +func TestSignalR_UnknownOperationTarget(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + handshakeSignalR(t, conn) + + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":1,"invocationId":"3","target":"missingOp"}`+"\x1e"))) + _, comp, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(comp)[0], &env)) + assert.Equal(t, signalRTypeCompletion, env.Type) + assert.Contains(t, env.Error, "unknown operation target") +} + +func handshakeSignalR(t *testing.T, conn *websocket.Conn) { + t.Helper() + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, err := conn.ReadMessage() + require.NoError(t, err) + _ = conn.SetReadDeadline(time.Time{}) +} + +/* +Scenario: Upgrade with an unknown token is rejected +Given a negotiated token +When a ws client upgrades with an unknown id token +Then the server rejects the upgrade with HTTP 404 + +Related spec scenarios: RS.SHR.12 +*/ +func TestSignalR_UpgradeUnknownToken(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/hub?id=unknown-token" + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.Error(t, err) + if resp != nil { + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + } +} + +/* +Scenario: Upgrade for an unsupported transport is rejected with 400 +Given a ws client upgrading to the hub path with a non-WebSockets transport +When the upgrade is attempted +Then the server rejects it with HTTP 400 + +Related spec scenarios: RS.SHR.10 +*/ +func TestSignalR_UpgradeUnsupportedTransport(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + ts := httptest.NewServer(srv.router) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/hub?transport=ServerSentEvents" + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.Error(t, err) + if resp != nil { + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + } +} + +/* +Scenario: Ping is echoed +Given a connected and handshaken SignalR client +When it sends a {type:6} ping frame +Then the server replies {type:6} without affecting streams + +Related spec scenarios: RS.SHR.20 +*/ +func TestSignalR_PingEcho(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + handshakeSignalR(t, conn) + + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":6}`+"\x1e"))) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, pong, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(pong)[0], &env)) + assert.Equal(t, signalRTypePing, env.Type) +} + +/* +Scenario: Handshake with an unsupported protocol closes the connection +Given a SignalR client sending a messagepack handshake +When the server processes it +Then it sends a handshake error and closes the connection + +Related spec scenarios: RS.SHR.15 +*/ +func TestSignalR_UnsupportedHandshake(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"messagepack","version":1}`))) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, hs, err := conn.ReadMessage() + require.NoError(t, err) + assert.Contains(t, string(hs), `"error"`) + assert.True(t, strings.HasSuffix(string(hs), "\x1e")) +} + +/* +Scenario: Event-driven item is appended to an open stream +Given a connected client with an open stream on a channel +When pushToStreams emits a payload for that channel +Then the client receives an additional StreamItem on the open invocationId + +Related spec scenarios: RS.SHR.18, RS.EVT.13 +*/ +func TestSignalR_PushToOpenStream(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + handshakeSignalR(t, conn) + + // Open a stream on priceFeed. + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"type":4,"invocationId":"s1","target":"priceFeed"}`+"\x1e"))) + _, _, err := conn.ReadMessage() + require.NoError(t, err) // snapshot + + hub := srv.hubMgr.hubs[0] + hub.pushToStreams("priceFeed", []byte(`{"symbol":"BTC","price":60000}`), "priceFeed") + + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(msg)[0], &env)) + assert.Equal(t, signalRTypeStreamItem, env.Type) + assert.Equal(t, "s1", env.InvocationID) + raw, _ := json.Marshal(env.Item) + assert.Contains(t, string(raw), `"symbol":"BTC"`) +} + +/* +Scenario: Server Invocation push when no open stream matches +Given a connected SignalR client with no open stream on a channel +When pushToStreams emits a payload for that channel +Then the server sends an Invocation with a server-assigned id + +Related spec scenarios: RS.SHR.19, RS.EVT.13 +*/ +func TestSignalR_PushWithoutOpenStream(t *testing.T) { + t.Parallel() + + srv := newSignalRServer(t) + conn := dialSignalR(t, srv) + handshakeSignalR(t, conn) + + // No stream opened: place a marker by sending a ping after which we expect + // only the server Invocation for the push. + hub := srv.hubMgr.hubs[0] + hub.pushToStreams("priceFeed", []byte(`{"symbol":"BTC","price":60000}`), "priceFeed") + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var env signalREnvelope + require.NoError(t, json.Unmarshal(splitSignalRFrames(msg)[0], &env)) + assert.Equal(t, signalRTypeInvocation, env.Type) + assert.True(t, strings.HasPrefix(env.InvocationID, "srv-")) + require.Len(t, env.Arguments, 1) + raw, _ := json.Marshal(env.Arguments[0]) + assert.Contains(t, string(raw), `"symbol":"BTC"`) +} diff --git a/internal/server/signalr_protocol.go b/internal/server/signalr_protocol.go new file mode 100644 index 0000000..9b5b52c --- /dev/null +++ b/internal/server/signalr_protocol.go @@ -0,0 +1,91 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// Async SignalR message type codes per the protocol spec. +const ( + signalRTypeInvocation = 1 + signalRTypeStreamItem = 2 + signalRTypeCompletion = 3 + signalRTypeStreamInvocation = 4 + signalRTypeCancelInvocation = 5 + signalRTypePing = 6 +) + +// recordSeparator terminates every SignalR JSON message. +const recordSeparator = byte('\x1e') + +// signalREnvelope is a single SignalR protocol message. +type signalREnvelope struct { + Protocol string `json:"protocol,omitempty"` + Version int `json:"version,omitempty"` + Type int `json:"type"` + InvocationID string `json:"invocationId,omitempty"` + Target string `json:"target,omitempty"` + Arguments []any `json:"arguments,omitempty"` + Error string `json:"error,omitempty"` + Result any `json:"result,omitempty"` + Item any `json:"item,omitempty"` + StreamIDs []string `json:"streamIds,omitempty"` + Headers map[string]any `json:"headers,omitempty"` +} + +// splitSignalRFrames splits one WebSocket text frame into SignalR messages on +// the 0x1E record separator (RS.SHR.16). +func splitSignalRFrames(frame []byte) [][]byte { + var out [][]byte + for _, chunk := range bytes.Split(frame, []byte{recordSeparator}) { + chunk = bytes.TrimSpace(chunk) + if len(chunk) == 0 { + continue + } + out = append(out, chunk) + } + return out +} + +// encodeSignalRMessage JSON-encodes a message and terminates it with 0x1E. +func encodeSignalRMessage(v any) []byte { + data, err := json.Marshal(v) + if err != nil { + data = []byte(`{"error":"encoding failure"}`) + } + return append(data, recordSeparator) +} + +// parseSignalRHandshake validates the first-frame handshake. Only the JSON +// protocol with version 1 is accepted (RS.SHR.14, RS.SHR.15). +func parseSignalRHandshake(data []byte) (string, int, error) { + var hs struct { + Protocol string `json:"protocol"` + Version int `json:"version"` + } + if err := json.Unmarshal(data, &hs); err != nil { + return "", 0, fmt.Errorf("invalid handshake: %w", err) + } + if hs.Protocol != "json" { + return "", 0, fmt.Errorf("unsupported protocol %q (only json is supported)", hs.Protocol) + } + if hs.Version != 1 { + return "", 0, fmt.Errorf("unsupported protocol version %d", hs.Version) + } + return hs.Protocol, hs.Version, nil +} + +// parseSignalREnvelope decodes a SignalR message envelope. +func parseSignalREnvelope(data []byte) (signalREnvelope, error) { + var env signalREnvelope + if err := json.Unmarshal(data, &env); err != nil { + return env, fmt.Errorf("invalid SignalR message: %w", err) + } + return env, nil +} + +// String renders a debugging representation of the envelope. +func (e signalREnvelope) String() string { + return fmt.Sprintf("type=%d invocationId=%s target=%s", e.Type, e.InvocationID, e.Target) +} diff --git a/internal/server/signalr_protocol_test.go b/internal/server/signalr_protocol_test.go new file mode 100644 index 0000000..13f7485 --- /dev/null +++ b/internal/server/signalr_protocol_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Splitting a SignalR text frame on the record separator +Given bytes containing several JSON messages separated by 0x1E +When splitSignalRFrames is called +Then each chunk is returned as a separate message + +Related spec scenarios: RS.SHR.16 +*/ +func TestSplitSignalRFrames(t *testing.T) { + t.Parallel() + + frame := []byte("{\"type\":4,\"target\":\"c\"}\x1e{\"type\":6}\x1e") + chunks := splitSignalRFrames(frame) + require.Len(t, chunks, 2) + assert.JSONEq(t, `{"type":4,"target":"c"}`, string(chunks[0])) + assert.JSONEq(t, `{"type":6}`, string(chunks[1])) +} + +/* +Scenario: Encoding a SignalR message with the record separator +Given a message struct +When encodeSignalRMessage is called +Then the JSON payload is terminated by the 0x1E byte + +Related spec scenarios: RS.SHR.16 +*/ +func TestEncodeSignalRMessage(t *testing.T) { + t.Parallel() + + out := encodeSignalRMessage(map[string]any{"type": 6}) + assert.Equal(t, "{\"type\":6}\x1e", string(out)) + assert.True(t, strings.HasSuffix(string(out), "\x1e")) +} + +/* +Scenario: Parsing a valid SignalR handshake +Given the JSON protocol handshake payload +When parseSignalRHandshake is called +Then it returns protocol json and version 1 without error + +Related spec scenarios: RS.SHR.14 +*/ +func TestParseSignalRHandshake_Valid(t *testing.T) { + t.Parallel() + + proto, version, err := parseSignalRHandshake([]byte(`{"protocol":"json","version":1}`)) + require.NoError(t, err) + assert.Equal(t, "json", proto) + assert.Equal(t, 1, version) +} + +/* +Scenario: Rejecting an unsupported SignalR handshake protocol +Given a handshake requesting messagepack +When parseSignalRHandshake is called +Then it returns an error + +Related spec scenarios: RS.SHR.15 +*/ +func TestParseSignalRHandshake_UnsupportedProtocol(t *testing.T) { + t.Parallel() + + _, _, err := parseSignalRHandshake([]byte(`{"protocol":"messagepack","version":1}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "messagepack") +} + +/* +Scenario: Parsing a SignalR envelope by type +Given a JSON representation of a type-4 StreamInvocation +When parseSignalREnvelope is called +Then the envelope type and target are decoded + +Related spec scenarios: RS.SHR.3, RS.SHR.16 +*/ +func TestParseSignalREnvelope_StreamInvocation(t *testing.T) { + t.Parallel() + + env, err := parseSignalREnvelope([]byte(`{"type":4,"invocationId":"1","target":"priceFeed"}`)) + require.NoError(t, err) + assert.Equal(t, 4, env.Type) + assert.Equal(t, "1", env.InvocationID) + assert.Equal(t, "priceFeed", env.Target) +} + +/* +Scenario: Marshaling a decoded SignalR envelope back to JSON +Given a parsed envelope of type 2 (StreamItem) +When the envelope is marshaled +Then the JSON round-trips through the record-separator encoding + +Related spec scenarios: RS.SHR.3, RS.SHR.16 +*/ +func TestSignalREnvelope_Marshal_Roundtrip(t *testing.T) { + t.Parallel() + + env := signalREnvelope{Type: 2, InvocationID: "1", Item: map[string]any{"price": 1}} + data, err := json.Marshal(env) + require.NoError(t, err) + back, err := parseSignalREnvelope(data) + require.NoError(t, err) + assert.Equal(t, 2, back.Type) + assert.Equal(t, "1", back.InvocationID) +} diff --git a/internal/server/signalr_server_test.go b/internal/server/signalr_server_test.go new file mode 100644 index 0000000..b8ea2c6 --- /dev/null +++ b/internal/server/signalr_server_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const signalRHubDoc = `asyncapi: 3.0.0 +info: + title: SignalR Hub + version: 1.0.0 +x-signalr: + path: /hub +channels: + priceFeed: + address: priceFeed + bindings: + ws: + method: GET + messages: + priceMsg: + examples: + - name: snap + payload: + symbol: ETH + price: 3000 +operations: + receivePrice: + action: receive + channel: + $ref: '#/channels/priceFeed' +` + +func parseSignalRDoc(t *testing.T) *asyncapi.Document { + t.Helper() + doc, err := asyncapi.Parse([]byte(signalRHubDoc)) + require.NoError(t, err) + return doc +} + +/* +Scenario: Declaring a SignalR hub document maps ws channels to the hub +Given an AsyncAPI document with root x-signalr declared on a ws channel +When the server is constructed +Then a SignalR hub is registered at the hub path with the ws channel available as a stream target + +Related spec scenarios: RS.SHR.1, RS.SHR.2, RS.SHR.3 +*/ +func TestNew_RegistersSignalRHub(t *testing.T) { + t.Parallel() + + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: parseSignalRDoc(t), Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + require.Len(t, srv.hubMgr.hubs, 1) + hub := srv.hubMgr.hubs[0] + assert.Equal(t, "/hub", hub.path) + require.Len(t, hub.channels, 1) + assert.Equal(t, "priceFeed", hub.channels["priceFeed"].ID) +} + +/* +Scenario: Negotiate endpoint is served for a SignalR hub +Given a server with a SignalR hub document +When POST {hubPath}/negotiate is invoked +Then the response carries a connection token and available transports + +Related spec scenarios: RS.SHR.8, RS.SHR.9 +*/ +func TestSignalR_ServerNegotiate(t *testing.T) { + t.Parallel() + + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: parseSignalRDoc(t), Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/hub/negotiate?negotiateVersion=1", nil) + srv.router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, float64(1), resp["negotiateVersion"]) + assert.NotEmpty(t, resp["connectionToken"]) + assert.NotEmpty(t, resp["connectionId"]) +} + +/* +Scenario: Signaling hub prefix is applied to the hub path +Given a SignalR hub document with a schema prefix /v1 +When the negotiate endpoint is requested +Then it is served under /v1/hub/negotiate + +Related spec scenarios: RS.SHR.1 +*/ +func TestSignalR_ServerNegotiateWithPrefix(t *testing.T) { + t.Parallel() + + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: parseSignalRDoc(t), Prefix: "/v1"}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/hub/negotiate?negotiateVersion=1", nil) + srv.router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + _ = strings.TrimSpace(rec.Body.String()) +} diff --git a/internal/server/templating_parity_test.go b/internal/server/templating_parity_test.go new file mode 100644 index 0000000..5dc19fa --- /dev/null +++ b/internal/server/templating_parity_test.go @@ -0,0 +1,237 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/golang/mock/gomock" + "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const messageTemplatingWsDoc = `asyncapi: 3.0.0 +info: + title: Echo + version: 1.0.0 +channels: + echo: + address: /echo + bindings: + ws: + method: GET + messages: + echoMsg: + examples: + - name: repl + payload: + echoed: "{$message.payload.id}" + fromChan: "{$channel.sid}" + counter: "{$state.counter}" +operations: + sendEcho: + action: send + channel: + $ref: '#/channels/echo' +` + +/* +Scenario: Message and channel expressions evaluate for AsyncAPI traffic +Given a client message with a payload and channel parameters +When the message is rendered +Then {$message.*} and {$channel.*} resolve against the inbound traffic + +Related spec scenarios: RS.ATM.1, RS.ATM.3 +*/ +func TestTemplateParity_MessageAndChannel(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncWSProtocol, + Action: "send", + Path: "/echo", + Pattern: "/echo", + Messages: mustMessageSpecs(t, messageTemplatingWsDoc), + } + + count, out, err := srv.renderAsyncMessage(mapping, InboundMessage{ + Payload: []byte(`{"id":"u-1"}`), + PathParams: map[string]string{"sid": "conn-9"}, + }) + require.NoError(t, err) + require.Equal(t, 1, count) + + var body map[string]any + require.NoError(t, json.Unmarshal(out, &body)) + assert.Equal(t, "u-1", body["echoed"]) + assert.Equal(t, "conn-9", body["fromChan"]) +} + +/* +Scenario: AsyncAPI state writes land in the schema namespace +Given a message example setting state via x-mock-set-state +When rendered against a prefixed schema +Then the state write goes to that schema's namespace + +Related spec scenarios: RS.ATM.11, RS.ATM.16 +*/ +func TestTemplateParity_StateNamespaceIsolation(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + var setNamespace string + stateStore.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(namespace, key string, value any) { setNamespace = namespace }).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncWSProtocol, + Action: "send", + Path: "/tenant/ch", + Pattern: "/ch", + Prefix: "/tenant", + Messages: mustMessageSpecs(t, statefulMsgDoc), + } + + _, _, err := srv.renderAsyncMessage(mapping, InboundMessage{}) + require.NoError(t, err) + assert.Equal(t, "/tenant", setNamespace) +} + +const headerTemplatingDoc = `asyncapi: 3.0.0 +info: + title: Echo + version: 1.0.0 +channels: + echo: + address: /echo + messages: + echoMsg: + examples: + - name: repl + payload: + trace: "{$message.headers.x-request-id}" +operations: + sendEcho: + action: send + channel: + $ref: '#/channels/echo' +` + +/* +Scenario: Header expression evaluates from inbound message headers +Given a message example referencing {$message.headers.x-request-id} +When rendered with inbound headers +Then the expression resolves to the header value + +Related spec scenarios: RS.ATM.2 +*/ +func TestTemplateParity_MessageHeader(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncWSProtocol, + Action: "send", + Path: "/echo", + Pattern: "/echo", + Messages: mustMessageSpecs(t, headerTemplatingDoc), + } + + count, out, err := srv.renderAsyncMessage(mapping, InboundMessage{ + Headers: map[string]string{"x-request-id": "req-42"}, + }) + require.NoError(t, err) + require.Equal(t, 1, count) + + var body map[string]any + require.NoError(t, json.Unmarshal(out, &body)) + assert.Equal(t, "req-42", body["trace"]) +} + +const stateTemplatingDoc = `asyncapi: 3.0.0 +info: + title: State + version: 1.0.0 +channels: + ch: + address: /ch + messages: + msg: + examples: + - name: ex + payload: + counter: "{$state.counter}" +operations: + send: + action: send + channel: + $ref: '#/channels/ch' +` + +/* +Scenario: State expression evaluates from the schema state store +Given a message example referencing {$state.counter} +When rendered against state containing counter +Then the expression resolves to the stored value + +Related spec scenarios: RS.ATM.4 +*/ +func TestTemplateParity_StateExpression(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{"counter": 7}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncWSProtocol, + Action: "send", + Path: "/ch", + Pattern: "/ch", + Messages: mustMessageSpecs(t, stateTemplatingDoc), + } + + count, out, err := srv.renderAsyncMessage(mapping, InboundMessage{}) + require.NoError(t, err) + require.Equal(t, 1, count) + + var body map[string]any + require.NoError(t, json.Unmarshal(out, &body)) + assert.Equal(t, float64(7), body["counter"]) +} + +const statefulMsgDoc = `asyncapi: 3.0.0 +info: + title: State + version: 1.0.0 +channels: + ch: + address: /ch + messages: + msg: + examples: + - name: ex + payload: {} + x-mock-set-state: + counter: 5 +operations: + send: + action: send + channel: + $ref: '#/channels/ch' +` + +func mustMessageSpecs(t *testing.T, raw string) []*loader.MessageSpec { + t.Helper() + doc, err := asyncapi.Parse([]byte(raw)) + require.NoError(t, err) + require.NotZero(t, len(doc.Channels)) + return loader.MessageSpecsFromAsync(doc.Channels[0].Messages) +} diff --git a/internal/server/wrappers.go b/internal/server/wrappers.go index 3607882..21ff995 100644 --- a/internal/server/wrappers.go +++ b/internal/server/wrappers.go @@ -21,6 +21,8 @@ func (p *loaderRouteProvider) BuildRouteMappings(schemas []SchemaInfo) ([]RouteM for i, schema := range schemas { loaderSchemas[i] = loader.SchemaInfo{ Spec: schema.Spec, + Kind: schema.Kind, + Async: schema.Async, Prefix: schema.Prefix, } } @@ -30,22 +32,7 @@ func (p *loaderRouteProvider) BuildRouteMappings(schemas []SchemaInfo) ([]RouteM return nil, err } - // Convert loader.RouteMapping to RouteMapping - mappings := make([]RouteMapping, len(loaderMappings)) - for i, lm := range loaderMappings { - mappings[i] = RouteMapping{ - Method: lm.Method, - Path: lm.Path, - Pattern: lm.Pattern, - Prefix: lm.Prefix, - ChiPattern: lm.ChiPattern, - Operation: lm.Operation, - Parameters: lm.Parameters, - Responses: lm.Responses, - } - } - - return mappings, nil + return ConvertRouteMappings(loaderMappings), nil } // stateManagerStore wraps state.Manager to implement StateStore. diff --git a/internal/server/ws_adapter.go b/internal/server/ws_adapter.go new file mode 100644 index 0000000..b3aff13 --- /dev/null +++ b/internal/server/ws_adapter.go @@ -0,0 +1,216 @@ +package server + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// wsUpgrader upgrades incoming HTTP requests to WebSocket connections. +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // The mock accepts connections from any origin. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// wsWriter serializes writes to a WebSocket connection. gorilla/websocket +// permits a single writer goroutine; management pushes, event delivery and +// the adapter's own replies all write through this lock. +type wsWriter struct { + mu sync.Mutex + conn *websocket.Conn +} + +// newWSWriter wraps a connection so every write goes through its mutex. +func newWSWriter(conn *websocket.Conn) *wsWriter { return &wsWriter{conn: conn} } + +// write sends a text frame, locking the connection's write mutex. +func (w *wsWriter) write(data []byte) { + if w == nil || w.conn == nil || len(data) == 0 { + return + } + w.mu.Lock() + defer w.mu.Unlock() + _ = w.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + _ = w.conn.WriteMessage(websocket.TextMessage, data) +} + +// writeMessage sends a raw frame (used for pong replies). +func (w *wsWriter) writeMessage(messageType int, data []byte) { + if w == nil || w.conn == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + _ = w.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + _ = w.conn.WriteMessage(messageType, data) +} + +// writeError sends a JSON-encoded error object on the connection. +func (w *wsWriter) writeError(err error) { + data, _ := json.Marshal(map[string]string{"error": err.Error()}) + w.write(data) +} + +// writeClose sends a normal close frame with a reason. +func (w *wsWriter) writeClose(code int, reason string) { + if w == nil || w.conn == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + msg := websocket.FormatCloseMessage(code, reason) + _ = w.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + _ = w.conn.WriteMessage(websocket.CloseMessage, msg) +} + +// abort closes the connection without a close frame (simulated abrupt drop, RS.AMG.17). +func (w *wsWriter) abort() { + if w == nil || w.conn == nil { + return + } + _ = w.conn.Close() +} + +// close closes the connection. +func (w *wsWriter) close() { + if w == nil || w.conn == nil { + return + } + _ = w.conn.Close() +} + +// wsConnection is a single registered WebSocket consumer connection. +type wsConnection struct { + id string + channel string + writer *wsWriter +} + +// connectionRegistry tracks active WebSocket consumer connections per channel, +// enabling broadcast push (D9), discovery (RS.AMG.8-9) and lifecycle control +// (RS.AMG.14-17). +type connectionRegistry struct { + mu sync.RWMutex + byID map[string]*wsConnection + byChan map[string]map[string]*wsConnection + autoID int +} + +func newConnectionRegistry() *connectionRegistry { + return &connectionRegistry{ + byID: make(map[string]*wsConnection), + byChan: make(map[string]map[string]*wsConnection), + } +} + +// register adds a connection and returns a fresh connection id. +func (r *connectionRegistry) register(channel string, writer *wsWriter) 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 r.byChan[channel] == nil { + r.byChan[channel] = make(map[string]*wsConnection) + } + r.byChan[channel][id] = conn + return id +} + +// unregister removes a connection by id. +func (r *connectionRegistry) unregister(id string) { + r.mu.Lock() + defer r.mu.Unlock() + ws, ok := r.byID[id] + if !ok { + return + } + delete(r.byChan[ws.channel], id) + if len(r.byChan[ws.channel]) == 0 { + delete(r.byChan, ws.channel) + } + delete(r.byID, id) +} + +// connections returns all connections for a channel. +func (r *connectionRegistry) connections(channel string) []*wsConnection { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*wsConnection, 0, len(r.byChan[channel])) + for _, ws := range r.byChan[channel] { + out = append(out, ws) + } + return out +} + +// 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 +} + +func newWSProtocolAdapter() *wsProtocolAdapter { + return &wsProtocolAdapter{registry: newConnectionRegistry()} +} + +// Protocol implements ProtocolAdapter. +func (a *wsProtocolAdapter) Protocol() string { return asyncWSProtocol } + +// Handler builds the WebSocket upgrade handler for an AsyncAPI ws channel. +func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + wr := newWSWriter(conn) + channel := mapping.Path + id := a.registry.register(channel, wr) + defer a.registry.unregister(id) + + slog.Debug("WebSocket consumer connected", "connectionId", id, "channel", channel) + + // Receive-operation emission on connect (RS.ASP.7). + if mapping.Action == "receive" { + out, herr := handler.HandleMessage(r.Context(), InboundMessage{ConnectionID: id, PathParams: addressParams(r)}) + if herr == nil && out != nil { + wr.write(out) + } + } + + // Read loop: send-operation acceptance + reply (RS.ASP.6, RS.ASP.9). + for { + messageType, payload, rerr := conn.ReadMessage() + if rerr != nil { + break + } + if messageType == websocket.PingMessage { + wr.writeMessage(websocket.PongMessage, payload) + continue + } + out, herr := handler.HandleMessage(r.Context(), InboundMessage{ + Payload: payload, + ConnectionID: id, + PathParams: addressParams(r), + }) + if herr != nil { + wr.writeError(herr) + continue + } + // A send with no reply messages back an ack frame (RS.ASP.9). + if out == nil { + out = []byte("{}") + } + wr.write(out) + } + } +} diff --git a/internal/server/ws_adapter_test.go b/internal/server/ws_adapter_test.go new file mode 100644 index 0000000..dfb0253 --- /dev/null +++ b/internal/server/ws_adapter_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Acknowledging a ws send with no reply via echo +Given an AsyncAPI ws channel with a send operation and no reply message +When a ws client sends a message +Then the server acknowledges receipt with a frame + +Related spec scenarios: RS.ASP.6, RS.ASP.9 +*/ +func TestWSProtocolAdapter_SendEchoAck(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncWSProtocol, + Action: "send", + Path: "/socket", + Pattern: "/socket", + Messages: nil, + } + + adapter := srv.adapterForProtocol(asyncWSProtocol) + require.NotNil(t, adapter) + + ts := httptest.NewServer(adapter.Handler(mapping, srv.asyncMessageHandler(mapping))) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + ts.URL[4:] + "/socket" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"hello":"world"}`))) + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(msg)) +} + +/* +Scenario: Receive operation emits the message example on connect +Given an AsyncAPI ws channel with a receive operation and a message example +When a ws client connects +Then the server emits the operation's message example + +Related spec scenarios: RS.ASP.2, RS.ASP.7 +*/ +func TestWSProtocolAdapter_ReceiveEmitsOnConnect(t *testing.T) { + t.Parallel() + + srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() + + mapping := &RouteMapping{ + Protocol: asyncWSProtocol, + Action: "receive", + Path: "/prices", + Pattern: "/prices", + Messages: []*loader.MessageSpec{ + { + Name: "priceMsg", + Examples: []*loader.MessageExampleSpec{ + {Payload: map[string]any{"symbol": "ETH", "price": 3000}}, + }, + }, + }, + } + + adapter := srv.adapterForProtocol(asyncWSProtocol) + require.NotNil(t, adapter) + + ts := httptest.NewServer(adapter.Handler(mapping, srv.asyncMessageHandler(mapping))) + defer ts.Close() //nolint:errcheck + wsURL := "ws" + ts.URL[4:] + "/prices" + 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.JSONEq(t, `{"symbol":"ETH","price":3000}`, string(msg)) +} + +/* +Scenario: Connection registry tracks and removes connections +Given a registry with a registered connection +When unregister is called +Then the connection is no longer listed + +Related spec scenarios: RS.AMG.8 +*/ +func TestConnectionRegistry_Lifecycle(t *testing.T) { + t.Parallel() + + registry := newConnectionRegistry() + id := registry.register("/chan", nil) + assert.Equal(t, "/chan", registry.connections("/chan")[0].channel) + + registry.unregister(id) + assert.Empty(t, registry.connections("/chan")) + assert.Nil(t, registry.byID[id]) +} diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/.openspec.yaml b/openspec/changes/archive/2026-09-02-add-asyncapi-support/.openspec.yaml new file mode 100644 index 0000000..032461f --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/design.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/design.md new file mode 100644 index 0000000..b054468 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/design.md @@ -0,0 +1,152 @@ +# Design: AsyncAPI 3.x Support (MVP) + +## Context + +OASMock loads OpenAPI specs via `internal/loader` (`LoadSchemas` → `loadSingleSchema`), builds `RouteMapping`s in `internal/loader/router.go`, and serves them through the HTTP server in `internal/server`. Templating (runtime expressions `{$...}`, `x-mock-*` extensions, state, history) is built around `*openapi3.Operation`/`*openapi3.Example` types and the `runtime.Evaluator`. + +The AsyncAPI ecosystem has no single canonical Go parser like `kin-openapi`. The `parser-go` official parser is archived and validates only 1.x–2.6.0 (it rejects `asyncapi: 3.x`). The viable Go option for 3.x is `github.com/benelser/go-asyncapi` (AsyncAPI 3.0.0 parser/validator with `$ref` resolution), which is young but loads/resolves both 3.0.0 and 3.1.0 documents and yields typed domain objects (channels, operations, messages, bindings, components). Because it is young it is adopted **behind an internal abstraction** so it stays cheap to swap. + +This is an MVP: `ws` and `http` protocol bindings get real but minimal serving, and the `ws` surface additionally hosts a **SignalR hub overlay** (negotiate, handshake, `\x1e` framing, held-open streams) plus an **event-driven push bus** (`x-event-trigger` / `x-send-events`) that connects REST producers to ws consumers without model-to-model references. `amqp` is **not** served in this change (it is treated as an unsupported protocol). Deeper protocol fidelity (AMQP broker, SSE/LongPolling, MessagePack, Ack/Sequence) is explicitly deferred. + +## Goals / Non-Goals + +**Goals:** +- Auto-detect OpenAPI vs AsyncAPI files by root version key, with no new CLI flags. +- Load/validate AsyncAPI 3.0.0 and 3.1.0 via the vendored parser (behind `internal/asyncapi`), exposing channels/operations/messages/bindings. +- Map channels to runnable mock surfaces for `http` and `ws` (MVP). +- **Event-driven push** (`event-driver`): OpenAPI examples fire named events (`x-event-trigger`); AsyncAPI message examples subscribe (`x-send-events` with `on: ` or built-ins `receive`/`connect`/`cron`) and emit to channel consumers; event payloads templated via `{$event.*}`. REST and AsyncAPI models never reference one another. +- Serve a **SignalR hub** (official-client compatible) declared at the document root via `x-signalr`: `negotiate`, handshake, `\x1e` framing, streams map to channels, one-shot invocations map to operations, server → client pushes. +- Reuse the existing templating pipeline unchanged for AsyncAPI message examples: expressions, `x-mock-*` extensions, state, history, dynamic examples. +- Keep prefixes, state-namespace isolation, CORS, delay, verbose logging, and management API working for AsyncAPI traffic. Management additionally fires named events ad-hoc. + +**Non-Goals:** +- AMQP 0-9-1 serving (`amqp` bindings fail startup like `kafka`). +- Binance-specific diff-depth book (U/u continuity, zero-qty deletes, snapshot re-bootstrap). Streaming clients needing sequence numbers/pacing use existing **state + inline templating + `cron` send-events**; a dedicated book engine is deferred. +- SSE / LongPolling (SignalR half-transports), MessagePack, `Ack`/`Sequence` (negotiate `useAck`): listed-and-declined or 400 on attempt. +- Per-connection session/account routing: event-driven delivery is **broadcast with client-side filtering**; session identity is not modeled. +- Additional AsyncAPI protocols (`kafka`, `mqtt`, `nats`, ...) — unsupported protocols produce a startup error. +- AsyncAPI 2.x or OpenAPI 2.0 (swagger) support. +- Re-implementing the runtime expression engine — it is reused as-is. + +## Decisions + +### D1: Use `github.com/benelser/go-asyncapi` behind an `internal/asyncapi` abstraction +Adopt `github.com/benelser/go-asyncapi` (vendored: it declares a wrong module path and requires Go 1.25) as the AsyncAPI 3.x parser, but expose it only through a thin internal abstraction (`internal/asyncapi` package): +- `internal/asyncapi` defines a **neutral `Document` view** (channels, operations, messages, bindings, examples with `x-mock-*` extensions) and a `Parse(data)` entry point — no third-party types leak past this package. +- A vendor copy (`third_party/go-asyncapi`) is wired via a `replace` directive; the vendored go.mod is corrected (module path `github.com/benelser/go-asyncapi`, `go 1.23` to match project/CI) and test files are pruned. The embedded `asyncapi-3.0.0.json` schema is kept only for ±structural checks. +- The abstraction performs its own **structural validation** for both 3.0.0 and 3.1.0 (mandatory top-level fields, channels/operations presence, unknown-protocol detection, version-major == 3). The library's bundled schema is NOT used as the source of truth because it is 3.0.0-only and rejects `x-mock-*` extensions (`additionalProperties: false`). +- `x-mock-*` extensions on message examples are captured by `internal/asyncapi` from the raw document (the vendored `MessageExample` is lightly patched to retain `x-*` keys). **Root/document-level `x-*` extensions** (`x-signalr`, and `x-send-events` on message examples) and **OpenAPI example `x-event-trigger`** are captured the same way on the neutral views. +- **Rationale**: isolates the unproven dependency behind two seams (one package imports it; one `replace` pins it), so swapping to a stricter 3.1.0 parser later only changes `internal/asyncapi` and the `replace`. +- **Alternative considered**: hand-rolled `map[string]any` model — rejected as it would duplicate ref-resolution/validation logic and drift from spec; `parser-go` — rejected (archived, no 3.x). + +### D2: Spec-type autodetect factory in `internal/loader` +Replace the direct `openapi3.NewLoader()` call with a factory that reads raw bytes and dispatches on the root key: +- `openapi:` → `loadOpenAPI(data)` (existing `kin-openapi` path) +- `asyncapi:` → `loadAsyncAPI(data)` (`internal/asyncapi` path, checks major version == 3) +- neither → schema error (preserves exit code 3 and `RS.MSC.3`). + +`SchemaInfo` gains a `Kind` field (`OpenAPI` | `AsyncAPI`) plus an AsyncAPI view (`*asyncapi.Document` from `internal/asyncapi`), keeping `Prefix` semantics unchanged. +- **Rationale**: centralized detection keeps `LoadSchemas` signature stable and mixes cleanly with multi-schema/prefix config. +- **Alternative considered**: file-extension sniffing — rejected (JSON/YAML has no reliable extension signal). + +### D3: Unify routing behind a `SpecRoute` consumer model +Introduce a protocol-neutral route representation the server can consume regardless of source: +```go +type SpecRoute struct { + Protocol string // "http" | "ws" + Address string // OpenAPI path pattern OR AsyncAPI channel address (prefixed) + Method string // for http + Action string // "send" | "receive" | "" (OpenAPI default) + Messages []MessageSpec // OpenAPI: examples; AsyncAPI: operation messages w/ examples +} +``` +`BuildRouteMappings` keeps its signature (`[]RouteMapping`) for OpenAPI compatibility, while AsyncAPI channels produce `RouteMapping`s carrying an AsyncAPI-backed `MessageSpec` list. The server switches example resolution from `*openapi3.Example` to a small internal `ExampleValue` that wraps either source. +- **Rationale**: avoids a parallel server for AsyncAPI; one selection/state/history pipeline for both spec kinds. +- **Alternative considered**: separate AsyncAPI server component — rejected (duplicates history/state/management logic; breaks cohesion goals). + +### D4: Protocol adapters as strategies (ws/http + SignalR overlay) +A small `ProtocolAdapter` interface drives per-protocol serving: +```go +type ProtocolAdapter interface { + Serve(ctx context.Context, route SpecRoute, handler MessageHandler) error +} +``` +- `httpAdapter`: plain HTTP routes (nearly free reuse of existing pipeline). +- `wsAdapter`: WebSocket upgrade endpoint; MVP = accept connection, echo/`x-mock-*`-shaped responses, broadcast receive-operation examples. When the document declares root `x-signalr`, the ws session is handed to the **SignalR overlay** (D7) instead of raw framing. +- No `amqpAdapter` in this change — `amqp` bindings are rejected as unsupported at load (startup error, exit code 3). +- **Rationale**: strategy isolates protocol differences; MVP keeping adapters small preserves low complexity. +- **Alternative considered**: full-featured brokers / separate SignalR server library — rejected as heavy for MVP; the SignalR wire handling is small protocol code plus a spec-driven content layer. + +### D5: AsyncAPI templating via reused pipeline +Templating is source-agnostic: the runtime `Evaluator` already evaluates `{$...}` expressions from named `DataSource`s. For AsyncAPI we register a `MessageSource` (payload/headers/channel params) under the existing source names, so `RS.ATM.*` scenarios pass without engine changes. Event payloads reuse the same evaluator via an event `DataSource` (`{$event.*}`) for event-driven emission (D9). +`x-mock-*` extraction (extensions/match.go, extract.go) is refactored to operate on a thin `ExampleValue` wrapper (`Payload map[string]any` + `Headers`), implemented for both OpenAPI examples and AsyncAPI message examples. +- **Rationale**: maximal reuse, minimal new logic; satisfies the "all templating support from openapi" requirement. +- **Note**: the extension matching consumes `openapi3.Example.ExtensionProps` today — the wrapper keeps that internal. + +### D6: Channel parameters → expression data +AsyncAPI channel parameters (e.g. `user/{userId}`) are captured per connection/request and exposed through `{$channel.}` via `MessageSource`. HTTP channel params map onto existing path-param handling. + +### D7: Root-level SignalR hub runtime as a ws overlay +An AsyncAPI document with a root-level `x-signalr` extension is served as a single SignalR hub (one hub per document, mirroring the single-gateway `x-rpc` precedent): +- **Negotiate** — `POST {hubPath}/negotiate` (also `?negotiateVersion=0|1`) returns `connectionToken`, `connectionId`, `negotiateVersion`, `availableTransports` (WebSockets Text/Binary only). Unsupported transport→HTTP 400; the token is server-generated and correlated with the subsequent upgrade. +- **Handshake** — the first ws frame must be `{"protocol":"json","version":1}`; the server replies `{}\x1e`; any other content closes the connection. +- **Framing** — ws text frames are split on byte `0x1E` (record separator); each chunk is one SignalR message `{type,…}`. Types: 1 Invocation, 2 StreamItem, 3 Completion, 4 StreamInvocation, 5 CancelInvocation, 6 Ping. +- **Streams = channels**: a `StreamInvocation` (type 4) with `target` equal to a channel ID emits the channel's snapshot example as `StreamItem(s)` on the client's `invocationId` and holds the stream open. The open-stream registry tracks `(connection, invocationId, channel ID)` so event-driven pushes (D9) can append items. +- **One-shot invocations = operations**: an `Invocation` (type 1) with `target` equal to an operation ID is answered with a `Completion` carrying the operation's message example. +- **CancelInvocation / completion** → `Completion` (type 3); **server→client one-shot push** uses `Invocation` (type 1) with a server-assigned id. +- **Rationale**: official SignalR clients enforce the wire protocol (negotiate token, handshake, `\x1e` framing, envelope types, invocationId correlation) — a raw example emitter cannot satisfy them. Mapping streams→channels and invocations→operations keeps all content native AsyncAPI (no parallel hub config vocabulary), exactly as `x-rpc` maps procedures→operations. +- **Alternative considered**: per-channel `x-signalr` config — rejected as over-expanded (hub/targets/push/invocations vocabulary duplicate of channels/operations/messages); per-connection session identity — rejected (broadcast + client-side filtering at D9). + +### D8: Event bus — producer/consumer decoupling via named events (event-driver) +A server-side event broker decouples REST producers from ws/SignalR consumers; the two models never reference each other. +- **Trigger — `x-event-trigger` on an OpenAPI response example** (list form): `{name, payload?, delay?, global?}`. Fired when that example is selected and its response produced. `delay` (ms) schedules delivery; `global: true` makes the event server-wide, otherwise it is schema-local. +- **Subscription — `x-send-events` on an AsyncAPI message example**: each entry is `{on: , wait?: ms}` or a bare built-in (`receive`) / object built-in (`{on: connect, wait}` / `{on: cron, wait}`). When a named event fires (or the built-in trigger occurs), the subscribed message is emitted to the channel's consumers. +- **Delivery is broadcast; clients filter.** No session registry or account routing: every consumer of the channel receives the templated message, and consuming apps filter on event payload (`{$event.accountId}`). For a SignalR channel, emission targets its **open streams** as `StreamItem`s, or a server `Invocation` when no stream is open. +- **`{$event.*}` data source** — the event payload is exposed to consumer templates via a runtime `DataSource`, evaluated at emission time alongside `{$state.*}`/`{$env.*}`. +- **Management fire-event endpoint** fires a named event ad-hoc with the same delay/global semantics (covers monitoring and tests without a REST example). +- **Rationale**: keeps REST and AsyncAPI models orthogonal (the earlier `x-mock-push` cross-reference is removed); fan-out, cross-schema (`global`) delivery and ad-hoc firing all reuse the same broker + scheduler (delay → existing push scheduler, RS.AMG.1-4). +- **Alternative considered**: `x-mock-push` referencing a target channel/session from a REST example — rejected: couples the two models and reintroduces session routing; per-connection session identity — rejected: broadcast + `{$event.*}` filtering is simpler and matches real pub-sub. + +### D9: Event-driven push into channels and SignalR streams +Events bridge producers to the ws/http delivery surface: +- A fired event resolves every message example subscribed to it (schema-local unless `global`), evaluates its templates with the event payload + schema state/env, and emits the resulting message to the channel's connected consumers (broadcast). `delay` on the trigger is honored before any emission; a consumer's `wait` applies to `connect`/`cron` built-ins. +- On a SignalR channel, emission targets **open streams** registered per `(connection, invocationId, channel)` — matching `RS.EVT.13`/`RS.SHR.18`; when none are open, the message is sent as a server `Invocation` (RS.SHR.19). +- **Rationale**: one broker serves spec-triggered, built-in-paced, and management-fired events; delivery logic (open stream vs server invocation vs raw ws) is owned by the protocol adapter, not the event bus. + +### D10: Management API as the async-mocking control surface +`/_mock/examples` route resolution is extended to AsyncAPI route identifiers (protocol + address, plus method/action for http) so dynamic examples work for ws/http channels. A dedicated async-mocking surface is added for runtime consumer control: +- **Delayed push**: `delay` (ms) on push requests; delivery scheduled per-consumer; `0`/omitted = immediate. +- **Targeted/broadcast push**: optional `connectionId` selects one consumer; omitting broadcasts to all consumers of the channel. +- **Consumer discovery**: list active `connectionId`s per channel, including open SignalR streams. +- **Templated push payloads**: pushed payloads run through the existing runtime evaluator ({$state.*}, {$env.*}) using the schema namespace at delivery time. +- **Recurring push**: schedule a message at a fixed interval; cancellable by push ID. +- **Fire-event**: fire a named event ad-hoc with payload/delay/global, reusing the event broker (D8). +- **Connection lifecycle control**: force-disconnect a consumer by `connectionId` (with optional close reason/code) or simulate an abrupt drop (abort without a normal close frame) for behavior-testing reconnect/backoff logic. +- **Rationale**: keeps the server the single owner of connections/state; management API is the natural control plane (mirrors `/_mock/examples` philosophy). +- **Other useful async-mock extensions considered (future)**: correlation/reply simulation, consumer-group sequencing (e.g. RoundRobin on broadcast), batch push, rate-limited push, per-connection scripted push sessions. + +### D11: Stream sequence/pacing via state + cron send-events (depth book deferred) +Streaming clients that need monotonic sequence numbers or cadence (the original Binance diff-depth story) are served by **existing primitives**, not a book engine: +- Counters/IDs live in the schema's state namespace (`x-mock-set-state`), referenced in payloads via `{$state.counter}` with increments. +- Pacing is emulated with the built-in **`cron` send-event** on a message example (D8) at the desired interval. +- No `x-mock-depth`, no `internal/depthbook`, no U/u book, no snapshot re-bootstrap in this change. +- **Rationale**: covers the generic "numbered, paced stream" pattern with zero new state machines; a real order-book engine (zero-qty deletes, snapshot↔stream `lastUpdateId` correlation, forced-reconnect re-bootstrap) is tightly coupled to one client and is deferred to a dedicated future change. +- **Alternative considered**: full `internal/depthbook` — rejected as out-of-scope for the MVP and too concrete to a single story. + +## Risks / Trade-offs + +- **benelser/go-asyncapi maturity** → Vendored and pinned via `replace`; `internal/asyncapi` abstraction keeps it out of the rest of the codebase; structural validation and `x-mock-*` extraction are done in `internal/asyncapi` so parser quirks don't leak. Swap path = new adapter + `replace` change. +- **SignalR wire complexity** → The overlay is ~a few hundred lines of protocol code; correctness is pinned by a spec-conformance integration test implementing our own frames (handshake → StreamInvocation → held-open stream → pushFill → cancel). Content stays declarative, so only framing/envelope logic is bespoke. +- **WebSocket upgrade vs reverse-proxy edge cases** (timeouts, ping/pong) → Use a battle-tested ws library (`github.com/gorilla/websocket`); keep default ping/pong grace periods. +- **Behavior drift between OpenAPI and AsyncAPI templating** → Reuse the exact same selection/state/history code paths (D3/D5); add parity integration tests over both spec kinds. +- **Startup failure surface grows** (unsupported protocol/version must fail fast) → Extend exit-code-3 tests; deterministic message listing unsupported protocol/version (now includes `amqp`). + +## Migration Plan + +- No breaking CLI changes: `--from`, `--prefix`, and config `schemas` are unchanged (AsyncAPI files are just accepted and auto-detected). +- Rolling back is not applicable mid-change; the change is additive except that `amqp` bindings switch from "accepted" (previously planned) to "unsupported". Since the change is unshipped, this is a design-level correction, not a migration. +- Docs updated in the same change: `docs/architecture.md` (loader/server sections), `docs/cli.md` (schema types), `docs/project.md` (structure additions for asyncapi loader/adapters/signalr overlay). + +## Open Questions +- Whether the vendored `benelser/go-asyncapi` parser must later be replaced by a stricter 3.1.0 parser; the `internal/asyncapi` abstraction makes that a local change. +- Whether a future change should deliver the Binance diff-depth book engine (U/u continuity, zero-qty deletes, snapshot re-bootstrap) on top of the sequence/pacing primitives (D11). \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/proposal.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/proposal.md new file mode 100644 index 0000000..01ce2a8 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/proposal.md @@ -0,0 +1,44 @@ +## Why + +OASMock currently only understands OpenAPI patterns, limiting its usefulness for teams building event-driven and real-time APIs. AsyncAPI is the de-facto standard for describing event-driven systems (WebSockets, message brokers). Supporting AsyncAPI 3.0.0/3.1.0 lets users mock event-driven and real-time backends with the same workflow and templating power they already get for OpenAPI — including SignalR hubs and server-initiated pushes that real-time consumers rely on. + +## What Changes + +- **Autodetect spec factory**: The loader detects whether a given file is an OpenAPI or AsyncAPI spec (by `openapi` vs `asyncapi` root key and version) and dispatches to the correct loader automatically — no new CLI flags required. +- **AsyncAPI loading & validation**: Load and validate AsyncAPI 3.0.0 and 3.1.0 specs from files and inline references, with the same failure semantics as OpenAPI (exit code 3). +- **MVP protocol support**: Mock channels/operations for `ws` and `http` protocol bindings. HTTP channels reuse the existing HTTP mock pipeline; ws channels get minimal MVP serving (connect, echo, receive-operation emission). Unsupported protocols (including `amqp` and `kafka`) fail startup with a clear error. +- **SignalR hub runtime**: An AsyncAPI document whose root declares `x-signalr` is served as a single ASP.NET Core SignalR hub — `negotiate`, handshake, `\x1e` framing, `StreamInvocation` → held-open `StreamItem` streams, cancel/completion, and server→client pushes. Hub streams map to channels and one-shot invocations map to operations, so all content stays native AsyncAPI. +- **Event-driven push bus**: A server-side event broker decouples producers from consumers. OpenAPI response examples fire named events via `x-event-trigger` (list form, optional payload/delay/global); AsyncAPI message examples subscribe via `x-send-events` (`on: ` or built-ins `receive`/`connect`/`cron`) and emit to channel consumers, templated with `{$event.*}`. REST and AsyncAPI models never reference one another; delivery is broadcast with client-side filtering. +- **Full templating parity**: AsyncAPI message examples support the complete OpenAPI templating realization — runtime expressions (`{$request.*}`, `{$state.*}`, `{$env.*}`, `{$message.*}`, `{$channel.*}`, `{$event.*}`), example selection with `x-mock-*` extensions, and dynamic example handling. +- **Management API for AsyncAPI**: `/_mock/examples` also accepts AsyncAPI channel routes (protocol + address) for dynamic example injection. A new async-mocking surface drives consumers at runtime: delayed push, targeted/broadcast push, connected-consumer discovery (connection IDs and open streams), templated push payloads, recurring scheduled push, a fire-event endpoint, and connection lifecycle control (force disconnect / simulate drop). +- **Isolated namespaces**: Each AsyncAPI spec keeps its own state namespace and prefix behavior, consistent with multi-schema OpenAPI support; events are schema-local unless declared `global: true`. +- **Non-goal for this change**: AMQP serving; Binance diff-depth book (U/u continuity, zero-qty deletes, snapshot re-bootstrap) — streaming sequence/pacing is provided via state + `cron` send-events; SignalR half-transports (SSE/LongPolling), MessagePack, and Ack/Sequence; per-connection session/account routing. + +## Capabilities + +### New Capabilities +- `asyncapi-loader`: Auto-detect AsyncAPI vs OpenAPI specs and load/validate AsyncAPI 3.0.0 & 3.1.0. +- `asyncapi-protocols`: Map AsyncAPI channels/operations/messages to runnable mocks for `ws` and `http` protocol bindings (MVP); unsupported protocols fail startup. +- `signalr-hub-runtime`: Serve documents with root `x-signalr` as ASP.NET Core SignalR hubs — negotiate, handshake, `\x1e` framing, held-open streams (channels) and one-shot invocations (operations), server pushes. +- `event-driver`: Event bus — `x-event-trigger` on OpenAPI examples and `x-send-events` on AsyncAPI message examples, with `{$event.*}` templating, schema-local/global scoping, and a management fire-event endpoint. +- `asyncapi-templating`: Reuse the full runtime-expression + `x-mock-*` extension + state/history pipeline for AsyncAPI message examples. +- `asyncapi-management`: Management API for driving async mocking — delayed push delivery, targeted/broadcast push, consumer discovery, templated push payloads, recurring scheduled push, fire-event, and connection lifecycle control (force disconnect / simulate drop). + +### Modified Capabilities +- `mock-server-core`: Schema loading and request routing requirements are extended to accept AsyncAPI specs (autodetected) in addition to OpenAPI, retaining prefixing, state isolation, history, CORS, and delay behavior. +- `cli`: `--from`/config file `schemas` entries and schema-failure exit-code behavior now apply to AsyncAPI specs too. +- `management-api`: Dynamic example injection (`/_mock/examples`) accepts AsyncAPI channel routes in addition to OpenAPI path/method routes. + +## Impact + +- `internal/loader/schema.go` — introduce autodetect factory; add AsyncAPI load path + validation. +- `internal/loader/router.go` — build channel/operation mappings for AsyncAPI alongside OpenAPI path mappings. +- `internal/asyncapi/` — vendored-parser-backed neutral AsyncAPI document view, structural validation, and `x-*` extension capture (message examples + root `x-signalr`/`x-send-events`). +- `internal/server/` — new protocol adapters (ws, http), the SignalR overlay (negotiate/handshake/framing/stream registry), and an event broker (`x-event-trigger`/`x-send-events` emission); reuse of the example-selection, state, history, and management pipelines. +- `internal/server/server_management.go` — AsyncAPI route resolution for `/_mock/examples`, the async-mocking endpoints (push, schedule, consumers/streams, lifecycle), and the fire-event endpoint. +- `internal/runtime/` — message- and event-oriented data sources for expressions (`MessageSource`, `EventSource`; `{$message.*}`, `{$channel.*}`, `{$event.*}`). +- `internal/extensions/` — reuse `x-mock-*` extraction on AsyncAPI message examples (`ExampleValue` wrapper), plus `x-event-trigger` handling on OpenAPI examples. +- `cmd/oasmock/mock.go` — wire AsyncAPI specs through config without breaking flag semantics. +- `api/openapi.yaml` — add async-mocking endpoints (including fire-event) and AsyncAPI route targeting to the management API contract. +- `go.mod` — new dependency for AsyncAPI parsing (`github.com/benelser/go-asyncapi`, vendored under `third_party/` via `replace`) and the WebSocket library (`github.com/gorilla/websocket`). +- Docs: `docs/architecture.md`, `docs/project.md` (structure), and CLI docs updated. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-loader/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-loader/spec.md new file mode 100644 index 0000000..3771d6b --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-loader/spec.md @@ -0,0 +1,72 @@ +# AsyncAPI Loader + +## Purpose + +Automatic detection and loading of AsyncAPI 3.0.0/3.1.0 specifications alongside OpenAPI specifications, so the mock server can consume event-driven API descriptions with the same `--from`/`schemas` workflow used for OpenAPI. + +## ADDED Requirements + +### Requirement: AsyncAPI specification detection +The loader SHALL detect, for each loaded file, whether it is an OpenAPI or an AsyncAPI specification by inspecting the root-level version key (`openapi` vs `asyncapi`), choosing the corresponding loader automatically without explicit user configuration. + +#### Scenario RS.AAL.1: Detecting an OpenAPI specification +- **WHEN** a file contains root key `openapi: 3.1.0` +- **THEN** the loader treats the file as an OpenAPI specification and loads it with the OpenAPI loader + +#### Scenario RS.AAL.2: Detecting an AsyncAPI specification +- **WHEN** a file contains root key `asyncapi: 3.0.0` +- **THEN** the loader treats the file as an AsyncAPI specification and loads it with the AsyncAPI loader + +#### Scenario RS.AAL.3: Detecting AsyncAPI 3.1.0 +- **WHEN** a file contains root key `asyncapi: 3.1.0` +- **THEN** the loader loads the file as an AsyncAPI 3.1.0 specification + +#### Scenario RS.AAL.4: File with neither version key +- **WHEN** a file contains neither an `openapi` nor an `asyncapi` root key +- **THEN** the loader reports a schema loading error for that file + +### Requirement: AsyncAPI 3.0.0 loading +The loader SHALL load and structurally validate AsyncAPI 3.0.0 specification files (YAML or JSON), resolving inline references within the document. + +#### Scenario RS.AAL.5: Loading a valid AsyncAPI 3.0.0 spec +- **WHEN** the server starts with `--from asyncapi30.yaml` +- **AND** the file is a valid AsyncAPI 3.0.0 specification +- **THEN** the server parses and validates the file without error + +#### Scenario RS.AAL.6: Invalid AsyncAPI 3.0.0 spec +- **WHEN** the file is missing mandatory AsyncAPI fields (e.g., no `channels`, no `operations`) +- **THEN** the loader reports a schema validation error + +### Requirement: AsyncAPI 3.1.0 loading +The loader SHALL load and structurally validate AsyncAPI 3.1.0 specification files (YAML or JSON), which introduce changes such as the `webhooks` component and adjusted `components` requirements. + +#### Scenario RS.AAL.7: Loading a valid AsyncAPI 3.1.0 spec +- **WHEN** the server starts with `--from asyncapi31.yaml` +- **AND** the file is a valid AsyncAPI 3.1.0 specification +- **THEN** the server parses and validates the file without error + +#### Scenario RS.AAL.8: 3.x version without supported protocol +- **WHEN** an AsyncAPI 3.x file is otherwise valid but contains only unknown protocol bindings (including `amqp`) +- **THEN** the loader reports a validation error naming the unsupported protocol + +### Requirement: Multiple AsyncAPI schemas with prefixes +The loader SHALL load multiple AsyncAPI schemas alongside OpenAPI schemas, pairing each source with its prefix in the same way OpenAPI schemas are handled today. + +#### Scenario RS.AAL.9: Multiple AsyncAPI schemas with prefixes +- **WHEN** the server starts with `--from async1.yaml --prefix /a1 --from async2.yaml --prefix /a2` +- **THEN** the server loads both AsyncAPI schemas and attaches the respective prefixes to their channels + +#### Scenario RS.AAL.10: Mixing OpenAPI and AsyncAPI sources +- **WHEN** the server starts with `--from openapi.yaml --from asyncapi.yaml` +- **THEN** both specifications are loaded through their respective loaders and served together + +### Requirement: AsyncAPI model exposure +The loader SHALL expose the loaded AsyncAPI model (channels, operations, messages, components, bindings) to the routing layer in a form that preserves the original structure for further mapping. + +#### Scenario RS.AAL.11: Exposing channels and operations +- **WHEN** an AsyncAPI 3.x spec is loaded +- **THEN** the router receives channel, operation, message, and binding definitions derived from the spec + +#### Scenario RS.AAL.12: Unsupported AsyncAPI major version +- **WHEN** a file has root key `asyncapi` with a version major other than 3 (e.g., `2.6.0`) +- **THEN** the loader reports a schema validation error stating the version is unsupported \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-management/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-management/spec.md new file mode 100644 index 0000000..1699f9f --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-management/spec.md @@ -0,0 +1,104 @@ +# AsyncAPI Management + +## Purpose + +Management API surface for driving async mocking at runtime: pushing message examples to connected consumers (immediately or after a delay), targeting individual consumers or broadcasting, listing live connections and open streams, firing events on the event bus, and injecting with runtime expressions — complementing the static spec-driven behavior of `asyncapi-protocols`, `asyncapi-templating`, and `event-driver`. + +## ADDED Requirements + +### Requirement: Delayed example push to consumer +The mock server SHALL extend the management API with an endpoint to push a message example to consumers of an AsyncAPI channel, accepting an optional `delay` (milliseconds) before the push is delivered. + +#### Scenario RS.AMG.1: Pushing with a delay +- **WHEN** a management request pushes a message to an AsyncAPI channel with `delay: 500` +- **THEN** the message is delivered to the channel's connected consumers 500 ms after the request (and the push is accepted immediately) + +#### Scenario RS.AMG.2: Pushing without a delay +- **WHEN** a management request pushes a message without a `delay` +- **THEN** the message is delivered to connected consumers immediately + +#### Scenario RS.AMG.3: Negative or zero delay validation +- **WHEN** a management request includes a negative `delay` +- **THEN** the server responds with HTTP 400; `delay: 0` is allowed and means immediate + +#### Scenario RS.AMG.4: Pushing to a channel with no consumers +- **WHEN** a management request pushes a message to a valid AsyncAPI channel that has no connected consumers +- **THEN** the server accepts the request without error and no message is delivered + +### Requirement: Targeted and broadcast push +The pushed message SHALL be deliverable to a single consumer connection or broadcast to all consumers of the channel. + +#### Scenario RS.AMG.5: Pushing to a specific consumer +- **WHEN** a management request includes a `connectionId` for an active consumer +- **THEN** only that consumer receives the message + +#### Scenario RS.AMG.6: Broadcasting to all consumers +- **WHEN** a management request omits `connectionId` +- **THEN** all consumers currently connected to the channel receive the message + +#### Scenario RS.AMG.7: Unknown consumer reference +- **WHEN** a management request includes a `connectionId` that has no active connection +- **THEN** the server responds with HTTP 404 + +### Requirement: Connected consumer discovery +The mock server SHALL expose the currently connected consumers per AsyncAPI channel, including open SignalR streams. + +#### 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 + +### Requirement: Templated push payloads +Pushed message payloads SHALL support runtime expressions ({$state.*}, {$env.*}) evaluated at delivery time, using the schema's state namespace. + +#### Scenario RS.AMG.10: Pushing a templated payload +- **WHEN** a management request pushes a payload containing `{$state.counter}` or `{$env.X}` +- **THEN** the expression is evaluated against the schema's state/ environment before delivery + +#### Scenario RS.AMG.11: Invalid expression in pushed payload +- **WHEN** a management request pushes a payload containing an unresolvable or malformed expression +- **THEN** the server rejects the request with HTTP 400 + +### 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 + +### Requirement: Connection lifecycle control +The mock server SHALL allow a management request to terminate a connected consumer's connection, with an optional close reason, or to simulate an abrupt client-side drop. + +#### Scenario RS.AMG.14: Force disconnecting a consumer +- **WHEN** a management request force-disconnects a consumer by `connectionId` +- **THEN** the server closes that consumer's connection with a normal close frame + +#### Scenario RS.AMG.15: Disconnect with a close reason +- **WHEN** a management request force-disconnects a consumer including a close reason/code +- **THEN** the server closes the connection delivering that reason/code to the peer + +#### Scenario RS.AMG.16: Disconnect of an unknown consumer +- **WHEN** a management request force-disconnects a `connectionId` that has no active connection +- **THEN** the server responds with HTTP 404 + +#### Scenario RS.AMG.17: Simulating an abrupt client drop +- **WHEN** a management request simulates a drop for a consumer +- **THEN** the server aborts the connection without a normal close frame, mimicking a network-level loss + +### Requirement: Fire an event on the event bus +The mock server SHALL expose a management endpoint to fire a named event ad-hoc, reusing the event broker and its delay semantics (per `event-driver`). + +#### Scenario RS.AMG.20: Firing an event via management API +- **WHEN** a management request fires a named event with a payload and optional delay +- **THEN** the server delivers it like a spec-triggered event (immediately or after the delay) + +#### Scenario RS.AMG.21: 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 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-protocols/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-protocols/spec.md new file mode 100644 index 0000000..d7538b7 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-protocols/spec.md @@ -0,0 +1,59 @@ +# AsyncAPI Protocols + +## Purpose + +Mapping AsyncAPI 3.x channels, operations, and messages onto runnable mock surfaces for the `http` and `ws` protocol bindings. `ws` channels are served as raw WebSockets, or as SignalR hub streams when the document declares a root-level `x-signalr` (see `signalr-hub-runtime`). This is an MVP: each protocol gets minimal but real serving so clients can connect and exchange mock messages. `amqp` (and any other protocol) is not served and fails startup with a clear error. + +## ADDED Requirements + +### Requirement: Channel to route mapping +The router SHALL convert AsyncAPI channels into mock routes according to their protocol binding. Channels without a recognized protocol binding, or operations referencing a channel with no bindings, SHALL be reported as unsupported. + +#### Scenario RS.ASP.1: Mapping an HTTP channel +- **WHEN** an AsyncAPI channel declares an `http` binding with a method and path +- **THEN** the router creates a mock HTTP route matching that method and path + +#### Scenario RS.ASP.2: Mapping a WebSocket channel +- **WHEN** an AsyncAPI channel declares a `ws` binding with an address (e.g., `wss://host/socket`) +- **THEN** the server exposes an upgradeable WebSocket endpoint at the corresponding relative path + +#### Scenario RS.ASP.3: Declaring a SignalR hub document +- **WHEN** an AsyncAPI document declares a root-level `x-signalr` extension +- **THEN** the server serves the document's ws channels as a SignalR hub (negotiate + framed streams) per `signalr-hub-runtime` + +#### Scenario RS.ASP.4: Channel with unknown protocol binding +- **WHEN** an AsyncAPI channel declares a protocol binding other than `http`, `ws`, or `amqp` (e.g., `kafka`) +- **THEN** the server fails to start and reports the unsupported protocol + +#### Scenario RS.ASP.5: Channel without binding information +- **WHEN** an AsyncAPI channel has no `bindings` section usable to determine a server protocol +- **THEN** the router reports the channel as invalid with a clear error + +### Requirement: Operation handling +The router SHALL map AsyncAPI send and receive operations onto concrete mock behaviors: send operations accept incoming messages and publish/produce the operation's reply message; receive operations emit the operation's message to connected clients. + +#### Scenario RS.ASP.6: Send operation accepts messages +- **WHEN** a client sends a message to a channel whose operation `action` is `send` +- **THEN** the server accepts it and, when a reply message is present, responds with the reply message's example + +#### Scenario RS.ASP.7: Receive operation emits messages +- **WHEN** a client connects to a channel whose operation `action` is `receive` +- **THEN** the server emits the operation's message example to the client (over ws) or exposes it for polling (over http) + +### Requirement: Prefix handling for channels +Channel addresses SHALL honor the schema-level prefix used on the command line/config, while repeated-serving of a single channel is a non-goal for this MVP. + +#### Scenario RS.ASP.8: Channel address with prefix +- **WHEN** an AsyncAPI schema is loaded with prefix `/v1` and declares channel `user/signedup` +- **THEN** the mock channel is served under the prefixed address `/v1/user/signedup` + +### Requirement: Default responses +For a send operation without an explicit reply message, the server SHALL acknowledge the message using a protocol-appropriate default. + +#### Scenario RS.ASP.9: Acknowledging a send with no reply +- **WHEN** a client sends a message over ws and the operation has no reply message +- **THEN** the server acknowledges receipt (ws: echo/ack frame) without producing a payload + +#### Scenario RS.ASP.10: HTTP send without reply +- **WHEN** an HTTP POST arrives for a send operation that has no reply message +- **THEN** the server responds with HTTP 200 and an empty body \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-templating/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-templating/spec.md new file mode 100644 index 0000000..2ecc3f5 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/asyncapi-templating/spec.md @@ -0,0 +1,107 @@ +# AsyncAPI Templating + +## Purpose + +Bring the complete OpenAPI templating realization to AsyncAPI message examples: runtime expression evaluation (`{$...}`), `x-mock-*` extension-driven example selection and response shaping, and state/history integration — identical behavior to what OpenAPI operations enjoy today. This includes the event-driven data source `{$event.*}` for messages emitted by the `event-driver` bus. + +## ADDED Requirements + +### Requirement: Event data source in message templates +The mock server SHALL expose event payload data to subscribed AsyncAPI message examples as the `{$event.*}` data source, evaluated at emission time. + +#### Scenario RS.ATM.17: Evaluating an event payload expression +- **WHEN** a message example subscribed to an event (per `event-driver` RS.EVT.7) references `{$event.orderId}` +- **AND** the fired event's payload contains key `orderId` +- **THEN** the expression evaluates to that event payload value at emission time + +#### Scenario RS.ATM.18: Sequence/pacing via state and cron trigger +- **WHEN** a mock consumer needs monotonic sequence numbers or paced delivery for a stream +- **THEN** the server supports it with `x-mock-set-state` counters referenced via `{$state.*}` and the built-in `cron` send-event trigger (per `event-driver` RS.EVT.10); no dedicated sequence engine is required + +### Requirement: Runtime expression evaluation on message examples +The mock server SHALL evaluate runtime expressions in AsyncAPI message examples using the same evaluator used for OpenAPI, exposing protocol-relevant data sources: the incoming message payload, headers, the channel address, server state, environment, and event payloads. + +#### Scenario RS.ATM.1: Evaluating payload expression +- **WHEN** a message example contains expression `{$message.payload.id}` +- **AND** the client message payload contains property `id` +- **THEN** the expression evaluates to that property value + +#### Scenario RS.ATM.2: Evaluating header expression +- **WHEN** a message example contains expression `{$message.header.trace}` +- **AND** the client message carries header `trace` +- **THEN** the expression evaluates to the header value + +#### Scenario RS.ATM.3: Evaluating channel parameter expression +- **WHEN** a message example contains expression `{$channel.sid}` +- **AND** the channel defines parameter `sid` captured from the address +- **THEN** the expression evaluates to the captured parameter value + +#### Scenario RS.ATM.4: Evaluating state expression +- **WHEN** a message example contains expression `{$state.counter}` +- **AND** state for the schema contains key `counter` +- **THEN** the expression evaluates to the stored value + +#### Scenario RS.ATM.5: Evaluating environment expression +- **WHEN** a message example contains expression `{$env.SERVICE_NAME}` +- **AND** the environment variable `SERVICE_NAME` is set +- **THEN** the expression evaluates to the environment variable value + +### Requirement: x-mock-match example selection +The mock server SHALL select AsyncAPI message examples using the `x-mock-match` extension (with legacy `x-mock-params-match` handling) evaluated against the incoming message, mirroring OpenAPI behavior. + +#### Scenario RS.ATM.6: Selecting message example by x-mock-match +- **WHEN** multiple message examples exist and exactly one has `x-mock-match` conditions satisfied by the client message +- **THEN** the server selects that example + +#### Scenario RS.ATM.7: Selecting first example with no conditions +- **WHEN** an operation has multiple message examples without `x-mock-match`/`x-mock-params-match` +- **THEN** the server selects the first example (by AsyncAPI definition order) + +#### Scenario RS.ATM.8: x-mock-match overrides deprecated alias +- **WHEN** a message example has both `x-mock-match` and `x-mock-params-match` +- **THEN** only `x-mock-match` is considered and a deprecation error is written to stderr + +### Requirement: x-mock-skip and x-mock-once on message examples +The mock server SHALL honor `x-mock-skip` and `x-mock-once` on AsyncAPI message examples with the same semantics as OpenAPI. + +#### Scenario RS.ATM.9: Skipping a message example +- **WHEN** a message example has `x-mock-skip: true` +- **THEN** the server skips that example during selection + +#### Scenario RS.ATM.10: One-time message example removal +- **WHEN** a message example with `x-mock-once: true` is selected +- **THEN** the server removes it from future consideration + +### Requirement: State mutation via x-mock-set-state +The mock server SHALL apply `x-mock-set-state` from a matched AsyncAPI message example to the schema's state namespace, including increment and delete semantics. + +#### Scenario RS.ATM.11: Setting state from message example +- **WHEN** a selected message example has `x-mock-set-state` containing key-value pairs +- **THEN** the server updates the schema state with those pairs + +#### Scenario RS.ATM.12: Incrementing state from message example +- **WHEN** `x-mock-set-state` contains `{ counter: { increment: 1 } }` +- **AND** previous `counter` value is a number +- **THEN** the server increments `counter` by 1 + +#### Scenario RS.ATM.13: Deleting state key from message example +- **WHEN** `x-mock-set-state` contains `key: null` +- **THEN** the server removes `key` from state + +### Requirement: x-mock-headers on message responses +The mock server SHALL apply `x-mock-headers` from a matched AsyncAPI message example to the outgoing message/response envelope. + +#### Scenario RS.ATM.14: Response headers from message example +- **WHEN** a selected message example has `x-mock-headers` +- **THEN** the server includes those headers in the outgoing message/response + +### Requirement: State and history integration for AsyncAPI traffic +Message handling SHALL record request/response history records and use the same state store as OpenAPI traffic, keeping each AsyncAPI schema isolated in its own namespace. + +#### Scenario RS.ATM.15: Recording AsyncAPI message exchanges in history +- **WHEN** a ws/http message is processed against an AsyncAPI channel +- **THEN** the exchange is recorded in the request history with channel/address, headers, payload, and timestamp + +#### Scenario RS.ATM.16: AsyncAPI state namespace isolation +- **WHEN** multiple AsyncAPI schemas are served +- **THEN** each schema's `x-mock-set-state` writes go to its own isolated state namespace \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/cli/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/cli/spec.md new file mode 100644 index 0000000..101a7cb --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/cli/spec.md @@ -0,0 +1,46 @@ +# CLI Delta + +## Purpose + +The `--from`/`schemas` configuration and schema-failure handling now apply to AsyncAPI 3.x specifications as well as OpenAPI, with no new flags required. + +## MODIFIED Requirements + +### Requirement: Mock command +The mock command SHALL start a mock server based on OpenAPI and/or AsyncAPI schema(s), using the extensions described in extensions.md. + +#### Scenario RS.CLI.4: Starting mock server with default schema +- **WHEN** user runs `oasmock` +- **THEN** the server starts listening on port 19191 with schema from src/openapi.yaml + +#### Scenario RS.CLI.6: Starting mock server with multiple schemas +- **WHEN** user runs `oasmock --from api/v1/openapi.yaml --prefix /v1 --from api/v2/openapi.yaml --prefix /v2` +- **THEN** the server loads both schemas and routes requests under the respective prefixes + +#### Scenario RS.CLI.30: Starting mock server with an AsyncAPI spec +- **WHEN** user runs `oasmock --from api/asyncapi.yaml` +- **THEN** the server auto-detects the AsyncAPI 3.x spec and serves its channels + +#### Scenario RS.CLI.31: Mixing OpenAPI and AsyncAPI via config file +- **WHEN** a `.oasmock.yaml` file contains: + ```yaml + schemas: + - src: openapi.yaml + - src: asyncapi.yaml + ``` +- **THEN** the CLI loads both, auto-detecting each specification type + +### Requirement: Exit codes +The CLI SHALL return appropriate exit codes as defined in cli.md, extending the schema-failure code to AsyncAPI specifications. + +#### Scenario RS.CLI.14: Successful execution +- **WHEN** the mock server starts successfully +- **THEN** the CLI exits with code 0 + +#### Scenario RS.CLI.16: Schema loading or validation failure +- **WHEN** an OpenAPI or AsyncAPI schema cannot be loaded or is invalid +- **THEN** the CLI exits with code 3 + +#### Scenario RS.CLI.17: Port already in use +- **WHEN** the requested port is already occupied +- **THEN** the CLI exits with code 4 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/event-driver/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/event-driver/spec.md new file mode 100644 index 0000000..291105a --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/event-driver/spec.md @@ -0,0 +1,90 @@ +# Event Driver + +## Purpose + +A server-side event bus that decouples producers from consumers: an OpenAPI response example fires a named event (`x-event-trigger`), and AsyncAPI message examples subscribe to events (`x-send-events`) to emit messages into a channel's consumers — without the two models referencing each other. Event payloads are available to consumer templates via the `{$event.*}` data source. + +## ADDED Requirements + +### Requirement: Event trigger on an OpenAPI example +The mock server SHALL support firing named events from an OpenAPI response example via the `x-event-trigger` extension, triggered whenever that example is selected for a response. Multiple triggers per example SHALL be supported (list form). + +#### Scenario RS.EVT.1: Firing a single event +- **WHEN** a selected OpenAPI example has `x-event-trigger` with a `name` +- **THEN** the server fires the named event with an empty payload after the response is produced + +#### Scenario RS.EVT.2: Firing multiple events from one example +- **WHEN** an OpenAPI example has an `x-event-trigger` list with several `name` entries +- **THEN** the server fires each named event + +#### Scenario RS.EVT.3: Event with payload +- **WHEN** an `x-event-trigger` entry includes `payload` +- **THEN** the event carries that payload, evaluable by consumers as `{$event.*}` + +#### Scenario RS.EVT.4: Delayed event +- **WHEN** an `x-event-trigger` entry includes `delay` +- **THEN** the server delivers the event (and any resulting consumer messages) after that delay + +### Requirement: Event name scoping +Event names SHALL be schema-local by default and server-wide only when declared `global: true`. + +#### Scenario RS.EVT.5: Schema-local event +- **WHEN** an OpenAPI schema fires an event without `global: true` +- **THEN** only `x-send-events` subscriptions within the same schema receive it + +#### Scenario RS.EVT.6: Global event +- **WHEN** an `x-event-trigger` entry sets `global: true` +- **THEN** the event is broadcast over all loaded schemas and any matching subscription anywhere receives it + +### 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 + +### Requirement: Broadcast delivery with client-side filtering +Event-driven messages SHALL be broadcast to the consuming channel's connected consumers; the mock does NOT route by session or account — consumers filter by payload. + +#### Scenario RS.EVT.12: Broadcasting an event-driven message +- **WHEN** an event fires and a message example subscribed to it exists on a channel with active consumers +- **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 subscribed 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 subscribes to it +- **THEN** the event is accepted with no delivery (no error) + +#### Scenario RS.EVT.15: Event with no consumers +- **WHEN** an event fires, a subscription exists, but the channel has no connected consumers +- **THEN** the event is accepted without error and no message is delivered + +### Requirement: Management fire-event endpoint +The mock server SHALL expose a management API endpoint to fire a named event ad-hoc, reusing the event broker and its delay semantics. + +#### Scenario RS.EVT.16: Firing an event via management API +- **WHEN** a management request fires a named event with a payload and optional delay +- **THEN** the server delivers it like a spec-triggered event (immediately or after the delay) + +#### Scenario RS.EVT.17: Event payload templating at fire time +- **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 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/management-api/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/management-api/spec.md new file mode 100644 index 0000000..de31fe0 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/management-api/spec.md @@ -0,0 +1,33 @@ +# Management API Delta + +## Purpose + +The management API extends to AsyncAPI: dynamic example injection now resolves not only OpenAPI paths but also AsyncAPI channels/routes, so `/_mock/examples` works for ws/http channels served from AsyncAPI specs. The async-mocking surface (see `asyncapi-management`) additionally supports push by connection, consumer/stream discovery, recurring push, a fire-event endpoint, and connection lifecycle control. + +## ADDED Requirements + +### Requirement: Add example targeting an AsyncAPI route +The mock server SHALL accept a route identifier that resolves to an AsyncAPI channel/operation (protocol + address, and HTTP method/action when applicable) in `POST /_mock/examples`, with the same storage, matching, once/TTL, conditions, and validation semantics as OpenAPI routes. + +#### Scenario RS.MAPI.19: Adding a dynamic example for an AsyncAPI channel +- **WHEN** a POST request is sent to `/_mock/examples` with an AsyncAPI route identifier (protocol `ws`/`http` and a channel address) +- **THEN** the server stores the example for that channel and responds with `AddExampleResponse` containing success and an example ID + +#### Scenario RS.MAPI.20: Dynamic example used by AsyncAPI traffic +- **WHEN** a ws/http message arrives for an AsyncAPI channel that has a dynamic example with matching conditions +- **THEN** the server selects the dynamic example using the same selection pipeline as spec examples + +#### Scenario RS.MAPI.21: No matching AsyncAPI route +- **WHEN** a POST request is sent to `/_mock/examples` with an AsyncAPI route identifier that does not match any loaded channel +- **THEN** the server responds with HTTP 400 (no matching route) + +### Requirement: Fire an event on the event bus +The management API SHALL expose an endpoint to fire a named event ad-hoc, reusing the event broker and its delay semantics (per `event-driver`). + +#### Scenario RS.MAPI.22: Firing an event via management API +- **WHEN** a management request fires a named event with a payload and optional delay +- **THEN** the server delivers it like a spec-triggered event (immediately or after the delay) to matching `x-send-events` consumers + +#### 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 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/mock-server-core/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/mock-server-core/spec.md new file mode 100644 index 0000000..60f606d --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/mock-server-core/spec.md @@ -0,0 +1,45 @@ +# Mock Server Core Delta + +## Purpose + +Extend the core mock server so schema loading and routing accept AsyncAPI 3.x specifications (autodetected) in addition to OpenAPI, while preserving prefixing, state isolation, history, CORS, and delay behavior. + +## MODIFIED Requirements + +### Requirement: OpenAPI schema loading +The mock server SHALL load one or more OpenAPI 3.x or AsyncAPI 3.x schemas (auto-detected by the loader) from files specified via the `--from` CLI option, pairing each with an optional path prefix. + +#### Scenario RS.MSC.1: Loading a single schema +- **WHEN** the server starts with `--from api/openapi.yaml` +- **THEN** the server parses the YAML file and validates it as OpenAPI (3.1 or 3.0) + +#### Scenario RS.MSC.2: Loading multiple schemas with prefixes +- **WHEN** the server starts with `--from v1.yaml --prefix /v1 --from v2.yaml --prefix /v2` +- **THEN** the server loads both schemas and routes requests under the respective path prefixes + +#### Scenario RS.MSC.3: Schema validation failure +- **WHEN** the specified file is not a valid OpenAPI or AsyncAPI 3.x schema +- **THEN** the server fails to start and exits with code 3 + +#### Scenario RS.MSC.50: Loading an AsyncAPI schema alongside OpenAPI +- **WHEN** the server starts with `--from openapi.yaml --from asyncapi.yaml` +- **THEN** the server auto-detects and loads both specifications through their respective loaders + +#### Scenario RS.MSC.51: AsyncAPI channels honor schema prefix +- **WHEN** an AsyncAPI schema is loaded with a prefix +- **THEN** its channel addresses are served under that prefix + +### Requirement: Request routing +The mock server SHALL route incoming traffic to the matching OpenAPI path or AsyncAPI channel based on method and path pattern for HTTP, and channel address for ws, so that only defined operations are served. + +#### Scenario RS.MSC.7: No matching operation +- **WHEN** a request arrives at a path/method not defined in any loaded schema +- **THEN** the server responds with HTTP 404 + +#### Scenario RS.MSC.52: Routing an AsyncAPI HTTP channel +- **WHEN** an HTTP request arrives matching an AsyncAPI channel with an `http` binding +- **THEN** the server selects the corresponding operation for processing + +#### Scenario RS.MSC.53: Routing an AsyncAPI ws channel +- **WHEN** a ws client connects to a channel address defined in an AsyncAPI schema +- **THEN** the server selects the corresponding receive/send operation for processing \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/signalr-hub-runtime/spec.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/signalr-hub-runtime/spec.md new file mode 100644 index 0000000..c09a8a1 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/specs/signalr-hub-runtime/spec.md @@ -0,0 +1,121 @@ +# SignalR Hub Runtime + +## Purpose + +Serve an ASP.NET Core SignalR hub over the existing WebSocket transport so real SignalR clients (`@microsoft/signalr`, ASP.NET Core clients) can connect, invoke streaming and one-shot hub targets, and receive server-initiated pushes. The hub is declared at the document root via the `x-signalr` extension; streams map to AsyncAPI channels and one-shot invocations map to operations. Only the wire protocol (negotiate, handshake, `\x1e` framing, envelopes) is implemented by the mock. + +## ADDED Requirements + +### Requirement: Root-level x-signalr extension +The mock server SHALL treat a parseable AsyncAPI document whose root declares `x-signalr` as a single SignalR hub, served over the document's WebSocket channels. + +#### Scenario RS.SHR.1: Declaring a SignalR hub document +- **WHEN** an AsyncAPI document has a root-level `x-signalr` extension with a hub path +- **THEN** the server serves the document as one SignalR hub at that path, exposing a negotiate endpoint and framed WebSocket streams + +#### Scenario RS.SHR.2: One hub per document +- **WHEN** an AsyncAPI document declares `x-signalr` with a single hub configuration +- **THEN** the server registers exactly one hub for that document + +### Requirement: Streams map to channels +WebSocket channels in an `x-signalr` document SHALL be streamable hub targets: a client `StreamInvocation` whose `target` is a channel ID is answered by the channel's snapshot message, which stays open for further items. + +#### Scenario RS.SHR.3: StreamInvocation by channel ID +- **WHEN** a client sends a `StreamInvocation` (type 4) with `target` equal to a declared channel ID +- **THEN** the server emits the channel's snapshot example as a `StreamItem` (type 2) on the client's `invocationId` + +#### Scenario RS.SHR.4: Stream held open +- **WHEN** the snapshot `StreamItem` has been sent +- **THEN** the server does NOT send a `Completion`; the stream stays open and registered for that `(connection, invocationId)` + +#### Scenario RS.SHR.5: Unknown channel target +- **WHEN** a `StreamInvocation` names a `target` that matches no channel ID +- **THEN** the server replies with a `Completion` (type 3) carrying an error for that invocation + +### Requirement: One-shot invocations map to operations +Operations in an `x-signalr` document SHALL be invocable as one-shot hub targets: a client `Invocation` (type 1) whose `target` is an operation ID is answered by a `Completion` with the operation's message example. + +#### Scenario RS.SHR.6: Invocation by operation ID +- **WHEN** a client sends an `Invocation` with `target` equal to an operation ID +- **THEN** the server replies with a `Completion` (type 3) carrying the operation's message example as the result + +#### Scenario RS.SHR.7: Unknown operation target +- **WHEN** an `Invocation` names a target matching no operation ID +- **THEN** the server replies with a `Completion` carrying an error for that invocation + +### Requirement: Negotiate endpoint +For the hub path, the server SHALL expose `POST {hubPath}/negotiate` returning supported transport info and a connection token used by the client's subsequent WebSocket upgrade. + +#### Scenario RS.SHR.8: Successful negotiation +- **WHEN** a client POSTs to `{hubPath}/negotiate` with `negotiateVersion=1` +- **THEN** the server responds 200 with `connectionToken`, `connectionId`, `negotiateVersion: 1`, and `availableTransports` listing WebSockets with Text and Binary transfer formats + +#### Scenario RS.SHR.9: Negotiate protocol version +- **WHEN** a client requests negotiation without `negotiateVersion` (treated as 0) +- **THEN** the server responds with `negotiateVersion: 1` (its supported version) and includes both `connectionToken` and `connectionId` + +#### Scenario RS.SHR.10: Negotiate for an unsupported transport +- **WHEN** a client requests a transport other than WebSockets (e.g., server-sent events or long polling) +- **THEN** the server lists WebSockets only; upgrades for SSE/long-polling return HTTP 400 + +### Requirement: WebSocket upgrade with token correlation +The server SHALL require the WebSocket upgrade request to the hub path to carry the `id` query parameter matching a previously issued connection token. + +#### Scenario RS.SHR.11: Upgrade with matching token +- **WHEN** a client upgrades to the hub path with `?id=` where the token was issued by negotiate +- **THEN** the server accepts the upgrade and binds the connection to that token/connection + +#### Scenario RS.SHR.12: Upgrade with unknown token +- **WHEN** a client upgrades with an `id` token that was not issued +- **THEN** the server rejects the upgrade with HTTP 404 + +#### Scenario RS.SHR.13: Upgrade without token +- **WHEN** a client upgrades without an `id` parameter +- **THEN** the server binds the connection to a fresh internally generated token so the connection can still be addressed by `connectionId` + +### Requirement: Handshake and framing +The first message on a SignalR connection SHALL be the protocol handshake, and all subsequent messages SHALL be JSON terminated by the ASCII record separator `0x1E` (unit separator byte). + +#### Scenario RS.SHR.14: Valid handshake +- **WHEN** the client's first WebSocket text frame is `{"protocol":"json","version":1}` +- **THEN** the server replies `{}\x1e` and switches to framed messaging + +#### Scenario RS.SHR.15: Unsupported protocol handshake +- **WHEN** the client's first frame requests a protocol other than `json` (e.g., `messagepack`) +- **THEN** the server sends a handshake error and closes the connection + +#### Scenario RS.SHR.16: Framed messages carry the record separator +- **WHEN** the server sends an `Invocation`, `StreamItem`, or `Completion` +- **THEN** the message JSON is terminated by the `0x1E` byte, and multiple messages may share one WebSocket text frame separated by that byte + +### Requirement: Streaming invocation lifecycle +A `StreamInvocation` to a channel target SHALL produce a snapshot, keep the stream open for further items, and complete on `CancelInvocation` or stream end. + +#### Scenario RS.SHR.17: Cancel closes the stream +- **WHEN** the client sends a `CancelInvocation` (type 5) for an open `invocationId` +- **THEN** the server sends a `Completion` (type 3) and removes the stream from the open-stream registry + +#### Scenario RS.SHR.18: Event-driven item appended to open stream +- **WHEN** a server-initiated event triggers a message on a channel with open stream handles +- **THEN** the server emits the templated message as an additional `StreamItem` on each open `invocationId` without completing the stream (per `event-driver` RS.EVT.13) + +### Requirement: Server-initiated one-shot push +For a SignalR hub, a server-side push that does not target an open stream SHALL be sent as a server-to-client `Invocation` with a server-assigned invocation id. + +#### Scenario RS.SHR.19: Server Invocation push +- **WHEN** an event-driven message is emitted for a hub channel but no open stream matches +- **THEN** the server sends an `Invocation` (type 1) with `invocationId: ` and the message as `arguments` + +### Requirement: Ping handling +The server SHALL respond to SignalR `Ping` messages (type 6); pings carry no invocation id. + +#### Scenario RS.SHR.20: Ping is echoed +- **WHEN** the client sends `{type:6}` +- **THEN** the server replies `{type:6}` without affecting any streams + +### Requirement: Open stream registry +The server SHALL keep an open-stream registry per connection so event-driven messages can be pushed into held-open streams and so management discovery can list them. + +#### Scenario RS.SHR.21: Registry tracks open streams +- **WHEN** one or more streams are open on a connection +- **THEN** the registry retains `(connectionId, invocationId, channel ID)` so discovery and push endpoints can list and target them \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-02-add-asyncapi-support/tasks.md b/openspec/changes/archive/2026-09-02-add-asyncapi-support/tasks.md new file mode 100644 index 0000000..3609bd4 --- /dev/null +++ b/openspec/changes/archive/2026-09-02-add-asyncapi-support/tasks.md @@ -0,0 +1,97 @@ +# Tasks: AsyncAPI 3.x Support (MVP) + +Reference: proposal.md (why), specs/** (what), design.md (how). + +## 1. Dependencies & Spec-Type Detection + +- [x] 1.1 Vendor AsyncAPI parser (`github.com/benelser/go-asyncapi`) under `third_party/` with corrected module path + `go 1.23`, wire via `replace` in `go.mod`; add `github.com/gorilla/websocket` (RS.AAL.1-4; design D1) +- [x] 1.2 Implement spec-type detection: read raw file bytes, dispatch on root key `openapi` vs `asyncapi`, else schema error (RS.AAL.1, RS.AAL.2, RS.AAL.4) +- [x] 1.3 Extend `SchemaInfo` with `Kind` (OpenAPI|AsyncAPI) and AsyncAPI document view; keep `Prefix` semantics (RS.AAL.9-12) +- [x] 1.4 Unit tests for loadSchemas dispatch covering OpenAPI, AsyncAPI 3.0.0/3.1.0, and non-spec files + +## 2. AsyncAPI Loading & Validation + +- [x] 2.1 Implement `internal/asyncapi` abstraction: neutral `Document` view + `Parse` from vendored benelser parser; reject non-3.x major versions with clear error (RS.AAL.3, RS.AAL.12; design D1) +- [x] 2.2 Validate AsyncAPI 3.0.0 specs structurally (mandatory channels/operations) in `internal/asyncapi`, surfacing validation failures as schema errors (RS.AAL.5, RS.AAL.6) +- [x] 2.3 Validate AsyncAPI 3.1.0 specs including `webhooks`/components handling (RS.AAL.7) +- [x] 2.4 Detect unknown/unsupported protocol bindings (including `amqp`) and report errors naming the unsupported protocol (RS.AAL.8); capture `x-mock-*` extensions on message examples (design D1) +- [x] 2.5 Unit tests for AsyncAPI 3.0.0/3.1.0 load+validate and coverage of scenario RS.AAL.5-8, 12 + +## 3. Unified Routing Model + +- [x] 3.1 Introduce protocol-neutral `SpecRoute` (Protocol, Address, Method, Action, Messages) and adapt `RouteMapping` to carry AsyncAPI-backed message specs (design D3) +- [x] 3.2 Map AsyncAPI channels to routes: `http` bindings → method+path; `ws` bindings → address (RS.ASP.1-3) +- [x] 3.3 Report channels with unknown bindings (including `amqp`) or missing binding info as startup errors (RS.ASP.4, RS.ASP.5) +- [x] 3.4 Apply schema prefix to AsyncAPI channel addresses (RS.ASP.8, RS.MSC.51) +- [x] 3.5 Unit tests for channel→route mapping and error cases (RS.ASP.1-5, RS.ASP.8) +- [x] 3.6 Extend model capture for root/document-level `x-signalr` and message-example `x-send-events`; surface in the neutral views (design D1, D7-D8) + +## 4. Protocol Adapters (MVP) + +- [x] 4.1 Define `ProtocolAdapter` interface and `MessageHandler`; register adapters keyed by protocol (design D4) +- [x] 4.2 Implement `httpAdapter` reusing the existing HTTP pipeline for AsyncAPI http channels (RS.ASP.1, RS.ASP.10) +- [x] 4.3 Implement `wsAdapter` (WebSocket upgrade; accept/send; MVP echo + receive-operation emission; connection registration) (RS.ASP.2, RS.ASP.6-7, RS.ASP.9) +- [x] 4.4 Wire adapters into server startup; server fails to start on unsupported protocol (including `amqp`) with exit code 3 (RS.ASP.4) +- [x] 4.5 Unit tests per adapter: http, ws echo/ack; integration test connecting a ws client + +## 5. SignalR Hub Runtime (root-scoped x-signalr) + +- [x] 5.1 Implement root-level `x-signalr` parsing: hub path; streams map to channels, one-shot invocations map to operations (RS.SHR.1-7, design D7) +- [x] 5.2 Implement `negotiate` endpoint (`POST {hubPath}/negotiate`): connectionToken/connectionId/negotiateVersion/availableTransports; transport/version handling (RS.SHR.8-10) +- [x] 5.3 Implement token-correlated WebSocket upgrade (`?id=`; 404 on unknown; fresh token when absent) (RS.SHR.11-13) +- [x] 5.4 Implement handshake + `\x1e` framing: first-frame handshake validation, JSON record-separator framing, frame splitting (RS.SHR.14-16) +- [x] 5.5 Implement `StreamInvocation` (type 4) by channel ID → snapshot `StreamItem`, held-open stream, cancel → `Completion` (RS.SHR.3-5, RS.SHR.17) +- [x] 5.6 Implement one-shot `Invocation` (type 1) by operation ID → `Completion` result (RS.SHR.6-7) +- [x] 5.7 Implement open-stream registry and event-driven item append into open streams; server `Invocation` when no stream open (RS.SHR.18-19, RS.EVT.13) +- [x] 5.8 Implement `Ping` (type 6) response (RS.SHR.20); register streams for discovery (RS.SHR.21) +- [x] 5.9 Unit tests per layer (negotiate, handshake, framing, stream/invocation lifecycle); integration test with a raw-frame SignalR client (handshake → StreamInvocation → snapshot → held-open → event-driven item → cancel) + +## 6. Event Driver (x-event-trigger / x-send-events) + +- [x] 6.1 Parse `x-event-trigger` (list form: name/payload/delay/global) on OpenAPI examples and `x-send-events` (named + `connect`/`cron`/`receive`) on AsyncAPI message examples (RS.EVT.1-11, design D8) +- [x] 6.2 Implement the event broker: schema-local vs `global: true` scoping, fire-on-example-selection, subscriber resolution, delay scheduling (RS.EVT.4-6, RS.EVT.14-15) +- [x] 6.3 Implement `{$event.*}` runtime data source evaluated at emission time alongside schema state/env (RS.EVT.8, RS.ATM.17) +- [x] 6.4 Implement broadcast emission to channel consumers and SignalR open-stream targeting (RS.EVT.12-13) +- [x] 6.5 Implement fire-event management endpoint with delay and payload templating (RS.EVT.16-17, RS.MAPI.22-23, RS.AMG.20-21) +- [x] 6.6 Unit tests for parsing, scoping, broker, templating, and fire-event; integration test covering REST fill → open SignalR stream + +## 7. Templating Parity + +- [x] 7.1 Add `MessageSource` and `EventSource` data sources (payload/headers/channel params/event payload) registered into `runtime.Evaluator`; wire `{$message.*}`, `{$channel.*}`, `{$event.*}` (RS.ATM.1-3, RS.ATM.5, RS.ATM.17) +- [x] 7.2 Introduce `ExampleValue` wrapper abstracting OpenAPI examples and AsyncAPI message examples; refactor extension extraction (`x-mock-match`, `skip`, `once`, `set-state`, `headers`) onto it (RS.ATM.6-14, design D5) +- [x] 7.3 Evaluate `{$state.*}` for AsyncAPI traffic in the schema's isolated namespace (RS.ATM.4, RS.ATM.16) +- [x] 7.4 Record AsyncAPI message exchanges in the request history store (RS.ATM.15) +- [x] 7.5 Unit tests covering RS.ATM.1-18; parity tests asserting identical selection behavior across OpenAPI and AsyncAPI examples + +## 8. AsyncAPI + Async Mocking Management API + +- [x] 8.1 Extend `/_mock/examples` route resolution to AsyncAPI route identifiers (protocol + address; method/action for http); return 400 for unmatched AsyncAPI routes (RS.MAPI.19, RS.MAPI.21) +- [x] 8.2 Implement dynamic-example selection for ws/http AsyncAPI traffic using the shared selection pipeline (RS.MAPI.20) +- [x] 8.3 Implement push endpoint with `delay` (ms): immediate (0/omitted) and delayed delivery to channel consumers; negative delay → 400; no-consumers accepted (RS.AMG.1-4) +- [x] 8.4 Implement targeted push (`connectionId`) and broadcast push (omitted); unknown `connectionId` → 404 (RS.AMG.5-7) +- [x] 8.5 Implement consumer discovery endpoint returning active connections per channel (with open streams for SignalR); empty list when none (RS.AMG.8-9) +- [x] 8.6 Evaluate runtime expressions in pushed payloads against schema state/env; unresolvable expression → 400 (RS.AMG.10-11) +- [x] 8.7 Implement recurring scheduled push by interval with cancellation by push ID (RS.AMG.12-13); recurrences may target SignalR streams +- [x] 8.8 Implement fire-event endpoint (payload/delay/global, templated) (RS.AMG.20-21, RS.EVT.16-17) +- [x] 8.9 Implement connection lifecycle control: force-disconnect by `connectionId` with optional close reason/code, and simulate abrupt drop (abort without close frame); unknown consumer → 404 (RS.AMG.14-17) +- [x] 8.10 Add async-mocking endpoints (push, consumers/streams, recurring, fire-event, lifecycle) and AsyncAPI route targeting to `api/openapi.yaml` contract +- [x] 8.11 Unit tests for RS.AMG.1-17, RS.AMG.20-21, RS.EVT.16-17 and RS.MAPI.19-23; integration tests driving push/schedule/consumers/disconnect/fire-event against live ws + SignalR connections + +## 9. Server & CLI Wiring + +- [x] 9.1 Route incoming traffic by protocol: HTTP asyncapi channels, ws upgrade endpoints (raw / SignalR) (RS.MSC.52, RS.MSC.53) +- [x] 9.2 Keep prefixing, CORS, delay, verbose logging, and management API working for AsyncAPI routes (RS.ATM.15, RS.MSC.1-3, RS.MSC.50-51) +- [x] 9.3 Ensure `--from`/config `schemas` accept AsyncAPI files with no flag changes; schema failure exits code 3 (RS.CLI.30-31, RS.CLI.16) +- [x] 9.4 Integration tests: start server with AsyncAPI spec mix (http + ws + SignalR + event-driven push), verify history/state/CORS/delay + +## 10. Docs & Validation + +- [x] 10.1 Update `docs/architecture.md` (loader autodetect, adapters, SchemaInfo, SignalR overlay, event broker), `docs/cli.md` (schema types), `docs/project.md` (structure) +- [x] 10.2 Update openspec coverage map: all new RS.* scenarios (AAL/ASP/SHR/EVT/ATM/AMG/MAPI/MSC/CLI) marked covered by tests +- [x] 10.3 Run `go vet`/lint, full test suite, and coverage threshold check; regenerate mocks + +## 11. Post-Implementation Review + +- [x] 11.1 Run `openspec verify` to confirm specs, design, and implementation coherence +- [x] 11.2 Confirm deferred scope recorded as non-goals (AMQP, Binance diff-depth book, SSE/LongPolling, MessagePack, Ack/Sequence, session/account routing) +- [x] 11.3 Archive the change per the archive workflow \ No newline at end of file diff --git a/openspec/specs/asyncapi-loader/spec.md b/openspec/specs/asyncapi-loader/spec.md new file mode 100644 index 0000000..ba2fab2 --- /dev/null +++ b/openspec/specs/asyncapi-loader/spec.md @@ -0,0 +1,70 @@ +# asyncapi-loader Specification + +## Purpose +Auto-detection and loading of AsyncAPI 3.x specifications (3.0.0/3.1.0) with structural validation and multi-schema prefixing. +## Requirements +### Requirement: AsyncAPI specification detection +The loader SHALL detect, for each loaded file, whether it is an OpenAPI or an AsyncAPI specification by inspecting the root-level version key (`openapi` vs `asyncapi`), choosing the corresponding loader automatically without explicit user configuration. + +#### Scenario RS.AAL.1: Detecting an OpenAPI specification +- **WHEN** a file contains root key `openapi: 3.1.0` +- **THEN** the loader treats the file as an OpenAPI specification and loads it with the OpenAPI loader + +#### Scenario RS.AAL.2: Detecting an AsyncAPI specification +- **WHEN** a file contains root key `asyncapi: 3.0.0` +- **THEN** the loader treats the file as an AsyncAPI specification and loads it with the AsyncAPI loader + +#### Scenario RS.AAL.3: Detecting AsyncAPI 3.1.0 +- **WHEN** a file contains root key `asyncapi: 3.1.0` +- **THEN** the loader loads the file as an AsyncAPI 3.1.0 specification + +#### Scenario RS.AAL.4: File with neither version key +- **WHEN** a file contains neither an `openapi` nor an `asyncapi` root key +- **THEN** the loader reports a schema loading error for that file + +### Requirement: AsyncAPI 3.0.0 loading +The loader SHALL load and structurally validate AsyncAPI 3.0.0 specification files (YAML or JSON), resolving inline references within the document. + +#### Scenario RS.AAL.5: Loading a valid AsyncAPI 3.0.0 spec +- **WHEN** the server starts with `--from asyncapi30.yaml` +- **AND** the file is a valid AsyncAPI 3.0.0 specification +- **THEN** the server parses and validates the file without error + +#### Scenario RS.AAL.6: Invalid AsyncAPI 3.0.0 spec +- **WHEN** the file is missing mandatory AsyncAPI fields (e.g., no `channels`, no `operations`) +- **THEN** the loader reports a schema validation error + +### Requirement: AsyncAPI 3.1.0 loading +The loader SHALL load and structurally validate AsyncAPI 3.1.0 specification files (YAML or JSON), which introduce changes such as the `webhooks` component and adjusted `components` requirements. + +#### Scenario RS.AAL.7: Loading a valid AsyncAPI 3.1.0 spec +- **WHEN** the server starts with `--from asyncapi31.yaml` +- **AND** the file is a valid AsyncAPI 3.1.0 specification +- **THEN** the server parses and validates the file without error + +#### Scenario RS.AAL.8: 3.x version without supported protocol +- **WHEN** an AsyncAPI 3.x file is otherwise valid but contains only unknown protocol bindings (including `amqp`) +- **THEN** the loader reports a validation error naming the unsupported protocol + +### Requirement: Multiple AsyncAPI schemas with prefixes +The loader SHALL load multiple AsyncAPI schemas alongside OpenAPI schemas, pairing each source with its prefix in the same way OpenAPI schemas are handled today. + +#### Scenario RS.AAL.9: Multiple AsyncAPI schemas with prefixes +- **WHEN** the server starts with `--from async1.yaml --prefix /a1 --from async2.yaml --prefix /a2` +- **THEN** the server loads both AsyncAPI schemas and attaches the respective prefixes to their channels + +#### Scenario RS.AAL.10: Mixing OpenAPI and AsyncAPI sources +- **WHEN** the server starts with `--from openapi.yaml --from asyncapi.yaml` +- **THEN** both specifications are loaded through their respective loaders and served together + +### Requirement: AsyncAPI model exposure +The loader SHALL expose the loaded AsyncAPI model (channels, operations, messages, components, bindings) to the routing layer in a form that preserves the original structure for further mapping. + +#### Scenario RS.AAL.11: Exposing channels and operations +- **WHEN** an AsyncAPI 3.x spec is loaded +- **THEN** the router receives channel, operation, message, and binding definitions derived from the spec + +#### Scenario RS.AAL.12: Unsupported AsyncAPI major version +- **WHEN** a file has root key `asyncapi` with a version major other than 3 (e.g., `2.6.0`) +- **THEN** the loader reports a schema validation error stating the version is unsupported + diff --git a/openspec/specs/asyncapi-management/spec.md b/openspec/specs/asyncapi-management/spec.md new file mode 100644 index 0000000..248e8a8 --- /dev/null +++ b/openspec/specs/asyncapi-management/spec.md @@ -0,0 +1,102 @@ +# asyncapi-management Specification + +## Purpose +Management API endpoints for driving AsyncAPI mocking: delayed/targeted/broadcast push, consumer discovery, recurring schedules, fire-event, and connection lifecycle control. +## Requirements +### Requirement: Delayed example push to consumer +The mock server SHALL extend the management API with an endpoint to push a message example to consumers of an AsyncAPI channel, accepting an optional `delay` (milliseconds) before the push is delivered. + +#### Scenario RS.AMG.1: Pushing with a delay +- **WHEN** a management request pushes a message to an AsyncAPI channel with `delay: 500` +- **THEN** the message is delivered to the channel's connected consumers 500 ms after the request (and the push is accepted immediately) + +#### Scenario RS.AMG.2: Pushing without a delay +- **WHEN** a management request pushes a message without a `delay` +- **THEN** the message is delivered to connected consumers immediately + +#### Scenario RS.AMG.3: Negative or zero delay validation +- **WHEN** a management request includes a negative `delay` +- **THEN** the server responds with HTTP 400; `delay: 0` is allowed and means immediate + +#### Scenario RS.AMG.4: Pushing to a channel with no consumers +- **WHEN** a management request pushes a message to a valid AsyncAPI channel that has no connected consumers +- **THEN** the server accepts the request without error and no message is delivered + +### Requirement: Targeted and broadcast push +The pushed message SHALL be deliverable to a single consumer connection or broadcast to all consumers of the channel. + +#### Scenario RS.AMG.5: Pushing to a specific consumer +- **WHEN** a management request includes a `connectionId` for an active consumer +- **THEN** only that consumer receives the message + +#### Scenario RS.AMG.6: Broadcasting to all consumers +- **WHEN** a management request omits `connectionId` +- **THEN** all consumers currently connected to the channel receive the message + +#### Scenario RS.AMG.7: Unknown consumer reference +- **WHEN** a management request includes a `connectionId` that has no active connection +- **THEN** the server responds with HTTP 404 + +### Requirement: Connected consumer discovery +The mock server SHALL expose the currently connected consumers per AsyncAPI channel, including open SignalR streams. + +#### 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 + +### Requirement: Templated push payloads +Pushed message payloads SHALL support runtime expressions ({$state.*}, {$env.*}) evaluated at delivery time, using the schema's state namespace. + +#### Scenario RS.AMG.10: Pushing a templated payload +- **WHEN** a management request pushes a payload containing `{$state.counter}` or `{$env.X}` +- **THEN** the expression is evaluated against the schema's state/ environment before delivery + +#### Scenario RS.AMG.11: Invalid expression in pushed payload +- **WHEN** a management request pushes a payload containing an unresolvable or malformed expression +- **THEN** the server rejects the request with HTTP 400 + +### 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 + +### Requirement: Connection lifecycle control +The mock server SHALL allow a management request to terminate a connected consumer's connection, with an optional close reason, or to simulate an abrupt client-side drop. + +#### Scenario RS.AMG.14: Force disconnecting a consumer +- **WHEN** a management request force-disconnects a consumer by `connectionId` +- **THEN** the server closes that consumer's connection with a normal close frame + +#### Scenario RS.AMG.15: Disconnect with a close reason +- **WHEN** a management request force-disconnects a consumer including a close reason/code +- **THEN** the server closes the connection delivering that reason/code to the peer + +#### Scenario RS.AMG.16: Disconnect of an unknown consumer +- **WHEN** a management request force-disconnects a `connectionId` that has no active connection +- **THEN** the server responds with HTTP 404 + +#### Scenario RS.AMG.17: Simulating an abrupt client drop +- **WHEN** a management request simulates a drop for a consumer +- **THEN** the server aborts the connection without a normal close frame, mimicking a network-level loss + +### Requirement: Fire an event on the event bus +The mock server SHALL expose a management endpoint to fire a named event ad-hoc, reusing the event broker and its delay semantics (per `event-driver`). + +#### Scenario RS.AMG.20: Firing an event via management API +- **WHEN** a management request fires a named event with a payload and optional delay +- **THEN** the server delivers it like a spec-triggered event (immediately or after the delay) + +#### Scenario RS.AMG.21: 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 + diff --git a/openspec/specs/asyncapi-protocols/spec.md b/openspec/specs/asyncapi-protocols/spec.md new file mode 100644 index 0000000..1776ec6 --- /dev/null +++ b/openspec/specs/asyncapi-protocols/spec.md @@ -0,0 +1,57 @@ +# asyncapi-protocols Specification + +## Purpose +Mapping of AsyncAPI channels/operations to runnable mock surfaces for ws and http bindings, with deterministic startup errors for unsupported bindings. +## Requirements +### Requirement: Channel to route mapping +The router SHALL convert AsyncAPI channels into mock routes according to their protocol binding. Channels without a recognized protocol binding, or operations referencing a channel with no bindings, SHALL be reported as unsupported. + +#### Scenario RS.ASP.1: Mapping an HTTP channel +- **WHEN** an AsyncAPI channel declares an `http` binding with a method and path +- **THEN** the router creates a mock HTTP route matching that method and path + +#### Scenario RS.ASP.2: Mapping a WebSocket channel +- **WHEN** an AsyncAPI channel declares a `ws` binding with an address (e.g., `wss://host/socket`) +- **THEN** the server exposes an upgradeable WebSocket endpoint at the corresponding relative path + +#### Scenario RS.ASP.3: Declaring a SignalR hub document +- **WHEN** an AsyncAPI document declares a root-level `x-signalr` extension +- **THEN** the server serves the document's ws channels as a SignalR hub (negotiate + framed streams) per `signalr-hub-runtime` + +#### Scenario RS.ASP.4: Channel with unknown protocol binding +- **WHEN** an AsyncAPI channel declares a protocol binding other than `http`, `ws`, or `amqp` (e.g., `kafka`) +- **THEN** the server fails to start and reports the unsupported protocol + +#### Scenario RS.ASP.5: Channel without binding information +- **WHEN** an AsyncAPI channel has no `bindings` section usable to determine a server protocol +- **THEN** the router reports the channel as invalid with a clear error + +### Requirement: Operation handling +The router SHALL map AsyncAPI send and receive operations onto concrete mock behaviors: send operations accept incoming messages and publish/produce the operation's reply message; receive operations emit the operation's message to connected clients. + +#### Scenario RS.ASP.6: Send operation accepts messages +- **WHEN** a client sends a message to a channel whose operation `action` is `send` +- **THEN** the server accepts it and, when a reply message is present, responds with the reply message's example + +#### Scenario RS.ASP.7: Receive operation emits messages +- **WHEN** a client connects to a channel whose operation `action` is `receive` +- **THEN** the server emits the operation's message example to the client (over ws) or exposes it for polling (over http) + +### Requirement: Prefix handling for channels +Channel addresses SHALL honor the schema-level prefix used on the command line/config, while repeated-serving of a single channel is a non-goal for this MVP. + +#### Scenario RS.ASP.8: Channel address with prefix +- **WHEN** an AsyncAPI schema is loaded with prefix `/v1` and declares channel `user/signedup` +- **THEN** the mock channel is served under the prefixed address `/v1/user/signedup` + +### Requirement: Default responses +For a send operation without an explicit reply message, the server SHALL acknowledge the message using a protocol-appropriate default. + +#### Scenario RS.ASP.9: Acknowledging a send with no reply +- **WHEN** a client sends a message over ws and the operation has no reply message +- **THEN** the server acknowledges receipt (ws: echo/ack frame) without producing a payload + +#### Scenario RS.ASP.10: HTTP send without reply +- **WHEN** an HTTP POST arrives for a send operation that has no reply message +- **THEN** the server responds with HTTP 200 and an empty body + diff --git a/openspec/specs/asyncapi-templating/spec.md b/openspec/specs/asyncapi-templating/spec.md new file mode 100644 index 0000000..6058879 --- /dev/null +++ b/openspec/specs/asyncapi-templating/spec.md @@ -0,0 +1,105 @@ +# asyncapi-templating Specification + +## Purpose +Runtime-expression evaluation, x-mock-* example selection, state mutation and history recording for AsyncAPI message examples, identical to the OpenAPI pipeline. +## Requirements +### Requirement: Event data source in message templates +The mock server SHALL expose event payload data to subscribed AsyncAPI message examples as the `{$event.*}` data source, evaluated at emission time. + +#### Scenario RS.ATM.17: Evaluating an event payload expression +- **WHEN** a message example subscribed to an event (per `event-driver` RS.EVT.7) references `{$event.orderId}` +- **AND** the fired event's payload contains key `orderId` +- **THEN** the expression evaluates to that event payload value at emission time + +#### Scenario RS.ATM.18: Sequence/pacing via state and cron trigger +- **WHEN** a mock consumer needs monotonic sequence numbers or paced delivery for a stream +- **THEN** the server supports it with `x-mock-set-state` counters referenced via `{$state.*}` and the built-in `cron` send-event trigger (per `event-driver` RS.EVT.10); no dedicated sequence engine is required + +### Requirement: Runtime expression evaluation on message examples +The mock server SHALL evaluate runtime expressions in AsyncAPI message examples using the same evaluator used for OpenAPI, exposing protocol-relevant data sources: the incoming message payload, headers, the channel address, server state, environment, and event payloads. + +#### Scenario RS.ATM.1: Evaluating payload expression +- **WHEN** a message example contains expression `{$message.payload.id}` +- **AND** the client message payload contains property `id` +- **THEN** the expression evaluates to that property value + +#### Scenario RS.ATM.2: Evaluating header expression +- **WHEN** a message example contains expression `{$message.header.trace}` +- **AND** the client message carries header `trace` +- **THEN** the expression evaluates to the header value + +#### Scenario RS.ATM.3: Evaluating channel parameter expression +- **WHEN** a message example contains expression `{$channel.sid}` +- **AND** the channel defines parameter `sid` captured from the address +- **THEN** the expression evaluates to the captured parameter value + +#### Scenario RS.ATM.4: Evaluating state expression +- **WHEN** a message example contains expression `{$state.counter}` +- **AND** state for the schema contains key `counter` +- **THEN** the expression evaluates to the stored value + +#### Scenario RS.ATM.5: Evaluating environment expression +- **WHEN** a message example contains expression `{$env.SERVICE_NAME}` +- **AND** the environment variable `SERVICE_NAME` is set +- **THEN** the expression evaluates to the environment variable value + +### Requirement: x-mock-match example selection +The mock server SHALL select AsyncAPI message examples using the `x-mock-match` extension (with legacy `x-mock-params-match` handling) evaluated against the incoming message, mirroring OpenAPI behavior. + +#### Scenario RS.ATM.6: Selecting message example by x-mock-match +- **WHEN** multiple message examples exist and exactly one has `x-mock-match` conditions satisfied by the client message +- **THEN** the server selects that example + +#### Scenario RS.ATM.7: Selecting first example with no conditions +- **WHEN** an operation has multiple message examples without `x-mock-match`/`x-mock-params-match` +- **THEN** the server selects the first example (by AsyncAPI definition order) + +#### Scenario RS.ATM.8: x-mock-match overrides deprecated alias +- **WHEN** a message example has both `x-mock-match` and `x-mock-params-match` +- **THEN** only `x-mock-match` is considered and a deprecation error is written to stderr + +### Requirement: x-mock-skip and x-mock-once on message examples +The mock server SHALL honor `x-mock-skip` and `x-mock-once` on AsyncAPI message examples with the same semantics as OpenAPI. + +#### Scenario RS.ATM.9: Skipping a message example +- **WHEN** a message example has `x-mock-skip: true` +- **THEN** the server skips that example during selection + +#### Scenario RS.ATM.10: One-time message example removal +- **WHEN** a message example with `x-mock-once: true` is selected +- **THEN** the server removes it from future consideration + +### Requirement: State mutation via x-mock-set-state +The mock server SHALL apply `x-mock-set-state` from a matched AsyncAPI message example to the schema's state namespace, including increment and delete semantics. + +#### Scenario RS.ATM.11: Setting state from message example +- **WHEN** a selected message example has `x-mock-set-state` containing key-value pairs +- **THEN** the server updates the schema state with those pairs + +#### Scenario RS.ATM.12: Incrementing state from message example +- **WHEN** `x-mock-set-state` contains `{ counter: { increment: 1 } }` +- **AND** previous `counter` value is a number +- **THEN** the server increments `counter` by 1 + +#### Scenario RS.ATM.13: Deleting state key from message example +- **WHEN** `x-mock-set-state` contains `key: null` +- **THEN** the server removes `key` from state + +### Requirement: x-mock-headers on message responses +The mock server SHALL apply `x-mock-headers` from a matched AsyncAPI message example to the outgoing message/response envelope. + +#### Scenario RS.ATM.14: Response headers from message example +- **WHEN** a selected message example has `x-mock-headers` +- **THEN** the server includes those headers in the outgoing message/response + +### Requirement: State and history integration for AsyncAPI traffic +Message handling SHALL record request/response history records and use the same state store as OpenAPI traffic, keeping each AsyncAPI schema isolated in its own namespace. + +#### Scenario RS.ATM.15: Recording AsyncAPI message exchanges in history +- **WHEN** a ws/http message is processed against an AsyncAPI channel +- **THEN** the exchange is recorded in the request history with channel/address, headers, payload, and timestamp + +#### Scenario RS.ATM.16: AsyncAPI state namespace isolation +- **WHEN** multiple AsyncAPI schemas are served +- **THEN** each schema's `x-mock-set-state` writes go to its own isolated state namespace + diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index e522550..9c36826 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -18,7 +18,7 @@ The CLI SHALL provide a command-line interface with global options and subcomman - **THEN** the tool prints global help text describing available commands and options ### Requirement: Mock command -The mock command SHALL start an HTTP mock server based on OpenAPI schema(s) using extensions described in extensions.md. +The mock command SHALL start a mock server based on OpenAPI and/or AsyncAPI schema(s), using the extensions described in extensions.md. #### Scenario RS.CLI.4: Starting mock server with default schema - **WHEN** user runs `oasmock` @@ -48,6 +48,19 @@ The mock command SHALL start an HTTP mock server based on OpenAPI schema(s) usin - **WHEN** user runs `oasmock --help` or `oasmock -h` - **THEN** the tool prints help text for the mock command including all options +#### Scenario RS.CLI.30: Starting mock server with an AsyncAPI spec +- **WHEN** user runs `oasmock --from api/asyncapi.yaml` +- **THEN** the server auto-detects the AsyncAPI 3.x spec and serves its channels + +#### Scenario RS.CLI.31: Mixing OpenAPI and AsyncAPI via config file +- **WHEN** a `.oasmock.yaml` file contains: + ```yaml + schemas: + - src: openapi.yaml + - src: asyncapi.yaml + ``` +- **THEN** the CLI loads both, auto-detecting each specification type + ### Requirement: Environment variable overrides The CLI SHALL support configuration sources with the following precedence: command-line arguments > environment variables > configuration file > defaults. Environment variables SHALL override configuration file values but be overridden by command-line arguments. @@ -64,7 +77,7 @@ The CLI SHALL support configuration sources with the following precedence: comma - **THEN** the server disables CORS headers (unless a CLI flag overrides) ### Requirement: Exit codes -The CLI SHALL return appropriate exit codes as defined in [cli.md](../../../../cli.md). +The CLI SHALL return appropriate exit codes as defined in cli.md, extending the schema-failure code to AsyncAPI specifications. #### Scenario RS.CLI.14: Successful execution - **WHEN** the mock server starts successfully @@ -75,7 +88,7 @@ The CLI SHALL return appropriate exit codes as defined in [cli.md](../../../../c - **THEN** the CLI exits with code 2 #### Scenario RS.CLI.16: Schema loading or validation failure -- **WHEN** the OpenAPI schema cannot be loaded or is invalid +- **WHEN** an OpenAPI or AsyncAPI schema cannot be loaded or is invalid - **THEN** the CLI exits with code 3 #### Scenario RS.CLI.17: Port already in use diff --git a/openspec/specs/event-driver/spec.md b/openspec/specs/event-driver/spec.md new file mode 100644 index 0000000..db4a3b6 --- /dev/null +++ b/openspec/specs/event-driver/spec.md @@ -0,0 +1,88 @@ +# event-driver Specification + +## Purpose +Named event bus decoupling OpenAPI example triggers (x-event-trigger) from AsyncAPI message subscriptions (x-send-events), with {$event.*} payload templating and schema-local/global scoping. +## Requirements +### Requirement: Event trigger on an OpenAPI example +The mock server SHALL support firing named events from an OpenAPI response example via the `x-event-trigger` extension, triggered whenever that example is selected for a response. Multiple triggers per example SHALL be supported (list form). + +#### Scenario RS.EVT.1: Firing a single event +- **WHEN** a selected OpenAPI example has `x-event-trigger` with a `name` +- **THEN** the server fires the named event with an empty payload after the response is produced + +#### Scenario RS.EVT.2: Firing multiple events from one example +- **WHEN** an OpenAPI example has an `x-event-trigger` list with several `name` entries +- **THEN** the server fires each named event + +#### Scenario RS.EVT.3: Event with payload +- **WHEN** an `x-event-trigger` entry includes `payload` +- **THEN** the event carries that payload, evaluable by consumers as `{$event.*}` + +#### Scenario RS.EVT.4: Delayed event +- **WHEN** an `x-event-trigger` entry includes `delay` +- **THEN** the server delivers the event (and any resulting consumer messages) after that delay + +### Requirement: Event name scoping +Event names SHALL be schema-local by default and server-wide only when declared `global: true`. + +#### Scenario RS.EVT.5: Schema-local event +- **WHEN** an OpenAPI schema fires an event without `global: true` +- **THEN** only `x-send-events` subscriptions within the same schema receive it + +#### Scenario RS.EVT.6: Global event +- **WHEN** an `x-event-trigger` entry sets `global: true` +- **THEN** the event is broadcast over all loaded schemas and any matching subscription anywhere receives it + +### 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 + +### Requirement: Broadcast delivery with client-side filtering +Event-driven messages SHALL be broadcast to the consuming channel's connected consumers; the mock does NOT route by session or account — consumers filter by payload. + +#### Scenario RS.EVT.12: Broadcasting an event-driven message +- **WHEN** an event fires and a message example subscribed to it exists on a channel with active consumers +- **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 subscribed 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 subscribes to it +- **THEN** the event is accepted with no delivery (no error) + +#### Scenario RS.EVT.15: Event with no consumers +- **WHEN** an event fires, a subscription exists, but the channel has no connected consumers +- **THEN** the event is accepted without error and no message is delivered + +### Requirement: Management fire-event endpoint +The mock server SHALL expose a management API endpoint to fire a named event ad-hoc, reusing the event broker and its delay semantics. + +#### Scenario RS.EVT.16: Firing an event via management API +- **WHEN** a management request fires a named event with a payload and optional delay +- **THEN** the server delivers it like a spec-triggered event (immediately or after the delay) + +#### Scenario RS.EVT.17: Event payload templating at fire time +- **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 + diff --git a/openspec/specs/management-api/spec.md b/openspec/specs/management-api/spec.md index a109e7a..139227a 100644 --- a/openspec/specs/management-api/spec.md +++ b/openspec/specs/management-api/spec.md @@ -1,9 +1,7 @@ ## Purpose HTTP management API for runtime control of the OASMock server, allowing dynamic addition of mock examples and retrieval of request history. - ## Requirements - ### Requirement: Management API availability The mock server SHALL provide an HTTP API for runtime management under the `/_mock` path prefix according to [openapi](../../../../openapi.yaml) spec @@ -94,3 +92,30 @@ The `AddExampleRequest` and `AddExampleResponse` SHALL follow the schemas define #### Scenario RS.MAPI.15: ExampleResponse structure - **WHEN** an example response is provided - **THEN** it includes `code` (integer), `headers` (object), and `body` (any JSON value) + +### Requirement: Add example targeting an AsyncAPI route +The mock server SHALL accept a route identifier that resolves to an AsyncAPI channel/operation (protocol + address, and HTTP method/action when applicable) in `POST /_mock/examples`, with the same storage, matching, once/TTL, conditions, and validation semantics as OpenAPI routes. + +#### Scenario RS.MAPI.19: Adding a dynamic example for an AsyncAPI channel +- **WHEN** a POST request is sent to `/_mock/examples` with an AsyncAPI route identifier (protocol `ws`/`http` and a channel address) +- **THEN** the server stores the example for that channel and responds with `AddExampleResponse` containing success and an example ID + +#### Scenario RS.MAPI.20: Dynamic example used by AsyncAPI traffic +- **WHEN** a ws/http message arrives for an AsyncAPI channel that has a dynamic example with matching conditions +- **THEN** the server selects the dynamic example using the same selection pipeline as spec examples + +#### Scenario RS.MAPI.21: No matching AsyncAPI route +- **WHEN** a POST request is sent to `/_mock/examples` with an AsyncAPI route identifier that does not match any loaded channel +- **THEN** the server responds with HTTP 400 (no matching route) + +### Requirement: Fire an event on the event bus +The management API SHALL expose an endpoint to fire a named event ad-hoc, reusing the event broker and its delay semantics (per `event-driver`). + +#### Scenario RS.MAPI.22: Firing an event via management API +- **WHEN** a management request fires a named event with a payload and optional delay +- **THEN** the server delivers it like a spec-triggered event (immediately or after the delay) to matching `x-send-events` consumers + +#### 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 + diff --git a/openspec/specs/mock-server-core/spec.md b/openspec/specs/mock-server-core/spec.md index d52e35a..f5d8c92 100644 --- a/openspec/specs/mock-server-core/spec.md +++ b/openspec/specs/mock-server-core/spec.md @@ -1,11 +1,9 @@ ## Purpose Core HTTP mock server that loads OpenAPI schemas, routes requests, selects examples based on conditions, evaluates runtime expressions, manages state, and generates responses. - ## Requirements - ### Requirement: OpenAPI schema loading -The mock server SHALL load one or more OpenAPI 3.0 schemas from files specified via `--from` CLI option. +The mock server SHALL load one or more OpenAPI 3.x or AsyncAPI 3.x schemas (auto-detected by the loader) from files specified via the `--from` CLI option, pairing each with an optional path prefix. #### Scenario RS.MSC.1: Loading a single schema - **WHEN** the server starts with `--from api/openapi.yaml` @@ -16,11 +14,19 @@ The mock server SHALL load one or more OpenAPI 3.0 schemas from files specified - **THEN** the server loads both schemas and routes requests under the respective path prefixes #### Scenario RS.MSC.3: Schema validation failure -- **WHEN** the specified file is not a valid OpenAPI 3.x schema +- **WHEN** the specified file is not a valid OpenAPI or AsyncAPI 3.x schema - **THEN** the server fails to start and exits with code 3 +#### Scenario RS.MSC.50: Loading an AsyncAPI schema alongside OpenAPI +- **WHEN** the server starts with `--from openapi.yaml --from asyncapi.yaml` +- **THEN** the server auto-detects and loads both specifications through their respective loaders + +#### Scenario RS.MSC.51: AsyncAPI channels honor schema prefix +- **WHEN** an AsyncAPI schema is loaded with a prefix +- **THEN** its channel addresses are served under that prefix + ### Requirement: Request routing -The mock server SHALL route incoming HTTP requests to the matching OpenAPI path based on method and path pattern. +The mock server SHALL route incoming traffic to the matching OpenAPI path or AsyncAPI channel based on method and path pattern for HTTP, and channel address for ws, so that only defined operations are served. #### Scenario RS.MSC.4: Routing to exact path - **WHEN** a GET request arrives at `/users` and the schema defines a GET operation at `/users` @@ -38,6 +44,14 @@ The mock server SHALL route incoming HTTP requests to the matching OpenAPI path - **WHEN** a request arrives at a path/method not defined in any loaded schema - **THEN** the server responds with HTTP 404 +#### Scenario RS.MSC.52: Routing an AsyncAPI HTTP channel +- **WHEN** an HTTP request arrives matching an AsyncAPI channel with an `http` binding +- **THEN** the server selects the corresponding operation for processing + +#### Scenario RS.MSC.53: Routing an AsyncAPI ws channel +- **WHEN** a ws client connects to a channel address defined in an AsyncAPI schema +- **THEN** the server selects the corresponding receive/send operation for processing + ### Requirement: Example selection The mock server SHALL select an example from the operation's examples collection based on extension conditions. @@ -249,3 +263,4 @@ The server SHALL periodically remove expired dynamic examples from memory via a #### Scenario RS.MSC.49: Sweep stops on server shutdown - **WHEN** the server shuts down - **THEN** the background goroutine for TTL sweeping is stopped + diff --git a/openspec/specs/signalr-hub-runtime/spec.md b/openspec/specs/signalr-hub-runtime/spec.md new file mode 100644 index 0000000..a088cf4 --- /dev/null +++ b/openspec/specs/signalr-hub-runtime/spec.md @@ -0,0 +1,119 @@ +# signalr-hub-runtime Specification + +## Purpose +ASP.NET Core SignalR-compatible hub serving for AsyncAPI documents declaring root x-signalr: negotiate, token-correlated upgrade, handshake, \x1e framing, streams, invocations and server pushes. +## Requirements +### Requirement: Root-level x-signalr extension +The mock server SHALL treat a parseable AsyncAPI document whose root declares `x-signalr` as a single SignalR hub, served over the document's WebSocket channels. + +#### Scenario RS.SHR.1: Declaring a SignalR hub document +- **WHEN** an AsyncAPI document has a root-level `x-signalr` extension with a hub path +- **THEN** the server serves the document as one SignalR hub at that path, exposing a negotiate endpoint and framed WebSocket streams + +#### Scenario RS.SHR.2: One hub per document +- **WHEN** an AsyncAPI document declares `x-signalr` with a single hub configuration +- **THEN** the server registers exactly one hub for that document + +### Requirement: Streams map to channels +WebSocket channels in an `x-signalr` document SHALL be streamable hub targets: a client `StreamInvocation` whose `target` is a channel ID is answered by the channel's snapshot message, which stays open for further items. + +#### Scenario RS.SHR.3: StreamInvocation by channel ID +- **WHEN** a client sends a `StreamInvocation` (type 4) with `target` equal to a declared channel ID +- **THEN** the server emits the channel's snapshot example as a `StreamItem` (type 2) on the client's `invocationId` + +#### Scenario RS.SHR.4: Stream held open +- **WHEN** the snapshot `StreamItem` has been sent +- **THEN** the server does NOT send a `Completion`; the stream stays open and registered for that `(connection, invocationId)` + +#### Scenario RS.SHR.5: Unknown channel target +- **WHEN** a `StreamInvocation` names a `target` that matches no channel ID +- **THEN** the server replies with a `Completion` (type 3) carrying an error for that invocation + +### Requirement: One-shot invocations map to operations +Operations in an `x-signalr` document SHALL be invocable as one-shot hub targets: a client `Invocation` (type 1) whose `target` is an operation ID is answered by a `Completion` with the operation's message example. + +#### Scenario RS.SHR.6: Invocation by operation ID +- **WHEN** a client sends an `Invocation` with `target` equal to an operation ID +- **THEN** the server replies with a `Completion` (type 3) carrying the operation's message example as the result + +#### Scenario RS.SHR.7: Unknown operation target +- **WHEN** an `Invocation` names a target matching no operation ID +- **THEN** the server replies with a `Completion` carrying an error for that invocation + +### Requirement: Negotiate endpoint +For the hub path, the server SHALL expose `POST {hubPath}/negotiate` returning supported transport info and a connection token used by the client's subsequent WebSocket upgrade. + +#### Scenario RS.SHR.8: Successful negotiation +- **WHEN** a client POSTs to `{hubPath}/negotiate` with `negotiateVersion=1` +- **THEN** the server responds 200 with `connectionToken`, `connectionId`, `negotiateVersion: 1`, and `availableTransports` listing WebSockets with Text and Binary transfer formats + +#### Scenario RS.SHR.9: Negotiate protocol version +- **WHEN** a client requests negotiation without `negotiateVersion` (treated as 0) +- **THEN** the server responds with `negotiateVersion: 1` (its supported version) and includes both `connectionToken` and `connectionId` + +#### Scenario RS.SHR.10: Negotiate for an unsupported transport +- **WHEN** a client requests a transport other than WebSockets (e.g., server-sent events or long polling) +- **THEN** the server lists WebSockets only; upgrades for SSE/long-polling return HTTP 400 + +### Requirement: WebSocket upgrade with token correlation +The server SHALL require the WebSocket upgrade request to the hub path to carry the `id` query parameter matching a previously issued connection token. + +#### Scenario RS.SHR.11: Upgrade with matching token +- **WHEN** a client upgrades to the hub path with `?id=` where the token was issued by negotiate +- **THEN** the server accepts the upgrade and binds the connection to that token/connection + +#### Scenario RS.SHR.12: Upgrade with unknown token +- **WHEN** a client upgrades with an `id` token that was not issued +- **THEN** the server rejects the upgrade with HTTP 404 + +#### Scenario RS.SHR.13: Upgrade without token +- **WHEN** a client upgrades without an `id` parameter +- **THEN** the server binds the connection to a fresh internally generated token so the connection can still be addressed by `connectionId` + +### Requirement: Handshake and framing +The first message on a SignalR connection SHALL be the protocol handshake, and all subsequent messages SHALL be JSON terminated by the ASCII record separator `0x1E` (unit separator byte). + +#### Scenario RS.SHR.14: Valid handshake +- **WHEN** the client's first WebSocket text frame is `{"protocol":"json","version":1}` +- **THEN** the server replies `{}\x1e` and switches to framed messaging + +#### Scenario RS.SHR.15: Unsupported protocol handshake +- **WHEN** the client's first frame requests a protocol other than `json` (e.g., `messagepack`) +- **THEN** the server sends a handshake error and closes the connection + +#### Scenario RS.SHR.16: Framed messages carry the record separator +- **WHEN** the server sends an `Invocation`, `StreamItem`, or `Completion` +- **THEN** the message JSON is terminated by the `0x1E` byte, and multiple messages may share one WebSocket text frame separated by that byte + +### Requirement: Streaming invocation lifecycle +A `StreamInvocation` to a channel target SHALL produce a snapshot, keep the stream open for further items, and complete on `CancelInvocation` or stream end. + +#### Scenario RS.SHR.17: Cancel closes the stream +- **WHEN** the client sends a `CancelInvocation` (type 5) for an open `invocationId` +- **THEN** the server sends a `Completion` (type 3) and removes the stream from the open-stream registry + +#### Scenario RS.SHR.18: Event-driven item appended to open stream +- **WHEN** a server-initiated event triggers a message on a channel with open stream handles +- **THEN** the server emits the templated message as an additional `StreamItem` on each open `invocationId` without completing the stream (per `event-driver` RS.EVT.13) + +### Requirement: Server-initiated one-shot push +For a SignalR hub, a server-side push that does not target an open stream SHALL be sent as a server-to-client `Invocation` with a server-assigned invocation id. + +#### Scenario RS.SHR.19: Server Invocation push +- **WHEN** an event-driven message is emitted for a hub channel but no open stream matches +- **THEN** the server sends an `Invocation` (type 1) with `invocationId: ` and the message as `arguments` + +### Requirement: Ping handling +The server SHALL respond to SignalR `Ping` messages (type 6); pings carry no invocation id. + +#### Scenario RS.SHR.20: Ping is echoed +- **WHEN** the client sends `{type:6}` +- **THEN** the server replies `{type:6}` without affecting any streams + +### Requirement: Open stream registry +The server SHALL keep an open-stream registry per connection so event-driven messages can be pushed into held-open streams and so management discovery can list them. + +#### Scenario RS.SHR.21: Registry tracks open streams +- **WHEN** one or more streams are open on a connection +- **THEN** the registry retains `(connectionId, invocationId, channel ID)` so discovery and push endpoints can list and target them + diff --git a/test/_shared/resources/asyncapi-26.yaml b/test/_shared/resources/asyncapi-26.yaml new file mode 100644 index 0000000..1ce9c7e --- /dev/null +++ b/test/_shared/resources/asyncapi-26.yaml @@ -0,0 +1,5 @@ +asyncapi: 2.6.0 +info: + title: Old Spec + version: 1.0.0 +channels: {} \ No newline at end of file diff --git a/test/_shared/resources/asyncapi-30.yaml b/test/_shared/resources/asyncapi-30.yaml new file mode 100644 index 0000000..a9ae7c9 --- /dev/null +++ b/test/_shared/resources/asyncapi-30.yaml @@ -0,0 +1,17 @@ +asyncapi: 3.0.0 +info: + title: User Events + version: 1.0.0 +channels: + userSignedUp: + address: user/signedup + messages: + auserSignedUp: + examples: + - payload: + id: "{$request.path.id}" +operations: + receiveUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' \ No newline at end of file diff --git a/test/_shared/resources/asyncapi-31.yaml b/test/_shared/resources/asyncapi-31.yaml new file mode 100644 index 0000000..28ba1f8 --- /dev/null +++ b/test/_shared/resources/asyncapi-31.yaml @@ -0,0 +1,17 @@ +asyncapi: 3.1.0 +info: + title: Webhook Events + version: 1.0.0 +channels: + signedUp: + address: user/signedup + messages: + signedUpMsg: + examples: + - payload: + id: 1 +operations: + receiveSignedUp: + action: receive + channel: + $ref: '#/channels/signedUp' \ No newline at end of file diff --git a/test/_shared/resources/not-a-spec.yaml b/test/_shared/resources/not-a-spec.yaml new file mode 100644 index 0000000..95ba386 --- /dev/null +++ b/test/_shared/resources/not-a-spec.yaml @@ -0,0 +1,3 @@ +foo: bar +some: + nested: value \ No newline at end of file diff --git a/test/cli/cli_integration_test.go b/test/cli/cli_integration_test.go index f3683c6..b1eb1fa 100644 --- a/test/cli/cli_integration_test.go +++ b/test/cli/cli_integration_test.go @@ -1108,3 +1108,123 @@ func TestCLIIntegrationTestLocation(t *testing.T) { // Optional: verify we're in test/cli directory // This test passes by virtue of being in the correct location. } + +/* +Scenario: CLI accepts AsyncAPI files with no flag changes +Given the oasmock binary and an AsyncAPI file with an http binding +When invoked with --from +Then the server starts and serves the channel under the prefix + +Related spec scenarios: RS.CLI.30, RS.CLI.31, RS.ASP.1 +*/ +func TestCLIAsyncAPIAccepted(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + t.Parallel() + + port := clihelper.FindFreePort(t) + dir := t.TempDir() + async := filepath.Join(dir, "asyncapi.yaml") + err := os.WriteFile(async, []byte(`asyncapi: 3.0.0 +info: + title: HTTP Events + version: 1.0.0 +channels: + employees: + address: /employees + messages: + emplMsg: + examples: + - payload: + id: 1 +operations: + getEmployees: + action: send + channel: + $ref: '#/channels/employees' + bindings: + http: + method: GET +`), 0644) + require.NoError(t, err) + + cmd := exec.Command(binaryPath(t), "mock", "--from", async, "--port", fmt.Sprintf("%d", port)) + stderrPipe, err := cmd.StderrPipe() + require.NoError(t, err) + require.NoError(t, cmd.Start(), "failed to start mock command") + + outputChan := make(chan string) + go func() { + var acc strings.Builder + buf := make([]byte, 1024) + for { + n, err := stderrPipe.Read(buf) + if n > 0 { + acc.Write(buf[:n]) + if strings.Contains(acc.String(), "Mock server started") { + outputChan <- acc.String() + return + } + } + if err != nil { + outputChan <- acc.String() + return + } + } + }() + select { + case output := <-outputChan: + assert.Contains(t, output, "Mock server started", "mock command did not start with AsyncAPI: %s", output) + case <-time.After(2 * time.Second): + require.Fail(t, "timeout waiting for mock output") + } + _ = cmd.Process.Kill() + _ = cmd.Wait() +} + +/* +Scenario: CLI fails with exit code 3 on an invalid AsyncAPI file +Given the oasmock binary and an AsyncAPI file with unsupported protocol bindings +When invoked with --from +Then the process exits with code 3 + +Related spec scenarios: RS.CLI.16, RS.AAL.8, RS.ASP.4 +*/ +func TestCLIAsyncAPIInvalidExitCode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + t.Parallel() + + dir := t.TempDir() + bad := filepath.Join(dir, "bad-asyncapi.yaml") + err := os.WriteFile(bad, []byte(`asyncapi: 3.0.0 +info: + title: Kafka + version: 1.0.0 +channels: + k: + address: topic + bindings: + kafka: + topic: events + messages: + msg: + examples: + - payload: {} +operations: + receiveK: + action: receive + channel: + $ref: '#/channels/k' +`), 0644) + require.NoError(t, err) + + cmd := exec.Command(binaryPath(t), "mock", "--from", bad, "--port", fmt.Sprintf("%d", clihelper.FindFreePort(t))) + err = cmd.Run() + require.Error(t, err) + exitErr, ok := err.(*exec.ExitError) + require.True(t, ok) + assert.Equal(t, 3, exitErr.ExitCode()) +} diff --git a/test/server-core/server_integration_test.go b/test/server-core/server_integration_test.go index 327f96c..d6314c8 100644 --- a/test/server-core/server_integration_test.go +++ b/test/server-core/server_integration_test.go @@ -5,10 +5,14 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "strings" "testing" "time" + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/test/_shared/clihelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -620,3 +624,148 @@ func TestServerCustomDelay(t *testing.T) { // No error yet, process still running } } + +/* +Scenario: Server serves an AsyncAPI ws channel end to end +Given an AsyncAPI spec with a ws channel +When the server starts and a ws client connects +Then a receive-operation message is emitted to the client + +Related spec scenarios: RS.MSC.52, RS.ASP.2, RS.ASP.7 +*/ +func TestServerAsyncAPIWS(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + + dir := t.TempDir() + schemaPath := filepath.Join(dir, "asyncapi.yaml") + require.NoError(t, os.WriteFile(schemaPath, []byte(`asyncapi: 3.0.0 +info: + title: Alerts + version: 1.0.0 +channels: + alerts: + address: /alerts + bindings: + ws: + method: GET + messages: + alertMsg: + examples: + - name: snap + payload: + level: info + msg: hello-ws +operations: + receiveAlerts: + action: receive + channel: + $ref: '#/channels/alerts' +`), 0644)) + + cmd, errCh, port := clihelper.Cmd(t).SetSchema(schemaPath, "").Run() + defer clihelper.StopServer(t, cmd) + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + wsURL := fmt.Sprintf("ws://localhost:%d/alerts", port) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + //nolint:errcheck + 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), "hello-ws") + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: Server serves a SignalR hub from an x-signalr document +Given an AsyncAPI spec with root x-signalr +When a negotiate request is made and a hub ws client connects +Then negotiate returns a token and the hub answers the handshake + +Related spec scenarios: RS.MSC.53, RS.SHR.8, RS.SHR.14 +*/ +func TestServerSignalRHub(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + + dir := t.TempDir() + schemaPath := filepath.Join(dir, "signalr.yaml") + require.NoError(t, os.WriteFile(schemaPath, []byte(`asyncapi: 3.0.0 +info: + title: Prices + version: 1.0.0 +x-signalr: + path: /hub +channels: + priceFeed: + address: priceFeed + bindings: + ws: + method: GET + messages: + priceMsg: + examples: + - name: snap + payload: + symbol: ETH + price: 3000 +operations: + receivePrice: + action: receive + channel: + $ref: '#/channels/priceFeed' +`), 0644)) + + cmd, errCh, port := clihelper.Cmd(t).SetSchema(schemaPath, "").Run() + defer clihelper.StopServer(t, cmd) + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/hub/negotiate?negotiateVersion=1", port), + "application/json", nil) + require.NoError(t, err) + //nolint:errcheck + defer resp.Body.Close() //nolint:errcheck + var negotiate map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&negotiate)) + assert.Equal(t, float64(1), negotiate["negotiateVersion"]) + token, ok := negotiate["connectionToken"].(string) + require.True(t, ok) + require.NotEmpty(t, token) + + wsURL := fmt.Sprintf("ws://localhost:%d/hub?id=%s", port, token) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + //nolint:errcheck + defer conn.Close() //nolint:errcheck + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"protocol":"json","version":1}`))) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, hs, err := conn.ReadMessage() + require.NoError(t, err) + assert.Equal(t, "{}\x1e", string(hs)) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} diff --git a/third_party/go-asyncapi/.gitignore b/third_party/go-asyncapi/.gitignore new file mode 100644 index 0000000..f192fc2 --- /dev/null +++ b/third_party/go-asyncapi/.gitignore @@ -0,0 +1,29 @@ +# Binaries +asyncapi +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go coverage tool +*.out + +# Dependency directories +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Fuzz test cache +testdata/fuzz/ diff --git a/third_party/go-asyncapi/LICENSE b/third_party/go-asyncapi/LICENSE new file mode 100644 index 0000000..5aa9ad1 --- /dev/null +++ b/third_party/go-asyncapi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ben Elser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/go-asyncapi/asyncapi.go b/third_party/go-asyncapi/asyncapi.go new file mode 100644 index 0000000..847752e --- /dev/null +++ b/third_party/go-asyncapi/asyncapi.go @@ -0,0 +1,20 @@ +// Package asyncapi provides a parser, validator, and utilities for AsyncAPI 3.0.0 documents. +// +// The package follows the AsyncAPI 3.0.0 specification as the source of truth. +// See https://www.asyncapi.com/docs/reference/specification/v3.0.0 +// +// Basic usage: +// +// doc, err := asyncapi.LoadFromFile("api.yaml") +// if err != nil { +// log.Fatal(err) +// } +// +// if err := doc.Validate(); err != nil { +// log.Fatal(err) +// } +// +// for name, op := range doc.Operations { +// fmt.Printf("Operation %s: %s\n", name, op.Value.Action) +// } +package asyncapi diff --git a/third_party/go-asyncapi/bindings.go b/third_party/go-asyncapi/bindings.go new file mode 100644 index 0000000..1aef517 --- /dev/null +++ b/third_party/go-asyncapi/bindings.go @@ -0,0 +1,203 @@ +package asyncapi + +import "encoding/json" + +// ServerBindings contains protocol-specific definitions for a server. +type ServerBindings struct { + HTTP *HTTPServerBinding `json:"http,omitempty" yaml:"http,omitempty"` + WS *WSServerBinding `json:"ws,omitempty" yaml:"ws,omitempty"` + Kafka *KafkaServerBinding `json:"kafka,omitempty" yaml:"kafka,omitempty"` + AMQP *AMQPServerBinding `json:"amqp,omitempty" yaml:"amqp,omitempty"` + MQTT *MQTTServerBinding `json:"mqtt,omitempty" yaml:"mqtt,omitempty"` + NATS *NATSServerBinding `json:"nats,omitempty" yaml:"nats,omitempty"` + // Raw captures any additional/unknown bindings + Raw map[string]json.RawMessage `json:"-" yaml:"-"` +} + +// ChannelBindings contains protocol-specific definitions for a channel. +type ChannelBindings struct { + HTTP *HTTPChannelBinding `json:"http,omitempty" yaml:"http,omitempty"` + WS *WSChannelBinding `json:"ws,omitempty" yaml:"ws,omitempty"` + Kafka *KafkaChannelBinding `json:"kafka,omitempty" yaml:"kafka,omitempty"` + AMQP *AMQPChannelBinding `json:"amqp,omitempty" yaml:"amqp,omitempty"` + MQTT *MQTTChannelBinding `json:"mqtt,omitempty" yaml:"mqtt,omitempty"` + NATS *NATSChannelBinding `json:"nats,omitempty" yaml:"nats,omitempty"` + Raw map[string]json.RawMessage `json:"-" yaml:"-"` +} + +// OperationBindings contains protocol-specific definitions for an operation. +type OperationBindings struct { + HTTP *HTTPOperationBinding `json:"http,omitempty" yaml:"http,omitempty"` + WS *WSOperationBinding `json:"ws,omitempty" yaml:"ws,omitempty"` + Kafka *KafkaOperationBinding `json:"kafka,omitempty" yaml:"kafka,omitempty"` + AMQP *AMQPOperationBinding `json:"amqp,omitempty" yaml:"amqp,omitempty"` + MQTT *MQTTOperationBinding `json:"mqtt,omitempty" yaml:"mqtt,omitempty"` + NATS *NATSOperationBinding `json:"nats,omitempty" yaml:"nats,omitempty"` + Raw map[string]json.RawMessage `json:"-" yaml:"-"` +} + +// MessageBindings contains protocol-specific definitions for a message. +type MessageBindings struct { + HTTP *HTTPMessageBinding `json:"http,omitempty" yaml:"http,omitempty"` + WS *WSMessageBinding `json:"ws,omitempty" yaml:"ws,omitempty"` + Kafka *KafkaMessageBinding `json:"kafka,omitempty" yaml:"kafka,omitempty"` + AMQP *AMQPMessageBinding `json:"amqp,omitempty" yaml:"amqp,omitempty"` + MQTT *MQTTMessageBinding `json:"mqtt,omitempty" yaml:"mqtt,omitempty"` + NATS *NATSMessageBinding `json:"nats,omitempty" yaml:"nats,omitempty"` + Raw map[string]json.RawMessage `json:"-" yaml:"-"` +} + +// HTTP Bindings + +type HTTPServerBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type HTTPChannelBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type HTTPOperationBinding struct { + Method string `json:"method,omitempty" yaml:"method,omitempty"` + Query *SchemaRef `json:"query,omitempty" yaml:"query,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type HTTPMessageBinding struct { + Headers *SchemaRef `json:"headers,omitempty" yaml:"headers,omitempty"` + StatusCode *int `json:"statusCode,omitempty" yaml:"statusCode,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +// WebSocket Bindings + +type WSServerBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type WSChannelBinding struct { + Method string `json:"method,omitempty" yaml:"method,omitempty"` + Query *SchemaRef `json:"query,omitempty" yaml:"query,omitempty"` + Headers *SchemaRef `json:"headers,omitempty" yaml:"headers,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type WSOperationBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type WSMessageBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +// Kafka Bindings + +type KafkaServerBinding struct { + SchemaRegistryURL string `json:"schemaRegistryUrl,omitempty" yaml:"schemaRegistryUrl,omitempty"` + SchemaRegistryVendor string `json:"schemaRegistryVendor,omitempty" yaml:"schemaRegistryVendor,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type KafkaChannelBinding struct { + Topic string `json:"topic,omitempty" yaml:"topic,omitempty"` + Partitions *int `json:"partitions,omitempty" yaml:"partitions,omitempty"` + Replicas *int `json:"replicas,omitempty" yaml:"replicas,omitempty"` + TopicConfiguration any `json:"topicConfiguration,omitempty" yaml:"topicConfiguration,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type KafkaOperationBinding struct { + GroupID *SchemaRef `json:"groupId,omitempty" yaml:"groupId,omitempty"` + ClientID *SchemaRef `json:"clientId,omitempty" yaml:"clientId,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type KafkaMessageBinding struct { + Key *SchemaRef `json:"key,omitempty" yaml:"key,omitempty"` + SchemaIDLocation string `json:"schemaIdLocation,omitempty" yaml:"schemaIdLocation,omitempty"` + SchemaIDPayloadEncoding string `json:"schemaIdPayloadEncoding,omitempty" yaml:"schemaIdPayloadEncoding,omitempty"` + SchemaLookupStrategy string `json:"schemaLookupStrategy,omitempty" yaml:"schemaLookupStrategy,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +// AMQP Bindings + +type AMQPServerBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type AMQPChannelBinding struct { + Is string `json:"is,omitempty" yaml:"is,omitempty"` // queue or routingKey + Exchange any `json:"exchange,omitempty" yaml:"exchange,omitempty"` + Queue any `json:"queue,omitempty" yaml:"queue,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type AMQPOperationBinding struct { + Expiration int `json:"expiration,omitempty" yaml:"expiration,omitempty"` + UserID string `json:"userId,omitempty" yaml:"userId,omitempty"` + CC []string `json:"cc,omitempty" yaml:"cc,omitempty"` + Priority *int `json:"priority,omitempty" yaml:"priority,omitempty"` + DeliveryMode *int `json:"deliveryMode,omitempty" yaml:"deliveryMode,omitempty"` + Mandatory bool `json:"mandatory,omitempty" yaml:"mandatory,omitempty"` + BCC []string `json:"bcc,omitempty" yaml:"bcc,omitempty"` + Timestamp bool `json:"timestamp,omitempty" yaml:"timestamp,omitempty"` + Ack bool `json:"ack,omitempty" yaml:"ack,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type AMQPMessageBinding struct { + ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` + MessageType string `json:"messageType,omitempty" yaml:"messageType,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +// MQTT Bindings + +type MQTTServerBinding struct { + ClientID string `json:"clientId,omitempty" yaml:"clientId,omitempty"` + CleanSession bool `json:"cleanSession,omitempty" yaml:"cleanSession,omitempty"` + LastWill any `json:"lastWill,omitempty" yaml:"lastWill,omitempty"` + KeepAlive *int `json:"keepAlive,omitempty" yaml:"keepAlive,omitempty"` + SessionExpiryInterval *int `json:"sessionExpiryInterval,omitempty" yaml:"sessionExpiryInterval,omitempty"` + MaximumPacketSize *int `json:"maximumPacketSize,omitempty" yaml:"maximumPacketSize,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type MQTTChannelBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type MQTTOperationBinding struct { + QoS *int `json:"qos,omitempty" yaml:"qos,omitempty"` + Retain bool `json:"retain,omitempty" yaml:"retain,omitempty"` + MessageExpiryInterval *int `json:"messageExpiryInterval,omitempty" yaml:"messageExpiryInterval,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type MQTTMessageBinding struct { + PayloadFormatIndicator *int `json:"payloadFormatIndicator,omitempty" yaml:"payloadFormatIndicator,omitempty"` + CorrelationData *SchemaRef `json:"correlationData,omitempty" yaml:"correlationData,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + ResponseTopic string `json:"responseTopic,omitempty" yaml:"responseTopic,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +// NATS Bindings + +type NATSServerBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type NATSChannelBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type NATSOperationBinding struct { + Queue string `json:"queue,omitempty" yaml:"queue,omitempty"` + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} + +type NATSMessageBinding struct { + BindingVersion string `json:"bindingVersion,omitempty" yaml:"bindingVersion,omitempty"` +} diff --git a/third_party/go-asyncapi/channel.go b/third_party/go-asyncapi/channel.go new file mode 100644 index 0000000..def3e0f --- /dev/null +++ b/third_party/go-asyncapi/channel.go @@ -0,0 +1,24 @@ +package asyncapi + +// Channel describes a shared communication channel. +type Channel struct { + Address *string `json:"address" yaml:"address"` // nullable per spec + Messages map[string]*MessageRef `json:"messages,omitempty" yaml:"messages,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Servers []*ServerRef `json:"servers,omitempty" yaml:"servers,omitempty"` + Parameters map[string]*ParameterRef `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Tags []*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Bindings *ChannelBindingsRef `json:"bindings,omitempty" yaml:"bindings,omitempty"` +} + +// Parameter describes a parameter included in a channel address. +type Parameter struct { + Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"` + Default string `json:"default,omitempty" yaml:"default,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Examples []string `json:"examples,omitempty" yaml:"examples,omitempty"` + Location string `json:"location,omitempty" yaml:"location,omitempty"` // runtime expression +} diff --git a/third_party/go-asyncapi/components.go b/third_party/go-asyncapi/components.go new file mode 100644 index 0000000..4eade51 --- /dev/null +++ b/third_party/go-asyncapi/components.go @@ -0,0 +1,24 @@ +package asyncapi + +// Components holds reusable objects for different aspects of the AsyncAPI specification. +type Components struct { + Schemas map[string]*SchemaRef `json:"schemas,omitempty" yaml:"schemas,omitempty"` + Servers map[string]*ServerRef `json:"servers,omitempty" yaml:"servers,omitempty"` + Channels map[string]*ChannelRef `json:"channels,omitempty" yaml:"channels,omitempty"` + Operations map[string]*OperationRef `json:"operations,omitempty" yaml:"operations,omitempty"` + Messages map[string]*MessageRef `json:"messages,omitempty" yaml:"messages,omitempty"` + SecuritySchemes map[string]*SecuritySchemeRef `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` + ServerVariables map[string]*ServerVariableRef `json:"serverVariables,omitempty" yaml:"serverVariables,omitempty"` + Parameters map[string]*ParameterRef `json:"parameters,omitempty" yaml:"parameters,omitempty"` + CorrelationIDs map[string]*CorrelationIDRef `json:"correlationIds,omitempty" yaml:"correlationIds,omitempty"` + Replies map[string]*OperationReplyRef `json:"replies,omitempty" yaml:"replies,omitempty"` + ReplyAddresses map[string]*ReplyAddressRef `json:"replyAddresses,omitempty" yaml:"replyAddresses,omitempty"` + ExternalDocs map[string]*ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Tags map[string]*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + OperationTraits map[string]*OperationTraitRef `json:"operationTraits,omitempty" yaml:"operationTraits,omitempty"` + MessageTraits map[string]*MessageTraitRef `json:"messageTraits,omitempty" yaml:"messageTraits,omitempty"` + ServerBindings map[string]*ServerBindingsRef `json:"serverBindings,omitempty" yaml:"serverBindings,omitempty"` + ChannelBindings map[string]*ChannelBindingsRef `json:"channelBindings,omitempty" yaml:"channelBindings,omitempty"` + OperationBindings map[string]*OperationBindingsRef `json:"operationBindings,omitempty" yaml:"operationBindings,omitempty"` + MessageBindings map[string]*MessageBindingsRef `json:"messageBindings,omitempty" yaml:"messageBindings,omitempty"` +} diff --git a/third_party/go-asyncapi/document.go b/third_party/go-asyncapi/document.go new file mode 100644 index 0000000..63502e8 --- /dev/null +++ b/third_party/go-asyncapi/document.go @@ -0,0 +1,100 @@ +package asyncapi + +import ( + "encoding/json" +) + +// Document is the root AsyncAPI 3.0.0 specification object. +type Document struct { + AsyncAPI string `json:"asyncapi" yaml:"asyncapi"` + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Info Info `json:"info" yaml:"info"` + Servers map[string]*ServerRef `json:"servers,omitempty" yaml:"servers,omitempty"` + DefaultContentType string `json:"defaultContentType,omitempty" yaml:"defaultContentType,omitempty"` + Channels map[string]*ChannelRef `json:"channels,omitempty" yaml:"channels,omitempty"` + Operations map[string]*OperationRef `json:"operations,omitempty" yaml:"operations,omitempty"` + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` + + // extensions holds spec extensions (x-* fields) + extensions map[string]json.RawMessage + + // raw holds the original document bytes for validation + raw []byte +} + +// Extension returns a spec extension by name (e.g., "x-custom"). +func (d *Document) Extension(name string) json.RawMessage { + if d.extensions == nil { + return nil + } + return d.extensions[name] +} + +// SetExtension sets a spec extension. +func (d *Document) SetExtension(name string, value json.RawMessage) { + if d.extensions == nil { + d.extensions = make(map[string]json.RawMessage) + } + d.extensions[name] = value +} + +// Raw returns the original document bytes. +func (d *Document) Raw() []byte { + return d.raw +} + +// Version returns the AsyncAPI version string. +func (d *Document) Version() string { + return d.AsyncAPI +} + +// Title returns the API title. +func (d *Document) Title() string { + return d.Info.Title +} + +// GetServer returns a server by name. +func (d *Document) GetServer(name string) *Server { + if ref, ok := d.Servers[name]; ok && ref != nil && ref.Value != nil { + return ref.Value + } + return nil +} + +// GetChannel returns a channel by ID. +func (d *Document) GetChannel(id string) *Channel { + if ref, ok := d.Channels[id]; ok && ref != nil && ref.Value != nil { + return ref.Value + } + return nil +} + +// GetOperation returns an operation by ID. +func (d *Document) GetOperation(id string) *Operation { + if ref, ok := d.Operations[id]; ok && ref != nil && ref.Value != nil { + return ref.Value + } + return nil +} + +// GetMessage returns a message from components by name. +func (d *Document) GetMessage(name string) *Message { + if d.Components == nil { + return nil + } + if ref, ok := d.Components.Messages[name]; ok && ref != nil && ref.Value != nil { + return ref.Value + } + return nil +} + +// GetSchema returns a schema from components by name. +func (d *Document) GetSchema(name string) *Schema { + if d.Components == nil { + return nil + } + if ref, ok := d.Components.Schemas[name]; ok && ref != nil && ref.Value != nil { + return ref.Value + } + return nil +} diff --git a/third_party/go-asyncapi/errors.go b/third_party/go-asyncapi/errors.go new file mode 100644 index 0000000..5a140c3 --- /dev/null +++ b/third_party/go-asyncapi/errors.go @@ -0,0 +1,79 @@ +// Package asyncapi provides a parser and validator for AsyncAPI 3.0.0 documents. +package asyncapi + +import ( + "fmt" + "strings" +) + +// ParseError represents an error that occurred during parsing. +type ParseError struct { + Message string + Cause error +} + +func (e *ParseError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Cause) + } + return e.Message +} + +func (e *ParseError) Unwrap() error { + return e.Cause +} + +// ValidationError represents a validation error at a specific path. +type ValidationError struct { + Path string // JSON pointer to error location + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Path, e.Message) +} + +// ValidationResult contains the results of document validation. +type ValidationResult struct { + Errors []ValidationError +} + +// IsValid returns true if there are no validation errors. +func (r *ValidationResult) IsValid() bool { + return len(r.Errors) == 0 +} + +// Error returns a combined error message for all validation errors. +func (r *ValidationResult) Error() string { + if r.IsValid() { + return "" + } + var msgs []string + for _, e := range r.Errors { + msgs = append(msgs, e.Error()) + } + return strings.Join(msgs, "; ") +} + +// Add adds a validation error to the result. +func (r *ValidationResult) Add(path, message string) { + r.Errors = append(r.Errors, ValidationError{Path: path, Message: message}) +} + +// RefError represents an error during reference resolution. +type RefError struct { + Ref string + Message string + Cause error +} + +func (e *RefError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("$ref %q: %s: %v", e.Ref, e.Message, e.Cause) + } + return fmt.Sprintf("$ref %q: %s", e.Ref, e.Message) +} + +func (e *RefError) Unwrap() error { + return e.Cause +} diff --git a/third_party/go-asyncapi/go.mod b/third_party/go-asyncapi/go.mod new file mode 100644 index 0000000..96d8543 --- /dev/null +++ b/third_party/go-asyncapi/go.mod @@ -0,0 +1,8 @@ +module github.com/benelser/go-asyncapi + +go 1.23.0 + +require ( + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + gopkg.in/yaml.v3 v3.0.1 +) \ No newline at end of file diff --git a/third_party/go-asyncapi/go.sum b/third_party/go-asyncapi/go.sum new file mode 100644 index 0000000..de648bb --- /dev/null +++ b/third_party/go-asyncapi/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/third_party/go-asyncapi/info.go b/third_party/go-asyncapi/info.go new file mode 100644 index 0000000..35e3749 --- /dev/null +++ b/third_party/go-asyncapi/info.go @@ -0,0 +1,39 @@ +package asyncapi + +// Info provides metadata about the API. +type Info struct { + Title string `json:"title" yaml:"title"` + Version string `json:"version" yaml:"version"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + TermsOfService string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` + Contact *Contact `json:"contact,omitempty" yaml:"contact,omitempty"` + License *License `json:"license,omitempty" yaml:"license,omitempty"` + Tags []*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` +} + +// Contact information for the exposed API. +type Contact struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Email string `json:"email,omitempty" yaml:"email,omitempty"` +} + +// License information for the exposed API. +type License struct { + Name string `json:"name" yaml:"name"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` +} + +// Tag allows adding metadata to a single tag. +type Tag struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` +} + +// ExternalDocs allows referencing an external resource for extended documentation. +type ExternalDocs struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + URL string `json:"url" yaml:"url"` +} diff --git a/third_party/go-asyncapi/internal/jsonschema/asyncapi-3.0.0.json b/third_party/go-asyncapi/internal/jsonschema/asyncapi-3.0.0.json new file mode 100644 index 0000000..f7687fd --- /dev/null +++ b/third_party/go-asyncapi/internal/jsonschema/asyncapi-3.0.0.json @@ -0,0 +1,8971 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "AsyncAPI 3.0.0 schema.", + "type": "object", + "required": [ + "asyncapi", + "info" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "asyncapi": { + "type": "string", + "const": "3.0.0", + "description": "The AsyncAPI specification version of this document." + }, + "id": { + "type": "string", + "description": "A unique id representing the application.", + "format": "uri" + }, + "info": { + "$ref": "#/definitions/info" + }, + "servers": { + "$ref": "#/definitions/servers" + }, + "defaultContentType": { + "type": "string", + "description": "Default content type to use when encoding/decoding a message's payload." + }, + "channels": { + "$ref": "#/definitions/channels" + }, + "operations": { + "$ref": "#/definitions/operations" + }, + "components": { + "$ref": "#/definitions/components" + } + }, + "definitions": { + "specificationExtension": { + "description": "Any property starting with x- is valid.", + "additionalProperties": true, + "additionalItems": true + }, + "info": { + "description": "The object provides metadata about the API. The metadata can be used by the clients if needed.", + "allOf": [ + { + "type": "object", + "required": [ + "version", + "title" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "title": { + "type": "string", + "description": "A unique and precise title of the API." + }, + "version": { + "type": "string", + "description": "A semantic version number of the API." + }, + "description": { + "type": "string", + "description": "A longer description of the API. Should be different from the title. CommonMark is allowed." + }, + "termsOfService": { + "type": "string", + "description": "A URL to the Terms of Service for the API. MUST be in the format of a URL.", + "format": "uri" + }, + "contact": { + "$ref": "#/definitions/contact" + }, + "license": { + "$ref": "#/definitions/license" + }, + "tags": { + "type": "array", + "description": "A list of tags for application API documentation control. Tags can be used for logical grouping of applications.", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/tag" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + } + } + }, + { + "$ref": "#/definitions/infoExtensions" + } + ], + "examples": [ + { + "title": "AsyncAPI Sample App", + "version": "1.0.1", + "description": "This is a sample app.", + "termsOfService": "https://asyncapi.org/terms/", + "contact": { + "name": "API Support", + "url": "https://www.asyncapi.org/support", + "email": "support@asyncapi.org" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "externalDocs": { + "description": "Find more info here", + "url": "https://www.asyncapi.org" + }, + "tags": [ + { + "name": "e-commerce" + } + ] + } + ] + }, + "contact": { + "type": "object", + "description": "Contact information for the exposed API.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The identifying name of the contact person/organization." + }, + "url": { + "type": "string", + "description": "The URL pointing to the contact information.", + "format": "uri" + }, + "email": { + "type": "string", + "description": "The email address of the contact person/organization.", + "format": "email" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "examples": [ + { + "name": "API Support", + "url": "https://www.example.com/support", + "email": "support@example.com" + } + ] + }, + "license": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the license type. It's encouraged to use an OSI compatible license." + }, + "url": { + "type": "string", + "description": "The URL pointing to the license.", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "examples": [ + { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + ] + }, + "Reference": { + "type": "object", + "description": "A simple object to allow referencing other components in the specification, internally and externally.", + "required": [ + "$ref" + ], + "properties": { + "$ref": { + "description": "The reference string.", + "$ref": "#/definitions/ReferenceObject" + } + }, + "examples": [ + { + "$ref": "#/components/schemas/Pet" + } + ] + }, + "ReferenceObject": { + "type": "string", + "format": "uri-reference" + }, + "tag": { + "type": "object", + "description": "Allows adding metadata to a single tag.", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the tag." + }, + "description": { + "type": "string", + "description": "A short description for the tag. CommonMark syntax can be used for rich text representation." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "examples": [ + { + "name": "user", + "description": "User-related messages" + } + ] + }, + "externalDocs": { + "type": "object", + "additionalProperties": false, + "description": "Allows referencing an external resource for extended documentation.", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string", + "description": "A short description of the target documentation. CommonMark syntax can be used for rich text representation." + }, + "url": { + "type": "string", + "description": "The URL for the target documentation. This MUST be in the form of an absolute URL.", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "examples": [ + { + "description": "Find more info here", + "url": "https://example.com" + } + ] + }, + "infoExtensions": { + "type": "object", + "description": "The object that lists all the extensions of Info", + "properties": { + "x-x": { + "$ref": "#/definitions/extensions-x-0.1.0-schema" + }, + "x-linkedin": { + "$ref": "#/definitions/extensions-linkedin-0.1.0-schema" + } + } + }, + "extensions-x-0.1.0-schema": { + "type": "string", + "description": "This extension allows you to provide the Twitter username of the account representing the team/company of the API.", + "example": [ + "sambhavgupta75", + "AsyncAPISpec" + ] + }, + "extensions-linkedin-0.1.0-schema": { + "type": "string", + "pattern": "^http(s)?://(www\\.)?linkedin\\.com.*$", + "description": "This extension allows you to provide the Linkedin profile URL of the account representing the team/company of the API.", + "example": [ + "https://www.linkedin.com/company/asyncapi/", + "https://www.linkedin.com/in/sambhavgupta0705/" + ] + }, + "servers": { + "description": "An object representing multiple servers.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/server" + } + ] + }, + "examples": [ + { + "development": { + "host": "localhost:5672", + "description": "Development AMQP broker.", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "tags": [ + { + "name": "env:development", + "description": "This environment is meant for developers to run their own tests." + } + ] + }, + "staging": { + "host": "rabbitmq-staging.in.mycompany.com:5672", + "description": "RabbitMQ broker for the staging environment.", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "tags": [ + { + "name": "env:staging", + "description": "This environment is a replica of the production environment." + } + ] + }, + "production": { + "host": "rabbitmq.in.mycompany.com:5672", + "description": "RabbitMQ broker for the production environment.", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "tags": [ + { + "name": "env:production", + "description": "This environment is the live environment available for final users." + } + ] + } + } + ] + }, + "server": { + "type": "object", + "description": "An object representing a message broker, a server or any other kind of computer program capable of sending and/or receiving data.", + "required": [ + "host", + "protocol" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "host": { + "type": "string", + "description": "The server host name. It MAY include the port. This field supports Server Variables. Variable substitutions will be made when a variable is named in {braces}." + }, + "pathname": { + "type": "string", + "description": "The path to a resource in the host. This field supports Server Variables. Variable substitutions will be made when a variable is named in {braces}." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the server." + }, + "summary": { + "type": "string", + "description": "A brief summary of the server." + }, + "description": { + "type": "string", + "description": "A longer description of the server. CommonMark is allowed." + }, + "protocol": { + "type": "string", + "description": "The protocol this server supports for connection." + }, + "protocolVersion": { + "type": "string", + "description": "An optional string describing the server. CommonMark syntax MAY be used for rich text representation." + }, + "variables": { + "$ref": "#/definitions/serverVariables" + }, + "security": { + "$ref": "#/definitions/securityRequirements" + }, + "tags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/tag" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + }, + "bindings": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/serverBindingsObject" + } + ] + } + }, + "examples": [ + { + "host": "kafka.in.mycompany.com:9092", + "description": "Production Kafka broker.", + "protocol": "kafka", + "protocolVersion": "3.2" + }, + { + "host": "rabbitmq.in.mycompany.com:5672", + "pathname": "/production", + "protocol": "amqp", + "description": "Production RabbitMQ broker (uses the `production` vhost)." + } + ] + }, + "serverVariables": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/serverVariable" + } + ] + } + }, + "serverVariable": { + "type": "object", + "description": "An object representing a Server Variable for server URL template substitution.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "enum": { + "type": "array", + "description": "An enumeration of string values to be used if the substitution options are from a limited set.", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "default": { + "type": "string", + "description": "The default value to use for substitution, and to send, if an alternate value is not supplied." + }, + "description": { + "type": "string", + "description": "An optional description for the server variable. CommonMark syntax MAY be used for rich text representation." + }, + "examples": { + "type": "array", + "description": "An array of examples of the server variable.", + "items": { + "type": "string" + } + } + }, + "examples": [ + { + "host": "rabbitmq.in.mycompany.com:5672", + "pathname": "/{env}", + "protocol": "amqp", + "description": "RabbitMQ broker. Use the `env` variable to point to either `production` or `staging`.", + "variables": { + "env": { + "description": "Environment to connect to. It can be either `production` or `staging`.", + "enum": [ + "production", + "staging" + ] + } + } + } + ] + }, + "securityRequirements": { + "description": "An array representing security requirements.", + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/SecurityScheme" + } + ] + } + }, + "SecurityScheme": { + "description": "Defines a security scheme that can be used by the operations.", + "oneOf": [ + { + "$ref": "#/definitions/userPassword" + }, + { + "$ref": "#/definitions/apiKey" + }, + { + "$ref": "#/definitions/X509" + }, + { + "$ref": "#/definitions/symmetricEncryption" + }, + { + "$ref": "#/definitions/asymmetricEncryption" + }, + { + "$ref": "#/definitions/HTTPSecurityScheme" + }, + { + "$ref": "#/definitions/oauth2Flows" + }, + { + "$ref": "#/definitions/openIdConnect" + }, + { + "$ref": "#/definitions/SaslSecurityScheme" + } + ], + "examples": [ + { + "type": "userPassword" + } + ] + }, + "userPassword": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "userPassword" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "userPassword" + } + ] + }, + "apiKey": { + "type": "object", + "required": [ + "type", + "in" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme", + "enum": [ + "apiKey" + ] + }, + "in": { + "type": "string", + "description": " The location of the API key.", + "enum": [ + "user", + "password" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "apiKey", + "in": "user" + } + ] + }, + "X509": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "X509" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "X509" + } + ] + }, + "symmetricEncryption": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "symmetricEncryption" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "symmetricEncryption" + } + ] + }, + "asymmetricEncryption": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "asymmetricEncryption" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "HTTPSecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/NonBearerHTTPSecurityScheme" + }, + { + "$ref": "#/definitions/BearerHTTPSecurityScheme" + }, + { + "$ref": "#/definitions/APIKeyHTTPSecurityScheme" + } + ] + }, + "NonBearerHTTPSecurityScheme": { + "not": { + "type": "object", + "properties": { + "scheme": { + "type": "string", + "description": "A short description for security scheme.", + "enum": [ + "bearer" + ] + } + } + }, + "type": "object", + "required": [ + "scheme", + "type" + ], + "properties": { + "scheme": { + "type": "string", + "description": "The name of the HTTP Authorization scheme to be used in the Authorization header as defined in RFC7235." + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + }, + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "http" + ] + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "BearerHTTPSecurityScheme": { + "type": "object", + "required": [ + "type", + "scheme" + ], + "properties": { + "scheme": { + "type": "string", + "description": "The name of the HTTP Authorization scheme to be used in the Authorization header as defined in RFC7235.", + "enum": [ + "bearer" + ] + }, + "bearerFormat": { + "type": "string", + "description": "A hint to the client to identify how the bearer token is formatted. Bearer tokens are usually generated by an authorization server, so this information is primarily for documentation purposes." + }, + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "http" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "APIKeyHTTPSecurityScheme": { + "type": "object", + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "httpApiKey" + ] + }, + "name": { + "type": "string", + "description": "The name of the header, query or cookie parameter to be used." + }, + "in": { + "type": "string", + "description": "The location of the API key", + "enum": [ + "header", + "query", + "cookie" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "httpApiKey", + "name": "api_key", + "in": "header" + } + ] + }, + "oauth2Flows": { + "type": "object", + "description": "Allows configuration of the supported OAuth Flows.", + "required": [ + "type", + "flows" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "oauth2" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + }, + "flows": { + "type": "object", + "properties": { + "implicit": { + "description": "Configuration for the OAuth Implicit flow.", + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "authorizationUrl", + "availableScopes" + ] + }, + { + "not": { + "required": [ + "tokenUrl" + ] + } + } + ] + }, + "password": { + "description": "Configuration for the OAuth Resource Owner Protected Credentials flow.", + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "tokenUrl", + "availableScopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "clientCredentials": { + "description": "Configuration for the OAuth Client Credentials flow.", + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "tokenUrl", + "availableScopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "authorizationCode": { + "description": "Configuration for the OAuth Authorization Code flow.", + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "authorizationUrl", + "tokenUrl", + "availableScopes" + ] + } + ] + } + }, + "additionalProperties": false + }, + "scopes": { + "type": "array", + "description": "List of the needed scope names.", + "items": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "oauth2Flow": { + "type": "object", + "description": "Configuration details for a supported OAuth Flow", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "The authorization URL to be used for this flow. This MUST be in the form of an absolute URL." + }, + "tokenUrl": { + "type": "string", + "format": "uri", + "description": "The token URL to be used for this flow. This MUST be in the form of an absolute URL." + }, + "refreshUrl": { + "type": "string", + "format": "uri", + "description": "The URL to be used for obtaining refresh tokens. This MUST be in the form of an absolute URL." + }, + "availableScopes": { + "$ref": "#/definitions/oauth2Scopes", + "description": "The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "authorizationUrl": "https://example.com/api/oauth/dialog", + "tokenUrl": "https://example.com/api/oauth/token", + "availableScopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + ] + }, + "oauth2Scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "openIdConnect": { + "type": "object", + "required": [ + "type", + "openIdConnectUrl" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "openIdConnect" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme. CommonMark syntax MAY be used for rich text representation." + }, + "openIdConnectUrl": { + "type": "string", + "format": "uri", + "description": "OpenId Connect URL to discover OAuth2 configuration values. This MUST be in the form of an absolute URL." + }, + "scopes": { + "type": "array", + "description": "List of the needed scope names. An empty array means no scopes are needed.", + "items": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "SaslSecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/SaslPlainSecurityScheme" + }, + { + "$ref": "#/definitions/SaslScramSecurityScheme" + }, + { + "$ref": "#/definitions/SaslGssapiSecurityScheme" + } + ] + }, + "SaslPlainSecurityScheme": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme. Valid values", + "enum": [ + "plain" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "scramSha512" + } + ] + }, + "SaslScramSecurityScheme": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "scramSha256", + "scramSha512" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "scramSha512" + } + ] + }, + "SaslGssapiSecurityScheme": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the security scheme.", + "enum": [ + "gssapi" + ] + }, + "description": { + "type": "string", + "description": "A short description for security scheme." + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false, + "examples": [ + { + "type": "scramSha512" + } + ] + }, + "serverBindingsObject": { + "type": "object", + "description": "Map describing protocol-specific definitions for a server.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "http": {}, + "ws": {}, + "amqp": {}, + "amqp1": {}, + "mqtt": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-mqtt-0.2.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-mqtt-0.2.0-server" + } + } + ] + }, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.4.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.3.0-server" + } + } + ] + }, + "anypointmq": {}, + "nats": {}, + "jms": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-jms-0.0.1-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-jms-0.0.1-server" + } + } + ] + }, + "sns": {}, + "sqs": {}, + "stomp": {}, + "redis": {}, + "ibmmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-ibmmq-0.1.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-ibmmq-0.1.0-server" + } + } + ] + }, + "solace": { + "properties": { + "bindingVersion": { + "enum": [ + "0.4.0", + "0.3.0", + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.4.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.4.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.3.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.2.0-server" + } + } + ] + }, + "googlepubsub": {}, + "pulsar": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-pulsar-0.1.0-server" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-pulsar-0.1.0-server" + } + } + ] + } + } + }, + "bindings-mqtt-0.2.0-server": { + "title": "Server Schema", + "description": "This object contains information about the server representation in MQTT.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "clientId": { + "type": "string", + "description": "The client identifier." + }, + "cleanSession": { + "type": "boolean", + "description": "Whether to create a persistent connection or not. When 'false', the connection will be persistent. This is called clean start in MQTTv5." + }, + "lastWill": { + "type": "object", + "description": "Last Will and Testament configuration.", + "properties": { + "topic": { + "type": "string", + "description": "The topic where the Last Will and Testament message will be sent." + }, + "qos": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "description": "Defines how hard the broker/client will try to ensure that the Last Will and Testament message is received. Its value MUST be either 0, 1 or 2." + }, + "message": { + "type": "string", + "description": "Last Will message." + }, + "retain": { + "type": "boolean", + "description": "Whether the broker should retain the Last Will and Testament message or not." + } + } + }, + "keepAlive": { + "type": "integer", + "description": "Interval in seconds of the longest period of time the broker and the client can endure without sending a message." + }, + "sessionExpiryInterval": { + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "Interval time in seconds or a Schema Object containing the definition of the interval. The broker maintains a session for a disconnected client until this interval expires." + }, + "maximumPacketSize": { + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "Number of bytes or a Schema Object representing the Maximum Packet Size the Client is willing to accept." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "clientId": "guest", + "cleanSession": true, + "lastWill": { + "topic": "/last-wills", + "qos": 2, + "message": "Guest gone offline.", + "retain": false + }, + "keepAlive": 60, + "sessionExpiryInterval": 120, + "maximumPacketSize": 1024, + "bindingVersion": "0.2.0" + } + ] + }, + "schema": { + "description": "The Schema Object allows the definition of input and output data types. These types can be objects, but also primitives and arrays. This object is a superset of the JSON Schema Specification Draft 07. The empty schema (which allows any instance to validate) MAY be represented by the boolean value true and a schema which allows no instance to validate MAY be represented by the boolean value false.", + "allOf": [ + { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + { + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "boolean" + } + ], + "default": {} + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + } + ], + "default": {} + }, + "allOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + }, + "oneOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + }, + "anyOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + }, + "not": { + "$ref": "#/definitions/schema" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "propertyNames": { + "$ref": "#/definitions/schema" + }, + "contains": { + "$ref": "#/definitions/schema" + }, + "discriminator": { + "type": "string", + "description": "Adds support for polymorphism. The discriminator is the schema property name that is used to differentiate between other schema that inherit this schema. The property name used MUST be defined at this schema and it MUST be in the required property list. When used, the value MUST be the name of this schema or any schema that inherits it. See Composition and Inheritance for more details." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + }, + "deprecated": { + "type": "boolean", + "description": "Specifies that a schema is deprecated and SHOULD be transitioned out of usage. Default value is false.", + "default": false + } + } + } + ] + }, + "json-schema-draft-07-schema": { + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/json-schema-draft-07-schema" + } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/nonNegativeInteger" + }, + { + "default": 0 + } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "default": [] + } + }, + "type": [ + "object", + "boolean" + ], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/nonNegativeInteger" + }, + "minLength": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/nonNegativeIntegerDefault0" + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/schemaArray" + } + ], + "default": true + }, + "maxItems": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/nonNegativeInteger" + }, + "minItems": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/nonNegativeIntegerDefault0" + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "maxProperties": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/nonNegativeInteger" + }, + "minProperties": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/nonNegativeIntegerDefault0" + }, + "required": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/stringArray" + }, + "additionalProperties": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "propertyNames": { + "format": "regex" + }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/stringArray" + } + ] + } + }, + "propertyNames": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/simpleTypes" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/simpleTypes" + }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { + "type": "string" + }, + "contentMediaType": { + "type": "string" + }, + "contentEncoding": { + "type": "string" + }, + "if": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "then": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "else": { + "$ref": "#/definitions/json-schema-draft-07-schema" + }, + "allOf": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/schemaArray" + }, + "anyOf": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/schemaArray" + }, + "oneOf": { + "$ref": "#/definitions/json-schema-draft-07-schema/definitions/schemaArray" + }, + "not": { + "$ref": "#/definitions/json-schema-draft-07-schema" + } + }, + "default": true + }, + "bindings-kafka-0.5.0-server": { + "title": "Server Schema", + "description": "This object contains server connection information to a Kafka broker. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemaRegistryUrl": { + "type": "string", + "description": "API URL for the Schema Registry used when producing Kafka messages (if a Schema Registry was used)." + }, + "schemaRegistryVendor": { + "type": "string", + "description": "The vendor of the Schema Registry and Kafka serdes library that should be used." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "schemaRegistryUrl": "https://my-schema-registry.com", + "schemaRegistryVendor": "confluent", + "bindingVersion": "0.5.0" + } + ] + }, + "bindings-kafka-0.4.0-server": { + "title": "Server Schema", + "description": "This object contains server connection information to a Kafka broker. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemaRegistryUrl": { + "type": "string", + "description": "API URL for the Schema Registry used when producing Kafka messages (if a Schema Registry was used)." + }, + "schemaRegistryVendor": { + "type": "string", + "description": "The vendor of the Schema Registry and Kafka serdes library that should be used." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "schemaRegistryUrl": "https://my-schema-registry.com", + "schemaRegistryVendor": "confluent", + "bindingVersion": "0.4.0" + } + ] + }, + "bindings-kafka-0.3.0-server": { + "title": "Server Schema", + "description": "This object contains server connection information to a Kafka broker. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemaRegistryUrl": { + "type": "string", + "description": "API URL for the Schema Registry used when producing Kafka messages (if a Schema Registry was used)." + }, + "schemaRegistryVendor": { + "type": "string", + "description": "The vendor of the Schema Registry and Kafka serdes library that should be used." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "schemaRegistryUrl": "https://my-schema-registry.com", + "schemaRegistryVendor": "confluent", + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-jms-0.0.1-server": { + "title": "Server Schema", + "description": "This object contains configuration for describing a JMS broker as an AsyncAPI server. This objects only contains configuration that can not be provided in the AsyncAPI standard server object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "required": [ + "jmsConnectionFactory" + ], + "properties": { + "jmsConnectionFactory": { + "type": "string", + "description": "The classname of the ConnectionFactory implementation for the JMS Provider." + }, + "properties": { + "type": "array", + "items": { + "$ref": "#/definitions/bindings-jms-0.0.1-server/definitions/property" + }, + "description": "Additional properties to set on the JMS ConnectionFactory implementation for the JMS Provider." + }, + "clientID": { + "type": "string", + "description": "A client identifier for applications that use this JMS connection factory. If the Client ID Policy is set to 'Restricted' (the default), then configuring a Client ID on the ConnectionFactory prevents more than one JMS client from using a connection from this factory." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "definitions": { + "property": { + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of a property" + }, + "value": { + "type": [ + "string", + "boolean", + "number", + "null" + ], + "description": "The name of a property" + } + } + } + }, + "examples": [ + { + "jmsConnectionFactory": "org.apache.activemq.ActiveMQConnectionFactory", + "properties": [ + { + "name": "disableTimeStampsByDefault", + "value": false + } + ], + "clientID": "my-application-1", + "bindingVersion": "0.0.1" + } + ] + }, + "bindings-ibmmq-0.1.0-server": { + "title": "IBM MQ server bindings object", + "description": "This object contains server connection information about the IBM MQ server, referred to as an IBM MQ queue manager. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "groupId": { + "type": "string", + "description": "Defines a logical group of IBM MQ server objects. This is necessary to specify multi-endpoint configurations used in high availability deployments. If omitted, the server object is not part of a group." + }, + "ccdtQueueManagerName": { + "type": "string", + "default": "*", + "description": "The name of the IBM MQ queue manager to bind to in the CCDT file." + }, + "cipherSpec": { + "type": "string", + "description": "The recommended cipher specification used to establish a TLS connection between the client and the IBM MQ queue manager. More information on SSL/TLS cipher specifications supported by IBM MQ can be found on this page in the IBM MQ Knowledge Center." + }, + "multiEndpointServer": { + "type": "boolean", + "default": false, + "description": "If 'multiEndpointServer' is 'true' then multiple connections can be workload balanced and applications should not make assumptions as to where messages are processed. Where message ordering, or affinity to specific message resources is necessary, a single endpoint ('multiEndpointServer' = 'false') may be required." + }, + "heartBeatInterval": { + "type": "integer", + "minimum": 0, + "maximum": 999999, + "default": 300, + "description": "The recommended value (in seconds) for the heartbeat sent to the queue manager during periods of inactivity. A value of zero means that no heart beats are sent. A value of 1 means that the client will use the value defined by the queue manager. More information on heart beat interval can be found on this page in the IBM MQ Knowledge Center." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "groupId": "PRODCLSTR1", + "cipherSpec": "ANY_TLS12_OR_HIGHER", + "bindingVersion": "0.1.0" + }, + { + "groupId": "PRODCLSTR1", + "bindingVersion": "0.1.0" + } + ] + }, + "bindings-solace-0.4.0-server": { + "title": "Solace server bindings object", + "description": "This object contains server connection information about the Solace broker. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "msgVpn": { + "type": "string", + "description": "The name of the Virtual Private Network to connect to on the Solace broker." + }, + "clientName": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "A unique client name to use to register to the appliance. If specified, it must be a valid Topic name, and a maximum of 160 bytes in length when encoded as UTF-8." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "msgVpn": "ProdVPN", + "bindingVersion": "0.4.0" + } + ] + }, + "bindings-solace-0.3.0-server": { + "title": "Solace server bindings object", + "description": "This object contains server connection information about the Solace broker. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "msgVpn": { + "type": "string", + "description": "The name of the Virtual Private Network to connect to on the Solace broker." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "msgVpn": "ProdVPN", + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-solace-0.2.0-server": { + "title": "Solace server bindings object", + "description": "This object contains server connection information about the Solace broker. This object contains additional connectivity information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "msvVpn": { + "type": "string", + "description": "The name of the Virtual Private Network to connect to on the Solace broker." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding." + } + }, + "examples": [ + { + "msgVpn": "ProdVPN", + "bindingVersion": "0.2.0" + } + ] + }, + "bindings-pulsar-0.1.0-server": { + "title": "Server Schema", + "description": "This object contains server information of Pulsar broker, which covers cluster and tenant admin configuration. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "tenant": { + "type": "string", + "description": "The pulsar tenant. If omitted, 'public' MUST be assumed." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "tenant": "contoso", + "bindingVersion": "0.1.0" + } + ] + }, + "channels": { + "type": "object", + "description": "An object containing all the Channel Object definitions the Application MUST use during runtime.", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/channel" + } + ] + }, + "examples": [ + { + "userSignedUp": { + "address": "user.signedup", + "messages": { + "userSignedUp": { + "$ref": "#/components/messages/userSignedUp" + } + } + } + } + ] + }, + "channel": { + "type": "object", + "description": "Describes a shared communication channel.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "address": { + "type": [ + "string", + "null" + ], + "description": "An optional string representation of this channel's address. The address is typically the \"topic name\", \"routing key\", \"event type\", or \"path\". When `null` or absent, it MUST be interpreted as unknown. This is useful when the address is generated dynamically at runtime or can't be known upfront. It MAY contain Channel Address Expressions." + }, + "messages": { + "$ref": "#/definitions/channelMessages" + }, + "parameters": { + "$ref": "#/definitions/parameters" + }, + "title": { + "type": "string", + "description": "A human-friendly title for the channel." + }, + "summary": { + "type": "string", + "description": "A brief summary of the channel." + }, + "description": { + "type": "string", + "description": "A longer description of the channel. CommonMark is allowed." + }, + "servers": { + "type": "array", + "description": "The references of the servers on which this channel is available. If absent or empty then this channel must be available on all servers.", + "items": { + "$ref": "#/definitions/Reference" + }, + "uniqueItems": true + }, + "tags": { + "type": "array", + "description": "A list of tags for logical grouping of channels.", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/tag" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + }, + "bindings": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/channelBindingsObject" + } + ] + } + }, + "examples": [ + { + "address": "users.{userId}", + "title": "Users channel", + "description": "This channel is used to exchange messages about user events.", + "messages": { + "userSignedUp": { + "$ref": "#/components/messages/userSignedUp" + }, + "userCompletedOrder": { + "$ref": "#/components/messages/userCompletedOrder" + } + }, + "parameters": { + "userId": { + "$ref": "#/components/parameters/userId" + } + }, + "servers": [ + { + "$ref": "#/servers/rabbitmqInProd" + }, + { + "$ref": "#/servers/rabbitmqInStaging" + } + ], + "bindings": { + "amqp": { + "is": "queue", + "queue": { + "exclusive": true + } + } + }, + "tags": [ + { + "name": "user", + "description": "User-related messages" + } + ], + "externalDocs": { + "description": "Find more info here", + "url": "https://example.com" + } + } + ] + }, + "channelMessages": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageObject" + } + ] + }, + "description": "A map of the messages that will be sent to this channel by any application at any time. **Every message sent to this channel MUST be valid against one, and only one, of the message objects defined in this map.**" + }, + "messageObject": { + "type": "object", + "description": "Describes a message received on a given channel and operation.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "contentType": { + "type": "string", + "description": "The content type to use when encoding/decoding a message's payload. The value MUST be a specific media type (e.g. application/json). When omitted, the value MUST be the one specified on the defaultContentType field." + }, + "headers": { + "$ref": "#/definitions/anySchema" + }, + "payload": { + "$ref": "#/definitions/anySchema" + }, + "correlationId": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + }, + "tags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/tag" + } + ] + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "description": "List of examples.", + "items": { + "$ref": "#/definitions/messageExampleObject" + } + }, + "bindings": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageBindingsObject" + } + ] + }, + "traits": { + "type": "array", + "description": "A list of traits to apply to the message object. Traits MUST be merged using traits merge mechanism. The resulting object MUST be a valid Message Object.", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageTrait" + }, + { + "type": "array", + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageTrait" + } + ] + }, + { + "type": "object", + "additionalItems": true + } + ] + } + ] + } + } + }, + "examples": [ + { + "messageId": "userSignup", + "name": "UserSignup", + "title": "User signup", + "summary": "Action to sign a user up.", + "description": "A longer description", + "contentType": "application/json", + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + }, + { + "name": "register" + } + ], + "headers": { + "type": "object", + "properties": { + "correlationId": { + "description": "Correlation ID set by application", + "type": "string" + }, + "applicationInstanceId": { + "description": "Unique identifier for a given instance of the publishing application", + "type": "string" + } + } + }, + "payload": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/userCreate" + }, + "signup": { + "$ref": "#/components/schemas/signup" + } + } + }, + "correlationId": { + "description": "Default Correlation ID", + "location": "$message.header#/correlationId" + }, + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "examples": [ + { + "name": "SimpleSignup", + "summary": "A simple UserSignup example message", + "headers": { + "correlationId": "my-correlation-id", + "applicationInstanceId": "myInstanceId" + }, + "payload": { + "user": { + "someUserKey": "someUserValue" + }, + "signup": { + "someSignupKey": "someSignupValue" + } + } + } + ] + } + ] + }, + "anySchema": { + "if": { + "required": [ + "schema" + ] + }, + "then": { + "$ref": "#/definitions/multiFormatSchema" + }, + "else": { + "$ref": "#/definitions/schema" + }, + "description": "An object representing either a schema or a multiFormatSchema based on the existence of the 'schema' property. If the property 'schema' is present, use the multi-format schema. Use the default AsyncAPI Schema otherwise." + }, + "multiFormatSchema": { + "description": "The Multi Format Schema Object represents a schema definition. It differs from the Schema Object in that it supports multiple schema formats or languages (e.g., JSON Schema, Avro, etc.).", + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "if": { + "not": { + "type": "object" + } + }, + "then": { + "$ref": "#/definitions/schema" + }, + "else": { + "properties": { + "schemaFormat": { + "description": "A string containing the name of the schema format that is used to define the information. If schemaFormat is missing, it MUST default to application/vnd.aai.asyncapi+json;version={{asyncapi}} where {{asyncapi}} matches the AsyncAPI Version String. In such a case, this would make the Multi Format Schema Object equivalent to the Schema Object. When using Reference Object within the schema, the schemaFormat of the resource being referenced MUST match the schemaFormat of the schema that contains the initial reference. For example, if you reference Avro schema, then schemaFormat of referencing resource and the resource being reference MUST match.", + "anyOf": [ + { + "type": "string" + }, + { + "description": "All the schema formats tooling MUST support", + "enum": [ + "application/schema+json;version=draft-07", + "application/schema+yaml;version=draft-07", + "application/vnd.aai.asyncapi;version=3.0.0", + "application/vnd.aai.asyncapi+json;version=3.0.0", + "application/vnd.aai.asyncapi+yaml;version=3.0.0" + ] + }, + { + "description": "All the schema formats tools are RECOMMENDED to support", + "enum": [ + "application/vnd.oai.openapi;version=3.0.0", + "application/vnd.oai.openapi+json;version=3.0.0", + "application/vnd.oai.openapi+yaml;version=3.0.0", + "application/vnd.apache.avro;version=1.9.0", + "application/vnd.apache.avro+json;version=1.9.0", + "application/vnd.apache.avro+yaml;version=1.9.0", + "application/raml+yaml;version=1.0" + ] + } + ] + } + }, + "allOf": [ + { + "if": { + "not": { + "description": "If no schemaFormat has been defined, default to schema or reference", + "required": [ + "schemaFormat" + ] + } + }, + "then": { + "properties": { + "schema": { + "$ref": "#/definitions/schema" + } + } + } + }, + { + "if": { + "description": "If schemaFormat has been defined check if it's one of the AsyncAPI Schema Object formats", + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/vnd.aai.asyncapi;version=2.0.0", + "application/vnd.aai.asyncapi+json;version=2.0.0", + "application/vnd.aai.asyncapi+yaml;version=2.0.0", + "application/vnd.aai.asyncapi;version=2.1.0", + "application/vnd.aai.asyncapi+json;version=2.1.0", + "application/vnd.aai.asyncapi+yaml;version=2.1.0", + "application/vnd.aai.asyncapi;version=2.2.0", + "application/vnd.aai.asyncapi+json;version=2.2.0", + "application/vnd.aai.asyncapi+yaml;version=2.2.0", + "application/vnd.aai.asyncapi;version=2.3.0", + "application/vnd.aai.asyncapi+json;version=2.3.0", + "application/vnd.aai.asyncapi+yaml;version=2.3.0", + "application/vnd.aai.asyncapi;version=2.4.0", + "application/vnd.aai.asyncapi+json;version=2.4.0", + "application/vnd.aai.asyncapi+yaml;version=2.4.0", + "application/vnd.aai.asyncapi;version=2.5.0", + "application/vnd.aai.asyncapi+json;version=2.5.0", + "application/vnd.aai.asyncapi+yaml;version=2.5.0", + "application/vnd.aai.asyncapi;version=2.6.0", + "application/vnd.aai.asyncapi+json;version=2.6.0", + "application/vnd.aai.asyncapi+yaml;version=2.6.0", + "application/vnd.aai.asyncapi;version=3.0.0", + "application/vnd.aai.asyncapi+json;version=3.0.0", + "application/vnd.aai.asyncapi+yaml;version=3.0.0" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "$ref": "#/definitions/schema" + } + } + } + }, + { + "if": { + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/schema+json;version=draft-07", + "application/schema+yaml;version=draft-07" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "$ref": "#/definitions/json-schema-draft-07-schema" + } + } + } + }, + { + "if": { + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/vnd.oai.openapi;version=3.0.0", + "application/vnd.oai.openapi+json;version=3.0.0", + "application/vnd.oai.openapi+yaml;version=3.0.0" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/openapiSchema_3_0" + } + ] + } + } + } + }, + { + "if": { + "required": [ + "schemaFormat" + ], + "properties": { + "schemaFormat": { + "enum": [ + "application/vnd.apache.avro;version=1.9.0", + "application/vnd.apache.avro+json;version=1.9.0", + "application/vnd.apache.avro+yaml;version=1.9.0" + ] + } + } + }, + "then": { + "properties": { + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/avroSchema_v1" + } + ] + } + } + } + } + ] + } + }, + "openapiSchema_3_0": { + "type": "object", + "definitions": { + "ExternalDocumentation": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Discriminator": { + "type": "object", + "required": [ + "propertyName" + ], + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "Reference": { + "type": "object", + "required": [ + "$ref" + ], + "patternProperties": { + "^\\$ref$": { + "type": "string", + "format": "uri-reference" + } + } + }, + "XML": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "default": false + }, + "wrapped": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + } + }, + "properties": { + "title": { + "type": "string" + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "minLength": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { + "type": "integer", + "minimum": 0 + }, + "minProperties": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": false + }, + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string" + ] + }, + "not": { + "oneOf": [ + { + "$ref": "#/definitions/openapiSchema_3_0" + }, + { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Reference" + } + ] + }, + "allOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/openapiSchema_3_0" + }, + { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Reference" + } + ] + } + }, + "oneOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/openapiSchema_3_0" + }, + { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Reference" + } + ] + } + }, + "anyOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/openapiSchema_3_0" + }, + { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Reference" + } + ] + } + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/openapiSchema_3_0" + }, + { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Reference" + } + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/openapiSchema_3_0" + }, + { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Reference" + } + ] + } + }, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/openapiSchema_3_0" + }, + { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Reference" + }, + { + "type": "boolean" + } + ], + "default": true + }, + "description": { + "type": "string" + }, + "format": { + "type": "string" + }, + "default": true, + "nullable": { + "type": "boolean", + "default": false + }, + "discriminator": { + "$ref": "#/definitions/openapiSchema_3_0/definitions/Discriminator" + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "example": true, + "externalDocs": { + "$ref": "#/definitions/openapiSchema_3_0/definitions/ExternalDocumentation" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "xml": { + "$ref": "#/definitions/openapiSchema_3_0/definitions/XML" + } + }, + "patternProperties": { + "^x-": true + }, + "additionalProperties": false + }, + "avroSchema_v1": { + "definitions": { + "avroSchema": { + "title": "Avro Schema", + "description": "Root Schema", + "oneOf": [ + { + "$ref": "#/definitions/avroSchema_v1/definitions/types" + } + ] + }, + "types": { + "title": "Avro Types", + "description": "Allowed Avro types", + "oneOf": [ + { + "$ref": "#/definitions/avroSchema_v1/definitions/primitiveType" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/primitiveTypeWithMetadata" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/customTypeReference" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/avroRecord" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/avroEnum" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/avroArray" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/avroMap" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/avroFixed" + }, + { + "$ref": "#/definitions/avroSchema_v1/definitions/avroUnion" + } + ] + }, + "primitiveType": { + "title": "Primitive Type", + "description": "Basic type primitives.", + "type": "string", + "enum": [ + "null", + "boolean", + "int", + "long", + "float", + "double", + "bytes", + "string" + ] + }, + "primitiveTypeWithMetadata": { + "title": "Primitive Type With Metadata", + "description": "A primitive type with metadata attached.", + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/avroSchema_v1/definitions/primitiveType" + } + }, + "required": [ + "type" + ] + }, + "customTypeReference": { + "title": "Custom Type", + "description": "Reference to a ComplexType", + "not": { + "$ref": "#/definitions/avroSchema_v1/definitions/primitiveType" + }, + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*$" + }, + "avroUnion": { + "title": "Union", + "description": "A Union of types", + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/avroSchema" + }, + "minItems": 1 + }, + "avroField": { + "title": "Field", + "description": "A field within a Record", + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + }, + "type": { + "$ref": "#/definitions/avroSchema_v1/definitions/types" + }, + "doc": { + "type": "string" + }, + "default": true, + "order": { + "enum": [ + "ascending", + "descending", + "ignore" + ] + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + } + } + }, + "required": [ + "name", + "type" + ] + }, + "avroRecord": { + "title": "Record", + "description": "A Record", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "record" + }, + "name": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/avroSchema_v1/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + } + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/avroField" + } + } + }, + "required": [ + "type", + "name", + "fields" + ] + }, + "avroEnum": { + "title": "Enum", + "description": "An enumeration", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "enum" + }, + "name": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/avroSchema_v1/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + } + }, + "symbols": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + } + } + }, + "required": [ + "type", + "name", + "symbols" + ] + }, + "avroArray": { + "title": "Array", + "description": "An array", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + }, + "name": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/avroSchema_v1/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + } + }, + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/types" + } + }, + "required": [ + "type", + "items" + ] + }, + "avroMap": { + "title": "Map", + "description": "A map of values", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "map" + }, + "name": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/avroSchema_v1/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + } + }, + "values": { + "$ref": "#/definitions/avroSchema_v1/definitions/types" + } + }, + "required": [ + "type", + "values" + ] + }, + "avroFixed": { + "title": "Fixed", + "description": "A fixed sized array of bytes", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "fixed" + }, + "name": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + }, + "namespace": { + "$ref": "#/definitions/avroSchema_v1/definitions/namespace" + }, + "doc": { + "type": "string" + }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/definitions/avroSchema_v1/definitions/name" + } + }, + "size": { + "type": "number" + } + }, + "required": [ + "type", + "name", + "size" + ] + }, + "name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "namespace": { + "type": "string", + "pattern": "^([A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*)*$" + } + }, + "description": "Json-Schema definition for Avro AVSC files.", + "oneOf": [ + { + "$ref": "#/definitions/avroSchema_v1/definitions/avroSchema" + } + ], + "title": "Avro Schema Definition" + }, + "correlationId": { + "type": "object", + "description": "An object that specifies an identifier at design time that can used for message tracing and correlation.", + "required": [ + "location" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A optional description of the correlation ID. GitHub Flavored Markdown is allowed." + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the correlation ID", + "pattern": "^\\$message\\.(header|payload)#(\\/(([^\\/~])|(~[01]))*)*" + } + }, + "examples": [ + { + "description": "Default Correlation ID", + "location": "$message.header#/correlationId" + } + ] + }, + "messageExampleObject": { + "type": "object", + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "payload" + ] + }, + { + "required": [ + "headers" + ] + } + ], + "properties": { + "name": { + "type": "string", + "description": "Machine readable name of the message example." + }, + "summary": { + "type": "string", + "description": "A brief summary of the message example." + }, + "headers": { + "type": "object", + "description": "Example of the application headers. It MUST be a map of key-value pairs." + }, + "payload": { + "type": [ + "number", + "string", + "boolean", + "object", + "array", + "null" + ], + "description": "Example of the message payload. It can be of any type." + } + } + }, + "messageBindingsObject": { + "type": "object", + "description": "Map describing protocol-specific definitions for a message.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "http": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-http-0.3.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-http-0.2.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-http-0.3.0-message" + } + } + ] + }, + "ws": {}, + "amqp": { + "properties": { + "bindingVersion": { + "enum": [ + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-amqp-0.3.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-amqp-0.3.0-message" + } + } + ] + }, + "amqp1": {}, + "mqtt": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-mqtt-0.2.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-mqtt-0.2.0-message" + } + } + ] + }, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.4.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.3.0-message" + } + } + ] + }, + "anypointmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-anypointmq-0.0.1-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-anypointmq-0.0.1-message" + } + } + ] + }, + "nats": {}, + "jms": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-jms-0.0.1-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-jms-0.0.1-message" + } + } + ] + }, + "sns": {}, + "sqs": {}, + "stomp": {}, + "redis": {}, + "ibmmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-ibmmq-0.1.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-ibmmq-0.1.0-message" + } + } + ] + }, + "solace": {}, + "googlepubsub": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-googlepubsub-0.2.0-message" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-googlepubsub-0.2.0-message" + } + } + ] + } + } + }, + "bindings-http-0.3.0-message": { + "title": "HTTP message bindings object", + "description": "This object contains information about the message representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "headers": { + "$ref": "#/definitions/schema", + "description": "\tA Schema object containing the definitions for HTTP-specific headers. This schema MUST be of type 'object' and have a 'properties' key." + }, + "statusCode": { + "type": "number", + "description": "The HTTP response status code according to [RFC 9110](https://httpwg.org/specs/rfc9110.html#overview.of.status.codes). `statusCode` is only relevant for messages referenced by the [Operation Reply Object](https://www.asyncapi.com/docs/reference/specification/v3.0.0#operationReplyObject), as it defines the status code for the response. In all other cases, this value can be safely ignored." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "properties": { + "Content-Type": { + "type": "string", + "enum": [ + "application/json" + ] + } + } + }, + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-http-0.2.0-message": { + "title": "HTTP message bindings object", + "description": "This object contains information about the message representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "headers": { + "$ref": "#/definitions/schema", + "description": "\tA Schema object containing the definitions for HTTP-specific headers. This schema MUST be of type 'object' and have a 'properties' key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "properties": { + "Content-Type": { + "type": "string", + "enum": [ + "application/json" + ] + } + } + }, + "bindingVersion": "0.2.0" + } + ] + }, + "bindings-amqp-0.3.0-message": { + "title": "AMQP message bindings object", + "description": "This object contains information about the message representation in AMQP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "contentEncoding": { + "type": "string", + "description": "A MIME encoding for the message content." + }, + "messageType": { + "type": "string", + "description": "Application-specific message type." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "contentEncoding": "gzip", + "messageType": "user.signup", + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-mqtt-0.2.0-message": { + "title": "MQTT message bindings object", + "description": "This object contains information about the message representation in MQTT.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "payloadFormatIndicator": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "description": "1 indicates that the payload is UTF-8 encoded character data. 0 indicates that the payload format is unspecified.", + "default": 0 + }, + "correlationData": { + "oneOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "Correlation Data is used by the sender of the request message to identify which request the response message is for when it is received." + }, + "contentType": { + "type": "string", + "description": "String describing the content type of the message payload. This should not conflict with the contentType field of the associated AsyncAPI Message object." + }, + "responseTopic": { + "oneOf": [ + { + "type": "string", + "format": "uri-template", + "minLength": 1 + }, + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "The topic (channel URI) to be used for a response message." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "bindingVersion": "0.2.0" + }, + { + "contentType": "application/json", + "correlationData": { + "type": "string", + "format": "uuid" + }, + "responseTopic": "application/responses", + "bindingVersion": "0.2.0" + } + ] + }, + "bindings-kafka-0.5.0-message": { + "title": "Message Schema", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "key": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/schema" + } + ], + "description": "The message key." + }, + "schemaIdLocation": { + "type": "string", + "description": "If a Schema Registry is used when performing this operation, tells where the id of schema is stored.", + "enum": [ + "header", + "payload" + ] + }, + "schemaIdPayloadEncoding": { + "type": "string", + "description": "Number of bytes or vendor specific values when schema id is encoded in payload." + }, + "schemaLookupStrategy": { + "type": "string", + "description": "Freeform string for any naming strategy class to use. Clients should default to the vendor default if not supplied." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "key": { + "type": "string", + "enum": [ + "myKey" + ] + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "apicurio-new", + "schemaLookupStrategy": "TopicIdStrategy", + "bindingVersion": "0.5.0" + }, + { + "key": { + "$ref": "path/to/user-create.avsc#/UserCreate" + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "4", + "bindingVersion": "0.5.0" + } + ] + }, + "bindings-kafka-0.4.0-message": { + "title": "Message Schema", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "key": { + "anyOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/avroSchema_v1" + } + ], + "description": "The message key." + }, + "schemaIdLocation": { + "type": "string", + "description": "If a Schema Registry is used when performing this operation, tells where the id of schema is stored.", + "enum": [ + "header", + "payload" + ] + }, + "schemaIdPayloadEncoding": { + "type": "string", + "description": "Number of bytes or vendor specific values when schema id is encoded in payload." + }, + "schemaLookupStrategy": { + "type": "string", + "description": "Freeform string for any naming strategy class to use. Clients should default to the vendor default if not supplied." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "key": { + "type": "string", + "enum": [ + "myKey" + ] + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "apicurio-new", + "schemaLookupStrategy": "TopicIdStrategy", + "bindingVersion": "0.4.0" + }, + { + "key": { + "$ref": "path/to/user-create.avsc#/UserCreate" + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "4", + "bindingVersion": "0.4.0" + } + ] + }, + "bindings-kafka-0.3.0-message": { + "title": "Message Schema", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "key": { + "$ref": "#/definitions/schema", + "description": "The message key." + }, + "schemaIdLocation": { + "type": "string", + "description": "If a Schema Registry is used when performing this operation, tells where the id of schema is stored.", + "enum": [ + "header", + "payload" + ] + }, + "schemaIdPayloadEncoding": { + "type": "string", + "description": "Number of bytes or vendor specific values when schema id is encoded in payload." + }, + "schemaLookupStrategy": { + "type": "string", + "description": "Freeform string for any naming strategy class to use. Clients should default to the vendor default if not supplied." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "key": { + "type": "string", + "enum": [ + "myKey" + ] + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "apicurio-new", + "schemaLookupStrategy": "TopicIdStrategy", + "bindingVersion": "0.3.0" + }, + { + "key": { + "$ref": "path/to/user-create.avsc#/UserCreate" + }, + "schemaIdLocation": "payload", + "schemaIdPayloadEncoding": "4", + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-anypointmq-0.0.1-message": { + "title": "Anypoint MQ message bindings object", + "description": "This object contains configuration for describing an Anypoint MQ message as an AsyncAPI message. This objects only contains configuration that can not be provided in the AsyncAPI standard message object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "headers": { + "oneOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "A Schema object containing the definitions for Anypoint MQ-specific headers (protocol headers). This schema MUST be of type 'object' and have a 'properties' key. Examples of Anypoint MQ protocol headers are 'messageId' and 'messageGroupId'." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + } + } + }, + "bindingVersion": "0.0.1" + } + ] + }, + "bindings-jms-0.0.1-message": { + "title": "Message Schema", + "description": "This object contains configuration for describing a JMS message as an AsyncAPI message. This objects only contains configuration that can not be provided in the AsyncAPI standard message object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "headers": { + "$ref": "#/definitions/schema", + "description": "A Schema object containing the definitions for JMS headers (protocol headers). This schema MUST be of type 'object' and have a 'properties' key. Examples of JMS protocol headers are 'JMSMessageID', 'JMSTimestamp', and 'JMSCorrelationID'." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "headers": { + "type": "object", + "required": [ + "JMSMessageID" + ], + "properties": { + "JMSMessageID": { + "type": [ + "string", + "null" + ], + "description": "A unique message identifier. This may be set by your JMS Provider on your behalf." + }, + "JMSTimestamp": { + "type": "integer", + "description": "The time the message was sent. This may be set by your JMS Provider on your behalf. The time the message was sent. The value of the timestamp is the amount of time, measured in milliseconds, that has elapsed since midnight, January 1, 1970, UTC." + }, + "JMSDeliveryMode": { + "type": "string", + "enum": [ + "PERSISTENT", + "NON_PERSISTENT" + ], + "default": "PERSISTENT", + "description": "Denotes the delivery mode for the message. This may be set by your JMS Provider on your behalf." + }, + "JMSPriority": { + "type": "integer", + "default": 4, + "description": "The priority of the message. This may be set by your JMS Provider on your behalf." + }, + "JMSExpires": { + "type": "integer", + "description": "The time at which the message expires. This may be set by your JMS Provider on your behalf. A value of zero means that the message does not expire. Any non-zero value is the amount of time, measured in milliseconds, that has elapsed since midnight, January 1, 1970, UTC, at which the message will expire." + }, + "JMSType": { + "type": [ + "string", + "null" + ], + "description": "The type of message. Some JMS providers use a message repository that contains the definitions of messages sent by applications. The 'JMSType' header field may reference a message's definition in the provider's repository. The JMS API does not define a standard message definition repository, nor does it define a naming policy for the definitions it contains. Some messaging systems require that a message type definition for each application message be created and that each message specify its type. In order to work with such JMS providers, JMS clients should assign a value to 'JMSType', whether the application makes use of it or not. This ensures that the field is properly set for those providers that require it." + }, + "JMSCorrelationID": { + "type": [ + "string", + "null" + ], + "description": "The correlation identifier of the message. A client can use the 'JMSCorrelationID' header field to link one message with another. A typical use is to link a response message with its request message. Since each message sent by a JMS provider is assigned a message ID value, it is convenient to link messages via message ID, such message ID values must start with the 'ID:' prefix. Conversely, application-specified values must not start with the 'ID:' prefix; this is reserved for provider-generated message ID values." + }, + "JMSReplyTo": { + "type": "string", + "description": "The queue or topic that the message sender expects replies to." + } + } + }, + "bindingVersion": "0.0.1" + } + ] + }, + "bindings-ibmmq-0.1.0-message": { + "title": "IBM MQ message bindings object", + "description": "This object contains information about the message representation in IBM MQ.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "jms", + "binary" + ], + "default": "string", + "description": "The type of the message." + }, + "headers": { + "type": "string", + "description": "Defines the IBM MQ message headers to include with this message. More than one header can be specified as a comma separated list. Supporting information on IBM MQ message formats can be found on this [page](https://www.ibm.com/docs/en/ibm-mq/9.2?topic=mqmd-format-mqchar8) in the IBM MQ Knowledge Center." + }, + "description": { + "type": "string", + "description": "Provides additional information for application developers: describes the message type or format." + }, + "expiry": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "The recommended setting the client should use for the TTL (Time-To-Live) of the message. This is a period of time expressed in milliseconds and set by the application that puts the message. 'expiry' values are API dependant e.g., MQI and JMS use different units of time and default values for 'unlimited'. General information on IBM MQ message expiry can be found on this [page](https://www.ibm.com/docs/en/ibm-mq/9.2?topic=mqmd-expiry-mqlong) in the IBM MQ Knowledge Center." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding." + } + }, + "oneOf": [ + { + "properties": { + "type": { + "const": "binary" + } + } + }, + { + "properties": { + "type": { + "const": "jms" + } + }, + "not": { + "required": [ + "headers" + ] + } + }, + { + "properties": { + "type": { + "const": "string" + } + }, + "not": { + "required": [ + "headers" + ] + } + } + ], + "examples": [ + { + "type": "string", + "bindingVersion": "0.1.0" + }, + { + "type": "jms", + "description": "JMS stream message", + "bindingVersion": "0.1.0" + } + ] + }, + "bindings-googlepubsub-0.2.0-message": { + "title": "Cloud Pub/Sub Channel Schema", + "description": "This object contains information about the message representation for Google Cloud Pub/Sub.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding." + }, + "attributes": { + "type": "object" + }, + "orderingKey": { + "type": "string" + }, + "schema": { + "type": "object", + "additionalItems": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "examples": [ + { + "schema": { + "name": "projects/your-project-id/schemas/your-avro-schema-id" + } + }, + { + "schema": { + "name": "projects/your-project-id/schemas/your-protobuf-schema-id" + } + } + ] + }, + "messageTrait": { + "type": "object", + "description": "Describes a trait that MAY be applied to a Message Object. This object MAY contain any property from the Message Object, except payload and traits.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "contentType": { + "type": "string", + "description": "The content type to use when encoding/decoding a message's payload. The value MUST be a specific media type (e.g. application/json). When omitted, the value MUST be the one specified on the defaultContentType field." + }, + "headers": { + "$ref": "#/definitions/anySchema" + }, + "correlationId": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + }, + "tags": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/tag" + } + ] + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "description": "List of examples.", + "items": { + "$ref": "#/definitions/messageExampleObject" + } + }, + "bindings": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageBindingsObject" + } + ] + } + }, + "examples": [ + { + "contentType": "application/json" + } + ] + }, + "parameters": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/parameter" + } + ] + }, + "description": "JSON objects describing re-usable channel parameters.", + "examples": [ + { + "address": "user/{userId}/signedup", + "parameters": { + "userId": { + "description": "Id of the user." + } + } + } + ] + }, + "parameter": { + "description": "Describes a parameter included in a channel address.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "enum": { + "description": "An enumeration of string values to be used if the substitution options are from a limited set.", + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "description": "The default value to use for substitution, and to send, if an alternate value is not supplied.", + "type": "string" + }, + "examples": { + "description": "An array of examples of the parameter value.", + "type": "array", + "items": { + "type": "string" + } + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the parameter value", + "pattern": "^\\$message\\.(header|payload)#(\\/(([^\\/~])|(~[01]))*)*" + } + }, + "examples": [ + { + "address": "user/{userId}/signedup", + "parameters": { + "userId": { + "description": "Id of the user.", + "location": "$message.payload#/user/id" + } + } + } + ] + }, + "channelBindingsObject": { + "type": "object", + "description": "Map describing protocol-specific definitions for a channel.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "http": {}, + "ws": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-websockets-0.1.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-websockets-0.1.0-channel" + } + } + ] + }, + "amqp": { + "properties": { + "bindingVersion": { + "enum": [ + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-amqp-0.3.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-amqp-0.3.0-channel" + } + } + ] + }, + "amqp1": {}, + "mqtt": {}, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.4.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.3.0-channel" + } + } + ] + }, + "anypointmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-anypointmq-0.0.1-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-anypointmq-0.0.1-channel" + } + } + ] + }, + "nats": {}, + "jms": { + "properties": { + "bindingVersion": { + "enum": [ + "0.0.1" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-jms-0.0.1-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.0.1" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-jms-0.0.1-channel" + } + } + ] + }, + "sns": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-sns-0.1.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-sns-0.1.0-channel" + } + } + ] + }, + "sqs": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-sqs-0.2.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-sqs-0.2.0-channel" + } + } + ] + }, + "stomp": {}, + "redis": {}, + "ibmmq": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-ibmmq-0.1.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-ibmmq-0.1.0-channel" + } + } + ] + }, + "solace": {}, + "googlepubsub": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-googlepubsub-0.2.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-googlepubsub-0.2.0-channel" + } + } + ] + }, + "pulsar": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-pulsar-0.1.0-channel" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-pulsar-0.1.0-channel" + } + } + ] + } + } + }, + "bindings-websockets-0.1.0-channel": { + "title": "WebSockets channel bindings object", + "description": "When using WebSockets, the channel represents the connection. Unlike other protocols that support multiple virtual channels (topics, routing keys, etc.) per connection, WebSockets doesn't support virtual channels or, put it another way, there's only one channel and its characteristics are strongly related to the protocol used for the handshake, i.e., HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "POST" + ], + "description": "The HTTP method to use when establishing the connection. Its value MUST be either 'GET' or 'POST'." + }, + "query": { + "oneOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "A Schema object containing the definitions for each query parameter. This schema MUST be of type 'object' and have a 'properties' key." + }, + "headers": { + "oneOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "A Schema object containing the definitions of the HTTP headers to use when establishing the connection. This schema MUST be of type 'object' and have a 'properties' key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "method": "POST", + "bindingVersion": "0.1.0" + } + ] + }, + "bindings-amqp-0.3.0-channel": { + "title": "AMQP channel bindings object", + "description": "This object contains information about the channel representation in AMQP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "is": { + "type": "string", + "enum": [ + "queue", + "routingKey" + ], + "description": "Defines what type of channel is it. Can be either 'queue' or 'routingKey' (default)." + }, + "exchange": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "description": "The name of the exchange. It MUST NOT exceed 255 characters long." + }, + "type": { + "type": "string", + "enum": [ + "topic", + "direct", + "fanout", + "default", + "headers" + ], + "description": "The type of the exchange. Can be either 'topic', 'direct', 'fanout', 'default' or 'headers'." + }, + "durable": { + "type": "boolean", + "description": "Whether the exchange should survive broker restarts or not." + }, + "autoDelete": { + "type": "boolean", + "description": "Whether the exchange should be deleted when the last queue is unbound from it." + }, + "vhost": { + "type": "string", + "default": "/", + "description": "The virtual host of the exchange. Defaults to '/'." + } + }, + "description": "When is=routingKey, this object defines the exchange properties." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "description": "The name of the queue. It MUST NOT exceed 255 characters long." + }, + "durable": { + "type": "boolean", + "description": "Whether the queue should survive broker restarts or not." + }, + "exclusive": { + "type": "boolean", + "description": "Whether the queue should be used only by one connection or not." + }, + "autoDelete": { + "type": "boolean", + "description": "Whether the queue should be deleted when the last consumer unsubscribes." + }, + "vhost": { + "type": "string", + "default": "/", + "description": "The virtual host of the queue. Defaults to '/'." + } + }, + "description": "When is=queue, this object defines the queue properties." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "oneOf": [ + { + "properties": { + "is": { + "const": "routingKey" + } + }, + "required": [ + "exchange" + ], + "not": { + "required": [ + "queue" + ] + } + }, + { + "properties": { + "is": { + "const": "queue" + } + }, + "required": [ + "queue" + ], + "not": { + "required": [ + "exchange" + ] + } + } + ], + "examples": [ + { + "is": "routingKey", + "exchange": { + "name": "myExchange", + "type": "topic", + "durable": true, + "autoDelete": false, + "vhost": "/" + }, + "bindingVersion": "0.3.0" + }, + { + "is": "queue", + "queue": { + "name": "my-queue-name", + "durable": true, + "exclusive": true, + "autoDelete": false, + "vhost": "/" + }, + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-kafka-0.5.0-channel": { + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "topic": { + "type": "string", + "description": "Kafka topic name if different from channel name." + }, + "partitions": { + "type": "integer", + "minimum": 1, + "description": "Number of partitions configured on this topic." + }, + "replicas": { + "type": "integer", + "minimum": 1, + "description": "Number of replicas configured on this topic." + }, + "topicConfiguration": { + "description": "Topic configuration properties that are relevant for the API.", + "type": "object", + "additionalProperties": true, + "properties": { + "cleanup.policy": { + "description": "The [`cleanup.policy`](https://kafka.apache.org/documentation/#topicconfigs_cleanup.policy) configuration option.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "compact", + "delete" + ] + } + }, + "retention.ms": { + "description": "The [`retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_retention.ms) configuration option.", + "type": "integer", + "minimum": -1 + }, + "retention.bytes": { + "description": "The [`retention.bytes`](https://kafka.apache.org/documentation/#topicconfigs_retention.bytes) configuration option.", + "type": "integer", + "minimum": -1 + }, + "delete.retention.ms": { + "description": "The [`delete.retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_delete.retention.ms) configuration option.", + "type": "integer", + "minimum": 0 + }, + "max.message.bytes": { + "description": "The [`max.message.bytes`](https://kafka.apache.org/documentation/#topicconfigs_max.message.bytes) configuration option.", + "type": "integer", + "minimum": 0 + }, + "confluent.key.schema.validation": { + "description": "It shows whether the schema validation for the message key is enabled. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-key-schema-validation)", + "type": "boolean" + }, + "confluent.key.subject.name.strategy": { + "description": "The name of the schema lookup strategy for the message key. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-key-subject-name-strategy)", + "type": "string" + }, + "confluent.value.schema.validation": { + "description": "It shows whether the schema validation for the message value is enabled. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-value-schema-validation)", + "type": "boolean" + }, + "confluent.value.subject.name.strategy": { + "description": "The name of the schema lookup strategy for the message value. Vendor specific config. For more details: (https://docs.confluent.io/platform/current/installation/configuration/topic-configs.html#confluent-value-subject-name-strategy)", + "type": "string" + } + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "topic": "my-specific-topic", + "partitions": 20, + "replicas": 3, + "bindingVersion": "0.5.0" + } + ] + }, + "bindings-kafka-0.4.0-channel": { + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "topic": { + "type": "string", + "description": "Kafka topic name if different from channel name." + }, + "partitions": { + "type": "integer", + "minimum": 1, + "description": "Number of partitions configured on this topic." + }, + "replicas": { + "type": "integer", + "minimum": 1, + "description": "Number of replicas configured on this topic." + }, + "topicConfiguration": { + "description": "Topic configuration properties that are relevant for the API.", + "type": "object", + "additionalProperties": false, + "properties": { + "cleanup.policy": { + "description": "The [`cleanup.policy`](https://kafka.apache.org/documentation/#topicconfigs_cleanup.policy) configuration option.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "compact", + "delete" + ] + } + }, + "retention.ms": { + "description": "The [`retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_retention.ms) configuration option.", + "type": "integer", + "minimum": -1 + }, + "retention.bytes": { + "description": "The [`retention.bytes`](https://kafka.apache.org/documentation/#topicconfigs_retention.bytes) configuration option.", + "type": "integer", + "minimum": -1 + }, + "delete.retention.ms": { + "description": "The [`delete.retention.ms`](https://kafka.apache.org/documentation/#topicconfigs_delete.retention.ms) configuration option.", + "type": "integer", + "minimum": 0 + }, + "max.message.bytes": { + "description": "The [`max.message.bytes`](https://kafka.apache.org/documentation/#topicconfigs_max.message.bytes) configuration option.", + "type": "integer", + "minimum": 0 + } + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "topic": "my-specific-topic", + "partitions": 20, + "replicas": 3, + "bindingVersion": "0.4.0" + } + ] + }, + "bindings-kafka-0.3.0-channel": { + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "topic": { + "type": "string", + "description": "Kafka topic name if different from channel name." + }, + "partitions": { + "type": "integer", + "minimum": 1, + "description": "Number of partitions configured on this topic." + }, + "replicas": { + "type": "integer", + "minimum": 1, + "description": "Number of replicas configured on this topic." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "topic": "my-specific-topic", + "partitions": 20, + "replicas": 3, + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-anypointmq-0.0.1-channel": { + "title": "Anypoint MQ channel bindings object", + "description": "This object contains configuration for describing an Anypoint MQ exchange, queue, or FIFO queue as an AsyncAPI channel. This objects only contains configuration that can not be provided in the AsyncAPI standard channel object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "destination": { + "type": "string", + "description": "The destination (queue or exchange) name for this channel. SHOULD only be specified if the channel name differs from the actual destination name, such as when the channel name is not a valid destination name in Anypoint MQ. Defaults to the channel name." + }, + "destinationType": { + "type": "string", + "enum": [ + "exchange", + "queue", + "fifo-queue" + ], + "default": "queue", + "description": "The type of destination. SHOULD be specified to document the messaging model (publish/subscribe, point-to-point, strict message ordering) supported by this channel." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "destination": "user-signup-exchg", + "destinationType": "exchange", + "bindingVersion": "0.0.1" + } + ] + }, + "bindings-jms-0.0.1-channel": { + "title": "Channel Schema", + "description": "This object contains configuration for describing a JMS queue, or FIFO queue as an AsyncAPI channel. This objects only contains configuration that can not be provided in the AsyncAPI standard channel object.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "destination": { + "type": "string", + "description": "The destination (queue) name for this channel. SHOULD only be specified if the channel name differs from the actual destination name, such as when the channel name is not a valid destination name according to the JMS Provider. Defaults to the channel name." + }, + "destinationType": { + "type": "string", + "enum": [ + "queue", + "fifo-queue" + ], + "default": "queue", + "description": "The type of destination. SHOULD be specified to document the messaging model (point-to-point, or strict message ordering) supported by this channel." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.0.1" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "destination": "user-signed-up", + "destinationType": "fifo-queue", + "bindingVersion": "0.0.1" + } + ] + }, + "bindings-sns-0.1.0-channel": { + "title": "Channel Schema", + "description": "This object contains information about the channel representation in SNS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "name": { + "type": "string", + "description": "The name of the topic. Can be different from the channel name to allow flexibility around AWS resource naming limitations." + }, + "ordering": { + "$ref": "#/definitions/bindings-sns-0.1.0-channel/definitions/ordering" + }, + "policy": { + "$ref": "#/definitions/bindings-sns-0.1.0-channel/definitions/policy" + }, + "tags": { + "type": "object", + "description": "Key-value pairs that represent AWS tags on the topic." + }, + "bindingVersion": { + "type": "string", + "description": "The version of this binding.", + "default": "latest" + } + }, + "required": [ + "name" + ], + "definitions": { + "ordering": { + "type": "object", + "description": "By default, we assume an unordered SNS topic. This field allows configuration of a FIFO SNS Topic.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "type": { + "type": "string", + "description": "Defines the type of SNS Topic.", + "enum": [ + "standard", + "FIFO" + ] + }, + "contentBasedDeduplication": { + "type": "boolean", + "description": "True to turn on de-duplication of messages for a channel." + } + }, + "required": [ + "type" + ] + }, + "policy": { + "type": "object", + "description": "The security policy for the SNS Topic.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "statements": { + "type": "array", + "description": "An array of statement objects, each of which controls a permission for this topic", + "items": { + "$ref": "#/definitions/bindings-sns-0.1.0-channel/definitions/statement" + } + } + }, + "required": [ + "statements" + ] + }, + "statement": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "principal": { + "description": "The AWS account or resource ARN that this statement applies to.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "action": { + "description": "The SNS permission being allowed or denied e.g. sns:Publish", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "effect", + "principal", + "action" + ] + } + }, + "examples": [ + { + "name": "my-sns-topic", + "policy": { + "statements": [ + { + "effect": "Allow", + "principal": "*", + "action": "SNS:Publish" + } + ] + } + } + ] + }, + "bindings-sqs-0.2.0-channel": { + "title": "Channel Schema", + "description": "This object contains information about the channel representation in SQS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "queue": { + "description": "A definition of the queue that will be used as the channel.", + "$ref": "#/definitions/bindings-sqs-0.2.0-channel/definitions/queue" + }, + "deadLetterQueue": { + "description": "A definition of the queue that will be used for un-processable messages.", + "$ref": "#/definitions/bindings-sqs-0.2.0-channel/definitions/queue" + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0", + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed.", + "default": "latest" + } + }, + "required": [ + "queue" + ], + "definitions": { + "queue": { + "type": "object", + "description": "A definition of a queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "name": { + "type": "string", + "description": "The name of the queue. When an SNS Operation Binding Object references an SQS queue by name, the identifier should be the one in this field." + }, + "fifoQueue": { + "type": "boolean", + "description": "Is this a FIFO queue?", + "default": false + }, + "deduplicationScope": { + "type": "string", + "enum": [ + "queue", + "messageGroup" + ], + "description": "Specifies whether message deduplication occurs at the message group or queue level. Valid values are messageGroup and queue (default).", + "default": "queue" + }, + "fifoThroughputLimit": { + "type": "string", + "enum": [ + "perQueue", + "perMessageGroupId" + ], + "description": "Specifies whether the FIFO queue throughput quota applies to the entire queue or per message group. Valid values are perQueue (default) and perMessageGroupId.", + "default": "perQueue" + }, + "deliveryDelay": { + "type": "integer", + "description": "The number of seconds to delay before a message sent to the queue can be received. used to create a delay queue.", + "minimum": 0, + "maximum": 900, + "default": 0 + }, + "visibilityTimeout": { + "type": "integer", + "description": "The length of time, in seconds, that a consumer locks a message - hiding it from reads - before it is unlocked and can be read again.", + "minimum": 0, + "maximum": 43200, + "default": 30 + }, + "receiveMessageWaitTime": { + "type": "integer", + "description": "Determines if the queue uses short polling or long polling. Set to zero the queue reads available messages and returns immediately. Set to a non-zero integer, long polling waits the specified number of seconds for messages to arrive before returning.", + "default": 0 + }, + "messageRetentionPeriod": { + "type": "integer", + "description": "How long to retain a message on the queue in seconds, unless deleted.", + "minimum": 60, + "maximum": 1209600, + "default": 345600 + }, + "redrivePolicy": { + "$ref": "#/definitions/bindings-sqs-0.2.0-channel/definitions/redrivePolicy" + }, + "policy": { + "$ref": "#/definitions/bindings-sqs-0.2.0-channel/definitions/policy" + }, + "tags": { + "type": "object", + "description": "Key-value pairs that represent AWS tags on the queue." + } + }, + "required": [ + "name", + "fifoQueue" + ] + }, + "redrivePolicy": { + "type": "object", + "description": "Prevent poison pill messages by moving un-processable messages to an SQS dead letter queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "deadLetterQueue": { + "$ref": "#/definitions/bindings-sqs-0.2.0-channel/definitions/identifier" + }, + "maxReceiveCount": { + "type": "integer", + "description": "The number of times a message is delivered to the source queue before being moved to the dead-letter queue.", + "default": 10 + } + }, + "required": [ + "deadLetterQueue" + ] + }, + "identifier": { + "type": "object", + "description": "The SQS queue to use as a dead letter queue (DLQ).", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "arn": { + "type": "string", + "description": "The target is an ARN. For example, for SQS, the identifier may be an ARN, which will be of the form: arn:aws:sqs:{region}:{account-id}:{queueName}" + }, + "name": { + "type": "string", + "description": "The endpoint is identified by a name, which corresponds to an identifying field called 'name' of a binding for that protocol on this publish Operation Object. For example, if the protocol is 'sqs' then the name refers to the name field sqs binding." + } + } + }, + "policy": { + "type": "object", + "description": "The security policy for the SQS Queue", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "statements": { + "type": "array", + "description": "An array of statement objects, each of which controls a permission for this queue.", + "items": { + "$ref": "#/definitions/bindings-sqs-0.2.0-channel/definitions/statement" + } + } + }, + "required": [ + "statements" + ] + }, + "statement": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "principal": { + "description": "The AWS account or resource ARN that this statement applies to.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "action": { + "description": "The SQS permission being allowed or denied e.g. sqs:ReceiveMessage", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "effect", + "principal", + "action" + ] + } + }, + "examples": [ + { + "queue": { + "name": "myQueue", + "fifoQueue": true, + "deduplicationScope": "messageGroup", + "fifoThroughputLimit": "perMessageGroupId", + "deliveryDelay": 15, + "visibilityTimeout": 60, + "receiveMessageWaitTime": 0, + "messageRetentionPeriod": 86400, + "redrivePolicy": { + "deadLetterQueue": { + "arn": "arn:aws:SQS:eu-west-1:0000000:123456789" + }, + "maxReceiveCount": 15 + }, + "policy": { + "statements": [ + { + "effect": "Deny", + "principal": "arn:aws:iam::123456789012:user/dec.kolakowski", + "action": [ + "sqs:SendMessage", + "sqs:ReceiveMessage" + ] + } + ] + }, + "tags": { + "owner": "AsyncAPI.NET", + "platform": "AsyncAPIOrg" + } + }, + "deadLetterQueue": { + "name": "myQueue_error", + "deliveryDelay": 0, + "visibilityTimeout": 0, + "receiveMessageWaitTime": 0, + "messageRetentionPeriod": 604800 + } + } + ] + }, + "bindings-ibmmq-0.1.0-channel": { + "title": "IBM MQ channel bindings object", + "description": "This object contains information about the channel representation in IBM MQ. Each channel corresponds to a Queue or Topic within IBM MQ.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "destinationType": { + "type": "string", + "enum": [ + "topic", + "queue" + ], + "default": "topic", + "description": "Defines the type of AsyncAPI channel." + }, + "queue": { + "type": "object", + "description": "Defines the properties of a queue.", + "properties": { + "objectName": { + "type": "string", + "maxLength": 48, + "description": "Defines the name of the IBM MQ queue associated with the channel." + }, + "isPartitioned": { + "type": "boolean", + "default": false, + "description": "Defines if the queue is a cluster queue and therefore partitioned. If 'true', a binding option MAY be specified when accessing the queue. More information on binding options can be found on this page in the IBM MQ Knowledge Center." + }, + "exclusive": { + "type": "boolean", + "default": false, + "description": "Specifies if it is recommended to open the queue exclusively." + } + }, + "required": [ + "objectName" + ] + }, + "topic": { + "type": "object", + "description": "Defines the properties of a topic.", + "properties": { + "string": { + "type": "string", + "maxLength": 10240, + "description": "The value of the IBM MQ topic string to be used." + }, + "objectName": { + "type": "string", + "maxLength": 48, + "description": "The name of the IBM MQ topic object." + }, + "durablePermitted": { + "type": "boolean", + "default": true, + "description": "Defines if the subscription may be durable." + }, + "lastMsgRetained": { + "type": "boolean", + "default": false, + "description": "Defines if the last message published will be made available to new subscriptions." + } + } + }, + "maxMsgLength": { + "type": "integer", + "minimum": 0, + "maximum": 104857600, + "description": "The maximum length of the physical message (in bytes) accepted by the Topic or Queue. Messages produced that are greater in size than this value may fail to be delivered. More information on the maximum message length can be found on this [page](https://www.ibm.com/support/knowledgecenter/SSFKSJ_latest/com.ibm.mq.ref.dev.doc/q097520_.html) in the IBM MQ Knowledge Center." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding." + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "const": "topic" + } + }, + "not": { + "required": [ + "queue" + ] + } + }, + { + "properties": { + "destinationType": { + "const": "queue" + } + }, + "required": [ + "queue" + ], + "not": { + "required": [ + "topic" + ] + } + } + ], + "examples": [ + { + "destinationType": "topic", + "topic": { + "objectName": "myTopicName" + }, + "bindingVersion": "0.1.0" + }, + { + "destinationType": "queue", + "queue": { + "objectName": "myQueueName", + "exclusive": true + }, + "bindingVersion": "0.1.0" + } + ] + }, + "bindings-googlepubsub-0.2.0-channel": { + "title": "Cloud Pub/Sub Channel Schema", + "description": "This object contains information about the channel representation for Google Cloud Pub/Sub.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding." + }, + "labels": { + "type": "object" + }, + "messageRetentionDuration": { + "type": "string" + }, + "messageStoragePolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowedPersistenceRegions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "schemaSettings": { + "type": "object", + "additionalItems": false, + "properties": { + "encoding": { + "type": "string" + }, + "firstRevisionId": { + "type": "string" + }, + "lastRevisionId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "encoding", + "name" + ] + } + }, + "required": [ + "schemaSettings" + ], + "examples": [ + { + "labels": { + "label1": "value1", + "label2": "value2" + }, + "messageRetentionDuration": "86400s", + "messageStoragePolicy": { + "allowedPersistenceRegions": [ + "us-central1", + "us-east1" + ] + }, + "schemaSettings": { + "encoding": "json", + "name": "projects/your-project-id/schemas/your-schema" + } + } + ] + }, + "bindings-pulsar-0.1.0-channel": { + "title": "Channel Schema", + "description": "This object contains information about the channel representation in Pulsar, which covers namespace and topic level admin configuration. This object contains additional information not possible to represent within the core AsyncAPI specification.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "required": [ + "namespace", + "persistence" + ], + "properties": { + "namespace": { + "type": "string", + "description": "The namespace, the channel is associated with." + }, + "persistence": { + "type": "string", + "enum": [ + "persistent", + "non-persistent" + ], + "description": "persistence of the topic in Pulsar." + }, + "compaction": { + "type": "integer", + "minimum": 0, + "description": "Topic compaction threshold given in MB" + }, + "geo-replication": { + "type": "array", + "description": "A list of clusters the topic is replicated to.", + "items": { + "type": "string" + } + }, + "retention": { + "type": "object", + "additionalProperties": false, + "properties": { + "time": { + "type": "integer", + "minimum": 0, + "description": "Time given in Minutes. `0` = Disable message retention." + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "Size given in MegaBytes. `0` = Disable message retention." + } + } + }, + "ttl": { + "type": "integer", + "description": "TTL in seconds for the specified topic" + }, + "deduplication": { + "type": "boolean", + "description": "Whether deduplication of events is enabled or not." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "namespace": "ns1", + "persistence": "persistent", + "compaction": 1000, + "retention": { + "time": 15, + "size": 1000 + }, + "ttl": 360, + "geo-replication": [ + "us-west", + "us-east" + ], + "deduplication": true, + "bindingVersion": "0.1.0" + } + ] + }, + "operations": { + "type": "object", + "description": "Holds a dictionary with all the operations this application MUST implement.", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operation" + } + ] + }, + "examples": [ + { + "onUserSignUp": { + "title": "User sign up", + "summary": "Action to sign a user up.", + "description": "A longer description", + "channel": { + "$ref": "#/channels/userSignup" + }, + "action": "send", + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + }, + { + "name": "register" + } + ], + "bindings": { + "amqp": { + "ack": false + } + }, + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ] + } + } + ] + }, + "operation": { + "type": "object", + "description": "Describes a specific operation.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "required": [ + "action", + "channel" + ], + "properties": { + "action": { + "type": "string", + "description": "Allowed values are send and receive. Use send when it's expected that the application will send a message to the given channel, and receive when the application should expect receiving messages from the given channel.", + "enum": [ + "send", + "receive" + ] + }, + "channel": { + "$ref": "#/definitions/Reference" + }, + "messages": { + "type": "array", + "description": "A list of $ref pointers pointing to the supported Message Objects that can be processed by this operation. It MUST contain a subset of the messages defined in the channel referenced in this operation. Every message processed by this operation MUST be valid against one, and only one, of the message objects referenced in this list. Please note the messages property value MUST be a list of Reference Objects and, therefore, MUST NOT contain Message Objects. However, it is RECOMMENDED that parsers (or other software) dereference this property for a better development experience.", + "items": { + "$ref": "#/definitions/Reference" + } + }, + "reply": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationReply" + } + ] + }, + "traits": { + "type": "array", + "description": "A list of traits to apply to the operation object. Traits MUST be merged using traits merge mechanism. The resulting object MUST be a valid Operation Object.", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationTrait" + } + ] + } + }, + "title": { + "type": "string", + "description": "A human-friendly title for the operation." + }, + "summary": { + "type": "string", + "description": "A brief summary of the operation." + }, + "description": { + "type": "string", + "description": "A longer description of the operation. CommonMark is allowed." + }, + "security": { + "$ref": "#/definitions/securityRequirements" + }, + "tags": { + "type": "array", + "description": "A list of tags for logical grouping and categorization of operations.", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/tag" + } + ] + }, + "uniqueItems": true + }, + "externalDocs": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + }, + "bindings": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationBindingsObject" + } + ] + } + }, + "examples": [ + { + "title": "User sign up", + "summary": "Action to sign a user up.", + "description": "A longer description", + "channel": { + "$ref": "#/channels/userSignup" + }, + "action": "send", + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + }, + { + "name": "register" + } + ], + "bindings": { + "amqp": { + "ack": false + } + }, + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "messages": [ + { + "$ref": "/components/messages/userSignedUp" + } + ], + "reply": { + "address": { + "location": "$message.header#/replyTo" + }, + "channel": { + "$ref": "#/channels/userSignupReply" + }, + "messages": [ + { + "$ref": "/components/messages/userSignedUpReply" + } + ] + } + } + ] + }, + "operationReply": { + "type": "object", + "description": "Describes the reply part that MAY be applied to an Operation Object. If an operation implements the request/reply pattern, the reply object represents the response message.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "address": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationReplyAddress" + } + ] + }, + "channel": { + "$ref": "#/definitions/Reference" + }, + "messages": { + "type": "array", + "description": "A list of $ref pointers pointing to the supported Message Objects that can be processed by this operation as reply. It MUST contain a subset of the messages defined in the channel referenced in this operation reply. Every message processed by this operation MUST be valid against one, and only one, of the message objects referenced in this list. Please note the messages property value MUST be a list of Reference Objects and, therefore, MUST NOT contain Message Objects. However, it is RECOMMENDED that parsers (or other software) dereference this property for a better development experience.", + "items": { + "$ref": "#/definitions/Reference" + } + } + } + }, + "operationReplyAddress": { + "type": "object", + "description": "An object that specifies where an operation has to send the reply", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "required": [ + "location" + ], + "properties": { + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the reply address.", + "pattern": "^\\$message\\.(header|payload)#(\\/(([^\\/~])|(~[01]))*)*" + }, + "description": { + "type": "string", + "description": "An optional description of the address. CommonMark is allowed." + } + }, + "examples": [ + { + "description": "Consumer inbox", + "location": "$message.header#/replyTo" + } + ] + }, + "operationTrait": { + "type": "object", + "description": "Describes a trait that MAY be applied to an Operation Object. This object MAY contain any property from the Operation Object, except the action, channel and traits ones.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "title": { + "description": "A human-friendly title for the operation.", + "$ref": "#/definitions/operation/properties/title" + }, + "summary": { + "description": "A short summary of what the operation is about.", + "$ref": "#/definitions/operation/properties/summary" + }, + "description": { + "description": "A verbose explanation of the operation. CommonMark syntax can be used for rich text representation.", + "$ref": "#/definitions/operation/properties/description" + }, + "security": { + "description": "A declaration of which security schemes are associated with this operation. Only one of the security scheme objects MUST be satisfied to authorize an operation. In cases where Server Security also applies, it MUST also be satisfied.", + "$ref": "#/definitions/operation/properties/security" + }, + "tags": { + "description": "A list of tags for logical grouping and categorization of operations.", + "$ref": "#/definitions/operation/properties/tags" + }, + "externalDocs": { + "description": "Additional external documentation for this operation.", + "$ref": "#/definitions/operation/properties/externalDocs" + }, + "bindings": { + "description": "A map where the keys describe the name of the protocol and the values describe protocol-specific definitions for the operation.", + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationBindingsObject" + } + ] + } + }, + "examples": [ + { + "bindings": { + "amqp": { + "ack": false + } + } + } + ] + }, + "operationBindingsObject": { + "type": "object", + "description": "Map describing protocol-specific definitions for an operation.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "http": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-http-0.3.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-http-0.2.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-http-0.3.0-operation" + } + } + ] + }, + "ws": {}, + "amqp": { + "properties": { + "bindingVersion": { + "enum": [ + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-amqp-0.3.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-amqp-0.3.0-operation" + } + } + ] + }, + "amqp1": {}, + "mqtt": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-mqtt-0.2.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-mqtt-0.2.0-operation" + } + } + ] + }, + "kafka": { + "properties": { + "bindingVersion": { + "enum": [ + "0.5.0", + "0.4.0", + "0.3.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.5.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.5.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.4.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-kafka-0.3.0-operation" + } + } + ] + }, + "anypointmq": {}, + "nats": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-nats-0.1.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-nats-0.1.0-operation" + } + } + ] + }, + "jms": {}, + "sns": { + "properties": { + "bindingVersion": { + "enum": [ + "0.1.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.1.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation" + } + } + ] + }, + "sqs": { + "properties": { + "bindingVersion": { + "enum": [ + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-sqs-0.2.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-sqs-0.2.0-operation" + } + } + ] + }, + "stomp": {}, + "redis": {}, + "ibmmq": {}, + "solace": { + "properties": { + "bindingVersion": { + "enum": [ + "0.4.0", + "0.3.0", + "0.2.0" + ] + } + }, + "allOf": [ + { + "description": "If no bindingVersion specified, use the latest binding", + "if": { + "not": { + "required": [ + "bindingVersion" + ] + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.4.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.4.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.4.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.3.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.3.0-operation" + } + }, + { + "if": { + "required": [ + "bindingVersion" + ], + "properties": { + "bindingVersion": { + "const": "0.2.0" + } + } + }, + "then": { + "$ref": "#/definitions/bindings-solace-0.2.0-operation" + } + } + ] + }, + "googlepubsub": {} + } + }, + "bindings-http-0.3.0-operation": { + "title": "HTTP operation bindings object", + "description": "This object contains information about the operation representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "PUT", + "POST", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE" + ], + "description": "When 'type' is 'request', this is the HTTP method, otherwise it MUST be ignored. Its value MUST be one of 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'CONNECT', and 'TRACE'." + }, + "query": { + "$ref": "#/definitions/schema", + "description": "A Schema object containing the definitions for each query parameter. This schema MUST be of type 'object' and have a properties key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.3.0" + }, + { + "method": "GET", + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-http-0.2.0-operation": { + "title": "HTTP operation bindings object", + "description": "This object contains information about the operation representation in HTTP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "PUT", + "POST", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE" + ], + "description": "When 'type' is 'request', this is the HTTP method, otherwise it MUST be ignored. Its value MUST be one of 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'CONNECT', and 'TRACE'." + }, + "query": { + "$ref": "#/definitions/schema", + "description": "A Schema object containing the definitions for each query parameter. This schema MUST be of type 'object' and have a properties key." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.2.0" + }, + { + "method": "GET", + "query": { + "type": "object", + "required": [ + "companyId" + ], + "properties": { + "companyId": { + "type": "number", + "minimum": 1, + "description": "The Id of the company." + } + }, + "additionalProperties": false + }, + "bindingVersion": "0.2.0" + } + ] + }, + "bindings-amqp-0.3.0-operation": { + "title": "AMQP operation bindings object", + "description": "This object contains information about the operation representation in AMQP.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "expiration": { + "type": "integer", + "minimum": 0, + "description": "TTL (Time-To-Live) for the message. It MUST be greater than or equal to zero." + }, + "userId": { + "type": "string", + "description": "Identifies the user who has sent the message." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The routing keys the message should be routed to at the time of publishing." + }, + "priority": { + "type": "integer", + "description": "A priority for the message." + }, + "deliveryMode": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "description": "Delivery mode of the message. Its value MUST be either 1 (transient) or 2 (persistent)." + }, + "mandatory": { + "type": "boolean", + "description": "Whether the message is mandatory or not." + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Like cc but consumers will not receive this information." + }, + "timestamp": { + "type": "boolean", + "description": "Whether the message should include a timestamp or not." + }, + "ack": { + "type": "boolean", + "description": "Whether the consumer should ack the message or not." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "expiration": 100000, + "userId": "guest", + "cc": [ + "user.logs" + ], + "priority": 10, + "deliveryMode": 2, + "mandatory": false, + "bcc": [ + "external.audit" + ], + "timestamp": true, + "ack": false, + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-mqtt-0.2.0-operation": { + "title": "MQTT operation bindings object", + "description": "This object contains information about the operation representation in MQTT.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "qos": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "description": "Defines the Quality of Service (QoS) levels for the message flow between client and server. Its value MUST be either 0 (At most once delivery), 1 (At least once delivery), or 2 (Exactly once delivery)." + }, + "retain": { + "type": "boolean", + "description": "Whether the broker should retain the message or not." + }, + "messageExpiryInterval": { + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/Reference" + } + ], + "description": "Lifetime of the message in seconds" + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "qos": 2, + "retain": true, + "messageExpiryInterval": 60, + "bindingVersion": "0.2.0" + } + ] + }, + "bindings-kafka-0.5.0-operation": { + "title": "Operation Schema", + "description": "This object contains information about the operation representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "groupId": { + "$ref": "#/definitions/schema", + "description": "Id of the consumer group." + }, + "clientId": { + "$ref": "#/definitions/schema", + "description": "Id of the consumer inside a consumer group." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.5.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "groupId": { + "type": "string", + "enum": [ + "myGroupId" + ] + }, + "clientId": { + "type": "string", + "enum": [ + "myClientId" + ] + }, + "bindingVersion": "0.5.0" + } + ] + }, + "bindings-kafka-0.4.0-operation": { + "title": "Operation Schema", + "description": "This object contains information about the operation representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "groupId": { + "$ref": "#/definitions/schema", + "description": "Id of the consumer group." + }, + "clientId": { + "$ref": "#/definitions/schema", + "description": "Id of the consumer inside a consumer group." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "groupId": { + "type": "string", + "enum": [ + "myGroupId" + ] + }, + "clientId": { + "type": "string", + "enum": [ + "myClientId" + ] + }, + "bindingVersion": "0.4.0" + } + ] + }, + "bindings-kafka-0.3.0-operation": { + "title": "Operation Schema", + "description": "This object contains information about the operation representation in Kafka.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "groupId": { + "$ref": "#/definitions/schema", + "description": "Id of the consumer group." + }, + "clientId": { + "$ref": "#/definitions/schema", + "description": "Id of the consumer inside a consumer group." + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "groupId": { + "type": "string", + "enum": [ + "myGroupId" + ] + }, + "clientId": { + "type": "string", + "enum": [ + "myClientId" + ] + }, + "bindingVersion": "0.3.0" + } + ] + }, + "bindings-nats-0.1.0-operation": { + "title": "NATS operation bindings object", + "description": "This object contains information about the operation representation in NATS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "queue": { + "type": "string", + "description": "Defines the name of the queue to use. It MUST NOT exceed 255 characters.", + "maxLength": 255 + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed." + } + }, + "examples": [ + { + "queue": "MyCustomQueue", + "bindingVersion": "0.1.0" + } + ] + }, + "bindings-sns-0.1.0-operation": { + "title": "Operation Schema", + "description": "This object contains information about the operation representation in SNS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "topic": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation/definitions/identifier", + "description": "Often we can assume that the SNS Topic is the channel name-we provide this field in case the you need to supply the ARN, or the Topic name is not the channel name in the AsyncAPI document." + }, + "consumers": { + "type": "array", + "description": "The protocols that listen to this topic and their endpoints.", + "items": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation/definitions/consumer" + }, + "minItems": 1 + }, + "deliveryPolicy": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation/definitions/deliveryPolicy", + "description": "Policy for retries to HTTP. The field is the default for HTTP receivers of the SNS Topic which may be overridden by a specific consumer." + }, + "bindingVersion": { + "type": "string", + "description": "The version of this binding.", + "default": "latest" + } + }, + "required": [ + "consumers" + ], + "definitions": { + "identifier": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "url": { + "type": "string", + "description": "The endpoint is a URL." + }, + "email": { + "type": "string", + "description": "The endpoint is an email address." + }, + "phone": { + "type": "string", + "description": "The endpoint is a phone number." + }, + "arn": { + "type": "string", + "description": "The target is an ARN. For example, for SQS, the identifier may be an ARN, which will be of the form: arn:aws:sqs:{region}:{account-id}:{queueName}" + }, + "name": { + "type": "string", + "description": "The endpoint is identified by a name, which corresponds to an identifying field called 'name' of a binding for that protocol on this publish Operation Object. For example, if the protocol is 'sqs' then the name refers to the name field sqs binding. We don't use $ref because we are referring, not including." + } + } + }, + "consumer": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "protocol": { + "description": "The protocol that this endpoint receives messages by.", + "type": "string", + "enum": [ + "http", + "https", + "email", + "email-json", + "sms", + "sqs", + "application", + "lambda", + "firehose" + ] + }, + "endpoint": { + "description": "The endpoint messages are delivered to.", + "$ref": "#/definitions/bindings-sns-0.1.0-operation/definitions/identifier" + }, + "filterPolicy": { + "type": "object", + "description": "Only receive a subset of messages from the channel, determined by this policy. Depending on the FilterPolicyScope, a map of either a message attribute or message body to an array of possible matches. The match may be a simple string for an exact match, but it may also be an object that represents a constraint and values for that constraint.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + }, + { + "type": "object" + } + ] + } + }, + "filterPolicyScope": { + "type": "string", + "description": "Determines whether the FilterPolicy applies to MessageAttributes or MessageBody.", + "enum": [ + "MessageAttributes", + "MessageBody" + ], + "default": "MessageAttributes" + }, + "rawMessageDelivery": { + "type": "boolean", + "description": "If true AWS SNS attributes are removed from the body, and for SQS, SNS message attributes are copied to SQS message attributes. If false the SNS attributes are included in the body." + }, + "redrivePolicy": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation/definitions/redrivePolicy" + }, + "deliveryPolicy": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation/definitions/deliveryPolicy", + "description": "Policy for retries to HTTP. The parameter is for that SNS Subscription and overrides any policy on the SNS Topic." + }, + "displayName": { + "type": "string", + "description": "The display name to use with an SNS subscription" + } + }, + "required": [ + "protocol", + "endpoint", + "rawMessageDelivery" + ] + }, + "deliveryPolicy": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "minDelayTarget": { + "type": "integer", + "description": "The minimum delay for a retry in seconds." + }, + "maxDelayTarget": { + "type": "integer", + "description": "The maximum delay for a retry in seconds." + }, + "numRetries": { + "type": "integer", + "description": "The total number of retries, including immediate, pre-backoff, backoff, and post-backoff retries." + }, + "numNoDelayRetries": { + "type": "integer", + "description": "The number of immediate retries (with no delay)." + }, + "numMinDelayRetries": { + "type": "integer", + "description": "The number of immediate retries (with delay)." + }, + "numMaxDelayRetries": { + "type": "integer", + "description": "The number of post-backoff phase retries, with the maximum delay between retries." + }, + "backoffFunction": { + "type": "string", + "description": "The algorithm for backoff between retries.", + "enum": [ + "arithmetic", + "exponential", + "geometric", + "linear" + ] + }, + "maxReceivesPerSecond": { + "type": "integer", + "description": "The maximum number of deliveries per second, per subscription." + } + } + }, + "redrivePolicy": { + "type": "object", + "description": "Prevent poison pill messages by moving un-processable messages to an SQS dead letter queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "deadLetterQueue": { + "$ref": "#/definitions/bindings-sns-0.1.0-operation/definitions/identifier", + "description": "The SQS queue to use as a dead letter queue (DLQ)." + }, + "maxReceiveCount": { + "type": "integer", + "description": "The number of times a message is delivered to the source queue before being moved to the dead-letter queue.", + "default": 10 + } + }, + "required": [ + "deadLetterQueue" + ] + } + }, + "examples": [ + { + "topic": { + "name": "someTopic" + }, + "consumers": [ + { + "protocol": "sqs", + "endpoint": { + "name": "someQueue" + }, + "filterPolicy": { + "store": [ + "asyncapi_corp" + ], + "event": [ + { + "anything-but": "order_cancelled" + } + ], + "customer_interests": [ + "rugby", + "football", + "baseball" + ] + }, + "filterPolicyScope": "MessageAttributes", + "rawMessageDelivery": false, + "redrivePolicy": { + "deadLetterQueue": { + "arn": "arn:aws:SQS:eu-west-1:0000000:123456789" + }, + "maxReceiveCount": 25 + }, + "deliveryPolicy": { + "minDelayTarget": 10, + "maxDelayTarget": 100, + "numRetries": 5, + "numNoDelayRetries": 2, + "numMinDelayRetries": 3, + "numMaxDelayRetries": 5, + "backoffFunction": "linear", + "maxReceivesPerSecond": 2 + } + } + ] + } + ] + }, + "bindings-sqs-0.2.0-operation": { + "title": "Operation Schema", + "description": "This object contains information about the operation representation in SQS.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "queues": { + "type": "array", + "description": "Queue objects that are either the endpoint for an SNS Operation Binding Object, or the deadLetterQueue of the SQS Operation Binding Object.", + "items": { + "$ref": "#/definitions/bindings-sqs-0.2.0-operation/definitions/queue" + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.1.0", + "0.2.0" + ], + "description": "The version of this binding. If omitted, 'latest' MUST be assumed.", + "default": "latest" + } + }, + "required": [ + "queues" + ], + "definitions": { + "queue": { + "type": "object", + "description": "A definition of a queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "$ref": { + "type": "string", + "description": "Allows for an external definition of a queue. The referenced structure MUST be in the format of a Queue. If there are conflicts between the referenced definition and this Queue's definition, the behavior is undefined." + }, + "name": { + "type": "string", + "description": "The name of the queue. When an SNS Operation Binding Object references an SQS queue by name, the identifier should be the one in this field." + }, + "fifoQueue": { + "type": "boolean", + "description": "Is this a FIFO queue?", + "default": false + }, + "deduplicationScope": { + "type": "string", + "enum": [ + "queue", + "messageGroup" + ], + "description": "Specifies whether message deduplication occurs at the message group or queue level. Valid values are messageGroup and queue (default).", + "default": "queue" + }, + "fifoThroughputLimit": { + "type": "string", + "enum": [ + "perQueue", + "perMessageGroupId" + ], + "description": "Specifies whether the FIFO queue throughput quota applies to the entire queue or per message group. Valid values are perQueue (default) and perMessageGroupId.", + "default": "perQueue" + }, + "deliveryDelay": { + "type": "integer", + "description": "The number of seconds to delay before a message sent to the queue can be received. Used to create a delay queue.", + "minimum": 0, + "maximum": 900, + "default": 0 + }, + "visibilityTimeout": { + "type": "integer", + "description": "The length of time, in seconds, that a consumer locks a message - hiding it from reads - before it is unlocked and can be read again.", + "minimum": 0, + "maximum": 43200, + "default": 30 + }, + "receiveMessageWaitTime": { + "type": "integer", + "description": "Determines if the queue uses short polling or long polling. Set to zero the queue reads available messages and returns immediately. Set to a non-zero integer, long polling waits the specified number of seconds for messages to arrive before returning.", + "default": 0 + }, + "messageRetentionPeriod": { + "type": "integer", + "description": "How long to retain a message on the queue in seconds, unless deleted.", + "minimum": 60, + "maximum": 1209600, + "default": 345600 + }, + "redrivePolicy": { + "$ref": "#/definitions/bindings-sqs-0.2.0-operation/definitions/redrivePolicy" + }, + "policy": { + "$ref": "#/definitions/bindings-sqs-0.2.0-operation/definitions/policy" + }, + "tags": { + "type": "object", + "description": "Key-value pairs that represent AWS tags on the queue." + } + }, + "required": [ + "name" + ] + }, + "redrivePolicy": { + "type": "object", + "description": "Prevent poison pill messages by moving un-processable messages to an SQS dead letter queue.", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "deadLetterQueue": { + "$ref": "#/definitions/bindings-sqs-0.2.0-operation/definitions/identifier" + }, + "maxReceiveCount": { + "type": "integer", + "description": "The number of times a message is delivered to the source queue before being moved to the dead-letter queue.", + "default": 10 + } + }, + "required": [ + "deadLetterQueue" + ] + }, + "identifier": { + "type": "object", + "description": "The SQS queue to use as a dead letter queue (DLQ).", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "arn": { + "type": "string", + "description": "The target is an ARN. For example, for SQS, the identifier may be an ARN, which will be of the form: arn:aws:sqs:{region}:{account-id}:{queueName}" + }, + "name": { + "type": "string", + "description": "The endpoint is identified by a name, which corresponds to an identifying field called 'name' of a binding for that protocol on this publish Operation Object. For example, if the protocol is 'sqs' then the name refers to the name field sqs binding." + } + } + }, + "policy": { + "type": "object", + "description": "The security policy for the SQS Queue", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "statements": { + "type": "array", + "description": "An array of statement objects, each of which controls a permission for this queue.", + "items": { + "$ref": "#/definitions/bindings-sqs-0.2.0-operation/definitions/statement" + } + } + }, + "required": [ + "statements" + ] + }, + "statement": { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "effect": { + "type": "string", + "enum": [ + "Allow", + "Deny" + ] + }, + "principal": { + "description": "The AWS account or resource ARN that this statement applies to.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "action": { + "description": "The SQS permission being allowed or denied e.g. sqs:ReceiveMessage", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "effect", + "principal", + "action" + ] + } + }, + "examples": [ + { + "queues": [ + { + "name": "myQueue", + "fifoQueue": true, + "deduplicationScope": "messageGroup", + "fifoThroughputLimit": "perMessageGroupId", + "deliveryDelay": 10, + "redrivePolicy": { + "deadLetterQueue": { + "name": "myQueue_error" + }, + "maxReceiveCount": 15 + }, + "policy": { + "statements": [ + { + "effect": "Deny", + "principal": "arn:aws:iam::123456789012:user/dec.kolakowski", + "action": [ + "sqs:SendMessage", + "sqs:ReceiveMessage" + ] + } + ] + } + }, + { + "name": "myQueue_error", + "deliveryDelay": 10 + } + ] + } + ] + }, + "bindings-solace-0.4.0-operation": { + "title": "Solace operation bindings object", + "description": "This object contains information about the operation representation in Solace.", + "type": "object", + "additionalProperties": false, + "properties": { + "bindingVersion": { + "type": "string", + "enum": [ + "0.4.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + }, + "destinations": { + "description": "The list of Solace destinations referenced in the operation.", + "type": "array", + "items": { + "type": "object", + "properties": { + "deliveryMode": { + "type": "string", + "enum": [ + "direct", + "persistent" + ] + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "type": "string", + "const": "queue", + "description": "If the type is queue, then the subscriber can bind to the queue. The queue subscribes to the given topicSubscriptions. If no topicSubscriptions are provied, the queue will subscribe to the topic as represented by the channel name." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the queue" + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the queue subscribes to.", + "items": { + "type": "string" + } + }, + "accessType": { + "type": "string", + "enum": [ + "exclusive", + "nonexclusive" + ] + }, + "maxTtl": { + "type": "string", + "description": "The maximum TTL to apply to messages to be spooled." + }, + "maxMsgSpoolUsage": { + "type": "string", + "description": "The maximum amount of message spool that the given queue may use" + } + } + } + } + }, + { + "properties": { + "destinationType": { + "type": "string", + "const": "topic", + "description": "If the type is topic, then the subscriber subscribes to the given topicSubscriptions. If no topicSubscriptions are provided, the client will subscribe to the topic as represented by the channel name." + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the client subscribes to.", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "timeToLive": { + "type": "integer", + "description": "Interval in milliseconds or a Schema Object containing the definition of the lifetime of the message." + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "The valid priority value range is 0-255 with 0 as the lowest priority and 255 as the highest or a Schema Object containing the definition of the priority." + }, + "dmqEligible": { + "type": "boolean", + "description": "Set the message to be eligible to be moved to a Dead Message Queue. The default value is false." + } + }, + "examples": [ + { + "bindingVersion": "0.4.0", + "destinations": [ + { + "destinationType": "queue", + "queue": { + "name": "sampleQueue", + "topicSubscriptions": [ + "samples/*" + ], + "accessType": "nonexclusive" + } + }, + { + "destinationType": "topic", + "topicSubscriptions": [ + "samples/*" + ] + } + ] + } + ] + }, + "bindings-solace-0.3.0-operation": { + "title": "Solace operation bindings object", + "description": "This object contains information about the operation representation in Solace.", + "type": "object", + "additionalProperties": false, + "properties": { + "destinations": { + "description": "The list of Solace destinations referenced in the operation.", + "type": "array", + "items": { + "type": "object", + "properties": { + "deliveryMode": { + "type": "string", + "enum": [ + "direct", + "persistent" + ] + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "type": "string", + "const": "queue", + "description": "If the type is queue, then the subscriber can bind to the queue. The queue subscribes to the given topicSubscriptions. If no topicSubscriptions are provied, the queue will subscribe to the topic as represented by the channel name." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the queue" + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the queue subscribes to.", + "items": { + "type": "string" + } + }, + "accessType": { + "type": "string", + "enum": [ + "exclusive", + "nonexclusive" + ] + }, + "maxTtl": { + "type": "string", + "description": "The maximum TTL to apply to messages to be spooled." + }, + "maxMsgSpoolUsage": { + "type": "string", + "description": "The maximum amount of message spool that the given queue may use" + } + } + } + } + }, + { + "properties": { + "destinationType": { + "type": "string", + "const": "topic", + "description": "If the type is topic, then the subscriber subscribes to the given topicSubscriptions. If no topicSubscriptions are provided, the client will subscribe to the topic as represented by the channel name." + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the client subscribes to.", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.3.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "bindingVersion": "0.3.0", + "destinations": [ + { + "destinationType": "queue", + "queue": { + "name": "sampleQueue", + "topicSubscriptions": [ + "samples/*" + ], + "accessType": "nonexclusive" + } + }, + { + "destinationType": "topic", + "topicSubscriptions": [ + "samples/*" + ] + } + ] + } + ] + }, + "bindings-solace-0.2.0-operation": { + "title": "Solace operation bindings object", + "description": "This object contains information about the operation representation in Solace.", + "type": "object", + "additionalProperties": false, + "properties": { + "destinations": { + "description": "The list of Solace destinations referenced in the operation.", + "type": "array", + "items": { + "type": "object", + "properties": { + "deliveryMode": { + "type": "string", + "enum": [ + "direct", + "persistent" + ] + } + }, + "oneOf": [ + { + "properties": { + "destinationType": { + "type": "string", + "const": "queue", + "description": "If the type is queue, then the subscriber can bind to the queue. The queue subscribes to the given topicSubscriptions. If no topicSubscriptions are provied, the queue will subscribe to the topic as represented by the channel name." + }, + "queue": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the queue" + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the queue subscribes to.", + "items": { + "type": "string" + } + }, + "accessType": { + "type": "string", + "enum": [ + "exclusive", + "nonexclusive" + ] + } + } + } + } + }, + { + "properties": { + "destinationType": { + "type": "string", + "const": "topic", + "description": "If the type is topic, then the subscriber subscribes to the given topicSubscriptions. If no topicSubscriptions are provided, the client will subscribe to the topic as represented by the channel name." + }, + "topicSubscriptions": { + "type": "array", + "description": "The list of topics that the client subscribes to.", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "bindingVersion": { + "type": "string", + "enum": [ + "0.2.0" + ], + "description": "The version of this binding. If omitted, \"latest\" MUST be assumed." + } + }, + "examples": [ + { + "bindingVersion": "0.2.0", + "destinations": [ + { + "destinationType": "queue", + "queue": { + "name": "sampleQueue", + "topicSubscriptions": [ + "samples/*" + ], + "accessType": "nonexclusive" + } + }, + { + "destinationType": "topic", + "topicSubscriptions": [ + "samples/*" + ] + } + ] + } + ] + }, + "components": { + "type": "object", + "description": "An object to hold a set of reusable objects for different aspects of the AsyncAPI specification. All objects defined within the components object will have no effect on the API unless they are explicitly referenced from properties outside the components object.", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\x2d_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemas": { + "type": "object", + "description": "An object to hold reusable Schema Object. If this is a Schema Object, then the schemaFormat will be assumed to be 'application/vnd.aai.asyncapi+json;version=asyncapi' where the version is equal to the AsyncAPI Version String.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "$ref": "#/definitions/anySchema" + } + } + }, + "servers": { + "type": "object", + "description": "An object to hold reusable Server Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/server" + } + ] + } + } + }, + "channels": { + "type": "object", + "description": "An object to hold reusable Channel Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/channel" + } + ] + } + } + }, + "serverVariables": { + "type": "object", + "description": "An object to hold reusable Server Variable Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/serverVariable" + } + ] + } + } + }, + "operations": { + "type": "object", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operation" + } + ] + } + } + }, + "messages": { + "type": "object", + "description": "An object to hold reusable Message Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageObject" + } + ] + } + } + }, + "securitySchemes": { + "type": "object", + "description": "An object to hold reusable Security Scheme Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/SecurityScheme" + } + ] + } + } + }, + "parameters": { + "type": "object", + "description": "An object to hold reusable Parameter Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/parameter" + } + ] + } + } + }, + "correlationIds": { + "type": "object", + "description": "An object to hold reusable Correlation ID Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + } + } + }, + "operationTraits": { + "type": "object", + "description": "An object to hold reusable Operation Trait Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationTrait" + } + ] + } + } + }, + "messageTraits": { + "type": "object", + "description": "An object to hold reusable Message Trait Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageTrait" + } + ] + } + } + }, + "replies": { + "type": "object", + "description": "An object to hold reusable Operation Reply Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationReply" + } + ] + } + } + }, + "replyAddresses": { + "type": "object", + "description": "An object to hold reusable Operation Reply Address Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationReplyAddress" + } + ] + } + } + }, + "serverBindings": { + "type": "object", + "description": "An object to hold reusable Server Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/serverBindingsObject" + } + ] + } + } + }, + "channelBindings": { + "type": "object", + "description": "An object to hold reusable Channel Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/channelBindingsObject" + } + ] + } + } + }, + "operationBindings": { + "type": "object", + "description": "An object to hold reusable Operation Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationBindingsObject" + } + ] + } + } + }, + "messageBindings": { + "type": "object", + "description": "An object to hold reusable Message Bindings Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageBindingsObject" + } + ] + } + } + }, + "tags": { + "type": "object", + "description": "An object to hold reusable Tag Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/tag" + } + ] + } + } + }, + "externalDocs": { + "type": "object", + "description": "An object to hold reusable External Documentation Objects.", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/externalDocs" + } + ] + } + } + } + }, + "examples": [ + { + "components": { + "schemas": { + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + } + }, + "AvroExample": { + "schemaFormat": "application/vnd.apache.avro+json;version=1.9.0", + "schema": { + "$ref": "path/to/user-create.avsc#/UserCreate" + } + } + }, + "servers": { + "development": { + "host": "{stage}.in.mycompany.com:{port}", + "description": "RabbitMQ broker", + "protocol": "amqp", + "protocolVersion": "0-9-1", + "variables": { + "stage": { + "$ref": "#/components/serverVariables/stage" + }, + "port": { + "$ref": "#/components/serverVariables/port" + } + } + } + }, + "serverVariables": { + "stage": { + "default": "demo", + "description": "This value is assigned by the service provider, in this example `mycompany.com`" + }, + "port": { + "enum": [ + "5671", + "5672" + ], + "default": "5672" + } + }, + "channels": { + "user/signedup": { + "subscribe": { + "message": { + "$ref": "#/components/messages/userSignUp" + } + } + } + }, + "messages": { + "userSignUp": { + "summary": "Action to sign a user up.", + "description": "Multiline description of what this action does.\nHere you have another line.\n", + "tags": [ + { + "name": "user" + }, + { + "name": "signup" + } + ], + "headers": { + "type": "object", + "properties": { + "applicationInstanceId": { + "description": "Unique identifier for a given instance of the publishing application", + "type": "string" + } + } + }, + "payload": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/userCreate" + }, + "signup": { + "$ref": "#/components/schemas/signup" + } + } + } + } + }, + "parameters": { + "userId": { + "description": "Id of the user." + } + }, + "correlationIds": { + "default": { + "description": "Default Correlation ID", + "location": "$message.header#/correlationId" + } + }, + "messageTraits": { + "commonHeaders": { + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + } + } + } + } + ] + } + }, + "description": "!!Auto generated!! \n Do not manually edit. " +} \ No newline at end of file diff --git a/third_party/go-asyncapi/loader.go b/third_party/go-asyncapi/loader.go new file mode 100644 index 0000000..abe93a7 --- /dev/null +++ b/third_party/go-asyncapi/loader.go @@ -0,0 +1,122 @@ +package asyncapi + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Loader loads AsyncAPI documents from various sources. +type Loader struct { + // ReadFile reads a file from the filesystem. + // If nil, os.ReadFile is used. + ReadFile func(path string) ([]byte, error) + + // ReadURL fetches content from a URL. + // If nil, http.Get is used. + ReadURL func(url string) ([]byte, error) + + // BasePath is the base path for resolving relative file references. + BasePath string +} + +// NewLoader creates a new Loader with default settings. +func NewLoader() *Loader { + return &Loader{ + ReadFile: os.ReadFile, + ReadURL: defaultReadURL, + } +} + +func defaultReadURL(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +// LoadFromFile loads an AsyncAPI document from a file. +func LoadFromFile(path string) (*Document, error) { + loader := NewLoader() + loader.BasePath = filepath.Dir(path) + return loader.LoadFromFile(path) +} + +// LoadFromData loads an AsyncAPI document from bytes. +func LoadFromData(data []byte) (*Document, error) { + loader := NewLoader() + return loader.LoadFromData(data) +} + +// LoadFromFile loads an AsyncAPI document from a file. +func (l *Loader) LoadFromFile(path string) (*Document, error) { + readFile := l.ReadFile + if readFile == nil { + readFile = os.ReadFile + } + + data, err := readFile(path) + if err != nil { + return nil, &ParseError{Message: "failed to read file", Cause: err} + } + + if l.BasePath == "" { + l.BasePath = filepath.Dir(path) + } + + return l.LoadFromData(data) +} + +// LoadFromURL loads an AsyncAPI document from a URL. +func (l *Loader) LoadFromURL(url string) (*Document, error) { + readURL := l.ReadURL + if readURL == nil { + readURL = defaultReadURL + } + + data, err := readURL(url) + if err != nil { + return nil, &ParseError{Message: "failed to fetch URL", Cause: err} + } + + return l.LoadFromData(data) +} + +// LoadFromData loads an AsyncAPI document from bytes. +func (l *Loader) LoadFromData(data []byte) (*Document, error) { + doc := &Document{ + raw: data, + } + + // Detect format and parse + if isJSON(data) { + if err := json.Unmarshal(data, doc); err != nil { + return nil, &ParseError{Message: "failed to parse JSON", Cause: err} + } + } else { + if err := yaml.Unmarshal(data, doc); err != nil { + return nil, &ParseError{Message: "failed to parse YAML", Cause: err} + } + } + + // Validate version + if !strings.HasPrefix(doc.AsyncAPI, "3.") { + return nil, &ParseError{Message: "unsupported AsyncAPI version: " + doc.AsyncAPI} + } + + return doc, nil +} + +// isJSON returns true if data looks like JSON. +func isJSON(data []byte) bool { + data = bytes.TrimSpace(data) + return len(data) > 0 && (data[0] == '{' || data[0] == '[') +} diff --git a/third_party/go-asyncapi/message.go b/third_party/go-asyncapi/message.go new file mode 100644 index 0000000..7b5ac09 --- /dev/null +++ b/third_party/go-asyncapi/message.go @@ -0,0 +1,127 @@ +package asyncapi + +import ( + "encoding/json" + "strings" + + "gopkg.in/yaml.v3" +) + +// Message describes a message received on a given channel and operation. +type Message struct { + Headers *MultiFormatSchemaRef `json:"headers,omitempty" yaml:"headers,omitempty"` + Payload *MultiFormatSchemaRef `json:"payload,omitempty" yaml:"payload,omitempty"` + CorrelationID *CorrelationIDRef `json:"correlationId,omitempty" yaml:"correlationId,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Tags []*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Bindings *MessageBindingsRef `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Examples []*MessageExample `json:"examples,omitempty" yaml:"examples,omitempty"` + Traits []*MessageTraitRef `json:"traits,omitempty" yaml:"traits,omitempty"` +} + +// MessageTrait describes a trait that may be applied to a Message. +type MessageTrait struct { + Headers *MultiFormatSchemaRef `json:"headers,omitempty" yaml:"headers,omitempty"` + CorrelationID *CorrelationIDRef `json:"correlationId,omitempty" yaml:"correlationId,omitempty"` + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Tags []*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Bindings *MessageBindingsRef `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Examples []*MessageExample `json:"examples,omitempty" yaml:"examples,omitempty"` +} + +// MessageExample represents an example of a Message. +type MessageExample struct { + Headers map[string]any `json:"headers,omitempty" yaml:"headers,omitempty"` + Payload any `json:"payload,omitempty" yaml:"payload,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + + extensions map[string]any +} + +// Extension returns a spec extension by name (e.g., "x-mock-match"). +func (e *MessageExample) Extension(name string) (any, bool) { + if e == nil || e.extensions == nil { + return nil, false + } + v, ok := e.extensions[name] + return v, ok +} + +// Extensions returns a copy of all spec extensions of the example. +func (e *MessageExample) Extensions() map[string]any { + out := make(map[string]any, len(e.extensions)) + for k, v := range e.extensions { + out[k] = v + } + return out +} + +// captureExtensions records all x-* fields from a generic map. +func (e *MessageExample) captureExtensions(m map[string]any) { + for k, v := range m { + if strings.HasPrefix(k, "x-") { + if e.extensions == nil { + e.extensions = make(map[string]any) + } + e.extensions[k] = v + } + } +} + +// UnmarshalJSON keeps unknown x-* keys on the example. +func (e *MessageExample) UnmarshalJSON(data []byte) error { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return err + } + e.captureExtensions(m) + // Drop extension keys so the strict struct decode below ignores them. + for k := range m { + if strings.HasPrefix(k, "x-") { + delete(m, k) + } + } + raw, err := json.Marshal(m) + if err != nil { + return err + } + type plain MessageExample + return json.Unmarshal(raw, (*plain)(e)) +} + +// UnmarshalYAML keeps unknown x-* keys on the example. +func (e *MessageExample) UnmarshalYAML(node *yaml.Node) error { + var m map[string]any + if err := node.Decode(&m); err != nil { + return err + } + e.captureExtensions(m) + for k := range m { + if strings.HasPrefix(k, "x-") { + delete(m, k) + } + } + raw, err := json.Marshal(m) + if err != nil { + return err + } + type plain MessageExample + return json.Unmarshal(raw, (*plain)(e)) +} + +// CorrelationID specifies an identifier for message tracing or matching. +type CorrelationID struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Location string `json:"location" yaml:"location"` // runtime expression +} diff --git a/third_party/go-asyncapi/operation.go b/third_party/go-asyncapi/operation.go new file mode 100644 index 0000000..58e18c5 --- /dev/null +++ b/third_party/go-asyncapi/operation.go @@ -0,0 +1,57 @@ +package asyncapi + +// Action is the operation action type. +type Action string + +const ( + // ActionSend indicates the application sends messages to the channel. + ActionSend Action = "send" + // ActionReceive indicates the application receives messages from the channel. + ActionReceive Action = "receive" +) + +// Operation describes a specific operation. +type Operation struct { + Action Action `json:"action" yaml:"action"` + Channel *ChannelRef `json:"channel" yaml:"channel"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Security []*SecuritySchemeRef `json:"security,omitempty" yaml:"security,omitempty"` + Tags []*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Bindings *OperationBindingsRef `json:"bindings,omitempty" yaml:"bindings,omitempty"` + Traits []*OperationTraitRef `json:"traits,omitempty" yaml:"traits,omitempty"` + Messages []*MessageRef `json:"messages,omitempty" yaml:"messages,omitempty"` + Reply *OperationReplyRef `json:"reply,omitempty" yaml:"reply,omitempty"` +} + +// IsSender returns true if this operation sends messages. +func (o *Operation) IsSender() bool { return o.Action == ActionSend } + +// IsReceiver returns true if this operation receives messages. +func (o *Operation) IsReceiver() bool { return o.Action == ActionReceive } + +// OperationTrait describes a trait that may be applied to an Operation. +type OperationTrait struct { + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Security []*SecuritySchemeRef `json:"security,omitempty" yaml:"security,omitempty"` + Tags []*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Bindings *OperationBindingsRef `json:"bindings,omitempty" yaml:"bindings,omitempty"` +} + +// OperationReply describes the reply part of a request/reply operation. +type OperationReply struct { + Address *ReplyAddressRef `json:"address,omitempty" yaml:"address,omitempty"` + Channel *ChannelRef `json:"channel,omitempty" yaml:"channel,omitempty"` + Messages []*MessageRef `json:"messages,omitempty" yaml:"messages,omitempty"` +} + +// ReplyAddress specifies where an operation sends the reply. +type ReplyAddress struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Location string `json:"location" yaml:"location"` // runtime expression +} diff --git a/third_party/go-asyncapi/reference.go b/third_party/go-asyncapi/reference.go new file mode 100644 index 0000000..18da8ea --- /dev/null +++ b/third_party/go-asyncapi/reference.go @@ -0,0 +1,727 @@ +package asyncapi + +import ( + "encoding/json" + + "gopkg.in/yaml.v3" +) + +// Reference represents a JSON Reference ($ref). +type Reference struct { + Ref string `json:"$ref" yaml:"$ref"` +} + +// IsRef returns true if this is a reference (has $ref set). +func (r *Reference) IsRef() bool { + return r.Ref != "" +} + +// refContainer is used for detecting $ref in raw data. +type refContainer struct { + Ref string `json:"$ref" yaml:"$ref"` +} + +// ServerRef wraps a Server that may be a $ref or inline definition. +type ServerRef struct { + Ref string `json:"-" yaml:"-"` + Value *Server `json:"-" yaml:"-"` + inline Server +} + +func (r *ServerRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ServerRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ServerRef) MarshalJSON() ([]byte, error) { + if r.Ref != "" { + return json.Marshal(Reference{Ref: r.Ref}) + } + if r.Value != nil { + return json.Marshal(r.Value) + } + return json.Marshal(&r.inline) +} + +// ServerVariableRef wraps a ServerVariable that may be a $ref or inline definition. +type ServerVariableRef struct { + Ref string `json:"-" yaml:"-"` + Value *ServerVariable `json:"-" yaml:"-"` + inline ServerVariable +} + +func (r *ServerVariableRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ServerVariableRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// ChannelRef wraps a Channel that may be a $ref or inline definition. +type ChannelRef struct { + Ref string `json:"-" yaml:"-"` + Value *Channel `json:"-" yaml:"-"` + inline Channel +} + +func (r *ChannelRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ChannelRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// ParameterRef wraps a Parameter that may be a $ref or inline definition. +type ParameterRef struct { + Ref string `json:"-" yaml:"-"` + Value *Parameter `json:"-" yaml:"-"` + inline Parameter +} + +func (r *ParameterRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ParameterRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// OperationRef wraps an Operation that may be a $ref or inline definition. +type OperationRef struct { + Ref string `json:"-" yaml:"-"` + Value *Operation `json:"-" yaml:"-"` + inline Operation +} + +func (r *OperationRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *OperationRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// OperationTraitRef wraps an OperationTrait that may be a $ref or inline definition. +type OperationTraitRef struct { + Ref string `json:"-" yaml:"-"` + Value *OperationTrait `json:"-" yaml:"-"` + inline OperationTrait +} + +func (r *OperationTraitRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *OperationTraitRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// OperationReplyRef wraps an OperationReply that may be a $ref or inline definition. +type OperationReplyRef struct { + Ref string `json:"-" yaml:"-"` + Value *OperationReply `json:"-" yaml:"-"` + inline OperationReply +} + +func (r *OperationReplyRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *OperationReplyRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// ReplyAddressRef wraps a ReplyAddress that may be a $ref or inline definition. +type ReplyAddressRef struct { + Ref string `json:"-" yaml:"-"` + Value *ReplyAddress `json:"-" yaml:"-"` + inline ReplyAddress +} + +func (r *ReplyAddressRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ReplyAddressRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// MessageRef wraps a Message that may be a $ref or inline definition. +type MessageRef struct { + Ref string `json:"-" yaml:"-"` + Value *Message `json:"-" yaml:"-"` + inline Message +} + +func (r *MessageRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *MessageRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// MessageTraitRef wraps a MessageTrait that may be a $ref or inline definition. +type MessageTraitRef struct { + Ref string `json:"-" yaml:"-"` + Value *MessageTrait `json:"-" yaml:"-"` + inline MessageTrait +} + +func (r *MessageTraitRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *MessageTraitRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// TagRef wraps a Tag that may be a $ref or inline definition. +type TagRef struct { + Ref string `json:"-" yaml:"-"` + Value *Tag `json:"-" yaml:"-"` + inline Tag +} + +func (r *TagRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *TagRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// ExternalDocsRef wraps an ExternalDocs that may be a $ref or inline definition. +type ExternalDocsRef struct { + Ref string `json:"-" yaml:"-"` + Value *ExternalDocs `json:"-" yaml:"-"` + inline ExternalDocs +} + +func (r *ExternalDocsRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ExternalDocsRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// SchemaRef wraps a Schema that may be a $ref or inline definition. +type SchemaRef struct { + Ref string `json:"-" yaml:"-"` + Value *Schema `json:"-" yaml:"-"` + inline Schema +} + +func (r *SchemaRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *SchemaRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// MultiFormatSchemaRef wraps a schema with optional schemaFormat. +type MultiFormatSchemaRef struct { + Ref string `json:"-" yaml:"-"` + SchemaFormat string `json:"schemaFormat,omitempty" yaml:"schemaFormat,omitempty"` + Value *Schema `json:"-" yaml:"-"` + inline Schema +} + +func (r *MultiFormatSchemaRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + // Try as MultiFormatSchema first + var mfs struct { + SchemaFormat string `json:"schemaFormat"` + Schema json.RawMessage `json:"schema"` + } + if err := json.Unmarshal(data, &mfs); err == nil && mfs.SchemaFormat != "" { + r.SchemaFormat = mfs.SchemaFormat + if len(mfs.Schema) > 0 { + if err := json.Unmarshal(mfs.Schema, &r.inline); err != nil { + return err + } + r.Value = &r.inline + } + return nil + } + // Otherwise treat as inline Schema + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *MultiFormatSchemaRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + // Try as MultiFormatSchema first + var mfs struct { + SchemaFormat string `yaml:"schemaFormat"` + Schema yaml.Node `yaml:"schema"` + } + if err := node.Decode(&mfs); err == nil && mfs.SchemaFormat != "" { + r.SchemaFormat = mfs.SchemaFormat + if mfs.Schema.Kind != 0 { + if err := mfs.Schema.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + } + return nil + } + // Otherwise treat as inline Schema + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// SecuritySchemeRef wraps a SecurityScheme that may be a $ref or inline definition. +type SecuritySchemeRef struct { + Ref string `json:"-" yaml:"-"` + Value *SecurityScheme `json:"-" yaml:"-"` + inline SecurityScheme + // Scopes for this specific usage (oauth2/openIdConnect) + Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` +} + +func (r *SecuritySchemeRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *SecuritySchemeRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// CorrelationIDRef wraps a CorrelationID that may be a $ref or inline definition. +type CorrelationIDRef struct { + Ref string `json:"-" yaml:"-"` + Value *CorrelationID `json:"-" yaml:"-"` + inline CorrelationID +} + +func (r *CorrelationIDRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *CorrelationIDRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// ServerBindingsRef wraps ServerBindings that may be a $ref or inline definition. +type ServerBindingsRef struct { + Ref string `json:"-" yaml:"-"` + Value *ServerBindings `json:"-" yaml:"-"` + inline ServerBindings +} + +func (r *ServerBindingsRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ServerBindingsRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// ChannelBindingsRef wraps ChannelBindings that may be a $ref or inline definition. +type ChannelBindingsRef struct { + Ref string `json:"-" yaml:"-"` + Value *ChannelBindings `json:"-" yaml:"-"` + inline ChannelBindings +} + +func (r *ChannelBindingsRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *ChannelBindingsRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// OperationBindingsRef wraps OperationBindings that may be a $ref or inline definition. +type OperationBindingsRef struct { + Ref string `json:"-" yaml:"-"` + Value *OperationBindings `json:"-" yaml:"-"` + inline OperationBindings +} + +func (r *OperationBindingsRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *OperationBindingsRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +// MessageBindingsRef wraps MessageBindings that may be a $ref or inline definition. +type MessageBindingsRef struct { + Ref string `json:"-" yaml:"-"` + Value *MessageBindings `json:"-" yaml:"-"` + inline MessageBindings +} + +func (r *MessageBindingsRef) UnmarshalJSON(data []byte) error { + var ref refContainer + if err := json.Unmarshal(data, &ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := json.Unmarshal(data, &r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} + +func (r *MessageBindingsRef) UnmarshalYAML(node *yaml.Node) error { + var ref refContainer + if err := node.Decode(&ref); err == nil && ref.Ref != "" { + r.Ref = ref.Ref + return nil + } + if err := node.Decode(&r.inline); err != nil { + return err + } + r.Value = &r.inline + return nil +} diff --git a/third_party/go-asyncapi/resolver.go b/third_party/go-asyncapi/resolver.go new file mode 100644 index 0000000..220bfaa --- /dev/null +++ b/third_party/go-asyncapi/resolver.go @@ -0,0 +1,495 @@ +package asyncapi + +import ( + "fmt" + "path/filepath" + "strings" +) + +// RefResolver resolves $ref pointers within a document. +type RefResolver struct { + doc *Document + loader *Loader + resolving map[string]bool // cycle detection + cache map[string]*Document // external document cache +} + +// NewRefResolver creates a new reference resolver for the given document. +func NewRefResolver(doc *Document) *RefResolver { + return &RefResolver{ + doc: doc, + loader: NewLoader(), + resolving: make(map[string]bool), + cache: make(map[string]*Document), + } +} + +// WithLoader sets a custom loader for external references. +func (r *RefResolver) WithLoader(loader *Loader) *RefResolver { + r.loader = loader + return r +} + +// ResolveRefs resolves all references in the document. +func (d *Document) ResolveRefs() error { + resolver := NewRefResolver(d) + return resolver.ResolveAll() +} + +// ResolveAll resolves all references in the document. +func (r *RefResolver) ResolveAll() error { + // Resolve servers + for name, ref := range r.doc.Servers { + if err := r.resolveServerRef(ref, "/servers/"+name); err != nil { + return err + } + } + + // Resolve channels + for name, ref := range r.doc.Channels { + if err := r.resolveChannelRef(ref, "/channels/"+name); err != nil { + return err + } + } + + // Resolve operations + for name, ref := range r.doc.Operations { + if err := r.resolveOperationRef(ref, "/operations/"+name); err != nil { + return err + } + } + + // Resolve components + if r.doc.Components != nil { + if err := r.resolveComponents(); err != nil { + return err + } + } + + return nil +} + +func (r *RefResolver) resolveComponents() error { + c := r.doc.Components + + for name, ref := range c.Schemas { + if err := r.resolveSchemaRef(ref, "/components/schemas/"+name); err != nil { + return err + } + } + + for name, ref := range c.Messages { + if err := r.resolveMessageRef(ref, "/components/messages/"+name); err != nil { + return err + } + } + + for name, ref := range c.Servers { + if err := r.resolveServerRef(ref, "/components/servers/"+name); err != nil { + return err + } + } + + for name, ref := range c.Channels { + if err := r.resolveChannelRef(ref, "/components/channels/"+name); err != nil { + return err + } + } + + for name, ref := range c.Operations { + if err := r.resolveOperationRef(ref, "/components/operations/"+name); err != nil { + return err + } + } + + return nil +} + +func (r *RefResolver) resolveServerRef(ref *ServerRef, path string) error { + if ref == nil { + return nil + } + if ref.Ref == "" { + return nil // Already resolved (inline) + } + + // Check for cycle + if r.resolving[ref.Ref] { + return &RefError{Ref: ref.Ref, Message: "circular reference detected"} + } + r.resolving[ref.Ref] = true + defer delete(r.resolving, ref.Ref) + + // Resolve the reference + resolved, err := r.lookupServer(ref.Ref) + if err != nil { + return &RefError{Ref: ref.Ref, Message: "failed to resolve", Cause: err} + } + ref.Value = resolved + return nil +} + +func (r *RefResolver) resolveChannelRef(ref *ChannelRef, path string) error { + if ref == nil { + return nil + } + if ref.Ref == "" { + // Inline - resolve nested refs + if ref.Value != nil { + for msgName, msgRef := range ref.Value.Messages { + if err := r.resolveMessageRef(msgRef, path+"/messages/"+msgName); err != nil { + return err + } + } + for paramName, paramRef := range ref.Value.Parameters { + if err := r.resolveParameterRef(paramRef, path+"/parameters/"+paramName); err != nil { + return err + } + } + } + return nil + } + + // Check for cycle + if r.resolving[ref.Ref] { + return &RefError{Ref: ref.Ref, Message: "circular reference detected"} + } + r.resolving[ref.Ref] = true + defer delete(r.resolving, ref.Ref) + + // Resolve the reference + resolved, err := r.lookupChannel(ref.Ref) + if err != nil { + return &RefError{Ref: ref.Ref, Message: "failed to resolve", Cause: err} + } + ref.Value = resolved + return nil +} + +func (r *RefResolver) resolveOperationRef(ref *OperationRef, path string) error { + if ref == nil { + return nil + } + if ref.Ref == "" { + // Inline - resolve nested refs + if ref.Value != nil { + if err := r.resolveChannelRef(ref.Value.Channel, path+"/channel"); err != nil { + return err + } + for i, msgRef := range ref.Value.Messages { + if err := r.resolveMessageRef(msgRef, fmt.Sprintf("%s/messages/%d", path, i)); err != nil { + return err + } + } + } + return nil + } + + // Check for cycle + if r.resolving[ref.Ref] { + return &RefError{Ref: ref.Ref, Message: "circular reference detected"} + } + r.resolving[ref.Ref] = true + defer delete(r.resolving, ref.Ref) + + // Resolve the reference + resolved, err := r.lookupOperation(ref.Ref) + if err != nil { + return &RefError{Ref: ref.Ref, Message: "failed to resolve", Cause: err} + } + ref.Value = resolved + return nil +} + +func (r *RefResolver) resolveMessageRef(ref *MessageRef, path string) error { + if ref == nil { + return nil + } + if ref.Ref == "" { + // Inline - resolve nested refs + if ref.Value != nil { + if err := r.resolveMultiFormatSchemaRef(ref.Value.Payload, path+"/payload"); err != nil { + return err + } + if err := r.resolveMultiFormatSchemaRef(ref.Value.Headers, path+"/headers"); err != nil { + return err + } + } + return nil + } + + // Check for cycle + if r.resolving[ref.Ref] { + return &RefError{Ref: ref.Ref, Message: "circular reference detected"} + } + r.resolving[ref.Ref] = true + defer delete(r.resolving, ref.Ref) + + // Resolve the reference + resolved, err := r.lookupMessage(ref.Ref) + if err != nil { + return &RefError{Ref: ref.Ref, Message: "failed to resolve", Cause: err} + } + ref.Value = resolved + return nil +} + +func (r *RefResolver) resolveParameterRef(ref *ParameterRef, path string) error { + if ref == nil || ref.Ref == "" { + return nil + } + + if r.resolving[ref.Ref] { + return &RefError{Ref: ref.Ref, Message: "circular reference detected"} + } + r.resolving[ref.Ref] = true + defer delete(r.resolving, ref.Ref) + + resolved, err := r.lookupParameter(ref.Ref) + if err != nil { + return &RefError{Ref: ref.Ref, Message: "failed to resolve", Cause: err} + } + ref.Value = resolved + return nil +} + +func (r *RefResolver) resolveSchemaRef(ref *SchemaRef, path string) error { + if ref == nil || ref.Ref == "" { + return nil + } + + if r.resolving[ref.Ref] { + return &RefError{Ref: ref.Ref, Message: "circular reference detected"} + } + r.resolving[ref.Ref] = true + defer delete(r.resolving, ref.Ref) + + resolved, err := r.lookupSchema(ref.Ref) + if err != nil { + return &RefError{Ref: ref.Ref, Message: "failed to resolve", Cause: err} + } + ref.Value = resolved + return nil +} + +func (r *RefResolver) resolveMultiFormatSchemaRef(ref *MultiFormatSchemaRef, path string) error { + if ref == nil || ref.Ref == "" { + return nil + } + + if r.resolving[ref.Ref] { + return &RefError{Ref: ref.Ref, Message: "circular reference detected"} + } + r.resolving[ref.Ref] = true + defer delete(r.resolving, ref.Ref) + + resolved, err := r.lookupSchema(ref.Ref) + if err != nil { + return &RefError{Ref: ref.Ref, Message: "failed to resolve", Cause: err} + } + ref.Value = resolved + return nil +} + +// Lookup functions for resolving references + +func (r *RefResolver) lookupServer(ref string) (*Server, error) { + name := extractRefName(ref, "servers") + if name == "" { + return nil, fmt.Errorf("invalid server reference: %s", ref) + } + + // Check root servers + if srv, ok := r.doc.Servers[name]; ok && srv.Value != nil { + return srv.Value, nil + } + + // Check components + if r.doc.Components != nil { + if srv, ok := r.doc.Components.Servers[name]; ok && srv.Value != nil { + return srv.Value, nil + } + } + + return nil, fmt.Errorf("server not found: %s", name) +} + +func (r *RefResolver) lookupChannel(ref string) (*Channel, error) { + name := extractRefName(ref, "channels") + if name == "" { + return nil, fmt.Errorf("invalid channel reference: %s", ref) + } + + // Check root channels + if ch, ok := r.doc.Channels[name]; ok && ch.Value != nil { + return ch.Value, nil + } + + // Check components + if r.doc.Components != nil { + if ch, ok := r.doc.Components.Channels[name]; ok && ch.Value != nil { + return ch.Value, nil + } + } + + return nil, fmt.Errorf("channel not found: %s", name) +} + +func (r *RefResolver) lookupOperation(ref string) (*Operation, error) { + name := extractRefName(ref, "operations") + if name == "" { + return nil, fmt.Errorf("invalid operation reference: %s", ref) + } + + // Check root operations + if op, ok := r.doc.Operations[name]; ok && op.Value != nil { + return op.Value, nil + } + + // Check components + if r.doc.Components != nil { + if op, ok := r.doc.Components.Operations[name]; ok && op.Value != nil { + return op.Value, nil + } + } + + return nil, fmt.Errorf("operation not found: %s", name) +} + +func (r *RefResolver) lookupMessage(ref string) (*Message, error) { + // Messages can be in channels or components + // Format: #/channels/{channel}/messages/{message} or #/components/messages/{message} + + if strings.Contains(ref, "/channels/") && strings.Contains(ref, "/messages/") { + // Extract channel and message names + parts := strings.Split(ref, "/") + var channelName, messageName string + for i, part := range parts { + if part == "channels" && i+1 < len(parts) { + channelName = parts[i+1] + } + if part == "messages" && i+1 < len(parts) { + messageName = parts[i+1] + } + } + if channelName != "" && messageName != "" { + if ch, ok := r.doc.Channels[channelName]; ok && ch.Value != nil { + if msg, ok := ch.Value.Messages[messageName]; ok && msg.Value != nil { + return msg.Value, nil + } + } + } + } + + // Check components/messages + name := extractRefName(ref, "messages") + if name != "" && r.doc.Components != nil { + if msg, ok := r.doc.Components.Messages[name]; ok && msg.Value != nil { + return msg.Value, nil + } + } + + return nil, fmt.Errorf("message not found: %s", ref) +} + +func (r *RefResolver) lookupParameter(ref string) (*Parameter, error) { + name := extractRefName(ref, "parameters") + if name == "" { + return nil, fmt.Errorf("invalid parameter reference: %s", ref) + } + + if r.doc.Components != nil { + if param, ok := r.doc.Components.Parameters[name]; ok && param.Value != nil { + return param.Value, nil + } + } + + return nil, fmt.Errorf("parameter not found: %s", name) +} + +func (r *RefResolver) lookupSchema(ref string) (*Schema, error) { + name := extractRefName(ref, "schemas") + if name == "" { + return nil, fmt.Errorf("invalid schema reference: %s", ref) + } + + if r.doc.Components != nil { + if schema, ok := r.doc.Components.Schemas[name]; ok && schema.Value != nil { + return schema.Value, nil + } + } + + return nil, fmt.Errorf("schema not found: %s", name) +} + +// extractRefName extracts the name from a reference like "#/components/schemas/MySchema" +// given the expected parent path component (e.g., "schemas"). +func extractRefName(ref, parent string) string { + // Handle external refs with fragments + if idx := strings.Index(ref, "#"); idx > 0 { + ref = ref[idx:] + } + + if !strings.HasPrefix(ref, "#/") { + return "" + } + + parts := strings.Split(ref[2:], "/") + for i, part := range parts { + if part == parent && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// isExternalRef returns true if the reference points to an external file/URL. +func isExternalRef(ref string) bool { + return !strings.HasPrefix(ref, "#") && (strings.Contains(ref, "/") || strings.Contains(ref, ".")) +} + +// splitExternalRef splits an external ref into file path and fragment. +// e.g., "./common/schemas.yaml#/components/schemas/User" -> ("./common/schemas.yaml", "#/components/schemas/User") +func splitExternalRef(ref string) (filePath, fragment string) { + if idx := strings.Index(ref, "#"); idx >= 0 { + return ref[:idx], ref[idx:] + } + return ref, "" +} + +// loadExternalDocument loads an external document and caches it. +func (r *RefResolver) loadExternalDocument(path string) (*Document, error) { + // Check cache + if doc, ok := r.cache[path]; ok { + return doc, nil + } + + // Resolve relative paths + basePath := r.loader.BasePath + if basePath == "" { + basePath = "." + } + + fullPath := path + if !filepath.IsAbs(path) && !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") { + fullPath = filepath.Join(basePath, path) + } + + // Load the document + var doc *Document + var err error + + if strings.HasPrefix(fullPath, "http://") || strings.HasPrefix(fullPath, "https://") { + doc, err = r.loader.LoadFromURL(fullPath) + } else { + doc, err = r.loader.LoadFromFile(fullPath) + } + + if err != nil { + return nil, err + } + + // Cache and return + r.cache[path] = doc + return doc, nil +} diff --git a/third_party/go-asyncapi/runtime_expr.go b/third_party/go-asyncapi/runtime_expr.go new file mode 100644 index 0000000..569ef86 --- /dev/null +++ b/third_party/go-asyncapi/runtime_expr.go @@ -0,0 +1,58 @@ +package asyncapi + +import ( + "fmt" + "strings" +) + +// RuntimeExpr represents a parsed runtime expression. +// Format: $message.source#/json/pointer +type RuntimeExpr struct { + Source string // "message" + Location string // "header" or "payload" + Pointer string // JSON pointer, e.g., "/correlationId" +} + +// ParseRuntimeExpr parses a runtime expression like "$message.header#/correlationId". +func ParseRuntimeExpr(expr string) (*RuntimeExpr, error) { + if !strings.HasPrefix(expr, "$") { + return nil, fmt.Errorf("runtime expression must start with $: %s", expr) + } + + expr = expr[1:] // Remove leading $ + + // Split on # + parts := strings.SplitN(expr, "#", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("runtime expression must contain #: %s", expr) + } + + // Parse source.location + sourceParts := strings.SplitN(parts[0], ".", 2) + if len(sourceParts) != 2 { + return nil, fmt.Errorf("runtime expression source must be source.location: %s", parts[0]) + } + + result := &RuntimeExpr{ + Source: sourceParts[0], + Location: sourceParts[1], + Pointer: parts[1], + } + + // Validate source + if result.Source != "message" { + return nil, fmt.Errorf("runtime expression source must be 'message': %s", result.Source) + } + + // Validate location + if result.Location != "header" && result.Location != "payload" { + return nil, fmt.Errorf("runtime expression location must be 'header' or 'payload': %s", result.Location) + } + + return result, nil +} + +// String returns the string representation of the runtime expression. +func (r *RuntimeExpr) String() string { + return fmt.Sprintf("$%s.%s#%s", r.Source, r.Location, r.Pointer) +} diff --git a/third_party/go-asyncapi/schema.go b/third_party/go-asyncapi/schema.go new file mode 100644 index 0000000..7a342e4 --- /dev/null +++ b/third_party/go-asyncapi/schema.go @@ -0,0 +1,171 @@ +package asyncapi + +import ( + "encoding/json" + + "gopkg.in/yaml.v3" +) + +// Schema is JSON Schema Draft-07 with AsyncAPI extensions. +type Schema struct { + // Core vocabulary + ID string `json:"$id,omitempty" yaml:"$id,omitempty"` + Schema string `json:"$schema,omitempty" yaml:"$schema,omitempty"` + + // Type + Type Types `json:"type,omitempty" yaml:"type,omitempty"` + Const any `json:"const,omitempty" yaml:"const,omitempty"` + Enum []any `json:"enum,omitempty" yaml:"enum,omitempty"` + + // Numeric + MultipleOf *float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` + Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` + ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` + ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` + + // String + MaxLength *int64 `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` + MinLength *int64 `json:"minLength,omitempty" yaml:"minLength,omitempty"` + Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + + // Content + ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` + ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty"` + + // Array + Items *SchemaRef `json:"items,omitempty" yaml:"items,omitempty"` + AdditionalItems *BoolOrSchema `json:"additionalItems,omitempty" yaml:"additionalItems,omitempty"` + MaxItems *int64 `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` + MinItems *int64 `json:"minItems,omitempty" yaml:"minItems,omitempty"` + UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` + Contains *SchemaRef `json:"contains,omitempty" yaml:"contains,omitempty"` + + // Object + Properties map[string]*SchemaRef `json:"properties,omitempty" yaml:"properties,omitempty"` + Required []string `json:"required,omitempty" yaml:"required,omitempty"` + AdditionalProperties *BoolOrSchema `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` + MaxProperties *int64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` + MinProperties *int64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` + PatternProperties map[string]*SchemaRef `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` + PropertyNames *SchemaRef `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` + Dependencies map[string]*SchemaRef `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` + + // Composition + AllOf []*SchemaRef `json:"allOf,omitempty" yaml:"allOf,omitempty"` + AnyOf []*SchemaRef `json:"anyOf,omitempty" yaml:"anyOf,omitempty"` + OneOf []*SchemaRef `json:"oneOf,omitempty" yaml:"oneOf,omitempty"` + Not *SchemaRef `json:"not,omitempty" yaml:"not,omitempty"` + + // Conditionals + If *SchemaRef `json:"if,omitempty" yaml:"if,omitempty"` + Then *SchemaRef `json:"then,omitempty" yaml:"then,omitempty"` + Else *SchemaRef `json:"else,omitempty" yaml:"else,omitempty"` + + // Metadata + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Default any `json:"default,omitempty" yaml:"default,omitempty"` + Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"` + ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + WriteOnly bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty"` + + // Definitions + Definitions map[string]*SchemaRef `json:"definitions,omitempty" yaml:"definitions,omitempty"` + + // AsyncAPI extensions + Discriminator string `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` +} + +// Types handles JSON Schema type which can be string or []string. +type Types []string + +func (t *Types) UnmarshalJSON(data []byte) error { + var single string + if err := json.Unmarshal(data, &single); err == nil { + *t = Types{single} + return nil + } + var multi []string + if err := json.Unmarshal(data, &multi); err != nil { + return err + } + *t = multi + return nil +} + +func (t *Types) UnmarshalYAML(node *yaml.Node) error { + var single string + if err := node.Decode(&single); err == nil { + *t = Types{single} + return nil + } + var multi []string + if err := node.Decode(&multi); err != nil { + return err + } + *t = multi + return nil +} + +func (t Types) MarshalJSON() ([]byte, error) { + if len(t) == 1 { + return json.Marshal(t[0]) + } + return json.Marshal([]string(t)) +} + +// Contains returns true if the type list contains the given type. +func (t Types) Contains(typ string) bool { + for _, v := range t { + if v == typ { + return true + } + } + return false +} + +// Is returns true if the type list has exactly one type matching the given type. +func (t Types) Is(typ string) bool { + return len(t) == 1 && t[0] == typ +} + +// BoolOrSchema handles additionalProperties/additionalItems which can be bool or Schema. +type BoolOrSchema struct { + Allowed bool // if false, no additional properties/items + Schema *Schema // if non-nil, additional properties/items must match +} + +func (b *BoolOrSchema) UnmarshalJSON(data []byte) error { + var boolVal bool + if err := json.Unmarshal(data, &boolVal); err == nil { + b.Allowed = boolVal + b.Schema = nil + return nil + } + b.Allowed = true + b.Schema = &Schema{} + return json.Unmarshal(data, b.Schema) +} + +func (b *BoolOrSchema) UnmarshalYAML(node *yaml.Node) error { + var boolVal bool + if err := node.Decode(&boolVal); err == nil { + b.Allowed = boolVal + b.Schema = nil + return nil + } + b.Allowed = true + b.Schema = &Schema{} + return node.Decode(b.Schema) +} + +func (b *BoolOrSchema) MarshalJSON() ([]byte, error) { + if b.Schema != nil { + return json.Marshal(b.Schema) + } + return json.Marshal(b.Allowed) +} diff --git a/third_party/go-asyncapi/security.go b/third_party/go-asyncapi/security.go new file mode 100644 index 0000000..bd133c3 --- /dev/null +++ b/third_party/go-asyncapi/security.go @@ -0,0 +1,48 @@ +package asyncapi + +// SecuritySchemeType is the type of security scheme. +type SecuritySchemeType string + +const ( + SecurityTypeUserPassword SecuritySchemeType = "userPassword" + SecurityTypeAPIKey SecuritySchemeType = "apiKey" + SecurityTypeX509 SecuritySchemeType = "X509" + SecurityTypeSymmetricEncrypt SecuritySchemeType = "symmetricEncryption" + SecurityTypeAsymmetricEncrypt SecuritySchemeType = "asymmetricEncryption" + SecurityTypeHTTPAPIKey SecuritySchemeType = "httpApiKey" + SecurityTypeHTTP SecuritySchemeType = "http" + SecurityTypeOAuth2 SecuritySchemeType = "oauth2" + SecurityTypeOpenIDConnect SecuritySchemeType = "openIdConnect" + SecurityTypePlain SecuritySchemeType = "plain" + SecurityTypeScramSHA256 SecuritySchemeType = "scramSha256" + SecurityTypeScramSHA512 SecuritySchemeType = "scramSha512" + SecurityTypeGSSAPI SecuritySchemeType = "gssapi" +) + +// SecurityScheme defines a security scheme that can be used by operations. +type SecurityScheme struct { + Type SecuritySchemeType `json:"type" yaml:"type"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` // httpApiKey + In string `json:"in,omitempty" yaml:"in,omitempty"` // apiKey, httpApiKey + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` // http + BearerFormat string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` + Flows *OAuthFlows `json:"flows,omitempty" yaml:"flows,omitempty"` + OpenIDConnectURL string `json:"openIdConnectUrl,omitempty" yaml:"openIdConnectUrl,omitempty"` +} + +// OAuthFlows allows configuration of the supported OAuth Flows. +type OAuthFlows struct { + Implicit *OAuthFlow `json:"implicit,omitempty" yaml:"implicit,omitempty"` + Password *OAuthFlow `json:"password,omitempty" yaml:"password,omitempty"` + ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty" yaml:"clientCredentials,omitempty"` + AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty" yaml:"authorizationCode,omitempty"` +} + +// OAuthFlow contains configuration details for a supported OAuth Flow. +type OAuthFlow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` + RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"` + AvailableScopes map[string]string `json:"availableScopes,omitempty" yaml:"availableScopes,omitempty"` +} diff --git a/third_party/go-asyncapi/server.go b/third_party/go-asyncapi/server.go new file mode 100644 index 0000000..d23d473 --- /dev/null +++ b/third_party/go-asyncapi/server.go @@ -0,0 +1,25 @@ +package asyncapi + +// Server represents a message broker, server, or any computer program capable of sending/receiving data. +type Server struct { + Host string `json:"host" yaml:"host"` + Protocol string `json:"protocol" yaml:"protocol"` + ProtocolVersion string `json:"protocolVersion,omitempty" yaml:"protocolVersion,omitempty"` + Pathname string `json:"pathname,omitempty" yaml:"pathname,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Variables map[string]*ServerVariableRef `json:"variables,omitempty" yaml:"variables,omitempty"` + Security []*SecuritySchemeRef `json:"security,omitempty" yaml:"security,omitempty"` + Tags []*TagRef `json:"tags,omitempty" yaml:"tags,omitempty"` + ExternalDocs *ExternalDocsRef `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Bindings *ServerBindingsRef `json:"bindings,omitempty" yaml:"bindings,omitempty"` +} + +// ServerVariable represents a variable for server URL template substitution. +type ServerVariable struct { + Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"` + Default string `json:"default,omitempty" yaml:"default,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Examples []string `json:"examples,omitempty" yaml:"examples,omitempty"` +} diff --git a/third_party/go-asyncapi/traits.go b/third_party/go-asyncapi/traits.go new file mode 100644 index 0000000..4e528d6 --- /dev/null +++ b/third_party/go-asyncapi/traits.go @@ -0,0 +1,126 @@ +package asyncapi + +// MergeTraits applies traits to operations and messages using RFC 7386 JSON Merge Patch. +// Traits are merged in order, and explicit values on the target object take precedence. +func (d *Document) MergeTraits() error { + // Merge operation traits + for _, opRef := range d.Operations { + if opRef == nil || opRef.Value == nil || len(opRef.Value.Traits) == 0 { + continue + } + if err := mergeOperationTraits(opRef.Value); err != nil { + return err + } + } + + // Merge message traits in channels + for _, chRef := range d.Channels { + if chRef == nil || chRef.Value == nil { + continue + } + for _, msgRef := range chRef.Value.Messages { + if msgRef == nil || msgRef.Value == nil || len(msgRef.Value.Traits) == 0 { + continue + } + if err := mergeMessageTraits(msgRef.Value); err != nil { + return err + } + } + } + + // Merge message traits in components + if d.Components != nil { + for _, msgRef := range d.Components.Messages { + if msgRef == nil || msgRef.Value == nil || len(msgRef.Value.Traits) == 0 { + continue + } + if err := mergeMessageTraits(msgRef.Value); err != nil { + return err + } + } + } + + return nil +} + +// mergeOperationTraits merges traits into an operation. +// Per RFC 7386, values in the target take precedence over trait values. +func mergeOperationTraits(op *Operation) error { + for _, traitRef := range op.Traits { + if traitRef == nil || traitRef.Value == nil { + continue + } + trait := traitRef.Value + + // Merge each field only if the operation doesn't have it set + if op.Title == "" && trait.Title != "" { + op.Title = trait.Title + } + if op.Summary == "" && trait.Summary != "" { + op.Summary = trait.Summary + } + if op.Description == "" && trait.Description != "" { + op.Description = trait.Description + } + if op.Security == nil && trait.Security != nil { + op.Security = trait.Security + } + if op.Tags == nil && trait.Tags != nil { + op.Tags = trait.Tags + } + if op.ExternalDocs == nil && trait.ExternalDocs != nil { + op.ExternalDocs = trait.ExternalDocs + } + if op.Bindings == nil && trait.Bindings != nil { + op.Bindings = trait.Bindings + } + } + return nil +} + +// mergeMessageTraits merges traits into a message. +// Per RFC 7386, values in the target take precedence over trait values. +func mergeMessageTraits(msg *Message) error { + for _, traitRef := range msg.Traits { + if traitRef == nil || traitRef.Value == nil { + continue + } + trait := traitRef.Value + + // Merge each field only if the message doesn't have it set + if msg.Headers == nil && trait.Headers != nil { + msg.Headers = trait.Headers + } + if msg.CorrelationID == nil && trait.CorrelationID != nil { + msg.CorrelationID = trait.CorrelationID + } + if msg.ContentType == "" && trait.ContentType != "" { + msg.ContentType = trait.ContentType + } + if msg.Name == "" && trait.Name != "" { + msg.Name = trait.Name + } + if msg.Title == "" && trait.Title != "" { + msg.Title = trait.Title + } + if msg.Summary == "" && trait.Summary != "" { + msg.Summary = trait.Summary + } + if msg.Description == "" && trait.Description != "" { + msg.Description = trait.Description + } + if msg.Tags == nil && trait.Tags != nil { + msg.Tags = trait.Tags + } + if msg.ExternalDocs == nil && trait.ExternalDocs != nil { + msg.ExternalDocs = trait.ExternalDocs + } + if msg.Bindings == nil && trait.Bindings != nil { + msg.Bindings = trait.Bindings + } + if msg.Examples == nil && trait.Examples != nil { + msg.Examples = trait.Examples + } + } + return nil +} diff --git a/third_party/go-asyncapi/validate.go b/third_party/go-asyncapi/validate.go new file mode 100644 index 0000000..0fc2106 --- /dev/null +++ b/third_party/go-asyncapi/validate.go @@ -0,0 +1,267 @@ +package asyncapi + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v5" + "gopkg.in/yaml.v3" +) + +//go:embed internal/jsonschema/asyncapi-3.0.0.json +var asyncAPISchema []byte + +var schemaCompiler *jsonschema.Compiler +var compiledSchema *jsonschema.Schema + +func init() { + schemaCompiler = jsonschema.NewCompiler() + schemaCompiler.Draft = jsonschema.Draft7 + + if err := schemaCompiler.AddResource("asyncapi-3.0.0.json", bytes.NewReader(asyncAPISchema)); err != nil { + panic(fmt.Sprintf("failed to add AsyncAPI schema: %v", err)) + } + + var err error + compiledSchema, err = schemaCompiler.Compile("asyncapi-3.0.0.json") + if err != nil { + panic(fmt.Sprintf("failed to compile AsyncAPI schema: %v", err)) + } +} + +// Validate validates the document against JSON Schema and semantic rules. +func (d *Document) Validate() error { + result := d.ValidateAll() + if !result.IsValid() { + return &ParseError{Message: result.Error()} + } + return nil +} + +// ValidateAll performs all validation and returns detailed results. +func (d *Document) ValidateAll() *ValidationResult { + result := &ValidationResult{} + + // Layer 1: Basic required field checks + d.validateRequired(result) + + // Layer 2: JSON Schema validation + d.validateJSONSchema(result) + + // Layer 3: Semantic validation + d.validateSemantics(result) + + return result +} + +func (d *Document) validateRequired(result *ValidationResult) { + if d.AsyncAPI == "" { + result.Add("/asyncapi", "asyncapi version is required") + } + if d.Info.Title == "" { + result.Add("/info/title", "title is required") + } + if d.Info.Version == "" { + result.Add("/info/version", "version is required") + } +} + +func (d *Document) validateJSONSchema(result *ValidationResult) { + if len(d.raw) == 0 { + return + } + + // Convert YAML to JSON for schema validation if needed + var jsonData []byte + if isJSON(d.raw) { + jsonData = d.raw + } else { + var obj interface{} + if err := yaml.Unmarshal(d.raw, &obj); err != nil { + result.Add("/", fmt.Sprintf("failed to parse for validation: %v", err)) + return + } + var err error + jsonData, err = json.Marshal(obj) + if err != nil { + result.Add("/", fmt.Sprintf("failed to convert to JSON: %v", err)) + return + } + } + + var doc interface{} + if err := json.Unmarshal(jsonData, &doc); err != nil { + result.Add("/", fmt.Sprintf("failed to parse JSON: %v", err)) + return + } + + if err := compiledSchema.Validate(doc); err != nil { + if ve, ok := err.(*jsonschema.ValidationError); ok { + addSchemaErrors(result, ve, "") + } else { + result.Add("/", fmt.Sprintf("schema validation failed: %v", err)) + } + } +} + +func addSchemaErrors(result *ValidationResult, ve *jsonschema.ValidationError, prefix string) { + if ve.Message != "" { + path := prefix + if ve.InstanceLocation != "" { + path = ve.InstanceLocation + } + result.Add(path, ve.Message) + } + for _, cause := range ve.Causes { + addSchemaErrors(result, cause, prefix) + } +} + +func (d *Document) validateSemantics(result *ValidationResult) { + // Unique operationIds + d.validateUniqueOperationIDs(result) + + // Channel parameter resolution + d.validateChannelParameters(result) + + // Server variable resolution + d.validateServerVariables(result) + + // Unique tags + d.validateUniqueTags(result) +} + +func (d *Document) validateUniqueOperationIDs(result *ValidationResult) { + seen := make(map[string]string) + for id := range d.Operations { + if existing, ok := seen[id]; ok { + result.Add("/operations/"+id, fmt.Sprintf("duplicate operationId (also at %s)", existing)) + } + seen[id] = "/operations/" + id + } +} + +func (d *Document) validateChannelParameters(result *ValidationResult) { + paramRegex := regexp.MustCompile(`\{([^}]+)\}`) + + for name, chRef := range d.Channels { + if chRef == nil || chRef.Value == nil || chRef.Value.Address == nil { + continue + } + + address := *chRef.Value.Address + matches := paramRegex.FindAllStringSubmatch(address, -1) + + for _, match := range matches { + paramName := match[1] + if _, ok := chRef.Value.Parameters[paramName]; !ok { + result.Add( + fmt.Sprintf("/channels/%s", name), + fmt.Sprintf("parameter {%s} in address not defined in parameters", paramName), + ) + } + } + } +} + +func (d *Document) validateServerVariables(result *ValidationResult) { + varRegex := regexp.MustCompile(`\{([^}]+)\}`) + + for name, srvRef := range d.Servers { + if srvRef == nil || srvRef.Value == nil { + continue + } + + srv := srvRef.Value + varsToCheck := []string{srv.Host, srv.Pathname} + + for _, str := range varsToCheck { + if str == "" { + continue + } + + matches := varRegex.FindAllStringSubmatch(str, -1) + for _, match := range matches { + varName := match[1] + if _, ok := srv.Variables[varName]; !ok { + result.Add( + fmt.Sprintf("/servers/%s", name), + fmt.Sprintf("variable {%s} not defined in variables", varName), + ) + } + } + } + } +} + +func (d *Document) validateUniqueTags(result *ValidationResult) { + // Check info tags + if d.Info.Tags != nil { + seen := make(map[string]bool) + for i, tagRef := range d.Info.Tags { + if tagRef == nil || tagRef.Value == nil { + continue + } + if seen[tagRef.Value.Name] { + result.Add( + fmt.Sprintf("/info/tags/%d", i), + fmt.Sprintf("duplicate tag name: %s", tagRef.Value.Name), + ) + } + seen[tagRef.Value.Name] = true + } + } + + // Check operation tags + for opName, opRef := range d.Operations { + if opRef == nil || opRef.Value == nil || opRef.Value.Tags == nil { + continue + } + seen := make(map[string]bool) + for i, tagRef := range opRef.Value.Tags { + if tagRef == nil || tagRef.Value == nil { + continue + } + if seen[tagRef.Value.Name] { + result.Add( + fmt.Sprintf("/operations/%s/tags/%d", opName, i), + fmt.Sprintf("duplicate tag name: %s", tagRef.Value.Name), + ) + } + seen[tagRef.Value.Name] = true + } + } +} + +// ValidateSchema validates just the JSON Schema layer. +func (d *Document) ValidateSchema() error { + result := &ValidationResult{} + d.validateJSONSchema(result) + if !result.IsValid() { + return &ParseError{Message: result.Error()} + } + return nil +} + +// ValidateSemantics validates just the semantic rules. +func (d *Document) ValidateSemantics() error { + result := &ValidationResult{} + d.validateSemantics(result) + if !result.IsValid() { + return &ParseError{Message: result.Error()} + } + return nil +} + +// isJSON is duplicated here to avoid import cycle +func isJSONValidate(data []byte) bool { + data = bytes.TrimSpace(data) + return len(data) > 0 && (data[0] == '{' || data[0] == '[') +} + +// Suppress unused import warning +var _ = strings.Contains diff --git a/third_party/go-asyncapi/walk.go b/third_party/go-asyncapi/walk.go new file mode 100644 index 0000000..d443262 --- /dev/null +++ b/third_party/go-asyncapi/walk.go @@ -0,0 +1,286 @@ +package asyncapi + +// Visitor defines callbacks for walking an AsyncAPI document. +// Return false from any callback to stop walking. +type Visitor struct { + // VisitServer is called for each server. + VisitServer func(name string, server *Server) bool + + // VisitChannel is called for each channel. + VisitChannel func(name string, channel *Channel) bool + + // VisitOperation is called for each operation. + VisitOperation func(name string, operation *Operation) bool + + // VisitMessage is called for each message (from channels and components). + VisitMessage func(name string, message *Message) bool + + // VisitSchema is called for each schema in components. + VisitSchema func(name string, schema *Schema) bool + + // VisitSecurityScheme is called for each security scheme. + VisitSecurityScheme func(name string, scheme *SecurityScheme) bool +} + +// Walk traverses the document and calls visitor callbacks. +// References should be resolved before walking for full access. +func (d *Document) Walk(v *Visitor) { + if v == nil { + return + } + + // Walk servers + if v.VisitServer != nil { + for name, ref := range d.Servers { + if ref != nil && ref.Value != nil { + if !v.VisitServer(name, ref.Value) { + return + } + } + } + } + + // Walk channels + if v.VisitChannel != nil { + for name, ref := range d.Channels { + if ref != nil && ref.Value != nil { + if !v.VisitChannel(name, ref.Value) { + return + } + } + } + } + + // Walk operations + if v.VisitOperation != nil { + for name, ref := range d.Operations { + if ref != nil && ref.Value != nil { + if !v.VisitOperation(name, ref.Value) { + return + } + } + } + } + + // Walk messages (from channels) + if v.VisitMessage != nil { + seen := make(map[string]bool) + for _, chRef := range d.Channels { + if chRef == nil || chRef.Value == nil { + continue + } + for name, msgRef := range chRef.Value.Messages { + if msgRef != nil && msgRef.Value != nil && !seen[name] { + seen[name] = true + if !v.VisitMessage(name, msgRef.Value) { + return + } + } + } + } + // Also from components + if d.Components != nil { + for name, msgRef := range d.Components.Messages { + if msgRef != nil && msgRef.Value != nil && !seen[name] { + seen[name] = true + if !v.VisitMessage(name, msgRef.Value) { + return + } + } + } + } + } + + // Walk schemas + if v.VisitSchema != nil && d.Components != nil { + for name, ref := range d.Components.Schemas { + if ref != nil && ref.Value != nil { + if !v.VisitSchema(name, ref.Value) { + return + } + } + } + } + + // Walk security schemes + if v.VisitSecurityScheme != nil && d.Components != nil { + for name, ref := range d.Components.SecuritySchemes { + if ref != nil && ref.Value != nil { + if !v.VisitSecurityScheme(name, ref.Value) { + return + } + } + } + } +} + +// AllServers returns all servers as a slice. +func (d *Document) AllServers() []*Server { + var servers []*Server + for _, ref := range d.Servers { + if ref != nil && ref.Value != nil { + servers = append(servers, ref.Value) + } + } + return servers +} + +// AllChannels returns all channels as a slice. +func (d *Document) AllChannels() []*Channel { + var channels []*Channel + for _, ref := range d.Channels { + if ref != nil && ref.Value != nil { + channels = append(channels, ref.Value) + } + } + return channels +} + +// AllOperations returns all operations as a slice. +func (d *Document) AllOperations() []*Operation { + var ops []*Operation + for _, ref := range d.Operations { + if ref != nil && ref.Value != nil { + ops = append(ops, ref.Value) + } + } + return ops +} + +// AllMessages returns all unique messages from channels and components. +func (d *Document) AllMessages() []*Message { + seen := make(map[*Message]bool) + var messages []*Message + + for _, chRef := range d.Channels { + if chRef == nil || chRef.Value == nil { + continue + } + for _, msgRef := range chRef.Value.Messages { + if msgRef != nil && msgRef.Value != nil && !seen[msgRef.Value] { + seen[msgRef.Value] = true + messages = append(messages, msgRef.Value) + } + } + } + + if d.Components != nil { + for _, msgRef := range d.Components.Messages { + if msgRef != nil && msgRef.Value != nil && !seen[msgRef.Value] { + seen[msgRef.Value] = true + messages = append(messages, msgRef.Value) + } + } + } + + return messages +} + +// AllSchemas returns all schemas from components. +func (d *Document) AllSchemas() map[string]*Schema { + schemas := make(map[string]*Schema) + if d.Components != nil { + for name, ref := range d.Components.Schemas { + if ref != nil && ref.Value != nil { + schemas[name] = ref.Value + } + } + } + return schemas +} + +// OperationsByAction returns operations grouped by action (send/receive). +func (d *Document) OperationsByAction() (send, receive []*Operation) { + for _, ref := range d.Operations { + if ref == nil || ref.Value == nil { + continue + } + switch ref.Value.Action { + case ActionSend: + send = append(send, ref.Value) + case ActionReceive: + receive = append(receive, ref.Value) + } + } + return +} + +// ChannelOperations returns all operations for a given channel. +func (d *Document) ChannelOperations(channelName string) []*Operation { + var ops []*Operation + for _, ref := range d.Operations { + if ref == nil || ref.Value == nil || ref.Value.Channel == nil { + continue + } + // Check if this operation references the channel + if ref.Value.Channel.Ref == "#/channels/"+channelName { + ops = append(ops, ref.Value) + } else if ref.Value.Channel.Value != nil { + // Check by resolved value (address comparison) + if ch, ok := d.Channels[channelName]; ok && ch != nil && ch.Value == ref.Value.Channel.Value { + ops = append(ops, ref.Value) + } + } + } + return ops +} + +// MessagePayloadSchema returns the payload schema for a message, if any. +func (m *Message) PayloadSchema() *Schema { + if m.Payload != nil && m.Payload.Value != nil { + return m.Payload.Value + } + return nil +} + +// MessageHeaderSchema returns the headers schema for a message, if any. +func (m *Message) HeaderSchema() *Schema { + if m.Headers != nil && m.Headers.Value != nil { + return m.Headers.Value + } + return nil +} + +// SchemaProperties returns all properties of an object schema. +func (s *Schema) SchemaProperties() map[string]*Schema { + props := make(map[string]*Schema) + for name, ref := range s.Properties { + if ref != nil && ref.Value != nil { + props[name] = ref.Value + } + } + return props +} + +// IsObject returns true if this schema represents an object type. +func (s *Schema) IsObject() bool { + return s.Type.Is("object") || len(s.Properties) > 0 +} + +// IsArray returns true if this schema represents an array type. +func (s *Schema) IsArray() bool { + return s.Type.Is("array") +} + +// IsString returns true if this schema represents a string type. +func (s *Schema) IsString() bool { + return s.Type.Is("string") +} + +// IsNumber returns true if this schema represents a number type. +func (s *Schema) IsNumber() bool { + return s.Type.Is("number") || s.Type.Is("integer") +} + +// IsBoolean returns true if this schema represents a boolean type. +func (s *Schema) IsBoolean() bool { + return s.Type.Is("boolean") +} + +// ItemSchema returns the schema for array items, if this is an array schema. +func (s *Schema) ItemSchema() *Schema { + if s.Items != nil && s.Items.Value != nil { + return s.Items.Value + } + return nil +} From 6866a4746d83d538c9cab810c08b09e29d9c6f54 Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Thu, 3 Sep 2026 22:25:45 +0300 Subject: [PATCH 2/4] Remove unused Server forwarders flagged by golangci-lint The GRASP refactor left 12 thin Server forwarders that nothing references (production or tests). golangci-lint 'unused' flagged them; delete them. golangci-lint run: 0 issues. Unit/race/integration suites green, scenario coverage 100%. --- internal/server/async_message.go | 12 ------------ internal/server/event_server.go | 6 ------ internal/server/server_example.go | 22 ---------------------- internal/server/server_state.go | 8 -------- 4 files changed, 48 deletions(-) diff --git a/internal/server/async_message.go b/internal/server/async_message.go index b5b38bd..1e1f38c 100644 --- a/internal/server/async_message.go +++ b/internal/server/async_message.go @@ -35,26 +35,14 @@ func (s *Server) renderAsyncMessage(mapping *RouteMapping, in InboundMessage) (i return s.engine.renderAsyncMessage(mapping, in) } -func (s *Server) newAsyncEvaluator(mapping *RouteMapping, in InboundMessage) runtime.Evaluator { - return s.engine.newAsyncEvaluator(mapping, in) -} - func (s *Server) renderMessageSpecs(messages []*loader.MessageSpec, prefix, opID string, in InboundMessage) (int, []byte, error) { return s.engine.RenderMessageSpecs(messages, prefix, opID, in) } -func (s *Server) asyncRequestSource(in InboundMessage) *runtime.RequestSource { - return s.engine.asyncRequestSource(in) -} - func (s *Server) selectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*MessageExampleView, string) { return s.engine.SelectAsyncExample(message, evaluator, opID) } -func (s *Server) renderAsyncPayload(example *MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) { - return s.engine.RenderAsyncPayload(example, evaluator) -} - func (s *Server) recordAsyncExchange(in InboundMessage, address string, status int, responseBody []byte) { s.engine.recordAsyncExchange(in, address, status, responseBody) } diff --git a/internal/server/event_server.go b/internal/server/event_server.go index 4c93c51..dc56b64 100644 --- a/internal/server/event_server.go +++ b/internal/server/event_server.go @@ -82,12 +82,6 @@ func (b *eventBus) deliver(sub channelSubscription, payload map[string]any) { b.bus.WSBroadcast(sub.address, body) } -// 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). -func (s *Server) signalRPush(address string, payload []byte) { - s.hubMgr.SignalRPush(address, payload) -} - // hubForAddress finds the SignalR hub owning a channel address. func (s *Server) hubForAddress(address string) *signalRHub { return s.hubMgr.hubForAddress(address) diff --git a/internal/server/server_example.go b/internal/server/server_example.go index 37da2a8..bfba870 100644 --- a/internal/server/server_example.go +++ b/internal/server/server_example.go @@ -11,8 +11,6 @@ import ( // methods below are thin forwarders preserved for the HTTP pipeline and the // test surface. -func (s *Server) startTTLSweep() { s.registry.startSweep() } - func (s *Server) sweepExpiredExamples() { s.registry.sweepExpired() } func (s *Server) selectDynamicExample(mapping *RouteMapping, eval runtime.Evaluator) (*dynamicExample, string) { @@ -45,26 +43,6 @@ func (s *Server) markOnceUsed(id string) { s.registry.markOnceUsed(id) } // isOnceUsed checks if an example has been used. func (s *Server) isOnceUsed(id string) bool { return s.registry.isOnceUsed(id) } -func (s *Server) shouldSkipExample(ex *openapi3.Example, exampleKey, opID string) bool { - return s.engine.shouldSkipExample(ex, exampleKey, opID) -} - -func (s *Server) categorizeExamples(examples openapi3.Examples, keys []string, eval runtime.Evaluator, opID string) (withParamsMatch, withoutParamsMatch map[string]*openapi3.Example) { - return s.engine.categorizeExamples(examples, keys, eval, opID) -} - -func (s *Server) evaluateExample(example *openapi3.Example, eval runtime.Evaluator) ([]byte, error) { - return s.engine.evaluateExample(example, eval) -} - -func (s *Server) evaluateHeaders(example *openapi3.Example, eval runtime.Evaluator) map[string]string { - return s.engine.evaluateHeaders(example, eval) -} - -func (s *Server) resolveHeaderValue(val any, eval runtime.Evaluator) (string, bool) { - return s.engine.resolveHeaderValue(val, eval) -} - func getStatusCode(mapping *loader.RouteMapping, response *openapi3.Response) int { // TODO: parse status code from mapping (key in Responses map) // For now, default to 200 diff --git a/internal/server/server_state.go b/internal/server/server_state.go index 5b4a7e1..77abe79 100644 --- a/internal/server/server_state.go +++ b/internal/server/server_state.go @@ -5,10 +5,6 @@ import "github.com/mamonth/oasmock/internal/runtime" // State mutation (x-mock-set-state) lives in exampleEngine; these forwarders // keep the HTTP pipeline and tests working through Server. -func (s *Server) handleDeleteState(prefix, resolvedKey string) { - s.engine.handleDeleteState(prefix, resolvedKey) -} - func (s *Server) handleIncrementState(prefix, resolvedKey string, incVal any, eval runtime.Evaluator) error { return s.engine.handleIncrementState(prefix, resolvedKey, incVal, eval) } @@ -21,10 +17,6 @@ func (s *Server) handleMapState(prefix, resolvedKey string, m map[string]any, ev return s.engine.handleMapState(prefix, resolvedKey, m, eval) } -func (s *Server) handleSimpleState(prefix, resolvedKey string, val any, eval runtime.Evaluator) error { - return s.engine.handleSimpleState(prefix, resolvedKey, val, eval) -} - func (s *Server) applySetState(stateMap map[string]any, eval runtime.Evaluator, prefix string) { s.engine.ApplySetState(stateMap, eval, prefix) } From 6188e4234f4fcd46bd4808a3f330b6167515dbd9 Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Thu, 3 Sep 2026 22:41:26 +0300 Subject: [PATCH 3/4] Fix flaky CLI integration tests and quiet route-registration debug log The CLI startup tests captured output with a single read issued 100ms after process start, racing slower startup in CI (only the first flushed log line was observed, e.g. registerMockRoutes). Add a captureUntil helper that polls a command's output pipe until every expected substring appears, and use it in the 15 affected tests so assertions are deterministic. Also demote the unconditional "registerMockRoutes called" INFO record to Debug to match the per-route "Registered route" debug logs. Verified: golangci-lint 0 issues, unit/race/integration suites green, CLI suite stable across repeated runs, scenario coverage 100%. --- internal/server/server.go | 2 +- test/cli/cli_integration_test.go | 367 ++++++++----------------------- 2 files changed, 98 insertions(+), 271 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 3b174c2..22ff1e6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -309,7 +309,7 @@ func (s *Server) setupRouter() { } func (s *Server) registerMockRoutes(r chi.Router) { - slog.Info("registerMockRoutes called", "verbose", s.config.Verbose, "numMappings", len(s.mappings)) + slog.Debug("registerMockRoutes called", "verbose", s.config.Verbose, "numMappings", len(s.mappings)) rpcChiPatterns := make(map[string]bool) for _, m := range s.rpcMappings { diff --git a/test/cli/cli_integration_test.go b/test/cli/cli_integration_test.go index b1eb1fa..4b338cc 100644 --- a/test/cli/cli_integration_test.go +++ b/test/cli/cli_integration_test.go @@ -2,11 +2,13 @@ package cli_test import ( "fmt" + "io" "net/http" "os" "os/exec" "path/filepath" "strings" + "sync" "syscall" "testing" "time" @@ -21,6 +23,61 @@ func binaryPath(t *testing.T) string { return binhelper.GetBuilded(t) } +// captureUntil reads from a command's output pipe until every want substring +// has been observed or the timeout elapses, returning all output captured. It +// replaces the former single-timed-read capture that raced with slower process +// startup in CI (only the first flushed log line was observed). +func captureUntil(t *testing.T, pipe io.ReadCloser, timeout time.Duration, want ...string) string { + t.Helper() + var ( + mu sync.Mutex + sb strings.Builder + ) + go func() { + buf := make([]byte, 1024) + for { + n, err := pipe.Read(buf) + if n > 0 { + mu.Lock() + sb.Write(buf[:n]) + mu.Unlock() + } + if err != nil { + return + } + } + }() + deadline := time.After(timeout) + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + mu.Lock() + cur := sb.String() + mu.Unlock() + if containsAll(cur, want) { + return cur + } + case <-deadline: + mu.Lock() + cur := sb.String() + mu.Unlock() + _ = pipe.Close() // unblock the reader goroutine + return cur + } + } +} + +func containsAll(s string, subs []string) bool { + for _, sub := range subs { + if !strings.Contains(s, sub) { + return false + } + } + return true +} + /* Scenario: CLI version flag displays version Given the oasmock binary @@ -85,24 +142,9 @@ func TestCLIEnvVarOverride(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") // Read output with a short timeout - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "env var override not reflected in output: %s", output) - assert.Contains(t, output, "port=9999", "env var override not reflected in output: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started", "port=9999") + assert.Contains(t, output, "Mock server started", "env var override not reflected in output: %s", output) + assert.Contains(t, output, "port=9999", "env var override not reflected in output: %s", output) // Kill the process _ = cmd.Process.Kill() _ = cmd.Wait() @@ -134,24 +176,8 @@ func TestCLIEnvVarVerbose(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Registered route", "env var override not reflected in output: %s", output) - // verbose flag may not be logged; debug logs indicate verbose enabled - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Registered route") + assert.Contains(t, output, "Registered route", "env var override not reflected in output: %s", output) _ = cmd.Process.Kill() _ = cmd.Wait() } @@ -182,24 +208,9 @@ func TestCLIEnvVarNoCORS(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "env var override not reflected in output: %s", output) - // cors flag may not be logged; we'll verify via request header check - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started") + assert.Contains(t, output, "Mock server started", "env var override not reflected in output: %s", output) + // cors flag may not be logged; we'll verify via request header check // Wait for server to be ready time.Sleep(200 * time.Millisecond) // Make a request and verify no CORS headers @@ -235,23 +246,8 @@ func TestCLISuccessfulExitCode(t *testing.T) { require.NoError(t, cmd.Start(), "failed to start mock command") // Wait for server to start and verify output - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "server did not start: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started") + assert.Contains(t, output, "Mock server started", "server did not start: %s", output) // Send SIGTERM require.NoError(t, cmd.Process.Signal(syscall.SIGTERM), "failed to send SIGTERM") // Wait for process to exit @@ -310,25 +306,10 @@ func TestCLINoArguments(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - // Read output with a short timeout - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) - assert.Contains(t, output, "port=19191", "default port not used: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + // Read output until the server starts (polling, not a single timed read) + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started", "port=19191") + assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) + assert.Contains(t, output, "port=19191", "default port not used: %s", output) // Kill the process _ = cmd.Process.Kill() _ = cmd.Wait() @@ -378,24 +359,9 @@ func TestCLICustomPort(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) - assert.Contains(t, output, fmt.Sprintf("port=%d", port), "custom port not used: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started", fmt.Sprintf("port=%d", port)) + assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) + assert.Contains(t, output, fmt.Sprintf("port=%d", port), "custom port not used: %s", output) _ = cmd.Process.Kill() _ = cmd.Wait() } @@ -425,24 +391,9 @@ func TestCLIMultipleSchemas(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) - assert.Contains(t, output, fmt.Sprintf("port=%d", port), "port not shown: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started", fmt.Sprintf("port=%d", port)) + assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) + assert.Contains(t, output, fmt.Sprintf("port=%d", port), "port not shown: %s", output) // Verify both prefixes work (make HTTP requests) // Wait a bit for server to be ready time.Sleep(200 * time.Millisecond) @@ -484,24 +435,9 @@ func TestCLIDelay(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) - // delay flag may not be logged; server starting is sufficient - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started") + assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) + // delay flag may not be logged; server starting is sufficient // Verify delay by making a request and measuring response time // This is tricky; we could skip for now _ = cmd.Process.Kill() @@ -530,24 +466,8 @@ func TestCLIVerbose(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Registered route", "mock command not executed: %s", output) - // verbose flag may not be logged; debug logs indicate verbose enabled - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Registered route") + assert.Contains(t, output, "Registered route", "mock command not executed: %s", output) _ = cmd.Process.Kill() _ = cmd.Wait() } @@ -574,24 +494,9 @@ func TestCLINoCORS(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) - // cors flag may not be logged; we'll verify via request header check - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started") + assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) + // cors flag may not be logged; we'll verify via request header check // Wait for server to be ready time.Sleep(200 * time.Millisecond) // Make a request and verify no CORS headers @@ -644,23 +549,8 @@ func TestCLIDefaultSchema(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started") + assert.Contains(t, output, "Mock server started", "mock command not executed: %s", output) // Wait for server to be ready time.Sleep(200 * time.Millisecond) // Make a request to verify server works with default schema @@ -786,26 +676,8 @@ verbose true` require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - // Should still start on default port (19191) - assert.Contains(t, output, "port=19191", "default port not used when config file malformed: %s", output) - // Should contain warning about config file (but warning may be at DEBUG level) - // We'll just ensure server starts - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "port=19191") + assert.Contains(t, output, "port=19191", "default port not used when config file malformed: %s", output) _ = cmd.Process.Kill() _ = cmd.Wait() } @@ -847,23 +719,8 @@ func TestCLIConfigFileMissing(t *testing.T) { require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "port=19191", "default port not used when config file missing: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "port=19191") + assert.Contains(t, output, "port=19191", "default port not used when config file missing: %s", output) _ = cmd.Process.Kill() _ = cmd.Wait() } @@ -907,24 +764,9 @@ schemas: require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "port=9090", "CLI flag did not override config file: %s", output) - assert.NotContains(t, output, "port=8080", "Config file value incorrectly used: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "port=9090") + assert.Contains(t, output, "port=9090", "CLI flag did not override config file: %s", output) + assert.NotContains(t, output, "port=8080", "Config file value incorrectly used: %s", output) _ = cmd.Process.Kill() _ = cmd.Wait() } @@ -969,24 +811,9 @@ schemas: require.NoError(t, err, "failed to get stderr pipe") require.NoError(t, cmd.Start(), "failed to start mock command") - outputChan := make(chan string) - go func() { - var lines []string - buf := make([]byte, 1024) - time.Sleep(100 * time.Millisecond) - n, _ := stderrPipe.Read(buf) - if n > 0 { - lines = append(lines, string(buf[:n])) - } - outputChan <- strings.Join(lines, "") - }() - select { - case output := <-outputChan: - assert.Contains(t, output, "port=7070", "environment variable did not override config file: %s", output) - assert.NotContains(t, output, "port=8080", "Config file value incorrectly used: %s", output) - case <-time.After(2 * time.Second): - require.Fail(t, "timeout waiting for mock output") - } + output := captureUntil(t, stderrPipe, 6*time.Second, "port=7070") + assert.Contains(t, output, "port=7070", "environment variable did not override config file: %s", output) + assert.NotContains(t, output, "port=8080", "Config file value incorrectly used: %s", output) _ = cmd.Process.Kill() _ = cmd.Wait() } From db9c79d5553290bd381655a64467af0b88010e14 Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Fri, 4 Sep 2026 09:21:51 +0300 Subject: [PATCH 4/4] Fix parallel integration flake: OS-assigned ports + bind-before-log The subprocess-based integration tests reserved ports via a free-port TOCTOU (findFreePort: bind :0, read port, close, later bind by the subprocess). Under parallel test load two subprocesses could be handed the same port, so a server bound it, printed "Mock server started" (previously logged before the bind) and exited on collision, while a collided test still dialed the other live server on that port and hit "connection reset by peer" mid-request. - server: split Start() into Listen()/Serve()/BoundPort(); --port 0 now binds an OS-assigned port and the actual port is knowable before serving. - CLI: bind before logging "Mock server started", log the real bound port, report "port already in use" synchronously with exit code 4 (RS.CLI.17), and accept --port 0 (RS.CLI.32) via validatePort. - clihelper: auto-discovery uses --port 0 and reads the actual port back from the startup log; quiet the benign teardown scanner error. - tests/specs: add RS.CLI.32 (ephemeral port) + TestCLIEphemeralPort and TestValidatePort; docs/cli.md notes --port 0. Verified: golangci-lint 0 issues, unit+race green, full integration suites green, and the previously-flaky packages (cli/management-api/server-core/ jsonrpc/extensions) pass repeated concurrent cross-package stress runs; scenario coverage 100% (251/251). --- cmd/oasmock/mock.go | 33 +++++++++-- cmd/oasmock/mock_test.go | 39 +++++++++++- docs/cli.md | 2 +- internal/server/server.go | 45 +++++++++++++- openspec/specs/cli/spec.md | 4 ++ test/_shared/clihelper/clihelper.go | 92 +++++++++++++++++++++++++---- test/cli/cli_integration_test.go | 39 ++++++++++++ 7 files changed, 233 insertions(+), 21 deletions(-) diff --git a/cmd/oasmock/mock.go b/cmd/oasmock/mock.go index e800120..ec93fb3 100644 --- a/cmd/oasmock/mock.go +++ b/cmd/oasmock/mock.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -55,6 +56,15 @@ func portError(format string, args ...any) error { } } +// validatePort validates the --port flag value. Port 0 selects an OS-assigned +// (ephemeral) port and is always valid (RS.CLI.32). +func validatePort(port int) error { + if port != 0 && (port < minPort || port > maxPort) { + return validationError("port must be between 1 and 65535") + } + return nil +} + func parseSchemaConfig(cmd *cobra.Command) error { // If --from flag was provided, ignore YAML schema configuration if cmd != nil && cmd.Flags().Changed("from") { @@ -182,8 +192,9 @@ func runMock(cmd *cobra.Command, args []string) error { if len(config.sources) != len(config.prefixes) && len(config.prefixes) != 0 { return validationError("number of --prefix flags must match number of --from flags, or no --prefix flags provided") } - if port <= 0 || port > maxPort { - return validationError("port must be between 1 and 65535") + // port 0 selects an OS-assigned (ephemeral) port (RS.CLI.32). + if err := validatePort(port); err != nil { + return err } if delay < 0 { return validationError("delay cannot be negative") @@ -214,17 +225,29 @@ func runMock(cmd *cobra.Command, args []string) error { return schemaError("failed to create server: %v", err) } - // Start server in a goroutine so we can handle signals + // Bind the port up front so the "started" log is only emitted after a + // successful bind and carries the actual bound port (--port 0 selects an + // OS-assigned port, RS.CLI.11/RS.CLI.32). A collision is reported + // synchronously with exit code 4 (RS.CLI.17). + ln, boundPort, err := srv.Listen() + if err != nil { + if errors.Is(err, syscall.EADDRINUSE) { + return portError("port %d is already in use", port) + } + return portError("failed to listen on port %d: %v", port, err) + } + + // Serve in a goroutine so we can handle signals serverErrChan := make(chan error, 1) go func() { - if err := srv.Start(); err != nil && err != http.ErrServerClosed { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { slog.Error("Server error", "err", err) serverErrChan <- err } }() // Wait for interrupt signal - slog.Info("Mock server started", "port", port) + slog.Info("Mock server started", "port", boundPort) slog.Info("Press Ctrl+C to stop") // Set up signal handling for graceful shutdown diff --git a/cmd/oasmock/mock_test.go b/cmd/oasmock/mock_test.go index d54d3ec..fe22e28 100644 --- a/cmd/oasmock/mock_test.go +++ b/cmd/oasmock/mock_test.go @@ -67,6 +67,43 @@ func TestPortError(t *testing.T) { assert.Equal(t, 4, cliErr.code, "cliError.code mismatch") } +/* +Scenario: Validating the port flag value +Given a port number +When validatePort is called +Then out-of-range values (below 1 or above 65535) are rejected and port 0 (ephemeral) is accepted + +Related spec scenarios: RS.CLI.15, RS.CLI.32 +*/ +func TestValidatePort(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + port int + wantErr bool + }{ + {name: "default port", port: 19191}, + {name: "custom port", port: 8080}, + {name: "ephemeral port", port: 0}, + {name: "lower bound", port: 1}, + {name: "upper bound", port: 65535}, + {name: "below range", port: -1, wantErr: true}, + {name: "above range", port: 65536, wantErr: true}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validatePort(tt.port) + if tt.wantErr { + assert.Error(t, err, "expected validation error for port %d", tt.port) + } else { + assert.NoError(t, err, "unexpected validation error for port %d", tt.port) + } + }) + } +} + /* Scenario: CLI error type behavior Given a cliError instance with code and message @@ -117,7 +154,7 @@ func TestRunMockValidationErrors(t *testing.T) { setup: func() { config = mockConfig{} viper.Reset() - viper.Set("port", 0) + viper.Set("port", -1) }, checkError: func(t *testing.T, err error) { require.Error(t, err, "expected validation error for invalid port") diff --git a/docs/cli.md b/docs/cli.md index ab868a9..0fd58c6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,7 +16,7 @@ oasmock [options] |--------------------|-----------|--------------------|--------------------------------------------------------------------------| | `--from` | string | `src/openapi.yaml` | Source OpenAPI or AsyncAPI schema (autodetected by root key). Can be specified multiple times. | | `--prefix` | string | `''` | URI prefix for the schema. Can be specified for each `--from` parameter. | -| `--port` | number | `19191` | Port to listen on. | +| `--port` | number | `19191` | Port to listen on. Use `0` to bind an OS-assigned (ephemeral) port; the actual bound port is logged under `port=` after startup. | | `--delay` | number | `100` | Delay between request and response in milliseconds. | | `--verbose` | boolean | `false` | Enable verbose logging. | | `--nocors` | boolean | `false` | Disable automatic CORS compliance. | diff --git a/internal/server/server.go b/internal/server/server.go index 22ff1e6..6d584bc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -664,17 +664,56 @@ func (s *Server) extractPathParams(r *http.Request, mapping *RouteMapping) map[s return params } -// Start starts the HTTP server. +// Start starts the HTTP server, returning once the server is serving. func (s *Server) Start() error { + ln, _, err := s.Listen() + if err != nil { + return err + } + return s.Serve(ln) +} + +// Listen binds the configured port (config.Port 0 picks an OS-assigned port) +// and returns the listener along with the actual bound port. The server is +// not serving until Serve is called. Binding before serving lets the CLI +// report a truthful "started" log and detect port collisions synchronously. +func (s *Server) Listen() (net.Listener, int, error) { addr := fmt.Sprintf(":%d", s.config.Port) - slog.Info("Starting mock server", "address", addr) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, 0, err + } + bound := s.config.Port + if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok { + bound = tcpAddr.Port + } s.httpMu.Lock() s.httpServer = &http.Server{ Addr: addr, Handler: s.router, } s.httpMu.Unlock() - return s.httpServer.ListenAndServe() + return ln, bound, nil +} + +// Serve serves requests on an already-bound listener until Shutdown. +func (s *Server) Serve(ln net.Listener) error { + return s.httpServer.Serve(ln) +} + +// BoundPort returns the port the server is listening on (config.Port, or the +// OS-assigned port when config.Port was 0). It is meaningful after Listen. +func (s *Server) BoundPort() int { + s.httpMu.Lock() + defer s.httpMu.Unlock() + if s.httpServer != nil && s.httpServer.Addr != "" { + if _, portStr, err := net.SplitHostPort(s.httpServer.Addr); err == nil { + if p, err := strconv.Atoi(portStr); err == nil && p > 0 { + return p + } + } + } + return s.config.Port } // Shutdown gracefully shuts down the server. It is idempotent: subsequent diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 9c36826..814a2f9 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -61,6 +61,10 @@ The mock command SHALL start a mock server based on OpenAPI and/or AsyncAPI sche ``` - **THEN** the CLI loads both, auto-detecting each specification type +#### Scenario RS.CLI.32: Binding an ephemeral port +- **WHEN** user runs `oasmock --port 0` +- **THEN** the server binds an OS-assigned port and logs the actual bound port under `port=` + ### Requirement: Environment variable overrides The CLI SHALL support configuration sources with the following precedence: command-line arguments > environment variables > configuration file > defaults. Environment variables SHALL override configuration file values but be overridden by command-line arguments. diff --git a/test/_shared/clihelper/clihelper.go b/test/_shared/clihelper/clihelper.go index 9ca95b0..644c739 100644 --- a/test/_shared/clihelper/clihelper.go +++ b/test/_shared/clihelper/clihelper.go @@ -2,10 +2,16 @@ package clihelper import ( "bufio" + "errors" "fmt" + "io" "net" "os" "os/exec" + "regexp" + "strconv" + "strings" + "sync" "syscall" "testing" "time" @@ -113,16 +119,11 @@ func (b *ServerBuilder) SetEnv(env []string) *ServerBuilder { } // Run starts the configured oasmock server and returns the command, -// error channel, and actual port used (useful when port=0). +// error channel, and actual port used (useful when port=0, where the OS +// assigns the port and it is read back from the startup log). func (b *ServerBuilder) Run() (*exec.Cmd, <-chan error, int) { binaryPath := getBinaryPath(b.t) - // Determine actual port - actualPort := b.port - if actualPort <= 0 { - actualPort = findFreePort(b.t) - } - // Build arguments args := []string{"mock"} for i, schema := range b.schemas { @@ -131,7 +132,15 @@ func (b *ServerBuilder) Run() (*exec.Cmd, <-chan error, int) { args = append(args, "--prefix", b.prefixes[i]) } } - args = append(args, "--port", fmt.Sprintf("%d", actualPort)) + // When no port is pinned, let the OS assign one (--port 0) and read the + // actual bound port back from the startup log. This removes the race + // between picking a "free" port and binding it (findFreePort TOCTOU) that + // made parallel subprocess tests collide on the same port. + if b.port <= 0 { + args = append(args, "--port", "0") + } else { + args = append(args, "--port", fmt.Sprintf("%d", b.port)) + } if b.verbose { args = append(args, "--verbose") } @@ -174,12 +183,16 @@ func (b *ServerBuilder) Run() (*exec.Cmd, <-chan error, int) { close(errCh) }() - // Read stderr output for debugging + // Drain stderr: forward each line to t.Logf and accumulate it so the + // bound port can be parsed back. + captured := newCapturedOutput() go func() { // Capture t early to avoid race with test completion t := b.t scanner := bufio.NewScanner(stderr) for scanner.Scan() { + line := scanner.Text() + captured.append(line + "\n") // Use recover to avoid panic if test has already completed func() { defer func() { @@ -187,20 +200,77 @@ func (b *ServerBuilder) Run() (*exec.Cmd, <-chan error, int) { // Test likely completed, ignore logging } }() - t.Logf("oasmock stderr: %s", scanner.Text()) + t.Logf("oasmock stderr: %s", line) }() } if err := scanner.Err(); err != nil { func() { defer func() { recover() }() - t.Logf("stderr scanner error: %v", err) + // A closed pipe during teardown is expected; other scanner + // errors are worth surfacing. + if !errors.Is(err, os.ErrClosed) && !errors.Is(err, io.ErrClosedPipe) && !errors.Is(err, net.ErrClosed) { + t.Logf("stderr scanner error: %v", err) + } }() } }() + actualPort := b.port + if b.port <= 0 { + actualPort = waitForBoundPort(b.t, captured, errCh, 5*time.Second) + } + return cmd, errCh, actualPort } +// boundPortPattern matches the mock command's startup log line +// (`msg="Mock server started" port=N`), which is emitted only after the +// listener has bound successfully. +var boundPortPattern = regexp.MustCompile(`msg="Mock server started"[^\n]*port=(\d+)`) + +// waitForBoundPort blocks until the server's startup log reports the bound +// port, or the process exits / a timeout elapses. +func waitForBoundPort(t *testing.T, captured *capturedOutput, errCh <-chan error, timeout time.Duration) int { + t.Helper() + deadline := time.After(timeout) + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if m := boundPortPattern.FindStringSubmatch(captured.string()); m != nil { + if p, err := strconv.Atoi(m[1]); err == nil && p > 0 { + return p + } + } + case err := <-errCh: + t.Fatalf("oasmock exited before reporting its bound port (err=%v); log:\n%s", err, captured.string()) + case <-deadline: + t.Fatalf("timed out waiting for oasmock to report its bound port; log:\n%s", captured.string()) + } + } +} + +// capturedOutput accumulates subprocess stderr under a mutex. +type capturedOutput struct { + mu sync.Mutex + sb strings.Builder +} + +func newCapturedOutput() *capturedOutput { return &capturedOutput{} } + +func (c *capturedOutput) append(s string) { + c.mu.Lock() + defer c.mu.Unlock() + c.sb.WriteString(s) +} + +func (c *capturedOutput) string() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.sb.String() +} + // StartServer starts the oasmock CLI server as a subprocess. // Returns the command, an error channel that will receive the exit error, and the actual port used. // Deprecated: Use Cmd(t).SetSchema(schemaFile, "").SetPort(port).AddArg(extraArgs...).Run() instead. diff --git a/test/cli/cli_integration_test.go b/test/cli/cli_integration_test.go index 4b338cc..11d969d 100644 --- a/test/cli/cli_integration_test.go +++ b/test/cli/cli_integration_test.go @@ -7,6 +7,8 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" "sync" "syscall" @@ -366,6 +368,43 @@ func TestCLICustomPort(t *testing.T) { _ = cmd.Wait() } +/* +Scenario: Binding an ephemeral port +Given the oasmock binary and a test schema +When invoked with --port 0 +Then the server binds an OS-assigned port and logs the actual bound port + +Related spec scenarios: RS.CLI.32 +*/ +func TestCLIEphemeralPort(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + t.Parallel() + + cmd := exec.Command(binaryPath(t), "mock", "--from", "../../test/_shared/resources/test.yaml", "--port", "0") + stderrPipe, err := cmd.StderrPipe() + require.NoError(t, err, "failed to get stderr pipe") + require.NoError(t, cmd.Start(), "failed to start mock command") + + output := captureUntil(t, stderrPipe, 6*time.Second, "Mock server started") + assert.Contains(t, output, "Mock server started", "ephemeral-port server did not start: %s", output) + + m := regexp.MustCompile(`port=(\d+)`).FindStringSubmatch(output) + require.NotEmpty(t, m, "actual bound port not logged: %s", output) + port, err := strconv.Atoi(m[1]) + require.NoError(t, err, "failed to parse bound port") + require.NotZero(t, port, "bound port should be OS-assigned (non-zero)") + + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/users", port)) + require.NoError(t, err, "failed to reach server on bound port %d", port) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, 200, resp.StatusCode, "expected 200 from /users on bound port %d", port) + + _ = cmd.Process.Kill() + _ = cmd.Wait() +} + /* Scenario: Starting mock server with multiple schemas Given the oasmock binary and two test schemas