packages feed

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 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