Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
22 changes: 22 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

124 changes: 124 additions & 0 deletions docs/json-rpc.md
Original file line number Diff line number Diff line change
@@ -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.
132 changes: 132 additions & 0 deletions internal/loader/rpc.go
Original file line number Diff line number Diff line change
@@ -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+"/")
}
18 changes: 18 additions & 0 deletions internal/loader/rpc_config.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading