Generate the browser contract from ProductApi

This commit is contained in:
ms2k 2026-07-20 03:20:08 +09:00
commit c9642f4067
21 changed files with 2572 additions and 130 deletions

View file

@ -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: test:
./tests/run-host-tests.sh ./tests/run-host-tests.sh
@ -39,9 +39,15 @@ check-model-correspondence:
check-protocol: check-model-correspondence check-protocol: check-model-correspondence
./tests/kind/protocol-conformance-run.sh ./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 ./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 check-runtime: test check-recovery
./tests/runtime-static.sh ./tests/runtime-static.sh
./tests/kind/runtime-resilience-run.sh ./tests/kind/runtime-resilience-run.sh

19
app/GenerateContract.hs Normal file
View 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"

View file

@ -9,7 +9,7 @@ mutation protocol remains in [`Nuthatch.tla`](../protocol/Nuthatch.tla).
| Contract | Implementation owner | Owning gate | | 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` | | SQL | `sql/`, `src/Nuthatch/Db.hs`, `src/Nuthatch/Protocol.hs` | `make check-storage-foundation` |
| Identity encoding | `src/Nuthatch/Paths.hs` | `make test` | | Identity encoding | `src/Nuthatch/Paths.hs` | `make test` |
| Content identity | `src/Nuthatch/Streaming.hs`, `src/Nuthatch/S3.hs` | `make check-streaming` | | 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 clients never submit complete object keys. Operation ids are 1 through 128
URL-safe ASCII characters from the unreserved set. 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 Upload requests stream `application/octet-stream`, require
`X-Declared-Size`, `X-Content-SHA256`, `X-CSRF-Token`, and an operation id, and `X-Declared-Size`, `X-Content-SHA256`, `X-CSRF-Token`, and an operation id, and
return a typed mutation result with the advertised terminal idempotency window. return a typed mutation result with the advertised terminal idempotency window.

View file

@ -13,6 +13,20 @@ examples, and Hedgehog through Tasty Hedgehog for generated properties.
Hedgehog failures include the seed needed to replay the shrunk counterexample. Hedgehog failures include the seed needed to replay the shrunk counterexample.
Generators represent valid and invalid domains with distinct types. 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 ## Model correspondence
`make check-model-correspondence` runs before the kind protocol-conformance `make check-model-correspondence` runs before the kind protocol-conformance

View file

@ -9,6 +9,10 @@ category: Network
build-type: Simple build-type: Simple
extra-source-files: extra-source-files:
sql/001_initial_schema.sql 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 common shared
default-language: Haskell2010 default-language: Haskell2010
@ -106,6 +110,7 @@ library
Nuthatch.Config Nuthatch.Config
Nuthatch.Db Nuthatch.Db
Nuthatch.Http.Auth Nuthatch.Http.Auth
Nuthatch.Http.Contract
Nuthatch.Http.Error Nuthatch.Http.Error
Nuthatch.Http.Server Nuthatch.Http.Server
Nuthatch.Mutation.Effects Nuthatch.Mutation.Effects
@ -136,6 +141,27 @@ executable nuthatch
-rtsopts -rtsopts
-with-rtsopts=-N -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 executable nuthatch-storage-integration
import: shared import: shared
main-is: Integration.hs main-is: Integration.hs
@ -381,6 +407,7 @@ test-suite nuthatch-unit
other-modules: other-modules:
Nuthatch.Test.Config Nuthatch.Test.Config
Nuthatch.Test.Http Nuthatch.Test.Http
Nuthatch.Test.HttpContract
Nuthatch.Test.Identity Nuthatch.Test.Identity
Nuthatch.Test.Oidc Nuthatch.Test.Oidc
Nuthatch.Test.Path Nuthatch.Test.Path

View file

@ -23,6 +23,7 @@ module Nuthatch.Api
, DeclaredSize (..) , DeclaredSize (..)
, HealthResponse (..) , HealthResponse (..)
, ReadyResponse (..) , ReadyResponse (..)
, EntryResponse (..)
, BrowseResponse (..) , BrowseResponse (..)
, DeleteRequest (..) , DeleteRequest (..)
, SessionResponse (..) , SessionResponse (..)
@ -63,6 +64,7 @@ import Data.Proxy (Proxy (..))
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import GHC.Generics (Generic)
import Network.HTTP.Media ((//), (/:)) import Network.HTTP.Media ((//), (/:))
import Nuthatch.Types import Nuthatch.Types
import Servant.API import Servant.API
@ -117,12 +119,36 @@ instance ToJSON ReadyResponse where
toJSON response = object ["ready" .= ready response] toJSON response = object ["ready" .= ready response]
data BrowseResponse = BrowseResponse data BrowseResponse = BrowseResponse
{ browseEntries :: [ListEntry] { browseEntries :: [EntryResponse]
, browseContinuation :: Maybe PaginationToken , browseContinuation :: Maybe PaginationToken
, browsePartial :: Bool , browsePartial :: Bool
} }
deriving stock (Eq, Show) 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 instance ToJSON BrowseResponse where
toJSON response = object toJSON response = object
[ "entries" .= browseEntries response [ "entries" .= browseEntries response
@ -130,6 +156,13 @@ instance ToJSON BrowseResponse where
, "partial" .= browsePartial response , "partial" .= browsePartial response
] ]
instance FromJSON BrowseResponse where
parseJSON = withObject "BrowseResponse" $ \o ->
BrowseResponse
<$> o .: "entries"
<*> o .: "continuation"
<*> o .: "partial"
data DeleteRequest = DeleteRequest data DeleteRequest = DeleteRequest
{ deleteRoot :: Root { deleteRoot :: Root
, deletePath :: RelativePath , deletePath :: RelativePath
@ -146,6 +179,14 @@ instance FromJSON DeleteRequest where
<*> o .: "operation_id" <*> o .: "operation_id"
<*> o .: "csrf" <*> 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 data UploadResponse = UploadResponse
{ uploadResponseOperationId :: OperationId { uploadResponseOperationId :: OperationId
, uploadResponseResult :: MutationResult , uploadResponseResult :: MutationResult
@ -156,11 +197,18 @@ data UploadResponse = UploadResponse
instance ToJSON UploadResponse where instance ToJSON UploadResponse where
toJSON response = object toJSON response = object
[ "operation_id" .= uploadResponseOperationId response [ "operation_id" .= uploadResponseOperationId response
, "result" .= mutationResultText (uploadResponseResult response) , "result" .= uploadResponseResult response
, "idempotency_window_seconds" , "idempotency_window_seconds"
.= uploadResponseIdempotencyWindowSeconds response .= uploadResponseIdempotencyWindowSeconds response
] ]
instance FromJSON UploadResponse where
parseJSON = withObject "UploadResponse" $ \o ->
UploadResponse
<$> o .: "operation_id"
<*> o .: "result"
<*> o .: "idempotency_window_seconds"
data DeleteResponse = DeleteResponse data DeleteResponse = DeleteResponse
{ deleteResponseOperationId :: OperationId { deleteResponseOperationId :: OperationId
, deleteResponseResult :: MutationResult , deleteResponseResult :: MutationResult
@ -171,11 +219,18 @@ data DeleteResponse = DeleteResponse
instance ToJSON DeleteResponse where instance ToJSON DeleteResponse where
toJSON response = object toJSON response = object
[ "operation_id" .= deleteResponseOperationId response [ "operation_id" .= deleteResponseOperationId response
, "result" .= mutationResultText (deleteResponseResult response) , "result" .= deleteResponseResult response
, "idempotency_window_seconds" , "idempotency_window_seconds"
.= deleteResponseIdempotencyWindowSeconds response .= deleteResponseIdempotencyWindowSeconds response
] ]
instance FromJSON DeleteResponse where
parseJSON = withObject "DeleteResponse" $ \o ->
DeleteResponse
<$> o .: "operation_id"
<*> o .: "result"
<*> o .: "idempotency_window_seconds"
data Throws error data Throws error
instance HasServer endpoint context => instance HasServer endpoint context =>
@ -203,7 +258,7 @@ instance ToJSON BoundaryError where
data AuthenticationError data AuthenticationError
= AuthenticationRequired = AuthenticationRequired
| MalformedAuthentication | MalformedAuthentication
deriving stock (Eq, Show) deriving stock (Eq, Generic, Show)
instance ToJSON AuthenticationError where instance ToJSON AuthenticationError where
toJSON AuthenticationRequired = toJSON AuthenticationRequired =
@ -240,7 +295,7 @@ instance ToJSON LogoutError where
errorValue "csrf_validation_failed" "CSRF validation failed" Nothing errorValue "csrf_validation_failed" "CSRF validation failed" Nothing
newtype BrowseError = BrowseObjectStoreFailed Text newtype BrowseError = BrowseObjectStoreFailed Text
deriving stock (Eq, Show) deriving stock (Eq, Generic, Show)
instance ToJSON BrowseError where instance ToJSON BrowseError where
toJSON (BrowseObjectStoreFailed message) = toJSON (BrowseObjectStoreFailed message) =
@ -252,7 +307,7 @@ data DownloadError
| DownloadRangeUnsupported | DownloadRangeUnsupported
| DownloadThrottleUnavailable | DownloadThrottleUnavailable
| DownloadBandwidthExceeded | DownloadBandwidthExceeded
deriving stock (Eq, Show) deriving stock (Eq, Generic, Show)
instance ToJSON DownloadError where instance ToJSON DownloadError where
toJSON (DownloadObjectStoreFailed message) = toJSON (DownloadObjectStoreFailed message) =
@ -275,7 +330,7 @@ data UploadError
| UploadThrottleUnavailable | UploadThrottleUnavailable
| UploadBandwidthExceeded | UploadBandwidthExceeded
| UploadMutationRejected Text | UploadMutationRejected Text
deriving stock (Eq, Show) deriving stock (Eq, Generic, Show)
instance ToJSON UploadError where instance ToJSON UploadError where
toJSON UploadCsrfRejected = toJSON UploadCsrfRejected =
@ -298,7 +353,7 @@ instance ToJSON UploadError where
data DeleteError data DeleteError
= DeleteCsrfRejected = DeleteCsrfRejected
| DeleteMutationRejected Text | DeleteMutationRejected Text
deriving stock (Eq, Show) deriving stock (Eq, Generic, Show)
instance ToJSON DeleteError where instance ToJSON DeleteError where
toJSON DeleteCsrfRejected = toJSON DeleteCsrfRejected =
@ -309,7 +364,7 @@ instance ToJSON DeleteError where
data DeletePrefixError data DeletePrefixError
= DeletePrefixCsrfRejected = DeletePrefixCsrfRejected
| DeletePrefixMutationRejected Text | DeletePrefixMutationRejected Text
deriving stock (Eq, Show) deriving stock (Eq, Generic, Show)
instance ToJSON DeletePrefixError where instance ToJSON DeletePrefixError where
toJSON DeletePrefixCsrfRejected = toJSON DeletePrefixCsrfRejected =
@ -331,6 +386,13 @@ instance ToJSON SessionResponse where
, "csrf" .= sessionResponseCsrf response , "csrf" .= sessionResponseCsrf response
] ]
instance FromJSON SessionResponse where
parseJSON = withObject "SessionResponse" $ \o ->
SessionResponse
<$> o .: "user_id"
<*> o .: "display"
<*> o .: "csrf"
data DeletePlanResponse = DeletePlanResponse data DeletePlanResponse = DeletePlanResponse
{ deletePlanPaths :: [RelativePath] { deletePlanPaths :: [RelativePath]
, deletePlanContinuation :: Maybe PaginationToken , deletePlanContinuation :: Maybe PaginationToken
@ -343,6 +405,12 @@ instance ToJSON DeletePlanResponse where
, "continuation" .= deletePlanContinuation response , "continuation" .= deletePlanContinuation response
] ]
instance FromJSON DeletePlanResponse where
parseJSON = withObject "DeletePlanResponse" $ \o ->
DeletePlanResponse
<$> o .: "paths"
<*> o .: "continuation"
data DeleteBatchItem = DeleteBatchItem data DeleteBatchItem = DeleteBatchItem
{ deleteBatchPath :: RelativePath { deleteBatchPath :: RelativePath
, deleteBatchOperationId :: OperationId , deleteBatchOperationId :: OperationId
@ -355,6 +423,12 @@ instance FromJSON DeleteBatchItem where
<$> o .: "path" <$> o .: "path"
<*> o .: "operation_id" <*> o .: "operation_id"
instance ToJSON DeleteBatchItem where
toJSON item = object
[ "path" .= deleteBatchPath item
, "operation_id" .= deleteBatchOperationId item
]
data DeleteBatchRequest = DeleteBatchRequest data DeleteBatchRequest = DeleteBatchRequest
{ deleteBatchRoot :: Root { deleteBatchRoot :: Root
, deleteBatchCsrf :: CsrfToken , deleteBatchCsrf :: CsrfToken
@ -369,6 +443,13 @@ instance FromJSON DeleteBatchRequest where
<*> o .: "csrf" <*> o .: "csrf"
<*> o .: "items" <*> o .: "items"
instance ToJSON DeleteBatchRequest where
toJSON request = object
[ "root" .= deleteBatchRoot request
, "csrf" .= deleteBatchCsrf request
, "items" .= deleteBatchItems request
]
data DeleteItemResponse = DeleteItemResponse data DeleteItemResponse = DeleteItemResponse
{ deleteItemPath :: RelativePath { deleteItemPath :: RelativePath
, deleteItemOperationId :: OperationId , deleteItemOperationId :: OperationId
@ -381,10 +462,18 @@ instance ToJSON DeleteItemResponse where
toJSON response = object toJSON response = object
[ "path" .= unRelativePath (deleteItemPath response) [ "path" .= unRelativePath (deleteItemPath response)
, "operation_id" .= deleteItemOperationId response , "operation_id" .= deleteItemOperationId response
, "result" .= fmap mutationResultText (deleteItemResult response) , "result" .= deleteItemResult response
, "error" .= deleteItemError 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 newtype DeleteBatchResponse = DeleteBatchResponse
{ deleteBatchResults :: [DeleteItemResponse] { deleteBatchResults :: [DeleteItemResponse]
} }
@ -393,6 +482,10 @@ newtype DeleteBatchResponse = DeleteBatchResponse
instance ToJSON DeleteBatchResponse where instance ToJSON DeleteBatchResponse where
toJSON response = object ["results" .= deleteBatchResults response] toJSON response = object ["results" .= deleteBatchResults response]
instance FromJSON DeleteBatchResponse where
parseJSON = withObject "DeleteBatchResponse" $ \o ->
DeleteBatchResponse <$> o .: "results"
errorValue :: Text -> Text -> Maybe Value -> Value errorValue :: Text -> Text -> Maybe Value -> Value
errorValue code message details = object errorValue code message details = object
[ "error" .= code [ "error" .= code
@ -583,10 +676,3 @@ validatePaginationToken raw
| T.length raw > 2048 = Left "pagination token is too long" | T.length raw > 2048 = Left "pagination token is too long"
| T.any isControl raw = Left "pagination token contains a control character" | T.any isControl raw = Left "pagination token contains a control character"
| otherwise = Right (PaginationToken raw) | otherwise = Right (PaginationToken raw)
mutationResultText :: MutationResult -> Text
mutationResultText MutationPending = "pending"
mutationResultText MutationSucceeded = "succeeded"
mutationResultText MutationFailed = "failed"
mutationResultText MutationRejected = "rejected"
mutationResultText MutationConflict = "conflict"

View file

@ -62,10 +62,17 @@ handleBrowseRequest env session selectedRoot selectedPrefix search continuation
pure pure
result result
pure BrowseResponse pure BrowseResponse
{ browseEntries = entries { browseEntries = map entryResponse entries
, browseContinuation = PaginationToken <$> nextContinuation , browseContinuation = PaginationToken <$> nextContinuation
, browsePartial = partial , browsePartial = partial
} }
where
entryResponse entry = EntryResponse
{ entryResponseName = leName entry
, entryResponseIsPrefix = leIsPrefix entry
, entryResponseSize = leSize entry
, entryResponsePath = leRelativePath entry
}
handleDeletePlanRequest handleDeletePlanRequest
:: Env :: Env

View 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 <> "'"

View file

@ -22,6 +22,8 @@ module Nuthatch.Types
, parseState , parseState
, externalLabel , externalLabel
, parseExternal , parseExternal
, mutationResultLabel
, parseMutationResult
, validateRelativePath , validateRelativePath
, validateOperationId , validateOperationId
, validateContentSha256 , validateContentSha256
@ -44,11 +46,12 @@ import Data.Aeson
import Data.Char (isAsciiLower, isAsciiUpper, isControl, isDigit) import Data.Char (isAsciiLower, isAsciiUpper, isControl, isDigit)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import GHC.Generics (Generic)
import qualified Data.Text.Normalize as TN import qualified Data.Text.Normalize as TN
import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..)) import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))
data Root = Personal | Shared data Root = Personal | Shared
deriving stock (Eq, Show) deriving stock (Eq, Generic, Show)
rootLabel :: Root -> Text rootLabel :: Root -> Text
rootLabel Personal = "personal" rootLabel Personal = "personal"
@ -199,7 +202,29 @@ data MutationResult
| MutationFailed | MutationFailed
| MutationRejected | MutationRejected
| MutationConflict | 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 instance ToJSON Root where
toJSON = toJSON . rootLabel toJSON = toJSON . rootLabel

View 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 (:) []

View file

@ -1,6 +1,7 @@
module Main (main) where module Main (main) where
import qualified Nuthatch.Test.Http as Http 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.Identity as Identity
import qualified Nuthatch.Test.Oidc as Oidc import qualified Nuthatch.Test.Oidc as Oidc
import qualified Nuthatch.Test.Path as Path import qualified Nuthatch.Test.Path as Path
@ -23,4 +24,5 @@ main = defaultMain $
, Runtime.tests , Runtime.tests
, Oidc.tests , Oidc.tests
, Http.tests , Http.tests
, HttpContract.tests
] ]

View 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
]

View file

@ -18,7 +18,7 @@ for heading in \
done done
for ownership in \ 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` |' \ '| SQL | `sql/`, `src/Nuthatch/Db.hs`, `src/Nuthatch/Protocol.hs` | `make check-storage-foundation` |' \
'| Identity encoding | `src/Nuthatch/Paths.hs` | `make test` |' \ '| Identity encoding | `src/Nuthatch/Paths.hs` | `make test` |' \
'| Content identity | `src/Nuthatch/Streaming.hs`, `src/Nuthatch/S3.hs` | `make check-streaming` |' \ '| 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/Config.hs \
src/Nuthatch/Db.hs \ src/Nuthatch/Db.hs \
src/Nuthatch/Http/Auth.hs \ src/Nuthatch/Http/Auth.hs \
src/Nuthatch/Http/Contract.hs \
src/Nuthatch/Http/Error.hs \ src/Nuthatch/Http/Error.hs \
src/Nuthatch/Http/Server.hs \ src/Nuthatch/Http/Server.hs \
src/Nuthatch/Paths.hs \ src/Nuthatch/Paths.hs \
@ -42,6 +43,8 @@ for path in \
src/Nuthatch/S3.hs \ src/Nuthatch/S3.hs \
src/Nuthatch/Streaming.hs \ src/Nuthatch/Streaming.hs \
src/Nuthatch/Transfer.hs \ src/Nuthatch/Transfer.hs \
web/src/generated/contract.ts \
web/src/transport.ts \
sql/001_initial_schema.sql \ sql/001_initial_schema.sql \
deploy/kubernetes/nuthatch.yaml.in \ deploy/kubernetes/nuthatch.yaml.in \
tests/kind/fixture/target.sh \ tests/kind/fixture/target.sh \

14
tests/web-contract.sh Executable file
View 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

View file

@ -1,5 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type {
DeleteBatchItem as DeleteItem,
EntryResponse as Entry,
Root,
SessionResponse as Session
} from './generated/contract.ts';
import { import {
captureOperation, captureOperation,
deleteWarning, deleteWarning,
@ -10,36 +16,10 @@
sharedUploadWarning, sharedUploadWarning,
unfinishedDeleteItems, unfinishedDeleteItems,
type CapturedOperation, type CapturedOperation,
type DeleteItem,
type DeleteItemResult,
type MutationFollowup, type MutationFollowup,
type MutationResult, type PendingDelete
type PendingDelete,
type Root
} from './mutations'; } from './mutations';
import { browserProductClient, requireValue } from './transport.ts';
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;
};
type PendingUpload = { type PendingUpload = {
file: File; file: File;
@ -69,7 +49,7 @@
onMount(async () => { onMount(async () => {
try { try {
session = await requestJson<Session>('/api/session'); session = requireValue(await browserProductClient.session({}));
resumableDelete = readPendingDelete( resumableDelete = readPendingDelete(
localStorage.getItem(recursiveStorageKey) 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> { async function browse(append: boolean): Promise<void> {
busy = true; busy = true;
error = ''; error = '';
try { try {
const values: Record<string, string> = { root }; const page = requireValue(
if (prefix !== '') values.prefix = prefix; await browserProductClient.browse({
if (query !== '') values.q = query; root,
if (append && continuation !== null) values.continuation = continuation; ...(prefix === '' ? {} : { prefix }),
const page = await requestJson<BrowseResponse>(apiUrl('/api/browse', values), { ...(query === '' ? {} : { q: query }),
headers: { Accept: 'application/json' } ...(append && continuation !== null ? { continuation } : {})
}); })
);
entries = append ? [...entries, ...page.entries] : page.entries; entries = append ? [...entries, ...page.entries] : page.entries;
continuation = page.continuation; continuation = page.continuation;
partial = page.partial; partial = page.partial;
@ -175,21 +137,17 @@
notice = ''; notice = '';
try { try {
const digest = await sha256(pendingUpload.file); const digest = await sha256(pendingUpload.file);
const url = apiUrl('/api/upload', { const response = requireValue(
await browserProductClient.upload({
root: pendingUpload.root, root: pendingUpload.root,
path: pendingUpload.path, path: pendingUpload.path,
operation_id: pendingUpload.operationId operationId: pendingUpload.operationId,
}); declaredSize: pendingUpload.file.size,
const response = await requestJson<MutationResponse>(url, { contentSha256: digest,
method: 'POST', csrfToken: session.csrf,
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 body: pendingUpload.file
}); })
);
uploadFollowup = mutationFollowup(response.result); uploadFollowup = mutationFollowup(response.result);
if (uploadFollowup !== 'complete') { if (uploadFollowup !== 'complete') {
throw new Error( throw new Error(
@ -256,16 +214,16 @@
busy = true; busy = true;
error = ''; error = '';
try { try {
const response = await requestJson<MutationResponse>('/api/delete', { const response = requireValue(
method: 'POST', await browserProductClient.delete({
headers: { 'Content-Type': 'application/json' }, body: {
body: JSON.stringify({
root: pendingSingleDelete.root, root: pendingSingleDelete.root,
path: pendingSingleDelete.path, path: pendingSingleDelete.path,
operation_id: pendingSingleDelete.operationId, operation_id: pendingSingleDelete.operationId,
csrf: session.csrf csrf: session.csrf
}
}) })
}); );
singleDeleteFollowup = mutationFollowup(response.result); singleDeleteFollowup = mutationFollowup(response.result);
if (singleDeleteFollowup !== 'complete') { if (singleDeleteFollowup !== 'complete') {
throw new Error( throw new Error(
@ -295,8 +253,11 @@
for (let page = 0; page < 100; page += 1) { for (let page = 0; page < 100; page += 1) {
let items = loadPendingDelete(deleteRoot, path); let items = loadPendingDelete(deleteRoot, path);
if (items.length === 0) { if (items.length === 0) {
const plan = await requestJson<{ paths: string[] }>( const plan = requireValue(
apiUrl('/api/delete-plan', { root: deleteRoot, path }) await browserProductClient.deletePlan({
root: deleteRoot,
path
})
); );
if (plan.paths.length === 0) { if (plan.paths.length === 0) {
localStorage.removeItem(recursiveStorageKey); localStorage.removeItem(recursiveStorageKey);
@ -311,13 +272,14 @@
})); }));
savePendingDelete(deleteRoot, path, items); savePendingDelete(deleteRoot, path, items);
} }
const response = await requestJson<{ results: DeleteItemResult[] }>( const response = requireValue(
'/api/delete-batch', await browserProductClient.deleteBatch({
{ body: {
method: 'POST', root: deleteRoot,
headers: { 'Content-Type': 'application/json' }, csrf: session.csrf,
body: JSON.stringify({ root: deleteRoot, csrf: session.csrf, items }) items
} }
})
); );
const unfinished = unfinishedDeleteItems( const unfinished = unfinishedDeleteItems(
response.results, response.results,
@ -381,7 +343,7 @@
} }
function downloadUrl(entry: Entry): string { 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> { async function logout(): Promise<void> {

View 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>>;
}

View file

@ -1,21 +1,13 @@
export type Root = 'personal' | 'shared'; import type {
DeleteBatchItem,
DeleteItemResponse,
MutationResult,
Root
} from './generated/contract.ts';
export type MutationResult = export type DeleteItem = DeleteBatchItem;
| 'succeeded' export type DeleteItemResult = DeleteItemResponse;
| 'pending' export type { MutationResult, Root };
| 'failed'
| 'rejected'
| 'conflict';
export type DeleteItem = {
path: string;
operation_id: string;
};
export type DeleteItemResult = DeleteItem & {
result: MutationResult | null;
error: string | null;
};
export type PendingDelete = { export type PendingDelete = {
root: Root; root: Root;

548
web/src/transport.ts Normal file
View 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;
}

View 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
View 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