use alibi_core::endpoint::{EndpointCall, EndpointDefinition, EndpointResponse, ServerEndpoint};
use alibi_core::{
AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
AuthRoute, AuthSchema, HttpMethod, OpenApiBuilder, OpenApiEndpoint, OpenApiRegistry,
PluginOpenApiMetadata, core_paths,
};
use async_trait::async_trait;
#[derive(Debug, Clone, Default)]
pub struct OpenApiConfig {
pub path: Option<String>,
pub theme: Option<String>,
pub nonce: Option<String>,
pub disable_default_reference: bool,
pub include_native_extensions: bool,
}
impl OpenApiConfig {
#[must_use]
pub fn path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
#[must_use]
pub fn theme(mut self, theme: impl Into<String>) -> Self {
self.theme = Some(theme.into());
self
}
#[must_use]
pub fn nonce(mut self, nonce: impl Into<String>) -> Self {
self.nonce = Some(nonce.into());
self
}
#[must_use]
pub const fn include_native_extensions(mut self, included: bool) -> Self {
self.include_native_extensions = included;
self
}
#[must_use]
pub const fn disable_default_reference(mut self, disabled: bool) -> Self {
self.disable_default_reference = disabled;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct OpenApiPlugin {
config: OpenApiConfig,
}
impl OpenApiPlugin {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn with_config(config: OpenApiConfig) -> Self {
Self { config }
}
#[must_use]
pub fn generate_open_api_schema_endpoint() -> ServerEndpoint<serde_json::Value> {
ServerEndpoint::new("open-api", "generateOpenAPISchema")
}
#[must_use]
pub fn open_api_reference_endpoint() -> ServerEndpoint<serde_json::Value> {
ServerEndpoint::new("open-api", "openAPIReference")
}
fn reference<S: AuthSchema>(&self, ctx: &AuthContext<S>) -> AuthResult<AuthResponse> {
let spec = Self::generate_schema(ctx, self.config.include_native_extensions)?;
let schema = serde_json::to_string(&spec)?;
Ok(
AuthResponse::html(200, reference_html(&schema, &self.config))
.with_header("content-type", "text/html"),
)
}
fn generate_schema<S: AuthSchema>(
ctx: &AuthContext<S>,
include_native_extensions: bool,
) -> AuthResult<alibi_core::openapi::OpenApiSpec> {
let registry = ctx
.extensions
.get::<OpenApiRegistry>()
.ok_or_else(|| AuthError::internal("OpenAPI registry is not initialized"))?;
Ok(OpenApiBuilder::registered_with_native_extensions(
&ctx.config,
®istry,
include_native_extensions,
)
.build())
}
fn path(&self) -> &str {
self.config.path.as_deref().unwrap_or("/reference")
}
}
#[async_trait]
impl<S: AuthSchema> AuthPlugin<S> for OpenApiPlugin {
fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
crate::metadata::plugin_metadata(
<Self as alibi_core::AuthPlugin<S>>::name(self),
&<Self as alibi_core::AuthPlugin<S>>::routes(self),
)
}
fn server_endpoints(&self) -> Vec<EndpointDefinition> {
vec![
crate::endpoint::definition(
"generateOpenAPISchema",
"generateOpenAPISchema",
Some("/open-api/generate-schema"),
HttpMethod::Get,
),
crate::endpoint::definition(
"openAPIReference",
"openAPIReference",
Some(self.path()),
HttpMethod::Get,
),
]
}
async fn on_endpoint(
&self,
call: &EndpointCall,
ctx: &AuthContext<S>,
) -> AuthResult<EndpointResponse> {
if call.operation_id() == "openAPIReference" {
if self.config.disable_default_reference {
return Ok(EndpointResponse::error(AuthError::Api {
status: 404,
code: None,
message: String::new(),
})
.without_error_body()
.with_header("content-type", "application/json"));
}
return Ok(EndpointResponse::from_http_response(self.reference(ctx)?));
}
EndpointResponse::json(&Self::generate_schema(
ctx,
self.config.include_native_extensions,
)?)
}
fn name(&self) -> &'static str {
"open-api"
}
fn routes(&self) -> Vec<AuthRoute> {
vec![
AuthRoute::get(core_paths::OPENAPI_SPEC, "openapi_spec"),
AuthRoute::get("/open-api/generate-schema", "generate_open_api_schema"),
AuthRoute::get(self.path(), "open_api_reference"),
]
}
fn openapi_metadata(&self, _ctx: &AuthInitContext<S>) -> PluginOpenApiMetadata {
PluginOpenApiMetadata::default().endpoint(
HttpMethod::Get,
core_paths::OPENAPI_SPEC,
OpenApiEndpoint {
native_extension: true,
..Default::default()
},
)
}
async fn on_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
let path = req.path();
let is_schema = path == "/open-api/generate-schema" || path == core_paths::OPENAPI_SPEC;
if req.method() != &HttpMethod::Get || !(is_schema || path == self.path()) {
return Ok(None);
}
if !is_schema && self.config.disable_default_reference {
return Ok(Some(
AuthResponse::new(404).with_header("content-type", "application/json"),
));
}
if is_schema {
let spec = Self::generate_schema(
ctx,
path != core_paths::OPENAPI_SPEC && self.config.include_native_extensions,
)?;
return Ok(Some(AuthResponse::json(200, &spec)?));
}
Ok(Some(self.reference(ctx)?))
}
}
fn reference_html(schema: &str, config: &OpenApiConfig) -> String {
let nonce = config
.nonce
.as_deref()
.filter(|nonce| !nonce.is_empty())
.map(|nonce| format!("nonce=\"{nonce}\""))
.unwrap_or_default();
let theme = config
.theme
.as_deref()
.filter(|theme| !theme.is_empty())
.unwrap_or("default");
let favicon = encode_uri_component(include_str!("logo.svg"));
format!(
r#"<!doctype html>
<html>
<head>
<title>Scalar API Reference</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1" />
</head>
<body>
<script
id="api-reference"
type="application/json">
{schema}
</script>
<script {nonce}>
var configuration = {{
favicon: "data:image/svg+xml;utf8,{favicon}",
theme: "{theme}",
metaData: {{
title: "Better Auth API",
description: "API Reference for your Better Auth Instance",
}}
}}
document.getElementById('api-reference').dataset.configuration =
JSON.stringify(configuration)
</script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference" {nonce}></script>
</body>
</html>"#
)
}
fn encode_uri_component(input: &str) -> String {
let mut output = String::new();
for byte in input.bytes() {
if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
output.push(char::from(byte));
} else {
output.push('%');
output.push(hex_digit(byte >> 4));
output.push(hex_digit(byte & 15));
}
}
output
}
fn hex_digit(digit: u8) -> char {
char::from(if digit < 10 {
b'0' + digit
} else {
b'A' + digit - 10
})
}