Skip to main content

oapi_codegen/lower/
servers.rs

1//! Lowering the spec's `servers:` block into the [`ServerUrls`] IR.
2//!
3//! Each declared server becomes either a constant (no `{placeholder}` in the
4//! URL) or a builder function that substitutes its variables. Enum-constrained
5//! variables additionally produce a dedicated Rust `enum` type so callers pass a
6//! validated value rather than a bare string.
7//!
8//! This mirrors `oapi-codegen`'s `server-urls` generator, including its handling
9//! of declared-but-unused variables (skipped) and URL placeholders with no
10//! matching variable (emitted as free-form `&str` parameters).
11
12use std::collections::HashMap;
13use std::collections::HashSet;
14
15use openapiv3::Server;
16use openapiv3::ServerVariable;
17
18use crate::error::Error;
19use crate::error::Result;
20use crate::ir::ServerUrl;
21use crate::ir::ServerUrlBuilder;
22use crate::ir::ServerUrlConst;
23use crate::ir::ServerUrlEnum;
24use crate::ir::ServerUrlEnumVariant;
25use crate::ir::ServerUrlParam;
26use crate::ir::ServerUrlParamType;
27use crate::ir::ServerUrls;
28use crate::loader::Spec;
29use crate::naming::Case;
30use crate::naming::X_RUST_NAME;
31use crate::naming::deconflict_ident;
32use crate::naming::to_ident;
33
34/// Prefix applied to server identifiers derived from a description or URL, so a
35/// bare description like `Production` becomes `ServerUrlProduction`.
36const SERVER_PREFIX: &str = "server url";
37
38/// Lower the spec's `servers:` into the [`ServerUrls`] IR, or `None` when the
39/// document declares no servers.
40pub fn lower_server_urls(spec: &Spec) -> Result<Option<ServerUrls>> {
41    let servers = spec.servers();
42    if servers.is_empty() {
43        return Ok(None);
44    }
45    let mut used_names: HashMap<String, usize> = HashMap::new();
46    let mut enums = Vec::new();
47    let mut lowered = Vec::new();
48    for server in servers {
49        let seed = deconflict(name_seed(server)?, &mut used_names);
50        lowered.push(lower_server(server, &seed, &mut enums)?);
51    }
52    return Ok(Some(ServerUrls {
53        enums,
54        servers: lowered,
55    }));
56}
57
58/// Lower a single server into a constant or builder, pushing any enum types it
59/// needs onto `enums`.
60fn lower_server(server: &Server, seed: &str, enums: &mut Vec<ServerUrlEnum>) -> Result<ServerUrl> {
61    let label = label_of(server)?;
62    let doc = Some(doc_of(server, &label));
63    let placeholders = placeholders(&server.url);
64    if placeholders.is_empty() {
65        return Ok(ServerUrl::Const(ServerUrlConst {
66            name: to_ident(seed, Case::ScreamingSnake),
67            doc,
68            url: server.url.clone(),
69        }));
70    }
71    let variables = server.variables.as_ref();
72    let mut params = Vec::with_capacity(placeholders.len());
73    for placeholder in &placeholders {
74        let variable = variables.and_then(|vars| return vars.get(placeholder));
75        let ty = param_type(server, seed, &label, placeholder, variable, enums)?;
76        params.push(ServerUrlParam {
77            ident: to_ident(placeholder, Case::Snake),
78            placeholder: placeholder.clone(),
79            ty,
80        });
81    }
82    return Ok(ServerUrl::Builder(ServerUrlBuilder {
83        name: to_ident(seed, Case::Snake),
84        doc,
85        url_template: server.url.clone(),
86        params,
87    }));
88}
89
90/// Determine a placeholder's parameter type, synthesising an enum type for an
91/// enum-constrained variable.
92fn param_type(
93    server: &Server,
94    seed: &str,
95    label: &str,
96    placeholder: &str,
97    variable: Option<&ServerVariable>,
98    enums: &mut Vec<ServerUrlEnum>,
99) -> Result<ServerUrlParamType> {
100    let Some(variable) = variable else {
101        return Ok(ServerUrlParamType::Str);
102    };
103    if variable.enumeration.is_empty() {
104        return Ok(ServerUrlParamType::Str);
105    }
106    let enom = build_enum(server, seed, label, placeholder, variable)?;
107    let name = enom.name.clone();
108    enums.push(enom);
109    return Ok(ServerUrlParamType::Enum(name));
110}
111
112/// Build the enum type for an enum-constrained server variable, validating that
113/// its declared `default` (if any) is one of the enum values.
114fn build_enum(
115    server: &Server,
116    seed: &str,
117    label: &str,
118    placeholder: &str,
119    variable: &ServerVariable,
120) -> Result<ServerUrlEnum> {
121    let mut seen: HashSet<String> = HashSet::new();
122    let mut variants = Vec::with_capacity(variable.enumeration.len());
123    let mut default = None;
124    for value in &variable.enumeration {
125        let ident = deconflict_ident(to_ident(value, Case::Pascal), &mut seen);
126        if !variable.default.is_empty() && value == &variable.default {
127            default = Some(ident.clone());
128        }
129        variants.push(ServerUrlEnumVariant {
130            name: ident,
131            value: value.clone(),
132        });
133    }
134    if !variable.default.is_empty() && default.is_none() {
135        return Err(Error::UnsupportedSchema {
136            path: format!("servers[{}].variables.{placeholder}", server.url),
137            reason: format!(
138                "default value `{}` is not one of the declared enum values",
139                variable.default
140            ),
141        });
142    }
143    return Ok(ServerUrlEnum {
144        name: to_ident(&format!("{seed} {placeholder}"), Case::Pascal),
145        doc: Some(format!("`{placeholder}` variable of the `{label}` server URL.")),
146        variants,
147        default,
148    });
149}
150
151/// The identifier seed for a server: an explicit `x-rust-name`, else the
152/// description or URL prefixed with `server url`.
153fn name_seed(server: &Server) -> Result<String> {
154    if let Some(name) = extension_str(server, X_RUST_NAME)? {
155        return Ok(name.to_owned());
156    }
157    let basis = server.description.as_deref().unwrap_or(&server.url);
158    return Ok(format!("{SERVER_PREFIX} {basis}"));
159}
160
161/// A human-facing label for docs: an explicit `x-rust-name`, else the
162/// description, else the URL.
163fn label_of(server: &Server) -> Result<String> {
164    if let Some(name) = extension_str(server, X_RUST_NAME)? {
165        return Ok(name.to_owned());
166    }
167    return Ok(server.description.clone().unwrap_or_else(|| return server.url.clone()));
168}
169
170/// The doc comment for a server: its trimmed `description`, else a synthesised
171/// sentence naming the server, so every emitted item carries documentation.
172fn doc_of(server: &Server, label: &str) -> String {
173    if let Some(text) = server.description.as_ref() {
174        let text = text.trim();
175        if !text.is_empty() {
176            return text.to_owned();
177        }
178    }
179    return format!("The `{label}` server URL.");
180}
181
182/// Ensure a seed's Rust identifier is unique across servers, appending a numeric
183/// suffix on collision (`Production`, `Production 2`, ...).
184fn deconflict(seed: String, used: &mut HashMap<String, usize>) -> String {
185    let key = to_ident(&seed, Case::Pascal).logical().to_owned();
186    let count = used.entry(key).or_insert(0);
187    *count += 1;
188    if *count == 1 {
189        return seed;
190    }
191    return format!("{seed} {count}");
192}
193
194/// Extract the `{placeholder}` names from a URL, in order and de-duplicated.
195///
196/// A placeholder cannot contain `/`, `{`, or `}` (matching the reference
197/// implementation's `\{([^/{}]+)\}`). An interrupting `/` or nested `{` discards
198/// the partial name.
199fn placeholders(url: &str) -> Vec<String> {
200    let mut out: Vec<String> = Vec::new();
201    let mut current: Option<String> = None;
202    for c in url.chars() {
203        match c {
204            '{' => current = Some(String::new()),
205            '/' => current = None,
206            '}' => {
207                if let Some(name) = current.take()
208                    && !name.is_empty()
209                    && !out.contains(&name)
210                {
211                    out.push(name);
212                }
213            }
214            other => {
215                if let Some(buf) = current.as_mut() {
216                    buf.push(other);
217                }
218            }
219        }
220    }
221    return out;
222}
223
224/// Read a string-valued extension (for example `x-rust-name`) from a server object.
225fn extension_str<'a>(server: &'a Server, key: &str) -> Result<Option<&'a str>> {
226    return crate::lower::extension::str_value(&server.extensions, key, &server.url);
227}
228
229#[cfg(test)]
230mod tests {
231    use std::path::PathBuf;
232
233    use super::*;
234
235    /// Parse an inline document and lower its `servers:` block.
236    fn lower(yaml: &str) -> Result<Option<ServerUrls>> {
237        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
238        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
239        return lower_server_urls(&spec);
240    }
241
242    const PREAMBLE: &str = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\n";
243
244    #[test]
245    fn placeholders_extracted_in_order_without_duplicates() {
246        let got = placeholders("https://{host}.example.com:{port}/{base}/{host}");
247        assert_eq!(got, vec!["host", "port", "base"]);
248    }
249
250    #[test]
251    fn placeholder_does_not_span_a_slash() {
252        assert!(placeholders("https://{a/b}.example.com").is_empty());
253    }
254
255    #[test]
256    fn url_without_placeholders_has_none() {
257        assert!(placeholders("https://api.example.com/v1").is_empty());
258    }
259
260    #[test]
261    fn no_servers_lowers_to_none() {
262        let lowered = lower(PREAMBLE).expect("lower");
263        assert!(lowered.is_none());
264    }
265
266    #[test]
267    fn variable_free_server_becomes_a_const() {
268        let yaml = format!("{PREAMBLE}servers:\n  - url: https://api.example.com\n    description: Production\n");
269        let lowered = lower(&yaml).expect("lower").expect("servers");
270        assert!(lowered.enums.is_empty());
271        match &lowered.servers[..] {
272            [ServerUrl::Const(konst)] => {
273                assert_eq!(konst.name.logical(), "SERVER_URL_PRODUCTION");
274                assert_eq!(konst.url, "https://api.example.com");
275            }
276            other => panic!("expected a single const, got {other:?}"),
277        }
278    }
279
280    #[test]
281    fn enum_variable_yields_enum_param_and_default() {
282        let yaml = format!(
283            "{PREAMBLE}servers:\n  - url: https://api.example.com:{{port}}\n    description: Prod\n    variables:\n      \
284             port:\n        default: '443'\n        enum: ['443', '8443']\n"
285        );
286        let lowered = lower(&yaml).expect("lower").expect("servers");
287        let enom = &lowered.enums[0];
288        assert_eq!(enom.name.logical(), "ServerUrlProdPort");
289        assert_eq!(enom.variants.len(), 2);
290        assert_eq!(
291            enom.default.as_ref().map(|d| return d.logical().to_owned()),
292            Some("_443".to_owned())
293        );
294        match &lowered.servers[0] {
295            ServerUrl::Builder(builder) => match &builder.params[0].ty {
296                ServerUrlParamType::Enum(name) => assert_eq!(name.logical(), "ServerUrlProdPort"),
297                other => panic!("expected enum param, got {other:?}"),
298            },
299            other => panic!("expected a builder, got {other:?}"),
300        }
301    }
302
303    #[test]
304    fn unused_variable_is_skipped_and_undeclared_placeholder_is_a_str_param() {
305        let yaml = format!(
306            "{PREAMBLE}servers:\n  - url: https://{{tenant}}.example.com/{{basePath}}\n    description: Regional\n    \
307             variables:\n      tenant:\n        default: acme\n      unused:\n        default: x\n"
308        );
309        let lowered = lower(&yaml).expect("lower").expect("servers");
310        assert!(lowered.enums.is_empty(), "no enum variables declared");
311        match &lowered.servers[0] {
312            ServerUrl::Builder(builder) => {
313                let names: Vec<&str> = builder.params.iter().map(|p| return p.placeholder.as_str()).collect();
314                assert_eq!(
315                    names,
316                    vec!["tenant", "basePath"],
317                    "unused var skipped, placeholders in template-appearance order"
318                );
319                assert!(
320                    builder
321                        .params
322                        .iter()
323                        .all(|p| return matches!(p.ty, ServerUrlParamType::Str))
324                );
325            }
326            other => panic!("expected a builder, got {other:?}"),
327        }
328    }
329
330    #[test]
331    fn default_not_in_enum_is_rejected() {
332        let yaml = format!(
333            "{PREAMBLE}servers:\n  - url: https://api.example.com:{{port}}\n    variables:\n      port:\n        \
334             default: '12345'\n        enum: ['443', '8443']\n"
335        );
336        let error = lower(&yaml).expect_err("default not in enum must be rejected");
337        let message = error.to_string();
338        assert!(
339            message.contains("12345"),
340            "error should name the bad default: {message}"
341        );
342    }
343
344    #[test]
345    fn colliding_seed_names_are_deconflicted() {
346        let yaml = format!(
347            "{PREAMBLE}servers:\n  - url: https://a.example.com\n    description: Prod\n  - url: https://b.example.com\n    \
348             description: Prod\n"
349        );
350        let lowered = lower(&yaml).expect("lower").expect("servers");
351        let names: Vec<&str> = lowered
352            .servers
353            .iter()
354            .map(|server| match server {
355                ServerUrl::Const(konst) => return konst.name.logical(),
356                ServerUrl::Builder(builder) => return builder.name.logical(),
357            })
358            .collect();
359        assert_eq!(names, vec!["SERVER_URL_PROD", "SERVER_URL_PROD_2"]);
360    }
361}