juhaku / utoipa

Simple, Fast, Code first and Compile time generated OpenAPI documentation for Rust
Apache License 2.0
2.22k stars 172 forks source link

Support NewType pattern by flattening #663

Open absolutejam opened 1 year ago

absolutejam commented 1 year ago

Amazing work on this crate!

I'm just testing it at the moment and my current approach is to use a request struct containing my domain objects - which leverage the NewType pattern. Then I have a single type that I can validate in my handler (using garde), like so:

// domain/user.rs
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, garde::Validate, ToSchema)]
pub struct Username {
    #[garde(ascii, length = 3, max = 64)]
    value: String
}

impl Username {
    pub fn new(name: String) -> Result<String, garde::Error> {
        let username = Username { value: name }
        username.validate(&()).map(|_| username)
    }
}

// api/user.rs
#[derive(Debug, Deserialize, ToSchema, IntoParams, garde::Validate)]
pub struct CreateUserRequest {
    #[param(min_length = 3, max_length = 64)]
    #[garde::garde(dive)]
    pub username: Username,
    // etc.
}

#[utoipa(...)]
#[post("/user")]
pub async fn create_user(json_body: Json<Unvalidated<CreateUserRequest>>) -> Result<Json<CreateUserReponse>> {
    let validated: CreateUserRequest = json_body.into_inner().validate().err_as_unprocessable_entity()?.value();
    // do something with validated.username ...
}

And as far as the user is concerned, they're just sending a String, but serde (and the #[serde(transparent)] annotation) make this wrapping step invisible to the user.

However, in the Swagger UI, I see the schema is presented as:

{
  "name": {
    "value": "string"
  }
}

Would it be possible to 'flatten' this so that it is still represented as { "name": "string" } to the user (Both in the params and the response), and so that I don't have to add Username to the schema? I tried using a mix of inline, explode and value_type to see if any of them did what I needed, but they don't seem to.

jayvdb commented 1 year ago

https://github.com/juhaku/utoipa/discussions/655 is related, and has pointers to where the improvements might be needed in utoipa to better support newtype pattern.

asaaki commented 1 year ago

Better newtype support would be really great. 💙

In my case, where I wrap String a lot, and while #[schema(value_type = String)] does the job, it's a lot of repetition whenever I use my newtype'd strings.

absolutejam commented 1 year ago

Thanks for the comments on this!

I actually switched to using tuple types as my new type wrappers so this isn't an issue for me right now, but I agree it would be a 'nice to have'. Not sure I have the capacity to contribute on this at the moment as I'm still too unfamiliar with Rust & macros, but I'll leave it up to the maintain if they want to close it. (Might be worth just tracking #655)

juhaku commented 1 year ago

Typically the new type pattern is a struct with one unnamed field e.g. struct Foo(Bar); And that is supported, but struct with named fields is not considered as a new type idiom. Furthermore it is considered as schema struct.

According to https://serde.rs/container-attrs.html#transparent, adding a support for #[serde(transparent)] could be achieved with quite low effort. Then following code would work but as the docs states, transparent attribute only works for structs with single field. The only requirement is that the field must implement ToSchema or be one of the primitive types.

// api/user.rs
#[derive(Debug, Deserialize, ToSchema, IntoParams, garde::Validate)]
#[serde(transparent)] // <-- note here
pub struct CreateUserRequest {
    #[param(min_length = 3, max_length = 64)]
    #[garde::garde(dive)]
    pub username: Username,
}
JMLX42 commented 8 months ago

Typically the new type pattern is a struct with one unnamed field e.g. struct Foo(Bar); And that is supported, but struct with named fields is not considered as a new type idiom. Furthermore it is considered as schema struct.

@juhaku the problem is some parameters are not supported for new types/enum fields:

#[derive(Debug, Clone, Deref, ToSchema, Serialize, Deserialize, Validate, Default, PartialEq)]
pub struct ByteLength(
    #[schema(minimum = 1)]
    #[garde(range(min = 1))]
    u32,
);

In this case, the minimum:

Not being able to specify such parameter makes the new type a lot let useful IMHO.

juhaku commented 2 days ago

True, the unnamed fields do not support macro attributes at the moment at all. And all the attributes for new types need to be defined on the type itself. Sure this kind of support would be quite beneficial so the validation attributes could be defined for new types as well.

@absolutejam @jayvdb @JMLX42 @asaaki Regarding new type pattern in general (also discussed here #655), in the coming 5.0.0 release utoipa will have support for global aliases https://github.com/juhaku/utoipa/tree/master/utoipa-config.

These global aliases allows users to define aliases how certain types should be treated across the utoipa. This will also solve the @jayvdb mentioned issue with UUID warpping https://github.com/juhaku/utoipa/discussions/655#discussion-5325854.

pub struct FooId(pub uuid::Uuid);

// then in build.rs
utoipa_config::Config::new().alias_for("FooId", "Uuid").write_to_file();

There is test crate for testing and demonstrating the functionality: https://github.com/juhaku/utoipa/tree/master/utoipa-config/config-test-crate. You might want to look into build.rs and main.rs for details.

JMLX42 commented 2 days ago

These global aliases allows users to define aliases how certain types should be treated across the utoipa.

That's great news @juhaku .

The problem remains that those are global aliases for one or more types. But I don't see how to create a new schema using a newtype that specializes an existing generic type. Having to manually list the types to alias in build.rs and making those aliases global is - as far as I am concerned - not solving my problem.

I don't need to replace a type with another type. I need to set the type for a generic. Example:

struct Id<T> {
  value: T,
}

#[derive(ToSchema)]
struct Uuid(Id<uuid::Uuid>)

This way, I can write a generic implementation of my JSON:API HTTP responses, but template the primary data with the actual type. Here is my example:

In my jsonapi crate, generic data structures. For example:

#[derive(ToSchema)]
struct Response<T> {
  data: Vec<T>,
}

Then in my api crate that depends on jsonapi:

struct Book {
  attributes: BookAttributes,
}

struct BookAttributes {
  author: String,
}

#[derive(ToSchema)]
struct BookResponse(jsonapi::Response<Book>)

That would generate the following JSON response:

{
  "data": [
    {
      "attributes": {
        "author": "Jean-Luc Picard"
      }
    }
  ]
}

I might be mistaken, but this is still not possible.