Vouchfx.Community.JsonRpc
1.0.0-alpha.1
Prefix Reserved
dotnet add package Vouchfx.Community.JsonRpc --version 1.0.0-alpha.1
NuGet\Install-Package Vouchfx.Community.JsonRpc -Version 1.0.0-alpha.1
<PackageReference Include="Vouchfx.Community.JsonRpc" Version="1.0.0-alpha.1" />
<PackageVersion Include="Vouchfx.Community.JsonRpc" Version="1.0.0-alpha.1" />
<PackageReference Include="Vouchfx.Community.JsonRpc" />
paket add Vouchfx.Community.JsonRpc --version 1.0.0-alpha.1
#r "nuget: Vouchfx.Community.JsonRpc, 1.0.0-alpha.1"
#:package Vouchfx.Community.JsonRpc@1.0.0-alpha.1
#addin nuget:?package=Vouchfx.Community.JsonRpc&version=1.0.0-alpha.1&prerelease
#tool nuget:?package=Vouchfx.Community.JsonRpc&version=1.0.0-alpha.1&prerelease
rpc.json-rpc — JSON-RPC 2.0 over HTTP
The first Community-tier provider for the vouchfx hub — listed in the
community provider registry, hosted in this repository, and
richer than the copyable template/ skeleton (though it does not carry
the Vouched badge). It doubles as the reference implementation the
implementing-a-provider guide walks through:
a real HTTP-calling, BCL-plus-JsonPath.Net provider built outside the engine repo,
following exactly the same patterns the engine's own Core http.rest and
mail-expect.smtp providers use.
What it is
JSON-RPC 2.0 is a small, transport-agnostic
remote-procedure-call wire protocol: a JSON request envelope naming a method and
params, and a JSON response envelope carrying either a result or an error. It is
the wire protocol behind:
- the Language Server Protocol (every editor ↔ language-server exchange),
- Ethereum JSON-RPC APIs (
eth_call,eth_getBalance, …), - Bitcoin Core's RPC interface,
- and many internal microservice RPC layers.
This provider issues JSON-RPC 2.0 requests over HTTP and asserts on the response —
letting an .e2e.yaml suite exercise a JSON-RPC endpoint the same way http.rest
exercises a plain REST one.
Worked examples
1. Happy path, with a capture
metadata:
name: jsonrpc-sum-happy-path
environment:
services:
calc:
image: my-org/jsonrpc-calculator:latest
steps:
- id: call-sum
type: rpc.json-rpc
url: "http://{calc-host}:{calc-port}/rpc"
method: sum
params:
a: 2
b: 3
expect:
result:
- path: "$.sum"
value: 5
capture:
total: "$.result.sum"
params is a YAML mapping, so it becomes a JSON-RPC named-params object
({"a":2,"b":3}). expect.result runs a JSONPath assertion against the response
envelope's result value. capture (the engine-standard field, DSL §6.1) reads the
full envelope — hence "$.result.sum", not "$.sum" — and writes total into
Vars for a later step.
url, method, and every string leaf of params are resolved at
step-execution time through the same {placeholder} + ${secret:source/path}
mechanism (Secret_Helpers.ResolveTemplate, §17) — see examples 3 and 4 below,
where params carries a {orderId} placeholder. params is parsed into a JSON
tree first and each string value is resolved individually, never by
template-substituting the raw JSON text before parsing, so a resolved value can
never corrupt the surrounding JSON structure.
2. Negative test — asserting a specific JSON-RPC error
steps:
- id: call-unknown-method
type: rpc.json-rpc
url: "http://{calc-host}:{calc-port}/rpc"
method: subtractWithCarry # deliberately unsupported by the target service
expect:
error:
code: -32601 # JSON-RPC 2.0 standard "Method not found"
expect.error.code is a negative test: the step passes only when the response
genuinely is a JSON-RPC error envelope with exactly this code. expect.result and
expect.error are mutually exclusive — declaring both fails model validation before
any request is sent.
3. RETRY — polling an eventually-consistent read
steps:
- id: wait-for-projection
type: rpc.json-rpc
url: "http://{projector-host}:{projector-port}/rpc"
method: getOrderStatus
params:
orderId: "{orderId}"
expect:
result:
- path: "$.status"
value: "SHIPPED"
verifyMode: RETRY
timeout: PT10S
verifyMode: RETRY is a purely engine-side wrapper (CsxAssembler /
Vouchfx.Engine.Abstractions.Retry.RetryRunner) that re-invokes this provider's
emitted block, unchanged, until it passes or timeout elapses. The provider does not
implement (and does not need) any "RETRY capability" interface — the only contract it
honours is: write exactly one StepOutcome on every invocation, and use Fail (never
Inconclusive) for a not-yet-satisfied assertion. The engine converts a sustained
Fail into Inconclusive once the window elapses; see the table below.
The {orderId} inside params.orderId above is a genuine substitution, not a
literal: it is resolved from Vars on every poll, exactly like {projector-host}
in url.
4. Fire-and-forget notification
steps:
- id: notify-audit-log
type: rpc.json-rpc
url: "http://{audit-host}:{audit-port}/rpc"
method: recordEvent
params: ["order.shipped", "{orderId}"]
notification: true
params is a YAML sequence here, so it becomes a JSON-RPC positional params
array (["order.shipped", "<orderId>"], with {orderId} resolved from Vars the
same way as the mapping form above). notification: true omits the request id
entirely; the step asserts only that the transport call succeeded (a 2xx HTTP status)
and never parses a response envelope — there usually isn't one. notification is
incompatible with expect (rejected at model validation) and with capture (there is
no response body to capture from — see "Known limitations").
Verdict-mapping table
This is the teaching core of this provider — verified against the engine's own exception-
to-verdict conventions (http.rest, mail-expect.smtp) rather than invented from
scratch.
| Condition | Verdict | Notes |
|---|---|---|
| Connection refused / DNS failure / TLS failure | EnvironmentError |
Generic catch-all; a run-environment problem, not a product defect (§12.1). |
| Response body is not valid JSON | EnvironmentError |
The server returned something that isn't even a JSON-RPC envelope. |
Resolved url scheme is not http/https |
EnvironmentError |
A defence-in-depth guard, thrown before any request is sent. |
${secret:source/path} resolves to nothing / unknown source |
EnvironmentError |
Reference-only observation (§17) — never the value, never even the exception message. |
Client-side timeout (TaskCanceledException / TimeoutException) |
Inconclusive |
Mirrors http.rest: "the test could not complete due to a stall," not a defect. |
expect.result declared, response is a JSON-RPC error instead |
Fail |
{"unexpectedError":true} |
expect.result declared, response id does not match the request |
Fail |
{"idMismatch":true} |
expect.result declared, any JSONPath assertion mismatches |
Fail |
{"resultMismatch":{"path":...,"expected":...,"actual":...}} — renderable as a diff, see below. |
expect.result declared, all assertions match |
Pass |
|
expect.error.code declared, response is a result envelope instead |
Fail |
{"unexpectedResult":true} |
expect.error.code declared, response has neither result nor error |
Fail |
{"missingError":true} |
expect.error.code declared, error present but code differs |
Fail |
{"errorCodeMismatch":{"expected":...,"actual":...}} — renderable as a diff. |
expect.error.code declared, code matches |
Pass |
|
Neither expect.result nor expect.error declared ("bare" call), an error envelope arrives |
Fail |
{"unexpectedError":true} |
Bare call, id mismatch |
Fail |
{"idMismatch":true} |
Bare call, clean success envelope with matching id |
Pass |
|
notification: true, HTTP status is 2xx |
Pass |
No envelope is parsed. |
notification: true, HTTP status is not 2xx |
Fail |
|
capture: declared, JSONPath yields no match against the full envelope |
Inconclusive |
{"captureUnmet":"<varName>"} — mirrors http.rest's "upstream-capture-unmet" convention exactly; only evaluated when the primary assertion above did not already resolve to Fail. |
notification: true and capture: both declared |
Fail |
Rejected at compile (Emit) time, not validation time — see "Known limitations". An author misconfiguration (a step shape that can never do what it declares), not a timing uncertainty, so it deliberately does not use Inconclusive (which would not break CI by default and could hide the mistake). |
expect.result declared as an empty list (result: []) |
(rejected) | Model-validation error, not a runtime verdict — an empty list would otherwise silently degrade to bare-call semantics; see "Known limitations". |
params is present but not a mapping or a sequence (a scalar) |
(rejected) | Schema and model-validation error — JSON-RPC 2.0 §4.2 requires params to be structured when present. |
verifyMode: RETRY, the assertion never converges before timeout |
Inconclusive |
Written entirely by the engine's RetryRunner, not by this provider — a sustained Fail (or Inconclusive) is converted once the polling window elapses. |
Two of the Fail shapes above — resultMismatch and errorCodeMismatch — implement
the optional IStepDiffRenderer interface, so a failing assertion renders as a small
expected-vs-observed table in supporting renderers, exactly as the Core
mail-expect.smtp provider does for its own count mismatches.
Known limitations
- HTTP transport only. No WebSocket, no stdio (the LSP transport most editors
actually use), no raw TCP framing. Adding a transport is a separate provider (or a
richer model with a
transport:discriminator) — out of scope for this provider. - No batch requests. The JSON-RPC 2.0 spec allows a request array processed as a batch; this provider always sends exactly one request object per step.
- No
headersfield. Unlikehttp.rest, this provider's model has no way to set arbitrary request headers (e.g.Authorization). The engine's canonical pattern for a header value isSecret_Helpers.ResolveTemplateinside the emitted helper's guarded region (exactly as this provider already does forurl,method, and every string leaf ofparams) — addingheaderswould cost only a small, mechanical amount of extra Bind/Emit plumbing (parallel name/value-template arrays, mirroringhttp.rest's own header handling almost verbatim). It is omitted here to keep the teaching surface focused on the JSON-RPC envelope semantics themselves. url,method, andparamsall support${secret:...}, even though the brief for this provider only asked for{placeholder}support —Secret_Helpers.ResolveTemplategives both in a single call, at no extra cost, so it was included throughout.paramsis parsed into a JSON tree and each STRING LEAF is resolved individually (never by template-substituting the raw JSON text before parsing), so a resolved value can never corrupt the surrounding JSON structure, even if it contains a quote or a brace.- Captures are always evaluated as JSONPath, regardless of an explicit
{xpath: ...}capture form — a JSON-RPC envelope is never XML, so an XPath-typed capture is simply passed through as raw expression text (it will normally just fail to match, yieldingInconclusive, not a hard validation error). notification: true+expectis rejected at model validation (a clear, author-facing error).notification: true+capturecannot be rejected there —IProjectContext(available inValidate) carries no view of the step'scapturemap; onlyICompileContext(available inEmit) does. That combination is instead short-circuited atEmittime to a trivialFailblock that skips the HTTP call entirely, with a{"error": "..."}observation explaining why.Fail, notInconclusive: this is an author misconfiguration, not a timing uncertainty, andInconclusivedoes not break CI by default (§12.1) — using it here would let the mistake hide.expect.resultmust be non-empty when declared. Writingexpect.result: []is rejected at model validation rather than silently degrading to bare-call semantics — an author who writes an empty list almost certainly means "assert a result exists," which is a stronger claim than a bare call actually checks.paramsmust be structured. Per JSON-RPC 2.0 §4.2,paramsmust be a mapping or a sequence when present; a bare scalar (params: 5,params: "x") is rejected both by the JSON Schema and byValidate.- Response
idmatching is string-only. This provider always sends the requestidas a JSON string (the step id) and matches the responseidwith an ordinal string comparison. Some JSON-RPC ecosystems — Ethereum and Bitcoin Core's RPC APIs among them — conventionally use integer ids, and a server that coerces or echoes a numeric id back will fail the id-match check even though the call itself succeeded. There is no per-step way to opt into numeric ids in this provider. - No
IResourceContributor. This provider declares no Aspire-managed infrastructure — the target is whatever absolute URL the author supplies, resolved entirely at execution time. There is consequently no dependency-reconciliation check againstenvironment.services/environment.dependenciesthe wayhttp.restandmail-expect.smtphave. - This provider needs a
JsonPath.NetPackageReference(pinned to3.0.2, matching the engine's ownDirectory.Packages.propsentry) beyondVouchfx.Sdk— verified, not assumed: the engine'scapture:field (and this provider's ownexpect.result) is evaluated with JSONPath.Net inside the provider's own emitted CSX, exactly as the Corehttp.restandmq-expect.*providers do, which requires both the compile-time package reference (soICompileReferenceContributorcan name the type) and the contributor interface itself. - Conformance tests use a small custom harness (
JsonRpcHarness), not only the publishedVouchfx.Sdk.Testing.ProviderTestHarness.ProviderTestHarnessdoes not run theICompileReferenceContributorstage, and its default Roslyn reference set lacksSystem.Net.Http/System.Text.Json/JsonPath.Netentirely — a genuine HTTP-calling provider's execution tests cannot compile through it as-is. SeeVouchfx.Community.JsonRpc.Tests/JsonRpcHarness.csfor the full explanation and the engine-repo precedent (HttpRestExecutionTests.cs) this mirrors. Tests that halt before any compile (schema/model-validation rejection) use the published harness directly, with no workaround needed.
Mapping to the community-hub path
This provider lives under community/, alongside template/. It is a
real Community-tier provider — the first entry in
registry/community-providers.json — hosted in
the hub itself so the tier has a canonical, CI-tested reference implementation. It does not
carry the Vouched badge: it has not been through the Vouched rubric. If you want to build on
it as your own, independently-shipped provider:
- Copy
Vouchfx.Community.JsonRpcto your own repository under your own namespace (neverVouchfx.Steps.*/Vouchfx.Engine.*). - Publish it as a NuGet package under Apache-2.0.
- List it in the community provider index (Community tier),
or work towards the Vouched badge rubric if you
want platform-team endorsement — see
CONTRIBUTING.mdfor both paths.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- JsonPath.Net (>= 3.0.2)
- Vouchfx.Sdk (>= 1.0.0-alpha.4)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-alpha.1 | 119 | 7/10/2026 |