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 +11/−0
- README.md +46/−6
- notion-client-effectful.cabal +50/−4
- src/Notion/V1/Effectful.hs +5/−2
- src/Notion/V1/Effectful/Interpreter.hs +3/−1
- test/OAuthFixtures.hs +88/−0
- test/OAuthRuntimeMain.hs +69/−0
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)