use std::collections::BTreeMap;
#[cfg(feature = "openapi")]
use std::marker::PhantomData;
use std::{
fmt::{self, Debug},
sync::Arc,
};
use crate::{
extract::Extractor,
responder::Responder,
routing::{IntoRouteNode, RouteNode},
Body, Endpoint, Request, Response, Route,
};
use http_kit::{header, http_error, Method, StatusCode};
use utoipa::openapi::{
content::Content,
info::Info,
path::{
HttpMethod, Operation, OperationBuilder, Parameter, ParameterBuilder, ParameterIn,
PathItemBuilder, Paths, PathsBuilder,
},
request_body::RequestBodyBuilder,
response::{ResponseBuilder, ResponsesBuilder},
schema::{ComponentsBuilder, ObjectBuilder, Schema, SchemaType, Type},
Deprecated, OpenApi as UtoipaSpec, RefOr, Required,
};
use utoipa_redoc::Redoc;
pub type SchemaRef = RefOr<Schema>;
#[cfg(feature = "openapi")]
pub use skyzen_core::openapi::{
ExtractorSchema, ParameterLocation, ResponseSchema, SchemaCollector,
};
#[cfg(not(feature = "openapi"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParameterLocation {
Body,
Query,
Header,
}
#[cfg(not(feature = "openapi"))]
#[derive(Clone)]
pub struct ExtractorSchema {
pub location: ParameterLocation,
pub content_type: Option<&'static str>,
pub schema: Option<SchemaRef>,
}
#[cfg(not(feature = "openapi"))]
#[derive(Clone)]
pub struct ResponseSchema {
pub status: Option<StatusCode>,
pub description: Option<&'static str>,
pub schema: Option<SchemaRef>,
pub content_type: Option<&'static str>,
}
#[cfg(not(feature = "openapi"))]
impl fmt::Debug for ExtractorSchema {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ExtractorSchema")
.field("location", &self.location)
.field("content_type", &self.content_type)
.field("has_schema", &self.schema.is_some())
.finish()
}
}
#[cfg(not(feature = "openapi"))]
impl fmt::Debug for ResponseSchema {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ResponseSchema")
.field("status", &self.status)
.field("description", &self.description)
.field("content_type", &self.content_type)
.field("has_schema", &self.schema.is_some())
.finish()
}
}
#[cfg(not(feature = "openapi"))]
pub type SchemaCollector = fn(&mut BTreeMap<String, SchemaRef>);
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
pub use linkme;
mod builtins;
pub use builtins::IgnoreOpenApi;
#[must_use]
pub fn trim_crate(path: &str) -> &str {
path.split_once("::").map_or(path, |(_, rest)| rest)
}
pub type ExtractorSchemaFn = fn() -> Option<ExtractorSchema>;
pub type ResponderSchemaFn = fn() -> Option<Vec<ResponseSchema>>;
#[must_use]
pub fn schema_of<T>() -> Option<SchemaRef>
where
T: crate::ToSchema,
{
Some(<T as crate::PartialSchema>::schema())
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn extractor_schema_of<T>() -> Option<ExtractorSchema>
where
T: Extractor,
{
#[cfg(feature = "openapi")]
{
<T as Extractor>::openapi()
}
#[cfg(not(feature = "openapi"))]
{
let _ = core::marker::PhantomData::<T>;
None
}
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn responder_schemas_of<T>() -> Option<Vec<ResponseSchema>>
where
T: Responder,
{
#[cfg(feature = "openapi")]
{
<T as Responder>::openapi()
}
#[cfg(not(feature = "openapi"))]
{
let _ = core::marker::PhantomData::<T>;
None
}
}
#[allow(clippy::missing_const_for_fn)]
pub fn register_extractor_schemas_for<T>(defs: &mut BTreeMap<String, SchemaRef>)
where
T: Extractor,
{
#[cfg(feature = "openapi")]
{
<T as Extractor>::register_openapi_schemas(defs);
}
#[cfg(not(feature = "openapi"))]
{
let _ = (core::marker::PhantomData::<T>, defs);
}
}
#[allow(clippy::missing_const_for_fn)]
pub fn register_responder_schemas_for<T>(defs: &mut BTreeMap<String, SchemaRef>)
where
T: Responder,
{
#[cfg(feature = "openapi")]
{
<T as Responder>::register_openapi_schemas(defs);
}
#[cfg(not(feature = "openapi"))]
{
let _ = (core::marker::PhantomData::<T>, defs);
}
}
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
#[linkme::distributed_slice]
#[linkme(crate = ::skyzen::openapi::linkme)]
pub static HANDLER_SPECS: [HandlerSpec] = [..];
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
#[derive(Debug, Clone, Copy)]
pub struct HandlerSpec {
pub type_name: &'static str,
pub operation_name: &'static str,
pub docs: Option<&'static str>,
pub deprecated: bool,
pub parameters: &'static [ExtractorSchemaFn],
pub parameter_names: &'static [&'static str],
pub response: Option<ResponderSchemaFn>,
pub schemas: &'static [SchemaCollector],
}
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
fn find_handler_spec(type_name: &str) -> Option<&'static HandlerSpec> {
HANDLER_SPECS
.iter()
.find(|spec| spec.type_name == type_name)
}
#[cfg(feature = "openapi")]
fn register_type<T>(defs: &mut BTreeMap<String, SchemaRef>)
where
T: crate::PartialSchema + crate::ToSchema,
{
let name = <T as crate::ToSchema>::name().into_owned();
defs.entry(name)
.or_insert_with(<T as crate::PartialSchema>::schema);
let mut nested = Vec::new();
<T as crate::ToSchema>::schemas(&mut nested);
for (dep_name, schema) in nested {
defs.entry(dep_name).or_insert(schema);
}
}
#[cfg(feature = "openapi")]
struct SchemaProbe<T>(PhantomData<T>);
#[cfg(feature = "openapi")]
trait MaybeSchemaProbe {
fn maybe_schema(self) -> Option<SchemaRef>;
fn maybe_register(self, defs: &mut BTreeMap<String, SchemaRef>);
}
#[cfg(feature = "openapi")]
impl<T> MaybeSchemaProbe for &SchemaProbe<T> {
fn maybe_schema(self) -> Option<SchemaRef> {
None
}
fn maybe_register(self, _defs: &mut BTreeMap<String, SchemaRef>) {}
}
#[cfg(feature = "openapi")]
impl<T> MaybeSchemaProbe for &&SchemaProbe<T>
where
T: crate::PartialSchema + crate::ToSchema,
{
fn maybe_schema(self) -> Option<SchemaRef> {
Some(<T as crate::PartialSchema>::schema())
}
fn maybe_register(self, defs: &mut BTreeMap<String, SchemaRef>) {
register_type::<T>(defs);
}
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn maybe_schema_of<T>() -> Option<SchemaRef> {
let probe = SchemaProbe::<T>(PhantomData);
(&probe).maybe_schema()
}
#[cfg(feature = "openapi")]
pub fn maybe_register_schema_for<T>(defs: &mut BTreeMap<String, SchemaRef>) {
let probe = SchemaProbe::<T>(PhantomData);
(&probe).maybe_register(defs);
}
#[allow(clippy::missing_const_for_fn)]
pub fn register_schema_for<T>(defs: &mut BTreeMap<String, SchemaRef>)
where
T: crate::PartialSchema + crate::ToSchema,
{
#[cfg(feature = "openapi")]
register_type::<T>(defs);
let _ = defs;
}
#[cfg(feature = "openapi")]
pub trait RegisterSchemas {
fn register(defs: &mut BTreeMap<String, SchemaRef>);
}
#[cfg(feature = "openapi")]
impl<T> RegisterSchemas for T
where
T: crate::PartialSchema + crate::ToSchema,
{
fn register(defs: &mut BTreeMap<String, SchemaRef>) {
register_type::<T>(defs);
}
}
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
fn collect_schemas(collectors: &[SchemaCollector], defs: &mut BTreeMap<String, SchemaRef>) {
for collector in collectors {
collector(defs);
}
}
#[derive(Clone, Copy, Debug)]
pub struct RouteHandlerDoc {
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
type_name: &'static str,
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
spec: Option<&'static HandlerSpec>,
}
impl RouteHandlerDoc {
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
const fn new(type_name: &'static str, spec: Option<&'static HandlerSpec>) -> Self {
Self { type_name, spec }
}
#[cfg(not(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32"))))]
const fn new() -> Self {
Self {}
}
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn describe_handler<H: 'static>() -> RouteHandlerDoc {
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
{
let type_name = std::any::type_name::<H>();
let spec = find_handler_spec(type_name);
RouteHandlerDoc::new(type_name, spec)
}
#[cfg(not(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32"))))]
{
let _ = ::core::marker::PhantomData::<H>;
RouteHandlerDoc::new()
}
}
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
#[derive(Debug, Clone)]
pub struct RouteOpenApiEntry {
pub path: String,
pub method: Method,
pub handler: RouteHandlerDoc,
}
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
impl RouteOpenApiEntry {
#[must_use]
pub const fn new(path: String, method: Method, handler: RouteHandlerDoc) -> Self {
Self {
path,
method,
handler,
}
}
}
#[derive(Clone, Default)]
pub struct OpenApi {
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
operations: Vec<OpenApiOperation>,
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
schemas: Vec<(String, SchemaRef)>,
}
impl Debug for OpenApi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenApi")
.field("operations", &"[..]")
.field("schemas", &"[..]")
.finish()
}
}
impl OpenApi {
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
#[must_use]
pub(crate) fn from_entries(entries: &[RouteOpenApiEntry]) -> Self {
let mut schema_defs = BTreeMap::new();
let operations = entries
.iter()
.map(|entry| {
let handler_type = entry.handler.type_name;
entry.handler.spec.map_or_else(
|| OpenApiOperation {
path: entry.path.clone(),
method: entry.method.clone(),
handler_type,
operation_id: trim_crate(handler_type).to_owned(),
docs: None,
deprecated: false,
parameters: Vec::new(),
responses: Vec::new(),
},
|spec| {
collect_schemas(spec.schemas, &mut schema_defs);
let docs = spec.docs;
let mut parameters = Vec::new();
for (idx, schema_fn) in spec.parameters.iter().enumerate() {
if let Some(schema) = schema_fn() {
let name =
spec.parameter_names.get(idx).copied().unwrap_or("param");
parameters.push(NamedExtractorSchema {
name: name.to_string(),
schema,
});
}
}
let responses = spec
.response
.and_then(|schema| schema())
.unwrap_or_default();
OpenApiOperation {
path: entry.path.clone(),
method: entry.method.clone(),
handler_type,
operation_id: spec.operation_name.to_owned(),
docs,
deprecated: spec.deprecated,
parameters,
responses,
}
},
)
})
.collect();
let schemas = schema_defs.into_iter().collect();
Self {
operations,
schemas,
}
}
#[cfg(not(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32"))))]
#[must_use]
#[allow(dead_code)]
pub(crate) const fn from_entries(_: &[()]) -> Self {
Self {}
}
#[must_use]
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
pub fn operations(&self) -> &[OpenApiOperation] {
&self.operations
}
#[must_use]
#[cfg(not(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32"))))]
pub const fn operations(&self) -> &[OpenApiOperation] {
&[]
}
#[must_use]
pub const fn is_enabled(&self) -> bool {
cfg!(all(
debug_assertions,
feature = "openapi",
not(target_arch = "wasm32")
))
}
#[must_use]
pub fn redoc(&self) -> OpenApiRedocEndpoint {
if !self.is_enabled() {
return OpenApiRedocEndpoint::disabled();
}
let html = Redoc::new(self.to_utoipa_spec()).to_html();
OpenApiRedocEndpoint::enabled(html)
}
#[must_use]
pub fn redoc_route(&self, mount_path: impl Into<String>) -> RouteNode {
let endpoint = self.redoc();
redoc_route(endpoint, mount_path.into())
}
#[must_use]
pub fn to_utoipa_spec(&self) -> UtoipaSpec {
UtoipaSpec::builder()
.info(Self::default_info())
.paths(self.build_paths())
.components(Some(self.build_components()))
.build()
}
fn default_info() -> Info {
Info::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
fn build_paths(&self) -> Paths {
self.operations()
.iter()
.fold(PathsBuilder::new(), |builder, op| {
if let Some(http_method) = method_to_http_method(&op.method) {
let operation = build_operation(op);
let path_item = PathItemBuilder::new()
.operation(http_method, operation)
.build();
builder.path(op.path.clone(), path_item)
} else {
builder
}
})
.build()
}
#[cfg(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32")))]
fn build_components(&self) -> utoipa::openapi::schema::Components {
self.schemas
.iter()
.cloned()
.fold(ComponentsBuilder::new(), |builder, (name, schema)| {
builder.schema(name, schema)
})
.build()
}
#[cfg(not(all(debug_assertions, feature = "openapi", not(target_arch = "wasm32"))))]
#[allow(clippy::unused_self)]
fn build_components(&self) -> utoipa::openapi::schema::Components {
ComponentsBuilder::new().build()
}
}
#[derive(Clone, Debug)]
pub struct NamedExtractorSchema {
pub name: String,
pub schema: ExtractorSchema,
}
#[derive(Clone)]
pub struct OpenApiOperation {
pub path: String,
pub method: Method,
pub handler_type: &'static str,
pub operation_id: String,
pub docs: Option<&'static str>,
pub deprecated: bool,
pub parameters: Vec<NamedExtractorSchema>,
pub responses: Vec<ResponseSchema>,
}
impl fmt::Debug for OpenApiOperation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenApiOperation")
.field("path", &self.path)
.field("method", &self.method)
.field("handler_type", &self.handler_type)
.field("operation_id", &self.operation_id)
.field("docs", &self.docs)
.field("deprecated", &self.deprecated)
.field("parameters", &self.parameters.len())
.field("responses", &self.responses.len())
.finish()
}
}
#[derive(Clone, Debug)]
pub struct OpenApiRedocEndpoint {
html: Option<Arc<String>>,
}
impl OpenApiRedocEndpoint {
fn enabled(html: String) -> Self {
Self {
html: Some(Arc::new(html)),
}
}
const fn disabled() -> Self {
Self { html: None }
}
}
http_error!(
pub OpenApiRedocDisabledError, StatusCode::NOT_IMPLEMENTED, "OpenAPI support is disabled for this build");
impl Endpoint for OpenApiRedocEndpoint {
type Error = OpenApiRedocDisabledError;
async fn respond(&mut self, _request: &mut Request) -> Result<Response, Self::Error> {
self.html.as_ref().map_or_else(
|| Err(OpenApiRedocDisabledError::new()),
|html| {
let mut response = Response::new(Body::from(html.as_bytes().to_vec()));
response.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("text/html; charset=utf-8"),
);
Ok(response)
},
)
}
}
fn redoc_route(endpoint: OpenApiRedocEndpoint, mount_path: String) -> RouteNode {
let wildcard_suffix = "/{*path}";
let route = Route::new((
RouteNode::new_endpoint("", Method::GET, endpoint.clone(), None),
RouteNode::new_endpoint(wildcard_suffix, Method::GET, endpoint, None),
));
RouteNode::new_route(mount_path, route)
}
pub const DEFAULT_API_DOCS_MOUNT: &str = "/api-docs";
impl IntoRouteNode for OpenApiRedocEndpoint {
fn into_route_node(self) -> RouteNode {
redoc_route(self, DEFAULT_API_DOCS_MOUNT.to_string())
}
}
fn method_to_http_method(method: &Method) -> Option<HttpMethod> {
match method.as_str() {
"GET" => Some(HttpMethod::Get),
"POST" => Some(HttpMethod::Post),
"PUT" => Some(HttpMethod::Put),
"DELETE" => Some(HttpMethod::Delete),
"PATCH" => Some(HttpMethod::Patch),
"OPTIONS" => Some(HttpMethod::Options),
"HEAD" => Some(HttpMethod::Head),
"TRACE" => Some(HttpMethod::Trace),
_ => None,
}
}
fn build_operation(op: &OpenApiOperation) -> Operation {
let summary = op
.docs
.and_then(doc_summary)
.or_else(|| Some(op.operation_id.clone()));
let mut builder = OperationBuilder::new()
.operation_id(Some(op.operation_id.clone()))
.summary(summary)
.responses(build_responses(op));
if op.deprecated {
builder = builder.deprecated(Some(Deprecated::True));
}
let parameters = build_parameters(op);
if !parameters.is_empty() {
builder = builder.parameters(Some(parameters));
}
if let Some(body) = build_request_body(op) {
builder = builder.request_body(Some(body));
}
if let Some(docs) = op.docs {
builder = builder.description(Some(docs.to_owned()));
}
builder.build()
}
fn string_param_schema() -> RefOr<Schema> {
RefOr::T(Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::from(Type::String))
.build(),
))
}
fn path_parameter_names(path: &str) -> Vec<String> {
let mut names = Vec::new();
let mut rest = path;
while let Some(start) = rest.find('{') {
let after = &rest[start + 1..];
let Some(end) = after.find('}') else { break };
let raw = &after[..end];
let name = raw.strip_prefix('*').unwrap_or(raw);
if !name.is_empty() {
names.push(name.to_owned());
}
rest = &after[end + 1..];
}
names
}
fn build_parameters(op: &OpenApiOperation) -> Vec<Parameter> {
let mut parameters = Vec::new();
for name in path_parameter_names(&op.path) {
parameters.push(
ParameterBuilder::new()
.name(name)
.parameter_in(ParameterIn::Path)
.required(Required::True)
.schema(Some(string_param_schema()))
.build(),
);
}
for named in &op.parameters {
match named.schema.location {
ParameterLocation::Query => append_query_parameters(&mut parameters, named),
ParameterLocation::Header => parameters.push(
ParameterBuilder::new()
.name(named.name.clone())
.parameter_in(ParameterIn::Header)
.required(Required::False)
.schema(Some(
named
.schema
.schema
.clone()
.unwrap_or_else(string_param_schema),
))
.build(),
),
ParameterLocation::Body => {}
}
}
parameters
}
fn append_query_parameters(out: &mut Vec<Parameter>, named: &NamedExtractorSchema) {
if let Some(RefOr::T(Schema::Object(object))) = &named.schema.schema {
for (name, schema) in &object.properties {
let required = object.required.iter().any(|field| field == name);
out.push(
ParameterBuilder::new()
.name(name.clone())
.parameter_in(ParameterIn::Query)
.required(if required {
Required::True
} else {
Required::False
})
.schema(Some(schema.clone()))
.build(),
);
}
} else {
out.push(
ParameterBuilder::new()
.name(named.name.clone())
.parameter_in(ParameterIn::Query)
.required(Required::False)
.schema(Some(
named
.schema
.schema
.clone()
.unwrap_or_else(string_param_schema),
))
.build(),
);
}
}
fn build_responses(op: &OpenApiOperation) -> utoipa::openapi::response::Responses {
if op.responses.is_empty() {
let response = ResponseBuilder::new()
.description("Successful response")
.build();
return ResponsesBuilder::new()
.response(StatusCode::OK.as_str(), response)
.build();
}
let mut builder = ResponsesBuilder::new();
for response in &op.responses {
let status = response.status.unwrap_or(StatusCode::OK);
let mut response_builder =
ResponseBuilder::new().description(response.description.unwrap_or("Response"));
if let Some(schema) = &response.schema {
let content_type = response.content_type.unwrap_or("application/json");
response_builder =
response_builder.content(content_type, Content::new(Some(schema.clone())));
}
builder = builder.response(status.as_str(), response_builder.build());
}
builder.build()
}
fn build_request_body(op: &OpenApiOperation) -> Option<utoipa::openapi::request_body::RequestBody> {
let mut by_content_type: BTreeMap<&str, Vec<(String, RefOr<Schema>)>> = BTreeMap::new();
for param in &op.parameters {
if param.schema.location != ParameterLocation::Body {
continue;
}
let Some(content_type) = param.schema.content_type else {
continue;
};
let schema = param
.schema
.schema
.clone()
.unwrap_or_else(|| utoipa::openapi::schema::empty().into());
by_content_type
.entry(content_type)
.or_default()
.push((param.name.clone(), schema));
}
if by_content_type.is_empty() {
return None;
}
let mut builder = RequestBodyBuilder::new()
.description(Some("Extractor arguments"))
.required(Some(Required::True));
for (content_type, schemas) in by_content_type {
let schema = aggregate_parameter_schema(&schemas);
builder = builder.content(content_type, Content::new(Some(schema)));
}
Some(builder.build())
}
fn aggregate_parameter_schema(parameters: &[(String, RefOr<Schema>)]) -> RefOr<Schema> {
if parameters.len() == 1 {
return parameters[0].1.clone();
}
let object = parameters.iter().fold(
ObjectBuilder::new().schema_type(SchemaType::from(Type::Object)),
|builder, (name, schema)| {
builder
.property(name.clone(), schema.clone())
.required(name.clone())
},
);
RefOr::T(Schema::from(object.build()))
}
fn doc_summary(docs: &str) -> Option<String> {
let lines = docs.lines();
let mut paragraph = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
if !paragraph.is_empty() {
break;
}
continue;
}
paragraph.push(trimmed);
}
if paragraph.is_empty() {
None
} else {
Some(paragraph.join(" "))
}
}