notion-client 0.6.0.0 → 0.6.1.0
raw patch · 9 files changed
+623/−4 lines, 9 filesdep ~http-api-dataPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependency ranges changed: http-api-data
API changes (from Hackage documentation)
+ Notion.V1: [completeFileUpload] :: Methods -> FileUploadID -> IO FileUploadObject
+ Notion.V1: [createFileUpload] :: Methods -> CreateFileUpload -> IO FileUploadObject
+ Notion.V1: [listFileUploads] :: Methods -> Maybe FileUploadStatus -> Maybe Text -> Maybe Natural -> IO (ListOf FileUploadObject)
+ Notion.V1: [retrieveFileUpload] :: Methods -> FileUploadID -> IO FileUploadObject
+ Notion.V1: [sendFileUploadContent] :: Methods -> FileUploadID -> SendFileUpload -> IO FileUploadObject
+ Notion.V1.Common: FileUploadCover :: UUID -> Cover
+ Notion.V1.Common: FileUploadIcon :: UUID -> Icon
+ Notion.V1.Common: [fileUploadId] :: Cover -> UUID
+ Notion.V1.FileUploads: CreateFileUpload :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Natural -> Maybe Text -> CreateFileUpload
+ Notion.V1.FileUploads: Expired :: FileUploadStatus
+ Notion.V1.FileUploads: Failed :: FileUploadStatus
+ Notion.V1.FileUploads: FileImportError :: POSIXTime -> Text -> Text -> Text -> Maybe Text -> Maybe Int -> FileImportResult
+ Notion.V1.FileUploads: FileImportSuccess :: POSIXTime -> FileImportResult
+ Notion.V1.FileUploads: FileUploadObject :: FileUploadID -> Text -> FileUploadStatus -> Maybe Text -> Maybe Text -> Maybe Natural -> POSIXTime -> POSIXTime -> Value -> Bool -> Maybe POSIXTime -> Maybe NumberOfParts -> Maybe FileImportResult -> FileUploadObject
+ Notion.V1.FileUploads: NumberOfParts :: Natural -> Natural -> NumberOfParts
+ Notion.V1.FileUploads: Pending :: FileUploadStatus
+ Notion.V1.FileUploads: SendFileUpload :: FilePath -> Text -> Text -> Maybe Natural -> SendFileUpload
+ Notion.V1.FileUploads: Uploaded :: FileUploadStatus
+ Notion.V1.FileUploads: [contentLength] :: FileUploadObject -> Maybe Natural
+ Notion.V1.FileUploads: [contentType] :: CreateFileUpload -> Maybe Text
+ Notion.V1.FileUploads: [createdBy] :: FileUploadObject -> Value
+ Notion.V1.FileUploads: [createdTime] :: FileUploadObject -> POSIXTime
+ Notion.V1.FileUploads: [errorCode] :: FileImportResult -> Text
+ Notion.V1.FileUploads: [errorMessage] :: FileImportResult -> Text
+ Notion.V1.FileUploads: [errorParameter] :: FileImportResult -> Maybe Text
+ Notion.V1.FileUploads: [errorStatusCode] :: FileImportResult -> Maybe Int
+ Notion.V1.FileUploads: [errorType] :: FileImportResult -> Text
+ Notion.V1.FileUploads: [expiryTime] :: FileUploadObject -> Maybe POSIXTime
+ Notion.V1.FileUploads: [externalUrl] :: CreateFileUpload -> Maybe Text
+ Notion.V1.FileUploads: [fileContentType] :: SendFileUpload -> Text
+ Notion.V1.FileUploads: [fileImportResult] :: FileUploadObject -> Maybe FileImportResult
+ Notion.V1.FileUploads: [fileName] :: SendFileUpload -> Text
+ Notion.V1.FileUploads: [filePath] :: SendFileUpload -> FilePath
+ Notion.V1.FileUploads: [filename] :: CreateFileUpload -> Maybe Text
+ Notion.V1.FileUploads: [id] :: FileUploadObject -> FileUploadID
+ Notion.V1.FileUploads: [importedTime] :: FileImportResult -> POSIXTime
+ Notion.V1.FileUploads: [inTrash] :: FileUploadObject -> Bool
+ Notion.V1.FileUploads: [lastEditedTime] :: FileUploadObject -> POSIXTime
+ Notion.V1.FileUploads: [mode] :: CreateFileUpload -> Maybe Text
+ Notion.V1.FileUploads: [numberOfParts] :: CreateFileUpload -> Maybe Natural
+ Notion.V1.FileUploads: [object] :: FileUploadObject -> Text
+ Notion.V1.FileUploads: [partNumber] :: SendFileUpload -> Maybe Natural
+ Notion.V1.FileUploads: [sent] :: NumberOfParts -> Natural
+ Notion.V1.FileUploads: [status] :: FileUploadObject -> FileUploadStatus
+ Notion.V1.FileUploads: [total] :: NumberOfParts -> Natural
+ Notion.V1.FileUploads: data CreateFileUpload
+ Notion.V1.FileUploads: data FileImportResult
+ Notion.V1.FileUploads: data FileUploadObject
+ Notion.V1.FileUploads: data FileUploadStatus
+ Notion.V1.FileUploads: data NumberOfParts
+ Notion.V1.FileUploads: data SendFileUpload
+ Notion.V1.FileUploads: instance Data.Aeson.Types.FromJSON.FromJSON Notion.V1.FileUploads.FileImportResult
+ Notion.V1.FileUploads: instance Data.Aeson.Types.FromJSON.FromJSON Notion.V1.FileUploads.FileUploadObject
+ Notion.V1.FileUploads: instance Data.Aeson.Types.FromJSON.FromJSON Notion.V1.FileUploads.FileUploadStatus
+ Notion.V1.FileUploads: instance Data.Aeson.Types.FromJSON.FromJSON Notion.V1.FileUploads.NumberOfParts
+ Notion.V1.FileUploads: instance Data.Aeson.Types.ToJSON.ToJSON Notion.V1.FileUploads.CreateFileUpload
+ Notion.V1.FileUploads: instance Data.Aeson.Types.ToJSON.ToJSON Notion.V1.FileUploads.FileImportResult
+ Notion.V1.FileUploads: instance Data.Aeson.Types.ToJSON.ToJSON Notion.V1.FileUploads.FileUploadObject
+ Notion.V1.FileUploads: instance Data.Aeson.Types.ToJSON.ToJSON Notion.V1.FileUploads.FileUploadStatus
+ Notion.V1.FileUploads: instance Data.Aeson.Types.ToJSON.ToJSON Notion.V1.FileUploads.NumberOfParts
+ Notion.V1.FileUploads: instance GHC.Classes.Eq Notion.V1.FileUploads.FileUploadStatus
+ Notion.V1.FileUploads: instance GHC.Classes.Eq Notion.V1.FileUploads.NumberOfParts
+ Notion.V1.FileUploads: instance GHC.Internal.Generics.Generic Notion.V1.FileUploads.CreateFileUpload
+ Notion.V1.FileUploads: instance GHC.Internal.Generics.Generic Notion.V1.FileUploads.FileUploadObject
+ Notion.V1.FileUploads: instance GHC.Internal.Generics.Generic Notion.V1.FileUploads.NumberOfParts
+ Notion.V1.FileUploads: instance GHC.Internal.Show.Show Notion.V1.FileUploads.CreateFileUpload
+ Notion.V1.FileUploads: instance GHC.Internal.Show.Show Notion.V1.FileUploads.FileImportResult
+ Notion.V1.FileUploads: instance GHC.Internal.Show.Show Notion.V1.FileUploads.FileUploadObject
+ Notion.V1.FileUploads: instance GHC.Internal.Show.Show Notion.V1.FileUploads.FileUploadStatus
+ Notion.V1.FileUploads: instance GHC.Internal.Show.Show Notion.V1.FileUploads.NumberOfParts
+ Notion.V1.FileUploads: instance GHC.Internal.Show.Show Notion.V1.FileUploads.SendFileUpload
+ Notion.V1.FileUploads: instance Servant.Multipart.API.ToMultipart Servant.Multipart.API.Tmp Notion.V1.FileUploads.SendFileUpload
+ Notion.V1.FileUploads: instance Web.Internal.HttpApiData.ToHttpApiData Notion.V1.FileUploads.FileUploadStatus
+ Notion.V1.FileUploads: mkExternalUrlUpload :: Text -> Maybe Text -> CreateFileUpload
+ Notion.V1.FileUploads: mkMultiPartUpload :: Text -> Natural -> Maybe Text -> CreateFileUpload
+ Notion.V1.FileUploads: mkSendFileUpload :: FilePath -> Text -> Text -> SendFileUpload
+ Notion.V1.FileUploads: mkSinglePartUpload :: Maybe Text -> CreateFileUpload
+ Notion.V1.FileUploads: type API = "file_uploads" :> ReqBody '[JSON] CreateFileUpload :> Post '[JSON] FileUploadObject :<|> Capture "file_upload_id" FileUploadID :> Get '[JSON] FileUploadObject :<|> Capture "file_upload_id" FileUploadID :> "send" :> MultipartForm Tmp SendFileUpload :> Post '[JSON] FileUploadObject :<|> Capture "file_upload_id" FileUploadID :> "complete" :> Post '[JSON] FileUploadObject :<|> QueryParam "status" FileUploadStatus :> QueryParam "start_cursor" Text :> QueryParam "page_size" Natural :> Get '[JSON] ListOf FileUploadObject
+ Notion.V1.FileUploads: type FileUploadID = UUID
+ Notion.V1.PropertyValue: FileUploadFileValue :: Text -> UUID -> FileValue
+ Notion.V1.PropertyValue: [fileUploadId] :: FileValue -> UUID
+ Notion.V1.PropertyValue: fileUploadFilesValue :: [(Text, UUID)] -> PropertyValue
- Notion.V1: Methods :: (CreateDatabase -> IO DatabaseObject) -> (DatabaseID -> IO DatabaseObject) -> (DatabaseID -> UpdateDatabase -> IO DatabaseObject) -> (DatabaseID -> QueryDatabase -> IO (ListOf PageObject)) -> (DataSourceID -> IO DataSourceObject) -> (CreateDataSource -> IO DataSourceObject) -> (DataSourceID -> UpdateDataSource -> IO DataSourceObject) -> (DataSourceID -> QueryDataSource -> IO (ListOf PageObject)) -> (DataSourceID -> Maybe Text -> Maybe Text -> Maybe Natural -> IO ListTemplatesResponse) -> (CreatePage -> IO PageObject) -> (PageID -> IO PageObject) -> (PageID -> UpdatePage -> IO PageObject) -> (PageID -> Text -> Maybe Text -> Maybe Natural -> IO PropertyItemResponse) -> (PageID -> Maybe Bool -> IO PageMarkdown) -> (PageID -> UpdatePageMarkdown -> IO PageMarkdown) -> (PageID -> MovePage -> IO PageObject) -> (BlockID -> IO BlockObject) -> (BlockID -> BlockUpdate -> IO BlockObject) -> (ParentID -> Maybe Natural -> Maybe Text -> IO (ListOf BlockObject)) -> (ParentID -> AppendBlockChildren -> IO (ListOf BlockObject)) -> (BlockID -> IO BlockObject) -> (UserID -> IO UserObject) -> (Maybe Natural -> Maybe Text -> IO (ListOf UserObject)) -> IO UserObject -> (SearchRequest -> IO (ListOf Value)) -> (CreateComment -> IO CommentObject) -> (Maybe BlockID -> Maybe Text -> Maybe Natural -> IO (ListOf CommentObject)) -> (CreateView -> IO ViewObject) -> (ViewID -> IO ViewObject) -> (ViewID -> UpdateView -> IO ViewObject) -> (ViewID -> IO ViewObject) -> (Maybe UUID -> Maybe UUID -> Maybe Text -> Maybe Natural -> IO (ListOf ViewObject)) -> (ViewID -> QueryView -> IO (ListOf PageObject)) -> (Maybe Text -> Maybe Text -> Maybe Natural -> IO (ListOf CustomEmoji)) -> Methods
+ Notion.V1: Methods :: (CreateDatabase -> IO DatabaseObject) -> (DatabaseID -> IO DatabaseObject) -> (DatabaseID -> UpdateDatabase -> IO DatabaseObject) -> (DatabaseID -> QueryDatabase -> IO (ListOf PageObject)) -> (DataSourceID -> IO DataSourceObject) -> (CreateDataSource -> IO DataSourceObject) -> (DataSourceID -> UpdateDataSource -> IO DataSourceObject) -> (DataSourceID -> QueryDataSource -> IO (ListOf PageObject)) -> (DataSourceID -> Maybe Text -> Maybe Text -> Maybe Natural -> IO ListTemplatesResponse) -> (CreatePage -> IO PageObject) -> (PageID -> IO PageObject) -> (PageID -> UpdatePage -> IO PageObject) -> (PageID -> Text -> Maybe Text -> Maybe Natural -> IO PropertyItemResponse) -> (PageID -> Maybe Bool -> IO PageMarkdown) -> (PageID -> UpdatePageMarkdown -> IO PageMarkdown) -> (PageID -> MovePage -> IO PageObject) -> (BlockID -> IO BlockObject) -> (BlockID -> BlockUpdate -> IO BlockObject) -> (ParentID -> Maybe Natural -> Maybe Text -> IO (ListOf BlockObject)) -> (ParentID -> AppendBlockChildren -> IO (ListOf BlockObject)) -> (BlockID -> IO BlockObject) -> (UserID -> IO UserObject) -> (Maybe Natural -> Maybe Text -> IO (ListOf UserObject)) -> IO UserObject -> (SearchRequest -> IO (ListOf Value)) -> (CreateComment -> IO CommentObject) -> (Maybe BlockID -> Maybe Text -> Maybe Natural -> IO (ListOf CommentObject)) -> (CreateView -> IO ViewObject) -> (ViewID -> IO ViewObject) -> (ViewID -> UpdateView -> IO ViewObject) -> (ViewID -> IO ViewObject) -> (Maybe UUID -> Maybe UUID -> Maybe Text -> Maybe Natural -> IO (ListOf ViewObject)) -> (ViewID -> QueryView -> IO (ListOf PageObject)) -> (Maybe Text -> Maybe Text -> Maybe Natural -> IO (ListOf CustomEmoji)) -> (CreateFileUpload -> IO FileUploadObject) -> (FileUploadID -> IO FileUploadObject) -> (FileUploadID -> SendFileUpload -> IO FileUploadObject) -> (FileUploadID -> IO FileUploadObject) -> (Maybe FileUploadStatus -> Maybe Text -> Maybe Natural -> IO (ListOf FileUploadObject)) -> Methods
- Notion.V1: type API = Header' '[Required, Strict] "Authorization" Text :> Header' '[Required, Strict] "Notion-Version" Text :> API :<|> API :<|> API :<|> API :<|> API :<|> API :<|> API :<|> API :<|> API
+ Notion.V1: type API = Header' '[Required, Strict] "Authorization" Text :> Header' '[Required, Strict] "Notion-Version" Text :> API :<|> API :<|> API :<|> API :<|> API :<|> API :<|> API :<|> API :<|> API :<|> API
Files
- CHANGELOG.md +10/−0
- notion-client-example/FileUploadDemo.hs +67/−0
- notion-client-example/Main.hs +7/−0
- notion-client.cabal +4/−1
- src/Notion/V1.hs +28/−2
- src/Notion/V1/Common.hs +11/−0
- src/Notion/V1/FileUploads.hs +308/−0
- src/Notion/V1/PropertyValue.hs +16/−0
- tasty/Main.hs +172/−1
CHANGELOG.md view
@@ -1,5 +1,15 @@ # Changelog for notion-client +## 0.6.1.0 (2026-03-31)++### New Features+* **File Upload API**: New `Notion.V1.FileUploads` module with complete file upload support — create, send (multipart form-data), complete, retrieve, and list endpoints+* Smart constructors for single-part, multi-part, and external URL upload modes+* Add `file_upload` variant to `FileValue`, `Icon`, and `Cover` types++### Other Changes+* Update seihou scaffolding (exec-plan 0.1.2 → 0.1.3)+ ## 0.6.0.0 (2026-03-30) ### Breaking Changes
+ notion-client-example/FileUploadDemo.hs view
@@ -0,0 +1,67 @@+-- |+-- File Uploads API demonstration.+--+-- Shows how to:+-- - Create a single-part file upload+-- - Send file content+-- - Retrieve file upload status+-- - List file uploads+module FileUploadDemo+ ( runFileUploadDemo,+ )+where++import Console (printHeader, runTest)+import Data.Text qualified as Text+import Data.Vector qualified as Vector+import Notion.V1 (Methods (..))+import Notion.V1.FileUploads (FileUploadObject (..), FileUploadStatus (..), mkSendFileUpload, mkSinglePartUpload)+import Notion.V1.ListOf (ListOf (..))+import Prelude hiding (id)++-- | Run the File Upload API demonstration+runFileUploadDemo :: Methods -> FilePath -> IO ()+runFileUploadDemo methods testFilePath = do+ printHeader (Text.pack "File Uploads API")++ -- ---------------------------------------------------------------+ -- Part 1: Create a single-part file upload+ -- ---------------------------------------------------------------+ upload <-+ runTest (Text.pack "Creating single-part file upload") $+ createFileUpload methods (mkSinglePartUpload (Just "test-upload.txt"))++ putStrLn $ " Upload ID: " <> show (id upload)+ putStrLn $ " Status: " <> show (status upload)++ -- ---------------------------------------------------------------+ -- Part 2: Send file content+ -- ---------------------------------------------------------------+ updated <-+ runTest (Text.pack "Sending file content") $+ sendFileUploadContent methods (id upload) (mkSendFileUpload testFilePath "test-upload.txt" "text/plain")++ putStrLn $ " Status after send: " <> show (status updated)++ -- ---------------------------------------------------------------+ -- Part 3: Retrieve file upload+ -- ---------------------------------------------------------------+ retrieved <-+ runTest (Text.pack "Retrieving file upload") $+ retrieveFileUpload methods (id upload)++ putStrLn $ " Status: " <> show (status retrieved)+ putStrLn $ " Filename: " <> show (filename retrieved)+ putStrLn $ " Content type: " <> show (contentType retrieved)++ -- ---------------------------------------------------------------+ -- Part 4: List file uploads+ -- ---------------------------------------------------------------+ result <-+ runTest (Text.pack "Listing file uploads (uploaded)") $+ listFileUploads methods (Just Uploaded) Nothing (Just 5)++ let List {results = uploads} = result+ putStrLn $ " Found " <> show (Vector.length uploads) <> " uploaded file(s)"+ Vector.forM_ uploads $ \fu ->+ putStrLn $ " - " <> show (id fu) <> " (" <> show (filename fu) <> ")"
notion-client-example/Main.hs view
@@ -36,6 +36,7 @@ import Data.Text qualified as Text import Data.Vector qualified as Vector import DatabaseDemo (runDatabaseDemo)+import FileUploadDemo (runFileUploadDemo) import MarkdownDemo (runMarkdownDemo) import Notion.V1 (Methods (..), getClientEnv, makeMethods) import Notion.V1.Search (SearchRequest (..), SearchResult (..), SearchSort (..), SearchSortDirection (..), dataSourceFilter, pageFilter, parseSearchResults)@@ -74,6 +75,12 @@ -- Custom Emojis demo (no setup needed) runCustomEmojiDemo methods++ -- File upload demo (requires a test file path)+ filePathEnv <- Environment.lookupEnv "NOTION_TEST_FILE"+ case filePathEnv of+ Just filePath -> runFileUploadDemo methods filePath+ Nothing -> putStrLn "Skipping file upload demo (set NOTION_TEST_FILE to a file path to enable)" -- Optional Page tests case pageIdEnv of
notion-client.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: notion-client-version: 0.6.0.0+version: 0.6.1.0 synopsis: Type-safe Haskell client for the Notion API description: This package provides comprehensive and type-safe bindings@@ -65,6 +65,7 @@ Notion.V1.Databases Notion.V1.DataSources Notion.V1.Error+ Notion.V1.FileUploads Notion.V1.Filter Notion.V1.ListOf Notion.V1.Pages@@ -102,6 +103,7 @@ , base , bytestring , containers+ , http-api-data , http-client , http-client-tls , notion-client@@ -124,6 +126,7 @@ Console CustomEmojiDemo DatabaseDemo+ FileUploadDemo MarkdownDemo PageDemo TemplateDemo
src/Notion/V1.hs view
@@ -50,6 +50,8 @@ import Notion.V1.Databases (CreateDatabase, DatabaseID, DatabaseObject, QueryDatabase, UpdateDatabase) import Notion.V1.Databases qualified as Databases import Notion.V1.Error (parseNotionError)+import Notion.V1.FileUploads (FileUploadID, FileUploadObject, FileUploadStatus)+import Notion.V1.FileUploads qualified as FileUploads import Notion.V1.ListOf (ListOf (..)) import Notion.V1.Pages (CreatePage, MovePage, PageID, PageMarkdown, PageObject, PropertyItemResponse, UpdatePage, UpdatePageMarkdown) import Notion.V1.Pages qualified as Pages@@ -61,7 +63,7 @@ import Notion.V1.Views qualified as Views import Servant.Client (ClientEnv) import Servant.Client qualified as Client-import Servant.Multipart.Client ()+import Servant.Multipart.Client (genBoundary) -- | Convenient utility to get a `ClientEnv` for the most common use case getClientEnv ::@@ -125,6 +127,12 @@ :<|> queryView ) :<|> listCustomEmojis_+ :<|> ( createFileUpload+ :<|> retrieveFileUpload+ :<|> sendFileUploadContent_+ :<|> completeFileUpload+ :<|> listFileUploads_+ ) ) = Client.hoistClient @API Proxy run (Client.client @API Proxy) authorization notionVersion authorization = "Bearer " <> token@@ -146,6 +154,10 @@ listDataSourceTemplates = listDataSourceTemplates_ listViews = listViews_ listCustomEmojis = listCustomEmojis_+ listFileUploads = listFileUploads_+ sendFileUploadContent fid upload = do+ boundary <- genBoundary+ sendFileUploadContent_ fid (boundary, upload) -- | API methods data Methods = Methods@@ -260,7 +272,20 @@ -- \^ start_cursor Maybe Natural -> -- \^ page_size- IO (ListOf CustomEmoji)+ IO (ListOf CustomEmoji),+ -- \* File Uploads+ createFileUpload :: FileUploads.CreateFileUpload -> IO FileUploadObject,+ retrieveFileUpload :: FileUploadID -> IO FileUploadObject,+ sendFileUploadContent :: FileUploadID -> FileUploads.SendFileUpload -> IO FileUploadObject,+ completeFileUpload :: FileUploadID -> IO FileUploadObject,+ listFileUploads ::+ Maybe FileUploadStatus ->+ -- \^ status filter+ Maybe Text ->+ -- \^ start_cursor+ Maybe Natural ->+ -- \^ page_size+ IO (ListOf FileUploadObject) } -- | Servant API@@ -276,4 +301,5 @@ :<|> Comments.API :<|> Views.API :<|> CustomEmojis.API+ :<|> FileUploads.API )
src/Notion/V1/Common.hs view
@@ -132,6 +132,8 @@ NativeIcon {iconName :: Text, iconColor :: Maybe Text} | -- | Custom emoji icon specified by ID CustomEmojiIcon {customEmojiId :: UUID}+ | -- | File upload icon referenced by upload ID+ FileUploadIcon {fileUploadId :: UUID} deriving stock (Eq, Generic, Show) instance FromJSON Icon where@@ -144,6 +146,9 @@ "external" -> ExternalIcon <$> o .: "external" "icon" -> NativeIcon <$> o .: "name" <*> o .:? "color" "custom_emoji" -> CustomEmojiIcon <$> o .: "id"+ "file_upload" -> do+ uploadObj <- o .: "file_upload"+ FileUploadIcon <$> uploadObj .: "id" _ -> fail $ "Unknown icon type: " <> unpack iconType _ -> fail "Expected object for Icon" @@ -153,11 +158,13 @@ toJSON (ExternalIcon external) = object ["type" .= ("external" :: Text), "external" .= external] toJSON (NativeIcon name color) = object $ ["type" .= ("icon" :: Text), "name" .= name] <> maybe [] (\c -> ["color" .= c]) color toJSON (CustomEmojiIcon eid) = object ["type" .= ("custom_emoji" :: Text), "id" .= eid]+ toJSON (FileUploadIcon uid) = object ["type" .= ("file_upload" :: Text), "file_upload" .= object ["id" .= uid]] -- | Cover object for pages/databases data Cover = FileCover {file :: File} | ExternalCover {external :: ExternalFile}+ | FileUploadCover {fileUploadId :: UUID} deriving stock (Eq, Generic, Show) instance FromJSON Cover where@@ -167,12 +174,16 @@ case coverType of "file" -> FileCover <$> o .: "file" "external" -> ExternalCover <$> o .: "external"+ "file_upload" -> do+ uploadObj <- o .: "file_upload"+ FileUploadCover <$> uploadObj .: "id" _ -> fail $ "Unknown cover type: " <> unpack coverType _ -> fail "Expected object for Cover" instance ToJSON Cover where toJSON (FileCover file) = object ["type" .= ("file" :: Text), "file" .= file] toJSON (ExternalCover external) = object ["type" .= ("external" :: Text), "external" .= external]+ toJSON (FileUploadCover uid) = object ["type" .= ("file_upload" :: Text), "file_upload" .= object ["id" .= uid]] -- | Internal file object data File = File
+ src/Notion/V1/FileUploads.hs view
@@ -0,0 +1,308 @@+-- | @\/v1\/file_uploads@+module Notion.V1.FileUploads+ ( -- * Main types+ FileUploadID,+ FileUploadObject (..),+ FileUploadStatus (..),+ CreateFileUpload (..),+ SendFileUpload (..),++ -- * Supporting types+ NumberOfParts (..),+ FileImportResult (..),++ -- * Smart constructors+ mkSinglePartUpload,+ mkMultiPartUpload,+ mkExternalUrlUpload,+ mkSendFileUpload,++ -- * Servant+ API,+ )+where++import Data.Aeson ((.:), (.:?), (.=))+import Data.Aeson qualified as Aeson+import Notion.Prelude+import Notion.V1.Common (UUID)+import Notion.V1.ListOf (ListOf)+import Prelude hiding (id)++-- | File upload ID+type FileUploadID = UUID++-- | Status of a file upload+data FileUploadStatus+ = Pending+ | Uploaded+ | Expired+ | Failed+ deriving stock (Eq, Show)++instance FromJSON FileUploadStatus where+ parseJSON = \case+ String "pending" -> pure Pending+ String "uploaded" -> pure Uploaded+ String "expired" -> pure Expired+ String "failed" -> pure Failed+ String other -> fail $ "Unknown FileUploadStatus: " <> unpack other+ _ -> fail "Expected string for FileUploadStatus"++instance ToJSON FileUploadStatus where+ toJSON = \case+ Pending -> String "pending"+ Uploaded -> String "uploaded"+ Expired -> String "expired"+ Failed -> String "failed"++instance ToHttpApiData FileUploadStatus where+ toQueryParam = \case+ Pending -> "pending"+ Uploaded -> "uploaded"+ Expired -> "expired"+ Failed -> "failed"++-- | Number of parts for multi-part uploads+data NumberOfParts = NumberOfParts+ { total :: Natural,+ sent :: Natural+ }+ deriving stock (Eq, Generic, Show)++instance FromJSON NumberOfParts where+ parseJSON = genericParseJSON aesonOptions++instance ToJSON NumberOfParts where+ toJSON = genericToJSON aesonOptions++-- | Result of a file import operation+data FileImportResult+ = FileImportSuccess+ { importedTime :: POSIXTime+ }+ | FileImportError+ { importedTime :: POSIXTime,+ errorType :: Text,+ errorCode :: Text,+ errorMessage :: Text,+ errorParameter :: Maybe Text,+ errorStatusCode :: Maybe Int+ }+ deriving stock (Show)++instance FromJSON FileImportResult where+ parseJSON = \case+ Object o -> do+ resultType <- o .: "type"+ importedTimeStr <- o .: "imported_time"+ importedTime <- parseISO8601 importedTimeStr+ case resultType of+ "success" -> pure FileImportSuccess {..}+ "error" -> do+ errObj <- o .: "error"+ errorType <- errObj .: "type"+ errorCode <- errObj .: "code"+ errorMessage <- errObj .: "message"+ errorParameter <- errObj .:? "parameter"+ errorStatusCode <- errObj .:? "status_code"+ pure FileImportError {..}+ other -> fail $ "Unknown FileImportResult type: " <> unpack (other :: Text)+ _ -> fail "Expected object for FileImportResult"++instance ToJSON FileImportResult where+ toJSON FileImportSuccess {..} =+ Aeson.object+ [ "type" .= ("success" :: Text),+ "imported_time" .= posixToISO8601 importedTime,+ "success" .= Aeson.object []+ ]+ toJSON FileImportError {..} =+ Aeson.object+ [ "type" .= ("error" :: Text),+ "imported_time" .= posixToISO8601 importedTime,+ "error"+ .= Aeson.object+ ( [ "type" .= errorType,+ "code" .= errorCode,+ "message" .= errorMessage+ ]+ <> maybe [] (\p -> ["parameter" .= p]) errorParameter+ <> maybe [] (\s -> ["status_code" .= s]) errorStatusCode+ )+ ]++-- | Notion file upload object+data FileUploadObject = FileUploadObject+ { id :: FileUploadID,+ object :: Text,+ status :: FileUploadStatus,+ filename :: Maybe Text,+ contentType :: Maybe Text,+ contentLength :: Maybe Natural,+ createdTime :: POSIXTime,+ lastEditedTime :: POSIXTime,+ createdBy :: Value,+ inTrash :: Bool,+ expiryTime :: Maybe POSIXTime,+ numberOfParts :: Maybe NumberOfParts,+ fileImportResult :: Maybe FileImportResult+ }+ deriving stock (Generic, Show)++instance FromJSON FileUploadObject where+ parseJSON = \case+ Object o -> do+ id <- o .: "id"+ object <- o .: "object"+ status <- o .: "status"+ filename <- o .:? "filename"+ contentType <- o .:? "content_type"+ contentLength <- o .:? "content_length"+ createdTimeStr <- o .: "created_time"+ createdTime <- parseISO8601 createdTimeStr+ lastEditedTimeStr <- o .: "last_edited_time"+ lastEditedTime <- parseISO8601 lastEditedTimeStr+ createdBy <- o .: "created_by"+ inTrash <- o .: "in_trash"+ mExpiryStr <- o .:? "expiry_time"+ expiryTime <- case mExpiryStr of+ Nothing -> pure Nothing+ Just str -> Just <$> parseISO8601 str+ numberOfParts <- o .:? "number_of_parts"+ fileImportResult <- o .:? "file_import_result"+ pure FileUploadObject {..}+ _ -> fail "Expected object for FileUploadObject"++instance ToJSON FileUploadObject where+ toJSON FileUploadObject {..} =+ Aeson.object $+ [ "id" .= id,+ "object" .= object,+ "status" .= status,+ "created_time" .= posixToISO8601 createdTime,+ "last_edited_time" .= posixToISO8601 lastEditedTime,+ "created_by" .= createdBy,+ "in_trash" .= inTrash+ ]+ <> maybe [] (\f -> ["filename" .= f]) filename+ <> maybe [] (\ct -> ["content_type" .= ct]) contentType+ <> maybe [] (\cl -> ["content_length" .= cl]) contentLength+ <> maybe [] (\et -> ["expiry_time" .= posixToISO8601 et]) expiryTime+ <> maybe [] (\np -> ["number_of_parts" .= np]) numberOfParts+ <> maybe [] (\fir -> ["file_import_result" .= fir]) fileImportResult++-- | Request body for creating a file upload+data CreateFileUpload = CreateFileUpload+ { mode :: Maybe Text,+ filename :: Maybe Text,+ contentType :: Maybe Text,+ numberOfParts :: Maybe Natural,+ externalUrl :: Maybe Text+ }+ deriving stock (Generic, Show)++instance ToJSON CreateFileUpload where+ toJSON = genericToJSON aesonOptions++-- | Data for sending file content via multipart/form-data+data SendFileUpload = SendFileUpload+ { filePath :: FilePath,+ fileName :: Text,+ fileContentType :: Text,+ partNumber :: Maybe Natural+ }+ deriving stock (Show)++instance ToMultipart Tmp SendFileUpload where+ toMultipart SendFileUpload {..} =+ MultipartData+ { inputs = maybe [] (\n -> [Input "part_number" (pack (show n))]) partNumber,+ files = [FileData "file" fileName fileContentType filePath]+ }++-- ---------------------------------------------------------------------------+-- Smart constructors+-- ---------------------------------------------------------------------------++-- | Create a single-part file upload request with an optional filename.+mkSinglePartUpload :: Maybe Text -> CreateFileUpload+mkSinglePartUpload fname =+ CreateFileUpload+ { mode = Nothing,+ filename = fname,+ contentType = Nothing,+ numberOfParts = Nothing,+ externalUrl = Nothing+ }++-- | Create a multi-part file upload request.+mkMultiPartUpload ::+ -- | filename (required)+ Text ->+ -- | number of parts (required)+ Natural ->+ -- | content type (optional)+ Maybe Text ->+ CreateFileUpload+mkMultiPartUpload fname parts ct =+ CreateFileUpload+ { mode = Just "multi_part",+ filename = Just fname,+ contentType = ct,+ numberOfParts = Just parts,+ externalUrl = Nothing+ }++-- | Create an external URL file upload request.+mkExternalUrlUpload ::+ -- | external URL (required, must be HTTPS)+ Text ->+ -- | filename (optional)+ Maybe Text ->+ CreateFileUpload+mkExternalUrlUpload url fname =+ CreateFileUpload+ { mode = Just "external_url",+ filename = fname,+ contentType = Nothing,+ numberOfParts = Nothing,+ externalUrl = Just url+ }++-- | Create a 'SendFileUpload' for a single-part upload.+mkSendFileUpload ::+ -- | path to the file on disk+ FilePath ->+ -- | filename+ Text ->+ -- | MIME content type (e.g., @"image\/png"@)+ Text ->+ SendFileUpload+mkSendFileUpload fp fname ct =+ SendFileUpload+ { filePath = fp,+ fileName = fname,+ fileContentType = ct,+ partNumber = Nothing+ }++-- | Servant API+type API =+ "file_uploads"+ :> ( ReqBody '[JSON] CreateFileUpload+ :> Post '[JSON] FileUploadObject+ :<|> Capture "file_upload_id" FileUploadID+ :> Get '[JSON] FileUploadObject+ :<|> Capture "file_upload_id" FileUploadID+ :> "send"+ :> MultipartForm Tmp SendFileUpload+ :> Post '[JSON] FileUploadObject+ :<|> Capture "file_upload_id" FileUploadID+ :> "complete"+ :> Post '[JSON] FileUploadObject+ :<|> QueryParam "status" FileUploadStatus+ :> QueryParam "start_cursor" Text+ :> QueryParam "page_size" Natural+ :> Get '[JSON] (ListOf FileUploadObject)+ )
src/Notion/V1/PropertyValue.hs view
@@ -38,6 +38,7 @@ statusValue, peopleValue, filesValue,+ fileUploadFilesValue, ) where @@ -176,6 +177,7 @@ data FileValue = InternalFileValue {name :: Text, file :: File} | ExternalFileValue {name :: Text, external :: ExternalFile}+ | FileUploadFileValue {name :: Text, fileUploadId :: UUID} deriving stock (Show) instance FromJSON FileValue where@@ -186,6 +188,10 @@ case fileType of "file" -> InternalFileValue n <$> o .: "file" "external" -> ExternalFileValue n <$> o .: "external"+ "file_upload" -> do+ uploadObj <- o .: "file_upload"+ uploadId <- uploadObj .: "id"+ pure (FileUploadFileValue n uploadId) other -> fail $ "Unknown file value type: " <> unpack other _ -> fail "Expected object for FileValue" @@ -194,6 +200,12 @@ Aeson.object ["type" .= ("file" :: Text), "name" .= n, "file" .= f] toJSON (ExternalFileValue n e) = Aeson.object ["type" .= ("external" :: Text), "name" .= n, "external" .= e]+ toJSON (FileUploadFileValue n uid) =+ Aeson.object+ [ "type" .= ("file_upload" :: Text),+ "name" .= n,+ "file_upload" .= Aeson.object ["id" .= uid]+ ] -- | A relation reference (just a page ID). newtype RelationRef = RelationRef {id :: UUID}@@ -351,3 +363,7 @@ -- | Create a files property value from a list of external URLs. filesValue :: [Text] -> PropertyValue filesValue urls = FilesValue "" (Vector.fromList (map (\u -> ExternalFileValue "" (ExternalFile u)) urls))++-- | Create a files property value from a list of file upload references.+fileUploadFilesValue :: [(Text, UUID)] -> PropertyValue+fileUploadFilesValue pairs = FilesValue "" (Vector.fromList (map (\(n, uid) -> FileUploadFileValue n uid) pairs))
tasty/Main.hs view
@@ -14,13 +14,14 @@ import Notion.V1.Blocks qualified as Blocks import Notion.V1.Comments (CommentObject (..), CreateComment (..)) import Notion.V1.Comments qualified as Comments-import Notion.V1.Common (Color (..), ExternalFile (..), Icon (..), Parent (..), UUID (..))+import Notion.V1.Common (Color (..), Cover (..), ExternalFile (..), Icon (..), Parent (..), UUID (..)) import Notion.V1.CustomEmojis (CustomEmoji (..)) import Notion.V1.DataSources (DataSourceObject (..)) import Notion.V1.DataSources qualified as DataSources import Notion.V1.Databases (DataSource (..), DatabaseObject (..)) import Notion.V1.Databases qualified as Databases import Notion.V1.Error (NotionError (..))+import Notion.V1.FileUploads qualified as FU import Notion.V1.Filter qualified as F import Notion.V1.ListOf (ListOf (..)) import Notion.V1.ListOf qualified as ListOf@@ -49,6 +50,7 @@ import System.Environment qualified as Environment import Test.Tasty import Test.Tasty.HUnit+import Web.HttpApiData (toQueryParam) main :: IO () main = do@@ -158,6 +160,7 @@ [ jsonParsingTests, jsonSerializationTests, propertyValueTests,+ fileUploadTests, basicIntegration, markdownE2E, pageE2E,@@ -1807,6 +1810,174 @@ Aeson.Error err -> assertFailure $ "Failed to decode " <> show fn <> ": " <> err ) fns++-- =====================================================================+-- File Upload Tests+-- =====================================================================++fileUploadTests :: TestTree+fileUploadTests =+ testGroup+ "File Uploads"+ [ testCase "FileUploadObject JSON round-trip (pending)" testFileUploadObjectPending,+ testCase "FileUploadObject JSON parse (uploaded with expiry)" testFileUploadObjectUploaded,+ testCase "CreateFileUpload single-part serialization" testCreateSinglePart,+ testCase "CreateFileUpload multi-part serialization" testCreateMultiPart,+ testCase "CreateFileUpload external URL serialization" testCreateExternalUrl,+ testCase "FileUploadStatus ToHttpApiData" testFileUploadStatusQueryParam,+ testCase "FileValue file_upload round-trip" testFileValueFileUpload,+ testCase "Icon file_upload round-trip" testIconFileUpload,+ testCase "Cover file_upload round-trip" testCoverFileUpload+ ]++testFileUploadObjectPending :: Assertion+testFileUploadObjectPending = do+ let json =+ "{\"id\":\"fu-123\",\"object\":\"file_upload\",\"status\":\"pending\""+ <> ",\"created_time\":\"2026-03-31T10:00:00.000+00:00\""+ <> ",\"last_edited_time\":\"2026-03-31T10:00:00.000+00:00\""+ <> ",\"created_by\":{\"id\":\"user-1\",\"type\":\"person\"}"+ <> ",\"in_trash\":false}"+ case Aeson.eitherDecode json of+ Left err -> assertFailure $ "Failed to parse FileUploadObject: " <> err+ Right (obj :: FU.FileUploadObject) -> do+ let FU.FileUploadObject {id = objId, object = objObject, status = objStatus, filename = objFilename} = obj+ assertEqual "status" FU.Pending objStatus+ assertEqual "object" "file_upload" objObject+ assertEqual "filename" Nothing objFilename+ -- Round-trip: re-encode and re-decode+ case Aeson.fromJSON (Aeson.toJSON obj) of+ Aeson.Error err2 -> assertFailure $ "Round-trip failed: " <> err2+ Aeson.Success (obj2 :: FU.FileUploadObject) -> do+ let FU.FileUploadObject {id = objId2, status = objStatus2} = obj2+ assertEqual "round-trip status" objStatus objStatus2+ assertEqual "round-trip id" objId objId2++testFileUploadObjectUploaded :: Assertion+testFileUploadObjectUploaded = do+ let json =+ "{\"id\":\"fu-456\",\"object\":\"file_upload\",\"status\":\"uploaded\""+ <> ",\"filename\":\"photo.png\",\"content_type\":\"image/png\",\"content_length\":12345"+ <> ",\"created_time\":\"2026-03-31T10:00:00.000+00:00\""+ <> ",\"last_edited_time\":\"2026-03-31T10:05:00.000+00:00\""+ <> ",\"created_by\":{\"id\":\"user-1\",\"type\":\"bot\"}"+ <> ",\"in_trash\":false"+ <> ",\"expiry_time\":\"2026-03-31T11:00:00.000+00:00\""+ <> ",\"number_of_parts\":{\"total\":1,\"sent\":1}}"+ case Aeson.eitherDecode json of+ Left err -> assertFailure $ "Failed to parse FileUploadObject: " <> err+ Right (obj :: FU.FileUploadObject) -> do+ let FU.FileUploadObject+ { status = objStatus,+ filename = objFilename,+ contentType = objContentType,+ contentLength = objContentLength,+ expiryTime = objExpiryTime,+ numberOfParts = objNumberOfParts+ } = obj+ assertEqual "status" FU.Uploaded objStatus+ assertEqual "filename" (Just "photo.png") objFilename+ assertEqual "content_type" (Just "image/png") objContentType+ assertEqual "content_length" (Just 12345) objContentLength+ assertBool "expiry_time should be Just" (objExpiryTime /= Nothing)+ case objNumberOfParts of+ Just np -> do+ assertEqual "total parts" 1 (FU.total np)+ assertEqual "sent parts" 1 (FU.sent np)+ Nothing -> assertFailure "Expected number_of_parts"++testCreateSinglePart :: Assertion+testCreateSinglePart = do+ let req = FU.mkSinglePartUpload (Just "test.txt")+ json = Aeson.toJSON req+ case json of+ Aeson.Object o -> do+ assertBool "should not have mode key (Nothing is omitted)" (not $ KeyMap.member "mode" o)+ assertEqual "filename" (Just (Aeson.String "test.txt")) (KeyMap.lookup "filename" o)+ _ -> assertFailure "Expected JSON object"++testCreateMultiPart :: Assertion+testCreateMultiPart = do+ let req = FU.mkMultiPartUpload "big.zip" 5 (Just "application/zip")+ json = Aeson.toJSON req+ case json of+ Aeson.Object o -> do+ assertEqual "mode" (Just (Aeson.String "multi_part")) (KeyMap.lookup "mode" o)+ assertEqual "filename" (Just (Aeson.String "big.zip")) (KeyMap.lookup "filename" o)+ assertEqual "number_of_parts" (Just (Aeson.Number 5)) (KeyMap.lookup "number_of_parts" o)+ assertEqual "content_type" (Just (Aeson.String "application/zip")) (KeyMap.lookup "content_type" o)+ _ -> assertFailure "Expected JSON object"++testCreateExternalUrl :: Assertion+testCreateExternalUrl = do+ let req = FU.mkExternalUrlUpload "https://example.com/file.pdf" Nothing+ json = Aeson.toJSON req+ case json of+ Aeson.Object o -> do+ assertEqual "mode" (Just (Aeson.String "external_url")) (KeyMap.lookup "mode" o)+ assertEqual "external_url" (Just (Aeson.String "https://example.com/file.pdf")) (KeyMap.lookup "external_url" o)+ assertBool "should not have filename" (not $ KeyMap.member "filename" o)+ _ -> assertFailure "Expected JSON object"++testFileUploadStatusQueryParam :: Assertion+testFileUploadStatusQueryParam = do+ assertEqual "Pending" "pending" (toQueryParam FU.Pending)+ assertEqual "Uploaded" "uploaded" (toQueryParam FU.Uploaded)+ assertEqual "Expired" "expired" (toQueryParam FU.Expired)+ assertEqual "Failed" "failed" (toQueryParam FU.Failed)++testFileValueFileUpload :: Assertion+testFileValueFileUpload = do+ let json =+ "{\"type\":\"file_upload\",\"name\":\"photo.png\",\"file_upload\":{\"id\":\"fu-789\"}}"+ case Aeson.eitherDecode json of+ Left err -> assertFailure $ "Failed to parse FileValue: " <> err+ Right (PV.FileUploadFileValue n uid) -> do+ assertEqual "name" "photo.png" n+ assertEqual "fileUploadId" (UUID "fu-789") uid+ Right other -> assertFailure $ "Expected FileUploadFileValue, got: " <> show other+ -- Round-trip+ let val = PV.FileUploadFileValue "doc.pdf" (UUID "fu-abc")+ case Aeson.fromJSON (Aeson.toJSON val) of+ Aeson.Error err -> assertFailure $ "Round-trip failed: " <> err+ Aeson.Success (PV.FileUploadFileValue n2 uid2) -> do+ assertEqual "name rt" "doc.pdf" n2+ assertEqual "id rt" (UUID "fu-abc") uid2+ Aeson.Success other -> assertFailure $ "Expected FileUploadFileValue, got: " <> show other++testIconFileUpload :: Assertion+testIconFileUpload = do+ let json =+ "{\"type\":\"file_upload\",\"file_upload\":{\"id\":\"fu-icon\"}}"+ case Aeson.eitherDecode json of+ Left err -> assertFailure $ "Failed to parse Icon: " <> err+ Right (FileUploadIcon uid) ->+ assertEqual "fileUploadId" (UUID "fu-icon") uid+ Right other -> assertFailure $ "Expected FileUploadIcon, got: " <> show other+ -- Round-trip+ let icon = FileUploadIcon (UUID "fu-icon-2")+ case Aeson.fromJSON (Aeson.toJSON icon) of+ Aeson.Error err -> assertFailure $ "Round-trip failed: " <> err+ Aeson.Success (FileUploadIcon uid2) ->+ assertEqual "id rt" (UUID "fu-icon-2") uid2+ Aeson.Success other -> assertFailure $ "Expected FileUploadIcon, got: " <> show other++testCoverFileUpload :: Assertion+testCoverFileUpload = do+ let json =+ "{\"type\":\"file_upload\",\"file_upload\":{\"id\":\"fu-cover\"}}"+ case Aeson.eitherDecode json of+ Left err -> assertFailure $ "Failed to parse Cover: " <> err+ Right (FileUploadCover uid) ->+ assertEqual "fileUploadId" (UUID "fu-cover") uid+ Right other -> assertFailure $ "Expected FileUploadCover, got: " <> show other+ -- Round-trip+ let cover = FileUploadCover (UUID "fu-cover-2")+ case Aeson.fromJSON (Aeson.toJSON cover) of+ Aeson.Error err -> assertFailure $ "Round-trip failed: " <> err+ Aeson.Success (FileUploadCover uid2) ->+ assertEqual "id rt" (UUID "fu-cover-2") uid2+ Aeson.Success other -> assertFailure $ "Expected FileUploadCover, got: " <> show other -- ===================================================================== -- Helpers