Type aliases does not work well with generics
oxalica opened this issue · 4 comments
With utoipa = "5.1"
, the following code does not correctly instantiate two copies ot Signed<_>
and result in colliding schemas.
But when I manually inline type aliases, the output become correct. I guess it's due to we are processing type names as strings without relying on real Rust type and trait system to call on the correct ToSchema
impls?
I saw a related PR #1048 which provides a global way to define type aliases, but it's tedious and error-prone.
#![allow(dead_code)]
use utoipa::{OpenApi, ToSchema};
#[derive(ToSchema)]
struct Signed<T> {
sig: String,
inner: T,
}
#[derive(ToSchema)]
struct Msg {
text: String,
}
#[derive(ToSchema)]
struct AdminOp {
op: String,
}
type SignedMsg = Signed<Msg>;
type SignedAdminOp = Signed<AdminOp>;
#[derive(ToSchema)]
pub struct MyRequest {
// op: Signed<AdminOp>, // This would work.
op: SignedAdminOp, // This does not.
}
#[derive(ToSchema)]
pub struct MyResponse {
// msgs: Vec<Signed<Msg>>, // This would work.
msgs: Vec<SignedMsg>, // This does not.
}
#[derive(OpenApi)]
#[openapi(components(schemas(MyRequest, MyResponse)))]
struct Api;
fn main() {
println!("{}", Api::openapi().to_pretty_json().unwrap());
}
Output:
{
"openapi": "3.1.0",
"info": {
"title": "testt",
"description": "",
"license": {
"name": ""
},
"version": "0.1.0"
},
"paths": {},
"components": {
"schemas": {
"MyRequest": {
"type": "object",
"required": [
"op"
],
"properties": {
"op": {
"$ref": "#/components/schemas/Signed"
}
}
},
"MyResponse": {
"type": "object",
"required": [
"msgs"
],
"properties": {
"msgs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Signed"
}
}
}
},
"Signed": {
"type": "object",
"required": [
"sig",
"inner"
],
"properties": {
"inner": {
"$ref": "#/components/schemas/Msg"
},
"sig": {
"type": "string"
}
}
}
}
}
}
There are two issues:
Signed
should be generic but is collapsed into a single type.Msg
is referenced but is never defined (dead reference).
Hmm, just ran the code myself with exact types.
build.rs
fn main() {
utoipa_config::Config::new()
.alias_for("SignedMsg", "Signed<Msg>")
.alias_for("SignedAdminOp", "Signed<AdminOp>")
.write_to_file()
}
Output:
{
"openapi": "3.1.0",
"info": {
"title": "utoipa-config-test",
"description": "",
"license": {
"name": ""
},
"version": "0.1.0"
},
"paths": {},
"components": {
"schemas": {
"MyRequest": {
"type": "object",
"required": [
"op"
],
"properties": {
"op": {
"$ref": "#/components/schemas/Signed_AdminOp"
}
}
},
"MyResponse": {
"type": "object",
"required": [
"msgs"
],
"properties": {
"msgs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Signed_Msg"
}
}
}
},
"Signed_AdminOp": {
"type": "object",
"required": [
"sig",
"inner"
],
"properties": {
"inner": {
"type": "object",
"required": [
"op"
],
"properties": {
"op": {
"type": "string"
}
}
},
"sig": {
"type": "string"
}
}
},
"Signed_Msg": {
"type": "object",
"required": [
"sig",
"inner"
],
"properties": {
"inner": {
"type": "object",
"required": [
"text"
],
"properties": {
"text": {
"type": "string"
}
}
},
"sig": {
"type": "string"
}
}
}
}
}
}
The thing is that the config is written projects OUTPUT directory, and I am not sure by what algorithm it will use the built stuff from there. It would seem that sometimes rust compiler seems to get not up-to-date instance of the file which causes wrong types in the output. There is no remedy for this that I know.
Best thing to this would actually be to mention this in the docs. I'll write a heads up for people to the README.md
fn main() { utoipa_config::Config::new() .alias_for("SignedMsg", "Signed<Msg>") .alias_for("SignedAdminOp", "Signed<AdminOp>") .write_to_file() }
I mean I don't want to explicitly write this. They should work automatically. In proc-macro, it already got enough information to call <SignedMsg as ToSchema>::to_schema
which should resolve to the correct type regardless if it's a type alias or not. But I'm not sure if I understand the real underlying issue about why we need a global config to make alias work, since the relevant generation code seems extremely complex to me.
Okay, let me open this up a bit for you.
Let's say we have a type like the one below. When ToSchema
is being compiled for the Item
the macro execution only sees the type Item
and what's inside of it.
#[derive(ToSchema)]
struct Item {
value: Option<i32>,
}
Now if we change the type a bit to this for example the rust compiler, when ToScheme
executes will again see only what's inside the Item
. It does not have access to the information about what the NullableNumber
actually is.
type NullableNumber = Option<i32>;
#[derive(ToSchema)]
struct Item {
value: NullableNumber,
}
The problem arises when we want to resolve the schema for the NullableNumber
. In this case it is quite straight forward since it is Option<i32>
we could blindly call <NullableNumber as ToShema>::schema()
and it should return correct value. This is because the types does not need to be composed of any other types. But what if we had more complex situation, like the one above.
#[derive(ToSchema)]
struct Signed<T> {
sig: String,
inner: T,
}
#[derive(ToSchema)]
struct Msg {
text: String,
}
#[derive(ToSchema)]
struct AdminOp {
op: String,
}
type SignedMsg = Signed<Msg>;
type SignedAdminOp = Signed<AdminOp>;
When we define the usage for the any of those aliases, we do not know the actual type that is being used, but instead what ends up happening is when the rust compiler executes the ToSchema
for the Item
it does not know what the SignedMsg
is. We cannot call <SignedMsg as ToSchema>::schema()
because it is a generic type so it need to be composed. If we called that it would return schema implementation of a generic Signed<T>
which would be incorrect. That is because the ToSchema
for Signed
type is evaluated also compile time regardless of AdminOp
or Msg
and each type have their own base implementation of schema.
Instead in order to get correct type for SignedMsg
we need to call it by it's actual type composing multiple ToSchema
implementation to single one something like this <SignedMsg as _dev::ComposeSchema>::compose([<Msg as ToSchema>::schema()])
But because SignedMsg
does not have any generic arguments during Item
ToSchema
execution it will not know what type to use.
#[derive(ToSchema)]
struct Item {
value: SignedMsg,
}
I hope this opened the problem with type aliases for you. Maybe there are some other ways around this, but the approach utoipa has been built with is not very dynamic due to nature of relying only to token stream of Rust code. Because there is no reflection known to other languages to dynamically work with types and evaluating their values. And I am not fond of idea to start scanning project files for possible types and matching them with the values of structs and enums. This would be really unfeasible and time consuming during build time.
Closing for now, but can reopened if needed.