notion-client (empty) → 0.1.0.0
raw patch · 26 files changed
+3252/−0 lines, 26 filesdep +aesondep +basedep +base16-bytestring
Dependencies added: aeson, base, base16-bytestring, bytestring, containers, cryptohash-sha256, filepath, http-api-data, http-client, http-client-tls, notion-client, servant, servant-client, servant-multipart-api, servant-multipart-client, tasty, tasty-hunit, text, time, time-compat, unordered-containers, vector
Files
- CHANGELOG.md +16/−0
- LICENSE +21/−0
- README.md +114/−0
- notion-client-example/Blocks.hs +67/−0
- notion-client-example/Console.hs +39/−0
- notion-client-example/DatabaseDemo.hs +344/−0
- notion-client-example/Main.hs +131/−0
- notion-client-example/PageDemo.hs +189/−0
- notion-client-example/UserDemo.hs +29/−0
- notion-client.cabal +132/−0
- src/Notion/Prelude.hs +129/−0
- src/Notion/V1.hs +191/−0
- src/Notion/V1/Blocks.hs +95/−0
- src/Notion/V1/Comments.hs +103/−0
- src/Notion/V1/Common.hs +191/−0
- src/Notion/V1/DataSources.hs +133/−0
- src/Notion/V1/Databases.hs +168/−0
- src/Notion/V1/Error.hs +21/−0
- src/Notion/V1/ListOf.hs +30/−0
- src/Notion/V1/Pages.hs +217/−0
- src/Notion/V1/Pagination.hs +27/−0
- src/Notion/V1/RichText.hs +207/−0
- src/Notion/V1/Search.hs +141/−0
- src/Notion/V1/Users.hs +107/−0
- src/Notion/V1/Webhooks.hs +340/−0
- tasty/Main.hs +70/−0
+ CHANGELOG.md view
@@ -0,0 +1,16 @@+# Changelog for notion-client++## 0.1.0.0 (2026-02-28)++* Initial release+* Support for core Notion API endpoints:+ * Databases+ * Data Sources+ * Pages+ * Blocks+ * Users+ * Search+ * Comments+ * Webhooks (event types and signature verification)+* Type-safe client with Servant-based implementation+* Targets Notion API version 2025-09-03
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2024 Nadeem Bitar++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.
+ README.md view
@@ -0,0 +1,114 @@+# Notion API Client for Haskell++A type-safe Haskell client for the [Notion API](https://developers.notion.com/reference/intro) (version `2025-09-03`).++## Features++- Type-safe API bindings using Servant+- Comprehensive coverage of Notion API endpoints+- Support for all Notion object types: Pages, Databases, Data Sources, Blocks, Users, etc.+- Simple client interface with sensible defaults++## Installation++Add to your `package.yaml` or `.cabal` file:++```yaml+dependencies:+ - notion-client+```++## Usage++Here's a simple example of retrieving a Notion page:++```haskell+module Main where++import Notion.V1+import Notion.V1.Pages+import Data.Text qualified as Text+import System.Environment qualified as Environment++main :: IO ()+main = do+ token <- Environment.getEnv "NOTION_TOKEN"++ clientEnv <- getClientEnv "https://api.notion.com/v1"++ let Methods{ retrievePage } = makeMethods clientEnv (Text.pack token)++ page <- retrievePage "page-id-here"++ print page+```++### Creating a page++```haskell+import Notion.V1+import Notion.V1.Common (Parent(..))+import Notion.V1.Pages+import Data.Map qualified as Map+import Data.Aeson (toJSON)++createNewPage :: Methods -> IO PageObject+createNewPage Methods{createPage} = do+ let pageProperties = Map.fromList+ [ ("title", PropertyValue+ { type_ = Title+ , value = Just $ toJSON [-- rich text objects --]+ })+ ]++ newPage = mkCreatePage+ (DataSourceParent { dataSourceId = "data-source-id-here" })+ pageProperties++ createPage newPage+```++## API Coverage++- Databases: Create, retrieve, and update databases+- Data Sources: Create, retrieve, update, and query data sources+- Pages: Create, retrieve, and update pages+- Blocks: Retrieve, update, append children, and delete blocks+- Users: List, retrieve users and bot users+- Search: Search for pages and data sources+- Comments: Create and list comments+- Webhooks: Event types and signature verification++## Running the Example++The repository includes a comprehensive example in the `notion-client-example` directory that demonstrates how to use most API endpoints.++To run the example:++```bash+# Set required environment variables+export NOTION_TOKEN="your-integration-token"++# Optional: Set these if you want to test specific database/page endpoints+export NOTION_TEST_DATABASE_ID="your-database-id"+export NOTION_TEST_PAGE_ID="your-page-id"++# Run the example+cabal run notion-client-example+```++### Obtaining API Credentials++1. Create a Notion integration at [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations)+2. Get your integration token from the integration settings+3. Share any Notion pages or databases you want to access with your integration+ - Open the page/database in Notion+ - Click "Share" in the top right+ - Enter your integration name and click "Invite"+4. Get the page/database IDs from their URLs:+ - Page URL: `https://www.notion.so/Your-Page-Title-83715d7c1111424aaa11d7fc1111bd2a`+ - Page ID: `83715d7c1111424aaa11d7fc1111bd2a` (the last part of the URL)++## License++BSD-3-Clause
+ notion-client-example/Blocks.hs view
@@ -0,0 +1,67 @@+-- |+-- Helper functions for creating Notion block JSON objects.+--+-- These functions create JSON objects that match the Notion API's block format.+module Blocks+ ( createParagraphBlock,+ createHeadingBlock,+ createBulletedListItemBlock,+ )+where++import Data.Aeson qualified as Aeson+import Data.String (fromString)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Vector qualified as Vector++-- | Create a paragraph block with text content+-- @param content The text content to include in the paragraph+-- @return A JSON representation of a paragraph block+createParagraphBlock :: Text -> Aeson.Value+createParagraphBlock content =+ let -- Structure: {"text": {"content": "Your text here"}}+ textObj = Aeson.object [("content", Aeson.String content)]+ textItem = Aeson.object [("text", textObj)]+ -- Rich text is always an array of text items+ richText = Aeson.Array (Vector.singleton textItem)+ -- The paragraph property contains rich_text array+ paragraphContent = Aeson.object [("rich_text", richText)]+ in Aeson.object+ [ ("type", Aeson.String "paragraph"), -- Block type+ ("paragraph", paragraphContent) -- Block content+ ]++-- | Create a heading block with text content+-- @param content The text content for the heading+-- @param level The heading level (1-3)+-- @return A JSON representation of a heading block+createHeadingBlock :: Text -> Int -> Aeson.Value+createHeadingBlock content level =+ let -- Create the same rich text structure as paragraph+ textObj = Aeson.object [("content", Aeson.String content)]+ textItem = Aeson.object [("text", textObj)]+ richText = Aeson.Array (Vector.singleton textItem)+ -- Headings use heading_1, heading_2, or heading_3 as type+ headingType = "heading_" <> Text.pack (show level)+ headingContent = Aeson.object [("rich_text", richText)]+ in Aeson.object+ [ ("type", Aeson.String headingType), -- Block type+ (fromString (Text.unpack headingType), headingContent) -- Block content+ ]++-- | Create a bulleted list item block with text content+-- @param content The text content for the list item+-- @return A JSON representation of a bulleted list item block+createBulletedListItemBlock :: Text -> Aeson.Value+createBulletedListItemBlock content =+ let -- Create the same rich text structure as other blocks+ textObj = Aeson.object [("content", Aeson.String content)]+ textItem = Aeson.object [("text", textObj)]+ richText = Aeson.Array (Vector.singleton textItem)+ -- List items are structured the same way as paragraphs+ listContent = Aeson.object [("rich_text", richText)]+ in Aeson.object+ [ ("type", Aeson.String "bulleted_list_item"), -- Block type+ ("bulleted_list_item", listContent) -- Block content+ ]
+ notion-client-example/Console.hs view
@@ -0,0 +1,39 @@+-- |+-- Console output helpers for the example application.+module Console+ ( printHeader,+ logError,+ printSuccess,+ runTest,+ )+where++import Data.Text (Text)+import Data.Text qualified as Text+import System.Exit (exitFailure)+import System.IO (hPutStrLn, stderr)++-- | Print a section header+printHeader :: Text -> IO ()+printHeader title = do+ putStrLn ""+ putStrLn $ "=== " <> Text.unpack title <> " ==="+ putStrLn ""++-- | Log an error and exit+logError :: Text -> IO a+logError msg = do+ hPutStrLn stderr $ "ERROR: " <> Text.unpack msg+ exitFailure++-- | Print a success message+printSuccess :: Text -> IO ()+printSuccess msg = putStrLn $ "✓ " <> Text.unpack msg++-- | Run a test with label+runTest :: Text -> IO a -> IO a+runTest label action = do+ putStr $ Text.unpack label <> "... "+ result <- action+ printSuccess (Text.pack "Done")+ pure result
+ notion-client-example/DatabaseDemo.hs view
@@ -0,0 +1,344 @@+-- |+-- Database API demonstration.+module DatabaseDemo+ ( runDatabaseDemo,+ )+where++import Blocks (createBulletedListItemBlock, createHeadingBlock, createParagraphBlock)+import Console (printHeader, runTest)+import Data.Aeson qualified as Aeson+import Data.Map (fromList)+import Data.String (fromString)+import Data.Text qualified as Text+import Data.Vector qualified as Vector+import Notion.V1 (Methods (..))+import Notion.V1.Blocks qualified as Blocks+import Notion.V1.Comments (CommentObject (..), CreateComment (..))+import Notion.V1.Common (Icon (..), Parent (..))+import Notion.V1.DataSources qualified as DataSources+import Notion.V1.Databases (DataSource (..), DatabaseObject (..))+import Notion.V1.ListOf (ListOf (..))+import Notion.V1.Pages (CreatePage (..), PageObject (..), PropertyValue (..), PropertyValueType (Select, Title))+import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)+import Prelude hiding (id)++-- | Run the Database API demonstration+runDatabaseDemo :: Methods -> String -> IO ()+runDatabaseDemo methods databaseIdStr = do+ let databaseId = fromString databaseIdStr++ printHeader (Text.pack "Database API")++ -- Retrieve database and display new fields+ database <-+ runTest (Text.pack "Retrieving database") $+ retrieveDatabase methods databaseId+ putStrLn $ "Database retrieved, ID: " <> databaseIdStr++ -- Display database fields+ let DatabaseObject {isInline, inTrash, publicUrl, dataSources, isLocked} = database+ putStrLn $ " isInline: " <> show isInline+ putStrLn $ " inTrash: " <> show inTrash+ putStrLn $ " isLocked: " <> show isLocked+ putStrLn $ " publicUrl: " <> show publicUrl+ putStrLn $ " dataSources: " <> show dataSources++ -- Retrieve the first data source to inspect its schema+ -- Note: In API version 2025-09-03, querying goes through data sources, not databases+ printHeader (Text.pack "Data Source API")++ let DataSource {id = dsId, name = dsName} = Vector.head dataSources+ putStrLn $ "First data source: " <> Text.unpack dsName <> " (" <> show dsId <> ")"++ dataSource <-+ runTest (Text.pack "Retrieving data source") $+ retrieveDataSource methods dsId+ let DataSources.DataSourceObject {properties = dsProperties, parent = dsParent} = dataSource+ putStrLn $ " parent: " <> show dsParent+ putStrLn $ " properties: " <> show dsProperties++ -- Query the data source directly (preferred over queryDatabase in 2025-09-03)+ let dsQueryParams =+ DataSources.QueryDataSource+ { filter = Nothing,+ sorts = Nothing,+ startCursor = Nothing,+ pageSize = Just 5,+ archived = Nothing,+ inTrash = Nothing+ }+ dsResults <-+ runTest (Text.pack "Querying data source") $+ queryDataSource methods dsId dsQueryParams+ let List {results = dsQueryResults} = dsResults+ putStrLn $ "Data source query returned " <> show (Vector.length dsQueryResults) <> " results"++ -- Create a new data source within the database+ printHeader (Text.pack "Creating Data Source")++ let newDsProperties =+ Aeson.object+ [ ( "Name",+ Aeson.object+ [ ("type", Aeson.String "title"),+ ("title", Aeson.object [])+ ]+ ),+ ( "Description",+ Aeson.object+ [ ("type", Aeson.String "rich_text"),+ ("rich_text", Aeson.object [])+ ]+ )+ ]++ createDsRequest =+ DataSources.CreateDataSource+ { parent = DatabaseParent {databaseId = databaseId},+ properties = newDsProperties,+ title = Nothing,+ icon = Nothing+ }++ newDataSource <-+ runTest (Text.pack "Creating new data source in database") $+ createDataSource methods createDsRequest++ let DataSources.DataSourceObject {id = newDsId, properties = newDsProps} = newDataSource+ putStrLn $ "New data source created with ID: " <> show newDsId+ putStrLn $ " properties: " <> show newDsProps++ -- Update data source schema: add properties+ printHeader (Text.pack "Updating Data Source Schema")++ let -- Define Status and Priority select properties with options+ combinedProperties =+ Aeson.object+ [ ( "Status",+ Aeson.object+ [ ("type", Aeson.String "select"),+ ( "select",+ Aeson.object+ [ ( "options",+ Aeson.Array $+ Vector.fromList+ [ Aeson.object [("name", Aeson.String "Not Started"), ("color", Aeson.String "red")],+ Aeson.object [("name", Aeson.String "In Progress"), ("color", Aeson.String "yellow")],+ Aeson.object [("name", Aeson.String "Done"), ("color", Aeson.String "green")]+ ]+ )+ ]+ )+ ]+ ),+ ( "Priority",+ Aeson.object+ [ ("type", Aeson.String "select"),+ ( "select",+ Aeson.object+ [ ( "options",+ Aeson.Array $+ Vector.fromList+ [ Aeson.object [("name", Aeson.String "High"), ("color", Aeson.String "red")],+ Aeson.object [("name", Aeson.String "Medium"), ("color", Aeson.String "yellow")],+ Aeson.object [("name", Aeson.String "Low"), ("color", Aeson.String "gray")]+ ]+ )+ ]+ )+ ]+ )+ ]++ updateDsRequest =+ DataSources.UpdateDataSource+ { title = Nothing,+ icon = Nothing,+ properties = Just combinedProperties,+ inTrash = Nothing,+ archived = Nothing,+ parent = Nothing+ }++ -- Update the data source with new properties+ _updatedDataSource <-+ runTest (Text.pack "Adding Status and Priority properties via data source") $+ updateDataSource methods dsId updateDsRequest++ putStrLn "Data source updated with new properties"++ -- Create a new page in the database with initial content+ let -- Step 1: Create title property (required for database pages)+ -- The "title" key should match your database title field name+ textObj = Aeson.object [("content", Aeson.String "Test Page from API")]+ textItem = Aeson.object [("text", textObj)]+ titleArray = Aeson.Array (Vector.singleton textItem)+ titleProp = Aeson.object [("title", titleArray)]++ -- Step 2: Create select property values for Status and Priority+ -- Select properties need a "select" wrapper with a "name" field+ statusProp =+ Aeson.object+ [ ( "select",+ Aeson.object [("name", Aeson.String "In Progress")]+ )+ ]+ priorityProp =+ Aeson.object+ [ ( "select",+ Aeson.object [("name", Aeson.String "High")]+ )+ ]++ -- Step 3: Create page properties map with all properties+ -- Add the title and the new Status/Priority properties+ pageProperties =+ fromList+ [ ( "title", -- This must match your database's title field name+ PropertyValue+ { type_ = Title,+ value = Just titleProp+ }+ ),+ ( "Status", -- Set the Status property+ PropertyValue+ { type_ = Select,+ value = Just statusProp+ }+ ),+ ( "Priority", -- Set the Priority property+ PropertyValue+ { type_ = Select,+ value = Just priorityProp+ }+ )+ ]++ -- Step 4: Create initial blocks for the page (optional)+ -- Pages can be created with content already in them+ initialBlocks =+ Vector.fromList+ [ createHeadingBlock "Initial Content" 1,+ createParagraphBlock "This page was created with initial content via the Notion API."+ ]++ -- Step 5: Assemble the CreatePage request+ -- In API version 2025-09-03, pages are created under a data source+ createPageRequest =+ CreatePage+ { parent = DataSourceParent {dataSourceId = dsId}, -- Specify parent data source+ properties = pageProperties, -- Required page properties+ children = Just initialBlocks, -- Optional initial content+ icon = Just (EmojiIcon "📝"), -- Optional page icon+ cover = Nothing -- Optional page cover+ }++ -- Add page to database+ newPage <-+ runTest (Text.pack "Creating new page in database") $+ createPage methods createPageRequest++ let PageObject {id = newPageId, url = newPageUrl} = newPage+ putStrLn $ "New page created. Access at: " <> Text.unpack newPageUrl++ -- Retrieve the new page+ retrievedPage <-+ runTest (Text.pack "Retrieving newly created page") $+ retrievePage methods newPageId++ let PageObject {url = retrievedPageUrl} = retrievedPage+ putStrLn $ "Retrieved page URL: " <> Text.unpack retrievedPageUrl++ -- Add blocks to the newly created page+ let additionalBlocks =+ Vector.fromList+ [ createHeadingBlock "Example Content" 1,+ createParagraphBlock "This is a paragraph with some example content created via the Notion API.",+ createHeadingBlock "Features" 2,+ createBulletedListItemBlock "Create pages in databases",+ createBulletedListItemBlock "Add rich content to pages",+ createBulletedListItemBlock "Query and retrieve data"+ ]+ appendRequest = Blocks.AppendBlockChildren {children = additionalBlocks}++ -- Add blocks to the page+ _updatedPage <-+ runTest (Text.pack "Adding blocks to page") $+ appendBlockChildren methods newPageId appendRequest++ -- Fetch the blocks to verify+ pageBlocks <-+ runTest (Text.pack "Retrieving page blocks") $+ listBlockChildren methods newPageId Nothing Nothing++ let List {results = blockResults} = pageBlocks+ putStrLn $ "Page now contains " <> show (Vector.length blockResults) <> " blocks"++ -- Add a comment to the newly created page+ printHeader (Text.pack "Adding Comment to Page")++ let -- Create rich text content for the comment using typed RichText+ commentRichText =+ Vector.singleton+ RichText+ { plainText = "This is an automated comment added via the Notion API! 🎉",+ href = Nothing,+ annotations = defaultAnnotations,+ type_ = "text",+ content = TextContentWrapper (TextContent {content = "This is an automated comment added via the Notion API! 🎉", link = Nothing})+ }++ -- Create the parent reference using the typed Parent constructor+ commentParent = PageParent {pageId = newPageId}++ -- Create the comment request+ createCommentRequest =+ CreateComment+ { parent = commentParent,+ richText = commentRichText,+ discussionId = Nothing -- Creates a new discussion thread+ }++ -- Create the comment+ newComment <-+ runTest (Text.pack "Creating comment on page") $+ createComment methods createCommentRequest++ let CommentObject {id = commentId, discussionId = discId} = newComment+ putStrLn $ "Comment created with ID: " <> show commentId+ putStrLn $ "Discussion ID: " <> show discId++ -- Add a reply to the same discussion thread+ let -- Create reply rich text using typed RichText+ replyRichText =+ Vector.singleton+ RichText+ { plainText = "This is a reply in the same discussion thread.",+ href = Nothing,+ annotations = defaultAnnotations,+ type_ = "text",+ content = TextContentWrapper (TextContent {content = "This is a reply in the same discussion thread.", link = Nothing})+ }++ -- Reply to existing discussion by providing discussion_id+ replyRequest =+ CreateComment+ { parent = commentParent,+ richText = replyRichText,+ discussionId = Just discId -- Reply to the same discussion+ }++ _replyComment <-+ runTest (Text.pack "Adding reply to discussion") $+ createComment methods replyRequest++ putStrLn "Reply added to discussion"++ -- List all comments on the page+ allComments <-+ runTest (Text.pack "Listing all comments on page") $+ listComments methods (Just newPageId) Nothing (Just 10)++ let List {results = commentResults} = allComments+ putStrLn $ "Page now has " <> show (Vector.length commentResults) <> " comments"
+ notion-client-example/Main.hs view
@@ -0,0 +1,131 @@+-- |+-- Notion API Client Example (API version 2025-09-03)+--+-- This example demonstrates using the Notion API client to interact with Notion:+-- - Retrieving users and user information+-- - Retrieving databases and their data sources+-- - Updating data source schema (adding properties)+-- - Creating pages under data sources with properties and content+-- - Adding different types of blocks to pages+-- - Creating comments on pages and on specific blocks+-- - Listing and inspecting comments (with attachments and displayName)+-- - Querying and searching content+--+-- To run this example:+--+-- 1. Get a Notion API token from https://www.notion.so/my-integrations+-- 2. Set the NOTION_TOKEN environment variable with your token+-- 3. (Optional) Set NOTION_TEST_DATABASE_ID with a database ID to test database operations+-- 4. (Optional) Set NOTION_TEST_PAGE_ID with a page ID to test page operations+-- 5. Run with: cabal run notion-client-example+--+-- Note: Your integration must have access to the specified database and page.+module Main where++import Console (logError, printHeader, runTest)+import Control.Monad (when)+import Data.Maybe (isNothing)+import Data.Text qualified as Text+import Data.Vector qualified as Vector+import DatabaseDemo (runDatabaseDemo)+import Notion.V1 (Methods (..), getClientEnv, makeMethods)+import Notion.V1.Search (SearchRequest (..), SearchResult (..), SearchSort (..), SearchSortDirection (..), dataSourceFilter, pageFilter, parseSearchResults)+import PageDemo (runPageDemo)+import System.Environment qualified as Environment+import UserDemo (runUserDemo)++main :: IO ()+main = do+ putStrLn "Notion API Client Example"+ putStrLn "========================="++ -- Get environment variables with error handling+ token <- do+ mToken <- Environment.lookupEnv "NOTION_TOKEN"+ case mToken of+ Just t -> pure (Text.pack t)+ Nothing -> logError (Text.pack "NOTION_TOKEN environment variable is required")++ databaseIdEnv <- Environment.lookupEnv "NOTION_TEST_DATABASE_ID"+ pageIdEnv <- Environment.lookupEnv "NOTION_TEST_PAGE_ID"++ when (isNothing databaseIdEnv && isNothing pageIdEnv) $+ putStrLn "WARNING: Neither NOTION_TEST_DATABASE_ID nor NOTION_TEST_PAGE_ID are set.\n Only basic user API functionality will be demonstrated."++ printHeader (Text.pack "Client Initialization")+ clientEnv <- getClientEnv (Text.pack "https://api.notion.com/v1")+ putStrLn "Client initialized"++ let methods = makeMethods clientEnv token++ -- User API demo+ runUserDemo methods++ -- Optional Database tests+ case databaseIdEnv of+ Just databaseIdStr -> runDatabaseDemo methods databaseIdStr+ Nothing ->+ putStrLn "Skipping database tests (set NOTION_TEST_DATABASE_ID to enable)"++ -- Optional Page tests+ case pageIdEnv of+ Just pageIdStr -> runPageDemo methods pageIdStr+ Nothing ->+ putStrLn "Skipping page tests (set NOTION_TEST_PAGE_ID to enable)"++ -- Search API+ printHeader (Text.pack "Search API")++ -- General search+ let searchParams =+ SearchRequest+ { query = Nothing,+ sort = Just (SearchSort {direction = Descending, timestamp = Text.pack "last_edited_time"}),+ filter = Nothing,+ startCursor = Nothing,+ pageSize = Just 5+ }+ rawResults <-+ runTest (Text.pack "Searching (all objects, sorted by last_edited_time)") $+ search methods searchParams++ let typedResults = parseSearchResults rawResults+ putStrLn $ " Found " <> show (Vector.length typedResults) <> " typed results"+ Vector.forM_ typedResults $ \result ->+ case result of+ PageResult _ -> putStrLn " - page"+ DataSourceResult _ -> putStrLn " - data_source"++ -- Search filtered to pages only+ let pageSearchParams =+ SearchRequest+ { query = Nothing,+ sort = Nothing,+ filter = Just pageFilter,+ startCursor = Nothing,+ pageSize = Just 3+ }+ pageResults <-+ runTest (Text.pack "Searching (pages only)") $+ search methods pageSearchParams+ let typedPageResults = parseSearchResults pageResults+ putStrLn $ " Found " <> show (Vector.length typedPageResults) <> " pages"++ -- Search filtered to data sources only+ let dsSearchParams =+ SearchRequest+ { query = Nothing,+ sort = Nothing,+ filter = Just dataSourceFilter,+ startCursor = Nothing,+ pageSize = Just 3+ }+ dsResults <-+ runTest (Text.pack "Searching (data sources only)") $+ search methods dsSearchParams+ let typedDsResults = parseSearchResults dsResults+ putStrLn $ " Found " <> show (Vector.length typedDsResults) <> " data sources"++ -- All done+ printHeader (Text.pack "Test complete")+ putStrLn "All tests completed successfully!"
+ notion-client-example/PageDemo.hs view
@@ -0,0 +1,189 @@+-- |+-- Page API demonstration.+module PageDemo+ ( runPageDemo,+ )+where++import Console (printHeader, runTest)+import Control.Monad (when)+import Data.Aeson qualified as Aeson+import Data.String (fromString)+import Data.Text qualified as Text+import Data.Vector qualified as Vector+import Notion.V1 (Methods (..))+import Notion.V1.Blocks qualified as Blocks+import Notion.V1.Comments (CommentObject (..), CreateComment (..))+import Notion.V1.Common (Parent (..))+import Notion.V1.ListOf (ListOf (..))+import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)+import Prelude hiding (id)++-- | Run the Page API demonstration+runPageDemo :: Methods -> String -> IO ()+runPageDemo methods pageIdStr = do+ let pageId = fromString pageIdStr++ printHeader (Text.pack "Page API")++ -- Retrieve page+ _page <-+ runTest (Text.pack "Retrieving page") $+ retrievePage methods pageId+ putStrLn $ "Page retrieved, ID: " <> pageIdStr++ -- List blocks+ blocks <-+ runTest (Text.pack "Listing blocks") $+ listBlockChildren methods pageId Nothing Nothing+ let List {results = blockResults} = blocks+ putStrLn $ "Block count: " <> show (Vector.length blockResults)++ -- If we have blocks, retrieve the first one and add a comment to it+ when (not $ Vector.null blockResults) $ do+ let firstBlock = Vector.head blockResults+ firstBlockId = Blocks.id firstBlock+ block <-+ runTest (Text.pack "Retrieving block") $+ retrieveBlock methods firstBlockId+ putStrLn $ "Block retrieved, type: " <> Text.unpack (Blocks.type_ block)++ -- Add a comment to the specific block (not the page)+ -- This demonstrates commenting on inline content+ printHeader (Text.pack "Adding Comment to Block")++ let -- Create rich text content for the block comment using typed RichText+ blockCommentRichText =+ Vector.singleton+ RichText+ { plainText = "This comment is attached to a specific block, not the page!",+ href = Nothing,+ annotations = defaultAnnotations,+ type_ = "text",+ content = TextContentWrapper (TextContent {content = "This comment is attached to a specific block, not the page!", link = Nothing})+ }++ -- Create the parent reference using the typed Parent constructor+ -- This is different from page comments which use PageParent+ blockCommentParent = BlockParent {blockId = firstBlockId}++ -- Create the comment request for the block+ createBlockCommentRequest =+ CreateComment+ { parent = blockCommentParent,+ richText = blockCommentRichText,+ discussionId = Nothing -- Creates a new discussion thread on the block+ }++ -- Create the comment on the block+ blockComment <-+ runTest (Text.pack "Creating comment on block") $+ createComment methods createBlockCommentRequest++ let CommentObject {id = blockCommentId, discussionId = blockDiscId} = blockComment+ putStrLn $ "Block comment created with ID: " <> show blockCommentId+ putStrLn $ "Block discussion ID: " <> show blockDiscId++ -- List comments on the block+ blockComments <-+ runTest (Text.pack "Listing comments on block") $+ listComments methods (Just firstBlockId) Nothing (Just 10)++ let List {results = blockCommentResults} = blockComments+ putStrLn $ "Block has " <> show (Vector.length blockCommentResults) <> " comments"++ -- Add new blocks to the existing page+ let codeBlock =+ Aeson.object+ [ ("type", Aeson.String "code"),+ ( "code",+ Aeson.object+ [ ( "rich_text",+ Aeson.Array+ ( Vector.singleton $+ Aeson.object+ [ ("text", Aeson.object [("content", Aeson.String "const example = () => {\n console.log('Hello from Notion API');\n};")])+ ]+ )+ ),+ ("language", Aeson.String "javascript")+ ]+ )+ ]+ quoteBlock =+ Aeson.object+ [ ("type", Aeson.String "quote"),+ ( "quote",+ Aeson.object+ [ ( "rich_text",+ Aeson.Array+ ( Vector.singleton $+ Aeson.object+ [ ("text", Aeson.object [("content", Aeson.String "This is a quote block added via the API")])+ ]+ )+ )+ ]+ )+ ]+ calloutBlock =+ Aeson.object+ [ ("type", Aeson.String "callout"),+ ( "callout",+ Aeson.object+ [ ( "rich_text",+ Aeson.Array+ ( Vector.singleton $+ Aeson.object+ [ ("text", Aeson.object [("content", Aeson.String "This is a callout block with an emoji")])+ ]+ )+ ),+ ("icon", Aeson.object [("emoji", Aeson.String "🔥")])+ ]+ )+ ]+ specializedBlocks = Vector.fromList [codeBlock, quoteBlock, calloutBlock]+ appendRequest = Blocks.AppendBlockChildren {children = specializedBlocks}++ -- Append blocks to the existing page+ _updatedPage <-+ runTest (Text.pack "Adding specialized blocks to page") $+ appendBlockChildren methods pageId appendRequest++ -- Refresh the blocks to see all blocks now+ allBlocks <-+ runTest (Text.pack "Retrieving all page blocks") $+ listBlockChildren methods pageId Nothing Nothing++ let List {results = allBlockResults} = allBlocks+ putStrLn $ "Page now contains " <> show (Vector.length allBlockResults) <> " blocks"++ -- Comments API demonstration (using the page)+ -- Note: Pages are blocks in Notion, so we use the page ID as block_id+ printHeader (Text.pack "Comments API")++ -- List comments on the page using block_id (pages are blocks in Notion)+ comments <-+ runTest (Text.pack "Listing comments on page") $+ listComments methods (Just pageId) Nothing (Just 10)++ let List {results = commentResults} = comments+ putStrLn $ "Found " <> show (Vector.length commentResults) <> " comments on page"++ -- Display comment details if any exist+ when (not $ Vector.null commentResults) $ do+ let firstComment = Vector.head commentResults+ CommentObject+ { id = commentId,+ discussionId = discId,+ createdBy = createdBy,+ attachments = commentAttachments,+ displayName = displayName+ } = firstComment+ putStrLn "First comment details:"+ putStrLn $ " id: " <> show commentId+ putStrLn $ " discussionId: " <> show discId+ putStrLn $ " createdBy: " <> show createdBy+ putStrLn $ " attachments: " <> show commentAttachments+ putStrLn $ " displayName: " <> show displayName
+ notion-client-example/UserDemo.hs view
@@ -0,0 +1,29 @@+-- |+-- User API demonstration.+module UserDemo+ ( runUserDemo,+ )+where++import Console (printHeader, runTest)+import Data.Text qualified as Text+import Data.Vector qualified as Vector+import Notion.V1 (Methods (..))+import Notion.V1.ListOf (ListOf (..))++-- | Run the User API demonstration+runUserDemo :: Methods -> IO ()+runUserDemo methods = do+ printHeader (Text.pack "User API")++ user <-+ runTest (Text.pack "Retrieving current user information") $+ retrieveMyUser methods+ putStrLn $ "User info: " <> show user++ -- List all users+ users <-+ runTest (Text.pack "Listing users") $+ listUsers methods Nothing Nothing+ let List {results = userResults} = users+ putStrLn $ "User count: " <> show (Vector.length userResults)
+ notion-client.cabal view
@@ -0,0 +1,132 @@+cabal-version: 3.4+name: notion-client+version: 0.1.0.0+synopsis: Type-safe Haskell client for the Notion API+description:+ This package provides comprehensive and type-safe bindings+ to the Notion API, providing both a Servant interface and+ non-Servant interface for convenience.+ .+ Read the @README@ below for a fully worked usage example.+ .+ Otherwise, browse the "Notion.V1" module, which is the+ intended package entrypoint.++license: MIT+license-file: LICENSE+category: Web+author: Nadeem Bitar+maintainer: nadeem@gmail.com+homepage: https://github.com/shinzui/notion-client+bug-reports: https://github.com/shinzui/notion-client/issues+build-type: Simple+tested-with: GHC ==9.12.2+extra-doc-files:+ CHANGELOG.md+ README.md++extra-source-files: LICENSE++source-repository head+ type: git+ location: https://github.com/shinzui/notion-client.git++library+ default-language: GHC2024+ hs-source-dirs: src+ build-depends:+ , aeson >=2.2 && <2.3+ , base >=4.15.0.0 && <5+ , base16-bytestring >=1.0 && <1.1+ , bytestring >=0.11 && <0.13+ , containers >=0.6 && <0.8+ , cryptohash-sha256 >=0.11 && <0.12+ , filepath >=1.4 && <1.6+ , http-api-data >=0.6 && <0.7+ , http-client-tls >=0.3 && <0.4+ , servant >=0.20 && <0.21+ , servant-client >=0.20 && <0.21+ , servant-multipart-api >=0.12 && <0.13+ , servant-multipart-client >=0.12 && <0.13+ , text >=2.0 && <2.2+ , time >=1.11 && <1.15+ , time-compat >=1.9 && <1.10+ , unordered-containers >=0.2 && <0.3+ , vector >=0.13 && <0.14++ exposed-modules:+ Notion.V1+ Notion.V1.Blocks+ Notion.V1.Comments+ Notion.V1.Common+ Notion.V1.Databases+ Notion.V1.DataSources+ Notion.V1.Error+ Notion.V1.ListOf+ Notion.V1.Pages+ Notion.V1.Pagination+ Notion.V1.RichText+ Notion.V1.Search+ Notion.V1.Users+ Notion.V1.Webhooks++ other-modules: Notion.Prelude+ default-extensions:+ DuplicateRecordFields+ OverloadedLabels+ OverloadedStrings+ RecordWildCards++ ghc-options: -Wall++test-suite tasty+ default-language: GHC2024+ type: exitcode-stdio-1.0+ hs-source-dirs: tasty+ main-is: Main.hs+ default-extensions:+ DuplicateRecordFields+ OverloadedLabels+ OverloadedStrings+ RecordWildCards++ build-depends:+ , aeson+ , base+ , http-client+ , http-client-tls+ , notion-client+ , servant-client+ , tasty+ , tasty-hunit+ , text++ ghc-options: -Wall++executable notion-client-example+ default-language: GHC2024+ hs-source-dirs: notion-client-example+ main-is: Main.hs+ other-modules:+ Blocks+ Console+ DatabaseDemo+ PageDemo+ UserDemo++ default-extensions:+ DuplicateRecordFields+ OverloadedLabels+ OverloadedStrings+ RecordWildCards++ build-depends:+ , aeson+ , base+ , containers+ , notion-client+ , text+ , unordered-containers+ , vector++ ghc-options: -Wall
+ src/Notion/Prelude.hs view
@@ -0,0 +1,129 @@+module Notion.Prelude+ ( -- * JSON+ aesonOptions,+ stripPrefix,+ labelModifier,+ parseISO8601,++ -- * Re-exports+ module Data.Aeson,+ module Data.ByteString.Lazy,+ module Data.List.NonEmpty,+ module Data.Map,+ module Data.String,+ module Data.Text,+ module Data.Time.Clock.POSIX,+ module Data.Vector,+ module Data.Void,+ module Data.Word,+ module GHC.Generics,+ module Numeric.Natural,+ module Servant.API,+ module Servant.Multipart.API,+ module Web.HttpApiData,+ )+where++import Data.Aeson+ ( FromJSON (..),+ Options (..),+ SumEncoding (..),+ ToJSON (..),+ Value (..),+ genericParseJSON,+ genericToJSON,+ )+import Data.Aeson qualified as Aeson+import Data.Aeson.Types qualified as Aeson+import Data.ByteString.Lazy (ByteString)+import Data.Char qualified as Char+import Data.List qualified as List+import Data.List.NonEmpty (NonEmpty (..))+import Data.Map (Map)+-- Non-qualified function to use in implementation++import Data.Maybe (fromMaybe)+import Data.String (IsString (..))+import Data.Text (Text, pack, unpack)+import Data.Time.Clock qualified as Clock+import Data.Time.Clock.POSIX (POSIXTime)+import Data.Time.Clock.POSIX qualified as Time+import Data.Time.Format.ISO8601 qualified as ISO8601+import Data.Time.LocalTime (ZonedTime, zonedTimeToUTC)+import Data.Vector (Vector)+import Data.Void (Void)+import Data.Word (Word8)+import GHC.Generics (Generic)+import Numeric.Natural (Natural)+import Servant.API+ ( Accept (..),+ Capture,+ Delete,+ Get,+ Header',+ JSON,+ MimeUnrender (..),+ OctetStream,+ Patch,+ Post,+ QueryParam,+ ReqBody,+ Required,+ Strict,+ (:<|>) (..),+ (:>),+ )+import Servant.Multipart.API+ ( FileData (..),+ Input (..),+ MultipartData (..),+ MultipartForm,+ Tmp,+ ToMultipart (..),+ )+import Web.HttpApiData (ToHttpApiData (..))++dropTrailingUnderscore :: String -> String+dropTrailingUnderscore "_" = ""+dropTrailingUnderscore "" = ""+dropTrailingUnderscore (c : cs) = c : dropTrailingUnderscore cs++-- | Convert camelCase to snake_case and handle trailing underscores+-- e.g., "createdTime" -> "created_time", "type_" -> "type"+camelToSnake :: String -> String+camelToSnake = \case+ [] -> []+ (c : cs) -> Char.toLower c : go cs+ where+ go [] = []+ go (c : cs)+ | Char.isUpper c = '_' : Char.toLower c : go cs+ | otherwise = c : go cs++labelModifier :: String -> String+labelModifier = camelToSnake . dropTrailingUnderscore++stripPrefix :: String -> String -> String+stripPrefix prefix string = labelModifier suffix+ where+ suffix = fromMaybe string (List.stripPrefix prefix string)++aesonOptions :: Options+aesonOptions =+ Aeson.defaultOptions+ { fieldLabelModifier = labelModifier,+ constructorTagModifier = labelModifier,+ omitNothingFields = True+ }++-- | Parse an ISO8601 timestamp string to POSIXTime+-- Handles both UTC format (Z suffix) and timezone offset format (+00:00)+parseISO8601 :: Text -> Aeson.Parser POSIXTime+parseISO8601 text =+ case (ISO8601.iso8601ParseM str :: Maybe Clock.UTCTime) of+ Just utcTime -> return $ Time.utcTimeToPOSIXSeconds utcTime+ Nothing -> case (ISO8601.iso8601ParseM str :: Maybe ZonedTime) of+ Just zonedTime -> return $ Time.utcTimeToPOSIXSeconds (zonedTimeToUTC zonedTime)+ Nothing -> fail $ "Failed to parse ISO8601 timestamp: " <> str+ where+ str = unpack text
+ src/Notion/V1.hs view
@@ -0,0 +1,191 @@+-- | Notion API v1+--+-- Example usage:+--+-- @+-- module Main where+--+-- import Notion.V1+-- import Notion.V1.Pages+-- import Data.Text qualified as Text+-- import System.Environment qualified as Environment+--+-- main :: IO ()+-- main = do+-- token <- Environment.getEnv "NOTION_TOKEN"+--+-- clientEnv <- getClientEnv "https://api.notion.com/v1"+--+-- let Methods{ retrievePage } = makeMethods clientEnv (Text.pack token)+--+-- page <- retrievePage "page-id-here"+--+-- print page+-- @+module Notion.V1+ ( -- * Methods+ getClientEnv,+ makeMethods,+ Methods (..),++ -- * Servant+ API,+ )+where++import Control.Exception qualified as Exception+import Data.Proxy (Proxy (..))+import Data.Text qualified as Text+import Network.HTTP.Client.TLS qualified as TLS+import Notion.Prelude+import Notion.V1.Blocks (BlockID, BlockObject)+import Notion.V1.Blocks qualified as Blocks+import Notion.V1.Comments (CommentObject)+import Notion.V1.Comments qualified as Comments+import Notion.V1.Common (ParentID)+import Notion.V1.DataSources (DataSourceID, DataSourceObject)+import Notion.V1.DataSources qualified as DataSources+import Notion.V1.Databases (CreateDatabase, DatabaseID, DatabaseObject, QueryDatabase, UpdateDatabase)+import Notion.V1.Databases qualified as Databases+import Notion.V1.ListOf (ListOf (..))+import Notion.V1.Pages (CreatePage, PageID, PageObject, UpdatePage)+import Notion.V1.Pages qualified as Pages+import Notion.V1.Search (SearchRequest)+import Notion.V1.Search qualified as Search+import Notion.V1.Users (UserID, UserObject)+import Notion.V1.Users qualified as Users+import Servant.Client (ClientEnv)+import Servant.Client qualified as Client+import Servant.Multipart.Client ()++-- | Convenient utility to get a `ClientEnv` for the most common use case+getClientEnv ::+ -- | Base URL for API+ Text ->+ IO ClientEnv+getClientEnv baseUrlText = do+ baseUrl <- Client.parseBaseUrl (Text.unpack baseUrlText)+ manager <- TLS.newTlsManager+ pure (Client.mkClientEnv manager baseUrl)++-- | Get a record of API methods after providing an API token+makeMethods ::+ ClientEnv ->+ -- | API token+ Text ->+ Methods+makeMethods clientEnv token = Methods {..}+ where+ notionVersion = "2025-09-03" -- Notion API version with data source support+ -- If you experience 400 errors, check for updated versions at+ -- https://developers.notion.com/reference/versioning+ ( ( createDatabase+ :<|> retrieveDatabase+ :<|> updateDatabase+ :<|> queryDatabase+ )+ :<|> ( retrieveDataSource+ :<|> createDataSource+ :<|> updateDataSource+ :<|> queryDataSource+ )+ :<|> ( retrievePage+ :<|> createPage+ :<|> updatePage+ )+ :<|> ( retrieveBlock+ :<|> updateBlock+ :<|> retrieveBlockChildren_+ :<|> appendBlockChildren+ :<|> deleteBlock+ )+ :<|> ( retrieveUser+ :<|> listUsers_+ :<|> retrieveMyUser+ )+ :<|> search_+ :<|> ( createComment+ :<|> listComments_+ )+ ) = Client.hoistClient @API Proxy run (Client.client @API Proxy) authorization notionVersion++ authorization = "Bearer " <> token++ run :: Client.ClientM a -> IO a+ run clientM = do+ result <- Client.runClientM clientM clientEnv+ case result of+ Left exception -> Exception.throwIO exception+ Right a -> return a++ -- Keep the ListOf structure+ listBlockChildren = retrieveBlockChildren_+ listUsers = listUsers_+ listComments = listComments_+ search = search_++-- | API methods+data Methods = Methods+ { -- \* Databases+ createDatabase :: CreateDatabase -> IO DatabaseObject,+ retrieveDatabase :: DatabaseID -> IO DatabaseObject,+ updateDatabase :: DatabaseID -> UpdateDatabase -> IO DatabaseObject,+ queryDatabase :: DatabaseID -> QueryDatabase -> IO (ListOf PageObject),+ -- \* Data Sources+ retrieveDataSource :: DataSourceID -> IO DataSourceObject,+ createDataSource :: DataSources.CreateDataSource -> IO DataSourceObject,+ updateDataSource :: DataSourceID -> DataSources.UpdateDataSource -> IO DataSourceObject,+ queryDataSource :: DataSourceID -> DataSources.QueryDataSource -> IO (ListOf PageObject),+ -- \* Pages+ createPage :: CreatePage -> IO PageObject,+ retrievePage :: PageID -> IO PageObject,+ updatePage :: PageID -> UpdatePage -> IO PageObject,+ -- \* Blocks+ retrieveBlock :: BlockID -> IO BlockObject,+ updateBlock :: BlockID -> Blocks.BlockContent -> IO BlockObject,+ listBlockChildren ::+ ParentID ->+ Maybe Natural ->+ -- \^ page_size+ Maybe Text ->+ -- \^ start_cursor+ IO (ListOf BlockObject),+ appendBlockChildren :: ParentID -> Blocks.AppendBlockChildren -> IO (ListOf BlockObject),+ deleteBlock :: BlockID -> IO BlockObject,+ -- \* Users+ retrieveUser :: UserID -> IO UserObject,+ listUsers ::+ Maybe Natural ->+ -- \^ page_size+ Maybe Text ->+ -- \^ start_cursor+ IO (ListOf UserObject),+ retrieveMyUser :: IO UserObject,+ -- \* Search+ search :: SearchRequest -> IO (ListOf Value),+ -- \* Comments+ createComment :: Comments.CreateComment -> IO CommentObject,+ -- | List comments on a block or page. To list comments on a page, use the page ID+ -- as the block_id parameter (pages are blocks in Notion).+ listComments ::+ Maybe BlockID ->+ -- \^ block_id (use page ID here for page comments)+ Maybe Text ->+ -- \^ start_cursor+ Maybe Natural ->+ -- \^ page_size+ IO (ListOf CommentObject)+ }++-- | Servant API+type API =+ Header' [Required, Strict] "Authorization" Text+ :> Header' [Required, Strict] "Notion-Version" Text+ :> ( Databases.API+ :<|> DataSources.API+ :<|> Pages.API+ :<|> Blocks.API+ :<|> Users.API+ :<|> Search.API+ :<|> Comments.API+ )
+ src/Notion/V1/Blocks.hs view
@@ -0,0 +1,95 @@+-- | @\/v1\/blocks@+module Notion.V1.Blocks+ ( -- * Main types+ BlockID,+ BlockObject (..),+ BlockContent (..),+ AppendBlockChildren (..),++ -- * Servant+ API,+ )+where++import Data.Aeson ((.:))+import Data.Aeson.Key qualified as Key+import Notion.Prelude+import Notion.V1.Common (BlockID, ObjectType (..), Parent)+import Notion.V1.ListOf (ListOf)+import Notion.V1.Users (UserReference)+import Prelude hiding (id)++-- | Notion block object+data BlockObject = BlockObject+ { id :: BlockID,+ parent :: Parent,+ createdTime :: POSIXTime,+ lastEditedTime :: POSIXTime,+ createdBy :: UserReference,+ lastEditedBy :: UserReference,+ hasChildren :: Bool,+ archived :: Bool,+ type_ :: Text,+ content :: Value,+ object :: ObjectType+ }+ deriving stock (Generic, Show)++instance FromJSON BlockObject where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ parent <- o .: "parent"+ createdTimeStr <- o .: "created_time"+ createdTime <- parseISO8601 createdTimeStr+ lastEditedTimeStr <- o .: "last_edited_time"+ lastEditedTime <- parseISO8601 lastEditedTimeStr+ createdBy <- o .: "created_by"+ lastEditedBy <- o .: "last_edited_by"+ hasChildren <- o .: "has_children"+ archived <- o .: "archived"+ type_ <- o .: "type"+ -- Content is stored under a field named after the block type (e.g., "heading_1", "paragraph")+ content <- o .: Key.fromText type_+ object <- o .: "object"+ return BlockObject {..}+ _ -> fail "Expected object for BlockObject"++-- | Block content for update+newtype BlockContent = BlockContent+ { content :: Value+ }+ deriving stock (Generic, Show)++instance ToJSON BlockContent where+ toJSON = genericToJSON aesonOptions++-- | Append children to a block+newtype AppendBlockChildren = AppendBlockChildren+ { children :: Vector Value+ }+ deriving stock (Generic, Show)++instance ToJSON AppendBlockChildren where+ toJSON = genericToJSON aesonOptions++-- | Servant API+type API =+ "blocks"+ :> ( Capture "block_id" BlockID+ :> Get '[JSON] BlockObject+ :<|> Capture "block_id" BlockID+ :> ReqBody '[JSON] BlockContent+ :> Patch '[JSON] BlockObject+ :<|> Capture "block_id" BlockID+ :> "children"+ :> QueryParam "page_size" Natural+ :> QueryParam "start_cursor" Text+ :> Get '[JSON] (ListOf BlockObject)+ :<|> Capture "block_id" BlockID+ :> "children"+ :> ReqBody '[JSON] AppendBlockChildren+ :> Patch '[JSON] (ListOf BlockObject)+ :<|> Capture "block_id" BlockID+ :> Delete '[JSON] BlockObject+ )
+ src/Notion/V1/Comments.hs view
@@ -0,0 +1,103 @@+-- | @\/v1\/comments@+module Notion.V1.Comments+ ( -- * Main types+ CommentID,+ CommentObject (..),+ CommentAttachment (..),+ CommentDisplayName (..),+ CreateComment (..),++ -- * Servant+ API,+ )+where++import Data.Aeson ((.:), (.:?))+import Notion.Prelude+import Notion.V1.Common (BlockID, ExternalFile, File, ObjectType (..), Parent, UUID)+import Notion.V1.ListOf (ListOf)+import Notion.V1.RichText (RichText)+import Notion.V1.Users (UserReference)+import Prelude hiding (id)++-- | Comment ID+type CommentID = UUID++-- | Comment attachment (files attached to comments)+data CommentAttachment = CommentAttachment+ { name :: Text,+ type_ :: Text,+ external :: Maybe ExternalFile,+ file :: Maybe File+ }+ deriving stock (Generic, Show)++instance FromJSON CommentAttachment where+ parseJSON = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}++-- | Comment display name (custom display name for comments)+data CommentDisplayName = CommentDisplayName+ { type_ :: Text,+ emoji :: Maybe Text,+ displayName :: Maybe Text+ }+ deriving stock (Generic, Show)++instance FromJSON CommentDisplayName where+ parseJSON = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}++-- | Notion comment object+data CommentObject = CommentObject+ { id :: CommentID,+ parent :: Parent,+ discussionId :: UUID,+ createdTime :: POSIXTime,+ lastEditedTime :: POSIXTime,+ createdBy :: UserReference,+ richText :: Vector RichText,+ attachments :: Maybe (Vector CommentAttachment),+ displayName :: Maybe CommentDisplayName,+ object :: ObjectType+ }+ deriving stock (Generic, Show)++instance FromJSON CommentObject where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ parent <- o .: "parent"+ discussionId <- o .: "discussion_id"+ createdTimeStr <- o .: "created_time"+ createdTime <- parseISO8601 createdTimeStr+ lastEditedTimeStr <- o .: "last_edited_time"+ lastEditedTime <- parseISO8601 lastEditedTimeStr+ createdBy <- o .: "created_by"+ richText <- o .: "rich_text"+ attachments <- o .:? "attachments"+ displayName <- o .:? "display_name"+ object <- o .: "object"+ return CommentObject {..}+ _ -> fail "Expected object for CommentObject"++-- | Create comment request+data CreateComment = CreateComment+ { parent :: Parent,+ richText :: Vector RichText,+ discussionId :: Maybe UUID+ }+ deriving stock (Generic, Show)++instance ToJSON CreateComment where+ toJSON = genericToJSON aesonOptions++-- | Servant API+-- Note: To list comments on a page, use the page ID as block_id (pages are blocks in Notion)+type API =+ "comments"+ :> ( ReqBody '[JSON] CreateComment+ :> Post '[JSON] CommentObject+ :<|> QueryParam "block_id" BlockID+ :> QueryParam "start_cursor" Text+ :> QueryParam "page_size" Natural+ :> Get '[JSON] (ListOf CommentObject)+ )
+ src/Notion/V1/Common.hs view
@@ -0,0 +1,191 @@+-- | Common Notion API types+module Notion.V1.Common+ ( -- * Common types+ UUID,+ BlockID,+ ObjectType (..),+ Parent (..),+ ParentID,+ Color (..),+ Icon (..),+ Cover (..),+ File (..),+ ExternalFile (..),+ )+where++import Data.Aeson (Object, object, (.:), (.:?), (.=))+import Data.Aeson.Types (Parser)+import Data.Foldable (asum)+import Notion.Prelude++-- | UUID type for Notion resource IDs+newtype UUID = UUID {text :: Text}+ deriving newtype (Eq, FromJSON, IsString, Show, ToHttpApiData, ToJSON)++-- | Block ID+type BlockID = UUID++-- | Possible Notion object types+data ObjectType+ = Database+ | DataSource+ | Page+ | Block+ | User+ | Comment+ deriving stock (Eq, Show, Generic)++instance FromJSON ObjectType where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON ObjectType where+ toJSON = genericToJSON aesonOptions++-- | Parent object that can be a database, data source, page, block, or workspace+data Parent+ = DatabaseParent {databaseId :: UUID}+ | DataSourceParent {dataSourceId :: UUID}+ | PageParent {pageId :: UUID}+ | BlockParent {blockId :: UUID}+ | WorkspaceParent {workspace :: Bool}+ deriving stock (Generic, Show)++instance FromJSON Parent where+ parseJSON = \case+ Object o -> do+ mParentType <- o .:? "type"+ case mParentType of+ Just parentType -> parseByType parentType o+ Nothing -> parseByKey o+ _ -> fail "Expected object for Parent"+ where+ parseByType :: Text -> Object -> Parser Parent+ parseByType = \case+ "database" -> fmap DatabaseParent . (.: "database_id")+ "database_id" -> fmap DatabaseParent . (.: "database_id")+ "data_source" -> fmap DataSourceParent . (.: "data_source_id")+ "data_source_id" -> fmap DataSourceParent . (.: "data_source_id")+ "page" -> fmap PageParent . (.: "page_id")+ "page_id" -> fmap PageParent . (.: "page_id")+ "block" -> fmap BlockParent . (.: "block_id")+ "block_id" -> fmap BlockParent . (.: "block_id")+ "workspace" -> fmap WorkspaceParent . (.: "workspace")+ other -> \_ -> fail $ "Unknown parent type: " <> unpack other++ parseByKey :: Object -> Parser Parent+ parseByKey o =+ asum+ [ DataSourceParent <$> o .: "data_source_id",+ DatabaseParent <$> o .: "database_id",+ PageParent <$> o .: "page_id",+ BlockParent <$> o .: "block_id",+ WorkspaceParent <$> o .: "workspace"+ ]++instance ToJSON Parent where+ toJSON (DatabaseParent dbId) = object ["type" .= ("database_id" :: Text), "database_id" .= dbId]+ toJSON (DataSourceParent dsId) = object ["type" .= ("data_source_id" :: Text), "data_source_id" .= dsId]+ toJSON (PageParent pId) = object ["type" .= ("page_id" :: Text), "page_id" .= pId]+ toJSON (BlockParent bId) = object ["type" .= ("block_id" :: Text), "block_id" .= bId]+ toJSON (WorkspaceParent ws) = object ["type" .= ("workspace" :: Text), "workspace" .= ws]++-- | Unified parent ID type+type ParentID = UUID++-- | Notion color options+data Color+ = Default+ | Gray+ | Brown+ | Orange+ | Yellow+ | Green+ | Blue+ | Purple+ | Pink+ | Red+ | GrayBackground+ | BrownBackground+ | OrangeBackground+ | YellowBackground+ | GreenBackground+ | BlueBackground+ | PurpleBackground+ | PinkBackground+ | RedBackground+ deriving stock (Eq, Show, Generic)++instance FromJSON Color where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON Color where+ toJSON = genericToJSON aesonOptions++-- | Icon object for pages/databases+data Icon+ = EmojiIcon {emoji :: Text}+ | FileIcon {file :: File}+ | ExternalIcon {external :: ExternalFile}+ deriving stock (Generic, Show)++instance FromJSON Icon where+ parseJSON = \case+ Object o -> do+ iconType <- o .: "type"+ case iconType of+ "emoji" -> EmojiIcon <$> o .: "emoji"+ "file" -> FileIcon <$> o .: "file"+ "external" -> ExternalIcon <$> o .: "external"+ _ -> fail $ "Unknown icon type: " <> unpack iconType+ _ -> fail "Expected object for Icon"++instance ToJSON Icon where+ toJSON (EmojiIcon emoji) = object ["type" .= ("emoji" :: Text), "emoji" .= emoji]+ toJSON (FileIcon file) = object ["type" .= ("file" :: Text), "file" .= file]+ toJSON (ExternalIcon external) = object ["type" .= ("external" :: Text), "external" .= external]++-- | Cover object for pages/databases+data Cover+ = FileCover {file :: File}+ | ExternalCover {external :: ExternalFile}+ deriving stock (Generic, Show)++instance FromJSON Cover where+ parseJSON = \case+ Object o -> do+ coverType <- o .: "type"+ case coverType of+ "file" -> FileCover <$> o .: "file"+ "external" -> ExternalCover <$> o .: "external"+ _ -> fail $ "Unknown cover type: " <> unpack coverType+ _ -> fail "Expected object for Cover"++instance ToJSON Cover where+ toJSON (FileCover file) = object ["type" .= ("file" :: Text), "file" .= file]+ toJSON (ExternalCover external) = object ["type" .= ("external" :: Text), "external" .= external]++-- | Internal file object+data File = File+ { url :: Text,+ expiryTime :: Maybe POSIXTime+ }+ deriving stock (Generic, Show)++instance FromJSON File where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON File where+ toJSON = genericToJSON aesonOptions++-- | External file object+newtype ExternalFile = ExternalFile+ { url :: Text+ }+ deriving stock (Generic, Show)++instance FromJSON ExternalFile where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON ExternalFile where+ toJSON = genericToJSON aesonOptions
+ src/Notion/V1/DataSources.hs view
@@ -0,0 +1,133 @@+-- | @\/v1\/data_sources@+--+-- Data sources represent the schema and content within a database.+-- A single database can contain multiple data sources (API version 2025-09-03+).+module Notion.V1.DataSources+ ( -- * Main types+ DataSourceID,+ DataSourceObject (..),+ CreateDataSource (..),+ UpdateDataSource (..),+ QueryDataSource (..),++ -- * Servant+ API,+ )+where++import Data.Aeson ((.:), (.:?))+import Notion.Prelude+import Notion.V1.Common (Cover, Icon, ObjectType, Parent, UUID)+import Notion.V1.ListOf (ListOf)+import Notion.V1.Pages (PageObject)+import Notion.V1.RichText (RichText)+import Notion.V1.Users (UserReference)+import Prelude hiding (id)++-- | Data source ID+type DataSourceID = UUID++-- | Notion data source object+data DataSourceObject = DataSourceObject+ { id :: DataSourceID,+ createdTime :: POSIXTime,+ lastEditedTime :: POSIXTime,+ createdBy :: UserReference,+ lastEditedBy :: UserReference,+ title :: Vector RichText,+ description :: Vector RichText,+ properties :: Value,+ url :: Text,+ parent :: Parent,+ databaseParent :: Maybe Parent,+ archived :: Bool,+ isInline :: Maybe Bool,+ inTrash :: Maybe Bool,+ publicUrl :: Maybe Text,+ icon :: Maybe Icon,+ cover :: Maybe Cover,+ object :: ObjectType+ }+ deriving stock (Generic, Show)++instance FromJSON DataSourceObject where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ createdTimeStr <- o .: "created_time"+ createdTime <- parseISO8601 createdTimeStr+ lastEditedTimeStr <- o .: "last_edited_time"+ lastEditedTime <- parseISO8601 lastEditedTimeStr+ createdBy <- o .: "created_by"+ lastEditedBy <- o .: "last_edited_by"+ title <- o .: "title"+ description <- o .: "description"+ properties <- o .: "properties"+ url <- o .: "url"+ parent <- o .: "parent"+ databaseParent <- o .:? "database_parent"+ archived <- o .: "archived"+ isInline <- o .:? "is_inline"+ inTrash <- o .:? "in_trash"+ publicUrl <- o .:? "public_url"+ icon <- o .:? "icon"+ cover <- o .:? "cover"+ object <- o .: "object"+ return DataSourceObject {..}+ _ -> fail "Expected object for DataSourceObject"++-- | Create data source request+data CreateDataSource = CreateDataSource+ { parent :: Parent,+ properties :: Value,+ title :: Maybe (Vector RichText),+ icon :: Maybe Icon+ }+ deriving stock (Generic, Show)++instance ToJSON CreateDataSource where+ toJSON = genericToJSON aesonOptions++-- | Update data source request+data UpdateDataSource = UpdateDataSource+ { title :: Maybe (Vector RichText),+ icon :: Maybe Icon,+ properties :: Maybe Value,+ inTrash :: Maybe Bool,+ archived :: Maybe Bool,+ parent :: Maybe Parent+ }+ deriving stock (Generic, Show)++instance ToJSON UpdateDataSource where+ toJSON = genericToJSON aesonOptions++-- | Query data source request+data QueryDataSource = QueryDataSource+ { filter :: Maybe Value,+ sorts :: Maybe [Value],+ startCursor :: Maybe Text,+ pageSize :: Maybe Natural,+ archived :: Maybe Bool,+ inTrash :: Maybe Bool+ }+ deriving stock (Generic, Show)++instance ToJSON QueryDataSource where+ toJSON = genericToJSON aesonOptions++-- | Servant API+type API =+ "data_sources"+ :> ( Capture "data_source_id" DataSourceID+ :> Get '[JSON] DataSourceObject+ :<|> ReqBody '[JSON] CreateDataSource+ :> Post '[JSON] DataSourceObject+ :<|> Capture "data_source_id" DataSourceID+ :> ReqBody '[JSON] UpdateDataSource+ :> Patch '[JSON] DataSourceObject+ :<|> Capture "data_source_id" DataSourceID+ :> "query"+ :> ReqBody '[JSON] QueryDataSource+ :> Post '[JSON] (ListOf PageObject)+ )
+ src/Notion/V1/Databases.hs view
@@ -0,0 +1,168 @@+-- | @\/v1\/databases@+module Notion.V1.Databases+ ( -- * Main types+ DatabaseID,+ DatabaseObject (..),+ DataSource (..),+ InitialDataSource (..),+ CreateDatabase (..),+ UpdateDatabase (..),+ QueryDatabase (..),++ -- * Servant+ API,+ )+where++import Data.Aeson ((.:), (.:?))+import Notion.Prelude+import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID)+import Notion.V1.ListOf (ListOf)+import Notion.V1.Pages (PageObject)+import Notion.V1.RichText (RichText)+import Notion.V1.Users (UserReference)+import Prelude hiding (id)++-- | Database ID+type DatabaseID = UUID++-- | Data source reference within a database (API version 2025-09-03+)+data DataSource = DataSource+ { id :: UUID,+ name :: Text+ }+ deriving stock (Generic, Show)++instance FromJSON DataSource where+ parseJSON = genericParseJSON aesonOptions++-- | Notion database object+--+-- In API version 2025-09-03, database schema (properties) moved to data sources.+-- The 'properties' field may be absent; use 'dataSources' and the data source+-- endpoints to access schema information.+data DatabaseObject = DatabaseObject+ { id :: DatabaseID,+ createdTime :: POSIXTime,+ lastEditedTime :: POSIXTime,+ createdBy :: Maybe UserReference,+ lastEditedBy :: Maybe UserReference,+ title :: Vector RichText,+ description :: Maybe (Vector RichText),+ properties :: Maybe Value,+ icon :: Maybe Icon,+ cover :: Maybe Cover,+ url :: Text,+ parent :: Parent,+ archived :: Maybe Bool,+ isInline :: Maybe Bool,+ inTrash :: Maybe Bool,+ isLocked :: Maybe Bool,+ publicUrl :: Maybe Text,+ dataSources :: Vector DataSource,+ object :: ObjectType+ }+ deriving stock (Generic, Show)++instance FromJSON DatabaseObject where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ createdTimeStr <- o .: "created_time"+ createdTime <- parseISO8601 createdTimeStr+ lastEditedTimeStr <- o .: "last_edited_time"+ lastEditedTime <- parseISO8601 lastEditedTimeStr+ createdBy <- o .:? "created_by"+ lastEditedBy <- o .:? "last_edited_by"+ title <- o .: "title"+ description <- o .:? "description"+ properties <- o .:? "properties"+ icon <- o .:? "icon"+ cover <- o .:? "cover"+ url <- o .: "url"+ parent <- o .: "parent"+ archived <- o .:? "archived"+ isInline <- o .:? "is_inline"+ inTrash <- o .:? "in_trash"+ isLocked <- o .:? "is_locked"+ publicUrl <- o .:? "public_url"+ dataSources <- o .: "data_sources"+ object <- o .: "object"+ return DatabaseObject {..}+ _ -> fail "Expected object for DatabaseObject"++-- | Initial data source configuration for database creation.+-- Contains the property schema for the database's first data source.+newtype InitialDataSource = InitialDataSource+ { properties :: Value+ }+ deriving stock (Generic, Show)++instance ToJSON InitialDataSource where+ toJSON = genericToJSON aesonOptions++-- | Create database request+--+-- In API version 2025-09-03, schema is specified via 'initialDataSource'+-- rather than a top-level @properties@ field.+data CreateDatabase = CreateDatabase+ { parent :: Parent,+ title :: Vector RichText,+ initialDataSource :: Maybe InitialDataSource,+ icon :: Maybe Icon,+ cover :: Maybe Cover,+ description :: Maybe (Vector RichText),+ isInline :: Maybe Bool+ }+ deriving stock (Generic, Show)++instance ToJSON CreateDatabase where+ toJSON = genericToJSON aesonOptions++-- | Update database request+--+-- In API version 2025-09-03, schema updates (properties) are handled via+-- the Update Data Source API ('Notion.V1.DataSources.UpdateDataSource').+-- This endpoint only handles database-level attributes.+data UpdateDatabase = UpdateDatabase+ { title :: Maybe (Vector RichText),+ icon :: Maybe Icon,+ cover :: Maybe Cover,+ description :: Maybe (Vector RichText),+ archived :: Maybe Bool,+ isInline :: Maybe Bool,+ inTrash :: Maybe Bool,+ parent :: Maybe Parent+ }+ deriving stock (Generic, Show)++instance ToJSON UpdateDatabase where+ toJSON = genericToJSON aesonOptions++-- | Query database request+data QueryDatabase = QueryDatabase+ { filter :: Maybe Value,+ sorts :: Maybe [Value],+ startCursor :: Maybe Text,+ pageSize :: Maybe Natural+ }+ deriving stock (Generic, Show)++instance ToJSON QueryDatabase where+ toJSON = genericToJSON aesonOptions++-- | Servant API+type API =+ "databases"+ :> ( ReqBody '[JSON] CreateDatabase+ :> Post '[JSON] DatabaseObject+ :<|> Capture "database_id" DatabaseID+ :> Get '[JSON] DatabaseObject+ :<|> Capture "database_id" DatabaseID+ :> ReqBody '[JSON] UpdateDatabase+ :> Patch '[JSON] DatabaseObject+ :<|> Capture "database_id" DatabaseID+ :> "query"+ :> ReqBody '[JSON] QueryDatabase+ :> Post '[JSON] (ListOf PageObject)+ )
+ src/Notion/V1/Error.hs view
@@ -0,0 +1,21 @@+-- | Error handling for Notion API+module Notion.V1.Error+ ( -- * Error types+ NotionError (..),+ )+where++import Notion.Prelude++-- | Notion API error response+data NotionError = NotionError+ { object :: Text,+ status :: Natural,+ code :: Text,+ message :: Text,+ details :: Maybe Value+ }+ deriving stock (Generic, Show)++instance FromJSON NotionError where+ parseJSON = genericParseJSON aesonOptions
+ src/Notion/V1/ListOf.hs view
@@ -0,0 +1,30 @@+-- | The `ListOf` type constructor for handling paginated Notion API responses+module Notion.V1.ListOf+ ( -- * Types+ ListOf (..),+ )+where++import Data.Aeson ((.!=), (.:), (.:?))+import Notion.Prelude++-- | Notion API typically returns paginated results with this structure+data ListOf a = List+ { results :: Vector a,+ nextCursor :: Maybe Text,+ hasMore :: Bool,+ type_ :: Maybe Text,+ object :: Maybe Text+ }+ deriving stock (Generic, Show)++instance (FromJSON a) => FromJSON (ListOf a) where+ parseJSON = \case+ Object o -> do+ results <- o .: "results"+ nextCursor <- o .:? "next_cursor"+ hasMore <- o .:? "has_more" .!= False+ type_ <- o .:? "type"+ object <- o .:? "object"+ return $ List {..}+ _ -> fail "Expected object for ListOf"
+ src/Notion/V1/Pages.hs view
@@ -0,0 +1,217 @@+-- | @\/v1\/pages@+module Notion.V1.Pages+ ( -- * Main types+ PageID,+ PageObject (..),+ CreatePage (..),+ UpdatePage (..),+ PageProperties,+ PropertyValue (..),+ PropertyItem (..),+ PropertyValueType (..),+ SelectOption (..),+ mkCreatePage,+ mkUpdatePage,++ -- * Servant+ API,+ )+where++import Data.Aeson (Object, Value, (.:), (.:?), (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Char qualified as Char+import Data.String (fromString)+import Notion.Prelude+import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID)+import Notion.V1.Users (UserReference)++-- | Page ID+type PageID = UUID++-- | Notion page object+data PageObject = PageObject+ { id :: PageID,+ createdTime :: POSIXTime,+ lastEditedTime :: POSIXTime,+ createdBy :: UserReference,+ lastEditedBy :: UserReference,+ cover :: Maybe Cover,+ icon :: Maybe Icon,+ parent :: Parent,+ archived :: Bool,+ inTrash :: Bool,+ properties :: Map Text PropertyItem,+ url :: Text,+ object :: ObjectType+ }+ deriving stock (Generic, Show)++instance FromJSON PageObject where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ createdTimeStr <- o .: "created_time"+ createdTime <- parseISO8601 createdTimeStr+ lastEditedTimeStr <- o .: "last_edited_time"+ lastEditedTime <- parseISO8601 lastEditedTimeStr+ createdBy <- o .: "created_by"+ lastEditedBy <- o .: "last_edited_by"+ cover <- o .:? "cover"+ icon <- o .:? "icon"+ parent <- o .: "parent"+ archived <- o .: "archived"+ inTrash <- o .: "in_trash"+ properties <- o .: "properties"+ url <- o .: "url"+ object <- o .: "object"+ return PageObject {..}+ _ -> fail "Expected object for PageObject"++-- | Create a page request+data CreatePage = CreatePage+ { parent :: Parent,+ properties :: PageProperties,+ children :: Maybe (Vector Value),+ icon :: Maybe Icon,+ cover :: Maybe Cover+ }+ deriving stock (Generic, Show)++instance ToJSON CreatePage where+ toJSON = genericToJSON aesonOptions++-- | Smart constructor for 'CreatePage' with required fields+mkCreatePage :: Parent -> PageProperties -> CreatePage+mkCreatePage parent properties =+ CreatePage+ { parent,+ properties,+ children = Nothing,+ icon = Nothing,+ cover = Nothing+ }++-- | Update a page request+data UpdatePage = UpdatePage+ { properties :: PageProperties,+ archived :: Maybe Bool,+ icon :: Maybe Icon,+ cover :: Maybe Cover+ }+ deriving stock (Generic, Show)++instance ToJSON UpdatePage where+ toJSON = genericToJSON aesonOptions++-- | Smart constructor for 'UpdatePage' with required fields+mkUpdatePage :: PageProperties -> UpdatePage+mkUpdatePage properties =+ UpdatePage+ { properties,+ archived = Nothing,+ icon = Nothing,+ cover = Nothing+ }++-- | Page properties map+type PageProperties = Map Text PropertyValue++-- | Property value type for creating or updating pages+data PropertyValue = PropertyValue+ { type_ :: PropertyValueType,+ value :: Maybe Value+ }+ deriving stock (Generic, Show)++instance ToJSON PropertyValue where+ -- Direct conversion - the important part is that we're NOT nesting under "type" or "value" fields+ toJSON PropertyValue {type_ = Title, value = Just v} =+ -- For title property, directly put the array into a "title" field+ case v of+ Object o ->+ if KeyMap.member "title" o+ then Aeson.Object o -- Use this object directly, just have to wrap it+ else Aeson.object ["title" .= v] -- Otherwise wrap it+ _ -> Aeson.object ["title" .= ([] :: [Value])]+ -- Handle other property types+ toJSON PropertyValue {type_ = t, value = Just v} =+ -- Just directly use the value as the property content+ case v of+ Object o -> Aeson.Object o -- Use the object directly, but wrap it+ _ -> Aeson.object [] -- Empty object as fallback++ -- Empty property values+ toJSON PropertyValue {type_} = Aeson.object []++-- | Property item returned by API+data PropertyItem = PropertyItem+ { id :: Text,+ type_ :: PropertyValueType,+ value :: Maybe Value+ }+ deriving stock (Generic, Show)++instance FromJSON PropertyItem where+ parseJSON = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}++-- | Property value types+data PropertyValueType+ = Title+ | RichText+ | Number+ | Select+ | MultiSelect+ | Date+ | People+ | Files+ | Checkbox+ | Url+ | Email+ | PhoneNumber+ | Formula+ | Relation+ | Rollup+ | CreatedTime+ | CreatedBy+ | LastEditedTime+ | LastEditedBy+ | Status+ | UniqueId+ | Place+ | Button+ | Verification+ deriving stock (Generic, Show)++instance FromJSON PropertyValueType where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON PropertyValueType where+ toJSON = genericToJSON aesonOptions++-- | Select option+data SelectOption = SelectOption+ { id :: Maybe Text,+ name :: Text,+ color :: Maybe Text+ }+ deriving stock (Generic, Show)++instance FromJSON SelectOption where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON SelectOption where+ toJSON = genericToJSON aesonOptions++-- | Servant API+type API =+ "pages"+ :> ( Capture "page_id" PageID+ :> Get '[JSON] PageObject+ :<|> ReqBody '[JSON] CreatePage+ :> Post '[JSON] PageObject+ :<|> Capture "page_id" PageID+ :> ReqBody '[JSON] UpdatePage+ :> Patch '[JSON] PageObject+ )
+ src/Notion/V1/Pagination.hs view
@@ -0,0 +1,27 @@+-- | Pagination utilities for Notion API+module Notion.V1.Pagination+ ( -- * Pagination types+ PaginationParams (..),+ defaultPaginationParams,+ )+where++import Notion.Prelude++-- | Pagination parameters for Notion API requests+data PaginationParams = PaginationParams+ { pageSize :: Maybe Natural,+ startCursor :: Maybe Text+ }+ deriving stock (Generic, Show)++instance ToJSON PaginationParams where+ toJSON = genericToJSON aesonOptions++-- | Default pagination parameters+defaultPaginationParams :: PaginationParams+defaultPaginationParams =+ PaginationParams+ { pageSize = Nothing,+ startCursor = Nothing+ }
+ src/Notion/V1/RichText.hs view
@@ -0,0 +1,207 @@+-- | Rich Text objects in Notion API+module Notion.V1.RichText+ ( -- * Rich Text types+ RichText (..),+ RichTextContent (..),+ TextContent (..),+ MentionContent (..),+ EquationContent (..),+ Annotations (..),+ defaultAnnotations,+ Link (..),+ Date (..),+ TimeZone (..),+ )+where++import Data.Aeson (object, (.:), (.:?), (.=))+import Notion.Prelude+import Notion.V1.Common (Color (..), UUID)++-- | Rich text object in Notion+data RichText = RichText+ { plainText :: Text,+ href :: Maybe Text,+ annotations :: Annotations,+ type_ :: Text,+ content :: RichTextContent+ }+ deriving stock (Generic, Show)++instance FromJSON RichText where+ parseJSON = \case+ Object o -> do+ plainText <- o .: "plain_text"+ href <- o .:? "href"+ annotations <- o .: "annotations"+ type_ <- o .: "type"+ content <- case type_ of+ "text" -> TextContentWrapper <$> o .: "text"+ "mention" -> MentionContentWrapper <$> o .: "mention"+ "equation" -> EquationContentWrapper <$> o .: "equation"+ other -> fail $ "Unknown rich text type: " <> unpack other+ return RichText {..}+ _ -> fail "Expected object for RichText"++instance ToJSON RichText where+ toJSON RichText {..} =+ object $+ [ "type" .= type_,+ "plain_text" .= plainText,+ "annotations" .= annotations+ ]+ ++ maybe [] (\h -> ["href" .= h]) href+ ++ case content of+ TextContentWrapper tc -> ["text" .= tc]+ MentionContentWrapper mc -> ["mention" .= mc]+ EquationContentWrapper ec -> ["equation" .= ec]++-- | Text content+data TextContent = TextContent+ { content :: Text,+ link :: Maybe Link+ }+ deriving stock (Generic, Show)++instance FromJSON TextContent where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON TextContent where+ toJSON = genericToJSON aesonOptions++-- | Mention content+--+-- Notion mentions have a @type@ discriminator and the content nested under+-- the corresponding field name. For example:+--+-- @+-- { "type": "user", "user": { "id": "..." } }+-- @+data MentionContent+ = UserMention {user :: UUID}+ | PageMention {page :: UUID}+ | DatabaseMention {database :: UUID}+ | DateMention {date :: Date}+ | LinkPreviewMention {url :: Text}+ deriving stock (Generic, Show)++instance FromJSON MentionContent where+ parseJSON = \case+ Object o -> do+ mentionType <- o .: "type"+ case mentionType of+ "user" -> do+ userObj <- o .: "user"+ UserMention <$> parseIdField userObj+ "page" -> do+ pageObj <- o .: "page"+ PageMention <$> parseIdField pageObj+ "database" -> do+ dbObj <- o .: "database"+ DatabaseMention <$> parseIdField dbObj+ "date" -> DateMention <$> o .: "date"+ "link_preview" -> do+ lpObj <- o .: "link_preview"+ LinkPreviewMention <$> parseUrlField lpObj+ other -> fail $ "Unknown mention type: " <> unpack other+ _ -> fail "Expected object for MentionContent"+ where+ parseIdField = \case+ Object o -> o .: "id"+ _ -> fail "Expected object with id field"+ parseUrlField = \case+ Object o -> o .: "url"+ _ -> fail "Expected object with url field"++instance ToJSON MentionContent where+ toJSON = \case+ UserMention uid ->+ object ["type" .= ("user" :: Text), "user" .= object ["id" .= uid]]+ PageMention pid ->+ object ["type" .= ("page" :: Text), "page" .= object ["id" .= pid]]+ DatabaseMention dbid ->+ object ["type" .= ("database" :: Text), "database" .= object ["id" .= dbid]]+ DateMention d ->+ object ["type" .= ("date" :: Text), "date" .= d]+ LinkPreviewMention u ->+ object ["type" .= ("link_preview" :: Text), "link_preview" .= object ["url" .= u]]++-- | Equation content+newtype EquationContent = EquationContent+ { expression :: Text+ }+ deriving stock (Generic, Show)++instance FromJSON EquationContent where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON EquationContent where+ toJSON = genericToJSON aesonOptions++-- | Content of a rich text object+data RichTextContent+ = TextContentWrapper TextContent+ | MentionContentWrapper MentionContent+ | EquationContentWrapper EquationContent+ deriving stock (Generic, Show)++-- | Text annotations+data Annotations = Annotations+ { bold :: Bool,+ italic :: Bool,+ strikethrough :: Bool,+ underline :: Bool,+ code :: Bool,+ color :: Color+ }+ deriving stock (Generic, Show)++instance FromJSON Annotations where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON Annotations where+ toJSON = genericToJSON aesonOptions++-- | Default annotations+defaultAnnotations :: Annotations+defaultAnnotations =+ Annotations+ { bold = False,+ italic = False,+ strikethrough = False,+ underline = False,+ code = False,+ color = Default+ }++-- | Link object+newtype Link = Link+ { url :: Text+ }+ deriving stock (Generic, Show)++instance FromJSON Link where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON Link where+ toJSON = genericToJSON aesonOptions++-- | Date object+data Date = Date+ { start :: Text,+ end :: Maybe Text,+ timeZone :: Maybe TimeZone+ }+ deriving stock (Generic, Show)++instance FromJSON Date where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON Date where+ toJSON = genericToJSON aesonOptions++-- | Time zone+newtype TimeZone = TimeZone+ { text :: Text+ }+ deriving newtype (FromJSON, IsString, Show, ToJSON)
+ src/Notion/V1/Search.hs view
@@ -0,0 +1,141 @@+-- | @\/v1\/search@+module Notion.V1.Search+ ( -- * Main types+ SearchRequest (..),+ _SearchRequest,+ SearchSortDirection (..),+ SearchSort (..),+ SearchFilter (..),+ SearchObjectType (..),++ -- * Response parsing+ SearchResult (..),+ parseSearchResults,++ -- * Convenience constructors+ pageFilter,+ dataSourceFilter,++ -- * Servant+ API,+ )+where++import Data.Aeson qualified as Aeson+import Data.Vector qualified as Vector+import Notion.Prelude+import Notion.V1.DataSources (DataSourceObject)+import Notion.V1.ListOf (ListOf (..))+import Notion.V1.Pages (PageObject)++-- | Search request+data SearchRequest = SearchRequest+ { query :: Maybe Text,+ sort :: Maybe SearchSort,+ filter :: Maybe SearchFilter,+ startCursor :: Maybe Text,+ pageSize :: Maybe Natural+ }+ deriving stock (Generic, Show)++instance ToJSON SearchRequest where+ toJSON = genericToJSON aesonOptions++-- | Default search request+_SearchRequest :: SearchRequest+_SearchRequest =+ SearchRequest+ { query = Nothing,+ sort = Nothing,+ filter = Nothing,+ startCursor = Nothing,+ pageSize = Nothing+ }++-- | Search sort direction+data SearchSortDirection+ = Ascending+ | Descending+ deriving stock (Generic, Show)++instance ToJSON SearchSortDirection where+ toJSON = genericToJSON aesonOptions++-- | Search sort+data SearchSort = SearchSort+ { direction :: SearchSortDirection,+ timestamp :: Text+ }+ deriving stock (Generic, Show)++instance ToJSON SearchSort where+ toJSON = genericToJSON aesonOptions++-- | Object types supported by the search filter.+-- In API version 2025-09-03, the search API filters by @page@ or @data_source@.+data SearchObjectType+ = SearchPage+ | SearchDataSource+ deriving stock (Eq, Show, Generic)++instance ToJSON SearchObjectType where+ toJSON SearchPage = Aeson.String "page"+ toJSON SearchDataSource = Aeson.String "data_source"++instance FromJSON SearchObjectType where+ parseJSON = Aeson.withText "SearchObjectType" $ \case+ "page" -> pure SearchPage+ "data_source" -> pure SearchDataSource+ other -> fail $ "Unknown search object type: " <> unpack other++-- | Search filter+data SearchFilter = SearchFilter+ { value :: SearchObjectType,+ property :: Text+ }+ deriving stock (Generic, Show)++instance ToJSON SearchFilter where+ toJSON = genericToJSON aesonOptions++-- | Create a filter to search only for pages+pageFilter :: SearchFilter+pageFilter = SearchFilter {value = SearchPage, property = "object"}++-- | Create a filter to search only for data sources+dataSourceFilter :: SearchFilter+dataSourceFilter = SearchFilter {value = SearchDataSource, property = "object"}++-- | Servant API+type API =+ "search"+ :> ReqBody '[JSON] SearchRequest+ :> Post '[JSON] (ListOf Aeson.Value)++-- * Response parsing++-- | A search result can be either a page or a data source+data SearchResult+ = PageResult PageObject+ | DataSourceResult DataSourceObject+ deriving stock (Show)++instance FromJSON SearchResult where+ parseJSON v = do+ obj <- Aeson.parseJSON v+ objectType <- obj Aeson..: "object"+ case objectType of+ "page" -> PageResult <$> Aeson.parseJSON v+ "data_source" -> DataSourceResult <$> Aeson.parseJSON v+ other -> fail $ "Unknown object type in search result: " <> other++-- | Parse raw search results into typed 'SearchResult' values.+-- Results that fail to parse are silently dropped.+parseSearchResults :: ListOf Aeson.Value -> Vector SearchResult+parseSearchResults listOf =+ Vector.mapMaybe parseOne (results listOf)+ where+ parseOne :: Aeson.Value -> Maybe SearchResult+ parseOne v = case Aeson.fromJSON v of+ Aeson.Success r -> Just r+ Aeson.Error _ -> Nothing
+ src/Notion/V1/Users.hs view
@@ -0,0 +1,107 @@+{-# LANGUAGE LambdaCase #-}++-- | @\/v1\/users@+module Notion.V1.Users+ ( -- * Main types+ UserID,+ UserObject (..),+ UserType (..),+ PersonUser (..),+ BotUser (..),+ UserReference (..),++ -- * Servant+ API,+ )+where++import Data.Aeson.Types ((.:))+import Notion.Prelude+import Notion.V1.Common (ObjectType (..), UUID)+import Notion.V1.ListOf (ListOf)++-- | User ID+type UserID = UUID++-- | Notion user object+data UserObject = UserObject+ { id :: UserID,+ name :: Maybe Text,+ avatarUrl :: Maybe Text,+ type_ :: UserType,+ person :: Maybe PersonUser,+ bot :: Maybe BotUser,+ object :: ObjectType+ }+ deriving stock (Generic, Show)++instance FromJSON UserObject where+ parseJSON = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}++-- | User type+data UserType+ = Person+ | Bot+ deriving stock (Generic, Show)++instance FromJSON UserType where+ parseJSON = genericParseJSON aesonOptions++-- | Person user+newtype PersonUser = PersonUser+ { email :: Text+ }+ deriving stock (Generic, Show)++instance FromJSON PersonUser where+ parseJSON = genericParseJSON aesonOptions++-- | Bot user+data BotUser = BotUser+ { owner :: Maybe UserOwner,+ workspaceName :: Maybe Text+ }+ deriving stock (Generic, Show)++instance FromJSON BotUser where+ parseJSON = genericParseJSON aesonOptions++-- | User owner+data UserOwner+ = UserOwner {type_ :: Text, user :: UserID}+ | WorkspaceOwner {type_ :: Text, workspace :: Bool}+ deriving stock (Generic, Show)++instance FromJSON UserOwner where+ parseJSON = \case+ Object o -> do+ ownerType <- o .: "type"+ case ownerType of+ "user" -> UserOwner ownerType <$> (o .: "user")+ "workspace" -> WorkspaceOwner ownerType <$> (o .: "workspace")+ _ -> fail $ "Unknown owner type: " <> unpack ownerType+ _ -> fail "Expected object for UserOwner"++-- | Simple user reference objects that appear in created_by and last_edited_by fields+data UserReference = UserReference+ { id :: UserID,+ object :: Text+ }+ deriving stock (Generic, Show)++instance FromJSON UserReference where+ parseJSON = \case+ Object o -> UserReference <$> o .: "id" <*> o .: "object"+ _ -> fail "Expected object for UserReference"++-- | Servant API+type API =+ "users"+ :> ( Capture "user_id" UserID+ :> Get '[JSON] UserObject+ :<|> QueryParam "page_size" Natural+ :> QueryParam "start_cursor" Text+ :> Get '[JSON] (ListOf UserObject)+ :<|> "me"+ :> Get '[JSON] UserObject+ )
+ src/Notion/V1/Webhooks.hs view
@@ -0,0 +1,340 @@+-- | Notion Webhook types and utilities+--+-- This module provides types for handling incoming webhook events from Notion.+-- Webhook subscriptions are created via the Notion integration UI, not via API.+--+-- Usage:+--+-- @+-- import Notion.V1.Webhooks+-- import Data.Aeson (eitherDecode)+--+-- handleWebhook :: ByteString -> Text -> Text -> IO ()+-- handleWebhook body signature verificationToken = do+-- -- Verify the signature+-- case verifySignature verificationToken body signature of+-- False -> error "Invalid signature"+-- True -> do+-- -- Parse the event+-- case eitherDecode body of+-- Left err -> error err+-- Right event -> processEvent event+--+-- processEvent :: WebhookEvent -> IO ()+-- processEvent event = case event.type_ of+-- PageCreated -> putStrLn "Page created!"+-- CommentCreated -> putStrLn "Comment created!"+-- _ -> putStrLn "Other event"+-- @+module Notion.V1.Webhooks+ ( -- * Event types+ WebhookEvent (..),+ EventType (..),+ WebhookEntity (..),+ EntityType (..),+ Author (..),+ AccessibleBy (..),++ -- * Verification+ VerificationPayload (..),+ verifySignature,+ computeSignature,+ )+where++import Crypto.Hash.SHA256 qualified as SHA256+import Data.Aeson (object, (.:), (.:?), (.=))+import Data.Bits (xor, (.|.))+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Base16 qualified as Base16+import Data.Text.Encoding qualified as Text+import Notion.Prelude hiding (ByteString)+import Notion.V1.Common (UUID (..))++-- | Webhook event types supported by Notion+data EventType+ = -- | Page events+ PageCreated+ | PageDeleted+ | PageUndeleted+ | PagePropertiesUpdated+ | PageContentUpdated+ | PageMoved+ | PageLocked+ | PageUnlocked+ | -- | Database events (deprecated as of 2025-09-03)+ DatabaseCreated+ | DatabaseDeleted+ | DatabaseUndeleted+ | DatabaseContentUpdated+ | DatabaseSchemaUpdated+ | DatabaseMoved+ | -- | Data source events (new in 2025-09-03)+ DataSourceCreated+ | DataSourceDeleted+ | DataSourceUndeleted+ | DataSourceContentUpdated+ | DataSourceSchemaUpdated+ | DataSourceMoved+ | -- | Comment events+ CommentCreated+ | CommentUpdated+ | CommentDeleted+ | -- | Unknown event type (for forward compatibility)+ UnknownEvent Text+ deriving stock (Eq, Show, Generic)++instance FromJSON EventType where+ parseJSON = \case+ String "page.created" -> pure PageCreated+ String "page.deleted" -> pure PageDeleted+ String "page.undeleted" -> pure PageUndeleted+ String "page.properties_updated" -> pure PagePropertiesUpdated+ String "page.content_updated" -> pure PageContentUpdated+ String "page.moved" -> pure PageMoved+ String "page.locked" -> pure PageLocked+ String "page.unlocked" -> pure PageUnlocked+ String "database.created" -> pure DatabaseCreated+ String "database.deleted" -> pure DatabaseDeleted+ String "database.undeleted" -> pure DatabaseUndeleted+ String "database.content_updated" -> pure DatabaseContentUpdated+ String "database.schema_updated" -> pure DatabaseSchemaUpdated+ String "database.moved" -> pure DatabaseMoved+ String "data_source.created" -> pure DataSourceCreated+ String "data_source.deleted" -> pure DataSourceDeleted+ String "data_source.undeleted" -> pure DataSourceUndeleted+ String "data_source.content_updated" -> pure DataSourceContentUpdated+ String "data_source.schema_updated" -> pure DataSourceSchemaUpdated+ String "data_source.moved" -> pure DataSourceMoved+ String "comment.created" -> pure CommentCreated+ String "comment.updated" -> pure CommentUpdated+ String "comment.deleted" -> pure CommentDeleted+ String other -> pure $ UnknownEvent other+ _ -> fail "Expected string for EventType"++instance ToJSON EventType where+ toJSON = \case+ PageCreated -> String "page.created"+ PageDeleted -> String "page.deleted"+ PageUndeleted -> String "page.undeleted"+ PagePropertiesUpdated -> String "page.properties_updated"+ PageContentUpdated -> String "page.content_updated"+ PageMoved -> String "page.moved"+ PageLocked -> String "page.locked"+ PageUnlocked -> String "page.unlocked"+ DatabaseCreated -> String "database.created"+ DatabaseDeleted -> String "database.deleted"+ DatabaseUndeleted -> String "database.undeleted"+ DatabaseContentUpdated -> String "database.content_updated"+ DatabaseSchemaUpdated -> String "database.schema_updated"+ DatabaseMoved -> String "database.moved"+ DataSourceCreated -> String "data_source.created"+ DataSourceDeleted -> String "data_source.deleted"+ DataSourceUndeleted -> String "data_source.undeleted"+ DataSourceContentUpdated -> String "data_source.content_updated"+ DataSourceSchemaUpdated -> String "data_source.schema_updated"+ DataSourceMoved -> String "data_source.moved"+ CommentCreated -> String "comment.created"+ CommentUpdated -> String "comment.updated"+ CommentDeleted -> String "comment.deleted"+ UnknownEvent t -> String t++-- | Entity types in webhook events+data EntityType+ = PageEntity+ | DatabaseEntity+ | DataSourceEntity+ | CommentEntity+ | UnknownEntityType Text+ deriving stock (Eq, Show, Generic)++instance FromJSON EntityType where+ parseJSON = \case+ String "page" -> pure PageEntity+ String "database" -> pure DatabaseEntity+ String "data_source" -> pure DataSourceEntity+ String "comment" -> pure CommentEntity+ String other -> pure $ UnknownEntityType other+ _ -> fail "Expected string for EntityType"++instance ToJSON EntityType where+ toJSON = \case+ PageEntity -> String "page"+ DatabaseEntity -> String "database"+ DataSourceEntity -> String "data_source"+ CommentEntity -> String "comment"+ UnknownEntityType t -> String t++-- | Entity that triggered the webhook event+data WebhookEntity = WebhookEntity+ { id :: UUID,+ type_ :: EntityType+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON WebhookEntity where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ type_ <- o .: "type"+ pure WebhookEntity {..}+ _ -> fail "Expected object for WebhookEntity"++instance ToJSON WebhookEntity where+ toJSON WebhookEntity {..} =+ object+ [ "id" .= id,+ "type" .= type_+ ]++-- | Author who triggered the event (user or bot)+data Author = Author+ { id :: UUID,+ type_ :: Text+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON Author where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ type_ <- o .: "type"+ pure Author {..}+ _ -> fail "Expected object for Author"++instance ToJSON Author where+ toJSON Author {..} =+ object+ [ "id" .= id,+ "type" .= type_+ ]++-- | User or bot with access to the affected entity+data AccessibleBy = AccessibleBy+ { id :: UUID,+ type_ :: Text+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON AccessibleBy where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ type_ <- o .: "type"+ pure AccessibleBy {..}+ _ -> fail "Expected object for AccessibleBy"++instance ToJSON AccessibleBy where+ toJSON AccessibleBy {..} =+ object+ [ "id" .= id,+ "type" .= type_+ ]++-- | A webhook event sent by Notion to your endpoint+data WebhookEvent = WebhookEvent+ { -- | Unique identifier for this event+ id :: UUID,+ -- | When the event occurred (ISO 8601)+ timestamp :: POSIXTime,+ -- | Workspace where the event originated+ workspaceId :: UUID,+ -- | Associated webhook subscription+ subscriptionId :: UUID,+ -- | Integration that owns the subscription+ integrationId :: UUID,+ -- | Type of event+ type_ :: EventType,+ -- | Users/bots who triggered the action+ authors :: Vector Author,+ -- | Users/bots with access to the entity+ accessibleBy :: Vector AccessibleBy,+ -- | Delivery attempt number (1-8)+ attemptNumber :: Int,+ -- | Entity that triggered the event+ entity :: WebhookEntity,+ -- | Event-specific data (varies by event type)+ data_ :: Maybe Value+ }+ deriving stock (Show, Generic)++instance FromJSON WebhookEvent where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ timestampText <- o .: "timestamp"+ timestamp <- parseISO8601 timestampText+ workspaceId <- o .: "workspace_id"+ subscriptionId <- o .: "subscription_id"+ integrationId <- o .: "integration_id"+ type_ <- o .: "type"+ authors <- o .: "authors"+ accessibleBy <- o .: "accessible_by"+ attemptNumber <- o .: "attempt_number"+ entity <- o .: "entity"+ data_ <- o .:? "data"+ pure WebhookEvent {..}+ _ -> fail "Expected object for WebhookEvent"++instance ToJSON WebhookEvent where+ toJSON = genericToJSON aesonOptions++-- | Verification payload sent by Notion when setting up a webhook+-- Your endpoint should receive this and confirm the token in the Notion UI+data VerificationPayload = VerificationPayload+ { verificationToken :: Text+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON VerificationPayload where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON VerificationPayload where+ toJSON = genericToJSON aesonOptions++-- | Compute HMAC-SHA256 signature for webhook payload validation+--+-- The signature is computed as: sha256=HMAC-SHA256(verification_token, body)+computeSignature ::+ -- | Verification token (used as HMAC key)+ Text ->+ -- | Request body (minified JSON)+ ByteString ->+ -- | Computed signature in "sha256=..." format+ Text+computeSignature verificationToken body =+ "sha256=" <> Text.decodeUtf8 (Base16.encode hmacDigest)+ where+ key = Text.encodeUtf8 verificationToken+ hmacDigest = SHA256.hmac key body++-- | Verify webhook signature from X-Notion-Signature header+--+-- Uses constant-time comparison to prevent timing attacks.+--+-- Example:+--+-- @+-- isValid = verifySignature myToken requestBody headerSignature+-- @+verifySignature ::+ -- | Verification token (from webhook setup)+ Text ->+ -- | Request body (minified JSON as received)+ ByteString ->+ -- | Signature from X-Notion-Signature header+ Text ->+ -- | True if signature is valid+ Bool+verifySignature verificationToken body headerSignature =+ constantTimeCompare expected actual+ where+ expected = Text.encodeUtf8 $ computeSignature verificationToken body+ actual = Text.encodeUtf8 headerSignature++-- | Constant-time comparison to prevent timing attacks+constantTimeCompare :: ByteString -> ByteString -> Bool+constantTimeCompare a b+ | BS.length a /= BS.length b = False+ | otherwise = 0 == BS.foldl' (\acc w -> acc .|. w) 0 (BS.packZipWith xor a b)
+ tasty/Main.hs view
@@ -0,0 +1,70 @@+module Main where++import Data.Text qualified as Text+import Notion.V1+import Notion.V1.ListOf (ListOf (..))+import Notion.V1.Search (SearchRequest (..))+import Notion.V1.Users (UserObject (..))+import System.Environment qualified as Environment+import Test.Tasty+import Test.Tasty.HUnit++main :: IO ()+main = do+ -- These tests expect a NOTION_TOKEN environment variable+ -- and only run against the actual Notion API+ defaultMain =<< tests++tests :: IO TestTree+tests = do+ mToken <- lookupEnv "NOTION_TOKEN"++ case mToken of+ Nothing ->+ pure $+ testGroup+ "Notion API Tests"+ [ testCase "No API token found" $+ assertFailure "Set NOTION_TOKEN environment variable to run tests"+ ]+ Just token -> do+ clientEnv <- getClientEnv "https://api.notion.com/v1"+ let methods = makeMethods clientEnv (Text.pack token)++ pure $+ testGroup+ "Notion API Tests"+ [ testCase "Retrieve current user" $ testRetrieveCurrentUser methods,+ testCase "List users" $ testListUsers methods,+ testCase "Timestamp parsing works" $ testSearchAPI methods+ ]++testRetrieveCurrentUser :: Methods -> Assertion+testRetrieveCurrentUser Methods {retrieveMyUser} = do+ user <- retrieveMyUser+ let UserObject {id = userId} = user+ -- Using Show instance of UUID to verify it's not empty+ assertBool "User object should have an ID" (show userId /= "")++testListUsers :: Methods -> Assertion+testListUsers Methods {listUsers} = do+ users <- listUsers Nothing Nothing+ let userCount = length $ results users+ assertBool "Should have at least one user" (userCount > 0)++testSearchAPI :: Methods -> Assertion+testSearchAPI Methods {search} = do+ let searchParams =+ Notion.V1.Search.SearchRequest+ { query = Just "test",+ sort = Nothing,+ filter = Nothing,+ startCursor = Nothing,+ pageSize = Nothing+ }+ _ <- search searchParams+ -- If this test runs without errors, it means timestamp parsing works+ assertBool "Search should run without timestamp parsing errors" True++lookupEnv :: String -> IO (Maybe String)+lookupEnv var = Environment.lookupEnv var