use crate::{entity::EntityType, Language, RestApi, RestApiError};
use nutype::nutype;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[nutype(
validate(greater_or_equal = 1, less_or_equal = 500),
derive(Debug, Display, Clone, PartialEq)
)]
pub struct SearchLimit(u16);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchResultText {
language: String,
value: String,
}
impl SearchResultText {
pub fn language(&self) -> &str {
&self.language
}
pub fn value(&self) -> &str {
&self.value
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchResultMatch {
#[serde(rename = "type")]
match_type: String,
language: String,
text: String,
}
impl SearchResultMatch {
pub fn language(&self) -> &str {
&self.language
}
pub fn text(&self) -> &str {
&self.text
}
pub fn match_type(&self) -> &str {
&self.match_type
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchResult {
id: String,
#[serde(rename = "display-label")]
display_label: Option<SearchResultText>,
description: Option<SearchResultText>,
#[serde(rename = "match")]
search_match: SearchResultMatch,
}
impl SearchResult {
pub fn id(&self) -> &str {
&self.id
}
pub const fn display_label(&self) -> Option<&SearchResultText> {
self.display_label.as_ref()
}
pub const fn description(&self) -> Option<&SearchResultText> {
self.description.as_ref()
}
pub const fn search_match(&self) -> &SearchResultMatch {
&self.search_match
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum SearchKind {
Search,
Suggest,
}
impl SearchKind {
const fn path_prefix(self) -> &'static str {
match self {
SearchKind::Search => "search",
SearchKind::Suggest => "suggest",
}
}
}
#[derive(Debug)]
pub struct Search {
entity_type: EntityType,
kind: SearchKind,
q: String,
language: Language,
limit: Option<SearchLimit>,
offset: Option<usize>,
}
impl Search {
pub fn items<S: Into<String>>(q: S, language: Language) -> Self {
Self {
entity_type: EntityType::Item,
kind: SearchKind::Search,
q: q.into(),
language,
limit: None,
offset: None,
}
}
pub fn properties<S: Into<String>>(q: S, language: Language) -> Self {
Self {
entity_type: EntityType::Property,
kind: SearchKind::Search,
q: q.into(),
language,
limit: None,
offset: None,
}
}
pub fn suggest_items<S: Into<String>>(q: S, language: Language) -> Self {
Self {
entity_type: EntityType::Item,
kind: SearchKind::Suggest,
q: q.into(),
language,
limit: None,
offset: None,
}
}
pub fn suggest_properties<S: Into<String>>(q: S, language: Language) -> Self {
Self {
entity_type: EntityType::Property,
kind: SearchKind::Suggest,
q: q.into(),
language,
limit: None,
offset: None,
}
}
pub const fn with_limit(mut self, limit: SearchLimit) -> Self {
self.limit = Some(limit);
self
}
pub const fn with_offset(mut self, offset: usize) -> Self {
self.offset = Some(offset);
self
}
async fn generate_json_request(&self, api: &RestApi) -> Result<reqwest::Request, RestApiError> {
let path = self.get_my_rest_api_path();
let mut params = HashMap::new();
params.insert("q".to_string(), self.q.to_string());
params.insert("language".to_string(), self.language.to_string());
if let Some(limit) = &self.limit {
params.insert("limit".to_string(), format!("{limit}"));
}
if let Some(offset) = &self.offset {
params.insert("offset".to_string(), offset.to_string());
}
let mut request = api
.wikibase_request_builder(&path, params, reqwest::Method::GET)
.await?
.build()?;
request
.headers_mut()
.insert(reqwest::header::CONTENT_TYPE, "application/json".parse()?);
Ok(request)
}
pub async fn get(&self, api: &RestApi) -> Result<Vec<SearchResult>, RestApiError> {
let request = self.generate_json_request(api).await?;
let response = api.execute(request).await?;
let response = self.filter_response_error(response).await?;
Self::response_to_results(response)
}
fn response_to_results(response: Value) -> Result<Vec<SearchResult>, RestApiError> {
let results = response["results"]
.as_array()
.ok_or(RestApiError::MissingResults)?
.iter()
.filter_map(|result| serde_json::from_value(result.clone()).ok())
.collect();
Ok(results)
}
fn get_my_rest_api_path(&self) -> String {
format!(
"/{prefix}/{group}",
prefix = self.kind.path_prefix(),
group = self.entity_type.group_name()
)
}
async fn filter_response_error(
&self,
response: reqwest::Response,
) -> Result<Value, RestApiError> {
if !response.status().is_success() {
return Err(RestApiError::from_response(response).await);
}
let j: Value = response.error_for_status()?.json().await?;
Ok(j)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_my_rest_api_path() {
let en = Language::try_new("en").unwrap();
assert_eq!(
Search::items("foo", en.clone()).get_my_rest_api_path(),
"/search/items"
);
assert_eq!(
Search::properties("foo", en.clone()).get_my_rest_api_path(),
"/search/properties"
);
assert_eq!(
Search::suggest_items("foo", en.clone()).get_my_rest_api_path(),
"/suggest/items"
);
assert_eq!(
Search::suggest_properties("foo", en).get_my_rest_api_path(),
"/suggest/properties"
);
}
#[test]
#[cfg_attr(miri, ignore)]
fn test_response_to_results() {
let v = std::fs::read_to_string("test_data/test_search_response.json").unwrap();
let v: Value = serde_json::from_str(&v).unwrap();
let results = Search::response_to_results(v).unwrap();
assert_eq!(results.len(), 4);
assert_eq!(results[0].id(), "Q123");
assert_eq!(results[1].id(), "Q234");
assert_eq!(results[2].id(), "Q345");
assert_eq!(results[3].id(), "Q456");
assert_eq!(results[0].display_label().unwrap().value(), "potato");
assert_eq!(results[0].description().unwrap().value(), "staple food");
assert_eq!(results[1].display_label().unwrap().value(), "potato");
assert_eq!(
results[1].description().unwrap().value(),
"species of plant"
);
assert!(results[2].description().is_none());
assert!(results[3].display_label().is_none());
assert_eq!(results[0].search_match().match_type(), "label");
assert_eq!(results[1].search_match().match_type(), "label");
assert_eq!(results[2].search_match().match_type(), "label");
assert_eq!(results[3].search_match().match_type(), "description");
}
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn test_search() {
let query = "Magnus Manske";
let language = Language::try_new("en").unwrap();
let api = RestApi::builder("https://www.wikidata.org/w/rest.php")
.unwrap()
.build();
let results = Search::items(query, language).get(&api).await.unwrap();
assert!(results
.iter()
.map(|result| result.id())
.any(|id| id == "Q13520818"));
assert!(results
.iter()
.map(|result| result.id())
.any(|id| id == "Q10995651"));
}
}