Skip to main content

roas_file_fetcher/
lib.rs

1//! Filesystem [`ResourceFetcher`] for the [`roas`] OpenAPI loader.
2//!
3//! [`FileFetcher`] is blocking and backed by [`std::fs::read`]. Non-`file://`
4//! URIs are rejected with [`LoaderError::UnsupportedFetcherUri`]; I/O failures
5//! surface as [`LoaderError::ReadFile`]; body parse failures as
6//! [`LoaderError::Parse`].
7//!
8//! Optional features:
9//!   * `async` — also expose `AsyncFileFetcher` for
10//!     `roas::loader::Loader::register_async_fetcher`, backed by
11//!     `tokio::fs::read`. Requires an active tokio runtime when the returned
12//!     future is awaited. Off by default; enabling it pulls in `tokio` with
13//!     `fs` + `rt` features.
14//!   * `yaml` — parse YAML file bodies in addition to JSON. Selection is by
15//!     file path extension (`.yaml` / `.yml`). Pulls in `serde_yaml_ng`.
16
17#[cfg(feature = "async")]
18use roas::loader::{AsyncResourceFetcher, FetchFuture};
19use roas::loader::{LoaderError, ResourceFetcher};
20#[cfg(feature = "yaml")]
21use serde::de::Error as _;
22use serde_json::Value;
23use std::path::PathBuf;
24use url::Url;
25
26/// Blocking filesystem fetcher, suitable for
27/// [`Loader::register_fetcher`](roas::loader::Loader::register_fetcher).
28#[derive(Clone, Debug, Default)]
29pub struct FileFetcher;
30
31impl FileFetcher {
32    /// Construct a blocking file fetcher.
33    pub fn new() -> Self {
34        Self
35    }
36}
37
38/// Async filesystem fetcher, suitable for
39/// [`Loader::register_async_fetcher`](roas::loader::Loader::register_async_fetcher).
40/// A tokio runtime must be active when the returned future is awaited.
41///
42/// Available only with the `async` feature.
43#[cfg(feature = "async")]
44#[derive(Clone, Debug, Default)]
45pub struct AsyncFileFetcher;
46
47#[cfg(feature = "async")]
48impl AsyncFileFetcher {
49    /// Construct an async file fetcher.
50    pub fn new() -> Self {
51        Self
52    }
53}
54
55impl ResourceFetcher for FileFetcher {
56    fn fetch(&mut self, uri: &Url) -> Result<Value, LoaderError> {
57        check_scheme(uri)?;
58        let path = uri_to_path(uri)?;
59        let bytes =
60            std::fs::read(&path).map_err(|source| LoaderError::ReadFile { path, source })?;
61        parse_body(uri, &bytes)
62    }
63}
64
65#[cfg(feature = "async")]
66impl AsyncResourceFetcher for AsyncFileFetcher {
67    fn fetch<'a>(&'a mut self, uri: &'a Url) -> FetchFuture<'a> {
68        Box::pin(async move {
69            check_scheme(uri)?;
70            let path = uri_to_path(uri)?;
71            let bytes = tokio::fs::read(&path)
72                .await
73                .map_err(|source| LoaderError::ReadFile { path, source })?;
74            parse_body(uri, &bytes)
75        })
76    }
77}
78
79fn check_scheme(uri: &Url) -> Result<(), LoaderError> {
80    if uri.scheme() == "file" {
81        Ok(())
82    } else {
83        Err(LoaderError::UnsupportedFetcherUri(uri.as_str().to_string()))
84    }
85}
86
87fn uri_to_path(uri: &Url) -> Result<PathBuf, LoaderError> {
88    uri.to_file_path()
89        .map_err(|()| LoaderError::InvalidFileUri(uri.as_str().to_string()))
90}
91
92fn parse_body(uri: &Url, bytes: &[u8]) -> Result<Value, LoaderError> {
93    if is_yaml(uri) {
94        parse_yaml(uri, bytes)
95    } else {
96        serde_json::from_slice(bytes).map_err(|source| LoaderError::Parse {
97            uri: uri.as_str().to_string(),
98            source,
99        })
100    }
101}
102
103/// Decide whether to treat the file body as YAML.
104///
105/// With the `yaml` feature off this always returns `false`. With it on, the
106/// URL path is checked against the `.yaml` / `.yml` extensions (case-insensitive).
107#[allow(unused_variables)]
108fn is_yaml(uri: &Url) -> bool {
109    #[cfg(feature = "yaml")]
110    {
111        let path = uri.path().to_ascii_lowercase();
112        path.ends_with(".yaml") || path.ends_with(".yml")
113    }
114    #[cfg(not(feature = "yaml"))]
115    {
116        false
117    }
118}
119
120#[cfg(feature = "yaml")]
121fn parse_yaml(uri: &Url, bytes: &[u8]) -> Result<Value, LoaderError> {
122    serde_yaml_ng::from_slice(bytes).map_err(|yaml_err| LoaderError::Parse {
123        uri: uri.as_str().to_string(),
124        source: serde_json::Error::custom(yaml_err.to_string()),
125    })
126}
127
128#[cfg(not(feature = "yaml"))]
129#[allow(dead_code)]
130fn parse_yaml(_uri: &Url, _bytes: &[u8]) -> Result<Value, LoaderError> {
131    unreachable!("parse_yaml is only reached when the `yaml` feature is enabled")
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::path::Path;
138
139    /// Helper: extract `&Path` from `LoaderError::ReadFile` so the error tests
140    /// stay one line each.
141    fn read_file_path(err: &LoaderError) -> Option<&Path> {
142        match err {
143            LoaderError::ReadFile { path, .. } => Some(path.as_path()),
144            _ => None,
145        }
146    }
147
148    #[test]
149    fn file_fetcher_default_constructs() {
150        let _: FileFetcher = Default::default();
151        let _ = FileFetcher::new();
152    }
153
154    #[cfg(feature = "async")]
155    #[test]
156    fn async_file_fetcher_default_constructs() {
157        let _: AsyncFileFetcher = Default::default();
158        let _ = AsyncFileFetcher::new();
159    }
160
161    #[test]
162    fn check_scheme_accepts_only_file() {
163        check_scheme(&Url::parse("file:///tmp/x.json").unwrap()).unwrap();
164        let err = check_scheme(&Url::parse("http://example.test/x.json").unwrap())
165            .expect_err("http must be rejected");
166        assert!(matches!(err, LoaderError::UnsupportedFetcherUri(s) if s.starts_with("http://")));
167    }
168
169    #[test]
170    fn read_file_path_extracts_path_from_read_file_variant() {
171        let err = LoaderError::ReadFile {
172            path: PathBuf::from("/nope"),
173            source: std::io::Error::other("missing"),
174        };
175        assert_eq!(read_file_path(&err), Some(Path::new("/nope")));
176        let parse_err = LoaderError::Parse {
177            uri: "x".into(),
178            source: serde_json::from_str::<Value>("@").unwrap_err(),
179        };
180        assert_eq!(read_file_path(&parse_err), None);
181    }
182}