Skip to main content

ploidy_codegen_rust/
schema.rs

1use ploidy_core::codegen::IntoCode;
2use ploidy_core::ir::{ContainerView, SchemaTypeView};
3use proc_macro2::TokenStream;
4use quote::{ToTokens, TokenStreamExt, quote};
5
6use super::{
7    doc_attrs, enum_::CodegenEnum, inlines::CodegenInlines, naming::CodegenTypeName,
8    primitive::CodegenPrimitive, ref_::CodegenRef, struct_::CodegenStruct, tagged::CodegenTagged,
9    untagged::CodegenUntagged,
10};
11
12/// Generates a module for a named schema type.
13#[derive(Debug)]
14pub struct CodegenSchemaType<'a> {
15    ty: &'a SchemaTypeView<'a>,
16}
17
18impl<'a> CodegenSchemaType<'a> {
19    pub fn new(ty: &'a SchemaTypeView<'a>) -> Self {
20        Self { ty }
21    }
22}
23
24impl ToTokens for CodegenSchemaType<'_> {
25    fn to_tokens(&self, tokens: &mut TokenStream) {
26        let name = CodegenTypeName::Schema(self.ty);
27        let ty = match self.ty {
28            SchemaTypeView::Struct(_, view) => CodegenStruct::new(name, view).into_token_stream(),
29            SchemaTypeView::Enum(_, view) => CodegenEnum::new(name, view).into_token_stream(),
30            SchemaTypeView::Tagged(_, view) => CodegenTagged::new(name, view).into_token_stream(),
31            SchemaTypeView::Untagged(_, view) => {
32                CodegenUntagged::new(name, view).into_token_stream()
33            }
34            SchemaTypeView::Container(_, ContainerView::Array(inner)) => {
35                let doc_attrs = inner.description().map(doc_attrs);
36                let inner_ty = inner.ty();
37                let inner_ref = CodegenRef::new(&inner_ty);
38                quote! {
39                    #doc_attrs
40                    pub type #name = ::std::vec::Vec<#inner_ref>;
41                }
42            }
43            SchemaTypeView::Container(_, ContainerView::Map(inner)) => {
44                let doc_attrs = inner.description().map(doc_attrs);
45                let inner_ty = inner.ty();
46                let inner_ref = CodegenRef::new(&inner_ty);
47                quote! {
48                    #doc_attrs
49                    pub type #name = ::std::collections::BTreeMap<::std::string::String, #inner_ref>;
50                }
51            }
52            SchemaTypeView::Container(_, ContainerView::Optional(inner)) => {
53                let doc_attrs = inner.description().map(doc_attrs);
54                let inner_ty = inner.ty();
55                let inner_ref = CodegenRef::new(&inner_ty);
56                quote! {
57                    #doc_attrs
58                    pub type #name = ::std::option::Option<#inner_ref>;
59                }
60            }
61            SchemaTypeView::Primitive(_, view) => {
62                let primitive = CodegenPrimitive::new(view);
63                quote! {
64                    pub type #name = #primitive;
65                }
66            }
67            SchemaTypeView::Any(_, _) => {
68                quote! {
69                    pub type #name = ::ploidy_util::serde_json::Value;
70                }
71            }
72        };
73        let inlines = CodegenInlines::Schema(self.ty);
74        tokens.append_all(quote! {
75            #ty
76            #inlines
77        });
78    }
79}
80
81impl IntoCode for CodegenSchemaType<'_> {
82    type Code = (String, TokenStream);
83
84    fn into_code(self) -> Self::Code {
85        let name = CodegenTypeName::Schema(self.ty);
86        (
87            format!("src/types/{}.rs", name.into_module_name().display()),
88            self.into_token_stream(),
89        )
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    use ploidy_core::{
98        arena::Arena,
99        ir::{RawGraph, SchemaTypeView, Spec},
100        parse::Document,
101    };
102    use pretty_assertions::assert_eq;
103    use syn::parse_quote;
104
105    use crate::CodegenGraph;
106
107    #[test]
108    fn test_schema_inline_types_order() {
109        // Inline types are defined in reverse alphabetical order (Zebra, Mango, Apple),
110        // to verify that they're sorted in the output.
111        let doc = Document::from_yaml(indoc::indoc! {"
112            openapi: 3.0.0
113            info:
114              title: Test API
115              version: 1.0.0
116            paths: {}
117            components:
118              schemas:
119                Container:
120                  type: object
121                  properties:
122                    zebra:
123                      type: object
124                      properties:
125                        name:
126                          type: string
127                    mango:
128                      type: object
129                      properties:
130                        name:
131                          type: string
132                    apple:
133                      type: object
134                      properties:
135                        name:
136                          type: string
137        "})
138        .unwrap();
139
140        let arena = Arena::new();
141        let spec = Spec::from_doc(&arena, &doc).unwrap();
142        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
143
144        let schema = graph.schemas().find(|s| s.name() == "Container");
145        let Some(schema @ SchemaTypeView::Struct(_, _)) = &schema else {
146            panic!("expected struct `Container`; got `{schema:?}`");
147        };
148
149        let codegen = CodegenSchemaType::new(schema);
150
151        let actual: syn::File = parse_quote!(#codegen);
152        // The struct fields remain in their original order (`zebra`, `mango`, `apple`),
153        // but the inline types in `mod types` should be sorted alphabetically
154        // (`Apple`, `Mango`, `Zebra`).
155        let expected: syn::File = parse_quote! {
156            #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
157            #[serde(crate = "::ploidy_util::serde")]
158            #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
159            pub struct Container {
160                #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
161                pub zebra: ::ploidy_util::absent::AbsentOr<crate::types::container::types::Zebra>,
162                #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
163                pub mango: ::ploidy_util::absent::AbsentOr<crate::types::container::types::Mango>,
164                #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
165                pub apple: ::ploidy_util::absent::AbsentOr<crate::types::container::types::Apple>,
166            }
167            pub mod types {
168                #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
169                #[serde(crate = "::ploidy_util::serde")]
170                #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
171                pub struct Apple {
172                    #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
173                    pub name: ::ploidy_util::absent::AbsentOr<::std::string::String>,
174                }
175                #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
176                #[serde(crate = "::ploidy_util::serde")]
177                #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
178                pub struct Mango {
179                    #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
180                    pub name: ::ploidy_util::absent::AbsentOr<::std::string::String>,
181                }
182                #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
183                #[serde(crate = "::ploidy_util::serde")]
184                #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
185                pub struct Zebra {
186                    #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
187                    pub name: ::ploidy_util::absent::AbsentOr<::std::string::String>,
188                }
189            }
190        };
191        assert_eq!(actual, expected);
192    }
193
194    #[test]
195    fn test_container_schema_emits_type_alias_with_inline_types() {
196        // A named array of inline structs should emit a type alias for the array,
197        // and a `mod types` with the inline type (linabutler/ploidy#30).
198        let doc = Document::from_yaml(indoc::indoc! {"
199            openapi: 3.0.0
200            info:
201              title: Test API
202              version: 1.0.0
203            paths: {}
204            components:
205              schemas:
206                InvalidParameters:
207                  type: array
208                  items:
209                    type: object
210                    required:
211                      - name
212                      - reason
213                    properties:
214                      name:
215                        type: string
216                      reason:
217                        type: string
218        "})
219        .unwrap();
220
221        let arena = Arena::new();
222        let spec = Spec::from_doc(&arena, &doc).unwrap();
223        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
224
225        let schema = graph.schemas().find(|s| s.name() == "InvalidParameters");
226        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
227            panic!("expected container `InvalidParameters`; got `{schema:?}`");
228        };
229
230        let codegen = CodegenSchemaType::new(schema);
231
232        let actual: syn::File = parse_quote!(#codegen);
233        let expected: syn::File = parse_quote! {
234            pub type InvalidParameters = ::std::vec::Vec<crate::types::invalid_parameters::types::Item>;
235            pub mod types {
236                #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
237                #[serde(crate = "::ploidy_util::serde")]
238                #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
239                pub struct Item {
240                    pub name: ::std::string::String,
241                    pub reason: ::std::string::String,
242                }
243            }
244        };
245        assert_eq!(actual, expected);
246    }
247
248    #[test]
249    fn test_container_schema_emits_type_alias_without_inline_types() {
250        // A named array of primitives should emit a type alias, and no `mod types`.
251        let doc = Document::from_yaml(indoc::indoc! {"
252            openapi: 3.0.0
253            info:
254              title: Test API
255              version: 1.0.0
256            paths: {}
257            components:
258              schemas:
259                Tags:
260                  type: array
261                  items:
262                    type: string
263        "})
264        .unwrap();
265
266        let arena = Arena::new();
267        let spec = Spec::from_doc(&arena, &doc).unwrap();
268        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
269
270        let schema = graph.schemas().find(|s| s.name() == "Tags");
271        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
272            panic!("expected container `Tags`; got `{schema:?}`");
273        };
274
275        let codegen = CodegenSchemaType::new(schema);
276
277        let actual: syn::File = parse_quote!(#codegen);
278        let expected: syn::File = parse_quote! {
279            pub type Tags = ::std::vec::Vec<::std::string::String>;
280        };
281        assert_eq!(actual, expected);
282    }
283
284    #[test]
285    fn test_container_schema_map_emits_type_alias() {
286        let doc = Document::from_yaml(indoc::indoc! {"
287            openapi: 3.0.0
288            info:
289              title: Test API
290              version: 1.0.0
291            paths: {}
292            components:
293              schemas:
294                Metadata:
295                  type: object
296                  additionalProperties:
297                    type: string
298        "})
299        .unwrap();
300
301        let arena = Arena::new();
302        let spec = Spec::from_doc(&arena, &doc).unwrap();
303        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
304
305        let schema = graph.schemas().find(|s| s.name() == "Metadata");
306        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
307            panic!("expected container `Metadata`; got `{schema:?}`");
308        };
309
310        let codegen = CodegenSchemaType::new(schema);
311
312        let actual: syn::File = parse_quote!(#codegen);
313        let expected: syn::File = parse_quote! {
314            pub type Metadata = ::std::collections::BTreeMap<::std::string::String, ::std::string::String>;
315        };
316        assert_eq!(actual, expected);
317    }
318
319    #[test]
320    fn test_container_nullable_schema() {
321        let doc = Document::from_yaml(indoc::indoc! {"
322            openapi: 3.1.0
323            info:
324              title: Test API
325              version: 1.0.0
326            paths: {}
327            components:
328              schemas:
329                NullableString:
330                  type: [string, 'null']
331                NullableArray:
332                  type: [array, 'null']
333                  items:
334                    type: string
335                NullableMap:
336                  type: [object, 'null']
337                  additionalProperties:
338                    type: string
339                NullableOneOf:
340                  oneOf:
341                    - type: object
342                      properties:
343                        value:
344                          type: string
345                    - type: 'null'
346        "})
347        .unwrap();
348
349        let arena = Arena::new();
350        let spec = Spec::from_doc(&arena, &doc).unwrap();
351        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
352
353        // `type: ["string", "null"]` becomes `Option<String>`.
354        let schema = graph.schemas().find(|s| s.name() == "NullableString");
355        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
356            panic!("expected container `NullableString`; got `{schema:?}`");
357        };
358        let codegen = CodegenSchemaType::new(schema);
359        let actual: syn::File = parse_quote!(#codegen);
360        let expected: syn::File = parse_quote! {
361            pub type NullableString = ::std::option::Option<::std::string::String>;
362        };
363        assert_eq!(actual, expected);
364
365        // `type: ["array", "null"]` becomes `Option<Vec<String>>`.
366        let schema = graph.schemas().find(|s| s.name() == "NullableArray");
367        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
368            panic!("expected container `NullableArray`; got `{schema:?}`");
369        };
370        let codegen = CodegenSchemaType::new(schema);
371        let actual: syn::File = parse_quote!(#codegen);
372        let expected: syn::File = parse_quote! {
373            pub type NullableArray = ::std::option::Option<::std::vec::Vec<::std::string::String>>;
374        };
375        assert_eq!(actual, expected);
376
377        // `type: ["object", "null"]` with `additionalProperties` becomes
378        // `Option<BTreeMap<String, String>>`.
379        let schema = graph.schemas().find(|s| s.name() == "NullableMap");
380        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
381            panic!("expected container `NullableMap`; got `{schema:?}`");
382        };
383        let codegen = CodegenSchemaType::new(schema);
384        let actual: syn::File = parse_quote!(#codegen);
385        let expected: syn::File = parse_quote! {
386            pub type NullableMap = ::std::option::Option<::std::collections::BTreeMap<::std::string::String, ::std::string::String>>;
387        };
388        assert_eq!(actual, expected);
389
390        // `oneOf` with an inline schema and `null` becomes an `Option<InlineStruct>`,
391        // with the inline struct definition emitted in `mod types`.
392        let schema = graph.schemas().find(|s| s.name() == "NullableOneOf");
393        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
394            panic!("expected container `NullableOneOf`; got `{schema:?}`");
395        };
396        let codegen = CodegenSchemaType::new(schema);
397        let actual: syn::File = parse_quote!(#codegen);
398        let expected: syn::File = parse_quote! {
399            pub type NullableOneOf = ::std::option::Option<crate::types::nullable_one_of::types::V1>;
400            pub mod types {
401                #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
402                #[serde(crate = "::ploidy_util::serde")]
403                #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
404                pub struct V1 {
405                    #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
406                    pub value: ::ploidy_util::absent::AbsentOr<::std::string::String>,
407                }
408            }
409        };
410        assert_eq!(actual, expected);
411    }
412
413    #[test]
414    fn test_container_schema_preserves_description() {
415        let doc = Document::from_yaml(indoc::indoc! {"
416            openapi: 3.0.0
417            info:
418              title: Test API
419              version: 1.0.0
420            paths: {}
421            components:
422              schemas:
423                Tags:
424                  description: A list of tags.
425                  type: array
426                  items:
427                    type: string
428        "})
429        .unwrap();
430
431        let arena = Arena::new();
432        let spec = Spec::from_doc(&arena, &doc).unwrap();
433        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
434
435        let schema = graph.schemas().find(|s| s.name() == "Tags");
436        let Some(schema @ SchemaTypeView::Container(_, _)) = &schema else {
437            panic!("expected container `Tags`; got `{schema:?}`");
438        };
439
440        let codegen = CodegenSchemaType::new(schema);
441
442        let actual: syn::File = parse_quote!(#codegen);
443        let expected: syn::File = parse_quote! {
444            #[doc = "A list of tags."]
445            pub type Tags = ::std::vec::Vec<::std::string::String>;
446        };
447        assert_eq!(actual, expected);
448    }
449}