From 0a905f1092d4caff77cedffd0dc68261a5fda121 Mon Sep 17 00:00:00 2001 From: Bram Dingelstad Date: Sun, 29 Jan 2023 13:15:41 +0100 Subject: [PATCH] first commit --- .gitignore | 2 + Cargo.toml | 15 + LICENSE | 21 ++ README.md | 2 + src/lib.rs | 1057 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1097 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b3adb3b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "notion-client" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +base64 = "0.21.0" +chrono = "0.4.23" +lazy_static = "1.4.0" +regex = "1.7.1" +reqwest = { version = "0.11.14", features = ["json"] } +serde = { version = "1.0.152", features = ["derive"] } +serde_json = "1.0.91" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7cb4644 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Bram Dingelstad + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd05dc9 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# notion-client-rs +A Notion Client written in Rust diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..23a14ab --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1057 @@ +use std::collections::HashMap; + +// TODO: Add the ability to hack into the code or add queuing + +use regex::Regex; +use serde_json::Value; +use chrono::{DateTime, Utc}; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; +use reqwest::header::{HeaderMap, HeaderValue}; + +lazy_static! { + static ref ISO_8601_DATE : Regex = Regex::new(r"^\d{4}-\d{2}-\d{2}$") + .expect("ISO 8601 date regex to be parseable"); +} + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + HttpError(reqwest::Error), + DeserializationError(serde_json::Error, Option), + HeaderError(reqwest::header::InvalidHeaderValue), + ChronoParseError(chrono::ParseError), + NoSuchProperty(String), + Unknown +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Error::HttpError(error) + } +} + +impl From for Error { + fn from(error: reqwest::header::InvalidHeaderValue) -> Self { + Error::HeaderError(error) + } +} + +impl From for Error { + fn from(error: serde_json::Error) -> Self { + Error::DeserializationError(error, None) + } +} + +impl From for Error { + fn from(error: chrono::ParseError) -> Self { + Error::ChronoParseError(error) + } +} + +// TODO: Convert to macro? +// TODO: Investigate if I need to add a case for Some(Value::Null) instead of None +fn parse Deserialize<'de>>(key: &str, data: &Value) -> Result { + Ok( + serde_json::from_value::( + data.get(key).ok_or_else(|| Error::NoSuchProperty(key.to_string()))?.clone() + )? + ) +} + +const NOTION_VERSION: &str = "2022-06-28"; + +fn get_http_client(notion_api_key: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert("Authorization", HeaderValue::from_str(&format!("Bearer {notion_api_key}"))?); + headers.insert("Notion-Version", HeaderValue::from_str(NOTION_VERSION)?); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + + Ok( + reqwest::ClientBuilder::new() + .default_headers(headers) + .build()? + ) +} + +pub struct Client { + token: String, +} + +impl Client { + pub fn new(notion_api_key: &str) -> Result { + get_http_client(notion_api_key)?; + + Ok( + Client { + token: notion_api_key.to_owned(), + } + ) + } + + pub fn pages(&self) -> Pages { + Pages { + token: self.token.to_owned() + } + } + + pub fn blocks(&self) -> Blocks { + Blocks { + token: self.token.to_owned() + } + } + + pub fn databases(&self) -> Databases { + Databases { + token: self.token.to_owned() + } + } +} + +pub struct PageOptions<'a> { + pub page_id: &'a str +} + +pub struct Pages { + token: String +} + +impl Pages { + pub async fn retrieve(self, options: PageOptions<'_>) -> Result { + let url = format!("https://api.notion.com/v1/pages/{page_id}", page_id = options.page_id); + + let request = get_http_client(&self.token)? + .get(url) + .send() + .await?; + + match request.error_for_status_ref() { + Ok(_) => Ok(request.json().await?), + Err(error) => { + println!("Error: {error:#?}"); + println!("Body: {:#?}", request.json::().await?); + Err(Error::HttpError(error)) + } + } + } +} + +pub struct Blocks { + token: String, +} + +impl Blocks { + pub fn children(&self) -> BlockChildren { + BlockChildren { + token: self.token.to_owned() + } + } +} + +pub struct BlockChildren { + token: String +} + +pub struct BlockChildrenListOptions<'a> { + pub block_id: &'a str +} + +impl BlockChildren { + pub async fn list(self, options: BlockChildrenListOptions<'_>) -> Result> { + let url = format!("https://api.notion.com/v1/blocks/{block_id}/children", block_id = options.block_id); + + let request = get_http_client(&self.token)? + .get(&url) + .send() + .await?; + + match request.error_for_status_ref() { + Ok(_) => { + Ok(request.json().await?) + }, + Err(error) => { + println!("Error: {error:#?}"); + println!("Body: {:#?}", request.json::().await?); + Err(Error::HttpError(error)) + } + } + } +} + +pub struct Databases { + token: String +} + +impl Databases { + pub async fn query(self, options: DatabaseQueryOptions) -> Result> { + let url = format!("https://api.notion.com/v1/databases/{database_id}/query", database_id = options.database_id); + + let request = get_http_client(&self.token)? + .post(url) + .send() + .await?; + + match request.error_for_status_ref() { + Ok(_) => Ok(request.json().await?), + Err(error) => { + println!("Error: {error:#?}"); + println!("Body: {:#?}", request.json::().await?); + Err(Error::HttpError(error)) + } + } + } +} + +pub struct DatabaseQueryOptions { + pub database_id: String, + pub filter: Option // TODO: Implement spec for filter +} + + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(try_from = "Value")] +pub struct Block { + pub id: String, + pub parent: Parent, + pub created_time: DateValue, + pub last_edited_time: DateValue, + pub created_by: PartialUser, + pub last_edited_by: PartialUser, + pub has_children: bool, + pub archived: bool, + pub value: BlockType +} + +impl TryFrom for Block { + type Error = Error; + + fn try_from(data: Value) -> Result { + Ok( + Block { + id: parse("id", &data)?, + parent: parse("parent", &data)?, + created_time: parse("created_time", &data)?, + last_edited_time: parse("last_edited_time", &data)?, + created_by: parse("created_by", &data)?, + last_edited_by: parse("last_edited_by", &data)?, + has_children: parse("has_children", &data)?, + archived: parse("archived", &data)?, + value: match parse::("type", &data)?.as_str() { + "heading_1" => BlockType::Heading1(parse("heading_1", &data)?), + "heading_2" => BlockType::Heading2(parse("heading_2", &data)?), + "heading_3" => BlockType::Heading3(parse("heading_3", &data)?), + "paragraph" => BlockType::Paragraph(parse("paragraph", &data)?), + "child_database" => BlockType::ChildDatabase(parse("child_database", &data)?), + "child_page" => BlockType::ChildPage(parse("child_page", &data)?), + "code" => BlockType::Code(parse("code", &data)?), + "bulleted_list_item" => BlockType::BulletedListItem(parse("bulleted_list_item", &data)?), + "numbered_list_item" => BlockType::NumberedListItem(parse("numbered_list_item", &data)?), + "quote" => BlockType::Quote(parse("quote", &data)?), + "callout" => BlockType::Callout(parse("callout", &data)?), + "to_do" => BlockType::ToDo(parse("to_do", &data)?), + "image" => BlockType::Image(serde_json::from_value(data)?), + "column_list" => BlockType::ColumnList(parse("column_list", &data)?), + "column" => BlockType::Column(parse("column", &data)?), + + string => BlockType::Unsupported(string.to_string()) + } + } + ) + } +} + + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[allow(unused)] +pub enum BlockType { + Paragraph(Paragraph), + BulletedListItem(ListItem), + NumberedListItem(ListItem), + ToDo(ToDoItem), + Quote(Quote), + Callout(Callout), + ChildPage(ChildPage), + ChildDatabase(ChildDatabase), + Heading1(Heading), + Heading2(Heading), + Heading3(Heading), + Code(Code), + Image(Image), + Video(Video), + File(FileBlock), + PDF(PDF), + ColumnList(ColumnList), + Column(Column), + Unsupported(String), + + // TODO: Implement + Toggle, + SyncedBlock, + Template, + Table, + Bookmark, + Divider, + TableOfContents +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Heading { + pub color: Color, + pub rich_text: Vec, + pub is_toggleable: bool +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PDF { + pub pdf: File +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FileBlock { + pub file: File, + pub caption: Vec +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Image { + pub image: File +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Video { + pub video: File +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ColumnList { + pub children: Option> +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Column { + pub children: Option> +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Callout { + pub icon: Option, + pub color: Color, + pub rich_text: Vec, + pub children: Option> +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Quote { + pub color: Color, + pub rich_text: Vec, + pub children: Option> +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ToDoItem { + pub color: Color, + pub rich_text: Vec, + pub checked: Option, + pub children: Option> +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ListItem { + pub color: Color, + pub rich_text: Vec, + pub children: Option> +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Paragraph { + pub color: Color, + pub rich_text: Vec +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Code { + pub language: CodeLanguage, + pub caption: Vec, + pub rich_text: Vec +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum CodeLanguage { + #[serde(rename = "abap")] + ABAP, + Arduino, + Bash, + Basic, + C, + Clojure, + #[serde(rename = "coffeescript")] + CoffeeScript, + #[serde(rename = "c++")] + Cpp, + #[serde(rename = "c#")] + CSharp, + #[serde(rename = "css")] + CSS, + Dart, + Diff, + Docker, + Elixer, + Elm, + Erlang, + Flow, + Fortan, + #[serde(rename = "f#")] + FSharp, + Gherkin, + #[serde(rename = "glsl")] + GLSL, + Go, + #[serde(rename = "graphql")] + GraphQL, + Groovy, + Haskell, + #[serde(rename = "html")] + HTML, + Java, + #[serde(rename = "javascript")] + JavaScript, + #[serde(rename = "json")] + JSON, + Julia, + Kotlin, + Latex, + Less, + Lisp, + #[serde(rename = "livescript")] + LiveScript, + Lua, + #[serde(rename = "makefile")] + MakeFile, + Markdown, + Markup, + Matlab, + Mermaid, + Nix, + #[serde(rename = "objective-c")] + ObjectiveC, + #[serde(rename = "ocaml")] + OCaml, + Pascal, + Perl, + #[serde(rename = "php")] + PHP, + #[default] + #[serde(rename = "plain text")] + PlainText, + #[serde(rename = "powershell")] + PowerShell, + Prolog, + Protobuf, + Python, + R, + Reason, + Ruby, + Rust, + Sass, + Scala, + Scheme, + #[serde(rename = "scss")] + SCSS, + Shell, + #[serde(rename = "sql")] + SQL, + Swift, + #[serde(rename = "typescript")] + TypeScript, + #[serde(rename = "vb.net")] + VBNet, + Verilog, + #[serde(rename = "vhdl")] + VHDL, + #[serde(rename = "visual basic")] + VisualBasic, + #[serde(rename = "webassembly")] + WebAssembly, + #[serde(rename = "xml")] + XML, + #[serde(rename = "yaml")] + YAML, + #[serde(rename = "java/c/c++/c#")] + JavaCCppCSharp +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChildPage { + pub title: String +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChildDatabase { + pub title: String +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct User { + pub id: String, + pub name: String, + pub person: Option, + pub avatar_url: Option +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Workspace { + pub workspace: bool +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Person { + pub email: String +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Database { + pub id: String, + pub title: Vec, + pub description: Vec, + pub properties: Properties, + pub url: String, + + pub parent: Parent, + pub created_time: DateValue, + pub last_edited_time: DateValue, + pub last_edited_by: PartialUser, + pub icon: Option, + pub cover: Option, + pub archived: bool, + pub is_inline: bool +} + +// TODO: Paginate all possible responses +#[derive(Debug, Serialize, Deserialize, Clone)] +#[allow(unused)] +pub struct QueryResponse { + pub has_more: bool, + pub next_cursor: Option, + pub results: Vec +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[allow(unused)] +pub struct Page { + pub id: String, + pub created_by: PartialUser, + pub url: String, + pub parent: Parent, + + pub created_time: DateValue, + pub last_edited_time: DateValue, + + pub cover: Option, + pub icon: Option, + + pub properties: Properties, + + pub archived: bool +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PartialUser { + pub id: String +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(try_from = "Value")] +pub struct Properties { + pub map: HashMap +} + +impl Properties { + pub fn get(&self, key: &str) -> Option { + match self.map.get(key) { + Some(property) => Some(property.to_owned()), + None => None + } + } + + pub fn keys(&self) -> Vec { + self.map.keys() + .map(|key| key.to_string()) + .collect() + } +} + +impl TryFrom for Properties { + type Error = Error; + + fn try_from(data: Value) -> Result { + let mut map = HashMap::new(); + + for key in data.as_object().unwrap().keys() { + map.insert(key.to_owned(), parse(key, &data)?); + } + + Ok(Properties { map }) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum PropertyResponse { + List(Vec), + PropertyItem(Box) +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[allow(unused)] +pub struct PartialProperty { + pub id: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[allow(unused)] +pub enum PropertyType { + RichText(Vec), + Number, + Select(Select), + MultiSelect(MultiSelect), + Date(Date), + Formula(Formula), + Relation, + Rollup, + Title(Vec), + People, + Files, + Checkbox(bool), + Url, + Email, + PhoneNumber, + CreatedTime, + CreatedBy, + LastEditedTime, + LastEditedBy, + Unknown +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(try_from = "Value")] +pub enum Formula { + String(Option), + Number(Option), + Boolean(Option), + Date(Option), + Unknown +} + +impl TryFrom for Formula { + type Error = Error; + + fn try_from(data: Value) -> Result { + Ok( + match parse::("type", &data)?.as_str() { + "string" => Formula::String(parse("string", &data)?), + "number" => Formula::Number(parse("number", &data)?), + "boolean" => Formula::Boolean(parse("boolean", &data)?), + "date" => Formula::Date(parse("date", &data)?), + _ => Formula::Unknown + } + ) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(try_from = "Value")] +pub struct Select(pub SelectOption); + +impl TryFrom for Select { + type Error = Error; + + fn try_from(data: Value) -> Result