1use 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
34const SERVER_PREFIX: &str = "server url";
37
38pub 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
58fn 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
90fn 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
112fn 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
151fn 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
161fn 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
170fn 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
182fn 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
194fn 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
224fn 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 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}