use std::collections::BTreeMap;
use axum::{Json, Router, response::Html, routing::get};
use nidus_http::error::RoutePathError;
use nidus_http::router::{OpenApiSchemaRegistrar, RouteMetadata};
use serde_json::{Value, json};
use utoipa::{PartialSchema, ToSchema};
use crate::html::docs_html;
use crate::route::OpenApiRoute;
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum OpenApiDocumentError {
#[error("duplicate OpenAPI operation `{method}` `{path}`")]
DuplicateOperation {
method: String,
path: String,
},
#[error(transparent)]
RoutePath(#[from] RoutePathError),
#[error("OpenAPI schema registration failed: {message}")]
SchemaRegistration {
message: String,
},
}
#[derive(Clone, Debug)]
pub struct OpenApiDocument {
title: String,
version: String,
routes: Vec<OpenApiRoute>,
schemas: BTreeMap<String, Value>,
}
impl OpenApiDocument {
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
Self {
title: title.into(),
version: version.into(),
routes: Vec::new(),
schemas: BTreeMap::new(),
}
}
pub fn route(mut self, route: OpenApiRoute) -> Self {
self = self
.try_route(route)
.unwrap_or_else(|error| panic!("{error}"));
self
}
pub fn try_route(mut self, route: OpenApiRoute) -> Result<Self, OpenApiDocumentError> {
if self
.routes
.iter()
.any(|existing| existing.path() == route.path() && existing.method() == route.method())
{
return Err(OpenApiDocumentError::DuplicateOperation {
method: route.method().to_owned(),
path: route.path().to_owned(),
});
}
self.routes.push(route);
Ok(self)
}
pub fn controller_routes(self, controller_prefix: &str, routes: &[RouteMetadata]) -> Self {
self.try_controller_routes(controller_prefix, routes)
.unwrap_or_else(|error| panic!("{error}"))
}
pub fn try_controller_routes(
mut self,
controller_prefix: &str,
routes: &[RouteMetadata],
) -> Result<Self, OpenApiDocumentError> {
for route in routes {
self = self.try_route(OpenApiRoute::try_from_route_metadata_at_path(
route,
route.try_full_path(controller_prefix)?,
)?)?;
}
Ok(self)
}
pub fn schemas_from_route_metadata(mut self, routes: &[RouteMetadata]) -> Self {
self = self
.try_schemas_from_route_metadata(routes)
.unwrap_or_else(|error| panic!("{error}"));
self
}
pub fn try_schemas_from_route_metadata(
mut self,
routes: &[RouteMetadata],
) -> Result<Self, OpenApiDocumentError> {
for route in routes {
self = self
.try_with_schema_registrar(route.request_schema_registrar())?
.try_with_schema_registrar(route.response_schema_registrar())?;
}
Ok(self)
}
pub fn schema<T>(mut self) -> Self
where
T: ToSchema,
{
self = self
.try_schema::<T>()
.unwrap_or_else(|error| panic!("{error}"));
self
}
pub fn try_schema<T>(mut self) -> Result<Self, OpenApiDocumentError>
where
T: ToSchema,
{
self.schemas = self.register_schemas(Self::try_collect_openapi_schemas::<T>()?);
Ok(self)
}
fn try_collect_openapi_schemas<T: ToSchema>()
-> Result<Vec<(String, Value)>, OpenApiDocumentError> {
let mut openapi_schemas: Vec<(
String,
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
)> = vec![(T::name().to_string(), <T as PartialSchema>::schema())];
<T as ToSchema>::schemas(&mut openapi_schemas);
let mut schemas = Vec::new();
for (name, schema) in openapi_schemas {
let value = serde_json::to_value(schema).map_err(|error| {
OpenApiDocumentError::SchemaRegistration {
message: format!("schema `{name}`: {error}"),
}
})?;
schemas.push((name, value));
}
Ok(schemas)
}
pub fn from_route_metadata(
title: impl Into<String>,
version: impl Into<String>,
routes: &[RouteMetadata],
) -> Self {
Self::try_from_route_metadata(title, version, routes)
.unwrap_or_else(|error| panic!("{error}"))
}
pub fn try_from_route_metadata(
title: impl Into<String>,
version: impl Into<String>,
routes: &[RouteMetadata],
) -> Result<Self, OpenApiDocumentError> {
let mut document = Self::new(title, version);
for route in routes {
document = document.try_route(OpenApiRoute::try_from_route_metadata(route)?)?;
}
Ok(document)
}
pub fn from_controller_routes(
title: impl Into<String>,
version: impl Into<String>,
controller_prefix: &str,
routes: &[RouteMetadata],
) -> Self {
Self::try_from_controller_routes(title, version, controller_prefix, routes)
.unwrap_or_else(|error| panic!("{error}"))
}
pub fn try_from_controller_routes(
title: impl Into<String>,
version: impl Into<String>,
controller_prefix: &str,
routes: &[RouteMetadata],
) -> Result<Self, OpenApiDocumentError> {
Self::new(title, version).try_controller_routes(controller_prefix, routes)
}
pub fn to_json_value(&self) -> Value {
let mut paths = serde_json::Map::new();
for route in &self.routes {
let entry = paths
.entry(route.path().to_owned())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Value::Object(methods) = entry {
methods.insert(route.method().to_owned(), route.to_json_value());
}
}
let mut document = json!({
"openapi": "3.1.0",
"info": {
"title": self.title,
"version": self.version,
},
"paths": paths,
});
if !self.schemas.is_empty() {
document["components"] = json!({
"schemas": &self.schemas,
});
}
document
}
pub fn into_router(self) -> Router {
let json = self.to_json_value();
let docs = docs_html(&self.title, "/openapi.json");
Router::new()
.route(
"/openapi.json",
get(move || {
let json = json.clone();
async move { Json(json) }
}),
)
.route(
"/docs",
get(move || {
let docs = docs.clone();
async move { Html(docs) }
}),
)
}
fn try_with_schema_registrar(
mut self,
registrar: Option<OpenApiSchemaRegistrar>,
) -> Result<Self, OpenApiDocumentError> {
let Some(registrar) = registrar else {
return Ok(self);
};
let mut schemas = Vec::new();
registrar(&mut schemas).map_err(|error| OpenApiDocumentError::SchemaRegistration {
message: error.to_string(),
})?;
self.schemas = self.register_schemas(schemas);
Ok(self)
}
fn register_schemas(&self, schemas: Vec<(String, Value)>) -> BTreeMap<String, Value> {
let mut registered = self.schemas.clone();
for (name, schema) in schemas {
registered.entry(name).or_insert(schema);
}
registered
}
}