packages feed

notion-client 0.8.0.0 → 0.9.0.0

raw patch · 7 files changed

+645/−26 lines, 7 files

Files

CHANGELOG.md view
@@ -1,5 +1,18 @@ # Changelog for notion-client +## 0.9.0.0 (2026-10-12)++### Breaking Changes++* `RelationValue` now carries a trailing `Maybe Bool` containing the supplied relation `has_more`. Change patterns from `RelationValue pid refs` to `RelationValue pid refs hasMore`; manually constructed mutation values become `RelationValue pid refs Nothing`. The `relationValue :: [UUID] -> PropertyValue` smart constructor keeps its signature.+* Use `notion-client-effectful >=0.3 && <0.4` with this core release; the companion now requires `notion-client >=0.9 && <0.10`.++### New Features++* `Notion.V1.PropertyValue.propertyValueResponseJSON` and `Notion.V1.Pages.pageResponseJSON` serialize modeled response data, preserving property IDs/types, supplied relation completeness, full users in people/mentions/verification, and nested rollup values. Switch response output from `Aeson.encode value` to `Aeson.encode (propertyValueResponseJSON value)` or `Aeson.encode (pageResponseJSON page)`.+* `Just True` means an incomplete relation list, `Just False` means complete, and `Nothing` means absent/null; no completeness state is inferred from reference count and no additional HTTP request is made. Empty property IDs are treated as absent and omitted, including in nested rollups; nonempty IDs are preserved verbatim. Unmodeled fields and original JSON bytes are outside the guarantee.+* Existing `ToJSON` instances and mutation builders keep their request encoding.+ ## 0.8.0.0 (2026-09-15)  ### Breaking Changes
README.md view
@@ -86,6 +86,35 @@         _ -> Nothing ``` +### Serializing page responses++Use the response helpers when exporting API results:++```haskell+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy (ByteString)+import Notion.V1.Pages (PageObject, pageResponseJSON)+import Notion.V1.PropertyValue (PropertyValue, propertyValueResponseJSON)++encodePageResponse :: PageObject -> ByteString+encodePageResponse = Aeson.encode . pageResponseJSON+encodePropertyResponse :: PropertyValue -> ByteString+encodePropertyResponse = Aeson.encode . propertyValueResponseJSON+```++These helpers preserve modeled page fields and property IDs, type discriminators,+full users, and nested rollup values. Existing `ToJSON` instances and create/update+builders keep their mutation encoding. Response helpers preserve modeled data;+they do not preserve unmodeled fields or the original JSON bytes. An empty property+ID is the model's absent-ID sentinel and is omitted, including inside rollups.+Nonempty IDs, including URL-encoded IDs, are emitted unchanged.++`RelationValue pid refs hasMore` retains `has_more` as `Maybe Bool`: `Just True`+means the reference list is incomplete, `Just False` means complete, and `Nothing`+means the flag was absent (or null). No state is inferred from the reference count,+and the encoder does not fetch further references. `relationValue` keeps its+existing `[UUID] -> PropertyValue` signature and supplies `Nothing` for writes.+ ### Creating a page with markdown  ```haskell
notion-client.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: notion-client-version: 0.8.0.0+version: 0.9.0.0 synopsis: Type-safe Haskell client for the Notion API description:   This package provides comprehensive and type-safe bindings@@ -121,6 +121,7 @@     MeetingNotesTests     OAuthTests     ObjectFieldTests+    ResponseJSONTests     RuntimeTests     ViewTests     WireFormatTests
src/Notion/V1/Pages.hs view
@@ -3,6 +3,7 @@   ( -- * Main types     PageID,     PageObject (..),+    pageResponseJSON,     PartialPageObject (..),     CreatePage (..),     PagePosition (..),@@ -52,7 +53,7 @@ import Notion.V1.Clearable (Clearable (..)) import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID) import Notion.V1.ListOf (ListOf)-import Notion.V1.PropertyValue (PropertyValue, RollupResult)+import Notion.V1.PropertyValue (PropertyValue, RollupResult, propertyValueResponseJSON) import Notion.V1.Users (UserReference) import Servant.API (QueryParams, StdMethod (PATCH, POST)) @@ -103,24 +104,36 @@     _ -> fail "Expected object for PageObject"  instance ToJSON PageObject where-  toJSON PageObject {..} =-    Aeson.object $-      [ "id" .= id,-        "created_time" .= posixToISO8601 createdTime,-        "last_edited_time" .= posixToISO8601 lastEditedTime,-        "created_by" .= createdBy,-        "last_edited_by" .= lastEditedBy,-        "cover" .= cover,-        "icon" .= icon,-        "parent" .= parent,-        "in_trash" .= inTrash,-        "properties" .= properties,-        "url" .= url,-        "object" .= object-      ]-        <> maybe [] (\v -> ["is_locked" .= v]) isLocked-        <> maybe [] (\v -> ["is_archived" .= v]) isArchived-        <> maybe [] (\pu -> ["public_url" .= pu]) publicUrl+  toJSON = pageJSONWith toJSON++-- | Encode the page's modeled response fields and property metadata.+-- Properties use 'propertyValueResponseJSON', retaining supplied relation+-- completeness and nested response data. Optional page fields follow the+-- existing encoding conventions. Unmodeled fields and original JSON bytes are+-- not retained; this helper performs no HTTP requests. Mutation builders and+-- the existing 'ToJSON' instances keep their established request encoding.+pageResponseJSON :: PageObject -> Value+pageResponseJSON = pageJSONWith propertyValueResponseJSON++pageJSONWith :: (PropertyValue -> Value) -> PageObject -> Value+pageJSONWith encodeProperty PageObject {..} =+  Aeson.object $+    [ "id" .= id,+      "created_time" .= posixToISO8601 createdTime,+      "last_edited_time" .= posixToISO8601 lastEditedTime,+      "created_by" .= createdBy,+      "last_edited_by" .= lastEditedBy,+      "cover" .= cover,+      "icon" .= icon,+      "parent" .= parent,+      "in_trash" .= inTrash,+      "properties" .= Map.map encodeProperty properties,+      "url" .= url,+      "object" .= object+    ]+      <> maybe [] (\v -> ["is_locked" .= v]) isLocked+      <> maybe [] (\v -> ["is_archived" .= v]) isArchived+      <> maybe [] (\pu -> ["public_url" .= pu]) publicUrl  -- | @{"object":"page","id":...}@ --
src/Notion/V1/PropertyValue.hs view
@@ -9,10 +9,12 @@ -- -- * A 'PropertyValue' sum type for reading page properties via pattern matching -- * Smart constructors ('titleValue', 'selectValue', etc.) for writing properties+-- * 'propertyValueResponseJSON' for exporting modeled response data -- * Supporting types ('SelectOptionValue', 'FileValue', 'RelationRef', etc.) module Notion.V1.PropertyValue   ( -- * Property value     PropertyValue (..),+    propertyValueResponseJSON,      -- * Supporting types     SelectOptionValue (..),@@ -51,14 +53,17 @@ import Data.Aeson ((.:), (.:?), (.=)) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (Pair) import Data.Maybe (fromMaybe) import Data.Scientific (Scientific) import Data.Vector qualified as Vector import Notion.Prelude import Notion.V1.Common (ExternalFile (..), File, UUID (..)) import Notion.V1.Properties (RollupFunction)-import Notion.V1.RichText (Date (..), RichText)+import Notion.V1.RichText (Date (..), MentionContent (..), RichText (..), RichTextContent (..)) import Notion.V1.Users (PeopleEntry (..), UserReference (..), UserValue (..))+import Notion.V1.Users qualified as Users import Prelude hiding (id)  -- | A typed property value from a Notion page.@@ -88,7 +93,10 @@   | EmailValue Text (Maybe Text)   | PhoneNumberValue Text (Maybe Text)   | FormulaValue Text FormulaResult-  | RelationValue Text (Vector RelationRef)+  | -- | The supplied completeness flag: @Just True@ means the references are+    -- incomplete, @Just False@ means complete, and @Nothing@ means unspecified.+    -- No flag is inferred from the reference count and no extra request is made.+    RelationValue Text (Vector RelationRef) (Maybe Bool)   | RollupValue Text RollupResult   | CreatedTimeValue Text Text   | CreatedByValue Text UserReference@@ -124,7 +132,7 @@         "email" -> EmailValue pid <$> o .:? key         "phone_number" -> PhoneNumberValue pid <$> o .:? key         "formula" -> FormulaValue pid <$> o .: key-        "relation" -> RelationValue pid <$> o .: key+        "relation" -> RelationValue pid <$> o .: key <*> o .:? "has_more"         "rollup" -> RollupValue pid <$> o .: key         "created_time" -> CreatedTimeValue pid <$> o .: key         "created_by" -> CreatedByValue pid <$> o .: key@@ -153,7 +161,7 @@     EmailValue _ v -> Aeson.object ["email" .= v]     PhoneNumberValue _ v -> Aeson.object ["phone_number" .= v]     FormulaValue _ v -> Aeson.object ["formula" .= v]-    RelationValue _ v -> Aeson.object ["relation" .= v]+    RelationValue _ v _ -> Aeson.object ["relation" .= v]     RollupValue _ v -> Aeson.object ["rollup" .= v]     CreatedTimeValue _ v -> Aeson.object ["created_time" .= v]     CreatedByValue _ v -> Aeson.object ["created_by" .= v]@@ -166,6 +174,99 @@     VerificationValue _ v -> Aeson.object ["verification" .= v]     UnknownPropertyValue _ t v -> Aeson.object [Key.fromText t .= v] +-- | Encode modeled response data, including the property's ID and type.+-- Unlike 'toJSON', which builds mutation payloads, this preserves supplied+-- relation completeness and full users, and recursively encodes rollup arrays.+-- An empty ID is the model's absent-ID sentinel and is omitted (including in+-- nested rollups). Nonempty IDs are preserved verbatim. Unmodeled response+-- fields and original JSON bytes are not retained.+propertyValueResponseJSON :: PropertyValue -> Value+propertyValueResponseJSON = \case+  TitleValue pid v -> envelope pid "title" (toJSON (fmap richTextResponseJSON v)) []+  RichTextValue pid v -> envelope pid "rich_text" (toJSON (fmap richTextResponseJSON v)) []+  NumberValue pid v -> envelope pid "number" (toJSON v) []+  SelectValue pid v -> envelope pid "select" (toJSON v) []+  MultiSelectValue pid v -> envelope pid "multi_select" (toJSON v) []+  DateValue pid v -> envelope pid "date" (toJSON v) []+  PeopleValue pid v -> envelope pid "people" (toJSON (fmap peopleEntryResponseJSON v)) []+  FilesValue pid v -> envelope pid "files" (toJSON v) []+  CheckboxValue pid v -> envelope pid "checkbox" (toJSON v) []+  UrlValue pid v -> envelope pid "url" (toJSON v) []+  EmailValue pid v -> envelope pid "email" (toJSON v) []+  PhoneNumberValue pid v -> envelope pid "phone_number" (toJSON v) []+  FormulaValue pid v -> envelope pid "formula" (toJSON v) []+  RelationValue pid v hasMore -> envelope pid "relation" (toJSON v) (optionalResponsePair "has_more" hasMore)+  RollupValue pid v -> envelope pid "rollup" (rollupResponseJSON v) []+  CreatedTimeValue pid v -> envelope pid "created_time" (toJSON v) []+  CreatedByValue pid v -> envelope pid "created_by" (toJSON v) []+  LastEditedTimeValue pid v -> envelope pid "last_edited_time" (toJSON v) []+  LastEditedByValue pid v -> envelope pid "last_edited_by" (toJSON v) []+  StatusValue pid v -> envelope pid "status" (toJSON v) []+  UniqueIdValue pid v -> envelope pid "unique_id" (toJSON v) []+  PlaceValue pid v -> envelope pid "place" (toJSON v) []+  ButtonValue pid v -> envelope pid "button" (toJSON v) []+  VerificationValue pid v -> envelope pid "verification" (maybe Null verificationResponseJSON v) []+  UnknownPropertyValue pid t v -> envelope pid t v []+  where+    envelope pid t v extra =+      Aeson.object (["id" .= pid | pid /= ""] <> ["type" .= t, Key.fromText t .= v] <> extra)++optionalResponsePair :: (ToJSON a) => Key.Key -> Maybe a -> [Pair]+optionalResponsePair key = maybe [] (\v -> [key .= v])++rollupResponseJSON :: RollupResult -> Value+rollupResponseJSON = \case+  RollupArrayResult v fn ->+    Aeson.object ["type" .= ("array" :: Text), "array" .= fmap propertyValueResponseJSON v, "function" .= fn]+  other -> toJSON other++peopleEntryResponseJSON :: PeopleEntry -> Value+peopleEntryResponseJSON = \case+  PersonEntry u -> userResponseJSON u+  group@(GroupEntry _) -> toJSON group++userResponseJSON :: UserValue -> Value+userResponseJSON = \case+  partial@(PartialUser _) -> toJSON partial+  FullUser (Users.UserObject uid uname avatar userType person bot objectType) ->+    Aeson.object $+      ["object" .= objectType, "id" .= uid, "type" .= userTypeText userType]+        <> optionalResponsePair "name" uname+        <> optionalResponsePair "avatar_url" avatar+        <> optionalResponsePair "person" (fmap personResponseJSON person)+        <> optionalResponsePair "bot" (fmap botResponseJSON bot)+  where+    userTypeText Users.Person = "person" :: Text+    userTypeText Users.Bot = "bot"+    personResponseJSON (Users.PersonUser email) = Aeson.object (optionalResponsePair "email" email)+    botResponseJSON (Users.BotUser owner workspaceName workspaceId limits) =+      Aeson.object $+        optionalResponsePair "owner" (fmap ownerResponseJSON owner)+          <> optionalResponsePair "workspace_name" workspaceName+          <> optionalResponsePair "workspace_id" workspaceId+          <> optionalResponsePair "workspace_limits" (fmap limitsResponseJSON limits)+    limitsResponseJSON (Users.WorkspaceLimits maxSize) =+      Aeson.object (optionalResponsePair "max_file_upload_size_in_bytes" maxSize)+    ownerResponseJSON = \case+      Users.UserOwner t uid -> Aeson.object ["type" .= t, "user" .= Aeson.object ["id" .= uid]]+      Users.WorkspaceOwner t workspace -> Aeson.object ["type" .= t, "workspace" .= workspace]+      Users.UnknownOwner _ raw -> raw++verificationResponseJSON :: VerificationResult -> Value+verificationResponseJSON VerificationResult {..} =+  Aeson.object $+    ["state" .= state]+      <> optionalResponsePair "date" date+      <> optionalResponsePair "verified_by" (fmap userResponseJSON verifiedBy)++-- Reuse the established rich-text envelope, replacing only the nested user.+richTextResponseJSON :: RichText -> Value+richTextResponseJSON rt@RichText {content = MentionContentWrapper (UserMention u)} =+  case toJSON rt of+    Object o -> Object (KeyMap.insert "mention" (Aeson.object ["type" .= ("user" :: Text), "user" .= userResponseJSON u]) o)+    v -> v+richTextResponseJSON rt = toJSON rt+ -- --------------------------------------------------------------------------- -- Supporting types -- ---------------------------------------------------------------------------@@ -426,7 +527,7 @@  -- | Create a relation property value from a list of page IDs. relationValue :: [UUID] -> PropertyValue-relationValue ids = RelationValue "" (Vector.fromList (map RelationRef ids))+relationValue ids = RelationValue "" (Vector.fromList (map RelationRef ids)) Nothing  -- | Create a status property value by option name. statusValue :: Text -> PropertyValue
tasty/Main.hs view
@@ -58,6 +58,7 @@ import Notion.V1.Views qualified as Views import OAuthTests qualified import ObjectFieldTests qualified+import ResponseJSONTests qualified import RuntimeTests qualified import System.Environment qualified as Environment import Test.Tasty@@ -180,6 +181,7 @@         MeetingNotesTests.tests,         WireFormatTests.tests,         ObjectFieldTests.tests,+        ResponseJSONTests.tests,         RuntimeTests.tests,         OAuthTests.tests,         HelpersTests.tests,@@ -1887,7 +1889,8 @@   let json = "{\"id\":\"rel\",\"type\":\"relation\",\"relation\":[{\"id\":\"page-1\"},{\"id\":\"page-2\"}]}"   case Aeson.eitherDecode json of     Left err -> assertFailure $ "Failed to parse RelationValue: " <> err-    Right (PV.RelationValue pid refs) -> do+    Right (PV.RelationValue pid refs hasMore) -> do+      hasMore @?= Nothing       assertEqual "property id" "rel" pid       assertEqual "relation count" 2 (Vector.length refs)     Right other -> assertFailure $ "Expected RelationValue, got: " <> show other
+ tasty/ResponseJSONTests.hs view
@@ -0,0 +1,459 @@+-- | Response serialization and request isolation, using only scripted HTTP.+module ResponseJSONTests (tests) where++import Control.Monad (forM_)+import Data.Aeson ((.=))+import Data.Aeson qualified as A+import Data.Aeson.Key qualified as K+import Data.Aeson.KeyMap qualified as KM+import Data.Map qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Vector qualified as V+import FakeNotion (fakeClientEnv, jsonReply)+import Notion.V1 (Methods (..), defaultClientConfig, makeMethodsWithEnv)+import Notion.V1.Common (Parent (..))+import Notion.V1.DataSources (PageOrDataSource (..), _QueryDataSource)+import Notion.V1.ListOf qualified as List+import Notion.V1.Pages qualified as Pages+import Notion.V1.PropertyValue hiding (date, id)+import Notion.V1.RichText qualified as RT+import Notion.V1.Search (_SearchRequest)+import Notion.V1.Users qualified as U+import Test.Tasty+import Test.Tasty.HUnit++fromValue :: (A.FromJSON a) => A.Value -> IO a+fromValue v = case A.fromJSON v of+  A.Success a -> pure a+  A.Error e -> assertFailure e++field :: K.Key -> A.Value -> A.Value+field key (A.Object o) = maybe A.Null id (KM.lookup key o)+field _ _ = A.Null++hasField :: K.Key -> A.Value -> Bool+hasField key (A.Object o) = KM.member key o+hasField _ _ = False++prop :: Text -> A.Value -> A.Value+prop t v = A.object ["id" .= ("x%3Ay" :: Text), "type" .= t, K.fromText t .= v]++relation :: Maybe Bool -> [A.Value] -> A.Value+relation flag refs =+  A.object $+    ["id" .= ("rel" :: Text), "type" .= ("relation" :: Text), "relation" .= refs]+      <> maybe [] (\b -> ["has_more" .= b]) flag++userRef :: A.Value+userRef = A.object ["object" .= ("user" :: Text), "id" .= ("u1" :: Text)]++person :: A.Value+person =+  A.object+    [ "object" .= ("user" :: Text),+      "id" .= ("u1" :: Text),+      "type" .= ("person" :: Text),+      "name" .= ("Ada" :: Text),+      "avatar_url" .= ("https://example.com/avatar" :: Text),+      "person" .= A.object ["email" .= ("ada@example.com" :: Text)]+    ]++date :: A.Value+date = A.object ["start" .= ("2026-10-12" :: Text), "end" .= ("2026-10-13" :: Text), "time_zone" .= ("UTC" :: Text)]++mention :: A.Value -> A.Value+mention m =+  A.object+    [ "type" .= ("mention" :: Text),+      "plain_text" .= ("Ada" :: Text),+      "href" .= ("https://example.com/user" :: Text),+      "annotations" .= RT.defaultAnnotations,+      "mention" .= m+    ]++userMention :: A.Value+userMention = mention (A.object ["type" .= ("user" :: Text), "user" .= person])++-- Check the typed constructor independently so an unknown fallback cannot hide+-- a failed known decoder while still passing the structural equality assertion.+constructorKind :: PropertyValue -> Text+constructorKind = \case+  TitleValue {} -> "title"+  RichTextValue {} -> "rich_text"+  NumberValue {} -> "number"+  SelectValue {} -> "select"+  MultiSelectValue {} -> "multi_select"+  DateValue {} -> "date"+  PeopleValue {} -> "people"+  FilesValue {} -> "files"+  CheckboxValue {} -> "checkbox"+  UrlValue {} -> "url"+  EmailValue {} -> "email"+  PhoneNumberValue {} -> "phone_number"+  FormulaValue {} -> "formula"+  RelationValue {} -> "relation"+  RollupValue {} -> "rollup"+  CreatedTimeValue {} -> "created_time"+  CreatedByValue {} -> "created_by"+  LastEditedTimeValue {} -> "last_edited_time"+  LastEditedByValue {} -> "last_edited_by"+  StatusValue {} -> "status"+  UniqueIdValue {} -> "unique_id"+  PlaceValue {} -> "place"+  ButtonValue {} -> "button"+  VerificationValue {} -> "verification"+  UnknownPropertyValue {} -> "unknown"++fixtures :: [(Text, A.Value)]+fixtures =+  [ ("title", A.toJSON [userMention]),+    ("rich_text", A.toJSON [userMention]),+    ("number", A.Number 42),+    ("select", option),+    ("multi_select", A.toJSON [option]),+    ("date", date),+    ("people", A.toJSON [person, userRef, A.object ["object" .= ("group" :: Text), "id" .= ("g1" :: Text), "name" .= ("Team" :: Text)]]),+    ( "files",+      A.toJSON+        [ A.object ["type" .= ("external" :: Text), "name" .= ("Link" :: Text), "external" .= A.object ["url" .= ("https://example.com/file" :: Text)]],+          A.object ["type" .= ("file" :: Text), "name" .= ("File" :: Text), "file" .= A.object ["url" .= ("https://example.com/file" :: Text), "expiry_time" .= ("2026-10-13T00:00:00Z" :: Text)]],+          A.object ["type" .= ("file_upload" :: Text), "name" .= ("Upload" :: Text), "file_upload" .= A.object ["id" .= ("f1" :: Text)]]+        ]+    ),+    ("checkbox", A.Bool False),+    ("url", A.String "https://example.com"),+    ("email", A.String "ada@example.com"),+    ("phone_number", A.String "555-0123"),+    ("formula", A.object ["type" .= ("number" :: Text), "number" .= (4 :: Int)]),+    ("relation", A.toJSON [A.object ["id" .= ("p2" :: Text)]]),+    ("rollup", A.object ["type" .= ("number" :: Text), "number" .= (4 :: Int), "function" .= ("sum" :: Text)]),+    ("created_time", A.String "2026-10-12T00:00:00Z"),+    ("created_by", userRef),+    ("last_edited_time", A.String "2026-10-13T00:00:00Z"),+    ("last_edited_by", userRef),+    ("status", option),+    ("unique_id", A.object ["number" .= (7 :: Int), "prefix" .= ("TASK" :: Text)]),+    ("place", A.object ["lat" .= (1 :: Int), "lon" .= (2 :: Int), "name" .= ("Home" :: Text), "address" .= ("Main St" :: Text), "aws_place_id" .= ("aws1" :: Text), "google_place_id" .= ("google1" :: Text)]),+    ("button", A.object ["arbitrary" .= True]),+    ("verification", A.object ["state" .= ("verified" :: Text), "verified_by" .= person, "date" .= date]),+    ("future_property", A.object ["raw" .= [True, False]])+  ]+  where+    option = A.object ["id" .= ("o1" :: Text), "name" .= ("Ready" :: Text), "color" .= ("green" :: Text), "description" .= ("Next" :: Text)]++checkProperty :: Text -> A.Value -> Assertion+checkProperty kind raw = do+  p <- fromValue raw+  constructorKind p @?= kind+  propertyValueResponseJSON p @?= raw++relationTests :: TestTree+relationTests =+  testGroup+    "Relations"+    [ testGroup+        "Relation metadata"+        [ testCase (show flag <> "/" <> show count) $ do+            let raw = relation flag (replicate count (A.object ["id" .= ("p2" :: Text)]))+            p <- fromValue raw+            case p of+              RelationValue pid refs retained -> do+                pid @?= "rel"+                V.length refs @?= count+                retained @?= flag+              other -> assertFailure (show other)+            propertyValueResponseJSON p @?= raw+            hasField "has_more" (propertyValueResponseJSON p) @?= maybe False (const True) flag+        | flag <- [Just True, Just False, Nothing],+          count <- [0, 1, 25]+        ],+      testGroup+        "Flag decoding"+        [ testCase "null is absent" $ do+            p <- fromValue (setField "has_more" A.Null (relation Nothing []))+            case p of+              RelationValue _ _ flag -> flag @?= Nothing+              other -> assertFailure (show other),+          testCase "malformed flag is an error" $+            case A.fromJSON @PropertyValue (setField "has_more" (A.String "true") (relation Nothing [])) of+              A.Error _ -> pure ()+              A.Success p -> assertFailure (show p)+        ]+    ]++setField :: K.Key -> A.Value -> A.Value -> A.Value+setField key v (A.Object o) = A.Object (KM.insert key v o)+setField _ _ v = v++tests :: TestTree+tests =+  testGroup+    "Response JSON"+    [ relationTests,+      testGroup "Every constructor" [testCase (Text.unpack t) (checkProperty (if t == "future_property" then "unknown" else t) (prop t v)) | (t, v) <- fixtures],+      testGroup "Nullable values" [testCase (Text.unpack t) (checkProperty t (prop t A.Null)) | t <- ["number", "select", "date", "url", "email", "phone_number", "status", "place", "button", "verification"]],+      formulaTests,+      rollupTests,+      userTests,+      mutationTests,+      pageTests+    ]++formulaTests :: TestTree+formulaTests =+  testGroup+    "Formulas"+    [ testCase (Text.unpack t <> if v == A.Null then " null" else " populated") $ do+        let raw = A.object ["type" .= t, K.fromText t .= v]+        p <- fromValue (prop "formula" raw)+        case p of+          FormulaValue _ r -> assertBool "typed formula constructor" (typedFormula t r)+          other -> assertFailure (show other)+        propertyValueResponseJSON p @?= prop "formula" raw+    | (t, v) <- [("string", A.String "text"), ("string", A.Null), ("number", A.Number 4), ("number", A.Null), ("boolean", A.Bool False), ("boolean", A.Null), ("date", date), ("date", A.Null), ("unsupported", A.object []), ("future_formula", A.object ["raw" .= True])]+    ]+  where+    typedFormula "string" FormulaStringResult {} = True+    typedFormula "number" FormulaNumberResult {} = True+    typedFormula "boolean" FormulaBooleanResult {} = True+    typedFormula "date" FormulaDateResult {} = True+    typedFormula "unsupported" FormulaUnsupportedResult = True+    typedFormula "future_formula" UnknownFormulaResult {} = True+    typedFormula _ _ = False++rollupTests :: TestTree+rollupTests =+  testGroup+    "Rollup responses"+    [ testGroup+        "Rollups"+        [ testCase (Text.unpack t <> if v == A.Null then " null" else " populated") $ do+            let raw = A.object (["type" .= t, "function" .= ("show_original" :: Text)] <> [K.fromText t .= v | t `notElem` ["incomplete", "unsupported"]])+            p <- fromValue (prop "rollup" raw)+            case p of+              RollupValue _ r -> assertBool "typed rollup constructor" (typedRollup t r)+              other -> assertFailure (show other)+            propertyValueResponseJSON p @?= prop "rollup" raw+        | (t, v) <- [("number", A.Number 4), ("number", A.Null), ("date", date), ("date", A.Null), ("incomplete", A.Null), ("unsupported", A.Null), ("future_rollup", A.object ["raw" .= True]), ("array", A.toJSON [A.object ["type" .= ("rollup" :: Text), "rollup" .= nested]])]+        ],+      testGroup+        "Nested arrays"+        [ testCase "ID-less relations and unknown entries recur; mutation remains request-shaped" $ do+            p <- fromValue (prop "rollup" nested)+            case p of+              RollupValue _ (RollupArrayResult entries _) -> case V.toList entries of+                [RelationValue pid _ flag, UnknownPropertyValue unknownId t _] -> do+                  pid @?= ""+                  flag @?= Just True+                  unknownId @?= ""+                  t @?= "future_nested"+                other -> assertFailure (show other)+              other -> assertFailure (show other)+            propertyValueResponseJSON p @?= prop "rollup" nested+            let entries = field "array" (field "rollup" (A.toJSON p))+            case entries of+              A.Array vs -> forM_ vs $ \v -> forM_ ["id", "type", "has_more"] $ \key -> assertBool "mutation envelope" (not (hasField key v))+              other -> assertFailure (show other)+        ]+    ]+  where+    nested =+      A.object+        [ "type" .= ("array" :: Text),+          "function" .= ("show_original" :: Text),+          "array"+            .= [ A.object ["type" .= ("relation" :: Text), "relation" .= ([] :: [A.Value]), "has_more" .= True],+                 A.object ["type" .= ("future_nested" :: Text), "future_nested" .= A.object ["raw" .= True]]+               ]+        ]+    typedRollup "number" RollupNumberResult {} = True+    typedRollup "date" RollupDateResult {} = True+    typedRollup "array" RollupArrayResult {} = True+    typedRollup "incomplete" RollupIncompleteResult {} = True+    typedRollup "unsupported" RollupUnsupportedResult {} = True+    typedRollup "future_rollup" RollupUnknownResult {} = True+    typedRollup _ _ = False++userTests :: TestTree+userTests =+  testGroup+    "Users"+    [ testGroup+        "Response users"+        [ testCase "full people, partial people and groups remain typed" $ do+            p <- fromValue (prop "people" (A.toJSON [person, userRef, A.object ["object" .= ("group" :: Text), "id" .= ("g1" :: Text)]]))+            case p of+              PeopleValue _ entries -> case V.toList entries of+                [U.PersonEntry (U.FullUser _), U.PersonEntry (U.PartialUser _), U.GroupEntry _] -> pure ()+                other -> assertFailure (show other)+              other -> assertFailure (show other)+            field "people" (A.toJSON p) @?= A.toJSON [userRef, userRef, A.object ["object" .= ("group" :: Text), "id" .= ("g1" :: Text)]],+          testCase "title, rich text and verification keep typed full users" $ do+            forM_ ["title", "rich_text"] $ \t -> do+              p <- fromValue (prop t (A.toJSON [userMention]))+              let entries = case p of TitleValue _ es -> es; RichTextValue _ es -> es; _ -> V.empty+              case V.toList entries of+                [RT.RichText {RT.content = RT.MentionContentWrapper (RT.UserMention (U.FullUser _))}] -> pure ()+                other -> assertFailure (show other)+              propertyValueResponseJSON p @?= prop t (A.toJSON [userMention])+              field "user" (field "mention" (case field (K.fromText t) (A.toJSON p) of A.Array vs -> V.head vs; _ -> A.Null)) @?= userRef+            p <- fromValue (prop "verification" (A.object ["state" .= ("verified" :: Text), "verified_by" .= person, "date" .= date]))+            case p of+              VerificationValue _ (Just VerificationResult {verifiedBy = Just (U.FullUser _)}) -> pure ()+              other -> assertFailure (show other)+            field "verified_by" (field "verification" (A.toJSON p)) @?= userRef,+          testCase "other and unknown mentions preserve their existing envelopes"+            $ forM_+              [ A.object ["type" .= ("page" :: Text), "page" .= A.object ["id" .= ("p2" :: Text)]],+                A.object ["type" .= ("future_mention" :: Text), "future_mention" .= A.object ["raw" .= True]]+              ]+            $ \m -> checkProperty "rich_text" (prop "rich_text" (A.toJSON [mention m])),+          testCase "absent person details are omitted" $+            checkProperty "people" (prop "people" (A.toJSON [A.object ["object" .= ("user" :: Text), "id" .= ("u1" :: Text), "type" .= ("person" :: Text), "person" .= A.object []]]))+        ],+      testGroup+        "Bot owners"+        [ testCase (show (field "type" owner)) $ do+            let bot =+                  A.object+                    [ "object" .= ("user" :: Text),+                      "id" .= ("b1" :: Text),+                      "type" .= ("bot" :: Text),+                      "bot"+                        .= A.object+                          [ "owner" .= owner,+                            "workspace_name" .= ("Team" :: Text),+                            "workspace_id" .= ("w1" :: Text),+                            "workspace_limits" .= A.object ["max_file_upload_size_in_bytes" .= (100000 :: Int)]+                          ]+                    ]+            p <- fromValue (prop "people" (A.toJSON [bot]))+            case p of+              PeopleValue _ es -> case V.toList es of+                [U.PersonEntry (U.FullUser U.UserObject {U.bot = Just U.BotUser {U.owner = Just o}})] -> assertBool "typed owner" (ownerKind owner o)+                other -> assertFailure (show other)+              other -> assertFailure (show other)+            propertyValueResponseJSON p @?= prop "people" (A.toJSON [bot])+        | owner <-+            [ A.object ["type" .= ("user" :: Text), "user" .= A.object ["id" .= ("u1" :: Text)]],+              A.object ["type" .= ("workspace" :: Text), "workspace" .= False],+              A.object ["type" .= ("future_owner" :: Text), "future_owner" .= [True, False]]+            ]+        ]+    ]+  where+    ownerKind raw o = case (field "type" raw, o) of+      (A.String "user", U.UserOwner {}) -> True+      (A.String "workspace", U.WorkspaceOwner {}) -> True+      (A.String "future_owner", U.UnknownOwner {}) -> True+      _ -> False++mutationTests :: TestTree+mutationTests =+  testGroup+    "Mutation isolation"+    [ testCase "decoded true and false relations retain only their request payload" $ forM_ [True, False] $ \b -> do+        p <- fromValue @PropertyValue (relation (Just b) [A.object ["id" .= ("p2" :: Text)]])+        A.toJSON p @?= A.object ["relation" .= [A.object ["id" .= ("p2" :: Text)]]],+      testCase "smart constructors retain their signatures and request JSON" $ do+        let p = relationValue ["p2"]+        case p of+          RelationValue pid refs flag -> do pid @?= ""; V.length refs @?= 1; flag @?= Nothing+          other -> assertFailure (show other)+        A.toJSON p @?= A.object ["relation" .= [A.object ["id" .= ("p2" :: Text)]]]+        A.toJSON (peopleValue ["u1"]) @?= A.object ["people" .= [userRef]],+      testCase "create and update property envelopes keep legitimate nested IDs" $ do+        rel <- fromValue (relation (Just True) [A.object ["id" .= ("p2" :: Text)]])+        people <- fromValue (prop "people" (A.toJSON [person]))+        let ps = Map.fromList [("Related", rel), ("People", people)]+            expected = A.object ["Related" .= A.object ["relation" .= [A.object ["id" .= ("p2" :: Text)]]], "People" .= A.object ["people" .= [userRef]]]+        forM_ [A.toJSON (Pages.mkCreatePage (WorkspaceParent True) ps), A.toJSON (Pages.mkUpdatePage ps)] $ \request -> do+          field "properties" request @?= expected+          forM_ ["Related", "People"] $ \name -> forM_ ["id", "type", "has_more"] $ \key ->+            assertBool "no response metadata at property envelope" (not (hasField key (field name (field "properties" request))))+    ]++pageFixture :: Bool -> A.Value+pageFixture populated =+  A.object $+    [ "object" .= ("page" :: Text),+      "id" .= ("p1" :: Text),+      "created_time" .= ("2026-10-12T00:00:00Z" :: Text),+      "last_edited_time" .= ("2026-10-13T00:00:00Z" :: Text),+      "created_by" .= userRef,+      "last_edited_by" .= userRef,+      "parent" .= A.object ["type" .= ("workspace" :: Text), "workspace" .= True],+      "in_trash" .= False,+      "url" .= ("https://www.notion.so/p1" :: Text),+      "cover" .= if populated then A.object ["type" .= ("external" :: Text), "external" .= A.object ["url" .= ("https://example.com/cover" :: Text)]] else A.Null,+      "icon" .= if populated then A.object ["type" .= ("emoji" :: Text), "emoji" .= ("📚" :: Text)] else A.Null,+      "properties" .= A.object ["Related" .= relation (Just True) [], "People" .= prop "people" (A.toJSON [person])]+    ]+      <> if populated then ["is_locked" .= False, "is_archived" .= False, "public_url" .= ("https://example.com/public" :: Text)] else []++checkPage :: Bool -> Pages.PageObject -> Assertion+checkPage populated page@Pages.PageObject {Pages.properties = ps} = do+  let raw = pageFixture populated+      encoded = Pages.pageResponseJSON page+  forM_ ["id", "object", "created_time", "last_edited_time", "created_by", "last_edited_by", "parent", "in_trash", "url", "cover", "icon"] $ \key -> do+    assertBool ("page field present: " <> show key) (hasField key encoded)+    assertEqual (show key) (field key raw) (field key encoded)+  forM_ ["is_locked", "is_archived", "public_url"] $ \key -> do+    hasField key encoded @?= populated+    field key encoded @?= field key raw+  field "properties" encoded @?= field "properties" raw+  let old = A.toJSON page+  field "Related" (field "properties" old) @?= A.object ["relation" .= ([] :: [A.Value])]+  field "People" (field "properties" old) @?= A.object ["people" .= [userRef]]+  case Map.lookup "Related" ps of+    Just (RelationValue pid refs flag) -> do pid @?= "rel"; V.null refs @?= True; flag @?= Just True+    other -> assertFailure (show other)++withReply :: A.Value -> (Methods -> IO a) -> IO a+withReply raw call = do+  (env, _) <- fakeClientEnv [jsonReply 200 (A.encode raw)]+  call (makeMethodsWithEnv defaultClientConfig env "test-token")++pageTests :: TestTree+pageTests =+  testGroup+    "Pages"+    [ testGroup+        "Page integration"+        [ testCase "retrievePage retains relation metadata through the public encoder" $+            withReply (pageFixture False) (\m -> retrievePage m "p1") >>= checkPage False,+          testCase "createPage retains relation metadata through the public encoder" $+            withReply (pageFixture True) (\m -> createPage m (Pages.mkCreatePage (WorkspaceParent True) Map.empty)) >>= checkPage True,+          testCase "missing optional page fields follow existing null and omission rules" $ do+            let A.Object raw = pageFixture False+            page <- fromValue @Pages.PageObject (A.Object (KM.delete "icon" (KM.delete "cover" raw)))+            checkPage False page+        ],+      testGroup+        "Full and partial lists"+        [ testCase name $ do+            list <- withReply rows call+            case V.toList (List.results list) of+              [PageResult p, PartialPageResult _, PartialDataSourceResult _, UnknownResult unknown] -> do+                checkPage True p+                unknown @?= future+              other -> assertFailure (show other)+            List.hasMore list @?= False+        | (name, call) <- [("queryDataSource", \m -> queryDataSource m "ds1" _QueryDataSource), ("search", \m -> search m _SearchRequest)]+        ]+    ]+  where+    future = A.object ["object" .= ("future_object" :: Text), "raw" .= True]+    rows =+      A.object+        [ "object" .= ("list" :: Text),+          "results"+            .= [ pageFixture True,+                 A.object ["object" .= ("page" :: Text), "id" .= ("p2" :: Text)],+                 A.object ["object" .= ("data_source" :: Text), "id" .= ("ds2" :: Text)],+                 future+               ],+          "next_cursor" .= A.Null,+          "has_more" .= False+        ]