@@ -643,9 +643,22 @@ impl<T: Serialize> Paginated<T> {
643643}
644644
645645impl < T : Serialize + Send > crate :: response:: IntoResponse for Paginated < T > {
646+ /// Convert to an HTTP response.
647+ ///
648+ /// The response includes `X-Total-Count`, `X-Total-Pages`, and RFC 8288
649+ /// `Link` headers. Navigation links (first/prev/next/last) are generated as
650+ /// **relative query strings** (e.g. `?page=2&per_page=20`) because
651+ /// `IntoResponse` does not have access to the original request URI.
652+ ///
653+ /// If you need absolute URLs in the Link header, wrap this type in a
654+ /// `ResponseModifier` or interceptor that has access to the request URI,
655+ /// or build the response manually using [`Paginated::link_header`] and
656+ /// [`Paginated::to_body_with_path`].
646657 fn into_response ( self ) -> crate :: response:: Response {
647- // Use a generic base path since we don't have access to the request URI
648- // in IntoResponse. Users can override via ResponseModifier or interceptors.
658+ // Use an empty base path since IntoResponse has no access to the
659+ // request URI. Navigation links will be relative query strings only
660+ // (e.g. `?page=2`). Callers that need absolute URLs should use
661+ // link_header(base_path) / to_body_with_path(base_path) directly.
649662 let base_path = "" ;
650663 let link_header = self . link_header ( base_path) ;
651664 let body = self . to_body_with_path ( base_path) ;
@@ -868,4 +881,150 @@ mod tests {
868881 let resource = user. with_links ( ) . self_link ( "/users/1" ) ;
869882 assert ! ( resource. links. contains_key( "self" ) ) ;
870883 }
884+
885+ // ─── IntoResponse tests ─────────────────────────────────────────────────
886+
887+ /// Collect a [`crate::response::Body`] into [`bytes::Bytes`] synchronously
888+ /// using a one-shot tokio runtime (avoids pulling in `#[tokio::test]`).
889+ fn collect_body ( body : crate :: response:: Body ) -> bytes:: Bytes {
890+ use http_body_util:: BodyExt ;
891+ tokio:: runtime:: Builder :: new_current_thread ( )
892+ . build ( )
893+ . unwrap ( )
894+ . block_on ( async { body. collect ( ) . await . unwrap ( ) . to_bytes ( ) } )
895+ }
896+
897+ #[ test]
898+ fn test_paginated_into_response_status_and_headers ( ) {
899+ use crate :: response:: IntoResponse ;
900+
901+ let users = vec ! [
902+ User { id: 1 , name: "Alice" . to_string( ) } ,
903+ User { id: 2 , name: "Bob" . to_string( ) } ,
904+ ] ;
905+ let paginated = Paginated :: new ( users, 1 , 10 , 25 ) ;
906+ let response = paginated. into_response ( ) ;
907+
908+ assert_eq ! ( response. status( ) , http:: StatusCode :: OK ) ;
909+ assert_eq ! (
910+ response. headers( ) . get( http:: header:: CONTENT_TYPE ) . unwrap( ) ,
911+ "application/json"
912+ ) ;
913+ assert_eq ! (
914+ response. headers( ) . get( "X-Total-Count" ) . unwrap( ) ,
915+ "25"
916+ ) ;
917+ assert_eq ! (
918+ response. headers( ) . get( "X-Total-Pages" ) . unwrap( ) ,
919+ "3" // ceil(25 / 10) = 3
920+ ) ;
921+ // Link header should be present (non-first page has prev/next/first/last)
922+ assert ! ( response. headers( ) . contains_key( http:: header:: LINK ) ) ;
923+ }
924+
925+ #[ test]
926+ fn test_paginated_into_response_json_body ( ) {
927+ use crate :: response:: IntoResponse ;
928+
929+ let users = vec ! [ User { id: 42 , name: "Carol" . to_string( ) } ] ;
930+ let paginated = Paginated :: new ( users, 2 , 5 , 10 ) ;
931+ let response = paginated. into_response ( ) ;
932+
933+ let ( parts, body) = response. into_parts ( ) ;
934+ assert_eq ! ( parts. status, http:: StatusCode :: OK ) ;
935+
936+ let bytes = collect_body ( body) ;
937+ let json: serde_json:: Value = serde_json:: from_slice ( & bytes) . unwrap ( ) ;
938+
939+ assert ! ( json. get( "items" ) . is_some( ) ) ;
940+ assert ! ( json. get( "meta" ) . is_some( ) ) ;
941+ // Links use the HAL `_links` convention
942+ assert ! ( json. get( "_links" ) . is_some( ) ) ;
943+
944+ let items = json[ "items" ] . as_array ( ) . unwrap ( ) ;
945+ assert_eq ! ( items. len( ) , 1 ) ;
946+ assert_eq ! ( items[ 0 ] [ "id" ] , 42 ) ;
947+
948+ let meta = & json[ "meta" ] ;
949+ assert_eq ! ( meta[ "page" ] , 2 ) ;
950+ assert_eq ! ( meta[ "per_page" ] , 5 ) ;
951+ assert_eq ! ( meta[ "total" ] , 10 ) ;
952+ assert_eq ! ( meta[ "total_pages" ] , 2 ) ;
953+ }
954+
955+ #[ test]
956+ fn test_paginated_into_response_empty_items ( ) {
957+ use crate :: response:: IntoResponse ;
958+
959+ let paginated: Paginated < User > = Paginated :: new ( vec ! [ ] , 1 , 10 , 0 ) ;
960+ let response = paginated. into_response ( ) ;
961+
962+ assert_eq ! ( response. status( ) , http:: StatusCode :: OK ) ;
963+ assert_eq ! ( response. headers( ) . get( "X-Total-Count" ) . unwrap( ) , "0" ) ;
964+ assert_eq ! ( response. headers( ) . get( "X-Total-Pages" ) . unwrap( ) , "0" ) ;
965+ // At minimum the `first` link is always included in the Link header
966+ let link = response. headers ( ) . get ( http:: header:: LINK ) ;
967+ let link_str = link. map ( |v| v. to_str ( ) . unwrap_or ( "" ) ) . unwrap_or ( "" ) ;
968+ // Empty result set has no next or prev links
969+ assert ! ( !link_str. contains( "rel=\" next\" " ) ) ;
970+ assert ! ( !link_str. contains( "rel=\" prev\" " ) ) ;
971+ }
972+
973+ #[ test]
974+ fn test_cursor_paginated_into_response_status_and_headers ( ) {
975+ use crate :: response:: IntoResponse ;
976+
977+ let users = vec ! [ User { id: 1 , name: "Dave" . to_string( ) } ] ;
978+ let paginated = CursorPaginated :: new ( users, Some ( "cursor_abc" . to_string ( ) ) , true ) ;
979+ let response = paginated. into_response ( ) ;
980+
981+ assert_eq ! ( response. status( ) , http:: StatusCode :: OK ) ;
982+ assert_eq ! (
983+ response. headers( ) . get( http:: header:: CONTENT_TYPE ) . unwrap( ) ,
984+ "application/json"
985+ ) ;
986+ }
987+
988+ #[ test]
989+ fn test_cursor_paginated_into_response_json_body ( ) {
990+ use crate :: response:: IntoResponse ;
991+
992+ let users = vec ! [ User { id: 7 , name: "Eve" . to_string( ) } ] ;
993+ let paginated =
994+ CursorPaginated :: new ( users, Some ( "next_cursor_xyz" . to_string ( ) ) , true ) ;
995+ let response = paginated. into_response ( ) ;
996+
997+ let ( _parts, body) = response. into_parts ( ) ;
998+ let bytes = collect_body ( body) ;
999+ let json: serde_json:: Value = serde_json:: from_slice ( & bytes) . unwrap ( ) ;
1000+
1001+ assert ! ( json. get( "items" ) . is_some( ) ) ;
1002+ assert ! ( json. get( "meta" ) . is_some( ) ) ;
1003+
1004+ let items = json[ "items" ] . as_array ( ) . unwrap ( ) ;
1005+ assert_eq ! ( items. len( ) , 1 ) ;
1006+ assert_eq ! ( items[ 0 ] [ "id" ] , 7 ) ;
1007+
1008+ let meta = & json[ "meta" ] ;
1009+ assert_eq ! ( meta[ "next_cursor" ] , "next_cursor_xyz" ) ;
1010+ assert_eq ! ( meta[ "has_more" ] , true ) ;
1011+ }
1012+
1013+ #[ test]
1014+ fn test_cursor_paginated_into_response_last_page ( ) {
1015+ use crate :: response:: IntoResponse ;
1016+
1017+ let users = vec ! [ User { id: 9 , name: "Frank" . to_string( ) } ] ;
1018+ let paginated = CursorPaginated :: new ( users, None , false ) ;
1019+ let response = paginated. into_response ( ) ;
1020+
1021+ let ( _parts, body) = response. into_parts ( ) ;
1022+ let bytes = collect_body ( body) ;
1023+ let json: serde_json:: Value = serde_json:: from_slice ( & bytes) . unwrap ( ) ;
1024+
1025+ let meta = & json[ "meta" ] ;
1026+ // next_cursor should be omitted when None
1027+ assert ! ( meta. get( "next_cursor" ) . is_none( ) ) ;
1028+ assert_eq ! ( meta[ "has_more" ] , false ) ;
1029+ }
8711030}
0 commit comments