Skip to main content

panproto_protocols/
lib.rs

1#![allow(
2    unknown_lints,
3    clippy::match_same_arms,
4    clippy::similar_names,
5    clippy::only_used_in_recursion,
6    clippy::option_if_let_else,
7    clippy::collapsible_else_if,
8    clippy::branches_sharing_code,
9    clippy::explicit_iter_loop,
10    clippy::manual_let_else,
11    clippy::hashset_insert_after_contains,
12    clippy::set_contains_or_insert
13)]
14
15//! # panproto-protocols
16//!
17//! Built-in protocol definitions for panproto.
18//!
19//! Each protocol is defined by a schema theory GAT and an instance theory GAT,
20//! composed via colimit from reusable building-block theories. Every protocol
21//! provides both a parser (native format → `Schema`) and an emitter
22//! (`Schema` → native format) for bidirectional format conversion.
23//!
24//! ## Protocol Categories
25//!
26//! - **Serialization**: Avro, `FlatBuffers`, ASN.1, Bond, `MsgPack`
27//! - **Data Schema**: CDDL, BSON
28//! - **API**: `OpenAPI`, `AsyncAPI`, RAML, JSON:API
29//! - **Database**: `MongoDB`, Cassandra, `DynamoDB`, Neo4j, Redis
30//! - **Web/Document**: `ATProto`, DOCX, ODF
31//! - **Data Science**: Parquet, Arrow, `DataFrame`
32//! - **Domain**: `GeoJSON`, FHIR, RSS/Atom, vCard/iCal, EDI X12, SWIFT MT
33//! - **Config**: K8s CRD, Docker Compose, `CloudFormation`, Ansible
34
35/// Linguistic annotation format protocol definitions.
36pub mod annotation;
37/// API specification protocol definitions.
38pub mod api;
39/// Configuration format protocol definitions.
40pub mod config;
41/// Data schema protocol definitions.
42pub mod data_schema;
43/// Data science and analytics protocol definitions.
44pub mod data_science;
45/// Database schema protocol definitions.
46pub mod database;
47/// Domain-specific protocol definitions.
48pub mod domain;
49/// Shared emit helpers for protocol serialization.
50pub mod emit;
51/// Error types for protocol operations.
52pub mod error;
53/// Raw file protocol for non-code files (README, LICENSE, images, etc.).
54pub mod raw_file;
55
56/// The canonical record of what each protocol supports.
57pub mod registry;
58
59/// A parsed document's size, for charging against an input allowance.
60///
61/// Serializing to measure would double the cost of every parse, so this
62/// walks the value instead. It is an estimate of the document's own
63/// weight, not of its serialized length; what it has to be is monotone
64/// in the document's size, which is what makes a bound meaningful.
65fn measure(value: &serde_json::Value) -> u64 {
66    match value {
67        serde_json::Value::Null => 4,
68        serde_json::Value::Bool(_) => 5,
69        serde_json::Value::Number(_) => 8,
70        serde_json::Value::String(s) => s.len() as u64 + 2,
71        serde_json::Value::Array(items) => {
72            2 + items.iter().map(measure).sum::<u64>() + items.len() as u64
73        }
74        serde_json::Value::Object(entries) => {
75            2 + entries
76                .iter()
77                .map(|(k, v)| k.len() as u64 + 4 + measure(v))
78                .sum::<u64>()
79        }
80    }
81}
82/// Serialization and IDL protocol definitions.
83pub mod serialization;
84/// Shared component theory definitions (building-block GATs).
85pub mod theories;
86/// Web and document format protocol definitions.
87pub mod web_document;
88
89use panproto_expr::limits::{Budget, Resource};
90use panproto_schema::Schema;
91
92pub use error::ProtocolError;
93
94// Re-export existing protocols at crate root for backward compatibility.
95pub use web_document::atproto;
96
97/// Parse a bundle of schema documents into one [`Schema`], resolving
98/// cross-document references across the whole bundle.
99///
100/// A single-document parser sees one document at a time, so a reference
101/// into another document resolves to an opaque placeholder vertex
102/// carrying no fields, and a lens has nothing typed to bind to. Passing
103/// the referenced documents alongside the referring one resolves each
104/// such reference to the definition's real, typed vertex. A reference
105/// whose target is in no document of the bundle stays a placeholder,
106/// which is what marks it as genuinely external.
107///
108/// This is the protocol-dispatching entry point the generic crates call,
109/// so that protocol names stay inside this crate. A protocol gains
110/// bundle support by adding an arm here; no binding surface changes.
111///
112/// # Errors
113///
114/// Returns [`ProtocolError::Parse`] if no bundle parser is registered
115/// for `protocol`, or the protocol's own error if the documents are not
116/// a well-formed bundle for it.
117pub fn parse_schema_bundle(
118    protocol: &str,
119    docs: &[serde_json::Value],
120) -> Result<Schema, ProtocolError> {
121    parse_schema_bundle_within(protocol, docs, &Budget::with_defaults())
122}
123
124/// Parse a bundle within a caller-supplied budget.
125///
126/// The entry count and the documents' total size are charged before any
127/// parsing begins, so a bundle built to exhaust a machine is refused on
128/// its shape rather than part way through being read.
129///
130/// Pass a clone of an enclosing operation's budget to have the two draw
131/// from one allowance; see [`panproto_expr::limits`].
132///
133/// # Errors
134///
135/// Returns [`ProtocolError::LimitExceeded`] naming the resource and its
136/// bound, or the underlying parser's error.
137pub fn parse_schema_bundle_within(
138    protocol: &str,
139    docs: &[serde_json::Value],
140    budget: &Budget,
141) -> Result<Schema, ProtocolError> {
142    budget.charge(Resource::BundleEntries, docs.len() as u64)?;
143    for doc in docs {
144        budget.charge(Resource::InputBytes, measure(doc))?;
145    }
146
147    match protocol.replace('_', "-").as_str() {
148        "atproto" => atproto::parse_lexicon_bundle(docs),
149        "openapi" => api::openapi::parse_openapi_bundle(docs),
150        "json-schema" => data_schema::json_schema::parse_json_schema_bundle(docs),
151        "avro" => serialization::avro::parse_avsc_bundle(docs),
152        other => Err(ProtocolError::Parse(format!(
153            "no bundle parser registered for protocol {other:?}; supported: {:?}",
154            bundle_parser_protocols()
155        ))),
156    }
157}
158
159/// The protocol names [`parse_schema_bundle`] accepts.
160///
161/// Lets a caller report or validate bundle support without hard-coding a
162/// protocol name outside this crate.
163#[must_use]
164pub fn bundle_parser_protocols() -> Vec<&'static str> {
165    registry::names_where(|d| d.bundle)
166}
167
168/// Parse a set of schema documents into per-file schemas, keyed by path.
169///
170/// The result also carries the edges that cross document boundaries: the
171/// shape [`build_project_tree`](https://docs.rs/panproto-project)
172/// consumes to store a document set as the per-file tree the VCS diffs
173/// incrementally.
174///
175/// Where [`parse_schema_bundle`] fuses a document set into one flat
176/// [`Schema`], this keeps each document a separate schema, so a
177/// version-controlled lexicon set can reuse unchanged per-file object
178/// ids across commits. Dispatch normalizes an underscore key to its
179/// canonical hyphenated protocol name, matching [`parse_schema_bundle`].
180/// Only the protocols in [`bundle_project_protocols`] retain per-file
181/// provenance today; any other returns an error.
182///
183/// # Errors
184///
185/// Returns [`ProtocolError::Parse`] for a protocol with no per-file
186/// bundle parser, or the underlying parser's error.
187pub fn parse_schema_bundle_project(
188    protocol: &str,
189    docs: &[(std::path::PathBuf, serde_json::Value)],
190) -> Result<atproto::LexiconProject, ProtocolError> {
191    match protocol.replace('_', "-").as_str() {
192        "atproto" => {
193            let lexicon_docs: Vec<atproto::LexiconDoc> = docs
194                .iter()
195                .map(|(path, value)| atproto::LexiconDoc {
196                    path: path.clone(),
197                    value: value.clone(),
198                })
199                .collect();
200            atproto::parse_lexicon_project(&lexicon_docs)
201        }
202        other => Err(ProtocolError::Parse(format!(
203            "no per-file bundle parser registered for protocol {other:?}; supported: [\"atproto\"]"
204        ))),
205    }
206}
207
208/// Protocols whose bundle parse retains per-file provenance for the VCS
209/// (via [`parse_schema_bundle_project`]).
210#[must_use]
211pub fn bundle_project_protocols() -> Vec<&'static str> {
212    registry::names_where(|d| d.bundle_project)
213}
214
215/// Parse a single JSON schema *document* into a [`Schema`], dispatching
216/// on protocol name.
217///
218/// This is the generic entry point that exposes every JSON-document
219/// schema parser through one call, so a binding forwards a protocol
220/// string here rather than reaching each protocol's parser directly.
221/// Protocols whose source is text rather than JSON (SQL DDL, GraphQL
222/// SDL, `.proto`, CDDL, CQL, Cypher, `ASN.1`, Bond, `FlatBuffers`, `CoNLL-U`)
223/// are served by [`parse_schema_source`] instead.
224///
225/// The `protocol` argument is matched against each protocol's canonical
226/// [`Protocol::name`](panproto_schema::Protocol) (hyphenated). An
227/// underscore is normalized to a hyphen first, so the underscore
228/// registry keys that [`crate`] callers list (`iso_space`,
229/// `msgpack_schema`, …) resolve too; `uima` is accepted as an alias of
230/// its canonical `uima-cas`.
231///
232/// # Errors
233///
234/// Returns [`ProtocolError::Parse`] if no JSON-document parser is
235/// registered for `protocol` (a text-source protocol, or an unknown
236/// name), or the protocol's own error if the document is malformed.
237pub fn parse_schema_document(
238    protocol: &str,
239    doc: &serde_json::Value,
240) -> Result<Schema, ProtocolError> {
241    parse_schema_document_within(protocol, doc, &Budget::with_defaults())
242}
243
244/// Parse a schema document within a caller-supplied budget.
245///
246/// # Errors
247///
248/// Returns [`ProtocolError::LimitExceeded`] naming the resource and its
249/// bound, or the underlying parser's error.
250pub fn parse_schema_document_within(
251    protocol: &str,
252    doc: &serde_json::Value,
253    budget: &Budget,
254) -> Result<Schema, ProtocolError> {
255    budget.charge(Resource::InputBytes, measure(doc))?;
256
257    match registry::descriptor(protocol) {
258        Some(d) => match d.parser {
259            registry::Parser::Document(parse) => parse(doc),
260            registry::Parser::Source(_) => Err(ProtocolError::Parse(format!(
261                "protocol {protocol:?} is read from source text, not a JSON document; \
262                 use parse_schema_source"
263            ))),
264        },
265        None => Err(ProtocolError::Parse(format!(
266            "no document parser registered for protocol {protocol:?}; supported: {:?}",
267            document_parser_protocols()
268        ))),
269    }
270}
271
272/// Parse a *text/source* schema (an IDL or DDL string) into a
273/// [`Schema`], dispatching on protocol name.
274///
275/// The text counterpart to [`parse_schema_document`], for the protocols
276/// whose source is a language rather than a JSON document: SQL DDL,
277/// GraphQL SDL, Protocol Buffers `.proto`, CDDL, Cassandra CQL, Cypher,
278/// `ASN.1`, Microsoft Bond, `FlatBuffers` `.fbs`, and `CoNLL-U`. Name matching
279/// is the same normalization as [`parse_schema_document`].
280///
281/// # Errors
282///
283/// Returns [`ProtocolError::Parse`] if no text-source parser is
284/// registered for `protocol`, or the protocol's own error if the source
285/// is malformed.
286pub fn parse_schema_source(protocol: &str, source: &str) -> Result<Schema, ProtocolError> {
287    parse_schema_source_within(protocol, source, &Budget::with_defaults())
288}
289
290/// Parse schema source text within a caller-supplied budget.
291///
292/// # Errors
293///
294/// Returns [`ProtocolError::LimitExceeded`] naming the resource and its
295/// bound, or the underlying parser's error.
296pub fn parse_schema_source_within(
297    protocol: &str,
298    source: &str,
299    budget: &Budget,
300) -> Result<Schema, ProtocolError> {
301    budget.charge(Resource::InputBytes, source.len() as u64)?;
302
303    match registry::descriptor(protocol) {
304        Some(d) => match d.parser {
305            registry::Parser::Source(parse) => parse(source),
306            registry::Parser::Document(_) => Err(ProtocolError::Parse(format!(
307                "protocol {protocol:?} is read from a JSON document, not source text; \
308                 use parse_schema_document"
309            ))),
310        },
311        None => Err(ProtocolError::Parse(format!(
312            "no source parser registered for protocol {protocol:?}; supported: {:?}",
313            source_parser_protocols()
314        ))),
315    }
316}
317
318/// The protocol names [`parse_schema_document`] accepts (canonical,
319/// hyphenated form).
320#[must_use]
321pub fn document_parser_protocols() -> Vec<&'static str> {
322    registry::names_where(|d| matches!(d.parser, registry::Parser::Document(_)))
323}
324
325/// The protocol names [`parse_schema_source`] accepts (canonical,
326/// hyphenated form).
327#[must_use]
328pub fn source_parser_protocols() -> Vec<&'static str> {
329    registry::names_where(|d| matches!(d.parser, registry::Parser::Source(_)))
330}
331
332#[cfg(test)]
333#[allow(clippy::expect_used)]
334mod dispatch_tests {
335    use super::*;
336
337    #[test]
338    fn document_dispatch_routes_json_schema() {
339        let doc = serde_json::json!({
340            "type": "object",
341            "properties": { "name": { "type": "string" }, "age": { "type": "integer" } }
342        });
343        let schema = parse_schema_document("json-schema", &doc).expect("json-schema should parse");
344        assert!(schema.has_vertex("root"));
345        assert!(schema.has_vertex("root.name"));
346        assert!(schema.has_vertex("root.age"));
347    }
348
349    #[test]
350    fn document_dispatch_normalizes_underscore_to_hyphen() {
351        // The underscore registry-key spelling resolves to the same
352        // canonical hyphenated parser.
353        let doc = serde_json::json!({ "type": "object" });
354        let via_hyphen = parse_schema_document("json-schema", &doc).expect("hyphen form");
355        let via_underscore = parse_schema_document("json_schema", &doc).expect("underscore form");
356        assert_eq!(via_hyphen.vertex_count(), via_underscore.vertex_count());
357    }
358
359    #[test]
360    fn source_dispatch_routes_graphql_sql_protobuf() {
361        let g = parse_schema_source("graphql", "type Query { hello: String }")
362            .expect("graphql sdl should parse");
363        assert!(g.has_vertex("Query"));
364
365        let s = parse_schema_source("sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);")
366            .expect("sql ddl should parse");
367        assert!(s.has_vertex("users"));
368
369        let p = parse_schema_source("protobuf", "message User { string name = 1; }")
370            .expect("proto should parse");
371        assert!(p.has_vertex("User"));
372    }
373
374    #[test]
375    fn uima_is_accepted_under_both_names() {
376        // The `uima` registry key aliases the canonical `uima-cas`; both
377        // route to the parser rather than the unknown-protocol arm.
378        let doc = serde_json::json!({});
379        // A malformed doc may error, but never with the "no parser" message.
380        for name in ["uima", "uima-cas"] {
381            if let Err(ProtocolError::Parse(msg)) = parse_schema_document(name, &doc) {
382                assert!(
383                    !msg.contains("no document parser"),
384                    "{name} must route to the uima parser, got: {msg}"
385                );
386            }
387        }
388    }
389
390    #[test]
391    fn cross_category_calls_point_at_the_other_dispatch() {
392        // A text-source protocol passed to the document dispatch is told
393        // to use the source dispatch, and vice versa.
394        let doc = serde_json::json!({});
395        let err = parse_schema_document("sql", &doc).expect_err("sql is text-source");
396        assert!(err.to_string().contains("parse_schema_source"));
397
398        let err = parse_schema_source("json-schema", "{}").expect_err("json-schema is a document");
399        assert!(err.to_string().contains("parse_schema_document"));
400    }
401
402    #[test]
403    fn parser_protocol_lists_have_expected_sizes() {
404        assert_eq!(document_parser_protocols().len(), 43);
405        assert_eq!(source_parser_protocols().len(), 11);
406        assert!(document_parser_protocols().contains(&"json-schema"));
407        assert!(source_parser_protocols().contains(&"graphql"));
408        assert!(source_parser_protocols().contains(&"sql"));
409        assert!(source_parser_protocols().contains(&"protobuf"));
410    }
411}