diff --git a/docs/architecture.md b/docs/architecture.md index 2b64240..306db0d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -999,4 +999,6 @@ OASMock follows a clean, modular architecture with clear separation of concerns. 4. **Performance**: Efficient algorithms and memory management 5. **Standards Compliance**: Full OpenAPI 3.0 support with extensions -The architecture supports both static OpenAPI-based mocking and dynamic runtime behavior through extensions and the management API. \ No newline at end of file +The architecture supports both static OpenAPI-based mocking and dynamic runtime behavior through extensions and the management API. + +The server also supports protocol-level routing via `x-rpc`: a single gateway endpoint dispatches requests by procedure name (extracted from the request body), reusing the same example selection, expression evaluation, and extension processing pipeline. See [JSON-RPC Documentation](json-rpc.md) for details. \ No newline at end of file diff --git a/docs/extensions.md b/docs/extensions.md index 410d468..0a8822f 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -120,3 +120,25 @@ Value modifiers can be specified after a `|` sign. Example: `{$request.path.para | `$state.someSavedParam` | State data (set previously with `x-mock-set-state`) | `param saved to state` | | `$env.ENV_VAR` | Runtime environment variables | `value from env` | +## x-rpc + +**Location**: OpenAPI document root + +**Purpose**: Configures an RPC gateway endpoint for routing calls by body field instead of URL path. Currently supports JSON-RPC 2.0. + +**Example**: +```yaml +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +``` + +When `x-rpc` is present, all POST operations under the gateway path are treated as RPC procedures. The procedure name is derived from the operation's `operationId`. Requests are dispatched by matching the `method` field in the JSON-RPC request body against the procedure name. + +For JSON-RPC contexts, `{$request.body.id}`, `{$request.body.method}`, and `{$request.body.params.*}` expressions evaluate against the individual call object (not the batch array), enabling per-call resolution in batch requests. + +See [JSON-RPC Documentation](json-rpc.md) for full details. + diff --git a/docs/json-rpc.md b/docs/json-rpc.md new file mode 100644 index 0000000..bce2c5f --- /dev/null +++ b/docs/json-rpc.md @@ -0,0 +1,124 @@ +# JSON-RPC 2.0 Support + +OASMock supports JSON-RPC 2.0 via the `x-rpc` root-level OpenAPI extension. All JSON-RPC calls are routed to a single gateway endpoint and dispatched by the procedure name (the `method` field in the request body). + +## x-rpc Extension + +Add the `x-rpc` extension at the document root of your OpenAPI spec: + +```yaml +openapi: "3.0.3" +x-rpc: + gateway: /rpc + protocolType: json-rpc + contentType: application/json # optional, defaults per protocol + procedure: + call: method # body property containing the procedure name + match: post.operationId # spec operation field to match against +``` + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `gateway` | Yes | Path prefix for the RPC endpoint. All RPC operations should be defined under this path. | +| `protocolType` | Yes | RPC protocol to use. Currently only `json-rpc` is supported. | +| `contentType` | No | Content type for responses. Defaults to `application/json` for JSON-RPC. | +| `procedure.call` | Yes | Dot-separated path in the request body to extract the procedure name. For JSON-RPC, this is `method`. | +| `procedure.match` | Yes | How procedure names are derived from spec operations. Format: `{httpMethod}.{field}`. Only `post.operationId` is currently supported. | + +## Defining Procedures + +Procedures are defined as POST operations under the gateway path. The procedure name is derived from the operation's `operationId`: + +```yaml +paths: + /rpc/subtract: + post: + operationId: subtract + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: "{$request.body.params.a} - {$request.body.params.b}" + id: "{$request.body.id}" +``` + +## Runtime Expressions in RPC Context + +All standard runtime expressions work within RPC examples. The `{$request.body.*}` source references the **individual call object** (not the batch array), enabling per-call expression resolution: + +- `{$request.body.id}` — the request id +- `{$request.body.method}` — the procedure name +- `{$request.body.params.*}` — call parameters (supports nested paths) +- `{$request.body.jsonrpc}` — protocol version + +## Batch Support + +JSON-RPC batch requests (array body) are supported. Each call is processed individually through the example selection pipeline, and responses are collected into an array: + +**Request:** +```json +[ + {"jsonrpc":"2.0","method":"subtract","params":{"a":10,"b":3},"id":1}, + {"jsonrpc":"2.0","method":"add","params":{"a":1,"b":2},"id":2} +] +``` + +**Response:** +```json +[ + {"jsonrpc":"2.0","result":"10 - 3","id":1}, + {"jsonrpc":"2.0","result":"1 + 2 = sum","id":2} +] +``` + +## Notifications + +JSON-RPC notifications (calls without an `id` field) are processed through the pipeline to trigger side effects (`x-mock-set-state`, history recording), but generate no response entry: + +- Single notification → HTTP 204 No Content +- Notification in a batch → excluded from the response array +- All-notification batch → empty JSON array `[]` + +## Error Responses + +Standard JSON-RPC 2.0 error codes are returned for protocol-level errors: + +| Code | Message | Condition | +|------|---------|-----------| +| -32700 | Parse error | Request body is not valid JSON | +| -32600 | Invalid Request | Missing or invalid `jsonrpc`/`method` fields | +| -32601 | Method not found | Procedure name not found in the operation map | +| -32603 | Internal error | Pipeline execution error | + +## Coexistence with HTTP Routes + +A single OpenAPI spec can contain both RPC procedures and normal HTTP routes. Paths under the gateway are served by the RPC handler; all other paths are served by the normal HTTP handler. + +## CLI Usage + +Start the server with a spec containing `x-rpc`: + +```bash +oasmock mock --schema spec.yaml +``` + +With a schema prefix: + +```bash +oasmock mock --schema spec.yaml --prefix /api +# Gateway available at POST /api/rpc +``` + +All existing flags (`--port`, `--delay`, `--verbose`, `--cors`, `--history-size`, `--control-api`) work identically with RPC-enabled specs. diff --git a/internal/loader/rpc.go b/internal/loader/rpc.go new file mode 100644 index 0000000..bb84adc --- /dev/null +++ b/internal/loader/rpc.go @@ -0,0 +1,132 @@ +package loader + +import ( + "fmt" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +const ( + ProtocolTypeJsonRpc = "json-rpc" +) + +var supportedProtocols = map[string]bool{ + ProtocolTypeJsonRpc: true, +} + +func ParseRpcConfig(spec *openapi3.T) (*RpcConfig, error) { + ext := spec.Extensions["x-rpc"] + if ext == nil { + return nil, nil + } + + extMap, ok := ext.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("x-rpc must be a map") + } + + cfg := &RpcConfig{} + + if v, ok := extMap["gateway"].(string); ok { + cfg.Gateway = v + } else { + return nil, fmt.Errorf("x-rpc.gateway is required") + } + + if v, ok := extMap["protocolType"].(string); ok { + if !supportedProtocols[v] { + return nil, fmt.Errorf("unsupported protocolType %q", v) + } + cfg.ProtocolType = v + } else { + return nil, fmt.Errorf("x-rpc.protocolType is required") + } + + if v, ok := extMap["contentType"].(string); ok { + cfg.ContentType = v + } else if cfg.ProtocolType == ProtocolTypeJsonRpc { + cfg.ContentType = "application/json" + } + + procRaw, ok := extMap["procedure"] + if !ok { + return nil, fmt.Errorf("x-rpc.procedure is required") + } + procMap, ok := procRaw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("x-rpc.procedure must be a map") + } + + if v, ok := procMap["call"].(string); ok { + cfg.Procedure.Call = v + } + if v, ok := procMap["match"].(string); ok { + cfg.Procedure.Match = v + } + + return cfg, nil +} + +func BuildRpcMappings(infos []SchemaInfo, cfg *RpcConfig) ([]*RpcRouteMapping, error) { + if cfg == nil { + return nil, nil + } + + seen := make(map[string]bool) + var mappings []*RpcRouteMapping + + for _, info := range infos { + spec := info.Spec + prefix := info.Prefix + + pathMap := spec.Paths.Map() + for path, pathItem := range pathMap { + if pathItem == nil { + continue + } + + fullPath := applyPrefix(prefix, path) + + if !isUnderGateway(fullPath, cfg.Gateway, prefix) { + continue + } + + if pathItem.Post == nil { + continue + } + + if pathItem.Post.OperationID == "" { + continue + } + + procedureName := pathItem.Post.OperationID + if seen[procedureName] { + return nil, fmt.Errorf("duplicate procedure name %q under gateway", procedureName) + } + seen[procedureName] = true + + mapping := &RpcRouteMapping{ + Procedure: procedureName, + RouteMapping: RouteMapping{ + Method: "POST", + Path: fullPath, + Pattern: path, + Prefix: prefix, + ChiPattern: OpenAPIPatternToChi(fullPath), + Operation: pathItem.Post, + Parameters: pathItem.Parameters, + Responses: pathItem.Post.Responses, + }, + } + mappings = append(mappings, mapping) + } + } + + return mappings, nil +} + +func isUnderGateway(path, gateway, prefix string) bool { + gwPath := applyPrefix(prefix, gateway) + return path == gwPath || strings.HasPrefix(path, gwPath+"/") +} diff --git a/internal/loader/rpc_config.go b/internal/loader/rpc_config.go new file mode 100644 index 0000000..5c81ab8 --- /dev/null +++ b/internal/loader/rpc_config.go @@ -0,0 +1,18 @@ +package loader + +type ProcedureConfig struct { + Call string `json:"call"` + Match string `json:"match"` +} + +type RpcConfig struct { + Gateway string `json:"gateway"` + ProtocolType string `json:"protocolType"` + ContentType string `json:"contentType"` + Procedure ProcedureConfig `json:"procedure"` +} + +type RpcRouteMapping struct { + Procedure string + RouteMapping +} diff --git a/internal/loader/rpc_test.go b/internal/loader/rpc_test.go new file mode 100644 index 0000000..15a19dd --- /dev/null +++ b/internal/loader/rpc_test.go @@ -0,0 +1,532 @@ +package loader + +import ( + "fmt" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func specWithRpcExt(xrpc string) *openapi3.T { + yaml := xrpcSpecBase + "\n" + xrpc + return mustLoad([]byte(yaml)) +} + +const xrpcSpecBase = `openapi: "3.0.3" +info: + title: Test + version: "1.0" +paths: + /rpc/subtract: + post: + operationId: subtract + responses: + "200": + description: OK + /rpc/add: + post: + operationId: add + responses: + "200": + description: OK +` + +func specFromYAML(yaml string) *openapi3.T { + return mustLoad([]byte(yaml)) +} + +func mustLoad(data []byte) *openapi3.T { + loader := openapi3.NewLoader() + spec, err := loader.LoadFromData(data) + if err != nil { + panic(fmt.Sprintf("failed to load spec: %v", err)) + } + return spec +} + +/* +Scenario: ParseRpcConfig with valid full config returns correctly parsed struct +Given an OpenAPI spec with x-rpc containing all optional fields +When ParseRpcConfig is called +Then it returns a populated RpcConfig with all fields correctly parsed + +Related spec scenarios: RS.JRP.1 +*/ +func TestParseRpcConfig_Valid(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt(` +x-rpc: + gateway: /rpc + protocolType: json-rpc + contentType: application/json + procedure: + call: method + match: post.operationId +`) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + assert.Equal(t, "/rpc", cfg.Gateway) + assert.Equal(t, "json-rpc", cfg.ProtocolType) + assert.Equal(t, "application/json", cfg.ContentType) + assert.Equal(t, "method", cfg.Procedure.Call) + assert.Equal(t, "post.operationId", cfg.Procedure.Match) +} + +/* +Scenario: ParseRpcConfig with missing gateway returns error +Given an OpenAPI spec with x-rpc but no gateway field +When ParseRpcConfig is called +Then it returns an error + +Related spec scenarios: RS.JRP.3 +*/ +func TestParseRpcConfig_MissingGateway(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt(` +x-rpc: + protocolType: json-rpc + procedure: + call: method + match: post.operationId +`) + + _, err := ParseRpcConfig(spec) + assert.Error(t, err) + assert.Contains(t, err.Error(), "gateway") +} + +/* +Scenario: ParseRpcConfig with unsupported protocolType returns error +Given an OpenAPI spec with x-rpc specifying an unsupported protocol type +When ParseRpcConfig is called +Then it returns an error + +Related spec scenarios: RS.JRP.5 +*/ +func TestParseRpcConfig_UnsupportedProtocol(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt(` +x-rpc: + gateway: /rpc + protocolType: xml-rpc + procedure: + call: method + match: post.operationId +`) + + _, err := ParseRpcConfig(spec) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} + +/* +Scenario: ParseRpcConfig with missing procedure field returns error +Given an OpenAPI spec with x-rpc but no procedure field +When ParseRpcConfig is called +Then it returns an error + +Related spec scenarios: RS.JRP.4 +*/ +func TestParseRpcConfig_MissingProcedure(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt(` +x-rpc: + gateway: /rpc + protocolType: json-rpc +`) + + _, err := ParseRpcConfig(spec) + assert.Error(t, err) + assert.Contains(t, err.Error(), "procedure") +} + +/* +Scenario: ParseRpcConfig uses default contentType for json-rpc when not specified +Given an OpenAPI spec with x-rpc but no contentType field +When ParseRpcConfig is called +Then contentType defaults to "application/json" + +Related spec scenarios: RS.JRP.1 +*/ +func TestParseRpcConfig_DefaultContentType(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt(` +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +`) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + assert.Equal(t, "application/json", cfg.ContentType) +} + +/* +Scenario: ParseRpcConfig returns nil when x-rpc extension is absent +Given an OpenAPI spec without x-rpc extension +When ParseRpcConfig is called +Then it returns nil, nil + +Related spec scenarios: RS.JRP.2 +*/ +func TestParseRpcConfig_NoExtension(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt("") // no x-rpc in base spec + + cfg, err := ParseRpcConfig(spec) + assert.NoError(t, err) + assert.Nil(t, cfg) +} + +/* +Scenario: ParseRpcConfig with malformed x-rpc (not a map) returns error +Given an OpenAPI spec where x-rpc is a scalar value instead of a map +When ParseRpcConfig is called +Then it returns an error + +Related spec scenarios: RS.JRP.1 +*/ +func TestParseRpcConfig_Malformed(t *testing.T) { + t.Parallel() + + yaml := xrpcSpecBase + ` +x-rpc: "invalid" +` + spec := specFromYAML(yaml) + + _, err := ParseRpcConfig(spec) + assert.Error(t, err) +} + +/* +Scenario: BuildRpcMappings maps paths under gateway with POST by operationId +Given schema infos with gateway /rpc and POST operations with operationIds +When BuildRpcMappings is called +Then it returns RpcRouteMappings keyed by operationId + +Related spec scenarios: RS.JRP.6 +*/ +func TestBuildRpcMappings_ProceduresUnderGateway(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt(` +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +`) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + + infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + mappings, err := BuildRpcMappings(infos, cfg) + require.NoError(t, err) + + procMap := make(map[string]string) + for _, m := range mappings { + procMap[m.Procedure] = m.Path + } + + assert.Equal(t, "/rpc/subtract", procMap["subtract"]) + assert.Equal(t, "/rpc/add", procMap["add"]) + assert.Len(t, mappings, 2) +} + +/* +Scenario: BuildRpcMappings excludes paths not under gateway +Given schema infos with gateway /rpc and paths outside /rpc +When BuildRpcMappings is called +Then paths outside gateway are not in RPC mappings + +Related spec scenarios: RS.JRP.7 +*/ +func TestBuildRpcMappings_PathsNotUnderGateway(t *testing.T) { + t.Parallel() + + yaml := `openapi: "3.0.3" +info: + title: Test + version: "1.0" +paths: + /rpc/subtract: + post: + operationId: subtract + responses: + "200": + description: OK + /users: + post: + operationId: createUser + responses: + "200": + description: OK + get: + operationId: listUsers + responses: + "200": + description: OK +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +` + spec := specFromYAML(yaml) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + + infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + mappings, err := BuildRpcMappings(infos, cfg) + require.NoError(t, err) + + procedureNames := make(map[string]bool) + for _, m := range mappings { + procedureNames[m.Procedure] = true + } + + assert.True(t, procedureNames["subtract"]) + assert.False(t, procedureNames["createUser"], "non-gateway operations should be excluded") + assert.False(t, procedureNames["listUsers"], "non-gateway GET should be excluded") + assert.Len(t, mappings, 1) +} + +/* +Scenario: BuildRpcMappings excludes non-POST operations under gateway +Given schema infos with gateway /rpc and a GET operation under it +When BuildRpcMappings is called +Then GET operations are excluded from RPC mappings + +Related spec scenarios: RS.JRP.6 +*/ +func TestBuildRpcMappings_NonPostExcluded(t *testing.T) { + t.Parallel() + + yaml := `openapi: "3.0.3" +info: + title: Test + version: "1.0" +paths: + /rpc/subtract: + get: + operationId: getSubtract + responses: + "200": + description: OK + post: + operationId: subtract + responses: + "200": + description: OK +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +` + spec := specFromYAML(yaml) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + + infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + mappings, err := BuildRpcMappings(infos, cfg) + require.NoError(t, err) + + assert.Len(t, mappings, 1) + assert.Equal(t, "subtract", mappings[0].Procedure) +} + +/* +Scenario: BuildRpcMappings returns error on duplicate operationId under gateway +Given schema infos with two POST operations under gateway sharing the same operationId +When BuildRpcMappings is called +Then it returns an error + +Related spec scenarios: RS.JRP.8 +*/ +func TestBuildRpcMappings_DuplicateOperationId(t *testing.T) { + t.Parallel() + + yaml := `openapi: "3.0.3" +info: + title: Test + version: "1.0" +paths: + /rpc/subtract: + post: + operationId: sub + responses: + "200": + description: OK + /rpc/minus: + post: + operationId: sub + responses: + "200": + description: OK +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +` + spec := specFromYAML(yaml) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + + infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + _, err = BuildRpcMappings(infos, cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") +} + +/* +Scenario: BuildRpcMappings returns empty when no POST operations under gateway +Given schema infos with gateway /rpc but only GET operations under it +When BuildRpcMappings is called +Then it returns no mappings and no error + +Related spec scenarios: RS.JRP.9 +*/ +func TestBuildRpcMappings_NoPostUnderGateway(t *testing.T) { + t.Parallel() + + yaml := `openapi: "3.0.3" +info: + title: Test + version: "1.0" +paths: + /rpc/status: + get: + operationId: status + responses: + "200": + description: OK +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +` + spec := specFromYAML(yaml) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + + infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + mappings, err := BuildRpcMappings(infos, cfg) + require.NoError(t, err) + assert.Empty(t, mappings) +} + +/* +Scenario: BuildRpcMappings applies schema prefix to gateway path +Given schema infos with prefix /api and gateway /rpc +When BuildRpcMappings is called +Then gateway paths include the prefix + +Related spec scenarios: RS.JRP.32 +*/ +func TestBuildRpcMappings_WithPrefix(t *testing.T) { + t.Parallel() + + spec := specWithRpcExt(` +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +`) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + + infos := []SchemaInfo{{Spec: spec, Prefix: "/api"}} + mappings, err := BuildRpcMappings(infos, cfg) + require.NoError(t, err) + + for _, m := range mappings { + assert.Contains(t, m.Path, "/api/") + } +} + +/* +Scenario: Coexistence of RPC and normal HTTP route mappings +Given a spec with both RPC gateway and non-RPC paths +When both BuildRpcMappings and BuildRouteMappings are called +Then RPC mappings contain only gateway operations and RouteMappings contain all operations + +Related spec scenarios: RS.JRP.31 +*/ +func TestBuildRpcMappings_Coexistence(t *testing.T) { + t.Parallel() + + yaml := `openapi: "3.0.3" +info: + title: Test + version: "1.0" +paths: + /rpc/subtract: + post: + operationId: subtract + responses: + "200": + description: OK + /rpc/add: + post: + operationId: add + responses: + "200": + description: OK + /users: + get: + operationId: listUsers + responses: + "200": + description: OK +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +` + spec := specFromYAML(yaml) + + cfg, err := ParseRpcConfig(spec) + require.NoError(t, err) + + infos := []SchemaInfo{{Spec: spec, Prefix: ""}} + + // Regular route mappings include everything + routeMappings, err := BuildRouteMappings(infos) + require.NoError(t, err) + assert.Len(t, routeMappings, 3) // GET /users, POST /rpc/subtract, POST /rpc/add + + // RPC mappings include only gateway POST ops + rpcMappings, err := BuildRpcMappings(infos, cfg) + require.NoError(t, err) + assert.Len(t, rpcMappings, 2) // subtract, add +} diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index b4b30a0..9bd1279 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -1,6 +1,6 @@ package server -//go:generate mockgen -destination=interfaces_mock_test.go -package=server . RouteProvider,StateStore,HistoryStore,DataSource,RequestSourceFactory,StateSourceFactory,EnvSourceFactory,ExpressionEvaluator,ExtensionProcessor +//go:generate mockgen -destination=interfaces_mock_test.go -package=server . RouteProvider,StateStore,HistoryStore,DataSource,RequestSourceFactory,StateSourceFactory,EnvSourceFactory,ExpressionEvaluator,ExtensionProcessor,RpcProtocol import ( "net/http" @@ -135,6 +135,21 @@ type ExtensionProcessor interface { ExtractHeaders(example *openapi3.Example) (map[string]any, bool) } +// RpcProtocol parses RPC request bodies and formats error responses. +type RpcProtocol interface { + ParseBody(body []byte) ([]RpcCall, error) + ErrorResponse(code int, message string, id any) []byte + ContentType() string +} + +// RpcCall represents a single parsed RPC call. +type RpcCall struct { + Procedure string + Raw any + ID any + HasID bool +} + // Dependencies holds all dependencies for the Server. type Dependencies struct { RouteProvider RouteProvider diff --git a/internal/server/interfaces_mock_test.go b/internal/server/interfaces_mock_test.go index ec46344..b28246e 100644 --- a/internal/server/interfaces_mock_test.go +++ b/internal/server/interfaces_mock_test.go @@ -1,5 +1,5 @@ // 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: github.com/mamonth/oasmock/internal/server (interfaces: RouteProvider,StateStore,HistoryStore,DataSource,RequestSourceFactory,StateSourceFactory,EnvSourceFactory,ExpressionEvaluator,ExtensionProcessor,RpcProtocol) // Package server is a generated GoMock package. package server @@ -553,3 +553,69 @@ func (mr *MockExtensionProcessorMockRecorder) ExtractSkip(arg0 interface{}) *gom mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractSkip", reflect.TypeOf((*MockExtensionProcessor)(nil).ExtractSkip), arg0) } + +// 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 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ContentType") + ret0, _ := ret[0].(string) + return ret0 +} + +// 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, "ContentType", reflect.TypeOf((*MockRpcProtocol)(nil).ContentType)) +} + +// ErrorResponse mocks base method. +func (m *MockRpcProtocol) ErrorResponse(arg0 int, arg1 string, arg2 interface{}) []byte { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ErrorResponse", arg0, arg1, arg2) + ret0, _ := ret[0].([]byte) + return ret0 +} + +// ErrorResponse indicates an expected call of ErrorResponse. +func (mr *MockRpcProtocolMockRecorder) ErrorResponse(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ErrorResponse", reflect.TypeOf((*MockRpcProtocol)(nil).ErrorResponse), arg0, arg1, arg2) +} + +// ParseBody mocks base method. +func (m *MockRpcProtocol) ParseBody(arg0 []byte) ([]RpcCall, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ParseBody", arg0) + ret0, _ := ret[0].([]RpcCall) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ParseBody indicates an expected call of ParseBody. +func (mr *MockRpcProtocolMockRecorder) ParseBody(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ParseBody", reflect.TypeOf((*MockRpcProtocol)(nil).ParseBody), arg0) +} diff --git a/internal/server/jsonrpc.go b/internal/server/jsonrpc.go new file mode 100644 index 0000000..d86a621 --- /dev/null +++ b/internal/server/jsonrpc.go @@ -0,0 +1,144 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/mamonth/oasmock/internal/loader" +) + +type RpcHandler struct { + protocol RpcProtocol + procedureMap map[string]*RouteMapping + server *Server +} + +func NewRpcHandler(protocol RpcProtocol, procedureMap map[string]*RouteMapping, server *Server) *RpcHandler { + return &RpcHandler{ + protocol: protocol, + procedureMap: procedureMap, + server: server, + } +} + +// writeBody writes the response body, logging a debug message on failure. +func writeBody(w http.ResponseWriter, body []byte) { + if _, err := w.Write(body); err != nil { + slog.Debug("Failed to write RPC response body", "err", err) + } +} + +func (h *RpcHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + if err != nil { + errBody := h.protocol.ErrorResponse(-32700, "Parse error", nil) + w.Header().Set("Content-Type", h.protocol.ContentType()) + w.WriteHeader(http.StatusOK) + writeBody(w, errBody) + return + } + + isBatch := isBatchRequest(bodyBytes) + + calls, err := h.protocol.ParseBody(bodyBytes) + if err != nil { + errBody := h.protocol.ErrorResponse(-32700, "Parse error", 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)) + 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)) + } + 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 + } + + singleStatusCode = statusCode + singleHeaders = headers + results = append(results, json.RawMessage(body)) + } + + if isBatch { + // Batch: write array response + if len(results) == 0 { + w.Header().Set("Content-Type", h.protocol.ContentType()) + w.WriteHeader(http.StatusOK) + writeBody(w, []byte("[]")) + return + } + out, _ := json.Marshal(results) + w.Header().Set("Content-Type", h.protocol.ContentType()) + w.WriteHeader(http.StatusOK) + writeBody(w, out) + return + } + + // Single call + if len(calls) == 1 && !calls[0].HasID { + w.WriteHeader(http.StatusNoContent) + return + } + + if len(results) == 0 { + errBody := h.protocol.ErrorResponse(-32603, "Internal error", nil) + w.Header().Set("Content-Type", h.protocol.ContentType()) + w.WriteHeader(http.StatusOK) + writeBody(w, errBody) + return + } + + for k, v := range singleHeaders { + w.Header().Set(k, v) + } + w.Header().Set("Content-Type", h.protocol.ContentType()) + sc := parseStatusCode(singleStatusCode) + if sc <= 0 { + sc = http.StatusOK + } + w.WriteHeader(sc) + writeBody(w, results[0]) +} + +func isBatchRequest(body []byte) bool { + s := strings.TrimSpace(string(body)) + return len(s) > 0 && s[0] == '[' +} + +func newRpcProtocol(cfg *loader.RpcConfig) (RpcProtocol, error) { + switch cfg.ProtocolType { + case loader.ProtocolTypeJsonRpc: + return NewJsonRpcProtocol(cfg), nil + default: + return nil, strconv.ErrSyntax + } +} diff --git a/internal/server/jsonrpc_handler_test.go b/internal/server/jsonrpc_handler_test.go new file mode 100644 index 0000000..6a94a66 --- /dev/null +++ b/internal/server/jsonrpc_handler_test.go @@ -0,0 +1,488 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createResponsesWithExample() *openapi3.Responses { + const yamlSpec = ` +openapi: 3.0.3 +info: + title: Test API + version: 1.0.0 +paths: + /test: + get: + responses: + '200': + description: OK + content: + application/json: + examples: + default: + value: + message: "Hello, World!" +` + ldr := openapi3.NewLoader() + spec, err := ldr.LoadFromData([]byte(yamlSpec)) + if err != nil { + panic(err) + } + pathMap := spec.Paths.Map() + pathItem := pathMap["/test"] + op := pathItem.Get + if op == nil { + panic("GET operation not found") + } + return op.Responses +} + +func newRpcHandlerWithMocks(t *testing.T) (*RpcHandler, *MockRpcProtocol, *Server) { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + proto := NewMockRpcProtocol(ctrl) + + routeProvider := NewMockRouteProvider(ctrl) + routeProvider.EXPECT().BuildRouteMappings(gomock.Any()).Return([]RouteMapping{}, nil) + + stateStore := NewMockStateStore(ctrl) + stateStore.EXPECT().GetNamespace(gomock.Any()).Return(nil).AnyTimes() + stateStore.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + stateStore.EXPECT().Get(gomock.Any(), gomock.Any()).Return(nil, false).AnyTimes() + + 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, + } + + server, err := NewWithDependencies(Config{Port: 0, HistorySize: 1000}, []SchemaInfo{}, deps, nil, nil) + require.NoError(t, err) + + handler := &RpcHandler{ + protocol: proto, + procedureMap: make(map[string]*RouteMapping), + server: server, + } + + return handler, proto, server +} + +/* +Scenario: RpcHandler serves single JSON-RPC call via pipeline +Given a handler with protocol that parses a single call and a procedure mapping +When ServeHTTP is called with a single-call body +Then the protocol parses, dispatches to correct procedure, and returns the example body + +Related spec scenarios: RS.JRP.17 +*/ +func TestRpcHandler_SingleCall(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc/subtract", + Pattern: "/rpc/subtract", + ChiPattern: "/rpc/subtract", + Responses: createResponsesWithExample(), + } + 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().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + var body map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "Hello, World!", body["message"]) +} + +/* +Scenario: RpcHandler returns method not found error +Given a handler with a procedure map that doesn't contain the requested method +When ServeHTTP is called +Then the protocol's ErrorResponse is called with -32601 and the error is written + +Related spec scenarios: RS.JRP.18 +*/ +func TestRpcHandler_MethodNotFound(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(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}, + }, nil) + proto.EXPECT().ErrorResponse(-32601, "Method not found", float64(2)).Return(errBody) + proto.EXPECT().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "2.0", body["jsonrpc"]) + assert.Equal(t, float64(-32601), body["error"].(map[string]interface{})["code"]) + assert.Equal(t, float64(2), body["id"]) +} + +/* +Scenario: RpcHandler returns parse error +Given a handler with a protocol that fails to parse the body +When ServeHTTP is called +Then a parse error is written without calling the pipeline + +Related spec scenarios: RS.JRP.12 +*/ +func TestRpcHandler_ParseError(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + errBody := []byte(`{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}`) + + proto.EXPECT().ParseBody(gomock.Any()).Return(nil, assert.AnError) + proto.EXPECT().ErrorResponse(-32700, "Parse error", nil).Return(errBody) + proto.EXPECT().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, float64(-32700), body["error"].(map[string]interface{})["code"]) + assert.Nil(t, body["id"]) +} + +/* +Scenario: RpcHandler processes batch and returns array response +Given a handler with protocol parsing 3 calls +When ServeHTTP is called with a batch +Then 3 pipeline calls are made with per-call bodies and an array response is written + +Related spec scenarios: RS.JRP.19 +*/ +func TestRpcHandler_Batch(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc", + Pattern: "/rpc", + ChiPattern: "/rpc", + Responses: createResponsesWithExample(), + } + handler.procedureMap["a"] = mapping + handler.procedureMap["b"] = mapping + handler.procedureMap["c"] = mapping + + callA := RpcCall{Procedure: "a", Raw: map[string]interface{}{"jsonrpc": "2.0", "method": "a", "id": float64(1)}, ID: float64(1), HasID: true} + 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().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`[{"jsonrpc":"2.0","method":"a","id":1}]`)) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body []map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Len(t, body, 3) +} + +/* +Scenario: RpcHandler batch with notification skips response entry +Given a handler with protocol parsing 2 calls (1 normal, 1 notification) +When ServeHTTP is called +Then the notification runs pipeline but is not in the response array + +Related spec scenarios: RS.JRP.21 +*/ +func TestRpcHandler_BatchWithNotification(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc", + Pattern: "/rpc", + ChiPattern: "/rpc", + Responses: createResponsesWithExample(), + } + handler.procedureMap["a"] = mapping + handler.procedureMap["notify"] = mapping + + 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().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`[{"jsonrpc":"2.0","method":"a","id":1}]`)) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body []map[string]interface{} + err := json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Len(t, body, 1) +} + +/* +Scenario: RpcHandler all-notification batch returns empty array +Given a handler with protocol parsing only notification calls +When ServeHTTP is called +Then an empty JSON array is written + +Related spec scenarios: RS.JRP.22 +*/ +func TestRpcHandler_AllNotifications(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc", + Pattern: "/rpc", + ChiPattern: "/rpc", + Responses: createResponsesWithExample(), + } + handler.procedureMap["n1"] = mapping + handler.procedureMap["n2"] = mapping + + 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") + + req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(`[{"jsonrpc":"2.0","method":"n1"}]`)) + w := httptest.NewRecorder() + + 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) +} + +/* +Scenario: RpcHandler per-call RequestSource Body is individual call object +Given a handler with protocol parsing calls in a batch +When ServeHTTP is called +Then each call's RequestSource Body is the individual call object, not the batch array + +Related spec scenarios: RS.JRP.25 +*/ +func TestRpcHandler_PerCallBody(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc/a", + Pattern: "/rpc/a", + ChiPattern: "/rpc/a", + Responses: createResponsesWithExample(), + } + handler.procedureMap["a"] = mapping + + 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().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +/* +Scenario: RpcHandler single notification returns 204 +Given a handler with protocol parsing a single notification +When ServeHTTP is called +Then the pipeline runs and HTTP 204 No Content is returned + +Related spec scenarios: RS.JRP.23 +*/ +func TestRpcHandler_SingleNotification(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc/notify", + Pattern: "/rpc/notify", + ChiPattern: "/rpc/notify", + Responses: createResponsesWithExample(), + } + handler.procedureMap["notify"] = mapping + + 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) + + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +/* +Scenario: RpcHandler returns response headers from example +Given a handler with a mapping that includes example headers +When ServeHTTP is called +Then the response headers are included in the HTTP response + +Related spec scenarios: RS.JRP.29 +*/ +func TestRpcHandler_ResponseHeaders(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc/h", + Pattern: "/rpc/h", + ChiPattern: "/rpc/h", + Responses: createResponsesWithExample(), + } + 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().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) +} + +/* +Scenario: RpcHandler propagates response status code from example +Given a handler with a mapping that returns a specific status code +When ServeHTTP is called +Then the HTTP response uses that status code + +Related spec scenarios: RS.JRP.17 +*/ +func TestRpcHandler_ResponseStatusCode(t *testing.T) { + t.Parallel() + + handler, proto, _ := newRpcHandlerWithMocks(t) + + mapping := &RouteMapping{ + Method: "POST", + Path: "/rpc/s", + Pattern: "/rpc/s", + ChiPattern: "/rpc/s", + Responses: createResponsesWithExample(), + } + 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().ContentType().Return("application/json") + + req := httptest.NewRequest(http.MethodPost, "/rpc", 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 new file mode 100644 index 0000000..47624b9 --- /dev/null +++ b/internal/server/jsonrpc_protocol.go @@ -0,0 +1,154 @@ +package server + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/mamonth/oasmock/internal/loader" +) + +type JsonRpcProtocol struct { + contentType string + callPath string +} + +func NewJsonRpcProtocol(cfg *loader.RpcConfig) *JsonRpcProtocol { + ct := cfg.ContentType + if ct == "" { + ct = "application/json" + } + return &JsonRpcProtocol{ + contentType: ct, + callPath: cfg.Procedure.Call, + } +} + +func (p *JsonRpcProtocol) ParseBody(body []byte) ([]RpcCall, error) { + var raw any + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse error: %w", err) + } + + switch v := raw.(type) { + case []interface{}: + return p.parseBatch(v) + case map[string]interface{}: + call, err := p.parseSingle(v) + if err != nil { + return nil, err + } + return []RpcCall{call}, nil + default: + return nil, fmt.Errorf("invalid request: body must be object or array") + } +} + +func (p *JsonRpcProtocol) parseBatch(items []interface{}) ([]RpcCall, error) { + calls := make([]RpcCall, 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") + } + call, err := p.parseSingle(obj) + if err != nil { + return nil, err + } + calls = append(calls, call) + } + return calls, 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") + } + if version != "2.0" { + return RpcCall{}, fmt.Errorf("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") + } + + procedureName, err := p.extractProcedureName(obj) + if err != nil { + return RpcCall{}, err + } + + id, hasID := obj["id"] + if !hasID { + return RpcCall{ + Procedure: procedureName, + Raw: obj, + ID: nil, + HasID: false, + }, nil + } + + if id == nil { + return RpcCall{ + Procedure: procedureName, + Raw: obj, + ID: nil, + HasID: false, + }, nil + } + + return RpcCall{ + Procedure: procedureName, + Raw: obj, + ID: id, + HasID: true, + }, nil +} + +func (p *JsonRpcProtocol) extractProcedureName(obj map[string]interface{}) (string, error) { + if p.callPath == "" { + method, _ := obj["method"].(string) + return method, nil + } + + parts := strings.Split(p.callPath, ".") + current := any(obj) + for _, part := range parts { + m, ok := current.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("cannot traverse path %q", p.callPath) + } + val, exists := m[part] + if !exists { + return "", fmt.Errorf("path %q not found in request", p.callPath) + } + current = val + } + + if s, ok := current.(string); ok { + return s, nil + } + return "", fmt.Errorf("value at path %q is not a string", p.callPath) +} + +func (p *JsonRpcProtocol) ErrorResponse(code int, message string, id any) []byte { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "error": map[string]interface{}{ + "code": code, + "message": message, + }, + } + if id != nil { + resp["id"] = id + } else { + resp["id"] = nil + } + data, _ := json.Marshal(resp) + return data +} + +func (p *JsonRpcProtocol) ContentType() string { + return p.contentType +} diff --git a/internal/server/jsonrpc_protocol_test.go b/internal/server/jsonrpc_protocol_test.go new file mode 100644 index 0000000..ca848fb --- /dev/null +++ b/internal/server/jsonrpc_protocol_test.go @@ -0,0 +1,324 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/mamonth/oasmock/internal/loader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProto(callPath string) *JsonRpcProtocol { + return &JsonRpcProtocol{ + contentType: "application/json", + callPath: callPath, + } +} + +/* +Scenario: JsonRpcProtocol.ParseBody parses a valid single call +Given a JsonRpcProtocol with default callPath "method" +When ParseBody is called with a valid JSON-RPC 2.0 single call +Then it returns a 1-element slice with correct Procedure, ID, and HasID=true + +Related spec scenarios: RS.JRP.10 +*/ +func TestJsonRpcProtocol_ParseBody_SingleCall(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"jsonrpc":"2.0","method":"subtract","params":{"a":10},"id":1}`) + + calls, err := proto.ParseBody(body) + require.NoError(t, err) + require.Len(t, calls, 1) + + assert.Equal(t, "subtract", calls[0].Procedure) + assert.Equal(t, float64(1), calls[0].ID) + assert.True(t, calls[0].HasID) +} + +/* +Scenario: JsonRpcProtocol.ParseBody parses a valid batch +Given a JsonRpcProtocol with default callPath "method" +When ParseBody is called with a batch array of 3 call objects +Then it returns a 3-element slice + +Related spec scenarios: RS.JRP.11 +*/ +func TestJsonRpcProtocol_ParseBody_Batch(t *testing.T) { + t.Parallel() + + 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) + require.NoError(t, err) + assert.Len(t, calls, 3) +} + +/* +Scenario: JsonRpcProtocol.ParseBody handles notification (no id) +Given a JsonRpcProtocol with default callPath "method" +When ParseBody is called with a notification (no id field) +Then it returns a call with HasID=false but still in the slice + +Related spec scenarios: RS.JRP.23 +*/ +func TestJsonRpcProtocol_ParseBody_Notification(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"jsonrpc":"2.0","method":"log","params":{"msg":"hello"}}`) + + calls, err := proto.ParseBody(body) + require.NoError(t, err) + require.Len(t, calls, 1) + + assert.False(t, calls[0].HasID) + assert.Nil(t, calls[0].ID) + assert.Equal(t, "log", calls[0].Procedure) +} + +/* +Scenario: JsonRpcProtocol.ParseBody handles null id +Given a JsonRpcProtocol with default callPath "method" +When ParseBody is called with id: null +Then HasID is false (per JSON-RPC 2.0 spec, null id means no response) + +Related spec scenarios: RS.JRP.23 +*/ +func TestJsonRpcProtocol_ParseBody_NullId(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"jsonrpc":"2.0","method":"notify","id":null}`) + + calls, err := proto.ParseBody(body) + require.NoError(t, err) + require.Len(t, calls, 1) + + assert.False(t, calls[0].HasID) + assert.Nil(t, calls[0].ID) +} + +/* +Scenario: JsonRpcProtocol.ParseBody returns error on invalid JSON +Given a JsonRpcProtocol +When ParseBody is called with invalid JSON +Then it returns an error + +Related spec scenarios: RS.JRP.12 +*/ +func TestJsonRpcProtocol_ParseBody_InvalidJSON(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`not json`) + + _, err := proto.ParseBody(body) + assert.Error(t, err) +} + +/* +Scenario: JsonRpcProtocol.ParseBody returns 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 + +Related spec scenarios: RS.JRP.13 +*/ +func TestJsonRpcProtocol_ParseBody_MissingJsonrpc(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"method":"sub","id":1}`) + + _, err := proto.ParseBody(body) + assert.Error(t, err) + assert.Contains(t, err.Error(), "jsonrpc") +} + +/* +Scenario: JsonRpcProtocol.ParseBody returns error on missing method +Given a JsonRpcProtocol +When ParseBody is called with jsonrpc: "2.0" but no method field +Then it returns an error + +Related spec scenarios: RS.JRP.14 +*/ +func TestJsonRpcProtocol_ParseBody_MissingMethod(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"jsonrpc":"2.0","id":1}`) + + _, err := proto.ParseBody(body) + assert.Error(t, err) + assert.Contains(t, err.Error(), "method") +} + +/* +Scenario: JsonRpcProtocol.ParseBody returns error on wrong jsonrpc version +Given a JsonRpcProtocol +When ParseBody is called with jsonrpc: "1.0" +Then it returns an error + +Related spec scenarios: RS.JRP.15 +*/ +func TestJsonRpcProtocol_ParseBody_WrongVersion(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`{"jsonrpc":"1.0","method":"sub","id":1}`) + + _, err := proto.ParseBody(body) + assert.Error(t, err) + assert.Contains(t, err.Error(), "version") +} + +/* +Scenario: JsonRpcProtocol.ParseBody handles empty batch array +Given a JsonRpcProtocol +When ParseBody is called with an empty JSON array +Then it returns an empty slice with no error + +Related spec scenarios: RS.JRP.11 +*/ +func TestJsonRpcProtocol_ParseBody_EmptyBatch(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + body := []byte(`[]`) + + calls, err := proto.ParseBody(body) + require.NoError(t, err) + assert.Empty(t, calls) +} + +/* +Scenario: JsonRpcProtocol.ParseBody uses configurable procedure.call path +Given a JsonRpcProtocol with callPath set to a custom field +When ParseBody is called with that field in the body +Then the procedure name is extracted from the custom field + +Related spec scenarios: RS.JRP.16 +*/ +func TestJsonRpcProtocol_ParseBody_CustomCallPath(t *testing.T) { + t.Parallel() + + proto := newTestProto("custom.proc") + body := []byte(`{"jsonrpc":"2.0","method":"ignore","custom":{"proc":"subtract"},"id":1}`) + + calls, err := proto.ParseBody(body) + require.NoError(t, err) + require.Len(t, calls, 1) + + assert.Equal(t, "subtract", calls[0].Procedure) +} + +/* +Scenario: JsonRpcProtocol.ErrorResponse format for various error codes and ids +Given a JsonRpcProtocol +When ErrorResponse is called with error code -32700, -32600, -32601 and various ids +Then it returns correctly formatted JSON-RPC error objects + +Related spec scenarios: RS.JRP.12, RS.JRP.13, RS.JRP.18 +*/ +func TestJsonRpcProtocol_ErrorResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code int + message string + id any + }{ + { + name: "parse error -32700 with null id", + code: -32700, + message: "Parse error", + id: nil, + }, + { + name: "invalid request -32600 with string id", + code: -32600, + message: "Invalid Request", + id: "req-1", + }, + { + name: "method not found -32601 with number id", + code: -32601, + message: "Method not found", + id: float64(42), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + proto := newTestProto("method") + data := proto.ErrorResponse(tt.code, tt.message, tt.id) + + var resp map[string]interface{} + err := json.Unmarshal(data, &resp) + require.NoError(t, err) + + assert.Equal(t, "2.0", resp["jsonrpc"]) + + errObj, ok := resp["error"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(tt.code), errObj["code"]) + assert.Equal(t, tt.message, errObj["message"]) + + if tt.id == nil { + assert.Nil(t, resp["id"]) + } else { + assert.Equal(t, tt.id, resp["id"]) + } + }) + } +} + +/* +Scenario: JsonRpcProtocol.ContentType returns configured or default content type +Given a JsonRpcProtocol with a configured content type +When ContentType is called +Then it returns the configured value + +Related spec scenarios: RS.JRP.1 +*/ +func TestJsonRpcProtocol_ContentType(t *testing.T) { + t.Parallel() + + t.Run("default", func(t *testing.T) { + t.Parallel() + proto := &JsonRpcProtocol{contentType: "application/json"} + assert.Equal(t, "application/json", proto.ContentType()) + }) + + t.Run("configured", func(t *testing.T) { + t.Parallel() + proto := &JsonRpcProtocol{contentType: "application/json-rpc"} + assert.Equal(t, "application/json-rpc", proto.ContentType()) + }) +} + +// Ensure JsonRpcProtocol implements RpcProtocol +var _ RpcProtocol = (*JsonRpcProtocol)(nil) + +// Ensure loader.RpcConfig integration +func TestNewJsonRpcProtocol_FromConfig(t *testing.T) { + cfg := &loader.RpcConfig{ + ContentType: "application/json", + Procedure: loader.ProcedureConfig{ + Call: "method", + }, + } + proto := NewJsonRpcProtocol(cfg) + assert.Equal(t, "application/json", proto.ContentType()) + assert.Equal(t, "method", proto.callPath) +} diff --git a/internal/server/server.go b/internal/server/server.go index c914ccb..ca09283 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -80,33 +80,49 @@ type Config struct { // Server represents the mock HTTP server. type Server struct { - config Config - router *chi.Mux - httpServer *http.Server - mappings []RouteMapping - stateStore StateStore - historyStore HistoryStore - // mapping from method+chiPattern to RouteMapping for quick lookup - routeMap map[string]*RouteMapping - // track once examples that have been used - onceExamples map[string]bool - onceMu sync.RWMutex - // dynamic examples added via management API + config Config + router *chi.Mux + httpServer *http.Server + mappings []RouteMapping + stateStore StateStore + historyStore HistoryStore + routeMap map[string]*RouteMapping + onceExamples map[string]bool + onceMu sync.RWMutex dynamicExamples map[string][]dynamicExample dyMu sync.RWMutex - // dependencies - deps Dependencies + deps Dependencies + rpcHandler *RpcHandler + rpcMappings []*loader.RpcRouteMapping + gatewayPath string } // New creates a new mock server with the given configuration and loaded schemas. func New(config Config, schemas []loader.SchemaInfo) (*Server, error) { - // Convert loader.SchemaInfo to server.SchemaInfo serverSchemas := make([]SchemaInfo, len(schemas)) + rpcConfig := (*loader.RpcConfig)(nil) for i, schema := range schemas { serverSchemas[i] = SchemaInfo{ Spec: schema.Spec, Prefix: schema.Prefix, } + + if rpcConfig == nil { + var err error + rpcConfig, err = loader.ParseRpcConfig(schema.Spec) + if err != nil { + return nil, fmt.Errorf("failed to parse RPC config: %w", err) + } + } + } + + var rpcMappings []*loader.RpcRouteMapping + if rpcConfig != nil { + var err error + rpcMappings, err = loader.BuildRpcMappings(schemas, rpcConfig) + if err != nil { + return nil, fmt.Errorf("failed to build RPC mappings: %w", err) + } } // Create default dependencies using wrappers @@ -130,11 +146,11 @@ func New(config Config, schemas []loader.SchemaInfo) (*Server, error) { ExtensionProcessor: &extensionsProcessorWrapper{}, } - return NewWithDependencies(config, serverSchemas, deps) + return NewWithDependencies(config, serverSchemas, deps, rpcConfig, rpcMappings) } // NewWithDependencies creates a new mock server with explicit dependencies. -func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies) (*Server, error) { +func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, rpcConfig *loader.RpcConfig, rpcMappings []*loader.RpcRouteMapping) (*Server, error) { // Build route mappings mappings, err := deps.RouteProvider.BuildRouteMappings(schemas) if err != nil { @@ -155,7 +171,39 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies) routeMap: make(map[string]*RouteMapping), onceExamples: make(map[string]bool), dynamicExamples: make(map[string][]dynamicExample), + rpcMappings: rpcMappings, } + + if rpcConfig != nil { + proto, err := newRpcProtocol(rpcConfig) + if err != nil { + return nil, fmt.Errorf("failed to create RPC protocol: %w", err) + } + + gwPath := rpcConfig.Gateway + for _, schema := range schemas { + gwPath = applyPrefixRpc(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 + } + s.rpcHandler = NewRpcHandler(proto, procMap, s) + s.gatewayPath = gwPath + } + s.setupRouter() return s, nil } @@ -200,6 +248,12 @@ func (s *Server) setupRouter() { // Register mock routes s.registerMockRoutes(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) @@ -212,13 +266,20 @@ func (s *Server) setupRouter() { func (s *Server) registerMockRoutes(r chi.Router) { slog.Info("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 - // Register route with chi using Method function - // chi.Method registers the route for the specified HTTP method 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) } @@ -306,116 +367,110 @@ func (s *Server) handleMockRequestWithMapping(w http.ResponseWriter, r *http.Req if s.config.Verbose { slog.Debug("handleMockRequestWithMapping called", "method", r.Method, "path", r.URL.Path, "mappingPattern", mapping.Pattern) } - // 1. Extract path parameters pathParams := s.extractPathParams(r, mapping) - // 2. Build runtime data sources + 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() - evaluator.AddSource("request", s.newRequestSource(r, pathParams)) + 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()) - // 3. Select response status code - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: selecting response", "mappingResponses", mapping.Responses != nil) - } statusCode, response := s.selectResponse(mapping, evaluator) - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: selected response", "statusCode", statusCode, "response", response != nil) - } if response == nil { - writeJSONError(w, http.StatusInternalServerError, "No response defined for operation") - return + return nil, nil, "", "", errNoResponse } - // 4. Select media type (for now, pick first) - var mediaType string var mediaTypeObj *openapi3.MediaType - if response.Content != nil { - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: selecting media type", "responseContent", true) - } + mediaType = "application/json" + if len(response.Content) > 0 { var mtErr error mediaType, mediaTypeObj, mtErr = s.selectMediaType(response) - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: selected media type", "mediaType", mediaType, "mtErr", mtErr) - } if mtErr != nil { - writeJSONError(w, http.StatusNotImplemented, mtErr.Error()) - return - } - } else { - // No content defined in schema; we'll use default media type if we have a dynamic example - mediaType = "application/json" // default - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: no content in response, using default media type", "mediaType", mediaType) + return nil, nil, "", "", mtErr } } - // Generate operation ID for once-example tracking opID := mapping.Prefix + ":" + mapping.Method + ":" + mapping.Pattern - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: selecting example", - "method", mapping.Method, - "pattern", mapping.Pattern, - "chiPattern", mapping.ChiPattern, - "key", routeKey(mapping.Method, mapping.ChiPattern)) - } - // 5. Select example (dynamic first, then built‑in) + var example *openapi3.Example var dynExample *dynamicExample - var exampleKey string - dynExample, exampleKey = s.selectDynamicExample(mapping, evaluator) - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: after selectDynamicExample", - "dynExample", dynExample != nil, - "exampleKey", exampleKey) - } + dynExample, _ = s.selectDynamicExample(mapping, evaluator) if dynExample == nil { if mediaTypeObj == nil { - // No content in schema and no dynamic example matched - writeJSONError(w, http.StatusNotImplemented, "No example available") - return - } - example, exampleKey = s.selectExample(mediaTypeObj, evaluator, opID) - if s.config.Verbose { - slog.Debug("handleMockRequestWithMapping: after selectExample", - "example", example != nil, - "exampleKey", exampleKey) + return nil, nil, "", "", errNotImplemented } + example, _ = s.selectExample(mediaTypeObj, evaluator, opID) if example == nil { - writeJSONError(w, http.StatusNotImplemented, "No example available") - return + return nil, nil, "", "", errNotImplemented } } - // Log selected example if verbose - if s.config.Verbose && exampleKey != "" { - slog.Debug("Selected example", "example", exampleKey) - } - // 6. Apply extensions (x-mock-set-state, x-mock-headers, x-mock-once) if example != nil { s.applyExtensions(example, evaluator, mapping.Prefix) } - // 7. Generate response body and headers - body, headers, finalStatusCode, err := s.generateResponse(example, dynExample, evaluator, statusCode) - if err != nil { - writeJSONErrorf(w, http.StatusInternalServerError, err.Error()) - return + body, headers, statusCode, genErr := s.generateResponse(example, dynExample, evaluator, statusCode) + if genErr != nil { + return nil, nil, "", "", genErr } - statusCode = finalStatusCode - // 8. Set response headers - for k, v := range headers { - w.Header().Set(k, v) - } + return body, headers, statusCode, mediaType, nil +} - // 9. Send response - w.Header().Set("Content-Type", mediaType) - w.WriteHeader(parseStatusCode(statusCode)) - if _, err := w.Write(body); err != nil && s.config.Verbose { - slog.Debug("Failed to write response body", "err", err) +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, } } @@ -536,3 +591,15 @@ func (s *Server) Shutdown(ctx context.Context) error { } return nil } + +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_test.go b/internal/server/server_test.go index f0e9e91..de6296c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -60,7 +60,7 @@ func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *M // Empty schemas since route provider will be mocked schemas := []SchemaInfo{} - server, err := NewWithDependencies(config, schemas, deps) + server, err := NewWithDependencies(config, schemas, deps, nil, nil) require.NoError(t, err, "NewWithDependencies should not error") return server, routeProvider, stateStore, historyStore, expressionEvaluator, requestSourceFactory, stateSourceFactory, envSourceFactory, extensionProcessor @@ -1991,9 +1991,8 @@ Related spec scenarios: RS.MSC.1 */ func TestStartAndShutdown(t *testing.T) { - // Use port 0 to let OS assign a random available port config := Config{ - Port: 8080, + Port: 0, Delay: 0, Verbose: false, EnableCORS: true, diff --git a/openspec/changes/archive/2026-08-08-add-json-rpc-support/.openspec.yaml b/openspec/changes/archive/2026-08-08-add-json-rpc-support/.openspec.yaml new file mode 100644 index 0000000..913564e --- /dev/null +++ b/openspec/changes/archive/2026-08-08-add-json-rpc-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-08 diff --git a/openspec/changes/archive/2026-08-08-add-json-rpc-support/design.md b/openspec/changes/archive/2026-08-08-add-json-rpc-support/design.md new file mode 100644 index 0000000..5cb15f7 --- /dev/null +++ b/openspec/changes/archive/2026-08-08-add-json-rpc-support/design.md @@ -0,0 +1,144 @@ +## Context + +OASMock is an HTTP-path router at its core. JSON-RPC 2.0 (and other RPC protocols) route by a body field instead, with a single gateway endpoint. This change introduces a protocol-agnostic `x-rpc` extension at the document root that declares routing configuration while keeping the existing OpenAPI operation model for examples, responses, and extensions. + +The core insight: everything downstream of routing — example selection via `x-mock-params-match`, runtime expressions (`{$request.body.*}`), extensions (`x-mock-set-state`, `x-mock-headers`, `x-mock-once`), state, and history — already works with arbitrary JSON bodies. The only new code is routing. + +## Goals / Non-Goals + +**Goals:** + +1. Support `x-rpc` root extension with modular protocol type (json-rpc first, extensible later). +2. Declare gateway path, procedure call source (body field), and procedure match target (spec operation field). +3. Route JSON-RPC calls by `method` body field to the correct OpenAPI operation's examples. +4. Batch: array body → per-call pipeline → array response. +5. Notifications: no `id` → run pipeline (side effects) but no response entry. +6. Standard JSON-RPC 2.0 errors: -32700 parse, -32600 invalid request, -32601 method not found. +7. Full JSON-RPC response envelope in examples (pass-through with `{$request.body.id}`). +8. RPC routes co-exist with normal HTTP routes in the same spec. +9. Reuse existing example selection, expression, extension, state, and history pipeline. + +**Non-Goals:** + +1. Protocols beyond JSON-RPC 2.0 (design is modular but only one implementation). +2. JSON-RPC 1.0 or over WebSocket. +3. Params-array positional dispatch (always treat params as arbitrary). +4. No CLI or management API changes. +5. No breaking changes to existing path-based routing. +6. No spec-level JSON-RPC schema generation or validation. + +## Decisions + +**1. x-rpc as a structured root extension** + +```yaml +x-rpc: + gateway: /rpc/single/endpoint + protocolType: json-rpc + contentType: application/json # optional, defaults per protocol + procedure: + call: method # body property with procedure name + match: post.operationId # spec operation field to match against +``` + +- **Decision**: `x-rpc` is a `map[string]interface{}` at the spec root. kin-openapi v0.133.0 preserves top-level `x-*` fields in `T.Extensions`. Parse into a typed struct. Validate required fields (`gateway`, `protocolType`, `procedure`). +- **Rationale**: Structuring as an object (not a string) enables protocol-specific configuration, future extensibility, and self-documenting schema. Using the same kin-openapi extension mechanism that already works for example-level `x-mock-*`. +- **Alternative**: Flat string `x-json-rpc-base-path: /rpc` — simpler but not extensible to other protocols or configurations. + +**2. Protocol abstraction — RpcProtocol interface** + +```go +type RpcProtocol interface { + ParseBody(body []byte) ([]RpcCall, error) + ErrorResponse(code int, message string, id any) []byte + ContentType() string +} +type RpcCall struct { + Procedure string // extracted method/procedure name + Raw any // raw call object for per-call RequestSource body + ID any // call id (nil for notifications) + HasID bool // true if id is present +} +``` + +- **Decision**: Server delegates body parsing and error formatting to a protocol implementation. `protocolType` selects the implementation via a registry or constructor. +- **Rationale**: Protocol-specific logic (batch detection, version validation, error format) is isolated behind an interface. Adding a new protocol (e.g., xml-rpc) means implementing three methods. Success responses need no wrapping — examples already contain the full envelope. +- **Alternative**: Embed protocol logic directly in the handler — not extensible, would require refactoring for each new protocol. + +**3. Procedure matching — `procedure.call` and `procedure.match`** + +- `procedure.call`: dot-separated path in the request body to extract the procedure name. For JSON-RPC: `"method"` → `body["method"]`. +- `procedure.match`: dot-separated path into the OpenAPI spec's operation to derive the procedure name. Format: `{httpMethod}.{field}`. Only POST is supported (JSON-RPC is POST-only). `post.operationId` → for each path under the gateway, the POST operation's `OperationID` is the procedure name. + +- **Decision**: Two configurable paths — one body-side, one spec-side — that extract names for comparison. The loader builds `map[string]*RouteMapping` (procedure name → mapping) by evaluating `procedure.match` for each POST operation under the gateway. The server handler uses `procedure.call` at runtime to extract the procedure name from the request body. +- **Rationale**: Decouples protocol conventions from spec structure. A future xml-rpc protocol could use `call: methodCall.methodName` and the same `match: post.operationId`. The dot-path resolver handles nesting. +- **Alternative**: Hardcode `body.method` → `operationId` — simpler but not protocol-agnostic. + +**4. Catch-all gateway registration, skip per-path registration under gateway** + +- **Decision**: Register `POST {gateway}` as a single chi route. Do NOT register individual HTTP routes for paths under the gateway — they would conflict with the catch-all handler and are unreachable anyway. +- **Rationale**: All RPC calls target the same URL. Individual routes would add noise and potential chi routing priority conflicts. Paths NOT under the gateway continue as normal HTTP routes — coexistence is automatic. +- **Coexistence example**: A spec with both `GET /api/users` and `x-rpc.gateway: /rpc` serves both independently. The gateway handler only intercepts POST to `/rpc` and its sub-paths. + +**5. Per-call RequestSource in batch** + +- **Decision**: For each `RpcCall`, construct a `runtime.RequestSource` with `Body: call.Raw` (the individual call object), NOT the batch array. This ensures `{$request.body.id}` and `{$request.body.params.*}` resolve per-call. +- **Rationale**: `RequestSource.Body` is `any` — can hold individual call objects directly. Zero runtime changes needed. The history middleware records the full batch body (as it should), not per-call. +- **Alternative**: Add a separate `rpc-call` data source — adds complexity for no gain since `request.body` already carries the right data semantically. + +**6. Response generation refactor** + +- **Decision**: Extract `selectAndGenerateResponse(r *http.Request, mapping *RouteMapping, pathParams map[string]string, callBody any) (body []byte, headers map[string]string, statusCode int, mediaType string, err error)` from `handleMockRequestWithMapping` (server.go:771). +- **Rationale**: The core pipeline (selectResponse → selectMediaType → selectExample → applyExtensions → generateResponse) is identical for HTTP and RPC. Extraction avoids duplication and keeps refactor surface small. Existing `server_test.go` validates no regressions. + +**7. Error handling** + +- **Decision**: `RpcProtocol.ErrorResponse()` formats protocol-compliant error objects. For JSON-RPC: `{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":}`. + - Parse error (-32700): body is not valid JSON → null id. + - Invalid request (-32600): missing `jsonrpc`/`method` or wrong version → null or request id. + - Method not found (-32601): procedure name not in map → echo received id. +- **Rationale**: Standard RFC compliance is expected by JSON-RPC clients. Error objects with proper codes allow clients to distinguish transport errors from application errors. +- **Alternative**: Return plain HTTP 4xx — would confuse JSON-RPC clients expecting structured errors. + +**8. Notification handling** + +- **Decision**: Process notifications (no `id` field) through the full pipeline to trigger side effects (`x-mock-set-state`, history), but produce no response entry. Single notification → HTTP 204 No Content. Batch notification → skipped in response array. +- **Rationale**: Notifications are valid JSON-RPC and can carry meaningful state updates. Ignoring them would break stateful mock scenarios. +- **Alternative**: Skip notification processing entirely — breaks `x-mock-set-state` semantics. + +**9. Content type defaults** + +- `contentType` field in `x-rpc` is optional. For `protocolType: json-rpc`, default is `"application/json"`. The handler sets response `Content-Type` accordingly. If specified, overrides the per-protocol default. + +**10. TDD approach** + +Write interfaces first, then unit tests (using gomock-generated mocks), then implementation. This follows the project's design-first, test-first guidelines and ensures the `RpcProtocol` interface is testable by construction. + +## Risks / Trade-offs + +- **[Risk] Body consumed by history middleware before RPC handler sees it.** + **Mitigation**: Existing `requestHistoryMiddleware` (server.go:909) restores body via `io.NopCloser`. Verified — RPC handler receives the full body. + +- **[Risk] Large batch arrays could cause OOM or slow responses.** + **Mitigation**: Existing `maxRequestBodySize = 1MB` limit. Per-call processing is sequential (no goroutine per call). Batch size is implicitly bounded by body size. + +- **[Trade-off] `procedure.match` supports only `post.operationId` initially.** + **Justification**: JSON-RPC is POST-only. Adding `post.path`, `post.x-rpc-name`, etc. later is a simple extension of the field resolver. + +- **[Trade-off] By-position params array routing is not supported (always treat params as arbitrary).** + **Justification**: Positional params are deprecated in JSON-RPC 2.0 spec. Named-object params are the recommended form and cover the vast majority of real-world usage. + +- **[Risk] Refactoring `handleMockRequestWithMapping` may break existing HTTP tests.** + **Mitigation**: Run full test suite after extraction. The change is purely structural (extract method, delegate) with zero behavioral diff. All existing `server_test.go` must pass. + +- **[Risk] Schema prefix (`--prefix /api`) combined with `gateway: /rpc` creates effective path `/api/rpc`.** + **Mitigation**: The loader's `applyPrefix` is already used for all paths. Gateway paths get prefixed identically. Integration test verifies prefix + gateway combination. + +## Open Questions + +1. Should `procedure.match` support HTTP methods other than POST in the initial implementation? + **Answer**: No — JSON-RPC is POST-only. Extend when needed. +2. Should `procedure.call` support nested paths (e.g., `jsonrpc.method`)? + **Answer**: Dot-separated support is included for forward compatibility. JSON-RPC uses flat `method`. +3. Should batch responses be parallelized? + **Answer**: No — sequential is safer, bounded by body size cap, and creates no goroutine leak risk. diff --git a/openspec/changes/archive/2026-08-08-add-json-rpc-support/proposal.md b/openspec/changes/archive/2026-08-08-add-json-rpc-support/proposal.md new file mode 100644 index 0000000..74615e6 --- /dev/null +++ b/openspec/changes/archive/2026-08-08-add-json-rpc-support/proposal.md @@ -0,0 +1,30 @@ +## Why + +OASMock currently routes exclusively by URL path + HTTP method. JSON-RPC 2.0 — widely used by Ethereum clients, LSP servers, and many other APIs — sends all calls to a single POST endpoint and dispatches by the `method` field in the request body. It also supports batch (array of calls → array of responses) and notifications (calls with no id, no response). Adding extensible RPC support closes this gap with a protocol-agnostic foundation. + +## What Changes + +- **New capability**: Root-level `x-rpc` extension that declares an RPC gateway endpoint with protocol type, content type, and procedure-to-spec matching rules. Initially supports `protocolType: json-rpc` (2.0). +- **New capability**: Catch-all POST handler at the gateway that parses the request body, dispatches by procedure name to the matching OpenAPI operation, and reuses the existing example-selection/extension/state pipeline. +- **New capability**: JSON-RPC batch (array body → array response), notifications (no id → no response entry), and standard JSON-RPC 2.0 error codes (-32700, -32600, -32601). +- **Modified capability**: `mock-server-core` — extract reusable response-body generation from the HTTP handler so both HTTP and RPC paths share the same example selection, expression evaluation, and extension processing pipeline. +- **Modified capability**: `extensions` — documentation-only; existing `x-mock-*` and `{$request.body.*}` expressions work unchanged with RPC calls. + +## Capabilities + +### New Capabilities + +- `json-rpc`: JSON-RPC 2.0 over HTTP POST — structured `x-rpc` extension, single-call dispatch, batch, notifications, standard errors, per-call runtime expression evaluation. + +### Modified Capabilities + +- `mock-server-core`: Response generation extracted from `handleMockRequestWithMapping` into a reusable function. No changes to HTTP routing, example selection, or extension behavior — purely structural refactor to enable RPC handler reuse. +- `extensions`: No behavioral changes — documentation-only update with JSON-RPC example patterns to show usage of `{$request.body.id}`, `{$request.body.method}`, and `{$request.body.params.*}` in RPC contexts. + +## Impact + +- **Code**: New interfaces (`RpcProtocol`, `RpcCall`), new file `internal/server/jsonrpc.go` plus protocol implementation, new loader file `internal/loader/rpc.go`, and a small refactor in `internal/server/server.go`. +- **APIs**: No CLI or management API changes — `x-rpc` is a schema-level extension detected at load time. +- **Dependencies**: None — uses existing `kin-openapi` (root extensions preserved as `map[string]any`), standard library `encoding/json`, and existing `runtime.RequestSource`. +- **Testing**: Unit tests (TDD order — interfaces first, then implementation), integration tests under `test/jsonrpc/`. Existing HTTP tests must pass after the refactor. +- **Documentation**: New `docs/json-rpc.md`; update `docs/extensions.md` with `x-rpc` schema reference and RPC example patterns. diff --git a/openspec/changes/archive/2026-08-08-add-json-rpc-support/specs/json-rpc/spec.md b/openspec/changes/archive/2026-08-08-add-json-rpc-support/specs/json-rpc/spec.md new file mode 100644 index 0000000..eea2379 --- /dev/null +++ b/openspec/changes/archive/2026-08-08-add-json-rpc-support/specs/json-rpc/spec.md @@ -0,0 +1,156 @@ +## ADDED Requirements + +### Requirement: x-rpc root extension +The mock server SHALL detect the root-level `x-rpc` extension to configure an RPC gateway endpoint. + +#### Scenario RS.JRP.1: Detecting x-rpc extension +- **WHEN** an OpenAPI spec has `x-rpc` with `gateway`, `protocolType`, and `procedure` fields at the document root +- **THEN** the server parses the configuration and registers a gateway handler at the specified path + +#### Scenario RS.JRP.2: Spec without x-rpc +- **WHEN** an OpenAPI spec does NOT have `x-rpc` +- **THEN** no RPC handler is registered and routing continues as normal HTTP path-based + +#### Scenario RS.JRP.3: Invalid x-rpc — missing gateway +- **WHEN** `x-rpc` is present but `gateway` field is missing or empty +- **THEN** the server fails to start with an error + +#### Scenario RS.JRP.4: Invalid x-rpc — missing procedure +- **WHEN** `x-rpc` is present but `procedure` field is missing +- **THEN** the server fails to start with an error + +#### Scenario RS.JRP.5: Invalid x-rpc — unsupported protocolType +- **WHEN** `x-rpc` specifies a `protocolType` that is not supported (e.g., "xml-rpc") +- **THEN** the server fails to start with an error + +### Requirement: Procedure name extraction from spec +The mock server SHALL derive procedure names from OpenAPI operations under the gateway using `procedure.match`. The match field specifies `{httpMethod}.{operationProperty}`. Only `post.operationId` is supported initially. + +#### Scenario RS.JRP.6: Building procedure mappings +- **WHEN** the gateway is `/rpc` and the spec defines POST operations at `/rpc/subtract` (operationId: "subtract") and `/rpc/add` (operationId: "add") +- **THEN** the server builds a procedure map: "subtract" → subtract RouteMapping, "add" → add RouteMapping + +#### Scenario RS.JRP.7: Paths not under gateway excluded +- **WHEN** the gateway is `/rpc` and the spec has a POST operation at `/users` (not under the gateway) +- **THEN** that operation is NOT included in the RPC procedure map and is treated as a normal HTTP route + +#### Scenario RS.JRP.8: Duplicate procedure names +- **WHEN** two POST operations under the gateway have the same `operationId` +- **THEN** the server fails to start with an error indicating the duplicate + +#### Scenario RS.JRP.9: No POST operations under gateway +- **WHEN** the gateway path is valid but no POST operations exist under it +- **THEN** the server starts successfully with an empty procedure map (all calls receive method-not-found responses) + +### Requirement: JSON-RPC 2.0 body parsing +The mock server SHALL parse incoming JSON-RPC request bodies according to the protocol configured by `protocolType: json-rpc` using the `procedure.call` field to extract procedure names. + +#### Scenario RS.JRP.10: Single call parsing +- **WHEN** a POST body is `{"jsonrpc":"2.0","method":"subtract","params":{"a":10},"id":1}` +- **THEN** the server parses it as one call with procedure "subtract", params `{"a":10}`, id `1`, HasID=true + +#### Scenario RS.JRP.11: Batch parsing +- **WHEN** a POST body is an array of two JSON-RPC call objects +- **THEN** the server parses it into two calls with correct fields per element + +#### Scenario RS.JRP.12: Invalid JSON body +- **WHEN** the POST body is not valid JSON +- **THEN** the server responds with `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}` + +#### Scenario RS.JRP.13: Missing jsonrpc field +- **WHEN** the POST body is a valid JSON object but missing the `jsonrpc` field +- **THEN** the server responds with `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request"},"id":null}` + +#### Scenario RS.JRP.14: Missing method field +- **WHEN** the POST body has `jsonrpc: "2.0"` but missing `method` +- **THEN** the server responds with error -32600 + +#### Scenario RS.JRP.15: Wrong jsonrpc version +- **WHEN** the POST body has `jsonrpc: "1.0"` +- **THEN** the server responds with error -32600 + +#### Scenario RS.JRP.16: Procedure name extracted via procedure.call +- **WHEN** `procedure.call` is `"method"` and the request body has `"method": "subtract"` +- **THEN** the extracted procedure name is `"subtract"` + +### Requirement: Single JSON-RPC call dispatch +The mock server SHALL route a single JSON-RPC call to the matching operation and return the example's response envelope. + +#### Scenario RS.JRP.17: Dispatch by procedure name +- **WHEN** a POST arrives at the gateway with body `{"jsonrpc":"2.0","method":"subtract","params":{"a":10,"b":3},"id":1}` and the procedure map has "subtract" +- **THEN** the server dispatches to the matched operation, evaluates the example (including `{$request.body.id}`), and returns the example's full response envelope + +#### Scenario RS.JRP.18: Method not found +- **WHEN** the `method` value does not match any `operationId` in the procedure map +- **THEN** the server responds with `{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":}` + +### Requirement: JSON-RPC batch processing +The mock server SHALL process batch JSON-RPC calls (array body) and return an array of responses. + +#### Scenario RS.JRP.19: Batch processing +- **WHEN** a POST body is an array of two valid calls with different ids and methods +- **THEN** the response is a JSON array of two response envelopes with matching ids + +#### Scenario RS.JRP.20: Batch with mixed success and error +- **WHEN** a batch contains one valid method and one unknown method +- **THEN** the response array contains one result envelope and one error envelope in the same order + +#### Scenario RS.JRP.21: Batch with notification +- **WHEN** a batch contains one call with id and one call without id (notification) +- **THEN** the response array contains only the entry for the call with id + +#### 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 `[]` + +### Requirement: JSON-RPC notification handling +The mock server SHALL process notification calls (no `id`) for side effects without returning a response entry. + +#### Scenario RS.JRP.23: Single notification returns no content +- **WHEN** a POST body is `{"jsonrpc":"2.0","method":"log","params":{"message":"hello"}}` (no `id` field) +- **THEN** the server processes the call (applying `x-mock-set-state` etc.) and returns HTTP 204 No Content + +#### Scenario RS.JRP.24: Notification applies side effects +- **WHEN** a notification matches an example with `x-mock-set-state` +- **THEN** the state update is applied even though no response body is returned + +### Requirement: Per-call runtime expression resolution +The mock server SHALL evaluate `{$request.body.*}` against the individual call object (not the full batch array). + +#### Scenario RS.JRP.25: Per-call body.id resolution in batch +- **WHEN** a batch includes two calls with ids 1 and 2 +- **THEN** each response envelope echoes the correct id via `{$request.body.id}` + +#### Scenario RS.JRP.26: Per-call body.params resolution +- **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 + +### Requirement: Extension compatibility +All existing `x-mock-*` extensions SHALL work identically for JSON-RPC calls as for HTTP requests. + +#### Scenario RS.JRP.27: x-mock-set-state with JSON-RPC +- **WHEN** a JSON-RPC call matches an example with `x-mock-set-state` +- **THEN** the server updates state as specified + +#### Scenario RS.JRP.28: x-mock-once with JSON-RPC +- **WHEN** a JSON-RPC call matches an example with `x-mock-once: true` +- **THEN** the example is removed from future consideration for that procedure + +#### Scenario RS.JRP.29: x-mock-headers with JSON-RPC +- **WHEN** a JSON-RPC call matches an example with `x-mock-headers` +- **THEN** the response includes those headers + +#### Scenario RS.JRP.30: x-mock-skip with JSON-RPC +- **WHEN** an example has `x-mock-skip: true` +- **THEN** the server never uses that example for JSON-RPC responses + +### Requirement: Coexistence with HTTP routes +The mock server SHALL serve RPC and non-RPC routes from the same spec simultaneously. + +#### Scenario RS.JRP.31: RPC and HTTP routes coexist +- **WHEN** a spec has both `x-rpc.gateway: /rpc` with procedure operations AND a regular `/api/users` GET endpoint +- **THEN** POST to `/rpc` dispatches to RPC handler and GET to `/api/users` dispatches to normal HTTP handler + +#### Scenario RS.JRP.32: Schema prefix applied to gateway +- **WHEN** the schema is loaded with `--prefix /api` and has `gateway: /rpc` +- **THEN** the gateway handler is registered at `/api/rpc` diff --git a/openspec/changes/archive/2026-08-08-add-json-rpc-support/tasks.md b/openspec/changes/archive/2026-08-08-add-json-rpc-support/tasks.md new file mode 100644 index 0000000..8d512f6 --- /dev/null +++ b/openspec/changes/archive/2026-08-08-add-json-rpc-support/tasks.md @@ -0,0 +1,111 @@ +## 1. Interface and type definitions (design first) + +- [x] 1.1 Define `loader.RpcConfig`, `loader.ProcedureConfig` structs in `internal/loader/rpc_config.go` +- [x] 1.2 Define `loader.RpcRouteMapping{Procedure string, RouteMapping}` type in `internal/loader/rpc_config.go` +- [x] 1.3 Define `server.RpcCall` and `server.RpcProtocol` interfaces in `internal/server/interfaces.go` +- [x] 1.4 Regenerate mocks: run `go generate ./internal/server/...` for `RpcProtocol` mock +- [x] 1.5 Define `server.RpcHandler` struct in `internal/server/jsonrpc.go` (holds protocol, procedure map, server ref) + +## 2. Unit tests for loader RPC config parsing (TDD: test first) + +- [x] 2.1 Test `ParseRpcConfig`: valid full config with all optional fields → correctly parsed struct +- [x] 2.2 Test `ParseRpcConfig`: missing gateway → error +- [x] 2.3 Test `ParseRpcConfig`: unsupported protocolType → error +- [x] 2.4 Test `ParseRpcConfig`: missing procedure field → error +- [x] 2.5 Test `ParseRpcConfig`: default contentType when not specified (should be "application/json" for json-rpc) +- [x] 2.6 Test `ParseRpcConfig`: returns nil when `x-rpc` extension is absent +- [x] 2.7 Test `ParseRpcConfig`: malformed x-rpc (not a map) → error +- [x] 2.8 Test `BuildRpcMappings`: paths under gateway with POST → mapped by operationId +- [x] 2.9 Test `BuildRpcMappings`: paths not under gateway → excluded from RPC mappings +- [x] 2.10 Test `BuildRpcMappings`: paths under gateway without POST operation → excluded +- [x] 2.11 Test `BuildRpcMappings`: duplicate operationId under gateway → error +- [x] 2.12 Test `BuildRpcMappings`: schema prefix applied to gateway path +- [x] 2.13 Test coexistence: one spec with both RPC (paths under gateway) and non-RPC paths → RpcMappings and regular RouteMappings both populated + +## 3. Unit tests for JSON-RPC protocol implementation (TDD: test first) + +- [x] 3.1 Test `JsonRpcProtocol.ParseBody`: valid single call → 1-element slice with correct Procedure, ID, HasID=true +- [x] 3.2 Test `JsonRpcProtocol.ParseBody`: valid batch (3 calls) → 3-element slice +- [x] 3.3 Test `JsonRpcProtocol.ParseBody`: notification (no id) → HasID=false, call still in slice +- [x] 3.4 Test `JsonRpcProtocol.ParseBody`: call with null id → HasID=false (null means no response per spec) +- [x] 3.5 Test `JsonRpcProtocol.ParseBody`: invalid JSON body → error +- [x] 3.6 Test `JsonRpcProtocol.ParseBody`: missing `jsonrpc` → error +- [x] 3.7 Test `JsonRpcProtocol.ParseBody`: missing `method` → error +- [x] 3.8 Test `JsonRpcProtocol.ParseBody`: wrong `jsonrpc` version ("1.0") → error +- [x] 3.9 Test `JsonRpcProtocol.ParseBody`: empty batch array → empty slice, no error +- [x] 3.10 Test `JsonRpcProtocol.ParseBody`: uses configurable `procedure.call` path to extract procedure name +- [x] 3.11 Test `JsonRpcProtocol.ErrorResponse`: format for -32700, -32600, -32601 with various ids (number, string, null) +- [x] 3.12 Test `JsonRpcProtocol.ContentType`: returns configured or default "application/json" + +## 4. Unit tests for RPC server handler (TDD: test first) + +- [x] 4.1 Test handler single call: protocol parses, dispatches to correct procedure, pipeline returns body, response written with correct Content-Type +- [x] 4.2 Test handler: method not found → calls ErrorResponse(-32601), writes error body +- [x] 4.3 Test handler: parse error → writes error body without calling pipeline +- [x] 4.4 Test handler: batch (3 calls) → 3 pipeline calls with per-call bodies, array response written +- [x] 4.5 Test handler: batch with notification → notification runs pipeline (side effects), not in response array +- [x] 4.6 Test handler: all-notification batch → empty JSON array written +- [x] 4.7 Test handler: per-call RequestSource Body is the individual call object, not the batch array +- [x] 4.8 Test handler: single notification → pipeline runs, HTTP 204 No Content +- [x] 4.9 Test handler: response headers from example are included in HTTP response +- [x] 4.10 Test handler: response status code from example is propagated + +## 5. Implement loader RPC config parsing and mapping + +- [x] 5.1 Implement `ParseRpcConfig(spec *openapi3.T) (*RpcConfig, error)` in `internal/loader/rpc.go` +- [x] 5.2 Implement `BuildRpcMappings(infos []SchemaInfo) ([]*RpcRouteMapping, error)` in `internal/loader/rpc.go` +- [x] 5.3 Implement `procedure.match` resolver: split on first dot → HTTP method + field name; validate method is POST; read `operation.OperationID` +- [x] 5.4 Wire into server initialization: call `ParseRpcConfig` + `BuildRpcMappings` during `New`/`NewWithDependencies` +- [x] 5.5 Skip HTTP route registration for paths under gateway (coexistence: only register RPC handler for those paths) +- [x] 5.6 Verify loader unit tests pass (phase 2 tests) + +## 6. Implement JSON-RPC protocol + +- [x] 6.1 Implement `NewJsonRpcProtocol(cfg *RpcConfig) server.RpcProtocol` in `internal/server/jsonrpc_protocol.go` +- [x] 6.2 Implement `ParseBody`: JSON decode, detect array vs object, validate jsonrpc, extract method via `procedure.call`, return `[]RpcCall` +- [x] 6.3 Implement `ErrorResponse`: format `{"jsonrpc":"2.0","error":{"code":-32601,"message":"..."},"id":...}` +- [x] 6.4 Implement `ContentType`: return configured or "application/json" +- [x] 6.5 Implement `procedure.call` resolver: split on dots, traverse body map to extract procedure name +- [x] 6.6 Verify protocol unit tests pass (phase 3 tests) + +## 7. Refactor server response generation + +- [x] 7.1 Extract method `selectAndGenerateResponse(r *http.Request, mapping *RouteMapping, pathParams map[string]string, callBody any) (body []byte, headers map[string]string, statusCode string, mediaType string, err error)` from `handleMockRequestWithMapping` +- [x] 7.2 Rewrite `handleMockRequestWithMapping` to call extracted method, then write to `http.ResponseWriter` +- [x] 7.3 Run existing `internal/server/server_test.go` — all must pass with no changes + +## 8. Implement RPC server handler + +- [x] 8.1 Implement `RpcHandler.ServeHTTP` in `internal/server/jsonrpc.go` +- [x] 8.2 Body read + protocol.ParseBody + per-call dispatch loop +- [x] 8.3 Per-call: look up procedure → mapping; if not found → protocol.ErrorResponse(-32601); if found → selectAndGenerateResponse +- [x] 8.4 Per-call: construct `runtime.RequestSource` with `Body: call.Raw` +- [x] 8.5 Result collection: skip calls with HasID=false +- [x] 8.6 Final assembly: batch (detected in handler) → `[...]` array; single → object; all notifications → `[]` or 204 +- [x] 8.7 Register gateway route in `setupRouter`: `r.Post(gatewayPath, server.makeRpcHandler(cfg))` +- [x] 8.8 Select `RpcProtocol` implementation based on `protocolType` (factory function) +- [x] 8.9 Verify server handler unit tests pass (phase 4 tests) + +## 9. Integration tests + +- [x] 9.1 Create test fixture OAS with `x-rpc` and multiple operationId under gateway in `test/_shared/resources/` +- [x] 9.2 Create test fixture OAS with both RPC and non-RPC paths in `test/_shared/resources/` +- [x] 9.3 Test: start server with RPC spec → single JSON-RPC call → correct example returned with `{$request.body.id}` echoed +- [x] 9.4 Test: batch of 3 calls (1 valid, 1 unknown method, 1 notification) → correct mixed response array +- [x] 9.5 Test: notification → state update via `x-mock-set-state` fires, HTTP 204 returned +- [x] 9.6 Test: method not found → -32601 error object with correct id +- [x] 9.7 Test: parse error (invalid JSON) → -32700 error +- [x] 9.8 Test: coexistence — same spec has RPC gateway + regular HTTP `/users` → both endpoints work independently +- [x] 9.9 Test: schema prefix (`--prefix /api`) + `gateway: /rpc` → gateway at `/api/rpc` +- [x] 9.10 Test: `x-mock-once` with RPC → example disposed after first matching call +- [x] 9.11 Test: `x-mock-skip` with RPC → skipped example never used +- [x] 9.12 Test: `x-mock-headers` with RPC → response includes evaluated headers +- [x] 9.13 Test: `x-mock-params-match` with RPC → per-call conditions evaluated against call params + +## 10. Documentation and coverage + +- [x] 10.1 Create `docs/json-rpc.md` with `x-rpc` schema reference, config fields, and usage examples +- [x] 10.2 Update `docs/extensions.md` with `x-rpc` entry and JSON-RPC example patterns +- [x] 10.3 Update `docs/architecture.md` to show RPC routing in the request flow +- [x] 10.4 Run full test suite and confirm 70% coverage threshold is maintained +- [x] 10.5 Verify all RS.JRP.* scenarios from `specs/json-rpc/spec.md` have corresponding tests diff --git a/openspec/specs/json-rpc/spec.md b/openspec/specs/json-rpc/spec.md new file mode 100644 index 0000000..173fbf1 --- /dev/null +++ b/openspec/specs/json-rpc/spec.md @@ -0,0 +1,160 @@ +## Purpose + +JSON-RPC 2.0 gateway support that configures an RPC endpoint via the root-level `x-rpc` extension, derives procedure names from gateway operations, parses single and batch JSON-RPC bodies, dispatches calls to matching operations, handles notifications, and reuses all existing `x-mock-*` extensions. + +## Requirements + +### Requirement: x-rpc root extension +The mock server SHALL detect the root-level `x-rpc` extension to configure an RPC gateway endpoint. + +#### Scenario RS.JRP.1: Detecting x-rpc extension +- **WHEN** an OpenAPI spec has `x-rpc` with `gateway`, `protocolType`, and `procedure` fields at the document root +- **THEN** the server parses the configuration and registers a gateway handler at the specified path + +#### Scenario RS.JRP.2: Spec without x-rpc +- **WHEN** an OpenAPI spec does NOT have `x-rpc` +- **THEN** no RPC handler is registered and routing continues as normal HTTP path-based + +#### Scenario RS.JRP.3: Invalid x-rpc — missing gateway +- **WHEN** `x-rpc` is present but `gateway` field is missing or empty +- **THEN** the server fails to start with an error + +#### Scenario RS.JRP.4: Invalid x-rpc — missing procedure +- **WHEN** `x-rpc` is present but `procedure` field is missing +- **THEN** the server fails to start with an error + +#### Scenario RS.JRP.5: Invalid x-rpc — unsupported protocolType +- **WHEN** `x-rpc` specifies a `protocolType` that is not supported (e.g., "xml-rpc") +- **THEN** the server fails to start with an error + +### Requirement: Procedure name extraction from spec +The mock server SHALL derive procedure names from OpenAPI operations under the gateway using `procedure.match`. The match field specifies `{httpMethod}.{operationProperty}`. Only `post.operationId` is supported initially. + +#### Scenario RS.JRP.6: Building procedure mappings +- **WHEN** the gateway is `/rpc` and the spec defines POST operations at `/rpc/subtract` (operationId: "subtract") and `/rpc/add` (operationId: "add") +- **THEN** the server builds a procedure map: "subtract" → subtract RouteMapping, "add" → add RouteMapping + +#### Scenario RS.JRP.7: Paths not under gateway excluded +- **WHEN** the gateway is `/rpc` and the spec has a POST operation at `/users` (not under the gateway) +- **THEN** that operation is NOT included in the RPC procedure map and is treated as a normal HTTP route + +#### Scenario RS.JRP.8: Duplicate procedure names +- **WHEN** two POST operations under the gateway have the same `operationId` +- **THEN** the server fails to start with an error indicating the duplicate + +#### Scenario RS.JRP.9: No POST operations under gateway +- **WHEN** the gateway path is valid but no POST operations exist under it +- **THEN** the server starts successfully with an empty procedure map (all calls receive method-not-found responses) + +### Requirement: JSON-RPC 2.0 body parsing +The mock server SHALL parse incoming JSON-RPC request bodies according to the protocol configured by `protocolType: json-rpc` using the `procedure.call` field to extract procedure names. + +#### Scenario RS.JRP.10: Single call parsing +- **WHEN** a POST body is `{"jsonrpc":"2.0","method":"subtract","params":{"a":10},"id":1}` +- **THEN** the server parses it as one call with procedure "subtract", params `{"a":10}`, id `1`, HasID=true + +#### Scenario RS.JRP.11: Batch parsing +- **WHEN** a POST body is an array of two JSON-RPC call objects +- **THEN** the server parses it into two calls with correct fields per element + +#### Scenario RS.JRP.12: Invalid JSON body +- **WHEN** the POST body is not valid JSON +- **THEN** the server responds with `{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}` + +#### Scenario RS.JRP.13: Missing jsonrpc field +- **WHEN** the POST body is a valid JSON object but missing the `jsonrpc` field +- **THEN** the server responds with `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request"},"id":null}` + +#### Scenario RS.JRP.14: Missing method field +- **WHEN** the POST body has `jsonrpc: "2.0"` but missing `method` +- **THEN** the server responds with error -32600 + +#### Scenario RS.JRP.15: Wrong jsonrpc version +- **WHEN** the POST body has `jsonrpc: "1.0"` +- **THEN** the server responds with error -32600 + +#### Scenario RS.JRP.16: Procedure name extracted via procedure.call +- **WHEN** `procedure.call` is `"method"` and the request body has `"method": "subtract"` +- **THEN** the extracted procedure name is `"subtract"` + +### Requirement: Single JSON-RPC call dispatch +The mock server SHALL route a single JSON-RPC call to the matching operation and return the example's response envelope. + +#### Scenario RS.JRP.17: Dispatch by procedure name +- **WHEN** a POST arrives at the gateway with body `{"jsonrpc":"2.0","method":"subtract","params":{"a":10,"b":3},"id":1}` and the procedure map has "subtract" +- **THEN** the server dispatches to the matched operation, evaluates the example (including `{$request.body.id}`), and returns the example's full response envelope + +#### Scenario RS.JRP.18: Method not found +- **WHEN** the `method` value does not match any `operationId` in the procedure map +- **THEN** the server responds with `{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":}` + +### Requirement: JSON-RPC batch processing +The mock server SHALL process batch JSON-RPC calls (array body) and return an array of responses. + +#### Scenario RS.JRP.19: Batch processing +- **WHEN** a POST body is an array of two valid calls with different ids and methods +- **THEN** the response is a JSON array of two response envelopes with matching ids + +#### Scenario RS.JRP.20: Batch with mixed success and error +- **WHEN** a batch contains one valid method and one unknown method +- **THEN** the response array contains one result envelope and one error envelope in the same order + +#### Scenario RS.JRP.21: Batch with notification +- **WHEN** a batch contains one call with id and one call without id (notification) +- **THEN** the response array contains only the entry for the call with id + +#### 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 `[]` + +### Requirement: JSON-RPC notification handling +The mock server SHALL process notification calls (no `id`) for side effects without returning a response entry. + +#### Scenario RS.JRP.23: Single notification returns no content +- **WHEN** a POST body is `{"jsonrpc":"2.0","method":"log","params":{"message":"hello"}}` (no `id` field) +- **THEN** the server processes the call (applying `x-mock-set-state` etc.) and returns HTTP 204 No Content + +#### Scenario RS.JRP.24: Notification applies side effects +- **WHEN** a notification matches an example with `x-mock-set-state` +- **THEN** the state update is applied even though no response body is returned + +### Requirement: Per-call runtime expression resolution +The mock server SHALL evaluate `{$request.body.*}` against the individual call object (not the full batch array). + +#### Scenario RS.JRP.25: Per-call body.id resolution in batch +- **WHEN** a batch includes two calls with ids 1 and 2 +- **THEN** each response envelope echoes the correct id via `{$request.body.id}` + +#### Scenario RS.JRP.26: Per-call body.params resolution +- **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 + +### Requirement: Extension compatibility +All existing `x-mock-*` extensions SHALL work identically for JSON-RPC calls as for HTTP requests. + +#### Scenario RS.JRP.27: x-mock-set-state with JSON-RPC +- **WHEN** a JSON-RPC call matches an example with `x-mock-set-state` +- **THEN** the server updates state as specified + +#### Scenario RS.JRP.28: x-mock-once with JSON-RPC +- **WHEN** a JSON-RPC call matches an example with `x-mock-once: true` +- **THEN** the example is removed from future consideration for that procedure + +#### Scenario RS.JRP.29: x-mock-headers with JSON-RPC +- **WHEN** a JSON-RPC call matches an example with `x-mock-headers` +- **THEN** the response includes those headers + +#### Scenario RS.JRP.30: x-mock-skip with JSON-RPC +- **WHEN** an example has `x-mock-skip: true` +- **THEN** the server never uses that example for JSON-RPC responses + +### Requirement: Coexistence with HTTP routes +The mock server SHALL serve RPC and non-RPC routes from the same spec simultaneously. + +#### Scenario RS.JRP.31: RPC and HTTP routes coexist +- **WHEN** a spec has both `x-rpc.gateway: /rpc` with procedure operations AND a regular `/api/users` GET endpoint +- **THEN** POST to `/rpc` dispatches to RPC handler and GET to `/api/users` dispatches to normal HTTP handler + +#### Scenario RS.JRP.32: Schema prefix applied to gateway +- **WHEN** the schema is loaded with `--prefix /api` and has `gateway: /rpc` +- **THEN** the gateway handler is registered at `/api/rpc` diff --git a/test/_shared/binhelper/binhelper.go b/test/_shared/binhelper/binhelper.go index 9378c32..0b8b640 100644 --- a/test/_shared/binhelper/binhelper.go +++ b/test/_shared/binhelper/binhelper.go @@ -15,12 +15,9 @@ const ( // Default environment variable names envSkipBuild = "OASMOCK_TEST_SKIP_BUILD" envLockTimeout = "OASMOCK_TEST_LOCK_TIMEOUT" - envKeepBinary = "OASMOCK_TEST_KEEP_BINARY" - // Default values defaultLockTimeout = 30 // seconds defaultSkipBuild = false - defaultKeepBinary = false // File names lockFileName = ".buildlock" @@ -69,7 +66,6 @@ func GetBuildedWithConfig(t *testing.T, skipBuildEnvVar string) string { // Get configuration from environment skipBuild := getBoolEnv(skipBuildEnvVar, defaultSkipBuild) lockTimeout := getIntEnv(envLockTimeout, defaultLockTimeout) - keepBinary := getBoolEnv(envKeepBinary, defaultKeepBinary) // Calculate absolute paths projectRoot, err := getProjectRoot() @@ -87,7 +83,7 @@ func GetBuildedWithConfig(t *testing.T, skipBuildEnvVar string) string { // Ensure cleanup of caller marker t.Cleanup(func() { - cleanupCaller(callerID, binDir, binPath, testBuiltPath, keepBinary) + cleanupCaller(callerID, binDir) }) // Check if binary already exists diff --git a/test/_shared/binhelper/tracking.go b/test/_shared/binhelper/tracking.go index 2d01fd7..a22032b 100644 --- a/test/_shared/binhelper/tracking.go +++ b/test/_shared/binhelper/tracking.go @@ -6,11 +6,14 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "syscall" "testing" "time" ) +var callerCounter atomic.Int64 + // registerCaller creates a caller marker and returns its ID. func registerCaller(t *testing.T, binDir string) string { callersDir := filepath.Join(binDir, callersDirName) @@ -25,7 +28,7 @@ func registerCaller(t *testing.T, binDir string) string { // Generate unique caller ID: {package}_{pid}_{timestamp} pid := os.Getpid() timestamp := time.Now().Format("20060102T150405") - callerID := fmt.Sprintf("test_%d_%s", pid, timestamp) + callerID := fmt.Sprintf("test_%d_%s_%d", pid, timestamp, callerCounter.Add(1)) // Create marker file markerPath := filepath.Join(callersDir, callerID) @@ -43,8 +46,10 @@ func registerCaller(t *testing.T, binDir string) string { return callerID } -// cleanupCaller removes the caller marker and potentially the binary. -func cleanupCaller(callerID, binDir, binPath, testBuiltPath string, keepBinary bool) { +// cleanupCaller removes the caller marker. +// The binary is a process-level resource — it is not deleted per-test +// to avoid breaking subsequent tests that reuse the same binary. +func cleanupCaller(callerID, binDir string) { // Remove our caller marker if it exists callerMarkersMu.Lock() markerPath, ok := callerMarkers[callerID] @@ -59,46 +64,13 @@ func cleanupCaller(callerID, binDir, binPath, testBuiltPath string, keepBinary b } } - // Check if we should delete the binary - if keepBinary { - return - } - - // Check if binary was test-built - if _, err := os.Stat(testBuiltPath); os.IsNotExist(err) { - // Not test-built, don't delete - return - } - - // Check if any callers remain callersDir := filepath.Join(binDir, callersDirName) - // Clean up stale markers before checking - cleanupStaleMarkers(callersDir) + // Remove empty callers directory if no callers remain if dirExists(callersDir) { entries, err := os.ReadDir(callersDir) - if err == nil && len(entries) > 0 { - // Other callers still exist - return - } - } - - // No callers remain and binary is test-built - delete it - if _, err := os.Stat(binPath); err == nil { - if err := os.Remove(binPath); err != nil { - fmt.Printf("Warning: failed to delete test-built binary %s: %v\n", binPath, err) - } else { - fmt.Printf("Deleted test-built binary %s\n", binPath) - } - - // Also remove test-built marker - if err := os.Remove(testBuiltPath); err != nil && !os.IsNotExist(err) { - fmt.Printf("Warning: failed to remove test-built marker: %v\n", err) - } - - // Remove empty callers directory - if dirExists(callersDir) { + if err == nil && len(entries) == 0 { if err := os.Remove(callersDir); err != nil && !os.IsNotExist(err) { - fmt.Printf("Warning: failed to remove callers directory: %v\n", err) + // Silent cleanup } } } diff --git a/test/_shared/resources/test-rpc-coexistence.yaml b/test/_shared/resources/test-rpc-coexistence.yaml new file mode 100644 index 0000000..10eba5d --- /dev/null +++ b/test/_shared/resources/test-rpc-coexistence.yaml @@ -0,0 +1,42 @@ +openapi: "3.0.3" +info: + title: RPC + HTTP Coexistence Test + version: "1.0" +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +paths: + /rpc/hello: + post: + operationId: hello + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: "hello {$request.body.params.name}" + id: "{$request.body.id}" + /users: + get: + operationId: listUsers + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + users: ["alice", "bob"] diff --git a/test/_shared/resources/test-rpc-empty.yaml b/test/_shared/resources/test-rpc-empty.yaml new file mode 100644 index 0000000..3b544f9 --- /dev/null +++ b/test/_shared/resources/test-rpc-empty.yaml @@ -0,0 +1,23 @@ +openapi: "3.0.3" +info: + title: JSON-RPC Empty Test API + version: "1.0" +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +paths: + /rpc/status: + get: + operationId: status + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + status: ok diff --git a/test/_shared/resources/test-rpc.yaml b/test/_shared/resources/test-rpc.yaml new file mode 100644 index 0000000..09177f0 --- /dev/null +++ b/test/_shared/resources/test-rpc.yaml @@ -0,0 +1,187 @@ +openapi: "3.0.3" +info: + title: JSON-RPC Test API + version: "1.0" +x-rpc: + gateway: /rpc + protocolType: json-rpc + procedure: + call: method + match: post.operationId +paths: + /rpc/subtract: + post: + operationId: subtract + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: "{$request.body.params.a} - {$request.body.params.b}" + id: "{$request.body.id}" + /rpc/add: + post: + operationId: add + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: "{$request.body.params.a} + {$request.body.params.b} = sum" + id: "{$request.body.id}" + /rpc/setState: + post: + operationId: setState + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + default: + x-mock-set-state: + counter: "{$request.body.params.value}" + value: + jsonrpc: "2.0" + result: ok + id: "{$request.body.id}" + /rpc/getState: + post: + operationId: getState + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + default: + value: + jsonrpc: "2.0" + result: "{$state.counter}" + id: "{$request.body.id}" + /rpc/once: + post: + operationId: once + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + a-first: + x-mock-once: true + value: + jsonrpc: "2.0" + result: once-only + id: "{$request.body.id}" + z-fallback: + value: + jsonrpc: "2.0" + result: fallback + id: "{$request.body.id}" + /rpc/skip: + post: + operationId: skip + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + skipped: + x-mock-skip: true + value: + jsonrpc: "2.0" + result: skipped + id: "{$request.body.id}" + default: + value: + jsonrpc: "2.0" + result: used + id: "{$request.body.id}" + /rpc/headers: + post: + operationId: headers + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + default: + x-mock-headers: + X-Custom-Header: "test-value-{$request.body.id}" + X-Static: static-value + value: + jsonrpc: "2.0" + result: with-headers + id: "{$request.body.id}" + /rpc/paramsMatch: + post: + operationId: paramsMatch + requestBody: + content: + application/json: + schema: + type: object + responses: + "200": + description: OK + content: + application/json: + examples: + admin: + x-mock-params-match: + '{$request.body.params.role}': admin + value: + jsonrpc: "2.0" + result: admin-response + id: "{$request.body.id}" + default: + value: + jsonrpc: "2.0" + result: user-response + id: "{$request.body.id}" diff --git a/test/jsonrpc/rpc_integration_test.go b/test/jsonrpc/rpc_integration_test.go new file mode 100644 index 0000000..dab9904 --- /dev/null +++ b/test/jsonrpc/rpc_integration_test.go @@ -0,0 +1,558 @@ +package jsonrpc_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/mamonth/oasmock/test/_shared/clihelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +/* +Scenario: Single JSON-RPC call returns correct example with body.id echoed +Given a schema with x-rpc gateway and subtract procedure +When a JSON-RPC single call is sent to the gateway +Then the response contains the correct result with {$request.body.id} evaluated + +Related spec scenarios: RS.JRP.17, RS.JRP.25 +*/ +func TestRpcSingleCall(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"subtract","params":{"a":"10","b":"3"},"id":1}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "2.0", result["jsonrpc"]) + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, "10 - 3", result["result"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: JSON-RPC batch with mixed success and error +Given a schema with multiple procedures +When a batch with one valid method, one unknown method, and one notification is sent +Then the response array contains entries for valid and error, notification is absent + +Related spec scenarios: RS.JRP.19, RS.JRP.20, RS.JRP.21 +*/ +func TestRpcBatchMixed(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `[{"jsonrpc":"2.0","method":"add","params":{"a":1,"b":2},"id":1},{"jsonrpc":"2.0","method":"unknown","id":2},{"jsonrpc":"2.0","method":"add","params":{"a":3,"b":4}}]` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var results []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&results) + require.NoError(t, err) + assert.Len(t, results, 2) + + var foundSuccess, foundError bool + for _, r := range results { + if r["id"] == float64(1) { + foundSuccess = true + assert.NotNil(t, r["result"]) + } + if r["id"] == float64(2) { + foundError = true + assert.NotNil(t, r["error"]) + assert.Equal(t, float64(-32601), r["error"].(map[string]interface{})["code"]) + } + } + assert.True(t, foundSuccess) + assert.True(t, foundError) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: JSON-RPC notification applies state changes and returns 204 +Given a schema with setState procedure that uses x-mock-set-state +When a notification is sent +Then the state is updated and the server returns HTTP 204 + +Related spec scenarios: RS.JRP.23, RS.JRP.24, RS.JRP.27 +*/ +func TestRpcNotificationState(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + // Cannot run in parallel since state is shared across tests + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + // Send notification to set state + notifyBody := `{"jsonrpc":"2.0","method":"setState","params":{"value":"42"}}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(notifyBody)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Verify state via another procedure + checkBody := `{"jsonrpc":"2.0","method":"getState","id":1}` + resp2, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(checkBody)) + require.NoError(t, err) + defer resp2.Body.Close() //nolint:errcheck + + var result map[string]interface{} + err = json.NewDecoder(resp2.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "42", result["result"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: JSON-RPC method not found returns -32601 error +Given a schema with limited procedures +When a method that doesn't exist is called +Then the server returns a -32601 Method not found error + +Related spec scenarios: RS.JRP.18 +*/ +func TestRpcMethodNotFound(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"nonexistent","id":99}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, float64(99), result["id"]) + assert.Equal(t, float64(-32601), result["error"].(map[string]interface{})["code"]) + assert.Contains(t, result["error"].(map[string]interface{})["message"], "not found") + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: RPC gateway with no POST operations starts with empty procedure map +Given a schema with x-rpc gateway but only GET operations under it +When a JSON-RPC call is sent to the gateway +Then the server starts successfully and returns method-not-found for any procedure + +Related spec scenarios: RS.JRP.9 +*/ +func TestRpcNoProcedures(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc-empty.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"anything","id":1}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, float64(-32601), result["error"].(map[string]interface{})["code"]) + assert.Contains(t, result["error"].(map[string]interface{})["message"], "not found") + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: JSON-RPC parse error returns -32700 +Given an RPC gateway +When invalid JSON is sent +Then the server returns -32700 Parse error + +Related spec scenarios: RS.JRP.12 +*/ +func TestRpcParseError(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `not json` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + raw, _ := io.ReadAll(resp.Body) + t.Logf("parse error response: %s", string(raw)) + + var result map[string]interface{} + err = json.Unmarshal(raw, &result) + require.NoError(t, err) + assert.Equal(t, float64(-32700), result["error"].(map[string]interface{})["code"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: RPC and HTTP routes coexist in the same spec +Given a schema with both RPC gateway and normal HTTP routes +When both endpoints are called +Then both respond correctly + +Related spec scenarios: RS.JRP.31 +*/ +func TestRpcCoexistence(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc-coexistence.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + // Test RPC endpoint + rpcBody := `{"jsonrpc":"2.0","method":"hello","params":{"name":"World"},"id":1}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(rpcBody)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + var rpcResult map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&rpcResult) + require.NoError(t, err) + assert.Equal(t, "hello World", rpcResult["result"]) + + // Test HTTP endpoint + httpResp, err := http.Get(fmt.Sprintf("http://localhost:%d/users", port)) + require.NoError(t, err) + defer httpResp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, httpResp.StatusCode) + + var httpResult map[string]interface{} + err = json.NewDecoder(httpResp.Body).Decode(&httpResult) + require.NoError(t, err) + assert.Contains(t, httpResult["users"], "alice") + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: Schema prefix applied to RPC gateway +Given a schema with gateway /rpc and CLI --prefix /api +When a POST is sent to /api/rpc +Then the RPC handler responds correctly + +Related spec scenarios: RS.JRP.32 +*/ +func TestRpcWithPrefix(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc-coexistence.yaml", "/api").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"hello","params":{"name":"Prefix"},"id":1}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/api/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "hello Prefix", result["result"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: x-mock-once with RPC disposes example after first use +Given a schema with an RPC procedure having x-mock-once example +When the procedure is called twice +Then the first call returns the once example and the second returns the fallback + +Related spec scenarios: RS.JRP.28 +*/ +func TestRpcOnceExample(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"once","id":1}` + url := fmt.Sprintf("http://localhost:%d/rpc", port) + + // First call + resp1, err := http.Post(url, "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp1.Body.Close() //nolint:errcheck + + var r1 map[string]interface{} + err = json.NewDecoder(resp1.Body).Decode(&r1) + require.NoError(t, err) + assert.Equal(t, "once-only", r1["result"]) + + // Second call + resp2, err := http.Post(url, "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp2.Body.Close() //nolint:errcheck + + var r2 map[string]interface{} + err = json.NewDecoder(resp2.Body).Decode(&r2) + require.NoError(t, err) + assert.Equal(t, "fallback", r2["result"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: x-mock-skip with RPC never uses skipped example +Given a schema with an RPC procedure having x-mock-skip example +When the procedure is called +Then the skipped example is never returned + +Related spec scenarios: RS.JRP.30 +*/ +func TestRpcSkipExample(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"skip","id":1}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + assert.Equal(t, "used", result["result"]) + assert.NotEqual(t, "skipped", result["result"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: x-mock-headers with RPC includes evaluated headers +Given a schema with an RPC procedure having x-mock-headers +When the procedure is called +Then the response includes the evaluated headers + +Related spec scenarios: RS.JRP.29 +*/ +func TestRpcHeaders(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + body := `{"jsonrpc":"2.0","method":"headers","id":42}` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/rpc", port), "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, "test-value-42", resp.Header.Get("X-Custom-Header")) + assert.Equal(t, "static-value", resp.Header.Get("X-Static")) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +} + +/* +Scenario: x-mock-params-match with RPC evaluates against per-call params +Given a schema with an RPC procedure having x-mock-params-match +When calls with different params are made +Then the matching example is returned per-call + +Related spec scenarios: RS.JRP.26 +*/ +func TestRpcParamsMatch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test-rpc.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + url := fmt.Sprintf("http://localhost:%d/rpc", port) + + // Admin call + adminBody := `{"jsonrpc":"2.0","method":"paramsMatch","params":{"role":"admin"},"id":1}` + resp1, err := http.Post(url, "application/json", strings.NewReader(adminBody)) + require.NoError(t, err) + defer resp1.Body.Close() //nolint:errcheck + + var r1 map[string]interface{} + err = json.NewDecoder(resp1.Body).Decode(&r1) + require.NoError(t, err) + assert.Equal(t, "admin-response", r1["result"]) + + // User call + userBody := `{"jsonrpc":"2.0","method":"paramsMatch","params":{"role":"user"},"id":2}` + resp2, err := http.Post(url, "application/json", strings.NewReader(userBody)) + require.NoError(t, err) + defer resp2.Body.Close() //nolint:errcheck + + var r2 map[string]interface{} + err = json.NewDecoder(resp2.Body).Decode(&r2) + require.NoError(t, err) + assert.Equal(t, "user-response", r2["result"]) + + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + } +}