Skip to main content

baukit_openapi/
naming.rs

1//! The camelCase naming check for property and parameter names.
2
3use std::fmt;
4
5use serde_json::{Map, Value};
6
7/// Keywords whose values are data or product-owned identifiers, never wire field names.
8const DATA_KEYWORDS: [&str; 6] = [
9    "const",
10    "default",
11    "discriminator",
12    "enum",
13    "example",
14    "examples",
15];
16/// Parameter locations the naming convention covers. Header names follow HTTP conventions.
17const CHECKED_PARAMETER_LOCATIONS: [&str; 2] = ["path", "query"];
18
19/// The kind of name a [`NamingViolation`] refers to.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum NameKind {
22    /// A JSON property name in a schema's `properties` map.
23    Property,
24    /// A path or query parameter name.
25    Parameter,
26}
27
28impl fmt::Display for NameKind {
29    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30        formatter.write_str(match self {
31            Self::Property => "property",
32            Self::Parameter => "parameter",
33        })
34    }
35}
36
37/// A property or parameter name that is not camelCase.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct NamingViolation {
40    /// JSON pointer to the property entry or the parameter object.
41    pub pointer: String,
42    /// The offending name.
43    pub name: String,
44    /// Whether the name is a property or a parameter.
45    pub kind: NameKind,
46}
47
48impl fmt::Display for NamingViolation {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(
51            formatter,
52            "{} {:?} at {}",
53            self.kind, self.name, self.pointer
54        )
55    }
56}
57
58/// Returns `true` for a lower camelCase name: an ASCII lowercase letter followed by ASCII letters
59/// and digits.
60#[must_use]
61pub fn is_camel_case(name: &str) -> bool {
62    let mut bytes = name.bytes();
63    bytes.next().is_some_and(|first| first.is_ascii_lowercase())
64        && bytes.all(|byte| byte.is_ascii_alphanumeric())
65}
66
67/// Finds every property and parameter name in a serialized OpenAPI document that is not camelCase.
68///
69/// The walk covers schema `properties` keys, including multipart form fields, and path and query
70/// parameter names. It never inspects enum or const values, examples, defaults, discriminator
71/// mappings, or `x-` extensions, and `additionalProperties` map keys never appear in a document.
72/// Names listed in `exemptions`, such as OAuth 2.0 `access_token`, are accepted wherever they occur.
73#[must_use]
74pub fn find_naming_violations(document: &Value, exemptions: &[&str]) -> Vec<NamingViolation> {
75    let mut walker = Walker {
76        exemptions,
77        violations: Vec::new(),
78    };
79    walker.walk(document, &mut String::new());
80    walker.violations
81}
82
83struct Walker<'a> {
84    exemptions: &'a [&'a str],
85    violations: Vec<NamingViolation>,
86}
87
88impl Walker<'_> {
89    fn walk(&mut self, value: &Value, pointer: &mut String) {
90        match value {
91            Value::Object(object) => self.walk_object(object, pointer),
92            Value::Array(items) => {
93                for (index, item) in items.iter().enumerate() {
94                    with_segment(pointer, &index.to_string(), |pointer| {
95                        self.walk(item, pointer)
96                    });
97                }
98            }
99            _ => {}
100        }
101    }
102
103    fn walk_object(&mut self, object: &Map<String, Value>, pointer: &mut String) {
104        self.check_parameter(object, pointer);
105        for (key, child) in object {
106            if is_skipped_keyword(key) {
107                continue;
108            }
109            with_segment(pointer, key, |pointer| match (key.as_str(), child) {
110                ("properties", Value::Object(properties)) => {
111                    self.walk_properties(properties, pointer)
112                }
113                _ => self.walk(child, pointer),
114            });
115        }
116    }
117
118    fn walk_properties(&mut self, properties: &Map<String, Value>, pointer: &mut String) {
119        for (name, schema) in properties {
120            with_segment(pointer, name, |pointer| {
121                self.check_name(name, pointer, NameKind::Property);
122                self.walk(schema, pointer);
123            });
124        }
125    }
126
127    fn check_parameter(&mut self, object: &Map<String, Value>, pointer: &str) {
128        let (Some(Value::String(location)), Some(Value::String(name))) =
129            (object.get("in"), object.get("name"))
130        else {
131            return;
132        };
133        if CHECKED_PARAMETER_LOCATIONS.contains(&location.as_str()) {
134            self.check_name(name, pointer, NameKind::Parameter);
135        }
136    }
137
138    fn check_name(&mut self, name: &str, pointer: &str, kind: NameKind) {
139        if is_camel_case(name) || self.exemptions.contains(&name) {
140            return;
141        }
142        self.violations.push(NamingViolation {
143            pointer: pointer.to_owned(),
144            name: name.to_owned(),
145            kind,
146        });
147    }
148}
149
150fn is_skipped_keyword(key: &str) -> bool {
151    key.starts_with("x-") || DATA_KEYWORDS.contains(&key)
152}
153
154fn with_segment(pointer: &mut String, segment: &str, visit: impl FnOnce(&mut String)) {
155    let length = pointer.len();
156    pointer.push('/');
157    pointer.push_str(&segment.replace('~', "~0").replace('/', "~1"));
158    visit(pointer);
159    pointer.truncate(length);
160}