use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use leptos::server_fn::ContentType;
use leptos::server_fn::Http;
use leptos::server_fn::ServerFn;
use leptos::server_fn::codec::Encoding;
use leptos::server_fn::error::FromServerFnError;
pub use leptoskit_rpc_codegen::ApiChannel;
pub use leptoskit_rpc_codegen::ApiFunction;
pub use leptoskit_rpc_codegen::ApiKind;
pub use leptoskit_rpc_codegen::ApiParam;
pub use leptoskit_rpc_codegen::ApiSpec;
pub use leptoskit_rpc_codegen::HttpInput;
pub use leptoskit_rpc_codegen::HttpOutput;
pub use leptoskit_rpc_codegen::spec::SPEC_DESCRIPTION_ENV;
pub use leptoskit_rpc_codegen::spec::SPEC_OUT_ENV;
pub use leptoskit_rpc_codegen::spec::SPEC_SERVERS_ENV;
pub use leptoskit_rpc_codegen::spec::SPEC_TITLE_ENV;
use leptoskit_rpc_codegen::spec::SPEC_VERSION;
pub use leptoskit_rpc_codegen::spec::SPEC_VERSION_ENV;
pub use schemars;
use schemars::Schema;
use schemars::SchemaGenerator;
use schemars::generate::SchemaSettings;
#[derive(Debug)]
pub struct ApiFnDescriptor {
pub name: &'static str,
pub kind: ApiKind,
pub module_path: &'static str,
pub struct_name: &'static str,
pub docs: &'static str,
pub path: &'static str,
pub method: http::Method,
pub input_content_type: &'static str,
pub output_content_type: &'static str,
pub error_content_type: &'static str,
pub params: &'static [ApiParamDescriptor],
pub output_schema: fn(&mut SchemaGenerator) -> Schema,
pub internal: bool,
pub deprecated: bool,
pub tags: &'static [&'static str],
pub scopes: &'static [&'static str],
}
#[derive(Debug)]
pub struct ApiParamDescriptor {
pub name: &'static str,
pub docs: &'static str,
pub schema: fn(&mut SchemaGenerator) -> Schema,
pub required: bool,
}
inventory::collect!(ApiFnDescriptor);
#[derive(Debug)]
pub struct ApiChannelDescriptor {
pub name: &'static str,
pub module_path: &'static str,
pub struct_name: &'static str,
pub docs: &'static str,
pub fields: &'static [&'static str],
pub args_schema: fn(&mut SchemaGenerator) -> Schema,
pub message_schema: fn(&mut SchemaGenerator) -> Schema,
pub presence_schema: fn(&mut SchemaGenerator) -> Schema,
pub internal: bool,
pub deprecated: bool,
pub tags: &'static [&'static str],
}
inventory::collect!(ApiChannelDescriptor);
pub const OPERATION_METHOD: http::Method = http::Method::POST;
#[diagnostic::on_unimplemented(
message = "`#[api]` only publishes server functions with an HTTP protocol, and `{Self}` is \
not one",
label = "not an `Http<Input, Output>` protocol",
note = "remove #[api] from this function, or give it an HTTP encoding such as `PostUrl`, \
`GetUrl`, or `Json`"
)]
pub trait HttpProtocolInfo {
const METHOD: http::Method;
const INPUT_CONTENT_TYPE: &'static str;
const OUTPUT_CONTENT_TYPE: &'static str;
}
impl<Input: Encoding, Output: Encoding> HttpProtocolInfo for Http<Input, Output> {
const INPUT_CONTENT_TYPE: &'static str = Input::CONTENT_TYPE;
const METHOD: http::Method = Input::METHOD;
const OUTPUT_CONTENT_TYPE: &'static str = Output::CONTENT_TYPE;
}
pub const fn method_of<T: ServerFn>() -> http::Method
where
T::Protocol: HttpProtocolInfo,
{
<T::Protocol as HttpProtocolInfo>::METHOD
}
pub const fn input_content_type_of<T: ServerFn>() -> &'static str
where
T::Protocol: HttpProtocolInfo,
{
<T::Protocol as HttpProtocolInfo>::INPUT_CONTENT_TYPE
}
pub const fn output_content_type_of<T: ServerFn>() -> &'static str
where
T::Protocol: HttpProtocolInfo,
{
<T::Protocol as HttpProtocolInfo>::OUTPUT_CONTENT_TYPE
}
pub const fn error_content_type_of<T: ServerFn>() -> &'static str {
<<T::Error as FromServerFnError>::Encoder as ContentType>::CONTENT_TYPE
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpecOptions {
pub title: String,
pub version: String,
pub description: Option<String>,
pub servers: Vec<String>,
pub live_path: String,
}
impl SpecOptions {
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
Self {
title: title.into(),
version: version.into(),
description: None,
servers: Vec::new(),
live_path: leptoskit_rpc_codegen::DEFAULT_LIVE_PATH.to_owned(),
}
}
#[must_use]
pub fn from_env() -> Self {
Self::from_lookup(|name| std::env::var(name).ok())
}
pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
let read = |name: &str| lookup(name).filter(|value| !value.is_empty());
Self {
title: read(SPEC_TITLE_ENV).unwrap_or_else(|| "RPC".to_owned()),
version: read(SPEC_VERSION_ENV).unwrap_or_else(|| "0.0.0".to_owned()),
description: read(SPEC_DESCRIPTION_ENV),
servers: read(SPEC_SERVERS_ENV)
.map(|servers| {
servers
.split(',')
.map(str::trim)
.filter(|server| !server.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default(),
live_path: leptoskit_rpc_codegen::DEFAULT_LIVE_PATH.to_owned(),
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_live_path(mut self, path: impl Into<String>) -> Self {
self.live_path = path.into();
self
}
#[must_use]
pub fn with_server(mut self, url: impl Into<String>) -> Self {
self.servers.push(url.into());
self
}
}
pub fn descriptors() -> Vec<&'static ApiFnDescriptor> {
let mut descriptors: Vec<&'static ApiFnDescriptor> =
inventory::iter::<ApiFnDescriptor>().collect();
descriptors.sort_by(|a, b| {
a.name
.cmp(b.name)
.then_with(|| a.module_path.cmp(b.module_path))
});
descriptors
}
pub fn channel_descriptors() -> Vec<&'static ApiChannelDescriptor> {
let mut descriptors: Vec<&'static ApiChannelDescriptor> =
inventory::iter::<ApiChannelDescriptor>().collect();
descriptors.sort_by(|a, b| {
a.name
.cmp(b.name)
.then_with(|| a.module_path.cmp(b.module_path))
});
descriptors
}
pub fn scopes_for(method: &http::Method, path: &str) -> Option<&'static [&'static str]> {
inventory::iter::<ApiFnDescriptor>()
.find(|descriptor| descriptor.method == *method && descriptor.path == path)
.map(|descriptor| descriptor.scopes)
}
pub fn collect(options: &SpecOptions) -> Result<ApiSpec, SpecError> {
collect_with_channels(&descriptors(), &channel_descriptors(), options)
}
pub fn check() -> Result<(), SpecError> {
check_from(&descriptors())
}
pub fn check_from(descriptors: &[&ApiFnDescriptor]) -> Result<(), SpecError> {
let mut by_name: HashMap<&str, &ApiFnDescriptor> = HashMap::new();
let mut by_route: HashMap<(&str, &str), &ApiFnDescriptor> = HashMap::new();
for descriptor in descriptors.iter().copied() {
if let Some(existing) = by_name.insert(descriptor.name, descriptor) {
return Err(SpecError::DuplicateName {
name: descriptor.name.to_owned(),
first: existing.module_path.to_owned(),
second: descriptor.module_path.to_owned(),
});
}
let route = (descriptor.path, descriptor.method.as_str());
if let Some(existing) = by_route.insert(route, descriptor) {
return Err(SpecError::DuplicateRoute {
method: descriptor.method.to_string(),
path: descriptor.path.to_owned(),
first: existing.name.to_owned(),
second: descriptor.name.to_owned(),
});
}
}
Ok(())
}
pub fn collect_from(
descriptors: &[&ApiFnDescriptor],
options: &SpecOptions,
) -> Result<ApiSpec, SpecError> {
collect_with_channels(descriptors, &[], options)
}
pub fn collect_with_channels(
descriptors: &[&ApiFnDescriptor],
channels: &[&ApiChannelDescriptor],
options: &SpecOptions,
) -> Result<ApiSpec, SpecError> {
check_from(descriptors)?;
let mut channel_names: HashMap<&str, &ApiChannelDescriptor> = HashMap::new();
for channel in channels.iter().copied() {
if let Some(existing) = channel_names.insert(channel.name, channel) {
return Err(SpecError::DuplicateName {
name: channel.name.to_owned(),
first: existing.module_path.to_owned(),
second: channel.module_path.to_owned(),
});
}
}
let mut generator = SchemaSettings::draft2020_12()
.with(|settings| {
settings.definitions_path = "/components/schemas".into();
settings.meta_schema = None;
})
.into_generator();
let functions = descriptors
.iter()
.map(|descriptor| {
let params = descriptor
.params
.iter()
.map(|param| {
ApiParam {
name: param.name.to_owned(),
docs: param.docs.to_owned(),
schema: (param.schema)(&mut generator).to_value(),
required: param.required,
}
})
.collect();
let output_schema = (descriptor.output_schema)(&mut generator).to_value();
ApiFunction {
name: descriptor.name.to_owned(),
kind: descriptor.kind,
module_path: descriptor.module_path.to_owned(),
struct_name: descriptor.struct_name.to_owned(),
docs: descriptor.docs.to_owned(),
path: descriptor.path.to_owned(),
method: descriptor.method.to_string(),
input: classify_input(&descriptor.method, descriptor.input_content_type),
output: classify_output(descriptor.output_content_type),
error_content_type: descriptor.error_content_type.to_owned(),
params,
output_schema,
internal: descriptor.internal,
deprecated: descriptor.deprecated,
tags: descriptor
.tags
.iter()
.map(|tag| (*tag).to_owned())
.collect(),
scopes: descriptor
.scopes
.iter()
.map(|scope| (*scope).to_owned())
.collect(),
}
})
.collect();
let channels = channels
.iter()
.map(|channel| {
ApiChannel {
name: channel.name.to_owned(),
module_path: channel.module_path.to_owned(),
struct_name: channel.struct_name.to_owned(),
docs: channel.docs.to_owned(),
fields: channel
.fields
.iter()
.map(|field| (*field).to_owned())
.collect(),
args_schema: (channel.args_schema)(&mut generator).to_value(),
message_schema: (channel.message_schema)(&mut generator).to_value(),
presence_schema: (channel.presence_schema)(&mut generator).to_value(),
internal: channel.internal,
deprecated: channel.deprecated,
tags: channel.tags.iter().map(|tag| (*tag).to_owned()).collect(),
}
})
.collect();
let schemas: BTreeMap<String, serde_json::Value> =
generator.take_definitions(false).into_iter().collect();
Ok(ApiSpec {
leptoskit_rpc_spec: SPEC_VERSION,
title: options.title.clone(),
version: options.version.clone(),
description: options.description.clone(),
servers: options.servers.clone(),
functions,
channels,
live_path: options.live_path.clone(),
schemas: schemas.into_iter().collect(),
})
}
fn classify_input(method: &http::Method, content_type: &str) -> HttpInput {
match content_type {
"application/x-www-form-urlencoded"
if *method == http::Method::GET || *method == http::Method::DELETE =>
{
HttpInput::Query
}
"application/x-www-form-urlencoded" => HttpInput::Form,
"application/json" => HttpInput::Json,
other => {
HttpInput::Other {
content_type: other.to_owned(),
}
}
}
}
fn classify_output(content_type: &str) -> HttpOutput {
match content_type {
"application/json" => HttpOutput::Json,
other => {
HttpOutput::Other {
content_type: other.to_owned(),
}
}
}
}
pub fn write_to(options: &SpecOptions, path: &Path) -> Result<ApiSpec, SpecError> {
let spec = collect(options)?;
let json = spec.to_json().map_err(SpecError::Codegen)?;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| {
SpecError::Write {
path: parent.to_path_buf(),
source,
}
})?;
}
std::fs::write(path, json).map_err(|source| {
SpecError::Write {
path: path.to_path_buf(),
source,
}
})?;
Ok(spec)
}
pub fn export_if_requested() {
export_if_requested_with(&SpecOptions::from_env());
}
pub fn export_if_requested_with(options: &SpecOptions) {
let Some(outcome) = maybe_export_from(|name| std::env::var(name).ok(), options) else {
if let Err(error) = check() {
eprintln!("error: {}", with_causes(&error));
std::process::exit(1);
}
return;
};
match outcome {
Ok(path) => {
eprintln!("wrote the RPC specification to {}", path.display());
std::process::exit(0);
}
Err(error) => {
eprintln!(
"error: could not export the RPC specification: {}",
with_causes(&error)
);
std::process::exit(1);
}
}
}
fn with_causes(error: &dyn std::error::Error) -> String {
let mut message = error.to_string();
let mut cause = error.source();
while let Some(source) = cause {
message.push_str(": ");
message.push_str(&source.to_string());
cause = source.source();
}
message
}
pub fn maybe_export(options: &SpecOptions) -> Option<Result<PathBuf, SpecError>> {
maybe_export_from(|name| std::env::var(name).ok(), options)
}
pub fn maybe_export_from(
lookup: impl Fn(&str) -> Option<String>,
options: &SpecOptions,
) -> Option<Result<PathBuf, SpecError>> {
let path = PathBuf::from(lookup(SPEC_OUT_ENV).filter(|path| !path.is_empty())?);
Some(write_to(options, &path).map(|_| path))
}
#[cfg(feature = "axum")]
pub fn openapi_router(options: &SpecOptions) -> Result<axum::Router, SpecError> {
use axum::http::HeaderValue;
use axum::http::header;
use axum::response::IntoResponse;
use axum::routing::get;
let spec = collect(options)?.public();
let json = serde_json::to_string_pretty(&spec.to_openapi()).map_err(|source| {
SpecError::Codegen(leptoskit_rpc_codegen::CodegenError::Serialize(source))
})?;
let yaml = spec.to_openapi_yaml();
let asyncapi = serde_json::to_string_pretty(&spec.to_asyncapi()).map_err(|source| {
SpecError::Codegen(leptoskit_rpc_codegen::CodegenError::Serialize(source))
})?;
let asyncapi_response = move || {
let asyncapi = asyncapi.clone();
async move {
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)],
asyncapi,
)
.into_response()
}
};
let json_response = move || {
let json = json.clone();
async move {
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)],
json,
)
.into_response()
}
};
let yaml_response = move || {
let yaml = yaml.clone();
async move {
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/yaml"),
)],
yaml,
)
.into_response()
}
};
Ok(axum::Router::new()
.route("/openapi.json", get(json_response))
.route("/openapi.yaml", get(yaml_response))
.route("/asyncapi.json", get(asyncapi_response)))
}
#[derive(Debug, thiserror::Error)]
pub enum SpecError {
#[error(
"two #[api] functions are named `{name}` (in `{first}` and `{second}`); rename one, or \
give one its own `#[api(name = \"...\")]`, which also becomes its route"
)]
DuplicateName {
name: String,
first: String,
second: String,
},
#[error(
"`{first}` and `{second}` both answer `{method} {path}`, so leptos only ever runs one of \
them; give one a different `endpoint` or `prefix` in its #[server] attribute"
)]
DuplicateRoute {
method: String,
path: String,
first: String,
second: String,
},
#[error(transparent)]
Codegen(leptoskit_rpc_codegen::CodegenError),
#[error("could not write {path}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}