firebase-hs-0.1.0.0: src/Firebase/Firestore/Internal.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
-- |
-- Module : Firebase.Firestore.Internal
-- Description : Pure URL builders and request helpers for Firestore
-- License : MIT
--
-- Internal utilities for constructing Firestore REST API URLs and requests.
-- All functions are pure and testable without IO.
module Firebase.Firestore.Internal
( -- * Constants
firestoreBaseUrl,
-- * URL Construction
documentUrl,
collectionUrl,
createDocUrl,
updateDocUrl,
queryUrl,
beginTransactionUrl,
commitUrl,
rollbackUrl,
-- * Request Helpers
authorizeRequest,
-- * Response Parsing
parseFirestoreError,
)
where
import Data.Aeson ((.:), (.:?))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KM
import Data.Aeson.Types (parseMaybe)
import qualified Data.ByteString.Lazy as LBS
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Firebase.Firestore.Types
import Network.HTTP.Client (Request, requestHeaders)
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
-- | Firestore REST API base URL.
firestoreBaseUrl :: String
firestoreBaseUrl = "https://firestore.googleapis.com/v1"
-- | Firestore database path segment.
firestoreDatabasePath :: String
firestoreDatabasePath = "/databases/(default)/documents"
-- ---------------------------------------------------------------------------
-- URL Construction
-- ---------------------------------------------------------------------------
-- | URL for a specific document.
--
-- >>> documentUrl (ProjectId "p") (DocumentPath (CollectionPath "c") (DocumentId "d"))
-- "https://firestore.googleapis.com/v1/projects/p/databases/(default)/documents/c/d"
documentUrl :: ProjectId -> DocumentPath -> String
documentUrl pid dp =
firestoreBaseUrl
<> "/projects/"
<> T.unpack (unProjectId pid)
<> firestoreDatabasePath
<> "/"
<> T.unpack (unCollectionPath (dpCollection dp))
<> "/"
<> T.unpack (unDocumentId (dpDocument dp))
-- | URL for a collection (used for listing).
--
-- >>> collectionUrl (ProjectId "p") (CollectionPath "users")
-- "https://firestore.googleapis.com/v1/projects/p/databases/(default)/documents/users"
collectionUrl :: ProjectId -> CollectionPath -> String
collectionUrl pid cp =
firestoreBaseUrl
<> "/projects/"
<> T.unpack (unProjectId pid)
<> firestoreDatabasePath
<> "/"
<> T.unpack (unCollectionPath cp)
-- | URL for creating a document with a specific ID.
--
-- The document ID is passed as a query parameter.
createDocUrl :: ProjectId -> CollectionPath -> DocumentId -> String
createDocUrl pid cp did =
collectionUrl pid cp
<> "?documentId="
<> T.unpack (unDocumentId did)
-- | URL for updating a document with an optional field mask.
--
-- Empty field list means update all fields.
updateDocUrl :: ProjectId -> DocumentPath -> [Text] -> String
updateDocUrl pid dp fields =
documentUrl pid dp <> fieldMaskParams fields
-- | URL for running a structured query.
queryUrl :: ProjectId -> String
queryUrl pid =
firestoreBaseUrl
<> "/projects/"
<> T.unpack (unProjectId pid)
<> firestoreDatabasePath
<> ":runQuery"
-- | URL for beginning a transaction.
beginTransactionUrl :: ProjectId -> String
beginTransactionUrl pid =
firestoreBaseUrl
<> "/projects/"
<> T.unpack (unProjectId pid)
<> firestoreDatabasePath
<> ":beginTransaction"
-- | URL for committing a transaction.
commitUrl :: ProjectId -> String
commitUrl pid =
firestoreBaseUrl
<> "/projects/"
<> T.unpack (unProjectId pid)
<> firestoreDatabasePath
<> ":commit"
-- | URL for rolling back a transaction.
rollbackUrl :: ProjectId -> String
rollbackUrl pid =
firestoreBaseUrl
<> "/projects/"
<> T.unpack (unProjectId pid)
<> firestoreDatabasePath
<> ":rollback"
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
-- | Build @updateMask.fieldPaths@ query parameters.
fieldMaskParams :: [Text] -> String
fieldMaskParams [] = ""
fieldMaskParams fs =
"?" <> intercalateAmp ["updateMask.fieldPaths=" <> T.unpack f | f <- fs]
where
intercalateAmp [] = ""
intercalateAmp [x] = x
intercalateAmp (x : xs) = x <> "&" <> intercalateAmp xs
-- ---------------------------------------------------------------------------
-- Request Helpers
-- ---------------------------------------------------------------------------
-- | Add an OAuth2 Bearer token to a request's Authorization header.
authorizeRequest :: AccessToken -> Request -> Request
authorizeRequest (AccessToken tok) req =
req {requestHeaders = ("Authorization", "Bearer " <> tok) : requestHeaders req}
-- ---------------------------------------------------------------------------
-- Response Parsing
-- ---------------------------------------------------------------------------
-- | Parse an HTTP error response body into a 'FirestoreError'.
--
-- Firestore returns errors in the form:
--
-- @
-- { "error": { "code": 404, "message": "...", "status": "NOT_FOUND" } }
-- @
parseFirestoreError :: Int -> LBS.ByteString -> FirestoreError
parseFirestoreError status body =
case Aeson.decode body of
Just errObj -> parseErrorObject status errObj
Nothing -> NetworkError ("HTTP " <> T.pack (show status))
-- | Parse a decoded Firestore error JSON object.
parseErrorObject :: Int -> Aeson.Value -> FirestoreError
parseErrorObject status = go
where
go (Aeson.Object o) =
case Aeson.fromJSON <$> KM.lookup "error" o of
Just (Aeson.Success errInner) -> parseErrorInner status errInner
_ -> fallback
go _ = fallback
fallback = FirestoreApiError status "" "unknown error"
-- | Parse the inner @\"error\"@ object.
parseErrorInner :: Int -> Aeson.Value -> FirestoreError
parseErrorInner status = go
where
go val = case parseMaybe parseInner val of
Just (code, grpcStatus, msg) -> classifyError code grpcStatus msg
Nothing -> FirestoreApiError status "" "unparseable error"
parseInner = Aeson.withObject "error" $ \o ->
(,,) <$> o .: "code" <*> o .:? "status" <*> o .:? "message"
-- | Classify a Firestore error by HTTP code and gRPC status.
classifyError :: Int -> Maybe Text -> Maybe Text -> FirestoreError
classifyError 404 _ _ = DocumentNotFound
classifyError 403 _ msg = PermissionDenied (fromMaybe "" msg)
classifyError 409 (Just "ABORTED") msg = TransactionAborted (fromMaybe "" msg)
classifyError code grpcStatus msg =
FirestoreApiError code (fromMaybe "" grpcStatus) (fromMaybe "" msg)