Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do not inline non-primitive type parameters by default #1184

Merged
merged 1 commit into from
Nov 14, 2024

Conversation

JMLX42
Copy link
Contributor

@JMLX42 JMLX42 commented Nov 4, 2024

Fixes #1182

  • Implement a more comprehensive inlining strategy for generic type parameters
  • Fix the tests
  • Update the CHANGELOG

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

@juhaku I attempted to fix the openapi_schemas_resolve_schema_references by adding #[schema(inline)] where the reference expects it to match with the current behavior on master. But I think the reference is wrong.

Here is the difference:

43c43
<                 "type": "object"
---
>                 "type": "string"
97a98,123
>                 "properties": {
>                   "accounts": {
>                     "items": {
>                       "oneOf": [
>                         {
>                           "type": "null"
>                         },
>                         {
>                           "$ref": "#/components/schemas/Account"
>                         }
>                       ]
>                     },
>                     "type": "array"
>                   },
>                   "foo_bar": {
>                     "$ref": "#/components/schemas/Foobar"
>                   },
>                   "name": {
>                     "type": "string"
>                   }
>                 },
>                 "required": [
>                   "name",
>                   "foo_bar",
>                   "accounts"
>                 ],

I think the reference is wrong:

  • Element_String.oneOf[1].properties.Many.items.type should be string (just like the One variant), but it is object in the reference.
  • Element_Yeah.oneOf[1].properties.Many.items.type should be the inlined schema of Yeah, but it is { "type": "object" } in the reference.

Should I fix the reference?

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

True it is indeed wrong, I definitely would appreciate if you want to take the initiative to enter to the rabbit hole of generics and fix the broken references as well. 😆 For now I have no idea as of yet that what is causing that misalignment.

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

But maybe we can just leave it as is. Like without the inline it will now create correct reference. And with inline it does inline the actual type. So I think this is probably something that we want and we can just settle the fact that for now on the generics are not inlined by default? 🤔

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

True it is indeed wrong, I definitely would appreciate if you want to take the initiative to enter to the rabbit hole of generics and fix the broken references as well. 😆 For now I have no idea as of yet that what is causing that misalignment.

@juhaku that's the neat part : my PR fixes it AFAIK. But I just wanted your approval before changing the reference JSON.

But maybe we can just leave it as is. Like without the inline it will now create correct reference. And with inline it does inline the actual type. So I think this is probably something that we want and we can just settle the fact that for now on the generics are not inlined by default?

My point exactly. The result with those changes is IMO better and fixes that broken test/reference. So if I just change the reference JSON:

  1. the expected result is correct
  2. the way inlining + generics works is IMHO more sound
  3. but generics are now using refs by default instead of inlining

But you can't have 2. without 3.

So if that's ok with you I will:

  • Fix the reference JSON for the broken test.
  • Update the CHANGELOG

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

Yes, that is ok for me, thanks for the work 🙂 👍

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

If I check only with schema.is_primitive(), I get the following failed test:

test derive_generic_schema_enum_variants ... FAILED

failures:

---- derive_generic_schema_enum_variants stdout ----
{
  "openapi": "3.1.0",
  "info": {
    "title": "title",
    "version": "version"
  },
  "paths": {},
  "components": {
    "schemas": {
      "BTreeMap": {
        "type": "object",
        "additionalProperties": {
          "type": "string"
        },
        "propertyNames": {
          "type": "string"
        }
      },
      "BTreeSet": {
        "type": "array",
        "items": {
          "type": "integer",
          "format": "int32"
        },
        "uniqueItems": true
      },
      "FooStruct_Option_i32": {
        "type": "object",
        "required": [
          "foo"
        ],
        "properties": {
          "foo": {
            "$ref": "#/components/schemas/Option"
          }
        }
      },
      "FoosEnum": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "ThingNoAliasOption"
            ],
            "properties": {
              "ThingNoAliasOption": {
                "$ref": "#/components/schemas/FooStruct_Option_i32"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "FooEnumThing"
            ],
            "properties": {
              "FooEnumThing": {
                "type": "object",
                "required": [
                  "foo"
                ],
                "properties": {
                  "foo": {
                    "$ref": "#/components/schemas/Vec"
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "FooThingOptionVec"
            ],
            "properties": {
              "FooThingOptionVec": {
                "type": "object",
                "required": [
                  "foo"
                ],
                "properties": {
                  "foo": {
                    "$ref": "#/components/schemas/Option"
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "FooThingLinkedList"
            ],
            "properties": {
              "FooThingLinkedList": {
                "type": "object",
                "required": [
                  "foo"
                ],
                "properties": {
                  "foo": {
                    "$ref": "#/components/schemas/LinkedList"
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "FooThingBTreeMap"
            ],
            "properties": {
              "FooThingBTreeMap": {
                "type": "object",
                "required": [
                  "foo"
                ],
                "properties": {
                  "foo": {
                    "$ref": "#/components/schemas/BTreeMap"
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "FooThingHashMap"
            ],
            "properties": {
              "FooThingHashMap": {
                "type": "object",
                "required": [
                  "foo"
                ],
                "properties": {
                  "foo": {
                    "$ref": "#/components/schemas/HashMap"
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "FooThingHashSet"
            ],
            "properties": {
              "FooThingHashSet": {
                "type": "object",
                "required": [
                  "foo"
                ],
                "properties": {
                  "foo": {
                    "$ref": "#/components/schemas/HashSet"
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "FooThingBTreeSet"
            ],
            "properties": {
              "FooThingBTreeSet": {
                "type": "object",
                "required": [
                  "foo"
                ],
                "properties": {
                  "foo": {
                    "$ref": "#/components/schemas/BTreeSet"
                  }
                }
              }
            }
          }
        ]
      },
      "HashMap": {
        "type": "object",
        "additionalProperties": {
          "type": "string"
        },
        "propertyNames": {
          "type": "integer",
          "format": "int32"
        }
      },
      "HashSet": {
        "type": "array",
        "items": {
          "type": "integer",
          "format": "int32"
        },
        "uniqueItems": true
      },
      "LinkedList": {
        "type": "array",
        "items": {
          "type": "integer",
          "format": "int32"
        }
      },
      "Option": {
        "oneOf": [
          {
            "type": "null"
          },
          {
            "type": "array",
            "items": {
              "type": "integer",
              "format": "int32"
            }
          }
        ]
      },
      "Vec": {
        "type": "array",
        "items": {
          "type": "integer",
          "format": "int32"
        }
      }
    }
  }
}

Of course, we do not want refs to known/native generics. So I will add a test for type_tree.generic_type.is_none().

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

Sure, it also seems that the it still creates a reference to String for some reason.

diff --git a/utoipa-gen/tests/testdata/openapi_schemas_resolve_inner_schema_references b/utoipa-gen/tests/testdata/openapi_schemas_resolve_inner_schema_references
index 7368bc5..718e792 100644
--- a/utoipa-gen/tests/testdata/openapi_schemas_resolve_inner_schema_references
+++ b/utoipa-gen/tests/testdata/openapi_schemas_resolve_inner_schema_references
@@ -28,7 +28,7 @@
         {
           "properties": {
             "One": {
-              "type": "string"
+              "$ref": "#/components/schemas/String"
             }
           },
           "required": [
@@ -40,7 +40,7 @@
           "properties": {
             "Many": {
               "items": {
-                "type": "object"
+                "$ref": "#/components/schemas/String"
               },
               "type": "array"
             }
@@ -57,33 +57,7 @@
         {
           "properties": {
             "One": {
-              "properties": {
-                "accounts": {
-                  "items": {
-                    "oneOf": [
-                      {
-                        "type": "null"
-                      },
-                      {
-                        "$ref": "#/components/schemas/Account"
-                      }
-                    ]
-                  },
-                  "type": "array"
-                },
-                "foo_bar": {
-                  "$ref": "#/components/schemas/Foobar"
-                },
-                "name": {
-                  "type": "string"
-                }
-              },
-              "required": [
-                "name",
-                "foo_bar",
-                "accounts"
-              ],
-              "type": "object"
+              "$ref": "#/components/schemas/Yeah"
             }
           },
           "required": [
@@ -95,7 +69,7 @@
           "properties": {
             "Many": {
               "items": {
-                "type": "object"
+                "$ref": "#/components/schemas/Yeah"
               },
               "type": "array"
             }
@@ -231,6 +205,9 @@
       ],
       "type": "object"
     },
+    "String": {
+      "type": "string"
+    },
     "ThisIsNone": {
       "default": null
     },

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

Sure, it also seems that the it still creates a reference to String for some reason.

@juhaku weird, that change should fix it:

https://github.com/juhaku/utoipa/pull/1184/files#diff-e437cad9daa59d1d04f3511c45a3ff26a0626b664485d236fca0944598b77a24R563-R564

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

https://github.com/juhaku/utoipa/pull/1184/files#diff-e437cad9daa59d1d04f3511c45a3ff26a0626b664485d236fca0944598b77a24R563-R564

Yup that fixes, it but if you remove the inline from the arguments, the reference is created correctly for the Yeah, but not for the String. It has something to do with the way the schema references is created 🤔

@JMLX42 JMLX42 force-pushed the fix/generic-type-param-inlining branch from 872f17c to fcb0ae7 Compare November 4, 2024 17:05
@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

Yup that fixes, it but if you remove the inline from the arguments, the reference is created correctly for the Yeah, but not for the String. It has something to do with the way the schema references is created 🤔

Correct. But if you add the type_tree.generic_type.is_none() it covers this case too. I just updated my branch:

https://github.com/juhaku/utoipa/pull/1184/files#diff-4eda00dd4f48f35e49679edd4d11d86a327ce484703e1352743c069c083e01e7R1263

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

Great

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

The only failing test AFAIK is openapi_schemas_resolve_schema_references .

43c43
<                 "type": "string"
---
>                 "type": "object"
98,123d97
<                 "properties": {
<                   "accounts": {
<                     "items": {
<                       "oneOf": [
<                         {
<                           "type": "null"
<                         },
<                         {
<                           "$ref": "#/components/schemas/Account"
<                         }
<                       ]
<                     },
<                     "type": "array"
<                   },
<                   "foo_bar": {
<                     "$ref": "#/components/schemas/Foobar"
<                   },
<                   "name": {
<                     "type": "string"
<                   }
<                 },
<                 "required": [
<                   "name",
<                   "foo_bar",
<                   "accounts"
<                 ],

And I'm a bit puzzled by this one...

@JMLX42 JMLX42 force-pushed the fix/generic-type-param-inlining branch from fcb0ae7 to dce8e51 Compare November 4, 2024 17:21
@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

And I'm a bit puzzled by this one...

I have created a separate test: openapi_schemas_resolve_generic_enum_schema

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

True, that is a bit weird, I am trying to debug it as well.

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

Could it be specific to how enum schemas are handled?

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

FYI here is the expanded code:

mod response {
    use std::ops::Deref;
    use utoipa::{openapi::schema::ArrayItems, ToSchema};
    pub enum Element<T> {
        One(T),
        Many(Vec<T>),
    }
    impl<T> utoipa::__dev::ComposeSchema for Element<T>
    where
        T: utoipa::ToSchema,
    {
        fn compose(
            mut generics: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>,
        ) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
            Into::<
                utoipa::openapi::schema::OneOfBuilder,
            >::into(utoipa::openapi::OneOf::with_capacity(2usize))
                .item(
                    utoipa::openapi::schema::Object::builder()
                        .property(
                            "One",
                            {
                                let _ = <T as utoipa::PartialSchema>::schema;
                                if let Some(composed) = generics.get_mut(0usize) {
                                    std::mem::take(composed)
                                } else {
                                    utoipa::openapi::schema::RefBuilder::new()
                                        .ref_location_from_schema_name(
                                            ::alloc::__export::must_use({
                                                let res = ::alloc::fmt::format(
                                                    format_args!("{0}", <T as utoipa::ToSchema>::name()),
                                                );
                                                res
                                            }),
                                        )
                                        .into()
                                }
                            },
                        )
                        .required("One"),
                )
                .item(
                    utoipa::openapi::schema::Object::builder()
                        .property(
                            "Many",
                            utoipa::openapi::schema::ArrayBuilder::new()
                                .items({
                                    let _ = <T as utoipa::PartialSchema>::schema;
                                    if let Some(composed) = generics.get_mut(0usize) {
                                        std::mem::take(composed)
                                    } else {
                                        utoipa::openapi::schema::RefBuilder::new()
                                            .ref_location_from_schema_name(
                                                ::alloc::__export::must_use({
                                                    let res = ::alloc::fmt::format(
                                                        format_args!("{0}", <T as utoipa::ToSchema>::name()),
                                                    );
                                                    res
                                                }),
                                            )
                                            .into()
                                    }
                                }),
                        )
                        .required("Many"),
                )
                .into()
        }
    }
    impl<T> utoipa::ToSchema for Element<T>
    where
        T: utoipa::ToSchema,
    {
        fn name() -> std::borrow::Cow<'static, str> {
            std::borrow::Cow::Borrowed("Element")
        }
        fn schemas(
            schemas: &mut Vec<
                (String, utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>),
            >,
        ) {
            schemas.extend([]);
            <T as utoipa::ToSchema>::schemas(schemas);
            <T as utoipa::ToSchema>::schemas(schemas);
        }
    }
}

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

Could it be specific to how enum schemas are handled?

Could be, but hard to tell right away.

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

        fn schemas(
            schemas: &mut Vec<
                (String, utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>),
            >,
        ) {
            schemas.extend([]);
            <T as utoipa::ToSchema>::schemas(schemas);
            <T as utoipa::ToSchema>::schemas(schemas);
        }

Not sure if it's related, but the <T as utoipa::ToSchema>::schemas(schemas) x2 looks like an issue.

Otherwise the code generated for the One and Many alternative are pretty much exactly the same...

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

       fn schemas(
           schemas: &mut Vec<
               (String, utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>),
           >,
       ) {
           schemas.extend([]);
           <T as utoipa::ToSchema>::schemas(schemas);
           <T as utoipa::ToSchema>::schemas(schemas);
       }

Not sure if it's related, but the ::schemas(schemas) x2 looks like an issue.

Yeah, it should not be an issue, but could also be fixed though, it in theory should result same spec but just redundantly calls the compose twice. There is no check whether the same reference call is already placed so it blindly adds schema reference call for each variant of the enum. Perhaps this could be fixed to the enum schema references implementation.

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

Alignment aside, the generated code is the same for the One and the Many.items variants:

image

Could it be that ArrayBuilder::items() behaves unexpectedly?

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

Could it be that ArrayBuilder::items() behaves unexpectedly?

Could be, as what dbg!(<Yeah as utoipa::PartialSchema>::schema()); returns, it returns correct schema, but when it is placed within the compose:

    let v = <Element<Yeah> as utoipa::__dev::ComposeSchema>::compose(
        [<Yeah as utoipa::PartialSchema>::schema()].to_vec(),
    );
    dbg!(&v);

This will result the invalid code for somehow.

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

It might have something to do with the utoipa/src/schema.rs ArrayItems default implementation, as It creates an empty object. I this might result the empty schema to be used instead 🤔

impl Default for ArrayItems {
    fn default() -> Self {
        Self::RefOrSchema(Box::new(Object::with_type(SchemaType::AnyValue).into()))
    }
}

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

Could it be that ArrayBuilder::items() behaves unexpectedly?

Could be, as what dbg!(<Yeah as utoipa::PartialSchema>::schema()); returns, it returns correct schema, but when it is placed within the compose:

    let v = <Element<Yeah> as utoipa::__dev::ComposeSchema>::compose(
        [<Yeah as utoipa::PartialSchema>::schema()].to_vec(),
    );
    dbg!(&v);

This will result the invalid code for somehow.

IMO that compose() leads to impl<T: ComposeSchema> ComposeSchema for Vec<T>. And for T = String, it leads to the code generated by impl_compose_schema!(String) which generates:

impl ComposeSchema for String {
                fn compose(_: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
                    schema!( String ).into()
                }
            }

But I cannot find the schema! macro...

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

IMO that compose() leads to impl<T: ComposeSchema> ComposeSchema for Vec<T>. And for T = String, it leads to the code generated by impl_compose_schema!(String) which generates:

My bad. In the broken case, T = Vec<String>.

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

It might have something to do with the utoipa/src/schema.rs ArrayItems default implementation, as It creates an empty object. I this might result the empty schema to be used instead 🤔

impl Default for ArrayItems {
    fn default() -> Self {
        Self::RefOrSchema(Box::new(Object::with_type(SchemaType::AnyValue).into()))
    }
}

I've added a dbg!("ArrayItems::default"); in ArrayItems::default() and it is called when running the faulty test.

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

I've added a dbg!("ArrayItems::default"); in ArrayItems::default() and it is called when running the faulty test.

But changing its impl does not change the outcome of the test:

impl Default for ArrayItems {
    fn default() -> Self {
        dbg!("ArrayItems::default");
        Self::False
    }
}

So IMO the problem is elsewhere. No?

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

Now I know what happens 🤦 the problem is mem::take it does not work for enums which are reusing same type for multiple variants. What ends up happening is that std::mem::take will remove the <Yeah as PartialSchema>::schema() for the first variant called One and replace it with empty object schema and the empty object schema is returned for the Many variant. 😄

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

Now I know what happens 🤦 the problem is mem::take it does not work for enums which are reusing same type for multiple variants. What ends up happening is that std::mem::take will remove the <Yeah as PartialSchema>::schema() for the first variant called One and replace it with empty object schema and the empty object schema is returned for the Many variant. 😄

That makes perfect sense. Why taking away the generic in the first place?

Shouldn't that trigger the same problem for a struct with multiple field typed with the same type parameter?

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

@JMLX42

In the test you created generic_request_body_schema.

Now this comes to a bit annoying point. The inline was not designed for the compose at all 😆. It was there before the compose was a thing. So what the inline does it actually calls ::schema() function in hopes of getting the schema. while this works for normal types, it does not work for generic types which are composed.

E.g. If we have type like this below. And provide a generic type for P called Type<i32>. What ends up happening is it ends up calling P::schema() which is same as calling <Type<i32> as PartialSchema>::schema() which equals to <Type<i32> as ComposeSchema>::compose(Vec::new()). So what happens here is that it calls compose with empty generics array and it makes the type a reference.

 // #[derive(ToSchema)]
struct Person<T: Sized, P> {
    field: T,
    // #[schema(inline)]
    t: P,
}

// ... part of the OpenApi derive impl.
let person = <Person<String, Type<i32>> as utoipa::__dev::ComposeSchema>::compose(
    [
        <String as utoipa::PartialSchema>::schema(),
        <Type<i32> as utoipa::__dev::ComposeSchema>::compose(
            [<i32 as utoipa::PartialSchema>::schema()].to_vec(),
        ),
    ]
    .to_vec(),
);
dbg!(person);

This is what gets generated for Person and note here because of aforementioned #[schema(inline)] the P is called as P::schema() but because it is a generic type it actually should be called with P::compose(args).

impl<T: Sized, P> utoipa::__dev::ComposeSchema for Person<T, P>
where
    T: utoipa::ToSchema,
    P: utoipa::ToSchema,
{
    fn compose(
        mut generics: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>,
    ) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
        {
            let mut object = utoipa::openapi::ObjectBuilder::new();
            object = object
                .property("field", {
                    let _ = <T as utoipa::PartialSchema>::schema;
                    if let Some(composed) = generics.get_mut(0usize) {
                        composed.clone()
                    } else {
                        utoipa::openapi::schema::RefBuilder::new()
                            .ref_location_from_schema_name(format!(
                                "{}",
                                <T as utoipa::ToSchema>::name()
                            ))
                            .into()
                    }
                })
                .required("field");
            let p = <P as utoipa::PartialSchema>::schema(); // <--- note here the P::schema()
            dbg!("got p", &p);
            object = object
                .property("t", p)
                .required("t");
            object
        }
        .into()
    }
}

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

The problem is here:
https://github.com/JMLX42/utoipa/blob/4ca70bdf0146bb05a8da4035d6a06ec4f5cbcbae/utoipa-gen/src/component.rs#L1220-L1233
We somehow should resolve here the actual type of P and depending whether it is a generic type it would call compose instead if schema. But I am not sure whether it actually is possible. Now the compose is called when has children` meaning that it actually has generic children.

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

If I change the implementation to this:

diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs
index 70fb0a5..f6f1f68 100644
--- a/utoipa-gen/src/component.rs
+++ b/utoipa-gen/src/component.rs
@@ -1228,7 +1228,8 @@ impl ComponentSchema {
                             }
                         } else {
                             quote_spanned! {type_path.span()=>
-                                <#rewritten_path as utoipa::PartialSchema>::schema()
+                                // <#rewritten_path as utoipa::PartialSchema>::schema()
+                                <#rewritten_path as utoipa::__dev::ComposeSchema>::compose(generics.clone())
                             }
                         };
                         object_schema_reference.tokens = items_tokens.clone();

I get following error:

1. the trait bound `P: utoipa::__dev::ComposeSchema` is not satisfied
   the trait `utoipa::__dev::ComposeSchema` is not implemented for `P` [E0277]

Because P does not implement ComposeSchema and it is not required to be implemented. 😮‍💨

This kind of gets to point where the generics implementation in general needs some adjustment e.g. it should go ComposeSchema first and make sure everything implements it in first place. 🤔

impl<T: Sized, P> utoipa::__dev::ComposeSchema for Person<T, P>
where
    T: utoipa::ToSchema,
    P: utoipa::ToSchema,
{
    fn compose(
        mut generics: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>,
    ) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
        {
            let mut object = utoipa::openapi::ObjectBuilder::new();
            object = object
                .property("field", {
                    let _ = <T as utoipa::PartialSchema>::schema;
                    if let Some(composed) = generics.get_mut(0usize) {
                        composed.clone()
                    } else {
                        utoipa::openapi::schema::RefBuilder::new()
                            .ref_location_from_schema_name(format!(
                                "{}",
                                <T as utoipa::ToSchema>::name()
                            ))
                            .into()
                    }
                })
                .required("field");
            object = object
                .property(
                    "t",
                    <P as utoipa::__dev::ComposeSchema>::compose(generics.clone()),
                )
                .required("t");
            object
        }
        .into()
    }
}

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 4, 2024

Another problem: I did all of this because I wanted my responses to properly use ref for there type parameter.

Here is my code:

#[derive(Debug, Serialize, ToSchema)]
pub struct SuccessResponsePayload<T: ResourceObject> {
    #[schema(inline = false)]
    pub data: Vec<T>,
    #[schema(ignore = Self::ignore_included)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub included: Option<Vec<T::RelationshipValue>>,
    #[schema(inline)]
    pub links: LinksObject<T>,
}

impl<T: ResourceObject> SuccessResponsePayload<T> {
    fn ignore_included() -> bool {
        !T::HAS_RELATIONSHIPS
    }
}

#[derive(Debug, Serialize, IntoResponses, ToSchema)]
#[serde(untagged)]
pub enum Response<T: ResourceObject> {
    #[response(
        status = 204,
        content_type = CONTENT_TYPE,
        description = "Success response with no content.",
    )]
    Empty,
    #[response(
        status = 200,
        content_type = CONTENT_TYPE,
        description = "Success response."
    )]
    Success(#[to_schema] SuccessResponsePayload<T>),
    #[response(
        status = "4XX",
        content_type = CONTENT_TYPE,
        description = "Client error response.",
        example = json!(EXAMPLE_ERROR_NOT_FOUND)
    )]
    ClientError(#[to_schema] ClientErrorResponse),
    #[response(
        status = "5XX",
        content_type = CONTENT_TYPE,
        description = "Server error response.",
        example = json!(EXAMPLE_ERROR_INTERNAL_SERVER_ERROR)
    )]
    ServerError(#[to_schema] ServerErrorResponse),
}

And yet I get:

"responses": {
          "200": {
            "description": "Success response.",
            "content": {
              "application/vnd.api+json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "links"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          {
                            "$ref": "#/components/schemas/AccessorSparseIndicesResourceIdentifierObject"
                          },
                          {
                            "type": "object",
                            "required": [
                              "attributes",
                              "relationships"
                            ],
                            "properties": {
                              "attributes": {
                                "$ref": "#/components/schemas/AccessorSparseIndicesResourceSparseAttributesObject"
                              },
                              "relationships": {
                                "$ref": "#/components/schemas/AccessorSparseIndicesResourceSparseRelationshipsObject"
                              }
                            }
                          }
                        ],
                        "description": "An object pointing to a buffer view containing the indices of deviating accessor values. The number of indices is\nequal to `accessor.sparse.count`. Indices **MUST** strictly increase."
                      }
                    },

For some reason, SuccessResponsePayload::data is still inlined 😢 . I'll write a specific test for this scenario tomorrow.

Now this comes to a bit annoying point. The inline was not designed for the compose at all 😆. It was there before the compose was a thing. So what the inline does it actually calls ::schema() function in hopes of getting the schema. while this works for normal types, it does not work for generic types which are composed.

The generic_request_body_schema passes if we inline all the way down though:

    #[derive(ToSchema)]
    #[schema(as = path::MyType<T>)]
    struct Type<T> {
        #[schema(inline)] // <= this is the change
        t: T,
    }

    #[derive(ToSchema)]
    struct Person<T: Sized, P> {
        field: T,
        #[schema(inline)]
        t: P,
    }

@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

Yeah those ToResponse and IntoResponses have not been touched in this in this 5.0.0 release other than just to make it work. But they are not guaranteed to actually do the right thing. That is perhaps a topic of own.

The generic_request_body_schema passes if we inline all the way down though:

That works because the compose implementation for the Type<i32> returns either given composed generic type or a reference by default. If inline is used within the Type<i32> it will no use the compose implementation but directly call i32::schema() eventually and use that as the return value for Type<i32> compose function instead. This results the fact that whether <Type<i32>>::schema() or <Type<i32>>::compose(any_vec) is called they both return the i32::schema().

@juhaku juhaku closed this Nov 4, 2024
@juhaku juhaku reopened this Nov 4, 2024
@juhaku
Copy link
Owner

juhaku commented Nov 4, 2024

I pressed the wrong button 🤦

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 5, 2024

Yeah those ToResponse and IntoResponses have not been touched in this in this 5.0.0 release other than just to make it work.

No problem. I'll open a separate PR for that. It should be easy enough when this is done.

That works because the compose implementation for the Type returns either given composed generic type or a reference by default.

Can the generated code check if the type is in generics and call compose() or schema() accordingly?

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 5, 2024

Can the generated code check if the type is in generics and call compose() or schema() accordingly?

That's probably the expected goal of generics.get_mut() here.

But the same check is not performed for the if is_inline branch. Maybe that's what's missing.

@juhaku
Copy link
Owner

juhaku commented Nov 5, 2024

Can the generated code check if the type is in generics and call compose() or schema() accordingly?

That's probably the expected goal of generics.get_mut() here.

But the same check is not performed for the if is_inline branch. Maybe that's what's missing.

let items_tokens = if let Some(children) = &type_tree.children {

The check is actually here it checks whether it has chidren, but in above case at that point it sees P as type which does not have children and not Type<i32>.

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 5, 2024

The check is actually here it checks whether it has chidren, but in above case at that point it sees P as type which does not have children and not Type<i32>.

Yes that's what I figured. But that's done at the macro time, not at runtime. Can't we have a better check at runtime using generics.get_mut()?

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 5, 2024

Yes that's what I figured. But that's done at the macro time, not at runtime. Can't we have a better check at runtime using generics.get_mut()?

I mean at some point, some code generates a "$ref": "#/components/schemas/i32" which does not make sense. I am having a hard time pinpointing where that code is.

@juhaku
Copy link
Owner

juhaku commented Nov 5, 2024

generics.get_mut()?

I mean at some point, some code generates a "$ref": "#/components/schemas/i32" which does not make sense. I am having a hard time pinpointing where that code is.

Yeah, not sure whether there is any way to actually get around this. But when the inline is taken out form the t it will work just fine, that is because it will go to the else block where it generates the type like shown below with the generics.get_mut(generics). (although it does not need to be .get_mut(...) as we are just cloning the type for now)

   #[derive(ToSchema)]
   #[schema(as = path::MyType<T>)]
   struct Type<T> {
       #[schema(inline)] // <= this is the change
       t: T,
   }

   #[derive(ToSchema)]
   struct Person<T: Sized, P> {
       field: T,
       //#[schema(inline)]       // <--- here 
       t: P,
   }
object = object
    .property("t", {
        let _ = <P as utoipa::PartialSchema>::schema;
        if let Some(composed) = generics.get_mut(1usize) {
            composed.clone()
        } else {
            utoipa::openapi::schema::RefBuilder::new()
                .ref_location_from_schema_name(format!(
                    "{}",
                    <P as utoipa::ToSchema>::name()
                ))
                .into()
        }
})
.required("t");

@juhaku
Copy link
Owner

juhaku commented Nov 5, 2024

What's more is that when type type is E.g. wrapped with another type like in below example. If it is inlined with #[schema(inline)] it will create a reference to the type 🤣 But if it is not inlined, It will not create a reference but will just directly inline the type when Person<T, P> is being used. After all it was designed so that it will directly inline the generic arguments.

    #[derive(ToSchema)]
    struct Wrapper {
        value: i32
    }

    #[derive(ToSchema)]
    struct Person<T: Sized, P> {
        field: T,
        #[schema(inline)]
        t: P,
    }

@juhaku
Copy link
Owner

juhaku commented Nov 5, 2024

If we change the if is_inline branch like the following, then whether #[schema(inline)] is defined or not, the result will always be inlined and seems to be incorrect. Because it will add unnecessary wrapping for the type.

diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs
index 70fb0a5..216e22d 100644
--- a/utoipa-gen/src/component.rs
+++ b/utoipa-gen/src/component.rs
@@ -1227,8 +1227,20 @@ impl ComponentSchema {
                                 <#rewritten_path as utoipa::__dev::ComposeSchema>::compose(#composed_generics.to_vec())
                             }
                         } else {
-                            quote_spanned! {type_path.span()=>
-                                <#rewritten_path as utoipa::PartialSchema>::schema()
+                            let index = container.generics.get_generic_type_param_index(type_tree);
+                            dbg!("inline got index", index, &rewritten_path);
+                            if index.is_some() {
+                                quote_spanned! {type_path.span()=>
+                                    if let Some(composed) = generics.get(#index) {
+                                        composed.clone()
+                                    } else {
+                                        <#rewritten_path as utoipa::PartialSchema>::schema()
+                                    }
+                                }
+                            } else {
+                                quote_spanned! {type_path.span()=>
+                                    <#rewritten_path as utoipa::PartialSchema>::schema()
+                                }
                             }
                         };
                         object_schema_reference.tokens = items_tokens.clone();

@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 5, 2024

@juhaku how about:

  • we document this edge case
  • we open a separate issue to track the fix

?

@juhaku
Copy link
Owner

juhaku commented Nov 7, 2024

@JMLX42 I guess that is what we need to do. Otherwise this will never end. 😆

@JMLX42 JMLX42 force-pushed the fix/generic-type-param-inlining branch from 9298bd7 to f292479 Compare November 8, 2024 15:05
@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 8, 2024

I guess that is what we need to do. Otherwise this will never end.

#1197

@JMLX42 JMLX42 marked this pull request as ready for review November 8, 2024 15:09
@JMLX42
Copy link
Contributor Author

JMLX42 commented Nov 8, 2024

@juhaku ready for review!

Copy link
Owner

@juhaku juhaku left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with this for now 👍 🥇 Thanks for the work

@juhaku
Copy link
Owner

juhaku commented Nov 14, 2024

I guess with this the schema(inline) behavior will become bit unstable for generic arguments as can be seen from above discussions. I'll add more documentation for the inline attribute regarding this.

@juhaku juhaku merged commit 553142a into juhaku:master Nov 14, 2024
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Generic type parameters are always inlined
2 participants