Contents

cocoanetics/jsonfoundation

The wire model for JSON, JSON Schema, and JSON-RPC 2.0 — plus a transport-agnostic

JSONValue

JSONValue is an enum over the JSON types — null, bool, integer, unsignedInteger, double, string, array, object — that is Codable, Sendable, Hashable, and ergonomic to build and inspect:

import JSONFoundation

// ExpressibleBy* literals make construction terse:
let payload: JSONValue = [
    "name": "acp",
    "tags": ["a", "b"],
    "count": 3,
]

// Subscripts + typed accessors to read back out:
payload["name"]?.stringValue      // "acp"
payload["tags"]?[0]?.stringValue  // "a"

let data = try JSONEncoder().encode(payload)
let back = try JSONDecoder().decode(JSONValue.self, from: data)

// Bridge from / wrap other values:
let a = JSONValue(jsonObject: anyFromJSONSerialization) // Foundation `Any` -> JSONValue
let b = try JSONValue(encoding: someEncodable)          // throwing
let c = JSONValue(someEncodable)                        // best-effort, non-throwing

Typed accessors (stringValue, intValue, uintValue, doubleValue, boolValue, arrayValue, dictionaryValue) and the JSONDictionary / JSONArray typealiases round it out. JSONCoding supplies the package's default encoder/decoder (ISO-8601 dates, base64 data, deterministic wire output).

JSONSchema

JSONSchema is an indirect enum describing a JSON shape — string, number, boolean, array, object, enum, oneOf — that round-trips to and from standard JSON Schema. Use it wherever you need to describe data rather than carry it, such as tool/function parameter schemas for LLMs or MCP:

let schema: JSONSchema = .object(.init(
    properties: [
        "city": .string(description: "City name"),
        "units": .enum(values: ["metric", "imperial"]),
    ],
    required: ["city"]
))

The @Schema macro

Attach @Schema to a struct and its schema is derived at compile time, with descriptions pulled from the doc comments:

/// A person's contact information
@Schema
struct ContactInfo {
    /// The person's full name
    let name: String

    /// The person's phone number (optional)
    let phone: String?
}

ContactInfo.schemaMetadata   // name, description, and typed property info

SchemaRepresentable, SchemaMetadata, SchemaPropertyInfo and JSONSchemaTypeConvertible are the underlying protocol surface if you want to derive schemas without the macro.

JSON-RPC 2.0 envelope

Foundation-only envelope types for JSON-RPC 2.0. params and result are any JSONValue (object, array, primitive, or null — the full spec shape). Ids accept integer/string literals, messages are Equatable/Hashable, and encoding is the symmetric inverse of decoding:

let request: JSONRPCMessage = .request(id: 1, method: "ping", params: ["x": .integer(1)])

// Encode one message as an object, or a batch as an array:
let object = try request.encoded()                              // {"id":1,"jsonrpc":"2.0",…}
let batch  = try JSONRPCMessage.encodeBatch([request, request]) // [ …, … ]

// Decode a single message or a batch from raw bytes (and recover the shape):
let messages = try JSONRPCMessage.decodeMessages(from: data)
let wasBatch = JSONRPCMessage.isBatchPayload(data)

Classify and read any message without switching, and correlate replies:

if message.isRequest, let method = message.method {
    route(method, message.params)
}

switch message.replyOutcome {                 // nil for a request/notification
case .success(let result)?: continuation.resume(returning: result)
case .failure(let error)?:  continuation.resume(throwing: error)
case .none: break
}

Errors are throwable, carry the reserved codes, and classify their range:

throw JSONRPCError.methodNotFound("frobnicate")            // -32601
throw JSONRPCError.serverError(code: -32050, message: "busy")
JSONRPCError.parseError().isReservedCode                  // true
  • JSONRPCID.integer / .string; ExpressibleBy{Integer,String}Literal, intValue / stringValue / description
  • JSONRPCMessagerequest / notification / response / errorResponse; Equatable + Hashable; accessors id / method / params / result / error, predicates isRequest / isNotification / isResponse / isErrorResponse / isReply, replyOutcome, validate(); framing encoded() / encodedString() / encodeBatch(:) / decodeMessages(from:) / isBatchPayload(:)
  • JSONRPCErrorError + LocalizedError; factories .parseError / .invalidRequest / .methodNotFound / .invalidParams / .internalError / .serverError; range checks isReservedCode / isServerError

JSON-RPC runtime

import JSONRPC (or the individual modules) adds a working peer and transports on top of the envelope. JSONRPCPeer owns the semantics — request/response correlation by id, concurrent request dispatch, in-order notifications — while the transport owns the wire (framing + JSON coding):

import Foundation
import JSONRPC

// Two peers wired back-to-back in memory (embedding, or subprocess-free tests):
let (clientTransport, serverTransport) = LoopbackTransport.pair()
let client = JSONRPCPeer(transport: clientTransport)
let server = JSONRPCPeer(transport: serverTransport)
await server.setHandlers(request: { method, _ in .success(.string("pong:\(method)")) },
                         notification: nil)
await server.start()
await client.start()
let result = try await client.sendRequest(method: "ping", params: nil)

Swap the loopback for a real wire without touching the peer:

// Spawn a child process and speak newline-framed JSON-RPC over its stdio
// (an MCP/ACP client; use ContentLengthFraming() for LSP):
let transport = try ProcessTransport(
    launch: ProcessLaunch(executable: "my-server", arguments: ["--stdio"]),
    framing: LineFraming()
)

// Or connect over TCP:
let tcp = try TCPClientTransport(host: "localhost", port: 8123, framing: LineFraming())

// Or POST to an HTTP endpoint that answers with JSON or an SSE stream
// (MCP's "Streamable HTTP" shape):
let sse = SSEClientTransport(endpoint: URL(string: "https://example.com/rpc")!)

JSONRPCSSEServer is the server-side counterpart of the SSE client: a transport-agnostic registry of Server-Sent-Event streams (SSEStreamHub) with replay buffers and Last-Event-ID resume. JSONRPCSubprocess provides an alternative stdio transport built on swift-subprocess — lock-free and fully Sendable — gated behind the Subprocess package trait (which also raises the platform floor).

Installation

.package(url: "https://github.com/Cocoanetics/JSONFoundation.git", from: "2.5.0")
// The model only:
.product(name: "JSONFoundation", package: "JSONFoundation")

// Model + peer + codecs + stdio/TCP/SSE transports:
.product(name: "JSONRPC", package: "JSONFoundation")

Any product from the table above can be added individually. For the swift-subprocess transport, depend on JSONRPCSubprocess and enable the trait:

.package(url: "https://github.com/Cocoanetics/JSONFoundation.git", from: "2.5.0",
         traits: ["Subprocess"])

License

BSD 2-Clause — see LICENSE.

Package Metadata

Repository: cocoanetics/jsonfoundation

Default branch: main

README: README.md