Skip to main content

esi_openapi/
spec.rs

1//! Struct types for the ESI OpenAPI specification data.
2//!
3//! Only the parts of the specification needed to resolve an
4//! `operationId` to a URL path are modeled; every other key in
5//! the document is ignored during deserialization.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// ESI OpenAPI spec type.
11#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
12pub struct Spec {
13    /// Map of URL path (e.g. `/markets/{region_id}/orders`) to its path item.
14    pub paths: HashMap<String, SpecPathItem>,
15    /// Reusable parts of the spec; only the security schemes are read.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub components: Option<SpecComponents>,
18}
19
20/// The `components` section of the spec.
21#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
22pub struct SpecComponents {
23    /// Security schemes by name, such as `OAuth2`.
24    #[serde(
25        rename = "securitySchemes",
26        default,
27        skip_serializing_if = "HashMap::is_empty"
28    )]
29    pub security_schemes: HashMap<String, SpecSecurityScheme>,
30}
31
32/// A security scheme; only the OAuth2 authorization code flow is read.
33#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
34pub struct SpecSecurityScheme {
35    /// OAuth2 flows of the scheme.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub flows: Option<SpecOAuthFlows>,
38}
39
40/// The OAuth2 flows of a security scheme.
41#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
42pub struct SpecOAuthFlows {
43    /// The authorization code flow.
44    #[serde(
45        rename = "authorizationCode",
46        default,
47        skip_serializing_if = "Option::is_none"
48    )]
49    pub authorization_code: Option<SpecOAuthFlow>,
50}
51
52/// One OAuth2 flow.
53#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
54pub struct SpecOAuthFlow {
55    /// Scope name to description.
56    #[serde(default)]
57    pub scopes: HashMap<String, String>,
58}
59
60/// An OpenAPI path item: the operations available on a single URL path.
61///
62/// Path-level keys other than the HTTP methods (such as `parameters`
63/// or `summary`) are ignored.
64#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
65pub struct SpecPathItem {
66    /// `GET` operation, if any.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub get: Option<SpecPathMethod>,
69    /// `POST` operation, if any.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub post: Option<SpecPathMethod>,
72    /// `PUT` operation, if any.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub put: Option<SpecPathMethod>,
75    /// `DELETE` operation, if any.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub delete: Option<SpecPathMethod>,
78    /// `PATCH` operation, if any.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub patch: Option<SpecPathMethod>,
81}
82
83impl SpecPathItem {
84    /// Iterate over the operations defined on this path.
85    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/// A single OpenAPI operation.
93#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
94pub struct SpecPathMethod {
95    /// The operation ID to use this endpoint, e.g. `GetMarketsRegionIdOrders`.
96    #[serde(
97        rename = "operationId",
98        default,
99        skip_serializing_if = "Option::is_none"
100    )]
101    pub operation_id: Option<String>,
102    /// How long, in seconds, a client may reuse the response (`x-client-cache-ttl`).
103    #[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    /// Security requirements: each entry maps a scheme (`OAuth2`) to the scopes it needs.
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub security: Vec<HashMap<String, Vec<String>>>,
112    /// Rate limit group and budget of the operation (`x-rate-limit`).
113    #[serde(
114        rename = "x-rate-limit",
115        default,
116        skip_serializing_if = "Option::is_none"
117    )]
118    pub rate_limit: Option<SpecRateLimit>,
119    /// Corporation roles of which the character needs at least one (`x-required-roles`).
120    #[serde(
121        rename = "x-required-roles",
122        default,
123        skip_serializing_if = "Vec::is_empty"
124    )]
125    pub required_roles: Vec<String>,
126    /// Pagination style of the operation (`x-pagination`), such as `cursor`.
127    #[serde(
128        rename = "x-pagination",
129        default,
130        skip_serializing_if = "Option::is_none"
131    )]
132    pub pagination: Option<String>,
133    /// Seconds a deleted resource keeps answering as gone (`x-tombstone-ttl`).
134    #[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/// The rate limit an operation declares with `x-rate-limit`.
143#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
144pub struct SpecRateLimit {
145    /// Route group the operation spends tokens from.
146    pub group: String,
147    /// Tokens available per window.
148    #[serde(rename = "max-tokens")]
149    pub max_tokens: i64,
150    /// Window length as ESI writes it, such as `15m`.
151    #[serde(rename = "window-size")]
152    pub window_size: String,
153}
154
155impl SpecPathMethod {
156    /// The OAuth2 scopes the operation needs, without duplicates.
157    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    /// Every OAuth2 scope the spec defines, sorted.
172    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    /// The scopes needed to call every operation in `op_ids`, sorted and without
186    /// duplicates, as a space-separated list ready for `EsiBuilder::scope`.
187    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    /// Find an operation by its `operationId`.
198    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    /// The scopes an operation needs (empty for public operations).
206    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    /// The rate limit every operation declares, keyed by route group.
213    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    /// The `x-client-cache-ttl` of the `GET` operation whose path template matches
223    /// `path` (with or without the leading slash, e.g. `characters/95465499`).
224    pub fn client_cache_ttl(&self, path: &str) -> Option<i64> {
225        self.get_operation_for_path(path)?.client_cache_ttl
226    }
227
228    /// The `x-tombstone-ttl` of the `GET` operation whose path template matches
229    /// `path`: how long ESI keeps answering for a deleted resource.
230    pub fn tombstone_ttl(&self, path: &str) -> Option<i64> {
231        self.get_operation_for_path(path)?.tombstone_ttl
232    }
233
234    /// The `GET` operation whose path template matches a concrete path such as
235    /// `characters/95465499`. When several templates match, the one with the
236    /// most literal segments wins (`characters/affiliation` over
237    /// `characters/{character_id}`).
238    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    /// Build a map of `operationId` to URL path, with the path's leading
258    /// slash removed so it can be appended to the base API URL.
259    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/// A path template split into segments, with the metadata looked up per request.
272#[derive(Debug, Clone, PartialEq, Eq)]
273struct PathTemplate {
274    /// One entry per segment: the literal text, or `None` for a `{parameter}`.
275    segments: Vec<Option<String>>,
276    /// How many segments are literal; more literals win when several templates match.
277    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/// Lookup tables built once from a [`Spec`], so that resolving an operation or
293/// matching a concrete path does not scan every path in the spec on each call.
294#[derive(Debug, Default, Clone, PartialEq, Eq)]
295pub(crate) struct SpecIndex {
296    /// `operationId` -> URL path without the leading slash.
297    paths: HashMap<String, String>,
298    /// `operationId` -> (path template, position among that path's methods).
299    locations: HashMap<String, (String, usize)>,
300    /// Templates by HTTP method and segment count, the most literal first.
301    templates: HashMap<&'static str, HashMap<usize, Vec<PathTemplate>>>,
302}
303
304impl SpecIndex {
305    /// The HTTP methods a path item can define, as written in requests.
306    const VERBS: [&'static str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"];
307
308    /// Index every operation and every path template of `spec`.
309    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    /// The URL path (without the leading slash) of an operation.
360    pub(crate) fn path(&self, op_id: &str) -> Option<&str> {
361        self.paths.get(op_id).map(String::as_str)
362    }
363
364    /// The operation's metadata, read from the `spec` this index was built from.
365    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    /// The template of the given HTTP method that best matches a concrete path.
371    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    /// Same as [`Spec::client_cache_ttl`], without scanning the spec.
384    pub(crate) fn client_cache_ttl(&self, path: &str) -> Option<i64> {
385        self.template_for("GET", path)?.client_cache_ttl
386    }
387
388    /// Same as [`Spec::tombstone_ttl`], without scanning the spec.
389    pub(crate) fn tombstone_ttl(&self, path: &str) -> Option<i64> {
390        self.template_for("GET", path)?.tombstone_ttl
391    }
392
393    /// The rate-limit group the operation with this HTTP method and path spends from.
394    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        // The same path template has other methods; each keeps its own metadata.
543        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        // An operation that declares no group, and an unknown method or path.
549        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}