Generate the browser contract from ProductApi
This commit is contained in:
parent
67cc0cb802
commit
c9642f4067
21 changed files with 2572 additions and 130 deletions
10
Makefile
10
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app check-app-boundaries check-streaming check-idempotency check-recovery check-model-correspondence check-protocol check-product check-runtime check-v1 check-specs app-up app-down test-frontend check-frontend build-frontend
|
||||
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app check-app-boundaries check-streaming check-idempotency check-recovery check-model-correspondence check-protocol check-product check-runtime check-v1 check-specs check-web-contract generate-web-contract app-up app-down test-frontend check-frontend build-frontend
|
||||
|
||||
test:
|
||||
./tests/run-host-tests.sh
|
||||
|
|
@ -39,9 +39,15 @@ check-model-correspondence:
|
|||
check-protocol: check-model-correspondence
|
||||
./tests/kind/protocol-conformance-run.sh
|
||||
|
||||
check-product: test-frontend check-frontend build-frontend
|
||||
check-product: test-frontend check-frontend build-frontend check-web-contract
|
||||
./tests/kind/product-run.sh
|
||||
|
||||
check-web-contract:
|
||||
./tests/web-contract.sh
|
||||
|
||||
generate-web-contract:
|
||||
cabal run nuthatch-generate-web-contract --offline -- web/src/generated/contract.ts web/test/fixtures/contract.json
|
||||
|
||||
check-runtime: test check-recovery
|
||||
./tests/runtime-static.sh
|
||||
./tests/kind/runtime-resilience-run.sh
|
||||
|
|
|
|||
19
app/GenerateContract.hs
Normal file
19
app/GenerateContract.hs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
module Main (main) where
|
||||
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Data.Text.IO as TIO
|
||||
import Nuthatch.Http.Contract
|
||||
( renderContractFixtures
|
||||
, renderTypeScriptContract
|
||||
)
|
||||
import System.Environment (getArgs)
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
arguments <- getArgs
|
||||
case arguments of
|
||||
[contractOutput, fixtureOutput] -> do
|
||||
TIO.writeFile contractOutput renderTypeScriptContract
|
||||
BL.writeFile fixtureOutput renderContractFixtures
|
||||
_ -> fail
|
||||
"usage: nuthatch-generate-web-contract CONTRACT_OUTPUT FIXTURE_OUTPUT"
|
||||
|
|
@ -9,7 +9,7 @@ mutation protocol remains in [`Nuthatch.tla`](../protocol/Nuthatch.tla).
|
|||
|
||||
| Contract | Implementation owner | Owning gate |
|
||||
| --- | --- | --- |
|
||||
| HTTP | `src/Nuthatch/Api.hs`, `src/Nuthatch/App.hs`, `src/Nuthatch/Http/Server.hs`, `src/Nuthatch/Http/Auth.hs`, `src/Nuthatch/Http/Error.hs`, `src/Nuthatch/Browse.hs`, `src/Nuthatch/Transfer.hs` | `make check-product` |
|
||||
| HTTP | `src/Nuthatch/Api.hs`, `src/Nuthatch/App.hs`, `src/Nuthatch/Http/Server.hs`, `src/Nuthatch/Http/Auth.hs`, `src/Nuthatch/Http/Error.hs`, `src/Nuthatch/Http/Contract.hs`, `src/Nuthatch/Browse.hs`, `src/Nuthatch/Transfer.hs`, `web/src/generated/contract.ts`, `web/src/transport.ts` | `make check-product` |
|
||||
| SQL | `sql/`, `src/Nuthatch/Db.hs`, `src/Nuthatch/Protocol.hs` | `make check-storage-foundation` |
|
||||
| Identity encoding | `src/Nuthatch/Paths.hs` | `make test` |
|
||||
| Content identity | `src/Nuthatch/Streaming.hs`, `src/Nuthatch/S3.hs` | `make check-streaming` |
|
||||
|
|
@ -42,6 +42,12 @@ of rewriting them. Root values are the `personal` and `shared` enum values;
|
|||
clients never submit complete object keys. Operation ids are 1 through 128
|
||||
URL-safe ASCII characters from the unreserved set.
|
||||
|
||||
`Nuthatch.Http.Contract` traverses `ProductApi` and its explicit wire DTO
|
||||
mappings to generate the checked TypeScript product contract. The handwritten
|
||||
browser transport implements that generated interface and exclusively owns
|
||||
fetch, credential, encoding, binary-body, response-header, retry, and
|
||||
non-success decoding policy.
|
||||
|
||||
Upload requests stream `application/octet-stream`, require
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@ examples, and Hedgehog through Tasty Hedgehog for generated properties.
|
|||
Hedgehog failures include the seed needed to replay the shrunk counterexample.
|
||||
Generators represent valid and invalid domains with distinct types.
|
||||
|
||||
## Browser product contract
|
||||
|
||||
`make check-web-contract` regenerates the TypeScript product contract and
|
||||
shared JSON fixtures into a temporary directory and requires byte identity
|
||||
with the checked artifacts. Host tests round-trip every generated wire DTO,
|
||||
validate explicit TypeScript mappings against representative Aeson shapes,
|
||||
and derive complete operation error sets from their Haskell constructors.
|
||||
|
||||
The same gate runs every handwritten browser product method against an
|
||||
in-process Servant `ProductApi`. Browser unit tests separately cover
|
||||
same-origin credentials, encoding, binary bodies, response headers, declared
|
||||
and undeclared failures, network failures, and the no-automatic-retry policy.
|
||||
`make check-product` runs this gate before its kind product lane.
|
||||
|
||||
## Model correspondence
|
||||
|
||||
`make check-model-correspondence` runs before the kind protocol-conformance
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ category: Network
|
|||
build-type: Simple
|
||||
extra-source-files:
|
||||
sql/001_initial_schema.sql
|
||||
web/src/generated/contract.ts
|
||||
web/src/transport.ts
|
||||
web/test/contract-integration.mjs
|
||||
web/test/fixtures/contract.json
|
||||
|
||||
common shared
|
||||
default-language: Haskell2010
|
||||
|
|
@ -106,6 +110,7 @@ library
|
|||
Nuthatch.Config
|
||||
Nuthatch.Db
|
||||
Nuthatch.Http.Auth
|
||||
Nuthatch.Http.Contract
|
||||
Nuthatch.Http.Error
|
||||
Nuthatch.Http.Server
|
||||
Nuthatch.Mutation.Effects
|
||||
|
|
@ -136,6 +141,27 @@ executable nuthatch
|
|||
-rtsopts
|
||||
-with-rtsopts=-N
|
||||
|
||||
executable nuthatch-generate-web-contract
|
||||
import: shared
|
||||
main-is: GenerateContract.hs
|
||||
hs-source-dirs: app
|
||||
build-depends:
|
||||
, nuthatch
|
||||
, base
|
||||
|
||||
executable nuthatch-web-contract-integration
|
||||
import: shared
|
||||
main-is: WebContractIntegration.hs
|
||||
hs-source-dirs: test
|
||||
build-depends:
|
||||
, nuthatch
|
||||
, base
|
||||
, bytestring
|
||||
, process
|
||||
, text
|
||||
ghc-options:
|
||||
-threaded
|
||||
|
||||
executable nuthatch-storage-integration
|
||||
import: shared
|
||||
main-is: Integration.hs
|
||||
|
|
@ -381,6 +407,7 @@ test-suite nuthatch-unit
|
|||
other-modules:
|
||||
Nuthatch.Test.Config
|
||||
Nuthatch.Test.Http
|
||||
Nuthatch.Test.HttpContract
|
||||
Nuthatch.Test.Identity
|
||||
Nuthatch.Test.Oidc
|
||||
Nuthatch.Test.Path
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ module Nuthatch.Api
|
|||
, DeclaredSize (..)
|
||||
, HealthResponse (..)
|
||||
, ReadyResponse (..)
|
||||
, EntryResponse (..)
|
||||
, BrowseResponse (..)
|
||||
, DeleteRequest (..)
|
||||
, SessionResponse (..)
|
||||
|
|
@ -63,6 +64,7 @@ import Data.Proxy (Proxy (..))
|
|||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import GHC.Generics (Generic)
|
||||
import Network.HTTP.Media ((//), (/:))
|
||||
import Nuthatch.Types
|
||||
import Servant.API
|
||||
|
|
@ -117,12 +119,36 @@ instance ToJSON ReadyResponse where
|
|||
toJSON response = object ["ready" .= ready response]
|
||||
|
||||
data BrowseResponse = BrowseResponse
|
||||
{ browseEntries :: [ListEntry]
|
||||
{ browseEntries :: [EntryResponse]
|
||||
, browseContinuation :: Maybe PaginationToken
|
||||
, browsePartial :: Bool
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
data EntryResponse = EntryResponse
|
||||
{ entryResponseName :: Text
|
||||
, entryResponseIsPrefix :: Bool
|
||||
, entryResponseSize :: Maybe Integer
|
||||
, entryResponsePath :: Text
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
instance ToJSON EntryResponse where
|
||||
toJSON response = object
|
||||
[ "name" .= entryResponseName response
|
||||
, "is_prefix" .= entryResponseIsPrefix response
|
||||
, "size" .= entryResponseSize response
|
||||
, "path" .= entryResponsePath response
|
||||
]
|
||||
|
||||
instance FromJSON EntryResponse where
|
||||
parseJSON = withObject "EntryResponse" $ \o ->
|
||||
EntryResponse
|
||||
<$> o .: "name"
|
||||
<*> o .: "is_prefix"
|
||||
<*> o .: "size"
|
||||
<*> o .: "path"
|
||||
|
||||
instance ToJSON BrowseResponse where
|
||||
toJSON response = object
|
||||
[ "entries" .= browseEntries response
|
||||
|
|
@ -130,6 +156,13 @@ instance ToJSON BrowseResponse where
|
|||
, "partial" .= browsePartial response
|
||||
]
|
||||
|
||||
instance FromJSON BrowseResponse where
|
||||
parseJSON = withObject "BrowseResponse" $ \o ->
|
||||
BrowseResponse
|
||||
<$> o .: "entries"
|
||||
<*> o .: "continuation"
|
||||
<*> o .: "partial"
|
||||
|
||||
data DeleteRequest = DeleteRequest
|
||||
{ deleteRoot :: Root
|
||||
, deletePath :: RelativePath
|
||||
|
|
@ -146,6 +179,14 @@ instance FromJSON DeleteRequest where
|
|||
<*> o .: "operation_id"
|
||||
<*> o .: "csrf"
|
||||
|
||||
instance ToJSON DeleteRequest where
|
||||
toJSON request = object
|
||||
[ "root" .= deleteRoot request
|
||||
, "path" .= deletePath request
|
||||
, "operation_id" .= deleteOperationId request
|
||||
, "csrf" .= deleteCsrf request
|
||||
]
|
||||
|
||||
data UploadResponse = UploadResponse
|
||||
{ uploadResponseOperationId :: OperationId
|
||||
, uploadResponseResult :: MutationResult
|
||||
|
|
@ -156,11 +197,18 @@ data UploadResponse = UploadResponse
|
|||
instance ToJSON UploadResponse where
|
||||
toJSON response = object
|
||||
[ "operation_id" .= uploadResponseOperationId response
|
||||
, "result" .= mutationResultText (uploadResponseResult response)
|
||||
, "result" .= uploadResponseResult response
|
||||
, "idempotency_window_seconds"
|
||||
.= uploadResponseIdempotencyWindowSeconds response
|
||||
]
|
||||
|
||||
instance FromJSON UploadResponse where
|
||||
parseJSON = withObject "UploadResponse" $ \o ->
|
||||
UploadResponse
|
||||
<$> o .: "operation_id"
|
||||
<*> o .: "result"
|
||||
<*> o .: "idempotency_window_seconds"
|
||||
|
||||
data DeleteResponse = DeleteResponse
|
||||
{ deleteResponseOperationId :: OperationId
|
||||
, deleteResponseResult :: MutationResult
|
||||
|
|
@ -171,11 +219,18 @@ data DeleteResponse = DeleteResponse
|
|||
instance ToJSON DeleteResponse where
|
||||
toJSON response = object
|
||||
[ "operation_id" .= deleteResponseOperationId response
|
||||
, "result" .= mutationResultText (deleteResponseResult response)
|
||||
, "result" .= deleteResponseResult response
|
||||
, "idempotency_window_seconds"
|
||||
.= deleteResponseIdempotencyWindowSeconds response
|
||||
]
|
||||
|
||||
instance FromJSON DeleteResponse where
|
||||
parseJSON = withObject "DeleteResponse" $ \o ->
|
||||
DeleteResponse
|
||||
<$> o .: "operation_id"
|
||||
<*> o .: "result"
|
||||
<*> o .: "idempotency_window_seconds"
|
||||
|
||||
data Throws error
|
||||
|
||||
instance HasServer endpoint context =>
|
||||
|
|
@ -203,7 +258,7 @@ instance ToJSON BoundaryError where
|
|||
data AuthenticationError
|
||||
= AuthenticationRequired
|
||||
| MalformedAuthentication
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
instance ToJSON AuthenticationError where
|
||||
toJSON AuthenticationRequired =
|
||||
|
|
@ -240,7 +295,7 @@ instance ToJSON LogoutError where
|
|||
errorValue "csrf_validation_failed" "CSRF validation failed" Nothing
|
||||
|
||||
newtype BrowseError = BrowseObjectStoreFailed Text
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
instance ToJSON BrowseError where
|
||||
toJSON (BrowseObjectStoreFailed message) =
|
||||
|
|
@ -252,7 +307,7 @@ data DownloadError
|
|||
| DownloadRangeUnsupported
|
||||
| DownloadThrottleUnavailable
|
||||
| DownloadBandwidthExceeded
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
instance ToJSON DownloadError where
|
||||
toJSON (DownloadObjectStoreFailed message) =
|
||||
|
|
@ -275,7 +330,7 @@ data UploadError
|
|||
| UploadThrottleUnavailable
|
||||
| UploadBandwidthExceeded
|
||||
| UploadMutationRejected Text
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
instance ToJSON UploadError where
|
||||
toJSON UploadCsrfRejected =
|
||||
|
|
@ -298,7 +353,7 @@ instance ToJSON UploadError where
|
|||
data DeleteError
|
||||
= DeleteCsrfRejected
|
||||
| DeleteMutationRejected Text
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
instance ToJSON DeleteError where
|
||||
toJSON DeleteCsrfRejected =
|
||||
|
|
@ -309,7 +364,7 @@ instance ToJSON DeleteError where
|
|||
data DeletePrefixError
|
||||
= DeletePrefixCsrfRejected
|
||||
| DeletePrefixMutationRejected Text
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
instance ToJSON DeletePrefixError where
|
||||
toJSON DeletePrefixCsrfRejected =
|
||||
|
|
@ -331,6 +386,13 @@ instance ToJSON SessionResponse where
|
|||
, "csrf" .= sessionResponseCsrf response
|
||||
]
|
||||
|
||||
instance FromJSON SessionResponse where
|
||||
parseJSON = withObject "SessionResponse" $ \o ->
|
||||
SessionResponse
|
||||
<$> o .: "user_id"
|
||||
<*> o .: "display"
|
||||
<*> o .: "csrf"
|
||||
|
||||
data DeletePlanResponse = DeletePlanResponse
|
||||
{ deletePlanPaths :: [RelativePath]
|
||||
, deletePlanContinuation :: Maybe PaginationToken
|
||||
|
|
@ -343,6 +405,12 @@ instance ToJSON DeletePlanResponse where
|
|||
, "continuation" .= deletePlanContinuation response
|
||||
]
|
||||
|
||||
instance FromJSON DeletePlanResponse where
|
||||
parseJSON = withObject "DeletePlanResponse" $ \o ->
|
||||
DeletePlanResponse
|
||||
<$> o .: "paths"
|
||||
<*> o .: "continuation"
|
||||
|
||||
data DeleteBatchItem = DeleteBatchItem
|
||||
{ deleteBatchPath :: RelativePath
|
||||
, deleteBatchOperationId :: OperationId
|
||||
|
|
@ -355,6 +423,12 @@ instance FromJSON DeleteBatchItem where
|
|||
<$> o .: "path"
|
||||
<*> o .: "operation_id"
|
||||
|
||||
instance ToJSON DeleteBatchItem where
|
||||
toJSON item = object
|
||||
[ "path" .= deleteBatchPath item
|
||||
, "operation_id" .= deleteBatchOperationId item
|
||||
]
|
||||
|
||||
data DeleteBatchRequest = DeleteBatchRequest
|
||||
{ deleteBatchRoot :: Root
|
||||
, deleteBatchCsrf :: CsrfToken
|
||||
|
|
@ -369,6 +443,13 @@ instance FromJSON DeleteBatchRequest where
|
|||
<*> o .: "csrf"
|
||||
<*> o .: "items"
|
||||
|
||||
instance ToJSON DeleteBatchRequest where
|
||||
toJSON request = object
|
||||
[ "root" .= deleteBatchRoot request
|
||||
, "csrf" .= deleteBatchCsrf request
|
||||
, "items" .= deleteBatchItems request
|
||||
]
|
||||
|
||||
data DeleteItemResponse = DeleteItemResponse
|
||||
{ deleteItemPath :: RelativePath
|
||||
, deleteItemOperationId :: OperationId
|
||||
|
|
@ -381,10 +462,18 @@ instance ToJSON DeleteItemResponse where
|
|||
toJSON response = object
|
||||
[ "path" .= unRelativePath (deleteItemPath response)
|
||||
, "operation_id" .= deleteItemOperationId response
|
||||
, "result" .= fmap mutationResultText (deleteItemResult response)
|
||||
, "result" .= deleteItemResult response
|
||||
, "error" .= deleteItemError response
|
||||
]
|
||||
|
||||
instance FromJSON DeleteItemResponse where
|
||||
parseJSON = withObject "DeleteItemResponse" $ \o ->
|
||||
DeleteItemResponse
|
||||
<$> o .: "path"
|
||||
<*> o .: "operation_id"
|
||||
<*> o .: "result"
|
||||
<*> o .: "error"
|
||||
|
||||
newtype DeleteBatchResponse = DeleteBatchResponse
|
||||
{ deleteBatchResults :: [DeleteItemResponse]
|
||||
}
|
||||
|
|
@ -393,6 +482,10 @@ newtype DeleteBatchResponse = DeleteBatchResponse
|
|||
instance ToJSON DeleteBatchResponse where
|
||||
toJSON response = object ["results" .= deleteBatchResults response]
|
||||
|
||||
instance FromJSON DeleteBatchResponse where
|
||||
parseJSON = withObject "DeleteBatchResponse" $ \o ->
|
||||
DeleteBatchResponse <$> o .: "results"
|
||||
|
||||
errorValue :: Text -> Text -> Maybe Value -> Value
|
||||
errorValue code message details = object
|
||||
[ "error" .= code
|
||||
|
|
@ -583,10 +676,3 @@ validatePaginationToken raw
|
|||
| T.length raw > 2048 = Left "pagination token is too long"
|
||||
| T.any isControl raw = Left "pagination token contains a control character"
|
||||
| otherwise = Right (PaginationToken raw)
|
||||
|
||||
mutationResultText :: MutationResult -> Text
|
||||
mutationResultText MutationPending = "pending"
|
||||
mutationResultText MutationSucceeded = "succeeded"
|
||||
mutationResultText MutationFailed = "failed"
|
||||
mutationResultText MutationRejected = "rejected"
|
||||
mutationResultText MutationConflict = "conflict"
|
||||
|
|
|
|||
|
|
@ -62,10 +62,17 @@ handleBrowseRequest env session selectedRoot selectedPrefix search continuation
|
|||
pure
|
||||
result
|
||||
pure BrowseResponse
|
||||
{ browseEntries = entries
|
||||
{ browseEntries = map entryResponse entries
|
||||
, browseContinuation = PaginationToken <$> nextContinuation
|
||||
, browsePartial = partial
|
||||
}
|
||||
where
|
||||
entryResponse entry = EntryResponse
|
||||
{ entryResponseName = leName entry
|
||||
, entryResponseIsPrefix = leIsPrefix entry
|
||||
, entryResponseSize = leSize entry
|
||||
, entryResponsePath = leRelativePath entry
|
||||
}
|
||||
|
||||
handleDeletePlanRequest
|
||||
:: Env
|
||||
|
|
|
|||
911
src/Nuthatch/Http/Contract.hs
Normal file
911
src/Nuthatch/Http/Contract.hs
Normal file
|
|
@ -0,0 +1,911 @@
|
|||
{-# LANGUAGE AllowAmbiguousTypes #-}
|
||||
{-# LANGUAGE ExplicitForAll #-}
|
||||
{-# LANGUAGE KindSignatures #-}
|
||||
{-# LANGUAGE PolyKinds #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Nuthatch.Http.Contract
|
||||
( renderTypeScriptContract
|
||||
, renderContractFixtures
|
||||
, contractShapeErrors
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.Key as Key
|
||||
import qualified Data.Aeson.KeyMap as KeyMap
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Char (isAlphaNum, toLower, toUpper)
|
||||
import Data.Kind (Type)
|
||||
import Data.List (nub, sort)
|
||||
import Data.Proxy (Proxy (..))
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import GHC.Generics
|
||||
( Generic
|
||||
, K1 (..)
|
||||
, M1 (..)
|
||||
, Rep
|
||||
, U1 (..)
|
||||
, V1
|
||||
, to
|
||||
, type (:*:) (..)
|
||||
, type (:+:) (..)
|
||||
)
|
||||
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
|
||||
import Nuthatch.Api
|
||||
import Nuthatch.Types
|
||||
import qualified Servant.API as S
|
||||
|
||||
data TypeScriptInfo = TypeScriptInfo
|
||||
{ tsiReference :: Text
|
||||
, tsiDefinitions :: [Text]
|
||||
, tsiAccepts :: A.Value -> Bool
|
||||
}
|
||||
|
||||
class TypeScriptType value where
|
||||
typeScriptInfo :: Proxy value -> TypeScriptInfo
|
||||
|
||||
instance TypeScriptType Text where
|
||||
typeScriptInfo _ = jsonScalar "string" $ \case
|
||||
A.String _ -> True
|
||||
_ -> False
|
||||
|
||||
instance TypeScriptType Integer where
|
||||
typeScriptInfo _ = jsonScalar "number" $ \case
|
||||
A.Number _ -> True
|
||||
_ -> False
|
||||
|
||||
instance TypeScriptType Bool where
|
||||
typeScriptInfo _ = jsonScalar "boolean" $ \case
|
||||
A.Bool _ -> True
|
||||
_ -> False
|
||||
|
||||
instance TypeScriptType value => TypeScriptType (Maybe value) where
|
||||
typeScriptInfo _ =
|
||||
let nested = typeScriptInfo (Proxy :: Proxy value)
|
||||
in nested
|
||||
{ tsiReference = tsiReference nested <> " | null"
|
||||
, tsiAccepts = \value ->
|
||||
value == A.Null || tsiAccepts nested value
|
||||
}
|
||||
|
||||
instance TypeScriptType value => TypeScriptType [value] where
|
||||
typeScriptInfo _ =
|
||||
let nested = typeScriptInfo (Proxy :: Proxy value)
|
||||
in nested
|
||||
{ tsiReference = "Array<" <> tsiReference nested <> ">"
|
||||
, tsiAccepts = \case
|
||||
A.Array values -> all (tsiAccepts nested) values
|
||||
_ -> False
|
||||
}
|
||||
|
||||
instance TypeScriptType Root where
|
||||
typeScriptInfo _ = genericStringEnum @Root "Root"
|
||||
|
||||
instance TypeScriptType RelativePath where
|
||||
typeScriptInfo _ = stringAlias "RelativePath"
|
||||
|
||||
instance TypeScriptType OperationId where
|
||||
typeScriptInfo _ = stringAlias "OperationId"
|
||||
|
||||
instance TypeScriptType CsrfToken where
|
||||
typeScriptInfo _ = stringAlias "CsrfToken"
|
||||
|
||||
instance TypeScriptType ContentSha256 where
|
||||
typeScriptInfo _ = stringAlias "ContentSha256"
|
||||
|
||||
instance TypeScriptType UserId where
|
||||
typeScriptInfo _ = stringAlias "UserId"
|
||||
|
||||
instance TypeScriptType BrowsePrefix where
|
||||
typeScriptInfo _ = typeScriptInfo (Proxy :: Proxy RelativePath)
|
||||
|
||||
instance TypeScriptType PaginationToken where
|
||||
typeScriptInfo _ = stringAlias "PaginationToken"
|
||||
|
||||
instance TypeScriptType SearchQuery where
|
||||
typeScriptInfo _ = stringAlias "SearchQuery"
|
||||
|
||||
instance TypeScriptType DeclaredSize where
|
||||
typeScriptInfo _ = numberAlias "DeclaredSize"
|
||||
|
||||
instance TypeScriptType MutationResult where
|
||||
typeScriptInfo _ = genericStringEnum @MutationResult "MutationResult"
|
||||
|
||||
instance TypeScriptType EntryResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "EntryResponse"
|
||||
[ property "name" (info @Text)
|
||||
, property "is_prefix" (info @Bool)
|
||||
, property "size" (info @(Maybe Integer))
|
||||
, property "path" (info @Text)
|
||||
]
|
||||
|
||||
instance TypeScriptType SessionResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "SessionResponse"
|
||||
[ property "user_id" (info @UserId)
|
||||
, property "display" (info @(Maybe Text))
|
||||
, property "csrf" (info @CsrfToken)
|
||||
]
|
||||
|
||||
instance TypeScriptType BrowseResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "BrowseResponse"
|
||||
[ property "entries" (info @[EntryResponse])
|
||||
, property "continuation" (info @(Maybe PaginationToken))
|
||||
, property "partial" (info @Bool)
|
||||
]
|
||||
|
||||
instance TypeScriptType DeleteRequest where
|
||||
typeScriptInfo _ =
|
||||
interface "DeleteRequest"
|
||||
[ property "root" (info @Root)
|
||||
, property "path" (info @RelativePath)
|
||||
, property "operation_id" (info @OperationId)
|
||||
, property "csrf" (info @CsrfToken)
|
||||
]
|
||||
|
||||
instance TypeScriptType UploadResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "UploadResponse"
|
||||
[ property "operation_id" (info @OperationId)
|
||||
, property "result" (info @MutationResult)
|
||||
, property "idempotency_window_seconds" (info @Integer)
|
||||
]
|
||||
|
||||
instance TypeScriptType DeleteResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "DeleteResponse"
|
||||
[ property "operation_id" (info @OperationId)
|
||||
, property "result" (info @MutationResult)
|
||||
, property "idempotency_window_seconds" (info @Integer)
|
||||
]
|
||||
|
||||
instance TypeScriptType DeletePlanResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "DeletePlanResponse"
|
||||
[ property "paths" (info @[RelativePath])
|
||||
, property "continuation" (info @(Maybe PaginationToken))
|
||||
]
|
||||
|
||||
instance TypeScriptType DeleteBatchItem where
|
||||
typeScriptInfo _ =
|
||||
interface "DeleteBatchItem"
|
||||
[ property "path" (info @RelativePath)
|
||||
, property "operation_id" (info @OperationId)
|
||||
]
|
||||
|
||||
instance TypeScriptType DeleteBatchRequest where
|
||||
typeScriptInfo _ =
|
||||
interface "DeleteBatchRequest"
|
||||
[ property "root" (info @Root)
|
||||
, property "csrf" (info @CsrfToken)
|
||||
, property "items" (info @[DeleteBatchItem])
|
||||
]
|
||||
|
||||
instance TypeScriptType DeleteItemResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "DeleteItemResponse"
|
||||
[ property "path" (info @RelativePath)
|
||||
, property "operation_id" (info @OperationId)
|
||||
, property "result" (info @(Maybe MutationResult))
|
||||
, property "error" (info @(Maybe Text))
|
||||
]
|
||||
|
||||
instance TypeScriptType DeleteBatchResponse where
|
||||
typeScriptInfo _ =
|
||||
interface "DeleteBatchResponse"
|
||||
[ property "results" (info @[DeleteItemResponse])
|
||||
]
|
||||
|
||||
info :: forall value. TypeScriptType value => TypeScriptInfo
|
||||
info = typeScriptInfo (Proxy :: Proxy value)
|
||||
|
||||
scalar :: Text -> TypeScriptInfo
|
||||
scalar reference = TypeScriptInfo reference [] (const False)
|
||||
|
||||
jsonScalar :: Text -> (A.Value -> Bool) -> TypeScriptInfo
|
||||
jsonScalar reference accepts = TypeScriptInfo reference [] accepts
|
||||
|
||||
alias :: Text -> Text -> TypeScriptInfo
|
||||
alias name value =
|
||||
TypeScriptInfo name ["export type " <> name <> " = " <> value <> ";"] (const False)
|
||||
|
||||
jsonAlias :: Text -> Text -> (A.Value -> Bool) -> TypeScriptInfo
|
||||
jsonAlias name value accepts =
|
||||
(alias name value) { tsiAccepts = accepts }
|
||||
|
||||
stringAlias :: Text -> TypeScriptInfo
|
||||
stringAlias name =
|
||||
jsonAlias name "string" $ \case
|
||||
A.String _ -> True
|
||||
_ -> False
|
||||
|
||||
numberAlias :: Text -> TypeScriptInfo
|
||||
numberAlias name =
|
||||
jsonAlias name "number" $ \case
|
||||
A.Number _ -> True
|
||||
_ -> False
|
||||
|
||||
data TypeScriptProperty = TypeScriptProperty
|
||||
{ tspName :: Text
|
||||
, tspType :: TypeScriptInfo
|
||||
}
|
||||
|
||||
property :: Text -> TypeScriptInfo -> TypeScriptProperty
|
||||
property = TypeScriptProperty
|
||||
|
||||
interface :: Text -> [TypeScriptProperty] -> TypeScriptInfo
|
||||
interface name properties =
|
||||
TypeScriptInfo
|
||||
name
|
||||
( concatMap (tsiDefinitions . tspType) properties
|
||||
<> [ T.unlines $
|
||||
["export interface " <> name <> " {"]
|
||||
<> map renderProperty properties
|
||||
<> ["}"]
|
||||
]
|
||||
)
|
||||
(\case
|
||||
A.Object value ->
|
||||
sort (KeyMap.keys value) == sort (map (Key.fromText . tspName) properties)
|
||||
&& all (propertyAccepts value) properties
|
||||
_ -> False)
|
||||
where
|
||||
renderProperty TypeScriptProperty{..} =
|
||||
" " <> tspName <> ": " <> tsiReference tspType <> ";"
|
||||
propertyAccepts value TypeScriptProperty{..} =
|
||||
maybe False (tsiAccepts tspType) (KeyMap.lookup (Key.fromText tspName) value)
|
||||
|
||||
data TypeScriptError = TypeScriptError
|
||||
{ tseReference :: Text
|
||||
, tseCodes :: [Text]
|
||||
, tseDefinitions :: [Text]
|
||||
}
|
||||
|
||||
class TypeScriptErrorType error where
|
||||
typeScriptError :: Proxy error -> TypeScriptError
|
||||
|
||||
instance TypeScriptErrorType AuthenticationError where
|
||||
typeScriptError _ =
|
||||
genericErrorType @AuthenticationError "AuthenticationErrorCode"
|
||||
|
||||
instance TypeScriptErrorType BrowseError where
|
||||
typeScriptError _ = genericErrorType @BrowseError "BrowseErrorCode"
|
||||
|
||||
instance TypeScriptErrorType DownloadError where
|
||||
typeScriptError _ = genericErrorType @DownloadError "DownloadErrorCode"
|
||||
|
||||
instance TypeScriptErrorType UploadError where
|
||||
typeScriptError _ = genericErrorType @UploadError "UploadErrorCode"
|
||||
|
||||
instance TypeScriptErrorType DeleteError where
|
||||
typeScriptError _ = genericErrorType @DeleteError "DeleteErrorCode"
|
||||
|
||||
instance TypeScriptErrorType DeletePrefixError where
|
||||
typeScriptError _ =
|
||||
genericErrorType @DeletePrefixError "DeletePrefixErrorCode"
|
||||
|
||||
class GenericSamples representation where
|
||||
genericSamples :: [representation value]
|
||||
|
||||
instance GenericSamples U1 where
|
||||
genericSamples = [U1]
|
||||
|
||||
instance GenericSamples V1 where
|
||||
genericSamples = []
|
||||
|
||||
instance SampleValue value => GenericSamples (K1 index value) where
|
||||
genericSamples = [K1 sampleValue]
|
||||
|
||||
instance GenericSamples value => GenericSamples (M1 index metadata value) where
|
||||
genericSamples = map M1 genericSamples
|
||||
|
||||
instance (GenericSamples left, GenericSamples right) =>
|
||||
GenericSamples (left :+: right) where
|
||||
genericSamples =
|
||||
map L1 (genericSamples @left) <> map R1 (genericSamples @right)
|
||||
|
||||
instance (GenericSamples left, GenericSamples right) =>
|
||||
GenericSamples (left :*: right) where
|
||||
genericSamples =
|
||||
[ left :*: right
|
||||
| left <- genericSamples @left
|
||||
, right <- genericSamples @right
|
||||
]
|
||||
|
||||
class SampleValue value where
|
||||
sampleValue :: value
|
||||
|
||||
instance SampleValue Text where
|
||||
sampleValue = "sample"
|
||||
|
||||
genericErrorType
|
||||
:: forall error
|
||||
. (Generic error, GenericSamples (Rep error), A.ToJSON error)
|
||||
=> Text
|
||||
-> TypeScriptError
|
||||
genericErrorType name =
|
||||
errorType name (map errorCode samples)
|
||||
where
|
||||
samples :: [error]
|
||||
samples = map to (genericSamples :: [Rep error ()])
|
||||
errorCode sample =
|
||||
case A.toJSON sample of
|
||||
A.Object value ->
|
||||
case KeyMap.lookup "error" value of
|
||||
Just (A.String code) -> code
|
||||
_ -> error "contract error encoding has no string error field"
|
||||
_ -> error "contract error encoding is not an object"
|
||||
|
||||
genericStringEnum
|
||||
:: forall value
|
||||
. (Generic value, GenericSamples (Rep value), A.ToJSON value)
|
||||
=> Text
|
||||
-> TypeScriptInfo
|
||||
genericStringEnum name =
|
||||
jsonAlias
|
||||
name
|
||||
(T.intercalate " | " (map quote labels))
|
||||
(\case
|
||||
A.String value -> value `elem` labels
|
||||
_ -> False)
|
||||
where
|
||||
samples :: [value]
|
||||
samples = map to (genericSamples :: [Rep value ()])
|
||||
labels = map encodedLabel samples
|
||||
encodedLabel sample =
|
||||
case A.toJSON sample of
|
||||
A.String value -> value
|
||||
_ -> error "contract string enum does not encode as a string"
|
||||
|
||||
errorType :: Text -> [Text] -> TypeScriptError
|
||||
errorType name codes = TypeScriptError
|
||||
{ tseReference = name
|
||||
, tseCodes = codes
|
||||
, tseDefinitions =
|
||||
[ "export type " <> name <> " = "
|
||||
<> T.intercalate " | " (map quote codes)
|
||||
<> ";"
|
||||
]
|
||||
}
|
||||
|
||||
data FieldLocation = QueryField | HeaderField | JsonBody | BinaryBody
|
||||
deriving stock (Eq)
|
||||
|
||||
data RequestField = RequestField
|
||||
{ rfName :: Text
|
||||
, rfWireName :: Text
|
||||
, rfType :: TypeScriptInfo
|
||||
, rfRequired :: Bool
|
||||
, rfLocation :: FieldLocation
|
||||
}
|
||||
|
||||
data EndpointDraft = EndpointDraft
|
||||
{ edPathSegments :: [Text]
|
||||
, edFields :: [RequestField]
|
||||
, edErrors :: [TypeScriptError]
|
||||
}
|
||||
|
||||
data Endpoint = Endpoint
|
||||
{ endpointName :: Text
|
||||
, endpointMethod :: Text
|
||||
, endpointPath :: Text
|
||||
, endpointFields :: [RequestField]
|
||||
, endpointResponse :: TypeScriptInfo
|
||||
, endpointErrors :: [TypeScriptError]
|
||||
}
|
||||
|
||||
class ContractApi api where
|
||||
collectEndpoints :: Proxy api -> EndpointDraft -> [Endpoint]
|
||||
|
||||
instance (ContractApi left, ContractApi right) =>
|
||||
ContractApi (left S.:<|> right) where
|
||||
collectEndpoints _ draft =
|
||||
collectEndpoints (Proxy :: Proxy left) draft
|
||||
<> collectEndpoints (Proxy :: Proxy right) draft
|
||||
|
||||
instance {-# OVERLAPPABLE #-}
|
||||
(KnownSymbol segment, ContractApi sub) =>
|
||||
ContractApi ((segment :: Symbol) S.:> sub) where
|
||||
collectEndpoints _ draft =
|
||||
collectEndpoints (Proxy :: Proxy sub) draft
|
||||
{ edPathSegments =
|
||||
edPathSegments draft <> [T.pack (symbolVal (Proxy :: Proxy segment))]
|
||||
}
|
||||
|
||||
instance ContractApi sub =>
|
||||
ContractApi (S.AuthProtect tag S.:> sub) where
|
||||
collectEndpoints _ = collectEndpoints (Proxy :: Proxy sub)
|
||||
|
||||
instance (TypeScriptErrorType error, ContractApi sub) =>
|
||||
ContractApi (Throws error S.:> sub) where
|
||||
collectEndpoints _ draft =
|
||||
collectEndpoints (Proxy :: Proxy sub) draft
|
||||
{ edErrors =
|
||||
edErrors draft <> [typeScriptError (Proxy :: Proxy error)]
|
||||
}
|
||||
|
||||
instance
|
||||
( KnownSymbol name
|
||||
, RequiredModifiers modifiers
|
||||
, TypeScriptType value
|
||||
, ContractApi sub
|
||||
) =>
|
||||
ContractApi (S.QueryParam' modifiers name value S.:> sub) where
|
||||
collectEndpoints _ draft =
|
||||
collectEndpoints (Proxy :: Proxy sub) draft
|
||||
{ edFields = edFields draft <> [requestField]
|
||||
}
|
||||
where
|
||||
wireName = T.pack (symbolVal (Proxy :: Proxy name))
|
||||
requestField = RequestField
|
||||
{ rfName = lowerCamel wireName
|
||||
, rfWireName = wireName
|
||||
, rfType = typeScriptInfo (Proxy :: Proxy value)
|
||||
, rfRequired = requiredModifiers (Proxy :: Proxy modifiers)
|
||||
, rfLocation = QueryField
|
||||
}
|
||||
|
||||
instance
|
||||
( KnownSymbol name
|
||||
, RequiredModifiers modifiers
|
||||
, TypeScriptType value
|
||||
, ContractApi sub
|
||||
) =>
|
||||
ContractApi (S.Header' modifiers name value S.:> sub) where
|
||||
collectEndpoints _ draft =
|
||||
collectEndpoints (Proxy :: Proxy sub) draft
|
||||
{ edFields = edFields draft <> [requestField]
|
||||
}
|
||||
where
|
||||
wireName = T.pack (symbolVal (Proxy :: Proxy name))
|
||||
requestField = RequestField
|
||||
{ rfName = lowerCamel (stripHeaderPrefix wireName)
|
||||
, rfWireName = wireName
|
||||
, rfType = typeScriptInfo (Proxy :: Proxy value)
|
||||
, rfRequired = requiredModifiers (Proxy :: Proxy modifiers)
|
||||
, rfLocation = HeaderField
|
||||
}
|
||||
|
||||
instance
|
||||
( RequiredModifiers modifiers
|
||||
, TypeScriptType value
|
||||
, ContractApi sub
|
||||
) =>
|
||||
ContractApi (S.ReqBody' modifiers contentTypes value S.:> sub) where
|
||||
collectEndpoints _ draft =
|
||||
collectEndpoints (Proxy :: Proxy sub) draft
|
||||
{ edFields = edFields draft <> [requestField]
|
||||
}
|
||||
where
|
||||
requestField = RequestField
|
||||
{ rfName = "body"
|
||||
, rfWireName = ""
|
||||
, rfType = typeScriptInfo (Proxy :: Proxy value)
|
||||
, rfRequired = requiredModifiers (Proxy :: Proxy modifiers)
|
||||
, rfLocation = JsonBody
|
||||
}
|
||||
|
||||
instance ContractApi sub =>
|
||||
ContractApi
|
||||
(S.StreamBody' modifiers framing contentType value S.:> sub) where
|
||||
collectEndpoints _ draft =
|
||||
collectEndpoints (Proxy :: Proxy sub) draft
|
||||
{ edFields = edFields draft <> [requestField]
|
||||
}
|
||||
where
|
||||
requestField = RequestField
|
||||
{ rfName = "body"
|
||||
, rfWireName = ""
|
||||
, rfType = scalar "Blob"
|
||||
, rfRequired = True
|
||||
, rfLocation = BinaryBody
|
||||
}
|
||||
|
||||
instance
|
||||
(S.ReflectMethod method, TypeScriptType response) =>
|
||||
ContractApi (S.Verb method status contentTypes response) where
|
||||
collectEndpoints _ = pure . finishEndpoint method response
|
||||
where
|
||||
method = TE.decodeUtf8 (S.reflectMethod (Proxy :: Proxy method))
|
||||
response = typeScriptInfo (Proxy :: Proxy response)
|
||||
|
||||
instance
|
||||
(S.ReflectMethod method, TypeScriptType response) =>
|
||||
ContractApi (S.Stream method status framing contentType response) where
|
||||
collectEndpoints _ = pure . finishEndpoint method response
|
||||
where
|
||||
method = TE.decodeUtf8 (S.reflectMethod (Proxy :: Proxy method))
|
||||
response = typeScriptInfo (Proxy :: Proxy response)
|
||||
|
||||
finishEndpoint :: Text -> TypeScriptInfo -> EndpointDraft -> Endpoint
|
||||
finishEndpoint method response draft = Endpoint
|
||||
{ endpointName = lowerCamel (last (edPathSegments draft))
|
||||
, endpointMethod = method
|
||||
, endpointPath = "/" <> T.intercalate "/" (edPathSegments draft)
|
||||
, endpointFields = edFields draft
|
||||
, endpointResponse = response
|
||||
, endpointErrors = edErrors draft
|
||||
}
|
||||
|
||||
class RequiredModifiers (modifiers :: [Type]) where
|
||||
requiredModifiers :: Proxy modifiers -> Bool
|
||||
|
||||
instance RequiredModifiers '[] where
|
||||
requiredModifiers _ = False
|
||||
|
||||
instance RequiredModifiers (S.Required ': rest) where
|
||||
requiredModifiers _ = True
|
||||
|
||||
instance {-# OVERLAPPABLE #-} RequiredModifiers rest =>
|
||||
RequiredModifiers (modifier ': rest) where
|
||||
requiredModifiers _ = requiredModifiers (Proxy :: Proxy rest)
|
||||
|
||||
class TypeScriptHeaders (headers :: [Type]) where
|
||||
typeScriptHeaders :: Proxy headers -> [RequestField]
|
||||
|
||||
instance TypeScriptHeaders '[] where
|
||||
typeScriptHeaders _ = []
|
||||
|
||||
instance
|
||||
( KnownSymbol name
|
||||
, RequiredModifiers modifiers
|
||||
, TypeScriptType value
|
||||
, TypeScriptHeaders rest
|
||||
) =>
|
||||
TypeScriptHeaders (S.Header' modifiers name value ': rest) where
|
||||
typeScriptHeaders _ = field : typeScriptHeaders (Proxy :: Proxy rest)
|
||||
where
|
||||
wireName = T.pack (symbolVal (Proxy :: Proxy name))
|
||||
field = RequestField
|
||||
{ rfName = lowerCamel wireName
|
||||
, rfWireName = wireName
|
||||
, rfType = typeScriptInfo (Proxy :: Proxy value)
|
||||
, rfRequired = requiredModifiers (Proxy :: Proxy modifiers)
|
||||
, rfLocation = HeaderField
|
||||
}
|
||||
|
||||
instance TypeScriptHeaders headers =>
|
||||
TypeScriptType
|
||||
(S.Headers headers (S.SourceIO BS.ByteString)) where
|
||||
typeScriptInfo _ =
|
||||
let headers = typeScriptHeaders (Proxy :: Proxy headers)
|
||||
properties =
|
||||
TypeScriptProperty "body" (scalar "ReadableStream<Uint8Array>")
|
||||
: map headerProperty headers
|
||||
in interface "DownloadResponse" properties
|
||||
where
|
||||
headerProperty field =
|
||||
TypeScriptProperty
|
||||
(rfName field)
|
||||
((rfType field)
|
||||
{ tsiReference =
|
||||
tsiReference (rfType field)
|
||||
<> (if rfRequired field then "" else " | null")
|
||||
})
|
||||
|
||||
renderTypeScriptContract :: Text
|
||||
renderTypeScriptContract =
|
||||
T.dropWhileEnd (== '\n')
|
||||
( T.intercalate "\n\n" $
|
||||
coreDefinitions
|
||||
<> definitions
|
||||
<> concatMap renderEndpointTypes endpoints
|
||||
<> [renderContractMetadata endpoints, renderEndpointInterface endpoints]
|
||||
)
|
||||
<> "\n"
|
||||
where
|
||||
endpoints = collectEndpoints
|
||||
(Proxy :: Proxy ProductApi)
|
||||
EndpointDraft
|
||||
{ edPathSegments = []
|
||||
, edFields = []
|
||||
, edErrors = []
|
||||
}
|
||||
definitions = nub $
|
||||
concatMap endpointDefinitions endpoints
|
||||
|
||||
renderContractFixtures :: BL.ByteString
|
||||
renderContractFixtures =
|
||||
BL.snoc (A.encode fixture) 10
|
||||
where
|
||||
operation = OperationId "operation-1"
|
||||
csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
path = RelativePath "folder/문서.txt"
|
||||
entry = EntryResponse
|
||||
{ entryResponseName = "문서.txt"
|
||||
, entryResponseIsPrefix = False
|
||||
, entryResponseSize = Just 7
|
||||
, entryResponsePath = unRelativePath path
|
||||
}
|
||||
upload = UploadResponse
|
||||
{ uploadResponseOperationId = operation
|
||||
, uploadResponseResult = MutationSucceeded
|
||||
, uploadResponseIdempotencyWindowSeconds = 3600
|
||||
}
|
||||
deletion = DeleteResponse
|
||||
{ deleteResponseOperationId = operation
|
||||
, deleteResponseResult = MutationPending
|
||||
, deleteResponseIdempotencyWindowSeconds = 3600
|
||||
}
|
||||
batchItem = DeleteBatchItem
|
||||
{ deleteBatchPath = path
|
||||
, deleteBatchOperationId = operation
|
||||
}
|
||||
itemResponse = DeleteItemResponse
|
||||
{ deleteItemPath = path
|
||||
, deleteItemOperationId = operation
|
||||
, deleteItemResult = Just MutationSucceeded
|
||||
, deleteItemError = Nothing
|
||||
}
|
||||
fixture = A.object
|
||||
[ "session_response" A..= SessionResponse
|
||||
{ sessionResponseUserId = UserId "user-1"
|
||||
, sessionResponseDisplay = Just "User"
|
||||
, sessionResponseCsrf = csrf
|
||||
}
|
||||
, "entry_response" A..= entry
|
||||
, "browse_response" A..= BrowseResponse
|
||||
{ browseEntries = [entry]
|
||||
, browseContinuation = Just (PaginationToken "next-page")
|
||||
, browsePartial = False
|
||||
}
|
||||
, "delete_request" A..= DeleteRequest
|
||||
{ deleteRoot = Shared
|
||||
, deletePath = path
|
||||
, deleteOperationId = operation
|
||||
, deleteCsrf = csrf
|
||||
}
|
||||
, "upload_response" A..= upload
|
||||
, "delete_response" A..= deletion
|
||||
, "delete_plan_response" A..= DeletePlanResponse
|
||||
{ deletePlanPaths = [path]
|
||||
, deletePlanContinuation = Just (PaginationToken "next-page")
|
||||
}
|
||||
, "delete_batch_item" A..= batchItem
|
||||
, "delete_batch_request" A..= DeleteBatchRequest
|
||||
{ deleteBatchRoot = Shared
|
||||
, deleteBatchCsrf = csrf
|
||||
, deleteBatchItems = [batchItem]
|
||||
}
|
||||
, "delete_item_response" A..= itemResponse
|
||||
, "delete_batch_response" A..= DeleteBatchResponse [itemResponse]
|
||||
, "errors" A..=
|
||||
[ A.toJSON AuthenticationRequired
|
||||
, A.toJSON MalformedAuthentication
|
||||
, A.toJSON (BrowseObjectStoreFailed "store")
|
||||
, A.toJSON (DownloadObjectStoreFailed "store")
|
||||
, A.toJSON DownloadNotFound
|
||||
, A.toJSON DownloadRangeUnsupported
|
||||
, A.toJSON DownloadThrottleUnavailable
|
||||
, A.toJSON DownloadBandwidthExceeded
|
||||
, A.toJSON UploadCsrfRejected
|
||||
, A.toJSON (UploadInvalidBodyStream "body")
|
||||
, A.toJSON UploadSizeMismatch
|
||||
, A.toJSON UploadTooLarge
|
||||
, A.toJSON UploadContentIdentityMismatch
|
||||
, A.toJSON UploadThrottleUnavailable
|
||||
, A.toJSON UploadBandwidthExceeded
|
||||
, A.toJSON (UploadMutationRejected "mutation")
|
||||
, A.toJSON DeleteCsrfRejected
|
||||
, A.toJSON (DeleteMutationRejected "mutation")
|
||||
, A.toJSON DeletePrefixCsrfRejected
|
||||
, A.toJSON (DeletePrefixMutationRejected "prefix")
|
||||
]
|
||||
]
|
||||
|
||||
contractShapeErrors :: [Text]
|
||||
contractShapeErrors =
|
||||
[ label
|
||||
| (label, shape, values) <- wireShapeSamples
|
||||
, value <- values
|
||||
, not (tsiAccepts shape value)
|
||||
]
|
||||
|
||||
wireShapeSamples :: [(Text, TypeScriptInfo, [A.Value])]
|
||||
wireShapeSamples =
|
||||
[ samples @SessionResponse "SessionResponse"
|
||||
[ SessionResponse user (Just "User") csrf
|
||||
, SessionResponse user Nothing csrf
|
||||
]
|
||||
, samples @EntryResponse "EntryResponse"
|
||||
[ EntryResponse "file.txt" False (Just 7) "folder/file.txt"
|
||||
, EntryResponse "folder" True Nothing "folder"
|
||||
]
|
||||
, samples @BrowseResponse "BrowseResponse"
|
||||
[ BrowseResponse [entry] (Just continuation) False
|
||||
, BrowseResponse [] Nothing True
|
||||
]
|
||||
, samples @DeleteRequest "DeleteRequest"
|
||||
[DeleteRequest Shared path operation csrf]
|
||||
, samples @UploadResponse "UploadResponse"
|
||||
[UploadResponse operation MutationSucceeded 3600]
|
||||
, samples @DeleteResponse "DeleteResponse"
|
||||
[DeleteResponse operation MutationPending 3600]
|
||||
, samples @DeletePlanResponse "DeletePlanResponse"
|
||||
[ DeletePlanResponse [path] (Just continuation)
|
||||
, DeletePlanResponse [] Nothing
|
||||
]
|
||||
, samples @DeleteBatchItem "DeleteBatchItem"
|
||||
[batchItem]
|
||||
, samples @DeleteBatchRequest "DeleteBatchRequest"
|
||||
[DeleteBatchRequest Shared csrf [batchItem]]
|
||||
, samples @DeleteItemResponse "DeleteItemResponse"
|
||||
[ DeleteItemResponse path operation (Just MutationSucceeded) Nothing
|
||||
, DeleteItemResponse path operation Nothing (Just "failed")
|
||||
]
|
||||
, samples @DeleteBatchResponse "DeleteBatchResponse"
|
||||
[DeleteBatchResponse [itemResponse]]
|
||||
]
|
||||
where
|
||||
user = UserId "user-1"
|
||||
csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
path = RelativePath "folder/file.txt"
|
||||
operation = OperationId "operation-1"
|
||||
continuation = PaginationToken "next-page"
|
||||
entry = EntryResponse "file.txt" False (Just 7) "folder/file.txt"
|
||||
batchItem = DeleteBatchItem path operation
|
||||
itemResponse =
|
||||
DeleteItemResponse path operation (Just MutationSucceeded) Nothing
|
||||
samples
|
||||
:: forall value
|
||||
. (TypeScriptType value, A.ToJSON value)
|
||||
=> Text
|
||||
-> [value]
|
||||
-> (Text, TypeScriptInfo, [A.Value])
|
||||
samples label values =
|
||||
(label, typeScriptInfo (Proxy :: Proxy value), map A.toJSON values)
|
||||
|
||||
coreDefinitions :: [Text]
|
||||
coreDefinitions =
|
||||
[ T.unlines
|
||||
[ "export interface DeclaredApiError<Code extends string> {"
|
||||
, " error: Code;"
|
||||
, " message: string;"
|
||||
, " details: unknown | null;"
|
||||
, "}"
|
||||
]
|
||||
, T.unlines
|
||||
[ "export type ApiFailure<Code extends string> ="
|
||||
, " | { kind: 'http'; status: number; error: DeclaredApiError<Code> }"
|
||||
, " | { kind: 'unexpected-http'; status: number; message: string }"
|
||||
, " | { kind: 'network'; message: string };"
|
||||
]
|
||||
, T.unlines
|
||||
[ "export type ApiResult<Value, Code extends string> ="
|
||||
, " | { ok: true; value: Value }"
|
||||
, " | { ok: false; failure: ApiFailure<Code> };"
|
||||
]
|
||||
, T.unlines
|
||||
[ "export const contractInvariants = {"
|
||||
, " relativePath: 'NFC canonical relative path with no empty, dot, backslash, or control segment',"
|
||||
, " operationId: '1 to 128 URL-safe ASCII characters from the unreserved set',"
|
||||
, " csrfToken: '43 base64url characters',"
|
||||
, " contentSha256: '64 lowercase hexadecimal characters',"
|
||||
, " declaredSize: 'positive integer no greater than Number.MAX_SAFE_INTEGER'"
|
||||
, "} as const;"
|
||||
]
|
||||
]
|
||||
|
||||
endpointDefinitions :: Endpoint -> [Text]
|
||||
endpointDefinitions Endpoint{..} =
|
||||
concatMap (tsiDefinitions . rfType) endpointFields
|
||||
<> tsiDefinitions endpointResponse
|
||||
<> concatMap tseDefinitions endpointErrors
|
||||
|
||||
renderEndpointTypes :: Endpoint -> [Text]
|
||||
renderEndpointTypes endpoint =
|
||||
[renderRequestType endpoint, renderFailureType endpoint]
|
||||
|
||||
renderRequestType :: Endpoint -> Text
|
||||
renderRequestType Endpoint{..}
|
||||
| null endpointFields =
|
||||
"export type " <> endpointRequestName endpointName
|
||||
<> " = Record<string, never>;"
|
||||
| otherwise =
|
||||
T.unlines $
|
||||
["export interface " <> endpointRequestName endpointName <> " {"]
|
||||
<> map renderRequestField endpointFields
|
||||
<> ["}"]
|
||||
where
|
||||
renderRequestField RequestField{..} =
|
||||
" " <> rfName <> (if rfRequired then ": " else "?: ")
|
||||
<> tsiReference rfType <> ";"
|
||||
|
||||
renderFailureType :: Endpoint -> Text
|
||||
renderFailureType Endpoint{..} =
|
||||
"export type " <> upperCamel endpointName <> "FailureCode = "
|
||||
<> case nub (map tseReference endpointErrors) of
|
||||
[] -> "never;"
|
||||
references -> T.intercalate " | " references <> ";"
|
||||
|
||||
renderContractMetadata :: [Endpoint] -> Text
|
||||
renderContractMetadata endpoints =
|
||||
T.unlines $
|
||||
["export const productContract = {"]
|
||||
<> concatMap renderEndpointMetadata endpoints
|
||||
<> ["} as const;"]
|
||||
|
||||
renderEndpointMetadata :: Endpoint -> [Text]
|
||||
renderEndpointMetadata Endpoint{..} =
|
||||
[ " " <> endpointName <> ": {"
|
||||
, " method: " <> quote endpointMethod <> ","
|
||||
, " path: " <> quote endpointPath <> ","
|
||||
, " query: " <> renderWireMap QueryField endpointFields <> ","
|
||||
, " headers: " <> renderWireMap HeaderField endpointFields <> ","
|
||||
, " body: " <> quote bodyKind <> ","
|
||||
, " failureCodes: ["
|
||||
<> T.intercalate ", " (map quote failureCodes)
|
||||
<> "]"
|
||||
, " },"
|
||||
]
|
||||
where
|
||||
bodyKind
|
||||
| any ((== JsonBody) . rfLocation) endpointFields = "json"
|
||||
| any ((== BinaryBody) . rfLocation) endpointFields = "binary"
|
||||
| otherwise = "none"
|
||||
failureCodes = nub (concatMap tseCodes endpointErrors)
|
||||
|
||||
renderWireMap :: FieldLocation -> [RequestField] -> Text
|
||||
renderWireMap location fields =
|
||||
"{ "
|
||||
<> T.intercalate ", "
|
||||
[ rfName field <> ": " <> quote (rfWireName field)
|
||||
| field <- fields
|
||||
, rfLocation field == location
|
||||
]
|
||||
<> " }"
|
||||
|
||||
renderEndpointInterface :: [Endpoint] -> Text
|
||||
renderEndpointInterface endpoints =
|
||||
T.unlines $
|
||||
["export interface ProductEndpoint {"]
|
||||
<> map renderMethod endpoints
|
||||
<> ["}"]
|
||||
where
|
||||
renderMethod Endpoint{..} =
|
||||
" " <> endpointName <> "(request: " <> endpointRequestName endpointName
|
||||
<> "): Promise<ApiResult<"
|
||||
<> tsiReference endpointResponse
|
||||
<> ", "
|
||||
<> upperCamel endpointName
|
||||
<> "FailureCode>>;"
|
||||
|
||||
stripHeaderPrefix :: Text -> Text
|
||||
stripHeaderPrefix value =
|
||||
maybe value id (T.stripPrefix "X-" value)
|
||||
|
||||
lowerCamel :: Text -> Text
|
||||
lowerCamel value =
|
||||
case wordsBySeparator value of
|
||||
[] -> ""
|
||||
first : rest ->
|
||||
T.toLower first <> T.concat (map capitalize rest)
|
||||
|
||||
upperCamel :: Text -> Text
|
||||
upperCamel value =
|
||||
case T.uncons value of
|
||||
Nothing -> ""
|
||||
Just (first, rest) -> T.cons (toUpper first) rest
|
||||
|
||||
capitalize :: Text -> Text
|
||||
capitalize value =
|
||||
case T.uncons value of
|
||||
Nothing -> ""
|
||||
Just (first, rest) ->
|
||||
T.cons (toUpper first) (T.map toLower rest)
|
||||
|
||||
wordsBySeparator :: Text -> [Text]
|
||||
wordsBySeparator =
|
||||
T.words
|
||||
. T.map (\character -> if isAlphaNum character then character else ' ')
|
||||
|
||||
endpointRequestName :: Text -> Text
|
||||
endpointRequestName name = upperCamel name <> "EndpointRequest"
|
||||
|
||||
quote :: Text -> Text
|
||||
quote value = "'" <> T.replace "'" "\\'" value <> "'"
|
||||
|
|
@ -22,6 +22,8 @@ module Nuthatch.Types
|
|||
, parseState
|
||||
, externalLabel
|
||||
, parseExternal
|
||||
, mutationResultLabel
|
||||
, parseMutationResult
|
||||
, validateRelativePath
|
||||
, validateOperationId
|
||||
, validateContentSha256
|
||||
|
|
@ -44,11 +46,12 @@ import Data.Aeson
|
|||
import Data.Char (isAsciiLower, isAsciiUpper, isControl, isDigit)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import GHC.Generics (Generic)
|
||||
import qualified Data.Text.Normalize as TN
|
||||
import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))
|
||||
|
||||
data Root = Personal | Shared
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
rootLabel :: Root -> Text
|
||||
rootLabel Personal = "personal"
|
||||
|
|
@ -199,7 +202,29 @@ data MutationResult
|
|||
| MutationFailed
|
||||
| MutationRejected
|
||||
| MutationConflict
|
||||
deriving stock (Eq, Show)
|
||||
deriving stock (Eq, Generic, Show)
|
||||
|
||||
mutationResultLabel :: MutationResult -> Text
|
||||
mutationResultLabel MutationPending = "pending"
|
||||
mutationResultLabel MutationSucceeded = "succeeded"
|
||||
mutationResultLabel MutationFailed = "failed"
|
||||
mutationResultLabel MutationRejected = "rejected"
|
||||
mutationResultLabel MutationConflict = "conflict"
|
||||
|
||||
parseMutationResult :: Text -> Maybe MutationResult
|
||||
parseMutationResult "pending" = Just MutationPending
|
||||
parseMutationResult "succeeded" = Just MutationSucceeded
|
||||
parseMutationResult "failed" = Just MutationFailed
|
||||
parseMutationResult "rejected" = Just MutationRejected
|
||||
parseMutationResult "conflict" = Just MutationConflict
|
||||
parseMutationResult _ = Nothing
|
||||
|
||||
instance ToJSON MutationResult where
|
||||
toJSON = toJSON . mutationResultLabel
|
||||
|
||||
instance FromJSON MutationResult where
|
||||
parseJSON = withText "MutationResult" $ \raw ->
|
||||
maybe (fail "invalid mutation result") pure (parseMutationResult raw)
|
||||
|
||||
instance ToJSON Root where
|
||||
toJSON = toJSON . rootLabel
|
||||
|
|
|
|||
104
test/Nuthatch/Test/HttpContract.hs
Normal file
104
test/Nuthatch/Test/HttpContract.hs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
module Nuthatch.Test.HttpContract
|
||||
( tests
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.Key as Key
|
||||
import qualified Data.Aeson.KeyMap as KeyMap
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Proxy (Proxy (..))
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.IO as TIO
|
||||
import Nuthatch.Api
|
||||
import Nuthatch.Http.Contract
|
||||
( renderContractFixtures
|
||||
, renderTypeScriptContract
|
||||
, contractShapeErrors
|
||||
)
|
||||
import Test.Tasty (TestTree, testGroup)
|
||||
import Test.Tasty.HUnit ((@?=), Assertion, assertBool, testCase)
|
||||
|
||||
tests :: TestTree
|
||||
tests = testGroup "Haskell to TypeScript contract"
|
||||
[ testCase "checked artifacts are byte-identical to production generation" $ do
|
||||
contract <- TIO.readFile "web/src/generated/contract.ts"
|
||||
fixtures <- BL.readFile "web/test/fixtures/contract.json"
|
||||
contract @?= renderTypeScriptContract
|
||||
fixtures @?= renderContractFixtures
|
||||
, testCase "shared fixtures round-trip every wire DTO" $ do
|
||||
fixture <- decodeFixture
|
||||
assertFixture fixture "session_response" (Proxy :: Proxy SessionResponse)
|
||||
assertFixture fixture "entry_response" (Proxy :: Proxy EntryResponse)
|
||||
assertFixture fixture "browse_response" (Proxy :: Proxy BrowseResponse)
|
||||
assertFixture fixture "delete_request" (Proxy :: Proxy DeleteRequest)
|
||||
assertFixture fixture "upload_response" (Proxy :: Proxy UploadResponse)
|
||||
assertFixture fixture "delete_response" (Proxy :: Proxy DeleteResponse)
|
||||
assertFixture fixture "delete_plan_response"
|
||||
(Proxy :: Proxy DeletePlanResponse)
|
||||
assertFixture fixture "delete_batch_item" (Proxy :: Proxy DeleteBatchItem)
|
||||
assertFixture fixture "delete_batch_request"
|
||||
(Proxy :: Proxy DeleteBatchRequest)
|
||||
assertFixture fixture "delete_item_response"
|
||||
(Proxy :: Proxy DeleteItemResponse)
|
||||
assertFixture fixture "delete_batch_response"
|
||||
(Proxy :: Proxy DeleteBatchResponse)
|
||||
, testCase "explicit TypeScript mappings accept the Aeson wire shapes" $
|
||||
contractShapeErrors @?= []
|
||||
, testCase "generated error unions cover every fixture error code" $ do
|
||||
fixture <- decodeFixture
|
||||
errors <- fixtureValue fixture "errors"
|
||||
case errors of
|
||||
A.Array values ->
|
||||
mapM_ assertGeneratedErrorCode (toList values)
|
||||
_ -> fail "errors fixture must be an array"
|
||||
, testCase "non-TypeScript invariants remain explicit" $ do
|
||||
let contract = renderTypeScriptContract
|
||||
assertBool "relative path invariant is named"
|
||||
("relativePath:" `T.isInfixOf` contract)
|
||||
assertBool "operation id invariant is named"
|
||||
("operationId:" `T.isInfixOf` contract)
|
||||
assertBool "content identity invariant is named"
|
||||
("contentSha256:" `T.isInfixOf` contract)
|
||||
]
|
||||
|
||||
decodeFixture :: IO A.Object
|
||||
decodeFixture = do
|
||||
bytes <- BL.readFile "web/test/fixtures/contract.json"
|
||||
value <- either fail pure (A.eitherDecode bytes)
|
||||
case value of
|
||||
A.Object fixture -> pure fixture
|
||||
_ -> fail "contract fixture must be an object"
|
||||
|
||||
assertFixture
|
||||
:: (A.FromJSON value, A.ToJSON value)
|
||||
=> A.Object
|
||||
-> Key.Key
|
||||
-> Proxy value
|
||||
-> Assertion
|
||||
assertFixture fixture key proxy = do
|
||||
raw <- fixtureValue fixture key
|
||||
decoded <- case A.fromJSON raw of
|
||||
A.Error message -> fail message
|
||||
A.Success value -> pure value
|
||||
A.toJSON (asProxyType proxy decoded) @?= raw
|
||||
|
||||
fixtureValue :: A.Object -> Key.Key -> IO A.Value
|
||||
fixtureValue fixture key =
|
||||
maybe (fail ("missing fixture " <> show key)) pure (KeyMap.lookup key fixture)
|
||||
|
||||
assertGeneratedErrorCode :: A.Value -> Assertion
|
||||
assertGeneratedErrorCode value =
|
||||
case value of
|
||||
A.Object objectValue ->
|
||||
case KeyMap.lookup "error" objectValue of
|
||||
Just (A.String code) ->
|
||||
assertBool ("missing generated error code " <> T.unpack code)
|
||||
(("'" <> code <> "'") `T.isInfixOf` renderTypeScriptContract)
|
||||
_ -> fail "error fixture must contain an error string"
|
||||
_ -> fail "error fixture must be an object"
|
||||
|
||||
asProxyType :: Proxy value -> value -> value
|
||||
asProxyType _ = id
|
||||
|
||||
toList :: Foldable collection => collection value -> [value]
|
||||
toList = foldr (:) []
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
module Main (main) where
|
||||
|
||||
import qualified Nuthatch.Test.Http as Http
|
||||
import qualified Nuthatch.Test.HttpContract as HttpContract
|
||||
import qualified Nuthatch.Test.Identity as Identity
|
||||
import qualified Nuthatch.Test.Oidc as Oidc
|
||||
import qualified Nuthatch.Test.Path as Path
|
||||
|
|
@ -23,4 +24,5 @@ main = defaultMain $
|
|||
, Runtime.tests
|
||||
, Oidc.tests
|
||||
, Http.tests
|
||||
, HttpContract.tests
|
||||
]
|
||||
|
|
|
|||
156
test/WebContractIntegration.hs
Normal file
156
test/WebContractIntegration.hs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
module Main (main) where
|
||||
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.Text as T
|
||||
import Network.Wai (Request)
|
||||
import Network.Wai.Handler.Warp (testWithApplication)
|
||||
import Nuthatch.Api
|
||||
import Nuthatch.Types
|
||||
import Servant
|
||||
import Servant.Server.Experimental.Auth
|
||||
( AuthHandler
|
||||
, mkAuthHandler
|
||||
)
|
||||
import Servant.Types.SourceT (source)
|
||||
import System.Process (callProcess)
|
||||
|
||||
main :: IO ()
|
||||
main =
|
||||
testWithApplication (pure application) $ \port ->
|
||||
callProcess
|
||||
"node"
|
||||
[ "web/test/contract-integration.mjs"
|
||||
, "http://127.0.0.1:" <> show port
|
||||
]
|
||||
|
||||
application :: Application
|
||||
application =
|
||||
serveWithContext
|
||||
(Proxy :: Proxy ProductApi)
|
||||
(authentication :. EmptyContext)
|
||||
productServer
|
||||
|
||||
authentication :: AuthHandler Request SessionContext
|
||||
authentication = mkAuthHandler $ \_ ->
|
||||
pure SessionContext
|
||||
{ sessionUser = SessionUser
|
||||
{ suIssuer = "issuer"
|
||||
, suSubject = "subject"
|
||||
, suUserId = UserId "user-1"
|
||||
, suDisplay = Just "User"
|
||||
}
|
||||
, sessionCsrf =
|
||||
Just (CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
}
|
||||
|
||||
productServer :: Server ProductApi
|
||||
productServer =
|
||||
sessionHandler
|
||||
:<|> browseHandler
|
||||
:<|> downloadHandler
|
||||
:<|> uploadHandler
|
||||
:<|> deleteHandler
|
||||
:<|> deletePlanHandler
|
||||
:<|> deleteBatchHandler
|
||||
|
||||
sessionHandler :: SessionContext -> Handler SessionResponse
|
||||
sessionHandler session =
|
||||
pure SessionResponse
|
||||
{ sessionResponseUserId = suUserId (sessionUser session)
|
||||
, sessionResponseDisplay = suDisplay (sessionUser session)
|
||||
, sessionResponseCsrf =
|
||||
CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
|
||||
browseHandler
|
||||
:: SessionContext
|
||||
-> Maybe Root
|
||||
-> Maybe BrowsePrefix
|
||||
-> Maybe SearchQuery
|
||||
-> Maybe PaginationToken
|
||||
-> Handler BrowseResponse
|
||||
browseHandler _ _ prefix query continuation =
|
||||
pure BrowseResponse
|
||||
{ browseEntries =
|
||||
[ EntryResponse
|
||||
{ entryResponseName = maybe "root" browsePrefixName prefix
|
||||
, entryResponseIsPrefix = False
|
||||
, entryResponseSize = Just 3
|
||||
, entryResponsePath = maybe "root" browsePrefixName prefix
|
||||
}
|
||||
]
|
||||
, browseContinuation = continuation
|
||||
, browsePartial = maybe False (const True) query
|
||||
}
|
||||
where
|
||||
browsePrefixName (BrowsePrefix selected) =
|
||||
maybe "" unRelativePath selected
|
||||
|
||||
downloadHandler
|
||||
:: SessionContext
|
||||
-> Root
|
||||
-> RelativePath
|
||||
-> Maybe T.Text
|
||||
-> Handler DownloadSuccess
|
||||
downloadHandler _ _ _ _ =
|
||||
pure $
|
||||
addHeader
|
||||
"attachment; filename=\"file.txt\"" $
|
||||
addHeader "nosniff" $
|
||||
addHeader "default-src 'none'" $
|
||||
addHeader 3 (source ["abc"])
|
||||
|
||||
uploadHandler
|
||||
:: SessionContext
|
||||
-> Root
|
||||
-> RelativePath
|
||||
-> OperationId
|
||||
-> DeclaredSize
|
||||
-> ContentSha256
|
||||
-> CsrfToken
|
||||
-> SourceIO BS.ByteString
|
||||
-> Handler UploadResponse
|
||||
uploadHandler _ _ _ operation _ _ _ _ =
|
||||
pure UploadResponse
|
||||
{ uploadResponseOperationId = operation
|
||||
, uploadResponseResult = MutationSucceeded
|
||||
, uploadResponseIdempotencyWindowSeconds = 3600
|
||||
}
|
||||
|
||||
deleteHandler
|
||||
:: SessionContext
|
||||
-> DeleteRequest
|
||||
-> Handler DeleteResponse
|
||||
deleteHandler _ request =
|
||||
pure DeleteResponse
|
||||
{ deleteResponseOperationId = deleteOperationId request
|
||||
, deleteResponseResult = MutationSucceeded
|
||||
, deleteResponseIdempotencyWindowSeconds = 3600
|
||||
}
|
||||
|
||||
deletePlanHandler
|
||||
:: SessionContext
|
||||
-> Root
|
||||
-> RelativePath
|
||||
-> Maybe PaginationToken
|
||||
-> Handler DeletePlanResponse
|
||||
deletePlanHandler _ _ path continuation =
|
||||
pure DeletePlanResponse
|
||||
{ deletePlanPaths = [path]
|
||||
, deletePlanContinuation = continuation
|
||||
}
|
||||
|
||||
deleteBatchHandler
|
||||
:: SessionContext
|
||||
-> DeleteBatchRequest
|
||||
-> Handler DeleteBatchResponse
|
||||
deleteBatchHandler _ request =
|
||||
pure $ DeleteBatchResponse
|
||||
[ DeleteItemResponse
|
||||
{ deleteItemPath = deleteBatchPath item
|
||||
, deleteItemOperationId = deleteBatchOperationId item
|
||||
, deleteItemResult = Just MutationSucceeded
|
||||
, deleteItemError = Nothing
|
||||
}
|
||||
| item <- deleteBatchItems request
|
||||
]
|
||||
|
|
@ -18,7 +18,7 @@ for heading in \
|
|||
done
|
||||
|
||||
for ownership in \
|
||||
'| HTTP | `src/Nuthatch/Api.hs`, `src/Nuthatch/App.hs`, `src/Nuthatch/Http/Server.hs`, `src/Nuthatch/Http/Auth.hs`, `src/Nuthatch/Http/Error.hs`, `src/Nuthatch/Browse.hs`, `src/Nuthatch/Transfer.hs` | `make check-product` |' \
|
||||
'| HTTP | `src/Nuthatch/Api.hs`, `src/Nuthatch/App.hs`, `src/Nuthatch/Http/Server.hs`, `src/Nuthatch/Http/Auth.hs`, `src/Nuthatch/Http/Error.hs`, `src/Nuthatch/Http/Contract.hs`, `src/Nuthatch/Browse.hs`, `src/Nuthatch/Transfer.hs`, `web/src/generated/contract.ts`, `web/src/transport.ts` | `make check-product` |' \
|
||||
'| SQL | `sql/`, `src/Nuthatch/Db.hs`, `src/Nuthatch/Protocol.hs` | `make check-storage-foundation` |' \
|
||||
'| Identity encoding | `src/Nuthatch/Paths.hs` | `make test` |' \
|
||||
'| Content identity | `src/Nuthatch/Streaming.hs`, `src/Nuthatch/S3.hs` | `make check-streaming` |' \
|
||||
|
|
@ -35,6 +35,7 @@ for path in \
|
|||
src/Nuthatch/Config.hs \
|
||||
src/Nuthatch/Db.hs \
|
||||
src/Nuthatch/Http/Auth.hs \
|
||||
src/Nuthatch/Http/Contract.hs \
|
||||
src/Nuthatch/Http/Error.hs \
|
||||
src/Nuthatch/Http/Server.hs \
|
||||
src/Nuthatch/Paths.hs \
|
||||
|
|
@ -42,6 +43,8 @@ for path in \
|
|||
src/Nuthatch/S3.hs \
|
||||
src/Nuthatch/Streaming.hs \
|
||||
src/Nuthatch/Transfer.hs \
|
||||
web/src/generated/contract.ts \
|
||||
web/src/transport.ts \
|
||||
sql/001_initial_schema.sql \
|
||||
deploy/kubernetes/nuthatch.yaml.in \
|
||||
tests/kind/fixture/target.sh \
|
||||
|
|
|
|||
14
tests/web-contract.sh
Executable file
14
tests/web-contract.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
temporary="$(mktemp -d)"
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
|
||||
cabal run nuthatch-generate-web-contract --offline -- \
|
||||
"$temporary/contract.ts" \
|
||||
"$temporary/contract.json"
|
||||
cmp web/src/generated/contract.ts "$temporary/contract.ts"
|
||||
cmp web/test/fixtures/contract.json "$temporary/contract.json"
|
||||
cabal run nuthatch-web-contract-integration --offline
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type {
|
||||
DeleteBatchItem as DeleteItem,
|
||||
EntryResponse as Entry,
|
||||
Root,
|
||||
SessionResponse as Session
|
||||
} from './generated/contract.ts';
|
||||
import {
|
||||
captureOperation,
|
||||
deleteWarning,
|
||||
|
|
@ -10,36 +16,10 @@
|
|||
sharedUploadWarning,
|
||||
unfinishedDeleteItems,
|
||||
type CapturedOperation,
|
||||
type DeleteItem,
|
||||
type DeleteItemResult,
|
||||
type MutationFollowup,
|
||||
type MutationResult,
|
||||
type PendingDelete,
|
||||
type Root
|
||||
type PendingDelete
|
||||
} from './mutations';
|
||||
|
||||
type Session = {
|
||||
user_id: string;
|
||||
display: string | null;
|
||||
csrf: string;
|
||||
};
|
||||
|
||||
type Entry = {
|
||||
name: string;
|
||||
is_prefix: boolean;
|
||||
size: number | null;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type BrowseResponse = {
|
||||
entries: Entry[];
|
||||
continuation: string | null;
|
||||
partial: boolean;
|
||||
};
|
||||
|
||||
type MutationResponse = {
|
||||
result: MutationResult;
|
||||
};
|
||||
import { browserProductClient, requireValue } from './transport.ts';
|
||||
|
||||
type PendingUpload = {
|
||||
file: File;
|
||||
|
|
@ -69,7 +49,7 @@
|
|||
|
||||
onMount(async () => {
|
||||
try {
|
||||
session = await requestJson<Session>('/api/session');
|
||||
session = requireValue(await browserProductClient.session({}));
|
||||
resumableDelete = readPendingDelete(
|
||||
localStorage.getItem(recursiveStorageKey)
|
||||
);
|
||||
|
|
@ -79,36 +59,18 @@
|
|||
}
|
||||
});
|
||||
|
||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string };
|
||||
message = body.message ?? message;
|
||||
} catch {
|
||||
// The HTTP status remains the stable fallback when no JSON error exists.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function apiUrl(path: string, values: Record<string, string>): string {
|
||||
return `${path}?${new URLSearchParams(values).toString()}`;
|
||||
}
|
||||
|
||||
async function browse(append: boolean): Promise<void> {
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const values: Record<string, string> = { root };
|
||||
if (prefix !== '') values.prefix = prefix;
|
||||
if (query !== '') values.q = query;
|
||||
if (append && continuation !== null) values.continuation = continuation;
|
||||
const page = await requestJson<BrowseResponse>(apiUrl('/api/browse', values), {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
const page = requireValue(
|
||||
await browserProductClient.browse({
|
||||
root,
|
||||
...(prefix === '' ? {} : { prefix }),
|
||||
...(query === '' ? {} : { q: query }),
|
||||
...(append && continuation !== null ? { continuation } : {})
|
||||
})
|
||||
);
|
||||
entries = append ? [...entries, ...page.entries] : page.entries;
|
||||
continuation = page.continuation;
|
||||
partial = page.partial;
|
||||
|
|
@ -175,21 +137,17 @@
|
|||
notice = '';
|
||||
try {
|
||||
const digest = await sha256(pendingUpload.file);
|
||||
const url = apiUrl('/api/upload', {
|
||||
root: pendingUpload.root,
|
||||
path: pendingUpload.path,
|
||||
operation_id: pendingUpload.operationId
|
||||
});
|
||||
const response = await requestJson<MutationResponse>(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Declared-Size': String(pendingUpload.file.size),
|
||||
'X-Content-SHA256': digest,
|
||||
'X-CSRF-Token': session.csrf
|
||||
},
|
||||
body: pendingUpload.file
|
||||
});
|
||||
const response = requireValue(
|
||||
await browserProductClient.upload({
|
||||
root: pendingUpload.root,
|
||||
path: pendingUpload.path,
|
||||
operationId: pendingUpload.operationId,
|
||||
declaredSize: pendingUpload.file.size,
|
||||
contentSha256: digest,
|
||||
csrfToken: session.csrf,
|
||||
body: pendingUpload.file
|
||||
})
|
||||
);
|
||||
uploadFollowup = mutationFollowup(response.result);
|
||||
if (uploadFollowup !== 'complete') {
|
||||
throw new Error(
|
||||
|
|
@ -256,16 +214,16 @@
|
|||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await requestJson<MutationResponse>('/api/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
root: pendingSingleDelete.root,
|
||||
path: pendingSingleDelete.path,
|
||||
operation_id: pendingSingleDelete.operationId,
|
||||
csrf: session.csrf
|
||||
const response = requireValue(
|
||||
await browserProductClient.delete({
|
||||
body: {
|
||||
root: pendingSingleDelete.root,
|
||||
path: pendingSingleDelete.path,
|
||||
operation_id: pendingSingleDelete.operationId,
|
||||
csrf: session.csrf
|
||||
}
|
||||
})
|
||||
});
|
||||
);
|
||||
singleDeleteFollowup = mutationFollowup(response.result);
|
||||
if (singleDeleteFollowup !== 'complete') {
|
||||
throw new Error(
|
||||
|
|
@ -295,8 +253,11 @@
|
|||
for (let page = 0; page < 100; page += 1) {
|
||||
let items = loadPendingDelete(deleteRoot, path);
|
||||
if (items.length === 0) {
|
||||
const plan = await requestJson<{ paths: string[] }>(
|
||||
apiUrl('/api/delete-plan', { root: deleteRoot, path })
|
||||
const plan = requireValue(
|
||||
await browserProductClient.deletePlan({
|
||||
root: deleteRoot,
|
||||
path
|
||||
})
|
||||
);
|
||||
if (plan.paths.length === 0) {
|
||||
localStorage.removeItem(recursiveStorageKey);
|
||||
|
|
@ -311,13 +272,14 @@
|
|||
}));
|
||||
savePendingDelete(deleteRoot, path, items);
|
||||
}
|
||||
const response = await requestJson<{ results: DeleteItemResult[] }>(
|
||||
'/api/delete-batch',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ root: deleteRoot, csrf: session.csrf, items })
|
||||
}
|
||||
const response = requireValue(
|
||||
await browserProductClient.deleteBatch({
|
||||
body: {
|
||||
root: deleteRoot,
|
||||
csrf: session.csrf,
|
||||
items
|
||||
}
|
||||
})
|
||||
);
|
||||
const unfinished = unfinishedDeleteItems(
|
||||
response.results,
|
||||
|
|
@ -381,7 +343,7 @@
|
|||
}
|
||||
|
||||
function downloadUrl(entry: Entry): string {
|
||||
return apiUrl('/api/download', { root, path: entry.path });
|
||||
return browserProductClient.downloadHref({ root, path: entry.path });
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
|
|
|
|||
272
web/src/generated/contract.ts
Normal file
272
web/src/generated/contract.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
export interface DeclaredApiError<Code extends string> {
|
||||
error: Code;
|
||||
message: string;
|
||||
details: unknown | null;
|
||||
}
|
||||
|
||||
|
||||
export type ApiFailure<Code extends string> =
|
||||
| { kind: 'http'; status: number; error: DeclaredApiError<Code> }
|
||||
| { kind: 'unexpected-http'; status: number; message: string }
|
||||
| { kind: 'network'; message: string };
|
||||
|
||||
|
||||
export type ApiResult<Value, Code extends string> =
|
||||
| { ok: true; value: Value }
|
||||
| { ok: false; failure: ApiFailure<Code> };
|
||||
|
||||
|
||||
export const contractInvariants = {
|
||||
relativePath: 'NFC canonical relative path with no empty, dot, backslash, or control segment',
|
||||
operationId: '1 to 128 URL-safe ASCII characters from the unreserved set',
|
||||
csrfToken: '43 base64url characters',
|
||||
contentSha256: '64 lowercase hexadecimal characters',
|
||||
declaredSize: 'positive integer no greater than Number.MAX_SAFE_INTEGER'
|
||||
} as const;
|
||||
|
||||
|
||||
export type UserId = string;
|
||||
|
||||
export type CsrfToken = string;
|
||||
|
||||
export interface SessionResponse {
|
||||
user_id: UserId;
|
||||
display: string | null;
|
||||
csrf: CsrfToken;
|
||||
}
|
||||
|
||||
|
||||
export type AuthenticationErrorCode = 'authentication_required' | 'malformed_authentication';
|
||||
|
||||
export type Root = 'personal' | 'shared';
|
||||
|
||||
export type RelativePath = string;
|
||||
|
||||
export type SearchQuery = string;
|
||||
|
||||
export type PaginationToken = string;
|
||||
|
||||
export interface EntryResponse {
|
||||
name: string;
|
||||
is_prefix: boolean;
|
||||
size: number | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
|
||||
export interface BrowseResponse {
|
||||
entries: Array<EntryResponse>;
|
||||
continuation: PaginationToken | null;
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
|
||||
export type BrowseErrorCode = 'object_store_error';
|
||||
|
||||
export interface DownloadResponse {
|
||||
body: ReadableStream<Uint8Array>;
|
||||
contentDisposition: string | null;
|
||||
xContentTypeOptions: string | null;
|
||||
contentSecurityPolicy: string | null;
|
||||
contentLength: number | null;
|
||||
}
|
||||
|
||||
|
||||
export type DownloadErrorCode = 'object_store_error' | 'not_found' | 'range_not_supported' | 'throttle_unavailable' | 'bandwidth_exceeded';
|
||||
|
||||
export type OperationId = string;
|
||||
|
||||
export type DeclaredSize = number;
|
||||
|
||||
export type ContentSha256 = string;
|
||||
|
||||
export type MutationResult = 'pending' | 'succeeded' | 'failed' | 'rejected' | 'conflict';
|
||||
|
||||
export interface UploadResponse {
|
||||
operation_id: OperationId;
|
||||
result: MutationResult;
|
||||
idempotency_window_seconds: number;
|
||||
}
|
||||
|
||||
|
||||
export type UploadErrorCode = 'csrf_validation_failed' | 'invalid_body_stream' | 'size_mismatch' | 'object_too_large' | 'content_identity_mismatch' | 'throttle_unavailable' | 'bandwidth_exceeded' | 'mutation_rejected';
|
||||
|
||||
export interface DeleteRequest {
|
||||
root: Root;
|
||||
path: RelativePath;
|
||||
operation_id: OperationId;
|
||||
csrf: CsrfToken;
|
||||
}
|
||||
|
||||
|
||||
export interface DeleteResponse {
|
||||
operation_id: OperationId;
|
||||
result: MutationResult;
|
||||
idempotency_window_seconds: number;
|
||||
}
|
||||
|
||||
|
||||
export type DeleteErrorCode = 'csrf_validation_failed' | 'mutation_rejected';
|
||||
|
||||
export interface DeletePlanResponse {
|
||||
paths: Array<RelativePath>;
|
||||
continuation: PaginationToken | null;
|
||||
}
|
||||
|
||||
|
||||
export type DeletePrefixErrorCode = 'csrf_validation_failed' | 'prefix_mutation_rejected';
|
||||
|
||||
export interface DeleteBatchItem {
|
||||
path: RelativePath;
|
||||
operation_id: OperationId;
|
||||
}
|
||||
|
||||
|
||||
export interface DeleteBatchRequest {
|
||||
root: Root;
|
||||
csrf: CsrfToken;
|
||||
items: Array<DeleteBatchItem>;
|
||||
}
|
||||
|
||||
|
||||
export interface DeleteItemResponse {
|
||||
path: RelativePath;
|
||||
operation_id: OperationId;
|
||||
result: MutationResult | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface DeleteBatchResponse {
|
||||
results: Array<DeleteItemResponse>;
|
||||
}
|
||||
|
||||
|
||||
export type SessionEndpointRequest = Record<string, never>;
|
||||
|
||||
export type SessionFailureCode = AuthenticationErrorCode;
|
||||
|
||||
export interface BrowseEndpointRequest {
|
||||
root?: Root;
|
||||
prefix?: RelativePath;
|
||||
q?: SearchQuery;
|
||||
continuation?: PaginationToken;
|
||||
}
|
||||
|
||||
|
||||
export type BrowseFailureCode = AuthenticationErrorCode | BrowseErrorCode;
|
||||
|
||||
export interface DownloadEndpointRequest {
|
||||
root: Root;
|
||||
path: RelativePath;
|
||||
range?: string;
|
||||
}
|
||||
|
||||
|
||||
export type DownloadFailureCode = AuthenticationErrorCode | DownloadErrorCode;
|
||||
|
||||
export interface UploadEndpointRequest {
|
||||
root: Root;
|
||||
path: RelativePath;
|
||||
operationId: OperationId;
|
||||
declaredSize: DeclaredSize;
|
||||
contentSha256: ContentSha256;
|
||||
csrfToken: CsrfToken;
|
||||
body: Blob;
|
||||
}
|
||||
|
||||
|
||||
export type UploadFailureCode = AuthenticationErrorCode | UploadErrorCode;
|
||||
|
||||
export interface DeleteEndpointRequest {
|
||||
body: DeleteRequest;
|
||||
}
|
||||
|
||||
|
||||
export type DeleteFailureCode = AuthenticationErrorCode | DeleteErrorCode;
|
||||
|
||||
export interface DeletePlanEndpointRequest {
|
||||
root: Root;
|
||||
path: RelativePath;
|
||||
continuation?: PaginationToken;
|
||||
}
|
||||
|
||||
|
||||
export type DeletePlanFailureCode = AuthenticationErrorCode | DeletePrefixErrorCode;
|
||||
|
||||
export interface DeleteBatchEndpointRequest {
|
||||
body: DeleteBatchRequest;
|
||||
}
|
||||
|
||||
|
||||
export type DeleteBatchFailureCode = AuthenticationErrorCode | DeletePrefixErrorCode;
|
||||
|
||||
export const productContract = {
|
||||
session: {
|
||||
method: 'GET',
|
||||
path: '/api/session',
|
||||
query: { },
|
||||
headers: { },
|
||||
body: 'none',
|
||||
failureCodes: ['authentication_required', 'malformed_authentication']
|
||||
},
|
||||
browse: {
|
||||
method: 'GET',
|
||||
path: '/api/browse',
|
||||
query: { root: 'root', prefix: 'prefix', q: 'q', continuation: 'continuation' },
|
||||
headers: { },
|
||||
body: 'none',
|
||||
failureCodes: ['authentication_required', 'malformed_authentication', 'object_store_error']
|
||||
},
|
||||
download: {
|
||||
method: 'GET',
|
||||
path: '/api/download',
|
||||
query: { root: 'root', path: 'path' },
|
||||
headers: { range: 'Range' },
|
||||
body: 'none',
|
||||
failureCodes: ['authentication_required', 'malformed_authentication', 'object_store_error', 'not_found', 'range_not_supported', 'throttle_unavailable', 'bandwidth_exceeded']
|
||||
},
|
||||
upload: {
|
||||
method: 'POST',
|
||||
path: '/api/upload',
|
||||
query: { root: 'root', path: 'path', operationId: 'operation_id' },
|
||||
headers: { declaredSize: 'X-Declared-Size', contentSha256: 'X-Content-SHA256', csrfToken: 'X-CSRF-Token' },
|
||||
body: 'binary',
|
||||
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'invalid_body_stream', 'size_mismatch', 'object_too_large', 'content_identity_mismatch', 'throttle_unavailable', 'bandwidth_exceeded', 'mutation_rejected']
|
||||
},
|
||||
delete: {
|
||||
method: 'POST',
|
||||
path: '/api/delete',
|
||||
query: { },
|
||||
headers: { },
|
||||
body: 'json',
|
||||
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'mutation_rejected']
|
||||
},
|
||||
deletePlan: {
|
||||
method: 'GET',
|
||||
path: '/api/delete-plan',
|
||||
query: { root: 'root', path: 'path', continuation: 'continuation' },
|
||||
headers: { },
|
||||
body: 'none',
|
||||
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'prefix_mutation_rejected']
|
||||
},
|
||||
deleteBatch: {
|
||||
method: 'POST',
|
||||
path: '/api/delete-batch',
|
||||
query: { },
|
||||
headers: { },
|
||||
body: 'json',
|
||||
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'prefix_mutation_rejected']
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
export interface ProductEndpoint {
|
||||
session(request: SessionEndpointRequest): Promise<ApiResult<SessionResponse, SessionFailureCode>>;
|
||||
browse(request: BrowseEndpointRequest): Promise<ApiResult<BrowseResponse, BrowseFailureCode>>;
|
||||
download(request: DownloadEndpointRequest): Promise<ApiResult<DownloadResponse, DownloadFailureCode>>;
|
||||
upload(request: UploadEndpointRequest): Promise<ApiResult<UploadResponse, UploadFailureCode>>;
|
||||
delete(request: DeleteEndpointRequest): Promise<ApiResult<DeleteResponse, DeleteFailureCode>>;
|
||||
deletePlan(request: DeletePlanEndpointRequest): Promise<ApiResult<DeletePlanResponse, DeletePlanFailureCode>>;
|
||||
deleteBatch(request: DeleteBatchEndpointRequest): Promise<ApiResult<DeleteBatchResponse, DeleteBatchFailureCode>>;
|
||||
}
|
||||
|
|
@ -1,21 +1,13 @@
|
|||
export type Root = 'personal' | 'shared';
|
||||
import type {
|
||||
DeleteBatchItem,
|
||||
DeleteItemResponse,
|
||||
MutationResult,
|
||||
Root
|
||||
} from './generated/contract.ts';
|
||||
|
||||
export type MutationResult =
|
||||
| 'succeeded'
|
||||
| 'pending'
|
||||
| 'failed'
|
||||
| 'rejected'
|
||||
| 'conflict';
|
||||
|
||||
export type DeleteItem = {
|
||||
path: string;
|
||||
operation_id: string;
|
||||
};
|
||||
|
||||
export type DeleteItemResult = DeleteItem & {
|
||||
result: MutationResult | null;
|
||||
error: string | null;
|
||||
};
|
||||
export type DeleteItem = DeleteBatchItem;
|
||||
export type DeleteItemResult = DeleteItemResponse;
|
||||
export type { MutationResult, Root };
|
||||
|
||||
export type PendingDelete = {
|
||||
root: Root;
|
||||
|
|
|
|||
548
web/src/transport.ts
Normal file
548
web/src/transport.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
import {
|
||||
productContract,
|
||||
type ApiFailure,
|
||||
type ApiResult,
|
||||
type BrowseEndpointRequest,
|
||||
type BrowseFailureCode,
|
||||
type BrowseResponse,
|
||||
type DeleteBatchEndpointRequest,
|
||||
type DeleteBatchFailureCode,
|
||||
type DeleteBatchResponse,
|
||||
type DeleteEndpointRequest,
|
||||
type DeleteFailureCode,
|
||||
type DeleteItemResponse,
|
||||
type DeletePlanEndpointRequest,
|
||||
type DeletePlanFailureCode,
|
||||
type DeletePlanResponse,
|
||||
type DeleteResponse,
|
||||
type DownloadEndpointRequest,
|
||||
type DownloadFailureCode,
|
||||
type DownloadResponse,
|
||||
type EntryResponse,
|
||||
type MutationResult,
|
||||
type ProductEndpoint,
|
||||
type SessionEndpointRequest,
|
||||
type SessionFailureCode,
|
||||
type SessionResponse,
|
||||
type UploadEndpointRequest,
|
||||
type UploadFailureCode,
|
||||
type UploadResponse
|
||||
} from './generated/contract.ts';
|
||||
|
||||
export type FetchRequest = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
) => Promise<Response>;
|
||||
|
||||
export class BrowserProductClient implements ProductEndpoint {
|
||||
private readonly fetchRequest: FetchRequest;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
fetchRequest: FetchRequest = globalThis.fetch.bind(globalThis),
|
||||
baseUrl = ''
|
||||
) {
|
||||
this.fetchRequest = fetchRequest;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
session(
|
||||
request: SessionEndpointRequest
|
||||
): Promise<ApiResult<SessionResponse, SessionFailureCode>> {
|
||||
return this.jsonRequest(
|
||||
productContract.session,
|
||||
request,
|
||||
decodeSessionResponse
|
||||
);
|
||||
}
|
||||
|
||||
browse(
|
||||
request: BrowseEndpointRequest
|
||||
): Promise<ApiResult<BrowseResponse, BrowseFailureCode>> {
|
||||
return this.jsonRequest(
|
||||
productContract.browse,
|
||||
request,
|
||||
decodeBrowseResponse
|
||||
);
|
||||
}
|
||||
|
||||
async download(
|
||||
request: DownloadEndpointRequest
|
||||
): Promise<ApiResult<DownloadResponse, DownloadFailureCode>> {
|
||||
const contract = productContract.download;
|
||||
const response = await this.send<DownloadFailureCode>(contract, request);
|
||||
if (!response.ok) {
|
||||
return response;
|
||||
}
|
||||
const body = response.value.body;
|
||||
if (body === null) {
|
||||
return unexpectedHttp(response.value, 'download response has no body');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
body,
|
||||
contentDisposition: response.value.headers.get('Content-Disposition'),
|
||||
xContentTypeOptions: response.value.headers.get(
|
||||
'X-Content-Type-Options'
|
||||
),
|
||||
contentSecurityPolicy: response.value.headers.get(
|
||||
'Content-Security-Policy'
|
||||
),
|
||||
contentLength: optionalIntegerHeader(
|
||||
response.value.headers.get('Content-Length')
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
upload(
|
||||
request: UploadEndpointRequest
|
||||
): Promise<ApiResult<UploadResponse, UploadFailureCode>> {
|
||||
return this.jsonRequest(
|
||||
productContract.upload,
|
||||
request,
|
||||
decodeUploadResponse
|
||||
);
|
||||
}
|
||||
|
||||
delete(
|
||||
request: DeleteEndpointRequest
|
||||
): Promise<ApiResult<DeleteResponse, DeleteFailureCode>> {
|
||||
return this.jsonRequest(
|
||||
productContract.delete,
|
||||
request,
|
||||
decodeDeleteResponse
|
||||
);
|
||||
}
|
||||
|
||||
deletePlan(
|
||||
request: DeletePlanEndpointRequest
|
||||
): Promise<ApiResult<DeletePlanResponse, DeletePlanFailureCode>> {
|
||||
return this.jsonRequest(
|
||||
productContract.deletePlan,
|
||||
request,
|
||||
decodeDeletePlanResponse
|
||||
);
|
||||
}
|
||||
|
||||
deleteBatch(
|
||||
request: DeleteBatchEndpointRequest
|
||||
): Promise<ApiResult<DeleteBatchResponse, DeleteBatchFailureCode>> {
|
||||
return this.jsonRequest(
|
||||
productContract.deleteBatch,
|
||||
request,
|
||||
decodeDeleteBatchResponse
|
||||
);
|
||||
}
|
||||
|
||||
downloadHref(request: DownloadEndpointRequest): string {
|
||||
return buildUrl(
|
||||
productContract.download.path,
|
||||
productContract.download.query,
|
||||
request,
|
||||
this.baseUrl
|
||||
);
|
||||
}
|
||||
|
||||
private async jsonRequest<Success, Code extends string>(
|
||||
contract: EndpointContract,
|
||||
request: object,
|
||||
decode: (value: unknown) => Success
|
||||
): Promise<ApiResult<Success, Code>> {
|
||||
const response = await this.send<Code>(contract, request);
|
||||
if (!response.ok) {
|
||||
return response;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: decode(await response.value.json())
|
||||
};
|
||||
} catch (caught) {
|
||||
return unexpectedHttp(
|
||||
response.value,
|
||||
caught instanceof Error ? caught.message : String(caught)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async send<Code extends string>(
|
||||
contract: EndpointContract,
|
||||
request: object
|
||||
): Promise<ApiResult<Response, Code>> {
|
||||
const values = request as Record<string, unknown>;
|
||||
const headers = new Headers({ Accept: acceptedResponse(contract) });
|
||||
addHeaders(headers, contract.headers, values);
|
||||
const body = requestBody(contract.body, values, headers);
|
||||
const url = buildUrl(contract.path, contract.query, values, this.baseUrl);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchRequest(url, {
|
||||
method: contract.method,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
body
|
||||
});
|
||||
} catch (caught) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
kind: 'network',
|
||||
message: caught instanceof Error ? caught.message : String(caught)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, value: response };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
failure: await decodeFailure<Code>(response, contract.failureCodes)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const browserProductClient = new BrowserProductClient();
|
||||
|
||||
export class ProductRequestError extends Error {
|
||||
readonly failure: ApiFailure<string>;
|
||||
|
||||
constructor(failure: ApiFailure<string>) {
|
||||
super(failureMessage(failure));
|
||||
this.name = 'ProductRequestError';
|
||||
this.failure = failure;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireValue<Value, Code extends string>(
|
||||
result: ApiResult<Value, Code>
|
||||
): Value {
|
||||
if (result.ok) {
|
||||
return result.value;
|
||||
}
|
||||
throw new ProductRequestError(result.failure);
|
||||
}
|
||||
|
||||
type EndpointContract = {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly query: Readonly<Record<string, string>>;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: 'none' | 'json' | 'binary';
|
||||
readonly failureCodes: readonly string[];
|
||||
};
|
||||
|
||||
function buildUrl(
|
||||
path: string,
|
||||
query: Readonly<Record<string, string>>,
|
||||
request: object,
|
||||
baseUrl: string
|
||||
): string {
|
||||
const values = request as Record<string, unknown>;
|
||||
const parameters = new URLSearchParams();
|
||||
for (const [field, wireName] of Object.entries(query)) {
|
||||
const value = values[field];
|
||||
if (value !== undefined && value !== null) {
|
||||
parameters.set(wireName, String(value));
|
||||
}
|
||||
}
|
||||
const suffix = parameters.size === 0 ? '' : `?${parameters.toString()}`;
|
||||
const relative = `${path}${suffix}`;
|
||||
return baseUrl === '' ? relative : new URL(relative, baseUrl).toString();
|
||||
}
|
||||
|
||||
function addHeaders(
|
||||
headers: Headers,
|
||||
mapping: Readonly<Record<string, string>>,
|
||||
values: Record<string, unknown>
|
||||
): void {
|
||||
for (const [field, wireName] of Object.entries(mapping)) {
|
||||
const value = values[field];
|
||||
if (value !== undefined && value !== null) {
|
||||
headers.set(wireName, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requestBody(
|
||||
bodyKind: EndpointContract['body'],
|
||||
values: Record<string, unknown>,
|
||||
headers: Headers
|
||||
): BodyInit | undefined {
|
||||
if (bodyKind === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
if (bodyKind === 'json') {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return JSON.stringify(values.body);
|
||||
}
|
||||
headers.set('Content-Type', 'application/octet-stream');
|
||||
const body = values.body;
|
||||
if (!(body instanceof Blob)) {
|
||||
throw new TypeError('binary request body must be a Blob');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function acceptedResponse(contract: EndpointContract): string {
|
||||
return contract === productContract.download
|
||||
? 'application/octet-stream'
|
||||
: 'application/json';
|
||||
}
|
||||
|
||||
async function decodeFailure<Code extends string>(
|
||||
response: Response,
|
||||
allowedCodes: readonly string[]
|
||||
): Promise<ApiFailure<Code>> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch {
|
||||
return {
|
||||
kind: 'unexpected-http',
|
||||
status: response.status,
|
||||
message: `${response.status} ${response.statusText}`
|
||||
};
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return unexpectedFailure(response, 'error response is not an object');
|
||||
}
|
||||
const code = value.error;
|
||||
if (
|
||||
typeof code !== 'string' ||
|
||||
!allowedCodes.includes(code) ||
|
||||
typeof value.message !== 'string'
|
||||
) {
|
||||
return unexpectedFailure(response, 'error response has an undeclared shape');
|
||||
}
|
||||
return {
|
||||
kind: 'http',
|
||||
status: response.status,
|
||||
error: {
|
||||
error: code as Code,
|
||||
message: value.message,
|
||||
details: value.details ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function unexpectedFailure(
|
||||
response: Response,
|
||||
message: string
|
||||
): ApiFailure<never> {
|
||||
return {
|
||||
kind: 'unexpected-http',
|
||||
status: response.status,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
function unexpectedHttp<Code extends string>(
|
||||
response: Response,
|
||||
message: string
|
||||
): ApiResult<never, Code> {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
kind: 'unexpected-http',
|
||||
status: response.status,
|
||||
message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function failureMessage(failure: ApiFailure<string>): string {
|
||||
if (failure.kind === 'http') {
|
||||
return failure.error.message;
|
||||
}
|
||||
if (failure.kind === 'unexpected-http') {
|
||||
return `${failure.status} ${failure.message}`;
|
||||
}
|
||||
return failure.message;
|
||||
}
|
||||
|
||||
function decodeSessionResponse(value: unknown): SessionResponse {
|
||||
const object = record(value, 'session response');
|
||||
return {
|
||||
user_id: stringField(object, 'user_id'),
|
||||
display: nullableStringField(object, 'display'),
|
||||
csrf: stringField(object, 'csrf')
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBrowseResponse(value: unknown): BrowseResponse {
|
||||
const object = record(value, 'browse response');
|
||||
const entries = object.entries;
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new TypeError('browse response entries must be an array');
|
||||
}
|
||||
return {
|
||||
entries: entries.map(decodeEntryResponse),
|
||||
continuation: nullableStringField(object, 'continuation'),
|
||||
partial: booleanField(object, 'partial')
|
||||
};
|
||||
}
|
||||
|
||||
function decodeEntryResponse(value: unknown): EntryResponse {
|
||||
const object = record(value, 'entry response');
|
||||
return {
|
||||
name: stringField(object, 'name'),
|
||||
is_prefix: booleanField(object, 'is_prefix'),
|
||||
size: nullableIntegerField(object, 'size'),
|
||||
path: stringField(object, 'path')
|
||||
};
|
||||
}
|
||||
|
||||
function decodeUploadResponse(value: unknown): UploadResponse {
|
||||
return decodeMutationResponse(value);
|
||||
}
|
||||
|
||||
function decodeDeleteResponse(value: unknown): DeleteResponse {
|
||||
return decodeMutationResponse(value);
|
||||
}
|
||||
|
||||
function decodeMutationResponse(value: unknown): UploadResponse {
|
||||
const object = record(value, 'mutation response');
|
||||
return {
|
||||
operation_id: stringField(object, 'operation_id'),
|
||||
result: mutationResultField(object, 'result'),
|
||||
idempotency_window_seconds: integerField(
|
||||
object,
|
||||
'idempotency_window_seconds'
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function decodeDeletePlanResponse(value: unknown): DeletePlanResponse {
|
||||
const object = record(value, 'delete plan response');
|
||||
const paths = object.paths;
|
||||
if (!Array.isArray(paths) || !paths.every((path) => typeof path === 'string')) {
|
||||
throw new TypeError('delete plan paths must be an array of strings');
|
||||
}
|
||||
return {
|
||||
paths,
|
||||
continuation: nullableStringField(object, 'continuation')
|
||||
};
|
||||
}
|
||||
|
||||
function decodeDeleteBatchResponse(value: unknown): DeleteBatchResponse {
|
||||
const object = record(value, 'delete batch response');
|
||||
const results = object.results;
|
||||
if (!Array.isArray(results)) {
|
||||
throw new TypeError('delete batch results must be an array');
|
||||
}
|
||||
return { results: results.map(decodeDeleteItemResponse) };
|
||||
}
|
||||
|
||||
function decodeDeleteItemResponse(value: unknown): DeleteItemResponse {
|
||||
const object = record(value, 'delete item response');
|
||||
return {
|
||||
path: stringField(object, 'path'),
|
||||
operation_id: stringField(object, 'operation_id'),
|
||||
result: nullableMutationResultField(object, 'result'),
|
||||
error: nullableStringField(object, 'error')
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new TypeError(`${label} must be an object`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
object: Record<string, unknown>,
|
||||
field: string
|
||||
): string {
|
||||
const value = object[field];
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError(`${field} must be a string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableStringField(
|
||||
object: Record<string, unknown>,
|
||||
field: string
|
||||
): string | null {
|
||||
const value = object[field];
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return stringField(object, field);
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
object: Record<string, unknown>,
|
||||
field: string
|
||||
): boolean {
|
||||
const value = object[field];
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new TypeError(`${field} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerField(
|
||||
object: Record<string, unknown>,
|
||||
field: string
|
||||
): number {
|
||||
const value = object[field];
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
||||
throw new TypeError(`${field} must be a safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableIntegerField(
|
||||
object: Record<string, unknown>,
|
||||
field: string
|
||||
): number | null {
|
||||
if (object[field] === null) {
|
||||
return null;
|
||||
}
|
||||
return integerField(object, field);
|
||||
}
|
||||
|
||||
function mutationResultField(
|
||||
object: Record<string, unknown>,
|
||||
field: string
|
||||
): MutationResult {
|
||||
const value = object[field];
|
||||
if (
|
||||
value !== 'pending' &&
|
||||
value !== 'succeeded' &&
|
||||
value !== 'failed' &&
|
||||
value !== 'rejected' &&
|
||||
value !== 'conflict'
|
||||
) {
|
||||
throw new TypeError(`${field} must be a mutation result`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableMutationResultField(
|
||||
object: Record<string, unknown>,
|
||||
field: string
|
||||
): MutationResult | null {
|
||||
if (object[field] === null) {
|
||||
return null;
|
||||
}
|
||||
return mutationResultField(object, field);
|
||||
}
|
||||
|
||||
function optionalIntegerHeader(value: string | null): number | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
98
web/test/contract-integration.mjs
Normal file
98
web/test/contract-integration.mjs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
BrowserProductClient,
|
||||
requireValue
|
||||
} from '../src/transport.ts';
|
||||
|
||||
const baseUrl = process.argv[2];
|
||||
if (baseUrl === undefined) {
|
||||
throw new Error('missing integration base URL');
|
||||
}
|
||||
|
||||
const client = new BrowserProductClient(globalThis.fetch.bind(globalThis), baseUrl);
|
||||
const csrf = 'a'.repeat(43);
|
||||
const path = 'folder/문서 a.txt';
|
||||
const operationId = 'operation-1';
|
||||
|
||||
const session = requireValue(await client.session({}));
|
||||
assert.deepEqual(session, {
|
||||
user_id: 'user-1',
|
||||
display: 'User',
|
||||
csrf
|
||||
});
|
||||
|
||||
const browse = requireValue(
|
||||
await client.browse({
|
||||
root: 'shared',
|
||||
prefix: path,
|
||||
q: 'needle',
|
||||
continuation: 'next-page'
|
||||
})
|
||||
);
|
||||
assert.equal(browse.entries[0].path, path);
|
||||
assert.equal(browse.continuation, 'next-page');
|
||||
assert.equal(browse.partial, true);
|
||||
|
||||
const download = requireValue(
|
||||
await client.download({ root: 'shared', path })
|
||||
);
|
||||
assert.equal(await new Response(download.body).text(), 'abc');
|
||||
assert.equal(download.contentDisposition, 'attachment; filename="file.txt"');
|
||||
assert.equal(download.xContentTypeOptions, 'nosniff');
|
||||
assert.equal(download.contentSecurityPolicy, "default-src 'none'");
|
||||
assert.equal(download.contentLength, 3);
|
||||
|
||||
const upload = requireValue(
|
||||
await client.upload({
|
||||
root: 'personal',
|
||||
path,
|
||||
operationId,
|
||||
declaredSize: 3,
|
||||
contentSha256: 'b'.repeat(64),
|
||||
csrfToken: csrf,
|
||||
body: new Blob(['abc'])
|
||||
})
|
||||
);
|
||||
assert.equal(upload.operation_id, operationId);
|
||||
assert.equal(upload.result, 'succeeded');
|
||||
|
||||
const deletion = requireValue(
|
||||
await client.delete({
|
||||
body: {
|
||||
root: 'shared',
|
||||
path,
|
||||
operation_id: operationId,
|
||||
csrf
|
||||
}
|
||||
})
|
||||
);
|
||||
assert.equal(deletion.operation_id, operationId);
|
||||
assert.equal(deletion.result, 'succeeded');
|
||||
|
||||
const plan = requireValue(
|
||||
await client.deletePlan({
|
||||
root: 'shared',
|
||||
path,
|
||||
continuation: 'next-page'
|
||||
})
|
||||
);
|
||||
assert.deepEqual(plan.paths, [path]);
|
||||
assert.equal(plan.continuation, 'next-page');
|
||||
|
||||
const batch = requireValue(
|
||||
await client.deleteBatch({
|
||||
body: {
|
||||
root: 'shared',
|
||||
csrf,
|
||||
items: [{ path, operation_id: operationId }]
|
||||
}
|
||||
})
|
||||
);
|
||||
assert.deepEqual(batch.results, [
|
||||
{
|
||||
path,
|
||||
operation_id: operationId,
|
||||
result: 'succeeded',
|
||||
error: null
|
||||
}
|
||||
]);
|
||||
1
web/test/fixtures/contract.json
vendored
Normal file
1
web/test/fixtures/contract.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"browse_response":{"continuation":"next-page","entries":[{"is_prefix":false,"name":"문서.txt","path":"folder/문서.txt","size":7}],"partial":false},"delete_batch_item":{"operation_id":"operation-1","path":"folder/문서.txt"},"delete_batch_request":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","items":[{"operation_id":"operation-1","path":"folder/문서.txt"}],"root":"shared"},"delete_batch_response":{"results":[{"error":null,"operation_id":"operation-1","path":"folder/문서.txt","result":"succeeded"}]},"delete_item_response":{"error":null,"operation_id":"operation-1","path":"folder/문서.txt","result":"succeeded"},"delete_plan_response":{"continuation":"next-page","paths":["folder/문서.txt"]},"delete_request":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation_id":"operation-1","path":"folder/문서.txt","root":"shared"},"delete_response":{"idempotency_window_seconds":3600,"operation_id":"operation-1","result":"pending"},"entry_response":{"is_prefix":false,"name":"문서.txt","path":"folder/문서.txt","size":7},"errors":[{"details":null,"error":"authentication_required","message":"login required"},{"details":null,"error":"malformed_authentication","message":"authentication data is malformed"},{"details":null,"error":"object_store_error","message":"store"},{"details":null,"error":"object_store_error","message":"store"},{"details":null,"error":"not_found","message":"object not found"},{"details":null,"error":"range_not_supported","message":"Range downloads are not supported"},{"details":null,"error":"throttle_unavailable","message":"bandwidth throttle unavailable"},{"details":null,"error":"bandwidth_exceeded","message":"bandwidth limit exceeded"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"invalid_body_stream","message":"body"},{"details":null,"error":"size_mismatch","message":"request body does not match declared size"},{"details":null,"error":"object_too_large","message":"object too large"},{"details":null,"error":"content_identity_mismatch","message":"request body digest does not match"},{"details":null,"error":"throttle_unavailable","message":"bandwidth throttle unavailable"},{"details":null,"error":"bandwidth_exceeded","message":"bandwidth limit exceeded"},{"details":null,"error":"mutation_rejected","message":"mutation"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"mutation_rejected","message":"mutation"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"prefix_mutation_rejected","message":"prefix"}],"session_response":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","display":"User","user_id":"user-1"},"upload_response":{"idempotency_window_seconds":3600,"operation_id":"operation-1","result":"succeeded"}}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue