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

Newtype over Vec<T> to set min_items and max_items #1187

Open
avandecreme opened this issue Nov 5, 2024 · 1 comment
Open

Newtype over Vec<T> to set min_items and max_items #1187

avandecreme opened this issue Nov 5, 2024 · 1 comment

Comments

@avandecreme
Copy link

First, thanks for this lib. Version 5 is really great!

I am having a small issue. I have a struct with a field which is a Vec but which should have between 1 and 4 elements.

Here is how I modeled it so that the between 1 and 4 elements constraint is enforced by serde:

#[derive(Debug, Deserialize, ToSchema)]
struct Foo {
  bars: Vec1To4<Bar>
}

#[derive(Debug, Deserialize, ToSchema)]
struct Bar {
  baz: String
}

#[derive(Debug)]
struct Vec1To4<T>(Vec<T>);

impl<'de, T: Deserialize<'de>> Deserialize<'de> for Vec1To4<T> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        Vec::deserialize(deserializer).and_then(|vec| match vec.len() {
            1..=4 => Ok(Vec1To4(vec)),
            len => Err(serde::de::Error::invalid_length(
                len,
                &"between 1 and 4 elements",
            )),
        })
    }
}

impl<T: ToSchema> ToSchema for Vec1To4<T>
where
    Vec1To4<T>: PartialSchema,
{
    fn schemas(
        schemas: &mut Vec<(
            String,
            utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
        )>,
    ) {
        T::schemas(schemas);
    }
}

impl<T: utoipa::__dev::ComposeSchema> utoipa::__dev::ComposeSchema for Vec1To4<T> {
    fn compose(
        schemas: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>,
    ) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
        utoipa::openapi::schema::ArrayBuilder::new()
            .items(T::compose(schemas))
            .min_items(Some(1))
            .max_items(Some(4))
            .into()
    }

This generate the following schema:

    Foo:
      type: object
      required:
      - bars
      properties:
        bars:
          $ref: '#/components/schemas/Vec1To4_Bar'
    Vec1To4_Bar:
      type: array
      items:
        type: object
        required:
        - baz
        properties:
          baz:
            type: string
      maxItems: 4
      minItems: 1

While it works, it is pretty ugly and hacky:

  1. I had to implement the supposedly private utoipa::__dev::ComposeSchema trait
  2. The resulting schema is leaking the Vec1To4 implementation detail and inlined the Bar object instead of using a $ref.

Here is what I would have liked:

    Foo:
      type: object
      required:
      - bars
      properties:
        bars:
          type: array
          items:
            $ref: '#/components/schemas/Bar'
          maxItems: 4
          minItems: 1
    Bar:
      type: object
      required:
      - baz
      properties:
        baz:
          type: string

Which is what is generated if I declare the following at the cost of no items length checking by serde:

#[derive(Debug, Deserialize, ToSchema)]
pub struct Foo {
    #[schema(min_items = 1, max_items = 4)]
    pub bars: Vec<Bar>,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct Bar {
    pub baz: String,
}

And experimenting a bit more while writing this issue, I actually found a way to get what I want:

#[derive(Debug, Deserialize, ToSchema)]
pub struct Foo {
    #[schema(inline)] // Add this
    pub bars: Vec1To4<Bar>,
}

impl<T: utoipa::__dev::ComposeSchema + ToSchema> utoipa::__dev::ComposeSchema for Vec1To4<T> {
    fn compose(
        _schemas: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>,
    ) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
        schema!(Vec<T>).min_items(Some(1)).max_items(Some(4)).into()
    }
}

So I guess this issue is mostly about making this more ergonomic:

  1. make ComposeSchema public
  2. make it possible to define that Vec1To4 should be inlined from within its ComposeSchema implementation

It seems #1184 might help for 2.

@juhaku
Copy link
Owner

juhaku commented Nov 14, 2024

So I guess this issue is mostly about making this more ergonomic:

  1. make ComposeSchema public
  2. make it possible to define that Vec1To4 should be inlined from within its ComposeSchema implementation

Yeah, current generics support has some walls and most likely the correct way forward would be making the ComposeSchema public type and it probably should be default for all in order to make the generic schema composition little less achy. For now there are some issues with the current implementation which tries to use different implementation depending whether type is a plain primitive type or a composable type and distincting this from a generic argument is not actually that easy.

This is something that probably should be addressed in future.

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

No branches or pull requests

2 participants