use std::{
any::TypeId,
collections::{BTreeMap, HashSet},
fmt::Write as _,
path::{Path, PathBuf},
};
pub use ts_rs;
pub use utoipa_ts_macros::path;
pub mod __private {
pub use inventory;
}
const NOTE: &str = "// This file was generated by utoipa-ts. Do not edit this file manually.\n";
pub const EXPORT_PATH_ENV: &str = "UTOIPA_TS_PATH";
pub const DEFAULT_EXPORT_PATH: &str = "api.ts";
const DEFAULT_EXPORT_FILE_NAME: &str = "api.ts";
pub struct Endpoint {
pub name: &'static str,
pub method: &'static str,
pub path: &'static str,
pub render: fn(&mut TypeCollector) -> EndpointSpec,
}
inventory::collect!(Endpoint);
#[derive(Debug, Clone)]
pub struct EndpointSpec {
pub name: &'static str,
pub method: &'static str,
pub path: &'static str,
pub params: Vec<FieldSpec>,
pub request_body: Option<String>,
pub responses: Vec<ResponseSpec>,
}
#[derive(Debug, Clone)]
pub struct FieldSpec {
pub name: &'static str,
pub ty: String,
}
#[derive(Debug, Clone)]
pub struct ResponseSpec {
pub status: &'static str,
pub body: Option<String>,
}
pub struct EndpointRender<'a> {
collector: &'a mut TypeCollector,
spec: EndpointSpec,
}
impl<'a> EndpointRender<'a> {
pub fn new(
collector: &'a mut TypeCollector,
name: &'static str,
method: &'static str,
path: &'static str,
) -> Self {
Self {
collector,
spec: EndpointSpec {
name,
method,
path,
params: Vec::new(),
request_body: None,
responses: Vec::new(),
},
}
}
pub fn param<T>(&mut self, name: &'static str)
where
T: ts_rs::TS + 'static,
{
self.spec.params.push(FieldSpec {
name,
ty: self.collector.type_ref::<T>(),
});
}
pub fn request_body<T>(&mut self)
where
T: ts_rs::TS + 'static,
{
self.spec.request_body = Some(self.collector.type_ref::<T>());
}
pub fn response<T>(&mut self, status: &'static str)
where
T: ts_rs::TS + 'static,
{
self.spec.responses.push(ResponseSpec {
status,
body: Some(self.collector.type_ref::<T>()),
});
}
pub fn empty_response(&mut self, status: &'static str) {
self.spec
.responses
.push(ResponseSpec { status, body: None });
}
pub fn finish(self) -> EndpointSpec {
self.spec
}
}
pub struct TypeCollector {
cfg: ts_rs::Config,
seen: HashSet<TypeId>,
declarations: BTreeMap<String, String>,
}
impl TypeCollector {
pub fn new() -> Self {
Self {
cfg: ts_rs::Config::from_env(),
seen: HashSet::new(),
declarations: BTreeMap::new(),
}
}
pub fn type_ref<T>(&mut self) -> String
where
T: ts_rs::TS + 'static,
{
self.collect::<T>();
T::name(&self.cfg)
}
fn collect<T>(&mut self)
where
T: ts_rs::TS + 'static,
{
if !self.seen.insert(TypeId::of::<T>()) {
return;
}
struct Visitor<'a>(&'a mut TypeCollector);
impl ts_rs::TypeVisitor for Visitor<'_> {
fn visit<T>(&mut self)
where
T: ts_rs::TS + 'static + ?Sized,
{
self.0.collect_unsized::<T>();
}
}
T::visit_dependencies(&mut Visitor(self));
T::visit_generics(&mut Visitor(self));
self.insert_declaration::<T>();
}
fn collect_unsized<T>(&mut self)
where
T: ts_rs::TS + 'static + ?Sized,
{
if !self.seen.insert(TypeId::of::<T>()) {
return;
}
struct Visitor<'a>(&'a mut TypeCollector);
impl ts_rs::TypeVisitor for Visitor<'_> {
fn visit<T>(&mut self)
where
T: ts_rs::TS + 'static + ?Sized,
{
self.0.collect_unsized::<T>();
}
}
T::visit_dependencies(&mut Visitor(self));
T::visit_generics(&mut Visitor(self));
self.insert_declaration_unsized::<T>();
}
fn insert_declaration<T>(&mut self)
where
T: ts_rs::TS + 'static,
{
self.insert_declaration_unsized::<T>();
}
fn insert_declaration_unsized<T>(&mut self)
where
T: ts_rs::TS + 'static + ?Sized,
{
if T::output_path().is_none() {
return;
}
let ident = T::ident(&self.cfg);
self.declarations
.entry(ident)
.or_insert_with(|| format!("export {}", T::decl(&self.cfg)));
}
}
impl Default for TypeCollector {
fn default() -> Self {
Self::new()
}
}
pub fn export_all(path: impl AsRef<Path>) -> std::io::Result<()> {
let path = resolve_export_path(path);
let mut collector = TypeCollector::new();
let mut endpoints = inventory::iter::<Endpoint>
.into_iter()
.map(|endpoint| (endpoint.render)(&mut collector))
.collect::<Vec<_>>();
endpoints.sort_by_key(|endpoint| (endpoint.method, endpoint.path, endpoint.name));
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, render_file(&collector.declarations, &endpoints))
}
pub fn export_all_default() -> std::io::Result<()> {
export_all_from_path_or_env(None::<&Path>)
}
pub fn export_all_from_path_or_env(path: Option<impl AsRef<Path>>) -> std::io::Result<()> {
let path = path
.map(|path| path.as_ref().to_path_buf())
.or_else(|| std::env::var_os(EXPORT_PATH_ENV).map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(DEFAULT_EXPORT_PATH));
export_all(path)
}
fn resolve_export_path(path: impl AsRef<Path>) -> PathBuf {
let path = path.as_ref();
if path.is_dir() || path.extension().is_none() {
path.join(DEFAULT_EXPORT_FILE_NAME)
} else {
path.to_path_buf()
}
}
#[macro_export]
macro_rules! export {
() => {
#[test]
fn export_api() -> ::std::io::Result<()> {
$crate::export_all_default()
}
};
($path:expr $(,)?) => {
#[test]
fn export_api() -> ::std::io::Result<()> {
$crate::export_all_from_path_or_env(Some($path))
}
};
}
fn render_file(declarations: &BTreeMap<String, String>, endpoints: &[EndpointSpec]) -> String {
let mut out = String::new();
out.push_str(NOTE);
for declaration in declarations.values() {
out.push('\n');
out.push_str(declaration);
out.push('\n');
}
out.push_str("\nexport type Api = {\n");
for endpoint in endpoints {
let _ = writeln!(
out,
" \"{} {}\": {{",
endpoint.method,
endpoint.path.replace('"', "\\\"")
);
if !&endpoint.params.is_empty() {
write_fields_object(&mut out, "params", &endpoint.params, 4);
}
if let Some(body) = &endpoint.request_body {
let _ = writeln!(out, " body: {};", body);
}
out.push_str(" responses: {\n");
for response in &endpoint.responses {
let body = response.body.as_deref().unwrap_or("never");
let _ = writeln!(out, " {}: {};", ts_key(response.status), body);
}
out.push_str(" };\n");
out.push_str(" };\n");
}
out.push_str("};\n");
out
}
fn write_fields_object(out: &mut String, name: &str, fields: &[FieldSpec], indent: usize) {
let padding = " ".repeat(indent);
if fields.is_empty() {
let _ = writeln!(out, "{padding}{name}: Record<string, never>;");
return;
}
let _ = writeln!(out, "{padding}{name}: {{");
for field in fields {
let _ = writeln!(out, "{padding} {}: {};", ts_key(field.name), field.ty);
}
let _ = writeln!(out, "{padding}}};");
}
fn ts_key(key: &str) -> String {
if key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
{
key.to_owned()
} else {
format!("\"{}\"", key.replace('"', "\\\""))
}
}