Skip to main content

ploidy_codegen_rust/
inlines.rs

1use itertools::Itertools;
2use ploidy_core::ir::{InlineTypeView, OperationView, SchemaTypeView, View};
3use proc_macro2::TokenStream;
4use quote::{ToTokens, TokenStreamExt, quote};
5
6use super::{
7    cfg::CfgFeature,
8    enum_::CodegenEnum,
9    naming::{CodegenTypeName, CodegenTypeNameSortKey},
10    struct_::CodegenStruct,
11    tagged::CodegenTagged,
12    untagged::CodegenUntagged,
13};
14
15/// Generates an inline `mod types`, with definitions for all the inline types
16/// that are reachable from a resource or schema type.
17///
18/// Inline types nested _within_ referenced schemas are excluded; those are
19/// emitted by [`CodegenSchemaType`](crate::CodegenSchemaType) instead.
20#[derive(Clone, Copy, Debug)]
21pub enum CodegenInlines<'a> {
22    Resource(&'a [OperationView<'a>]),
23    Schema(&'a SchemaTypeView<'a>),
24}
25
26impl ToTokens for CodegenInlines<'_> {
27    fn to_tokens(&self, tokens: &mut TokenStream) {
28        match self {
29            Self::Resource(ops) => {
30                let items = CodegenInlineItems(IncludeCfgFeatures::Include, ops);
31                items.to_tokens(tokens);
32            }
33            &Self::Schema(ty) => {
34                let items = CodegenInlineItems(IncludeCfgFeatures::Omit, std::slice::from_ref(ty));
35                items.to_tokens(tokens);
36            }
37        }
38    }
39}
40
41#[derive(Debug)]
42struct CodegenInlineItems<'a, V>(IncludeCfgFeatures, &'a [V]);
43
44impl<'a, V> ToTokens for CodegenInlineItems<'a, V>
45where
46    V: View<'a>,
47{
48    fn to_tokens(&self, tokens: &mut TokenStream) {
49        let mut inlines = self.1.iter().flat_map(|op| op.inlines()).collect_vec();
50        inlines.sort_by(|a, b| {
51            CodegenTypeNameSortKey::for_inline(a).cmp(&CodegenTypeNameSortKey::for_inline(b))
52        });
53
54        let mut items = inlines.into_iter().filter_map(|view| {
55            let name = CodegenTypeName::Inline(&view);
56            let ty = match &view {
57                InlineTypeView::Enum(_, view) => CodegenEnum::new(name, view).into_token_stream(),
58                InlineTypeView::Struct(_, view) => {
59                    CodegenStruct::new(name, view).into_token_stream()
60                }
61                InlineTypeView::Tagged(_, view) => {
62                    CodegenTagged::new(name, view).into_token_stream()
63                }
64                InlineTypeView::Untagged(_, view) => {
65                    CodegenUntagged::new(name, view).into_token_stream()
66                }
67                InlineTypeView::Container(..)
68                | InlineTypeView::Primitive(..)
69                | InlineTypeView::Any(..) => {
70                    // Container types, primitive types, and untyped values
71                    // are emitted directly; they don't need type aliases.
72                    return None;
73                }
74            };
75            Some(match self.0 {
76                IncludeCfgFeatures::Include => {
77                    // Wrap each type in an inner inline module, so that
78                    // the `#[cfg(...)]` applies to all items (types and `impl`s).
79                    let cfg = CfgFeature::for_inline_type(&view);
80                    let mod_name = name.into_module_name();
81                    quote! {
82                        #cfg
83                        mod #mod_name {
84                            #ty
85                        }
86                        #cfg
87                        pub use #mod_name::*;
88                    }
89                }
90                IncludeCfgFeatures::Omit => ty,
91            })
92        });
93
94        if let Some(first) = items.next() {
95            tokens.append_all(quote! {
96                pub mod types {
97                    #first
98                    #(#items)*
99                }
100            });
101        }
102    }
103}
104
105#[derive(Clone, Copy, Debug)]
106enum IncludeCfgFeatures {
107    Include,
108    Omit,
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    use itertools::Itertools;
116    use ploidy_core::{
117        arena::Arena,
118        ir::{RawGraph, Spec},
119        parse::Document,
120    };
121    use pretty_assertions::assert_eq;
122    use syn::parse_quote;
123
124    use crate::graph::CodegenGraph;
125
126    #[test]
127    fn test_includes_inline_types_from_operation_parameters() {
128        let doc = Document::from_yaml(indoc::indoc! {"
129            openapi: 3.0.0
130            info:
131              title: Test API
132              version: 1.0.0
133            paths:
134              /items:
135                get:
136                  operationId: getItems
137                  parameters:
138                    - name: filter
139                      in: query
140                      schema:
141                        type: object
142                        properties:
143                          status:
144                            type: string
145                  responses:
146                    '200':
147                      description: OK
148        "})
149        .unwrap();
150
151        let arena = Arena::new();
152        let spec = Spec::from_doc(&arena, &doc).unwrap();
153        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
154
155        let ops = graph.operations().collect_vec();
156        let inlines = CodegenInlines::Resource(&ops);
157
158        let actual: syn::File = parse_quote!(#inlines);
159        let expected: syn::File = parse_quote! {
160            pub mod types {
161                mod get_items_filter {
162                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
163                    #[serde(crate = "::ploidy_util::serde")]
164                    #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
165                    pub struct GetItemsFilter {
166                        #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
167                        pub status: ::ploidy_util::absent::AbsentOr<::std::string::String>,
168                    }
169                }
170                pub use get_items_filter::*;
171            }
172        };
173        assert_eq!(actual, expected);
174    }
175
176    #[test]
177    fn test_excludes_inline_types_from_referenced_schemas() {
178        // The operation references `Item`, which has an inline type `Details`.
179        // `Details` should _not_ be emitted by `CodegenInlines`; it belongs in
180        // the schema's module instead.
181        let doc = Document::from_yaml(indoc::indoc! {"
182            openapi: 3.0.0
183            info:
184              title: Test API
185              version: 1.0.0
186            paths:
187              /items:
188                get:
189                  operationId: getItems
190                  responses:
191                    '200':
192                      description: OK
193                      content:
194                        application/json:
195                          schema:
196                            $ref: '#/components/schemas/Item'
197            components:
198              schemas:
199                Item:
200                  type: object
201                  properties:
202                    details:
203                      type: object
204                      properties:
205                        description:
206                          type: string
207        "})
208        .unwrap();
209
210        let arena = Arena::new();
211        let spec = Spec::from_doc(&arena, &doc).unwrap();
212        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
213
214        let ops = graph.operations().collect_vec();
215        let inlines = CodegenInlines::Resource(&ops);
216
217        // No inline types should be emitted, since the only inline (`Details`)
218        // belongs to the referenced schema.
219        let actual: syn::File = parse_quote!(#inlines);
220        let expected: syn::File = parse_quote! {};
221        assert_eq!(actual, expected);
222    }
223
224    #[test]
225    fn test_sorts_inline_types_alphabetically() {
226        // Parameters defined in reverse order: zebra, mango, apple.
227        let doc = Document::from_yaml(indoc::indoc! {"
228            openapi: 3.0.0
229            info:
230              title: Test API
231              version: 1.0.0
232            paths:
233              /items:
234                get:
235                  operationId: getItems
236                  parameters:
237                    - name: zebra
238                      in: query
239                      schema:
240                        type: object
241                        properties:
242                          value:
243                            type: string
244                    - name: mango
245                      in: query
246                      schema:
247                        type: object
248                        properties:
249                          value:
250                            type: string
251                    - name: apple
252                      in: query
253                      schema:
254                        type: object
255                        properties:
256                          value:
257                            type: string
258                  responses:
259                    '200':
260                      description: OK
261        "})
262        .unwrap();
263
264        let arena = Arena::new();
265        let spec = Spec::from_doc(&arena, &doc).unwrap();
266        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
267
268        let ops = graph.operations().collect_vec();
269        let inlines = CodegenInlines::Resource(&ops);
270
271        let actual: syn::File = parse_quote!(#inlines);
272        // Types should be sorted: Apple, Mango, Zebra.
273        let expected: syn::File = parse_quote! {
274            pub mod types {
275                mod get_items_apple {
276                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
277                    #[serde(crate = "::ploidy_util::serde")]
278                    #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
279                    pub struct GetItemsApple {
280                        #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
281                        pub value: ::ploidy_util::absent::AbsentOr<::std::string::String>,
282                    }
283                }
284                pub use get_items_apple::*;
285                mod get_items_mango {
286                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
287                    #[serde(crate = "::ploidy_util::serde")]
288                    #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
289                    pub struct GetItemsMango {
290                        #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
291                        pub value: ::ploidy_util::absent::AbsentOr<::std::string::String>,
292                    }
293                }
294                pub use get_items_mango::*;
295                mod get_items_zebra {
296                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
297                    #[serde(crate = "::ploidy_util::serde")]
298                    #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
299                    pub struct GetItemsZebra {
300                        #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
301                        pub value: ::ploidy_util::absent::AbsentOr<::std::string::String>,
302                    }
303                }
304                pub use get_items_zebra::*;
305            }
306        };
307        assert_eq!(actual, expected);
308    }
309
310    #[test]
311    fn test_no_output_when_no_inline_types() {
312        let doc = Document::from_yaml(indoc::indoc! {"
313            openapi: 3.0.0
314            info:
315              title: Test API
316              version: 1.0.0
317            paths:
318              /items:
319                get:
320                  operationId: getItems
321                  parameters:
322                    - name: limit
323                      in: query
324                      schema:
325                        type: integer
326                  responses:
327                    '200':
328                      description: OK
329        "})
330        .unwrap();
331
332        let arena = Arena::new();
333        let spec = Spec::from_doc(&arena, &doc).unwrap();
334        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
335
336        let ops = graph.operations().collect_vec();
337        let inlines = CodegenInlines::Resource(&ops);
338
339        let actual: syn::File = parse_quote!(#inlines);
340        let expected: syn::File = parse_quote! {};
341        assert_eq!(actual, expected);
342    }
343
344    #[test]
345    fn test_finds_inline_types_within_optionals() {
346        let doc = Document::from_yaml(indoc::indoc! {"
347            openapi: 3.0.0
348            info:
349              title: Test API
350              version: 1.0.0
351            paths:
352              /items:
353                get:
354                  operationId: getItems
355                  parameters:
356                    - name: config
357                      in: query
358                      schema:
359                        nullable: true
360                        type: object
361                        properties:
362                          enabled:
363                            type: boolean
364                  responses:
365                    '200':
366                      description: OK
367        "})
368        .unwrap();
369
370        let arena = Arena::new();
371        let spec = Spec::from_doc(&arena, &doc).unwrap();
372        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
373
374        let ops = graph.operations().collect_vec();
375        let inlines = CodegenInlines::Resource(&ops);
376
377        let actual: syn::File = parse_quote!(#inlines);
378        let expected: syn::File = parse_quote! {
379            pub mod types {
380                mod get_items_config {
381                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
382                    #[serde(crate = "::ploidy_util::serde")]
383                    #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
384                    pub struct GetItemsConfig {
385                        #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
386                        pub enabled: ::ploidy_util::absent::AbsentOr<bool>,
387                    }
388                }
389                pub use get_items_config::*;
390            }
391        };
392        assert_eq!(actual, expected);
393    }
394
395    #[test]
396    fn test_finds_inline_types_within_arrays() {
397        let doc = Document::from_yaml(indoc::indoc! {"
398            openapi: 3.0.0
399            info:
400              title: Test API
401              version: 1.0.0
402            paths:
403              /items:
404                get:
405                  operationId: getItems
406                  parameters:
407                    - name: filters
408                      in: query
409                      schema:
410                        type: array
411                        items:
412                          type: object
413                          properties:
414                            field:
415                              type: string
416                  responses:
417                    '200':
418                      description: OK
419        "})
420        .unwrap();
421
422        let arena = Arena::new();
423        let spec = Spec::from_doc(&arena, &doc).unwrap();
424        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
425
426        let ops = graph.operations().collect_vec();
427        let inlines = CodegenInlines::Resource(&ops);
428
429        let actual: syn::File = parse_quote!(#inlines);
430        let expected: syn::File = parse_quote! {
431            pub mod types {
432                mod get_items_filters_item {
433                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
434                    #[serde(crate = "::ploidy_util::serde")]
435                    #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
436                    pub struct GetItemsFiltersItem {
437                        #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
438                        pub field: ::ploidy_util::absent::AbsentOr<::std::string::String>,
439                    }
440                }
441                pub use get_items_filters_item::*;
442            }
443        };
444        assert_eq!(actual, expected);
445    }
446
447    #[test]
448    fn test_finds_inline_types_within_maps() {
449        let doc = Document::from_yaml(indoc::indoc! {"
450            openapi: 3.0.0
451            info:
452              title: Test API
453              version: 1.0.0
454            paths:
455              /items:
456                get:
457                  operationId: getItems
458                  parameters:
459                    - name: metadata
460                      in: query
461                      schema:
462                        type: object
463                        additionalProperties:
464                          type: object
465                          properties:
466                            value:
467                              type: string
468                  responses:
469                    '200':
470                      description: OK
471        "})
472        .unwrap();
473
474        let arena = Arena::new();
475        let spec = Spec::from_doc(&arena, &doc).unwrap();
476        let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
477
478        let ops = graph.operations().collect_vec();
479        let inlines = CodegenInlines::Resource(&ops);
480
481        let actual: syn::File = parse_quote!(#inlines);
482        let expected: syn::File = parse_quote! {
483            pub mod types {
484                mod get_items_metadata_value {
485                    #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, ::ploidy_util::serde::Serialize, ::ploidy_util::serde::Deserialize, ::ploidy_util::pointer::JsonPointee, ::ploidy_util::pointer::JsonPointerTarget)]
486                    #[serde(crate = "::ploidy_util::serde")]
487                    #[ploidy(pointer(crate = "::ploidy_util::pointer"))]
488                    pub struct GetItemsMetadataValue {
489                        #[serde(default, skip_serializing_if = "::ploidy_util::absent::AbsentOr::is_absent")]
490                        pub value: ::ploidy_util::absent::AbsentOr<::std::string::String>,
491                    }
492                }
493                pub use get_items_metadata_value::*;
494            }
495        };
496        assert_eq!(actual, expected);
497    }
498}