juhaku / utoipa

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

Missing references for ToSchema when types are defined in dependencies #894

Closed aminya closed 1 month ago

aminya commented 2 months ago

I have added for ToSchema for a MyType defined in a Cargo dependency. However, when I declare MyType as a components of the schema in my project, the fields of the third party type, have missing models.

// define in a dependency:

#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct MyType {
    id: String,
    #[serde(rename = "type")]
    type_: Type,
    attributes: MyTypeAttributes,
    relationships: MyTypeRelationships,
}
// In my code:
use some_dependency::MyType;

// later:
#[derive(OpenApi)]
#[openapi(
    // ...
    components(
          schemas(MyType),
    ),
)]

Here you can see that MyTypeAttributes, MyTypeRelationships, and Type are not generated in the openapi schema.

"MyType": {
  "type": "object",
  "required": ["id", "type", "attributes", "relationships"],
  "properties": {
    "attributes": { "$ref": "#/components/schemas/models.MyTypeAttributes" }, // missing reference
    "id": { "type": "string" },
    "relationships": { "$ref": "#/components/schemas/models.MyTypeRelationships" }, // missing reference
    "type": { "$ref": "#/components/schemas/Type" } // missing reference
  }
},

It seems that the macro only goes deep one level, and doesn't consider that the fields of a struct can have schemas themselves.

aminya commented 2 months ago

I have found a hard workaround for this. I have to go through each of the fields of the dependencies and list them manually under components.

// later:
#[derive(OpenApi)]
#[openapi(
    // ...
    components(
          schemas(MyType, MyTypeAttributes, MyTypeRelationships, Type),
    ),
)]

Another issue I had is that the schema prints models.MyTypeAttributes because the type of the field is written as models::MyTypeAttributes.

So I had to write a modifier to change the opanapi.json responses

/// Remove the models. prefix from the openapi json
fn modify_openapi_json(openapi_json: &str) -> String {
  return openapi_json.replace("#/components/schemas/models.", "#/components/schemas/");
}

To print the fixed schema:

// in the main function
  let openapi = ApiDoc::openapi();
  println!("{}", modify_openapi_json(&openapi.to_json()?));

To modify the openapi response for Actix

App::new()
  .service(scope("/api/v1/openapi3.json").wrap_fn(|request, service| {
    let is_open_api = request.path() == "/api/v1/openapi3.json";
    return service.call(request).map(move |result| {
      if !is_open_api {
        return result;
      }

      // Modify the response to remove the models. prefix from the openapi json
      return result.map(|result| {
        return result.map_body(|_header, body| {
          // Extract the openapi json from the body
          let mut bytes = body
            .try_into_bytes()
            .expect("Failed to convert body to bytes");
          let body_str =
            str::from_utf8(&mut bytes).expect("Failed to convert bytes to string");

          let modified_body_str = modify_openapi_json(body_str);

          return BoxBody::new(modified_body_str);
        });
      });
    });
  }))
  .service(
    RapiDoc::with_openapi("/api/v1/openapi3.json", openapi.clone()).path("/api/v1/docs/"),
  );
junlarsen commented 2 months ago

I'm pretty sure that's the "intended" way. Utoipa doesn't include any types that don't derive ToSchema, see https://github.com/juhaku/utoipa/blob/master/utoipa/src/lib.rs#L528 for how the "standard" types are derived.

FWIW, I do the same in my project, but I didn't have to modify any paths

juhaku commented 1 month ago

As @junlarsen stated it does work as intended. However there are two separate things going on here with your particular question.

  1. Rust does not have reflection and there is no runtime evaluation that can be conditionally applied on types to see whether they have ToSchema trait implemented or not and then define the appropriate action for the type. Yet we could enforce a compile error when we see a token that is not recognized as a known type. But this would cause more trouble for users than benefit because there are cases where we cannot implement ToSchema trait for a particular type that is from third-party crate. This is then to be handled the same way as you would implement serde's Serialize and Deserializefor external types. More about the topic can be found here https://github.com/juhaku/utoipa/issues/790#issuecomment-1787754185

I have found a hard workaround for this. I have to go through each of the fields of the dependencies and list them manually under components.

// later:
#[derive(OpenApi)]
#[openapi(
   // ...
   components(
         schemas(MyType, MyTypeAttributes, MyTypeRelationships, Type),
   ),
)]

This is not a workaround, that is as it is intended. Utoipa requires explicit declaration of types that suppose to be present in the OpenAPI spec. You might be interested of this crate https://github.com/ProbablyClem/utoipauto. It will enable automatic schema and path recognition. But still the types to be recognized must implement ToSchema trait.

  1. The thing about that models::MyType becoming as models.MyType is necessary for namespacing. Users might have multiple types with same name but located in different modules which need to be distinct in OpenAPI spec. In order to achieve this we need namespacing. That is if you add type with prefix like models:: you need to reference to that type with the prefix as well. Here is link to an example https://github.com/juhaku/utoipa/issues/435#issuecomment-1405448818.

From the docs https://docs.rs/utoipa/latest/utoipa/derive.ToSchema.html#struct-optional-configuration-options-for-schema:

  • as = ... Can be used to define alternative path and name for the schema what will be used in the OpenAPI. E.g as = path::to::Pet. This would make the schema appear in the generated OpenAPI spec as path.to.Pet.

That is the schema path declaration used in openapi macro attribute must match to the either name of the schema or value defined with as = ... attribute of the type that implements ToSchema.

 #[openapi(
   schema(models::MyType)
 )]
bennoinbeta commented 2 weeks ago

@juhaku Regarding Point 2. Is it possible to rename the referenced Schama name for Named Fields?

        id:
          $ref: '#/components/schemas/crate.reference_id.ReferenceId'

to

        id:
          $ref: '#/components/schemas/ReferenceId'