use actix_web::web;
use serde_json::json;
use utoipa::{
openapi::{
path::{Operation, Parameter, ParameterBuilder, ParameterIn},
security::{ApiKey, ApiKeyValue, SecurityScheme},
Components, ObjectBuilder, OpenApi, PathItem, Required, SecurityRequirement, Server, Type,
},
OpenApi as _,
};
use utoipa_swagger_ui::SwaggerUi;
use crate::api::ApiDoc;
#[cfg(feature = "experimental")]
use crate::api::ExperimentalApiDoc;
const API_KEY_SCHEME_NAME: &str = "ApiKeyAuth";
pub(crate) fn configure_docs(cfg: &mut web::ServiceConfig, hosted_url: Option<&str>) {
if let Some(hosted_url) = hosted_url {
cfg.service(
SwaggerUi::new("/docs/hosted/{_:.*}")
.url("/api-docs/hosted/openapi.json", hosted_spec(hosted_url)),
);
}
cfg.service(SwaggerUi::new("/docs/{_:.*}").url("/api-docs/openapi.json", self_hosted_spec()));
}
fn self_hosted_spec() -> OpenApi {
#[allow(unused_mut)]
let mut openapi = ApiDoc::openapi();
#[cfg(feature = "experimental")]
openapi.merge(ExperimentalApiDoc::openapi());
openapi
}
fn hosted_spec(server_url: &str) -> OpenApi {
let mut openapi = self_hosted_spec();
let routed_paths = std::mem::take(&mut openapi.paths.paths);
for (path, mut path_item) in routed_paths {
let Some(endpoint) = path.strip_prefix("/v1/") else {
openapi
.paths
.paths
.insert(path, path_item);
continue
};
for operation in operations_mut(&mut path_item) {
operation
.parameters
.get_or_insert_with(Vec::new)
.push(chain_parameter())
}
openapi
.paths
.paths
.insert(format!("/v1/{{chain}}/{endpoint}"), path_item);
}
openapi
.components
.get_or_insert_with(Components::new)
.add_security_scheme(
API_KEY_SCHEME_NAME,
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::with_description(
"Authorization",
"Fynd API key, sent as the raw header value (no `Bearer ` prefix).",
))),
);
openapi.security =
Some(vec![SecurityRequirement::new(API_KEY_SCHEME_NAME, Vec::<String>::new())]);
openapi.servers = Some(vec![Server::new(server_url)]);
openapi
}
fn chain_parameter() -> Parameter {
ParameterBuilder::new()
.name("chain")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(Some("Chain to route the request to."))
.schema(Some(ObjectBuilder::new().schema_type(Type::String)))
.example(Some(json!("ethereum")))
.build()
}
fn operations_mut(path_item: &mut PathItem) -> impl Iterator<Item = &mut Operation> {
[
path_item.get.as_mut(),
path_item.put.as_mut(),
path_item.post.as_mut(),
path_item.delete.as_mut(),
path_item.options.as_mut(),
path_item.head.as_mut(),
path_item.patch.as_mut(),
path_item.trace.as_mut(),
]
.into_iter()
.flatten()
}
#[cfg(test)]
mod tests {
use actix_web::{http::StatusCode, test as actix_test, App};
use serde_json::Value;
use super::*;
const TEST_SERVER_URL: &str = "https://fynd.test";
fn hosted_spec_json(server_url: &str) -> Value {
serde_json::to_value(hosted_spec(server_url)).expect("spec serializes")
}
#[test]
fn self_hosted_spec_paths() {
let spec = self_hosted_spec();
let paths: Vec<&str> = spec
.paths
.paths
.keys()
.map(String::as_str)
.collect();
assert!(paths.contains(&"/v1/quote"), "{paths:?}");
assert!(paths.contains(&"/v1/health"), "{paths:?}");
assert!(paths.contains(&"/v1/info"), "{paths:?}");
assert!(
paths
.iter()
.all(|path| !path.contains("{chain}")),
"{paths:?}"
)
}
#[test]
fn self_hosted_spec_has_no_security() {
let spec = self_hosted_spec();
assert!(spec.security.is_none());
assert!(spec.servers.is_none());
assert!(spec
.components
.expect("schemas are declared")
.security_schemes
.is_empty())
}
#[test]
fn hosted_spec_paths_carry_chain_segment() {
let spec = hosted_spec(TEST_SERVER_URL);
let paths: Vec<&str> = spec
.paths
.paths
.keys()
.map(String::as_str)
.collect();
assert!(paths.contains(&"/v1/{chain}/quote"), "{paths:?}");
assert!(paths.contains(&"/v1/{chain}/health"), "{paths:?}");
assert!(paths.contains(&"/v1/{chain}/info"), "{paths:?}");
assert!(
paths
.iter()
.all(|path| path.starts_with("/v1/{chain}/")),
"{paths:?}"
)
}
#[cfg(feature = "experimental")]
#[test]
fn hosted_spec_includes_experimental_paths() {
let spec = hosted_spec(TEST_SERVER_URL);
assert!(spec
.paths
.paths
.contains_key("/v1/{chain}/prices"))
}
#[test]
fn hosted_spec_operations_declare_chain_parameter() {
let spec = hosted_spec_json(TEST_SERVER_URL);
let paths = spec["paths"]
.as_object()
.expect("paths are an object");
for (path, path_item) in paths {
for (method, operation) in path_item
.as_object()
.expect("path items are an object")
{
let parameters = operation["parameters"]
.as_array()
.unwrap_or_else(|| panic!("{method} {path} declares no parameters"));
let chain = parameters
.iter()
.find(|parameter| parameter["name"] == "chain")
.unwrap_or_else(|| panic!("{method} {path} declares no chain parameter"));
assert_eq!(chain["in"], "path", "{method} {path}");
assert_eq!(chain["required"], true, "{method} {path}")
}
}
}
#[test]
fn hosted_spec_declares_api_key_auth() {
let spec = hosted_spec_json(TEST_SERVER_URL);
let scheme = &spec["components"]["securitySchemes"]["ApiKeyAuth"];
assert_eq!(scheme["type"], "apiKey", "{scheme}");
assert_eq!(scheme["in"], "header", "{scheme}");
assert_eq!(scheme["name"], "Authorization", "{scheme}");
assert_eq!(spec["security"], json!([{"ApiKeyAuth": []}]))
}
#[test]
fn hosted_spec_server_url() {
let spec = hosted_spec(TEST_SERVER_URL);
let servers = spec.servers.expect("server is set");
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, TEST_SERVER_URL)
}
#[actix_web::test]
async fn both_swagger_uis_are_reachable() {
let app = actix_test::init_service(
App::new().configure(|cfg| configure_docs(cfg, Some(TEST_SERVER_URL))),
)
.await;
for path in ["/docs/", "/docs/hosted/"] {
let request = actix_test::TestRequest::get()
.uri(path)
.to_request();
let response = actix_test::call_service(&app, request).await;
assert!(response.status().is_success(), "{path}: {}", response.status())
}
}
#[actix_web::test]
async fn hosted_swagger_ui_needs_a_url() {
let app =
actix_test::init_service(App::new().configure(|cfg| configure_docs(cfg, None))).await;
let self_hosted = actix_test::call_service(
&app,
actix_test::TestRequest::get()
.uri("/docs/")
.to_request(),
)
.await;
assert!(self_hosted.status().is_success(), "{}", self_hosted.status());
for path in ["/docs/hosted/", "/api-docs/hosted/openapi.json"] {
let request = actix_test::TestRequest::get()
.uri(path)
.to_request();
let response = actix_test::call_service(&app, request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}")
}
}
#[actix_web::test]
async fn spec_endpoints_serve_their_own_paths() {
let app = actix_test::init_service(
App::new().configure(|cfg| configure_docs(cfg, Some(TEST_SERVER_URL))),
)
.await;
let self_hosted: Value = actix_test::call_and_read_body_json(
&app,
actix_test::TestRequest::get()
.uri("/api-docs/openapi.json")
.to_request(),
)
.await;
assert!(self_hosted["paths"]["/v1/quote"].is_object(), "{self_hosted}");
let hosted: Value = actix_test::call_and_read_body_json(
&app,
actix_test::TestRequest::get()
.uri("/api-docs/hosted/openapi.json")
.to_request(),
)
.await;
assert!(hosted["paths"]["/v1/{chain}/quote"].is_object(), "{hosted}");
assert!(
hosted["components"]["securitySchemes"][API_KEY_SCHEME_NAME].is_object(),
"{hosted}"
)
}
}