notion-client 0.8.0.0 → 0.9.0.0
raw patch · 7 files changed
+645/−26 lines, 7 files
Files
- CHANGELOG.md +13/−0
- README.md +29/−0
- notion-client.cabal +2/−1
- src/Notion/V1/Pages.hs +32/−19
- src/Notion/V1/PropertyValue.hs +106/−5
- tasty/Main.hs +4/−1
- tasty/ResponseJSONTests.hs +459/−0
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+ ]