utoipa-ts 0.1.0

Generate TypeScript API definitions from utoipa paths
Documentation
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('"', "\\\""))
    }
}