From 56923c11609505d37a90e4dafb7c2296be8aef0b Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 30 Sep 2026 06:32:04 +0900 Subject: [PATCH] feat: implement WIP over HTTPS v1 binding --- Cargo.lock | 313 +++++++ README.md | 14 +- crates/wip-https/Cargo.toml | 14 + crates/wip-https/src/batch.rs | 530 +++++++++++ crates/wip-https/src/codec.rs | 477 ++++++++++ crates/wip-https/src/endpoint.rs | 156 ++++ crates/wip-https/src/error.rs | 232 +++++ crates/wip-https/src/json.rs | 1293 +++++++++++++++++++++++++++ crates/wip-https/src/lib.rs | 65 ++ crates/wip-https/src/response.rs | 331 +++++++ crates/wip-https/tests/batch.rs | 184 ++++ crates/wip-https/tests/codecs.rs | 427 +++++++++ crates/wip-https/tests/responses.rs | 236 +++++ crates/wip-https/tests/routes.rs | 83 ++ 14 files changed, 4349 insertions(+), 6 deletions(-) create mode 100644 crates/wip-https/Cargo.toml create mode 100644 crates/wip-https/src/batch.rs create mode 100644 crates/wip-https/src/codec.rs create mode 100644 crates/wip-https/src/endpoint.rs create mode 100644 crates/wip-https/src/error.rs create mode 100644 crates/wip-https/src/json.rs create mode 100644 crates/wip-https/src/lib.rs create mode 100644 crates/wip-https/src/response.rs create mode 100644 crates/wip-https/tests/batch.rs create mode 100644 crates/wip-https/tests/codecs.rs create mode 100644 crates/wip-https/tests/responses.rs create mode 100644 crates/wip-https/tests/routes.rs diff --git a/Cargo.lock b/Cargo.lock index 7b40257..b9a0d18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,44 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "glob" version = "0.3.4" @@ -20,6 +52,120 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -36,12 +182,33 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -111,6 +278,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.119" @@ -133,6 +312,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "target-tuple" version = "1.0.2" @@ -148,6 +338,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "1.1.6+spec-1.1.0" @@ -208,6 +408,24 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "winapi-util" version = "0.1.11" @@ -247,6 +465,18 @@ dependencies = [ "wip-protocol", ] +[[package]] +name = "wip-https" +version = "0.1.0" +dependencies = [ + "base64", + "http", + "serde", + "serde_json", + "url", + "wip-protocol", +] + [[package]] name = "wip-interface" version = "0.1.0" @@ -278,6 +508,89 @@ dependencies = [ "wip-protocol", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/README.md b/README.md index abdaa50..8b83f4b 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,18 @@ Rust building blocks for the Web Interface Protocol (WIP). The repository is a resolver-3 virtual Cargo workspace whose packages all live -under `crates/*`. It provides a transport-independent protocol model, a Rust +under `crates/*`. It provides a transport-independent protocol model, the +standard framework-independent WIP over HTTPS v1 binding, a Rust interface-authoring facade, a request-scoped host runtime, and a private public-API conformance consumer. The repository root is not itself a package. -HTTP and other transport bindings are intentionally outside the supported -slice. ## Crate graph and responsibilities ```text wip-public-api-example (workspace-only executable, integration tests, doctest) ├── wip-protocol logical declarations, values, calls, and validation +├── wip-https standard HTTPS v1 routes, JSON codecs, envelopes, and batching +│ └── wip-protocol ├── wip-interface application DTO codecs and interface authoring facade │ ├── wip-protocol │ └── wip-interface-macros proc-macro implementation detail @@ -25,15 +26,16 @@ wip-public-api-example (workspace-only executable, integration tests, doctest) | Crate | Responsibility | Does not own | | --- | --- | --- | | `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. | +| `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. | | `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. | | `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. | | `wip-host` | Opaque interface registration, explicit object membership, validator checks, and interface-qualified dispatch against one host-owned instance. | Backend atomicity, global object lookup, or network serving. | -| `wip-public-api-example` | An independently compiled consumer of only the three public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. | +| `wip-public-api-example` | An independently compiled consumer of the protocol, authoring, and host public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. | The boundary is intentional: protocol types describe what can cross a transport; application DTOs opt into that model with `WipType`; a host-private -implementation owns credentials, handles, and backend state; and a future -transport maps its own routes/envelopes to `CallOperationRequest` and +implementation owns credentials, handles, and backend state; and `wip-https` +maps standard routes/envelopes to `CallOperationRequest` and `CallOperationResponse` without placing transport-specific types in descriptors or application traits. diff --git a/crates/wip-https/Cargo.toml b/crates/wip-https/Cargo.toml new file mode 100644 index 0000000..64d37a7 --- /dev/null +++ b/crates/wip-https/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "wip-https" +version = "0.1.0" +edition = "2024" +description = "Framework-independent standard WIP over HTTPS v1 binding" +license = "MIT OR Apache-2.0" + +[dependencies] +base64 = "0.22" +http = "1" +serde = "1" +serde_json = "1" +url = "2" +wip-protocol = { path = "../wip-protocol" } diff --git a/crates/wip-https/src/batch.rs b/crates/wip-https/src/batch.rs new file mode 100644 index 0000000..93e368e --- /dev/null +++ b/crates/wip-https/src/batch.rs @@ -0,0 +1,530 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use http::header::CONTENT_TYPE; +use http::{Method, Request, Response, StatusCode}; +use serde_json::{Map, Value as Json}; +use wip_protocol::{ + FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, + FetchTreeResponse, INTERFACE_FORMAT_V1, ProtocolError, ProtocolErrorCode, ProtocolInteraction, +}; + +use crate::response::{self, DecodedResponse}; +use crate::{ + BodyKind, ClientResponseError, CodecError, EncodeHttpError, Endpoint, InvalidResponseKind, + JSON_CONTENT_TYPE, Limits, Route, is_json_content_type, json, +}; + +/// One retrieval interaction accepted by the standard v1 batch endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BatchRequestKind { + /// Fetch one object. + Fetch(FetchRequest), + /// Fetch one bounded tree. + FetchTree(FetchTreeRequest), + /// Fetch one interface descriptor. + FetchInterface(FetchInterfaceRequest), +} + +impl BatchRequestKind { + fn interaction(&self) -> ProtocolInteraction { + match self { + Self::Fetch(_) => ProtocolInteraction::Fetch, + Self::FetchTree(_) => ProtocolInteraction::FetchTree, + Self::FetchInterface(_) => ProtocolInteraction::FetchInterface, + } + } +} + +/// One uniquely identified batch subrequest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchRequestItem { + /// Identifier used only to correlate the corresponding subresponse. + pub id: String, + /// Retrieval interaction and arguments. + pub request: BatchRequestKind, +} + +/// A standard v1 batch request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchRequest { + /// Independent subrequests. Identifiers must be unique. + pub requests: Vec, +} + +/// Successful result of one batch item. +#[derive(Debug, Clone, PartialEq)] +pub enum BatchSuccess { + /// Successful `fetch` result. + Fetch(FetchResponse), + /// Successful `fetch_tree` result. + FetchTree(FetchTreeResponse), + /// Successful `fetch_interface` result. + FetchInterface(FetchInterfaceResponse), +} + +/// One batch subresponse, correlated by `id` rather than array position. +#[derive(Debug, Clone, PartialEq)] +pub struct BatchResponseItem { + /// Identifier copied from its subrequest. + pub id: String, + /// Direct interaction result or canonical item-local protocol failure. + pub outcome: Result, +} + +/// A valid standard v1 batch response. +#[derive(Debug, Clone, PartialEq)] +pub struct BatchResponse { + /// Exactly one response for each requested identifier, in any order. + pub responses: Vec, +} + +fn validate_ids<'a>(ids: impl Iterator, maximum: usize) -> Result<(), CodecError> { + let mut seen = BTreeSet::new(); + let mut count = 0; + for id in ids { + count += 1; + if !seen.insert(id) { + return Err(CodecError::DuplicateBatchId { id: id.into() }); + } + } + if count > maximum { + return Err(CodecError::TooManyBatchItems { + actual: count, + maximum, + }); + } + Ok(()) +} + +fn request_item_to_json(item: &BatchRequestItem) -> Json { + let (method, arguments) = match &item.request { + BatchRequestKind::Fetch(value) => ("fetch", json::fetch_request_to_json(value)), + BatchRequestKind::FetchTree(value) => { + ("fetch_tree", json::fetch_tree_request_to_json(value)) + } + BatchRequestKind::FetchInterface(value) => ( + "fetch_interface", + json::fetch_interface_request_to_json(value), + ), + }; + serde_json::json!({"id": item.id, "method": method, "arguments": arguments}) +} + +fn request_to_json(request: &BatchRequest, limits: Limits) -> Result { + validate_ids( + request.requests.iter().map(|item| item.id.as_str()), + limits.max_batch_items, + )?; + for item in &request.requests { + match &item.request { + BatchRequestKind::Fetch(value) => value.validate()?, + BatchRequestKind::FetchTree(value) => value.validate()?, + BatchRequestKind::FetchInterface(_) => {} + } + } + Ok(serde_json::json!({ + "requests": request.requests.iter().map(request_item_to_json).collect::>() + })) +} + +fn request_from_json(value: &Json, limits: Limits) -> Result { + let fields = exact_object(value, "batch", &["requests"])?; + let values = array(required(fields, "requests")?, "requests")?; + if values.len() > limits.max_batch_items { + return Err(CodecError::TooManyBatchItems { + actual: values.len(), + maximum: limits.max_batch_items, + }); + } + let mut requests = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + let field = format!("requests[{index}]"); + let fields = exact_object(value, &field, &["id", "method", "arguments"])?; + let id = string(required(fields, "id")?, &format!("{field}.id"))?; + let method = string(required(fields, "method")?, &format!("{field}.method"))?; + let arguments = required(fields, "arguments")?; + let request = match method.as_str() { + "fetch" => BatchRequestKind::Fetch(json::fetch_request_from_json(arguments)?), + "fetch_tree" => { + BatchRequestKind::FetchTree(json::fetch_tree_request_from_json(arguments)?) + } + "fetch_interface" => BatchRequestKind::FetchInterface( + json::fetch_interface_request_from_json(arguments)?, + ), + _ => { + return Err(CodecError::InvalidField { + field: format!("{field}.method"), + reason: "batch supports only fetch, fetch_tree, and fetch_interface".into(), + }); + } + }; + requests.push(BatchRequestItem { id, request }); + } + validate_ids( + requests.iter().map(|item| item.id.as_str()), + limits.max_batch_items, + )?; + Ok(BatchRequest { requests }) +} + +/// Encodes a batch request using `POST v1/batch`. +pub fn encode_batch_request( + endpoint: &Endpoint, + value: &BatchRequest, + limits: Limits, +) -> Result>, EncodeHttpError> { + let body = json::serialize(&request_to_json(value, limits)?, BodyKind::Request, limits)?; + Ok(Request::builder() + .method(Method::POST) + .uri(endpoint.route(Route::Batch).as_str()) + .header(CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(body)?) +} + +/// Decodes and validates a batch request body. +pub fn decode_batch_request(body: &[u8], limits: Limits) -> Result { + let value = json::parse(body, BodyKind::Request, limits)?; + request_from_json(&value, limits) +} + +fn success_to_json(success: &BatchSuccess) -> Json { + match success { + BatchSuccess::Fetch(value) => json::object_to_json(value), + BatchSuccess::FetchTree(value) => json::fetch_tree_response_to_json(value), + BatchSuccess::FetchInterface(value) => json::fetch_interface_response_to_json(value), + } +} + +fn response_to_json( + request: &BatchRequest, + response: &BatchResponse, + limits: Limits, +) -> Result { + correlate(request, response, limits)?; + Ok(serde_json::json!({ + "responses": response.responses.iter().map(|item| { + let mut fields = Map::new(); + fields.insert("id".into(), Json::String(item.id.clone())); + match &item.outcome { + Ok(success) => { fields.insert("result".into(), success_to_json(success)); } + Err(error) => { fields.insert("error".into(), response::item_error_to_json(error)); } + } + Json::Object(fields) + }).collect::>() + })) +} + +fn correlate( + request: &BatchRequest, + response: &BatchResponse, + limits: Limits, +) -> Result<(), CodecError> { + validate_ids( + request.requests.iter().map(|item| item.id.as_str()), + limits.max_batch_items, + )?; + validate_ids( + response.responses.iter().map(|item| item.id.as_str()), + limits.max_batch_items, + )?; + let requested: BTreeMap<&str, &BatchRequestKind> = request + .requests + .iter() + .map(|item| (item.id.as_str(), &item.request)) + .collect(); + for item in &response.responses { + let Some(kind) = requested.get(item.id.as_str()) else { + return Err(CodecError::UnexpectedBatchId { + id: item.id.clone(), + }); + }; + match &item.outcome { + Ok(BatchSuccess::Fetch(value)) if matches!(kind, BatchRequestKind::Fetch(_)) => { + if let BatchRequestKind::Fetch(request) = kind { + request.validate_response(value)?; + } + } + Ok(BatchSuccess::FetchTree(value)) + if matches!(kind, BatchRequestKind::FetchTree(_)) => + { + if let BatchRequestKind::FetchTree(request) = kind { + value.validate_for(request)?; + } + } + Ok(BatchSuccess::FetchInterface(value)) + if matches!(kind, BatchRequestKind::FetchInterface(_)) => + { + if let BatchRequestKind::FetchInterface(request) = kind { + value.validate_for(request)?; + } + } + Ok(_) => { + return Err(CodecError::BatchInteractionMismatch { + id: item.id.clone(), + }); + } + Err(error) if error.code.is_allowed_for(kind.interaction()) => {} + Err(_) => { + return Err(CodecError::BatchInteractionMismatch { + id: item.id.clone(), + }); + } + } + } + for item in &request.requests { + if !response.responses.iter().any(|other| other.id == item.id) { + return Err(CodecError::MissingBatchId { + id: item.id.clone(), + }); + } + } + Ok(()) +} + +/// Encodes a valid batch response. Item failures remain inside HTTP 200. +pub fn encode_batch_response( + request: &BatchRequest, + value: &BatchResponse, + limits: Limits, +) -> Result>, EncodeHttpError> { + let body = json::serialize( + &response_to_json(request, value, limits)?, + BodyKind::Response, + limits, + )?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(body)?) +} + +/// Encodes a top-level batch-envelope failure. +/// +/// Item-local failures belong in [`BatchResponseItem`]. Only failures that can +/// reject the batch envelope before item execution are accepted here. +pub fn encode_batch_error_response( + error: &ProtocolError, + limits: Limits, +) -> Result>, EncodeHttpError> { + if !matches!( + error.code, + ProtocolErrorCode::InvalidRequest + | ProtocolErrorCode::ResourceLimitExceeded + | ProtocolErrorCode::Internal + ) { + return Err(CodecError::InvalidField { + field: "error.code".into(), + reason: "code is not valid for a top-level batch failure".into(), + } + .into()); + } + let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?; + Ok(Response::builder() + .status(response::status_for_error(error.code)) + .header(CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(body)?) +} + +fn response_from_json( + value: &Json, + request: &BatchRequest, + limits: Limits, +) -> Result { + let fields = exact_object(value, "batch", &["responses"])?; + let values = array(required(fields, "responses")?, "responses")?; + if values.len() > limits.max_batch_items { + return Err(CodecError::TooManyBatchItems { + actual: values.len(), + maximum: limits.max_batch_items, + }); + } + let requested: BTreeMap<&str, &BatchRequestKind> = request + .requests + .iter() + .map(|item| (item.id.as_str(), &item.request)) + .collect(); + let mut responses = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + let field = format!("responses[{index}]"); + let fields = object(value, &field)?; + for name in fields.keys() { + if !["id", "result", "error"].contains(&name.as_str()) { + return Err(CodecError::UnknownField { + field: format!("{field}.{name}"), + }); + } + } + let id = string(required(fields, "id")?, &format!("{field}.id"))?; + let Some(kind) = requested.get(id.as_str()) else { + return Err(CodecError::UnexpectedBatchId { id }); + }; + let outcome = match (fields.get("result"), fields.get("error")) { + (Some(_), Some(_)) | (None, None) => { + return Err(CodecError::InvalidField { + field, + reason: "batch item must contain exactly one of result or error".into(), + }); + } + (None, Some(error)) => { + let error = response::item_error_from_json(error)?; + if !error.code.is_allowed_for(kind.interaction()) { + return Err(CodecError::BatchInteractionMismatch { id }); + } + Err(error) + } + (Some(result), None) => Ok(match kind { + BatchRequestKind::Fetch(request) => { + let value = json::object_from_json(result, "result")?; + request.validate_response(&value)?; + BatchSuccess::Fetch(value) + } + BatchRequestKind::FetchTree(request) => { + let value = json::fetch_tree_response_from_json(result)?; + value.validate_for(request)?; + BatchSuccess::FetchTree(value) + } + BatchRequestKind::FetchInterface(request) => { + let value = json::fetch_interface_response_from_json(result)?; + value.validate_for(request)?; + BatchSuccess::FetchInterface(value) + } + }), + }; + responses.push(BatchResponseItem { id, outcome }); + } + let response = BatchResponse { responses }; + correlate(request, &response, limits)?; + Ok(response) +} + +fn unsupported_batch_descriptor_format(value: &Json, request: &BatchRequest) -> Option { + let responses = value.as_object()?.get("responses")?.as_array()?; + for item in responses { + let fields = item.as_object()?; + let id = fields.get("id")?.as_str()?; + let request_item = request.requests.iter().find(|item| item.id == id)?; + if matches!(request_item.request, BatchRequestKind::FetchInterface(_)) + && let Some(result) = fields.get("result") + && let Ok(format) = json::fetch_interface_response_format(result) + && format != INTERFACE_FORMAT_V1 + { + return Some(format); + } + } + None +} + +/// Decodes a batch response, including item-local failures within HTTP 200. +pub fn decode_batch_response>( + request: &BatchRequest, + value: &Response, + limits: Limits, +) -> Result, ClientResponseError> { + let valid_content_type = value + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(is_json_content_type); + if !valid_content_type { + if value.status().is_success() { + return Err(ClientResponseError::invalid( + InvalidResponseKind::InvalidBatch, + "missing or non-UTF-8 application/json Content-Type", + )); + } + return Err(response::transport_failure(value.status())); + } + if value.status().is_success() + && let Ok(json) = json::parse(value.body().as_ref(), BodyKind::Response, limits) + && let Some(format) = unsupported_batch_descriptor_format(&json, request) + { + return Err(ClientResponseError::UnsupportedDescriptorFormat { format }); + } + let decoded = response::decode( + value.status(), + value.body().as_ref(), + ProtocolInteraction::Fetch, + limits, + |json| response_from_json(json, request, limits), + ) + .map_err(|error| match error { + ClientResponseError::InvalidResponse { detail, .. } => { + ClientResponseError::invalid(InvalidResponseKind::InvalidBatch, detail) + } + other => other, + })?; + if let DecodedResponse::ProtocolFailure { error, .. } = &decoded + && !matches!( + error.code, + ProtocolErrorCode::InvalidRequest + | ProtocolErrorCode::ResourceLimitExceeded + | ProtocolErrorCode::Internal + ) + { + return Err(ClientResponseError::invalid( + InvalidResponseKind::InvalidBatch, + "protocol code is not valid for a top-level batch failure", + )); + } + if let DecodedResponse::Success(success) = &decoded { + for item in &success.responses { + if let Ok(BatchSuccess::FetchInterface(interface)) = &item.outcome + && interface.descriptor.format != INTERFACE_FORMAT_V1 + { + return Err(ClientResponseError::UnsupportedDescriptorFormat { + format: interface.descriptor.format.clone(), + }); + } + } + } + Ok(decoded) +} + +fn object<'a>(value: &'a Json, field: &str) -> Result<&'a Map, CodecError> { + value.as_object().ok_or_else(|| CodecError::InvalidField { + field: field.into(), + reason: "expected object".into(), + }) +} + +fn exact_object<'a>( + value: &'a Json, + field: &str, + allowed: &[&str], +) -> Result<&'a Map, CodecError> { + let fields = object(value, field)?; + for name in fields.keys() { + if !allowed.contains(&name.as_str()) { + return Err(CodecError::UnknownField { + field: format!("{field}.{name}"), + }); + } + } + Ok(fields) +} + +fn required<'a>(fields: &'a Map, name: &str) -> Result<&'a Json, CodecError> { + fields + .get(name) + .ok_or_else(|| CodecError::MissingField { field: name.into() }) +} + +fn string(value: &Json, field: &str) -> Result { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| CodecError::InvalidField { + field: field.into(), + reason: "expected string".into(), + }) +} + +fn array<'a>(value: &'a Json, field: &str) -> Result<&'a [Json], CodecError> { + value + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| CodecError::InvalidField { + field: field.into(), + reason: "expected array".into(), + }) +} diff --git a/crates/wip-https/src/codec.rs b/crates/wip-https/src/codec.rs new file mode 100644 index 0000000..4ed0fcd --- /dev/null +++ b/crates/wip-https/src/codec.rs @@ -0,0 +1,477 @@ +use http::header::CONTENT_TYPE; +use http::{Method, Request, Response, StatusCode}; +use wip_protocol::{ + CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, + FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, + InterfaceDescriptor, InterfaceTarget, ProtocolError, ProtocolInteraction, Target, +}; + +use crate::response::{self, DecodedResponse}; +use crate::{ + BodyKind, ClientResponseError, CodecError, Endpoint, InvalidResponseKind, JSON_CONTENT_TYPE, + Limits, Route, json, +}; + +fn request( + endpoint: &Endpoint, + route: Route, + body: Vec, +) -> Result>, http::Error> { + Request::builder() + .method(Method::POST) + .uri(endpoint.route(route).as_str()) + .header(CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(body) +} + +fn response(status: StatusCode, body: Vec) -> Result>, http::Error> { + Response::builder() + .status(status) + .header(CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(body) +} + +fn require_response_content_type(response: &Response) -> Result<(), ClientResponseError> { + let valid = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(is_json_content_type); + if valid { + Ok(()) + } else if response.status().is_success() { + Err(ClientResponseError::invalid( + InvalidResponseKind::InvalidSuccessBody, + "missing or non-UTF-8 application/json Content-Type", + )) + } else { + Err(response::transport_failure(response.status())) + } +} + +/// Returns whether a Content-Type value denotes UTF-8 `application/json`. +/// +/// The charset parameter is optional because JSON exchanged by this binding is +/// always UTF-8; when present, its value must be `utf-8`. Other parameters are +/// rejected so intermediaries cannot silently select another representation. +#[must_use] +pub fn is_json_content_type(value: &str) -> bool { + let mut parts = value.split(';'); + if !parts + .next() + .is_some_and(|media| media.trim().eq_ignore_ascii_case("application/json")) + { + return false; + } + let mut saw_charset = false; + for parameter in parts { + let Some((name, value)) = parameter.trim().split_once('=') else { + return false; + }; + let charset = value.trim(); + let charset_is_utf8 = charset.eq_ignore_ascii_case("utf-8") + || charset + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .is_some_and(|value| value.eq_ignore_ascii_case("utf-8")); + if !name.trim().eq_ignore_ascii_case("charset") || !charset_is_utf8 || saw_charset { + return false; + } + saw_charset = true; + } + true +} + +/// Validates mandatory HTTP metadata for an inbound WIP request. +/// +/// Both absolute-form and origin-form request URIs are accepted, but the path +/// must exactly equal the selected endpoint route and a query is forbidden. +pub fn validate_http_request( + endpoint: &Endpoint, + route: Route, + value: &Request, +) -> Result<(), CodecError> { + if value.method() != Method::POST { + return Err(CodecError::InvalidField { + field: "method".into(), + reason: "standard WIP routes require POST".into(), + }); + } + let expected = endpoint.route(route); + if value.uri().path() != expected.path() || value.uri().query().is_some() { + return Err(CodecError::InvalidField { + field: "route".into(), + reason: "request URI does not match the selected standard route".into(), + }); + } + let valid_content_type = value + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(is_json_content_type); + if !valid_content_type { + return Err(CodecError::InvalidField { + field: "content-type".into(), + reason: "expected UTF-8 application/json".into(), + }); + } + Ok(()) +} + +/// Encodes a `fetch` HTTP request. +pub fn encode_fetch_request( + endpoint: &Endpoint, + value: &FetchRequest, + limits: Limits, +) -> Result>, EncodeHttpError> { + value.validate().map_err(CodecError::from)?; + let body = json::serialize( + &json::fetch_request_to_json(value), + BodyKind::Request, + limits, + )?; + Ok(request(endpoint, Route::Fetch, body)?) +} + +/// Decodes a `fetch` request body. +pub fn decode_fetch_request(body: &[u8], limits: Limits) -> Result { + let value = json::parse(body, BodyKind::Request, limits)?; + json::fetch_request_from_json(&value) +} + +/// Encodes a `fetch_tree` HTTP request. +pub fn encode_fetch_tree_request( + endpoint: &Endpoint, + value: &FetchTreeRequest, + limits: Limits, +) -> Result>, EncodeHttpError> { + value.validate().map_err(CodecError::from)?; + let body = json::serialize( + &json::fetch_tree_request_to_json(value), + BodyKind::Request, + limits, + )?; + Ok(request(endpoint, Route::FetchTree, body)?) +} + +/// Decodes a `fetch_tree` request body. +pub fn decode_fetch_tree_request( + body: &[u8], + limits: Limits, +) -> Result { + let value = json::parse(body, BodyKind::Request, limits)?; + json::fetch_tree_request_from_json(&value) +} + +/// Encodes a `fetch_interface` HTTP request. +pub fn encode_fetch_interface_request( + endpoint: &Endpoint, + value: &FetchInterfaceRequest, + limits: Limits, +) -> Result>, EncodeHttpError> { + let body = json::serialize( + &json::fetch_interface_request_to_json(value), + BodyKind::Request, + limits, + )?; + Ok(request(endpoint, Route::FetchInterface, body)?) +} + +/// Decodes a `fetch_interface` request body. +pub fn decode_fetch_interface_request( + body: &[u8], + limits: Limits, +) -> Result { + let value = json::parse(body, BodyKind::Request, limits)?; + json::fetch_interface_request_from_json(&value) +} + +/// Descriptor-independent fields needed to resolve a `call_operation` request. +/// +/// A Host decodes this metadata first, fixes the current interface descriptor, +/// then passes the same body and descriptor to [`decode_call_operation_request`]. +/// This preserves canonical path/interface/operation lookup and error precedence +/// without treating unvalidated argument JSON as a logical [`wip_protocol::Value`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallOperationMetadata { + /// Path and optional object-state precondition. + pub target: Target, + /// Interface reference and optional interface precondition. + pub interface: InterfaceTarget, + /// Operation name used to select a declaration. + pub operation: String, +} + +impl CallOperationMetadata { + /// Decodes the descriptor-bound request and verifies that routing metadata + /// still matches the values used to resolve `descriptor`. + pub fn decode_request( + &self, + body: &[u8], + descriptor: &InterfaceDescriptor, + limits: Limits, + ) -> Result { + let request = decode_call_operation_request(body, descriptor, limits)?; + if request.target != self.target + || request.interface != self.interface + || request.operation != self.operation + { + return Err(CodecError::InvalidField { + field: "request".into(), + reason: "call metadata changed after descriptor resolution".into(), + }); + } + Ok(request) + } +} + +/// Decodes descriptor-independent `call_operation` routing metadata. +pub fn decode_call_operation_metadata( + body: &[u8], + limits: Limits, +) -> Result { + let value = json::parse(body, BodyKind::Request, limits)?; + let (target, interface, operation) = json::call_request_metadata_from_json(&value)?; + Ok(CallOperationMetadata { + target, + interface, + operation, + }) +} + +/// Encodes a descriptor-bound `call_operation` HTTP request. +pub fn encode_call_operation_request( + endpoint: &Endpoint, + value: &CallOperationRequest, + descriptor: &InterfaceDescriptor, + limits: Limits, +) -> Result>, EncodeHttpError> { + let value = json::call_request_to_json(value, descriptor)?; + let body = json::serialize(&value, BodyKind::Request, limits)?; + Ok(request(endpoint, Route::CallOperation, body)?) +} + +/// Decodes a descriptor-bound `call_operation` request body. +pub fn decode_call_operation_request( + body: &[u8], + descriptor: &InterfaceDescriptor, + limits: Limits, +) -> Result { + let value = json::parse(body, BodyKind::Request, limits)?; + json::call_request_from_json(&value, descriptor) +} + +/// Encodes a successful `fetch` response as a direct body with HTTP 200. +pub fn encode_fetch_response( + request_value: &FetchRequest, + value: &FetchResponse, + limits: Limits, +) -> Result>, EncodeHttpError> { + request_value + .validate_response(value) + .map_err(CodecError::from)?; + let body = json::serialize(&json::object_to_json(value), BodyKind::Response, limits)?; + Ok(response(StatusCode::OK, body)?) +} + +/// Decodes and classifies a `fetch` client response. +pub fn decode_fetch_response>( + request_value: &FetchRequest, + value: &Response, + limits: Limits, +) -> Result, ClientResponseError> { + require_response_content_type(value)?; + response::decode( + value.status(), + value.body().as_ref(), + ProtocolInteraction::Fetch, + limits, + |json| { + let object = json::object_from_json(json, "response")?; + request_value.validate_response(&object)?; + Ok(object) + }, + ) +} + +/// Encodes a successful `fetch_tree` response as a direct body with HTTP 200. +pub fn encode_fetch_tree_response( + request_value: &FetchTreeRequest, + value: &FetchTreeResponse, + limits: Limits, +) -> Result>, EncodeHttpError> { + value + .validate_for(request_value) + .map_err(CodecError::from)?; + let body = json::serialize( + &json::fetch_tree_response_to_json(value), + BodyKind::Response, + limits, + )?; + Ok(response(StatusCode::OK, body)?) +} + +/// Decodes and classifies a `fetch_tree` client response. +pub fn decode_fetch_tree_response>( + request_value: &FetchTreeRequest, + value: &Response, + limits: Limits, +) -> Result, ClientResponseError> { + require_response_content_type(value)?; + response::decode( + value.status(), + value.body().as_ref(), + ProtocolInteraction::FetchTree, + limits, + |json| { + let tree = json::fetch_tree_response_from_json(json)?; + tree.validate_for(request_value)?; + Ok(tree) + }, + ) +} + +/// Encodes a successful `fetch_interface` response as a direct body with HTTP 200. +pub fn encode_fetch_interface_response( + request_value: &FetchInterfaceRequest, + value: &FetchInterfaceResponse, + limits: Limits, +) -> Result>, EncodeHttpError> { + value + .validate_for(request_value) + .map_err(CodecError::from)?; + let body = json::serialize( + &json::fetch_interface_response_to_json(value), + BodyKind::Response, + limits, + )?; + Ok(response(StatusCode::OK, body)?) +} + +/// Decodes and classifies a `fetch_interface` client response. +pub fn decode_fetch_interface_response>( + request_value: &FetchInterfaceRequest, + value: &Response, + limits: Limits, +) -> Result, ClientResponseError> { + require_response_content_type(value)?; + if value.status().is_success() + && let Ok(json) = json::parse(value.body().as_ref(), BodyKind::Response, limits) + && let Ok(format) = json::fetch_interface_response_format(&json) + && format != INTERFACE_FORMAT_V1 + { + return Err(ClientResponseError::UnsupportedDescriptorFormat { format }); + } + let decoded = response::decode( + value.status(), + value.body().as_ref(), + ProtocolInteraction::FetchInterface, + limits, + |json| { + let interface = json::fetch_interface_response_from_json(json)?; + interface.validate_for(request_value)?; + Ok(interface) + }, + )?; + if let DecodedResponse::Success(success) = &decoded + && success.descriptor.format != INTERFACE_FORMAT_V1 + { + return Err(ClientResponseError::UnsupportedDescriptorFormat { + format: success.descriptor.format.clone(), + }); + } + Ok(decoded) +} + +/// Encodes a successful `call_operation` response as a direct body with HTTP 200. +pub fn encode_call_operation_response( + request_value: &CallOperationRequest, + descriptor: &InterfaceDescriptor, + value: &CallOperationResponse, + limits: Limits, +) -> Result>, EncodeHttpError> { + let json = json::call_response_to_json(value, descriptor, &request_value.operation)?; + let body = json::serialize(&json, BodyKind::Response, limits)?; + Ok(response(StatusCode::OK, body)?) +} + +/// Decodes and classifies a descriptor-bound `call_operation` client response. +pub fn decode_call_operation_response>( + request_value: &CallOperationRequest, + descriptor: &InterfaceDescriptor, + value: &Response, + limits: Limits, +) -> Result, ClientResponseError> { + require_response_content_type(value)?; + response::decode( + value.status(), + value.body().as_ref(), + ProtocolInteraction::CallOperation, + limits, + |json| json::call_response_from_json(json, descriptor, &request_value.operation), + ) +} + +/// Encodes a canonical Host error envelope using its standard HTTP status. +/// +/// The code must be allowed for `interaction`. This API accepts only +/// [`ProtocolError`], so client-local `InvalidResponse` and +/// `UnsupportedDescriptorFormat` classifications cannot be emitted as Host +/// error envelopes. +pub fn encode_protocol_error_response( + interaction: ProtocolInteraction, + error: &ProtocolError, + limits: Limits, +) -> Result>, EncodeHttpError> { + if !error.code.is_allowed_for(interaction) { + return Err(CodecError::InvalidField { + field: "error.code".into(), + reason: format!( + "code `{}` is not allowed for {interaction:?}", + response::error_code_name(error.code) + ), + } + .into()); + } + let body = json::serialize(&response::error_to_json(error), BodyKind::Response, limits)?; + Ok(response(response::status_for_error(error.code), body)?) +} + +/// Failure while building an outbound framework-neutral HTTP message. +#[derive(Debug)] +pub enum EncodeHttpError { + /// JSON or protocol encoding failed. + Codec(CodecError), + /// The framework-neutral HTTP message builder rejected the URI or headers. + Http(http::Error), +} + +impl std::fmt::Display for EncodeHttpError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Codec(error) => error.fmt(formatter), + Self::Http(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for EncodeHttpError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Codec(error) => Some(error), + Self::Http(error) => Some(error), + } + } +} + +impl From for EncodeHttpError { + fn from(value: CodecError) -> Self { + Self::Codec(value) + } +} + +impl From for EncodeHttpError { + fn from(value: http::Error) -> Self { + Self::Http(value) + } +} diff --git a/crates/wip-https/src/endpoint.rs b/crates/wip-https/src/endpoint.rs new file mode 100644 index 0000000..57e2f2f --- /dev/null +++ b/crates/wip-https/src/endpoint.rs @@ -0,0 +1,156 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +use url::Url; + +/// The media type emitted for every WIP request and every response with a body. +pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; + +/// A fixed standard WIP over HTTPS v1 route. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Route { + /// Fetch one object. + Fetch, + /// Fetch a bounded object tree. + FetchTree, + /// Fetch an interface descriptor. + FetchInterface, + /// Call one operation. + CallOperation, + /// Package independent retrieval interactions. + Batch, +} + +impl Route { + /// Returns the RFC 3986 relative reference used below an endpoint prefix. + #[must_use] + pub const fn relative_reference(self) -> &'static str { + match self { + Self::Fetch => "v1/fetch", + Self::FetchTree => "v1/fetch_tree", + Self::FetchInterface => "v1/fetch_interface", + Self::CallOperation => "v1/call_operation", + Self::Batch => "v1/batch", + } + } +} + +/// A canonical absolute HTTPS base URL whose path always ends in `/`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoint(Url); + +impl Endpoint { + /// Parses and canonicalizes an endpoint. + /// + /// The input must be an absolute HTTPS URL without a query or fragment. Its + /// path is an endpoint prefix, not a Worldspace path. A trailing slash is + /// appended when absent so RFC 3986 relative-reference resolution preserves + /// the final prefix segment. + pub fn parse(input: &str) -> Result { + let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?; + if url.scheme() != "https" { + return Err(EndpointError::NotHttps); + } + if url.cannot_be_a_base() || url.host_str().is_none() { + return Err(EndpointError::NotAbsoluteBase); + } + if url.query().is_some() { + return Err(EndpointError::HasQuery); + } + if url.fragment().is_some() { + return Err(EndpointError::HasFragment); + } + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + Ok(Self(url)) + } + + /// Returns the canonical base URL. + #[must_use] + pub fn as_url(&self) -> &Url { + &self.0 + } + + /// Resolves one fixed v1 route below this endpoint prefix. + #[must_use] + pub fn route(&self, route: Route) -> Url { + self.0 + .join(route.relative_reference()) + .expect("fixed relative WIP routes are valid URL references") + } + + /// Recognizes an exact standard route below this endpoint. + /// + /// Query strings, fragments, additional path segments, and Worldspace paths + /// are not route components and therefore never match. + #[must_use] + pub fn recognize(&self, candidate: &Url) -> Option { + const ROUTES: [Route; 5] = [ + Route::Fetch, + Route::FetchTree, + Route::FetchInterface, + Route::CallOperation, + Route::Batch, + ]; + if candidate.query().is_some() || candidate.fragment().is_some() { + return None; + } + ROUTES + .into_iter() + .find(|route| self.route(*route) == *candidate) + } +} + +impl Display for Endpoint { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl TryFrom<&str> for Endpoint { + type Error = EndpointError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +/// Why an endpoint is not a valid standard WIP HTTPS base. +#[derive(Debug)] +pub enum EndpointError { + /// URL parsing failed. + InvalidUrl(url::ParseError), + /// The URL scheme is not `https`. + NotHttps, + /// The URL is not a hierarchical absolute URL with an authority. + NotAbsoluteBase, + /// A query component was supplied. + HasQuery, + /// A fragment component was supplied. + HasFragment, +} + +impl Display for EndpointError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"), + Self::NotHttps => formatter.write_str("WIP endpoint must use HTTPS"), + Self::NotAbsoluteBase => { + formatter.write_str("WIP endpoint must be an absolute base URL") + } + Self::HasQuery => formatter.write_str("WIP endpoint must not contain a query"), + Self::HasFragment => formatter.write_str("WIP endpoint must not contain a fragment"), + } + } +} + +impl Error for EndpointError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::InvalidUrl(error) => Some(error), + _ => None, + } + } +} diff --git a/crates/wip-https/src/error.rs b/crates/wip-https/src/error.rs new file mode 100644 index 0000000..5b2ddff --- /dev/null +++ b/crates/wip-https/src/error.rs @@ -0,0 +1,232 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +/// Caller-selected hard limits applied before or while decoding JSON. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Largest accepted request body in bytes. + pub(crate) max_request_bytes: usize, + /// Largest accepted response body in bytes. + pub(crate) max_response_bytes: usize, + /// Largest accepted JSON container nesting depth. + pub(crate) max_nesting: usize, + /// Largest accepted number of batch items. + pub(crate) max_batch_items: usize, +} + +impl Limits { + /// Creates a nonzero limit set. + pub fn new( + max_request_bytes: usize, + max_response_bytes: usize, + max_nesting: usize, + max_batch_items: usize, + ) -> Result { + if max_request_bytes == 0 { + return Err(LimitConfigurationError::ZeroRequestBytes); + } + if max_response_bytes == 0 { + return Err(LimitConfigurationError::ZeroResponseBytes); + } + if max_nesting == 0 { + return Err(LimitConfigurationError::ZeroNesting); + } + if max_batch_items == 0 { + return Err(LimitConfigurationError::ZeroBatchItems); + } + Ok(Self { + max_request_bytes, + max_response_bytes, + max_nesting, + max_batch_items, + }) + } + + /// Returns the request body byte limit. + #[must_use] + pub const fn max_request_bytes(self) -> usize { + self.max_request_bytes + } + + /// Returns the response body byte limit. + #[must_use] + pub const fn max_response_bytes(self) -> usize { + self.max_response_bytes + } + + /// Returns the JSON container nesting limit. + #[must_use] + pub const fn max_nesting(self) -> usize { + self.max_nesting + } + + /// Returns the batch item count limit. + #[must_use] + pub const fn max_batch_items(self) -> usize { + self.max_batch_items + } +} + +/// Why a [`Limits`] configuration is not bounded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LimitConfigurationError { + /// Request-byte limit was zero. + ZeroRequestBytes, + /// Response-byte limit was zero. + ZeroResponseBytes, + /// Nesting limit was zero. + ZeroNesting, + /// Batch-item limit was zero. + ZeroBatchItems, +} + +impl Display for LimitConfigurationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::ZeroRequestBytes => "request byte limit must be nonzero", + Self::ZeroResponseBytes => "response byte limit must be nonzero", + Self::ZeroNesting => "nesting limit must be nonzero", + Self::ZeroBatchItems => "batch item limit must be nonzero", + }) + } +} + +impl Error for LimitConfigurationError {} + +/// Whether a body is being decoded as a request or response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyKind { + /// An inbound server request. + Request, + /// An inbound client response. + Response, +} + +/// A malformed or locally disallowed wire representation. +#[derive(Debug)] +pub enum CodecError { + /// The body exceeds its configured byte limit. + BodyTooLarge { + /// Actual body size. + actual: usize, + /// Configured maximum. + maximum: usize, + }, + /// JSON syntax is invalid. + InvalidJson(serde_json::Error), + /// A JSON container exceeds the configured nesting depth. + NestingTooDeep { + /// Configured maximum. + maximum: usize, + }, + /// A required field is absent. + MissingField { + /// Field name. + field: String, + }, + /// An undeclared field is present. + UnknownField { + /// Field name. + field: String, + }, + /// A field has an invalid JSON type or semantic representation. + InvalidField { + /// Field name or value location. + field: String, + /// Stable human-readable reason. + reason: String, + }, + /// An optional field was represented by JSON null rather than omission. + NullOptionalField { + /// Field name. + field: String, + }, + /// Base64 is not canonical RFC 4648 Section 4 encoding. + InvalidBase64 { + /// Field name or value location. + field: String, + }, + /// Logical protocol validation rejected the decoded representation. + ProtocolValidation(wip_protocol::ValidationError), + /// The batch exceeds its configured item count. + TooManyBatchItems { + /// Actual item count. + actual: usize, + /// Configured maximum. + maximum: usize, + }, + /// Batch item identifiers are not unique. + DuplicateBatchId { + /// Repeated identifier. + id: String, + }, + /// A response contains a batch identifier that was not requested. + UnexpectedBatchId { + /// Unexpected identifier. + id: String, + }, + /// A response omitted a requested batch identifier. + MissingBatchId { + /// Missing identifier. + id: String, + }, + /// A batch item result does not match its requested interaction. + BatchInteractionMismatch { + /// Item identifier. + id: String, + }, +} + +impl Display for CodecError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::BodyTooLarge { actual, maximum } => { + write!(formatter, "body has {actual} bytes; maximum is {maximum}") + } + Self::InvalidJson(error) => write!(formatter, "invalid JSON: {error}"), + Self::NestingTooDeep { maximum } => { + write!(formatter, "JSON nesting exceeds maximum {maximum}") + } + Self::MissingField { field } => write!(formatter, "missing field `{field}`"), + Self::UnknownField { field } => write!(formatter, "unknown field `{field}`"), + Self::InvalidField { field, reason } => { + write!(formatter, "invalid field `{field}`: {reason}") + } + Self::NullOptionalField { field } => { + write!( + formatter, + "optional field `{field}` must be omitted instead of null" + ) + } + Self::InvalidBase64 { field } => { + write!(formatter, "field `{field}` is not canonical base64") + } + Self::ProtocolValidation(error) => error.fmt(formatter), + Self::TooManyBatchItems { actual, maximum } => { + write!(formatter, "batch has {actual} items; maximum is {maximum}") + } + Self::DuplicateBatchId { id } => write!(formatter, "duplicate batch id `{id}`"), + Self::UnexpectedBatchId { id } => write!(formatter, "unexpected batch id `{id}`"), + Self::MissingBatchId { id } => write!(formatter, "missing batch id `{id}`"), + Self::BatchInteractionMismatch { id } => { + write!(formatter, "batch result does not match request `{id}`") + } + } + } +} + +impl Error for CodecError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::InvalidJson(error) => Some(error), + Self::ProtocolValidation(error) => Some(error), + _ => None, + } + } +} + +impl From for CodecError { + fn from(value: wip_protocol::ValidationError) -> Self { + Self::ProtocolValidation(value) + } +} diff --git a/crates/wip-https/src/json.rs b/crates/wip-https/src/json.rs new file mode 100644 index 0000000..40ad674 --- /dev/null +++ b/crates/wip-https/src/json.rs @@ -0,0 +1,1293 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value as Json}; +use wip_protocol::{ + CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, + FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, + InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, Object, + ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, + TypeDeclaration, TypeExpr, UnionCase, Value, +}; + +use crate::{BodyKind, CodecError, Limits}; + +struct StrictJson(Json); + +impl<'de> Deserialize<'de> for StrictJson { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(StrictJsonVisitor) + } +} + +struct StrictJsonVisitor; + +impl<'de> Visitor<'de> for StrictJsonVisitor { + type Value = StrictJson; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value without duplicate object members") + } + + fn visit_unit(self) -> Result { + Ok(StrictJson(Json::Null)) + } + + fn visit_bool(self, value: bool) -> Result { + Ok(StrictJson(Json::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(StrictJson(Json::Number(Number::from(value)))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(StrictJson(Json::Number(Number::from(value)))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + Number::from_f64(value) + .map(Json::Number) + .map(StrictJson) + .ok_or_else(|| E::custom("non-finite JSON number")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(StrictJson(Json::String(value.into()))) + } + + fn visit_string(self, value: String) -> Result { + Ok(StrictJson(Json::String(value))) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(StrictJson(value)) = sequence.next_element()? { + values.push(value); + } + Ok(StrictJson(Json::Array(values))) + } + + fn visit_map(self, mut object: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = Map::new(); + while let Some(name) = object.next_key::()? { + if values.contains_key(&name) { + return Err(de::Error::custom(format!( + "duplicate JSON object member `{name}`" + ))); + } + let StrictJson(value) = object.next_value()?; + values.insert(name, value); + } + Ok(StrictJson(Json::Object(values))) + } +} + +pub(crate) fn parse(body: &[u8], kind: BodyKind, limits: Limits) -> Result { + let maximum = match kind { + BodyKind::Request => limits.max_request_bytes, + BodyKind::Response => limits.max_response_bytes, + }; + if body.len() > maximum { + return Err(CodecError::BodyTooLarge { + actual: body.len(), + maximum, + }); + } + let StrictJson(value) = serde_json::from_slice(body).map_err(CodecError::InvalidJson)?; + check_nesting(&value, 0, limits.max_nesting)?; + Ok(value) +} + +pub(crate) fn serialize( + value: &Json, + kind: BodyKind, + limits: Limits, +) -> Result, CodecError> { + let body = serde_json::to_vec(value).expect("serde_json::Value serialization cannot fail"); + let maximum = match kind { + BodyKind::Request => limits.max_request_bytes, + BodyKind::Response => limits.max_response_bytes, + }; + if body.len() > maximum { + return Err(CodecError::BodyTooLarge { + actual: body.len(), + maximum, + }); + } + Ok(body) +} + +fn check_nesting(value: &Json, depth: usize, maximum: usize) -> Result<(), CodecError> { + match value { + Json::Array(items) => { + let next = depth.saturating_add(1); + if next > maximum { + return Err(CodecError::NestingTooDeep { maximum }); + } + for item in items { + check_nesting(item, next, maximum)?; + } + } + Json::Object(fields) => { + let next = depth.saturating_add(1); + if next > maximum { + return Err(CodecError::NestingTooDeep { maximum }); + } + for item in fields.values() { + check_nesting(item, next, maximum)?; + } + } + _ => {} + } + Ok(()) +} + +fn object<'a>(value: &'a Json, field: &str) -> Result<&'a Map, CodecError> { + value + .as_object() + .ok_or_else(|| invalid(field, "expected object")) +} + +fn exact_object<'a>( + value: &'a Json, + field: &str, + allowed: &[&str], +) -> Result<&'a Map, CodecError> { + let object = object(value, field)?; + for name in object.keys() { + if !allowed.contains(&name.as_str()) { + return Err(CodecError::UnknownField { + field: qualified(field, name), + }); + } + } + Ok(object) +} + +fn required<'a>(object: &'a Map, name: &str) -> Result<&'a Json, CodecError> { + object.get(name).ok_or_else(|| CodecError::MissingField { + field: name.to_owned(), + }) +} + +fn optional<'a>(object: &'a Map, name: &str) -> Result, CodecError> { + match object.get(name) { + Some(Json::Null) => Err(CodecError::NullOptionalField { + field: name.to_owned(), + }), + value => Ok(value), + } +} + +fn string(value: &Json, field: &str) -> Result { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid(field, "expected string")) +} + +fn boolean(value: &Json, field: &str) -> Result { + value + .as_bool() + .ok_or_else(|| invalid(field, "expected boolean")) +} + +fn u32_number(value: &Json, field: &str) -> Result { + let number = value + .as_u64() + .ok_or_else(|| invalid(field, "expected unsigned integer"))?; + u32::try_from(number).map_err(|_| invalid(field, "integer exceeds u32")) +} + +fn array<'a>(value: &'a Json, field: &str) -> Result<&'a [Json], CodecError> { + value + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| invalid(field, "expected array")) +} + +fn invalid(field: &str, reason: &str) -> CodecError { + CodecError::InvalidField { + field: field.to_owned(), + reason: reason.to_owned(), + } +} + +fn qualified(parent: &str, child: &str) -> String { + if parent.is_empty() { + child.to_owned() + } else { + format!("{parent}.{child}") + } +} + +fn base64_decode(value: &Json, field: &str) -> Result, CodecError> { + let encoded = value + .as_str() + .ok_or_else(|| invalid(field, "expected base64 string"))?; + let bytes = STANDARD + .decode(encoded) + .map_err(|_| CodecError::InvalidBase64 { + field: field.to_owned(), + })?; + if STANDARD.encode(&bytes) != encoded { + return Err(CodecError::InvalidBase64 { + field: field.to_owned(), + }); + } + Ok(bytes) +} + +fn base64_encode(bytes: &[u8]) -> Json { + Json::String(STANDARD.encode(bytes)) +} + +fn insert_optional_string(map: &mut Map, name: &str, value: &Option) { + if let Some(value) = value { + map.insert(name.into(), Json::String(value.clone())); + } +} + +fn insert_optional_bytes(map: &mut Map, name: &str, value: &Option>) { + if let Some(value) = value { + map.insert(name.into(), base64_encode(value)); + } +} + +pub(crate) fn fetch_request_to_json(request: &FetchRequest) -> Json { + serde_json::json!({"path": request.path}) +} + +pub(crate) fn fetch_request_from_json(value: &Json) -> Result { + let fields = exact_object(value, "request", &["path"])?; + let request = FetchRequest { + path: string(required(fields, "path")?, "path")?, + }; + request.validate()?; + Ok(request) +} + +pub(crate) fn fetch_tree_request_to_json(request: &FetchTreeRequest) -> Json { + serde_json::json!({"path": request.path, "depth": request.depth, "limit": request.limit}) +} + +pub(crate) fn fetch_tree_request_from_json(value: &Json) -> Result { + let fields = exact_object(value, "request", &["path", "depth", "limit"])?; + let request = FetchTreeRequest { + path: string(required(fields, "path")?, "path")?, + depth: u32_number(required(fields, "depth")?, "depth")?, + limit: u32_number(required(fields, "limit")?, "limit")?, + }; + request.validate()?; + Ok(request) +} + +pub(crate) fn fetch_interface_request_to_json(request: &FetchInterfaceRequest) -> Json { + serde_json::json!({"interface": request.interface}) +} + +pub(crate) fn fetch_interface_request_from_json( + value: &Json, +) -> Result { + let fields = exact_object(value, "request", &["interface"])?; + Ok(FetchInterfaceRequest { + interface: string(required(fields, "interface")?, "interface")?, + }) +} + +pub(crate) fn object_to_json(object: &Object) -> Json { + let mut fields = Map::new(); + fields.insert("name".into(), Json::String(object.name.clone())); + insert_optional_string(&mut fields, "description", &object.description); + fields.insert( + "interfaces".into(), + Json::Array( + object + .interfaces + .iter() + .cloned() + .map(Json::String) + .collect(), + ), + ); + insert_optional_string(&mut fields, "ref", &object.r#ref); + insert_optional_bytes(&mut fields, "validator", &object.validator); + Json::Object(fields) +} + +pub(crate) fn object_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object( + value, + field, + &["name", "description", "interfaces", "ref", "validator"], + )?; + let interfaces = array(required(fields, "interfaces")?, "interfaces")? + .iter() + .enumerate() + .map(|(index, value)| string(value, &format!("interfaces[{index}]"))) + .collect::, _>>()?; + let object = Object { + name: string(required(fields, "name")?, "name")?, + description: optional(fields, "description")? + .map(|value| string(value, "description")) + .transpose()?, + interfaces, + r#ref: optional(fields, "ref")? + .map(|value| string(value, "ref")) + .transpose()?, + validator: optional(fields, "validator")? + .map(|value| base64_decode(value, "validator")) + .transpose()?, + }; + object.validate()?; + Ok(object) +} + +fn tree_node_to_json(node: &ObjectTreeNode) -> Json { + serde_json::json!({ + "object": object_to_json(&node.object), + "children": node.children.iter().map(tree_node_to_json).collect::>() + }) +} + +fn tree_node_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["object", "children"])?; + Ok(ObjectTreeNode { + object: object_from_json(required(fields, "object")?, &qualified(field, "object"))?, + children: array(required(fields, "children")?, &qualified(field, "children"))? + .iter() + .enumerate() + .map(|(index, child)| tree_node_from_json(child, &format!("{field}.children[{index}]"))) + .collect::, _>>()?, + }) +} + +pub(crate) fn fetch_tree_response_to_json(response: &FetchTreeResponse) -> Json { + serde_json::json!({"root": tree_node_to_json(&response.root), "truncated": response.truncated}) +} + +pub(crate) fn fetch_tree_response_from_json(value: &Json) -> Result { + let fields = exact_object(value, "response", &["root", "truncated"])?; + Ok(FetchTreeResponse { + root: tree_node_from_json(required(fields, "root")?, "root")?, + truncated: boolean(required(fields, "truncated")?, "truncated")?, + }) +} + +fn documentation_to_json(documentation: &Documentation) -> Json { + let mut fields = Map::new(); + fields.insert( + "summary".into(), + Json::String(documentation.summary.clone()), + ); + insert_optional_string(&mut fields, "details", &documentation.details); + Json::Object(fields) +} + +fn documentation_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["summary", "details"])?; + Ok(Documentation { + summary: string(required(fields, "summary")?, &qualified(field, "summary"))?, + details: optional(fields, "details")? + .map(|value| string(value, &qualified(field, "details"))) + .transpose()?, + }) +} + +fn type_expr_to_json(r#type: &TypeExpr) -> Json { + let mut fields = Map::new(); + let kind = match r#type { + TypeExpr::Unit => "unit", + TypeExpr::Boolean => "boolean", + TypeExpr::Integer => "integer", + TypeExpr::Number => "number", + TypeExpr::String => "string", + TypeExpr::Bytes => "bytes", + TypeExpr::Json => "json", + TypeExpr::Entry => "entry", + TypeExpr::Named { name } => { + fields.insert("name".into(), Json::String(name.clone())); + "named" + } + TypeExpr::Record { + fields: record_fields, + } => { + fields.insert( + "fields".into(), + Json::Array(record_fields.iter().map(field_to_json).collect()), + ); + "record" + } + TypeExpr::List { items } => { + fields.insert("items".into(), type_expr_to_json(items)); + "list" + } + TypeExpr::Enum { cases } => { + fields.insert( + "cases".into(), + Json::Array(cases.iter().map(enum_case_to_json).collect()), + ); + "enum" + } + TypeExpr::Union { cases } => { + fields.insert( + "cases".into(), + Json::Array(cases.iter().map(union_case_to_json).collect()), + ); + "union" + } + }; + fields.insert("kind".into(), Json::String(kind.into())); + Json::Object(fields) +} + +fn type_expr_from_json(value: &Json, field: &str) -> Result { + let fields = object(value, field)?; + let kind = string(required(fields, "kind")?, &qualified(field, "kind"))?; + let allowed: &[&str] = match kind.as_str() { + "unit" | "boolean" | "integer" | "number" | "string" | "bytes" | "json" | "entry" => { + &["kind"] + } + "named" => &["kind", "name"], + "record" => &["kind", "fields"], + "list" => &["kind", "items"], + "enum" | "union" => &["kind", "cases"], + _ => return Err(invalid(&qualified(field, "kind"), "unknown type kind")), + }; + exact_object(value, field, allowed)?; + Ok(match kind.as_str() { + "unit" => TypeExpr::Unit, + "boolean" => TypeExpr::Boolean, + "integer" => TypeExpr::Integer, + "number" => TypeExpr::Number, + "string" => TypeExpr::String, + "bytes" => TypeExpr::Bytes, + "json" => TypeExpr::Json, + "entry" => TypeExpr::Entry, + "named" => TypeExpr::Named { + name: string(required(fields, "name")?, &qualified(field, "name"))?, + }, + "record" => TypeExpr::Record { + fields: array(required(fields, "fields")?, &qualified(field, "fields"))? + .iter() + .enumerate() + .map(|(index, value)| field_from_json(value, &format!("{field}.fields[{index}]"))) + .collect::, _>>()?, + }, + "list" => TypeExpr::List { + items: Box::new(type_expr_from_json( + required(fields, "items")?, + &qualified(field, "items"), + )?), + }, + "enum" => TypeExpr::Enum { + cases: array(required(fields, "cases")?, &qualified(field, "cases"))? + .iter() + .enumerate() + .map(|(index, value)| { + enum_case_from_json(value, &format!("{field}.cases[{index}]")) + }) + .collect::, _>>()?, + }, + "union" => TypeExpr::Union { + cases: array(required(fields, "cases")?, &qualified(field, "cases"))? + .iter() + .enumerate() + .map(|(index, value)| { + union_case_from_json(value, &format!("{field}.cases[{index}]")) + }) + .collect::, _>>()?, + }, + _ => unreachable!("kind checked above"), + }) +} + +fn field_to_json(field: &FieldDeclaration) -> Json { + let mut fields = Map::new(); + fields.insert("name".into(), Json::String(field.name.clone())); + fields.insert("required".into(), Json::Bool(field.required)); + if let Some(documentation) = &field.documentation { + fields.insert("documentation".into(), documentation_to_json(documentation)); + } + fields.insert("type".into(), type_expr_to_json(&field.r#type)); + Json::Object(fields) +} + +fn field_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["name", "required", "documentation", "type"])?; + Ok(FieldDeclaration { + name: string(required(fields, "name")?, &qualified(field, "name"))?, + required: boolean(required(fields, "required")?, &qualified(field, "required"))?, + documentation: optional(fields, "documentation")? + .map(|value| documentation_from_json(value, &qualified(field, "documentation"))) + .transpose()?, + r#type: type_expr_from_json(required(fields, "type")?, &qualified(field, "type"))?, + }) +} + +fn enum_case_to_json(case: &EnumCase) -> Json { + let mut fields = Map::new(); + fields.insert("name".into(), Json::String(case.name.clone())); + if let Some(documentation) = &case.documentation { + fields.insert("documentation".into(), documentation_to_json(documentation)); + } + Json::Object(fields) +} + +fn enum_case_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["name", "documentation"])?; + Ok(EnumCase { + name: string(required(fields, "name")?, &qualified(field, "name"))?, + documentation: optional(fields, "documentation")? + .map(|value| documentation_from_json(value, &qualified(field, "documentation"))) + .transpose()?, + }) +} + +fn union_case_to_json(case: &UnionCase) -> Json { + let mut fields = Map::new(); + fields.insert("name".into(), Json::String(case.name.clone())); + if let Some(documentation) = &case.documentation { + fields.insert("documentation".into(), documentation_to_json(documentation)); + } + if let Some(payload) = &case.payload { + fields.insert("payload".into(), type_expr_to_json(payload)); + } + Json::Object(fields) +} + +fn union_case_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["name", "documentation", "payload"])?; + Ok(UnionCase { + name: string(required(fields, "name")?, &qualified(field, "name"))?, + documentation: optional(fields, "documentation")? + .map(|value| documentation_from_json(value, &qualified(field, "documentation"))) + .transpose()?, + payload: optional(fields, "payload")? + .map(|value| type_expr_from_json(value, &qualified(field, "payload"))) + .transpose()?, + }) +} + +fn declaration_to_json(declaration: &TypeDeclaration) -> Json { + let mut fields = Map::new(); + fields.insert("name".into(), Json::String(declaration.name.clone())); + if let Some(documentation) = &declaration.documentation { + fields.insert("documentation".into(), documentation_to_json(documentation)); + } + fields.insert( + "definition".into(), + type_expr_to_json(&declaration.definition), + ); + Json::Object(fields) +} + +fn declaration_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["name", "documentation", "definition"])?; + Ok(TypeDeclaration { + name: string(required(fields, "name")?, &qualified(field, "name"))?, + documentation: optional(fields, "documentation")? + .map(|value| documentation_from_json(value, &qualified(field, "documentation"))) + .transpose()?, + definition: type_expr_from_json( + required(fields, "definition")?, + &qualified(field, "definition"), + )?, + }) +} + +fn parameter_to_json(parameter: &ParameterDeclaration) -> Json { + let mut fields = Map::new(); + fields.insert("name".into(), Json::String(parameter.name.clone())); + fields.insert("required".into(), Json::Bool(parameter.required)); + if let Some(documentation) = ¶meter.documentation { + fields.insert("documentation".into(), documentation_to_json(documentation)); + } + fields.insert("type".into(), type_expr_to_json(¶meter.r#type)); + Json::Object(fields) +} + +fn parameter_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["name", "required", "documentation", "type"])?; + Ok(ParameterDeclaration { + name: string(required(fields, "name")?, &qualified(field, "name"))?, + required: boolean(required(fields, "required")?, &qualified(field, "required"))?, + documentation: optional(fields, "documentation")? + .map(|value| documentation_from_json(value, &qualified(field, "documentation"))) + .transpose()?, + r#type: type_expr_from_json(required(fields, "type")?, &qualified(field, "type"))?, + }) +} + +fn operation_to_json(operation: &OperationDeclaration) -> Json { + let mut fields = Map::new(); + fields.insert("name".into(), Json::String(operation.name.clone())); + if let Some(documentation) = &operation.documentation { + fields.insert("documentation".into(), documentation_to_json(documentation)); + } + fields.insert( + "parameters".into(), + Json::Array(operation.parameters.iter().map(parameter_to_json).collect()), + ); + let mut returns = Map::new(); + if let Some(documentation) = &operation.returns.documentation { + returns.insert("documentation".into(), documentation_to_json(documentation)); + } + returns.insert("type".into(), type_expr_to_json(&operation.returns.r#type)); + fields.insert("returns".into(), Json::Object(returns)); + Json::Object(fields) +} + +fn operation_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object( + value, + field, + &["name", "documentation", "parameters", "returns"], + )?; + let returns_field = qualified(field, "returns"); + let returns = exact_object( + required(fields, "returns")?, + &returns_field, + &["documentation", "type"], + )?; + Ok(OperationDeclaration { + name: string(required(fields, "name")?, &qualified(field, "name"))?, + documentation: optional(fields, "documentation")? + .map(|value| documentation_from_json(value, &qualified(field, "documentation"))) + .transpose()?, + parameters: array( + required(fields, "parameters")?, + &qualified(field, "parameters"), + )? + .iter() + .enumerate() + .map(|(index, value)| parameter_from_json(value, &format!("{field}.parameters[{index}]"))) + .collect::, _>>()?, + returns: ReturnDeclaration { + documentation: optional(returns, "documentation")? + .map(|value| { + documentation_from_json(value, &qualified(&returns_field, "documentation")) + }) + .transpose()?, + r#type: type_expr_from_json( + required(returns, "type")?, + &qualified(&returns_field, "type"), + )?, + }, + }) +} + +pub(crate) fn descriptor_to_json(descriptor: &InterfaceDescriptor) -> Json { + let mut fields = Map::new(); + fields.insert("format".into(), Json::String(descriptor.format.clone())); + if let Some(documentation) = &descriptor.documentation { + fields.insert("documentation".into(), documentation_to_json(documentation)); + } + fields.insert( + "types".into(), + Json::Array(descriptor.types.iter().map(declaration_to_json).collect()), + ); + fields.insert( + "operations".into(), + Json::Array( + descriptor + .operations + .iter() + .map(operation_to_json) + .collect(), + ), + ); + Json::Object(fields) +} + +pub(crate) fn descriptor_from_json( + value: &Json, + field: &str, +) -> Result { + let fields = exact_object( + value, + field, + &["format", "documentation", "types", "operations"], + )?; + let descriptor = InterfaceDescriptor { + format: string(required(fields, "format")?, &qualified(field, "format"))?, + documentation: optional(fields, "documentation")? + .map(|value| documentation_from_json(value, &qualified(field, "documentation"))) + .transpose()?, + types: array(required(fields, "types")?, &qualified(field, "types"))? + .iter() + .enumerate() + .map(|(index, value)| declaration_from_json(value, &format!("{field}.types[{index}]"))) + .collect::, _>>()?, + operations: array( + required(fields, "operations")?, + &qualified(field, "operations"), + )? + .iter() + .enumerate() + .map(|(index, value)| operation_from_json(value, &format!("{field}.operations[{index}]"))) + .collect::, _>>()?, + }; + descriptor.validate()?; + Ok(descriptor) +} + +pub(crate) fn fetch_interface_response_to_json(response: &FetchInterfaceResponse) -> Json { + let mut fields = Map::new(); + fields.insert("interface".into(), Json::String(response.interface.clone())); + fields.insert( + "descriptor".into(), + descriptor_to_json(&response.descriptor), + ); + insert_optional_bytes(&mut fields, "validator", &response.validator); + Json::Object(fields) +} + +pub(crate) fn fetch_interface_response_format(value: &Json) -> Result { + let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?; + string(required(fields, "interface")?, "interface")?; + let descriptor = object(required(fields, "descriptor")?, "descriptor")?; + string(required(descriptor, "format")?, "descriptor.format") +} + +pub(crate) fn fetch_interface_response_from_json( + value: &Json, +) -> Result { + let fields = exact_object(value, "response", &["interface", "descriptor", "validator"])?; + Ok(FetchInterfaceResponse { + interface: string(required(fields, "interface")?, "interface")?, + descriptor: descriptor_from_json(required(fields, "descriptor")?, "descriptor")?, + validator: optional(fields, "validator")? + .map(|value| base64_decode(value, "validator")) + .transpose()?, + }) +} + +fn declaration<'a>(descriptor: &'a InterfaceDescriptor, name: &str) -> Option<&'a TypeExpr> { + descriptor + .types + .iter() + .find(|declaration| declaration.name == name) + .map(|declaration| &declaration.definition) +} + +fn type_accepts_json_null(r#type: &TypeExpr, descriptor: &InterfaceDescriptor) -> bool { + match r#type { + TypeExpr::Unit | TypeExpr::Json => true, + TypeExpr::Named { name } => declaration(descriptor, name) + .is_some_and(|definition| type_accepts_json_null(definition, descriptor)), + _ => false, + } +} + +pub(crate) fn protocol_value_to_json( + value: &Value, + r#type: &TypeExpr, + descriptor: &InterfaceDescriptor, + field: &str, +) -> Result { + descriptor.validate_value(r#type, value)?; + value_to_json_unchecked(value, r#type, descriptor, field) +} + +fn value_to_json_unchecked( + value: &Value, + r#type: &TypeExpr, + descriptor: &InterfaceDescriptor, + field: &str, +) -> Result { + if let TypeExpr::Named { name } = r#type { + let definition = + declaration(descriptor, name).ok_or_else(|| invalid(field, "unknown named type"))?; + return value_to_json_unchecked(value, definition, descriptor, field); + } + Ok(match (r#type, value) { + (TypeExpr::Unit, Value::Unit) => Json::Null, + (TypeExpr::Boolean, Value::Boolean(value)) => Json::Bool(*value), + (TypeExpr::Integer, Value::Integer(value)) => Json::Number(Number::from(*value)), + (TypeExpr::Number, Value::Number(value)) => Json::Number( + Number::from_f64(*value).ok_or_else(|| invalid(field, "non-finite number"))?, + ), + (TypeExpr::String | TypeExpr::Entry | TypeExpr::Enum { .. }, Value::String(value)) => { + Json::String(value.clone()) + } + (TypeExpr::Bytes, Value::Bytes(value)) => base64_encode(value), + (TypeExpr::Json, value) => json_value_to_json(value, field)?, + (TypeExpr::List { items }, Value::List(values)) => Json::Array( + values + .iter() + .enumerate() + .map(|(index, value)| { + value_to_json_unchecked(value, items, descriptor, &format!("{field}[{index}]")) + }) + .collect::, _>>()?, + ), + (TypeExpr::Record { fields }, Value::Record(values)) => { + let mut object = Map::new(); + for declaration in fields { + if let Some(value) = values.get(&declaration.name) { + object.insert( + declaration.name.clone(), + value_to_json_unchecked( + value, + &declaration.r#type, + descriptor, + &qualified(field, &declaration.name), + )?, + ); + } + } + Json::Object(object) + } + (TypeExpr::Union { cases }, Value::Record(values)) => { + let Value::String(case_name) = &values["$case"] else { + return Err(invalid(field, "invalid union discriminator")); + }; + let case = cases + .iter() + .find(|case| case.name == *case_name) + .ok_or_else(|| invalid(field, "unknown union case"))?; + let mut object = Map::new(); + object.insert("$case".into(), Json::String(case_name.clone())); + if let Some(payload_type) = &case.payload { + object.insert( + "value".into(), + value_to_json_unchecked( + &values["value"], + payload_type, + descriptor, + &qualified(field, "value"), + )?, + ); + } + Json::Object(object) + } + _ => return Err(invalid(field, "value does not match descriptor")), + }) +} + +fn json_value_to_json(value: &Value, field: &str) -> Result { + Ok(match value { + Value::Unit => Json::Null, + Value::Boolean(value) => Json::Bool(*value), + Value::Integer(value) if (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(value) => { + Json::Number(Number::from(*value)) + } + Value::Integer(_) => return Err(invalid(field, "integer outside safe range")), + Value::Number(value) => Json::Number( + Number::from_f64(*value).ok_or_else(|| invalid(field, "non-finite number"))?, + ), + Value::String(value) => Json::String(value.clone()), + Value::Bytes(_) => return Err(invalid(field, "bytes are not valid under Json")), + Value::Record(values) => Json::Object( + values + .iter() + .map(|(name, value)| { + Ok(( + name.clone(), + json_value_to_json(value, &qualified(field, name))?, + )) + }) + .collect::, CodecError>>()?, + ), + Value::List(values) => Json::Array( + values + .iter() + .enumerate() + .map(|(index, value)| json_value_to_json(value, &format!("{field}[{index}]"))) + .collect::, _>>()?, + ), + }) +} + +pub(crate) fn protocol_value_from_json( + value: &Json, + r#type: &TypeExpr, + descriptor: &InterfaceDescriptor, + field: &str, +) -> Result { + let value = value_from_json_unchecked(value, r#type, descriptor, field)?; + descriptor.validate_value(r#type, &value)?; + Ok(value) +} + +fn value_from_json_unchecked( + value: &Json, + r#type: &TypeExpr, + descriptor: &InterfaceDescriptor, + field: &str, +) -> Result { + if let TypeExpr::Named { name } = r#type { + let definition = + declaration(descriptor, name).ok_or_else(|| invalid(field, "unknown named type"))?; + return value_from_json_unchecked(value, definition, descriptor, field); + } + match r#type { + TypeExpr::Unit => match value { + Json::Null => Ok(Value::Unit), + _ => Err(invalid(field, "expected null for Unit")), + }, + TypeExpr::Boolean => Ok(Value::Boolean(boolean(value, field)?)), + TypeExpr::Integer => { + let integer = value + .as_i64() + .filter(|value| (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(value)) + .ok_or_else(|| invalid(field, "expected safe integer"))?; + Ok(Value::Integer(integer)) + } + TypeExpr::Number => { + let number = value + .as_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| invalid(field, "expected finite number"))?; + Ok(Value::Number(number)) + } + TypeExpr::String => Ok(Value::String(string(value, field)?)), + TypeExpr::Bytes => Ok(Value::Bytes(base64_decode(value, field)?)), + TypeExpr::Entry => { + let path = string(value, field)?; + wip_protocol::validate_path(&path)?; + Ok(Value::String(path)) + } + TypeExpr::Json => json_value_from_json(value, field), + TypeExpr::Enum { cases } => { + let case = string(value, field)?; + if !cases.iter().any(|declared| declared.name == case) { + return Err(invalid(field, "unknown enum case")); + } + Ok(Value::String(case)) + } + TypeExpr::List { items } => Ok(Value::List( + array(value, field)? + .iter() + .enumerate() + .map(|(index, item)| { + value_from_json_unchecked(item, items, descriptor, &format!("{field}[{index}]")) + }) + .collect::, _>>()?, + )), + TypeExpr::Record { fields } => { + let object = object(value, field)?; + let declared: BTreeSet<&str> = fields.iter().map(|field| field.name.as_str()).collect(); + for name in object.keys() { + if !declared.contains(name.as_str()) { + return Err(CodecError::UnknownField { + field: qualified(field, name), + }); + } + } + let mut record = BTreeMap::new(); + for declaration in fields { + match object.get(&declaration.name) { + Some(Json::Null) + if !type_accepts_json_null(&declaration.r#type, descriptor) => + { + return Err(CodecError::NullOptionalField { + field: qualified(field, &declaration.name), + }); + } + Some(value) => { + record.insert( + declaration.name.clone(), + value_from_json_unchecked( + value, + &declaration.r#type, + descriptor, + &qualified(field, &declaration.name), + )?, + ); + } + None if declaration.required => { + return Err(CodecError::MissingField { + field: qualified(field, &declaration.name), + }); + } + None => {} + } + } + Ok(Value::Record(record)) + } + TypeExpr::Union { cases } => { + let object = exact_object(value, field, &["$case", "value"])?; + let case_name = string(required(object, "$case")?, &qualified(field, "$case"))?; + let case = cases + .iter() + .find(|case| case.name == case_name) + .ok_or_else(|| invalid(field, "unknown union case"))?; + let mut record = BTreeMap::new(); + record.insert("$case".into(), Value::String(case_name)); + match (&case.payload, object.get("value")) { + (Some(_), None) => { + return Err(CodecError::MissingField { + field: qualified(field, "value"), + }); + } + (None, Some(_)) => return Err(invalid(field, "payload-free union has value")), + (Some(payload), Some(value)) => { + record.insert( + "value".into(), + value_from_json_unchecked( + value, + payload, + descriptor, + &qualified(field, "value"), + )?, + ); + } + (None, None) => {} + } + Ok(Value::Record(record)) + } + TypeExpr::Named { .. } => unreachable!("named handled above"), + } +} + +fn json_value_from_json(value: &Json, field: &str) -> Result { + match value { + Json::Null => Ok(Value::Unit), + Json::Bool(value) => Ok(Value::Boolean(*value)), + Json::Number(value) => { + if let Some(integer) = value.as_i64() + && (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&integer) + { + return Ok(Value::Integer(integer)); + } + let number = value + .as_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| invalid(field, "number is not finite or interoperable"))?; + Ok(Value::Number(number)) + } + Json::String(value) => Ok(Value::String(value.clone())), + Json::Array(values) => Ok(Value::List( + values + .iter() + .enumerate() + .map(|(index, value)| json_value_from_json(value, &format!("{field}[{index}]"))) + .collect::, _>>()?, + )), + Json::Object(values) => Ok(Value::Record( + values + .iter() + .map(|(name, value)| { + Ok(( + name.clone(), + json_value_from_json(value, &qualified(field, name))?, + )) + }) + .collect::, CodecError>>()?, + )), + } +} + +fn target_to_json(target: &Target) -> Json { + let mut fields = Map::new(); + fields.insert("path".into(), Json::String(target.path.clone())); + insert_optional_bytes(&mut fields, "validator", &target.validator); + Json::Object(fields) +} + +fn target_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["path", "validator"])?; + Ok(Target { + path: string(required(fields, "path")?, &qualified(field, "path"))?, + validator: optional(fields, "validator")? + .map(|value| base64_decode(value, &qualified(field, "validator"))) + .transpose()?, + }) +} + +fn interface_target_to_json(target: &InterfaceTarget) -> Json { + let mut fields = Map::new(); + fields.insert("reference".into(), Json::String(target.reference.clone())); + insert_optional_bytes(&mut fields, "validator", &target.validator); + Json::Object(fields) +} + +fn interface_target_from_json(value: &Json, field: &str) -> Result { + let fields = exact_object(value, field, &["reference", "validator"])?; + Ok(InterfaceTarget { + reference: string( + required(fields, "reference")?, + &qualified(field, "reference"), + )?, + validator: optional(fields, "validator")? + .map(|value| base64_decode(value, &qualified(field, "validator"))) + .transpose()?, + }) +} + +pub(crate) fn call_request_metadata_from_json( + value: &Json, +) -> Result<(Target, InterfaceTarget, String), CodecError> { + let fields = exact_object( + value, + "request", + &["target", "interface", "operation", "arguments"], + )?; + object(required(fields, "arguments")?, "arguments")?; + let target = target_from_json(required(fields, "target")?, "target")?; + wip_protocol::validate_path(&target.path)?; + Ok(( + target, + interface_target_from_json(required(fields, "interface")?, "interface")?, + string(required(fields, "operation")?, "operation")?, + )) +} + +pub(crate) fn call_request_to_json( + request: &CallOperationRequest, + descriptor: &InterfaceDescriptor, +) -> Result { + request.validate_with(descriptor)?; + let operation = descriptor + .operations + .iter() + .find(|operation| operation.name == request.operation) + .expect("validated operation exists"); + let mut arguments = Map::new(); + for parameter in &operation.parameters { + if let Some(value) = request.arguments.get(¶meter.name) { + arguments.insert( + parameter.name.clone(), + protocol_value_to_json(value, ¶meter.r#type, descriptor, ¶meter.name)?, + ); + } + } + Ok(serde_json::json!({ + "target": target_to_json(&request.target), + "interface": interface_target_to_json(&request.interface), + "operation": request.operation, + "arguments": arguments, + })) +} + +pub(crate) fn call_request_from_json( + value: &Json, + descriptor: &InterfaceDescriptor, +) -> Result { + descriptor.validate()?; + let fields = exact_object( + value, + "request", + &["target", "interface", "operation", "arguments"], + )?; + let operation_name = string(required(fields, "operation")?, "operation")?; + let operation = descriptor + .operations + .iter() + .find(|operation| operation.name == operation_name) + .ok_or_else(|| invalid("operation", "operation is not declared"))?; + let argument_fields = exact_object( + required(fields, "arguments")?, + "arguments", + &operation + .parameters + .iter() + .map(|parameter| parameter.name.as_str()) + .collect::>(), + )?; + let mut arguments = BTreeMap::new(); + for parameter in &operation.parameters { + match argument_fields.get(¶meter.name) { + Some(Json::Null) if !type_accepts_json_null(¶meter.r#type, descriptor) => { + return Err(CodecError::NullOptionalField { + field: qualified("arguments", ¶meter.name), + }); + } + Some(value) => { + arguments.insert( + parameter.name.clone(), + protocol_value_from_json( + value, + ¶meter.r#type, + descriptor, + &qualified("arguments", ¶meter.name), + )?, + ); + } + None if parameter.required => { + return Err(CodecError::MissingField { + field: qualified("arguments", ¶meter.name), + }); + } + None => {} + } + } + let request = CallOperationRequest { + target: target_from_json(required(fields, "target")?, "target")?, + interface: interface_target_from_json(required(fields, "interface")?, "interface")?, + operation: operation_name, + arguments, + }; + request.validate_with(descriptor)?; + Ok(request) +} + +pub(crate) fn call_response_to_json( + response: &CallOperationResponse, + descriptor: &InterfaceDescriptor, + operation_name: &str, +) -> Result { + response.validate_for(descriptor, operation_name)?; + let operation = descriptor + .operations + .iter() + .find(|operation| operation.name == operation_name) + .expect("validated operation exists"); + let mut fields = Map::new(); + fields.insert( + "result".into(), + protocol_value_to_json( + &response.result, + &operation.returns.r#type, + descriptor, + "result", + )?, + ); + insert_optional_bytes(&mut fields, "validator", &response.validator); + Ok(Json::Object(fields)) +} + +pub(crate) fn call_response_from_json( + value: &Json, + descriptor: &InterfaceDescriptor, + operation_name: &str, +) -> Result { + descriptor.validate()?; + let operation = descriptor + .operations + .iter() + .find(|operation| operation.name == operation_name) + .ok_or_else(|| invalid("operation", "operation is not declared"))?; + let fields = exact_object(value, "response", &["result", "validator"])?; + let response = CallOperationResponse { + result: protocol_value_from_json( + required(fields, "result")?, + &operation.returns.r#type, + descriptor, + "result", + )?, + validator: optional(fields, "validator")? + .map(|value| base64_decode(value, "validator")) + .transpose()?, + }; + response.validate_for(descriptor, operation_name)?; + Ok(response) +} diff --git a/crates/wip-https/src/lib.rs b/crates/wip-https/src/lib.rs new file mode 100644 index 0000000..31ee7a1 --- /dev/null +++ b/crates/wip-https/src/lib.rs @@ -0,0 +1,65 @@ +//! Framework-independent standard WIP over HTTPS v1 binding. +//! +//! This crate owns endpoint and route derivation, UTF-8 JSON encoding, canonical +//! success and error response handling, HTTP status mapping, descriptor-guided +//! operation-value conversion, and retrieval batching. It deliberately performs +//! no I/O and depends on no server framework, TLS implementation, filesystem +//! projection, client cache, or presentation layer. +//! +//! Logical values remain owned by [`wip_protocol`]. Validators and `Bytes` are +//! encoded in JSON bodies using canonical RFC 4648 Section 4 base64; this crate +//! does not infer WIP validator semantics from `ETag` or `If-Match`. +//! +//! ``` +//! use wip_https::{Endpoint, Limits, encode_fetch_request}; +//! use wip_protocol::FetchRequest; +//! +//! let endpoint = Endpoint::parse("https://example.test/api/wip")?; +//! let limits = Limits::new(64 * 1024, 1024 * 1024, 64, 100)?; +//! let request = encode_fetch_request( +//! &endpoint, +//! &FetchRequest { path: "/items/123".into() }, +//! limits, +//! )?; +//! assert_eq!(request.uri(), "https://example.test/api/wip/v1/fetch"); +//! assert_eq!(request.method(), http::Method::POST); +//! # Ok::<(), Box>(()) +//! ``` + +#![deny(missing_docs)] + +mod batch; +mod codec; +mod endpoint; +mod error; +mod json; +mod response; + +pub use batch::{ + BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchResponseItem, + BatchSuccess, decode_batch_request, decode_batch_response, encode_batch_error_response, + encode_batch_request, encode_batch_response, +}; +pub use codec::{ + CallOperationMetadata, EncodeHttpError, decode_call_operation_metadata, + decode_call_operation_request, decode_call_operation_response, decode_fetch_interface_request, + decode_fetch_interface_response, decode_fetch_request, decode_fetch_response, + decode_fetch_tree_request, decode_fetch_tree_response, encode_call_operation_request, + encode_call_operation_response, encode_fetch_interface_request, + encode_fetch_interface_response, encode_fetch_request, encode_fetch_response, + encode_fetch_tree_request, encode_fetch_tree_response, encode_protocol_error_response, + is_json_content_type, validate_http_request, +}; +pub use endpoint::{Endpoint, EndpointError, JSON_CONTENT_TYPE, Route}; +pub use error::{BodyKind, CodecError, LimitConfigurationError, Limits}; +pub use response::{ + ClientResponseError, DecodedResponse, InvalidResponseKind, StatusMismatch, + TransportBindingFailure, TransportFailureKind, error_code_name, status_for_error, +}; + +/// Framework-neutral HTTP types used by this binding's public API. +pub use http; +/// URL type used by [`Endpoint`]. +pub use url; +/// Transport-independent logical protocol model used by this binding. +pub use wip_protocol; diff --git a/crates/wip-https/src/response.rs b/crates/wip-https/src/response.rs new file mode 100644 index 0000000..016fb9c --- /dev/null +++ b/crates/wip-https/src/response.rs @@ -0,0 +1,331 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +use http::StatusCode; +use serde_json::{Map, Value as Json}; +use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; + +use crate::{BodyKind, CodecError, Limits, json}; + +/// A successfully classified client response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DecodedResponse { + /// A successful direct response body. + Success(T), + /// A canonical Host protocol failure. + ProtocolFailure { + /// The authoritative protocol error decoded from the envelope. + error: ProtocolError, + /// A diagnostic when the HTTP status differs from the canonical mapping. + status_mismatch: Option, + }, +} + +/// Diagnostic evidence that HTTP metadata disagrees with a valid error envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StatusMismatch { + /// Status received from the peer. + pub actual: StatusCode, + /// Canonical status for the envelope's protocol code. + pub expected: StatusCode, +} + +/// Class of non-protocol HTTP failure returned without a valid WIP envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportFailureKind { + /// HTTP 401, commonly emitted by authentication infrastructure. + AuthenticationRequired, + /// HTTP 403, commonly emitted by endpoint policy or infrastructure. + EndpointForbidden, + /// Another non-success HTTP response without a WIP error envelope. + HttpStatus, +} + +/// A non-success HTTP response that is not a valid WIP protocol failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransportBindingFailure { + /// HTTP status returned by the peer or intermediary. + pub status: StatusCode, + /// Coarse classification which does not invent a protocol error code. + pub kind: TransportFailureKind, +} + +/// Why a response violates the WIP v1 response contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InvalidResponseKind { + /// A 2xx response contained an error envelope. + ErrorEnvelopeOnSuccessStatus, + /// A non-2xx response contained a valid success body. + SuccessBodyOnErrorStatus, + /// A valid error envelope used a code forbidden for the interaction. + DisallowedProtocolCode, + /// A 2xx body was malformed or semantically invalid. + InvalidSuccessBody, + /// A batch response did not correlate exactly with its request. + InvalidBatch, +} + +/// A local failure while classifying or validating a peer response. +#[derive(Debug)] +pub enum ClientResponseError { + /// The HTTP exchange failed outside the protocol envelope. + TransportBinding(TransportBindingFailure), + /// The peer supplied a contradictory or invalid WIP response. + InvalidResponse { + /// Stable response-failure category. + kind: InvalidResponseKind, + /// Bounded diagnostic from local decoding. + detail: String, + }, + /// The descriptor is structurally valid but uses an unsupported format. + UnsupportedDescriptorFormat { + /// Format identifier received from the Host. + format: String, + }, +} + +impl ClientResponseError { + pub(crate) fn invalid(kind: InvalidResponseKind, detail: impl Into) -> Self { + Self::InvalidResponse { + kind, + detail: detail.into(), + } + } +} + +impl Display for ClientResponseError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::TransportBinding(failure) => write!( + formatter, + "transport binding failure ({:?}, HTTP {})", + failure.kind, failure.status + ), + Self::InvalidResponse { kind, detail } => { + write!(formatter, "invalid WIP response ({kind:?}): {detail}") + } + Self::UnsupportedDescriptorFormat { format } => { + write!( + formatter, + "unsupported interface descriptor format `{format}`" + ) + } + } + } +} + +impl Error for ClientResponseError {} + +/// Returns the canonical HTTP status for a wire-level protocol error. +#[must_use] +pub const fn status_for_error(code: ProtocolErrorCode) -> StatusCode { + match code { + ProtocolErrorCode::InvalidRequest => StatusCode::BAD_REQUEST, + ProtocolErrorCode::NotFound | ProtocolErrorCode::InterfaceNotFound => StatusCode::NOT_FOUND, + ProtocolErrorCode::InterfaceMismatch => StatusCode::CONFLICT, + ProtocolErrorCode::OperationNotFound => StatusCode::NOT_FOUND, + ProtocolErrorCode::InvalidArguments => StatusCode::UNPROCESSABLE_ENTITY, + ProtocolErrorCode::ValidatorRequired | ProtocolErrorCode::InterfaceValidatorRequired => { + StatusCode::PRECONDITION_REQUIRED + } + ProtocolErrorCode::ValidatorMismatch | ProtocolErrorCode::InterfaceValidatorMismatch => { + StatusCode::PRECONDITION_FAILED + } + ProtocolErrorCode::PermissionDenied => StatusCode::FORBIDDEN, + ProtocolErrorCode::ResourceLimitExceeded => StatusCode::TOO_MANY_REQUESTS, + ProtocolErrorCode::Internal | ProtocolErrorCode::OperationOutcomeUnknown => { + StatusCode::INTERNAL_SERVER_ERROR + } + } +} + +/// Returns the stable snake-case wire spelling of a protocol code. +#[must_use] +pub const fn error_code_name(code: ProtocolErrorCode) -> &'static str { + match code { + ProtocolErrorCode::InvalidRequest => "invalid_request", + ProtocolErrorCode::NotFound => "not_found", + ProtocolErrorCode::InterfaceNotFound => "interface_not_found", + ProtocolErrorCode::InterfaceMismatch => "interface_mismatch", + ProtocolErrorCode::OperationNotFound => "operation_not_found", + ProtocolErrorCode::InvalidArguments => "invalid_arguments", + ProtocolErrorCode::ValidatorRequired => "validator_required", + ProtocolErrorCode::ValidatorMismatch => "validator_mismatch", + ProtocolErrorCode::InterfaceValidatorRequired => "interface_validator_required", + ProtocolErrorCode::InterfaceValidatorMismatch => "interface_validator_mismatch", + ProtocolErrorCode::PermissionDenied => "permission_denied", + ProtocolErrorCode::ResourceLimitExceeded => "resource_limit_exceeded", + ProtocolErrorCode::Internal => "internal", + ProtocolErrorCode::OperationOutcomeUnknown => "operation_outcome_unknown", + } +} + +fn error_code_from_name(name: &str) -> Option { + Some(match name { + "invalid_request" => ProtocolErrorCode::InvalidRequest, + "not_found" => ProtocolErrorCode::NotFound, + "interface_not_found" => ProtocolErrorCode::InterfaceNotFound, + "interface_mismatch" => ProtocolErrorCode::InterfaceMismatch, + "operation_not_found" => ProtocolErrorCode::OperationNotFound, + "invalid_arguments" => ProtocolErrorCode::InvalidArguments, + "validator_required" => ProtocolErrorCode::ValidatorRequired, + "validator_mismatch" => ProtocolErrorCode::ValidatorMismatch, + "interface_validator_required" => ProtocolErrorCode::InterfaceValidatorRequired, + "interface_validator_mismatch" => ProtocolErrorCode::InterfaceValidatorMismatch, + "permission_denied" => ProtocolErrorCode::PermissionDenied, + "resource_limit_exceeded" => ProtocolErrorCode::ResourceLimitExceeded, + "internal" => ProtocolErrorCode::Internal, + "operation_outcome_unknown" => ProtocolErrorCode::OperationOutcomeUnknown, + _ => return None, + }) +} + +pub(crate) fn error_to_json(error: &ProtocolError) -> Json { + serde_json::json!({ + "error": { + "code": error_code_name(error.code), + "message": error.message, + } + }) +} + +pub(crate) fn item_error_to_json(error: &ProtocolError) -> Json { + serde_json::json!({ + "code": error_code_name(error.code), + "message": error.message, + }) +} + +pub(crate) fn item_error_from_json(value: &Json) -> Result { + let fields = value + .as_object() + .ok_or_else(|| invalid_field("error", "expected object"))?; + exact(fields, &["code", "message"], "error")?; + let code_name = required_string(fields, "code")?; + let code = error_code_from_name(code_name) + .ok_or_else(|| invalid_field("error.code", "unknown protocol error code"))?; + let message = required_string(fields, "message")?.to_owned(); + Ok(ProtocolError { code, message }) +} + +fn envelope_error_from_json(value: &Json) -> Result, CodecError> { + let Some(fields) = value.as_object() else { + return Ok(None); + }; + if fields.len() != 1 || !fields.contains_key("error") { + return Ok(None); + } + item_error_from_json(&fields["error"]).map(Some) +} + +fn exact(fields: &Map, allowed: &[&str], parent: &str) -> Result<(), CodecError> { + for name in fields.keys() { + if !allowed.contains(&name.as_str()) { + return Err(CodecError::UnknownField { + field: format!("{parent}.{name}"), + }); + } + } + for name in allowed { + if !fields.contains_key(*name) { + return Err(CodecError::MissingField { + field: format!("{parent}.{name}"), + }); + } + } + Ok(()) +} + +fn required_string<'a>(fields: &'a Map, name: &str) -> Result<&'a str, CodecError> { + fields + .get(name) + .and_then(Json::as_str) + .ok_or_else(|| invalid_field(&format!("error.{name}"), "expected string")) +} + +fn invalid_field(field: &str, reason: &str) -> CodecError { + CodecError::InvalidField { + field: field.into(), + reason: reason.into(), + } +} + +pub(crate) fn decode( + status: StatusCode, + body: &[u8], + interaction: ProtocolInteraction, + limits: Limits, + success: impl FnOnce(&Json) -> Result, +) -> Result, ClientResponseError> { + let value = match json::parse(body, BodyKind::Response, limits) { + Ok(value) => value, + Err(_) if !status.is_success() => { + return Err(transport_failure(status)); + } + Err(error) => { + return Err(ClientResponseError::invalid( + InvalidResponseKind::InvalidSuccessBody, + error.to_string(), + )); + } + }; + + match envelope_error_from_json(&value) { + Ok(Some(error)) => { + if status.is_success() { + return Err(ClientResponseError::invalid( + InvalidResponseKind::ErrorEnvelopeOnSuccessStatus, + "protocol error envelope carried by a success status", + )); + } + if !error.code.is_allowed_for(interaction) { + return Err(ClientResponseError::invalid( + InvalidResponseKind::DisallowedProtocolCode, + format!( + "code `{}` is not allowed for {interaction:?}", + error_code_name(error.code) + ), + )); + } + let expected = status_for_error(error.code); + Ok(DecodedResponse::ProtocolFailure { + error, + status_mismatch: (status != expected).then_some(StatusMismatch { + actual: status, + expected, + }), + }) + } + Err(_) if !status.is_success() => Err(transport_failure(status)), + Err(error) => Err(ClientResponseError::invalid( + InvalidResponseKind::InvalidSuccessBody, + error.to_string(), + )), + Ok(None) if status.is_success() => { + success(&value) + .map(DecodedResponse::Success) + .map_err(|error| { + ClientResponseError::invalid( + InvalidResponseKind::InvalidSuccessBody, + error.to_string(), + ) + }) + } + Ok(None) => match success(&value) { + Ok(_) => Err(ClientResponseError::invalid( + InvalidResponseKind::SuccessBodyOnErrorStatus, + "valid success body carried by a non-success status", + )), + Err(_) => Err(transport_failure(status)), + }, + } +} + +pub(crate) fn transport_failure(status: StatusCode) -> ClientResponseError { + let kind = match status { + StatusCode::UNAUTHORIZED => TransportFailureKind::AuthenticationRequired, + StatusCode::FORBIDDEN => TransportFailureKind::EndpointForbidden, + _ => TransportFailureKind::HttpStatus, + }; + ClientResponseError::TransportBinding(TransportBindingFailure { status, kind }) +} diff --git a/crates/wip-https/tests/batch.rs b/crates/wip-https/tests/batch.rs new file mode 100644 index 0000000..30d007b --- /dev/null +++ b/crates/wip-https/tests/batch.rs @@ -0,0 +1,184 @@ +use wip_https::http::StatusCode; +use wip_https::wip_protocol::{ + FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1, + InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, +}; +use wip_https::{ + BatchRequest, BatchRequestItem, BatchRequestKind, BatchResponse, BatchResponseItem, + BatchSuccess, ClientResponseError, DecodedResponse, Endpoint, InvalidResponseKind, Limits, + decode_batch_request, decode_batch_response, encode_batch_error_response, encode_batch_request, + encode_batch_response, +}; + +fn limits() -> Limits { + Limits::new(100_000, 100_000, 64, 4).unwrap() +} + +fn request() -> BatchRequest { + BatchRequest { + requests: vec![ + BatchRequestItem { + id: "object".into(), + request: BatchRequestKind::Fetch(FetchRequest { + path: "/item".into(), + }), + }, + BatchRequestItem { + id: "interface".into(), + request: BatchRequestKind::FetchInterface(FetchInterfaceRequest { + interface: "interface-v1".into(), + }), + }, + ], + } +} + +#[test] +fn batch_round_trips_by_unique_id_with_item_failures_and_validators() { + let request = request(); + let endpoint = Endpoint::parse("https://example.test/wip").unwrap(); + let encoded_request = encode_batch_request(&endpoint, &request, limits()).unwrap(); + assert_eq!(encoded_request.uri().path(), "/wip/v1/batch"); + assert_eq!( + decode_batch_request(encoded_request.body(), limits()).unwrap(), + request + ); + + let response = BatchResponse { + responses: vec![ + BatchResponseItem { + id: "interface".into(), + outcome: Err(ProtocolError { + code: ProtocolErrorCode::InterfaceNotFound, + message: "gone".into(), + }), + }, + BatchResponseItem { + id: "object".into(), + outcome: Ok(BatchSuccess::Fetch(Object { + name: "item".into(), + description: None, + interfaces: vec![], + r#ref: None, + validator: Some(vec![0, 1, 2, 255]), + })), + }, + ], + }; + let encoded = encode_batch_response(&request, &response, limits()).unwrap(); + assert_eq!(encoded.status(), StatusCode::OK); + assert!( + std::str::from_utf8(encoded.body()) + .unwrap() + .contains("AAEC/w==") + ); + assert_eq!( + decode_batch_response(&request, &encoded, limits()).unwrap(), + DecodedResponse::Success(response) + ); +} + +#[test] +fn batch_interface_validator_round_trips_in_item_body() { + let request = BatchRequest { + requests: vec![BatchRequestItem { + id: "interface".into(), + request: BatchRequestKind::FetchInterface(FetchInterfaceRequest { + interface: "interface-v1".into(), + }), + }], + }; + let response = BatchResponse { + responses: vec![BatchResponseItem { + id: "interface".into(), + outcome: Ok(BatchSuccess::FetchInterface(FetchInterfaceResponse { + interface: "interface-v1".into(), + descriptor: InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: vec![], + operations: vec![], + }, + validator: Some(vec![255, 254, 253]), + })), + }], + }; + let encoded = encode_batch_response(&request, &response, limits()).unwrap(); + assert!( + std::str::from_utf8(encoded.body()) + .unwrap() + .contains("//79") + ); + assert_eq!( + decode_batch_response(&request, &encoded, limits()).unwrap(), + DecodedResponse::Success(response) + ); +} + +#[test] +fn invalid_batch_envelopes_are_rejected_at_top_level() { + let duplicate = br#"{ + "requests": [ + {"id":"same","method":"fetch","arguments":{"path":"/a"}}, + {"id":"same","method":"fetch","arguments":{"path":"/b"}} + ] + }"#; + assert!(decode_batch_request(duplicate, limits()).is_err()); + + let operation = br#"{ + "requests": [ + {"id":"call","method":"call_operation","arguments":{}} + ] + }"#; + assert!(decode_batch_request(operation, limits()).is_err()); + + let too_many = Limits::new(100_000, 100_000, 64, 1).unwrap(); + assert!(decode_batch_request(duplicate, too_many).is_err()); +} + +#[test] +fn top_level_batch_errors_use_the_batch_allowed_code_set() { + let invalid = ProtocolError { + code: ProtocolErrorCode::InvalidRequest, + message: "bad envelope".into(), + }; + let encoded = encode_batch_error_response(&invalid, limits()).unwrap(); + assert_eq!( + decode_batch_response(&request(), &encoded, limits()).unwrap(), + DecodedResponse::ProtocolFailure { + error: invalid, + status_mismatch: None, + } + ); + + let not_found = wip_https::http::Response::builder() + .status(StatusCode::NOT_FOUND) + .header("content-type", "application/json; charset=utf-8") + .body(br#"{"error":{"code":"not_found","message":"not a batch failure"}}"#.to_vec()) + .unwrap(); + assert!(matches!( + decode_batch_response(&request(), ¬_found, limits()).unwrap_err(), + ClientResponseError::InvalidResponse { + kind: InvalidResponseKind::InvalidBatch, + .. + } + )); +} + +#[test] +fn batch_response_must_have_exactly_the_requested_ids() { + let request = request(); + let missing = BatchResponse { + responses: vec![BatchResponseItem { + id: "object".into(), + outcome: Ok(BatchSuccess::Fetch(Object { + name: "item".into(), + description: None, + interfaces: vec![], + r#ref: None, + validator: None, + })), + }], + }; + assert!(encode_batch_response(&request, &missing, limits()).is_err()); +} diff --git a/crates/wip-https/tests/codecs.rs b/crates/wip-https/tests/codecs.rs new file mode 100644 index 0000000..9bbae51 --- /dev/null +++ b/crates/wip-https/tests/codecs.rs @@ -0,0 +1,427 @@ +use std::collections::BTreeMap; + +use wip_https::http::StatusCode; +use wip_https::wip_protocol::{ + CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, + FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, + INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, Object, + ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ReturnDeclaration, Target, + TypeDeclaration, TypeExpr, UnionCase, Value, +}; +use wip_https::{ + ClientResponseError, DecodedResponse, Endpoint, InvalidResponseKind, Limits, + decode_call_operation_metadata, decode_call_operation_request, decode_call_operation_response, + decode_fetch_interface_request, decode_fetch_interface_response, decode_fetch_request, + decode_fetch_response, decode_fetch_tree_request, decode_fetch_tree_response, + encode_call_operation_request, encode_call_operation_response, encode_fetch_interface_request, + encode_fetch_interface_response, encode_fetch_request, encode_fetch_response, + encode_fetch_tree_request, encode_fetch_tree_response, +}; + +fn limits() -> Limits { + Limits::new(1_000_000, 1_000_000, 100, 100).unwrap() +} + +fn endpoint() -> Endpoint { + Endpoint::parse("https://example.test/wip").unwrap() +} + +fn object(name: &str, validator: &[u8]) -> Object { + Object { + name: name.into(), + description: Some("description".into()), + interfaces: vec!["interface-v1".into()], + r#ref: Some(format!("object:{name}")), + validator: Some(validator.to_vec()), + } +} + +fn descriptor() -> InterfaceDescriptor { + InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: Some(Documentation { + summary: "Codec fixture".into(), + details: None, + }), + types: vec![TypeDeclaration { + name: "Pair".into(), + documentation: None, + definition: TypeExpr::Record { + fields: vec![ + FieldDeclaration { + name: "required".into(), + required: true, + documentation: None, + r#type: TypeExpr::String, + }, + FieldDeclaration { + name: "optional".into(), + required: false, + documentation: None, + r#type: TypeExpr::String, + }, + FieldDeclaration { + name: "optional_json".into(), + required: false, + documentation: None, + r#type: TypeExpr::Json, + }, + ], + }, + }], + operations: vec![OperationDeclaration { + name: "everything".into(), + documentation: None, + parameters: vec![ + parameter("unit", TypeExpr::Unit), + parameter("boolean", TypeExpr::Boolean), + parameter("integer", TypeExpr::Integer), + parameter("number", TypeExpr::Number), + parameter("string", TypeExpr::String), + parameter("bytes", TypeExpr::Bytes), + parameter("entry", TypeExpr::Entry), + parameter( + "list", + TypeExpr::List { + items: Box::new(TypeExpr::Integer), + }, + ), + parameter( + "record", + TypeExpr::Named { + name: "Pair".into(), + }, + ), + parameter( + "enum", + TypeExpr::Enum { + cases: vec![EnumCase { + name: "ready".into(), + documentation: None, + }], + }, + ), + parameter( + "union", + TypeExpr::Union { + cases: vec![ + UnionCase { + name: "empty".into(), + documentation: None, + payload: None, + }, + UnionCase { + name: "found".into(), + documentation: None, + payload: Some(TypeExpr::Entry), + }, + ], + }, + ), + parameter("json", TypeExpr::Json), + ], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Bytes, + }, + }], + } +} + +fn parameter(name: &str, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required: true, + documentation: None, + r#type, + } +} + +fn record(fields: impl IntoIterator) -> Value { + Value::Record( + fields + .into_iter() + .map(|(name, value)| (name.into(), value)) + .collect(), + ) +} + +fn call_request() -> CallOperationRequest { + CallOperationRequest { + target: Target { + path: "/items/123".into(), + validator: Some(vec![0, 1, 2, 255]), + }, + interface: InterfaceTarget { + reference: "interface-v1".into(), + validator: Some(vec![9, 8, 7]), + }, + operation: "everything".into(), + arguments: BTreeMap::from([ + ("unit".into(), Value::Unit), + ("boolean".into(), Value::Boolean(true)), + ("integer".into(), Value::Integer(MAX_SAFE_INTEGER)), + ("number".into(), Value::Number(1.5)), + ("string".into(), Value::String("text".into())), + ("bytes".into(), Value::Bytes(vec![0, 1, 2, 255])), + ("entry".into(), Value::String("/items/123".into())), + ( + "list".into(), + Value::List(vec![Value::Integer(1), Value::Integer(2)]), + ), + ( + "record".into(), + record([("required", Value::String("present".into()))]), + ), + ("enum".into(), Value::String("ready".into())), + ( + "union".into(), + record([ + ("$case", Value::String("found".into())), + ("value", Value::String("/items/123".into())), + ]), + ), + ( + "json".into(), + record([ + ("null", Value::Unit), + ("number", Value::Integer(2)), + ("path_like", Value::String("/not-an-entry".into())), + ]), + ), + ]), + } +} + +#[test] +fn fetch_round_trips_direct_body_and_body_validator() { + let request = FetchRequest { + path: "/items/123".into(), + }; + let encoded_request = encode_fetch_request(&endpoint(), &request, limits()).unwrap(); + assert_eq!(encoded_request.uri().path(), "/wip/v1/fetch"); + assert_eq!( + decode_fetch_request(encoded_request.body(), limits()).unwrap(), + request + ); + + let expected = object("123", &[0, 1, 2, 255]); + let encoded = encode_fetch_response(&request, &expected, limits()).unwrap(); + assert_eq!(encoded.status(), StatusCode::OK); + assert_eq!( + decode_fetch_response(&request, &encoded, limits()).unwrap(), + DecodedResponse::Success(expected) + ); + assert!(encoded.headers().get("etag").is_none()); +} + +#[test] +fn tree_and_interface_round_trip_nested_logical_models() { + let tree_request = FetchTreeRequest { + path: "/items".into(), + depth: 1, + limit: 2, + }; + let encoded_request = encode_fetch_tree_request(&endpoint(), &tree_request, limits()).unwrap(); + assert_eq!( + decode_fetch_tree_request(encoded_request.body(), limits()).unwrap(), + tree_request + ); + let tree = FetchTreeResponse { + root: ObjectTreeNode { + object: object("items", b"root-validator"), + children: vec![ObjectTreeNode { + object: object("123", b"child-validator"), + children: vec![], + }], + }, + truncated: false, + }; + let encoded = encode_fetch_tree_response(&tree_request, &tree, limits()).unwrap(); + assert_eq!( + decode_fetch_tree_response(&tree_request, &encoded, limits()).unwrap(), + DecodedResponse::Success(tree) + ); + + let interface_request = FetchInterfaceRequest { + interface: "interface-v1".into(), + }; + let encoded_request = + encode_fetch_interface_request(&endpoint(), &interface_request, limits()).unwrap(); + assert_eq!( + decode_fetch_interface_request(encoded_request.body(), limits()).unwrap(), + interface_request + ); + let interface = FetchInterfaceResponse { + interface: interface_request.interface.clone(), + descriptor: descriptor(), + validator: Some(vec![4, 5, 6, 255]), + }; + let encoded = + encode_fetch_interface_response(&interface_request, &interface, limits()).unwrap(); + assert_eq!( + decode_fetch_interface_response(&interface_request, &encoded, limits()).unwrap(), + DecodedResponse::Success(interface) + ); +} + +#[test] +fn descriptor_guided_values_and_validators_round_trip_both_directions() { + let descriptor = descriptor(); + let request = call_request(); + let encoded = + encode_call_operation_request(&endpoint(), &request, &descriptor, limits()).unwrap(); + let body = std::str::from_utf8(encoded.body()).unwrap(); + assert!(body.contains("AAEC/w==")); + let metadata = decode_call_operation_metadata(encoded.body(), limits()).unwrap(); + assert_eq!(metadata.target, request.target); + assert_eq!(metadata.interface, request.interface); + assert_eq!(metadata.operation, request.operation); + assert_eq!( + metadata + .decode_request(encoded.body(), &descriptor, limits()) + .unwrap(), + request + ); + let mut changed_route = request.clone(); + changed_route.target.path = "/items/other".into(); + let changed = + encode_call_operation_request(&endpoint(), &changed_route, &descriptor, limits()).unwrap(); + assert!( + metadata + .decode_request(changed.body(), &descriptor, limits()) + .is_err() + ); + + let mut null_json_request = request.clone(); + null_json_request + .arguments + .insert("json".into(), Value::Unit); + null_json_request.arguments.insert( + "record".into(), + record([ + ("required", Value::String("present".into())), + ("optional_json", Value::Unit), + ]), + ); + let null_json = + encode_call_operation_request(&endpoint(), &null_json_request, &descriptor, limits()) + .unwrap(); + assert_eq!( + decode_call_operation_request(null_json.body(), &descriptor, limits()).unwrap(), + null_json_request + ); + + let expected = CallOperationResponse { + result: Value::Bytes(vec![255, 0, 1]), + validator: Some(vec![3, 2, 1, 0]), + }; + let encoded = + encode_call_operation_response(&request, &descriptor, &expected, limits()).unwrap(); + assert!( + std::str::from_utf8(encoded.body()) + .unwrap() + .contains("/wAB") + ); + assert_eq!( + decode_call_operation_response(&request, &descriptor, &encoded, limits()).unwrap(), + DecodedResponse::Success(expected) + ); +} + +#[test] +fn malformed_unknown_null_and_noncanonical_base64_are_rejected() { + let limits = limits(); + assert!(decode_fetch_request(br#"{"path":"/","extra":true}"#, limits).is_err()); + assert!(decode_fetch_request(br#"{"path":"relative","path":"/"}"#, limits).is_err()); + + let request = call_request(); + let descriptor = descriptor(); + let body = serde_json::to_vec(&serde_json::json!({ + "target": {"path": "/items/123", "validator": null}, + "interface": {"reference": "interface-v1"}, + "operation": "everything", + "arguments": {} + })) + .unwrap(); + assert!(decode_call_operation_request(&body, &descriptor, limits).is_err()); + + let mut json: serde_json::Value = serde_json::from_slice( + encode_call_operation_request(&endpoint(), &request, &descriptor, limits) + .unwrap() + .body(), + ) + .unwrap(); + json["arguments"]["bytes"] = serde_json::Value::String("AAEC_w==".into()); + assert!( + decode_call_operation_request(&serde_json::to_vec(&json).unwrap(), &descriptor, limits) + .is_err() + ); +} + +#[test] +fn caller_limits_bound_bytes_and_nesting() { + let tiny = Limits::new(8, 8, 2, 1).unwrap(); + assert!(decode_fetch_request(br#"{"path":"/long"}"#, tiny).is_err()); + + let nested = br#"{"path":[[["/"]]]}"#; + let nesting_limited = Limits::new(1024, 1024, 2, 1).unwrap(); + assert!(decode_fetch_request(nested, nesting_limited).is_err()); +} + +#[test] +fn unsupported_descriptor_format_is_client_local() { + let request = FetchInterfaceRequest { + interface: "interface-v1".into(), + }; + let mut interface = FetchInterfaceResponse { + interface: request.interface.clone(), + descriptor: descriptor(), + validator: None, + }; + interface.descriptor.format = "future-format".into(); + let response = encode_fetch_interface_response(&request, &interface, limits()).unwrap(); + let error = decode_fetch_interface_response(&request, &response, limits()).unwrap_err(); + assert!(matches!( + error, + ClientResponseError::UnsupportedDescriptorFormat { .. } + )); + + let future_shape = wip_https::http::Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json; charset=utf-8") + .body( + br#"{"interface":"interface-v1","descriptor":{"format":"wip-interface/2","future_shape":true}}"# + .to_vec(), + ) + .unwrap(); + assert!(matches!( + decode_fetch_interface_response(&request, &future_shape, limits()).unwrap_err(), + ClientResponseError::UnsupportedDescriptorFormat { format } + if format == "wip-interface/2" + )); +} + +#[test] +fn invalid_success_is_classified_as_invalid_response() { + let response = wip_https::http::Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json; charset=utf-8") + .body(br#"{"name":"wrong","interfaces":[]}"#.to_vec()) + .unwrap(); + let error = decode_fetch_response( + &FetchRequest { + path: "/expected".into(), + }, + &response, + limits(), + ) + .unwrap_err(); + assert!(matches!( + error, + ClientResponseError::InvalidResponse { + kind: InvalidResponseKind::InvalidSuccessBody, + .. + } + )); +} diff --git a/crates/wip-https/tests/responses.rs b/crates/wip-https/tests/responses.rs new file mode 100644 index 0000000..7456312 --- /dev/null +++ b/crates/wip-https/tests/responses.rs @@ -0,0 +1,236 @@ +use wip_https::http::{Response, StatusCode}; +use wip_https::wip_protocol::{ + FetchRequest, ProtocolError, ProtocolErrorCode, ProtocolInteraction, +}; +use wip_https::{ + ClientResponseError, DecodedResponse, InvalidResponseKind, Limits, TransportFailureKind, + decode_fetch_response, encode_protocol_error_response, error_code_name, is_json_content_type, + status_for_error, +}; + +fn limits() -> Limits { + Limits::new(64 * 1024, 64 * 1024, 32, 16).unwrap() +} + +fn request() -> FetchRequest { + FetchRequest { path: "/".into() } +} + +fn response(status: StatusCode, body: impl Into>) -> Response> { + Response::builder() + .status(status) + .header("content-type", "application/json; charset=utf-8") + .body(body.into()) + .unwrap() +} + +#[test] +fn content_type_accepts_only_utf8_json_syntax() { + assert!(is_json_content_type("application/json")); + assert!(is_json_content_type("Application/JSON; Charset=\"UTF-8\"")); + assert!(!is_json_content_type("application/json; charset=utf-16")); + assert!(!is_json_content_type("application/json; charset=\"utf-8")); + assert!(!is_json_content_type("application/json; charset=utf-8\"")); + assert!(!is_json_content_type("application/json; profile=other")); +} + +#[test] +fn every_protocol_code_has_stable_name_and_status() { + let expected = [ + ( + ProtocolErrorCode::InvalidRequest, + "invalid_request", + StatusCode::BAD_REQUEST, + ), + ( + ProtocolErrorCode::NotFound, + "not_found", + StatusCode::NOT_FOUND, + ), + ( + ProtocolErrorCode::InterfaceNotFound, + "interface_not_found", + StatusCode::NOT_FOUND, + ), + ( + ProtocolErrorCode::InterfaceMismatch, + "interface_mismatch", + StatusCode::CONFLICT, + ), + ( + ProtocolErrorCode::OperationNotFound, + "operation_not_found", + StatusCode::NOT_FOUND, + ), + ( + ProtocolErrorCode::InvalidArguments, + "invalid_arguments", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + ProtocolErrorCode::ValidatorRequired, + "validator_required", + StatusCode::PRECONDITION_REQUIRED, + ), + ( + ProtocolErrorCode::ValidatorMismatch, + "validator_mismatch", + StatusCode::PRECONDITION_FAILED, + ), + ( + ProtocolErrorCode::InterfaceValidatorRequired, + "interface_validator_required", + StatusCode::PRECONDITION_REQUIRED, + ), + ( + ProtocolErrorCode::InterfaceValidatorMismatch, + "interface_validator_mismatch", + StatusCode::PRECONDITION_FAILED, + ), + ( + ProtocolErrorCode::PermissionDenied, + "permission_denied", + StatusCode::FORBIDDEN, + ), + ( + ProtocolErrorCode::ResourceLimitExceeded, + "resource_limit_exceeded", + StatusCode::TOO_MANY_REQUESTS, + ), + ( + ProtocolErrorCode::Internal, + "internal", + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + ProtocolErrorCode::OperationOutcomeUnknown, + "operation_outcome_unknown", + StatusCode::INTERNAL_SERVER_ERROR, + ), + ]; + assert_eq!(expected.len(), ProtocolErrorCode::ALL.len()); + for (code, name, status) in expected { + assert_eq!(error_code_name(code), name); + assert_eq!(status_for_error(code), status); + } +} + +#[test] +fn server_encoder_rejects_codes_disallowed_for_the_interaction() { + let error = ProtocolError { + code: ProtocolErrorCode::InterfaceNotFound, + message: "not valid for fetch".into(), + }; + assert!(encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).is_err()); +} + +#[test] +fn valid_envelope_code_is_authoritative_when_status_mismatches() { + let error = ProtocolError { + code: ProtocolErrorCode::NotFound, + message: "missing".into(), + }; + let mut encoded = + encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap(); + *encoded.status_mut() = StatusCode::IM_A_TEAPOT; + + assert_eq!( + decode_fetch_response(&request(), &encoded, limits()).unwrap(), + DecodedResponse::ProtocolFailure { + error, + status_mismatch: Some(wip_https::StatusMismatch { + actual: StatusCode::IM_A_TEAPOT, + expected: StatusCode::NOT_FOUND, + }), + } + ); +} + +#[test] +fn envelope_and_status_contradictions_are_invalid_responses() { + let error_body = br#"{"error":{"code":"not_found","message":"missing"}}"#; + let on_success = + decode_fetch_response(&request(), &response(StatusCode::OK, error_body), limits()) + .unwrap_err(); + assert!(matches!( + on_success, + ClientResponseError::InvalidResponse { + kind: InvalidResponseKind::ErrorEnvelopeOnSuccessStatus, + .. + } + )); + + let success_body = br#"{"name":"","interfaces":[]}"#; + let on_error = decode_fetch_response( + &request(), + &response(StatusCode::BAD_GATEWAY, success_body), + limits(), + ) + .unwrap_err(); + assert!(matches!( + on_error, + ClientResponseError::InvalidResponse { + kind: InvalidResponseKind::SuccessBodyOnErrorStatus, + .. + } + )); +} + +#[test] +fn disallowed_protocol_code_is_invalid_instead_of_reclassified() { + let body = br#"{"error":{"code":"interface_not_found","message":"wrong interaction"}}"#; + let error = decode_fetch_response(&request(), &response(StatusCode::NOT_FOUND, body), limits()) + .unwrap_err(); + assert!(matches!( + error, + ClientResponseError::InvalidResponse { + kind: InvalidResponseKind::DisallowedProtocolCode, + .. + } + )); +} + +#[test] +fn proxy_authentication_and_endpoint_failures_stay_transport_failures() { + let cases = [ + ( + StatusCode::BAD_GATEWAY, + TransportFailureKind::HttpStatus, + b"proxy failure".as_slice(), + ), + ( + StatusCode::UNAUTHORIZED, + TransportFailureKind::AuthenticationRequired, + b"login required".as_slice(), + ), + ( + StatusCode::FORBIDDEN, + TransportFailureKind::EndpointForbidden, + b"denied".as_slice(), + ), + ]; + for (status, expected_kind, body) in cases { + let response = Response::builder() + .status(status) + .body(body.to_vec()) + .unwrap(); + let error = decode_fetch_response(&request(), &response, limits()).unwrap_err(); + assert!(matches!( + error, + ClientResponseError::TransportBinding(failure) + if failure.status == status && failure.kind == expected_kind + )); + } +} + +#[test] +fn malformed_non_success_envelope_is_transport_binding_failure() { + let malformed = response( + StatusCode::BAD_GATEWAY, + br#"{"error":{"code":"not_found","message":null}}"#, + ); + assert!(matches!( + decode_fetch_response(&request(), &malformed, limits()).unwrap_err(), + ClientResponseError::TransportBinding(_) + )); +} diff --git a/crates/wip-https/tests/routes.rs b/crates/wip-https/tests/routes.rs new file mode 100644 index 0000000..ce79b5b --- /dev/null +++ b/crates/wip-https/tests/routes.rs @@ -0,0 +1,83 @@ +use wip_https::http::{Method, Request}; +use wip_https::{Endpoint, Route, validate_http_request}; + +#[test] +fn endpoint_is_https_absolute_queryless_and_canonical() { + let endpoint = Endpoint::parse("https://example.test/root/prefix").unwrap(); + assert_eq!(endpoint.to_string(), "https://example.test/root/prefix/"); + + let expected = [ + (Route::Fetch, "https://example.test/root/prefix/v1/fetch"), + ( + Route::FetchTree, + "https://example.test/root/prefix/v1/fetch_tree", + ), + ( + Route::FetchInterface, + "https://example.test/root/prefix/v1/fetch_interface", + ), + ( + Route::CallOperation, + "https://example.test/root/prefix/v1/call_operation", + ), + (Route::Batch, "https://example.test/root/prefix/v1/batch"), + ]; + for (route, url) in expected { + assert_eq!(endpoint.route(route).as_str(), url); + assert_eq!( + endpoint.recognize(&wip_https::url::Url::parse(url).unwrap()), + Some(route) + ); + } + assert_eq!( + endpoint.recognize( + &wip_https::url::Url::parse("https://example.test/root/prefix/v1/fetch?path=/items") + .unwrap() + ), + None + ); +} + +#[test] +fn routes_never_mix_worldspace_paths_into_endpoint_prefixes() { + let endpoint = Endpoint::parse("https://example.test/wip/%E4%B8%96%E7%95%8C/").unwrap(); + let route = endpoint.route(Route::Fetch); + assert_eq!(route.path(), "/wip/%E4%B8%96%E7%95%8C/v1/fetch"); + assert!(!route.as_str().contains("items")); +} + +#[test] +fn inbound_http_metadata_enforces_post_route_and_json() { + let endpoint = Endpoint::parse("https://example.test/wip").unwrap(); + let valid = Request::builder() + .method(Method::POST) + .uri("/wip/v1/fetch") + .header("content-type", "application/json; charset=utf-8") + .body(Vec::::new()) + .unwrap(); + assert!(validate_http_request(&endpoint, Route::Fetch, &valid).is_ok()); + + let wrong_method = Request::builder() + .method(Method::GET) + .uri("/wip/v1/fetch") + .header("content-type", "application/json") + .body(Vec::::new()) + .unwrap(); + assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_method).is_err()); + + let wrong_route = Request::builder() + .method(Method::POST) + .uri("/wip/v1/fetch_tree") + .header("content-type", "application/json") + .body(Vec::::new()) + .unwrap(); + assert!(validate_http_request(&endpoint, Route::Fetch, &wrong_route).is_err()); +} + +#[test] +fn invalid_endpoint_components_are_rejected() { + assert!(Endpoint::parse("http://example.test/wip").is_err()); + assert!(Endpoint::parse("/relative").is_err()); + assert!(Endpoint::parse("https://example.test/wip?q=1").is_err()); + assert!(Endpoint::parse("https://example.test/wip#fragment").is_err()); +}