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}