From de75693867133c57d383ec9f20ad220c2340288c Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Sat, 5 Sep 2026 19:57:50 +0300 Subject: [PATCH 1/2] Fix review findings: chi path params, JSON-RPC conformance, SignalR/shutdown robustness Correctness: - chi v5.2.5 requires {param} braces; OpenAPIPatternToChi no longer rewrites to :id, so OpenAPI/AsyncAPI parameterized routes actually match through the router (was silently broken for every {id} route). Add end-to-end tests. - JSON-RPC 2.0 conformance: ParseBody returns ordered RpcEntry[] with correct -32600 Invalid Request (was all -32700), malformed batch elements no longer abort siblings, empty batch is invalid, all-notification batches answer 204, and procedure path params are extracted against the procedure's own pattern. - SignalR delivery: Candidates dedupes per connection (was NxN streams), the negotiate response advertises Text-only matching the handshake, handshake errors are JSON-escaped, and dead removeConnectionStreams is removed. - Manage-stream sockets get a read deadline (idle reaping); Shutdown cancels pending delayed emissions; state.GetNamespace/GetAll deep-copy nested values. - /_mock/examples validate (default true) now validates the response body against the route's OpenAPI schema instead of being a silent no-op. Design: - Collapse loader/server mirror types to type aliases (RouteMapping, SchemaInfo, RequestRecord, ResponseRecord); delete convert.go and the copy layers. - Single-source constants: protocol names -> asyncapi.Protocol*, expression source names -> runtime.Source*. - Consolidate "push to channel" onto the ConsumerBus; central writeJSON helper; pushPayload dead code removed. - docs/architecture.md and api/openapi.yaml reconciled with implementation; derivedExamples fails loud on a mis-typed match and deep-copies extensions. Specs: add RS.ASP.11, RS.AMG.29/30, RS.SHR.22, RS.JRP.33/34, RS.MAPI.34 and align RS.SHR.8/RS.JRP.22; 300/300 scenario coverage retained. --- .github/workflows/ci.yml | 6 +- .golangci.yml | 41 ++ Makefile | 4 + api/openapi.yaml | 118 +++- cmd/oasmock/mock.go | 139 +++-- docs/architecture.md | 52 +- internal/extensions/extract.go | 70 +-- internal/loader/async_router_test.go | 48 ++ internal/loader/router.go | 41 +- internal/loader/router_test.go | 99 +--- internal/loader/rpc.go | 8 +- internal/loader/rpc_test.go | 14 +- internal/runtime/expression.go | 73 ++- .../server/add_example_validation_test.go | 77 +++ internal/server/async_http_adapter_test.go | 25 +- internal/server/async_state_test.go | 4 +- internal/server/builtin_triggers.go | 3 +- internal/server/control_api_spec_sync_test.go | 74 +++ internal/server/convert.go | 26 - internal/server/engine.go | 514 +---------------- internal/server/engine_async.go | 206 +++++++ internal/server/engine_expr.go | 140 +++++ internal/server/engine_state.go | 141 +++++ internal/server/event_broker.go | 18 +- internal/server/event_delay_test.go | 31 + internal/server/event_deliver.go | 185 ++++++ internal/server/event_server.go | 299 +++------- internal/server/fire_event.go | 23 +- internal/server/history_test.go | 4 +- internal/server/http_adapter.go | 4 +- internal/server/hubmanager.go | 69 ++- internal/server/interfaces.go | 152 ++--- internal/server/interfaces_mock_test.go | 462 ++++++--------- internal/server/jsonrpc.go | 115 ++-- internal/server/jsonrpc_handler_test.go | 95 +-- internal/server/jsonrpc_protocol.go | 56 +- internal/server/jsonrpc_protocol_test.go | 187 +++++- .../server/manage_stream_lifecycle_test.go | 38 ++ internal/server/manage_ws.go | 17 +- internal/server/management_async.go | 95 ++- internal/server/openapi_param_test.go | 74 +++ internal/server/protocol.go | 13 +- internal/server/protocol_test.go | 6 +- internal/server/registry.go | 55 +- internal/server/server.go | 546 +----------------- internal/server/server_example.go | 7 - internal/server/server_http.go | 403 +++++++++++++ internal/server/server_management.go | 376 ++++++------ internal/server/server_requests.go | 122 ++++ internal/server/server_routes.go | 152 +++++ internal/server/server_runtime_example.go | 4 +- internal/server/server_test.go | 410 ++----------- internal/server/server_ttl_test.go | 24 +- internal/server/signalr_conn.go | 249 ++++++++ internal/server/signalr_hub.go | 328 +---------- internal/server/signalr_hub_test.go | 130 ++++- internal/server/signalr_push.go | 86 +++ internal/server/templating_parity_test.go | 16 +- internal/server/wrappers.go | 194 +------ internal/server/ws_adapter.go | 56 +- internal/server/ws_adapter_test.go | 13 +- internal/state/state.go | 37 +- internal/state/state_test.go | 28 + mock/server/interfaces_mock.go | 400 ++++++------- openspec/specs/asyncapi-management/spec.md | 8 + openspec/specs/asyncapi-protocols/spec.md | 4 + openspec/specs/json-rpc/spec.md | 10 +- openspec/specs/management-api/spec.md | 4 + openspec/specs/signalr-hub-runtime/spec.md | 6 +- scripts/check_test_headers.py | 69 +++ test/cli/cli_integration_test.go | 2 +- test/extensions/runtime_expressions_test.go | 6 +- test/management-api/management_api_test.go | 2 +- test/server-core/server_integration_test.go | 2 +- 74 files changed, 3941 insertions(+), 3674 deletions(-) create mode 100644 .golangci.yml delete mode 100644 internal/server/convert.go create mode 100644 internal/server/engine_async.go create mode 100644 internal/server/engine_expr.go create mode 100644 internal/server/engine_state.go create mode 100644 internal/server/event_deliver.go create mode 100644 internal/server/openapi_param_test.go create mode 100644 internal/server/server_http.go create mode 100644 internal/server/server_requests.go create mode 100644 internal/server/server_routes.go create mode 100644 internal/server/signalr_conn.go create mode 100644 internal/server/signalr_push.go create mode 100644 scripts/check_test_headers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea3dd13..3c2f628 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,12 @@ jobs: - name: Run linter uses: golangci/golangci-lint-action@v6 with: - version: latest + # Pinned to a v2 release matching the .golangci.yml schema in use. + version: v2.13.2 args: --timeout 5m + + - name: Check Gherkin test headers + run: python3 scripts/check_test_headers.py spec-coverage: name: Spec Coverage Check diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e3a597e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,41 @@ +# golangci-lint configuration (v2 schema). +# Run locally with: golangci-lint run +version: "2" + +run: + timeout: 5m + +linters: + default: standard + enable: + - gocyclo + - gocognit + - dupl + - misspell + - whitespace + - thelper + - usestdlibvars + - errname + settings: + gocyclo: + # Project standard: keep every function under this cyclomatic bound. + min-complexity: 15 + gocognit: + # Flag only deeply tangled functions (handlers and schema registrars + # legitimately branch on several states); small helpers are preferred. + min-complexity: 35 + dupl: + threshold: 150 + exclusions: + paths: + - mock/ + - third_party/ + rules: + # Integration test helpers build and launch the real binary; their JSON + # fixtures and server stubs resemble one another by design. + - path: test/ + linters: + - dupl + - path: internal/server/.*_test\.go + linters: + - dupl \ No newline at end of file diff --git a/Makefile b/Makefile index 6facbfc..fa31775 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,10 @@ spec-coverage: lint: golangci-lint run +# Check that every unit test carries a Gherkin Scenario: header +test-headers: + python3 scripts/check_test_headers.py + # Clean up clean: rm -rf bin/ diff --git a/api/openapi.yaml b/api/openapi.yaml index ee4fb07..12d9054 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5,7 +5,7 @@ info: version: 0.1.0 servers: - url: http://localhost:19191/_mock - description: Default mock server + description: Default mock server port (configurable via --port / OASMOCK_PORT) paths: /examples: post: @@ -34,9 +34,7 @@ paths: schema: $ref: '#/components/schemas/AddExampleResponse' '400': - description: Invalid request - '500': - description: Internal server error + $ref: '#/components/responses/InvalidRequest' /examples/{exampleId}: delete: operationId: deleteExample @@ -59,7 +57,7 @@ paths: schema: $ref: '#/components/schemas/AsyncActionResponse' '404': - description: Unknown exampleId + $ref: '#/components/responses/NotFound' /requests: get: summary: Retrieve request history @@ -80,12 +78,12 @@ paths: enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS] - name: limit in: query - description: Maximum number of records to return (default 100, max 100) + description: Maximum number of records to return (default 1000, max 1000) schema: type: integer minimum: 1 - maximum: 100 - default: 100 + maximum: 1000 + default: 1000 - name: offset in: query description: Offset for pagination @@ -132,27 +130,29 @@ paths: schema: $ref: '#/components/schemas/AsyncActionResponse' '400': - description: Invalid request (missing/unknown type or event) + $ref: '#/components/responses/InvalidRequest' + '500': + $ref: '#/components/responses/InternalError' /events/fire: post: operationId: fireEventLegacy summary: Fire a named event (deprecated alias) description: | Deprecated alias of POST /_mock/events. Accepts the legacy body without - the `type` discriminator (defaults to "fire") alongside the new shape. - Kept for backward compatibility; use /_mock/events. + the `type` discriminator (defaults to "fire"). Kept for backward + compatibility; use /_mock/events. requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/FireEventRequest' + $ref: '#/components/schemas/LegacyFireEventRequest' deprecated: true responses: '200': description: Event accepted '400': - description: Invalid request + $ref: '#/components/responses/InvalidRequest' /async/push: post: operationId: pushToChannel @@ -175,9 +175,9 @@ paths: schema: $ref: '#/components/schemas/AsyncActionResponse' '400': - description: Invalid request + $ref: '#/components/responses/InvalidRequest' '404': - description: Unknown connectionId + $ref: '#/components/responses/NotFound' /async/consumers: get: operationId: listConsumers @@ -221,9 +221,9 @@ paths: schema: $ref: '#/components/schemas/AsyncActionResponse' '400': - description: Invalid request + $ref: '#/components/responses/InvalidRequest' '404': - description: Unknown connectionId + $ref: '#/components/responses/NotFound' /stream: get: operationId: managementStream @@ -252,7 +252,7 @@ paths: server pushes ManageEnvelope frames (per the envelope schemas; not an HTTP response body) '405': - description: Non-upgrade request rejected + $ref: '#/components/responses/NotUpgraded' /ws/push: post: operationId: pushToChannelLegacy @@ -269,9 +269,9 @@ paths: '200': description: Push accepted '400': - description: Invalid request + $ref: '#/components/responses/InvalidRequest' '404': - description: Unknown connectionId + $ref: '#/components/responses/NotFound' /ws/consumers: get: operationId: listConsumersLegacy @@ -307,7 +307,7 @@ paths: $ref: '#/components/schemas/ScheduleRequest' responses: '410': - description: Gone — use POST /_mock/examples with interval + $ref: '#/components/responses/GoneExamples' /ws/schedule/{pushId}: delete: operationId: stopRecurringPush @@ -324,7 +324,7 @@ paths: type: string responses: '410': - description: Gone — use DELETE /_mock/examples/{exampleId} + $ref: '#/components/responses/GoneDelete' /ws/disconnect: post: operationId: disconnectConsumerLegacy @@ -345,11 +345,19 @@ paths: schema: $ref: '#/components/schemas/AsyncActionResponse' '400': - description: Invalid request + $ref: '#/components/responses/InvalidRequest' '404': - description: Unknown connectionId + $ref: '#/components/responses/NotFound' components: schemas: + Error: + type: object + required: + - error + properties: + error: + type: string + description: Human-readable error message AddExampleRequest: type: object required: @@ -530,6 +538,31 @@ components: type: boolean default: false description: When true, the event is broadcast over all loaded schemas + LegacyFireEventRequest: + type: object + required: + - event + properties: + type: + type: string + enum: [fire] + default: fire + description: Optional discriminator; the legacy alias defaults to "fire" + 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: @@ -677,3 +710,40 @@ components: type: string streamId: type: string + responses: + InvalidRequest: + description: The request is malformed, missing required fields, or references an unknown target + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: The referenced resource (example, connection, or route) does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InternalError: + description: An unexpected server-side error occurred + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotUpgraded: + description: The request is a valid GET but did not attempt a WebSocket upgrade + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + GoneExamples: + description: Removed — use POST /_mock/examples with interval + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + GoneDelete: + description: Removed — use DELETE /_mock/examples/{exampleId} + content: + application/json: + schema: + $ref: '#/components/schemas/Error' diff --git a/cmd/oasmock/mock.go b/cmd/oasmock/mock.go index ec93fb3..181f3ce 100644 --- a/cmd/oasmock/mock.go +++ b/cmd/oasmock/mock.go @@ -122,8 +122,7 @@ var mockCmd = &cobra.Command{ func init() { rootCmd.AddCommand(mockCmd) - // Set mock as default command when no subcommand given - rootCmd.Run = mockCmd.Run + // Set mock as default command when no subcommand given. rootCmd.RunE = mockCmd.RunE // Ensure errors and usage are handled by our custom logic @@ -158,69 +157,19 @@ func init() { } func runMock(cmd *cobra.Command, args []string) error { - // Read config file (if present) - if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - // Config file exists but is malformed - log warning - slog.Warn("Failed to read config file", "err", err) - } - // Config file not found is not an error - } - - // Parse schema configuration from YAML (if present) - if err := parseSchemaConfig(cmd); err != nil { - return err - } - - // Read values from viper (environment overrides) - port := viper.GetInt("port") - delay := viper.GetInt("delay") - verbose := viper.GetBool("verbose") - noCORS := viper.GetBool("nocors") - historySize := viper.GetInt("history_size") - noControlAPI := viper.GetBool("no_control_api") - - // Configure structured logging - level := slog.LevelInfo - if verbose { - level = slog.LevelDebug - } - handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}) - slog.SetDefault(slog.New(handler)) - - // Validate flag combinations - 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") - } - // port 0 selects an OS-assigned (ephemeral) port (RS.CLI.32). - if err := validatePort(port); err != nil { + cfg, err := resolveMockConfig(cmd) + if err != nil { return err } - if delay < 0 { - return validationError("delay cannot be negative") - } - if historySize < 0 { - return validationError("history size cannot be negative") - } // Load OpenAPI schemas - schemas, err := loader.LoadSchemas(config.sources, config.prefixes) + schemas, err := loader.LoadSchemas(cfg.sources, cfg.prefixes) if err != nil { return schemaError("failed to load schemas: %v", err) } - // Prepare server configuration - serverConfig := server.Config{ - Port: port, - Delay: time.Duration(delay) * time.Millisecond, - Verbose: verbose, - EnableCORS: !noCORS, - HistorySize: historySize, - EnableControlAPI: !noControlAPI, - } - // Create and start server - srv, err := server.New(serverConfig, schemas) + srv, err := server.New(cfg.serverConfig(), schemas) if err != nil { return schemaError("failed to create server: %v", err) } @@ -232,9 +181,9 @@ func runMock(cmd *cobra.Command, args []string) error { 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("port %d is already in use", cfg.port) } - return portError("failed to listen on port %d: %v", port, err) + return portError("failed to listen on port %d: %v", cfg.port, err) } // Serve in a goroutine so we can handle signals @@ -270,3 +219,77 @@ func runMock(cmd *cobra.Command, args []string) error { return fmt.Errorf("server error: %w", err) } } + +// serverConfig converts the resolved CLI configuration into the server config. +func (c mockConfig) serverConfig() server.Config { + return server.Config{ + Port: c.port, + Delay: time.Duration(c.delay) * time.Millisecond, + Verbose: c.verbose, + EnableCORS: !c.noCORS, + HistorySize: c.historySize, + EnableControlAPI: !c.noControlAPI, + } +} + +// resolveMockConfig reads the config file, environment and flags into a single +// resolved mockConfig and configures logging. Sources/prefixes resolve from +// flags or the config file's schemas list; scalar options resolve through viper +// (flag > env > config file). Validation covers the flag combinations with no +// server-side equivalent. +func resolveMockConfig(cmd *cobra.Command) (mockConfig, error) { + // Read config file (if present) + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + // Config file exists but is malformed - log warning + slog.Warn("Failed to read config file", "err", err) + } + // Config file not found is not an error + } + + // Parse schema configuration from YAML (if present) + if err := parseSchemaConfig(cmd); err != nil { + return mockConfig{}, err + } + + cfg := mockConfig{ + sources: config.sources, + prefixes: config.prefixes, + port: viper.GetInt("port"), + delay: viper.GetInt("delay"), + verbose: viper.GetBool("verbose"), + noCORS: viper.GetBool("nocors"), + historySize: viper.GetInt("history_size"), + noControlAPI: viper.GetBool("no_control_api"), + } + + // Configure structured logging from the single resolved verbosity value. + level := slog.LevelInfo + if cfg.verbose { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + + if err := validateMockConfig(cfg); err != nil { + return mockConfig{}, err + } + return cfg, nil +} + +// validateMockConfig validates flag combinations and option ranges. +func validateMockConfig(cfg mockConfig) error { + if len(cfg.sources) != len(cfg.prefixes) && len(cfg.prefixes) != 0 { + return validationError("number of --prefix flags must match number of --from flags, or no --prefix flags provided") + } + // port 0 selects an OS-assigned (ephemeral) port (RS.CLI.32). + if err := validatePort(cfg.port); err != nil { + return err + } + if cfg.delay < 0 { + return validationError("delay cannot be negative") + } + if cfg.historySize < 0 { + return validationError("history size cannot be negative") + } + return nil +} diff --git a/docs/architecture.md b/docs/architecture.md index 1727831..ba68dac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -178,11 +178,13 @@ flowchart LR - `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-*`) + - `RpcProtocol` - Parses JSON-RPC bodies and formats error responses (gateway) - `MessageRenderer` - Narrow rendering surface (engine) consumed by hub/event bus/adapters - `ConsumerBus` - Payload emission to SignalR streams + ws consumers (hub manager) + - **Note:** runtime-expression evaluation, data-source construction and + `x-mock-*` extension processing are owned by the example engine + (`internal/extensions`/`internal/runtime`) and are not exposed as server + interfaces. - **Responsibilities**: - HTTP request routing using Chi router - WebSocket upgrades and connection lifecycle @@ -228,11 +230,14 @@ flowchart LR - `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-mock-interval` / `x-mock-delay` - Periodically driven / delayed example timing - `x-event-trigger` - Fire a named event from an OpenAPI example + - `x-send-events` - Legacy AsyncAPI event subscription mapping (deprecated shim) - **Functions**: - `ExtractSetState()`, `ExtractParamsMatch()`, `ExtractHeaders()`, `ExtractEventTriggers()` - `EvaluateParamsMatch()` - Uses Runtime.Evaluator for expression evaluation - `ExtractSkip()`, `ExtractOnce()` + - `ClassifyTrigger()` - classifies an example into event-driven / periodic / reply - `OpenAPIExampleValue()` / `NewExampleValue()` - source-agnostic wrappers - **Responsibilities**: - Extract extension values from OpenAPI and AsyncAPI examples @@ -405,9 +410,10 @@ sequenceDiagram ServerNew-->>-CLI: *Server instance Note over CLI,HTTPServer: === Server Startup === - CLI->>+ServerStart: Start() (goroutine) - ServerStart->>+HTTPServer: ListenAndServe() - HTTPServer-->>-ServerStart: Listening on port + CLI->>+ServerStart: Listen() (binds the port) + ServerStart-->>-CLI: Bound port (OS-assigned when --port 0) + CLI->>+ServerStart: Serve(listener) (goroutine) + ServerStart->>+HTTPServer: Serve(ln) ServerStart-->>-CLI: Server running CLI->>CLI: Wait for interrupt signal CLI-->>-User: Server ready message @@ -601,32 +607,18 @@ type DataSource interface { Get(path string) (any, bool) } -// ExpressionEvaluator evaluates runtime expressions -type ExpressionEvaluator interface { - AddSource(name string, source DataSource) - Evaluate(expr string) (any, error) -} - -// ExtensionProcessor processes OpenAPI extensions -type ExtensionProcessor interface { - ExtractSetState(example *openapi3.Example) (map[string]any, bool) - ExtractSkip(example *openapi3.Example) bool - ExtractOnce(example *openapi3.Example) bool - ExtractParamsMatch(example *openapi3.Example) (map[string]any, bool) - EvaluateParamsMatch(params map[string]any, eval ExpressionEvaluator) (bool, error) - ExtractHeaders(example *openapi3.Example) (map[string]any, bool) -} +// Runtime-expression evaluation, data-source construction and x-mock-* +// extension processing are owned by the example engine (`internal/extensions`) +// and are not dependency-injected interfaces. The engine's `MessageRenderer` +// surface (below) is the narrow contract the async subsystems consume. -// Dependencies holds all dependencies for the Server +// Dependencies holds all dependencies for the Server. Only the stores and the +// route provider are injected; the example engine owns runtime-expression +// evaluation, data-source construction and extension processing directly. type Dependencies struct { - RouteProvider RouteProvider - StateStore StateStore - HistoryStore HistoryStore - RequestSourceFactory RequestSourceFactory - StateSourceFactory StateSourceFactory - EnvSourceFactory EnvSourceFactory - ExpressionEvaluator ExpressionEvaluator - ExtensionProcessor ExtensionProcessor + RouteProvider RouteProvider + StateStore StateStore + HistoryStore HistoryStore } ``` diff --git a/internal/extensions/extract.go b/internal/extensions/extract.go index 9ac3f55..3f172b5 100644 --- a/internal/extensions/extract.go +++ b/internal/extensions/extract.go @@ -1,77 +1,40 @@ package extensions import ( - "log/slog" - "github.com/getkin/kin-openapi/openapi3" ) -// extractExtension extracts an extension value by key and attempts to convert it to type T. -// Returns zero value and false if the extension is not present or type conversion fails. -func extractExtension[T any](ex *openapi3.Example, key string) (T, bool) { - var zero T - if ex == nil || ex.Extensions == nil { - return zero, false - } - raw, ok := ex.Extensions[key] - if !ok { - return zero, false - } - val, ok := raw.(T) - if !ok { - return zero, false - } - return val, true -} +// The Extract* functions below are the OpenAPI-typed surface used by the +// server's OpenAPI selection pipeline. They delegate to the source-agnostic +// Value* family over an OpenAPIExampleValue so both OpenAPI and AsyncAPI +// examples share one extraction implementation (design D5). // ExtractParamsMatch extracts the x-mock-params-match extension from an example. // If both x-mock-match and x-mock-params-match are present, uses x-mock-match // and writes a warning to stderr (as per spec). func ExtractParamsMatch(ex *openapi3.Example) (ParamsMatch, bool) { - if ex == nil || ex.Extensions == nil { - return nil, false - } - - _, hasParamsMatch := ex.Extensions["x-mock-params-match"] - _, hasMatch := ex.Extensions["x-mock-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 - } - - m, ok := extractExtension[map[string]any](ex, key) + m, ok := ValueMatch(OpenAPIExampleValue(ex)) return ParamsMatch(m), ok } // ExtractSkip extracts x-mock-skip extension. func ExtractSkip(ex *openapi3.Example) bool { - skip, _ := extractExtension[bool](ex, "x-mock-skip") - return skip + return ValueSkip(OpenAPIExampleValue(ex)) } // ExtractOnce extracts x-mock-once extension. func ExtractOnce(ex *openapi3.Example) bool { - once, _ := extractExtension[bool](ex, "x-mock-once") - return once + return ValueOnce(OpenAPIExampleValue(ex)) } // ExtractSetState extracts x-mock-set-state extension. func ExtractSetState(ex *openapi3.Example) (map[string]any, bool) { - return extractExtension[map[string]any](ex, "x-mock-set-state") + return ValueSetState(OpenAPIExampleValue(ex)) } // ExtractHeaders extracts x-mock-headers extension. func ExtractHeaders(ex *openapi3.Example) (map[string]any, bool) { - return extractExtension[map[string]any](ex, "x-mock-headers") + return ValueHeaders(OpenAPIExampleValue(ex)) } // EventTrigger is a single x-event-trigger entry (design D8). @@ -113,7 +76,7 @@ func ExtractEventTriggers(ex *openapi3.Example) ([]EventTrigger, bool) { if payload, ok := m["payload"].(map[string]any); ok { t.Payload = payload } - if delay, ok := asDelay(m["delay"]); ok { + if delay, ok := AsMilliseconds(m["delay"]); ok { t.Delay = delay } if global, ok := m["global"].(bool); ok { @@ -126,16 +89,3 @@ func ExtractEventTriggers(ex *openapi3.Example) ([]EventTrigger, bool) { } 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/loader/async_router_test.go b/internal/loader/async_router_test.go index 08bc321..809c5d3 100644 --- a/internal/loader/async_router_test.go +++ b/internal/loader/async_router_test.go @@ -54,6 +54,31 @@ operations: $ref: '#/channels/socket' ` +const parameterizedChannelSpec = `asyncapi: 3.0.0 +info: + title: Parameterized Events + version: 1.0.0 +channels: + userEvents: + address: /users/{id}/events + parameters: + id: + description: user id + bindings: + ws: + method: GET + messages: + evt: + examples: + - payload: + id: 1 +operations: + receiveUserEvents: + action: receive + channel: + $ref: '#/channels/userEvents' +` + const unsupportedChannelSpec = `asyncapi: 3.0.0 info: title: Kafka Events @@ -142,6 +167,29 @@ func TestBuildAsyncRouteMappings_WS(t *testing.T) { assert.Equal(t, "receive", rm.Action) } +/* +Scenario: Parameterized async channel addresses keep the brace parameter form +Given an asyncapi channel with address /users/{id}/events +When BuildRouteMappings is called +Then the ChiPattern keeps the {id} brace segment (chi v5 native parameter +syntax) so chi populates route params and {$channel.id} resolves instead of +silently evaluating to nothing + +Related spec scenarios: RS.ASP.11 +*/ +func TestBuildAsyncRouteMappings_ParameterizedAddress(t *testing.T) { + t.Parallel() + + info := mustAsyncInfo(t, parameterizedChannelSpec, "") + 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, "/users/{id}/events", rm.Path) + assert.Equal(t, "/users/{id}/events", rm.ChiPattern, "async address must keep the chi v5 brace parameter syntax") +} + /* Scenario: Mapping an AsyncAPI AMQP channel is rejected as unsupported Given an asyncapi spec with an amqp channel binding diff --git a/internal/loader/router.go b/internal/loader/router.go index 55dcaf0..a1e8cbe 100644 --- a/internal/loader/router.go +++ b/internal/loader/router.go @@ -150,12 +150,12 @@ func asyncRoute(ch *asyncapi.Channel, prefix, protocol string, op *asyncapi.Oper rm.Method = method rm.Path = fullAddress rm.Pattern = address - rm.ChiPattern = fullAddress + rm.ChiPattern = OpenAPIPatternToChi(fullAddress) case asyncapi.ProtocolWS: rm.Method = http.MethodGet rm.Path = fullAddress rm.Pattern = address - rm.ChiPattern = fullAddress + rm.ChiPattern = OpenAPIPatternToChi(fullAddress) default: return RouteMapping{}, fmt.Errorf("channel %q: unsupported protocol %q", ch.ID, protocol) } @@ -237,8 +237,10 @@ func BuildRouteMappings(infos []SchemaInfo) ([]RouteMapping, error) { return nil, err } mappings = append(mappings, asyncMappings...) - default: + case KindOpenAPI: mappings = append(mappings, buildOpenAPIRouteMappings(info)...) + default: + return nil, fmt.Errorf("unsupported schema kind: %q", info.Kind) } } @@ -264,7 +266,7 @@ func buildOpenAPIRouteMappings(info SchemaInfo) []RouteMapping { } // Apply prefix to the path - fullPath := applyPrefix(prefix, path) + fullPath := PrefixPath(prefix, path) // Create mappings for each HTTP method defined in the path item mappings = append(mappings, createMappingsForPath(path, fullPath, prefix, pathItem)...) @@ -272,7 +274,12 @@ func buildOpenAPIRouteMappings(info SchemaInfo) []RouteMapping { return mappings } -func applyPrefix(prefix, path string) string { +// PrefixPath joins a schema prefix and a route path into the fully-prefixed +// route path (e.g. "/api" + "/users" -> "/api/users"). It is the single +// prefix-normalization point shared by OpenAPI route mapping, RPC gateway +// mapping and the server's gateway setup. A bare "/" collapses to the prefix +// so the root route never double-slashes. +func PrefixPath(prefix, path string) string { if prefix == "" { return path } @@ -316,25 +323,11 @@ func createMappingsForPath(originalPath, fullPath, prefix string, pathItem *open return mappings } -// OpenAPIPatternToChi converts an OpenAPI path pattern (with {param}) to a Chi pattern. -// Chi supports both {param} and :param syntax. We keep the OpenAPI braces. +// OpenAPIPatternToChi returns the chi-compatible routing pattern for an +// OpenAPI/AsyncAPI path pattern containing {param} braces. chi v5 uses the +// same brace syntax as OpenAPI ({id}), so the pattern is preserved verbatim: +// converting to the legacy colon form (:id) would be treated by chi as a +// literal static segment and silently break every parameterized route. func OpenAPIPatternToChi(pattern string) string { return pattern } - -// FindOperation finds the operation that matches the given method and path. -// It returns the route mapping and extracted path parameters. -func FindOperation(mappings []RouteMapping, method, path string) (*RouteMapping, map[string]string, bool) { - for _, mapping := range mappings { - if mapping.Method != method { - continue - } - - // Simple exact match for now; later we need to handle path parameters - // For MVP, we'll do exact match on path - if mapping.Path == path { - return &mapping, nil, true - } - } - return nil, nil, false -} diff --git a/internal/loader/router_test.go b/internal/loader/router_test.go index 2c02b56..4b14584 100644 --- a/internal/loader/router_test.go +++ b/internal/loader/router_test.go @@ -11,7 +11,7 @@ import ( /* Scenario: Applying prefix to route path Given a prefix and a path -When applyPrefix is called +When PrefixPath is called Then it returns the concatenated path with proper slash handling, trimming trailing slashes Related spec scenarios: RS.MSC.6 @@ -73,17 +73,18 @@ func TestApplyPrefix(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := applyPrefix(tt.prefix, tt.path) - assert.Equal(t, tt.want, got, "applyPrefix(%q, %q)", tt.prefix, tt.path) + got := PrefixPath(tt.prefix, tt.path) + assert.Equal(t, tt.want, got, "PrefixPath(%q, %q)", tt.prefix, tt.path) }) } } /* -Scenario: Converting OpenAPI path pattern to Chi router pattern -Given an OpenAPI path pattern with optional curly‑brace parameters +Scenario: Transforming OpenAPI path pattern to Chi router pattern +Given an OpenAPI path pattern with optional curly-brace parameters When OpenAPIPatternToChi is called -Then it returns Chi‑style colon‑prefixed parameter names, preserving other characters +Then it preserves the brace parameter form ({id}), which chi v5 matches +natively; the legacy colon form would be treated as a literal segment Related spec scenarios: RS.MSC.4, RS.MSC.5 */ @@ -142,90 +143,6 @@ func TestOpenAPIPatternToChi(t *testing.T) { } } -/* -Scenario: Finding operation in route mappings by method and path -Given a list of route mappings -When FindOperation is called with a method and path -Then it returns the matching mapping and true if found, false otherwise - -Related spec scenarios: RS.MSC.4, RS.MSC.5, RS.MSC.6, RS.MSC.7 -*/ -func TestFindOperation(t *testing.T) { - t.Parallel() - - mappings := []RouteMapping{ - { - Method: "GET", - Path: "/api/users", - ChiPattern: "/api/users", - }, - { - Method: "POST", - Path: "/api/users", - ChiPattern: "/api/users", - }, - { - Method: "GET", - Path: "/api/posts/{id}", - ChiPattern: "/api/posts/:id", - }, - } - - tests := []struct { - name string - method string - path string - wantFound bool - wantPath string - }{ - { - name: "exact match GET /api/users", - method: "GET", - path: "/api/users", - wantFound: true, - wantPath: "/api/users", - }, - { - name: "exact match POST /api/users", - method: "POST", - path: "/api/users", - wantFound: true, - wantPath: "/api/users", - }, - { - name: "method mismatch", - method: "PUT", - path: "/api/users", - wantFound: false, - }, - { - name: "path mismatch", - method: "GET", - path: "/api/nonexistent", - wantFound: false, - }, - { - name: "path with param - exact match fails", - method: "GET", - path: "/api/posts/{id}", - wantFound: true, - wantPath: "/api/posts/{id}", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - mapping, _, found := FindOperation(mappings, tt.method, tt.path) - assert.Equal(t, tt.wantFound, found) - if tt.wantFound { - assert.Equal(t, tt.wantPath, mapping.Path) - } - }) - } -} - /* Scenario: Building route mappings from loaded OpenAPI schemas Given loaded OpenAPI schemas without prefix @@ -272,7 +189,7 @@ func TestBuildRouteMappings(t *testing.T) { key := fmt.Sprintf("%s %s", mapping.Method, mapping.Path) foundPaths[key] = true - // Check ChiPattern conversion for path parameters + // Check ChiPattern keeps the brace parameter form chi v5 matches if mapping.Path == "/users/{id}" { assert.Equal(t, "/users/{id}", mapping.ChiPattern, "ChiPattern conversion failed") } diff --git a/internal/loader/rpc.go b/internal/loader/rpc.go index e032221..5a6eefe 100644 --- a/internal/loader/rpc.go +++ b/internal/loader/rpc.go @@ -21,7 +21,7 @@ func ParseRpcConfig(spec *openapi3.T) (*RpcConfig, error) { return nil, nil } - extMap, ok := ext.(map[string]interface{}) + extMap, ok := ext.(map[string]any) if !ok { return nil, fmt.Errorf("x-rpc must be a map") } @@ -53,7 +53,7 @@ func ParseRpcConfig(spec *openapi3.T) (*RpcConfig, error) { if !ok { return nil, fmt.Errorf("x-rpc.procedure is required") } - procMap, ok := procRaw.(map[string]interface{}) + procMap, ok := procRaw.(map[string]any) if !ok { return nil, fmt.Errorf("x-rpc.procedure must be a map") } @@ -89,7 +89,7 @@ func BuildRpcMappings(infos []SchemaInfo, cfg *RpcConfig) ([]*RpcRouteMapping, e continue } - fullPath := applyPrefix(prefix, path) + fullPath := PrefixPath(prefix, path) if !isUnderGateway(fullPath, cfg.Gateway, prefix) { continue @@ -130,6 +130,6 @@ func BuildRpcMappings(infos []SchemaInfo, cfg *RpcConfig) ([]*RpcRouteMapping, e } func isUnderGateway(path, gateway, prefix string) bool { - gwPath := applyPrefix(prefix, gateway) + gwPath := PrefixPath(prefix, gateway) return path == gwPath || strings.HasPrefix(path, gwPath+"/") } diff --git a/internal/loader/rpc_test.go b/internal/loader/rpc_test.go index 15a19dd..4d4a8f2 100644 --- a/internal/loader/rpc_test.go +++ b/internal/loader/rpc_test.go @@ -233,7 +233,7 @@ x-rpc: cfg, err := ParseRpcConfig(spec) require.NoError(t, err) - infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + infos := []SchemaInfo{{Kind: KindOpenAPI, Spec: spec, Prefix: ""}} mappings, err := BuildRpcMappings(infos, cfg) require.NoError(t, err) @@ -292,7 +292,7 @@ x-rpc: cfg, err := ParseRpcConfig(spec) require.NoError(t, err) - infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + infos := []SchemaInfo{{Kind: KindOpenAPI, Spec: spec, Prefix: ""}} mappings, err := BuildRpcMappings(infos, cfg) require.NoError(t, err) @@ -346,7 +346,7 @@ x-rpc: cfg, err := ParseRpcConfig(spec) require.NoError(t, err) - infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + infos := []SchemaInfo{{Kind: KindOpenAPI, Spec: spec, Prefix: ""}} mappings, err := BuildRpcMappings(infos, cfg) require.NoError(t, err) @@ -394,7 +394,7 @@ x-rpc: cfg, err := ParseRpcConfig(spec) require.NoError(t, err) - infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + infos := []SchemaInfo{{Kind: KindOpenAPI, Spec: spec, Prefix: ""}} _, err = BuildRpcMappings(infos, cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "duplicate") @@ -434,7 +434,7 @@ x-rpc: cfg, err := ParseRpcConfig(spec) require.NoError(t, err) - infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + infos := []SchemaInfo{{Kind: KindOpenAPI, Spec: spec, Prefix: ""}} mappings, err := BuildRpcMappings(infos, cfg) require.NoError(t, err) assert.Empty(t, mappings) @@ -463,7 +463,7 @@ x-rpc: cfg, err := ParseRpcConfig(spec) require.NoError(t, err) - infos := []SchemaInfo{{Spec: spec, Prefix: "/api"}} + infos := []SchemaInfo{{Kind: KindOpenAPI, Spec: spec, Prefix: "/api"}} mappings, err := BuildRpcMappings(infos, cfg) require.NoError(t, err) @@ -518,7 +518,7 @@ x-rpc: cfg, err := ParseRpcConfig(spec) require.NoError(t, err) - infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + infos := []SchemaInfo{{Kind: KindOpenAPI, Spec: spec, Prefix: ""}} // Regular route mappings include everything routeMappings, err := BuildRouteMappings(infos) diff --git a/internal/runtime/expression.go b/internal/runtime/expression.go index 5b5046d..f78e5a2 100644 --- a/internal/runtime/expression.go +++ b/internal/runtime/expression.go @@ -8,6 +8,19 @@ import ( "strings" ) +// Data source names used in runtime expressions (e.g. {$request.path.id}). +// They are the single source of truth for the scriptable expression namespaces; +// the server registers sources with these names and the evaluator resolves them. +const ( + SourceRequest = "request" + SourceState = "state" + SourceEnv = "env" + SourceEvent = "event" + SourceConnection = "connection" + SourceMessage = "message" + SourceChannel = "channel" +) + // splitEscapedPath splits a path by dots, respecting escaped dots (\.). // Returns the parts with escapes removed. func splitEscapedPath(path string) []string { @@ -94,42 +107,47 @@ func (r *RequestSource) Get(path string) (any, bool) { switch category { case "path": - if val, ok := r.PathParams[key]; ok { - return val, true - } + return stringLookup(r.PathParams, key) case "query": - if vals, ok := r.QueryParams[key]; ok && len(vals) > 0 { - if len(vals) == 1 { - return vals[0], true - } - return vals, true - } + return sliceLookup(r.QueryParams, key) case "header": - if vals, ok := r.Headers[strings.ToLower(key)]; ok && len(vals) > 0 { - if len(vals) == 1 { - return vals[0], true - } - return vals, true - } + return sliceLookup(r.Headers, strings.ToLower(key)) case "body": - if r.Body == nil { - return nil, false - } - // Use getNested for nested path traversal - remainingParts := parts[1:] - if len(remainingParts) == 0 { - return nil, false - } - return getNested(r.Body, remainingParts) + return nestedLookup(r.Body, parts[1:]) case "cookie": - if val, ok := r.Cookies[key]; ok { - return val, true - } + return stringLookup(r.Cookies, key) } return nil, false } +// stringLookup returns the value of a flat map entry. +func stringLookup(m map[string]string, key string) (any, bool) { + val, ok := m[key] + return val, ok +} + +// sliceLookup returns a single value when a key has one entry and the full +// slice when it has several. +func sliceLookup(m map[string][]string, key string) (any, bool) { + vals, ok := m[key] + if !ok || len(vals) == 0 { + return nil, false + } + if len(vals) == 1 { + return vals[0], true + } + return vals, true +} + +// nestedLookup traverses a nested body value by path parts. +func nestedLookup(obj any, parts []string) (any, bool) { + if obj == nil || len(parts) == 0 { + return nil, false + } + return getNested(obj, parts) +} + // StateSource provides access to server state. type StateSource struct { Data map[string]any @@ -393,7 +411,6 @@ func (e *evaluator) Evaluate(expr string) (any, error) { func (e *evaluator) applyModifier(value any, modifier string) (any, error) { // Check for modifier with argument (e.g., "default:value", "getByPath:path") if name, arg, found := strings.Cut(modifier, ":"); found { - switch name { case "default": // Should have been handled earlier when value not found diff --git a/internal/server/add_example_validation_test.go b/internal/server/add_example_validation_test.go index f02e2fc..53b236d 100644 --- a/internal/server/add_example_validation_test.go +++ b/internal/server/add_example_validation_test.go @@ -6,10 +6,49 @@ import ( "net/http/httptest" "testing" + "github.com/getkin/kin-openapi/openapi3" + "github.com/mamonth/oasmock/internal/loader" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const validateOpenAPISpec = ` +openapi: 3.0.3 +info: + title: Validate API + version: 1.0.0 +paths: + /validate: + post: + operationId: validateIt + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [kind, count] + properties: + kind: + type: string + count: + type: integer + tags: + type: array + items: + type: string +` + +func mustOpenAPISpec(t *testing.T, yamlSpec string) *openapi3.T { + t.Helper() + ldr := openapi3.NewLoader() + spec, err := ldr.LoadFromData([]byte(yamlSpec)) + require.NoError(t, err) + require.NoError(t, spec.Validate(ldr.Context)) + return spec +} + /* Scenario: Mixing sync and async targeting is rejected Given a POST with both path and channel @@ -258,3 +297,41 @@ func TestAddExampleValidation_ErrorEnvelopeIsValidJSON(t *testing.T) { require.NotEmpty(t, envelope["error"]) } } + +/* +Scenario: Response body validation honors the validate flag +Given an OpenAPI route whose response schema constrains the body +When an add-example body violates the schema and validate is unset (default true) +Then the server responds with HTTP 400 +When the same body is posted with validate: false +Then the server accepts it + +Related spec scenarios: RS.MAPI.5 +*/ +func TestAddExampleValidation_ValidateFlag(t *testing.T) { + t.Parallel() + + schemas := []loader.SchemaInfo{{Kind: loader.KindOpenAPI, Spec: mustOpenAPISpec(t, validateOpenAPISpec), Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + // The /validate route's response schema requires body.kind to be a string + // and body.count to be an integer; body.tags to be an array of strings. + mismatched := `{"path":"/validate","method":"POST","response":{"code":200,"body":{"kind":42,"count":"oops"}}}` + + resp := postExample(t, ts.URL, mismatched) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "validate defaults to true") + + // Explicit opt-out. + resp2 := postExample(t, ts.URL, `{"path":"/validate","method":"POST","validate":false,"response":{"code":200,"body":{"kind":42,"count":"oops"}}}`) + defer resp2.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp2.StatusCode, "validate:false must skip schema validation") + + // A conforming body passes with validation on. + resp3 := postExample(t, ts.URL, `{"path":"/validate","method":"POST","response":{"code":200,"body":{"kind":"ok","count":1,"tags":["a"]}}}`) + defer resp3.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp3.StatusCode, "a conforming body must pass validation") +} diff --git a/internal/server/async_http_adapter_test.go b/internal/server/async_http_adapter_test.go index dbfb1e0..379b75d 100644 --- a/internal/server/async_http_adapter_test.go +++ b/internal/server/async_http_adapter_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/golang/mock/gomock" + "github.com/mamonth/oasmock/internal/asyncapi" "github.com/mamonth/oasmock/internal/loader" "github.com/mamonth/oasmock/internal/runtime" "github.com/stretchr/testify/assert" @@ -25,11 +26,11 @@ Related spec scenarios: RS.ASP.1, RS.ASP.10 func TestHTTPProtocolAdapter_RendersMessage(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncHTTPProtocol, + Protocol: asyncapi.ProtocolHTTP, Method: http.MethodPost, Prefix: "", Pattern: "/employees", @@ -43,7 +44,7 @@ func TestHTTPProtocolAdapter_RendersMessage(t *testing.T) { }, } - adapter := srv.adapterForProtocol(asyncHTTPProtocol) + adapter := srv.adapterForProtocol(asyncapi.ProtocolHTTP) require.NotNil(t, adapter) mh := srv.asyncMessageHandler(mapping) @@ -72,17 +73,17 @@ Related spec scenarios: RS.ASP.10 func TestHTTPProtocolAdapter_SendNoReply(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncHTTPProtocol, + Protocol: asyncapi.ProtocolHTTP, Method: http.MethodPost, Pattern: "/events", Messages: nil, } - adapter := srv.adapterForProtocol(asyncHTTPProtocol) + adapter := srv.adapterForProtocol(asyncapi.ProtocolHTTP) require.NotNil(t, adapter) handler := adapter.Handler(mapping, srv.asyncMessageHandler(mapping)) @@ -113,7 +114,7 @@ func TestSelectAsyncExample_Skip(t *testing.T) { {Name: "active", Payload: map[string]any{"id": 2}}, }, } - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) view, key := srv.selectAsyncExample(message, runtime.NewEvaluator(), "op") require.NotNil(t, view) @@ -141,7 +142,7 @@ func TestSelectAsyncExample_FirstNoConditions(t *testing.T) { {Name: "second", Payload: map[string]any{"id": 2}}, }, } - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) view, key := srv.selectAsyncExample(message, runtime.NewEvaluator(), "op") require.NotNil(t, view) @@ -168,7 +169,7 @@ func TestSelectAsyncExample_Once(t *testing.T) { {Name: "once", Extensions: map[string]any{"x-mock-once": true}, Payload: map[string]any{"id": 1}}, }, } - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) view, key := srv.selectAsyncExample(message, runtime.NewEvaluator(), "op") require.NotNil(t, view) @@ -189,7 +190,7 @@ Related spec scenarios: RS.ASP.4 func TestBuildRouteHandler_UnsupportedProtocol(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) mapping := &RouteMapping{ Protocol: "amqp", @@ -211,11 +212,11 @@ Then it returns a non-nil handler func TestBuildRouteHandler_WSAssignsAdapter(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncWSProtocol, + Protocol: asyncapi.ProtocolWS, Method: http.MethodGet, Pattern: "/socket", } diff --git a/internal/server/async_state_test.go b/internal/server/async_state_test.go index 73f7f65..71e8094 100644 --- a/internal/server/async_state_test.go +++ b/internal/server/async_state_test.go @@ -51,7 +51,7 @@ Related spec scenarios: RS.ATM.12 func TestRenderMessageSpecs_Increment(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + 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) @@ -83,7 +83,7 @@ Related spec scenarios: RS.ATM.13 func TestRenderMessageSpecs_Delete(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() stateStore.EXPECT().Delete("/ns", "key") diff --git a/internal/server/builtin_triggers.go b/internal/server/builtin_triggers.go index 8197343..af220d7 100644 --- a/internal/server/builtin_triggers.go +++ b/internal/server/builtin_triggers.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" + "github.com/mamonth/oasmock/internal/asyncapi" "github.com/mamonth/oasmock/internal/runtime" ) @@ -60,7 +61,7 @@ func (s *Server) wireBuiltInHooks() { s.notifyConsumerLifecycle("disconnected", channel, ConsumerInfo{ConnectionID: connID, Channel: channel}) }, } - if adapter, ok := s.protocolAdapters[asyncWSProtocol].(*wsProtocolAdapter); ok && adapter != nil { + if adapter, ok := s.protocolAdapters[asyncapi.ProtocolWS].(*wsProtocolAdapter); ok && adapter != nil { adapter.hooks = hookSet } for _, hub := range s.hubMgr.hubs { diff --git a/internal/server/control_api_spec_sync_test.go b/internal/server/control_api_spec_sync_test.go index 66ac1e2..1be5d5d 100644 --- a/internal/server/control_api_spec_sync_test.go +++ b/internal/server/control_api_spec_sync_test.go @@ -4,6 +4,7 @@ import ( "net/url" "os" "reflect" + "strconv" "strings" "testing" @@ -278,3 +279,76 @@ func TestControlAPISpecSync_CrossFormat(t *testing.T) { // AsyncAPI channel address. assert.Equal(t, "_mock/stream", strings.TrimPrefix(ch.Address, "/")) } + +/* +Scenario: The OpenAPI control spec documents every error response with the Error body +Given the api/openapi.yaml produces a valid OpenAPI document +When each 4xx/5xx response of every management operation is inspected +Then it carries an application/json body whose schema is the Error shape + +Related spec scenarios: RS.MAPI.6, RS.MAPI.21, RS.AMG.28 +*/ +func TestControlAPISpecSync_ErrorResponses(t *testing.T) { + t.Parallel() + doc := loadOpenAPISpec(t) + + operations := func(item *openapi3.PathItem) []*openapi3.Operation { + return []*openapi3.Operation{item.Get, item.Post, item.Delete, item.Patch, item.Put, item.Head, item.Options} + } + + checked := 0 + for path, item := range doc.Paths.Map() { + if item == nil { + continue + } + for _, op := range operations(item) { + if op == nil { + continue + } + for code, resp := range op.Responses.Map() { + status, err := strconv.Atoi(code) + if err != nil || status < 400 { + continue + } + require.NotNil(t, resp.Value, + "error response %s for %s %s must resolve to a value", code, methodOf(op, item), path) + content := resp.Value.Content + require.NotNil(t, content, + "error response %s for %s %s must declare content", code, methodOf(op, item), path) + mt := content.Get("application/json") + require.NotNil(t, mt, + "error response %s for %s %s must be application/json", code, methodOf(op, item), path) + schema := mt.Schema.Value + require.NotNil(t, schema, + "error response %s for %s %s must carry a schema", code, methodOf(op, item), path) + require.NotNil(t, schema.Properties, + "error response %s for %s %s must be an object", code, methodOf(op, item), path) + require.Contains(t, schema.Properties, "error", + "error response %s for %s %s must expose the error field", code, methodOf(op, item), path) + checked++ + } + } + } + require.GreaterOrEqual(t, checked, 15, + "the control spec should document error bodies for every management operation") +} + +// methodOf returns the HTTP method for an operation belonging to a path item. +func methodOf(op *openapi3.Operation, item *openapi3.PathItem) string { + switch op { + case item.Get: + return "GET" + case item.Post: + return "POST" + case item.Delete: + return "DELETE" + case item.Patch: + return "PATCH" + case item.Put: + return "PUT" + case item.Head: + return "HEAD" + default: + return "OPTIONS" + } +} diff --git a/internal/server/convert.go b/internal/server/convert.go deleted file mode 100644 index 233dc8a..0000000 --- a/internal/server/convert.go +++ /dev/null @@ -1,26 +0,0 @@ -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 index 6cc5be7..b4b38ef 100644 --- a/internal/server/engine.go +++ b/internal/server/engine.go @@ -6,16 +6,12 @@ import ( "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" ) @@ -40,270 +36,6 @@ func newExampleEngine(config Config, deps Dependencies, registry *exampleRegistr } } -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- @@ -414,53 +146,41 @@ func (e *exampleEngine) selectExample(mediaType *openapi3.MediaType, eval runtim withParamsMatch, withoutParamsMatch := e.categorizeExamples(mediaType.Examples, keys, eval, opID) // First, try examples with params-match + if ex, k := e.selectFromBucket(withParamsMatch, keys, eval, opID, true); ex != nil { + return ex, k + } + // No matched params-match examples, try those without params-match + return e.selectFromBucket(withoutParamsMatch, keys, eval, opID, false) +} + +// selectFromBucket returns the first example of a bucket (in key order) that +// passes the optional params-match evaluation, marking x-mock-once examples as +// used. It returns nil when none matches. +func (e *exampleEngine) selectFromBucket(bucket map[string]*openapi3.Example, keys []string, eval runtime.Evaluator, opID string, requireMatch bool) (*openapi3.Example, string) { for _, k := range keys { - ex, ok := withParamsMatch[k] + ex, ok := bucket[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 requireMatch { + pm, _ := extensions.ExtractParamsMatch(ex) + matched, err := extensions.EvaluateParamsMatch(pm, eval) + if err != nil { if e.verbose { - slog.Debug("Marked example as used (x-mock-once)", "example", k) + slog.Debug("Error evaluating params-match", "example", k, "error", err) } + continue + } + if !matched { + continue } - 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) + e.registry.markOnceUsed(opID + ":" + k) 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, "" @@ -585,193 +305,3 @@ func (e *exampleEngine) resolveHeaderValue(val any, eval runtime.Evaluator) (str } 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 -} - -// 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/engine_async.go b/internal/server/engine_async.go new file mode 100644 index 0000000..6c975e9 --- /dev/null +++ b/internal/server/engine_async.go @@ -0,0 +1,206 @@ +package server + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "slices" + "strings" + "time" + + "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" +) + +// --------------------------------------------------------------------------- +// 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(runtime.SourceRequest, e.asyncRequestSource(in)) + eval.AddSource(runtime.SourceMessage, &runtime.MessageSource{Payload: jsonPayload(in.Payload), Headers: in.Headers}) + eval.AddSource(runtime.SourceChannel, &runtime.ChannelSource{Params: in.PathParams}) + eval.AddSource(runtime.SourceState, e.NewStateSource(mapping.Prefix)) + eval.AddSource(runtime.SourceEnv, 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(runtime.SourceRequest, e.asyncRequestSource(in)) + evaluator.AddSource(runtime.SourceMessage, &runtime.MessageSource{ + Payload: jsonPayload(in.Payload), + Headers: in.Headers, + }) + evaluator.AddSource(runtime.SourceChannel, &runtime.ChannelSource{Params: in.PathParams}) + evaluator.AddSource(runtime.SourceState, e.NewStateSource(prefix)) + evaluator.AddSource(runtime.SourceEnv, 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 +} + +// 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/engine_expr.go b/internal/server/engine_expr.go new file mode 100644 index 0000000..0e14c2d --- /dev/null +++ b/internal/server/engine_expr.go @@ -0,0 +1,140 @@ +package server + +import ( + "encoding/json" + "strings" + + "github.com/mamonth/oasmock/internal/runtime" +) + +// --------------------------------------------------------------------------- +// 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 + } +} diff --git a/internal/server/engine_state.go b/internal/server/engine_state.go new file mode 100644 index 0000000..9ddd18c --- /dev/null +++ b/internal/server/engine_state.go @@ -0,0 +1,141 @@ +package server + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/mamonth/oasmock/internal/runtime" +) + +// --------------------------------------------------------------------------- +// 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 + } + } +} diff --git a/internal/server/event_broker.go b/internal/server/event_broker.go index f2d88be..a1b8ad7 100644 --- a/internal/server/event_broker.go +++ b/internal/server/event_broker.go @@ -48,6 +48,9 @@ type eventBroker struct { mu sync.RWMutex byEvent map[string][]channelSubscription // identity -> subscriptions deliver eventDeliverer + // done is closed on shutdown so pending delayed fires no longer deliver. + done chan struct{} + stopOne sync.Once } // newEventBroker creates an empty broker. When deliver is nil, fired events @@ -56,9 +59,18 @@ func newEventBroker(deliver eventDeliverer) *eventBroker { return &eventBroker{ byEvent: make(map[string][]channelSubscription), deliver: deliver, + done: make(chan struct{}), } } +// stop cancels any pending delayed deliveries. +func (b *eventBroker) stop() { + if b == nil { + return + } + b.stopOne.Do(func() { close(b.done) }) +} + // sanitizeIdentity maps a subscription identity to a broker key. An empty // identity becomes the wildcard key ("*") so payload-only matches evaluate // against every fired event. @@ -171,7 +183,11 @@ func (b *eventBroker) fire(event string, payload map[string]any, firingSchema st } if delay != nil && delay.ms > 0 { go func() { - time.Sleep(time.Duration(delay.ms) * time.Millisecond) + select { + case <-b.done: + return + case <-time.After(time.Duration(delay.ms) * time.Millisecond): + } b.deliverAll(subs, payload) }() return diff --git a/internal/server/event_delay_test.go b/internal/server/event_delay_test.go index c842043..681c927 100644 --- a/internal/server/event_delay_test.go +++ b/internal/server/event_delay_test.go @@ -63,6 +63,37 @@ func TestEventBus_DelayedEmissionDelaysDelivery(t *testing.T) { }) } +/* +Scenario: Shutdown cancels pending delayed emissions +Given an event-driven example with a large x-mock-delay +When the event fires and the bus shuts down before the delay elapses +Then no delivery occurs after shutdown + +Related spec scenarios: RS.AMG.30 +*/ +func TestEventBus_ShutdownCancelsDelayedEmission(t *testing.T) { + t.Parallel() + + var pushed atomic.Int64 + bus := newEventBus(&stubMessageRenderer{}, &stubConsumerBus{ + wsPush: func(ConsumerInfo, []byte) { pushed.Add(1) }, + }, false) + + spec := loaderExampleSpecForTest(map[string]any{"ring": "{$event.tag}"}, map[string]any{ + "x-mock-match": map[string]any{"{$event.name}": "orderCreated"}, + "x-mock-delay": float64(500), + }) + _, _, err := bus.registerRuntimeExample("ex-1", "/alerts", "", spec) + require.NoError(t, err) + + bus.fire("orderCreated", map[string]any{"tag": "hi"}, "", true, nil) + // Shut down long before the 500ms delay elapses. + bus.shutdown() + + time.Sleep(700 * time.Millisecond) + assert.Zero(t, pushed.Load(), "no delivery may occur after shutdown") +} + /* Scenario: Delayed connect built-in emission Given a connect example declaring x-mock-delay 60 on an event-driven match diff --git a/internal/server/event_deliver.go b/internal/server/event_deliver.go new file mode 100644 index 0000000..1e5dad6 --- /dev/null +++ b/internal/server/event_deliver.go @@ -0,0 +1,185 @@ +package server + +import ( + "cmp" + "encoding/json" + "log/slog" + "time" + + "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/loader" + "github.com/mamonth/oasmock/internal/runtime" +) + +func (b *eventBus) deliver(sub channelSubscription, payload map[string]any) { + b.deliverTo(sub, payload, nil) +} + +// deliverTargeted delivers a built-in event to a single candidate connection. +// The connection bucket (if any) is evaluated against that one recipient only; +// with no connection conditions the message is pushed to the recipient alone +// (RS.EVT.24, RS.EXT.26). +func (b *eventBus) deliverTargeted(sub channelSubscription, payload map[string]any, recipient ConsumerInfo) { + b.deliverTo(sub, payload, &recipient) +} + +// deliverTo runs the shared delayed-emission + delivery pipeline for a +// subscription. When target is non-nil, delivery is restricted to that single +// candidate (built-in connect recipient). +func (b *eventBus) deliverTo(sub channelSubscription, payload map[string]any, target *ConsumerInfo) { + if len(sub.messages) == 0 { + return + } + if sub.delay > 0 { + ms := sub.delay + sub.delay = 0 + go func() { + select { + case <-b.done: + return + case <-time.After(time.Duration(ms) * time.Millisecond): + } + b.deliverTo(sub, payload, target) + }() + return + } + deliverable := sub.messages[0] + addr := sub.address + prefix := deliverable.prefix + eventName := sub.event + opID := "event:" + cmp.Or(eventName, anyEventIdentity) + ":" + addr + + b.deliverExample(sub, deliverable.spec.Examples, addr, prefix, eventName, payload, opID, target) +} + +// stateEnvEvaluator wires the fixed state and environment sources shared by +// every emission path (periodic deliveries have no event/connection context). +func (b *eventBus) stateEnvEvaluator(prefix string) runtime.Evaluator { + eval := runtime.NewEvaluator() + eval.AddSource(runtime.SourceState, b.renderer.NewStateSource(prefix)) + eval.AddSource(runtime.SourceEnv, b.renderer.NewEnvSource()) + return eval +} + +// eventEvaluator wires the fixed emission sources (state, env, event) plus an +// optional per-connection source into a fresh evaluator. +func (b *eventBus) eventEvaluator(state, env runtime.DataSource, eventName string, payload map[string]any, connection *runtime.ConnectionSource) runtime.Evaluator { + eval := runtime.NewEvaluator() + eval.AddSource(runtime.SourceState, state) + eval.AddSource(runtime.SourceEnv, env) + eval.AddSource(runtime.SourceEvent, &runtime.EventSource{Name: eventName, Data: payload}) + if connection != nil { + eval.AddSource(runtime.SourceConnection, connection) + } + return eval +} + +// renderExample renders a single example's payload against an evaluator, +// honoring x-mock-skip and x-mock-set-state. It returns the rendered body, or +// nil when the example is skipped or rendering fails (verbose-logged). +func (b *eventBus) renderExample(view *MessageExampleView, eval runtime.Evaluator, prefix, opID string) []byte { + if extensions.ValueSkip(view) { + return nil + } + if stateMap, ok := extensions.ValueSetState(view); ok { + b.renderer.ApplySetState(stateMap, eval, prefix) + } + body, err := b.renderer.RenderAsyncPayload(view, eval) + if err != nil { + if b.verbose { + slog.Debug("Example delivery render failed", "opID", opID, "err", err) + } + return nil + } + return body +} + +// evaluateConnectionBucket evaluates an example's connection conditions +// against one candidate recipient. An empty bucket matches every candidate. +func (b *eventBus) evaluateConnectionBucket(bucket extensions.ParamsMatch, state, env runtime.DataSource, eventName string, payload map[string]any, candidate ConsumerInfo) (bool, error) { + if len(bucket) == 0 { + return true, nil + } + eval := b.eventEvaluator(state, env, eventName, payload, connectionSourceFromInfo(candidate)) + return extensions.EvaluateParamsMatch(bucket, eval) +} + +// deliverExample runs the shared selection + render + recipient-partition +// pipeline for one subscription's examples. When target is non-nil, delivery +// is restricted to that single candidate (built-in connect recipient). +func (b *eventBus) deliverExample(sub channelSubscription, examples []*loader.MessageExampleSpec, addr, prefix, eventName string, payload map[string]any, opID string, target *ConsumerInfo) { + // Fixed (non-connection) sources evaluated once per emission. + state := b.renderer.NewStateSource(prefix) + env := b.renderer.NewEnvSource() + + for _, example := range examples { + view := &MessageExampleView{spec: example} + common, connection := b.partitionedMatch(view) + var connSource *runtime.ConnectionSource + if target != nil { + connSource = connectionSourceFromInfo(*target) + } + evaluator := b.eventEvaluator(state, env, eventName, payload, connSource) + if len(common) > 0 { + ok, cErr := extensions.EvaluateParamsMatch(common, evaluator) + if cErr != nil || !ok { + continue + } + } + body := b.renderExample(view, evaluator, prefix, opID) + if body == nil { + continue + } + if target != nil { + // Built-in recipient: evaluate the connection bucket against the + // single candidate and deliver on match (or immediately when there + // is no connection bucket). + ok, okErr := b.evaluateConnectionBucket(connection, state, env, eventName, payload, *target) + if okErr != nil || !ok { + continue + } + b.notifyPush(addr, target.ConnectionID, body) + b.bus.PushTo(*target, addr, body) + continue + } + if len(connection) == 0 { + // Broadcast fast path (RS.EXT.25). + b.notifyPush(addr, "", body) + b.bus.SignalRPush(addr, body) + b.bus.WSBroadcast(addr, body) + continue + } + // Per-connection partition: evaluate the connection bucket against each + // candidate with its connection context (design D6). + for _, candidate := range b.bus.Candidates(addr) { + ok, okErr := b.evaluateConnectionBucket(connection, state, env, eventName, payload, candidate) + if okErr != nil || !ok { + continue + } + b.notifyPush(addr, candidate.ConnectionID, body) + b.bus.PushTo(candidate, addr, body) + } + } +} + +// notifyPush emits a push envelope to the management observer (RS.AMG.25). +func (b *eventBus) notifyPush(channel, connectionID string, body []byte) { + if b.observer == nil { + return + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + payload = map[string]any{"raw": string(body)} + } + env := manageEnvelope{Type: "push"} + env.Push = &managePushEnvelope{Channel: channel, ConnectionID: connectionID, Payload: payload} + b.observer(env) +} + +// partitionedMatch splits an example's x-mock-match into common conditions +// (evaluated once per emission) and connection conditions (evaluated per +// candidate recipient). A nil/absent match yields empty buckets. +func (b *eventBus) partitionedMatch(view *MessageExampleView) (extensions.ParamsMatch, extensions.ParamsMatch) { + match, _ := extensions.ValueMatch(view) + return extensions.PartitionConnectionConditions(extensions.ParamsMatch(match)) +} diff --git a/internal/server/event_server.go b/internal/server/event_server.go index 1e9943a..dd67a75 100644 --- a/internal/server/event_server.go +++ b/internal/server/event_server.go @@ -2,15 +2,14 @@ package server import ( "cmp" - "encoding/json" "fmt" "log/slog" + "sync" "time" "github.com/mamonth/oasmock/internal/asyncapi" "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" - "github.com/mamonth/oasmock/internal/runtime" ) // eventBus is the pure-fabrication coordinator behind the event driver @@ -29,6 +28,21 @@ type eventBus struct { // observer, when set, is invoked with every emitted envelope so the // management stream can mirror fired events and deliveries (RS.AMG.24-25). observer func(env manageEnvelope) + // done is closed on shutdown so pending delayed emissions no longer run. + done chan struct{} + stopOne sync.Once +} + +// doneChannel returns the bus shutdown signal so callers that schedule work on +// their own goroutines (e.g. delayed management pushes) can cancel it when the +// bus shuts down. +func (b *eventBus) doneChannel() <-chan struct{} { + if b == nil { + ch := make(chan struct{}) + close(ch) + return ch + } + return b.done } // setObserver installs the management-stream observer. @@ -45,10 +59,12 @@ func newEventBus(renderer MessageRenderer, bus ConsumerBus, verbose bool) *event scheduler: newJobScheduler(), verbose: verbose, wait: time.Sleep, + done: make(chan struct{}), } b.broker = &eventBroker{ byEvent: make(map[string][]channelSubscription), deliver: b.deliver, + done: make(chan struct{}), } return b } @@ -94,12 +110,15 @@ func (b *eventBus) hasSubscribers(name, schema string) bool { return b.broker != nil && b.broker.hasSubscribers(name, schema) } -// shutdown cancels all periodic interval jobs. +// shutdown cancels all periodic interval jobs and cancels pending delayed +// emissions so no delivery happens after shutdown. func (b *eventBus) shutdown() { if b == nil || b.scheduler == nil { return } b.scheduler.shutdown() + b.stopOne.Do(func() { close(b.done) }) + b.broker.stop() } // registerEventSubscriptions scans AsyncAPI schemas, classifies each message @@ -136,45 +155,9 @@ func (b *eventBus) registerSchema(prefix string, doc *asyncapi.Document) error { address := asyncAddressWithPrefix(prefix, ch.Address) for _, msg := range ch.Messages { for _, ex := range msg.Examples { - if ex == nil { - continue - } - derived, err := b.derivedExamples(ex) - if err != nil { + if err := b.classifyMessageExample(ch.ID, msg.Name, address, prefix, ex, &subs, &periodic); err != nil { return err } - for _, spec := range derived { - trig, err := extensions.ClassifyTrigger(&MessageExampleView{spec: spec}) - if err != nil { - return fmt.Errorf("channel %q example %q: %w", ch.ID, ex.Name, err) - } - switch trig.Kind { - case extensions.TriggerEvent: - subs = append(subs, channelSubscription{ - address: address, - event: trig.Identity, - delay: trig.Delay, - messages: []*messageDeliverable{{ - spec: &loader.MessageSpec{Name: msg.Name, Examples: []*loader.MessageExampleSpec{spec}}, - prefix: prefix, - }}, - }) - case extensions.TriggerPeriodic: - periodic = append(periodic, periodicRegistration{ - address: address, prefix: prefix, exampleID: ex.Name, spec: spec, interval: trig.Interval, - }) - case extensions.TriggerReply: - // Reply examples are served by the channel's normal - // reply path; nothing to register here. A match that - // still references {$connection.*} can never evaluate - // (no connection context in the reply path), so point it - // out in verbose mode instead of failing silently. - if b.verbose && extensions.MatchReferencesConnection(trig.Match) { - slog.Warn("reply example references {$connection.*} which never matches without an event context; remove the connection condition or make the example event-driven", - "channel", ch.ID, "example", ex.Name) - } - } - } } } } @@ -189,6 +172,53 @@ func (b *eventBus) registerSchema(prefix string, doc *asyncapi.Document) error { return nil } +// classifyMessageExample classifies one spec example (and its legacy +// x-send-events derivations) into an event subscription, a periodic +// registration, or nothing (a plain reply), appending to the commit-stage +// accumulators. +func (b *eventBus) classifyMessageExample(channelID, msgName, address, prefix string, ex *asyncapi.Example, subs *[]channelSubscription, periodic *[]periodicRegistration) error { + if ex == nil { + return nil + } + derived, err := b.derivedExamples(ex) + if err != nil { + return err + } + for _, spec := range derived { + trig, err := extensions.ClassifyTrigger(&MessageExampleView{spec: spec}) + if err != nil { + return fmt.Errorf("channel %q example %q: %w", channelID, ex.Name, err) + } + switch trig.Kind { + case extensions.TriggerEvent: + *subs = append(*subs, channelSubscription{ + address: address, + event: trig.Identity, + delay: trig.Delay, + messages: []*messageDeliverable{{ + spec: &loader.MessageSpec{Name: msgName, Examples: []*loader.MessageExampleSpec{spec}}, + prefix: prefix, + }}, + }) + case extensions.TriggerPeriodic: + *periodic = append(*periodic, periodicRegistration{ + address: address, prefix: prefix, exampleID: ex.Name, spec: spec, interval: trig.Interval, + }) + case extensions.TriggerReply: + // Reply examples are served by the channel's normal reply path; + // nothing to register here. A match that still references + // {$connection.*} can never evaluate (no connection context in the + // reply path), so point it out in verbose mode instead of failing + // silently. + if b.verbose && extensions.MatchReferencesConnection(trig.Match) { + slog.Warn("reply example references {$connection.*} which never matches without an event context; remove the connection condition or make the example event-driven", + "channel", channelID, "example", ex.Name) + } + } + } + return nil +} + // periodicRegistration is a validated periodically driven example awaiting // scheduler registration after the classification pass of registerSchema. type periodicRegistration struct { @@ -337,8 +367,13 @@ func (b *eventBus) derivedExamples(ex *asyncapi.Example) ([]*loader.MessageExamp if b.verbose { slog.Warn("x-send-events is deprecated; use x-mock-match: {'{$event.name}': }", "example", ex.Name) } - match, _ := ext["x-mock-match"].(map[string]any) - if match == nil { + match, ok := ext["x-mock-match"].(map[string]any) + if !ok { + // A pre-existing, non-object x-mock-match would be silently lost + // if overwritten; fail loud so the spec author fixes it. + if _, present := ext["x-mock-match"]; present { + return nil, fmt.Errorf("example %q: x-mock-match must be an object when combined with x-send-events", ex.Name) + } match = make(map[string]any) } match["{$event.name}"] = ev.On @@ -359,10 +394,20 @@ func (b *eventBus) derivedExamples(ex *asyncapi.Example) ([]*loader.MessageExamp return out, nil } -// cloneExtensions deep-copies an example's extension map. +// cloneExtensions deep-copies an example's extension map. Nested maps (e.g. +// x-mock-match) are copied too, so a legacy example with several x-send-events +// entries never shares the match map across the derived clones. func cloneExtensions(ext map[string]any) map[string]any { out := make(map[string]any, len(ext)) for k, v := range ext { + if m, ok := v.(map[string]any); ok { + inner := make(map[string]any, len(m)) + for ik, iv := range m { + inner[ik] = iv + } + out[k] = inner + continue + } out[k] = v } return out @@ -373,171 +418,3 @@ func cloneExtensions(ext map[string]any) map[string]any { // example's match references {$connection.*} (design D6, RS.EXT.24-25). An // example-level x-mock-delay schedules the emission that far after the fire // (RS.EXT.23). -func (b *eventBus) deliver(sub channelSubscription, payload map[string]any) { - b.deliverTo(sub, payload, nil) -} - -// deliverTargeted delivers a built-in event to a single candidate connection. -// The connection bucket (if any) is evaluated against that one recipient only; -// with no connection conditions the message is pushed to the recipient alone -// (RS.EVT.24, RS.EXT.26). -func (b *eventBus) deliverTargeted(sub channelSubscription, payload map[string]any, recipient ConsumerInfo) { - b.deliverTo(sub, payload, &recipient) -} - -// deliverTo runs the shared delayed-emission + delivery pipeline for a -// subscription. When target is non-nil, delivery is restricted to that single -// candidate (built-in connect recipient). -func (b *eventBus) deliverTo(sub channelSubscription, payload map[string]any, target *ConsumerInfo) { - if len(sub.messages) == 0 { - return - } - if sub.delay > 0 { - ms := sub.delay - sub.delay = 0 - go func() { - b.wait(time.Duration(ms) * time.Millisecond) - b.deliverTo(sub, payload, target) - }() - return - } - deliverable := sub.messages[0] - addr := sub.address - prefix := deliverable.prefix - eventName := sub.event - opID := "event:" + cmp.Or(eventName, anyEventIdentity) + ":" + addr - - b.deliverExample(sub, deliverable.spec.Examples, addr, prefix, eventName, payload, opID, target) -} - -// stateEnvEvaluator wires the fixed state and environment sources shared by -// every emission path (periodic deliveries have no event/connection context). -func (b *eventBus) stateEnvEvaluator(prefix string) runtime.Evaluator { - eval := runtime.NewEvaluator() - eval.AddSource("state", b.renderer.NewStateSource(prefix)) - eval.AddSource("env", b.renderer.NewEnvSource()) - return eval -} - -// eventEvaluator wires the fixed emission sources (state, env, event) plus an -// optional per-connection source into a fresh evaluator. -func (b *eventBus) eventEvaluator(state, env runtime.DataSource, eventName string, payload map[string]any, connection *runtime.ConnectionSource) runtime.Evaluator { - eval := runtime.NewEvaluator() - eval.AddSource("state", state) - eval.AddSource("env", env) - eval.AddSource("event", &runtime.EventSource{Name: eventName, Data: payload}) - if connection != nil { - eval.AddSource("connection", connection) - } - return eval -} - -// renderExample renders a single example's payload against an evaluator, -// honoring x-mock-skip and x-mock-set-state. It returns the rendered body, or -// nil when the example is skipped or rendering fails (verbose-logged). -func (b *eventBus) renderExample(view *MessageExampleView, eval runtime.Evaluator, prefix, opID string) []byte { - if extensions.ValueSkip(view) { - return nil - } - if stateMap, ok := extensions.ValueSetState(view); ok { - b.renderer.ApplySetState(stateMap, eval, prefix) - } - body, err := b.renderer.RenderAsyncPayload(view, eval) - if err != nil { - if b.verbose { - slog.Debug("Example delivery render failed", "opID", opID, "err", err) - } - return nil - } - return body -} - -// evaluateConnectionBucket evaluates an example's connection conditions -// against one candidate recipient. An empty bucket matches every candidate. -func (b *eventBus) evaluateConnectionBucket(bucket extensions.ParamsMatch, state, env runtime.DataSource, eventName string, payload map[string]any, candidate ConsumerInfo) (bool, error) { - if len(bucket) == 0 { - return true, nil - } - eval := b.eventEvaluator(state, env, eventName, payload, connectionSourceFromInfo(candidate)) - return extensions.EvaluateParamsMatch(bucket, eval) -} - -// deliverExample runs the shared selection + render + recipient-partition -// pipeline for one subscription's examples. When target is non-nil, delivery -// is restricted to that single candidate (built-in connect recipient). -func (b *eventBus) deliverExample(sub channelSubscription, examples []*loader.MessageExampleSpec, addr, prefix, eventName string, payload map[string]any, opID string, target *ConsumerInfo) { - // Fixed (non-connection) sources evaluated once per emission. - state := b.renderer.NewStateSource(prefix) - env := b.renderer.NewEnvSource() - - for _, example := range examples { - view := &MessageExampleView{spec: example} - common, connection := b.partitionedMatch(view) - var connSource *runtime.ConnectionSource - if target != nil { - connSource = connectionSourceFromInfo(*target) - } - evaluator := b.eventEvaluator(state, env, eventName, payload, connSource) - if len(common) > 0 { - ok, cErr := extensions.EvaluateParamsMatch(common, evaluator) - if cErr != nil || !ok { - continue - } - } - body := b.renderExample(view, evaluator, prefix, opID) - if body == nil { - continue - } - if target != nil { - // Built-in recipient: evaluate the connection bucket against the - // single candidate and deliver on match (or immediately when there - // is no connection bucket). - ok, okErr := b.evaluateConnectionBucket(connection, state, env, eventName, payload, *target) - if okErr != nil || !ok { - continue - } - b.notifyPush(addr, target.ConnectionID, body) - b.bus.PushTo(*target, addr, body) - continue - } - if len(connection) == 0 { - // Broadcast fast path (RS.EXT.25). - b.notifyPush(addr, "", body) - b.bus.SignalRPush(addr, body) - b.bus.WSBroadcast(addr, body) - continue - } - // Per-connection partition: evaluate the connection bucket against each - // candidate with its connection context (design D6). - for _, candidate := range b.bus.Candidates(addr) { - ok, okErr := b.evaluateConnectionBucket(connection, state, env, eventName, payload, candidate) - if okErr != nil || !ok { - continue - } - b.notifyPush(addr, candidate.ConnectionID, body) - b.bus.PushTo(candidate, addr, body) - } - } -} - -// notifyPush emits a push envelope to the management observer (RS.AMG.25). -func (b *eventBus) notifyPush(channel, connectionID string, body []byte) { - if b.observer == nil { - return - } - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - payload = map[string]any{"raw": string(body)} - } - env := manageEnvelope{Type: "push"} - env.Push = &managePushEnvelope{Channel: channel, ConnectionID: connectionID, Payload: payload} - b.observer(env) -} - -// partitionedMatch splits an example's x-mock-match into common conditions -// (evaluated once per emission) and connection conditions (evaluated per -// candidate recipient). A nil/absent match yields empty buckets. -func (b *eventBus) partitionedMatch(view *MessageExampleView) (extensions.ParamsMatch, extensions.ParamsMatch) { - match, _ := extensions.ValueMatch(view) - return extensions.PartitionConnectionConditions(extensions.ParamsMatch(match)) -} diff --git a/internal/server/fire_event.go b/internal/server/fire_event.go index c33cce3..fc26fee 100644 --- a/internal/server/fire_event.go +++ b/internal/server/fire_event.go @@ -1,8 +1,6 @@ package server import ( - "encoding/json" - "io" "net/http" "github.com/mamonth/oasmock/internal/runtime" @@ -23,13 +21,8 @@ type fireEventRequest struct { // old contract so pre-change clients keep working (design D1); the canonical // /_mock/events endpoint still requires the type discriminator. func (s *Server) handleFireEventLegacy(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) - if err != nil { - writeJSONError(w, http.StatusBadRequest, "failed to read request body") - return - } var req fireEventRequest - if err := json.Unmarshal(body, &req); err != nil { + if err := decodeJSONBody(r, &req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON body") return } @@ -41,13 +34,8 @@ func (s *Server) handleFireEventLegacy(w http.ResponseWriter, r *http.Request) { // broker. The type discriminator is required and only "fire" is accepted // (RS.MAPI.32); fire reuses the existing ad-hoc fire semantics. func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) - if err != nil { - writeJSONError(w, http.StatusBadRequest, "failed to read request body") - return - } var req fireEventRequest - if err := json.Unmarshal(body, &req); err != nil { + if err := decodeJSONBody(r, &req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON body") return } @@ -84,8 +72,8 @@ func (s *Server) dispatchFireEvent(w http.ResponseWriter, req fireEventRequest) // (RS.MAPI.23). if len(req.Payload) > 0 { eval := runtime.NewEvaluator() - eval.AddSource("state", s.newStateSource("")) - eval.AddSource("env", s.newEnvSource()) + eval.AddSource(runtime.SourceState, s.newStateSource("")) + eval.AddSource(runtime.SourceEnv, s.newEnvSource()) resolved, err := s.evaluateValue(req.Payload, eval) if err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) @@ -103,8 +91,7 @@ func (s *Server) dispatchFireEvent(w http.ResponseWriter, req fireEventRequest) // fires only reach empty-prefix subscriptions (use global: true for // prefixed channels). s.eventBus.fire(req.Event, req.Payload, "", req.Global, triggerDelay(req.Delay)) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ + writeJSON(w, http.StatusOK, map[string]any{ "success": true, "event": req.Event, }) diff --git a/internal/server/history_test.go b/internal/server/history_test.go index a87d9c2..1def34e 100644 --- a/internal/server/history_test.go +++ b/internal/server/history_test.go @@ -19,7 +19,7 @@ Related spec scenarios: RS.ATM.15 func TestServer_RecordAsyncExchange(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) // Use a real ring buffer behind the mock to observe the Add. realStore := newHistoryRingBufferStore(history.NewRingBuffer(32)) @@ -52,7 +52,7 @@ Related spec scenarios: RS.ATM.15 func TestServer_RecordAsyncExchange_NoResponse(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) realStore := newHistoryRingBufferStore(history.NewRingBuffer(32)) srv.historyStore = realStore srv.engine.historyStore = realStore diff --git a/internal/server/http_adapter.go b/internal/server/http_adapter.go index a2479de..b805cd7 100644 --- a/internal/server/http_adapter.go +++ b/internal/server/http_adapter.go @@ -4,6 +4,8 @@ import ( "io" "net/http" "strings" + + "github.com/mamonth/oasmock/internal/asyncapi" ) // httpProtocolAdapter serves AsyncAPI http channels by reusing the HTTP mock @@ -11,7 +13,7 @@ import ( type httpProtocolAdapter struct{} // Protocol implements ProtocolAdapter. -func (a *httpProtocolAdapter) Protocol() string { return asyncHTTPProtocol } +func (a *httpProtocolAdapter) Protocol() string { return asyncapi.ProtocolHTTP } // Handler builds the HTTP handler that renders an AsyncAPI http channel route. func (a *httpProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandler) http.HandlerFunc { diff --git a/internal/server/hubmanager.go b/internal/server/hubmanager.go index b856092..c7e4ff6 100644 --- a/internal/server/hubmanager.go +++ b/internal/server/hubmanager.go @@ -45,6 +45,22 @@ func (m *hubManager) hubForAddress(address string) *signalRHub { return nil } +// hubChannelForAddress finds the SignalR hub channel whose fully-prefixed +// address matches, returning the hub and its channel id. It is the single +// address-resolution point used by every delivery and discovery path. +func (m *hubManager) hubChannelForAddress(address string) (*signalRHub, string) { + hub := m.hubForAddress(address) + if hub == nil { + return nil, "" + } + for channelID, ch := range hub.channels { + if asyncAddressWithPrefix(hub.prefix, ch.Address) == address { + return hub, channelID + } + } + 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 { @@ -61,14 +77,9 @@ func (m *hubManager) hasConnection(id string) bool { // 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) - } + hub, channelID := m.hubChannelForAddress(address) + if hub != nil { + hub.pushToStreams(channelID, payload, channelID) } } @@ -82,7 +93,11 @@ func (m *hubManager) WSBroadcast(address string, payload []byte) { } // Candidates returns every consumer of a channel address (raw ws and SignalR) -// with the connection context captured at upgrade. +// with the connection context captured at upgrade. SignalR candidates are +// deduplicated per connection: a connection with several open streams on the +// channel appears once, carrying all of its streams, so a per-connection +// PushTo (which writes to every open stream) cannot duplicate delivery +// quadratically (RS.SHR.22). func (m *hubManager) Candidates(address string) []ConsumerInfo { var out []ConsumerInfo if m.ws != nil { @@ -95,20 +110,22 @@ func (m *hubManager) Candidates(address string) []ConsumerInfo { }) } } - if hub := m.hubForAddress(address); hub != nil { - for channelID, ch := range hub.channels { - if asyncAddressWithPrefix(hub.prefix, ch.Address) != address { + if hub, channelID := m.hubChannelForAddress(address); hub != nil { + seen := make(map[string]int) // connectionID -> index in out + for _, st := range hub.openStreamsForChannel(channelID) { + connID := st["connectionId"] + if idx, ok := seen[connID]; ok { + out[idx].Streams = append(out[idx].Streams, st) continue } - for _, st := range hub.openStreamsForChannel(channelID) { - out = append(out, ConsumerInfo{ - ConnectionID: st["connectionId"], - Channel: address, - Query: hub.connectionMetadata(st["connectionId"]), - Headers: hub.connectionHeaders(st["connectionId"]), - Streams: []map[string]string{st}, - }) - } + seen[connID] = len(out) + out = append(out, ConsumerInfo{ + ConnectionID: connID, + Channel: address, + Query: hub.connectionMetadata(connID), + Headers: hub.connectionHeaders(connID), + Streams: []map[string]string{st}, + }) } } return out @@ -145,15 +162,9 @@ func (m *hubManager) PushTo(consumer ConsumerInfo, address string, payload []byt return } } - hub := m.hubForAddress(address) + hub, channelID := m.hubChannelForAddress(address) if hub == nil { return } - for channelID, ch := range hub.channels { - if asyncAddressWithPrefix(hub.prefix, ch.Address) != address { - continue - } - hub.pushToConnection(consumer.ConnectionID, channelID, payload, channelID) - return - } + hub.pushToConnection(consumer.ConnectionID, channelID, payload, channelID) } diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index 7bdec12..9c9714b 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -1,13 +1,9 @@ package server -//go:generate mockgen -destination=interfaces_mock_test.go -package=server . RouteProvider,StateStore,HistoryStore,DataSource,RequestSourceFactory,StateSourceFactory,EnvSourceFactory,ExpressionEvaluator,ExtensionProcessor,RpcProtocol +//go:generate mockgen -destination=interfaces_mock_test.go -package=server . RouteProvider,StateStore,HistoryStore,RpcProtocol import ( - "net/http" - "time" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/mamonth/oasmock/internal/asyncapi" + "github.com/mamonth/oasmock/internal/history" "github.com/mamonth/oasmock/internal/loader" "github.com/mamonth/oasmock/internal/runtime" ) @@ -18,31 +14,14 @@ type RouteProvider interface { BuildRouteMappings(schemas []SchemaInfo) ([]RouteMapping, error) } -// 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}") - Pattern string // The path pattern without prefix (e.g., "/users/{id}") - Prefix string // The prefix for this route (e.g., "/v1") - ChiPattern string // Path converted to Chi pattern (e.g., "/v1/users/:id") - 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 -} +// RouteMapping is a route mapping for a single OpenAPI operation or AsyncAPI +// channel/operation. It aliases loader.RouteMapping so the server never owns a +// mirror copy of the loader's routing model (single source of truth). +type RouteMapping = loader.RouteMapping -// 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 -} +// SchemaInfo holds a loaded spec (OpenAPI or AsyncAPI) and its path prefix. It +// aliases loader.SchemaInfo; the server consumes the loader's model directly. +type SchemaInfo = loader.SchemaInfo // StateStore manages state per namespace. type StateStore interface { @@ -76,83 +55,43 @@ type HistoryStore interface { Clear() } -// RequestRecord captures details of an HTTP request served by the mock. -type RequestRecord struct { - ID string `json:"id"` - Timestamp time.Time `json:"timestamp"` - Method string `json:"method"` - Path string `json:"path"` - Query string `json:"query,omitempty"` - Headers http.Header `json:"headers"` - Body []byte `json:"body,omitempty"` - Response *ResponseRecord `json:"response,omitempty"` -} - -// ResponseRecord captures details of the HTTP response. -type ResponseRecord struct { - StatusCode int `json:"statusCode"` - Headers http.Header `json:"headers"` - Body []byte `json:"body,omitempty"` - Duration time.Duration `json:"duration"` -} +// RequestRecord captures details of an HTTP request served by the mock. It +// aliases history.RequestRecord so the server and store share one record type. +type RequestRecord = history.RequestRecord -// DataSource represents a source of data for runtime expressions. -type DataSource interface { - // Get retrieves a value from the data source by path. - // Path is a dot-separated string (e.g., "path.id", "query.page"). - // Returns the value and true if found, nil and false otherwise. - Get(path string) (any, bool) -} - -// RequestSourceFactory creates DataSource instances for HTTP requests. -type RequestSourceFactory interface { - // NewRequestSource creates a DataSource from an HTTP request and path parameters. - NewRequestSource(r *http.Request, pathParams map[string]string) DataSource -} - -// StateSourceFactory creates DataSource instances for state. -type StateSourceFactory interface { - // NewStateSource creates a DataSource for the given namespace. - NewStateSource(namespace string) DataSource -} - -// EnvSourceFactory creates DataSource instances for environment variables. -type EnvSourceFactory interface { - // NewEnvSource creates a DataSource for environment variables. - NewEnvSource() DataSource -} - -// ExpressionEvaluator evaluates runtime expressions. -type ExpressionEvaluator interface { - // AddSource adds a data source with the given name. - AddSource(name string, source DataSource) - // Evaluate evaluates an expression and returns the result. - Evaluate(expr string) (any, error) -} - -// ExtensionProcessor processes OpenAPI extensions. -type ExtensionProcessor interface { - // ExtractSetState extracts x-mock-set-state extension from an example. - ExtractSetState(example *openapi3.Example) (map[string]any, bool) - // ExtractSkip extracts x-mock-skip extension from an example. - ExtractSkip(example *openapi3.Example) bool - // ExtractOnce extracts x-mock-once extension from an example. - ExtractOnce(example *openapi3.Example) bool - // ExtractParamsMatch extracts x-mock-params-match extension from an example. - ExtractParamsMatch(example *openapi3.Example) (map[string]any, bool) - // EvaluateParamsMatch evaluates a params match against an evaluator. - EvaluateParamsMatch(params map[string]any, eval ExpressionEvaluator) (bool, error) - // ExtractHeaders extracts x-mock-headers extension from an example. - ExtractHeaders(example *openapi3.Example) (map[string]any, bool) -} +// ResponseRecord captures details of the HTTP response. It aliases +// history.ResponseRecord. +type ResponseRecord = history.ResponseRecord // RpcProtocol parses RPC request bodies and formats error responses. type RpcProtocol interface { - ParseBody(body []byte) ([]RpcCall, error) + // ParseBody parses a request body into an ordered sequence of entries. A + // valid call yields an entry with Call set; a malformed batch element + // yields an entry with Error set (code -32600) and does not abort the + // other elements. It returns a fatal error only when the body itself is + // unparseable JSON or is neither an object nor an array; the error carries + // a JSON-RPC code (see rpcErrorCode). + ParseBody(body []byte) ([]RpcEntry, error) ErrorResponse(code int, message string, id any) []byte ContentType() string } +// RpcEntry is one slot of a parsed JSON-RPC body: either a valid call or a +// per-element protocol error, preserving the request order for batch responses. +type RpcEntry struct { + Call *RpcCall // non-nil for a valid call + Error *RpcParsedError // non-nil for a malformed element +} + +// RpcParsedError is a per-element JSON-RPC error captured during batch +// parsing (for example a batch element missing the jsonrpc or method field). +// Per the JSON-RPC 2.0 spec, a malformed element is answered with -32600 and +// does not abort the other elements. +type RpcParsedError struct { + Code int + ID any +} + // RpcCall represents a single parsed RPC call. type RpcCall struct { Procedure string @@ -161,16 +100,13 @@ type RpcCall struct { HasID bool } -// Dependencies holds all dependencies for the Server. +// Dependencies holds all dependencies for the Server. Only the stores and the +// route provider are injected; the example engine owns runtime-expression +// evaluation, data-source construction and extension processing directly. type Dependencies struct { - RouteProvider RouteProvider - StateStore StateStore - HistoryStore HistoryStore - RequestSourceFactory RequestSourceFactory - StateSourceFactory StateSourceFactory - EnvSourceFactory EnvSourceFactory - ExpressionEvaluator ExpressionEvaluator - ExtensionProcessor ExtensionProcessor + RouteProvider RouteProvider + StateStore StateStore + HistoryStore HistoryStore } // MessageRenderer is the message-rendering surface consumed by the SignalR diff --git a/internal/server/interfaces_mock_test.go b/internal/server/interfaces_mock_test.go index b28246e..a21ee86 100644 --- a/internal/server/interfaces_mock_test.go +++ b/internal/server/interfaces_mock_test.go @@ -1,15 +1,15 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/mamonth/oasmock/internal/server (interfaces: RouteProvider,StateStore,HistoryStore,DataSource,RequestSourceFactory,StateSourceFactory,EnvSourceFactory,ExpressionEvaluator,ExtensionProcessor,RpcProtocol) +// Source: interfaces.go // Package server is a generated GoMock package. package server import ( - http "net/http" reflect "reflect" - openapi3 "github.com/getkin/kin-openapi/openapi3" gomock "github.com/golang/mock/gomock" + loader "github.com/mamonth/oasmock/internal/loader" + runtime "github.com/mamonth/oasmock/internal/runtime" ) // MockRouteProvider is a mock of RouteProvider interface. @@ -36,18 +36,18 @@ func (m *MockRouteProvider) EXPECT() *MockRouteProviderMockRecorder { } // BuildRouteMappings mocks base method. -func (m *MockRouteProvider) BuildRouteMappings(arg0 []SchemaInfo) ([]RouteMapping, error) { +func (m *MockRouteProvider) BuildRouteMappings(schemas []SchemaInfo) ([]RouteMapping, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BuildRouteMappings", arg0) + ret := m.ctrl.Call(m, "BuildRouteMappings", schemas) ret0, _ := ret[0].([]RouteMapping) ret1, _ := ret[1].(error) return ret0, ret1 } // BuildRouteMappings indicates an expected call of BuildRouteMappings. -func (mr *MockRouteProviderMockRecorder) BuildRouteMappings(arg0 interface{}) *gomock.Call { +func (mr *MockRouteProviderMockRecorder) BuildRouteMappings(schemas interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildRouteMappings", reflect.TypeOf((*MockRouteProvider)(nil).BuildRouteMappings), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildRouteMappings", reflect.TypeOf((*MockRouteProvider)(nil).BuildRouteMappings), schemas) } // MockStateStore is a mock of StateStore interface. @@ -74,37 +74,37 @@ func (m *MockStateStore) EXPECT() *MockStateStoreMockRecorder { } // Delete mocks base method. -func (m *MockStateStore) Delete(arg0, arg1 string) { +func (m *MockStateStore) Delete(namespace, key string) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Delete", arg0, arg1) + m.ctrl.Call(m, "Delete", namespace, key) } // Delete indicates an expected call of Delete. -func (mr *MockStateStoreMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Delete(namespace, key interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStateStore)(nil).Delete), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStateStore)(nil).Delete), namespace, key) } // Get mocks base method. -func (m *MockStateStore) Get(arg0, arg1 string) (interface{}, bool) { +func (m *MockStateStore) Get(namespace, key string) (any, bool) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1) - ret0, _ := ret[0].(interface{}) + ret := m.ctrl.Call(m, "Get", namespace, key) + ret0, _ := ret[0].(any) ret1, _ := ret[1].(bool) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockStateStoreMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Get(namespace, key interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStateStore)(nil).Get), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStateStore)(nil).Get), namespace, key) } // GetAll mocks base method. -func (m *MockStateStore) GetAll() map[string]map[string]interface{} { +func (m *MockStateStore) GetAll() map[string]map[string]any { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAll") - ret0, _ := ret[0].(map[string]map[string]interface{}) + ret0, _ := ret[0].(map[string]map[string]any) return ret0 } @@ -115,44 +115,44 @@ func (mr *MockStateStoreMockRecorder) GetAll() *gomock.Call { } // GetNamespace mocks base method. -func (m *MockStateStore) GetNamespace(arg0 string) map[string]interface{} { +func (m *MockStateStore) GetNamespace(namespace string) map[string]any { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNamespace", arg0) - ret0, _ := ret[0].(map[string]interface{}) + ret := m.ctrl.Call(m, "GetNamespace", namespace) + ret0, _ := ret[0].(map[string]any) return ret0 } // GetNamespace indicates an expected call of GetNamespace. -func (mr *MockStateStoreMockRecorder) GetNamespace(arg0 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) GetNamespace(namespace interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespace", reflect.TypeOf((*MockStateStore)(nil).GetNamespace), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespace", reflect.TypeOf((*MockStateStore)(nil).GetNamespace), namespace) } // Increment mocks base method. -func (m *MockStateStore) Increment(arg0, arg1 string, arg2 float64) (float64, error) { +func (m *MockStateStore) Increment(namespace, key string, delta float64) (float64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Increment", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Increment", namespace, key, delta) ret0, _ := ret[0].(float64) ret1, _ := ret[1].(error) return ret0, ret1 } // Increment indicates an expected call of Increment. -func (mr *MockStateStoreMockRecorder) Increment(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Increment(namespace, key, delta interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Increment", reflect.TypeOf((*MockStateStore)(nil).Increment), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Increment", reflect.TypeOf((*MockStateStore)(nil).Increment), namespace, key, delta) } // Set mocks base method. -func (m *MockStateStore) Set(arg0, arg1 string, arg2 interface{}) { +func (m *MockStateStore) Set(namespace, key string, value any) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Set", arg0, arg1, arg2) + m.ctrl.Call(m, "Set", namespace, key, value) } // Set indicates an expected call of Set. -func (mr *MockStateStoreMockRecorder) Set(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Set(namespace, key, value interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockStateStore)(nil).Set), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockStateStore)(nil).Set), namespace, key, value) } // MockHistoryStore is a mock of HistoryStore interface. @@ -179,15 +179,15 @@ func (m *MockHistoryStore) EXPECT() *MockHistoryStoreMockRecorder { } // Add mocks base method. -func (m *MockHistoryStore) Add(arg0 RequestRecord) { +func (m *MockHistoryStore) Add(record RequestRecord) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Add", arg0) + m.ctrl.Call(m, "Add", record) } // Add indicates an expected call of Add. -func (mr *MockHistoryStoreMockRecorder) Add(arg0 interface{}) *gomock.Call { +func (mr *MockHistoryStoreMockRecorder) Add(record interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockHistoryStore)(nil).Add), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockHistoryStore)(nil).Add), record) } // Capacity mocks base method. @@ -244,378 +244,250 @@ func (mr *MockHistoryStoreMockRecorder) GetAll() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockHistoryStore)(nil).GetAll)) } -// MockDataSource is a mock of DataSource interface. -type MockDataSource struct { +// MockRpcProtocol is a mock of RpcProtocol interface. +type MockRpcProtocol struct { ctrl *gomock.Controller - recorder *MockDataSourceMockRecorder + recorder *MockRpcProtocolMockRecorder } -// MockDataSourceMockRecorder is the mock recorder for MockDataSource. -type MockDataSourceMockRecorder struct { - mock *MockDataSource +// MockRpcProtocolMockRecorder is the mock recorder for MockRpcProtocol. +type MockRpcProtocolMockRecorder struct { + mock *MockRpcProtocol } -// NewMockDataSource creates a new mock instance. -func NewMockDataSource(ctrl *gomock.Controller) *MockDataSource { - mock := &MockDataSource{ctrl: ctrl} - mock.recorder = &MockDataSourceMockRecorder{mock} +// NewMockRpcProtocol creates a new mock instance. +func NewMockRpcProtocol(ctrl *gomock.Controller) *MockRpcProtocol { + mock := &MockRpcProtocol{ctrl: ctrl} + mock.recorder = &MockRpcProtocolMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockDataSource) EXPECT() *MockDataSourceMockRecorder { +func (m *MockRpcProtocol) EXPECT() *MockRpcProtocolMockRecorder { return m.recorder } -// Get mocks base method. -func (m *MockDataSource) Get(arg0 string) (interface{}, bool) { +// ContentType mocks base method. +func (m *MockRpcProtocol) ContentType() string { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0) - ret0, _ := ret[0].(interface{}) - ret1, _ := ret[1].(bool) - return ret0, ret1 + ret := m.ctrl.Call(m, "ContentType") + ret0, _ := ret[0].(string) + return ret0 } -// Get indicates an expected call of Get. -func (mr *MockDataSourceMockRecorder) Get(arg0 interface{}) *gomock.Call { +// ContentType indicates an expected call of ContentType. +func (mr *MockRpcProtocolMockRecorder) ContentType() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDataSource)(nil).Get), arg0) -} - -// MockRequestSourceFactory is a mock of RequestSourceFactory interface. -type MockRequestSourceFactory struct { - ctrl *gomock.Controller - recorder *MockRequestSourceFactoryMockRecorder -} - -// MockRequestSourceFactoryMockRecorder is the mock recorder for MockRequestSourceFactory. -type MockRequestSourceFactoryMockRecorder struct { - mock *MockRequestSourceFactory + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContentType", reflect.TypeOf((*MockRpcProtocol)(nil).ContentType)) } -// NewMockRequestSourceFactory creates a new mock instance. -func NewMockRequestSourceFactory(ctrl *gomock.Controller) *MockRequestSourceFactory { - mock := &MockRequestSourceFactory{ctrl: ctrl} - mock.recorder = &MockRequestSourceFactoryMockRecorder{mock} - return mock +// ErrorResponse mocks base method. +func (m *MockRpcProtocol) ErrorResponse(code int, message string, id any) []byte { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ErrorResponse", code, message, id) + ret0, _ := ret[0].([]byte) + return ret0 } -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockRequestSourceFactory) EXPECT() *MockRequestSourceFactoryMockRecorder { - return m.recorder +// ErrorResponse indicates an expected call of ErrorResponse. +func (mr *MockRpcProtocolMockRecorder) ErrorResponse(code, message, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ErrorResponse", reflect.TypeOf((*MockRpcProtocol)(nil).ErrorResponse), code, message, id) } -// NewRequestSource mocks base method. -func (m *MockRequestSourceFactory) NewRequestSource(arg0 *http.Request, arg1 map[string]string) DataSource { +// ParseBody mocks base method. +func (m *MockRpcProtocol) ParseBody(body []byte) ([]RpcEntry, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NewRequestSource", arg0, arg1) - ret0, _ := ret[0].(DataSource) - return ret0 + ret := m.ctrl.Call(m, "ParseBody", body) + ret0, _ := ret[0].([]RpcEntry) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// NewRequestSource indicates an expected call of NewRequestSource. -func (mr *MockRequestSourceFactoryMockRecorder) NewRequestSource(arg0, arg1 interface{}) *gomock.Call { +// ParseBody indicates an expected call of ParseBody. +func (mr *MockRpcProtocolMockRecorder) ParseBody(body interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewRequestSource", reflect.TypeOf((*MockRequestSourceFactory)(nil).NewRequestSource), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseBody", reflect.TypeOf((*MockRpcProtocol)(nil).ParseBody), body) } -// MockStateSourceFactory is a mock of StateSourceFactory interface. -type MockStateSourceFactory struct { +// MockMessageRenderer is a mock of MessageRenderer interface. +type MockMessageRenderer struct { ctrl *gomock.Controller - recorder *MockStateSourceFactoryMockRecorder + recorder *MockMessageRendererMockRecorder } -// MockStateSourceFactoryMockRecorder is the mock recorder for MockStateSourceFactory. -type MockStateSourceFactoryMockRecorder struct { - mock *MockStateSourceFactory +// MockMessageRendererMockRecorder is the mock recorder for MockMessageRenderer. +type MockMessageRendererMockRecorder struct { + mock *MockMessageRenderer } -// NewMockStateSourceFactory creates a new mock instance. -func NewMockStateSourceFactory(ctrl *gomock.Controller) *MockStateSourceFactory { - mock := &MockStateSourceFactory{ctrl: ctrl} - mock.recorder = &MockStateSourceFactoryMockRecorder{mock} +// NewMockMessageRenderer creates a new mock instance. +func NewMockMessageRenderer(ctrl *gomock.Controller) *MockMessageRenderer { + mock := &MockMessageRenderer{ctrl: ctrl} + mock.recorder = &MockMessageRendererMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockStateSourceFactory) EXPECT() *MockStateSourceFactoryMockRecorder { +func (m *MockMessageRenderer) EXPECT() *MockMessageRendererMockRecorder { return m.recorder } -// NewStateSource mocks base method. -func (m *MockStateSourceFactory) NewStateSource(arg0 string) DataSource { +// ApplySetState mocks base method. +func (m *MockMessageRenderer) ApplySetState(stateMap map[string]any, eval runtime.Evaluator, prefix string) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NewStateSource", arg0) - ret0, _ := ret[0].(DataSource) - return ret0 + m.ctrl.Call(m, "ApplySetState", stateMap, eval, prefix) } -// NewStateSource indicates an expected call of NewStateSource. -func (mr *MockStateSourceFactoryMockRecorder) NewStateSource(arg0 interface{}) *gomock.Call { +// ApplySetState indicates an expected call of ApplySetState. +func (mr *MockMessageRendererMockRecorder) ApplySetState(stateMap, eval, prefix interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStateSource", reflect.TypeOf((*MockStateSourceFactory)(nil).NewStateSource), arg0) -} - -// MockEnvSourceFactory is a mock of EnvSourceFactory interface. -type MockEnvSourceFactory struct { - ctrl *gomock.Controller - recorder *MockEnvSourceFactoryMockRecorder -} - -// MockEnvSourceFactoryMockRecorder is the mock recorder for MockEnvSourceFactory. -type MockEnvSourceFactoryMockRecorder struct { - mock *MockEnvSourceFactory -} - -// NewMockEnvSourceFactory creates a new mock instance. -func NewMockEnvSourceFactory(ctrl *gomock.Controller) *MockEnvSourceFactory { - mock := &MockEnvSourceFactory{ctrl: ctrl} - mock.recorder = &MockEnvSourceFactoryMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockEnvSourceFactory) EXPECT() *MockEnvSourceFactoryMockRecorder { - return m.recorder + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplySetState", reflect.TypeOf((*MockMessageRenderer)(nil).ApplySetState), stateMap, eval, prefix) } // NewEnvSource mocks base method. -func (m *MockEnvSourceFactory) NewEnvSource() DataSource { +func (m *MockMessageRenderer) NewEnvSource() *runtime.EnvSource { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewEnvSource") - ret0, _ := ret[0].(DataSource) + ret0, _ := ret[0].(*runtime.EnvSource) return ret0 } // NewEnvSource indicates an expected call of NewEnvSource. -func (mr *MockEnvSourceFactoryMockRecorder) NewEnvSource() *gomock.Call { +func (mr *MockMessageRendererMockRecorder) NewEnvSource() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewEnvSource", reflect.TypeOf((*MockEnvSourceFactory)(nil).NewEnvSource)) -} - -// MockExpressionEvaluator is a mock of ExpressionEvaluator interface. -type MockExpressionEvaluator struct { - ctrl *gomock.Controller - recorder *MockExpressionEvaluatorMockRecorder -} - -// MockExpressionEvaluatorMockRecorder is the mock recorder for MockExpressionEvaluator. -type MockExpressionEvaluatorMockRecorder struct { - mock *MockExpressionEvaluator -} - -// NewMockExpressionEvaluator creates a new mock instance. -func NewMockExpressionEvaluator(ctrl *gomock.Controller) *MockExpressionEvaluator { - mock := &MockExpressionEvaluator{ctrl: ctrl} - mock.recorder = &MockExpressionEvaluatorMockRecorder{mock} - return mock + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewEnvSource", reflect.TypeOf((*MockMessageRenderer)(nil).NewEnvSource)) } -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockExpressionEvaluator) EXPECT() *MockExpressionEvaluatorMockRecorder { - return m.recorder -} - -// AddSource mocks base method. -func (m *MockExpressionEvaluator) AddSource(arg0 string, arg1 DataSource) { +// NewStateSource mocks base method. +func (m *MockMessageRenderer) NewStateSource(prefix string) *runtime.StateSource { m.ctrl.T.Helper() - m.ctrl.Call(m, "AddSource", arg0, arg1) + ret := m.ctrl.Call(m, "NewStateSource", prefix) + ret0, _ := ret[0].(*runtime.StateSource) + return ret0 } -// AddSource indicates an expected call of AddSource. -func (mr *MockExpressionEvaluatorMockRecorder) AddSource(arg0, arg1 interface{}) *gomock.Call { +// NewStateSource indicates an expected call of NewStateSource. +func (mr *MockMessageRendererMockRecorder) NewStateSource(prefix interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSource", reflect.TypeOf((*MockExpressionEvaluator)(nil).AddSource), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStateSource", reflect.TypeOf((*MockMessageRenderer)(nil).NewStateSource), prefix) } -// Evaluate mocks base method. -func (m *MockExpressionEvaluator) Evaluate(arg0 string) (interface{}, error) { +// RenderAsyncPayload mocks base method. +func (m *MockMessageRenderer) RenderAsyncPayload(example *MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Evaluate", arg0) - ret0, _ := ret[0].(interface{}) + ret := m.ctrl.Call(m, "RenderAsyncPayload", example, evaluator) + ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(error) return ret0, ret1 } -// Evaluate indicates an expected call of Evaluate. -func (mr *MockExpressionEvaluatorMockRecorder) Evaluate(arg0 interface{}) *gomock.Call { +// RenderAsyncPayload indicates an expected call of RenderAsyncPayload. +func (mr *MockMessageRendererMockRecorder) RenderAsyncPayload(example, evaluator interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Evaluate", reflect.TypeOf((*MockExpressionEvaluator)(nil).Evaluate), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderAsyncPayload", reflect.TypeOf((*MockMessageRenderer)(nil).RenderAsyncPayload), example, evaluator) } -// MockExtensionProcessor is a mock of ExtensionProcessor interface. -type MockExtensionProcessor struct { - ctrl *gomock.Controller - recorder *MockExtensionProcessorMockRecorder -} - -// MockExtensionProcessorMockRecorder is the mock recorder for MockExtensionProcessor. -type MockExtensionProcessorMockRecorder struct { - mock *MockExtensionProcessor -} - -// NewMockExtensionProcessor creates a new mock instance. -func NewMockExtensionProcessor(ctrl *gomock.Controller) *MockExtensionProcessor { - mock := &MockExtensionProcessor{ctrl: ctrl} - mock.recorder = &MockExtensionProcessorMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockExtensionProcessor) EXPECT() *MockExtensionProcessorMockRecorder { - return m.recorder -} - -// EvaluateParamsMatch mocks base method. -func (m *MockExtensionProcessor) EvaluateParamsMatch(arg0 map[string]interface{}, arg1 ExpressionEvaluator) (bool, error) { +// RenderMessageSpecs mocks base method. +func (m *MockMessageRenderer) RenderMessageSpecs(messages []*loader.MessageSpec, prefix, opID string, in InboundMessage) (int, []byte, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EvaluateParamsMatch", arg0, arg1) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret := m.ctrl.Call(m, "RenderMessageSpecs", messages, prefix, opID, in) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].([]byte) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } -// EvaluateParamsMatch indicates an expected call of EvaluateParamsMatch. -func (mr *MockExtensionProcessorMockRecorder) EvaluateParamsMatch(arg0, arg1 interface{}) *gomock.Call { +// RenderMessageSpecs indicates an expected call of RenderMessageSpecs. +func (mr *MockMessageRendererMockRecorder) RenderMessageSpecs(messages, prefix, opID, in interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EvaluateParamsMatch", reflect.TypeOf((*MockExtensionProcessor)(nil).EvaluateParamsMatch), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderMessageSpecs", reflect.TypeOf((*MockMessageRenderer)(nil).RenderMessageSpecs), messages, prefix, opID, in) } -// ExtractHeaders mocks base method. -func (m *MockExtensionProcessor) ExtractHeaders(arg0 *openapi3.Example) (map[string]interface{}, bool) { +// SelectAsyncExample mocks base method. +func (m *MockMessageRenderer) SelectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*MessageExampleView, string) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractHeaders", arg0) - ret0, _ := ret[0].(map[string]interface{}) - ret1, _ := ret[1].(bool) + ret := m.ctrl.Call(m, "SelectAsyncExample", message, evaluator, opID) + ret0, _ := ret[0].(*MessageExampleView) + ret1, _ := ret[1].(string) return ret0, ret1 } -// ExtractHeaders indicates an expected call of ExtractHeaders. -func (mr *MockExtensionProcessorMockRecorder) ExtractHeaders(arg0 interface{}) *gomock.Call { +// SelectAsyncExample indicates an expected call of SelectAsyncExample. +func (mr *MockMessageRendererMockRecorder) SelectAsyncExample(message, evaluator, opID interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractHeaders", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractHeaders), arg0) -} - -// ExtractOnce mocks base method. -func (m *MockExtensionProcessor) ExtractOnce(arg0 *openapi3.Example) bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractOnce", arg0) - ret0, _ := ret[0].(bool) - return ret0 + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SelectAsyncExample", reflect.TypeOf((*MockMessageRenderer)(nil).SelectAsyncExample), message, evaluator, opID) } -// ExtractOnce indicates an expected call of ExtractOnce. -func (mr *MockExtensionProcessorMockRecorder) ExtractOnce(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractOnce", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractOnce), arg0) -} - -// ExtractParamsMatch mocks base method. -func (m *MockExtensionProcessor) ExtractParamsMatch(arg0 *openapi3.Example) (map[string]interface{}, bool) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractParamsMatch", arg0) - ret0, _ := ret[0].(map[string]interface{}) - ret1, _ := ret[1].(bool) - return ret0, ret1 +// MockConsumerBus is a mock of ConsumerBus interface. +type MockConsumerBus struct { + ctrl *gomock.Controller + recorder *MockConsumerBusMockRecorder } -// ExtractParamsMatch indicates an expected call of ExtractParamsMatch. -func (mr *MockExtensionProcessorMockRecorder) ExtractParamsMatch(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractParamsMatch", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractParamsMatch), arg0) +// MockConsumerBusMockRecorder is the mock recorder for MockConsumerBus. +type MockConsumerBusMockRecorder struct { + mock *MockConsumerBus } -// ExtractSetState mocks base method. -func (m *MockExtensionProcessor) ExtractSetState(arg0 *openapi3.Example) (map[string]interface{}, bool) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractSetState", arg0) - ret0, _ := ret[0].(map[string]interface{}) - ret1, _ := ret[1].(bool) - return ret0, ret1 +// NewMockConsumerBus creates a new mock instance. +func NewMockConsumerBus(ctrl *gomock.Controller) *MockConsumerBus { + mock := &MockConsumerBus{ctrl: ctrl} + mock.recorder = &MockConsumerBusMockRecorder{mock} + return mock } -// ExtractSetState indicates an expected call of ExtractSetState. -func (mr *MockExtensionProcessorMockRecorder) ExtractSetState(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractSetState", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractSetState), arg0) +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConsumerBus) EXPECT() *MockConsumerBusMockRecorder { + return m.recorder } -// ExtractSkip mocks base method. -func (m *MockExtensionProcessor) ExtractSkip(arg0 *openapi3.Example) bool { +// Candidates mocks base method. +func (m *MockConsumerBus) Candidates(address string) []ConsumerInfo { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractSkip", arg0) - ret0, _ := ret[0].(bool) + ret := m.ctrl.Call(m, "Candidates", address) + ret0, _ := ret[0].([]ConsumerInfo) return ret0 } -// ExtractSkip indicates an expected call of ExtractSkip. -func (mr *MockExtensionProcessorMockRecorder) ExtractSkip(arg0 interface{}) *gomock.Call { +// Candidates indicates an expected call of Candidates. +func (mr *MockConsumerBusMockRecorder) Candidates(address interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractSkip", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractSkip), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Candidates", reflect.TypeOf((*MockConsumerBus)(nil).Candidates), address) } -// MockRpcProtocol is a mock of RpcProtocol interface. -type MockRpcProtocol struct { - ctrl *gomock.Controller - recorder *MockRpcProtocolMockRecorder -} - -// MockRpcProtocolMockRecorder is the mock recorder for MockRpcProtocol. -type MockRpcProtocolMockRecorder struct { - mock *MockRpcProtocol -} - -// NewMockRpcProtocol creates a new mock instance. -func NewMockRpcProtocol(ctrl *gomock.Controller) *MockRpcProtocol { - mock := &MockRpcProtocol{ctrl: ctrl} - mock.recorder = &MockRpcProtocolMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockRpcProtocol) EXPECT() *MockRpcProtocolMockRecorder { - return m.recorder -} - -// ContentType mocks base method. -func (m *MockRpcProtocol) ContentType() string { +// PushTo mocks base method. +func (m *MockConsumerBus) PushTo(consumer ConsumerInfo, address string, payload []byte) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ContentType") - ret0, _ := ret[0].(string) - return ret0 + m.ctrl.Call(m, "PushTo", consumer, address, payload) } -// ContentType indicates an expected call of ContentType. -func (mr *MockRpcProtocolMockRecorder) ContentType() *gomock.Call { +// PushTo indicates an expected call of PushTo. +func (mr *MockConsumerBusMockRecorder) PushTo(consumer, address, payload interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContentType", reflect.TypeOf((*MockRpcProtocol)(nil).ContentType)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushTo", reflect.TypeOf((*MockConsumerBus)(nil).PushTo), consumer, address, payload) } -// ErrorResponse mocks base method. -func (m *MockRpcProtocol) ErrorResponse(arg0 int, arg1 string, arg2 interface{}) []byte { +// SignalRPush mocks base method. +func (m *MockConsumerBus) SignalRPush(address string, payload []byte) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ErrorResponse", arg0, arg1, arg2) - ret0, _ := ret[0].([]byte) - return ret0 + m.ctrl.Call(m, "SignalRPush", address, payload) } -// ErrorResponse indicates an expected call of ErrorResponse. -func (mr *MockRpcProtocolMockRecorder) ErrorResponse(arg0, arg1, arg2 interface{}) *gomock.Call { +// SignalRPush indicates an expected call of SignalRPush. +func (mr *MockConsumerBusMockRecorder) SignalRPush(address, payload interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ErrorResponse", reflect.TypeOf((*MockRpcProtocol)(nil).ErrorResponse), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignalRPush", reflect.TypeOf((*MockConsumerBus)(nil).SignalRPush), address, payload) } -// ParseBody mocks base method. -func (m *MockRpcProtocol) ParseBody(arg0 []byte) ([]RpcCall, error) { +// WSBroadcast mocks base method. +func (m *MockConsumerBus) WSBroadcast(address string, payload []byte) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ParseBody", arg0) - ret0, _ := ret[0].([]RpcCall) - ret1, _ := ret[1].(error) - return ret0, ret1 + m.ctrl.Call(m, "WSBroadcast", address, payload) } -// ParseBody indicates an expected call of ParseBody. -func (mr *MockRpcProtocolMockRecorder) ParseBody(arg0 interface{}) *gomock.Call { +// WSBroadcast indicates an expected call of WSBroadcast. +func (mr *MockConsumerBusMockRecorder) WSBroadcast(address, payload interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseBody", reflect.TypeOf((*MockRpcProtocol)(nil).ParseBody), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WSBroadcast", reflect.TypeOf((*MockConsumerBus)(nil).WSBroadcast), address, payload) } diff --git a/internal/server/jsonrpc.go b/internal/server/jsonrpc.go index d86a621..7539a21 100644 --- a/internal/server/jsonrpc.go +++ b/internal/server/jsonrpc.go @@ -44,56 +44,37 @@ func (h *RpcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { isBatch := isBatchRequest(bodyBytes) - calls, err := h.protocol.ParseBody(bodyBytes) + entries, err := h.protocol.ParseBody(bodyBytes) if err != nil { - errBody := h.protocol.ErrorResponse(-32700, "Parse error", nil) + errBody := h.protocol.ErrorResponse(rpcErrorCode(err), rpcErrorMessage(codeMessage(rpcErrorCode(err))), nil) w.Header().Set("Content-Type", h.protocol.ContentType()) w.WriteHeader(http.StatusOK) writeBody(w, errBody) return } - pathParams := h.server.extractPathParams(r, &RouteMapping{ChiPattern: r.URL.Path}) - - results := make([]json.RawMessage, 0, len(calls)) + pathParamsCache := make(map[string]map[string]string) + results := make([]json.RawMessage, 0, len(entries)) var singleStatusCode string var singleHeaders map[string]string - for _, call := range calls { - mapping, ok := h.procedureMap[call.Procedure] - if !ok { - if call.HasID { - errBody := h.protocol.ErrorResponse(-32601, "Method not found", call.ID) - results = append(results, json.RawMessage(errBody)) - } + for _, entry := range entries { + if entry.Error != nil { + results = append(results, json.RawMessage(h.protocol.ErrorResponse(entry.Error.Code, codeMessage(entry.Error.Code), entry.Error.ID))) continue } - - if !call.HasID { - _, _, _, _, err := h.server.selectAndGenerateResponse(r, mapping, pathParams, call.Raw) - if err != nil { - slog.Debug("RPC notification pipeline error", "procedure", call.Procedure, "err", err) - } - continue - } - - body, headers, statusCode, _, err := h.server.selectAndGenerateResponse(r, mapping, pathParams, call.Raw) - if err != nil { - errBody := h.protocol.ErrorResponse(-32603, "Internal error", call.ID) - results = append(results, json.RawMessage(errBody)) - continue + if sc, headers := h.handleCall(entry.Call, r, pathParamsCache, &results); sc != "" { + singleStatusCode = sc + singleHeaders = headers } - - singleStatusCode = statusCode - singleHeaders = headers - results = append(results, json.RawMessage(body)) } if isBatch { - // Batch: write array response + // Batch: every slot is either a result or an error; notifications and + // error slots with no id produce no response entry. An all-notification + // (or all-error-without-id) batch answers 204 No Content, matching the + // single-notification behavior. if len(results) == 0 { - w.Header().Set("Content-Type", h.protocol.ContentType()) - w.WriteHeader(http.StatusOK) - writeBody(w, []byte("[]")) + w.WriteHeader(http.StatusNoContent) return } out, _ := json.Marshal(results) @@ -104,7 +85,7 @@ func (h *RpcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Single call - if len(calls) == 1 && !calls[0].HasID { + if len(entries) == 1 && entries[0].Call != nil && !entries[0].Call.HasID { w.WriteHeader(http.StatusNoContent) return } @@ -129,11 +110,75 @@ func (h *RpcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeBody(w, results[0]) } +// codeMessage maps a JSON-RPC error code to its standard message. +func codeMessage(code int) string { + switch code { + case -32700: + return "Parse error" + case -32600: + return "Invalid Request" + case -32601: + return "Method not found" + case -32603: + return "Internal error" + default: + return "Server error" + } +} + +// rpcErrorMessage is a compatibility alias so fatal parse-path callers read +// clearly; it simply returns the given message. +func rpcErrorMessage(msg string) string { return msg } + func isBatchRequest(body []byte) bool { s := strings.TrimSpace(string(body)) return len(s) > 0 && s[0] == '[' } +// handleCall resolves the mapping for one JSON-RPC call and executes its mock +// pipeline, appending a response entry (a result body or a protocol error) to +// results for calls with an id. Notifications run without a response entry. +// It returns the status code and response headers of a successful call ("" and +// nil for notifications and errors), which the single-call path uses for the +// standard HTTP response. +func (h *RpcHandler) handleCall(call *RpcCall, r *http.Request, pathParamsCache map[string]map[string]string, results *[]json.RawMessage) (string, map[string]string) { + if call == nil { + return "", nil + } + mapping, ok := h.procedureMap[call.Procedure] + if !ok { + if call.HasID { + *results = append(*results, json.RawMessage(h.protocol.ErrorResponse(-32601, "Method not found", call.ID))) + } + return "", nil + } + + // Path parameters are extracted per procedure from the request against the + // procedure's own brace-form ChiPattern (the gateway route itself has no + // params); cached per pattern so a batch reuses one extraction. + pathParams, ok := pathParamsCache[call.Procedure] + if !ok { + pathParams = h.server.extractPathParams(r, mapping) + pathParamsCache[call.Procedure] = pathParams + } + + if !call.HasID { + _, _, _, _, err := h.server.selectAndGenerateResponse(r, mapping, pathParams, call.Raw) + if err != nil { + slog.Debug("RPC notification pipeline error", "procedure", call.Procedure, "err", err) + } + return "", nil + } + + body, headers, statusCode, _, err := h.server.selectAndGenerateResponse(r, mapping, pathParams, call.Raw) + if err != nil { + *results = append(*results, json.RawMessage(h.protocol.ErrorResponse(-32603, "Internal error", call.ID))) + return "", nil + } + *results = append(*results, json.RawMessage(body)) + return statusCode, headers +} + func newRpcProtocol(cfg *loader.RpcConfig) (RpcProtocol, error) { switch cfg.ProtocolType { case loader.ProtocolTypeJsonRpc: diff --git a/internal/server/jsonrpc_handler_test.go b/internal/server/jsonrpc_handler_test.go index 6a94a66..5eb2363 100644 --- a/internal/server/jsonrpc_handler_test.go +++ b/internal/server/jsonrpc_handler_test.go @@ -64,30 +64,10 @@ func newRpcHandlerWithMocks(t *testing.T) (*RpcHandler, *MockRpcProtocol, *Serve historyStore := NewMockHistoryStore(ctrl) historyStore.EXPECT().Add(gomock.Any()).AnyTimes() - expressionEvaluator := NewMockExpressionEvaluator(ctrl) - expressionEvaluator.EXPECT().AddSource(gomock.Any(), gomock.Any()).AnyTimes() - expressionEvaluator.EXPECT().Evaluate(gomock.Any()).Return("", nil).AnyTimes() - - requestSourceFactory := NewMockRequestSourceFactory(ctrl) - requestSourceFactory.EXPECT().NewRequestSource(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - - stateSourceFactory := NewMockStateSourceFactory(ctrl) - stateSourceFactory.EXPECT().NewStateSource(gomock.Any()).Return(nil).AnyTimes() - - envSourceFactory := NewMockEnvSourceFactory(ctrl) - envSourceFactory.EXPECT().NewEnvSource().Return(nil).AnyTimes() - - extensionProcessor := NewMockExtensionProcessor(ctrl) - deps := Dependencies{ - RouteProvider: routeProvider, - StateStore: stateStore, - HistoryStore: historyStore, - RequestSourceFactory: requestSourceFactory, - StateSourceFactory: stateSourceFactory, - EnvSourceFactory: envSourceFactory, - ExpressionEvaluator: expressionEvaluator, - ExtensionProcessor: extensionProcessor, + RouteProvider: routeProvider, + StateStore: stateStore, + HistoryStore: historyStore, } server, err := NewWithDependencies(Config{Port: 0, HistorySize: 1000}, []SchemaInfo{}, deps, nil, nil) @@ -124,9 +104,7 @@ func TestRpcHandler_SingleCall(t *testing.T) { } handler.procedureMap["subtract"] = mapping - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{ - {Procedure: "subtract", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "subtract", "id": float64(1)}, ID: float64(1), HasID: true}, - }, nil) + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &RpcCall{Procedure: "subtract", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "subtract", "id": float64(1)}, ID: float64(1), HasID: true}}}, nil) proto.EXPECT().ContentType().Return("application/json") req := httptest.NewRequest(http.MethodPost, "/rpc", nil) @@ -159,8 +137,8 @@ func TestRpcHandler_MethodNotFound(t *testing.T) { errBody := []byte(`{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":2}`) - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{ - {Procedure: "unknown", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "unknown", "id": float64(2)}, ID: float64(2), HasID: true}, + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{ + {Call: &RpcCall{Procedure: "unknown", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "unknown", "id": float64(2)}, ID: float64(2), HasID: true}}, }, nil) proto.EXPECT().ErrorResponse(-32601, "Method not found", float64(2)).Return(errBody) proto.EXPECT().ContentType().Return("application/json") @@ -243,7 +221,7 @@ func TestRpcHandler_Batch(t *testing.T) { callB := RpcCall{Procedure: "b", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "b", "id": float64(2)}, ID: float64(2), HasID: true} callC := RpcCall{Procedure: "c", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "c", "id": float64(3)}, ID: float64(3), HasID: true} - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{callA, callB, callC}, nil) + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &callA}, {Call: &callB}, {Call: &callC}}, nil) proto.EXPECT().ContentType().Return("application/json") req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`[{"jsonrpc":"2.0","method":"a","id":1}]`)) @@ -286,7 +264,7 @@ func TestRpcHandler_BatchWithNotification(t *testing.T) { callA := RpcCall{Procedure: "a", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "a", "id": float64(1)}, ID: float64(1), HasID: true} callN := RpcCall{Procedure: "notify", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "notify"}, ID: nil, HasID: false} - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{callA, callN}, nil) + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &callA}, {Call: &callN}}, nil) proto.EXPECT().ContentType().Return("application/json") req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`[{"jsonrpc":"2.0","method":"a","id":1}]`)) @@ -329,8 +307,7 @@ func TestRpcHandler_AllNotifications(t *testing.T) { call1 := RpcCall{Procedure: "n1", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "n1"}, ID: nil, HasID: false} call2 := RpcCall{Procedure: "n2", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "n2"}, ID: nil, HasID: false} - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{call1, call2}, nil) - proto.EXPECT().ContentType().Return("application/json") + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &call1}, {Call: &call2}}, nil) req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`[{"jsonrpc":"2.0","method":"n1"}]`)) w := httptest.NewRecorder() @@ -338,12 +315,10 @@ func TestRpcHandler_AllNotifications(t *testing.T) { handler.ServeHTTP(w, req) resp := w.Result() - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var body []interface{} - err := json.NewDecoder(resp.Body).Decode(&body) - require.NoError(t, err) - assert.Empty(t, body) + // An all-notification batch answers 204 No Content (JSON-RPC 2.0 §6: + // a response is not returned for notifications). + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + assert.Empty(t, resp.Header.Get("Content-Type")) } /* @@ -370,7 +345,7 @@ func TestRpcHandler_PerCallBody(t *testing.T) { callA := RpcCall{Procedure: "a", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "a", "id": float64(1), "params": map[string]interface{}{"x": float64(10)}}, ID: float64(1), HasID: true} - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{callA}, nil) + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &callA}}, nil) proto.EXPECT().ContentType().Return("application/json") req := httptest.NewRequest(http.MethodPost, "/rpc", nil) @@ -406,7 +381,7 @@ func TestRpcHandler_SingleNotification(t *testing.T) { call := RpcCall{Procedure: "notify", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "notify"}, ID: nil, HasID: false} - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{call}, nil) + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &call}}, nil) req := httptest.NewRequest(http.MethodPost, "/rpc", nil) w := httptest.NewRecorder() @@ -440,7 +415,7 @@ func TestRpcHandler_ResponseHeaders(t *testing.T) { handler.procedureMap["h"] = mapping call := RpcCall{Procedure: "h", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "h", "id": float64(1)}, ID: float64(1), HasID: true} - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{call}, nil) + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &call}}, nil) proto.EXPECT().ContentType().Return("application/json") req := httptest.NewRequest(http.MethodPost, "/rpc", nil) @@ -475,7 +450,7 @@ func TestRpcHandler_ResponseStatusCode(t *testing.T) { handler.procedureMap["s"] = mapping call := RpcCall{Procedure: "s", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "s", "id": float64(1)}, ID: float64(1), HasID: true} - proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcCall{call}, nil) + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &call}}, nil) proto.EXPECT().ContentType().Return("application/json") req := httptest.NewRequest(http.MethodPost, "/rpc", nil) @@ -486,3 +461,39 @@ func TestRpcHandler_ResponseStatusCode(t *testing.T) { resp := w.Result() assert.Equal(t, http.StatusOK, resp.StatusCode) } + +/* +Scenario: RpcHandler extracts procedure path params from the request URL +Given a procedure whose route is /rpc/users/{id} invoked at /rpc/users/123 +When ServeHTTP is called +Then path param id=123 is captured against the procedure's own ChiPattern +(even though the gateway route itself has no params) + +Related spec scenarios: RS.JRP.34 +*/ +func TestRpcHandler_ProcedurePathParams(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc/users/{id}", + Pattern: "/rpc/users/{id}", + ChiPattern: "/rpc/users/{id}", + Responses: createResponsesWithExample(), + } + handler.procedureMap["getUser"] = mapping + + call := RpcCall{Procedure: "getUser", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "getUser", "id": float64(1)}, ID: float64(1), HasID: true} + proto.EXPECT().ParseBody(gomock.Any()).Return([]RpcEntry{{Call: &call}}, nil) + proto.EXPECT().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc/users/123", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/internal/server/jsonrpc_protocol.go b/internal/server/jsonrpc_protocol.go index 47624b9..8fe1453 100644 --- a/internal/server/jsonrpc_protocol.go +++ b/internal/server/jsonrpc_protocol.go @@ -8,6 +8,28 @@ import ( "github.com/mamonth/oasmock/internal/loader" ) +// rpcProtocolError is a fatal JSON-RPC body error carrying the response code +// to emit (-32700 parse error, -32600 invalid request). +type rpcProtocolError struct { + code int + msg string +} + +func (e *rpcProtocolError) Error() string { return e.msg } + +// rpcErrorCode extracts the JSON-RPC code from an error returned by +// RpcProtocol.ParseBody. It returns -32700 for an error without a code. +func rpcErrorCode(err error) int { + if pe, ok := err.(*rpcProtocolError); ok { + return pe.code + } + return -32700 +} + +func rpcError(code int, format string, args ...any) error { + return &rpcProtocolError{code: code, msg: fmt.Sprintf(format, args...)} +} + type JsonRpcProtocol struct { contentType string callPath string @@ -24,10 +46,10 @@ func NewJsonRpcProtocol(cfg *loader.RpcConfig) *JsonRpcProtocol { } } -func (p *JsonRpcProtocol) ParseBody(body []byte) ([]RpcCall, error) { +func (p *JsonRpcProtocol) ParseBody(body []byte) ([]RpcEntry, error) { var raw any if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("parse error: %w", err) + return nil, rpcError(-32700, "parse error") } switch v := raw.(type) { @@ -38,40 +60,48 @@ func (p *JsonRpcProtocol) ParseBody(body []byte) ([]RpcCall, error) { if err != nil { return nil, err } - return []RpcCall{call}, nil + return []RpcEntry{{Call: &call}}, nil default: - return nil, fmt.Errorf("invalid request: body must be object or array") + return nil, rpcError(-32600, "invalid request: body must be an object or array") } } -func (p *JsonRpcProtocol) parseBatch(items []interface{}) ([]RpcCall, error) { - calls := make([]RpcCall, 0, len(items)) +func (p *JsonRpcProtocol) parseBatch(items []interface{}) ([]RpcEntry, error) { + // An empty [[ ]] is an Invalid Request per JSON-RPC 2.0 spec 7. + if len(items) == 0 { + return nil, rpcError(-32600, "invalid request: empty batch") + } + entries := make([]RpcEntry, 0, len(items)) for _, item := range items { obj, ok := item.(map[string]interface{}) if !ok { - return nil, fmt.Errorf("invalid request: batch element must be an object") + entries = append(entries, RpcEntry{Error: &RpcParsedError{Code: -32600}}) + continue } call, err := p.parseSingle(obj) if err != nil { - return nil, err + id, _ := obj["id"] + entries = append(entries, RpcEntry{Error: &RpcParsedError{Code: rpcErrorCode(err), ID: id}}) + continue } - calls = append(calls, call) + calls := call + entries = append(entries, RpcEntry{Call: &calls}) } - return calls, nil + return entries, nil } func (p *JsonRpcProtocol) parseSingle(obj map[string]interface{}) (RpcCall, error) { version, ok := obj["jsonrpc"].(string) if !ok { - return RpcCall{}, fmt.Errorf("invalid request: missing or invalid jsonrpc field") + return RpcCall{}, rpcError(-32600, "invalid request: missing or invalid jsonrpc field") } if version != "2.0" { - return RpcCall{}, fmt.Errorf("invalid request: unsupported jsonrpc version %q", version) + return RpcCall{}, rpcError(-32600, "invalid request: unsupported jsonrpc version %q", version) } method, ok := obj["method"].(string) if !ok || method == "" { - return RpcCall{}, fmt.Errorf("invalid request: missing or invalid method field") + return RpcCall{}, rpcError(-32600, "invalid request: missing or invalid method field") } procedureName, err := p.extractProcedureName(obj) diff --git a/internal/server/jsonrpc_protocol_test.go b/internal/server/jsonrpc_protocol_test.go index ca848fb..2cc89fd 100644 --- a/internal/server/jsonrpc_protocol_test.go +++ b/internal/server/jsonrpc_protocol_test.go @@ -30,13 +30,15 @@ func TestJsonRpcProtocol_ParseBody_SingleCall(t *testing.T) { proto := newTestProto("method") body := []byte(`{"jsonrpc":"2.0","method":"subtract","params":{"a":10},"id":1}`) - calls, err := proto.ParseBody(body) + entries, err := proto.ParseBody(body) require.NoError(t, err) - require.Len(t, calls, 1) + require.Len(t, entries, 1) + call := entries[0].Call + require.NotNil(t, call) - assert.Equal(t, "subtract", calls[0].Procedure) - assert.Equal(t, float64(1), calls[0].ID) - assert.True(t, calls[0].HasID) + assert.Equal(t, "subtract", call.Procedure) + assert.Equal(t, float64(1), call.ID) + assert.True(t, call.HasID) } /* @@ -53,9 +55,12 @@ func TestJsonRpcProtocol_ParseBody_Batch(t *testing.T) { proto := newTestProto("method") body := []byte(`[{"jsonrpc":"2.0","method":"add","params":{"a":1},"id":1},{"jsonrpc":"2.0","method":"sub","params":{"a":2},"id":2},{"jsonrpc":"2.0","method":"mul","params":{"a":3},"id":3}]`) - calls, err := proto.ParseBody(body) + entries, err := proto.ParseBody(body) require.NoError(t, err) - assert.Len(t, calls, 3) + assert.Len(t, entries, 3) + for _, e := range entries { + assert.NotNil(t, e.Call) + } } /* @@ -72,13 +77,15 @@ func TestJsonRpcProtocol_ParseBody_Notification(t *testing.T) { proto := newTestProto("method") body := []byte(`{"jsonrpc":"2.0","method":"log","params":{"msg":"hello"}}`) - calls, err := proto.ParseBody(body) + entries, err := proto.ParseBody(body) require.NoError(t, err) - require.Len(t, calls, 1) + require.Len(t, entries, 1) + call := entries[0].Call + require.NotNil(t, call) - assert.False(t, calls[0].HasID) - assert.Nil(t, calls[0].ID) - assert.Equal(t, "log", calls[0].Procedure) + assert.False(t, call.HasID) + assert.Nil(t, call.ID) + assert.Equal(t, "log", call.Procedure) } /* @@ -95,12 +102,14 @@ func TestJsonRpcProtocol_ParseBody_NullId(t *testing.T) { proto := newTestProto("method") body := []byte(`{"jsonrpc":"2.0","method":"notify","id":null}`) - calls, err := proto.ParseBody(body) + entries, err := proto.ParseBody(body) require.NoError(t, err) - require.Len(t, calls, 1) + require.Len(t, entries, 1) + call := entries[0].Call + require.NotNil(t, call) - assert.False(t, calls[0].HasID) - assert.Nil(t, calls[0].ID) + assert.False(t, call.HasID) + assert.Nil(t, call.ID) } /* @@ -122,7 +131,128 @@ func TestJsonRpcProtocol_ParseBody_InvalidJSON(t *testing.T) { } /* -Scenario: JsonRpcProtocol.ParseBody returns error on missing jsonrpc field +Scenario: JsonRpcProtocol.ParseBody reports error code -32600 for missing jsonrpc +Given a JsonRpcProtocol +When ParseBody is called with a valid JSON object but missing jsonrpc +Then it returns a typed error whose code is -32600 Invalid Request + +Related spec scenarios: RS.JRP.13 +*/ +func TestJsonRpcProtocol_ParseBody_MissingJsonrpc_Code(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"method":"sub","id":1}`) + + _, err := proto.ParseBody(body) + require.Error(t, err) + assert.Equal(t, -32600, rpcErrorCode(err)) +} + +/* +Scenario: JsonRpcProtocol.ParseBody reports -32600 for missing method +Given a JsonRpcProtocol +When ParseBody is called with jsonrpc: "2.0" but no method field +Then it returns a typed error whose code is -32600 Invalid Request + +Related spec scenarios: RS.JRP.14 +*/ +func TestJsonRpcProtocol_ParseBody_MissingMethod_Code(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"jsonrpc":"2.0","id":1}`) + + _, err := proto.ParseBody(body) + require.Error(t, err) + assert.Equal(t, -32600, rpcErrorCode(err)) +} + +/* +Scenario: JsonRpcProtocol.ParseBody reports -32600 for wrong version +Given a JsonRpcProtocol +When ParseBody is called with jsonrpc: "1.0" +Then it returns a typed error whose code is -32600 Invalid Request + +Related spec scenarios: RS.JRP.15 +*/ +func TestJsonRpcProtocol_ParseBody_WrongVersion_Code(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"jsonrpc":"1.0","method":"sub","id":1}`) + + _, err := proto.ParseBody(body) + require.Error(t, err) + assert.Equal(t, -32600, rpcErrorCode(err)) +} + +/* +Scenario: JsonRpcProtocol.ParseBody reports -32700 for malformed JSON +Given a JsonRpcProtocol +When ParseBody is called with invalid JSON +Then it returns a typed error whose code is -32700 Parse error + +Related spec scenarios: RS.JRP.12 +*/ +func TestJsonRpcProtocol_ParseBody_InvalidJSON_Code(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`not json`) + + _, err := proto.ParseBody(body) + require.Error(t, err) + assert.Equal(t, -32700, rpcErrorCode(err)) +} + +/* +Scenario: JsonRpcProtocol.ParseBody rejects a top-level non-object/array +Given a JsonRpcProtocol +When ParseBody is called with a valid JSON scalar (e.g. 42) +Then it returns a typed error whose code is -32600 Invalid Request + +Related spec scenarios: RS.JRP.33 +*/ +func TestJsonRpcProtocol_ParseBody_ScalarBody_Code(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`42`) + + _, err := proto.ParseBody(body) + require.Error(t, err) + assert.Equal(t, -32600, rpcErrorCode(err)) +} + +/* +Scenario: JsonRpcProtocol.ParseBody handles batch with a malformed element +Given a JsonRpcProtocol +When ParseBody is called with a batch containing one malformed element +Then the valid calls are returned and the malformed element yields an +RpcParsedError with code -32600 instead of failing the whole batch + +Related spec scenarios: RS.JRP.33 +*/ +func TestJsonRpcProtocol_ParseBody_BatchMalformedElement(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`[{"jsonrpc":"2.0","method":"add","id":1},"not-an-object",{"jsonrpc":"1.0","method":"sub","id":2}]`) + + entries, err := proto.ParseBody(body) + require.NoError(t, err, "one malformed batch element must not fail the whole batch") + require.Len(t, entries, 3, "batch order is preserved: 1 valid call + 2 errors") + require.NotNil(t, entries[0].Call, "first element is a valid call") + assert.Equal(t, "add", entries[0].Call.Procedure) + require.NotNil(t, entries[1].Error, "second element is a parse error") + assert.Equal(t, -32600, entries[1].Error.Code) + require.NotNil(t, entries[2].Error, "third element is a parse error") + assert.Equal(t, -32600, entries[2].Error.Code) +} + +/* +Scenario: JsonRpcProtocol.ParseBody handles error on missing jsonrpc field Given a JsonRpcProtocol When ParseBody is called with a valid JSON object but missing jsonrpc Then it returns an error @@ -192,9 +322,9 @@ func TestJsonRpcProtocol_ParseBody_EmptyBatch(t *testing.T) { proto := newTestProto("method") body := []byte(`[]`) - calls, err := proto.ParseBody(body) - require.NoError(t, err) - assert.Empty(t, calls) + _, err := proto.ParseBody(body) + require.Error(t, err, "an empty batch is an Invalid Request per JSON-RPC 2.0") + assert.Equal(t, -32600, rpcErrorCode(err)) } /* @@ -211,11 +341,11 @@ func TestJsonRpcProtocol_ParseBody_CustomCallPath(t *testing.T) { proto := newTestProto("custom.proc") body := []byte(`{"jsonrpc":"2.0","method":"ignore","custom":{"proc":"subtract"},"id":1}`) - calls, err := proto.ParseBody(body) + entries, err := proto.ParseBody(body) require.NoError(t, err) - require.Len(t, calls, 1) - - assert.Equal(t, "subtract", calls[0].Procedure) + require.Len(t, entries, 1) + require.NotNil(t, entries[0].Call) + assert.Equal(t, "subtract", entries[0].Call.Procedure) } /* @@ -311,6 +441,15 @@ func TestJsonRpcProtocol_ContentType(t *testing.T) { var _ RpcProtocol = (*JsonRpcProtocol)(nil) // Ensure loader.RpcConfig integration +/* +Scenario: Building a JSON-RPC protocol from a loader RpcConfig +Given a loader.RpcConfig with a content type and procedure call path +When NewJsonRpcProtocol is called +Then the protocol exposes the configured content type and extracts the +configured procedure path + +Related spec scenarios: RS.JRP.1 +*/ func TestNewJsonRpcProtocol_FromConfig(t *testing.T) { cfg := &loader.RpcConfig{ ContentType: "application/json", diff --git a/internal/server/manage_stream_lifecycle_test.go b/internal/server/manage_stream_lifecycle_test.go index 053dc6c..679fff3 100644 --- a/internal/server/manage_stream_lifecycle_test.go +++ b/internal/server/manage_stream_lifecycle_test.go @@ -151,3 +151,41 @@ func TestManageStream_ScheduleEnvelopes(t *testing.T) { assert.Equal(t, "/alerts", stopped[0].Schedule.Channel) assert.Equal(t, 50, stopped[0].Schedule.Interval) } + +/* +Scenario: A silently-dead management stream subscriber is reaped +Given a running server with a short management-stream read idle and a connected +/mock/stream subscriber that stops sending frames +When the idle interval elapses +Then the subscriber is removed from the management stream registry and its +handler goroutine returns + +Related spec scenarios: RS.AMG.29 +*/ +func TestManageStream_ReapsIdleSubscriber(t *testing.T) { + t.Parallel() + + doc, err := asyncapi.Parse([]byte(streamLifecycleDoc)) + require.NoError(t, err) + schemas := []loader.SchemaInfo{{Kind: loader.KindAsyncAPI, Async: doc, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize, EnableControlAPI: true}, schemas) + require.NoError(t, err) + // Force a short idle so the reaping path is exercised without waiting on + // the 60s production bound. + srv.manageStream.mu.Lock() + srv.manageStream.readIdle = 60 * time.Millisecond + srv.manageStream.mu.Unlock() + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + stream := dialManageStream(t, ts.URL, "") + // Do not read: a silently-dead peer. + time.Sleep(300 * time.Millisecond) + + srv.manageStream.mu.RLock() + subCount := len(srv.manageStream.subs) + srv.manageStream.mu.RUnlock() + _ = stream.Close() //nolint:errcheck + assert.Zero(t, subCount, "idle subscriber must be reaped from the registry") +} diff --git a/internal/server/manage_ws.go b/internal/server/manage_ws.go index d44245b..6147346 100644 --- a/internal/server/manage_ws.go +++ b/internal/server/manage_ws.go @@ -68,13 +68,17 @@ type manageStream struct { mu sync.RWMutex subs map[*wsWriter]*manageStreamSub verbose bool + // readIdle bounds how long a subscriber may stay silent before being + // reaped from the registry (defaults to wsReadIdleBounds). + readIdle time.Duration } // newManageStream creates the management stream registry. func newManageStream(verbose bool) *manageStream { return &manageStream{ - subs: make(map[*wsWriter]*manageStreamSub), - verbose: verbose, + subs: make(map[*wsWriter]*manageStreamSub), + verbose: verbose, + readIdle: wsReadIdleBounds, } } @@ -285,11 +289,20 @@ func (s *Server) handleManageStream(w http.ResponseWriter, r *http.Request) { stopPings := make(chan struct{}) defer close(stopPings) go servePings(wr, manageStreamPingInterval, stopPings) + // Bound the read loop so a silently-dead subscriber (half-open socket that + // stops sending peers) is reaped along with its registry entry instead of + // leaking the handler goroutine. + idle := s.manageStream.readIdle + if idle <= 0 { + idle = wsReadIdleBounds + } + _ = conn.SetReadDeadline(time.Now().Add(idle)) for { messageType, payload, rerr := conn.ReadMessage() if rerr != nil { return } + _ = conn.SetReadDeadline(time.Now().Add(idle)) if messageType == websocket.PingMessage { wr.writeMessage(websocket.PongMessage, payload) continue diff --git a/internal/server/management_async.go b/internal/server/management_async.go index 58bc7aa..691c532 100644 --- a/internal/server/management_async.go +++ b/internal/server/management_async.go @@ -2,7 +2,6 @@ package server import ( "encoding/json" - "io" "net/http" "time" @@ -57,16 +56,20 @@ func (s *Server) handleAsyncPush(w http.ResponseWriter, r *http.Request) { } if req.Delay > 0 { + done := s.eventBus.doneChannel() go func() { - time.Sleep(time.Duration(req.Delay) * time.Millisecond) + select { + case <-done: + return + case <-time.After(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}) + writeJSON(w, http.StatusOK, map[string]any{"success": true}) } // evaluatePushPayload evaluates runtime expressions in a pushed payload using @@ -74,8 +77,8 @@ func (s *Server) handleAsyncPush(w http.ResponseWriter, r *http.Request) { 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()) + evaluator.AddSource(runtime.SourceState, s.newStateSource(prefix)) + evaluator.AddSource(runtime.SourceEnv, s.newEnvSource()) return s.evaluateValue(payload, evaluator) } @@ -99,11 +102,7 @@ func (s *Server) prefixForChannel(channel string) string { // 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 { + if err := decodeJSONBody(r, &req); err != nil { return req, err } return req, nil @@ -122,28 +121,24 @@ func (s *Server) hasConnection(id string) bool { 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. +// pushToChannel delivers a payload to a channel's consumers through the +// ConsumerBus (hubManager) so the targeted and broadcast paths match the +// event-delivery pipeline instead of re-implementing the registry writes. A +// connection id targets one consumer; otherwise the payload is broadcast to +// every SignalR open-stream and raw ws consumer of the channel. 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) + if connectionID != "" { + for _, candidate := range s.hubMgr.Candidates(channel) { + if candidate.ConnectionID != connectionID { + continue } + s.hubMgr.PushTo(candidate, channel, payload) + return } + return } + s.hubMgr.SignalRPush(channel, payload) + s.hubMgr.WSBroadcast(channel, payload) } // matchingHubChannel finds the channel ID within a hub serving the address. @@ -204,14 +199,7 @@ func (s *Server) handleAsyncConsumers(w http.ResponseWriter, r *http.Request) { } } - 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) + writeJSON(w, http.StatusOK, map[string]any{"consumers": consumers}) } // handleGoneSchedule answers the removed /_mock/ws/schedule surface with HTTP @@ -222,23 +210,22 @@ func (s *Server) handleGoneSchedule(w http.ResponseWriter, r *http.Request) { "the async schedule endpoint is removed; use POST /_mock/examples with an AsyncAPI target, response.body and interval (and DELETE /_mock/examples/{exampleId} to stop)") } +// disconnectRequest is the payload of POST /_mock/async/disconnect +// (and the deprecated /_mock/ws/disconnect alias). +type disconnectRequest struct { + ConnectionID string `json:"connectionId"` + Reason string `json:"reason"` + Code int `json:"code"` + Abrupt bool `json:"abrupt"` +} + // 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 { + var req disconnectRequest + if err := decodeJSONBody(r, &req); 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 @@ -271,17 +258,11 @@ func (s *Server) handleAsyncDisconnect(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + writeJSON(w, http.StatusOK, 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"` -}) { +func (s *Server) disconnectWS(w *wsWriter, req disconnectRequest) { if w == nil { return } diff --git a/internal/server/openapi_param_test.go b/internal/server/openapi_param_test.go new file mode 100644 index 0000000..560e233 --- /dev/null +++ b/internal/server/openapi_param_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const paramOpenAPISpec = ` +openapi: 3.0.3 +info: + title: Param API + version: 1.0.0 +paths: + /users/{userId}: + get: + operationId: getUser + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + examples: + default: + value: + id: "{$request.path.userId}" +` + +/* +Scenario: OpenAPI path parameters are captured end-to-end via the router +Given an OpenAPI spec with GET /users/{userId} and an example referencing +{$request.path.userId} +When a request arrives at /users/123 through the real router +Then the response body contains the captured parameter value 123 + +Related spec scenarios: RS.MSC.5 +*/ +func TestOpenAPIParam_CapturedEndToEnd(t *testing.T) { + t.Parallel() + + ldr := openapi3.NewLoader() + spec, err := ldr.LoadFromData([]byte(paramOpenAPISpec)) + require.NoError(t, err) + require.NoError(t, spec.Validate(ldr.Context)) + + schemas := []loader.SchemaInfo{{Kind: loader.KindOpenAPI, Spec: spec, Prefix: ""}} + srv, err := New(Config{HistorySize: DefaultHistorySize}, schemas) + require.NoError(t, err) + defer func() { _ = srv.Shutdown(context.Background()) }() + + ts := httptest.NewServer(srv.router) + defer ts.Close() //nolint:errcheck + + resp, err := http.Get(ts.URL + "/users/123") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + body := make([]byte, 256) + n, _ := resp.Body.Read(body) + assert.Contains(t, string(body[:n]), `"id":"123"`) +} diff --git a/internal/server/protocol.go b/internal/server/protocol.go index f518bfa..940436b 100644 --- a/internal/server/protocol.go +++ b/internal/server/protocol.go @@ -3,6 +3,8 @@ package server import ( "context" "net/http" + + "github.com/mamonth/oasmock/internal/asyncapi" ) // InboundMessage is a message received from a client on an AsyncAPI channel. @@ -45,15 +47,15 @@ type ProtocolAdapter interface { // defaultProtocolAdapters is the set of adapters seeded for a new server. func defaultProtocolAdapters() map[string]ProtocolAdapter { return map[string]ProtocolAdapter{ - asyncHTTPProtocol: &httpProtocolAdapter{}, - asyncWSProtocol: newWSProtocolAdapter(), + asyncapi.ProtocolHTTP: &httpProtocolAdapter{}, + asyncapi.ProtocolWS: 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 { + if a, ok := s.protocolAdapters[asyncapi.ProtocolWS].(*wsProtocolAdapter); ok && a != nil { return a.registry } return nil @@ -63,8 +65,3 @@ func (s *Server) wsRegistry() *connectionRegistry { 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 index 529d49f..3f5ef8b 100644 --- a/internal/server/protocol_test.go +++ b/internal/server/protocol_test.go @@ -15,7 +15,7 @@ func newTestRecorder() *httptest.ResponseRecorder { } func newTestRequest() *http.Request { - return httptest.NewRequest("GET", "/", nil) + return httptest.NewRequest(http.MethodGet, "/", nil) } /* @@ -29,7 +29,7 @@ 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}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) httpAdapter := srv.adapterForProtocol("http") require.NotNil(t, httpAdapter) @@ -53,7 +53,7 @@ Related spec scenarios: RS.ASP.4 func TestServer_NoAdapterForUnsupportedProtocol(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) assert.Nil(t, srv.adapterForProtocol("amqp")) assert.Nil(t, srv.adapterForProtocol("kafka")) diff --git a/internal/server/registry.go b/internal/server/registry.go index 9e2820f..51c41ce 100644 --- a/internal/server/registry.go +++ b/internal/server/registry.go @@ -116,46 +116,9 @@ func (r *exampleRegistry) selectDynamic(key string, eval runtime.Evaluator) (*dy 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) - } + if !r.exampleEligible(ex, eval) { 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) @@ -171,6 +134,22 @@ func (r *exampleRegistry) selectDynamic(key string, eval runtime.Evaluator) (*dy return nil, "" } +// exampleEligible reports whether a dynamic example is selectable: it is not +// once-used or expired, and its conditions evaluate true (or it has none). +func (r *exampleRegistry) exampleEligible(ex dynamicExample, eval runtime.Evaluator) bool { + if ex.once && r.isOnceUsed(ex.onceID) { + return false + } + if isExpired(ex) { + return false + } + if len(ex.conditions) == 0 { + return true + } + matched, err := extensions.EvaluateParamsMatch(extensions.ParamsMatch(ex.conditions), eval) + return err == nil && matched +} + // sweepExpired removes expired dynamic examples and their once markers. func (r *exampleRegistry) sweepExpired() { r.dyMu.Lock() diff --git a/internal/server/server.go b/internal/server/server.go index 01d5730..8f7e1ae 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,29 +1,19 @@ package server import ( - "bufio" - "bytes" "context" - "encoding/json" "fmt" - "io" - "log/slog" "net" "net/http" "strconv" - "strings" "sync" "time" - "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/cors" - "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/asyncapi" "github.com/mamonth/oasmock/internal/history" "github.com/mamonth/oasmock/internal/loader" - "github.com/mamonth/oasmock/internal/runtime" "github.com/mamonth/oasmock/internal/state" ) @@ -34,58 +24,6 @@ const ( DefaultMethod = "GET" ) -// writeJSONError writes a JSON error response with the given status code and message. -func writeJSONError(w http.ResponseWriter, status int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _, _ = fmt.Fprintf(w, `{"error": %q}`, message) -} - -// writeJSONErrorf writes a formatted JSON error response. -func writeJSONErrorf(w http.ResponseWriter, status int, format string, args ...any) { - writeJSONError(w, status, fmt.Sprintf(format, args...)) -} - -// routeKey generates a unique key for a route mapping. -func routeKey(method, pattern string) string { - return method + " " + pattern -} - -// responseRecorder wraps http.ResponseWriter to capture status code and body. -type responseRecorder struct { - http.ResponseWriter - statusCode int - body []byte -} - -// WriteHeader captures the status code before writing. -func (r *responseRecorder) WriteHeader(statusCode int) { - r.statusCode = statusCode - r.ResponseWriter.WriteHeader(statusCode) -} - -// Write captures the written body. -func (r *responseRecorder) Write(b []byte) (int, error) { - r.body = append(r.body, b...) - 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 @@ -125,15 +63,9 @@ type Server struct { // New creates a new mock server with the given configuration and loaded schemas. func New(config Config, schemas []loader.SchemaInfo) (*Server, error) { serverSchemas := make([]SchemaInfo, len(schemas)) + copy(serverSchemas, schemas) rpcConfig := (*loader.RpcConfig)(nil) - for i, schema := range schemas { - serverSchemas[i] = SchemaInfo{ - Spec: schema.Spec, - Kind: schema.Kind, - Async: schema.Async, - Prefix: schema.Prefix, - } - + for _, schema := range schemas { if schema.Kind == loader.KindAsyncAPI { continue } @@ -166,14 +98,9 @@ func New(config Config, schemas []loader.SchemaInfo) (*Server, error) { historyStore := newHistoryRingBufferStore(history.NewRingBuffer(historySize)) deps := Dependencies{ - RouteProvider: routeProvider, - StateStore: stateStore, - HistoryStore: historyStore, - RequestSourceFactory: &runtimeRequestSourceFactory{}, - StateSourceFactory: newRuntimeStateSourceFactory(stateStore), - EnvSourceFactory: &runtimeEnvSourceFactory{}, - ExpressionEvaluator: newRuntimeExpressionEvaluatorWrapper(runtime.NewEvaluator()), - ExtensionProcessor: &extensionsProcessorWrapper{}, + RouteProvider: routeProvider, + StateStore: stateStore, + HistoryStore: historyStore, } return NewWithDependencies(config, serverSchemas, deps, rpcConfig, rpcMappings) @@ -205,7 +132,7 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, rpcMappings: rpcMappings, protocolAdapters: defaultProtocolAdapters(), } - s.hubMgr = newHubManager(s.engine, s.protocolAdapters[asyncWSProtocol].(*wsProtocolAdapter), schemas) + s.hubMgr = newHubManager(s.engine, s.protocolAdapters[asyncapi.ProtocolWS].(*wsProtocolAdapter), schemas) s.manageStream = newManageStream(config.Verbose) s.runtimeExamples = newRuntimeExampleRegistry() s.eventBus = newEventBus(s.engine, s.hubMgr, config.Verbose) @@ -227,23 +154,14 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, gwPath := rpcConfig.Gateway for _, schema := range schemas { - gwPath = applyPrefixRpc(schema.Prefix, rpcConfig.Gateway) + gwPath = loader.PrefixPath(schema.Prefix, rpcConfig.Gateway) break } procMap := make(map[string]*RouteMapping) - for _, m := range rpcMappings { - rm := &RouteMapping{ - Method: m.Method, - Path: m.Path, - Pattern: m.Pattern, - Prefix: m.Prefix, - ChiPattern: m.ChiPattern, - Operation: m.Operation, - Parameters: m.Parameters, - Responses: m.Responses, - } - procMap[m.Procedure] = rm + for i := range rpcMappings { + m := rpcMappings[i] + procMap[m.Procedure] = &m.RouteMapping } s.rpcHandler = NewRpcHandler(proto, procMap, s) s.gatewayPath = gwPath @@ -259,436 +177,6 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, return s, nil } -func (s *Server) setupRouter() { - r := chi.NewRouter() - - // Basic middleware - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - - // Request delay middleware - if s.config.Delay > 0 { - r.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(s.config.Delay) - next.ServeHTTP(w, r) - }) - }) - } - - // CORS middleware - if s.config.EnableCORS { - corsMiddleware := cors.New(cors.Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, - AllowedHeaders: []string{"*"}, - AllowCredentials: false, - MaxAge: 300, - }) - r.Use(corsMiddleware.Handler) - } - - // Request history recording middleware - r.Use(s.requestHistoryMiddleware) - - // Verbose logging middleware - r.Use(s.verboseLoggingMiddleware) - - // 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) - slog.Info("Registered RPC gateway", "path", s.gatewayPath, "procedures", len(s.rpcHandler.procedureMap)) - } - - // Register management API routes - if s.config.EnableControlAPI { - s.registerManagementRoutes(r) - } else { - slog.Debug("Management control API disabled") - } - - s.router = r -} - -func (s *Server) registerMockRoutes(r chi.Router) { - slog.Debug("registerMockRoutes called", "verbose", s.config.Verbose, "numMappings", len(s.mappings)) - - rpcChiPatterns := make(map[string]bool) - for _, m := range s.rpcMappings { - rpcChiPatterns[m.ChiPattern] = true - } - - for i := range s.mappings { - mapping := &s.mappings[i] - if rpcChiPatterns[mapping.ChiPattern] { - continue - } - 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, 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.Delete("/_mock/examples/{exampleId}", s.handleDeleteExample) - r.Get("/_mock/requests", s.handleGetRequests) - - // Canonical protocol-neutral async surface (design D1). - r.Post("/_mock/events", s.handleEvents) - r.Post("/_mock/async/push", s.handleAsyncPush) - r.Get("/_mock/async/consumers", s.handleAsyncConsumers) - r.Post("/_mock/async/disconnect", s.handleAsyncDisconnect) - r.Get("/_mock/stream", s.handleManageStream) - - // Deprecated aliases kept for one release (design D1). The events/fire - // alias serves the legacy type-less contract (handleFireEventLegacy); the - // ws aliases share the canonical handlers. - r.Post("/_mock/events/fire", s.handleFireEventLegacy) - r.Post("/_mock/ws/push", s.handleAsyncPush) - r.Get("/_mock/ws/consumers", s.handleAsyncConsumers) - r.Post("/_mock/ws/disconnect", s.handleAsyncDisconnect) - - // Removed schedule surface answers 410 Gone pointing at /_mock/examples. - r.Post("/_mock/ws/schedule", s.handleGoneSchedule) - r.Delete("/_mock/ws/schedule/{pushId}", s.handleGoneSchedule) -} - -func (s *Server) newRequestSource(r *http.Request, pathParams map[string]string) *runtime.RequestSource { - // Parse query parameters - query := r.URL.Query() - queryMap := make(map[string][]string) - for k, v := range query { - queryMap[k] = v - } - // Parse headers (lowercase keys) - headers := make(map[string][]string) - for k, v := range r.Header { - headers[strings.ToLower(k)] = v - } - // Parse cookies - cookies := make(map[string]string) - for _, c := range r.Cookies() { - cookies[c.Name] = c.Value - } - // Parse body (JSON only for now) - note: body already read by requestHistoryMiddleware - var body any - if r.Body != nil { - bodyBytes, err := io.ReadAll(r.Body) - if err == nil && len(bodyBytes) > 0 { - var parsed any - if err := json.Unmarshal(bodyBytes, &parsed); err == nil { - body = parsed - } - // Restore body for downstream handlers - r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - } - } - return &runtime.RequestSource{ - PathParams: pathParams, - QueryParams: queryMap, - Headers: headers, - Body: body, - Cookies: cookies, - } -} - -func (s *Server) newStateSource(prefix string) *runtime.StateSource { - return s.engine.NewStateSource(prefix) -} - -func (s *Server) newEnvSource() *runtime.EnvSource { - return s.engine.NewEnvSource() -} - -func (s *Server) makeMockHandler(mapping *RouteMapping) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if s.config.Verbose { - slog.Debug("makeMockHandler invoked", "method", r.Method, "path", r.URL.Path, "pattern", mapping.Pattern, "chiPattern", mapping.ChiPattern) - } - s.handleMockRequestWithMapping(w, r, mapping) - } -} - -func (s *Server) handleMockRequestWithMapping(w http.ResponseWriter, r *http.Request, mapping *RouteMapping) { - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping called", "method", r.Method, "path", r.URL.Path, "mappingPattern", mapping.Pattern) - } - pathParams := s.extractPathParams(r, mapping) - - body, headers, statusCodeStr, mediaType, err := s.selectAndGenerateResponse(r, mapping, pathParams, nil) - if err != nil { - if err == errNoResponse || err == errNoExample { - writeJSONError(w, http.StatusInternalServerError, "No response defined for operation") - return - } - if err == errNotImplemented { - writeJSONError(w, http.StatusNotImplemented, "No example available") - return - } - writeJSONErrorf(w, http.StatusInternalServerError, err.Error()) - return - } - - for k, v := range headers { - w.Header().Set(k, v) - } - w.Header().Set("Content-Type", mediaType) - w.WriteHeader(parseStatusCode(statusCodeStr)) - if _, writeErr := w.Write(body); writeErr != nil && s.config.Verbose { - slog.Debug("Failed to write response body", "err", writeErr) - } -} - -var ( - errNoResponse = fmt.Errorf("no response") - errNotImplemented = fmt.Errorf("not implemented") - errNoExample = fmt.Errorf("no example") -) - -func (s *Server) selectAndGenerateResponse(r *http.Request, mapping *RouteMapping, pathParams map[string]string, callBody any) (body []byte, headers map[string]string, statusCode string, mediaType string, err error) { - evaluator := runtime.NewEvaluator() - if callBody != nil { - evaluator.AddSource("request", s.newRpcRequestSource(r, pathParams, callBody)) - } else { - evaluator.AddSource("request", s.newRequestSource(r, pathParams)) - } - evaluator.AddSource("state", s.newStateSource(mapping.Prefix)) - evaluator.AddSource("env", s.newEnvSource()) - - statusCode, response := s.selectResponse(mapping, evaluator) - if response == nil { - return nil, nil, "", "", errNoResponse - } - - var mediaTypeObj *openapi3.MediaType - mediaType = "application/json" - if len(response.Content) > 0 { - var mtErr error - mediaType, mediaTypeObj, mtErr = s.selectMediaType(response) - if mtErr != nil { - return nil, nil, "", "", mtErr - } - } - - opID := mapping.Prefix + ":" + mapping.Method + ":" + mapping.Pattern - - var example *openapi3.Example - var dynExample *dynamicExample - dynExample, _ = s.selectDynamicExample(mapping, evaluator) - if dynExample == nil { - if mediaTypeObj == nil { - return nil, nil, "", "", errNotImplemented - } - example, _ = s.selectExample(mediaTypeObj, evaluator, opID) - if example == nil { - return nil, nil, "", "", errNotImplemented - } - } - - if example != nil { - s.applyExtensions(example, evaluator, mapping.Prefix) - } - - body, headers, statusCode, genErr := s.generateResponse(example, dynExample, evaluator, statusCode) - if genErr != nil { - 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) - for k, v := range query { - queryMap[k] = v - } - headers := make(map[string][]string) - for k, v := range r.Header { - headers[strings.ToLower(k)] = v - } - cookies := make(map[string]string) - for _, c := range r.Cookies() { - cookies[c.Name] = c.Value - } - return &runtime.RequestSource{ - PathParams: pathParams, - QueryParams: queryMap, - Headers: headers, - Body: callBody, - Cookies: cookies, - } -} - -func parseStatusCode(codeStr string) int { - if codeStr == "default" { - return DefaultStatusCode - } - code, err := strconv.Atoi(codeStr) - if err != nil { - return DefaultStatusCode - } - return code -} - -// requestHistoryMiddleware records incoming requests and responses. -func (s *Server) requestHistoryMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Capture request body (up to 1MB) - var requestBody []byte - if r.Body != nil { - body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) // 1MB - if err == nil { - requestBody = body - // Restore body for downstream handlers - r.Body = io.NopCloser(bytes.NewReader(body)) - } else if s.config.Verbose { - slog.Debug("Failed to read request body", "err", err) - } - } - - // Create response recorder - recorder := &responseRecorder{ - ResponseWriter: w, - statusCode: http.StatusOK, // default if not set - } - - start := time.Now() - next.ServeHTTP(recorder, r) - duration := time.Since(start) - - // Build request record - record := RequestRecord{ - ID: fmt.Sprintf("%d", start.UnixNano()), - Timestamp: start, - Method: r.Method, - Path: r.URL.Path, - Query: r.URL.RawQuery, - Headers: r.Header.Clone(), - Body: requestBody, - Response: &ResponseRecord{ - StatusCode: recorder.statusCode, - Headers: recorder.Header().Clone(), - Body: recorder.body, - Duration: duration, - }, - } - - s.historyStore.Add(record) - }) -} - -// verboseLoggingMiddleware logs request/response details when verbose is enabled. -func (s *Server) verboseLoggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !s.config.Verbose { - next.ServeHTTP(w, r) - return - } - - start := time.Now() - slog.Info("Request started", "time", start.Format(time.RFC3339), "method", r.Method, "path", r.URL.Path) - if ctx := chi.RouteContext(r.Context()); ctx != nil { - slog.Debug("Route matched", "pattern", ctx.RoutePattern(), "keys", ctx.URLParams.Keys, "values", ctx.URLParams.Values) - } else { - slog.Debug("No route matched") - } - next.ServeHTTP(w, r) - duration := time.Since(start) - slog.Info("Request completed", "time", start.Format(time.RFC3339), "duration", duration) - }) -} - -// extractPathParams extracts path parameters from the request using chi URL params. -func (s *Server) extractPathParams(r *http.Request, mapping *RouteMapping) map[string]string { - params := make(map[string]string) - // Get chi route context - ctx := chi.RouteContext(r.Context()) - if s.config.Verbose { - slog.Debug("extractPathParams", "ctxNil", ctx == nil, "method", r.Method, "path", r.URL.Path, "chiPattern", mapping.ChiPattern) - } - if ctx == nil { - return params - } - // URLParams are stored in ctx.URLParams.Keys and Values - for i, key := range ctx.URLParams.Keys { - if i < len(ctx.URLParams.Values) { - params[key] = ctx.URLParams.Values[i] - } - } - return params -} - -// Start starts the HTTP server, returning once the server is serving. func (s *Server) Start() error { ln, _, err := s.Listen() if err != nil { @@ -758,15 +246,3 @@ func (s *Server) Shutdown(ctx context.Context) error { }) return s.shutdownResult } - -func applyPrefixRpc(prefix, path string) string { - if prefix == "" { - return path - } - p := "/" + strings.Trim(prefix, "/") - pp := "/" + strings.Trim(path, "/") - if pp == "/" { - return p - } - return p + pp -} diff --git a/internal/server/server_example.go b/internal/server/server_example.go index bfba870..beb7621 100644 --- a/internal/server/server_example.go +++ b/internal/server/server_example.go @@ -2,7 +2,6 @@ package server import ( "github.com/getkin/kin-openapi/openapi3" - "github.com/mamonth/oasmock/internal/loader" "github.com/mamonth/oasmock/internal/runtime" ) @@ -42,9 +41,3 @@ 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 getStatusCode(mapping *loader.RouteMapping, response *openapi3.Response) int { - // TODO: parse status code from mapping (key in Responses map) - // For now, default to 200 - return 200 -} diff --git a/internal/server/server_http.go b/internal/server/server_http.go new file mode 100644 index 0000000..de91e0d --- /dev/null +++ b/internal/server/server_http.go @@ -0,0 +1,403 @@ +package server + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" + "github.com/mamonth/oasmock/internal/extensions" + "github.com/mamonth/oasmock/internal/runtime" +) + +type responseRecorder struct { + http.ResponseWriter + statusCode int + body []byte +} + +// writeJSONError writes a JSON error response with the given status code and message. +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"error": %q}`, message) +} + +// writeJSONErrorf writes a formatted JSON error response. +func writeJSONErrorf(w http.ResponseWriter, status int, format string, args ...any) { + writeJSONError(w, status, fmt.Sprintf(format, args...)) +} + +// writeJSON writes a JSON-encoded success/response body with the given status +// code. It is the single success-write point for the management handlers so +// they no longer duplicate the Content-Type + Encoder block per endpoint. +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body == nil { + return + } + if err := json.NewEncoder(w).Encode(body); err != nil { + slog.Debug("Failed to encode JSON response", "err", err) + } +} + +// decodeJSONBody reads a size-limited request body and unmarshals it into v. +// It returns an error on read failure, an empty body, or malformed JSON. +func decodeJSONBody(r *http.Request, v any) error { + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + if err != nil { + return err + } + if len(bytes.TrimSpace(body)) == 0 { + return fmt.Errorf("empty request body") + } + return json.Unmarshal(body, v) +} + +// routeKey generates a unique key for a route mapping. +func routeKey(method, pattern string) string { + return method + " " + pattern +} + +// WriteHeader captures the status code before writing. +func (r *responseRecorder) WriteHeader(statusCode int) { + r.statusCode = statusCode + r.ResponseWriter.WriteHeader(statusCode) +} + +// Write captures the written body. +func (r *responseRecorder) Write(b []byte) (int, error) { + r.body = append(r.body, b...) + 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() + } +} + +func buildRequestSource(r *http.Request, pathParams map[string]string, callBody any) *runtime.RequestSource { + // Parse query parameters + query := r.URL.Query() + queryMap := make(map[string][]string, len(query)) + for k, v := range query { + queryMap[k] = v + } + // Parse headers (lowercase keys) + headers := make(map[string][]string, len(r.Header)) + for k, v := range r.Header { + headers[strings.ToLower(k)] = v + } + // Parse cookies + cookies := make(map[string]string) + for _, c := range r.Cookies() { + cookies[c.Name] = c.Value + } + // Parse body (JSON only for now) - note: body already read by + // requestHistoryMiddleware; RPC bodies arrive pre-decoded in callBody. + body := callBody + if callBody == nil && r.Body != nil { + if bodyBytes, err := io.ReadAll(r.Body); err == nil && len(bodyBytes) > 0 { + var parsed any + if err := json.Unmarshal(bodyBytes, &parsed); err == nil { + body = parsed + } + // Restore body for downstream handlers + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + } + return &runtime.RequestSource{ + PathParams: pathParams, + QueryParams: queryMap, + Headers: headers, + Body: body, + Cookies: cookies, + } +} + +func (s *Server) newStateSource(prefix string) *runtime.StateSource { + return s.engine.NewStateSource(prefix) +} + +func (s *Server) newEnvSource() *runtime.EnvSource { + return s.engine.NewEnvSource() +} + +func (s *Server) makeMockHandler(mapping *RouteMapping) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.config.Verbose { + slog.Debug("makeMockHandler invoked", "method", r.Method, "path", r.URL.Path, "pattern", mapping.Pattern, "chiPattern", mapping.ChiPattern) + } + s.handleMockRequestWithMapping(w, r, mapping) + } +} + +func (s *Server) handleMockRequestWithMapping(w http.ResponseWriter, r *http.Request, mapping *RouteMapping) { + if s.config.Verbose { + slog.Debug("handleMockRequestWithMapping called", "method", r.Method, "path", r.URL.Path, "mappingPattern", mapping.Pattern) + } + pathParams := s.extractPathParams(r, mapping) + + body, headers, statusCodeStr, mediaType, err := s.selectAndGenerateResponse(r, mapping, pathParams, nil) + if err != nil { + if err == errNoResponse { + writeJSONError(w, http.StatusInternalServerError, "No response defined for operation") + return + } + if err == errNotImplemented { + writeJSONError(w, http.StatusNotImplemented, "No example available") + return + } + writeJSONErrorf(w, http.StatusInternalServerError, err.Error()) + return + } + + for k, v := range headers { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", mediaType) + w.WriteHeader(parseStatusCode(statusCodeStr)) + if _, writeErr := w.Write(body); writeErr != nil && s.config.Verbose { + slog.Debug("Failed to write response body", "err", writeErr) + } +} + +var ( + errNoResponse = fmt.Errorf("no response") + errNotImplemented = fmt.Errorf("not implemented") +) + +func (s *Server) selectAndGenerateResponse(r *http.Request, mapping *RouteMapping, pathParams map[string]string, callBody any) (body []byte, headers map[string]string, statusCode string, mediaType string, err error) { + evaluator := runtime.NewEvaluator() + evaluator.AddSource(runtime.SourceRequest, buildRequestSource(r, pathParams, callBody)) + evaluator.AddSource(runtime.SourceState, s.newStateSource(mapping.Prefix)) + evaluator.AddSource(runtime.SourceEnv, s.newEnvSource()) + + statusCode, response := s.selectResponse(mapping, evaluator) + if response == nil { + return nil, nil, "", "", errNoResponse + } + + var mediaTypeObj *openapi3.MediaType + mediaType = "application/json" + if len(response.Content) > 0 { + var mtErr error + mediaType, mediaTypeObj, mtErr = s.selectMediaType(response) + if mtErr != nil { + return nil, nil, "", "", mtErr + } + } + + opID := mapping.Prefix + ":" + mapping.Method + ":" + mapping.Pattern + + var example *openapi3.Example + var dynExample *dynamicExample + dynExample, _ = s.selectDynamicExample(mapping, evaluator) + if dynExample == nil { + if mediaTypeObj == nil { + return nil, nil, "", "", errNotImplemented + } + example, _ = s.selectExample(mediaTypeObj, evaluator, opID) + if example == nil { + return nil, nil, "", "", errNotImplemented + } + } + + if example != nil { + s.applyExtensions(example, evaluator, mapping.Prefix) + } + + body, headers, statusCode, genErr := s.generateResponse(example, dynExample, evaluator, statusCode) + if genErr != nil { + 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 parseStatusCode(codeStr string) int { + if codeStr == "default" { + return DefaultStatusCode + } + code, err := strconv.Atoi(codeStr) + if err != nil { + return DefaultStatusCode + } + return code +} + +// requestHistoryMiddleware records incoming requests and responses. +func (s *Server) requestHistoryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Capture request body (up to 1MB) + var requestBody []byte + if r.Body != nil { + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) // 1MB + if err == nil { + requestBody = body + // Restore body for downstream handlers + r.Body = io.NopCloser(bytes.NewReader(body)) + } else if s.config.Verbose { + slog.Debug("Failed to read request body", "err", err) + } + } + + // Create response recorder + recorder := &responseRecorder{ + ResponseWriter: w, + statusCode: http.StatusOK, // default if not set + } + + start := time.Now() + next.ServeHTTP(recorder, r) + duration := time.Since(start) + + // Build request record + record := RequestRecord{ + ID: fmt.Sprintf("%d", start.UnixNano()), + Timestamp: start, + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Headers: r.Header.Clone(), + Body: requestBody, + Response: &ResponseRecord{ + StatusCode: recorder.statusCode, + Headers: recorder.Header().Clone(), + Body: recorder.body, + Duration: duration, + }, + } + + s.historyStore.Add(record) + }) +} + +// verboseLoggingMiddleware logs request/response details when verbose is enabled. +func (s *Server) verboseLoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.config.Verbose { + next.ServeHTTP(w, r) + return + } + + start := time.Now() + slog.Info("Request started", "time", start.Format(time.RFC3339), "method", r.Method, "path", r.URL.Path) + if ctx := chi.RouteContext(r.Context()); ctx != nil { + slog.Debug("Route matched", "pattern", ctx.RoutePattern(), "keys", ctx.URLParams.Keys, "values", ctx.URLParams.Values) + } else { + slog.Debug("No route matched") + } + next.ServeHTTP(w, r) + duration := time.Since(start) + slog.Info("Request completed", "time", start.Format(time.RFC3339), "duration", duration) + }) +} + +// extractPathParams extracts path parameters from the request using chi URL +// params, falling back to pattern-matching the request path against the +// mapping's brace-form ChiPattern when chi has not populated them (the RPC +// gateway dispatch and direct handler invocations). +func (s *Server) extractPathParams(r *http.Request, mapping *RouteMapping) map[string]string { + params := make(map[string]string) + // Get chi route context + ctx := chi.RouteContext(r.Context()) + if s.config.Verbose { + slog.Debug("extractPathParams", "ctxNil", ctx == nil, "method", r.Method, "path", r.URL.Path, "chiPattern", mapping.ChiPattern) + } + if ctx != nil { + // URLParams are stored in ctx.URLParams.Keys and Values + for i, key := range ctx.URLParams.Keys { + if i < len(ctx.URLParams.Values) { + params[key] = ctx.URLParams.Values[i] + } + } + if len(params) > 0 { + return params + } + } + if mapping == nil || r == nil || r.URL == nil { + return params + } + matchPathParams(params, r.URL.Path, mapping.ChiPattern) + return params +} + +// matchPathParams extracts {param} captures by matching each segment of the +// actual request path against the corresponding segment of the brace-form chi +// pattern. Only well-formed brace segments capture; literal and malformed +// segments must match exactly. +func matchPathParams(params map[string]string, path, pattern string) { + if pattern == "" || path == "" { + return + } + patSegs := splitPathSegments(pattern) + pathSegs := splitPathSegments(path) + if len(patSegs) != len(pathSegs) { + return + } + for i, seg := range patSegs { + if len(seg) > 2 && seg[0] == '{' && seg[len(seg)-1] == '}' { + name := seg[1 : len(seg)-1] + if name != "" && !strings.ContainsAny(name, " \t") { + params[name] = pathSegs[i] + } + } + } +} + +// splitPathSegments splits an absolute path on "/" preserving empty segments +// for exact length comparison. +func splitPathSegments(p string) []string { + return strings.Split(p, "/") +} diff --git a/internal/server/server_management.go b/internal/server/server_management.go index b7d985c..65c4e2e 100644 --- a/internal/server/server_management.go +++ b/internal/server/server_management.go @@ -7,11 +7,10 @@ import ( "io" "log/slog" "net/http" - "net/url" - "strconv" "strings" "time" + "github.com/getkin/kin-openapi/openapi3" "github.com/mamonth/oasmock/internal/extensions" "github.com/mamonth/oasmock/internal/loader" "github.com/xeipuuv/gojsonschema" @@ -109,8 +108,15 @@ var addExampleRequestSchema = gojsonschema.NewGoLoader(map[string]any{ "validate": map[string]any{"type": "boolean"}, "ttl": map[string]any{"type": "integer", "minimum": 0}, "conditions": map[string]any{ - "type": "object", - "additionalProperties": true, + "type": "object", + "additionalProperties": map[string]any{ + "oneOf": []any{ + map[string]any{"type": "string"}, + map[string]any{"type": "number"}, + map[string]any{"type": "boolean"}, + map[string]any{"type": "object"}, + }, + }, }, "response": map[string]any{ "type": "object", @@ -145,248 +151,197 @@ func validateAddExampleRequest(rawJSON []byte) error { return nil } -// filterRecords filters request records based on query parameters. -func filterRecords(records []RequestRecord, query url.Values) []RequestRecord { - filtered := make([]RequestRecord, 0, len(records)) - for _, rec := range records { - // Filter by path - if path := query.Get("path"); path != "" && rec.Path != path { - continue - } - // Filter by method - if method := query.Get("method"); method != "" && rec.Method != method { - continue - } - // Filter by time_from (milliseconds since epoch) - if timeFromStr := query.Get("time_from"); timeFromStr != "" { - if timeFrom, err := strconv.ParseInt(timeFromStr, 10, 64); err == nil { - if rec.Timestamp.UnixMilli() < timeFrom { - continue - } - } - } - // Filter by time_till - if timeTillStr := query.Get("time_till"); timeTillStr != "" { - if timeTill, err := strconv.ParseInt(timeTillStr, 10, 64); err == nil { - if rec.Timestamp.UnixMilli() > timeTill { - continue - } - } - } - filtered = append(filtered, rec) - } - return filtered +// newExampleID returns a time-unique example id in the given namespace. The +// namespace prefix keeps runtime-async ids ("rtex-") disjoint from sync +// dynamic-example ids ("dynex-"), so DELETE /_mock/examples/{id} never has to +// disambiguate a collision between the two registries. +func newExampleID(namespace string) string { + return fmt.Sprintf("%s-%d", namespace, time.Now().UnixNano()) } -// paginateRecords applies offset and limit pagination to records. -func paginateRecords(records []RequestRecord, offset, limit int) []RequestRecord { - if offset < 0 { - offset = 0 - } - if offset > len(records) { - offset = len(records) - } - if limit < 0 { - limit = 0 - } - end := offset + limit - if end > len(records) { - end = len(records) - } - return records[offset:end] +// addExampleRequest is the decoded body of POST /_mock/examples. +type addExampleRequest struct { + Path string `json:"path"` + Method string `json:"method"` + Protocol string `json:"protocol"` + Channel string `json:"channel"` + Match map[string]any `json:"match"` + Interval int `json:"interval"` + Delay int `json:"delay"` + Once bool `json:"once"` + Validate *bool `json:"validate"` + TTL int `json:"ttl"` + Conditions map[string]any `json:"conditions"` + Response struct { + Code int `json:"code"` + Headers map[string]string `json:"headers"` + Body any `json:"body"` + } `json:"response"` } -// recordsToAPIResponse converts request records to API response format. -func recordsToAPIResponse(records []RequestRecord) []map[string]any { - items := make([]map[string]any, len(records)) - for i, rec := range records { - var body any - if len(rec.Body) > 0 { - // Try to unmarshal as JSON, else keep as string - var jsonBody any - if err := json.Unmarshal(rec.Body, &jsonBody); err == nil { - body = jsonBody - } else { - body = string(rec.Body) - } - } - headers := make(map[string]string) - for k, v := range rec.Headers { - if len(v) > 0 { - headers[k] = v[0] +func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { + req, err := decodeAddExampleRequest(r) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + mapping, err := s.resolveExampleTarget(req) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if req.Validate == nil || *req.Validate { + if mapping.Operation != nil && mapping.Responses != nil { + if verr := s.validateExampleResponse(req, mapping); verr != nil { + writeJSONError(w, http.StatusBadRequest, verr.Error()) + return } } - items[i] = map[string]any{ - "ts": rec.Timestamp.UnixMilli(), - "url": rec.Path + "?" + rec.Query, - "method": rec.Method, - "body": body, - "headers": headers, - } } - return items + if needsRuntimeRegistration(mapping, req) { + s.registerAsyncRuntimeExample(w, req, mapping) + return + } + s.registerDynamicExample(w, req, mapping) } -func (s *Server) handleGetRequests(w http.ResponseWriter, r *http.Request) { - records := s.historyStore.GetAll() - query := r.URL.Query() - - // Filtering - filtered := filterRecords(records, query) - - // Pagination - offset, _ := strconv.Atoi(query.Get("offset")) - if offset < 0 { - offset = 0 +// validateExampleResponse validates an add-example response body against the +// resolved route's OpenAPI response schema for the requested status code and +// media type. Async targets have no OpenAPI schema and skip validation. +func (s *Server) validateExampleResponse(req *addExampleRequest, mapping *RouteMapping) error { + if req.Response.Body == nil { + return nil } - limit, _ := strconv.Atoi(query.Get("limit")) - if limit <= 0 || limit > 100 { - limit = 100 + schema := responseSchemaFor(mapping.Responses, req.Response.Code) + if schema == nil { + return nil } - paginated := paginateRecords(filtered, offset, limit) - - // Convert to API response - items := recordsToAPIResponse(paginated) - response := map[string]any{ - "data": items, - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil && s.config.Verbose { - slog.Debug("Failed to encode response", "err", err) + if err := schema.VisitJSON(req.Response.Body); err != nil { + return fmt.Errorf("response body does not match the OpenAPI schema for status %d: %w", req.Response.Code, err) } + return nil } -// newExampleID returns a time-unique example id in the given namespace. The -// namespace prefix keeps runtime-async ids ("rtex-") disjoint from sync -// dynamic-example ids ("dynex-"), so DELETE /_mock/examples/{id} never has to -// disambiguate a collision between the two registries. -func newExampleID(namespace string) string { - return fmt.Sprintf("%s-%d", namespace, time.Now().UnixNano()) +// responseSchemaFor returns the JSON schema of a response status code's first +// JSON media type, or nil when none is declared. +func responseSchemaFor(responses *openapi3.Responses, code int) *openapi3.Schema { + if responses == nil { + return nil + } + respMap := responses.Map() + respRef := respMap[fmt.Sprintf("%d", code)] + if respRef == nil || respRef.Value == nil || respRef.Value.Content == nil { + return nil + } + for _, mt := range respRef.Value.Content { + if mt == nil || mt.Schema == nil || mt.Schema.Value == nil { + continue + } + return mt.Schema.Value + } + return nil } -func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { - // Read the raw body for validation +// decodeAddExampleRequest reads, schema-validates and decodes an add-example +// body, applying the field checks that are independent of the resolved target. +func decodeAddExampleRequest(r *http.Request) (*addExampleRequest, error) { bodyBytes, err := io.ReadAll(r.Body) if err != nil { - if s.config.Verbose { - slog.Debug("Failed to read request body", "err", err) - } - writeJSONError(w, http.StatusBadRequest, "failed to read request body") - return + return nil, fmt.Errorf("failed to read request body: %w", err) } - // Validate against OpenAPI schema if err := validateAddExampleRequest(bodyBytes); err != nil { - writeJSONError(w, http.StatusBadRequest, err.Error()) - return - } - // Decode into struct - var req struct { - Path string `json:"path"` - Method string `json:"method"` - Protocol string `json:"protocol"` - Channel string `json:"channel"` - Match map[string]any `json:"match"` - Interval int `json:"interval"` - Delay int `json:"delay"` - Once bool `json:"once"` - Validate bool `json:"validate"` - TTL int `json:"ttl"` - Conditions map[string]any `json:"conditions"` - Response struct { - Code int `json:"code"` - Headers map[string]string `json:"headers"` - Body any `json:"body"` - } `json:"response"` + return nil, err } + var req addExampleRequest if err := json.Unmarshal(bodyBytes, &req); err != nil { - http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest) - return + return nil, fmt.Errorf("invalid JSON") } if req.Response.Code == 0 || (req.Path == "" && req.Channel == "") { - writeJSONError(w, http.StatusBadRequest, "missing required fields") - return + return nil, fmt.Errorf("missing required fields") } req.Method = cmp.Or(req.Method, DefaultMethod) // Single-trigger rule (RS.MAPI.29): an async target has exactly one // trigger — interval OR an {$event.*}-based match, never both. if matchesEventContext(req.Match) && req.Interval > 0 { - writeJSONError(w, http.StatusBadRequest, "'interval' and an event-based 'match' are mutually exclusive") - return + return nil, fmt.Errorf("'interval' and an event-based 'match' are mutually exclusive") } + return &req, nil +} - // Resolve the target route: OpenAPI path/method or AsyncAPI channel. - var targetMapping *RouteMapping +// resolveExampleTarget maps an add-example request to its route: AsyncAPI +// targets resolve by protocol/channel, OpenAPI targets by path/method. A +// runtime match on an async target must drive emission, so only an +// {$event.*}-based match is accepted; a connection-only or literal match has +// no trigger and is rejected rather than silently registered nowhere +// (RS.MAPI.24-26, RS.MAPI.33). +func (s *Server) resolveExampleTarget(req *addExampleRequest) (*RouteMapping, error) { if req.Protocol != "" || req.Channel != "" { - targetMapping = s.findAsyncRouteMapping(req.Protocol, req.Channel, req.Method) - if targetMapping == nil { - writeJSONError(w, http.StatusBadRequest, "no matching route found") - return + mapping := s.findAsyncRouteMapping(req.Protocol, req.Channel, req.Method) + if mapping == nil { + return nil, fmt.Errorf("no matching route found") } - } else { - for i := range s.mappings { - mapping := &s.mappings[i] - if mapping.Pattern == req.Path && mapping.Method == req.Method { - targetMapping = mapping - break - } + if mapping.Protocol != "" && req.Match != nil && !matchesEventContext(req.Match) { + return nil, fmt.Errorf("async target 'match' must reference the event context ({$event.*}); use 'interval' for periodic emission") } - if targetMapping == nil { - writeJSONError(w, http.StatusBadRequest, "no matching route found") - return + return mapping, nil + } + for i := range s.mappings { + if m := &s.mappings[i]; m.Pattern == req.Path && m.Method == req.Method { + return m, nil } } - // TODO: validate response body against OpenAPI schema if req.Validate is true - // (skipped for now) + return nil, fmt.Errorf("no matching route found") +} - // Runtime async-driven examples (match/interval) register through the - // event broker / scheduler (RS.MAPI.24-26, RS.MAPI.33). A runtime match on - // an async target must drive emission, so only an {$event.*}-based match is - // accepted; a connection-only or literal match has no trigger and is - // rejected rather than silently registered nowhere. - if targetMapping.Protocol != "" && req.Match != nil && !matchesEventContext(req.Match) { - writeJSONError(w, http.StatusBadRequest, "async target 'match' must reference the event context ({$event.*}); use 'interval' for periodic emission") - return +// needsRuntimeRegistration reports whether an async target carries a trigger +// (match or interval) that registers through the event broker / scheduler. +func needsRuntimeRegistration(mapping *RouteMapping, req *addExampleRequest) bool { + return mapping.Protocol != "" && (req.Match != nil || req.Interval > 0) +} + +// registerAsyncRuntimeExample registers an event-driven or periodically driven +// example through the event broker / scheduler and responds with its runtime +// identity (RS.MAPI.24-26). +func (s *Server) registerAsyncRuntimeExample(w http.ResponseWriter, req *addExampleRequest, mapping *RouteMapping) { + id := newExampleID("rtex") + headers := make(map[string]any, len(req.Response.Headers)) + for k, v := range req.Response.Headers { + headers[k] = v } - if targetMapping.Protocol != "" && (req.Match != nil || req.Interval > 0) { - id := newExampleID("rtex") - headers := make(map[string]any, len(req.Response.Headers)) - for k, v := range req.Response.Headers { - headers[k] = v - } - ext := make(map[string]any) - if req.Match != nil { - ext["x-mock-match"] = req.Match - } - if req.Interval > 0 { - ext["x-mock-interval"] = req.Interval - } - if req.Delay > 0 { - ext["x-mock-delay"] = req.Delay - } - example := &loader.MessageExampleSpec{ - Name: "runtime-" + id, - Headers: headers, - Payload: req.Response.Body, - Extensions: ext, - } - kind, jobID, err := s.registerRuntimeExample(id, targetMapping, example) - if err != nil { - writeJSONError(w, http.StatusBadRequest, err.Error()) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "success": true, - "message": "Example added", - "id": id, - "kind": triggerKindString(kind), - "jobID": jobID, - }) + ext := make(map[string]any) + if req.Match != nil { + ext["x-mock-match"] = req.Match + } + if req.Interval > 0 { + ext["x-mock-interval"] = req.Interval + } + if req.Delay > 0 { + ext["x-mock-delay"] = req.Delay + } + example := &loader.MessageExampleSpec{ + Name: "runtime-" + id, + Headers: headers, + Payload: req.Response.Body, + Extensions: ext, + } + kind, jobID, err := s.registerRuntimeExample(id, mapping, example) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) return } + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "message": "Example added", + "id": id, + "kind": triggerKindString(kind), + "jobID": jobID, + }) +} - // Create dynamic example +// registerDynamicExample stores a sync or async reply example in the example +// registry and responds with its id. +func (s *Server) registerDynamicExample(w http.ResponseWriter, req *addExampleRequest, mapping *RouteMapping) { id := newExampleID("dynex") example := dynamicExample{ onceID: id, @@ -401,24 +356,21 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { example.response.headers = req.Response.Headers example.response.body = req.Response.Body // Store under mapping key - key := routeKey(targetMapping.Method, targetMapping.ChiPattern) + key := routeKey(mapping.Method, mapping.ChiPattern) if s.config.Verbose { slog.Debug("handleAddExample: storing dynamic example", "key", key, "path", req.Path, "method", req.Method, - "chiPattern", targetMapping.ChiPattern, - "pattern", targetMapping.Pattern, + "chiPattern", mapping.ChiPattern, + "pattern", mapping.Pattern, "numExamples", len(s.registry.dynamicExamples[key])+1) } s.registry.addDynamic(key, example) // Respond with success - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]any{ + writeJSON(w, http.StatusOK, map[string]any{ "success": true, "message": "Example added", "id": id, - }); err != nil && s.config.Verbose { - slog.Debug("Failed to encode success response", "err", err) - } + }) } diff --git a/internal/server/server_requests.go b/internal/server/server_requests.go new file mode 100644 index 0000000..10b3fa2 --- /dev/null +++ b/internal/server/server_requests.go @@ -0,0 +1,122 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" +) + +func filterRecords(records []RequestRecord, query url.Values) []RequestRecord { + filtered := make([]RequestRecord, 0, len(records)) + for _, rec := range records { + // Filter by path + if path := query.Get("path"); path != "" && rec.Path != path { + continue + } + // Filter by method + if method := query.Get("method"); method != "" && rec.Method != method { + continue + } + // Filter by time_from (milliseconds since epoch) + if timeFromStr := query.Get("time_from"); timeFromStr != "" { + if timeFrom, err := strconv.ParseInt(timeFromStr, 10, 64); err == nil { + if rec.Timestamp.UnixMilli() < timeFrom { + continue + } + } + } + // Filter by time_till + if timeTillStr := query.Get("time_till"); timeTillStr != "" { + if timeTill, err := strconv.ParseInt(timeTillStr, 10, 64); err == nil { + if rec.Timestamp.UnixMilli() > timeTill { + continue + } + } + } + filtered = append(filtered, rec) + } + return filtered +} + +// paginateRecords applies offset and limit pagination to records. +func paginateRecords(records []RequestRecord, offset, limit int) []RequestRecord { + if offset < 0 { + offset = 0 + } + if offset > len(records) { + offset = len(records) + } + if limit < 0 { + limit = 0 + } + end := offset + limit + if end > len(records) { + end = len(records) + } + return records[offset:end] +} + +// recordsToAPIResponse converts request records to API response format. +func recordsToAPIResponse(records []RequestRecord) []map[string]any { + items := make([]map[string]any, len(records)) + for i, rec := range records { + var body any + if len(rec.Body) > 0 { + // Try to unmarshal as JSON, else keep as string + var jsonBody any + if err := json.Unmarshal(rec.Body, &jsonBody); err == nil { + body = jsonBody + } else { + body = string(rec.Body) + } + } + headers := make(map[string]string) + for k, v := range rec.Headers { + if len(v) > 0 { + headers[k] = v[0] + } + } + items[i] = map[string]any{ + "ts": rec.Timestamp.UnixMilli(), + "url": rec.Path + "?" + rec.Query, + "method": rec.Method, + "body": body, + "headers": headers, + } + } + return items +} + +// maxRequestsPage is the upper bound for GET /_mock/requests pagination +// (RS.MAPI.7, RS.MAPI.12). +const maxRequestsPage = 1000 + +func (s *Server) handleGetRequests(w http.ResponseWriter, r *http.Request) { + records := s.historyStore.GetAll() + query := r.URL.Query() + + // Filtering + filtered := filterRecords(records, query) + + // Pagination + offset, _ := strconv.Atoi(query.Get("offset")) + if offset < 0 { + offset = 0 + } + limit, _ := strconv.Atoi(query.Get("limit")) + if limit <= 0 || limit > maxRequestsPage { + limit = maxRequestsPage + } + paginated := paginateRecords(filtered, offset, limit) + + // Convert to API response + items := recordsToAPIResponse(paginated) + writeJSON(w, http.StatusOK, map[string]any{ + "data": items, + }) +} + +// newExampleID returns a time-unique example id in the given namespace. The +// namespace prefix keeps runtime-async ids ("rtex-") disjoint from sync +// dynamic-example ids ("dynex-"), so DELETE /_mock/examples/{id} never has to diff --git a/internal/server/server_routes.go b/internal/server/server_routes.go new file mode 100644 index 0000000..f0a35c8 --- /dev/null +++ b/internal/server/server_routes.go @@ -0,0 +1,152 @@ +package server + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func (s *Server) setupRouter() { + r := chi.NewRouter() + + // Basic middleware + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // Request delay middleware + if s.config.Delay > 0 { + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(s.config.Delay) + next.ServeHTTP(w, r) + }) + }) + } + + // CORS middleware + if s.config.EnableCORS { + corsMiddleware := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, + AllowedHeaders: []string{"*"}, + AllowCredentials: false, + MaxAge: 300, + }) + r.Use(corsMiddleware.Handler) + } + + // Request history recording middleware + r.Use(s.requestHistoryMiddleware) + + // Verbose logging middleware + r.Use(s.verboseLoggingMiddleware) + + // 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) + slog.Info("Registered RPC gateway", "path", s.gatewayPath, "procedures", len(s.rpcHandler.procedureMap)) + } + + // Register management API routes + if s.config.EnableControlAPI { + s.registerManagementRoutes(r) + } else { + slog.Debug("Management control API disabled") + } + + s.router = r +} + +func (s *Server) registerMockRoutes(r chi.Router) { + slog.Debug("registerMockRoutes called", "verbose", s.config.Verbose, "numMappings", len(s.mappings)) + + rpcChiPatterns := make(map[string]bool) + for _, m := range s.rpcMappings { + rpcChiPatterns[m.ChiPattern] = true + } + + for i := range s.mappings { + mapping := &s.mappings[i] + if rpcChiPatterns[mapping.ChiPattern] { + continue + } + 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("Registering 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, 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.Delete("/_mock/examples/{exampleId}", s.handleDeleteExample) + r.Get("/_mock/requests", s.handleGetRequests) + + // Canonical protocol-neutral async surface (design D1). + r.Post("/_mock/events", s.handleEvents) + r.Post("/_mock/async/push", s.handleAsyncPush) + r.Get("/_mock/async/consumers", s.handleAsyncConsumers) + r.Post("/_mock/async/disconnect", s.handleAsyncDisconnect) + r.Get("/_mock/stream", s.handleManageStream) + + // Deprecated aliases kept for one release (design D1). The events/fire + // alias serves the legacy type-less contract (handleFireEventLegacy); the + // ws aliases share the canonical handlers. + r.Post("/_mock/events/fire", s.handleFireEventLegacy) + r.Post("/_mock/ws/push", s.handleAsyncPush) + r.Get("/_mock/ws/consumers", s.handleAsyncConsumers) + r.Post("/_mock/ws/disconnect", s.handleAsyncDisconnect) + + // Removed schedule surface answers 410 Gone pointing at /_mock/examples. + r.Post("/_mock/ws/schedule", s.handleGoneSchedule) + r.Delete("/_mock/ws/schedule/{pushId}", s.handleGoneSchedule) +} + +// buildRequestSource constructs the runtime request data source from an +// inbound HTTP request. When callBody is non-nil it is used verbatim (the RPC +// gateway passes the decoded call body); otherwise the request body is read, +// parsed as JSON when possible, and restored for downstream handlers. +// Start starts the HTTP server, returning once the server is serving. diff --git a/internal/server/server_runtime_example.go b/internal/server/server_runtime_example.go index 063c11a..3a158ae 100644 --- a/internal/server/server_runtime_example.go +++ b/internal/server/server_runtime_example.go @@ -1,7 +1,6 @@ package server import ( - "encoding/json" "net/http" "sync" @@ -97,6 +96,5 @@ func (s *Server) handleDeleteExample(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusNotFound, "unknown exampleId") return } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + writeJSON(w, http.StatusOK, map[string]any{"success": true}) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 97b3f59..b89afe9 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -19,7 +19,6 @@ import ( "github.com/golang/mock/gomock" "github.com/mamonth/oasmock/internal/history" - "github.com/mamonth/oasmock/internal/loader" "github.com/mamonth/oasmock/internal/state" mock_runtime "github.com/mamonth/oasmock/mock/runtime" "github.com/stretchr/testify/assert" @@ -27,7 +26,7 @@ import ( ) // 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) { +func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *MockRouteProvider, *MockStateStore, *MockHistoryStore) { t.Helper() ctrl := gomock.NewController(t) @@ -38,21 +37,11 @@ func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *M stateStore := NewMockStateStore(ctrl) historyStore := NewMockHistoryStore(ctrl) historyStore.EXPECT().Add(gomock.Any()).AnyTimes() - expressionEvaluator := NewMockExpressionEvaluator(ctrl) - requestSourceFactory := NewMockRequestSourceFactory(ctrl) - stateSourceFactory := NewMockStateSourceFactory(ctrl) - envSourceFactory := NewMockEnvSourceFactory(ctrl) - extensionProcessor := NewMockExtensionProcessor(ctrl) deps := Dependencies{ - RouteProvider: routeProvider, - StateStore: stateStore, - HistoryStore: historyStore, - RequestSourceFactory: requestSourceFactory, - StateSourceFactory: stateSourceFactory, - EnvSourceFactory: envSourceFactory, - ExpressionEvaluator: expressionEvaluator, - ExtensionProcessor: extensionProcessor, + RouteProvider: routeProvider, + StateStore: stateStore, + HistoryStore: historyStore, } // Empty schemas since route provider will be mocked @@ -65,7 +54,7 @@ func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *M _ = server.Shutdown(context.Background()) }) - return server, routeProvider, stateStore, historyStore, expressionEvaluator, requestSourceFactory, stateSourceFactory, envSourceFactory, extensionProcessor + return server, routeProvider, stateStore, historyStore } /* @@ -141,14 +130,16 @@ func TestValidateAddExampleRequest(t *testing.T) { } /* -Scenario: Creating new server instance with valid configuration -Given a valid server configuration and loaded OpenAPI schemas -When New is called -Then it returns a server instance with correct configuration - -Related spec scenarios: RS.MSC.1, RS.MSC.2 +Scenario: Evaluating a string that may contain embedded runtime expressions +Given a string and a mock evaluator +When evaluateExpressionInString is called +Then whole-string and embedded expressions are resolved, evaluation errors on +whole-string expressions propagate, and unmatched embedded expressions are kept +verbatim + +Related spec scenarios: RS.MSC.13, RS.MSC.14, RS.MSC.15, RS.MSC.16 */ -func TestNewServer(t *testing.T) { +func TestEvaluateExpressionInString(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -162,7 +153,7 @@ func TestNewServer(t *testing.T) { HistorySize: 1000, EnableControlAPI: true, } - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) tests := []struct { name string @@ -259,7 +250,7 @@ func TestEvaluateValue(t *testing.T) { HistorySize: 1000, EnableControlAPI: true, } - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) tests := []struct { name string @@ -399,7 +390,7 @@ func TestReplaceEmbeddedExpressions(t *testing.T) { HistorySize: 1000, EnableControlAPI: true, } - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) tests := []struct { name string @@ -507,7 +498,7 @@ func TestExtractPathParams(t *testing.T) { HistorySize: 1000, EnableControlAPI: true, } - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) tests := []struct { name string @@ -518,16 +509,25 @@ func TestExtractPathParams(t *testing.T) { { name: "no chi context", setupRequest: func() *http.Request { - req, _ := http.NewRequest("GET", "/users/123", nil) + req, _ := http.NewRequest(http.MethodGet, "/users/123", nil) return req }, mapping: &RouteMapping{}, want: map[string]string{}, }, + { + name: "no chi context but mapping chi pattern has params", + setupRequest: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "/users/123", nil) + return req + }, + mapping: &RouteMapping{ChiPattern: "/users/{id}"}, + want: map[string]string{"id": "123"}, + }, { name: "with path parameters", setupRequest: func() *http.Request { - req, _ := http.NewRequest("GET", "/users/123", nil) + req, _ := http.NewRequest(http.MethodGet, "/users/123", nil) // Create chi route context with URL params routeCtx := chi.NewRouteContext() routeCtx.URLParams.Keys = []string{"userID"} @@ -542,7 +542,7 @@ func TestExtractPathParams(t *testing.T) { { name: "multiple path parameters", setupRequest: func() *http.Request { - req, _ := http.NewRequest("GET", "/orgs/456/users/789", nil) + req, _ := http.NewRequest(http.MethodGet, "/orgs/456/users/789", nil) routeCtx := chi.NewRouteContext() routeCtx.URLParams.Keys = []string{"orgID", "userID"} routeCtx.URLParams.Values = []string{"456", "789"} @@ -555,7 +555,7 @@ func TestExtractPathParams(t *testing.T) { { name: "mismatched keys and values", setupRequest: func() *http.Request { - req, _ := http.NewRequest("GET", "/test", nil) + req, _ := http.NewRequest(http.MethodGet, "/test", nil) routeCtx := chi.NewRouteContext() routeCtx.URLParams.Keys = []string{"key1", "key2"} routeCtx.URLParams.Values = []string{"value1"} // Missing second value @@ -728,7 +728,7 @@ func TestApplySetState(t *testing.T) { defer ctrl.Finish() store, eval, callsPtr := tt.setup(ctrl) - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) server.stateStore = store server.engine.stateStore = store server.applySetState(tt.stateMap, eval, "") @@ -829,14 +829,14 @@ func TestHandleAddExample(t *testing.T) { t.Parallel() // Create server with mock dependencies - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) // Set up mappings server.mappings = tt.mappings // Initialize dynamic examples map server.registry.dynamicExamples = make(map[string][]dynamicExample) // Create request - req := httptest.NewRequest("POST", "/api/examples", strings.NewReader(tt.reqBody)) + req := httptest.NewRequest(http.MethodPost, "/api/examples", strings.NewReader(tt.reqBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -1002,8 +1002,8 @@ func TestHandleGetRequests(t *testing.T) { }, { name: "limit exceeds max", - query: "limit=150", - wantCount: 5, // limit capped at 100 + query: "limit=1500", + wantCount: 5, // limit capped at 1000 (RS.MAPI.12) wantMethod: "GET", wantPath: "/users", }, @@ -1025,7 +1025,7 @@ func TestHandleGetRequests(t *testing.T) { defer ctrl.Finish() // Create server with mock history store - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) // Replace history store with generated mock mockHistoryStore := NewMockHistoryStore(ctrl) mockHistoryStore.EXPECT().GetAll().Return(testRecords) @@ -1037,7 +1037,7 @@ func TestHandleGetRequests(t *testing.T) { if tt.query != "" { url += "?" + tt.query } - req := httptest.NewRequest("GET", url, nil) + req := httptest.NewRequest(http.MethodGet, url, nil) w := httptest.NewRecorder() // Call handler @@ -1344,7 +1344,7 @@ paths: tests := []struct { name string - setupServer func(*Server, *MockRequestSourceFactory, *MockStateSourceFactory, *MockEnvSourceFactory) + setupServer func(*Server) mapping *RouteMapping wantStatus int wantBody string @@ -1352,15 +1352,9 @@ paths: }{ { name: "successful request with built-in example", - setupServer: func(s *Server, reqFact *MockRequestSourceFactory, stateFact *MockStateSourceFactory, envFact *MockEnvSourceFactory) { + setupServer: func(s *Server) { // No dynamic examples 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 - reqFact.EXPECT().NewRequestSource(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - stateFact.EXPECT().NewStateSource(gomock.Any()).Return(nil).AnyTimes() - envFact.EXPECT().NewEnvSource().Return(nil).AnyTimes() }, mapping: &RouteMapping{ Method: "GET", @@ -1378,12 +1372,8 @@ paths: }, { name: "no response defined", - setupServer: func(s *Server, reqFact *MockRequestSourceFactory, stateFact *MockStateSourceFactory, envFact *MockEnvSourceFactory) { + setupServer: func(s *Server) { 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() - envFact.EXPECT().NewEnvSource().Return(nil).AnyTimes() }, mapping: &RouteMapping{ Method: "GET", @@ -1401,7 +1391,7 @@ paths: }, { name: "dynamic example selected", - setupServer: func(s *Server, reqFact *MockRequestSourceFactory, stateFact *MockStateSourceFactory, envFact *MockEnvSourceFactory) { + setupServer: func(s *Server) { // Add a dynamic example s.registry.dynamicExamples = make(map[string][]dynamicExample) key := "GET /test" @@ -1418,10 +1408,6 @@ paths: body: map[string]any{"source": "dynamic"}, }, }} - // Mock factories - allow any calls returning nil - reqFact.EXPECT().NewRequestSource(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() - stateFact.EXPECT().NewStateSource(gomock.Any()).Return(nil).AnyTimes() - envFact.EXPECT().NewEnvSource().Return(nil).AnyTimes() }, mapping: &RouteMapping{ Method: "GET", @@ -1445,7 +1431,7 @@ paths: t.Run(tt.name, func(t *testing.T) { t.Parallel() // Create server with generated mock dependencies - server, _, mockStateStore, mockHistoryStore, mockExpressionEvaluator, reqFact, stateFact, envFact, mockExtensionProcessor := newMockedServerWithGeneratedMocks(t, config) + server, _, mockStateStore, mockHistoryStore := newMockedServerWithGeneratedMocks(t, config) // Set default expectations for mocks that will be called by the handler mockStateStore.EXPECT().GetNamespace(gomock.Any()).Return(nil).AnyTimes() mockStateStore.EXPECT().Get(gomock.Any(), gomock.Any()).Return(nil, false).AnyTimes() @@ -1454,17 +1440,9 @@ paths: mockHistoryStore.EXPECT().GetAll().Return(nil).AnyTimes() mockHistoryStore.EXPECT().Count().Return(0).AnyTimes() mockHistoryStore.EXPECT().Capacity().Return(1000).AnyTimes() - mockExpressionEvaluator.EXPECT().AddSource(gomock.Any(), gomock.Any()).AnyTimes() - mockExpressionEvaluator.EXPECT().Evaluate(gomock.Any()).Return(nil, nil).AnyTimes() - mockExtensionProcessor.EXPECT().ExtractSetState(gomock.Any()).Return(nil, false).AnyTimes() - mockExtensionProcessor.EXPECT().ExtractSkip(gomock.Any()).Return(false).AnyTimes() - mockExtensionProcessor.EXPECT().ExtractOnce(gomock.Any()).Return(false).AnyTimes() - mockExtensionProcessor.EXPECT().ExtractParamsMatch(gomock.Any()).Return(nil, false).AnyTimes() - mockExtensionProcessor.EXPECT().EvaluateParamsMatch(gomock.Any(), gomock.Any()).Return(false, nil).AnyTimes() - mockExtensionProcessor.EXPECT().ExtractHeaders(gomock.Any()).Return(nil, false).AnyTimes() // Setup server if tt.setupServer != nil { - tt.setupServer(server, reqFact, stateFact, envFact) + tt.setupServer(server) } // Create request req := httptest.NewRequest(tt.mapping.Method, tt.mapping.Path, nil) @@ -1512,7 +1490,7 @@ func TestRequestHistoryMiddleware(t *testing.T) { HistorySize: 1000, EnableControlAPI: true, } - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) var capturedRecord RequestRecord mockHistoryStore := NewMockHistoryStore(ctrl) @@ -1524,7 +1502,7 @@ func TestRequestHistoryMiddleware(t *testing.T) { // Create a test request with body reqBody := `{"test":"data"}` - req := httptest.NewRequest("POST", "/api/test", bytes.NewReader([]byte(reqBody))) + req := httptest.NewRequest(http.MethodPost, "/api/test", bytes.NewReader([]byte(reqBody))) req.Header.Set("X-Custom", "value") // Create a response recorder that will be wrapped by middleware's recorder @@ -1583,9 +1561,9 @@ func TestVerboseLoggingMiddleware(t *testing.T) { HistorySize: 1000, EnableControlAPI: true, } - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() nextCalled := false nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1613,7 +1591,7 @@ func TestHandleIncrementState(t *testing.T) { t.Parallel() config := Config{} - server, _, mockStateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, mockStateStore, _ := newMockedServerWithGeneratedMocks(t, config) ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -1706,7 +1684,7 @@ func TestHandleMapState(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() config := Config{} - server, _, mockStateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, mockStateStore, _ := newMockedServerWithGeneratedMocks(t, config) ctrl := gomock.NewController(t) defer ctrl.Finish() mockEval := mock_runtime.NewMockEvaluator(ctrl) @@ -1758,7 +1736,7 @@ func TestHandleValueObjectState(t *testing.T) { t.Parallel() config := Config{} - server, _, mockStateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, mockStateStore, _ := newMockedServerWithGeneratedMocks(t, config) ctrl := gomock.NewController(t) defer ctrl.Finish() mockEval := mock_runtime.NewMockEvaluator(ctrl) @@ -1812,7 +1790,7 @@ func TestMarkOnceUsedAndIsOnceUsed(t *testing.T) { t.Parallel() config := Config{} - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) exampleID := "GET:/users:default" @@ -1827,31 +1805,6 @@ func TestMarkOnceUsedAndIsOnceUsed(t *testing.T) { assert.False(t, server.isOnceUsed("POST:/users:default"), "different example ID should not be marked as used") } -/* - Scenario: getStatusCode returns status code from mapping - Given a route mapping and response - When getStatusCode is called - Then it should return appropriate status code (currently defaults to 200) - - Related spec scenarios: RS.MSC.27 -*/ - -func TestGetStatusCode(t *testing.T) { - t.Parallel() - - mapping := &loader.RouteMapping{ - Method: "GET", - Path: "/test", - Pattern: "/test", - Prefix: "", - ChiPattern: "/test", - } - response := &openapi3.Response{} - - status := getStatusCode(mapping, response) - assert.Equal(t, 200, status, "should default to 200") -} - /* Scenario: selectResponse chooses appropriate response from mapping Given a route mapping with various responses @@ -1865,7 +1818,7 @@ func TestSelectResponse(t *testing.T) { t.Parallel() config := Config{} - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) ctrl := gomock.NewController(t) defer ctrl.Finish() mockEval := mock_runtime.NewMockEvaluator(ctrl) @@ -1998,7 +1951,6 @@ Then the server should start successfully and shut down without error Related spec scenarios: RS.MSC.1 */ func TestStartAndShutdown(t *testing.T) { - config := Config{ Port: 0, Delay: 0, @@ -2007,7 +1959,7 @@ func TestStartAndShutdown(t *testing.T) { HistorySize: 1000, EnableControlAPI: true, } - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) // Channel to capture error from Start startErrCh := make(chan error, 1) @@ -2057,7 +2009,7 @@ intermittently. The httpMu mutex in Start/Shutdown removes the race. Related spec scenarios: RS.MSC.1 */ func TestConcurrentStartAndShutdownNoDataRace(t *testing.T) { - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) startErrCh := make(chan error, 1) go func() { @@ -2087,7 +2039,7 @@ Then both calls return nil and only one graceful shutdown occurs Related spec scenarios: RS.MSC.1 */ func TestShutdownIsIdempotent(t *testing.T) { - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) startErrCh := make(chan error, 1) go func() { @@ -2125,7 +2077,7 @@ func TestShutdownWithNilHTTPServer(t *testing.T) { t.Parallel() config := Config{} - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, config) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, config) // Ensure httpServer is nil (should be by default) assert.Nil(t, server.httpServer, "httpServer should be nil before Start") @@ -2288,251 +2240,3 @@ func TestHistoryRingBufferStore(t *testing.T) { records = store.GetAll() assert.Empty(t, records, "GetAll should return empty slice after Clear") } - -/* - Scenario: runtimeDataSourceWrapper correctly delegates to runtime.DataSource - Given a mock runtime.DataSource - When Get is called on the wrapper - Then it should delegate to the underlying source - - Related spec scenarios: RS.MSC.13, RS.MSC.14, RS.MSC.15, RS.MSC.16, RS.MSC.17, RS.MSC.18, RS.MSC.19 -*/ - -func TestRuntimeDataSourceWrapper(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockSource := mock_runtime.NewMockDataSource(ctrl) - mockSource.EXPECT().Get("test.key").Return("value", true).Times(1) - mockSource.EXPECT().Get("nonexistent").Return(nil, false).Times(1) - - wrapper := &runtimeDataSourceWrapper{source: mockSource} - - // Test Get with existing key - val, ok := wrapper.Get("test.key") - assert.True(t, ok, "Get should return true for existing key") - assert.Equal(t, "value", val, "Get should return the value from underlying source") - - // Test Get with non-existent key - val, ok = wrapper.Get("nonexistent") - assert.False(t, ok, "Get should return false for non-existent key") - assert.Nil(t, val, "Get should return nil for non-existent key") - - // Verify mock expectations are satisfied (deferred ctrl.Finish will call ctrl.Verify) -} - -/* - Scenario: runtimeRequestSourceFactory creates DataSource for HTTP requests - Given an HTTP request with path parameters, query parameters, headers, cookies - When NewRequestSource is called - Then it should return a DataSource that provides access to request data - - Related spec scenarios: RS.MSC.13, RS.MSC.14, RS.MSC.15, RS.MSC.16, RS.MSC.17, RS.MSC.18, RS.MSC.19 -*/ - -func TestRuntimeRequestSourceFactory(t *testing.T) { - t.Parallel() - - factory := &runtimeRequestSourceFactory{} - - // Create a proper HTTP request with parsed URL - req, err := http.NewRequest("GET", "http://example.com/test?page=1&limit=10", nil) - require.NoError(t, err, "Failed to create request") - // Set headers with lowercased keys to match runtime.RequestSource's lowercasing - req.Header["content-type"] = []string{"application/json"} - req.Header["x-api-key"] = []string{"secret"} - req.AddCookie(&http.Cookie{Name: "session", Value: "abc123"}) - req.AddCookie(&http.Cookie{Name: "user", Value: "john"}) - - pathParams := map[string]string{ - "id": "123", - "name": "test", - } - - source := factory.NewRequestSource(req, pathParams) - require.NotNil(t, source, "NewRequestSource should return non-nil DataSource") - - // Test accessing path parameters - val, ok := source.Get("path.id") - assert.True(t, ok, "Should find path parameter 'id'") - assert.Equal(t, "123", val, "Path parameter value should match") - - val, ok = source.Get("path.name") - assert.True(t, ok, "Should find path parameter 'name'") - assert.Equal(t, "test", val) - - // Test accessing query parameters (single value returns string) - val, ok = source.Get("query.page") - assert.True(t, ok, "Should find query parameter 'page'") - assert.Equal(t, "1", val, "Query param value should match") - - val, ok = source.Get("query.limit") - assert.True(t, ok, "Should find query parameter 'limit'") - assert.Equal(t, "10", val) - - // Test accessing headers (category "header", keys lowercased) - val, ok = source.Get("header.content-type") - assert.True(t, ok, "Should find header 'content-type'") - assert.Equal(t, "application/json", val) - - val, ok = source.Get("header.x-api-key") - assert.True(t, ok, "Should find header 'x-api-key'") - assert.Equal(t, "secret", val) - - // Test accessing cookies (category "cookie") - val, ok = source.Get("cookie.session") - assert.True(t, ok, "Should find cookie 'session'") - assert.Equal(t, "abc123", val, "Cookie value should match") - - val, ok = source.Get("cookie.user") - assert.True(t, ok, "Should find cookie 'user'") - assert.Equal(t, "john", val) - - // Test non-existent paths - val, ok = source.Get("path.nonexistent") - assert.False(t, ok, "Should not find non-existent path parameter") - assert.Nil(t, val) - - val, ok = source.Get("query.missing") - assert.False(t, ok, "Should not find non-existent query parameter") - assert.Nil(t, val) -} - -/* - Scenario: runtimeStateSourceFactory creates DataSource for server state - Given a StateStore with some data - When NewStateSource is called with a namespace - Then it should return a DataSource that provides access to state data - - Related spec scenarios: RS.MSC.18 -*/ - -func TestRuntimeStateSourceFactory(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - store := NewMockStateStore(ctrl) - store.EXPECT().GetNamespace("testns").Return(map[string]any{ - "key1": "value1", - "key2": 42, - "nested": map[string]any{ - "subkey": "subvalue", - }, - }) - store.EXPECT().GetNamespace("nonexistent").Return(nil) - - factory := newRuntimeStateSourceFactory(store) - source := factory.NewStateSource("testns") - require.NotNil(t, source, "NewStateSource should return non-nil DataSource") - - // Test accessing top-level keys - val, ok := source.Get("key1") - assert.True(t, ok, "Should find top-level key 'key1'") - assert.Equal(t, "value1", val) - - val, ok = source.Get("key2") - assert.True(t, ok, "Should find top-level key 'key2'") - assert.Equal(t, 42, val) - - // Test accessing nested key (runtime.StateSource supports nested traversal) - val, ok = source.Get("nested.subkey") - assert.True(t, ok, "Should find nested key 'nested.subkey'") - assert.Equal(t, "subvalue", val) - - // Test non-existent namespace returns empty data source - emptySource := factory.NewStateSource("nonexistent") - require.NotNil(t, emptySource, "NewStateSource should return non-nil DataSource even for empty namespace") - val, ok = emptySource.Get("any") - assert.False(t, ok, "Empty namespace should have no data") - assert.Nil(t, val) - - // Test non-existent key - val, ok = source.Get("missing") - assert.False(t, ok, "Should not find non-existent key") - assert.Nil(t, val) -} - -/* -Scenario: runtimeEnvSourceFactory creates DataSource for environment variables -Given some environment variables are set -When NewEnvSource is called -Then it should return a DataSource that provides access to environment variables - -Related spec scenarios: RS.MSC.19 -*/ -func TestRuntimeEnvSourceFactory(t *testing.T) { - - // Set environment variables for this test (cannot run in parallel) - t.Setenv("TEST_FOO", "bar") - t.Setenv("TEST_NUM", "123") - t.Setenv("TEST_EMPTY", "") - - factory := &runtimeEnvSourceFactory{} - - source := factory.NewEnvSource() - require.NotNil(t, source, "NewEnvSource should return non-nil DataSource") - - // Test accessing existing environment variables - val, ok := source.Get("TEST_FOO") - assert.True(t, ok, "Should find env var TEST_FOO") - assert.Equal(t, "bar", val) - - val, ok = source.Get("TEST_NUM") - assert.True(t, ok, "Should find env var TEST_NUM") - assert.Equal(t, "123", val) - - val, ok = source.Get("TEST_EMPTY") - assert.True(t, ok, "Should find env var TEST_EMPTY even if empty") - assert.Equal(t, "", val) - - // Test non-existent environment variable - val, ok = source.Get("TEST_NONEXISTENT") - assert.False(t, ok, "Should not find non-existent env var") - assert.Nil(t, val) -} - -/* - Scenario: runtimeExpressionEvaluatorWrapper correctly delegates to runtime.Evaluator - Given a mock runtime.Evaluator - When AddSource and Evaluate are called on the wrapper - Then they should delegate to the underlying evaluator - - Related spec scenarios: RS.MSC.13, RS.MSC.14, RS.MSC.15, RS.MSC.16, RS.MSC.17, RS.MSC.18, RS.MSC.19 -*/ - -func TestRuntimeExpressionEvaluatorWrapper(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockEval := mock_runtime.NewMockEvaluator(ctrl) - mockSource := mock_runtime.NewMockDataSource(ctrl) - - // Expect AddSource call with any runtime.DataSource - mockEval.EXPECT().AddSource("test", gomock.Any()).Times(1) - - // Expect Evaluate calls - mockEval.EXPECT().Evaluate("1 + 1").Return(2, nil).Times(1) - mockEval.EXPECT().Evaluate("invalid").Return(nil, fmt.Errorf("evaluation error")).Times(1) - - wrapper := newRuntimeExpressionEvaluatorWrapper(mockEval) - - // Test AddSource - wrapper.AddSource("test", mockSource) - // Verify mockEval.AddSource expectation satisfied - - // Test Evaluate with successful expression - result, err := wrapper.Evaluate("1 + 1") - assert.NoError(t, err, "Evaluate should succeed") - assert.Equal(t, 2, result, "Evaluate should return correct result") - - // Test Evaluate with error - result, err = wrapper.Evaluate("invalid") - assert.Error(t, err, "Evaluate should return error") - assert.Nil(t, result, "Evaluate should return nil result on error") -} diff --git a/internal/server/server_ttl_test.go b/internal/server/server_ttl_test.go index 7ae2f30..ecbedb7 100644 --- a/internal/server/server_ttl_test.go +++ b/internal/server/server_ttl_test.go @@ -41,7 +41,7 @@ Related spec scenarios: RS.MSC.40, RS.MSC.41 func TestSelectDynamicExampleExpiry(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" server.registry.dynamicExamples = map[string][]dynamicExample{ key: { @@ -68,7 +68,7 @@ Related spec scenarios: RS.MSC.42 func TestSelectDynamicExampleZeroTTLNeverExpires(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" server.registry.dynamicExamples = map[string][]dynamicExample{ key: { @@ -94,7 +94,7 @@ Related spec scenarios: RS.MSC.43 func TestSelectDynamicExampleOnceWithTTL(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" ex := dynExample(3600, time.Now(), "once-ttl") ex.once = true @@ -122,7 +122,7 @@ Related spec scenarios: RS.MSC.44, RS.MSC.46 func TestSweepExpiredExamples(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) server.registry.dynamicExamples = map[string][]dynamicExample{ "GET /a": { dynExample(1, time.Now().Add(-2*time.Second), "expired"), @@ -157,7 +157,7 @@ Related spec scenarios: RS.MSC.45 func TestSweepExpiredExamplesCleansOnceExamples(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" ex := dynExample(1, time.Now().Add(-2*time.Second), "once-expired") ex.once = true @@ -184,7 +184,7 @@ Related spec scenarios: RS.MSC.43 func TestSweepDoesNotReuseConsumedOnceExample(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" expired := dynExample(1, time.Now().Add(-2*time.Second), "expired-once") @@ -249,7 +249,7 @@ func TestHandleAddExampleWithTTL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) server.mappings = []RouteMapping{{ Method: "GET", Path: "/test", @@ -258,7 +258,7 @@ func TestHandleAddExampleWithTTL(t *testing.T) { }} server.registry.dynamicExamples = make(map[string][]dynamicExample) - req := httptest.NewRequest("POST", "/_mock/examples", strings.NewReader(tt.reqBody)) + req := httptest.NewRequest(http.MethodPost, "/_mock/examples", strings.NewReader(tt.reqBody)) w := httptest.NewRecorder() server.handleAddExample(w, req) @@ -291,7 +291,7 @@ Related spec scenarios: RS.MAPI.17 func TestHandleAddExampleRejectsNegativeTTL(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) server.mappings = []RouteMapping{{ Method: "GET", Path: "/test", @@ -300,7 +300,7 @@ func TestHandleAddExampleRejectsNegativeTTL(t *testing.T) { }} server.registry.dynamicExamples = make(map[string][]dynamicExample) - req := httptest.NewRequest("POST", "/_mock/examples", strings.NewReader(`{"path":"/test","response":{"code":200},"ttl":-1}`)) + req := httptest.NewRequest(http.MethodPost, "/_mock/examples", strings.NewReader(`{"path":"/test","response":{"code":200},"ttl":-1}`)) w := httptest.NewRecorder() server.handleAddExample(w, req) @@ -320,7 +320,7 @@ Related spec scenarios: RS.MSC.48, RS.MSC.49 func TestTTLSweepStartsAndStops(t *testing.T) { t.Parallel() - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) require.NotNil(t, server.registry.sweepCancel, "sweep should be initialized on server creation") // Verify the background sweep goroutine runs: add an expired example and @@ -358,7 +358,7 @@ RLock. The fresh-slice allocation in sweepExpiredExamples removes the race. Related spec scenarios: RS.MSC.41, RS.MSC.44 */ func TestConcurrentSelectAndSweepNoDataRace(t *testing.T) { - server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) key := "GET /test" examples := make([]dynamicExample, 0, 64) diff --git a/internal/server/signalr_conn.go b/internal/server/signalr_conn.go new file mode 100644 index 0000000..a1d56af --- /dev/null +++ b/internal/server/signalr_conn.go @@ -0,0 +1,249 @@ +package server + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/loader" +) + +// runConnection drives the SignalR message loop for one connection. A read +// deadline bounds the lifetime of silently-dead peers (pings are answered, but +// a peer that stops sending entirely is reaped). +func (h *signalRHub) runConnection(sc *signalRConnection) { + handshaken := false + _ = sc.conn.SetReadDeadline(time.Now().Add(wsReadIdleBounds)) + for { + messageType, payload, err := sc.conn.ReadMessage() + if err != nil { + return + } + _ = sc.conn.SetReadDeadline(time.Now().Add(wsReadIdleBounds)) + 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). The +// frame is JSON-encoded so the message is escaped and cannot corrupt the frame. +func (h *signalRHub) writeHandshakeError(sc *signalRConnection, msg string) { + sc.writer.write(encodeSignalRMessage(signalREnvelope{Error: msg})) + 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: + // An inbound client invocation carries a payload; fire the receive + // built-in (RS.EVT.25) before answering. A single argument is exposed + // directly as the event payload (the common case for message mocks). + if h.hooks.Receive != nil { + payload := json.RawMessage("{}") + if len(env.Arguments) == 1 { + payload, _ = json.Marshal(env.Arguments[0]) + } else if len(env.Arguments) > 1 { + payload, _ = json.Marshal(env.Arguments) + } + ch := hubChannelAddress(h) + if ch != "" { + h.hooks.Receive(ch, InboundMessage{ + Payload: payload, + ConnectionID: sc.id, + }) + } + } + h.handleInvocation(sc, env) + case signalRTypeCancelInvocation: + h.handleCancelInvocation(sc, env) + } +} + +// hubDefaultChannel returns the channel address a SignalR hub uses for +// connection-level built-ins (connect/receive) and recipient metadata. A hub +// may serve several channels, so selection is deterministic (the +// lexicographically smallest prefixed address) rather than relying on map +// iteration order. It is empty when the hub has no addressable channel. +func hubDefaultChannel(h *signalRHub) string { + best := "" + for _, ch := range h.channels { + if ch.Address == "" { + continue + } + addr := asyncAddressWithPrefix(h.prefix, ch.Address) + if best == "" || addr < best { + best = addr + } + } + return best +} + +// hubChannelAddress returns the default channel address of a hub (used by the +// receive built-in dispatch). +func hubChannelAddress(h *signalRHub) string { + return hubDefaultChannel(h) +} + +// handleStreamInvocation answers a StreamInvocation by channel ID with the +// channel's snapshot message and holds the stream open (RS.SHR.3-5, RS.SHR.17). +func (h *signalRHub) handleStreamInvocation(sc *signalRConnection, env signalREnvelope) { + 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{}) +} + +// streamDelivery is one snapshotted write to perform after the hub lock is +// released, so network I/O never blocks other hub operations (negotiate, +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.go b/internal/server/signalr_hub.go index 95481c4..ec5d363 100644 --- a/internal/server/signalr_hub.go +++ b/internal/server/signalr_hub.go @@ -2,7 +2,6 @@ package server import ( "encoding/json" - "fmt" "net/http" "strconv" "strings" @@ -156,7 +155,10 @@ func (h *signalRHub) negotiate(w http.ResponseWriter, r *http.Request) { ConnectionID: connID, NegotiateVersion: 1, AvailableTransports: []signalRAvailableTransport{ - {Transport: "WebSockets", TransferFormats: []string{"Text", "Binary"}}, + // Only the Text transfer format is offered: the handshake rejects + // binary frames (RS.SHR.8, RS.SHR.15), so advertising Binary would + // promise a capability the server then disconnects. + {Transport: "WebSockets", TransferFormats: []string{"Text"}}, }, } w.Header().Set("Content-Type", "application/json") @@ -180,14 +182,6 @@ func (h *signalRHub) issueToken() (token, connID string) { 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() @@ -268,9 +262,11 @@ func (h *signalRHub) serveUpgrade(w http.ResponseWriter, r *http.Request) { defer func() { h.mu.Lock() + // The connection's open streams are discarded with the connection + // object: removing connID makes them undiscoverable (openStreamsForChannel + // iterates h.conns), so no separate stream cleanup is needed. delete(h.conns, connID) h.mu.Unlock() - h.removeConnectionStreams(connID) wr.close() if h.hooks.OnDisconnect != nil { h.hooks.OnDisconnect(channel, connID) @@ -280,297 +276,8 @@ func (h *signalRHub) serveUpgrade(w http.ResponseWriter, r *http.Request) { 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: - // An inbound client invocation carries a payload; fire the receive - // built-in (RS.EVT.25) before answering. A single argument is exposed - // directly as the event payload (the common case for message mocks). - if h.hooks.Receive != nil { - payload := json.RawMessage("{}") - if len(env.Arguments) == 1 { - payload, _ = json.Marshal(env.Arguments[0]) - } else if len(env.Arguments) > 1 { - payload, _ = json.Marshal(env.Arguments) - } - ch := hubChannelAddress(h) - if ch != "" { - h.hooks.Receive(ch, InboundMessage{ - Payload: payload, - ConnectionID: sc.id, - }) - } - } - h.handleInvocation(sc, env) - case signalRTypeCancelInvocation: - h.handleCancelInvocation(sc, env) - } -} - -// hubDefaultChannel returns the channel address a SignalR hub uses for -// connection-level built-ins (connect/receive) and recipient metadata. A hub -// may serve several channels, so selection is deterministic (the -// lexicographically smallest prefixed address) rather than relying on map -// iteration order. It is empty when the hub has no addressable channel. -func hubDefaultChannel(h *signalRHub) string { - best := "" - for _, ch := range h.channels { - if ch.Address == "" { - continue - } - addr := asyncAddressWithPrefix(h.prefix, ch.Address) - if best == "" || addr < best { - best = addr - } - } - return best -} - -// hubChannelAddress returns the default channel address of a hub (used by the -// receive built-in dispatch). -func hubChannelAddress(h *signalRHub) string { - return hubDefaultChannel(h) -} - -// handleStreamInvocation answers a StreamInvocation by channel ID with the -// channel's snapshot message and holds the stream open (RS.SHR.3-5, RS.SHR.17). -func (h *signalRHub) handleStreamInvocation(sc *signalRConnection, env signalREnvelope) { - 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). +// buildSignalRHubs constructs a SignalR hub for each AsyncAPI document +// declaring root x-signalr (design D7). func buildSignalRHubs(renderer MessageRenderer, schemas []SchemaInfo) []*signalRHub { var hubs []*signalRHub for _, schema := range schemas { @@ -598,20 +305,3 @@ func (s *Server) registerSignalRHubs(r interface { } // 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 index aa35462..027ac21 100644 --- a/internal/server/signalr_hub_test.go +++ b/internal/server/signalr_hub_test.go @@ -1,11 +1,13 @@ package server import ( - "fmt" + "encoding/json" "net/http" "net/http/httptest" "testing" + "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,7 +23,7 @@ Related spec scenarios: RS.SHR.8 func TestNegotiateSignalR_Success(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) hub := newSignalRHubAtPath(srv, "/hub", "", nil) rec := httptest.NewRecorder() @@ -48,7 +50,7 @@ Related spec scenarios: RS.SHR.9 func TestNegotiateSignalR_DefaultVersion(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) hub := newSignalRHubAtPath(srv, "/hub", "", nil) rec := httptest.NewRecorder() @@ -74,16 +76,19 @@ Related spec scenarios: RS.SHR.11, RS.SHR.12 func TestSignalRHub_TokenCorrelation(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + 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) + gotConnID, ok := hub.consumeToken(token) + assert.True(t, ok, "issued token must correlate with its connection id") + assert.Equal(t, connID, gotConnID) + // The token is consumed on correlation, so it cannot be reused. + _, ok = hub.consumeToken(token) + assert.False(t, ok, "consumed token must not correlate again") } /* @@ -97,13 +102,79 @@ Related spec scenarios: RS.SHR.13 func TestSignalRHub_FreshToken(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + 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 + // A fresh token is not correlated until an upgrade presents it. + _, ok := hub.consumeToken(token) + assert.False(t, ok, "fresh token is not yet correlated") +} + +/* +Scenario: Negotiate advertises only the Text transfer format +Given a negotiate request +When negotiateSignalR is called +Then WebSockets is offered with Text transfer format only, matching the +handshake that rejects binary frames + +Related spec scenarios: RS.SHR.8 +*/ +func TestNegotiateSignalR_TextOnlyTransferFormat(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) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `"transferFormats":["Text"]`) + assert.NotContains(t, body, `"Binary"`) +} + +/* +Scenario: Handshake error frames are JSON-escaped +Given a handshake error message containing a quote and backslash +When writeHandshakeError is used +Then the produced frame is valid JSON with the message escaped + +Related spec scenarios: RS.SHR.15 +*/ +func TestSignalRHub_HandshakeErrorIsJSONEscaped(t *testing.T) { + t.Parallel() + + hub := newSignalRHubAtPath(&Server{}, "/hub", "", nil) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + hub.writeHandshakeError(&signalRConnection{id: "x", writer: newWSWriter(conn)}, `bad "protocol" \ here`) + })) + defer ts.Close() //nolint:errcheck + + wsURL := "ws" + ts.URL[len("http"):] + "/hub" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer conn.Close() //nolint:errcheck + + _, frame, err := conn.ReadMessage() + require.NoError(t, err) + // The frame is `{...}\x1e`; validate the JSON message before the separator. + chunks := splitSignalRFrames(frame) + require.Len(t, chunks, 1, "handshake error should be a single framed message") + require.True(t, json.Valid(chunks[0]), "handshake error JSON must be valid") + frameStr := string(chunks[0]) + assert.Contains(t, frameStr, `bad \"protocol\" \\ here`) + assert.NotContains(t, frameStr, `bad "protocol"`, "quote must be escaped, not spliced raw") } /* @@ -117,7 +188,7 @@ Related spec scenarios: RS.SHR.10 func TestNegotiateSignalR_UnsupportedTransport(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) hub := newSignalRHubAtPath(srv, "/hub", "", nil) rec := httptest.NewRecorder() @@ -140,7 +211,7 @@ Related spec scenarios: RS.SHR.8, RS.SHR.10 func TestNegotiateSignalR_WebSocketsTransport(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) hub := newSignalRHubAtPath(srv, "/hub", "", nil) rec := httptest.NewRecorder() @@ -152,6 +223,41 @@ func TestNegotiateSignalR_WebSocketsTransport(t *testing.T) { assert.Contains(t, rec.Body.String(), `"WebSockets"`) } +/* +Scenario: Candidates deduplicates per-connection for multi-stream hubs +Given a hub connection holding two open streams on the same channel +When hubManager.Candidates is called for that channel +Then exactly one candidate (the single connection) is returned carrying both +streams, so the per-connection partition cannot emit one write per stream and +duplicate delivery quadratically + +Related spec scenarios: RS.SHR.22 +*/ +func TestHubManager_CandidatesDeduplicatesStreams(t *testing.T) { + t.Parallel() + + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + hub := newSignalRHubAtPath(srv, "/hub", "", nil) + hub.channels["priceFeed"] = &asyncapi.Channel{ID: "priceFeed", Address: "/price"} + hub.mu.Lock() + 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"} + sc.streams["inv-2"] = &signalRStream{invocationID: "inv-2", channelID: "priceFeed", connID: "signalr-1"} + hub.conns["signalr-1"] = sc + hub.mu.Unlock() + + mgr := &hubManager{hubs: []*signalRHub{hub}} + candidates := mgr.Candidates("/price") + + require.Len(t, candidates, 1, "one connection with two streams must yield one candidate") + assert.Equal(t, "signalr-1", candidates[0].ConnectionID) + assert.Len(t, candidates[0].Streams, 2, "the single candidate carries both open streams") +} + /* Scenario: Open-stream registry tracks per-channel streams Given a hub connection holding an open stream on a channel @@ -163,7 +269,7 @@ Related spec scenarios: RS.SHR.21 func TestSignalRHub_OpenStreamsForChannel(t *testing.T) { t.Parallel() - srv, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) hub := newSignalRHubAtPath(srv, "/hub", "", nil) sc := &signalRConnection{ diff --git a/internal/server/signalr_push.go b/internal/server/signalr_push.go new file mode 100644 index 0000000..8b14b94 --- /dev/null +++ b/internal/server/signalr_push.go @@ -0,0 +1,86 @@ +package server + +import ( + "encoding/json" + "strconv" +) + +type streamDelivery struct { + writer *wsWriter + env signalREnvelope +} + +// 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() + deliveries := h.buildStreamDeliveries(channelID, payload, target, nil) + h.mu.Unlock() + for _, d := range deliveries { + d.writer.write(encodeSignalRMessage(d.env)) + } +} + +// 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() + sc, ok := h.conns[connectionID] + var deliveries []streamDelivery + if ok { + deliveries = h.buildStreamDeliveries(channelID, payload, target, sc) + } + h.mu.Unlock() + for _, d := range deliveries { + d.writer.write(encodeSignalRMessage(d.env)) + } +} + +// buildStreamDeliveries snapshots the writes needed to deliver a payload to a +// channel's open streams, falling back to per-connection server Invocations +// (each with a distinct server-assigned id) when no stream matches. The caller +// must hold h.mu; when conn is non-nil delivery is restricted to that single +// connection. +func (h *signalRHub) buildStreamDeliveries(channelID string, payload []byte, target string, conn *signalRConnection) []streamDelivery { + var out []streamDelivery + emitInvocation := func(sc *signalRConnection) { + h.idSeq++ + out = append(out, streamDelivery{writer: sc.writer, env: signalREnvelope{ + Type: signalRTypeInvocation, + InvocationID: "srv-" + target + "-" + strconv.Itoa(h.idSeq), + Target: target, + Arguments: []any{json.RawMessage(payload)}, + }}) + } + matched := false + writeStreamItems := func(sc *signalRConnection) { + for invocationID, st := range sc.streams { + if st.channelID != channelID { + continue + } + matched = true + out = append(out, streamDelivery{writer: sc.writer, env: signalREnvelope{ + Type: signalRTypeStreamItem, + InvocationID: invocationID, + Item: json.RawMessage(payload), + }}) + } + } + if conn != nil { + writeStreamItems(conn) + if !matched { + emitInvocation(conn) + } + return out + } + for _, sc := range h.conns { + writeStreamItems(sc) + } + if !matched { + for _, sc := range h.conns { + emitInvocation(sc) + } + } + return out +} diff --git a/internal/server/templating_parity_test.go b/internal/server/templating_parity_test.go index 5dc19fa..c731a93 100644 --- a/internal/server/templating_parity_test.go +++ b/internal/server/templating_parity_test.go @@ -47,11 +47,11 @@ Related spec scenarios: RS.ATM.1, RS.ATM.3 func TestTemplateParity_MessageAndChannel(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncWSProtocol, + Protocol: asyncapi.ProtocolWS, Action: "send", Path: "/echo", Pattern: "/echo", @@ -82,7 +82,7 @@ Related spec scenarios: RS.ATM.11, RS.ATM.16 func TestTemplateParity_StateNamespaceIsolation(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() var setNamespace string @@ -90,7 +90,7 @@ func TestTemplateParity_StateNamespaceIsolation(t *testing.T) { func(namespace, key string, value any) { setNamespace = namespace }).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncWSProtocol, + Protocol: asyncapi.ProtocolWS, Action: "send", Path: "/tenant/ch", Pattern: "/ch", @@ -134,11 +134,11 @@ Related spec scenarios: RS.ATM.2 func TestTemplateParity_MessageHeader(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncWSProtocol, + Protocol: asyncapi.ProtocolWS, Action: "send", Path: "/echo", Pattern: "/echo", @@ -187,11 +187,11 @@ Related spec scenarios: RS.ATM.4 func TestTemplateParity_StateExpression(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{"counter": 7}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncWSProtocol, + Protocol: asyncapi.ProtocolWS, Action: "send", Path: "/ch", Pattern: "/ch", diff --git a/internal/server/wrappers.go b/internal/server/wrappers.go index 21ff995..b35cccb 100644 --- a/internal/server/wrappers.go +++ b/internal/server/wrappers.go @@ -1,14 +1,8 @@ package server 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/state" ) @@ -16,23 +10,7 @@ import ( type loaderRouteProvider struct{} func (p *loaderRouteProvider) BuildRouteMappings(schemas []SchemaInfo) ([]RouteMapping, error) { - // Convert SchemaInfo to loader.SchemaInfo - loaderSchemas := make([]loader.SchemaInfo, len(schemas)) - for i, schema := range schemas { - loaderSchemas[i] = loader.SchemaInfo{ - Spec: schema.Spec, - Kind: schema.Kind, - Async: schema.Async, - Prefix: schema.Prefix, - } - } - - loaderMappings, err := loader.BuildRouteMappings(loaderSchemas) - if err != nil { - return nil, err - } - - return ConvertRouteMappings(loaderMappings), nil + return loader.BuildRouteMappings(schemas) } // stateManagerStore wraps state.Manager to implement StateStore. @@ -78,56 +56,11 @@ func newHistoryRingBufferStore(buffer *history.RingBuffer) *historyRingBufferSto } func (s *historyRingBufferStore) Add(record 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, - } - } - - s.buffer.Add(historyRecord) + s.buffer.Add(record) } func (s *historyRingBufferStore) GetAll() []RequestRecord { - historyRecords := s.buffer.GetAll() - records := make([]RequestRecord, len(historyRecords)) - - for i, hr := range historyRecords { - record := 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 = &ResponseRecord{ - StatusCode: hr.Response.StatusCode, - Headers: hr.Response.Headers, - Body: hr.Response.Body, - Duration: hr.Response.Duration, - } - } - - records[i] = record - } - - return records + return s.buffer.GetAll() } func (s *historyRingBufferStore) Count() int { @@ -141,124 +74,3 @@ func (s *historyRingBufferStore) Capacity() int { func (s *historyRingBufferStore) Clear() { s.buffer.Clear() } - -// runtimeDataSourceWrapper wraps runtime.DataSource to implement DataSource. -type runtimeDataSourceWrapper struct { - source runtime.DataSource -} - -func (w *runtimeDataSourceWrapper) Get(path string) (any, bool) { - return w.source.Get(path) -} - -// runtimeRequestSourceFactory implements RequestSourceFactory using runtime package. -type runtimeRequestSourceFactory struct{} - -func (f *runtimeRequestSourceFactory) NewRequestSource(r *http.Request, pathParams map[string]string) DataSource { - source := &runtime.RequestSource{ - PathParams: pathParams, - QueryParams: r.URL.Query(), - Headers: r.Header, - Cookies: make(map[string]string), - Body: nil, - } - - // Parse cookies - for _, cookie := range r.Cookies() { - source.Cookies[cookie.Name] = cookie.Value - } - - return &runtimeDataSourceWrapper{source: source} -} - -// runtimeStateSourceFactory implements StateSourceFactory using runtime package. -type runtimeStateSourceFactory struct { - stateStore StateStore -} - -func newRuntimeStateSourceFactory(stateStore StateStore) *runtimeStateSourceFactory { - return &runtimeStateSourceFactory{stateStore: stateStore} -} - -func (f *runtimeStateSourceFactory) NewStateSource(namespace string) DataSource { - data := f.stateStore.GetNamespace(namespace) - source := &runtime.StateSource{Data: data} - return &runtimeDataSourceWrapper{source: source} -} - -// runtimeEnvSourceFactory implements EnvSourceFactory using runtime package. -type runtimeEnvSourceFactory struct{} - -func (f *runtimeEnvSourceFactory) NewEnvSource() 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 &runtimeDataSourceWrapper{source: source} -} - -// runtimeExpressionEvaluatorWrapper wraps runtime.Evaluator to implement ExpressionEvaluator. -type runtimeExpressionEvaluatorWrapper struct { - eval runtime.Evaluator -} - -func newRuntimeExpressionEvaluatorWrapper(eval runtime.Evaluator) *runtimeExpressionEvaluatorWrapper { - return &runtimeExpressionEvaluatorWrapper{eval: eval} -} - -func (w *runtimeExpressionEvaluatorWrapper) AddSource(name string, source DataSource) { - // Convert DataSource to runtime.DataSource - runtimeSource := &runtimeDataSourceWrapper{source: source} - w.eval.AddSource(name, runtimeSource) -} - -func (w *runtimeExpressionEvaluatorWrapper) Evaluate(expr string) (any, error) { - return w.eval.Evaluate(expr) -} - -// extensionsProcessorWrapper wraps extensions package to implement ExtensionProcessor. -type extensionsProcessorWrapper struct{} - -func (w *extensionsProcessorWrapper) ExtractSetState(example *openapi3.Example) (map[string]any, bool) { - return extensions.ExtractSetState(example) -} - -func (w *extensionsProcessorWrapper) ExtractSkip(example *openapi3.Example) bool { - return extensions.ExtractSkip(example) -} - -func (w *extensionsProcessorWrapper) ExtractOnce(example *openapi3.Example) bool { - return extensions.ExtractOnce(example) -} - -func (w *extensionsProcessorWrapper) ExtractParamsMatch(example *openapi3.Example) (map[string]any, bool) { - return extensions.ExtractParamsMatch(example) -} - -func (w *extensionsProcessorWrapper) EvaluateParamsMatch(params map[string]any, eval ExpressionEvaluator) (bool, error) { - // We need to convert the params to extensions.ParamsMatch - // and use the extensions.EvaluateParamsMatch function - // But extensions.EvaluateParamsMatch expects runtime.Evaluator - // This is complex - need proper adapter - // For now, return false (will be implemented later) - _ = params - _ = eval - return false, nil -} - -func (w *extensionsProcessorWrapper) 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/ws_adapter.go b/internal/server/ws_adapter.go index c291872..6e2e35e 100644 --- a/internal/server/ws_adapter.go +++ b/internal/server/ws_adapter.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/mamonth/oasmock/internal/asyncapi" ) // wsUpgrader upgrades incoming HTTP requests to WebSocket connections. @@ -20,6 +21,12 @@ var wsUpgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } +// wsReadIdleBounds is how long a consumer may go without sending any frame +// before the connection is considered dead and the read loop exits. It bounds +// the lifetime of silently-dead peers so their goroutines and registrations +// are not leaked. +const wsReadIdleBounds = 60 * time.Second + // 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. @@ -31,26 +38,30 @@ type wsWriter struct { // 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) { +// wsWriteDeadline bounds each write so a stalled peer does not block the +// connection's writer forever. +const wsWriteDeadline = 10 * time.Second + +// writeFrame serializes all writes behind the connection's write mutex (the +// gorilla/websocket single-writer rule). +func (w *wsWriter) writeFrame(messageType int, 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) + _ = w.conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + _ = w.conn.WriteMessage(messageType, data) +} + +// write sends a text frame. +func (w *wsWriter) write(data []byte) { + w.writeFrame(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) + w.writeFrame(messageType, data) } // writeError sends a JSON-encoded error object on the connection. @@ -61,22 +72,13 @@ func (w *wsWriter) writeError(err error) { // 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) + w.writeFrame(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason)) } -// abort closes the connection without a close frame (simulated abrupt drop, RS.AMG.17). +// 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() + w.close() } // close closes the connection. @@ -223,7 +225,7 @@ func newWSProtocolAdapter() *wsProtocolAdapter { } // Protocol implements ProtocolAdapter. -func (a *wsProtocolAdapter) Protocol() string { return asyncWSProtocol } +func (a *wsProtocolAdapter) Protocol() string { return asyncapi.ProtocolWS } // Handler builds the WebSocket upgrade handler for an AsyncAPI ws channel. func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandler) http.HandlerFunc { @@ -261,11 +263,15 @@ func (a *wsProtocolAdapter) Handler(mapping *RouteMapping, handler MessageHandle } // Read loop: send-operation acceptance + reply (RS.ASP.6, RS.ASP.9). + // The read deadline bails out of a silently-dead peer so the read-loop + // goroutine and the connection registration are not leaked. + _ = conn.SetReadDeadline(time.Now().Add(wsReadIdleBounds)) for { messageType, payload, rerr := conn.ReadMessage() if rerr != nil { break } + _ = conn.SetReadDeadline(time.Now().Add(wsReadIdleBounds)) if messageType == websocket.PingMessage { wr.writeMessage(websocket.PongMessage, payload) continue diff --git a/internal/server/ws_adapter_test.go b/internal/server/ws_adapter_test.go index c8a674b..9449d57 100644 --- a/internal/server/ws_adapter_test.go +++ b/internal/server/ws_adapter_test.go @@ -7,6 +7,7 @@ import ( "github.com/golang/mock/gomock" "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" @@ -23,18 +24,18 @@ Related spec scenarios: RS.ASP.6, RS.ASP.9 func TestWSProtocolAdapter_SendEchoAck(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncWSProtocol, + Protocol: asyncapi.ProtocolWS, Action: "send", Path: "/socket", Pattern: "/socket", Messages: nil, } - adapter := srv.adapterForProtocol(asyncWSProtocol) + adapter := srv.adapterForProtocol(asyncapi.ProtocolWS) require.NotNil(t, adapter) ts := httptest.NewServer(adapter.Handler(mapping, srv.asyncMessageHandler(mapping))) @@ -62,11 +63,11 @@ Related spec scenarios: RS.ASP.2, RS.ASP.7 func TestWSProtocolAdapter_ReceiveEmitsOnConnect(t *testing.T) { t.Parallel() - srv, _, stateStore, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) + srv, _, stateStore, _ := newMockedServerWithGeneratedMocks(t, Config{HistorySize: DefaultHistorySize}) stateStore.EXPECT().GetNamespace(gomock.Any()).Return(map[string]any{}).AnyTimes() mapping := &RouteMapping{ - Protocol: asyncWSProtocol, + Protocol: asyncapi.ProtocolWS, Action: "receive", Path: "/prices", Pattern: "/prices", @@ -80,7 +81,7 @@ func TestWSProtocolAdapter_ReceiveEmitsOnConnect(t *testing.T) { }, } - adapter := srv.adapterForProtocol(asyncWSProtocol) + adapter := srv.adapterForProtocol(asyncapi.ProtocolWS) require.NotNil(t, adapter) ts := httptest.NewServer(adapter.Handler(mapping, srv.asyncMessageHandler(mapping))) diff --git a/internal/state/state.go b/internal/state/state.go index ac12d57..b37bb68 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,7 +1,6 @@ package state import ( - "maps" "sync" ) @@ -101,7 +100,8 @@ func (m *Manager) ClearNamespace(namespace string) { delete(m.state, namespace) } -// GetNamespace returns a copy of all state for a namespace. +// GetNamespace returns a deep copy of all state for a namespace. Mutating the +// returned map, or any nested map/slice value, never affects live state. func (m *Manager) GetNamespace(namespace string) map[string]any { m.mu.RLock() defer m.mu.RUnlock() @@ -109,16 +109,43 @@ func (m *Manager) GetNamespace(namespace string) map[string]any { if !ok { return nil } - return maps.Clone(ns) + return deepCopyMap(ns) } -// GetAll returns a copy of all state (for debugging). +// GetAll returns a deep copy of all state (for debugging). Mutating the +// returned structure, including nested values, never affects live state. func (m *Manager) GetAll() map[string]map[string]any { m.mu.RLock() defer m.mu.RUnlock() copy := make(map[string]map[string]any, len(m.state)) for ns, kv := range m.state { - copy[ns] = maps.Clone(kv) + copy[ns] = deepCopyMap(kv) } return copy } + +// deepCopyMap returns a structural deep copy of a JSON-serializable value map. +func deepCopyMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = deepCopyValue(v) + } + return out +} + +// deepCopyValue recursively copies JSON-serializable values so callers cannot +// alias (and later mutate) maps or slices held in live state. +func deepCopyValue(v any) any { + switch t := v.(type) { + case map[string]any: + return deepCopyMap(t) + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = deepCopyValue(e) + } + return out + default: + return v + } +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 4990866..85f7e9c 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -231,6 +231,34 @@ func TestManagerGetNamespace(t *testing.T) { assert.Equal(t, "val1", val, "Modifying copy should not affect manager") } +/* +Scenario: GetNamespace returns a deep copy, not a shallow one +Given state containing a nested map value under a key +When GetNamespace is called and the nested value is mutated +Then live state is unchanged (deep copy), not aliased + +Related spec scenarios: RS.MSC.20 +*/ +func TestManagerGetNamespace_DeepCopy(t *testing.T) { + t.Parallel() + + m := NewManager() + m.Set("ns1", "user", map[string]any{"name": "alice", "tags": []any{"a", "b"}}) + + // Nested mutation on the returned copy must not reach live state. + ns := m.GetNamespace("ns1") + user, ok := ns["user"].(map[string]any) + require.True(t, ok) + user["name"] = "eve" + user["tags"].([]any)[0] = "hacked" + + liveUser, _ := m.Get("ns1", "user") + live, ok := liveUser.(map[string]any) + require.True(t, ok) + assert.Equal(t, "alice", live["name"], "nested mutation must not affect live state") + assert.Equal(t, "a", live["tags"].([]any)[0], "nested slice mutation must not affect live state") +} + /* Scenario: Retrieving a copy of all state from manager Given a state manager with multiple namespaces and keys diff --git a/mock/server/interfaces_mock.go b/mock/server/interfaces_mock.go index 74fe328..a2b485c 100644 --- a/mock/server/interfaces_mock.go +++ b/mock/server/interfaces_mock.go @@ -1,15 +1,15 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/mamonth/oasmock/internal/server (interfaces: RouteProvider,StateStore,HistoryStore,DataSource,RequestSourceFactory,StateSourceFactory,EnvSourceFactory,ExpressionEvaluator,ExtensionProcessor) +// Source: interfaces.go // Package mock_server is a generated GoMock package. package mock_server import ( - http "net/http" reflect "reflect" - openapi3 "github.com/getkin/kin-openapi/openapi3" gomock "github.com/golang/mock/gomock" + loader "github.com/mamonth/oasmock/internal/loader" + runtime "github.com/mamonth/oasmock/internal/runtime" server "github.com/mamonth/oasmock/internal/server" ) @@ -37,18 +37,18 @@ func (m *MockRouteProvider) EXPECT() *MockRouteProviderMockRecorder { } // BuildRouteMappings mocks base method. -func (m *MockRouteProvider) BuildRouteMappings(arg0 []server.SchemaInfo) ([]server.RouteMapping, error) { +func (m *MockRouteProvider) BuildRouteMappings(schemas []server.SchemaInfo) ([]server.RouteMapping, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BuildRouteMappings", arg0) + ret := m.ctrl.Call(m, "BuildRouteMappings", schemas) ret0, _ := ret[0].([]server.RouteMapping) ret1, _ := ret[1].(error) return ret0, ret1 } // BuildRouteMappings indicates an expected call of BuildRouteMappings. -func (mr *MockRouteProviderMockRecorder) BuildRouteMappings(arg0 interface{}) *gomock.Call { +func (mr *MockRouteProviderMockRecorder) BuildRouteMappings(schemas interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildRouteMappings", reflect.TypeOf((*MockRouteProvider)(nil).BuildRouteMappings), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildRouteMappings", reflect.TypeOf((*MockRouteProvider)(nil).BuildRouteMappings), schemas) } // MockStateStore is a mock of StateStore interface. @@ -75,37 +75,37 @@ func (m *MockStateStore) EXPECT() *MockStateStoreMockRecorder { } // Delete mocks base method. -func (m *MockStateStore) Delete(arg0, arg1 string) { +func (m *MockStateStore) Delete(namespace, key string) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Delete", arg0, arg1) + m.ctrl.Call(m, "Delete", namespace, key) } // Delete indicates an expected call of Delete. -func (mr *MockStateStoreMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Delete(namespace, key interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStateStore)(nil).Delete), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStateStore)(nil).Delete), namespace, key) } // Get mocks base method. -func (m *MockStateStore) Get(arg0, arg1 string) (interface{}, bool) { +func (m *MockStateStore) Get(namespace, key string) (any, bool) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1) - ret0, _ := ret[0].(interface{}) + ret := m.ctrl.Call(m, "Get", namespace, key) + ret0, _ := ret[0].(any) ret1, _ := ret[1].(bool) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockStateStoreMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Get(namespace, key interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStateStore)(nil).Get), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStateStore)(nil).Get), namespace, key) } // GetAll mocks base method. -func (m *MockStateStore) GetAll() map[string]map[string]interface{} { +func (m *MockStateStore) GetAll() map[string]map[string]any { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAll") - ret0, _ := ret[0].(map[string]map[string]interface{}) + ret0, _ := ret[0].(map[string]map[string]any) return ret0 } @@ -116,44 +116,44 @@ func (mr *MockStateStoreMockRecorder) GetAll() *gomock.Call { } // GetNamespace mocks base method. -func (m *MockStateStore) GetNamespace(arg0 string) map[string]interface{} { +func (m *MockStateStore) GetNamespace(namespace string) map[string]any { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNamespace", arg0) - ret0, _ := ret[0].(map[string]interface{}) + ret := m.ctrl.Call(m, "GetNamespace", namespace) + ret0, _ := ret[0].(map[string]any) return ret0 } // GetNamespace indicates an expected call of GetNamespace. -func (mr *MockStateStoreMockRecorder) GetNamespace(arg0 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) GetNamespace(namespace interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespace", reflect.TypeOf((*MockStateStore)(nil).GetNamespace), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespace", reflect.TypeOf((*MockStateStore)(nil).GetNamespace), namespace) } // Increment mocks base method. -func (m *MockStateStore) Increment(arg0, arg1 string, arg2 float64) (float64, error) { +func (m *MockStateStore) Increment(namespace, key string, delta float64) (float64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Increment", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Increment", namespace, key, delta) ret0, _ := ret[0].(float64) ret1, _ := ret[1].(error) return ret0, ret1 } // Increment indicates an expected call of Increment. -func (mr *MockStateStoreMockRecorder) Increment(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Increment(namespace, key, delta interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Increment", reflect.TypeOf((*MockStateStore)(nil).Increment), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Increment", reflect.TypeOf((*MockStateStore)(nil).Increment), namespace, key, delta) } // Set mocks base method. -func (m *MockStateStore) Set(arg0, arg1 string, arg2 interface{}) { +func (m *MockStateStore) Set(namespace, key string, value any) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Set", arg0, arg1, arg2) + m.ctrl.Call(m, "Set", namespace, key, value) } // Set indicates an expected call of Set. -func (mr *MockStateStoreMockRecorder) Set(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockStateStoreMockRecorder) Set(namespace, key, value interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockStateStore)(nil).Set), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockStateStore)(nil).Set), namespace, key, value) } // MockHistoryStore is a mock of HistoryStore interface. @@ -180,15 +180,15 @@ func (m *MockHistoryStore) EXPECT() *MockHistoryStoreMockRecorder { } // Add mocks base method. -func (m *MockHistoryStore) Add(arg0 server.RequestRecord) { +func (m *MockHistoryStore) Add(record server.RequestRecord) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Add", arg0) + m.ctrl.Call(m, "Add", record) } // Add indicates an expected call of Add. -func (mr *MockHistoryStoreMockRecorder) Add(arg0 interface{}) *gomock.Call { +func (mr *MockHistoryStoreMockRecorder) Add(record interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockHistoryStore)(nil).Add), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockHistoryStore)(nil).Add), record) } // Capacity mocks base method. @@ -245,312 +245,250 @@ func (mr *MockHistoryStoreMockRecorder) GetAll() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockHistoryStore)(nil).GetAll)) } -// MockDataSource is a mock of DataSource interface. -type MockDataSource struct { +// MockRpcProtocol is a mock of RpcProtocol interface. +type MockRpcProtocol struct { ctrl *gomock.Controller - recorder *MockDataSourceMockRecorder + recorder *MockRpcProtocolMockRecorder } -// MockDataSourceMockRecorder is the mock recorder for MockDataSource. -type MockDataSourceMockRecorder struct { - mock *MockDataSource +// MockRpcProtocolMockRecorder is the mock recorder for MockRpcProtocol. +type MockRpcProtocolMockRecorder struct { + mock *MockRpcProtocol } -// NewMockDataSource creates a new mock instance. -func NewMockDataSource(ctrl *gomock.Controller) *MockDataSource { - mock := &MockDataSource{ctrl: ctrl} - mock.recorder = &MockDataSourceMockRecorder{mock} +// NewMockRpcProtocol creates a new mock instance. +func NewMockRpcProtocol(ctrl *gomock.Controller) *MockRpcProtocol { + mock := &MockRpcProtocol{ctrl: ctrl} + mock.recorder = &MockRpcProtocolMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockDataSource) EXPECT() *MockDataSourceMockRecorder { +func (m *MockRpcProtocol) EXPECT() *MockRpcProtocolMockRecorder { return m.recorder } -// Get mocks base method. -func (m *MockDataSource) Get(arg0 string) (interface{}, bool) { +// ContentType mocks base method. +func (m *MockRpcProtocol) ContentType() string { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0) - ret0, _ := ret[0].(interface{}) - ret1, _ := ret[1].(bool) - return ret0, ret1 + ret := m.ctrl.Call(m, "ContentType") + ret0, _ := ret[0].(string) + return ret0 } -// Get indicates an expected call of Get. -func (mr *MockDataSourceMockRecorder) Get(arg0 interface{}) *gomock.Call { +// ContentType indicates an expected call of ContentType. +func (mr *MockRpcProtocolMockRecorder) ContentType() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDataSource)(nil).Get), arg0) -} - -// MockRequestSourceFactory is a mock of RequestSourceFactory interface. -type MockRequestSourceFactory struct { - ctrl *gomock.Controller - recorder *MockRequestSourceFactoryMockRecorder -} - -// MockRequestSourceFactoryMockRecorder is the mock recorder for MockRequestSourceFactory. -type MockRequestSourceFactoryMockRecorder struct { - mock *MockRequestSourceFactory + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContentType", reflect.TypeOf((*MockRpcProtocol)(nil).ContentType)) } -// NewMockRequestSourceFactory creates a new mock instance. -func NewMockRequestSourceFactory(ctrl *gomock.Controller) *MockRequestSourceFactory { - mock := &MockRequestSourceFactory{ctrl: ctrl} - mock.recorder = &MockRequestSourceFactoryMockRecorder{mock} - return mock +// ErrorResponse mocks base method. +func (m *MockRpcProtocol) ErrorResponse(code int, message string, id any) []byte { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ErrorResponse", code, message, id) + ret0, _ := ret[0].([]byte) + return ret0 } -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockRequestSourceFactory) EXPECT() *MockRequestSourceFactoryMockRecorder { - return m.recorder +// ErrorResponse indicates an expected call of ErrorResponse. +func (mr *MockRpcProtocolMockRecorder) ErrorResponse(code, message, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ErrorResponse", reflect.TypeOf((*MockRpcProtocol)(nil).ErrorResponse), code, message, id) } -// NewRequestSource mocks base method. -func (m *MockRequestSourceFactory) NewRequestSource(arg0 *http.Request, arg1 map[string]string) server.DataSource { +// ParseBody mocks base method. +func (m *MockRpcProtocol) ParseBody(body []byte) ([]server.RpcEntry, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NewRequestSource", arg0, arg1) - ret0, _ := ret[0].(server.DataSource) - return ret0 + ret := m.ctrl.Call(m, "ParseBody", body) + ret0, _ := ret[0].([]server.RpcEntry) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// NewRequestSource indicates an expected call of NewRequestSource. -func (mr *MockRequestSourceFactoryMockRecorder) NewRequestSource(arg0, arg1 interface{}) *gomock.Call { +// ParseBody indicates an expected call of ParseBody. +func (mr *MockRpcProtocolMockRecorder) ParseBody(body interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewRequestSource", reflect.TypeOf((*MockRequestSourceFactory)(nil).NewRequestSource), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseBody", reflect.TypeOf((*MockRpcProtocol)(nil).ParseBody), body) } -// MockStateSourceFactory is a mock of StateSourceFactory interface. -type MockStateSourceFactory struct { +// MockMessageRenderer is a mock of MessageRenderer interface. +type MockMessageRenderer struct { ctrl *gomock.Controller - recorder *MockStateSourceFactoryMockRecorder + recorder *MockMessageRendererMockRecorder } -// MockStateSourceFactoryMockRecorder is the mock recorder for MockStateSourceFactory. -type MockStateSourceFactoryMockRecorder struct { - mock *MockStateSourceFactory +// MockMessageRendererMockRecorder is the mock recorder for MockMessageRenderer. +type MockMessageRendererMockRecorder struct { + mock *MockMessageRenderer } -// NewMockStateSourceFactory creates a new mock instance. -func NewMockStateSourceFactory(ctrl *gomock.Controller) *MockStateSourceFactory { - mock := &MockStateSourceFactory{ctrl: ctrl} - mock.recorder = &MockStateSourceFactoryMockRecorder{mock} +// NewMockMessageRenderer creates a new mock instance. +func NewMockMessageRenderer(ctrl *gomock.Controller) *MockMessageRenderer { + mock := &MockMessageRenderer{ctrl: ctrl} + mock.recorder = &MockMessageRendererMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockStateSourceFactory) EXPECT() *MockStateSourceFactoryMockRecorder { +func (m *MockMessageRenderer) EXPECT() *MockMessageRendererMockRecorder { return m.recorder } -// NewStateSource mocks base method. -func (m *MockStateSourceFactory) NewStateSource(arg0 string) server.DataSource { +// ApplySetState mocks base method. +func (m *MockMessageRenderer) ApplySetState(stateMap map[string]any, eval runtime.Evaluator, prefix string) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NewStateSource", arg0) - ret0, _ := ret[0].(server.DataSource) - return ret0 + m.ctrl.Call(m, "ApplySetState", stateMap, eval, prefix) } -// NewStateSource indicates an expected call of NewStateSource. -func (mr *MockStateSourceFactoryMockRecorder) NewStateSource(arg0 interface{}) *gomock.Call { +// ApplySetState indicates an expected call of ApplySetState. +func (mr *MockMessageRendererMockRecorder) ApplySetState(stateMap, eval, prefix interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStateSource", reflect.TypeOf((*MockStateSourceFactory)(nil).NewStateSource), arg0) -} - -// MockEnvSourceFactory is a mock of EnvSourceFactory interface. -type MockEnvSourceFactory struct { - ctrl *gomock.Controller - recorder *MockEnvSourceFactoryMockRecorder -} - -// MockEnvSourceFactoryMockRecorder is the mock recorder for MockEnvSourceFactory. -type MockEnvSourceFactoryMockRecorder struct { - mock *MockEnvSourceFactory -} - -// NewMockEnvSourceFactory creates a new mock instance. -func NewMockEnvSourceFactory(ctrl *gomock.Controller) *MockEnvSourceFactory { - mock := &MockEnvSourceFactory{ctrl: ctrl} - mock.recorder = &MockEnvSourceFactoryMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockEnvSourceFactory) EXPECT() *MockEnvSourceFactoryMockRecorder { - return m.recorder + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplySetState", reflect.TypeOf((*MockMessageRenderer)(nil).ApplySetState), stateMap, eval, prefix) } // NewEnvSource mocks base method. -func (m *MockEnvSourceFactory) NewEnvSource() server.DataSource { +func (m *MockMessageRenderer) NewEnvSource() *runtime.EnvSource { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewEnvSource") - ret0, _ := ret[0].(server.DataSource) + ret0, _ := ret[0].(*runtime.EnvSource) return ret0 } // NewEnvSource indicates an expected call of NewEnvSource. -func (mr *MockEnvSourceFactoryMockRecorder) NewEnvSource() *gomock.Call { +func (mr *MockMessageRendererMockRecorder) NewEnvSource() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewEnvSource", reflect.TypeOf((*MockEnvSourceFactory)(nil).NewEnvSource)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewEnvSource", reflect.TypeOf((*MockMessageRenderer)(nil).NewEnvSource)) } -// MockExpressionEvaluator is a mock of ExpressionEvaluator interface. -type MockExpressionEvaluator struct { - ctrl *gomock.Controller - recorder *MockExpressionEvaluatorMockRecorder +// NewStateSource mocks base method. +func (m *MockMessageRenderer) NewStateSource(prefix string) *runtime.StateSource { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewStateSource", prefix) + ret0, _ := ret[0].(*runtime.StateSource) + return ret0 } -// MockExpressionEvaluatorMockRecorder is the mock recorder for MockExpressionEvaluator. -type MockExpressionEvaluatorMockRecorder struct { - mock *MockExpressionEvaluator +// NewStateSource indicates an expected call of NewStateSource. +func (mr *MockMessageRendererMockRecorder) NewStateSource(prefix interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStateSource", reflect.TypeOf((*MockMessageRenderer)(nil).NewStateSource), prefix) } -// NewMockExpressionEvaluator creates a new mock instance. -func NewMockExpressionEvaluator(ctrl *gomock.Controller) *MockExpressionEvaluator { - mock := &MockExpressionEvaluator{ctrl: ctrl} - mock.recorder = &MockExpressionEvaluatorMockRecorder{mock} - return mock +// RenderAsyncPayload mocks base method. +func (m *MockMessageRenderer) RenderAsyncPayload(example *server.MessageExampleView, evaluator runtime.Evaluator) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenderAsyncPayload", example, evaluator) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockExpressionEvaluator) EXPECT() *MockExpressionEvaluatorMockRecorder { - return m.recorder +// RenderAsyncPayload indicates an expected call of RenderAsyncPayload. +func (mr *MockMessageRendererMockRecorder) RenderAsyncPayload(example, evaluator interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderAsyncPayload", reflect.TypeOf((*MockMessageRenderer)(nil).RenderAsyncPayload), example, evaluator) } -// AddSource mocks base method. -func (m *MockExpressionEvaluator) AddSource(arg0 string, arg1 server.DataSource) { +// RenderMessageSpecs mocks base method. +func (m *MockMessageRenderer) RenderMessageSpecs(messages []*loader.MessageSpec, prefix, opID string, in server.InboundMessage) (int, []byte, error) { m.ctrl.T.Helper() - m.ctrl.Call(m, "AddSource", arg0, arg1) + ret := m.ctrl.Call(m, "RenderMessageSpecs", messages, prefix, opID, in) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].([]byte) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } -// AddSource indicates an expected call of AddSource. -func (mr *MockExpressionEvaluatorMockRecorder) AddSource(arg0, arg1 interface{}) *gomock.Call { +// RenderMessageSpecs indicates an expected call of RenderMessageSpecs. +func (mr *MockMessageRendererMockRecorder) RenderMessageSpecs(messages, prefix, opID, in interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSource", reflect.TypeOf((*MockExpressionEvaluator)(nil).AddSource), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenderMessageSpecs", reflect.TypeOf((*MockMessageRenderer)(nil).RenderMessageSpecs), messages, prefix, opID, in) } -// Evaluate mocks base method. -func (m *MockExpressionEvaluator) Evaluate(arg0 string) (interface{}, error) { +// SelectAsyncExample mocks base method. +func (m *MockMessageRenderer) SelectAsyncExample(message *loader.MessageSpec, evaluator runtime.Evaluator, opID string) (*server.MessageExampleView, string) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Evaluate", arg0) - ret0, _ := ret[0].(interface{}) - ret1, _ := ret[1].(error) + ret := m.ctrl.Call(m, "SelectAsyncExample", message, evaluator, opID) + ret0, _ := ret[0].(*server.MessageExampleView) + ret1, _ := ret[1].(string) return ret0, ret1 } -// Evaluate indicates an expected call of Evaluate. -func (mr *MockExpressionEvaluatorMockRecorder) Evaluate(arg0 interface{}) *gomock.Call { +// SelectAsyncExample indicates an expected call of SelectAsyncExample. +func (mr *MockMessageRendererMockRecorder) SelectAsyncExample(message, evaluator, opID interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Evaluate", reflect.TypeOf((*MockExpressionEvaluator)(nil).Evaluate), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SelectAsyncExample", reflect.TypeOf((*MockMessageRenderer)(nil).SelectAsyncExample), message, evaluator, opID) } -// MockExtensionProcessor is a mock of ExtensionProcessor interface. -type MockExtensionProcessor struct { +// MockConsumerBus is a mock of ConsumerBus interface. +type MockConsumerBus struct { ctrl *gomock.Controller - recorder *MockExtensionProcessorMockRecorder + recorder *MockConsumerBusMockRecorder } -// MockExtensionProcessorMockRecorder is the mock recorder for MockExtensionProcessor. -type MockExtensionProcessorMockRecorder struct { - mock *MockExtensionProcessor +// MockConsumerBusMockRecorder is the mock recorder for MockConsumerBus. +type MockConsumerBusMockRecorder struct { + mock *MockConsumerBus } -// NewMockExtensionProcessor creates a new mock instance. -func NewMockExtensionProcessor(ctrl *gomock.Controller) *MockExtensionProcessor { - mock := &MockExtensionProcessor{ctrl: ctrl} - mock.recorder = &MockExtensionProcessorMockRecorder{mock} +// NewMockConsumerBus creates a new mock instance. +func NewMockConsumerBus(ctrl *gomock.Controller) *MockConsumerBus { + mock := &MockConsumerBus{ctrl: ctrl} + mock.recorder = &MockConsumerBusMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockExtensionProcessor) EXPECT() *MockExtensionProcessorMockRecorder { +func (m *MockConsumerBus) EXPECT() *MockConsumerBusMockRecorder { return m.recorder } -// EvaluateParamsMatch mocks base method. -func (m *MockExtensionProcessor) EvaluateParamsMatch(arg0 map[string]interface{}, arg1 server.ExpressionEvaluator) (bool, error) { +// Candidates mocks base method. +func (m *MockConsumerBus) Candidates(address string) []server.ConsumerInfo { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EvaluateParamsMatch", arg0, arg1) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// EvaluateParamsMatch indicates an expected call of EvaluateParamsMatch. -func (mr *MockExtensionProcessorMockRecorder) EvaluateParamsMatch(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EvaluateParamsMatch", reflect.TypeOf((*MockExtensionProcessor)(nil).EvaluateParamsMatch), arg0, arg1) -} - -// ExtractHeaders mocks base method. -func (m *MockExtensionProcessor) ExtractHeaders(arg0 *openapi3.Example) (map[string]interface{}, bool) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractHeaders", arg0) - ret0, _ := ret[0].(map[string]interface{}) - ret1, _ := ret[1].(bool) - return ret0, ret1 -} - -// ExtractHeaders indicates an expected call of ExtractHeaders. -func (mr *MockExtensionProcessorMockRecorder) ExtractHeaders(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractHeaders", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractHeaders), arg0) -} - -// ExtractOnce mocks base method. -func (m *MockExtensionProcessor) ExtractOnce(arg0 *openapi3.Example) bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractOnce", arg0) - ret0, _ := ret[0].(bool) + ret := m.ctrl.Call(m, "Candidates", address) + ret0, _ := ret[0].([]server.ConsumerInfo) return ret0 } -// ExtractOnce indicates an expected call of ExtractOnce. -func (mr *MockExtensionProcessorMockRecorder) ExtractOnce(arg0 interface{}) *gomock.Call { +// Candidates indicates an expected call of Candidates. +func (mr *MockConsumerBusMockRecorder) Candidates(address interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractOnce", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractOnce), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Candidates", reflect.TypeOf((*MockConsumerBus)(nil).Candidates), address) } -// ExtractParamsMatch mocks base method. -func (m *MockExtensionProcessor) ExtractParamsMatch(arg0 *openapi3.Example) (map[string]interface{}, bool) { +// PushTo mocks base method. +func (m *MockConsumerBus) PushTo(consumer server.ConsumerInfo, address string, payload []byte) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractParamsMatch", arg0) - ret0, _ := ret[0].(map[string]interface{}) - ret1, _ := ret[1].(bool) - return ret0, ret1 + m.ctrl.Call(m, "PushTo", consumer, address, payload) } -// ExtractParamsMatch indicates an expected call of ExtractParamsMatch. -func (mr *MockExtensionProcessorMockRecorder) ExtractParamsMatch(arg0 interface{}) *gomock.Call { +// PushTo indicates an expected call of PushTo. +func (mr *MockConsumerBusMockRecorder) PushTo(consumer, address, payload interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractParamsMatch", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractParamsMatch), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushTo", reflect.TypeOf((*MockConsumerBus)(nil).PushTo), consumer, address, payload) } -// ExtractSetState mocks base method. -func (m *MockExtensionProcessor) ExtractSetState(arg0 *openapi3.Example) (map[string]interface{}, bool) { +// SignalRPush mocks base method. +func (m *MockConsumerBus) SignalRPush(address string, payload []byte) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractSetState", arg0) - ret0, _ := ret[0].(map[string]interface{}) - ret1, _ := ret[1].(bool) - return ret0, ret1 + m.ctrl.Call(m, "SignalRPush", address, payload) } -// ExtractSetState indicates an expected call of ExtractSetState. -func (mr *MockExtensionProcessorMockRecorder) ExtractSetState(arg0 interface{}) *gomock.Call { +// SignalRPush indicates an expected call of SignalRPush. +func (mr *MockConsumerBusMockRecorder) SignalRPush(address, payload interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractSetState", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractSetState), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignalRPush", reflect.TypeOf((*MockConsumerBus)(nil).SignalRPush), address, payload) } -// ExtractSkip mocks base method. -func (m *MockExtensionProcessor) ExtractSkip(arg0 *openapi3.Example) bool { +// WSBroadcast mocks base method. +func (m *MockConsumerBus) WSBroadcast(address string, payload []byte) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExtractSkip", arg0) - ret0, _ := ret[0].(bool) - return ret0 + m.ctrl.Call(m, "WSBroadcast", address, payload) } -// ExtractSkip indicates an expected call of ExtractSkip. -func (mr *MockExtensionProcessorMockRecorder) ExtractSkip(arg0 interface{}) *gomock.Call { +// WSBroadcast indicates an expected call of WSBroadcast. +func (mr *MockConsumerBusMockRecorder) WSBroadcast(address, payload interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractSkip", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractSkip), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WSBroadcast", reflect.TypeOf((*MockConsumerBus)(nil).WSBroadcast), address, payload) } diff --git a/openspec/specs/asyncapi-management/spec.md b/openspec/specs/asyncapi-management/spec.md index 248e8a8..4cd367f 100644 --- a/openspec/specs/asyncapi-management/spec.md +++ b/openspec/specs/asyncapi-management/spec.md @@ -100,3 +100,11 @@ The mock server SHALL expose a management endpoint to fire a named event ad-hoc, - **WHEN** an ad-hoc fired event payload contains `{$state.*}` or `{$env.*}` expressions - **THEN** they are evaluated against the schema's isolated state namespace and environment before delivery +#### Scenario RS.AMG.29: Management stream reaps idle subscribers +- **WHEN** a connected `/_mock/stream` subscriber stops sending frames past the read-idle bound +- **THEN** the subscriber is removed from the stream registry and its handler goroutine returns + +#### Scenario RS.AMG.30: Shutdown cancels pending delayed deliveries +- **WHEN** the server shuts down with in-flight delayed pushes/events pending +- **THEN** no delayed delivery occurs after shutdown (pending timers are cancelled) + diff --git a/openspec/specs/asyncapi-protocols/spec.md b/openspec/specs/asyncapi-protocols/spec.md index 1776ec6..dd2d81b 100644 --- a/openspec/specs/asyncapi-protocols/spec.md +++ b/openspec/specs/asyncapi-protocols/spec.md @@ -55,3 +55,7 @@ For a send operation without an explicit reply message, the server SHALL acknowl - **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 +#### Scenario RS.ASP.11: Parameterized channel addresses resolve +- **WHEN** an AsyncAPI ws/http channel address contains a `{param}` placeholder (e.g. `/users/{id}/events`) +- **THEN** the route is registered so chi captures the segment and `{$channel.}` / `addressParams` resolve instead of silently evaluating to nothing + diff --git a/openspec/specs/json-rpc/spec.md b/openspec/specs/json-rpc/spec.md index 8dacb00..9020054 100644 --- a/openspec/specs/json-rpc/spec.md +++ b/openspec/specs/json-rpc/spec.md @@ -105,7 +105,11 @@ The mock server SHALL process batch JSON-RPC calls (array body) and return an ar #### Scenario RS.JRP.22: All-notification batch - **WHEN** all calls in a batch are notifications (no id) -- **THEN** the response body is an empty JSON array `[]` +- **THEN** the server answers HTTP 204 No Content (a response is not returned for notifications per JSON-RPC 2.0 §6) + +#### Scenario RS.JRP.33: Batch with a malformed element +- **WHEN** a batch contains one element that is not an object, or is missing the jsonrpc/method field +- **THEN** the valid elements are processed normally and the malformed element yields a `-32600` error slot in its position, without aborting the rest of the batch ### Requirement: JSON-RPC notification handling The mock server SHALL process notification calls (no `id`) for side effects without returning a response entry. @@ -129,6 +133,10 @@ The mock server SHALL evaluate `{$request.body.*}` against the individual call o - **WHEN** a batch includes two calls with different params objects - **THEN** each call's `x-mock-params-match` conditions evaluate against its own params object, not the batch array +#### Scenario RS.JRP.34: Procedure path parameters resolve through the gateway +- **WHEN** a procedure is backed by a route such as `/rpc/users/{id}` and the request URL is `/rpc/users/123` +- **THEN** `{$request.path.id}` resolves to `123` (the params are extracted against the procedure's own route pattern, not the gateway route) + ### Requirement: Extension compatibility All existing `x-mock-*` extensions SHALL work identically for JSON-RPC calls as for HTTP requests. diff --git a/openspec/specs/management-api/spec.md b/openspec/specs/management-api/spec.md index 139227a..190a63b 100644 --- a/openspec/specs/management-api/spec.md +++ b/openspec/specs/management-api/spec.md @@ -28,6 +28,10 @@ The mock server SHALL provide `POST /_mock/examples` to add a custom mock exampl - **WHEN** the request includes `validate: false` - **THEN** the server does not validate the example data against the OpenAPI schema +#### Scenario RS.MAPI.34: Response body validation default +- **WHEN** `validate` is omitted (defaults to true) and the response body does not match the route's OpenAPI schema +- **THEN** the server responds with HTTP 400 + #### Scenario RS.MAPI.6: Invalid request body - **WHEN** the request body is missing required fields or malformed - **THEN** the server responds with HTTP 400 diff --git a/openspec/specs/signalr-hub-runtime/spec.md b/openspec/specs/signalr-hub-runtime/spec.md index a088cf4..61c1baa 100644 --- a/openspec/specs/signalr-hub-runtime/spec.md +++ b/openspec/specs/signalr-hub-runtime/spec.md @@ -45,7 +45,7 @@ For the hub path, the server SHALL expose `POST {hubPath}/negotiate` returning s #### 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 +- **THEN** the server responds 200 with `connectionToken`, `connectionId`, `negotiateVersion: 1`, and `availableTransports` listing WebSockets with the Text transfer format (Binary is not offered because the handshake rejects binary frames, RS.SHR.15) #### Scenario RS.SHR.9: Negotiate protocol version - **WHEN** a client requests negotiation without `negotiateVersion` (treated as 0) @@ -117,3 +117,7 @@ The server SHALL keep an open-stream registry per connection so event-driven mes - **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 +#### Scenario RS.SHR.22: Delivery deduplicates per connection +- **WHEN** a connection has N open streams on a channel and a payload is delivered +- **THEN** the payload reaches that connection's N streams exactly once each (never N×N): delivery candidates are unique per connection even when several streams are open + diff --git a/scripts/check_test_headers.py b/scripts/check_test_headers.py new file mode 100644 index 0000000..7da53eb --- /dev/null +++ b/scripts/check_test_headers.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Enforce the unit-test comment convention from docs/project.md: every test +function (func Test* / func Benchmark*) in the unit-test tree (internal/ and +cmd/) MUST be preceded by a Gherkin scenario header (a /**/ block containing a +"Scenario:" line). + +Exit code 1 when any unit test function lacks a header. Integration tests +(test/) and generated mocks (mock/, *_mock.go) are outside the unit-test tree +and skipped. Files under mock/ and generated files are also skipped. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +TEST_FN = re.compile(r"^func\s+(Test\w+|Benchmark\w+)\s*\(") + + +def test_files(): + for base in (ROOT / "internal", ROOT / "cmd"): + for p in base.rglob("*_test.go"): + yield p + + +def missing_headers() -> list: + missing = [] + for path in sorted(test_files()): + if "_mock" in path.name or "mock" in str(path).split("/internal")[0]: + pass + if "mock" in path.parts: + continue + content = path.read_text() + lines = content.splitlines() + for i, line in enumerate(lines): + m = TEST_FN.match(line.strip()) + if not m: + continue + # Scan backward from the line above the function up to the + # previous blank-line-separated comment block, looking for the + # Gherkin marker. 24 lines covers long race-regression headers. + start = max(0, i - 24) + header = "\n".join(lines[start:i]) + if "Scenario:" not in header: + missing.append( + f"{path.relative_to(ROOT)}:{i + 1}: {m.group(1)} lacks a " + f"Scenario: header (docs/project.md testing standards)" + ) + return missing + + +def main() -> int: + missing = missing_headers() + for entry in missing: + print(entry, file=sys.stderr) + if missing: + print( + f"\n{len(missing)} unit test function(s) miss a Gherkin header.", + file=sys.stderr, + ) + return 1 + print("All unit test functions carry a Gherkin Scenario: header.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/cli/cli_integration_test.go b/test/cli/cli_integration_test.go index 11d969d..c88f24f 100644 --- a/test/cli/cli_integration_test.go +++ b/test/cli/cli_integration_test.go @@ -22,6 +22,7 @@ import ( ) func binaryPath(t *testing.T) string { + t.Helper() return binhelper.GetBuilded(t) } @@ -263,7 +264,6 @@ func TestCLISuccessfulExitCode(t *testing.T) { if exitErr.ExitCode() != 0 { t.Logf("process exited with code %d: %v", exitErr.ExitCode(), err) } - } else { t.Logf("wait error: %v", err) } diff --git a/test/extensions/runtime_expressions_test.go b/test/extensions/runtime_expressions_test.go index 2356d28..7bd9e45 100644 --- a/test/extensions/runtime_expressions_test.go +++ b/test/extensions/runtime_expressions_test.go @@ -45,7 +45,7 @@ func TestRuntimeExpressionPathParam(t *testing.T) { defer builtinResp.Body.Close() //nolint:errcheck t.Logf("Built-in route test: status=%d", builtinResp.StatusCode) // Accept either 200 (example exists) or 501 (no example) but not 404 - if builtinResp.StatusCode == 404 { + if builtinResp.StatusCode == http.StatusNotFound { t.Fatal("Route /users/:id not found - route not registered") } @@ -148,7 +148,7 @@ func TestRuntimeExpressionRequestHeader(t *testing.T) { // Make request with Content-Type header client := &http.Client{} - req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/echo", port), strings.NewReader(`{}`)) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:%d/echo", port), strings.NewReader(`{}`)) require.NoError(t, err, "failed to create request") req.Header.Set("Content-Type", "application/json") resp2, err := client.Do(req) @@ -223,7 +223,7 @@ func TestRuntimeExpressionRequestBody(t *testing.T) { // Make request with JSON body containing field client := &http.Client{} reqBody := strings.NewReader(`{"field": "expected"}`) - req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/body", port), reqBody) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:%d/body", port), reqBody) require.NoError(t, err, "failed to create request") req.Header.Set("Content-Type", "application/json") resp2, err := client.Do(req) diff --git a/test/management-api/management_api_test.go b/test/management-api/management_api_test.go index 5d2efab..18dba65 100644 --- a/test/management-api/management_api_test.go +++ b/test/management-api/management_api_test.go @@ -401,7 +401,7 @@ func TestManagementAPITTLExpiration(t *testing.T) { return false } defer req.Body.Close() //nolint:errcheck - return req.StatusCode == 501 + return req.StatusCode == http.StatusNotImplemented }, 4*time.Second, 100*time.Millisecond, "expected status 501 after TTL expiry") // Check for any errors from the server process diff --git a/test/server-core/server_integration_test.go b/test/server-core/server_integration_test.go index d6314c8..aa0b08d 100644 --- a/test/server-core/server_integration_test.go +++ b/test/server-core/server_integration_test.go @@ -468,7 +468,7 @@ func TestServerCORSHeadersPresent(t *testing.T) { } // Make a request with Origin header to trigger CORS - req, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%d/users", port), nil) + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/users", port), nil) require.NoError(t, err, "failed to create request") req.Header.Set("Origin", "http://example.com") resp, err := http.DefaultClient.Do(req) From b3de8098008835b0ede1aade35be9bb0403c263b Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Sat, 5 Sep 2026 20:22:33 +0300 Subject: [PATCH 2/2] Bump golangci-lint-action to v7 for golangci-lint v2 support --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c2f628..598656a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: run: make coverage-unit - name: Run linter - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: # Pinned to a v2 release matching the .golangci.yml schema in use. version: v2.13.2