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:
|
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
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 |
|
| 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
, 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
|
||||||
|
|
|
||||||
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
|
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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
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
|
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
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">
|
<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> {
|
||||||
|
|
|
||||||
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 =
|
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
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