use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Default, serde::Serialize)]
struct Catalog {
spec_version: String,
spec_source: String,
objects: BTreeMap<String, ObjectEntry>,
json_schema_2020_12_keywords: Vec<String>,
parameter_style_matrix: Vec<StyleEntry>,
appendices: Vec<AppendixEntry>,
totals: Totals,
}
#[derive(Debug, Default, serde::Serialize)]
struct ObjectEntry {
section_anchor: String,
fields: Vec<FieldEntry>,
patterned_fields: Vec<FieldEntry>,
notes: Vec<String>,
}
#[derive(Debug, Default, serde::Serialize, Clone)]
struct FieldEntry {
name: String,
anchor: String,
type_str: String,
required: bool,
description_excerpt: String,
}
#[derive(Debug, serde::Serialize)]
struct StyleEntry {
style: &'static str,
parameter_in: &'static [&'static str],
types: &'static [&'static str],
rfc6570: &'static str,
}
#[derive(Debug, serde::Serialize)]
struct AppendixEntry {
id: &'static str,
title: &'static str,
}
#[derive(Debug, Default, serde::Serialize)]
struct Totals {
object_count: usize,
field_count: usize,
patterned_field_count: usize,
json_schema_keyword_count: usize,
parameter_style_combo_count: usize,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let workspace = workspace_root();
let spec_path = workspace.join("tests/conformance/specs/openapi-3.2.0.md");
let out_path = workspace.join("tests/conformance/catalog.yaml");
let md = fs::read_to_string(&spec_path)?;
let mut catalog = parse(&md);
catalog.spec_source = spec_path
.strip_prefix(&workspace)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| spec_path.display().to_string());
catalog.json_schema_2020_12_keywords = json_schema_2020_12_keywords();
catalog.parameter_style_matrix = parameter_style_matrix();
catalog.appendices = appendices();
catalog.totals.object_count = catalog.objects.len();
catalog.totals.field_count = catalog.objects.values().map(|o| o.fields.len()).sum();
catalog.totals.patterned_field_count = catalog
.objects
.values()
.map(|o| o.patterned_fields.len())
.sum();
catalog.totals.json_schema_keyword_count = catalog.json_schema_2020_12_keywords.len();
catalog.totals.parameter_style_combo_count = catalog.parameter_style_matrix.len();
let yaml = serde_yaml::to_string(&catalog)?;
let header = format!(
"# Generated by `cargo run --bin catalog-gen`. Do not edit by hand.\n\
# Source: {}\n",
catalog.spec_source
);
fs::write(&out_path, format!("{header}{yaml}"))?;
println!(
"wrote {} ({} objects, {} fields, {} patterned, {} JSON Schema keywords, {} style combos)",
out_path.display(),
catalog.totals.object_count,
catalog.totals.field_count,
catalog.totals.patterned_field_count,
catalog.totals.json_schema_keyword_count,
catalog.totals.parameter_style_combo_count,
);
Ok(())
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn parse(md: &str) -> Catalog {
let mut catalog = Catalog::default();
if let Some(v) = scan_version(md) {
catalog.spec_version = v;
}
enum State {
Outside,
InObject {
name: String,
},
AwaitingTable {
object: String,
kind: TableKind,
},
InTable {
object: String,
kind: TableKind,
saw_separator: bool,
},
}
enum TableKind {
Fixed,
Patterned,
}
let mut state = State::Outside;
for line in md.lines() {
match &state {
State::Outside | State::InObject { .. } | State::AwaitingTable { .. } => {
if let Some((name, anchor)) = scan_object_heading(line) {
catalog
.objects
.entry(name.clone())
.or_insert_with(|| ObjectEntry {
section_anchor: anchor.clone(),
..ObjectEntry::default()
});
state = State::InObject { name };
continue;
}
}
_ => {}
}
match &state {
State::InObject { name, .. } | State::AwaitingTable { object: name, .. } => {
if line.trim_start().starts_with("#### Fixed Fields") {
state = State::AwaitingTable {
object: name.clone(),
kind: TableKind::Fixed,
};
continue;
}
if line.trim_start().starts_with("#### Patterned Fields") {
state = State::AwaitingTable {
object: name.clone(),
kind: TableKind::Patterned,
};
continue;
}
}
_ => {}
}
if let State::AwaitingTable { object, kind } = &state {
if line.starts_with("| Field Name") {
state = State::InTable {
object: object.clone(),
kind: match kind {
TableKind::Fixed => TableKind::Fixed,
TableKind::Patterned => TableKind::Patterned,
},
saw_separator: false,
};
continue;
}
if line.trim_start().starts_with("####") || line.starts_with("### ") {
state = State::Outside;
}
}
if let State::InTable {
object,
kind,
saw_separator,
} = &mut state
{
if !*saw_separator {
if line.starts_with("| --") || line.starts_with("|--") {
*saw_separator = true;
}
continue;
}
if line.trim().is_empty() || !line.starts_with('|') {
state = State::Outside;
continue;
}
if let Some(field) = parse_field_row(line) {
if let Some(entry) = catalog.objects.get_mut(object) {
match kind {
TableKind::Fixed => entry.fields.push(field),
TableKind::Patterned => entry.patterned_fields.push(field),
}
}
}
}
}
catalog
}
fn scan_version(md: &str) -> Option<String> {
md.lines()
.find(|l| l.starts_with("## Version "))
.map(|l| l.trim_start_matches("## Version ").trim().to_string())
}
fn scan_object_heading(line: &str) -> Option<(String, String)> {
let rest = line.strip_prefix("### ")?;
let name = rest.trim_end().trim_end_matches(" Object").to_string();
if !rest.ends_with(" Object") {
return None;
}
let anchor = name.to_lowercase().replace(' ', "-") + "-object";
Some((name, anchor))
}
fn parse_field_row(line: &str) -> Option<FieldEntry> {
let cells: Vec<&str> = line
.trim()
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(|c| c.trim())
.collect();
if cells.len() < 3 {
return None;
}
let name_cell = cells[0];
let type_cell = cells[1];
let desc_cell = cells[2];
let (name, anchor) = extract_name_and_anchor(name_cell);
if name.is_empty() {
return None;
}
let required = desc_cell.contains("**REQUIRED**");
let description_excerpt = strip_markdown(desc_cell)
.chars()
.take(160)
.collect::<String>();
Some(FieldEntry {
name,
anchor,
type_str: strip_markdown(type_cell),
required,
description_excerpt,
})
}
fn extract_name_and_anchor(cell: &str) -> (String, String) {
let mut anchor = String::new();
let mut rest = cell;
if let Some(start) = cell.find("name=\"") {
let after = &cell[start + 6..];
if let Some(end) = after.find('"') {
anchor = after[..end].to_string();
}
}
if let Some(end_tag) = cell.find("</a>") {
rest = &cell[end_tag + 4..];
}
let name = rest.trim().trim_start_matches('`').trim_end_matches('`');
let name = name.split_whitespace().next().unwrap_or("").to_string();
(name, anchor)
}
fn strip_markdown(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_link_text = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'`' => {} '[' => {
in_link_text = true;
}
']' => {
in_link_text = false;
if let Some(&'(') = chars.peek() {
chars.next();
for inner in chars.by_ref() {
if inner == ')' {
break;
}
}
}
}
'*' => {} _ if !in_link_text && c == '<' => {
for inner in chars.by_ref() {
if inner == '>' {
break;
}
}
}
_ => out.push(c),
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn json_schema_2020_12_keywords() -> Vec<String> {
[
"$schema",
"$vocabulary",
"$id",
"$ref",
"$anchor",
"$dynamicRef",
"$dynamicAnchor",
"$defs",
"$comment",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
"dependentSchemas",
"prefixItems",
"items",
"contains",
"properties",
"patternProperties",
"additionalProperties",
"propertyNames",
"type",
"enum",
"const",
"multipleOf",
"maximum",
"exclusiveMaximum",
"minimum",
"exclusiveMinimum",
"maxLength",
"minLength",
"pattern",
"maxItems",
"minItems",
"uniqueItems",
"maxContains",
"minContains",
"maxProperties",
"minProperties",
"required",
"dependentRequired",
"title",
"description",
"default",
"deprecated",
"readOnly",
"writeOnly",
"examples",
"format",
"contentEncoding",
"contentMediaType",
"contentSchema",
"unevaluatedItems",
"unevaluatedProperties",
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn parameter_style_matrix() -> Vec<StyleEntry> {
vec![
StyleEntry {
style: "matrix",
parameter_in: &["path"],
types: &["primitive", "array", "object"],
rfc6570: "{;var}",
},
StyleEntry {
style: "label",
parameter_in: &["path"],
types: &["primitive", "array", "object"],
rfc6570: "{.var}",
},
StyleEntry {
style: "simple",
parameter_in: &["path", "header"],
types: &["primitive", "array", "object"],
rfc6570: "{var}",
},
StyleEntry {
style: "form",
parameter_in: &["query", "cookie"],
types: &["primitive", "array", "object"],
rfc6570: "{?var}/{&var}",
},
StyleEntry {
style: "spaceDelimited",
parameter_in: &["query"],
types: &["array", "object"],
rfc6570: "n/a (custom)",
},
StyleEntry {
style: "pipeDelimited",
parameter_in: &["query"],
types: &["array", "object"],
rfc6570: "n/a (custom)",
},
StyleEntry {
style: "deepObject",
parameter_in: &["query"],
types: &["object"],
rfc6570: "n/a (custom)",
},
]
}
fn appendices() -> Vec<AppendixEntry> {
vec![
AppendixEntry {
id: "A",
title: "Revision History",
},
AppendixEntry {
id: "B",
title: "Data Type Conversion",
},
AppendixEntry {
id: "C",
title: "Using RFC6570-Based Serialization",
},
AppendixEntry {
id: "D",
title: "Serializing Headers and Cookies",
},
AppendixEntry {
id: "E",
title: "Percent-Encoding and Form Media Types",
},
AppendixEntry {
id: "F",
title: "Base URI Determination and Reference Resolution",
},
AppendixEntry {
id: "G",
title: "Parsing and Resolution Guidance",
},
]
}