packages feed

notion-client 0.2.0.0 → 0.3.0.0

raw patch · 9 files changed

+297/−61 lines, 9 filesdep ~bytestringdep ~vectorPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: bytestring, vector

API changes (from Hackage documentation)

- Notion.V1.Blocks: [archived] :: BlockObject -> Bool
- Notion.V1.Blocks: newtype AppendBlockChildren
- Notion.V1.DataSources: [archived] :: QueryDataSource -> Maybe Bool
- Notion.V1.Databases: [archived] :: UpdateDatabase -> Maybe Bool
- Notion.V1.Pages: [archived] :: UpdatePage -> Maybe Bool
+ Notion.V1.Blocks: AfterBlock :: UUID -> Position
+ Notion.V1.Blocks: End :: Position
+ Notion.V1.Blocks: Start :: Position
+ Notion.V1.Blocks: [inTrash] :: BlockObject -> Bool
+ Notion.V1.Blocks: [position] :: AppendBlockChildren -> Maybe Position
+ Notion.V1.Blocks: data AppendBlockChildren
+ Notion.V1.Blocks: data Position
+ Notion.V1.Blocks: instance Data.Aeson.Types.ToJSON.ToJSON Notion.V1.Blocks.Position
+ Notion.V1.Blocks: instance GHC.Internal.Generics.Generic Notion.V1.Blocks.Position
+ Notion.V1.Blocks: instance GHC.Internal.Show.Show Notion.V1.Blocks.Position
- Notion.V1.Blocks: AppendBlockChildren :: Vector Value -> AppendBlockChildren
+ Notion.V1.Blocks: AppendBlockChildren :: Vector Value -> Maybe Position -> AppendBlockChildren
- Notion.V1.DataSources: DataSourceObject :: DataSourceID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Vector RichText -> Vector RichText -> Value -> Text -> Parent -> Maybe Parent -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Text -> Maybe Icon -> Maybe Cover -> ObjectType -> DataSourceObject
+ Notion.V1.DataSources: DataSourceObject :: DataSourceID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Vector RichText -> Vector RichText -> Value -> Text -> Parent -> Maybe Parent -> Maybe Bool -> Maybe Bool -> Maybe Text -> Maybe Icon -> Maybe Cover -> ObjectType -> DataSourceObject
- Notion.V1.DataSources: QueryDataSource :: Maybe Value -> Maybe [Value] -> Maybe Text -> Maybe Natural -> Maybe Bool -> Maybe Bool -> QueryDataSource
+ Notion.V1.DataSources: QueryDataSource :: Maybe Value -> Maybe [Value] -> Maybe Text -> Maybe Natural -> Maybe Bool -> QueryDataSource
- Notion.V1.DataSources: UpdateDataSource :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Value -> Maybe Bool -> Maybe Bool -> Maybe Parent -> UpdateDataSource
+ Notion.V1.DataSources: UpdateDataSource :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Value -> Maybe Bool -> Maybe Parent -> UpdateDataSource
- Notion.V1.Databases: DatabaseObject :: DatabaseID -> POSIXTime -> POSIXTime -> Maybe UserReference -> Maybe UserReference -> Vector RichText -> Maybe (Vector RichText) -> Maybe Value -> Maybe Icon -> Maybe Cover -> Text -> Parent -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Text -> Vector DataSource -> ObjectType -> DatabaseObject
+ Notion.V1.Databases: DatabaseObject :: DatabaseID -> POSIXTime -> POSIXTime -> Maybe UserReference -> Maybe UserReference -> Vector RichText -> Maybe (Vector RichText) -> Maybe Value -> Maybe Icon -> Maybe Cover -> Text -> Parent -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Text -> Vector DataSource -> ObjectType -> DatabaseObject
- Notion.V1.Databases: UpdateDatabase :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Cover -> Maybe (Vector RichText) -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Parent -> UpdateDatabase
+ Notion.V1.Databases: UpdateDatabase :: Maybe (Vector RichText) -> Maybe Icon -> Maybe Cover -> Maybe (Vector RichText) -> Maybe Bool -> Maybe Bool -> Maybe Parent -> UpdateDatabase
- Notion.V1.Pages: PageObject :: PageID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Maybe Cover -> Maybe Icon -> Parent -> Bool -> Bool -> Map Text PropertyItem -> Text -> ObjectType -> PageObject
+ Notion.V1.Pages: PageObject :: PageID -> POSIXTime -> POSIXTime -> UserReference -> UserReference -> Maybe Cover -> Maybe Icon -> Parent -> Bool -> Map Text PropertyItem -> Text -> ObjectType -> PageObject
- Notion.V1.Pages: [inTrash] :: PageObject -> Bool
+ Notion.V1.Pages: [inTrash] :: UpdatePage -> Maybe Bool

Files

CHANGELOG.md view
@@ -1,5 +1,25 @@ # Changelog for notion-client +## 0.3.0.0 (2026-03-29)++### Breaking Changes+* Remove `archived` field from `BlockObject` — use `inTrash` instead (renamed to match API 2026-03-11)+* Remove `archived` field from `PageObject` — use existing `inTrash` field+* Remove `archived` field from `DatabaseObject` — use existing `inTrash` field+* Remove `archived` field from `DataSourceObject` — use existing `inTrash` field+* Rename `archived` to `inTrash` in `UpdatePage` request type+* Remove `archived` from `UpdateDatabase` request type — use existing `inTrash` field+* Remove `archived` from `UpdateDataSource` request type — use existing `inTrash` field+* Remove `archived` from `QueryDataSource` request type — use existing `inTrash` field+* Change `AppendBlockChildren` from `newtype` to `data` with new optional `position` field++### New Features+* Add `Position` type (`AfterBlock`, `Start`, `End`) for specifying block insertion position+* The `transcription` block type is renamed to `meeting_notes` by the API (no library code change needed since block types are `Text`)++### Bug Fixes+* Fix backward-compatible `FromJSON` fallback: `.:?` with `<|>` was silently broken (always returned `Nothing`), now correctly falls back through `in_trash` → `is_archived` → `archived`+ ## 0.2.0.0 (2026-03-29)  ### Breaking Changes
notion-client-example/DatabaseDemo.hs view
@@ -65,7 +65,6 @@             sorts = Nothing,             startCursor = Nothing,             pageSize = Just 5,-            archived = Nothing,             inTrash = Nothing           }   dsResults <-@@ -157,7 +156,6 @@             icon = Nothing,             properties = Just combinedProperties,             inTrash = Nothing,-            archived = Nothing,             parent = Nothing           } @@ -260,7 +258,7 @@             createBulletedListItemBlock "Add rich content to pages",             createBulletedListItemBlock "Query and retrieve data"           ]-      appendRequest = Blocks.AppendBlockChildren {children = additionalBlocks}+      appendRequest = Blocks.AppendBlockChildren {children = additionalBlocks, position = Nothing}    -- Add blocks to the page   _updatedPage <-
notion-client-example/PageDemo.hs view
@@ -144,7 +144,7 @@             )           ]       specializedBlocks = Vector.fromList [codeBlock, quoteBlock, calloutBlock]-      appendRequest = Blocks.AppendBlockChildren {children = specializedBlocks}+      appendRequest = Blocks.AppendBlockChildren {children = specializedBlocks, position = Nothing}    -- Append blocks to the existing page   _updatedPage <-
notion-client.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               notion-client-version:            0.2.0.0+version:            0.3.0.0 synopsis:           Type-safe Haskell client for the Notion API description:   This package provides comprehensive and type-safe bindings@@ -93,6 +93,7 @@   build-depends:     , aeson     , base+    , bytestring     , http-client     , http-client-tls     , notion-client@@ -100,6 +101,7 @@     , tasty     , tasty-hunit     , text+    , vector    ghc-options:        -Wall 
src/Notion/V1/Blocks.hs view
@@ -5,6 +5,7 @@     BlockObject (..),     BlockContent (..),     AppendBlockChildren (..),+    Position (..),      -- * Servant     API,@@ -15,9 +16,8 @@ import Data.Aeson ((.:), (.:?), (.=)) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key-import Data.Maybe (fromMaybe) import Notion.Prelude-import Notion.V1.Common (BlockID, ObjectType (..), Parent)+import Notion.V1.Common (BlockID, ObjectType (..), Parent, UUID (..)) import Notion.V1.ListOf (ListOf) import Notion.V1.Users (UserReference) import Prelude hiding (id)@@ -31,7 +31,7 @@     createdBy :: UserReference,     lastEditedBy :: UserReference,     hasChildren :: Bool,-    archived :: Bool,+    inTrash :: Bool,     type_ :: Text,     content :: Value,     object :: ObjectType@@ -50,7 +50,7 @@       createdBy <- o .: "created_by"       lastEditedBy <- o .: "last_edited_by"       hasChildren <- o .: "has_children"-      archived <- fmap (fromMaybe False) (o .:? "is_archived" <|> o .:? "archived")+      inTrash <- (o .: "in_trash") <|> (o .: "is_archived") <|> (o .: "archived") <|> pure False       type_ <- o .: "type"       -- Content is stored under a field named after the block type (e.g., "heading_1", "paragraph")       content <- o .: Key.fromText type_@@ -68,7 +68,7 @@         "created_by" .= createdBy,         "last_edited_by" .= lastEditedBy,         "has_children" .= hasChildren,-        "archived" .= archived,+        "in_trash" .= inTrash,         "type" .= type_,         Key.fromText type_ .= content,         "object" .= object@@ -83,14 +83,48 @@ instance ToJSON BlockContent where   toJSON = genericToJSON aesonOptions +-- | Block insertion position for the Append Block Children endpoint.+--+-- In API version 2026-03-11, the old @after@ parameter was replaced by a+-- @position@ object supporting three placement types.+data Position+  = -- | Insert after a specific block (replaces the old @after@ parameter)+    AfterBlock UUID+  | -- | Insert at the beginning of the parent+    Start+  | -- | Insert at the end of the parent (the default when @position@ is omitted)+    End+  deriving stock (Generic, Show)++instance ToJSON Position where+  toJSON (AfterBlock blockId) =+    Aeson.object+      [ "type" .= ("after_block" :: Text),+        "after_block" .= Aeson.object ["id" .= text blockId]+      ]+  toJSON Start =+    Aeson.object+      [ "type" .= ("start" :: Text),+        "start" .= Aeson.object []+      ]+  toJSON End =+    Aeson.object+      [ "type" .= ("end" :: Text),+        "end" .= Aeson.object []+      ]+ -- | Append children to a block-newtype AppendBlockChildren = AppendBlockChildren-  { children :: Vector Value+data AppendBlockChildren = AppendBlockChildren+  { children :: Vector Value,+    position :: Maybe Position   }   deriving stock (Generic, Show)  instance ToJSON AppendBlockChildren where-  toJSON = genericToJSON aesonOptions+  toJSON AppendBlockChildren {..} =+    Aeson.object $+      ["children" .= children]+        <> maybe [] (\p -> ["position" .= p]) position  -- | Servant API type API =
src/Notion/V1/DataSources.hs view
@@ -41,7 +41,6 @@     url :: Text,     parent :: Parent,     databaseParent :: Maybe Parent,-    archived :: Maybe Bool,     isInline :: Maybe Bool,     inTrash :: Maybe Bool,     publicUrl :: Maybe Text,@@ -67,9 +66,8 @@       url <- o .: "url"       parent <- o .: "parent"       databaseParent <- o .:? "database_parent"-      archived <- o .:? "is_archived" <|> o .:? "archived"       isInline <- o .:? "is_inline"-      inTrash <- o .:? "in_trash"+      inTrash <- (fmap Just (o .: "in_trash")) <|> (fmap Just (o .: "is_archived")) <|> (fmap Just (o .: "archived")) <|> pure Nothing       publicUrl <- o .:? "public_url"       icon <- o .:? "icon"       cover <- o .:? "cover"@@ -95,7 +93,6 @@     icon :: Maybe Icon,     properties :: Maybe Value,     inTrash :: Maybe Bool,-    archived :: Maybe Bool,     parent :: Maybe Parent   }   deriving stock (Generic, Show)@@ -109,7 +106,6 @@     sorts :: Maybe [Value],     startCursor :: Maybe Text,     pageSize :: Maybe Natural,-    archived :: Maybe Bool,     inTrash :: Maybe Bool   }   deriving stock (Generic, Show)
src/Notion/V1/Databases.hs view
@@ -55,7 +55,6 @@     cover :: Maybe Cover,     url :: Text,     parent :: Parent,-    archived :: Maybe Bool,     isInline :: Maybe Bool,     inTrash :: Maybe Bool,     isLocked :: Maybe Bool,@@ -82,9 +81,8 @@       cover <- o .:? "cover"       url <- o .: "url"       parent <- o .: "parent"-      archived <- o .:? "is_archived" <|> o .:? "archived"       isInline <- o .:? "is_inline"-      inTrash <- o .:? "in_trash"+      inTrash <- (fmap Just (o .: "in_trash")) <|> (fmap Just (o .: "is_archived")) <|> (fmap Just (o .: "archived")) <|> pure Nothing       isLocked <- o .:? "is_locked"       publicUrl <- o .:? "public_url"       dataSources <- o .: "data_sources"@@ -130,7 +128,6 @@     icon :: Maybe Icon,     cover :: Maybe Cover,     description :: Maybe (Vector RichText),-    archived :: Maybe Bool,     isInline :: Maybe Bool,     inTrash :: Maybe Bool,     parent :: Maybe Parent
src/Notion/V1/Pages.hs view
@@ -26,7 +26,6 @@ import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap-import Data.Maybe (fromMaybe) import Notion.Prelude hiding (Number) import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID) import Notion.V1.Users (UserReference)@@ -44,7 +43,6 @@     cover :: Maybe Cover,     icon :: Maybe Icon,     parent :: Parent,-    archived :: Bool,     inTrash :: Bool,     properties :: Map Text PropertyItem,     url :: Text,@@ -65,8 +63,7 @@       cover <- o .:? "cover"       icon <- o .:? "icon"       parent <- o .: "parent"-      archived <- fmap (fromMaybe False) (o .:? "is_archived" <|> o .:? "archived")-      inTrash <- o .: "in_trash"+      inTrash <- (o .: "in_trash") <|> (o .: "is_archived") <|> (o .: "archived") <|> pure False       properties <- o .: "properties"       url <- o .: "url"       object <- o .: "object"@@ -84,7 +81,6 @@         "cover" .= cover,         "icon" .= icon,         "parent" .= parent,-        "archived" .= archived,         "in_trash" .= inTrash,         "properties" .= properties,         "url" .= url,@@ -118,7 +114,7 @@ -- | Update a page request data UpdatePage = UpdatePage   { properties :: PageProperties,-    archived :: Maybe Bool,+    inTrash :: Maybe Bool,     icon :: Maybe Icon,     cover :: Maybe Cover   }@@ -132,7 +128,7 @@ mkUpdatePage properties =   UpdatePage     { properties,-      archived = Nothing,+      inTrash = Nothing,       icon = Nothing,       cover = Nothing     }
tasty/Main.hs view
@@ -1,10 +1,17 @@ module Main where +import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy qualified as LBS import Data.Text qualified as Text+import Data.Vector qualified as Vector import Notion.V1+import Notion.V1.Blocks (AppendBlockChildren (..), BlockObject (..), Position (..)) import Notion.V1.Common (UUID (..))+import Notion.V1.DataSources (DataSourceObject (..))+import Notion.V1.Databases (DatabaseObject (..)) import Notion.V1.ListOf (ListOf (..))-import Notion.V1.Pages (PageMarkdown (..))+import Notion.V1.Pages (PageMarkdown (..), PageObject (..)) import Notion.V1.Search (SearchRequest (..)) import Notion.V1.Users (UserObject (..)) import System.Environment qualified as Environment@@ -13,50 +20,237 @@  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)--      mPageId <- lookupEnv "NOTION_TEST_PAGE_ID"+  let integrationTests = case mToken of+        Nothing ->+          testGroup+            "Integration Tests (skipped — no NOTION_TOKEN)"+            [ testCase "No API token found" $+                assertBool "Set NOTION_TOKEN environment variable to run integration tests" True+            ]+        Just token -> unsafePerformIOTestTree token -      let markdownTests = case mPageId of-            Just pageId ->-              [ testCase "Retrieve page as markdown" $-                  testRetrievePageMarkdown methods (Text.pack pageId)-              ]-            Nothing -> []+  mPageId <- lookupEnv "NOTION_TEST_PAGE_ID" -      pure $-        testGroup-          "Notion API Tests"-          ( [ testCase "Retrieve current user" $ testRetrieveCurrentUser methods,-              testCase "List users" $ testListUsers methods,-              testCase "Timestamp parsing works" $ testSearchAPI methods+  let markdownTests = case (mToken, mPageId) of+        (Just token, Just pageId) ->+          testGroup+            "Markdown Tests"+            [ testCase "Retrieve page as markdown" $ do+                clientEnv <- getClientEnv "https://api.notion.com/v1"+                let methods = makeMethods clientEnv (Text.pack token)+                testRetrievePageMarkdown methods (Text.pack pageId)             ]-              <> markdownTests-          )+        _ ->+          testGroup "Markdown Tests (skipped)" [] +  pure $+    testGroup+      "Notion Client Tests"+      [ jsonParsingTests,+        jsonSerializationTests,+        integrationTests,+        markdownTests+      ]++unsafePerformIOTestTree :: String -> TestTree+unsafePerformIOTestTree token =+  testGroup+    "Integration Tests"+    [ testCase "Retrieve current user" $ do+        clientEnv <- getClientEnv "https://api.notion.com/v1"+        let methods = makeMethods clientEnv (Text.pack token)+        testRetrieveCurrentUser methods,+      testCase "List users" $ do+        clientEnv <- getClientEnv "https://api.notion.com/v1"+        let methods = makeMethods clientEnv (Text.pack token)+        testListUsers methods,+      testCase "Timestamp parsing works" $ do+        clientEnv <- getClientEnv "https://api.notion.com/v1"+        let methods = makeMethods clientEnv (Text.pack token)+        testSearchAPI methods+    ]++-- ---------------------------------------------------------------------+-- JSON Parsing Tests (unit tests, no API token needed)+-- ---------------------------------------------------------------------++jsonParsingTests :: TestTree+jsonParsingTests =+  testGroup+    "JSON Parsing"+    [ testCase "Parse BlockObject with in_trash" testParseBlockObject,+      testCase "Parse BlockObject with legacy archived field" testParseBlockObjectLegacy,+      testCase "Parse PageObject with in_trash" testParsePageObject,+      testCase "Parse DatabaseObject with in_trash" testParseDatabaseObject,+      testCase "Parse DataSourceObject with in_trash" testParseDataSourceObject+    ]++testParseBlockObject :: Assertion+testParseBlockObject = do+  let json =+        "{\"object\":\"block\",\"id\":\"abc-123\",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"+          <> ",\"created_time\":\"2025-10-01T12:00:00.000+00:00\""+          <> ",\"last_edited_time\":\"2025-10-01T12:30:00.000+00:00\""+          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"+          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"+          <> ",\"has_children\":false"+          <> ",\"in_trash\":false"+          <> ",\"type\":\"paragraph\""+          <> ",\"paragraph\":{\"rich_text\":[]}}"+  case Aeson.eitherDecode json of+    Left err -> assertFailure $ "Failed to parse BlockObject: " <> err+    Right block -> do+      assertEqual "inTrash should be False" False (Notion.V1.Blocks.inTrash block)+      assertEqual "type should be paragraph" "paragraph" (Notion.V1.Blocks.type_ block)++testParseBlockObjectLegacy :: Assertion+testParseBlockObjectLegacy = do+  -- Test backward compatibility: old "archived" field still parses+  let json =+        "{\"object\":\"block\",\"id\":\"abc-123\",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"+          <> ",\"created_time\":\"2025-10-01T12:00:00.000+00:00\""+          <> ",\"last_edited_time\":\"2025-10-01T12:30:00.000+00:00\""+          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"+          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"+          <> ",\"has_children\":false"+          <> ",\"archived\":true"+          <> ",\"type\":\"paragraph\""+          <> ",\"paragraph\":{\"rich_text\":[]}}"+  case Aeson.eitherDecode json of+    Left err -> assertFailure $ "Failed to parse BlockObject with legacy archived: " <> err+    Right block -> do+      assertEqual "inTrash should be True (from legacy archived)" True (Notion.V1.Blocks.inTrash block)++testParsePageObject :: Assertion+testParsePageObject = do+  let json =+        "{\"object\":\"page\",\"id\":\"page-123\""+          <> ",\"created_time\":\"2025-08-07T10:11:07.504+00:00\""+          <> ",\"last_edited_time\":\"2025-08-10T15:53:11.386+00:00\""+          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"+          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"+          <> ",\"cover\":null,\"icon\":null"+          <> ",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"+          <> ",\"in_trash\":false"+          <> ",\"properties\":{}"+          <> ",\"url\":\"https://www.notion.so/page-123\"}"+  case Aeson.eitherDecode json of+    Left err -> assertFailure $ "Failed to parse PageObject: " <> err+    Right page -> do+      assertEqual "inTrash should be False" False (Notion.V1.Pages.inTrash page)+      assertEqual "url should match" "https://www.notion.so/page-123" (Notion.V1.Pages.url page)++testParseDatabaseObject :: Assertion+testParseDatabaseObject = do+  let json =+        "{\"object\":\"database\",\"id\":\"db-123\""+          <> ",\"created_time\":\"2025-08-07T10:11:07.504+00:00\""+          <> ",\"last_edited_time\":\"2025-08-10T15:53:11.386+00:00\""+          <> ",\"title\":[],\"url\":\"https://www.notion.so/db-123\""+          <> ",\"parent\":{\"type\":\"page_id\",\"page_id\":\"parent-1\"}"+          <> ",\"in_trash\":false"+          <> ",\"data_sources\":[]}"+  case Aeson.eitherDecode json of+    Left err -> assertFailure $ "Failed to parse DatabaseObject: " <> err+    Right db -> do+      assertEqual "inTrash should be Just False" (Just False) (Notion.V1.Databases.inTrash db)++testParseDataSourceObject :: Assertion+testParseDataSourceObject = do+  let json =+        "{\"object\":\"data_source\",\"id\":\"ds-123\""+          <> ",\"created_time\":\"2025-08-07T10:11:07.504+00:00\""+          <> ",\"last_edited_time\":\"2025-08-10T15:53:11.386+00:00\""+          <> ",\"created_by\":{\"object\":\"user\",\"id\":\"user-1\"}"+          <> ",\"last_edited_by\":{\"object\":\"user\",\"id\":\"user-2\"}"+          <> ",\"title\":[],\"description\":[],\"properties\":{}"+          <> ",\"url\":\"https://www.notion.so/ds-123\""+          <> ",\"parent\":{\"type\":\"database_id\",\"database_id\":\"db-1\"}"+          <> ",\"in_trash\":false}"+  case Aeson.eitherDecode json of+    Left err -> assertFailure $ "Failed to parse DataSourceObject: " <> err+    Right ds -> do+      assertEqual "inTrash should be Just False" (Just False) (Notion.V1.DataSources.inTrash ds)++-- ---------------------------------------------------------------------+-- JSON Serialization Tests+-- ---------------------------------------------------------------------++jsonSerializationTests :: TestTree+jsonSerializationTests =+  testGroup+    "JSON Serialization"+    [ testCase "AppendBlockChildren without position" testSerializeAppendNoPosition,+      testCase "AppendBlockChildren with AfterBlock position" testSerializeAppendAfterBlock,+      testCase "AppendBlockChildren with Start position" testSerializeAppendStart,+      testCase "AppendBlockChildren with End position" testSerializeAppendEnd+    ]++testSerializeAppendNoPosition :: Assertion+testSerializeAppendNoPosition = do+  let req = AppendBlockChildren {children = Vector.empty, position = Nothing}+      json = Aeson.toJSON req+  case json of+    Aeson.Object o -> do+      assertBool "should have children key" (KeyMap.member "children" o)+      assertBool "should not have position key" (not $ KeyMap.member "position" o)+    _ -> assertFailure "Expected JSON object"++testSerializeAppendAfterBlock :: Assertion+testSerializeAppendAfterBlock = do+  let req = AppendBlockChildren {children = Vector.empty, position = Just (AfterBlock (UUID "block-42"))}+      json = Aeson.toJSON req+  case json of+    Aeson.Object o -> do+      assertBool "should have position key" (KeyMap.member "position" o)+      case KeyMap.lookup "position" o of+        Just (Aeson.Object pos) -> do+          assertEqual "position type" (Just (Aeson.String "after_block")) (KeyMap.lookup "type" pos)+          case KeyMap.lookup "after_block" pos of+            Just (Aeson.Object ab) ->+              assertEqual "after_block id" (Just (Aeson.String "block-42")) (KeyMap.lookup "id" ab)+            _ -> assertFailure "Expected after_block object"+        _ -> assertFailure "Expected position object"+    _ -> assertFailure "Expected JSON object"++testSerializeAppendStart :: Assertion+testSerializeAppendStart = do+  let req = AppendBlockChildren {children = Vector.empty, position = Just Start}+      json = Aeson.toJSON req+  case json of+    Aeson.Object o ->+      case KeyMap.lookup "position" o of+        Just (Aeson.Object pos) ->+          assertEqual "position type" (Just (Aeson.String "start")) (KeyMap.lookup "type" pos)+        _ -> assertFailure "Expected position object"+    _ -> assertFailure "Expected JSON object"++testSerializeAppendEnd :: Assertion+testSerializeAppendEnd = do+  let req = AppendBlockChildren {children = Vector.empty, position = Just End}+      json = Aeson.toJSON req+  case json of+    Aeson.Object o ->+      case KeyMap.lookup "position" o of+        Just (Aeson.Object pos) ->+          assertEqual "position type" (Just (Aeson.String "end")) (KeyMap.lookup "type" pos)+        _ -> assertFailure "Expected position object"+    _ -> assertFailure "Expected JSON object"++-- ---------------------------------------------------------------------+-- Integration test helpers+-- ---------------------------------------------------------------------+ 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@@ -76,7 +270,6 @@             pageSize = Nothing           }   _ <- search searchParams-  -- If this test runs without errors, it means timestamp parsing works   assertBool "Search should run without timestamp parsing errors" True  testRetrievePageMarkdown :: Methods -> Text.Text -> Assertion