1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
12pub struct Spec {
13 pub paths: HashMap<String, SpecPathItem>,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub components: Option<SpecComponents>,
18}
19
20#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
22pub struct SpecComponents {
23 #[serde(
25 rename = "securitySchemes",
26 default,
27 skip_serializing_if = "HashMap::is_empty"
28 )]
29 pub security_schemes: HashMap<String, SpecSecurityScheme>,
30}
31
32#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
34pub struct SpecSecurityScheme {
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub flows: Option<SpecOAuthFlows>,
38}
39
40#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
42pub struct SpecOAuthFlows {
43 #[serde(
45 rename = "authorizationCode",
46 default,
47 skip_serializing_if = "Option::is_none"
48 )]
49 pub authorization_code: Option<SpecOAuthFlow>,
50}
51
52#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
54pub struct SpecOAuthFlow {
55 #[serde(default)]
57 pub scopes: HashMap<String, String>,
58}
59
60#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
65pub struct SpecPathItem {
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub get: Option<SpecPathMethod>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub post: Option<SpecPathMethod>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub put: Option<SpecPathMethod>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub delete: Option<SpecPathMethod>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub patch: Option<SpecPathMethod>,
81}
82
83impl SpecPathItem {
84 pub fn methods(&self) -> impl Iterator<Item = &SpecPathMethod> {
86 [&self.get, &self.post, &self.put, &self.delete, &self.patch]
87 .into_iter()
88 .flatten()
89 }
90}
91
92#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
94pub struct SpecPathMethod {
95 #[serde(
97 rename = "operationId",
98 default,
99 skip_serializing_if = "Option::is_none"
100 )]
101 pub operation_id: Option<String>,
102 #[serde(
104 rename = "x-client-cache-ttl",
105 default,
106 skip_serializing_if = "Option::is_none"
107 )]
108 pub client_cache_ttl: Option<i64>,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub security: Vec<HashMap<String, Vec<String>>>,
112 #[serde(
114 rename = "x-rate-limit",
115 default,
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub rate_limit: Option<SpecRateLimit>,
119 #[serde(
121 rename = "x-required-roles",
122 default,
123 skip_serializing_if = "Vec::is_empty"
124 )]
125 pub required_roles: Vec<String>,
126 #[serde(
128 rename = "x-pagination",
129 default,
130 skip_serializing_if = "Option::is_none"
131 )]
132 pub pagination: Option<String>,
133 #[serde(
135 rename = "x-tombstone-ttl",
136 default,
137 skip_serializing_if = "Option::is_none"
138 )]
139 pub tombstone_ttl: Option<i64>,
140}
141
142#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
144pub struct SpecRateLimit {
145 pub group: String,
147 #[serde(rename = "max-tokens")]
149 pub max_tokens: i64,
150 #[serde(rename = "window-size")]
152 pub window_size: String,
153}
154
155impl SpecPathMethod {
156 pub fn scopes(&self) -> Vec<String> {
158 let mut scopes: Vec<String> = Vec::new();
159 for requirement in &self.security {
160 for scope in requirement.values().flatten() {
161 if !scopes.contains(scope) {
162 scopes.push(scope.clone());
163 }
164 }
165 }
166 scopes
167 }
168}
169
170impl Spec {
171 pub fn oauth_scopes(&self) -> Vec<String> {
173 let mut scopes: Vec<String> = self
174 .components
175 .iter()
176 .flat_map(|c| c.security_schemes.values())
177 .filter_map(|scheme| scheme.flows.as_ref()?.authorization_code.as_ref())
178 .flat_map(|flow| flow.scopes.keys().cloned())
179 .collect();
180 scopes.sort();
181 scopes.dedup();
182 scopes
183 }
184
185 pub fn scope_string_for(&self, op_ids: &[&str]) -> String {
188 let mut scopes: Vec<String> = op_ids
189 .iter()
190 .flat_map(|id| self.required_scopes(id))
191 .collect();
192 scopes.sort();
193 scopes.dedup();
194 scopes.join(" ")
195 }
196
197 pub fn operation(&self, op_id: &str) -> Option<&SpecPathMethod> {
199 self.paths
200 .values()
201 .flat_map(SpecPathItem::methods)
202 .find(|m| m.operation_id.as_deref() == Some(op_id))
203 }
204
205 pub fn required_scopes(&self, op_id: &str) -> Vec<String> {
207 self.operation(op_id)
208 .map(SpecPathMethod::scopes)
209 .unwrap_or_default()
210 }
211
212 pub fn rate_limit_groups(&self) -> HashMap<String, SpecRateLimit> {
214 self.paths
215 .values()
216 .flat_map(SpecPathItem::methods)
217 .filter_map(|m| m.rate_limit.clone())
218 .map(|limit| (limit.group.clone(), limit))
219 .collect()
220 }
221
222 pub fn client_cache_ttl(&self, path: &str) -> Option<i64> {
225 self.get_operation_for_path(path)?.client_cache_ttl
226 }
227
228 pub fn tombstone_ttl(&self, path: &str) -> Option<i64> {
231 self.get_operation_for_path(path)?.tombstone_ttl
232 }
233
234 pub fn get_operation_for_path(&self, path: &str) -> Option<&SpecPathMethod> {
239 let wanted: Vec<&str> = path.trim_start_matches('/').split('/').collect();
240 self.paths
241 .iter()
242 .filter_map(|(template, item)| {
243 let parts: Vec<&str> = template.trim_start_matches('/').split('/').collect();
244 let is_param = |t: &&str| t.starts_with('{') && t.ends_with('}');
245 let matches = parts.len() == wanted.len()
246 && parts
247 .iter()
248 .zip(&wanted)
249 .all(|(t, w)| is_param(t) || t == w);
250 let operation = item.get.as_ref()?;
251 matches.then(|| (parts.iter().filter(|t| !is_param(t)).count(), operation))
252 })
253 .max_by_key(|(literals, _)| *literals)
254 .map(|(_, operation)| operation)
255 }
256
257 pub fn operation_index(&self) -> HashMap<String, String> {
260 let mut index = HashMap::new();
261 for (path, item) in &self.paths {
262 let path = path.strip_prefix('/').unwrap_or(path);
263 for op_id in item.methods().filter_map(|m| m.operation_id.as_ref()) {
264 index.insert(op_id.clone(), path.to_owned());
265 }
266 }
267 index
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273struct PathTemplate {
274 segments: Vec<Option<String>>,
276 literals: usize,
278 client_cache_ttl: Option<i64>,
279 tombstone_ttl: Option<i64>,
280 rate_limit_group: Option<String>,
281}
282
283impl PathTemplate {
284 fn matches(&self, wanted: &[&str]) -> bool {
285 self.segments
286 .iter()
287 .zip(wanted)
288 .all(|(segment, w)| segment.as_deref().is_none_or(|literal| literal == *w))
289 }
290}
291
292#[derive(Debug, Default, Clone, PartialEq, Eq)]
295pub(crate) struct SpecIndex {
296 paths: HashMap<String, String>,
298 locations: HashMap<String, (String, usize)>,
300 templates: HashMap<&'static str, HashMap<usize, Vec<PathTemplate>>>,
302}
303
304impl SpecIndex {
305 const VERBS: [&'static str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"];
307
308 pub(crate) fn new(spec: &Spec) -> Self {
310 let mut index = SpecIndex::default();
311 for (template, item) in &spec.paths {
312 let path = template.strip_prefix('/').unwrap_or(template);
313 for (position, method) in item.methods().enumerate() {
314 if let Some(op_id) = &method.operation_id {
315 index.paths.insert(op_id.clone(), path.to_owned());
316 index
317 .locations
318 .insert(op_id.clone(), (template.clone(), position));
319 }
320 }
321 let segments: Vec<Option<String>> = template
322 .trim_start_matches('/')
323 .split('/')
324 .map(|part| {
325 (!(part.starts_with('{') && part.ends_with('}'))).then(|| part.to_owned())
326 })
327 .collect();
328 let literals = segments.iter().flatten().count();
329 let methods = [&item.get, &item.post, &item.put, &item.delete, &item.patch];
330 for (verb, method) in Self::VERBS.into_iter().zip(methods) {
331 let Some(method) = method else { continue };
332 index
333 .templates
334 .entry(verb)
335 .or_default()
336 .entry(segments.len())
337 .or_default()
338 .push(PathTemplate {
339 segments: segments.clone(),
340 literals,
341 client_cache_ttl: method.client_cache_ttl,
342 tombstone_ttl: method.tombstone_ttl,
343 rate_limit_group: method.rate_limit.as_ref().map(|r| r.group.clone()),
344 });
345 }
346 }
347 for by_length in index.templates.values_mut() {
348 for templates in by_length.values_mut() {
349 templates.sort_by(|a, b| {
350 b.literals
351 .cmp(&a.literals)
352 .then_with(|| a.segments.cmp(&b.segments))
353 });
354 }
355 }
356 index
357 }
358
359 pub(crate) fn path(&self, op_id: &str) -> Option<&str> {
361 self.paths.get(op_id).map(String::as_str)
362 }
363
364 pub(crate) fn operation<'a>(&self, spec: &'a Spec, op_id: &str) -> Option<&'a SpecPathMethod> {
366 let (template, position) = self.locations.get(op_id)?;
367 spec.paths.get(template)?.methods().nth(*position)
368 }
369
370 fn template_for(&self, method: &str, path: &str) -> Option<&PathTemplate> {
372 let verb = Self::VERBS
373 .into_iter()
374 .find(|verb| verb.eq_ignore_ascii_case(method))?;
375 let wanted: Vec<&str> = path.trim_start_matches('/').split('/').collect();
376 self.templates
377 .get(verb)?
378 .get(&wanted.len())?
379 .iter()
380 .find(|template| template.matches(&wanted))
381 }
382
383 pub(crate) fn client_cache_ttl(&self, path: &str) -> Option<i64> {
385 self.template_for("GET", path)?.client_cache_ttl
386 }
387
388 pub(crate) fn tombstone_ttl(&self, path: &str) -> Option<i64> {
390 self.template_for("GET", path)?.tombstone_ttl
391 }
392
393 pub(crate) fn rate_limit_group(&self, method: &str, path: &str) -> Option<&str> {
395 self.template_for(method, path)?.rate_limit_group.as_deref()
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::{Spec, SpecIndex};
402
403 const FIXTURE: &str = include_str!("../resources/test/openapi.json");
404
405 #[test]
406 fn test_parse_openapi_fixture() {
407 let spec: Spec = serde_json::from_str(FIXTURE).expect("fixture should parse");
408 let index = spec.operation_index();
409 assert!(index.len() > 200, "only {} operations", index.len());
410 assert_eq!(
411 index.get("GetMarketsRegionIdOrders").map(String::as_str),
412 Some("markets/{region_id}/orders")
413 );
414 assert_eq!(
415 index.get("GetCharactersDetail").map(String::as_str),
416 Some("characters/{character_id}")
417 );
418 }
419
420 #[test]
421 fn test_client_cache_ttl_matches_templates() {
422 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
423 assert_eq!(spec.client_cache_ttl("/alliances"), Some(3600));
424 assert!(spec.client_cache_ttl("characters/95465499").is_some());
425 assert_eq!(spec.client_cache_ttl("no/such/path"), None);
426 }
427
428 #[test]
429 fn test_operation_metadata() {
430 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
431 let op = spec
432 .operation("GetCorporationsCorporationIdBlueprints")
433 .unwrap();
434 assert_eq!(op.scopes(), ["esi-corporations.read_blueprints.v1"]);
435 assert_eq!(op.required_roles, ["Director"]);
436 let limit = op.rate_limit.as_ref().unwrap();
437 assert_eq!(limit.group, "corp-industry");
438 assert_eq!(limit.max_tokens, 600);
439 assert_eq!(limit.window_size, "15m");
440 assert!(spec.required_scopes("GetStatus").is_empty());
441 assert_eq!(
442 spec.operation("GetFreelanceJobsListing")
443 .unwrap()
444 .pagination
445 .as_deref(),
446 Some("cursor")
447 );
448 assert_eq!(
449 spec.operation("GetParagonHubSkinr").unwrap().tombstone_ttl,
450 Some(604_800)
451 );
452 assert!(spec.rate_limit_groups().contains_key("char-wallet"));
453 }
454
455 #[test]
456 fn test_oauth_scopes_and_scope_string() {
457 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
458 let scopes = spec.oauth_scopes();
459 assert!(scopes.len() > 30, "only {} scopes", scopes.len());
460 assert!(scopes.contains(&"esi-wallet.read_character_wallet.v1".to_owned()));
461 assert_eq!(
462 spec.scope_string_for(&[
463 "GetCharactersCharacterIdWallet",
464 "GetCharactersCharacterIdWalletJournal",
465 "GetStatus"
466 ]),
467 "esi-wallet.read_character_wallet.v1"
468 );
469 }
470
471 #[test]
472 fn test_tombstone_ttl_and_path_matching() {
473 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
474 assert_eq!(spec.tombstone_ttl("paragon-hub/skinr"), Some(604_800));
475 assert_eq!(
476 spec.tombstone_ttl("paragon-hub/skinr/alliances/99000001"),
477 Some(604_800)
478 );
479 assert_eq!(spec.tombstone_ttl("characters/95465499"), None);
480 assert_eq!(
481 spec.get_operation_for_path("characters/95465499")
482 .and_then(|m| m.operation_id.as_deref()),
483 Some("GetCharactersDetail")
484 );
485 }
486
487 #[test]
488 fn test_index_agrees_with_spec() {
489 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
490 let index = SpecIndex::new(&spec);
491 for (op_id, path) in spec.operation_index() {
492 assert_eq!(index.path(&op_id), Some(path.as_str()));
493 assert_eq!(index.operation(&spec, &op_id), spec.operation(&op_id));
494 }
495 assert_eq!(index.path("NoSuchOperation"), None);
496 assert!(index.operation(&spec, "NoSuchOperation").is_none());
497 }
498
499 #[test]
500 fn test_index_path_matching_agrees_with_spec() {
501 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
502 let index = SpecIndex::new(&spec);
503 for path in [
504 "/alliances",
505 "alliances/99000001",
506 "characters/95465499",
507 "characters/affiliation",
508 "paragon-hub/skinr",
509 "paragon-hub/skinr/alliances/99000001",
510 "markets/10000002/orders",
511 "no/such/path",
512 "",
513 ] {
514 assert_eq!(
515 index.client_cache_ttl(path),
516 spec.client_cache_ttl(path),
517 "{path}"
518 );
519 assert_eq!(
520 index.tombstone_ttl(path),
521 spec.tombstone_ttl(path),
522 "{path}"
523 );
524 }
525 assert_eq!(index.tombstone_ttl("paragon-hub/skinr"), Some(604_800));
526 }
527
528 #[test]
529 fn test_index_rate_limit_group_by_method() {
530 let spec: Spec = serde_json::from_str(FIXTURE).unwrap();
531 let index = SpecIndex::new(&spec);
532 let group_of = |op_id: &str| {
533 spec.operation(op_id)
534 .and_then(|m| m.rate_limit.as_ref())
535 .map(|r| r.group.clone())
536 };
537 let post = group_of("PostCharactersCharacterIdAssetsNames").expect("declares a group");
538 assert_eq!(
539 index.rate_limit_group("post", "/characters/95465499/assets/names"),
540 Some(post.as_str())
541 );
542 let get = group_of("GetCharactersCharacterIdAssets").expect("declares a group");
544 assert_eq!(
545 index.rate_limit_group("GET", "characters/95465499/assets"),
546 Some(get.as_str())
547 );
548 assert_eq!(
550 index.rate_limit_group("POST", "characters/affiliation"),
551 None
552 );
553 assert_eq!(index.rate_limit_group("TRACE", "alliances"), None);
554 assert_eq!(index.rate_limit_group("GET", "no/such/path"), None);
555 }
556
557 #[test]
558 fn test_parse_ignores_unknown_keys() {
559 let source = r#"{
560 "openapi": "3.1.0",
561 "paths": {
562 "/status": {
563 "parameters": [{"name": "x", "in": "header"}],
564 "summary": "Server status",
565 "get": {"operationId": "GetStatus", "tags": ["Status"]}
566 },
567 "/no-op-id": {"get": {"summary": "missing id"}}
568 }
569 }"#;
570 let spec: Spec = serde_json::from_str(source).unwrap();
571 let index = spec.operation_index();
572 assert_eq!(index.len(), 1);
573 assert_eq!(index["GetStatus"], "status");
574 }
575}