Close endpoint failure types across service and web boundaries
This commit is contained in:
parent
fb94ea2c23
commit
390434bed1
14 changed files with 266 additions and 72 deletions
|
|
@ -51,6 +51,9 @@ non-success decoding policy.
|
||||||
Upload requests stream `application/octet-stream`, require
|
Upload requests stream `application/octet-stream`, require
|
||||||
`X-Declared-Size`, `X-Content-SHA256`, `X-CSRF-Token`, and an operation id, and
|
`X-Declared-Size`, `X-Content-SHA256`, `X-CSRF-Token`, and an operation id, and
|
||||||
return a typed mutation result with the advertised terminal idempotency window.
|
return a typed mutation result with the advertised terminal idempotency window.
|
||||||
|
Each delete-batch item contains exactly one typed outcome: a mutation result or
|
||||||
|
a closed dependency-failure code. Producers and consumers reject items with
|
||||||
|
both outcomes or neither outcome.
|
||||||
Downloads stream through Nuthatch, reject `Range`, use a safe attachment
|
Downloads stream through Nuthatch, reject `Range`, use a safe attachment
|
||||||
filename, and set active-content isolation headers. The compiled application
|
filename, and set active-content isolation headers. The compiled application
|
||||||
page governs its assets with the application content-security policy, and
|
page governs its assets with the application content-security policy, and
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ module Nuthatch.Api
|
||||||
, DeletePlanResponse (..)
|
, DeletePlanResponse (..)
|
||||||
, DeleteBatchItem (..)
|
, DeleteBatchItem (..)
|
||||||
, DeleteBatchRequest (..)
|
, DeleteBatchRequest (..)
|
||||||
|
, DeleteItemFailure (..)
|
||||||
|
, DeleteItemOutcome (..)
|
||||||
, DeleteItemResponse (..)
|
, DeleteItemResponse (..)
|
||||||
, DeleteBatchResponse (..)
|
, DeleteBatchResponse (..)
|
||||||
, UploadResponse (..)
|
, UploadResponse (..)
|
||||||
|
|
@ -54,6 +56,7 @@ import Data.Aeson
|
||||||
, Value
|
, Value
|
||||||
, object
|
, object
|
||||||
, withObject
|
, withObject
|
||||||
|
, withText
|
||||||
, (.:)
|
, (.:)
|
||||||
, (.=)
|
, (.=)
|
||||||
)
|
)
|
||||||
|
|
@ -450,29 +453,62 @@ instance ToJSON DeleteBatchRequest where
|
||||||
, "items" .= deleteBatchItems request
|
, "items" .= deleteBatchItems request
|
||||||
]
|
]
|
||||||
|
|
||||||
|
data DeleteItemFailure
|
||||||
|
= DeleteItemDatabaseUnavailable
|
||||||
|
| DeleteItemObjectStoreUnavailable
|
||||||
|
deriving stock (Eq, Generic, Show)
|
||||||
|
|
||||||
|
instance ToJSON DeleteItemFailure where
|
||||||
|
toJSON DeleteItemDatabaseUnavailable = "database_unavailable"
|
||||||
|
toJSON DeleteItemObjectStoreUnavailable = "object_store_unavailable"
|
||||||
|
|
||||||
|
instance FromJSON DeleteItemFailure where
|
||||||
|
parseJSON = withText "DeleteItemFailure" $ \label ->
|
||||||
|
case label of
|
||||||
|
"database_unavailable" -> pure DeleteItemDatabaseUnavailable
|
||||||
|
"object_store_unavailable" -> pure DeleteItemObjectStoreUnavailable
|
||||||
|
_ -> fail "invalid delete item failure"
|
||||||
|
|
||||||
|
data DeleteItemOutcome
|
||||||
|
= DeleteItemCompleted MutationResult
|
||||||
|
| DeleteItemFailed DeleteItemFailure
|
||||||
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
data DeleteItemResponse = DeleteItemResponse
|
data DeleteItemResponse = DeleteItemResponse
|
||||||
{ deleteItemPath :: RelativePath
|
{ deleteItemPath :: RelativePath
|
||||||
, deleteItemOperationId :: OperationId
|
, deleteItemOperationId :: OperationId
|
||||||
, deleteItemResult :: Maybe MutationResult
|
, deleteItemOutcome :: DeleteItemOutcome
|
||||||
, deleteItemError :: Maybe Text
|
|
||||||
}
|
}
|
||||||
deriving stock (Eq, Show)
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
instance ToJSON DeleteItemResponse where
|
instance ToJSON DeleteItemResponse where
|
||||||
toJSON response = object
|
toJSON response =
|
||||||
[ "path" .= unRelativePath (deleteItemPath response)
|
let (result, failure) =
|
||||||
, "operation_id" .= deleteItemOperationId response
|
case deleteItemOutcome response of
|
||||||
, "result" .= deleteItemResult response
|
DeleteItemCompleted value -> (Just value, Nothing)
|
||||||
, "error" .= deleteItemError response
|
DeleteItemFailed value -> (Nothing, Just value)
|
||||||
]
|
in object
|
||||||
|
[ "path" .= unRelativePath (deleteItemPath response)
|
||||||
|
, "operation_id" .= deleteItemOperationId response
|
||||||
|
, "result" .= result
|
||||||
|
, "error" .= failure
|
||||||
|
]
|
||||||
|
|
||||||
instance FromJSON DeleteItemResponse where
|
instance FromJSON DeleteItemResponse where
|
||||||
parseJSON = withObject "DeleteItemResponse" $ \o ->
|
parseJSON = withObject "DeleteItemResponse" $ \o -> do
|
||||||
DeleteItemResponse
|
path <- o .: "path"
|
||||||
<$> o .: "path"
|
operation <- o .: "operation_id"
|
||||||
<*> o .: "operation_id"
|
result <- o .: "result"
|
||||||
<*> o .: "result"
|
failure <- o .: "error"
|
||||||
<*> o .: "error"
|
outcome <- case (result, failure) of
|
||||||
|
(Just value, Nothing) -> pure (DeleteItemCompleted value)
|
||||||
|
(Nothing, Just value) -> pure (DeleteItemFailed value)
|
||||||
|
_ -> fail "delete item response must contain exactly one outcome"
|
||||||
|
pure DeleteItemResponse
|
||||||
|
{ deleteItemPath = path
|
||||||
|
, deleteItemOperationId = operation
|
||||||
|
, deleteItemOutcome = outcome
|
||||||
|
}
|
||||||
|
|
||||||
newtype DeleteBatchResponse = DeleteBatchResponse
|
newtype DeleteBatchResponse = DeleteBatchResponse
|
||||||
{ deleteBatchResults :: [DeleteItemResponse]
|
{ deleteBatchResults :: [DeleteItemResponse]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ module Nuthatch.Browse
|
||||||
import Control.Monad (when)
|
import Control.Monad (when)
|
||||||
import Control.Monad.IO.Class (liftIO)
|
import Control.Monad.IO.Class (liftIO)
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
import Data.Bifunctor (first)
|
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
@ -26,6 +25,7 @@ import Nuthatch.Redis
|
||||||
)
|
)
|
||||||
import Nuthatch.S3
|
import Nuthatch.S3
|
||||||
( ListPage (..)
|
( ListPage (..)
|
||||||
|
, S3Error
|
||||||
, listObjects
|
, listObjects
|
||||||
, renderS3Error
|
, renderS3Error
|
||||||
)
|
)
|
||||||
|
|
@ -58,7 +58,9 @@ handleBrowseRequest env session selectedRoot selectedPrefix search continuation
|
||||||
found <- searchEntries env user root (unSearchQuery query)
|
found <- searchEntries env user root (unSearchQuery query)
|
||||||
pure (fmap (\(entries, partial) -> (entries, Nothing, partial)) found)
|
pure (fmap (\(entries, partial) -> (entries, Nothing, partial)) found)
|
||||||
(entries, nextContinuation, partial) <- either
|
(entries, nextContinuation, partial) <- either
|
||||||
(throwOperationError status502Error . BrowseObjectStoreFailed)
|
(throwOperationError status502Error
|
||||||
|
. BrowseObjectStoreFailed
|
||||||
|
. renderS3Error)
|
||||||
pure
|
pure
|
||||||
result
|
result
|
||||||
pure BrowseResponse
|
pure BrowseResponse
|
||||||
|
|
@ -117,7 +119,7 @@ listEntries
|
||||||
-> Root
|
-> Root
|
||||||
-> Text
|
-> Text
|
||||||
-> Maybe Text
|
-> Maybe Text
|
||||||
-> IO (Either Text ([ListEntry], Maybe Text))
|
-> IO (Either S3Error ([ListEntry], Maybe Text))
|
||||||
listEntries env user root prefix continuation = do
|
listEntries env user root prefix continuation = do
|
||||||
generation <- cacheGeneration (envRedis env) (suUserId user) root
|
generation <- cacheGeneration (envRedis env) (suUserId user) root
|
||||||
let cacheOwner = case root of
|
let cacheOwner = case root of
|
||||||
|
|
@ -155,7 +157,7 @@ listEntries env user root prefix continuation = do
|
||||||
continuation
|
continuation
|
||||||
(cfgBrowsePageSize (cfgProduct (envConfig env)))
|
(cfgBrowsePageSize (cfgProduct (envConfig env)))
|
||||||
case page of
|
case page of
|
||||||
Left err -> pure (Left (renderS3Error err))
|
Left err -> pure (Left err)
|
||||||
Right listed -> do
|
Right listed -> do
|
||||||
let rootPrefix =
|
let rootPrefix =
|
||||||
case root of
|
case root of
|
||||||
|
|
@ -200,7 +202,7 @@ searchEntries
|
||||||
-> SessionUser
|
-> SessionUser
|
||||||
-> Root
|
-> Root
|
||||||
-> Text
|
-> Text
|
||||||
-> IO (Either Text ([ListEntry], Bool))
|
-> IO (Either S3Error ([ListEntry], Bool))
|
||||||
searchEntries env user root query = do
|
searchEntries env user root query = do
|
||||||
let s3Prefix =
|
let s3Prefix =
|
||||||
case root of
|
case root of
|
||||||
|
|
@ -239,20 +241,19 @@ collectKeysBounded
|
||||||
-> Text
|
-> Text
|
||||||
-> Int
|
-> Int
|
||||||
-> Word64
|
-> Word64
|
||||||
-> IO (Either Text ([(Text, Integer)], Bool))
|
-> IO (Either S3Error ([(Text, Integer)], Bool))
|
||||||
collectKeysBounded env prefix =
|
collectKeysBounded env prefix =
|
||||||
collectKeysBoundedWith
|
collectKeysBoundedWith
|
||||||
getMonotonicTimeNSec
|
getMonotonicTimeNSec
|
||||||
(\continuation limit ->
|
(\continuation limit ->
|
||||||
first renderS3Error
|
listObjects (envS3 env) prefix Nothing continuation limit)
|
||||||
<$> listObjects (envS3 env) prefix Nothing continuation limit)
|
|
||||||
|
|
||||||
collectKeysBoundedWith
|
collectKeysBoundedWith
|
||||||
:: IO Word64
|
:: IO Word64
|
||||||
-> (Maybe Text -> Int -> IO (Either Text ListPage))
|
-> (Maybe Text -> Int -> IO (Either failure ListPage))
|
||||||
-> Int
|
-> Int
|
||||||
-> Word64
|
-> Word64
|
||||||
-> IO (Either Text ([(Text, Integer)], Bool))
|
-> IO (Either failure ([(Text, Integer)], Bool))
|
||||||
collectKeysBoundedWith currentTime listPage maxKeys deadline =
|
collectKeysBoundedWith currentTime listPage maxKeys deadline =
|
||||||
go Nothing [] 0
|
go Nothing [] 0
|
||||||
where
|
where
|
||||||
|
|
|
||||||
|
|
@ -186,14 +186,41 @@ instance TypeScriptType DeleteBatchRequest where
|
||||||
, property "items" (info @[DeleteBatchItem])
|
, property "items" (info @[DeleteBatchItem])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
instance TypeScriptType DeleteItemFailure where
|
||||||
|
typeScriptInfo _ =
|
||||||
|
genericStringEnum @DeleteItemFailure "DeleteItemFailure"
|
||||||
|
|
||||||
instance TypeScriptType DeleteItemResponse where
|
instance TypeScriptType DeleteItemResponse where
|
||||||
typeScriptInfo _ =
|
typeScriptInfo _ =
|
||||||
interface "DeleteItemResponse"
|
TypeScriptInfo
|
||||||
[ property "path" (info @RelativePath)
|
"DeleteItemResponse"
|
||||||
, property "operation_id" (info @OperationId)
|
( concatMap tsiDefinitions
|
||||||
, property "result" (info @(Maybe MutationResult))
|
[ info @RelativePath
|
||||||
, property "error" (info @(Maybe Text))
|
, info @OperationId
|
||||||
]
|
, info @MutationResult
|
||||||
|
, info @DeleteItemFailure
|
||||||
|
]
|
||||||
|
<> [ T.unlines
|
||||||
|
[ "export type DeleteItemResponse ="
|
||||||
|
, " | {"
|
||||||
|
, " path: RelativePath;"
|
||||||
|
, " operation_id: OperationId;"
|
||||||
|
, " result: MutationResult;"
|
||||||
|
, " error: null;"
|
||||||
|
, " }"
|
||||||
|
, " | {"
|
||||||
|
, " path: RelativePath;"
|
||||||
|
, " operation_id: OperationId;"
|
||||||
|
, " result: null;"
|
||||||
|
, " error: DeleteItemFailure;"
|
||||||
|
, " };"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
(\value ->
|
||||||
|
case A.fromJSON value :: A.Result DeleteItemResponse of
|
||||||
|
A.Success _ -> True
|
||||||
|
A.Error _ -> False)
|
||||||
|
|
||||||
instance TypeScriptType DeleteBatchResponse where
|
instance TypeScriptType DeleteBatchResponse where
|
||||||
typeScriptInfo _ =
|
typeScriptInfo _ =
|
||||||
|
|
@ -639,8 +666,7 @@ renderContractFixtures =
|
||||||
itemResponse = DeleteItemResponse
|
itemResponse = DeleteItemResponse
|
||||||
{ deleteItemPath = path
|
{ deleteItemPath = path
|
||||||
, deleteItemOperationId = operation
|
, deleteItemOperationId = operation
|
||||||
, deleteItemResult = Just MutationSucceeded
|
, deleteItemOutcome = DeleteItemCompleted MutationSucceeded
|
||||||
, deleteItemError = Nothing
|
|
||||||
}
|
}
|
||||||
fixture = A.object
|
fixture = A.object
|
||||||
[ "session_response" A..= SessionResponse
|
[ "session_response" A..= SessionResponse
|
||||||
|
|
@ -735,8 +761,14 @@ wireShapeSamples =
|
||||||
, samples @DeleteBatchRequest "DeleteBatchRequest"
|
, samples @DeleteBatchRequest "DeleteBatchRequest"
|
||||||
[DeleteBatchRequest Shared csrf [batchItem]]
|
[DeleteBatchRequest Shared csrf [batchItem]]
|
||||||
, samples @DeleteItemResponse "DeleteItemResponse"
|
, samples @DeleteItemResponse "DeleteItemResponse"
|
||||||
[ DeleteItemResponse path operation (Just MutationSucceeded) Nothing
|
[ DeleteItemResponse
|
||||||
, DeleteItemResponse path operation Nothing (Just "failed")
|
path
|
||||||
|
operation
|
||||||
|
(DeleteItemCompleted MutationSucceeded)
|
||||||
|
, DeleteItemResponse
|
||||||
|
path
|
||||||
|
operation
|
||||||
|
(DeleteItemFailed DeleteItemDatabaseUnavailable)
|
||||||
]
|
]
|
||||||
, samples @DeleteBatchResponse "DeleteBatchResponse"
|
, samples @DeleteBatchResponse "DeleteBatchResponse"
|
||||||
[DeleteBatchResponse [itemResponse]]
|
[DeleteBatchResponse [itemResponse]]
|
||||||
|
|
@ -750,7 +782,10 @@ wireShapeSamples =
|
||||||
entry = EntryResponse "file.txt" False (Just 7) "folder/file.txt"
|
entry = EntryResponse "file.txt" False (Just 7) "folder/file.txt"
|
||||||
batchItem = DeleteBatchItem path operation
|
batchItem = DeleteBatchItem path operation
|
||||||
itemResponse =
|
itemResponse =
|
||||||
DeleteItemResponse path operation (Just MutationSucceeded) Nothing
|
DeleteItemResponse
|
||||||
|
path
|
||||||
|
operation
|
||||||
|
(DeleteItemCompleted MutationSucceeded)
|
||||||
samples
|
samples
|
||||||
:: forall value
|
:: forall value
|
||||||
. (TypeScriptType value, A.ToJSON value)
|
. (TypeScriptType value, A.ToJSON value)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import Nuthatch.Http.Error
|
||||||
import Nuthatch.Mutation.Effects
|
import Nuthatch.Mutation.Effects
|
||||||
import Nuthatch.Mutation.Service
|
import Nuthatch.Mutation.Service
|
||||||
import Nuthatch.Recovery (trySynchronous)
|
import Nuthatch.Recovery (trySynchronous)
|
||||||
|
import Nuthatch.S3 (renderS3Error)
|
||||||
import Nuthatch.Transfer
|
import Nuthatch.Transfer
|
||||||
import Nuthatch.Types
|
import Nuthatch.Types
|
||||||
import Servant
|
import Servant
|
||||||
|
|
@ -173,14 +174,15 @@ handleDeleteRequest env session request = do
|
||||||
(DeleteMutationRejected "database unavailable")
|
(DeleteMutationRejected "database unavailable")
|
||||||
Right value -> pure value
|
Right value -> pure value
|
||||||
result <- case outcome of
|
result <- case outcome of
|
||||||
Left reason -> do
|
Left failure -> do
|
||||||
liftIO $ audit "delete" "failed"
|
liftIO $ audit "delete" "failed"
|
||||||
(Just (unUserId (suUserId user)))
|
(Just (unUserId (suUserId user)))
|
||||||
([ ("root", A.toJSON (rootLabel root))
|
([ ("root", A.toJSON (rootLabel root))
|
||||||
, ("operation_id", A.toJSON (unOperationId operation))
|
, ("operation_id", A.toJSON (unOperationId operation))
|
||||||
, ("reason", A.String "object_store_unavailable")
|
, ("reason", A.String "object_store_unavailable")
|
||||||
] <> objectKeyAudit (cfgProduct (envConfig env)) (deletePath request))
|
] <> objectKeyAudit (cfgProduct (envConfig env)) (deletePath request))
|
||||||
throwOperationError err409 (DeleteMutationRejected reason)
|
throwOperationError err409
|
||||||
|
(DeleteMutationRejected (deleteFailureMessage failure))
|
||||||
Right value -> pure value
|
Right value -> pure value
|
||||||
liftIO $ audit "delete" (mutationAuditResult result)
|
liftIO $ audit "delete" (mutationAuditResult result)
|
||||||
(Just (unUserId (suUserId user)))
|
(Just (unUserId (suUserId user)))
|
||||||
|
|
@ -224,33 +226,39 @@ handleDeleteBatchRequest env session request = do
|
||||||
(deleteBatchPath item)
|
(deleteBatchPath item)
|
||||||
(deleteBatchOperationId item)
|
(deleteBatchOperationId item)
|
||||||
let outcome = case deleteAttempt of
|
let outcome = case deleteAttempt of
|
||||||
Left _ -> Left "database unavailable"
|
Left _ -> Left DeleteItemDatabaseUnavailable
|
||||||
Right value -> value
|
Right (Left _) -> Left DeleteItemObjectStoreUnavailable
|
||||||
failureReason = case deleteAttempt of
|
Right (Right result) -> Right result
|
||||||
Left _ -> "database_unavailable"
|
|
||||||
Right (Left _) -> "object_store_unavailable"
|
|
||||||
Right (Right _) -> ""
|
|
||||||
liftIO $ audit "delete" (either (const "failed") mutationAuditResult outcome)
|
liftIO $ audit "delete" (either (const "failed") mutationAuditResult outcome)
|
||||||
(Just (unUserId (suUserId (sessionUser session))))
|
(Just (unUserId (suUserId (sessionUser session))))
|
||||||
([ ("root", A.toJSON (rootLabel (deleteBatchRoot request)))
|
([ ("root", A.toJSON (rootLabel (deleteBatchRoot request)))
|
||||||
, ("operation_id", A.toJSON
|
, ("operation_id", A.toJSON
|
||||||
(unOperationId (deleteBatchOperationId item)))
|
(unOperationId (deleteBatchOperationId item)))
|
||||||
] <> either
|
] <> either
|
||||||
(const [("reason", A.String failureReason)])
|
(\failure ->
|
||||||
|
[("reason", A.String (deleteItemFailureReason failure))])
|
||||||
mutationReasonFields
|
mutationReasonFields
|
||||||
outcome
|
outcome
|
||||||
<> objectKeyAudit (cfgProduct (envConfig env)) (deleteBatchPath item))
|
<> objectKeyAudit (cfgProduct (envConfig env)) (deleteBatchPath item))
|
||||||
pure $ case outcome of
|
pure $ case outcome of
|
||||||
Left message -> DeleteItemResponse
|
Left failure -> DeleteItemResponse
|
||||||
{ deleteItemPath = deleteBatchPath item
|
{ deleteItemPath = deleteBatchPath item
|
||||||
, deleteItemOperationId = deleteBatchOperationId item
|
, deleteItemOperationId = deleteBatchOperationId item
|
||||||
, deleteItemResult = Nothing
|
, deleteItemOutcome = DeleteItemFailed failure
|
||||||
, deleteItemError = Just message
|
|
||||||
}
|
}
|
||||||
Right result -> DeleteItemResponse
|
Right result -> DeleteItemResponse
|
||||||
{ deleteItemPath = deleteBatchPath item
|
{ deleteItemPath = deleteBatchPath item
|
||||||
, deleteItemOperationId = deleteBatchOperationId item
|
, deleteItemOperationId = deleteBatchOperationId item
|
||||||
, deleteItemResult = Just result
|
, deleteItemOutcome = DeleteItemCompleted result
|
||||||
, deleteItemError = Nothing
|
|
||||||
}
|
}
|
||||||
pure (DeleteBatchResponse results)
|
pure (DeleteBatchResponse results)
|
||||||
|
|
||||||
|
deleteFailureMessage :: DeleteFailure -> Text
|
||||||
|
deleteFailureMessage (DeleteDispatchFailed failure) =
|
||||||
|
renderS3Error failure
|
||||||
|
|
||||||
|
deleteItemFailureReason :: DeleteItemFailure -> Text
|
||||||
|
deleteItemFailureReason DeleteItemDatabaseUnavailable =
|
||||||
|
"database_unavailable"
|
||||||
|
deleteItemFailureReason DeleteItemObjectStoreUnavailable =
|
||||||
|
"object_store_unavailable"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
module Nuthatch.Mutation.Service
|
module Nuthatch.Mutation.Service
|
||||||
( PutFailure (..)
|
( PutFailure (..)
|
||||||
|
, DeleteFailure (..)
|
||||||
, runPut
|
, runPut
|
||||||
, runDelete
|
, runDelete
|
||||||
, dispatchAndFinalize
|
, dispatchAndFinalize
|
||||||
|
|
@ -13,7 +14,6 @@ import Control.Exception
|
||||||
, fromException
|
, fromException
|
||||||
, tryJust
|
, tryJust
|
||||||
)
|
)
|
||||||
import Data.Text (Text)
|
|
||||||
import Database.PostgreSQL.Simple (SqlError)
|
import Database.PostgreSQL.Simple (SqlError)
|
||||||
import Nuthatch.App.Env
|
import Nuthatch.App.Env
|
||||||
import Nuthatch.Config
|
import Nuthatch.Config
|
||||||
|
|
@ -97,13 +97,17 @@ newtype PutFailure
|
||||||
= PutDispatchFailed PutObjectError
|
= PutDispatchFailed PutObjectError
|
||||||
deriving stock (Eq, Show)
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
|
newtype DeleteFailure
|
||||||
|
= DeleteDispatchFailed S3Error
|
||||||
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
runDelete
|
runDelete
|
||||||
:: Env
|
:: Env
|
||||||
-> SessionUser
|
-> SessionUser
|
||||||
-> Root
|
-> Root
|
||||||
-> RelativePath
|
-> RelativePath
|
||||||
-> OperationId
|
-> OperationId
|
||||||
-> IO (Either Text MutationResult)
|
-> IO (Either DeleteFailure MutationResult)
|
||||||
runDelete env user root rel op = do
|
runDelete env user root rel op = do
|
||||||
ensurePersonalPool
|
ensurePersonalPool
|
||||||
(envDb env)
|
(envDb env)
|
||||||
|
|
@ -136,7 +140,7 @@ runDelete env user root rel op = do
|
||||||
}
|
}
|
||||||
else pure Nothing
|
else pure Nothing
|
||||||
case admitted of
|
case admitted of
|
||||||
Nothing -> pure (Left "object store unavailable")
|
Nothing -> pure (Left (DeleteDispatchFailed S3TransportError))
|
||||||
Just outcome ->
|
Just outcome ->
|
||||||
case outcome of
|
case outcome of
|
||||||
AdmitConflict -> pure (Right MutationConflict)
|
AdmitConflict -> pure (Right MutationConflict)
|
||||||
|
|
@ -149,9 +153,9 @@ runDelete env user root rel op = do
|
||||||
user
|
user
|
||||||
root
|
root
|
||||||
record
|
record
|
||||||
"object request timed out"
|
(DeleteDispatchFailed S3TransportError)
|
||||||
(deleteObject (envS3 env) key
|
(deleteObject (envS3 env) key
|
||||||
>>= pure . either (Left . renderS3Error) Right)
|
>>= pure . either (Left . DeleteDispatchFailed) Right)
|
||||||
|
|
||||||
dispatchAndFinalize
|
dispatchAndFinalize
|
||||||
:: Env
|
:: Env
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import Nuthatch.Http.Contract
|
||||||
, renderTypeScriptContract
|
, renderTypeScriptContract
|
||||||
, contractShapeErrors
|
, contractShapeErrors
|
||||||
)
|
)
|
||||||
|
import Nuthatch.Types (MutationResult (..))
|
||||||
import Test.Tasty (TestTree, testGroup)
|
import Test.Tasty (TestTree, testGroup)
|
||||||
import Test.Tasty.HUnit ((@?=), Assertion, assertBool, testCase)
|
import Test.Tasty.HUnit ((@?=), Assertion, assertBool, testCase)
|
||||||
|
|
||||||
|
|
@ -44,6 +45,34 @@ tests = testGroup "Haskell to TypeScript contract"
|
||||||
(Proxy :: Proxy DeleteBatchResponse)
|
(Proxy :: Proxy DeleteBatchResponse)
|
||||||
, testCase "explicit TypeScript mappings accept the Aeson wire shapes" $
|
, testCase "explicit TypeScript mappings accept the Aeson wire shapes" $
|
||||||
contractShapeErrors @?= []
|
contractShapeErrors @?= []
|
||||||
|
, testCase "delete item responses contain exactly one typed outcome" $ do
|
||||||
|
let path = "folder/file.txt" :: T.Text
|
||||||
|
operation = "operation-1" :: T.Text
|
||||||
|
decode result failure =
|
||||||
|
A.fromJSON
|
||||||
|
(A.object
|
||||||
|
[ "path" A..= path
|
||||||
|
, "operation_id" A..= operation
|
||||||
|
, "result" A..= result
|
||||||
|
, "error" A..= failure
|
||||||
|
])
|
||||||
|
:: A.Result DeleteItemResponse
|
||||||
|
assertSuccess
|
||||||
|
(decode
|
||||||
|
(Just MutationSucceeded)
|
||||||
|
(Nothing :: Maybe DeleteItemFailure))
|
||||||
|
assertSuccess
|
||||||
|
(decode
|
||||||
|
(Nothing :: Maybe MutationResult)
|
||||||
|
(Just DeleteItemDatabaseUnavailable))
|
||||||
|
assertFailure
|
||||||
|
(decode
|
||||||
|
(Just MutationSucceeded)
|
||||||
|
(Just DeleteItemDatabaseUnavailable))
|
||||||
|
assertFailure
|
||||||
|
(decode
|
||||||
|
(Nothing :: Maybe MutationResult)
|
||||||
|
(Nothing :: Maybe DeleteItemFailure))
|
||||||
, testCase "generated error unions cover every fixture error code" $ do
|
, testCase "generated error unions cover every fixture error code" $ do
|
||||||
fixture <- decodeFixture
|
fixture <- decodeFixture
|
||||||
errors <- fixtureValue fixture "errors"
|
errors <- fixtureValue fixture "errors"
|
||||||
|
|
@ -102,3 +131,11 @@ asProxyType _ = id
|
||||||
|
|
||||||
toList :: Foldable collection => collection value -> [value]
|
toList :: Foldable collection => collection value -> [value]
|
||||||
toList = foldr (:) []
|
toList = foldr (:) []
|
||||||
|
|
||||||
|
assertSuccess :: A.Result value -> Assertion
|
||||||
|
assertSuccess (A.Success _) = pure ()
|
||||||
|
assertSuccess (A.Error message) = fail message
|
||||||
|
|
||||||
|
assertFailure :: A.Result value -> Assertion
|
||||||
|
assertFailure (A.Error _) = pure ()
|
||||||
|
assertFailure (A.Success _) = fail "unexpected successful decode"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ tests = testGroup "product behavior"
|
||||||
getMonotonicTimeNSec
|
getMonotonicTimeNSec
|
||||||
(\_ _ -> do
|
(\_ _ -> do
|
||||||
threadDelay 100000
|
threadDelay 100000
|
||||||
pure (Right (ListPage [] [] Nothing False)))
|
pure (Right (ListPage [] [] Nothing False) :: Either () ListPage))
|
||||||
100
|
100
|
||||||
(started + 5000000)
|
(started + 5000000)
|
||||||
finished <- getMonotonicTimeNSec
|
finished <- getMonotonicTimeNSec
|
||||||
|
|
|
||||||
|
|
@ -305,7 +305,9 @@ verifyStoreFence cfg database redis store = do
|
||||||
(OperationId "expired-delete-store-down")
|
(OperationId "expired-delete-store-down")
|
||||||
afterExpiredDelete <-
|
afterExpiredDelete <-
|
||||||
mutationSnapshot database "expired-delete-store-down"
|
mutationSnapshot database "expired-delete-store-down"
|
||||||
assert "store-down expired delete epoch rejected" (isLeft expiredDeleteResult)
|
assertEqual "store-down expired delete epoch rejected"
|
||||||
|
(Left (Mutation.DeleteDispatchFailed S3TransportError))
|
||||||
|
expiredDeleteResult
|
||||||
assertEqual "store-down does not start an expired delete epoch"
|
assertEqual "store-down does not start an expired delete epoch"
|
||||||
beforeExpiredDelete
|
beforeExpiredDelete
|
||||||
afterExpiredDelete
|
afterExpiredDelete
|
||||||
|
|
@ -313,7 +315,9 @@ verifyStoreFence cfg database redis store = do
|
||||||
freshDeleteResult <- runDelete
|
freshDeleteResult <- runDelete
|
||||||
env user Shared (RelativePath "delete-store-down.bin")
|
env user Shared (RelativePath "delete-store-down.bin")
|
||||||
(OperationId "delete-store-down")
|
(OperationId "delete-store-down")
|
||||||
assert "store-down delete rejected" (isLeft freshDeleteResult)
|
assertEqual "store-down delete rejected"
|
||||||
|
(Left (Mutation.DeleteDispatchFailed S3TransportError))
|
||||||
|
freshDeleteResult
|
||||||
freshDeleteRows <- Db.query database
|
freshDeleteRows <- Db.query database
|
||||||
"SELECT count(*) FROM mutations WHERE operation_id = 'delete-store-down'"
|
"SELECT count(*) FROM mutations WHERE operation_id = 'delete-store-down'"
|
||||||
()
|
()
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,7 @@ deleteBatchHandler _ request =
|
||||||
[ DeleteItemResponse
|
[ DeleteItemResponse
|
||||||
{ deleteItemPath = deleteBatchPath item
|
{ deleteItemPath = deleteBatchPath item
|
||||||
, deleteItemOperationId = deleteBatchOperationId item
|
, deleteItemOperationId = deleteBatchOperationId item
|
||||||
, deleteItemResult = Just MutationSucceeded
|
, deleteItemOutcome = DeleteItemCompleted MutationSucceeded
|
||||||
, deleteItemError = Nothing
|
|
||||||
}
|
}
|
||||||
| item <- deleteBatchItems request
|
| item <- deleteBatchItems request
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -129,12 +129,21 @@ export interface DeleteBatchRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface DeleteItemResponse {
|
export type DeleteItemFailure = 'database_unavailable' | 'object_store_unavailable';
|
||||||
path: RelativePath;
|
|
||||||
operation_id: OperationId;
|
export type DeleteItemResponse =
|
||||||
result: MutationResult | null;
|
| {
|
||||||
error: string | null;
|
path: RelativePath;
|
||||||
}
|
operation_id: OperationId;
|
||||||
|
result: MutationResult;
|
||||||
|
error: null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
path: RelativePath;
|
||||||
|
operation_id: OperationId;
|
||||||
|
result: null;
|
||||||
|
error: DeleteItemFailure;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export interface DeleteBatchResponse {
|
export interface DeleteBatchResponse {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type DeleteBatchResponse,
|
type DeleteBatchResponse,
|
||||||
type DeleteEndpointRequest,
|
type DeleteEndpointRequest,
|
||||||
type DeleteFailureCode,
|
type DeleteFailureCode,
|
||||||
|
type DeleteItemFailure,
|
||||||
type DeleteItemResponse,
|
type DeleteItemResponse,
|
||||||
type DeletePlanEndpointRequest,
|
type DeletePlanEndpointRequest,
|
||||||
type DeletePlanFailureCode,
|
type DeletePlanFailureCode,
|
||||||
|
|
@ -457,12 +458,32 @@ function decodeDeleteBatchResponse(value: unknown): DeleteBatchResponse {
|
||||||
|
|
||||||
function decodeDeleteItemResponse(value: unknown): DeleteItemResponse {
|
function decodeDeleteItemResponse(value: unknown): DeleteItemResponse {
|
||||||
const object = record(value, 'delete item response');
|
const object = record(value, 'delete item response');
|
||||||
return {
|
const path = stringField(object, 'path');
|
||||||
path: stringField(object, 'path'),
|
const operation_id = stringField(object, 'operation_id');
|
||||||
operation_id: stringField(object, 'operation_id'),
|
const result = nullableMutationResultField(object, 'result');
|
||||||
result: nullableMutationResultField(object, 'result'),
|
const error = nullableDeleteItemFailureField(object, 'error');
|
||||||
error: nullableStringField(object, 'error')
|
if (result !== null && error === null) {
|
||||||
};
|
return { path, operation_id, result, error };
|
||||||
|
}
|
||||||
|
if (result === null && error !== null) {
|
||||||
|
return { path, operation_id, result, error };
|
||||||
|
}
|
||||||
|
throw new TypeError('delete item response must contain exactly one outcome');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableDeleteItemFailureField(
|
||||||
|
object: Record<string, unknown>,
|
||||||
|
field: string
|
||||||
|
): DeleteItemFailure | null {
|
||||||
|
const value = object[field];
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
value === 'database_unavailable' ||
|
||||||
|
value === 'object_store_unavailable'
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new TypeError(`${field} must be a declared delete item failure or null`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(value: unknown, label: string): Record<string, unknown> {
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ test('partial delete recovery preserves only unfinished identities', () => {
|
||||||
path: 'uncertain',
|
path: 'uncertain',
|
||||||
operation_id: 'uncertain-id',
|
operation_id: 'uncertain-id',
|
||||||
result: null,
|
result: null,
|
||||||
error: 'transport interrupted'
|
error: 'object_store_unavailable'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
() => ids.shift()
|
() => ids.shift()
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,43 @@ test('shared fixtures decode through every JSON transport method', async () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('delete batch decoding enforces the typed item outcome', async () => {
|
||||||
|
const failureClient = new BrowserProductClient(async () =>
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
path: 'folder/file.txt',
|
||||||
|
operation_id: 'operation-1',
|
||||||
|
result: null,
|
||||||
|
error: 'database_unavailable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const failure = requireValue(
|
||||||
|
await failureClient.deleteBatch({ body: fixtures.delete_batch_request })
|
||||||
|
);
|
||||||
|
assert.equal(failure.results[0].error, 'database_unavailable');
|
||||||
|
|
||||||
|
const invalidClient = new BrowserProductClient(async () =>
|
||||||
|
jsonResponse({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
path: 'folder/file.txt',
|
||||||
|
operation_id: 'operation-1',
|
||||||
|
result: 'succeeded',
|
||||||
|
error: 'object_store_unavailable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const invalid = await invalidClient.deleteBatch({
|
||||||
|
body: fixtures.delete_batch_request
|
||||||
|
});
|
||||||
|
assert.equal(invalid.ok, false);
|
||||||
|
assert.equal(invalid.failure.kind, 'unexpected-http');
|
||||||
|
});
|
||||||
|
|
||||||
test('declared, undeclared, and network failures remain distinct without retries', async () => {
|
test('declared, undeclared, and network failures remain distinct without retries', async () => {
|
||||||
const declaredClient = new BrowserProductClient(async () =>
|
const declaredClient = new BrowserProductClient(async () =>
|
||||||
jsonResponse(
|
jsonResponse(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue