1use 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";
90pub const EXPORT_PATH_ENV: &str = "UTOIPA_TS_PATH";
92pub 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
340pub 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
361pub fn export_all_default() -> std::io::Result<()> {
363 export_all_from_env_or_path(None::<&Path>)
364}
365
366pub 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#[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}