use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::SearchError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub count: u32,
pub mode: PaginationMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PaginationMode {
Cursor(Option<PageCursor>),
Offset(u32),
}
impl Default for Pagination {
fn default() -> Self {
Self {
count: 20,
mode: PaginationMode::Cursor(None),
}
}
}
impl Pagination {
pub fn new(count: u32) -> Self {
Self {
count,
mode: PaginationMode::Cursor(None),
}
}
pub fn cursor() -> Self {
Self::default()
}
pub fn with_cursor(count: u32, cursor: String) -> Self {
match PageCursor::decode(&cursor) {
Ok(page_cursor) => Self {
count,
mode: PaginationMode::Cursor(Some(page_cursor)),
},
Err(_) => Self {
count,
mode: PaginationMode::Cursor(None),
},
}
}
pub fn offset(offset: u32) -> Self {
Self {
count: 20,
mode: PaginationMode::Offset(offset),
}
}
pub fn from_cursor(cursor: &str) -> Result<Self, SearchError> {
let page_cursor = PageCursor::decode(cursor)?;
Ok(Self {
count: 20,
mode: PaginationMode::Cursor(Some(page_cursor)),
})
}
pub fn with_count(mut self, count: u32) -> Self {
self.count = count;
self
}
pub fn offset_value(&self) -> Option<u32> {
match &self.mode {
PaginationMode::Offset(offset) => Some(*offset),
_ => None,
}
}
pub fn cursor_value(&self) -> Option<&PageCursor> {
match &self.mode {
PaginationMode::Cursor(Some(cursor)) => Some(cursor),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageCursor {
version: u8,
sort_values: Vec<CursorValue>,
resource_id: String,
direction: CursorDirection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CursorValue {
String(String),
Number(i64),
Decimal(f64),
Boolean(bool),
Null,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CursorDirection {
#[default]
Next,
Previous,
}
impl PageCursor {
pub fn new(sort_values: Vec<CursorValue>, resource_id: impl Into<String>) -> Self {
Self {
version: 1,
sort_values,
resource_id: resource_id.into(),
direction: CursorDirection::Next,
}
}
pub fn previous(sort_values: Vec<CursorValue>, resource_id: impl Into<String>) -> Self {
Self {
version: 1,
sort_values,
resource_id: resource_id.into(),
direction: CursorDirection::Previous,
}
}
pub fn sort_values(&self) -> &[CursorValue] {
&self.sort_values
}
pub fn resource_id(&self) -> &str {
&self.resource_id
}
pub fn direction(&self) -> CursorDirection {
self.direction
}
pub fn encode(&self) -> String {
let json = serde_json::to_vec(self).unwrap_or_default();
URL_SAFE_NO_PAD.encode(&json)
}
pub fn decode(s: &str) -> Result<Self, SearchError> {
let bytes = URL_SAFE_NO_PAD
.decode(s)
.map_err(|_| SearchError::InvalidCursor {
cursor: s.to_string(),
})?;
serde_json::from_slice(&bytes).map_err(|_| SearchError::InvalidCursor {
cursor: s.to_string(),
})
}
}
impl From<&str> for CursorValue {
fn from(s: &str) -> Self {
CursorValue::String(s.to_string())
}
}
impl From<String> for CursorValue {
fn from(s: String) -> Self {
CursorValue::String(s)
}
}
impl From<i64> for CursorValue {
fn from(n: i64) -> Self {
CursorValue::Number(n)
}
}
impl From<f64> for CursorValue {
fn from(n: f64) -> Self {
CursorValue::Decimal(n)
}
}
impl From<bool> for CursorValue {
fn from(b: bool) -> Self {
CursorValue::Boolean(b)
}
}
impl From<()> for CursorValue {
fn from(_: ()) -> Self {
CursorValue::Null
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageInfo {
pub next_cursor: Option<String>,
pub previous_cursor: Option<String>,
pub total: Option<u64>,
pub has_next: bool,
pub has_previous: bool,
}
impl PageInfo {
pub fn end() -> Self {
Self {
next_cursor: None,
previous_cursor: None,
total: None,
has_next: false,
has_previous: false,
}
}
pub fn with_next(cursor: PageCursor) -> Self {
Self {
next_cursor: Some(cursor.encode()),
previous_cursor: None,
total: None,
has_next: true,
has_previous: false,
}
}
pub fn with_total(mut self, total: u64) -> Self {
self.total = Some(total);
self
}
pub fn with_previous(mut self, cursor: PageCursor) -> Self {
self.previous_cursor = Some(cursor.encode());
self.has_previous = true;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub page_info: PageInfo,
}
impl<T> Page<T> {
pub fn new(items: Vec<T>, page_info: PageInfo) -> Self {
Self { items, page_info }
}
pub fn empty() -> Self {
Self {
items: Vec::new(),
page_info: PageInfo::end(),
}
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn map<U, F>(self, f: F) -> Page<U>
where
F: FnMut(T) -> U,
{
Page {
items: self.items.into_iter().map(f).collect(),
page_info: self.page_info,
}
}
}
impl<T> Default for Page<T> {
fn default() -> Self {
Self::empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchBundle {
#[serde(rename = "type")]
pub bundle_type: String,
pub total: Option<u64>,
pub link: Vec<BundleLink>,
pub entry: Vec<BundleEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleLink {
pub relation: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleEntry {
#[serde(rename = "fullUrl", skip_serializing_if = "Option::is_none")]
pub full_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search: Option<BundleEntrySearch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleEntrySearch {
pub mode: SearchEntryMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub score: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchEntryMode {
Match,
Include,
Outcome,
}
impl SearchBundle {
pub fn new() -> Self {
Self {
bundle_type: "searchset".to_string(),
total: None,
link: Vec::new(),
entry: Vec::new(),
}
}
pub fn with_total(mut self, total: u64) -> Self {
self.total = Some(total);
self
}
pub fn with_link(mut self, relation: impl Into<String>, url: impl Into<String>) -> Self {
self.link.push(BundleLink {
relation: relation.into(),
url: url.into(),
});
self
}
pub fn with_entry(mut self, entry: BundleEntry) -> Self {
self.entry.push(entry);
self
}
pub fn with_self_link(self, url: impl Into<String>) -> Self {
self.with_link("self", url)
}
pub fn with_next_link(self, url: impl Into<String>) -> Self {
self.with_link("next", url)
}
pub fn with_previous_link(self, url: impl Into<String>) -> Self {
self.with_link("previous", url)
}
}
impl Default for SearchBundle {
fn default() -> Self {
Self::new()
}
}
impl BundleEntry {
pub fn match_entry(full_url: impl Into<String>, resource: Value) -> Self {
Self {
full_url: Some(full_url.into()),
resource: Some(resource),
search: Some(BundleEntrySearch {
mode: SearchEntryMode::Match,
score: None,
}),
}
}
pub fn include_entry(full_url: impl Into<String>, resource: Value) -> Self {
Self {
full_url: Some(full_url.into()),
resource: Some(resource),
search: Some(BundleEntrySearch {
mode: SearchEntryMode::Include,
score: None,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pagination_default() {
let pagination = Pagination::default();
assert_eq!(pagination.count, 20);
assert!(matches!(pagination.mode, PaginationMode::Cursor(None)));
}
#[test]
fn test_pagination_offset() {
let pagination = Pagination::offset(100);
assert_eq!(pagination.offset_value(), Some(100));
}
#[test]
fn test_cursor_encode_decode() {
let cursor = PageCursor::new(
vec![CursorValue::String("2024-01-01".to_string())],
"patient-123",
);
let encoded = cursor.encode();
let decoded = PageCursor::decode(&encoded).unwrap();
assert_eq!(decoded.resource_id(), "patient-123");
assert_eq!(decoded.direction(), CursorDirection::Next);
}
#[test]
fn test_cursor_decode_invalid() {
let result = PageCursor::decode("not-valid-base64!!!");
assert!(result.is_err());
}
#[test]
fn test_cursor_previous() {
let cursor = PageCursor::previous(vec![CursorValue::Number(100)], "obs-456");
assert_eq!(cursor.direction(), CursorDirection::Previous);
}
#[test]
fn test_page_info_with_next() {
let cursor = PageCursor::new(vec![], "id");
let info = PageInfo::with_next(cursor);
assert!(info.has_next);
assert!(info.next_cursor.is_some());
}
#[test]
fn test_page_map() {
let page = Page::new(vec![1, 2, 3], PageInfo::end());
let mapped = page.map(|x| x * 2);
assert_eq!(mapped.items, vec![2, 4, 6]);
}
#[test]
fn test_search_bundle_builder() {
let bundle = SearchBundle::new()
.with_total(100)
.with_self_link("https://example.com/Patient?name=Smith")
.with_next_link("https://example.com/Patient?name=Smith&_cursor=xxx");
assert_eq!(bundle.total, Some(100));
assert_eq!(bundle.link.len(), 2);
assert_eq!(bundle.link[0].relation, "self");
assert_eq!(bundle.link[1].relation, "next");
}
#[test]
fn test_bundle_entry_match() {
let entry = BundleEntry::match_entry(
"https://example.com/Patient/123",
serde_json::json!({"resourceType": "Patient"}),
);
assert_eq!(entry.search.as_ref().unwrap().mode, SearchEntryMode::Match);
}
#[test]
fn test_cursor_value_conversions() {
let s: CursorValue = "test".into();
assert!(matches!(s, CursorValue::String(_)));
let n: CursorValue = 42i64.into();
assert!(matches!(n, CursorValue::Number(_)));
let b: CursorValue = true.into();
assert!(matches!(b, CursorValue::Boolean(_)));
}
}