Skip to content

feat: option for generic ToSchema fields to $ref type params instead of inlining #1586

Description

@csdog

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:

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions