Skip to main content

utoipa_ts/
lib.rs

1//! Generate TypeScript API definitions from [`utoipa`] endpoint definitions.
2//!
3//! [`utoipa-ts`] wraps [`utoipa::path`] with [`path`] so the same endpoint metadata
4//! can be used to generate a TypeScript `Api` type.
5//!
6//! [`utoipa-ts`]: https://docs.rs/utoipa-ts/latest/utoipa_ts
7//! [`utoipa::path`]: https://docs.rs/utoipa/latest/utoipa/attr.path.html
8//!
9//! # Quick start
10//!
11//! Derive both [`ts_rs::TS`] and [`utoipa::ToSchema`] for types that should appear
12//! in generated TypeScript:
13//!
14//! ```rust
15//! use utoipa::ToSchema;
16//!
17//! #[derive(ts_rs::TS, ToSchema)]
18//! struct Todo {
19//!     id: String,
20//!     title: String,
21//!     done: bool,
22//! }
23//!
24//! #[utoipa_ts::path(
25//!     get,
26//!     path = "/todos",
27//!     responses(
28//!         (status = 200, description = "Todo list", body = Vec<Todo>),
29//!     )
30//! )]
31//! async fn list_todos() {}
32//!
33//! utoipa_ts::export!("types/api.ts");
34//!
35//! fn main() {}
36//! ```
37//!
38//! Then generate the file with:
39//!
40//! ```text
41//! cargo test export_api
42//! ```
43//!
44//! # Existing utoipa projects
45//!
46//! Replace `#[utoipa::path(...)]` with `#[utoipa_ts::path(...)]` and add
47//! [`export!`] somewhere in your crate.
48//!
49//! # Export path
50//!
51//! [`export!`] writes to `types.ts` by default. You can pass a path:
52//!
53//! ```rust
54//! utoipa_ts::export!("types/api.ts");
55//! ```
56//!
57//! The `UTOIPA_TS_PATH` environment variable overrides the macro path.
58//!
59//! # Generated type shape
60//!
61//! The generated file exports all collected schema declarations and an `Api` type
62//! indexed by `"METHOD /path"`.
63//!
64//! # Supported endpoint metadata
65//!
66//! `utoipa-ts` currently reads:
67//!
68//! - HTTP method
69//! - `path = "..."`
70//! - `params(...)`
71//! - `request_body = Type` and `request_body(content = Type, ...)`
72//! - response status/body pairs
73
74use std::{
75    any::TypeId,
76    collections::{BTreeMap, HashSet},
77    fmt::Write as _,
78    path::{Path, PathBuf},
79};
80
81pub use ts_rs;
82pub use utoipa_ts_macros::path;
83
84#[doc(hidden)]
85pub mod __private {
86    pub use inventory;
87}
88
89const NOTE: &str = "// This file was generated by utoipa-ts. Do not edit it manually.\n";
90/// Environment variable that can be used to override the export path for [`export!`]
91pub const EXPORT_PATH_ENV: &str = "UTOIPA_TS_PATH";
92/// Default export path for [`export!`]
93pub const DEFAULT_EXPORT_PATH: &str = "types.ts";
94const DEFAULT_EXPORT_FILE_NAME: &str = "types.ts";
95
96#[doc(hidden)]
97pub struct Endpoint {
98    pub name: &'static str,
99    pub method: &'static str,
100    pub path: &'static str,
101    pub render: fn(&mut TypeCollector) -> EndpointSpec,
102}
103
104inventory::collect!(Endpoint);
105
106#[derive(Debug, Clone)]
107#[doc(hidden)]
108pub struct EndpointSpec {
109    pub name: &'static str,
110    pub method: &'static str,
111    pub path: &'static str,
112    pub params: Vec<FieldSpec>,
113    pub request_body: Option<String>,
114    pub responses: Vec<ResponseSpec>,
115}
116
117#[derive(Debug, Clone)]
118#[doc(hidden)]
119pub struct FieldSpec {
120    pub name: String,
121    pub ty: String,
122    pub required: bool,
123}
124
125#[derive(Debug, Clone)]
126#[doc(hidden)]
127pub struct ResponseSpec {
128    pub status: &'static str,
129    pub body: Option<String>,
130}
131
132#[doc(hidden)]
133pub struct EndpointRender<'a> {
134    collector: &'a mut TypeCollector,
135    spec: EndpointSpec,
136}
137
138impl<'a> EndpointRender<'a> {
139    pub fn new(
140        collector: &'a mut TypeCollector,
141        name: &'static str,
142        method: &'static str,
143        path: &'static str,
144    ) -> Self {
145        Self {
146            collector,
147            spec: EndpointSpec {
148                name,
149                method,
150                path,
151                params: Vec::new(),
152                request_body: None,
153                responses: Vec::new(),
154            },
155        }
156    }
157
158    pub fn param<T>(&mut self, name: &'static str)
159    where
160        T: ts_rs::TS + 'static,
161    {
162        self.spec.params.push(FieldSpec {
163            name: name.to_owned(),
164            ty: self.collector.type_ref::<T>(),
165            required: true,
166        });
167    }
168
169    pub fn params<T>(&mut self)
170    where
171        T: utoipa::IntoParams + utoipa::ToSchema,
172    {
173        self.collector.collect_schema_declarations::<T>();
174
175        self.spec.params.extend(
176            T::into_params(|| Some(utoipa::openapi::path::ParameterIn::Query))
177                .into_iter()
178                .map(|param| {
179                    let nullable = param.schema.as_ref().is_some_and(schema_ref_is_nullable);
180                    let defaulted = param.schema.as_ref().is_some_and(schema_ref_has_default);
181                    let required = matches!(param.required, utoipa::openapi::Required::True)
182                        && !nullable
183                        && !defaulted;
184
185                    FieldSpec {
186                        name: param.name,
187                        ty: param
188                            .schema
189                            .as_ref()
190                            .map(schema_ref_to_ts)
191                            .unwrap_or_else(|| "unknown".to_owned()),
192                        required,
193                    }
194                }),
195        );
196    }
197
198    pub fn request_body<T>(&mut self)
199    where
200        T: ts_rs::TS + 'static,
201    {
202        self.spec.request_body = Some(self.collector.type_ref::<T>());
203    }
204
205    pub fn response<T>(&mut self, status: &'static str)
206    where
207        T: ts_rs::TS + 'static,
208    {
209        self.spec.responses.push(ResponseSpec {
210            status,
211            body: Some(self.collector.type_ref::<T>()),
212        });
213    }
214
215    pub fn empty_response(&mut self, status: &'static str) {
216        self.spec
217            .responses
218            .push(ResponseSpec { status, body: None });
219    }
220
221    pub fn finish(self) -> EndpointSpec {
222        self.spec
223    }
224}
225
226#[doc(hidden)]
227pub struct TypeCollector {
228    cfg: ts_rs::Config,
229    seen: HashSet<TypeId>,
230    declarations: BTreeMap<String, String>,
231}
232
233impl TypeCollector {
234    pub fn new() -> Self {
235        Self {
236            cfg: ts_rs::Config::from_env(),
237            seen: HashSet::new(),
238            declarations: BTreeMap::new(),
239        }
240    }
241
242    pub fn type_ref<T>(&mut self) -> String
243    where
244        T: ts_rs::TS + 'static,
245    {
246        self.collect::<T>();
247        T::name(&self.cfg)
248    }
249
250    fn collect<T>(&mut self)
251    where
252        T: ts_rs::TS + 'static,
253    {
254        if !self.seen.insert(TypeId::of::<T>()) {
255            return;
256        }
257
258        struct Visitor<'a>(&'a mut TypeCollector);
259
260        impl ts_rs::TypeVisitor for Visitor<'_> {
261            fn visit<T>(&mut self)
262            where
263                T: ts_rs::TS + 'static + ?Sized,
264            {
265                self.0.collect_unsized::<T>();
266            }
267        }
268
269        T::visit_dependencies(&mut Visitor(self));
270        T::visit_generics(&mut Visitor(self));
271        self.insert_declaration::<T>();
272    }
273
274    fn collect_unsized<T>(&mut self)
275    where
276        T: ts_rs::TS + 'static + ?Sized,
277    {
278        if !self.seen.insert(TypeId::of::<T>()) {
279            return;
280        }
281
282        struct Visitor<'a>(&'a mut TypeCollector);
283
284        impl ts_rs::TypeVisitor for Visitor<'_> {
285            fn visit<T>(&mut self)
286            where
287                T: ts_rs::TS + 'static + ?Sized,
288            {
289                self.0.collect_unsized::<T>();
290            }
291        }
292
293        T::visit_dependencies(&mut Visitor(self));
294        T::visit_generics(&mut Visitor(self));
295        self.insert_declaration_unsized::<T>();
296    }
297
298    fn insert_declaration<T>(&mut self)
299    where
300        T: ts_rs::TS + 'static,
301    {
302        self.insert_declaration_unsized::<T>();
303    }
304
305    fn insert_declaration_unsized<T>(&mut self)
306    where
307        T: ts_rs::TS + 'static + ?Sized,
308    {
309        if T::output_path().is_none() {
310            return;
311        }
312
313        let ident = T::ident(&self.cfg);
314        self.declarations
315            .entry(ident)
316            .or_insert_with(|| format!("export {}", T::decl(&self.cfg)));
317    }
318
319    fn collect_schema_declarations<T>(&mut self)
320    where
321        T: utoipa::ToSchema,
322    {
323        let mut schemas = Vec::new();
324        T::schemas(&mut schemas);
325
326        for (name, schema) in schemas {
327            self.declarations
328                .entry(name.clone())
329                .or_insert_with(|| render_schema_declaration(&name, &schema));
330        }
331    }
332}
333
334impl Default for TypeCollector {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340/// Export all endpoints registered with [`path`] to a TypeScript file using a path
341pub fn export_all(path: impl AsRef<Path>) -> std::io::Result<()> {
342    let path = resolve_export_path(path);
343    let mut collector = TypeCollector::new();
344    let mut endpoints = inventory::iter::<Endpoint>
345        .into_iter()
346        .map(|endpoint| (endpoint.render)(&mut collector))
347        .collect::<Vec<_>>();
348
349    endpoints.sort_by_key(|endpoint| (endpoint.method, endpoint.path, endpoint.name));
350
351    if let Some(parent) = path
352        .parent()
353        .filter(|parent| !parent.as_os_str().is_empty())
354    {
355        std::fs::create_dir_all(parent)?;
356    }
357
358    std::fs::write(path, render_file(&collector.declarations, &endpoints))
359}
360
361/// Export all endpoints registered with [`path`] to a TypeScript file using defaults
362pub fn export_all_default() -> std::io::Result<()> {
363    export_all_from_env_or_path(None::<&Path>)
364}
365
366/// Export all endpoints registered with [`path`] to a TypeScript file using a path with fallbacks
367pub fn export_all_from_env_or_path(path: Option<impl AsRef<Path>>) -> std::io::Result<()> {
368    let path = std::env::var_os(EXPORT_PATH_ENV)
369        .map(PathBuf::from)
370        .or_else(|| path.map(|path| path.as_ref().to_path_buf()))
371        .unwrap_or_else(|| PathBuf::from(DEFAULT_EXPORT_PATH));
372    export_all(path)
373}
374
375fn resolve_export_path(path: impl AsRef<Path>) -> PathBuf {
376    let path = path.as_ref();
377
378    if path.is_dir() || path.extension().is_none() {
379        path.join(DEFAULT_EXPORT_FILE_NAME)
380    } else {
381        path.to_path_buf()
382    }
383}
384
385/// Export TypeScript API definitions for all collected endpoints to a file
386#[macro_export]
387macro_rules! export {
388    () => {
389        #[test]
390        fn export_api() -> ::std::io::Result<()> {
391            $crate::export_all_default()
392        }
393    };
394
395    ($path:expr $(,)?) => {
396        #[test]
397        fn export_api() -> ::std::io::Result<()> {
398            $crate::export_all_from_env_or_path(Some($path))
399        }
400    };
401}
402
403fn render_file(declarations: &BTreeMap<String, String>, endpoints: &[EndpointSpec]) -> String {
404    let mut out = String::new();
405    out.push_str(NOTE);
406
407    for declaration in declarations.values() {
408        out.push('\n');
409        out.push_str(declaration);
410        out.push('\n');
411    }
412
413    out.push_str("\nexport type Api = {\n");
414
415    for endpoint in endpoints {
416        let _ = writeln!(
417            out,
418            "  \"{} {}\": {{",
419            endpoint.method,
420            endpoint.path.replace('"', "\\\"")
421        );
422
423        if !&endpoint.params.is_empty() {
424            write_fields_object(&mut out, "params", &endpoint.params, 4);
425        }
426
427        if let Some(body) = &endpoint.request_body {
428            let _ = writeln!(out, "    body: {body};");
429        }
430
431        out.push_str("    responses: {\n");
432        for response in &endpoint.responses {
433            let body = response.body.as_deref().unwrap_or("never");
434            let _ = writeln!(out, "      {}: {};", ts_key(response.status), body);
435        }
436        out.push_str("    };\n");
437
438        out.push_str("  };\n");
439    }
440
441    out.push_str("};\n");
442    out
443}
444
445fn write_fields_object(out: &mut String, name: &str, fields: &[FieldSpec], indent: usize) {
446    let padding = " ".repeat(indent);
447    if fields.is_empty() {
448        let _ = writeln!(out, "{padding}{name}: Record<string, never>;");
449        return;
450    }
451
452    let _ = writeln!(out, "{padding}{name}: {{");
453    for field in fields {
454        let optional = if field.required { "" } else { "?" };
455        let _ = writeln!(
456            out,
457            "{padding}  {}{}: {};",
458            ts_key(&field.name),
459            optional,
460            field.ty
461        );
462    }
463    let _ = writeln!(out, "{padding}}};");
464}
465
466fn schema_ref_to_ts(schema: &utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>) -> String {
467    match schema {
468        utoipa::openapi::RefOr::T(schema) => schema_to_ts(schema),
469        utoipa::openapi::RefOr::Ref(reference) => reference
470            .ref_location
471            .rsplit('/')
472            .next()
473            .filter(|name| !name.is_empty())
474            .unwrap_or("unknown")
475            .to_owned(),
476    }
477}
478
479fn schema_ref_is_nullable(
480    schema: &utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
481) -> bool {
482    match schema {
483        utoipa::openapi::RefOr::T(schema) => schema_is_nullable(schema),
484        utoipa::openapi::RefOr::Ref(_) => false,
485    }
486}
487
488fn schema_ref_has_default(
489    schema: &utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
490) -> bool {
491    match schema {
492        utoipa::openapi::RefOr::T(schema) => schema_has_default(schema),
493        utoipa::openapi::RefOr::Ref(_) => false,
494    }
495}
496
497fn schema_is_nullable(schema: &utoipa::openapi::schema::Schema) -> bool {
498    match schema {
499        utoipa::openapi::schema::Schema::Object(object) => {
500            schema_type_is_nullable(&object.schema_type)
501        }
502        utoipa::openapi::schema::Schema::OneOf(one_of) => {
503            one_of.items.iter().any(schema_ref_is_nullable)
504        }
505        utoipa::openapi::schema::Schema::AllOf(all_of) => {
506            all_of.items.iter().any(schema_ref_is_nullable)
507        }
508        utoipa::openapi::schema::Schema::AnyOf(any_of) => {
509            any_of.items.iter().any(schema_ref_is_nullable)
510        }
511        _ => false,
512    }
513}
514
515fn schema_has_default(schema: &utoipa::openapi::schema::Schema) -> bool {
516    match schema {
517        utoipa::openapi::schema::Schema::Object(object) => object.default.is_some(),
518        utoipa::openapi::schema::Schema::Array(array) => array.default.is_some(),
519        utoipa::openapi::schema::Schema::OneOf(one_of) => one_of.default.is_some(),
520        utoipa::openapi::schema::Schema::AllOf(all_of) => all_of.default.is_some(),
521        utoipa::openapi::schema::Schema::AnyOf(any_of) => any_of.default.is_some(),
522        _ => false,
523    }
524}
525
526fn schema_type_is_nullable(schema_type: &utoipa::openapi::schema::SchemaType) -> bool {
527    match schema_type {
528        utoipa::openapi::schema::SchemaType::Type(utoipa::openapi::schema::Type::Null) => true,
529        utoipa::openapi::schema::SchemaType::Array(types) => {
530            types.contains(&utoipa::openapi::schema::Type::Null)
531        }
532        _ => false,
533    }
534}
535
536fn schema_to_ts(schema: &utoipa::openapi::schema::Schema) -> String {
537    match schema {
538        utoipa::openapi::schema::Schema::Object(object) => object_to_ts(object),
539        utoipa::openapi::schema::Schema::Array(array) => match &array.items {
540            utoipa::openapi::schema::ArrayItems::RefOrSchema(item) => {
541                format!("{}[]", schema_ref_to_ts(item))
542            }
543            utoipa::openapi::schema::ArrayItems::False => "never[]".to_owned(),
544        },
545        utoipa::openapi::schema::Schema::OneOf(one_of) => one_of
546            .items
547            .iter()
548            .map(schema_ref_to_ts)
549            .collect::<Vec<_>>()
550            .join(" | "),
551        utoipa::openapi::schema::Schema::AllOf(all_of) => all_of
552            .items
553            .iter()
554            .map(schema_ref_to_ts)
555            .collect::<Vec<_>>()
556            .join(" & "),
557        utoipa::openapi::schema::Schema::AnyOf(any_of) => any_of
558            .items
559            .iter()
560            .map(schema_ref_to_ts)
561            .collect::<Vec<_>>()
562            .join(" | "),
563        _ => "unknown".to_owned(),
564    }
565}
566
567fn render_schema_declaration(
568    name: &str,
569    schema: &utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
570) -> String {
571    format!(
572        "export type {} = {};",
573        ts_key(name),
574        schema_ref_to_ts(schema)
575    )
576}
577
578fn object_to_ts(object: &utoipa::openapi::schema::Object) -> String {
579    if let Some(enum_values) = &object.enum_values {
580        if enum_values.is_empty() {
581            return "never".to_owned();
582        }
583
584        return enum_values
585            .iter()
586            .map(ToString::to_string)
587            .collect::<Vec<_>>()
588            .join(" | ");
589    }
590
591    if !object.properties.is_empty() {
592        let fields = object
593            .properties
594            .iter()
595            .map(|(name, schema)| FieldSpec {
596                name: name.clone(),
597                ty: schema_ref_to_ts(schema),
598                required: object.required.contains(name),
599            })
600            .collect::<Vec<_>>();
601        let mut out = String::new();
602        out.push_str("{\n");
603
604        for field in fields {
605            let optional = if field.required { "" } else { "?" };
606            let _ = writeln!(out, "  {}{}: {};", ts_key(&field.name), optional, field.ty);
607        }
608
609        out.push('}');
610        return out;
611    }
612
613    schema_type_to_ts(&object.schema_type)
614}
615
616fn schema_type_to_ts(schema_type: &utoipa::openapi::schema::SchemaType) -> String {
617    match schema_type {
618        utoipa::openapi::schema::SchemaType::Type(ty) => primitive_type_to_ts(ty).to_owned(),
619        utoipa::openapi::schema::SchemaType::Array(types) => types
620            .iter()
621            .filter(|ty| **ty != utoipa::openapi::schema::Type::Null)
622            .map(primitive_type_to_ts)
623            .collect::<Vec<_>>()
624            .join(" | "),
625        utoipa::openapi::schema::SchemaType::AnyValue => "unknown".to_owned(),
626    }
627}
628
629fn primitive_type_to_ts(ty: &utoipa::openapi::schema::Type) -> &'static str {
630    match ty {
631        utoipa::openapi::schema::Type::Object => "Record<string, unknown>",
632        utoipa::openapi::schema::Type::String => "string",
633        utoipa::openapi::schema::Type::Integer | utoipa::openapi::schema::Type::Number => "number",
634        utoipa::openapi::schema::Type::Boolean => "boolean",
635        utoipa::openapi::schema::Type::Array => "unknown[]",
636        utoipa::openapi::schema::Type::Null => "null",
637    }
638}
639
640fn ts_key(key: &str) -> String {
641    if key
642        .chars()
643        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
644    {
645        key.to_owned()
646    } else {
647        format!("\"{}\"", key.replace('"', "\\\""))
648    }
649}