packages feed

notion-client-effectful 0.3.0.0 → 0.4.0.0

raw patch · 7 files changed

+272/−13 lines, 7 filesdep +bytestringdep +containersdep +http-clientdep ~aesondep ~basedep ~effectful-core

Dependencies added: bytestring, containers, http-client, http-types, mtl, notion-client-effectful, servant-client, servant-client-core, tasty, tasty-hunit, time

Dependency ranges changed: aeson, base, effectful-core, notion-client

Files

CHANGELOG.md view
@@ -1,5 +1,16 @@ # Changelog for `notion-client-effectful` +## 0.4.0.0 - 2026-10-19++### Breaking Changes++* Requires `notion-client >=0.10 && <0.11`. Upgrade this companion to 0.4.0.0 alongside core 0.10.0.0. `runNotion` accepts refreshing `Methods` unchanged; no effect constructors changed.+* `OAuthError`, `ReauthorizationRequired`, `TokenPersistenceError` and `RefreshOutcomeUnknown` propagate in IO; `Error NotionError` catches only final Notion API errors. Retry persistence on the same manager; reauthorization and uncertain refresh outcomes require a new login and manager.++### Validation++* Four OAuth runtime consumer tests verify reactive refresh and the IO exception boundary. The core client's live public-integration gate also passed login, rotation, persisted restart, cancellation and logout on 2026-10-19.+ ## 0.3.0.0 - 2026-10-12  ### Breaking Changes
README.md view
@@ -36,6 +36,18 @@  Then `NE.retrievePage`, `NE.search`, `NE.runNotion`, and so on. +The umbrella module exposes the common operations. For the complete surface,+including filtered page requests, background operations, meeting notes and+comment retrieval/update/deletion, import the effect and interpreter directly:++```haskell+import Notion.V1.Effectful.Effect qualified as NE+import Notion.V1.Effectful.Interpreter (runNotion)+```++Use `NE.createPageFiltered`, `NE.createPageAsync`, `NE.retrieveAsyncTask`, and+the other effect operations with the unqualified `runNotion` interpreter.+ ## Error handling  `runNotion` catches `Notion.V1.Error.NotionError` thrown by the@@ -50,12 +62,29 @@ library and lets callers layer their own `Error ClientError` interpretation on top later. The same applies to `UnknownHTTPResponseError`, `RequestTimeoutError` and-`InvalidPathParameterError` from `Notion.V1.Error`. Retries of-rate-limited requests happen inside `Methods`, before an error reaches-`runNotion`.+`InvalidPathParameterError` from `Notion.V1.Error`. Retries of rate-limited+requests and reactive OAuth renewal happen inside `Methods`, before an error+reaches `runNotion`. +### OAuth installations++Build `Methods` with core `makeMethodsWithTokenSource` and pass it to `runNotion`;+existing effect operations need no changes. See the core+[OAuth walkthrough](https://github.com/shinzui/notion-client#oauth) for browser+login and token-store setup.++`OAuthError`, `ReauthorizationRequired`, `TokenPersistenceError` and+`RefreshOutcomeUnknown` propagate as IO exceptions. Handle them at the application+boundary outside `runEff`; `Error NotionError` catches only final Notion API errors.+For a persistence failure, call `retryTokenPersistence` on the same manager to+save its retained pair without another refresh. Reauthorization or an uncertain+remote outcome requires a new login and manager. Use one manager per installation;+multiple processes must coordinate the entire load/refresh/save sequence.+ ## Minimum viable usage +Use `GHC2024` with `OverloadedStrings` for this example.+ ```haskell module Demo where @@ -110,6 +139,17 @@  ## Versioning -The `0.y.z` series tracks `notion-client` 0.7.x. A major version-bump is planned if `notion-client` changes the shape of `Methods`-in a way that forces a constructor rename.+Use compatible versions of both packages:++| `notion-client-effectful` | Required `notion-client` |+|---|---|+| 0.4.x | `>=0.10 && <0.11` |+| 0.3.x | `>=0.9 && <0.10` |+| 0.2.x | `>=0.8 && <0.9` |++The prepared release is `notion-client-effectful-0.4.0.0` with+`notion-client-0.10.0.0`. The companion's core bound moves with each breaking core+release, even when `Methods` fields are unchanged. See the+[changelog](CHANGELOG.md) for migration details and the repository's+[release preparation](https://github.com/shinzui/notion-client#release-preparation)+for validation.
notion-client-effectful.cabal view
@@ -1,15 +1,17 @@ cabal-version: 3.4 name: notion-client-effectful-version: 0.3.0.0+version: 0.4.0.0 synopsis: Effectful effects for notion-client description:-  Effectful effect + default interpreter for shinzui/notion-client.+  Effectful effect and default interpreter for notion-client.   Exposes every operation on 'Notion.V1.Methods' as a smart   constructor of the 'Notion.V1.Effectful.Notion' effect and provides   a 'runNotion' interpreter that dispatches through a concrete   @Methods@ value. API-level 'Notion.V1.Error.NotionError' responses   are surfaced via the @Error NotionError@ effect; other-  @Servant.Client.ClientError@ values remain 'IO' exceptions.+  @Servant.Client.ClientError@ values and OAuth lifecycle or persistence+  failures remain 'IO' exceptions. Accepts fixed-token or refreshing+  @Methods@ without changing effect operations.   See @README.md@ for the full import pattern and a worked example.  license: MIT@@ -17,11 +19,19 @@ author: Nadeem Bitar maintainer: nadeem@gmail.com category: Web+homepage: https://github.com/shinzui/notion-client/tree/master/notion-client-effectful+bug-reports: https://github.com/shinzui/notion-client/issues+tested-with: ghc ==9.12.4 build-type: Simple extra-doc-files:   CHANGELOG.md   README.md +source-repository head+  type: git+  location: https://github.com/shinzui/notion-client.git+  subdir: notion-client-effectful+ common warnings   ghc-options:     -Wall@@ -55,8 +65,44 @@     aeson >=2.2 && <2.3,     base >=4.18 && <5,     effectful-core >=2.5 && <3,-    notion-client >=0.9 && <0.10,+    notion-client >=0.10 && <0.11,     text >=2.0 && <2.2,    hs-source-dirs: src   default-language: GHC2024++test-suite oauth-runtime+  type: exitcode-stdio-1.0+  default-language: GHC2024+  default-extensions:+    DuplicateRecordFields+    OverloadedStrings+    RecordWildCards++  hs-source-dirs:+    test++  main-is: OAuthRuntimeMain.hs+  other-modules:+    OAuthFixtures++  build-depends:+    aeson,+    base,+    bytestring,+    containers,+    effectful-core,+    http-client,+    http-types,+    mtl,+    notion-client,+    notion-client-effectful,+    servant-client,+    servant-client-core,+    tasty,+    tasty-hunit,+    time,++  ghc-options:+    -Wall+    -threaded
src/Notion/V1/Effectful.hs view
@@ -14,10 +14,13 @@ -- here shares its name with a field of @Methods@ on purpose, so -- migrating an IO call site is a near-mechanical rewrite. ----- Error handling: 'runNotion' catches 'NotionError' from the--- underlying 'Methods' value and re-throws via the @Error NotionError@+-- Error handling: 'runNotion' catches @NotionError@ from the+-- underlying @Methods@ value and re-throws via the @Error NotionError@ -- effect. Other 'Servant.Client.ClientError' values (network -- failures, decoding errors) remain 'IO' exceptions.+-- Refreshing @Methods@ work with the same effect operations. OAuth errors,+-- required reauthorization, persistence failures and uncertain refresh outcomes+-- also remain 'IO' exceptions; handle them at the application boundary. module Notion.V1.Effectful   ( -- * Effect     Notion,
src/Notion/V1/Effectful/Interpreter.hs view
@@ -87,7 +87,9 @@ -- -- The dispatched 'IO' action is wrapped with 'Control.Exception.try': -- a 'NotionError' is re-thrown via 'throwError', anything else--- propagates as an 'IO' exception.+-- propagates as an 'IO' exception. OAuth reauthorization, pending-persistence+-- and uncertain-refresh exceptions also remain IO exceptions. A refreshing+-- Methods value works without changing effect constructors. runNotion ::   (IOE :> es, Error NotionError :> es) =>   Methods ->
+ test/OAuthFixtures.hs view
@@ -0,0 +1,88 @@+-- | A scripted stand-in for the Notion API that never touches the network.+--+-- 'fakeClientEnv' builds a 'ClientEnv' whose middleware ignores the real HTTP+-- application, records every request, and answers from a list of replies.+module OAuthFixtures+  ( tokenResponse,+    unauthorized,+    userJson,+    Recorded (..),+    FakeReply (..),+    fakeClientEnv,+    fakeClientEnvWith,+    fakeBaseUrl,+    jsonReply,+    lookupRecordedHeader,+  )+where++import Control.Monad.Error.Class (throwError)+import Control.Monad.IO.Class (liftIO)+import Data.Bifunctor (bimap)+import Data.ByteString qualified as BS+import Data.ByteString.Builder (toLazyByteString)+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Network.HTTP.Client (defaultManagerSettings, newManager)+import Network.HTTP.Types (Header, HeaderName, Method, http11, mkStatus)+import Servant.Client (BaseUrl (..), ClientEnv (..), ClientError (..), Scheme (..), mkClientEnv)+import Servant.Client.Core (RequestF (..), ResponseF (..))++-- | A request the fake received.+data Recorded = Recorded+  { method :: Method,+    path :: LBS.ByteString,+    headers :: [Header]+  }+  deriving stock (Show)++-- | A scripted reply.+data FakeReply = FakeReply+  { status :: Int,+    replyHeaders :: [Header],+    body :: LBS.ByteString+  }++-- | A reply with a JSON content type.+jsonReply :: Int -> LBS.ByteString -> FakeReply+jsonReply s = FakeReply s [("Content-Type", "application/json")]++fakeBaseUrl :: BaseUrl+fakeBaseUrl = BaseUrl Https "api.notion.com" 443 "/v1"++-- | First value of a header in a recorded request.+lookupRecordedHeader :: HeaderName -> Recorded -> Maybe BS.ByteString+lookupRecordedHeader name Recorded {headers} = lookup name headers++-- | A 'ClientEnv' answering from the script, and the requests it records.+fakeClientEnv :: [FakeReply] -> IO (ClientEnv, IORef [Recorded])+fakeClientEnv script = do+  remaining <- newIORef script+  fakeClientEnvWith $ \_ -> do+    next <- atomicModifyIORef' remaining (\case [] -> ([], Nothing); r : rs -> (rs, Just r))+    maybe (ioError (userError "FakeNotion: script exhausted")) pure next++-- | Header-dependent replies, with an atomic request recorder.+fakeClientEnvWith :: (Recorded -> IO FakeReply) -> IO (ClientEnv, IORef [Recorded])+fakeClientEnvWith reply = do+  manager <- newManager defaultManagerSettings+  recorded <- newIORef []+  let mw _realApp req = do+        let request = Recorded (requestMethod req) (toLazyByteString (requestPath req)) (toList (requestHeaders req))+        liftIO $ atomicModifyIORef' recorded (\rs -> (rs <> [request], ()))+        FakeReply {status, replyHeaders, body} <- liftIO (reply request)+        let resp = Response (mkStatus status "") (foldMap pure replyHeaders) http11 body+        if status >= 200 && status < 300+          then pure resp+          else throwError (FailureResponse (bimap (const ()) (\p -> (fakeBaseUrl, LBS.toStrict (toLazyByteString p))) req) resp)+  pure ((mkClientEnv manager fakeBaseUrl) {middleware = mw}, recorded)++tokenResponse :: LBS.ByteString+tokenResponse = "{\"access_token\":\"access\",\"token_type\":\"bearer\",\"refresh_token\":\"refresh\",\"bot_id\":\"bot-id\",\"workspace_id\":\"workspace-id\",\"workspace_name\":\"Tanaka Hanako\",\"owner\":{\"type\":\"workspace\"}}"++unauthorized :: FakeReply+unauthorized = jsonReply 401 "{\"object\":\"error\",\"code\":\"unauthorized\",\"message\":\"invalid token\"}"++userJson :: LBS.ByteString+userJson = "{\"object\":\"user\",\"id\":\"6f1c2b9e-4d3a-4e8f-9b7c-2a1d0e5f3c4b\",\"name\":\"Sato Kenji\",\"type\":\"bot\",\"bot\":{\"owner\":{\"type\":\"workspace\",\"workspace\":true}}}"
+ test/OAuthRuntimeMain.hs view
@@ -0,0 +1,69 @@+{-# LANGUAGE GHC2024 #-}+{-# LANGUAGE AllowAmbiguousTypes #-}++module Main (main) where++import Control.Exception+import Control.Monad (void)+import Data.Aeson qualified as A+import Data.Time.Clock+import Effectful (runEff)+import Effectful.Error.Static (runErrorNoCallStack)+import Notion.V1+import Notion.V1.Effectful qualified as E+import Notion.V1.Error+import Notion.V1.OAuth qualified as O+import Notion.V1.OAuth.Refresh qualified as R+import OAuthFixtures+import Test.Tasty+import Test.Tasty.HUnit++main :: IO ()+main =+  defaultMain $+    testGroup+      "Effectful OAuth"+      [ testCase "refreshing Methods succeeds and final API error uses Error effect" $ do+          (old, response) <- fixture+          store <- R.inMemoryTokenStore old+          manager <- R.newOAuthTokenManager (oauth (pure response)) store+          (env, _) <- fakeClientEnv [unauthorized, jsonReply 200 userJson, unauthorized, unauthorized]+          let api = makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager)+              call = runEff . runErrorNoCallStack @NotionError . E.runNotion api $ E.retrieveMyUser+          call >>= either (assertFailure . show) (const (pure ()))+          call >>= either (\e -> code e @?= Unauthorized) (const (assertFailure "expected final API error")),+        testCase "reauthorization stays an IO exception" $ do+          (old, response) <- fixture+          store <- R.inMemoryTokenStore old {R.refreshToken = Nothing}+          manager <- R.newOAuthTokenManager (oauth (pure response)) store+          expect @R.ReauthorizationRequired (run manager),+        testCase "pending persistence stays an IO exception" $ do+          (old, response) <- fixture+          manager <- R.newOAuthTokenManager (oauth (pure response)) (R.TokenStore (pure old) (\_ -> ioError (userError "storage")))+          expect @R.TokenPersistenceError (R.refreshTokensNow manager)+          expect @R.TokenPersistenceError (run manager),+        testCase "uncertain refresh stays an IO exception" $ do+          (old, _) <- fixture+          store <- R.inMemoryTokenStore old+          manager <- R.newOAuthTokenManager (oauth (ioError (userError "lost response"))) store+          expect @IOException (R.refreshTokensNow manager)+          expect @R.RefreshOutcomeUnknown (run manager)+      ]++expect :: forall e a. (Exception e) => IO a -> IO ()+expect action = try @e action >>= either (const (pure ())) (const (assertFailure "expected IO exception"))++run :: R.OAuthTokenManager -> IO ()+run manager = do+  (env, _) <- fakeClientEnv [unauthorized]+  let api = makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager)+  void $ runEff . runErrorNoCallStack @NotionError . E.runNotion api $ E.retrieveMyUser++oauth :: IO O.OAuthTokenResponse -> O.OAuthMethods+oauth action = O.OAuthMethods (const action) (const (pure (O.OAuthRevokeResponse Nothing))) (const (pure (O.OAuthIntrospectResponse False Nothing Nothing Nothing)))++fixture :: IO (R.OAuthTokens, O.OAuthTokenResponse)+fixture = do+  response <- either assertFailure pure (A.eitherDecode tokenResponse)+  now <- getCurrentTime+  pure ((R.oauthTokensFromResponse now response) {R.accessToken = "old", R.refreshToken = Just "old-refresh"}, response)