packages feed

google-cloud-storage-1.1.0.0: src/Google/Cloud/Storage/Bucket.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

{- | This module provides functions for interacting with Google Cloud Storage buckets and objects.
It includes functionality for listing, creating, updating, and deleting buckets, as well as
copying, deleting, downloading, and uploading objects.
-}
module Google.Cloud.Storage.Bucket
  ( Buckets (..)
  , Location (..)
  , StorageClass (..)
  , CreateBucketData (..)
  , CopyObjectRequest (..)
  , CopyObjectResp (..)
  , listBuckets
  , createBucket
  , updateBucketStorageClass
  , fetchBucket
  , copyObject
  , deleteObject
  , deleteBucket
  , downloadObject
  , uploadObject
  , googleStorageUrl
  ) where

import Data.Aeson
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy.Char8 as BSL
import GHC.Generics (Generic)
import Google.Cloud.Common.Core
import Network.HTTP.Simple
import Network.HTTP.Types (status200, status308)

-- | Represents a list of buckets in a Google Cloud Storage project.
data Buckets = Buckets
  { kind :: String
  -- ^ The kind of item this is. For buckets, this is always "storage#buckets".
  , items :: [Bucket]
  -- ^ The list of buckets.
  }
  deriving (Show, Generic, FromJSON, ToJSON)

-- | Represents a single bucket in Google Cloud Storage.
data Bucket = Bucket
  { kind :: String
  -- ^ The kind of item this is. For buckets, this is always "storage#bucket".
  , selfLink :: String
  -- ^ The URL of this bucket.
  , id :: String
  -- ^ The ID of the bucket.
  , name :: String
  -- ^ The name of the bucket.
  , projectNumber :: String
  -- ^ The project number of the project the bucket belongs to.
  , generation :: String
  -- ^ The generation of this bucket.
  , metageneration :: String
  -- ^ The metadata generation of this bucket.
  , location :: String
  -- ^ The location of the bucket.
  , storageClass :: String
  -- ^ The storage class of the bucket.
  , etag :: String
  -- ^ The entity tag for this bucket.
  , timeCreated :: String
  -- ^ The creation time of the bucket.
  , updated :: String
  -- ^ The last modification time of the bucket.
  , softDeletePolicy :: SoftDeletePolicy
  -- ^ The soft delete policy of the bucket.
  , iamConfiguration :: IAMConfiguration
  -- ^ The IAM configuration of the bucket.
  , locationType :: String
  -- ^ The type of location (e.g., region).
  , satisfiesPZI :: Maybe Bool
  -- ^ Whether the bucket satisfies the PZI (Per-Zone Isolation) requirement.
  , hierarchicalNamespace :: Maybe HierarchicalNamespace
  -- ^ The hierarchical namespace configuration.
  }
  deriving (Show, Generic, FromJSON, ToJSON)

-- | Represents the soft delete policy of a bucket.
data SoftDeletePolicy = SoftDeletePolicy
  { retentionDurationSeconds :: String
  -- ^ The duration in seconds that soft-deleted objects are retained.
  , effectiveTime :: String
  -- ^ The time from which the policy was enforced.
  }
  deriving (Show, Generic, FromJSON, ToJSON)

-- | Represents the IAM configuration of a bucket.
data IAMConfiguration = IAMConfiguration
  { bucketPolicyOnly :: BucketPolicyOnly
  -- ^ The Bucket Policy Only configuration.
  , uniformBucketLevelAccess :: UniformBucketLevelAccess
  -- ^ The Uniform Bucket-Level Access configuration.
  , publicAccessPrevention :: String
  -- ^ The public access prevention status.
  }
  deriving (Show, Generic, FromJSON, ToJSON)

-- | Represents the Bucket Policy Only configuration.
data BucketPolicyOnly = BucketPolicyOnly
  { enabled :: Bool
  -- ^ Whether Bucket Policy Only is enabled.
  , lockedTime :: Maybe String
  -- ^ The time when the configuration was locked, if applicable.
  }
  deriving (Show, Generic, FromJSON, ToJSON)

-- | Represents the Uniform Bucket-Level Access configuration.
data UniformBucketLevelAccess = UniformBucketLevelAccess
  { enabled :: Bool
  -- ^ Whether Uniform Bucket-Level Access is enabled.
  , lockedTime :: Maybe String
  -- ^ The time when the configuration was locked, if applicable.
  }
  deriving (Show, Generic, FromJSON, ToJSON)

-- | Represents the hierarchical namespace configuration of a bucket.
newtype HierarchicalNamespace = HierarchicalNamespace
  { enabled :: Bool
  -- ^ Whether hierarchical namespace is enabled.
  }
  deriving (Show, Generic, FromJSON, ToJSON)

-- | Represents the location of a bucket.
newtype Location = Location String
  deriving (Eq)

instance Show Location where
  show (Location location) = location

-- | Represents the storage class of a bucket or object.
data StorageClass
  = -- | Standard storage class, for frequently accessed data.
    STANDARD
  | -- | Nearline storage class, for infrequently accessed data.
    NEARLINE
  | -- | Coldline storage class, for rarely accessed data.
    COLDLINE
  | -- | Archive storage class, for long-term data archiving.
    ARCHIVE
  deriving (Eq, Show)

-- | Data required to create a new bucket.
data CreateBucketData = CreateBucketData
  { name :: String
  -- ^ The name of the bucket to create.
  , location :: Location
  -- ^ The location where the bucket will be created.
  , storageClass :: StorageClass
  -- ^ The storage class for the bucket.
  , projectId :: String
  -- ^ The ID of the project in which to create the bucket.
  }
  deriving (Show)

-- | Request data for copying an object.
data CopyObjectRequest = CopyObjectRequest
  { sourceBucketName :: String
  -- ^ The name of the source bucket.
  , sourceObjectName :: String
  -- ^ The name of the source object.
  , destinationBucketName :: String
  -- ^ The name of the destination bucket.
  , destinationObject :: String
  -- ^ The name of the destination object.
  }
  deriving (Show, Generic)

-- | Response data for copying an object.
data CopyObjectResp = CopyObjectResp
  { kind :: String
  -- ^ The kind of item this is.
  , totalBytesRewritten :: String
  -- ^ The total number of bytes rewritten.
  , objectSize :: String
  -- ^ The size of the object.
  , done :: Bool
  -- ^ Whether the copy operation is complete.
  , rewriteToken :: Maybe String
  -- ^ Token for continuing the rewrite operation, if not done.
  }
  deriving (Show, Eq, Generic, FromJSON)

{- | Lists all buckets in a given project.

@listBuckets projectId@ makes a request to list all buckets in the specified @projectId@.
It returns either an error message or a 'Buckets' object containing the list of buckets.
-}
listBuckets :: String -> IO (Either String Buckets)
listBuckets projectId =
  doRequestJSON
    RequestOptions
      { reqMethod = GET
      , reqUrl = googleStorageUrl
      , mbQueryParams = Just [("project", Just (BS.pack projectId))]
      , mbReqBody = Nothing
      , mbReqHeaders = Nothing
      , mbReqPath = Nothing
      }

{- | Creates a new bucket in Google Cloud Storage.

@createBucket createBucketData@ creates a new bucket with the specified name, location,
storage class, and project ID. It returns either an error message or a unit value indicating success.
-}
createBucket :: CreateBucketData -> IO (Either String ())
createBucket CreateBucketData {..} =
  doRequestJSON
    RequestOptions
      { reqMethod = POST
      , reqUrl = googleStorageUrl
      , mbReqPath = Nothing
      , mbReqBody = Just (encode $ CreateBucketRequest name (show location) storageClass)
      , mbReqHeaders = Just [("Content-Type", "application/json")]
      , mbQueryParams = Just [("project", Just (BS.pack projectId))]
      }

{- | Updates the storage class of an existing bucket.

@updateBucketStorageClass bucketName newStorageClass@ updates the storage class of the
specified bucket to @newStorageClass@. It returns either an error message or a unit value
indicating success.
-- Example:

>>> updateBucketStorageClass "my-bucket" COLDLINE
Right ()
-}
updateBucketStorageClass :: String -> StorageClass -> IO (Either String ())
updateBucketStorageClass bucketName newStorageClass =
  doRequestJSON
    RequestOptions
      { reqMethod = PATCH
      , reqUrl = googleStorageUrl
      , mbReqPath = Just ("/" <> bucketName)
      , mbReqBody = Just (encode $ UpdateStorageClassReq newStorageClass)
      , mbReqHeaders = Just [("Content-Type", "application/json")]
      , mbQueryParams = Just [("fields", Just "storageClass")]
      }

{- | Fetches the metadata of a specific bucket.

@fetchBucket bucketName@ retrieves the metadata for the bucket with the given name.
It returns either an error message or the 'Bucket' object.
Example:

>>> fetchBucket "my-bucket"
Right (Bucket {name = "my-bucket", ...})
-}
fetchBucket :: String -> IO (Either String Bucket)
fetchBucket bucketName =
  doRequestJSON
    RequestOptions
      { reqMethod = GET
      , reqUrl = googleStorageUrl
      , mbReqPath = Just ("/" <> bucketName)
      , mbReqBody = Nothing
      , mbReqHeaders = Nothing
      , mbQueryParams = Nothing
      }

{- | Copies an object from one bucket to another.

@copyObject copyObjectRequest@ copies the specified source object to the destination bucket
and object name. For large objects, this function handles the copy in multiple requests
using rewrite tokens. It returns either an error message or a 'CopyObjectResp' indicating
the status of the copy operation.
Example:

>>> let request = CopyObjectRequest "source-bucket" "source-object" "destination-bucket" "destination-object"
>>> copyObject request
Right (CopyObjectResp {kind = "storage#object", totalBytesRewritten = "12345", objectSize = "12345", done = True, rewriteToken = Nothing})
-}
copyObject :: CopyObjectRequest -> IO (Either String CopyObjectResp)
copyObject CopyObjectRequest {..} = do
  go Nothing
  where
    go mbRewriteToken = do
      eRes <-
        doRequestJSON
          RequestOptions
            { reqMethod = POST
            , reqUrl = googleStorageUrl
            , mbReqPath =
                Just
                  ( "/"
                      <> sourceBucketName
                      <> "/o/"
                      <> sourceObjectName
                      <> "/rewriteTo/b/"
                      <> destinationBucketName
                      <> "/o/"
                      <> destinationObject
                  )
            , mbReqBody = case mbRewriteToken of
                Nothing -> Nothing
                (Just r) -> Just (encode $ object ["rewriteToken" .= r])
            , mbReqHeaders = Just [("Content-Length", "0")]
            , mbQueryParams = Nothing
            }
      case eRes of
        Left err -> pure $ Left (show err)
        Right resp -> do
          if done resp
            then
              return $ Right resp
            else do
              go (Just (rewriteToken resp))

{- | Deletes an object from a bucket.

@deleteObject bucketName objectName@ deletes the specified object from the given bucket.
It returns either an error message or a unit value indicating success.
Example:

>>> deleteObject "my-bucket" "my-object"
Right ()
-}
deleteObject :: String -> String -> IO (Either String ())
deleteObject bucketName objectName =
  doRequestJSON
    RequestOptions
      { reqMethod = DELETE
      , reqUrl = googleStorageUrl
      , mbReqPath = Just ("/" <> bucketName <> "/o/" <> objectName)
      , mbReqBody = Nothing
      , mbReqHeaders = Nothing
      , mbQueryParams = Nothing
      }

{- | Deletes a bucket.

@deleteBucket bucketName@ deletes the specified bucket. Note that the bucket must be empty
before it can be deleted. It returns either an error message or a unit value indicating success.

Example:

>>> deleteBucket "my-bucket"
Right ()
-}
deleteBucket :: String -> IO (Either String ())
deleteBucket bucketName =
  doRequestJSON
    RequestOptions
      { reqMethod = DELETE
      , reqUrl = googleStorageUrl
      , mbReqPath = Just ("/" <> bucketName)
      , mbReqBody = Nothing
      , mbReqHeaders = Nothing
      , mbQueryParams = Nothing
      }

{- | Downloads an object from a bucket to a local file.

@downloadObject bucketName objectName saveLocation@ returns the specified object in
lazy bytestring format. It returns either an error message or content indicating success.
Note: This function downloads the entire object at once; for large objects, consider
implementing streaming or slicing (planned for future versions).
Example:

>>> downloadObject "my-bucket" "my-object"
Right (bytestring content)
-}
downloadObject :: String -> String -> IO (Either String BSL.ByteString)
downloadObject bucketName objectName = go 0 mempty
  where
    chunkSize :: Int
    chunkSize = 1024 * 1024 -- 1 MB per chunk (tune as needed)
    go :: Int -> BSL.ByteString -> IO (Either String BSL.ByteString)
    go offset acc = do
      let rangeHeader =
            ( "Range"
            , BS.pack $
                "bytes=" <> show offset <> "-" <> show (offset + chunkSize - 1)
            )
          reqOpts =
            RequestOptions
              { reqMethod = GET
              , reqUrl = googleStorageUrl
              , mbReqPath = Just $ "/" <> bucketName <> "/o/" <> objectName
              , mbReqHeaders = Just [rangeHeader]
              , mbQueryParams = Just [("alt", Just "media")]
              , mbReqBody = Nothing
              }
      eBody <- doRequest reqOpts
      case eBody of
        Left err -> pure $ Left err
        Right body -> do
          let len = fromIntegral (BSL.length body)
          if len < chunkSize
            then pure $ Right (acc `BSL.append` body) -- last chunk
            else do
              print ("donwloded so far: " :: String, offset + chunkSize)
              go (offset + chunkSize) (acc `BSL.append` body)

--
-- @uploadObject bucketName objectName objectContent@ uploads the given content to the specified
-- object in the bucket. It returns either an error message or a unit value indicating success.
-- Note: This function uploads the entire object at once; for large objects, consider
-- implementing streaming or slicing (planned for future versions).
-- Example:
--
-- >>> uploadObject "my-bucket" "my-object" (BSL.pack "object content")
-- Right ()
uploadObject :: String -> String -> BSL.ByteString -> IO (Either String ())
uploadObject = resumableUploadObject

{- | Initiates a resumable upload session.

Returns the resumable session URL (from the Location header) which can be
used to upload chunks of the object.
-}
initResumableUpload :: String -> String -> IO (Either String String)
initResumableUpload bucketName objectName = do
  eResp <-
    doRequestRaw
      RequestOptions
        { reqMethod = POST
        , reqUrl = "https://storage.googleapis.com/upload/storage/v1/b"
        , mbReqPath = Just ("/" <> bucketName <> "/o")
        , mbReqBody = Nothing
        , mbReqHeaders = Just [("Content-Type", "application/json")]
        , mbQueryParams =
            Just
              [ ("uploadType", Just "resumable")
              , ("name", BS.pack <$> Just objectName)
              ]
        }
  case eResp of
    Left err -> pure $ Left err
    Right resp -> case getResponseHeader "location" resp of
      [] -> pure $ Left "Missing resumable upload URL in response"
      (loc : _) -> pure $ Right (BS.unpack loc)

{- | Uploads a file using a resumable upload session in chunks.

This function splits the objectContent into chunks and uploads sequentially.
-}
resumableUploadObject :: String -> String -> BSL.ByteString -> IO (Either String ())
resumableUploadObject bucketName objectName objectContent = do
  eSessionUrl <- initResumableUpload bucketName objectName
  case eSessionUrl of
    Left err -> pure $ Left err
    Right sessionUrl -> uploadChunks sessionUrl 0 (BSL.toStrict objectContent)
  where
    chunkSize :: Int
    chunkSize = 1024 * 1024 -- 1MB per chunk (tune as needed)
    uploadChunks :: String -> Int -> BS.ByteString -> IO (Either String ())
    uploadChunks sessionUrl offset bs
      | BS.null bs = pure $ Right ()
      | otherwise = do
          let (chunk, rest) = BS.splitAt chunkSize bs
              totalSize = offset + BS.length chunk + BS.length rest
              endByte = offset + BS.length chunk - 1
              isLast = BS.null rest
              contentRange =
                if isLast
                  then
                    "bytes "
                      <> show offset
                      <> "-"
                      <> show endByte
                      <> "/"
                      <> show totalSize
                  else "bytes " <> show offset <> "-" <> show endByte <> "/*"

              headers =
                [ ("Content-Length", BS.pack (show (BS.length chunk)))
                , ("Content-Range", BS.pack contentRange)
                ]

          eResp <-
            doRequestRaw
              RequestOptions
                { reqMethod = PUT
                , reqUrl = sessionUrl
                , mbReqPath = Nothing
                , mbReqBody = Just (BSL.fromStrict chunk)
                , mbReqHeaders = Just headers
                , mbQueryParams = Nothing
                }

          case eResp of
            Left err -> pure $ Left err
            Right resp -> do
              let status = getResponseStatus resp
              if status == status200
                then pure $ Right () -- Finished
                else
                  if status == status308
                    then uploadChunks sessionUrl (offset + BS.length chunk) rest -- Continue
                    else pure $ Left $ "Unexpected status: " <> show status

-- Helper types and functions (not exported, so no Haddock documentation needed)

data CreateBucketRequest = CreateBucketRequest
  { name :: String
  -- ^ Name of the bucket
  , location :: String
  -- ^ Location of bucket e.g us-central1-a
  , storageClass :: StorageClass
  -- ^ Storage Class
  }
  deriving (Show)

instance ToJSON CreateBucketRequest where
  toJSON (CreateBucketRequest name location storageClass) =
    object ["name" .= name, "location" .= location, "storageClass" .= show storageClass]

newtype UpdateStorageClassReq = UpdateStorageClassReq StorageClass
  deriving (Eq)

instance ToJSON UpdateStorageClassReq where
  toJSON (UpdateStorageClassReq storageClass) = object ["storageClass" .= show storageClass]

-- | GCP Storage Url
googleStorageUrl :: String
googleStorageUrl = "https://storage.googleapis.com/storage/v1/b"