use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use async_trait::async_trait;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::id::{IdKind, WorkId};
use crate::record::{FieldSet, SourceRecord};
use crate::work::WorkType;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
pub struct ProviderId(Cow<'static, str>);
impl ProviderId {
pub const fn from_static(id: &'static str) -> Self {
assert!(Self::is_valid(id), "invalid provider id");
ProviderId(Cow::Borrowed(id))
}
pub fn parse(id: &str) -> Option<Self> {
Self::is_valid(id).then(|| ProviderId(Cow::Owned(id.to_owned())))
}
pub fn as_str(&self) -> &str {
&self.0
}
const fn is_valid(id: &str) -> bool {
let b = id.as_bytes();
if b.is_empty() {
return false;
}
let mut i = 0;
while i < b.len() {
if !(b[i].is_ascii_lowercase() || b[i].is_ascii_digit() || b[i] == b'-') {
return false;
}
i += 1;
}
true
}
}
impl fmt::Display for ProviderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for ProviderId {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
ProviderId::parse(&s)
.ok_or_else(|| serde::de::Error::custom(format!("invalid provider id {s:?}")))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Capabilities {
pub lookup_kinds: Vec<IdKind>,
pub max_batch: usize,
pub fields: FieldSet,
pub search: bool,
pub citation_search: bool,
pub search_filters: Vec<Filter>,
pub citations: bool,
pub references: bool,
pub recommend: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Filter {
Years,
WorkType,
OpenAccess,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct YearRange {
pub from: Option<i16>,
pub to: Option<i16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Cursor(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PageReq {
pub limit: u32,
pub cursor: Option<Cursor>,
}
impl PageReq {
pub const DEFAULT_LIMIT: u32 = 25;
pub fn first(limit: u32) -> Self {
PageReq {
limit,
cursor: None,
}
}
}
impl Default for PageReq {
fn default() -> Self {
PageReq::first(Self::DEFAULT_LIMIT)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SearchMode {
#[default]
Keywords,
Citation,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SearchQuery {
pub text: String,
pub mode: SearchMode,
pub years: Option<YearRange>,
pub work_type: Option<WorkType>,
pub open_access: Option<bool>,
pub fields: FieldSet,
pub page: PageReq,
pub extra: BTreeMap<String, String>,
}
impl SearchQuery {
pub fn new(text: impl Into<String>) -> Self {
SearchQuery {
text: text.into(),
fields: FieldSet::DEFAULT,
..SearchQuery::default()
}
}
pub fn citation(reference: impl Into<String>) -> Self {
SearchQuery {
mode: SearchMode::Citation,
..SearchQuery::new(reference)
}
}
pub fn filters(&self) -> Vec<Filter> {
[
self.years.map(|_| Filter::Years),
self.work_type.as_ref().map(|_| Filter::WorkType),
self.open_access.map(|_| Filter::OpenAccess),
]
.into_iter()
.flatten()
.collect()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Lookup {
pub id: WorkId,
pub record: Option<SourceRecord>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Page {
pub hits: Vec<Hit>,
pub total: Option<u64>,
pub next: Option<Cursor>,
}
impl Page {
pub fn new(hits: Vec<Hit>, total: Option<u64>, next: Option<Cursor>) -> Self {
Page { hits, total, next }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hit {
pub record: SourceRecord,
pub score: Option<f64>,
}
impl Hit {
pub fn new(record: SourceRecord, score: Option<f64>) -> Self {
Hit { record, score }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Usage {
pub cost: Option<f64>,
pub remaining: Option<f64>,
pub resets_at: Option<Timestamp>,
}
impl Usage {
pub fn then(self, later: Usage) -> Usage {
Usage {
cost: match (self.cost, later.cost) {
(Some(a), Some(b)) => Some(a + b),
(a, b) => a.or(b),
},
remaining: later.remaining.or(self.remaining),
resets_at: later.resets_at.or(self.resets_at),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Response<T> {
pub value: T,
pub usage: Option<Usage>,
}
impl<T> Response<T> {
pub fn new(value: T) -> Self {
Response { value, usage: None }
}
pub fn with_usage(value: T, usage: Usage) -> Self {
Response {
value,
usage: Some(usage),
}
}
}
#[async_trait]
pub trait Provider: Send + Sync {
fn id(&self) -> ProviderId;
fn capabilities(&self) -> &Capabilities;
async fn lookup(&self, ids: &[WorkId], fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
let _ = (ids, fields);
Err(Error::Unsupported("lookup"))
}
async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
let _ = query;
Err(Error::Unsupported("search"))
}
async fn citations(
&self,
id: &WorkId,
page: &PageReq,
fields: FieldSet,
) -> Result<Response<Page>> {
let _ = (id, page, fields);
Err(Error::Unsupported("citations"))
}
async fn references(
&self,
id: &WorkId,
page: &PageReq,
fields: FieldSet,
) -> Result<Response<Page>> {
let _ = (id, page, fields);
Err(Error::Unsupported("references"))
}
async fn recommend(
&self,
positive: &[WorkId],
negative: &[WorkId],
page: &PageReq,
fields: FieldSet,
) -> Result<Response<Page>> {
let _ = (positive, negative, page, fields);
Err(Error::Unsupported("recommend"))
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Nothing(Capabilities);
#[async_trait]
impl Provider for Nothing {
fn id(&self) -> ProviderId {
ProviderId::from_static("nothing")
}
fn capabilities(&self) -> &Capabilities {
&self.0
}
}
#[test]
fn usage_adds_costs_and_keeps_latest_budget() {
let first = Usage {
cost: Some(0.001),
remaining: Some(0.9),
resets_at: None,
};
let second = Usage {
cost: Some(0.0001),
remaining: None,
resets_at: None,
};
let total = first.then(second);
assert!((total.cost.unwrap() - 0.0011).abs() < 1e-12);
assert_eq!(total.remaining, Some(0.9));
}
#[test]
fn provider_ids_are_validated() {
assert!(ProviderId::parse("openalex").is_some());
assert!(ProviderId::parse("s2-graph").is_some());
assert!(ProviderId::parse("OpenAlex").is_none());
assert!(ProviderId::parse("").is_none());
assert!(serde_json::from_str::<ProviderId>(r#""bad id""#).is_err());
}
#[test]
fn query_reports_the_filters_it_uses() {
let mut q = SearchQuery::new("transformers");
assert!(q.filters().is_empty());
q.years = Some(YearRange {
from: Some(2017),
to: None,
});
q.open_access = Some(true);
assert_eq!(q.filters(), [Filter::Years, Filter::OpenAccess]);
}
#[test]
fn unimplemented_operations_are_unsupported_and_dyn_compatible() {
let p: Box<dyn Provider> = Box::new(Nothing(Capabilities::default()));
let query = SearchQuery::new("x");
let result = block_on_ready(p.search(&query));
assert!(matches!(result, Err(Error::Unsupported("search"))));
}
fn block_on_ready<F: std::future::Future>(fut: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let mut fut = std::pin::pin!(fut);
match fut.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
Poll::Ready(out) => out,
Poll::Pending => panic!("future was not immediately ready"),
}
}
}