1use serde_json::{json, Map, Value};
18
19use super::{Enum, Field, Message, ProtoFile, MSG_BODY, MSG_DOCUMENT, MSG_HEADER};
20
21pub const SCHEMA_EXTENSION: &str = "x-majordomus-schema";
23
24pub const DERIVED_EXTENSION: &str = "x-majordomus-derived-from";
27
28pub const SECTIONS_EXTENSION: &str = "x-majordomus-sections";
30
31pub fn schema_path(schema_id: &str) -> crate::error::Result<String> {
34 let proto = ProtoFile::expected_path(schema_id)?;
35 Ok(proto.replace(super::PROTO_SUFFIX, crate::share::SCHEMA_SUFFIX))
36}
37
38pub fn to_json_schema(file: &ProtoFile) -> crate::error::Result<Value> {
41 let header = file.header()?;
42 let body = file.body();
43
44 let mut defs = Map::new();
45 defs.insert(MSG_HEADER.into(), message_schema(file, header));
46 for message in &file.messages {
48 if message.name == MSG_HEADER || message.name == MSG_DOCUMENT || message.body {
49 continue;
50 }
51 if message.name == "Section" && body.is_none() {
52 continue;
53 }
54 defs.insert(message.name.clone(), message_schema(file, message));
55 }
56
57 let mut properties = Map::new();
58 properties.insert(
59 MSG_HEADER.to_lowercase(),
60 json!({ "$ref": "#/$defs/Header" }),
61 );
62 let mut required = vec![json!("header")];
63
64 if let Some(body) = body {
65 defs.insert(MSG_BODY.into(), body_schema(body));
66 properties.insert(MSG_BODY.to_lowercase(), json!({ "$ref": "#/$defs/Body" }));
67 required.push(json!("body"));
68 }
69
70 let name = file
71 .schema_id
72 .split_once('/')
73 .and_then(|(q, _)| q.rsplit_once('.').map(|(_, n)| n.to_string()))
74 .unwrap_or_else(|| file.schema_id.clone());
75
76 let mut root = Map::new();
77 root.insert(
78 "$schema".into(),
79 json!("https://json-schema.org/draft/2020-12/schema"),
80 );
81 root.insert(
82 "$id".into(),
83 json!(format!(
84 "https://majordomus.dev/schemas/{}",
85 schema_path(&file.schema_id)?
86 )),
87 );
88 root.insert(SCHEMA_EXTENSION.into(), json!(file.schema_id));
89 root.insert(
90 DERIVED_EXTENSION.into(),
91 json!(ProtoFile::expected_path(&file.schema_id)?),
92 );
93 if let Some(allow) = &file.allow_list {
94 root.insert(crate::generate::ALLOW_EXTENSION.into(), json!(allow));
95 }
96 if let Some(message) = file.message(MSG_DOCUMENT) {
97 if !message.doc.is_empty() {
98 root.insert("description".into(), json!(message.doc));
99 }
100 }
101 root.insert("title".into(), json!(name));
102 root.insert("type".into(), json!("object"));
103 root.insert("additionalProperties".into(), json!(false));
104 root.insert("required".into(), Value::Array(required));
105 root.insert("properties".into(), Value::Object(properties));
106 root.insert("$defs".into(), Value::Object(defs));
107 Ok(Value::Object(root))
108}
109
110fn message_schema(file: &ProtoFile, message: &Message) -> Value {
112 let mut out = Map::new();
113 if !message.doc.is_empty() {
114 out.insert("description".into(), json!(message.doc));
115 }
116 out.insert("type".into(), json!("object"));
117 out.insert("additionalProperties".into(), json!(!message.closed));
118 let required: Vec<Value> = message
119 .fields
120 .iter()
121 .filter(|f| f.options.required)
122 .map(|f| json!(f.key()))
123 .collect();
124 if !required.is_empty() {
125 out.insert("required".into(), Value::Array(required));
126 }
127 let mut properties = Map::new();
128 for field in &message.fields {
129 properties.insert(field.key().to_string(), field_schema(file, field));
130 }
131 out.insert("properties".into(), Value::Object(properties));
132 Value::Object(out)
133}
134
135fn field_schema(file: &ProtoFile, field: &Field) -> Value {
137 let mut inner = scalar_schema(file, field);
138 if let Value::Object(map) = &mut inner {
139 if !field.doc.is_empty() && !field.repeated {
140 map.insert("description".into(), json!(field.doc));
141 let mut ordered = Map::new();
144 ordered.insert("description".into(), json!(field.doc));
145 for (k, v) in map.iter() {
146 if k != "description" {
147 ordered.insert(k.clone(), v.clone());
148 }
149 }
150 *map = ordered;
151 }
152 }
153 if !field.repeated {
154 return inner;
155 }
156 let mut out = Map::new();
157 if !field.doc.is_empty() {
158 out.insert("description".into(), json!(field.doc));
159 }
160 out.insert("type".into(), json!("array"));
161 if let Some(n) = field.options.min_items {
162 out.insert("minItems".into(), json!(n));
163 }
164 out.insert("items".into(), inner);
165 Value::Object(out)
166}
167
168fn scalar_schema(file: &ProtoFile, field: &Field) -> Value {
171 if let Some(constant) = &field.options.const_value {
172 return json!({ "const": constant });
173 }
174 if let Some(enumeration) = file.enumeration(&field.type_name) {
175 return json!({ "enum": enum_values(enumeration) });
176 }
177 if file.message(&field.type_name).is_some() {
178 return json!({ "$ref": format!("#/$defs/{}", field.type_name) });
179 }
180 let mut out = Map::new();
181 match field.type_name.as_str() {
182 "string" => {
183 out.insert("type".into(), json!("string"));
184 if let Some(n) = field.options.min_length {
185 out.insert("minLength".into(), json!(n));
186 }
187 if let Some(p) = &field.options.pattern {
188 out.insert("pattern".into(), json!(p));
189 }
190 }
191 "bool" => {
192 out.insert("type".into(), json!("boolean"));
193 }
194 "uint32" | "uint64" | "int32" | "int64" => {
195 out.insert("type".into(), json!("integer"));
196 let minimum =
197 field.options.minimum.or_else(|| {
198 field.type_name.starts_with('u').then_some(0).filter(|_| {
199 field.options.maximum.is_some() || field.options.minimum.is_some()
200 })
201 });
202 if let Some(n) = minimum {
203 out.insert("minimum".into(), json!(n));
204 }
205 if let Some(n) = field.options.maximum {
206 out.insert("maximum".into(), json!(n));
207 }
208 }
209 other => {
210 out.insert(
213 "$comment".into(),
214 json!(format!("unmodelled proto type '{other}'")),
215 );
216 }
217 }
218 Value::Object(out)
219}
220
221fn enum_values(enumeration: &Enum) -> Vec<Value> {
223 enumeration
224 .values
225 .iter()
226 .filter_map(|v| v.written.as_ref())
227 .map(|v| json!(v))
228 .collect()
229}
230
231fn body_schema(body: &Message) -> Value {
235 let mut out = Map::new();
236 if !body.doc.is_empty() {
237 out.insert("description".into(), json!(body.doc));
238 }
239 out.insert("type".into(), json!("object"));
240 out.insert("additionalProperties".into(), json!(false));
241 out.insert("required".into(), json!(["sections"]));
242 out.insert(
243 "properties".into(),
244 json!({
245 "sections": {
246 "description": "Every heading of the body, in document order, as the reader flattens it.",
247 "type": "array",
248 "items": { "$ref": "#/$defs/Section" }
249 }
250 }),
251 );
252 out.insert(
253 SECTIONS_EXTENSION.into(),
254 Value::Array(body.fields.iter().map(section_entry).collect()),
255 );
256 Value::Object(out)
257}
258
259fn section_entry(field: &Field) -> Value {
260 let mut out = Map::new();
261 out.insert(
262 "heading".into(),
263 json!(field.options.heading.clone().unwrap_or_default()),
264 );
265 out.insert("level".into(), json!(field.options.level.unwrap_or(1)));
266 out.insert("required".into(), json!(field.options.required));
267 out.insert("non_empty".into(), json!(field.options.non_empty));
268 if !field.doc.is_empty() {
269 out.insert("description".into(), json!(field.doc));
270 }
271 Value::Object(out)
272}
273
274pub fn section_lines(file: &ProtoFile) -> Vec<String> {
301 let Some(body) = file.body() else {
302 return Vec::new();
303 };
304 body.fields
305 .iter()
306 .map(|f| {
307 format!(
308 "{}\t{}\t{}\t{}",
309 f.options.level.unwrap_or(1),
310 f.options.heading.clone().unwrap_or_default(),
311 if f.options.required {
312 "required"
313 } else {
314 "optional"
315 },
316 if f.options.non_empty {
317 "non-empty"
318 } else {
319 "any"
320 },
321 )
322 })
323 .collect()
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 const SRC: &str = r#"
331syntax = "proto3";
332package majordomus.adr.v1;
333import "majordomus/options.proto";
334option (majordomus.schema_id) = "majordomus.adr/v1";
335option (majordomus.allow_list) = "adr";
336option (majordomus.document_format) = "markdown";
337
338// The whole record.
339message Document {
340 Header header = 1;
341 Body body = 2;
342}
343
344message Header {
345 option (majordomus.closed) = true;
346 // The format version.
347 string schema = 1 [(majordomus.required) = true, (majordomus.const) = "adr/v1"];
348 string id = 2 [(majordomus.required) = true, (majordomus.pattern) = "^adr-[0-9]{4}$"];
349 string title = 3 [(majordomus.required) = true, (majordomus.min_length) = 1];
350 Status status = 4 [(majordomus.required) = true];
351 repeated string tags = 5 [(majordomus.pattern) = "^[a-z]+$"];
352 Provenance provenance = 6;
353 uint32 weight = 7 [(majordomus.minimum) = 0, (majordomus.maximum) = 9];
354}
355
356enum Status {
357 STATUS_UNSPECIFIED = 0;
358 STATUS_PROPOSED = 1 [(majordomus.value) = "proposed"];
359 STATUS_ACCEPTED = 2 [(majordomus.value) = "accepted"];
360}
361
362message Provenance {
363 option (majordomus.closed) = true;
364 Origin origin = 1 [(majordomus.required) = true];
365}
366
367enum Origin {
368 ORIGIN_UNSPECIFIED = 0;
369 ORIGIN_AUTHORED = 1 [(majordomus.value) = "authored"];
370}
371
372message Body {
373 option (majordomus.body) = true;
374 Section context = 1 [(majordomus.heading) = "Context", (majordomus.level) = 2,
375 (majordomus.required) = true, (majordomus.non_empty) = true];
376}
377
378message Section {
379 string heading = 1;
380 uint32 level = 2;
381 bool empty = 3;
382}
383"#;
384
385 fn schema() -> Value {
386 let file = crate::proto::parse("adr.v1.proto", SRC).expect("parses");
387 to_json_schema(&file).expect("projects")
388 }
389
390 #[test]
391 fn the_root_names_the_proto_it_came_from() {
392 let s = schema();
393 assert_eq!(s[SCHEMA_EXTENSION], json!("majordomus.adr/v1"));
394 assert_eq!(s[DERIVED_EXTENSION], json!("majordomus/adr/adr.v1.proto"));
395 assert_eq!(s[crate::generate::ALLOW_EXTENSION], json!("adr"));
396 assert_eq!(s["required"], json!(["header", "body"]));
397 assert_eq!(s["description"], json!("The whole record."));
398 }
399
400 #[test]
401 fn a_const_an_enum_and_a_reference_each_project_their_own_shape() {
402 let h = &schema()["$defs"]["Header"];
403 assert_eq!(h["properties"]["schema"]["const"], json!("adr/v1"));
404 assert_eq!(
405 h["properties"]["status"]["enum"],
406 json!(["proposed", "accepted"])
407 );
408 assert_eq!(
409 h["properties"]["provenance"]["$ref"],
410 json!("#/$defs/Provenance")
411 );
412 assert_eq!(h["additionalProperties"], json!(false));
413 assert_eq!(h["required"], json!(["schema", "id", "title", "status"]));
414 }
415
416 #[test]
417 fn a_repeated_field_becomes_an_array_around_its_item() {
418 let tags = &schema()["$defs"]["Header"]["properties"]["tags"];
419 assert_eq!(tags["type"], json!("array"));
420 assert_eq!(tags["items"]["pattern"], json!("^[a-z]+$"));
421 }
422
423 #[test]
424 fn constraints_and_prose_survive_the_projection() {
425 let h = &schema()["$defs"]["Header"];
426 assert_eq!(h["properties"]["id"]["pattern"], json!("^adr-[0-9]{4}$"));
427 assert_eq!(h["properties"]["title"]["minLength"], json!(1));
428 assert_eq!(h["properties"]["weight"]["maximum"], json!(9));
429 assert_eq!(
430 h["properties"]["schema"]["description"],
431 json!("The format version.")
432 );
433 }
434
435 #[test]
436 fn the_body_carries_the_section_contract() {
437 let body = &schema()["$defs"]["Body"];
438 let sections = body[SECTIONS_EXTENSION].as_array().expect("sections");
439 assert_eq!(sections[0]["heading"], json!("Context"));
440 assert_eq!(sections[0]["level"], json!(2));
441 assert_eq!(sections[0]["non_empty"], json!(true));
442 }
443
444 #[test]
445 fn the_projected_schema_is_a_valid_json_schema_and_accepts_a_real_record() {
446 let s = schema();
447 let validator = jsonschema::validator_for(&s).expect("compiles");
448 assert!(validator.is_valid(&json!({
449 "header": { "schema": "adr/v1", "id": "adr-0001", "title": "t", "status": "proposed" },
450 "body": { "sections": [] }
451 })));
452 assert!(!validator.is_valid(&json!({
453 "header": { "schema": "adr/v1", "id": "nope", "title": "t", "status": "proposed" },
454 "body": { "sections": [] }
455 })));
456 }
457
458 #[test]
459 fn the_allow_list_derives_from_the_header_alone() {
460 let s = schema();
461 assert_eq!(
462 crate::generate::allow_lines(&s),
463 [
464 "^schema$",
465 "^id$",
466 "^title$",
467 "^status$",
468 "^tags(\\.[0-9]+)?$",
469 "^provenance\\.origin$",
470 "^weight$",
471 ]
472 );
473 }
474}