OpenRPC documents

moonrockz/openrpc is a portable OpenRPC 1.3.2/1.4.x reader, writer and document validator. It contains the complete typed OpenRPC and draft-07 models, exact JSON values, a source-preserving CST, reference resolution over explicit resources, versioned document validation with explicit profiles, canonical and pretty writing, and checked value edits. It depends only on the MoonBit core library. It is licensed under Apache-2.0; embedded metaschema and Unicode data keep their notices in OPENRPC-LICENSE.txt and UNICODE-LICENSE.txt.

Reading keeps both the original JSON source and a typed document when structural decoding succeeds. Writing retains exact numeric tokens, extensions, absent fields and array order. Object keys sort by UTF-16 code units. Canonical writing does not insert defaults or change the document version.

The examples import the root as @openrpc, exact JSON as @exact, schemas as @schema, and references as @reference.

///|
test {
  let source = " {\"openrpc\":\"1.4.0\",\"info\":{\"title\":\"API\",\"version\":\"service-9\"},\"methods\":[],\"x-number\":9007199254740993} "
  let result = @openrpc.read(source)
  assert_eq(result.cst().unwrap().reproduce(), source)
  let document = result.document().unwrap()
  assert_eq(document.info().version(), "service-9")
  assert_eq(
    @openrpc.write(document).unwrap(),
    "{\"info\":{\"title\":\"API\",\"version\":\"service-9\"},\"methods\":[],\"openrpc\":\"1.4.0\",\"x-number\":9007199254740993}",
  )
}

The public json package provides immutable exact JSON values and an owned CST. Its node spans use half-open UTF-16 offsets. Nodes retain member occurrences and decoded keys; the owned source retains punctuation, whitespace and raw spelling. Duplicate decoded keys preserve the CST but fail semantic lowering. The parser, lowering and writer use explicit stacks.

Default JSON limits are 8,388,608 UTF-16 code units of source/output, 100,000 nodes, 256 open containers, 4,096 code units per number and 256 lowering findings. json.Limits::new accepts explicit bounds. Source size is a text bound, not a UTF-8 byte bound. Large decimal exponents are kept exact without expanding powers of ten.

CstDocument.tokens() partitions the original text into tokens and whitespace. Each token has its original text and span. source_map() maps decoded paths to all value occurrences, including duplicate members. A location separates the member-name span from the value span. Positions use one-based lines and UTF-16 columns; CRLF counts as one line break. Each location's node index belongs to the owning CST and is not stable across parses or edits. Generated AST values have no source map.

///|
test {
  let cst = @exact.parse("{\"n\":9007199254740993}", source_id=Some("api.json")).unwrap()
  let location = cst.source_map().locations(["n"])[0]
  assert_eq(location.source_id(), Some("api.json"))
  assert_eq(cst.slice(location.value_span()).unwrap(), "9007199254740993")
  assert_eq(cst.slice(location.key_span().unwrap()).unwrap(), "\"n\"")
}

Writing and editing

There are three ways to produce text, and they differ on purpose:

  • CstDocument::reproduce returns the original source exactly, including whitespace, CRLF, member order and escape and number spelling. It works for valid JSON that is not a valid OpenRPC document.
  • write returns canonical compact JSON: object keys sorted by UTF-16 code units, minimal string escapes, no whitespace and no final newline.
  • write(format=@exact.Pretty) is the same text with two-space indentation.

Canonical writing keeps every present field, extension and reference spelling, and keeps array order, so methods, parameters, examples and schema arrays are never reordered. It inserts no defaults: an absent paramStructure or deprecated stays absent. It is not RFC 8785 (JCS). JCS rewrites numbers in ECMAScript form; this writer keeps each number token, so 1.0 and 1 remain different bytes. Do not use it as a signing format. Strings with an unpaired UTF-16 surrogate are refused with InvalidValue, because they are not Unicode text and cannot be read back.

///|
test {
  let source = "{\"x-n\": 1.0, \"methods\": [{\"name\": \"b\", \"params\": []}, {\"name\": \"a\", \"params\": [{\"$ref\": \"#/components/contentDescriptors/P\"}]}],\r\n \"info\": {\"version\": \"1\", \"title\": \"API\"}, \"openrpc\": \"1.4.0\", \"components\": {\"contentDescriptors\": {\"P\": {\"name\": \"p\", \"schema\": true}}}}"
  let read = @openrpc.read(source)
  let document = read.document().unwrap()
  assert_eq(read.cst().unwrap().reproduce(), source)
  let compact = @openrpc.write(document).unwrap()
  assert_eq(
    compact,
    "{\"components\":{\"contentDescriptors\":{\"P\":{\"name\":\"p\",\"schema\":true}}},\"info\":{\"title\":\"API\",\"version\":\"1\"},\"methods\":[{\"name\":\"b\",\"params\":[]},{\"name\":\"a\",\"params\":[{\"$ref\":\"#/components/contentDescriptors/P\"}]}],\"openrpc\":\"1.4.0\",\"x-n\":1.0}",
  )
  let pretty = @openrpc.write(document, format=@exact.Pretty).unwrap()
  assert_true(pretty.has_prefix("{\n  \"components\": {"))
  // Both formats read back to the same document.
  let again = @openrpc.read(pretty).document().unwrap()
  assert_eq(@openrpc.write(again).unwrap(), compact)
}

replace_value edits one existing value in a parsed document. Find the node with CstDocument::node_at or a source-map location's node(). The new value is written compactly into that value's span, every other code unit is kept, and the text is read again into a fresh CST, document and source map. Node handles belong to one source text; using one after an edit that changed the text fails with StaleNode. Only existing values can be replaced; insertion, deletion and key renaming are not supported.

///|
test {
  let source = "{\"openrpc\":\"1.4.0\",\"info\":{\"title\":\"API\",\"version\":\"1\"},\r\n  \"methods\": [],\r\n  \"x-owner\": \"team-a\"}"
  let cst = @openrpc.read(source).cst().unwrap()
  let node = cst.node_at(["x-owner"]).unwrap()
  let edited = @openrpc.replace_value(cst, node, @exact.Value::string("team-b")).unwrap()
  let new_cst = edited.cst().unwrap()
  assert_eq(new_cst.reproduce(), source.replace(old="team-a", new="team-b"))
  // The old handle is stale for the edited text.
  assert_true(new_cst.replace_value(node, @exact.Value::null()) is Err(_))
  // Validation of the edited document locates findings in the new text.
  let title = new_cst.node_at(["info", "title"]).unwrap()
  let broken = @openrpc.replace_value(new_cst, title, @exact.Value::null()).unwrap()
  assert_true(broken.document() is None)
  let span = broken.diagnostics()[0].span().unwrap()
  assert_eq(broken.cst().unwrap().slice(span), Some("null"))
}

Exact Value.equals compares object members without regard to their order and retains numeric token distinctions. Select numeric=true for mathematical numeric equality. Number.numeric_compare orders exact decimals and exponents. Neither operation converts numbers to binary floating point. Equality returns Result[Bool, Diagnostic] and reports exhaustion through LimitExceeded. It bounds visited and queued node pairs, depth, number tokens, and aggregate key/string/number text on each operand. It never treats exhaustion as inequality.

Core Json interop is explicit. from_core_json uses a present numeric repr after checking its syntax; without repr, it captures the current finite Double representation. It cannot recover source digits that the caller lost. to_core_json always sets exact numeric repr. Core arithmetic and equality still use Double semantics. Both conversions copy collections and enforce the JSON limits. Limits.unlimited() removes policy bounds within the maximum representable String/Array sizes; available host memory is still finite.

///|
test {
  let exact = @exact.parse("9007199254740993").unwrap().lower().unwrap()
  let core = @exact.to_core_json(exact).unwrap()
  assert_true(@exact.from_core_json(core).unwrap().equals(exact).unwrap())
  let code = @openrpc.ErrorCode::parse("-90071992547409931234567890").unwrap()
  assert_eq(code.number().token(), "-90071992547409931234567890")
  let extensions = @openrpc.Extensions::from_json(
    @exact.parse("{\"x-value\":false}").unwrap().lower().unwrap(),
  ).unwrap()
  assert_true(
    extensions.get(@openrpc.ExtensionName::parse("x-value").unwrap()) is Some(_),
  )
}

ComponentKey, ExtensionName, and ErrorCode have checked constructors. Component keys follow the OpenRPC component grammar. Extension names begin with lowercase x- and retain arbitrary exact values. Error codes are mathematical integers with no machine-integer restriction. Each field keeps its own rules; these constructors do not impose a common string restriction on unrelated fields.

Validation

validate returns a report with five facets. Each is Passed, Failed or Incomplete; there is no single success flag.

Facet Checks
model Objects reached by validation decode into the typed model
schemas Embedded draft-07 schema documents, including regex syntax
references Every reference resolves within the supplied resources
normative Rules from the pinned prose of the document's version
artifact The document's own JSON against its version's pinned metaschema

Normative rules follow the document version. Both versions require unique method names, unique parameter names per method, unique error codes per method (exact integer equality, so 1 and 1e0 collide) and links that name an existing method, all checked after references resolve. A referenced target of the wrong kind is a references failure. Required parameters must precede optional ones. OpenRPC 1.3.2 also requires server, license and link names and component keys matching ^[a-zA-Z0-9.\-_]+$; the 1.4 tables state neither. OpenRPC 1.4 requires each example's name and inline value; its tables do not include the 1.3.2 externalValue alternative. Reading keeps any component key as written. While a method fails to resolve, link targets are left unchecked and the facet is incomplete. Members outside a version's field table are UnknownField warnings and never fail the facet.

The artifact facet uses the frozen 1.3.2 meta-schema (release 1.14.9) or the 1.4 schema at spec commit 876aa30e. Schema positions there are checked only as boolean-or-object; the schemas facet checks their keywords. Formats are annotations.

Profiles

Specification, the default, reports every normative failure as an error. MetaschemaCompatibility serves producers that pass the pinned artifact but differ from the prose. It downgrades exactly the discrepancies in compatibility_discrepancies(), and each such finding names its Discrepancy:

Discrepancy Prose Artifact
OptionalParameterBeforeRequired Optional params come last No rule
ServerNameOmitted 1.3.2 requires a server name Optional
LicenseNameOmitted 1.3.2 requires a license name Optional

Every other normative failure stays an error under both profiles. Neither profile reorders, repairs or fills in the document.

validate_closed returns the same report and, only when the required facets pass, a ValidatedDocument that records its profile, version and resource snapshot. Specification requires model, schemas, references and normative; an artifact failure stays in the report without blocking the wrapper. Compatibility requires those facets and artifact, with normative errors limited to the discrepancy list. An incomplete facet, including one truncated by limits, never produces a wrapper.

///|
test {
  let document = @openrpc.read(
      "{\"openrpc\":\"1.3.2\",\"info\":{\"title\":\"API\",\"version\":\"1\"},\"methods\":[{\"name\":\"find\",\"params\":[{\"name\":\"limit\",\"required\":false,\"schema\":true},{\"name\":\"query\",\"required\":true,\"schema\":true}]}]}",
    )
    .document()
    .unwrap()
  // The prose forbids an optional parameter before a required one.
  let strict = @openrpc.validate_closed(document)
  assert_eq(strict.report().normative(), @openrpc.Failed)
  assert_true(strict.document() is None)
  // Compatibility reports the same finding as a listed warning.
  let compatible = @openrpc.validate_closed(
    document,
    profile=@openrpc.MetaschemaCompatibility,
  )
  let order = compatible.report().findings()[0]
  assert_eq(order.severity(), @openrpc.Warning)
  assert_eq(order.discrepancy(), Some(@openrpc.OptionalParameterBeforeRequired))
  let validated = compatible.document().unwrap()
  assert_eq(validated.profile(), @openrpc.MetaschemaCompatibility)
  // Parameter order is unchanged.
  assert_eq(
    @openrpc.write(validated.document()).unwrap(),
    @openrpc.write(document).unwrap(),
  )
}

Findings

Each finding has a typed code, severity, phase, document version and profile. Its location names the resource (None for the input document), a pointer, and for the input document a half-open UTF-16 span when the caller passes the source_map from read. related() gives other occurrences, such as the first of two duplicate names. chain() lists the OpenRPC $ref sites followed to reach the location. A missing member points at its parent and names the member in expected_field(). Documents built with constructors have no spans.

///|
test {
  let source = "{\"openrpc\":\"1.4.0\",\"info\":{\"title\":\"😀\",\"version\":\"1\"},\n\"methods\":[{\"name\":\"a\",\"params\":[]},{\"$ref\":\"#/x-copy\"}],\"x-copy\":{\"name\":\"a\",\"params\":[]}}"
  let read = @openrpc.read(source, source_id=Some("api.json"))
  let source_map = read.source_map().unwrap()
  let report = @openrpc.validate(read.document().unwrap(), source_map=Some(source_map))
  let duplicate = report.findings()[0]
  assert_eq(duplicate.diagnostic().code(), @exact.DuplicateName)
  assert_eq(duplicate.location().path(), ["x-copy", "name"])
  assert_eq(duplicate.chain()[0].path(), ["methods", "1", "$ref"])
  assert_eq(duplicate.related()[0].path(), ["methods", "0", "name"])
  // The emoji counts as two UTF-16 units; lines and columns start at 1.
  let span = duplicate.location().span().unwrap()
  let position = source_map.position(span.start()).unwrap()
  assert_eq((position.line(), position.column()), (2, 75))
}

Findings are ordered by resource, pointer (array indices numerically), code, source position and message, so every target reports the same list. The max_findings limit (default 256) keeps the first findings in that order. Each phase that lost findings gets an Information LimitExceeded marker and becomes Incomplete.

///|
test {
  let document = @openrpc.read(
      "{\"openrpc\":\"1.4.0\",\"info\":{\"title\":\"API\",\"version\":\"1\"},\"methods\":[{\"name\":\"a\",\"params\":[]},{\"name\":\"a\",\"params\":[]},{\"name\":\"a\",\"params\":[]}]}",
    )
    .document()
    .unwrap()
  let limits = @exact.Limits::new(max_findings=1)
  let report = @openrpc.validate(document, limits~)
  assert_eq(report.normative(), @openrpc.Failed)
  assert_true(
    report
    .findings()
    .any(f => f.diagnostic().code() == @exact.LimitExceeded),
  )
  assert_true(@openrpc.validate_closed(document, limits~).document() is None)
}

Runtime boundary

Document validation says nothing about runtime exchanges. It does not validate JSON-RPC envelopes, request or response IDs, batches, notifications, or examples and error data as instances of their schemas. An absent result and a result whose schema allows only null are both valid document shapes; neither is checked against any call.

///|
test {
  let document = @openrpc.read(
      "{\"openrpc\":\"1.4.0\",\"info\":{\"title\":\"API\",\"version\":\"1\"},\"methods\":[{\"name\":\"log\",\"params\":[],\"result\":{\"name\":\"done\",\"schema\":{\"type\":\"null\"}},\"examples\":[{\"name\":\"e\",\"params\":[],\"result\":{\"name\":\"r\",\"value\":42}}]}]}",
    )
    .document()
    .unwrap()
  // The example value 42 does not match the null schema, and that is not a
  // document finding.
  let result = @openrpc.validate_closed(document)
  assert_true(result.document() is Some(_))
  assert_true(result.report().findings().is_empty())
}

References

References never trigger network or filesystem access. Validation resolves references only through an explicit @reference.ResourceSet and an optional document base URI. Each OpenRPC reference site (methods, parameters, results, errors, links, tags, example pairings and their examples) follows its reference-only chain. Each reference resolves against the resource that holds it, and the target is decoded with that kind's checked decoder. A target that does not decode is WrongType at the referencing site. A chain that revisits a location is ReferenceCycle. Schema references use draft-07 $id scopes and recursive schema graphs are valid. Server URL templates, example externalValue, error data, example values and x-* payloads are never resolved, even when they contain $ref.

///|
test {
  let document = @openrpc.read(
      "{\"openrpc\":\"1.4.0\",\"info\":{\"title\":\"API\",\"version\":\"1\"},\"methods\":[{\"$ref\":\"shared.json#/methods/ping\"}]}",
    )
    .document()
    .unwrap()
  let base = @reference.UriReference::parse("https://api.example/openrpc.json").unwrap()
  // Without the shared resource the reference is unresolved, not failed.
  let open = @openrpc.validate(document, base=Some(base))
  assert_eq(open.references(), @openrpc.Incomplete)
  assert_true(
    @openrpc.validate_closed(document, base=Some(base)).document() is None,
  )
  // Supplying it, under an alias, closes the check. Nothing is fetched.
  let shared = @reference.Resource::new(
    @reference.UriReference::parse("https://cdn.example/v1/shared.json").unwrap(),
    @exact.parse(
      "{\"methods\":{\"ping\":{\"name\":\"ping\",\"params\":[],\"result\":{\"name\":\"pong\",\"schema\":{\"$ref\":\"#/definitions/Pong\"}}}},\"definitions\":{\"Pong\":{\"type\":\"string\"}}}",
    )
    .unwrap()
    .lower()
    .unwrap(),
    aliases=[
      @reference.UriReference::parse("https://api.example/shared.json").unwrap(),
    ],
    schema_roots=[@reference.JsonPointer::parse("/definitions/Pong").unwrap()],
  ).unwrap()
  let resources = @reference.ResourceSet::new([shared]).unwrap()
  let closed = @openrpc.validate_closed(document, resources~, base=Some(base))
  assert_eq(closed.report().references(), @openrpc.Passed)
  assert_true(closed.document() is Some(_))
}

Document::as_resource registers a document with every schema position in its typed model declared as a schema root, so other documents can reference it. When base names a registered resource that holds exactly the validated document, that resource is used. Without base, relative external references report MissingBaseUri and leave reference coverage incomplete; a relative schema $id still scopes the fragment-only references inside it. Findings for targets in another resource carry pointers relative to that resource. Chains are bounded by max_depth, and schema graphs by their node, edge and finding bounds. Schema graph defaults are 10,000 visited nodes, 4,096 reference edges and 256 findings per check.

Schema-document validation checks schema keyword forms and references. It does not apply schemas to payload or example instances. Error data and arbitrary x-* payloads remain instance values. JSON-RPC envelopes, notifications, batches and exchanges require a separate runtime validator.

The draft-07 model guide covers the complete typed keyword model, checked exact bounds, tuple items, dependencies and literal-data boundaries. Schema.from_json checks typed forms. Use schema.validate_json to report malformed untyped schema documents.

The document API covers metadata, servers and variables, methods, descriptors, tags, links, errors, examples, pairings and all component tables. Each object offers checked new/from_json construction, typed accessors, to_json, shared extensions, and explicit additional_fields. Public arrays and maps are defensive copies. Generated values have no source locations.

///|
test {
  let null_schema = @schema.Schema::from_keywords([
    @schema.Type(@schema.TypeConstraint::one(@schema.NullType)),
  ]).unwrap()
  let result = @openrpc.ContentDescriptor::new(
    "result",
    null_schema,
    required=Some(true),
  ).unwrap()
  let op = @openrpc.Method::new(
    @openrpc.MethodName::parse("ping").unwrap(),
    [],
    result=Some(@openrpc.Inline(result)),
    param_structure=Some(@openrpc.ByName),
  ).unwrap()
  let document = @openrpc.Document::new(
    @openrpc.DocumentVersion::parse("1.3.2").unwrap(),
    @openrpc.Info::new("Example API", "service-v9").unwrap(),
    [@openrpc.Inline(op)],
  ).unwrap()
  assert_eq(document.info().version(), "service-v9")
  assert_true(@openrpc.write(document).unwrap().contains("\"type\":\"null\""))
  let notification = @openrpc.Method::new(
    @openrpc.MethodName::parse("notify").unwrap(),
    [],
  ).unwrap()
  assert_true(notification.result() is None)
}

An absent method result describes a notification. A present result whose schema permits null describes a successful null result. Optional flags and collections retain explicit false, empty arrays/maps and absence. Server URL templates and link parameters are retained data and are never executed.

///|
test {
  let schema = @schema.Schema::from_keywords([
    @schema.Type(@schema.TypeConstraint::one(@schema.StringType)),
  ]).unwrap()
  let components = @openrpc.Components::new(
    schemas=Some([(@openrpc.ComponentKey::parse("Text").unwrap(), schema)]),
  ).unwrap()
  let descriptor = @openrpc.ContentDescriptor::new(
    "text",
    @schema.Schema::from_json(
      @exact.parse("{\"$ref\":\"#/components/schemas/Text\"}")
      .unwrap()
      .lower()
      .unwrap(),
    ).unwrap(),
  ).unwrap()
  assert_eq(components.schemas().unwrap()[0].0.to_string(), "Text")
  assert_true(descriptor.schema().kind() is @schema.ReferenceSchema(_))
  let name = @openrpc.ExtensionName::parse("x-contract").unwrap()
  let extensions = @openrpc.Extensions::from_entries([(name, schema.to_json())]).unwrap()
  let submitted = @schema.Schema::from_json(extensions.get(name).unwrap()).unwrap()
  assert_true(
    @schema.validate(submitted, root=submitted.to_json())
    .diagnostics()
    .is_empty(),
  )
}

Extensions keep arbitrary exact values. A caller can explicitly submit an extension-held schema for schema-document checks. The library does not infer schemas from extension names or object keys.

///|
test {
  let error = @openrpc.ErrorObject::new(
    @openrpc.ErrorCode::parse("-90071992547409931234567890").unwrap(),
    "Failed",
    data=Some(
      @exact.parse("{\"$ref\":\"literal\",\"schema\":17}")
      .unwrap()
      .lower()
      .unwrap(),
    ),
  ).unwrap()
  let external = @openrpc.Example::new(
    @openrpc.ExternalExample(
      @reference.UriReference::parse("https://example.invalid/value").unwrap(),
    ),
  ).unwrap()
  let components = @openrpc.Components::new(
    errors=Some([(@openrpc.ComponentKey::parse("E").unwrap(), error)]),
    examples=Some([
      (@openrpc.ComponentKey::parse("External").unwrap(), external),
    ]),
    example_pairings=Some([]),
    example_pairing_objects=Some([]),
  ).unwrap()
  assert_true(components.to_json().get("examplePairings") is Some(_))
  assert_true(components.to_json().get("examplePairingObjects") is Some(_))
  assert_true(components.errors().unwrap()[0].1.data() is Some(_))
}

The two pairing table spellings remain distinct; pointers are never redirected. Example payloads distinguish absent, literal and external values. Error data is an instance value. Names whose requirements differ by version or profile retain their presence so conformance checks can report the applicable rule.

Run mise run check:openrpc-assets for the frozen asset checks and mise run check:openrpc for module tests, official inventories and the four-target public consumer comparison. No publication has occurred.

View source on GitHub