use crate::models::search::{DatabaseQuery, SearchRequest}; use crate::models::{Block, BlockId, Database, DatabaseId, ListResponse, Object, Page}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::{header, Client, ClientBuilder, RequestBuilder}; use serde::de::DeserializeOwned; use snafu::{ResultExt, Snafu}; pub mod models; const NOTION_API_VERSION: &str = "2021-05-13"; #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("Invalid Notion API Token: {}", source))] InvalidApiToken { source: reqwest::header::InvalidHeaderValue, }, #[snafu(display("Unable to build reqwest HTTP client: {}", source))] ErrorBuildingClient { source: reqwest::Error }, #[snafu(display("Error sending HTTP request: {}", source))] RequestFailed { source: reqwest::Error }, #[snafu(display("Error reading response: {}", source))] ResponseError { source: reqwest::Error }, #[snafu(display("Error parsing json response: {}", source))] JsonParseError { source: serde_json::Error }, } pub trait Identifiable { // There should only be one way to identify an object type Type; fn id(&self) -> &Self::Type; } pub struct NotionApi { client: Client, } impl NotionApi { pub fn new(api_token: String) -> Result { let mut headers = HeaderMap::new(); headers.insert( "Notion-Version", HeaderValue::from_static(NOTION_API_VERSION), ); let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", api_token)).context(InvalidApiToken)?; auth_value.set_sensitive(true); headers.insert(header::AUTHORIZATION, auth_value); let client = ClientBuilder::new() .default_headers(headers) .build() .context(ErrorBuildingClient)?; Ok(Self { client }) } async fn make_json_request(request: RequestBuilder) -> Result where T: DeserializeOwned, { let json = request .send() .await .context(RequestFailed)? .text() .await .context(ResponseError)?; #[cfg(test)] { println!("JSON: {}", json); dbg!(serde_json::from_str::(&json).context(JsonParseError)?); } let result = serde_json::from_str(&json).context(JsonParseError)?; Ok(result) } /// This method is apparently deprecated/"not recommended" pub async fn list_databases(&self) -> Result, Error> { let builder = self.client.get("https://api.notion.com/v1/databases"); Ok(NotionApi::make_json_request(builder).await?) } pub async fn search>( &self, query: T, ) -> Result, Error> { Ok(NotionApi::make_json_request( self.client .post("https://api.notion.com/v1/search") .json(&query.into()), ) .await?) } pub async fn get_database>( &self, database_id: T, ) -> Result { Ok(NotionApi::make_json_request(self.client.get(format!( "https://api.notion.com/v1/databases/{}", database_id.id().id() ))) .await?) } pub async fn query_database( &self, database: D, query: T, ) -> Result, Error> where T: Into, D: Identifiable, { Ok(NotionApi::make_json_request( self.client .post(&format!( "https://api.notion.com/v1/databases/{database_id}/query", database_id = database.id() )) .json(&query.into()), ) .await?) } pub async fn get_block_children>( &self, block_id: T, ) -> Result, Error> { Ok(NotionApi::make_json_request(self.client.get(&format!( "https://api.notion.com/v1/blocks/{block_id}/children", block_id = block_id.id() ))) .await?) } } #[cfg(test)] mod tests { use crate::models::search::PropertyCondition::Text; use crate::models::search::{ DatabaseQuery, FilterCondition, FilterProperty, FilterValue, NotionSearch, TextCondition, }; use crate::models::{BlockId, Object}; use crate::{Identifiable, NotionApi}; fn test_token() -> String { let token = { if let Some(token) = std::env::var("NOTION_API_TOKEN").ok() { token } else if let Some(token) = std::fs::read_to_string(".api_token").ok() { token } else { panic!("No API Token found in environment variable 'NOTION_API_TOKEN'!") } }; token.trim().to_string() } fn test_client() -> NotionApi { NotionApi::new(test_token()).unwrap() } #[tokio::test] async fn list_databases() -> Result<(), Box> { let api = test_client(); dbg!(api.list_databases().await?); Ok(()) } #[tokio::test] async fn search_databases() -> Result<(), Box> { let api = test_client(); let response = api .search(NotionSearch::Filter { property: FilterProperty::Object, value: FilterValue::Database, }) .await?; assert!(response.results.len() > 0); Ok(()) } #[tokio::test] async fn search_pages() -> Result<(), Box> { let api = test_client(); let response = api .search(NotionSearch::Filter { property: FilterProperty::Object, value: FilterValue::Page, }) .await?; assert!(response.results.len() > 0); Ok(()) } #[tokio::test] async fn get_database() -> Result<(), Box> { let api = test_client(); let response = api .search(NotionSearch::Filter { value: FilterValue::Database, property: FilterProperty::Object, }) .await?; let db = response .results() .iter() .filter_map(|o| match o { Object::Database { database } => Some(database), _ => None, }) .next() .expect("Test expected to find at least one database in notion") .clone(); // todo: fix this clone issue let db_result = api.get_database(db.clone()).await?; assert_eq!(db, db_result); Ok(()) } #[tokio::test] async fn get_block_children() -> Result<(), Box> { let api = test_client(); let search_response = api .search(NotionSearch::Filter { value: FilterValue::Page, property: FilterProperty::Object, }) .await?; println!("{:?}", search_response.results.len()); for object in search_response.results { match object { Object::Page { page } => api .get_block_children(BlockId::from(page.id())) .await .unwrap(), _ => panic!("Should not have received anything but pages!"), }; } Ok(()) } #[tokio::test] async fn query_database() -> Result<(), Box> { let api = test_client(); let response = api .search(NotionSearch::Filter { value: FilterValue::Database, property: FilterProperty::Object, }) .await?; let db = response .results() .iter() .filter_map(|o| match o { Object::Database { database } => Some(database), _ => None, }) .next() .expect("Test expected to find at least one database in notion") .clone(); let pages = api .query_database( db, DatabaseQuery { filter: Some(FilterCondition { property: "Name".to_string(), condition: Text(TextCondition::Contains("First".to_string())), }), ..Default::default() }, ) .await?; assert_eq!(pages.results().len(), 1); Ok(()) } }