use crate::ids::{BlockId, DatabaseId}; use crate::models::search::{DatabaseQuery, SearchRequest}; use crate::models::{Block, Database, ListResponse, Object, Page}; use ids::AsIdentifier; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::{header, Client, ClientBuilder, RequestBuilder}; pub mod ids; pub mod models; use crate::models::error::ErrorResponse; pub use chrono; const NOTION_API_VERSION: &str = "2021-08-16"; /// An wrapper Error type for all errors produced by the [`NotionApi`](NotionApi) client. #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Invalid Notion API Token: {}", source)] InvalidApiToken { source: reqwest::header::InvalidHeaderValue, }, #[error("Unable to build reqwest HTTP client: {}", source)] ErrorBuildingClient { source: reqwest::Error }, #[error("Error sending HTTP request: {}", source)] RequestFailed { #[from] source: reqwest::Error, }, #[error("Error reading response: {}", source)] ResponseIoError { source: reqwest::Error }, #[error("Error parsing json response: {}", source)] JsonParseError { source: serde_json::Error }, #[error("Unexpected API Response")] UnexpectedResponse { response: Object }, #[error("API Error {}({}): {}", .error.code, .error.status, .error.message)] ApiError { error: ErrorResponse }, } /// An API client for Notion. /// Create a client by using [new(api_token: String)](Self::new()). #[derive(Clone)] pub struct NotionApi { client: Client, } impl NotionApi { /// Creates an instance of NotionApi. /// May fail if the provided api_token is an improper value. 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)) .map_err(|source| Error::InvalidApiToken { source })?; auth_value.set_sensitive(true); headers.insert(header::AUTHORIZATION, auth_value); let client = ClientBuilder::new() .default_headers(headers) .build() .map_err(|source| Error::ErrorBuildingClient { source })?; Ok(Self { client }) } async fn make_json_request(&self, request: RequestBuilder) -> Result { let request = request.build()?; let url = request.url(); tracing::trace!( method = request.method().as_str(), url = url.as_str(), "Sending request" ); let json = self .client .execute(request) .await .map_err(|source| Error::RequestFailed { source })? .text() .await .map_err(|source| Error::ResponseIoError { source })?; tracing::debug!("JSON Response: {}", json); #[cfg(test)] { dbg!(serde_json::from_str::(&json) .map_err(|source| Error::JsonParseError { source })?); } let result = serde_json::from_str(&json).map_err(|source| Error::JsonParseError { source })?; match result { Object::Error { error } => Err(Error::ApiError { error }), response => Ok(response), } } /// List all the databases shared with the supplied integration token. /// > This method is apparently deprecated/"not recommended" and /// > [search()](Self::search()) should be used instead. pub async fn list_databases(&self) -> Result, Error> { let builder = self.client.get("https://api.notion.com/v1/databases"); match self.make_json_request(builder).await? { Object::List { list } => Ok(list.expect_databases()?), response => Err(Error::UnexpectedResponse { response }), } } /// Search all pages in notion. /// `query` can either be a [SearchRequest] or a slightly more convenient /// [NotionSearch](models::search::NotionSearch) query. pub async fn search>( &self, query: T, ) -> Result, Error> { let result = self .make_json_request( self.client .post("https://api.notion.com/v1/search") .json(&query.into()), ) .await?; match result { Object::List { list } => Ok(list), response => Err(Error::UnexpectedResponse { response }), } } /// Get a database by [DatabaseId]. pub async fn get_database>( &self, database_id: T, ) -> Result { let result = self .make_json_request(self.client.get(format!( "https://api.notion.com/v1/databases/{}", database_id.as_id() ))) .await?; match result { Object::Database { database } => Ok(database), response => Err(Error::UnexpectedResponse { response }), } } /// Query a database and return the matching pages. pub async fn query_database( &self, database: D, query: T, ) -> Result, Error> where T: Into, D: AsIdentifier, { let result = self .make_json_request( self.client .post(&format!( "https://api.notion.com/v1/databases/{database_id}/query", database_id = database.as_id() )) .json(&query.into()), ) .await?; match result { Object::List { list } => Ok(list.expect_pages()?), response => Err(Error::UnexpectedResponse { response }), } } pub async fn get_block_children>( &self, block_id: T, ) -> Result, Error> { let result = self .make_json_request(self.client.get(&format!( "https://api.notion.com/v1/blocks/{block_id}/children", block_id = block_id.as_id() ))) .await?; match result { Object::List { list } => Ok(list.expect_blocks()?), response => Err(Error::UnexpectedResponse { response }), } } } #[cfg(test)] mod tests { use crate::ids::BlockId; use crate::models::search::PropertyCondition::Text; use crate::models::search::{ DatabaseQuery, FilterCondition, FilterProperty, FilterValue, NotionSearch, TextCondition, }; use crate::models::Object; use crate::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 { std::env::set_var("RUST_LOG", "notion"); 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(()) } }