packages feed

do-spaces-0.2: src/Network/DO/Spaces/Actions/UploadMultipart.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

-- |
-- Module      : Network.DO.Spaces.Actions.UploadMultipart
-- Copyright   : (c) 2021 Rory Tyler Hayford
-- License     : BSD-3-Clause
-- Maintainer  : rory.hayford@protonmail.com
-- Stability   : experimental
-- Portability : GHC
--
module Network.DO.Spaces.Actions.UploadMultipart
    ( BeginMultipart(..)
    , BeginMultipartResponse(..)
    , UploadPart(..)
    , UploadPartResponse(..)
    , ListParts(..)
    , Part(..)
    , ListPartsResponse(..)
    , UploadID
    , CancelMultipart(..)
    , MultipartSession(..)
    , CancelMultipartResponse
    , CompleteMultipart(..)
    , CompleteMultipartResponse(..)
    ) where

import           Control.Monad.Catch         ( MonadThrow(throwM) )
import           Control.Monad.Reader        ( MonadReader(ask) )
import           Control.Monad.Trans.Maybe   ( MaybeT(runMaybeT) )

import           Data.ByteString             ( ByteString )
import qualified Data.CaseInsensitive        as CI
import           Data.Generics.Product       ( HasField(field) )
import           Data.Sequence               ( Seq )
import qualified Data.Sequence               as S
import           Data.Text                   ( Text )
import           Data.Time                   ( UTCTime )

import           GHC.Generics                ( Generic )

import           Lens.Micro                  ( (^.), (^?) )

import           Network.DO.Spaces.Types
import           Network.DO.Spaces.Utils
import           Network.HTTP.Client.Conduit ( RequestBody(RequestBodyLBS) )
import qualified Network.HTTP.Types          as H
import           Network.Mime                ( MimeType )

import qualified Text.XML                    as X
import qualified Text.XML.Cursor             as X
import           Text.XML.Cursor             ( ($/), (&/), (&|) )

-- | A single part of a multipart upload session. Returned when querying 'ListParts'
data Part = Part
    { partNumber   :: Int
    , lastModified :: UTCTime
    , etag         :: ETag
    , size         :: Int -- ^ Size in bytes
    }
    deriving stock ( Show, Eq, Generic )

data MultipartSession = MultipartSession
    { bucket   :: Bucket
    , object   :: Object
    , uploadID :: UploadID --
    }
    deriving stock ( Show, Eq, Generic )

-- | A unique ID assigned to a multipart upload session
type UploadID = Text

-- | Initiate a multipart upload session
data BeginMultipart = BeginMultipart
    { bucket          :: Bucket
    , object          :: Object
    , optionalHeaders :: UploadHeaders
    , contentType     :: Maybe MimeType
    }
    deriving stock ( Show, Eq, Generic )

newtype BeginMultipartResponse =
    BeginMultipartResponse { session :: MultipartSession }
    deriving stock ( Show, Generic )
    deriving newtype ( Eq )

instance MonadSpaces m => Action m BeginMultipart where
    type (ConsumedResponse BeginMultipart) = BeginMultipartResponse

    buildRequest BeginMultipart { .. } = do
        spaces <- ask
        pure SpacesRequestBuilder
             { bucket         = Just bucket
             , object         = Just object
             , method         = Just POST
             , body           = Nothing
             , overrideRegion = Nothing
             , queryString    = Nothing
             , subresources   = Just
                   $ H.toQuery [ ( "uploads" :: ByteString
                                 , Nothing :: Maybe ByteString
                                 )
                               ]
             , ..
             }
      where
        headers = maybe id
                        (\ct -> (:) (CI.mk "Content-Type", ct))
                        contentType
                        (renderUploadHeaders optionalHeaders)

    consumeResponse raw = do
        cursor <- xmlDocCursor raw
        object <- objectP cursor
        bucket <- bucketP cursor
        uploadID <- X.force (xmlElemError "UploadId")
            $ cursor $/ X.laxElement "UploadId" &/ X.content
        pure $ BeginMultipartResponse { session = MultipartSession { .. } }

data UploadPart = UploadPart
    { session :: MultipartSession, partNumber :: Int, body :: RequestBody }
    deriving stock ( Generic )

data UploadPartResponse = UploadPartResponse { etag :: ETag }
    deriving stock ( Show, Eq, Generic )

instance MonadSpaces m => Action m UploadPart where
    type (ConsumedResponse UploadPart) = UploadPartResponse

    buildRequest UploadPart { .. } = do
        spaces <- ask
        pure SpacesRequestBuilder
             { bucket         = session ^? field @"bucket"
             , object         = session ^? field @"object"
             , body           = Just body
             , method         = Just PUT
             , overrideRegion = Nothing
             , subresources   = Nothing
             , headers        = mempty
             , queryString    = Just
                   $ H.toQuery [ ("partNumber" :: Text, tshow partNumber)
                               , ("uploadId", session ^. field @"uploadID")
                               ]
             , ..
             }

    consumeResponse raw =
        runMaybeT (UploadPartResponse
                   <$> (readEtag =<< lookupHeader raw "etag"))
        >>= \case
            Nothing -> throwM $ InvalidResponse "Missing/malformed headers"
            Just r  -> pure r

-- | Complete a multipart session
data CompleteMultipart = CompleteMultipart
    { session :: MultipartSession
    , parts   :: [(Int, ETag)]
      -- ^ The part numbers and 'ETag's of each uploaded part
    }
    deriving stock ( Show, Eq, Generic )

data CompleteMultipartResponse = CompleteMultipartResponse
    { location :: Text
    , bucket   :: Bucket
    , object   :: Object
    , etag     :: ETag
      -- ^ The MD5 hash of the final object, i.e. all of the cumulative
      -- uploaded parts
    }
    deriving stock ( Show, Eq, Generic )

instance MonadSpaces m => Action m CompleteMultipart where
    type ConsumedResponse CompleteMultipart = CompleteMultipartResponse

    buildRequest CompleteMultipart { .. } = do
        spaces <- ask
        pure SpacesRequestBuilder
             { bucket         = session ^? field @"bucket"
             , object         = session ^? field @"object"
             , method         = Just POST
             , overrideRegion = Nothing
             , subresources   = Nothing
             , headers        = mempty
             , queryString    = Just
                   $ H.toQuery [ ( "uploadId" :: Text
                                 , session ^. field @"uploadID"
                                 )
                               ]
             , ..
             }
      where
        body               = Just . RequestBodyLBS . X.renderLBS X.def
            $ X.Document prologue root mempty

        prologue           = X.Prologue mempty Nothing mempty

        root               =
            X.Element "CompleteMultipartUpload" mempty (partNode <$> parts)

        partNode (n, etag) = X.NodeElement
            $ X.Element "Part"
                        mempty
                        [ mkNode "PartNumber" (tshow n)
                        , mkNode "ETag" (quote etag)
                        ]

    consumeResponse raw = do
        cursor <- xmlDocCursor raw
        bucket <- bucketP cursor
        location <- X.force (xmlElemError "Location")
            $ cursor $/ X.laxElement "Location" &/ X.content
        object <- objectP cursor
        etag <- etagP cursor
        pure CompleteMultipartResponse { .. }

-- | Cancel an active multipart upload session
newtype CancelMultipart = CancelMultipart { session :: MultipartSession }
    deriving stock ( Show, Generic )
    deriving newtype ( Eq )

type CancelMultipartResponse = ()

instance MonadSpaces m => Action m CancelMultipart where
    type (ConsumedResponse CancelMultipart) = CancelMultipartResponse

    buildRequest CancelMultipart { .. } = do
        spaces <- ask
        pure SpacesRequestBuilder
             { bucket         = session ^? field @"bucket"
             , object         = session ^? field @"object"
             , method         = Just DELETE
             , body           = Nothing
             , overrideRegion = Nothing
             , headers        = mempty
             , subresources   = Nothing
             , queryString    = Just
                   $ H.toQuery [ ( "uploadId" :: Text
                                 , session ^. field @"uploadID"
                                 )
                               ]
             , ..
             }

    consumeResponse _ = pure ()

-- | List all of the 'Part's of a multipart upload session
newtype ListParts = ListParts { session :: MultipartSession }
    deriving stock ( Show, Generic )
    deriving newtype ( Eq )

data ListPartsResponse = ListPartsResponse
    { bucket         :: Bucket
    , object         :: Object
    , uploadID       :: UploadID
    , parts          :: Seq Part
    , partMarker     :: Int
      -- ^ Part number marking the beginning of the list
    , nextPartMarker :: Int
      -- ^ If truncated, the list location where the next response will begin
    , maxParts       :: Int
    , isTruncated    :: Bool
    }
    deriving stock ( Show, Eq, Generic )

instance MonadSpaces m => Action m ListParts where
    type (ConsumedResponse ListParts) = ListPartsResponse

    buildRequest ListParts { .. } = do
        spaces <- ask
        pure SpacesRequestBuilder
             { bucket         = session ^? field @"bucket"
             , object         = session ^? field @"object"
             , method         = Nothing
             , body           = Nothing
             , overrideRegion = Nothing
             , subresources   = Nothing
             , headers        = mempty
             , queryString    = Just
                   $ H.toQuery [ ( "uploadId" :: Text
                                 , session ^. field @"uploadID"
                                 )
                               ]
             , ..
             }

    consumeResponse raw = do
        cursor <- xmlDocCursor raw
        bucket <- bucketP cursor
        object <- objectP cursor
        uploadID <- X.force (xmlElemError "UploadId")
            $ cursor $/ X.laxElement "UploadId" &/ X.content
        isTruncated <- isTruncP cursor
        maxParts <- xmlNum "MaxParts" cursor
        parts <- S.fromList
            <$> sequence (cursor $/ X.laxElement "Part" &| partP)
        partMarker <- xmlNum "PartNumberMarker" cursor
        nextPartMarker <- xmlNum "NextPartNumberMarker" cursor

        pure ListPartsResponse { .. }
      where
        partP c = Part <$> xmlNum "PartNumber" c
            <*> lastModifiedP c
            <*> etagP c
            <*> xmlNum "Size" c