Summary
When a generic container like Page<T> is derived with ToSchema, the specialized schema (e.g. Page_XxxDto) inlines the full object schema of T into fields such as data: Vec<T>, instead of emitting:
"data": {
"type": "array",
"items": { "$ref": "#/components/schemas/XxxDto" }
}
Nested named types inside T still become $refs, but T itself (the type parameter) is duplicated inline. This duplicates large DTO schemas across every Page_* component and makes OpenAPI codegen / client SDKs less clean (SpringDoc / many Java generators typically keep $ref for the element type).
Related discussion: #1432
Minimal reproduction
use utoipa::{OpenApi, ToSchema};
#[derive(ToSchema)]
struct XxxDto {
id: String,
name: Option<String>,
}
#[derive(ToSchema)]
struct Page<T> {
page_num: i64,
page_size: i64,
total_count: i64,
total_page: i64,
data: Vec<T>,
}
#[derive(OpenApi)]
#[openapi(components(schemas(XxxDto, Page<XxxDto>)))]
struct ApiDoc;
fn main() {
let json = ApiDoc::openapi().to_pretty_json().unwrap();
println!("{json}");
// Observe: components.schemas.Page_XxxDto.properties.data.items
// is a full object, not { "$ref": "#/components/schemas/XxxDto" }
}
Actual vs expected
Actual (Page_XxxDto):
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": ["string", "null"] }
},
"required": ["id"]
}
}
Expected:
"data": {
"type": "array",
"items": { "$ref": "#/components/schemas/XxxDto" }
}
(XxxDto remains registered as its own component; Page_XxxDto only references it.)
Workaround today
Hand-implement utoipa::__dev::ComposeSchema + ToSchema for the generic container and force a ref:
impl<T: ToSchema> utoipa::__dev::ComposeSchema for Page<T> {
fn compose(
_generics: Vec<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>,
) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
use utoipa::openapi::schema::{ArrayBuilder, ObjectBuilder};
use utoipa::openapi::Ref;
ObjectBuilder::new()
// … pagination fields …
.property(
"data",
ArrayBuilder::new().items(Ref::from_schema_name(T::name())),
)
.required("data")
.into()
}
}
impl<T: ToSchema> ToSchema for Page<T> {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("Page")
}
fn schemas(
schemas: &mut Vec<(String, utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>)>,
) {
schemas.push((T::name().into(), T::schema()));
T::schemas(schemas);
}
}
This works, but relies on the hidden __dev::ComposeSchema API and must be maintained by hand for every generic wrapper.
Proposal
Please consider one (or both) of:
- Default / opt-in for type parameters: when composing a generic field whose type is a type parameter
T: ToSchema, emit $ref to T::name() (and collect T via schemas()), instead of substituting the inlined compose/schema() object.
- Field / container attribute, e.g.
#[schema(no_inline)] or #[schema(generic_as_ref)] on data: Vec<T> / on Page<T>, so derive users can choose $ref without a manual ComposeSchema impl.
Happy to help refine semantics (especially interaction with #[schema(inline)] and deeply nested generics).
Environment
utoipa 5.5.x
- OpenAPI 3.1 output
Summary
When a generic container like
Page<T>is derived withToSchema, the specialized schema (e.g.Page_XxxDto) inlines the full object schema ofTinto fields such asdata: Vec<T>, instead of emitting:Nested named types inside
Tstill become$refs, butTitself (the type parameter) is duplicated inline. This duplicates large DTO schemas across everyPage_*component and makes OpenAPI codegen / client SDKs less clean (SpringDoc / many Java generators typically keep$reffor the element type).Related discussion: #1432
Minimal reproduction
Actual vs expected
Actual (
Page_XxxDto):Expected:
(
XxxDtoremains registered as its own component;Page_XxxDtoonly references it.)Workaround today
Hand-implement
utoipa::__dev::ComposeSchema+ToSchemafor the generic container and force a ref:This works, but relies on the hidden
__dev::ComposeSchemaAPI and must be maintained by hand for every generic wrapper.Proposal
Please consider one (or both) of:
T: ToSchema, emit$reftoT::name()(and collectTviaschemas()), instead of substituting the inlinedcompose/schema()object.#[schema(no_inline)]or#[schema(generic_as_ref)]ondata: Vec<T>/ onPage<T>, so derive users can choose$refwithout a manualComposeSchemaimpl.Happy to help refine semantics (especially interaction with
#[schema(inline)]and deeply nested generics).Environment
utoipa5.5.x