Skip to main content

by_loco_openapi/
config.rs

1use std::collections::BTreeMap;
2use std::sync::OnceLock;
3
4use loco_rs::Error;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8static OPENAPI_CONFIG: OnceLock<Option<OpenAPIConfig>> = OnceLock::new();
9
10// Newtype wrapper for initialization config
11#[derive(Debug)]
12pub struct InitializerConfig<'a>(&'a Option<BTreeMap<String, Value>>);
13
14impl<'a> From<&'a Option<BTreeMap<String, Value>>> for InitializerConfig<'a> {
15    fn from(initializers: &'a Option<BTreeMap<String, Value>>) -> Self {
16        InitializerConfig(initializers)
17    }
18}
19
20impl<'a> From<InitializerConfig<'a>> for Option<OpenAPIConfig> {
21    fn from(config: InitializerConfig<'a>) -> Self {
22        config
23            .0
24            .as_ref()
25            .and_then(|m| m.get("openapi"))
26            .cloned()
27            .and_then(|json| serde_json::from_value(json).ok())
28    }
29}
30
31/// Set the `OpenAPI` configuration directly
32///
33/// # Errors
34///
35/// Will return `Err` if the configuration can't be set
36pub fn set_openapi_config(
37    config: Option<OpenAPIConfig>,
38) -> Result<Option<&'static OpenAPIConfig>, Error> {
39    Ok(OPENAPI_CONFIG.get_or_init(|| config).as_ref())
40}
41
42pub fn get_openapi_config() -> Option<&'static OpenAPIConfig> {
43    OPENAPI_CONFIG.get().unwrap_or(&None).as_ref()
44}
45
46/// `OpenAPI` configuration
47/// Example:
48/// ```yaml
49/// initializers:
50///   openapi:
51///     redoc:
52///       url: /redoc
53///       # spec_json_url: /redoc/openapi.json
54///       # spec_yaml_url: /redoc/openapi.yaml
55///     scalar:
56///       url: /scalar
57///       # spec_json_url: /scalar/openapi.json
58///       # spec_yaml_url: /scalar/openapi.yaml
59///     swagger:
60///       url: /swagger
61///       spec_json_url: /api-docs/openapi.json
62///       # spec_yaml_url: /api-docs/openapi.yaml
63/// ```
64#[derive(Debug, Clone, Deserialize, Serialize)]
65#[cfg_attr(test, derive(PartialEq, Eq))]
66pub struct OpenAPIConfig {
67    /// Redoc configuration
68    /// Example:
69    /// ```yaml
70    /// initializers:
71    ///   openapi:
72    ///     redoc:
73    ///       url: /redoc
74    /// ```
75    #[cfg(feature = "redoc")]
76    #[serde(flatten)]
77    pub redoc: Option<OpenAPIType>,
78    /// Scalar configuration
79    /// Example:
80    /// ```yaml
81    /// initializers:
82    ///   openapi:
83    ///     scalar:
84    ///       url: /scalar
85    /// ```
86    #[cfg(feature = "scalar")]
87    #[serde(flatten)]
88    pub scalar: Option<OpenAPIType>,
89    /// Swagger configuration
90    /// Example:
91    /// ```yaml
92    /// initializers:
93    ///   openapi:
94    ///     swagger:
95    ///       url: /swagger
96    ///       spec_json_url: /openapi.json
97    /// ```
98    #[cfg(feature = "swagger")]
99    #[serde(flatten)]
100    pub swagger: Option<OpenAPIType>,
101}
102
103/// `OpenAPI` configuration types
104#[derive(Debug, Clone, Deserialize, Serialize)]
105#[cfg_attr(test, derive(PartialEq, Eq))]
106pub enum OpenAPIType {
107    /// Redoc configuration
108    /// Example:
109    /// ```yaml
110    /// initializers:
111    ///   openapi:
112    ///     redoc:
113    ///       url: /redoc
114    /// ```
115    #[cfg(feature = "redoc")]
116    #[serde(rename = "redoc")]
117    Redoc {
118        /// URL for where to host the redoc `OpenAPI` spec, example: /redoc
119        url: String,
120        /// URL for openapi.json, for example: /openapi.json
121        spec_json_url: Option<String>,
122        /// URL for openapi.yaml, for example: /openapi.yaml
123        spec_yaml_url: Option<String>,
124    },
125    /// Scalar configuration
126    /// Example:
127    /// ```yaml
128    /// initializers:
129    ///   openapi:
130    ///     scalar:
131    ///       url: /scalar
132    /// ```
133    #[cfg(feature = "scalar")]
134    #[serde(rename = "scalar")]
135    Scalar {
136        /// URL for where to host the scalar `OpenAPI` spec, example: /scalar
137        url: String,
138        /// URL for openapi.json, for example: /openapi.json
139        spec_json_url: Option<String>,
140        /// URL for openapi.yaml, for example: /openapi.yaml
141        spec_yaml_url: Option<String>,
142    },
143    /// Swagger configuration
144    /// Example:
145    /// ```yaml
146    /// initializers:
147    ///   openapi:
148    ///     swagger:
149    ///       url: /swagger
150    ///       spec_json_url: /openapi.json
151    /// ```
152    #[cfg(feature = "swagger")]
153    #[serde(rename = "swagger")]
154    Swagger {
155        /// URL for where to host the swagger `OpenAPI` spec, example:
156        /// /swagger-ui
157        url: String,
158        /// URL for openapi.json, for example: /api-docs/openapi.json
159        spec_json_url: String,
160        /// URL for openapi.yaml, for example: /openapi.yaml
161        spec_yaml_url: Option<String>,
162    },
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    #[cfg(any(feature = "swagger", feature = "redoc", feature = "scalar"))]
169    use serde_json::json;
170
171    // Helper function to create a mock configuration
172    #[cfg(any(feature = "swagger", feature = "redoc", feature = "scalar"))]
173    fn create_mock_config() -> BTreeMap<String, Value> {
174        let mut config = BTreeMap::new();
175
176        // Create OpenAPI config JSON
177        let mut openapi_config = serde_json::Map::new();
178
179        // Add swagger config conditionally
180        #[cfg(feature = "swagger")]
181        {
182            openapi_config.insert(
183                "swagger".to_string(),
184                json!({
185                    "url": "/swagger",
186                    "spec_json_url": "/api-docs/openapi.json"
187                }),
188            );
189        }
190
191        // Add redoc config conditionally
192        #[cfg(feature = "redoc")]
193        {
194            openapi_config.insert(
195                "redoc".to_string(),
196                json!({
197                    "url": "/redoc",
198                    "spec_json_url": "/redoc/openapi.json",
199                    "spec_yaml_url": "/redoc/openapi.yaml"
200                }),
201            );
202        }
203
204        // Add scalar config conditionally
205        #[cfg(feature = "scalar")]
206        {
207            openapi_config.insert(
208                "scalar".to_string(),
209                json!({
210                    "url": "/scalar",
211                    "spec_json_url": "/scalar/openapi.json",
212                    "spec_yaml_url": "/scalar/openapi.yaml"
213                }),
214            );
215        }
216
217        config.insert("openapi".to_string(), Value::Object(openapi_config));
218        config
219    }
220
221    #[test]
222    #[cfg(any(feature = "swagger", feature = "redoc", feature = "scalar"))]
223    fn test_data_conversion() {
224        // Test the conversion pipeline with valid data
225        let initializers = Some(create_mock_config());
226
227        // Convert to InitializerConfig and then to OpenAPIConfig
228        let initializer_config: InitializerConfig = (&initializers).into();
229        let openapi_config: Option<OpenAPIConfig> = initializer_config.into();
230
231        // Verify the conversion produces the expected result
232        assert!(
233            openapi_config.is_some(),
234            "OpenAPIConfig should be created successfully"
235        );
236
237        // Check the values based on enabled features
238        let config = openapi_config.unwrap();
239
240        #[cfg(feature = "swagger")]
241        {
242            let swagger = config.swagger.as_ref();
243            assert!(swagger.is_some(), "Swagger config should be present");
244
245            let expected = OpenAPIType::Swagger {
246                url: "/swagger".to_string(),
247                spec_json_url: "/api-docs/openapi.json".to_string(),
248                spec_yaml_url: None,
249            };
250            assert_eq!(swagger, Some(&expected));
251        }
252
253        #[cfg(feature = "redoc")]
254        {
255            let redoc = config.redoc.as_ref();
256            assert!(redoc.is_some(), "Redoc config should be present");
257
258            let expected = OpenAPIType::Redoc {
259                url: "/redoc".to_string(),
260                spec_json_url: Some("/redoc/openapi.json".to_string()),
261                spec_yaml_url: Some("/redoc/openapi.yaml".to_string()),
262            };
263            assert_eq!(redoc, Some(&expected));
264        }
265
266        #[cfg(feature = "scalar")]
267        {
268            let scalar = config.scalar.as_ref();
269            assert!(scalar.is_some(), "Scalar config should be present");
270
271            let expected = OpenAPIType::Scalar {
272                url: "/scalar".to_string(),
273                spec_json_url: Some("/scalar/openapi.json".to_string()),
274                spec_yaml_url: Some("/scalar/openapi.yaml".to_string()),
275            };
276            assert_eq!(scalar, Some(&expected));
277        }
278    }
279
280    #[test]
281    fn test_none_conversion() {
282        // Test with None input
283        let initializers: Option<BTreeMap<String, Value>> = None;
284
285        // Convert to InitializerConfig and then to OpenAPIConfig
286        let openapi_config: Option<OpenAPIConfig> = InitializerConfig::from(&initializers).into();
287
288        // Verify the conversion handles None correctly
289        assert!(openapi_config.is_none(), "OpenAPIConfig should be None");
290    }
291}