packages feed

firebase-hs-0.1.0.0: src/Firebase/Firestore.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}

-- |
-- Module      : Firebase.Firestore
-- Description : Firestore REST API client
-- License     : MIT
--
-- CRUD operations, structured queries, and atomic transactions against the
-- Firestore REST API. All operations return @'Either' 'FirestoreError' a@ —
-- no exceptions are thrown for API-level errors.
--
-- @
-- import Firebase.Firestore
--
-- main :: IO ()
-- main = do
--   mgr <- newTlsManager
--   let pid = ProjectId \"my-project\"
--       tok = AccessToken \"ya29...\"
--       path = DocumentPath (CollectionPath \"users\") (DocumentId \"alice\")
--   result <- getDocument mgr tok pid path
--   case result of
--     Left err  -> print err
--     Right doc -> print (docFields doc)
-- @
module Firebase.Firestore
  ( -- * CRUD Operations
    getDocument,
    createDocument,
    updateDocument,
    deleteDocument,

    -- * Queries
    runQuery,

    -- * Transactions
    beginTransaction,
    commitTransaction,
    rollbackTransaction,
    runTransaction,

    -- * HTTP Manager
    newTlsManager,

    -- * Re-exports
    module Firebase.Firestore.Types,
    module Firebase.Firestore.Query,
  )
where

import Control.Exception (SomeException, try)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import Data.Map.Strict (Map)
import Data.Text (Text)
import qualified Data.Text as T
import Firebase.Firestore.Internal
import Firebase.Firestore.Query
import Firebase.Firestore.Types
import Network.HTTP.Client
  ( Manager,
    Request,
    RequestBody (..),
    Response,
    httpLbs,
    method,
    parseRequest,
    requestBody,
    requestHeaders,
    responseBody,
    responseStatus,
  )
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types.Status (statusCode)

-- ---------------------------------------------------------------------------
-- HTTP status code constants
-- ---------------------------------------------------------------------------

-- | HTTP 200 OK.
httpOk :: Int
httpOk = 200

-- | Upper bound for successful HTTP status codes (exclusive).
httpSuccessUpperBound :: Int
httpSuccessUpperBound = 300

-- ---------------------------------------------------------------------------
-- CRUD Operations
-- ---------------------------------------------------------------------------

-- | Fetch a single document by path.
getDocument ::
  Manager ->
  AccessToken ->
  ProjectId ->
  DocumentPath ->
  IO (Either FirestoreError Document)
getDocument mgr tok pid dp =
  fmap (>>= decodeDocument) (doGet mgr tok (documentUrl pid dp))

-- | Create a document with a specific ID in a collection.
createDocument ::
  Manager ->
  AccessToken ->
  ProjectId ->
  CollectionPath ->
  DocumentId ->
  Map Text FirestoreValue ->
  IO (Either FirestoreError Document)
createDocument mgr tok pid cp did fields =
  let body = Aeson.encode (Aeson.object ["fields" .= fields])
   in fmap (>>= decodeDocument) (doPost mgr tok (createDocUrl pid cp did) body)

-- | Update a document's fields. Pass field names to update specific fields,
-- or an empty list to replace all fields.
updateDocument ::
  Manager ->
  AccessToken ->
  ProjectId ->
  DocumentPath ->
  [Text] ->
  Map Text FirestoreValue ->
  IO (Either FirestoreError Document)
updateDocument mgr tok pid dp fieldPaths fields =
  let body = Aeson.encode (Aeson.object ["fields" .= fields])
   in fmap (>>= decodeDocument) (doPatch mgr tok (updateDocUrl pid dp fieldPaths) body)

-- | Delete a document by path.
deleteDocument ::
  Manager ->
  AccessToken ->
  ProjectId ->
  DocumentPath ->
  IO (Either FirestoreError ())
deleteDocument mgr tok pid dp =
  fmap (>> Right ()) (doRequest mgr tok (documentUrl pid dp) "DELETE" Nothing)

-- ---------------------------------------------------------------------------
-- Queries
-- ---------------------------------------------------------------------------

-- | Run a structured query against the Firestore REST API.
runQuery ::
  Manager ->
  AccessToken ->
  ProjectId ->
  StructuredQuery ->
  IO (Either FirestoreError [Document])
runQuery mgr tok pid sq =
  let body = Aeson.encode (encodeQuery sq)
   in fmap (>>= decodeQueryResults) (doPost mgr tok (queryUrl pid) body)

-- ---------------------------------------------------------------------------
-- Transactions
-- ---------------------------------------------------------------------------

-- | Begin a new transaction.
beginTransaction ::
  Manager ->
  AccessToken ->
  ProjectId ->
  TransactionMode ->
  IO (Either FirestoreError TransactionId)
beginTransaction mgr tok pid mode =
  let body = Aeson.encode (encodeTransactionOptions mode)
   in fmap (>>= decodeTransactionId) (doPost mgr tok (beginTransactionUrl pid) body)

-- | Commit a transaction with a list of write operations.
commitTransaction ::
  Manager ->
  AccessToken ->
  ProjectId ->
  TransactionId ->
  [Aeson.Value] ->
  IO (Either FirestoreError ())
commitTransaction mgr tok pid (TransactionId txnId) writes =
  let body =
        Aeson.encode
          (Aeson.object ["writes" .= writes, "transaction" .= txnId])
   in fmap (>> Right ()) (doPost mgr tok (commitUrl pid) body)

-- | Roll back a transaction without committing.
rollbackTransaction ::
  Manager ->
  AccessToken ->
  ProjectId ->
  TransactionId ->
  IO (Either FirestoreError ())
rollbackTransaction mgr tok pid (TransactionId txnId) =
  let body = Aeson.encode (Aeson.object ["transaction" .= txnId])
   in fmap (>> Right ()) (doPost mgr tok (rollbackUrl pid) body)

-- | Run an atomic transaction. The callback receives the transaction ID
-- and should return a list of writes to commit.
--
-- On success, all writes are applied atomically. On failure (including
-- callback errors), the transaction is rolled back automatically.
-- Use 'RetryWith' to retry an aborted transaction.
--
-- @
-- runTransaction mgr tok pid ReadWrite $ \\txnId -> runExceptT $ do
--   doc <- ExceptT $ getDocument mgr tok pid somePath
--   pure [mkUpdateWrite somePath (docFields doc)]
-- @
runTransaction ::
  Manager ->
  AccessToken ->
  ProjectId ->
  TransactionMode ->
  (TransactionId -> IO (Either FirestoreError [Aeson.Value])) ->
  IO (Either FirestoreError ())
runTransaction mgr tok pid mode action = do
  txnResult <- beginTransaction mgr tok pid mode
  case txnResult of
    Left err -> pure (Left err)
    Right txnId -> do
      result <- runExceptT $ do
        writes <- ExceptT $ action txnId
        ExceptT $ commitTransaction mgr tok pid txnId writes
      case result of
        Right () -> pure (Right ())
        Left err -> do
          _ <- rollbackTransaction mgr tok pid txnId
          pure (Left err)

-- ---------------------------------------------------------------------------
-- HTTP Helpers
-- ---------------------------------------------------------------------------

-- | Perform an authorized GET request.
doGet ::
  Manager -> AccessToken -> String -> IO (Either FirestoreError LBS.ByteString)
doGet mgr tok url = doRequest mgr tok url "GET" Nothing

-- | Perform an authorized POST request with a JSON body.
doPost ::
  Manager ->
  AccessToken ->
  String ->
  LBS.ByteString ->
  IO (Either FirestoreError LBS.ByteString)
doPost mgr tok url body = doRequest mgr tok url "POST" (Just body)

-- | Perform an authorized PATCH request with a JSON body.
doPatch ::
  Manager ->
  AccessToken ->
  String ->
  LBS.ByteString ->
  IO (Either FirestoreError LBS.ByteString)
doPatch mgr tok url body = doRequest mgr tok url "PATCH" (Just body)

-- | Core HTTP request executor.
doRequest ::
  Manager ->
  AccessToken ->
  String ->
  BS.ByteString ->
  Maybe LBS.ByteString ->
  IO (Either FirestoreError LBS.ByteString)
doRequest mgr tok url httpMethod mBody = do
  reqResult <- try (parseRequest url) :: IO (Either SomeException Request)
  case reqResult of
    Left err -> pure (Left (NetworkError (T.pack (show err))))
    Right baseReq -> do
      let req =
            authorizeRequest tok $
              baseReq
                { method = httpMethod,
                  requestHeaders =
                    ("Content-Type", "application/json")
                      : requestHeaders baseReq,
                  requestBody = maybe mempty RequestBodyLBS mBody
                }
      respResult <-
        try (httpLbs req mgr) ::
          IO (Either SomeException (Response LBS.ByteString))
      case respResult of
        Left err -> pure (Left (NetworkError (T.pack (show err))))
        Right resp ->
          let status = statusCode (responseStatus resp)
              body = responseBody resp
           in if status >= httpOk && status < httpSuccessUpperBound
                then pure (Right body)
                else pure (Left (parseFirestoreError status body))

-- ---------------------------------------------------------------------------
-- Response Decoders (pure)
-- ---------------------------------------------------------------------------

-- | Decode a response body as a 'Document'.
decodeDocument :: LBS.ByteString -> Either FirestoreError Document
decodeDocument body = case Aeson.eitherDecode body of
  Left err -> Left (InvalidResponse (T.pack err))
  Right doc -> Right doc

-- | Decode a query response (array of @{\"document\": ...}@ objects).
decodeQueryResults :: LBS.ByteString -> Either FirestoreError [Document]
decodeQueryResults body = case Aeson.eitherDecode body of
  Left err -> Left (InvalidResponse (T.pack err))
  Right results -> Right (extractDocuments results)

-- | Extract documents from query result objects, skipping entries without
-- a @\"document\"@ field (e.g. the final @\"readTime\"@-only entry).
extractDocuments :: [Aeson.Value] -> [Document]
extractDocuments = concatMap go
  where
    go (Aeson.Object o) = case KM.lookup "document" o of
      Just v -> case Aeson.fromJSON v of
        Aeson.Success doc -> [doc]
        _ -> []
      Nothing -> []
    go _ = []

-- | Decode a beginTransaction response to extract the transaction ID.
decodeTransactionId :: LBS.ByteString -> Either FirestoreError TransactionId
decodeTransactionId body = case Aeson.eitherDecode body of
  Left err -> Left (InvalidResponse (T.pack err))
  Right val -> case extractTxnId val of
    Just txnId -> Right txnId
    Nothing -> Left (InvalidResponse "missing transaction field")
  where
    extractTxnId (Aeson.Object o) = case KM.lookup "transaction" o of
      Just (Aeson.String t) -> Just (TransactionId t)
      _ -> Nothing
    extractTxnId _ = Nothing