@ -82,12 +82,65 @@ struct ListParams {
author : Option < String > ,
/// Filter by tag.
tag : Option < String > ,
/// Only include quotes dated on or after this year.
date_after_year : Option < u16 > ,
/// Narrows after-bound to this month (1– 12). Requires `date_after_year`.
date_after_month : Option < u8 > ,
/// Narrows after-bound to this day (1– 31). Requires `date_after_year` and `date_after_month`.
date_after_day : Option < u8 > ,
/// Only include quotes dated on or before this year.
date_before_year : Option < u16 > ,
/// Narrows before-bound to this month (1– 12). Requires `date_before_year`.
date_before_month : Option < u8 > ,
/// Narrows before-bound to this day (1– 31). Requires `date_before_year` and `date_before_month`.
date_before_day : Option < u8 > ,
}
fn default_page ( ) -> u32 {
1
}
/// Build an ISO date prefix string from optional year/month/day components.
///
/// Returns `None` if no year is given. For before-bounds, missing month
/// defaults to `12` and missing day defaults to `31` so the bound is
/// inclusive of the entire specified year/month.
///
/// # Examples
///
/// ```ignore
/// assert_eq!(build_date_bound(Some(2020), None, None, false), Some("2020".to_string()));
/// assert_eq!(build_date_bound(Some(2020), None, None, true), Some("2020-12-31".to_string()));
/// assert_eq!(build_date_bound(Some(2020), Some(6), None, true), Some("2020-06-31".to_string()));
/// assert_eq!(build_date_bound(Some(2020), Some(6), Some(15), false), Some("2020-06-15".to_string()));
/// assert_eq!(build_date_bound(None, Some(6), Some(15), false), None);
/// ```
fn build_date_bound (
year : Option < u16 > ,
month : Option < u8 > ,
day : Option < u8 > ,
is_before : bool ,
) -> Option < String > {
match ( year , month , day ) {
( None , _ , _ ) = > None ,
( Some ( y ) , None , _ ) = > {
if is_before {
Some ( format! ( "{y:04}-12-31" ) )
} else {
Some ( format! ( "{y:04}" ) )
}
}
( Some ( y ) , Some ( m ) , None ) = > {
if is_before {
Some ( format! ( "{y:04}-{m:02}-31" ) )
} else {
Some ( format! ( "{y:04}-{m:02}" ) )
}
}
( Some ( y ) , Some ( m ) , Some ( d ) ) = > Some ( format! ( "{y:04}-{m:02}-{d:02}" ) ) ,
}
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// `GET /api/` — return the OpenAPI specification as JSON.
@ -108,12 +161,65 @@ async fn openapi_handler() -> Response {
/// `GET /api/quotes` — list quotes with optional filtering and pagination.
///
/// Accepts `?page=N&author=X&tag=Y` query parameters. Defaults to page 1 and
/// no filters. Returns [`crate::db::ListResult`] serialised as JSON.
/// Accepts `?page=N&author=X&tag=Y&date_after_year=Y&date_before_year=Y` (and
/// month/day variants) query parameters. Defaults to page 1 and no filters.
/// Returns [`crate::db::ListResult`] serialised as JSON.
///
/// Returns `400 Bad Request` when date component ordering is violated (e.g.
/// `date_after_month` provided without `date_after_year`).
#[ cfg_attr(target_arch = " wasm32 " , worker::send) ]
async fn list_handler ( State ( repo ) : State < Repo > , Query ( params ) : Query < ListParams > ) -> Response {
// Validate: month requires year, day requires year+month
if params . date_after_month . is_some ( ) & & params . date_after_year . is_none ( ) {
return error_response (
StatusCode ::BAD_REQUEST ,
"date_after_month requires date_after_year" ,
) ;
}
if params . date_after_day . is_some ( )
& & ( params . date_after_year . is_none ( ) | | params . date_after_month . is_none ( ) )
{
return error_response (
StatusCode ::BAD_REQUEST ,
"date_after_day requires date_after_year and date_after_month" ,
) ;
}
if params . date_before_month . is_some ( ) & & params . date_before_year . is_none ( ) {
return error_response (
StatusCode ::BAD_REQUEST ,
"date_before_month requires date_before_year" ,
) ;
}
if params . date_before_day . is_some ( )
& & ( params . date_before_year . is_none ( ) | | params . date_before_month . is_none ( ) )
{
return error_response (
StatusCode ::BAD_REQUEST ,
"date_before_day requires date_before_year and date_before_month" ,
) ;
}
let date_after = build_date_bound (
params . date_after_year ,
params . date_after_month ,
params . date_after_day ,
false ,
) ;
let date_before = build_date_bound (
params . date_before_year ,
params . date_before_month ,
params . date_before_day ,
true ,
) ;
match repo
. list_quotes ( params . page , params . author . as_deref ( ) , params . tag . as_deref ( ) )
. list_quotes (
params . page ,
params . author . as_deref ( ) ,
params . tag . as_deref ( ) ,
date_after . as_deref ( ) ,
date_before . as_deref ( ) ,
)
. await
{
Ok ( result ) = > ( StatusCode ::OK , Json ( result ) ) . into_response ( ) ,
@ -291,6 +397,8 @@ mod tests {
page : u32 ,
_author : Option < & str > ,
_tag : Option < & str > ,
_date_after : Option < & str > ,
_date_before : Option < & str > ,
) -> Result < ListResult , DbError > {
let quotes = self . quotes . lock ( ) . unwrap ( ) ;
let all : Vec < Quote > = quotes . iter ( ) . map ( | ( q , _ ) | q . clone ( ) ) . collect ( ) ;
@ -1320,4 +1428,134 @@ mod integration_tests {
// The random handler returns the full Quote, not a CreateResponse
assert! ( v . get ( "id" ) . is_some ( ) , "should be a Quote, not an error" ) ;
}
// ── Date range filter integration tests ───────────────────────────────────
/// Create a quote with a specific date via PUT /api/quotes.
async fn create_quote_with_date (
app : Router ,
text : & str ,
date : Option < & str > ,
) -> ( Router , serde_json ::Value , String ) {
let mut payload = json ! ( {
"text" : text ,
"author" : "DateAuthor" ,
"tags" : [ ] ,
} ) ;
if let Some ( d ) = date {
payload [ "date" ] = json ! ( d ) ;
}
let req = Request ::builder ( )
. method ( Method ::PUT )
. uri ( "/api/quotes" )
. header ( "Content-Type" , "application/json" )
. body ( Body ::from ( payload . to_string ( ) ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app . clone ( ) , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::CREATED ) ;
let v = body_json ( resp ) . await ;
let auth_code = v [ "auth_code" ] . as_str ( ) . unwrap ( ) . to_owned ( ) ;
let quote = v [ "quote" ] . clone ( ) ;
( app , quote , auth_code )
}
/// `?date_after_year=` filters out quotes dated before that year.
#[ tokio::test ]
async fn integration_date_filter_after_year ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "Old quote" , Some ( "1990-01-01" ) ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "New quote" , Some ( "2020-06-15" ) ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "No date quote" , None ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?date_after_year=2000" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
// Only the 2020 quote qualifies; 1990 is before 2000, no-date excluded
assert_eq! ( v [ "total_count" ] , 1 ) ;
assert_eq! ( v [ "quotes" ] [ 0 ] [ "text" ] , "New quote" ) ;
}
/// `?date_before_year=` filters out quotes dated after that year.
#[ tokio::test ]
async fn integration_date_filter_before_year ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "Old quote" , Some ( "1990-01-01" ) ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "New quote" , Some ( "2020-06-15" ) ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "No date quote" , None ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?date_before_year=2000" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
// Only the 1990 quote qualifies; 2020 is after 2000-12-31, no-date excluded
assert_eq! ( v [ "total_count" ] , 1 ) ;
assert_eq! ( v [ "quotes" ] [ 0 ] [ "text" ] , "Old quote" ) ;
}
/// `?date_after_year=&date_before_year=` combined bounds narrow the window.
#[ tokio::test ]
async fn integration_date_filter_range ( ) {
let ( app , _f ) = test_router ( ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "Old quote" , Some ( "1990-01-01" ) ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "Mid quote" , Some ( "2000-06-15" ) ) . await ;
let ( app , _ , _ ) = create_quote_with_date ( app , "New quote" , Some ( "2020-12-31" ) ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?date_after_year=1995&date_before_year=2010" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::OK ) ;
let v = body_json ( resp ) . await ;
assert_eq! ( v [ "total_count" ] , 1 ) ;
assert_eq! ( v [ "quotes" ] [ 0 ] [ "text" ] , "Mid quote" ) ;
}
/// `?date_after_month=` without a year returns 400 Bad Request.
#[ tokio::test ]
async fn integration_date_filter_month_without_year_returns_400 ( ) {
let ( app , _f ) = test_router ( ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?date_after_month=6" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::BAD_REQUEST ) ;
}
/// `?date_before_day=` without year+month returns 400 Bad Request.
#[ tokio::test ]
async fn integration_date_filter_day_without_year_month_returns_400 ( ) {
let ( app , _f ) = test_router ( ) . await ;
let req = Request ::builder ( )
. method ( Method ::GET )
. uri ( "/api/quotes?date_before_day=15" )
. body ( Body ::empty ( ) )
. unwrap ( ) ;
let resp = ServiceExt ::< Request < Body > > ::oneshot ( app , req )
. await
. unwrap ( ) ;
assert_eq! ( resp . status ( ) , StatusCode ::BAD_REQUEST ) ;
}
}