diff --git a/checksum/hash_test.go b/checksum/hash_test.go index cf41e51..8ae8211 100644 --- a/checksum/hash_test.go +++ b/checksum/hash_test.go @@ -75,4 +75,3 @@ var _ = Describe("HashBase64ReadWriter", func() { }) }) }) - diff --git a/client.go b/client.go index 2ddecec..22c9145 100644 --- a/client.go +++ b/client.go @@ -17,7 +17,7 @@ import ( // headed to func NewClient(client *http.Client, baseURL *url.URL) *Client { c := &Client{ - ProtocolVersion: "1.0.0", + ProtocolVersion: DefaultProtocolVersion, GetRequest: newRequest, client: client, BaseURL: baseURL, @@ -49,7 +49,7 @@ type Client struct { // BaseURL is base url the client making queries to. For example, "http://example.com/files" BaseURL *url.URL - // ProtocolVersion is TUS protocol version will be used in requests. Default is "1.0.0" + // ProtocolVersion is TUS protocol version will be used in requests. Default is DefaultProtocolVersion. ProtocolVersion string // Server capabilities and settings. Use UpdateCapabilities to query the capabilities from a server @@ -237,6 +237,9 @@ func (c *Client) CreateUploadWithData(u *Upload, data []byte, remoteSize int64, s.ChunkSize = int64(len(data)) // Data must be uploaded in one request s.uploadMethod = http.MethodPost headers := map[string]string{"Upload-Length": strconv.Itoa(int(remoteSize)), "Upload-Offset": ""} + if headerName, value, ok := protocolUploadCompleteHeader(c.ProtocolVersion, true); ok { + headers[headerName] = value + } if partial { headers["Upload-Concat"] = "partial" } @@ -429,8 +432,18 @@ func (c *Client) UpdateCapabilities() (response *http.Response, err error) { } func (c *Client) tusRequest(ctx context.Context, req *http.Request) (response *http.Response, err error) { - if req.Method != http.MethodOptions && req.Header.Get("Tus-Resumable") == "" { - req.Header.Set("Tus-Resumable", c.ProtocolVersion) + if req.Method != http.MethodOptions { + requestHeaders, ok := protocolRequestHeaders(c.ProtocolVersion) + if !ok { + err = ErrProtocol.WithText(fmt.Sprintf("unsupported protocol version %q", c.ProtocolVersion)) + return + } + for headerName, value := range requestHeaders { + if req.Header.Get(headerName) != "" { + continue + } + req.Header.Set(headerName, value) + } } if ctx != nil { req = req.WithContext(ctx) diff --git a/contract_fixture_test.go b/contract_fixture_test.go new file mode 100644 index 0000000..1129911 --- /dev/null +++ b/contract_fixture_test.go @@ -0,0 +1,429 @@ +package tusgo + +import ( + _ "embed" + "encoding/json" + "strconv" + "strings" + "testing" +) + +// The JSON fixture is generated by api2. This file is the Go-specific test adapter: it decodes +// language-neutral contract facts and exposes the small shapes used by this SDK's behavior tests. +// +//go:embed testdata/api2_tus_contract.json +var generatedTusContractJSON []byte + +type generatedTusWireVersion struct { + Default bool `json:"default"` + Value string `json:"value"` +} + +type generatedTusHeaderField struct { + DisplayName string `json:"displayName"` + Name string `json:"name"` + Required bool `json:"required"` +} + +type generatedTusHeaderVariant struct { + Fields []generatedTusHeaderField `json:"fields"` +} + +type generatedTusRequestContract struct { + BodyKind string `json:"bodyKind"` + ContentType string `json:"contentType"` + HeaderVariants []generatedTusHeaderVariant `json:"headerVariants"` +} + +type generatedTusResponseContract struct { + StatusCode int `json:"statusCode"` + BodyKind string `json:"bodyKind"` + HeaderVariants []generatedTusHeaderVariant `json:"headerVariants"` +} + +type generatedTusProtocolOperation struct { + OperationID string `json:"operationId"` + Role string `json:"role"` + Method string `json:"method"` + Path string `json:"path"` + Request generatedTusRequestContract `json:"request"` + Responses []generatedTusResponseContract `json:"responses"` +} + +type generatedTusClientFeature struct { + Conformance generatedTusClientFeatureConformance `json:"conformance"` + Description string `json:"description"` + FeatureID string `json:"featureId"` + Flow []generatedTusClientFeatureFlowStep `json:"flow"` + OperationIDs []string `json:"operationIds"` + Primitives []string `json:"primitives"` +} + +type generatedTusClientFeatureConformance struct { + ScenarioIDs []string `json:"scenarioIds"` + Status string `json:"status"` +} + +type generatedTusClientFeatureFlowStep struct { + Kind string `json:"kind"` + OperationID string `json:"operationId"` + Primitive string `json:"primitive"` + Condition string `json:"condition"` + Summary string `json:"summary"` +} + +type generatedTusClientUrlStorageConformanceScenario struct { + Actions []generatedTusClientUrlStorageConformanceAction `json:"actions"` + Backend string `json:"backend"` + FeatureID string `json:"featureId"` + Runtimes []string `json:"runtimes"` + ScenarioID string `json:"scenarioId"` +} + +type generatedTusClientUrlStorageConformanceAction struct { + ExpectedKeyPrefix string `json:"expectedKeyPrefix"` + ExpectedKeyRefs []string `json:"expectedKeyRefs"` + Fingerprint string `json:"fingerprint"` + KeyRef string `json:"keyRef"` + Kind string `json:"kind"` + Upload map[string]any `json:"upload"` +} + +type generatedTusManagedUploadProofCase struct { + FeatureID string + Layer string + ScenarioID string + RequiredPrimitives []string + ProtocolFeatureIDs []string + RuntimeProfiles []string +} + +type generatedTusContractFixture struct { + ClientFeatures []generatedTusClientFeature `json:"clientFeatures"` + ClientProtocol generatedTusClientProtocol `json:"clientProtocol"` + ClientUrlStorageConformanceScenarios []generatedTusClientUrlStorageConformanceScenario `json:"clientUrlStorageConformanceScenarios"` + ManagedUpload generatedTusManagedUpload `json:"managedUpload"` + Operations []generatedTusProtocolOperation `json:"operations"` + Versions []generatedTusProtocolVersion `json:"versions"` + WireVersions []generatedTusWireVersion `json:"wireVersions"` +} + +type generatedTusClientProtocol struct { + CompatibilityVersions []generatedTusCompatibilityVersion `json:"compatibilityVersions"` +} + +type generatedTusCompatibilityVersion struct { + ProtocolVersion string `json:"protocolVersion"` + RequestHeaders []generatedTusContractHeaderValue `json:"requestHeaders"` + ResponseHeaders []generatedTusContractHeaderValue `json:"responseHeaders"` +} + +type generatedTusContractHeaderValue struct { + HeaderName string `json:"headerName"` + Value string `json:"value"` +} + +type generatedTusManagedUpload struct { + FeatureID string `json:"featureId"` + Flow []generatedTusManagedUploadFlowStep `json:"flow"` + Layer string `json:"layer"` + RuntimeProfiles []generatedTusRuntimeProfile `json:"runtimeProfiles"` + Scenarios []generatedTusManagedUploadScenario `json:"scenarios"` +} + +type generatedTusManagedUploadFlowStep struct { + FeatureID string `json:"featureId"` + Kind string `json:"kind"` +} + +type generatedTusManagedUploadScenario struct { + RequiredPrimitives []string `json:"requiredPrimitives"` + ScenarioID string `json:"scenarioId"` +} + +type generatedTusProtocolVersion struct { + Default bool `json:"default"` + Value string `json:"value"` +} + +type generatedTusRuntimeProfile struct { + Runtime string `json:"runtime"` +} + +var generatedTusContract = parseGeneratedTusContract() +var generatedTusClientFeatures = generatedTusContract.ClientFeatures +var generatedTusClientUrlStorageConformanceScenarios = generatedTusContract.ClientUrlStorageConformanceScenarios +var generatedTusManagedUploadProofCases = buildGeneratedTusManagedUploadProofCases(generatedTusContract.ManagedUpload) +var generatedTusProtocolOperations = generatedTusContract.Operations +var generatedTusWireVersions = generatedTusContract.WireVersions +var generatedTusDefaultRequestHeaderValues, generatedTusDefaultResponseHeaderValues = generatedTusDefaultHeaderValues(generatedTusContract) + +func parseGeneratedTusContract() generatedTusContractFixture { + var contract generatedTusContractFixture + if err := json.Unmarshal(generatedTusContractJSON, &contract); err != nil { + panic("decode generated TUS contract fixture: " + err.Error()) + } + return contract +} + +func generatedTusDefaultHeaderValues(contract generatedTusContractFixture) (map[string]string, map[string]string) { + defaultProtocolVersion := "" + for _, version := range contract.Versions { + if version.Default { + defaultProtocolVersion = version.Value + break + } + } + if defaultProtocolVersion == "" { + panic("generated TUS contract fixture has no default protocol version") + } + + for _, compatibility := range contract.ClientProtocol.CompatibilityVersions { + if compatibility.ProtocolVersion != defaultProtocolVersion { + continue + } + return generatedTusHeaderValueMap(compatibility.RequestHeaders), generatedTusHeaderValueMap(compatibility.ResponseHeaders) + } + panic("generated TUS contract fixture has no compatibility facts for its default protocol") +} + +func generatedTusHeaderValueMap(headers []generatedTusContractHeaderValue) map[string]string { + values := make(map[string]string, len(headers)) + for _, header := range headers { + values[header.HeaderName] = header.Value + } + return values +} + +func buildGeneratedTusManagedUploadProofCases(managedUpload generatedTusManagedUpload) []generatedTusManagedUploadProofCase { + protocolFeatureIDs := make([]string, 0) + for _, step := range managedUpload.Flow { + if step.Kind == "protocol-feature" { + protocolFeatureIDs = append(protocolFeatureIDs, step.FeatureID) + } + } + runtimeProfiles := make([]string, 0, len(managedUpload.RuntimeProfiles)) + for _, profile := range managedUpload.RuntimeProfiles { + runtimeProfiles = append(runtimeProfiles, profile.Runtime) + } + + proofCases := make([]generatedTusManagedUploadProofCase, 0, len(managedUpload.Scenarios)) + for _, scenario := range managedUpload.Scenarios { + proofCases = append(proofCases, generatedTusManagedUploadProofCase{ + FeatureID: managedUpload.FeatureID, + Layer: managedUpload.Layer, + ScenarioID: scenario.ScenarioID, + RequiredPrimitives: scenario.RequiredPrimitives, + ProtocolFeatureIDs: protocolFeatureIDs, + RuntimeProfiles: runtimeProfiles, + }) + } + return proofCases +} + +func generatedTusHeaderValue(defaultValues map[string]string, values map[string]string, name string) string { + if value, ok := values[name]; ok { + return value + } + return defaultValues[name] +} + +func generatedTusRequestHeaderValue(values map[string]string, name string) string { + return generatedTusHeaderValue(generatedTusDefaultRequestHeaderValues, values, name) +} + +func generatedTusResponseHeaderValue(values map[string]string, name string) string { + return generatedTusHeaderValue(generatedTusDefaultResponseHeaderValues, values, name) +} + +type generatedTusTestingT interface { + Fatalf(format string, args ...any) + Helper() +} + +func generatedTusAssertEvents( + t generatedTusTestingT, + scenarioID string, + matching string, + allowedExtraPrefixes []string, + expected []string, + actual []string, +) { + t.Helper() + + if matching == "exact" { + if generatedTusStringSlicesEqual(expected, actual) { + return + } + t.Fatalf("expected %s events %#v, got %#v", scenarioID, expected, actual) + } + + if matching != "exact-except-allowed-extra-events" { + t.Fatalf("unsupported generated event policy %s for %s", matching, scenarioID) + } + + expectedIndex := 0 + for _, event := range actual { + if expectedIndex < len(expected) && event == expected[expectedIndex] { + expectedIndex++ + continue + } + if generatedTusHasAllowedExtraEventPrefix(event, allowedExtraPrefixes) { + continue + } + t.Fatalf( + "%s emitted unexpected extra event %s; allowed prefixes %#v; expected %#v, got %#v", + scenarioID, + event, + allowedExtraPrefixes, + expected, + actual, + ) + } + if expectedIndex == len(expected) { + return + } + t.Fatalf( + "%s did not emit every expected non-extra event; expected %#v, got %#v", + scenarioID, + expected, + actual, + ) +} + +func TestGeneratedTusManagedUploadProofCases(t *testing.T) { + if len(generatedTusManagedUploadProofCases) == 0 { + t.Fatal("expected generated managed upload proof cases") + } + + for _, testCase := range generatedTusManagedUploadProofCases { + if testCase.FeatureID != "managedUpload" { + t.Fatalf("expected managed upload feature ID, got %s", testCase.FeatureID) + } + if testCase.Layer != "feature-over-protocol" { + t.Fatalf("expected managed upload feature-over-protocol layer, got %s", testCase.Layer) + } + if len(testCase.RequiredPrimitives) == 0 { + t.Fatalf("expected %s required primitives", testCase.ScenarioID) + } + if len(testCase.RuntimeProfiles) == 0 { + t.Fatalf("expected %s runtime profiles", testCase.ScenarioID) + } + for _, featureID := range testCase.ProtocolFeatureIDs { + if generatedTusFindClientFeature(featureID) == nil { + t.Fatalf( + "managed upload proof case %s references missing feature %s", + testCase.ScenarioID, + featureID, + ) + } + } + } +} + +func generatedTusFindClientFeature(featureID string) *generatedTusClientFeature { + for index := range generatedTusClientFeatures { + if generatedTusClientFeatures[index].FeatureID == featureID { + return &generatedTusClientFeatures[index] + } + } + return nil +} + +func generatedTusHasAllowedExtraEventPrefix(event string, allowedExtraPrefixes []string) bool { + for _, prefix := range allowedExtraPrefixes { + if len(event) >= len(prefix) && event[:len(prefix)] == prefix { + return true + } + } + return false +} + +const generatedTusEventKeyPartSeparator = ":" + +func generatedTusEventKey(parts ...string) string { + return strings.Join(parts, generatedTusEventKeyPartSeparator) +} + +func generatedTusEventKeyBool(value bool) string { + if value { + return "true" + } + return "false" +} + +func generatedTusEventKeyNumber(value int64) string { + return strconv.FormatInt(value, 10) +} + +func generatedTusEventKeyAfterResponse(requestIndex string) string { + return generatedTusEventKey("after-response", requestIndex) +} + +func generatedTusEventKeyBeforeRequest(requestIndex string) string { + return generatedTusEventKey("before-request", requestIndex) +} + +func generatedTusEventKeyChunkComplete(chunkSize string, bytesAccepted string, bytesTotal string) string { + return generatedTusEventKey("chunk-complete", chunkSize, bytesAccepted, bytesTotal) +} + +func generatedTusEventKeyFingerprint(fingerprint string) string { + return generatedTusEventKey("fingerprint", fingerprint) +} + +func generatedTusEventKeyProgress(bytesSent string, bytesTotal string) string { + return generatedTusEventKey("progress", bytesSent, bytesTotal) +} + +func generatedTusEventKeyRequestAbort(requestIndex string) string { + return generatedTusEventKey("request-abort", requestIndex) +} + +func generatedTusEventKeyRetrySchedule(delay string) string { + return generatedTusEventKey("retry-schedule", delay) +} + +func generatedTusEventKeyShouldRetry(retryAttempt string, decision string) string { + return generatedTusEventKey("should-retry", retryAttempt, decision) +} + +func generatedTusEventKeySourceClose() string { + return generatedTusEventKey("source-close") +} + +func generatedTusEventKeySourceOpen(inputKind string, size string) string { + return generatedTusEventKey("source-open", inputKind, size) +} + +func generatedTusEventKeySuccess() string { + return generatedTusEventKey("success") +} + +func generatedTusEventKeyUploadUrlAvailable() string { + return generatedTusEventKey("upload-url-available") +} + +func generatedTusEventKeyUrlStorageAdd(fingerprint string, uploadURL string) string { + return generatedTusEventKey("url-storage-add", fingerprint, uploadURL) +} + +func generatedTusEventKeyUrlStorageFind(fingerprint string, count string) string { + return generatedTusEventKey("url-storage-find", fingerprint, count) +} + +func generatedTusEventKeyUrlStorageRemove(urlStorageKey string) string { + return generatedTusEventKey("url-storage-remove", urlStorageKey) +} + +func generatedTusStringSlicesEqual(expected []string, actual []string) bool { + if len(expected) != len(actual) { + return false + } + for index, expectedValue := range expected { + if actual[index] != expectedValue { + return false + } + } + return true +} diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.go b/examples/api2-devdock-transloadit-assembly-upload/main.go new file mode 100644 index 0000000..c5d432c --- /dev/null +++ b/examples/api2-devdock-transloadit-assembly-upload/main.go @@ -0,0 +1,90 @@ +//go:build api2devdock + +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithTus( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return "", err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return "", err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return "", err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + upload := tusgo.Upload{} + if _, err := client.CreateUpload(&upload, int64(len(content)), false, metadata); err != nil { + return "", err + } + if upload.Location == "" { + return "", fmt.Errorf("created upload did not include a Location") + } + + stream := tusgo.NewUploadStream(client, &upload) + stream.ChunkSize = tusgo.NoChunked + written, err := stream.Write(content) + if err != nil { + return "", err + } + if written != len(content) { + return "", fmt.Errorf("wrote %d bytes, expected %d", written, len(content)) + } + if upload.RemoteOffset != int64(len(content)) { + return "", fmt.Errorf("remote offset %d, expected %d", upload.RemoteOffset, len(content)) + } + + return upload.Location, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-transloadit-assembly-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + uploadURL, err := uploadWithTus(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("upload: %v", err) + } + if err := api2devdock.WriteResult(map[string]interface{}{"uploadUrl": uploadURL}); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s uploaded to %s\n", scenarioID, uploadURL) +} diff --git a/examples/api2-devdock-tus-abort-upload/main.go b/examples/api2-devdock-tus-abort-upload/main.go new file mode 100644 index 0000000..4f34d51 --- /dev/null +++ b/examples/api2-devdock-tus-abort-upload/main.go @@ -0,0 +1,467 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type observedAbortRequest struct { + Method string + URL string +} + +type abortConformanceServer struct { + cancelUpload context.CancelFunc + endpointOrigin *url.URL + errs []error + mu sync.Mutex + observed []observedAbortRequest + requests []interface{} + server *httptest.Server + events []map[string]interface{} +} + +func newAbortConformanceServer( + conformanceScenario map[string]interface{}, + endpointOrigin *url.URL, + cancelUpload context.CancelFunc, +) (*abortConformanceServer, error) { + requests, err := api2devdock.ArrayValue( + conformanceScenario["requests"], + "conformanceScenario.requests", + ) + if err != nil { + return nil, err + } + + conformanceServer := &abortConformanceServer{ + cancelUpload: cancelUpload, + endpointOrigin: endpointOrigin, + requests: requests, + } + conformanceServer.server = httptest.NewServer(conformanceServer) + + return conformanceServer, nil +} + +func (conformanceServer *abortConformanceServer) Close() { + conformanceServer.server.Close() +} + +func (conformanceServer *abortConformanceServer) EndpointURL() (*url.URL, error) { + endpointURL := *conformanceServer.endpointOrigin + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return nil, err + } + endpointURL.Scheme = serverURL.Scheme + endpointURL.Host = serverURL.Host + + return &endpointURL, nil +} + +func (conformanceServer *abortConformanceServer) CanonicalURL(actualURL string) (string, error) { + parsedActual, err := url.Parse(actualURL) + if err != nil { + return "", err + } + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + if parsedActual.Scheme != serverURL.Scheme || parsedActual.Host != serverURL.Host { + return actualURL, nil + } + + canonical := *parsedActual + canonical.Scheme = conformanceServer.endpointOrigin.Scheme + canonical.Host = conformanceServer.endpointOrigin.Host + + return canonical.String(), nil +} + +func (conformanceServer *abortConformanceServer) Result() (map[string]interface{}, error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + if len(conformanceServer.errs) > 0 { + return nil, conformanceServer.errs[0] + } + + requestMethods := make([]string, 0, len(conformanceServer.observed)) + requestURLs := make([]string, 0, len(conformanceServer.observed)) + for _, request := range conformanceServer.observed { + requestMethods = append(requestMethods, request.Method) + requestURLs = append(requestURLs, request.URL) + } + + return map[string]interface{}{ + "events": conformanceServer.events, + "requestCount": len(conformanceServer.observed), + "requestMethods": requestMethods, + "requestUrls": requestURLs, + }, nil +} + +func (conformanceServer *abortConformanceServer) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + requestIndex, requestPlan, err := conformanceServer.nextRequestPlan() + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + + expectedURL, err := api2devdock.StringValue( + requestPlan["expectedUrl"], + fmt.Sprintf("conformanceScenario.requests[%d].expectedUrl", requestIndex), + ) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + expectedMethod, err := api2devdock.StringValue( + requestPlan["effectiveMethod"], + fmt.Sprintf("conformanceScenario.requests[%d].effectiveMethod", requestIndex), + ) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + requestURL := conformanceServer.endpointOrigin.ResolveReference(request.URL).String() + conformanceServer.observeRequest(observedAbortRequest{ + Method: request.Method, + URL: requestURL, + }) + if requestURL != expectedURL { + conformanceServer.recordErr( + fmt.Errorf("request %d expected URL %s, got %s", requestIndex, expectedURL, requestURL), + ) + } + if request.Method != expectedMethod { + conformanceServer.recordErr( + fmt.Errorf("request %d expected method %s, got %s", requestIndex, expectedMethod, request.Method), + ) + } + + shouldAbort, err := api2devdock.BoolValue( + requestPlan["abort"], + fmt.Sprintf("conformanceScenario.requests[%d].abort", requestIndex), + ) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + if shouldAbort { + conformanceServer.recordAbortEvent(requestIndex, request.Method, requestURL) + conformanceServer.cancelUpload() + select { + case <-request.Context().Done(): + case <-time.After(2 * time.Second): + conformanceServer.recordErr( + fmt.Errorf("request %d did not observe cancellation", requestIndex), + ) + } + return + } + + if err := conformanceServer.writeResponse(responseWriter, requestIndex, requestPlan); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + } +} + +func (conformanceServer *abortConformanceServer) nextRequestPlan() ( + int, + map[string]interface{}, + error, +) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + requestIndex := len(conformanceServer.observed) + if requestIndex >= len(conformanceServer.requests) { + return 0, nil, fmt.Errorf("unexpected request %d", requestIndex) + } + requestPlan, err := api2devdock.ObjectValue( + conformanceServer.requests[requestIndex], + fmt.Sprintf("conformanceScenario.requests[%d]", requestIndex), + ) + if err != nil { + return 0, nil, err + } + + return requestIndex, requestPlan, nil +} + +func (conformanceServer *abortConformanceServer) observeRequest(request observedAbortRequest) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.observed = append(conformanceServer.observed, request) +} + +func (conformanceServer *abortConformanceServer) recordAbortEvent( + requestIndex int, + method string, + requestURL string, +) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.events = append(conformanceServer.events, map[string]interface{}{ + "kind": "request-abort", + "method": method, + "requestIndex": requestIndex, + "url": requestURL, + }) +} + +func (conformanceServer *abortConformanceServer) recordErr(err error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.errs = append(conformanceServer.errs, err) +} + +func (conformanceServer *abortConformanceServer) writeResponse( + responseWriter http.ResponseWriter, + requestIndex int, + requestPlan map[string]interface{}, +) error { + responsePlan, err := api2devdock.ObjectValue( + requestPlan["response"], + fmt.Sprintf("conformanceScenario.requests[%d].response", requestIndex), + ) + if err != nil { + return err + } + headers, err := api2devdock.StringMapValue( + responsePlan["effectiveHeaders"], + fmt.Sprintf("conformanceScenario.requests[%d].response.effectiveHeaders", requestIndex), + ) + if err != nil { + return err + } + for name, value := range headers { + if name == "Location" { + value, err = conformanceServer.localResponseURL(value) + if err != nil { + return err + } + } + responseWriter.Header().Set(name, value) + } + statusCode, err := api2devdock.IntValue( + responsePlan["statusCode"], + fmt.Sprintf("conformanceScenario.requests[%d].response.statusCode", requestIndex), + ) + if err != nil { + return err + } + responseWriter.WriteHeader(statusCode) + + return nil +} + +func (conformanceServer *abortConformanceServer) localResponseURL(value string) (string, error) { + canonicalURL, err := url.Parse(value) + if err != nil { + return "", err + } + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + if canonicalURL.Scheme != conformanceServer.endpointOrigin.Scheme || + canonicalURL.Host != conformanceServer.endpointOrigin.Host { + return value, nil + } + + localURL := *canonicalURL + localURL.Scheme = serverURL.Scheme + localURL.Host = serverURL.Host + + return localURL.String(), nil +} + +func uploadAndAbort( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption(conformanceScenario, "metadata") + if err != nil { + return nil, err + } + headers, err := api2devdock.TusConformanceInputStringMapOption(conformanceScenario, "headers") + if err != nil { + return nil, err + } + overridePatchMethod, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "overridePatchMethod", + false, + ) + if err != nil { + return nil, err + } + terminateUploadOnAbort, err := api2devdock.TusConformanceRuntimeAbortTerminateUpload( + conformanceScenario, + ) + if err != nil { + return nil, err + } + fingerprint, err := api2devdock.TusConformanceRuntimeFingerprint(conformanceScenario) + if err != nil { + return nil, err + } + if fingerprint == "" { + fingerprint = "api2-go-abort-conformance-fingerprint" + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + capabilities := &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + + uploadCtx, cancelUpload := context.WithCancel(ctx) + defer cancelUpload() + conformanceServer, err := newAbortConformanceServer( + conformanceScenario, + endpointOrigin, + cancelUpload, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = capabilities + + type uploadResult struct { + err error + upload *tusgo.Upload + } + result := make(chan uploadResult, 1) + go func() { + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: uploadCtx, + Fingerprint: fingerprint, + Headers: headers, + Metadata: metadata, + OverridePatchMethod: overridePatchMethod, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + TerminateUploadOnAbort: terminateUploadOnAbort, + EventHooks: tusgo.UploadEventHooks{ + OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + return nil + }, + }, + }) + result <- uploadResult{err: err, upload: upload} + }() + + var upload *tusgo.Upload + select { + case uploadResult := <-result: + if !errors.Is(uploadResult.err, context.Canceled) { + return nil, fmt.Errorf("expected upload abort, got upload=%#v err=%v", uploadResult.upload, uploadResult.err) + } + upload = uploadResult.upload + case <-time.After(5 * time.Second): + return nil, fmt.Errorf("timed out waiting for upload abort") + } + + serverResult, err := conformanceServer.Result() + if err != nil { + return nil, err + } + uploadURL := interface{}(nil) + if upload != nil && upload.Location != "" { + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + uploadURL = canonicalUploadURL + } + serverResult["completionKind"] = "aborted" + serverResult["errorCalled"] = false + serverResult["successCalled"] = successCalled + serverResult["uploadUrl"] = uploadURL + + return serverResult, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-abort-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadAndAbort(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("abort upload: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s aborted the upload\n", scenarioID) +} diff --git a/examples/api2-devdock-tus-creation-with-upload/main.go b/examples/api2-devdock-tus-creation-with-upload/main.go new file mode 100644 index 0000000..125c8a6 --- /dev/null +++ b/examples/api2-devdock-tus-creation-with-upload/main.go @@ -0,0 +1,115 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithCreationData( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + upload, err := api2devdock.ObjectValue(scenario["upload"], "upload") + if err != nil { + return "", err + } + uploadDataDuringCreation, err := api2devdock.BoolValue( + upload["uploadDataDuringCreation"], + "upload.uploadDataDuringCreation", + ) + if err != nil { + return "", err + } + if !uploadDataDuringCreation { + return "", fmt.Errorf("creation-with-upload scenario must set uploadDataDuringCreation") + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return "", err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return "", err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return "", err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return "", err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return "", err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadDataDuringCreation: true, + }) + if err != nil { + return "", err + } + if createdUpload == nil || createdUpload.Location == "" { + return "", fmt.Errorf("creation-with-upload TUS upload did not expose an upload URL") + } + if createdUpload.RemoteOffset != int64(len(content)) { + return "", fmt.Errorf( + "creation-with-upload accepted %d bytes, expected %d", + createdUpload.RemoteOffset, + len(content), + ) + } + + return createdUpload.Location, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-creation-with-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + uploadURL, err := uploadWithCreationData(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("creation-with-upload: %v", err) + } + if err := api2devdock.WriteResult(map[string]interface{}{"uploadUrl": uploadURL}); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s uploaded to %s\n", scenarioID, uploadURL) +} diff --git a/examples/api2-devdock-tus-custom-request-headers/main.go b/examples/api2-devdock-tus-custom-request-headers/main.go new file mode 100644 index 0000000..e70f48b --- /dev/null +++ b/examples/api2-devdock-tus-custom-request-headers/main.go @@ -0,0 +1,152 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "reflect" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func observedCustomHeaders( + request *http.Request, + expectedHeaders map[string]string, +) map[string]string { + observed := map[string]string{} + for name := range expectedHeaders { + observed[name] = request.Header.Get(name) + } + + return observed +} + +func assertObservedCustomHeaders( + label string, + actual map[string]string, + expected map[string]string, +) error { + if !reflect.DeepEqual(actual, expected) { + return fmt.Errorf("%s expected headers %v, got %v", label, expected, actual) + } + + return nil +} + +func uploadWithCustomHeaders( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + headers, err := api2devdock.UploadHeaders(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + headersByMethod := map[string]map[string]string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + switch request.Method { + case http.MethodPost, http.MethodPatch: + headersByMethod[request.Method] = observedCustomHeaders(request, headers) + } + + return nil + }, + }, + ).WithContext(ctx) + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Headers: headers, + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("custom request headers TUS upload did not expose an upload URL") + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "custom request headers upload accepted %d bytes, expected %d", + upload.RemoteOffset, + len(content), + ) + } + if err := assertObservedCustomHeaders("POST", headersByMethod[http.MethodPost], headers); err != nil { + return nil, err + } + if err := assertObservedCustomHeaders("PATCH", headersByMethod[http.MethodPatch], headers); err != nil { + return nil, err + } + + return map[string]interface{}{ + "headersByMethod": headersByMethod, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-custom-request-headers/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithCustomHeaders(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("custom request headers: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s sent custom headers to %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-deferred-length-upload/main.go b/examples/api2-devdock-tus-deferred-length-upload/main.go new file mode 100644 index 0000000..ec3fb06 --- /dev/null +++ b/examples/api2-devdock-tus-deferred-length-upload/main.go @@ -0,0 +1,117 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithDeferredLength( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + uploadLengthDeferred, err := api2devdock.UploadLengthDeferred(scenario) + if err != nil { + return "", err + } + if !uploadLengthDeferred { + return "", fmt.Errorf("deferred-length scenario must set uploadLengthDeferred") + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return "", err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return "", err + } + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return "", err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return "", err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return "", err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + ChunkSize: chunkSize, + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadLengthDeferred: uploadLengthDeferred, + }) + if err != nil { + return "", err + } + if createdUpload == nil || createdUpload.Location == "" { + return "", fmt.Errorf("deferred-length TUS upload did not expose an upload URL") + } + if createdUpload.RemoteSize != int64(len(content)) { + return "", fmt.Errorf( + "deferred-length upload size is %d, expected %d", + createdUpload.RemoteSize, + len(content), + ) + } + if createdUpload.RemoteOffset != int64(len(content)) { + return "", fmt.Errorf( + "deferred-length upload accepted %d bytes, expected %d", + createdUpload.RemoteOffset, + len(content), + ) + } + + return createdUpload.Location, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-deferred-length-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + uploadURL, err := uploadWithDeferredLength(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("deferred-length upload: %v", err) + } + if err := api2devdock.WriteResult(map[string]interface{}{"uploadUrl": uploadURL}); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s deferred length to %s\n", scenarioID, uploadURL) +} diff --git a/examples/api2-devdock-tus-detailed-error/main.go b/examples/api2-devdock-tus-detailed-error/main.go new file mode 100644 index 0000000..07ef99c --- /dev/null +++ b/examples/api2-devdock-tus-detailed-error/main.go @@ -0,0 +1,307 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type detailedErrorTransport struct { + requestCount int + requestMethods []string + requestPlan map[string]interface{} + requestURLs []string +} + +func (transport *detailedErrorTransport) RoundTrip( + request *http.Request, +) (*http.Response, error) { + transport.requestCount += 1 + transport.requestMethods = append(transport.requestMethods, request.Method) + transport.requestURLs = append(transport.requestURLs, request.URL.String()) + + errorPlan, ok, err := optionalObject( + transport.requestPlan, + "error", + "conformanceScenario.requests[0].error", + ) + if err != nil { + return nil, err + } + if ok { + message, err := api2devdock.StringValue( + errorPlan["message"], + "conformanceScenario.requests[0].error.message", + ) + if err != nil { + return nil, err + } + + return nil, errors.New(message) + } + if rawErrorMessage, ok := transport.requestPlan["errorMessage"]; ok && rawErrorMessage != nil { + message, err := api2devdock.StringValue( + rawErrorMessage, + "conformanceScenario.requests[0].errorMessage", + ) + if err != nil { + return nil, err + } + + return nil, errors.New(message) + } + + responsePlan, ok, err := optionalObject( + transport.requestPlan, + "response", + "conformanceScenario.requests[0].response", + ) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("detailed error scenario did not provide a response or error plan") + } + + headers, err := api2devdock.StringMapValue( + responsePlan["effectiveHeaders"], + "conformanceScenario.requests[0].response.effectiveHeaders", + ) + if err != nil { + return nil, err + } + statusCode, err := api2devdock.IntValue( + responsePlan["statusCode"], + "conformanceScenario.requests[0].response.statusCode", + ) + if err != nil { + return nil, err + } + body := "" + if rawBody, ok := responsePlan["body"]; ok && rawBody != nil { + body, err = api2devdock.StringValue( + rawBody, + "conformanceScenario.requests[0].response.body", + ) + if err != nil { + return nil, err + } + } + + responseHeaders := http.Header{} + for name, value := range headers { + responseHeaders.Set(name, value) + } + + return &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + Header: responseHeaders, + Request: request, + StatusCode: statusCode, + }, nil +} + +func optionalObject( + object map[string]interface{}, + key string, + label string, +) (map[string]interface{}, bool, error) { + rawValue, ok := object[key] + if !ok || rawValue == nil { + return nil, false, nil + } + value, err := api2devdock.ObjectValue(rawValue, label) + if err != nil { + return nil, false, err + } + + return value, true, nil +} + +func firstDetailedErrorRequestPlan( + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + requests, err := api2devdock.ArrayValue( + conformanceScenario["requests"], + "conformanceScenario.requests", + ) + if err != nil { + return nil, err + } + if len(requests) != 1 { + return nil, fmt.Errorf("detailed error scenario expected one request, got %d", len(requests)) + } + + return api2devdock.ObjectValue(requests[0], "conformanceScenario.requests[0]") +} + +func detailedErrorRequestIDHeaderName( + conformanceScenario map[string]interface{}, + requestPlan map[string]interface{}, +) (string, error) { + inputHeaders, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "headers", + ) + if err != nil { + return "", err + } + expectedHeaders, err := api2devdock.StringMapValue( + requestPlan["effectiveHeaders"], + "conformanceScenario.requests[0].effectiveHeaders", + ) + if err != nil { + return "", err + } + + matchingHeaderNames := []string{} + for name, value := range inputHeaders { + if expectedHeaders[name] == value { + matchingHeaderNames = append(matchingHeaderNames, name) + } + } + if len(matchingHeaderNames) != 1 { + return "", fmt.Errorf( + "detailed error scenario expected one request ID header candidate, got %d", + len(matchingHeaderNames), + ) + } + + return matchingHeaderNames[0], nil +} + +func uploadExpectingDetailedError( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointURL, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + headers, err := api2devdock.TusConformanceInputStringMapOption(conformanceScenario, "headers") + if err != nil { + return nil, err + } + requestPlan, err := firstDetailedErrorRequestPlan(conformanceScenario) + if err != nil { + return nil, err + } + requestIDHeaderName, err := detailedErrorRequestIDHeaderName(conformanceScenario, requestPlan) + if err != nil { + return nil, err + } + + transport := &detailedErrorTransport{ + requestPlan: requestPlan, + } + client := tusgo.NewClient(&http.Client{Transport: transport}, endpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: []string{"creation"}, + ProtocolVersions: []string{tusgo.DefaultProtocolVersion}, + } + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-detailed-error-conformance-fingerprint", + Headers: headers, + Metadata: metadata, + RetryDelays: []time.Duration{}, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err == nil { + return nil, fmt.Errorf("detailed error scenario unexpectedly created upload %#v", upload) + } + var detailedError *tusgo.DetailedError + errorIsDetailed := errors.As(err, &detailedError) + result := map[string]interface{}{ + "errorCaught": true, + "errorMessage": err.Error(), + "errorIsDetailed": errorIsDetailed, + "requestCount": transport.requestCount, + "requestMethods": transport.requestMethods, + "requestUrls": transport.requestURLs, + } + if !errorIsDetailed { + return result, nil + } + + result["causingErrorPresent"] = detailedError.CausingError != nil + if detailedError.CausingError != nil { + result["causingErrorMessage"] = detailedError.CausingError.Error() + } + if detailedError.OriginalRequest != nil { + result["originalRequestMethod"] = detailedError.OriginalRequest.Method + result["originalRequestRequestId"] = detailedError.OriginalRequest.Header.Get( + requestIDHeaderName, + ) + result["originalRequestUrl"] = detailedError.OriginalRequest.URL.String() + } + result["originalResponsePresent"] = detailedError.OriginalResponse != nil + if detailedError.OriginalResponse != nil { + result["originalResponseBody"] = detailedError.OriginalResponseBody + result["originalResponseStatus"] = detailedError.OriginalResponse.StatusCode + } + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-detailed-error/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadExpectingDetailedError(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("detailed error: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s reported a detailed error\n", scenarioID) +} diff --git a/examples/api2-devdock-tus-node-path-input-source/main.go b/examples/api2-devdock-tus-node-path-input-source/main.go new file mode 100644 index 0000000..05834ec --- /dev/null +++ b/examples/api2-devdock-tus-node-path-input-source/main.go @@ -0,0 +1,237 @@ +//go:build api2devdock + +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func appendSourceOpenEvent( + events []map[string]interface{}, + conformanceScenario map[string]interface{}, + inputKind string, + size int, +) ([]map[string]interface{}, error) { + wantsSourceOpen, err := api2devdock.TusConformanceScenarioWantsEvent( + conformanceScenario, + "source-open", + ) + if err != nil { + return nil, err + } + if !wantsSourceOpen { + return events, nil + } + + return append(events, map[string]interface{}{ + "inputKind": inputKind, + "kind": "source-open", + "size": size, + }), nil +} + +func appendSourceCloseEvent( + events []map[string]interface{}, + conformanceScenario map[string]interface{}, +) ([]map[string]interface{}, error) { + wantsSourceClose, err := api2devdock.TusConformanceScenarioWantsEvent( + conformanceScenario, + "source-close", + ) + if err != nil { + return nil, err + } + if !wantsSourceClose { + return events, nil + } + + return append(events, map[string]interface{}{"kind": "source-close"}), nil +} + +func appendSuccessEvent( + events []map[string]interface{}, + conformanceScenario map[string]interface{}, +) ([]map[string]interface{}, error) { + wantsSuccess, err := api2devdock.TusConformanceScenarioWantsEvent( + conformanceScenario, + "success", + ) + if err != nil { + return nil, err + } + if !wantsSuccess { + return events, nil + } + + return append(events, map[string]interface{}{"kind": "success"}), nil +} + +func uploadWithNodePathInputSource( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + inputKind, err := api2devdock.TusConformanceInputSourceKind(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + + tmpDir, err := os.MkdirTemp("", "api2-go-tus-node-path-input-source-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + inputPath := filepath.Join(tmpDir, "input.txt") + if err := os.WriteFile(inputPath, content, 0o600); err != nil { + return nil, err + } + source, err := os.Open(inputPath) + if err != nil { + return nil, err + } + + events := []map[string]interface{}{} + events, err = appendSourceOpenEvent(events, conformanceScenario, inputKind, len(content)) + if err != nil { + source.Close() + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + source.Close() + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + source.Close() + return nil, err + } + + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-node-path-input-source-conformance-fingerprint", + Metadata: metadata, + Size: int64(len(content)), + Source: source, + Storage: tusgo.NewMemoryURLStorage(), + EventHooks: tusgo.UploadEventHooks{ + OnSuccess: func(tusgo.UploadSuccessPayload) error { + var eventErr error + events, eventErr = appendSuccessEvent(events, conformanceScenario) + return eventErr + }, + }, + }) + closeErr := source.Close() + if err != nil { + return nil, err + } + if closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { + return nil, closeErr + } + events, err = appendSourceCloseEvent(events, conformanceScenario) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("node-path TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["events"] = events + result["inputKind"] = inputKind + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-node-path-input-source/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithNodePathInputSource(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("node path input source: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s read %s for %s\n", + scenarioID, + result["inputKind"], + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-override-patch-method/main.go b/examples/api2-devdock-tus-override-patch-method/main.go new file mode 100644 index 0000000..5951c53 --- /dev/null +++ b/examples/api2-devdock-tus-override-patch-method/main.go @@ -0,0 +1,152 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithOverridePatchMethod( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + uploadURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "uploadUrl", + ) + if err != nil { + return nil, err + } + overridePatchMethod, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "overridePatchMethod", + false, + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + fingerprint, err := api2devdock.TusConformanceRuntimeFingerprint(conformanceScenario) + if err != nil { + return nil, err + } + if fingerprint == "" { + fingerprint = "api2-go-override-patch-conformance-fingerprint" + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + localUploadURL, err := conformanceServer.LocalURL(uploadURLValue) + if err != nil { + return nil, err + } + + storage := tusgo.NewMemoryURLStorage() + if _, err := storage.AddUpload( + fingerprint, + tusgo.URLStorageUpload{"uploadUrl": localUploadURL}, + ); err != nil { + return nil, err + } + + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: fingerprint, + OverridePatchMethod: overridePatchMethod, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: storage, + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("override-PATCH TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-override-patch-method/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithOverridePatchMethod(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("override PATCH method: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s overrode PATCH for %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-parallel-upload-concat/main.go b/examples/api2-devdock-tus-parallel-upload-concat/main.go new file mode 100644 index 0000000..083b3ac --- /dev/null +++ b/examples/api2-devdock-tus-parallel-upload-concat/main.go @@ -0,0 +1,198 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithParallelConcat( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + metadataForPartialUploads, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadataForPartialUploads", + ) + if err != nil { + return nil, err + } + parallelUploads, err := api2devdock.TusConformanceInputIntOption( + conformanceScenario, + "parallelUploads", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return nil, err + } + completionKind, err := api2devdock.StringValue( + completion["kind"], + "conformanceScenario.completion.kind", + ) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + events := []map[string]interface{}{} + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-parallel-concat-conformance-fingerprint", + Metadata: metadata, + MetadataForPartialUploads: metadataForPartialUploads, + ParallelUploads: parallelUploads, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + EventHooks: tusgo.UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + event := map[string]interface{}{ + "bytesSent": bytesSent, + "kind": "progress", + } + if bytesTotal != nil { + event["bytesTotal"] = *bytesTotal + } + events = append(events, event) + + return nil + }, + OnChunkComplete: func( + chunkSize int64, + bytesAccepted int64, + bytesTotal *int64, + ) error { + event := map[string]interface{}{ + "bytesAccepted": bytesAccepted, + "chunkSize": chunkSize, + "kind": "chunk-complete", + } + if bytesTotal != nil { + event["bytesTotal"] = *bytesTotal + } + events = append(events, event) + + return nil + }, + OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + + return nil + }, + }, + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("parallel TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["completionKind"] = completionKind + result["errorCalled"] = false + result["eventCount"] = len(events) + result["events"] = events + result["successCalled"] = successCalled + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-parallel-upload-concat/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithParallelConcat(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("parallel upload concat: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s concatenated %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-protocol-version-selection/main.go b/examples/api2-devdock-tus-protocol-version-selection/main.go new file mode 100644 index 0000000..d1def96 --- /dev/null +++ b/examples/api2-devdock-tus-protocol-version-selection/main.go @@ -0,0 +1,164 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithProtocolVersionSelection( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + protocol, err := api2devdock.TusConformanceInputStringOption(conformanceScenario, "protocol") + if err != nil { + return nil, err + } + uploadDataDuringCreation, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "uploadDataDuringCreation", + false, + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return nil, err + } + completionKind, err := api2devdock.StringValue( + completion["kind"], + "conformanceScenario.completion.kind", + ) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.ProtocolVersion = protocol + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-protocol-version-conformance-fingerprint", + Metadata: metadata, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadDataDuringCreation: uploadDataDuringCreation, + EventHooks: tusgo.UploadEventHooks{ + OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + + return nil + }, + }, + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("protocol-version TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["completionKind"] = completionKind + result["errorCalled"] = false + result["successCalled"] = successCalled + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-protocol-version-selection/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithProtocolVersionSelection(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("protocol version selection: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s selected protocol for %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-relative-location-resolution/main.go b/examples/api2-devdock-tus-relative-location-resolution/main.go new file mode 100644 index 0000000..1361620 --- /dev/null +++ b/examples/api2-devdock-tus-relative-location-resolution/main.go @@ -0,0 +1,125 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithRelativeLocationResolution( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-relative-location-conformance-fingerprint", + Metadata: metadata, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("relative-location TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-relative-location-resolution/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithRelativeLocationResolution(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("relative Location resolution: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s resolved %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-request-id-headers/main.go b/examples/api2-devdock-tus-request-id-headers/main.go new file mode 100644 index 0000000..5735e06 --- /dev/null +++ b/examples/api2-devdock-tus-request-id-headers/main.go @@ -0,0 +1,149 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func observedRequestIDHeader(request *http.Request, headerName string) (string, error) { + value := request.Header.Get(headerName) + if value == "" { + return "", fmt.Errorf( + "request ID headers scenario did not observe %s on %s", + headerName, + request.Method, + ) + } + + return value, nil +} + +func uploadWithRequestIDHeaders( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + addRequestID, err := api2devdock.UploadAddRequestID(scenario) + if err != nil { + return nil, err + } + requestIDHeaderName, err := api2devdock.UploadRequestIDHeaderName(scenario) + if err != nil { + return nil, err + } + headers, err := api2devdock.UploadHeaders(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + headersByMethod := map[string]map[string]string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + switch request.Method { + case http.MethodPost, http.MethodPatch: + value, err := observedRequestIDHeader(request, requestIDHeaderName) + if err != nil { + return err + } + headersByMethod[request.Method] = map[string]string{ + requestIDHeaderName: value, + } + } + + return nil + }, + }, + ).WithContext(ctx) + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + AddRequestID: addRequestID, + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Headers: headers, + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("request ID headers TUS upload did not expose an upload URL") + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "request ID headers upload accepted %d bytes, expected %d", + upload.RemoteOffset, + len(content), + ) + } + + return map[string]interface{}{ + "headersByMethod": headersByMethod, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-request-id-headers/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithRequestIDHeaders(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("request ID headers: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s observed request ID headers to %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-request-lifecycle-hooks/main.go b/examples/api2-devdock-tus-request-lifecycle-hooks/main.go new file mode 100644 index 0000000..facfa9a --- /dev/null +++ b/examples/api2-devdock-tus-request-lifecycle-hooks/main.go @@ -0,0 +1,199 @@ +//go:build api2devdock + +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func assertRequestMethods(label string, actual []string, expected []string) error { + if len(actual) != len(expected) { + return fmt.Errorf("%s expected request methods %v, got %v", label, expected, actual) + } + + for index, method := range expected { + if actual[index] != method { + return fmt.Errorf( + "%s expected request method %s at index %d, got %s", + label, + method, + index, + actual[index], + ) + } + } + + return nil +} + +func assertStatusCodes(actual []int, expected []int) error { + if len(actual) != len(expected) { + return fmt.Errorf( + "request lifecycle hooks expected status codes %v, got %v", + expected, + actual, + ) + } + + for index, statusCode := range expected { + if actual[index] != statusCode { + return fmt.Errorf( + "request lifecycle hooks expected status code %d at index %d, got %d", + statusCode, + index, + actual[index], + ) + } + } + + return nil +} + +func shouldIgnoreRequestMethod(method string, ignoredMethods []string) bool { + for _, ignoredMethod := range ignoredMethods { + if method == ignoredMethod { + return true + } + } + + return false +} + +func uploadWithLifecycleHooks( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + requestLifecycleHooks, err := api2devdock.RequestLifecycleHooks(scenario) + if err != nil { + return nil, err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + + afterResponseMethods := []string{} + afterResponseStatusCodes := []int{} + beforeRequestMethods := []string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + if shouldIgnoreRequestMethod(request.Method, requestLifecycleHooks.IgnoredRequestMethods) { + return nil + } + beforeRequestMethods = append(beforeRequestMethods, request.Method) + + return nil + }, + AfterResponse: func(request *http.Request, response *http.Response) error { + if shouldIgnoreRequestMethod(request.Method, requestLifecycleHooks.IgnoredRequestMethods) { + return nil + } + afterResponseMethods = append(afterResponseMethods, request.Method) + afterResponseStatusCodes = append(afterResponseStatusCodes, response.StatusCode) + + return nil + }, + }, + ).WithContext(ctx) + + upload := tusgo.Upload{} + if _, err := client.CreateUpload(&upload, int64(len(content)), false, metadata); err != nil { + return nil, err + } + if upload.Location == "" { + return nil, fmt.Errorf("request lifecycle hooks TUS upload did not expose an upload URL") + } + + stream := tusgo.NewUploadStream(client, &upload) + stream.ChunkSize = tusgo.NoChunked + written, err := stream.Write(content) + if err != nil { + return nil, err + } + if written != len(content) { + return nil, fmt.Errorf("wrote %d bytes, expected %d", written, len(content)) + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf("remote offset %d, expected %d", upload.RemoteOffset, len(content)) + } + if err := assertRequestMethods( + "before request lifecycle hooks", + beforeRequestMethods, + requestLifecycleHooks.ExpectedBeforeRequestMethods, + ); err != nil { + return nil, err + } + if err := assertRequestMethods( + "after response lifecycle hooks", + afterResponseMethods, + requestLifecycleHooks.ExpectedAfterResponseMethods, + ); err != nil { + return nil, err + } + if err := assertStatusCodes( + afterResponseStatusCodes, + requestLifecycleHooks.ExpectedAfterResponseStatusCodes, + ); err != nil { + return nil, err + } + + return map[string]interface{}{ + "afterResponseMethods": afterResponseMethods, + "afterResponseStatusCodes": afterResponseStatusCodes, + "beforeRequestMethods": beforeRequestMethods, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-request-lifecycle-hooks/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithLifecycleHooks(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("request lifecycle hooks: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s observed lifecycle hooks for %s\n", + scenarioID, + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-resume-upload/main.go b/examples/api2-devdock-tus-resume-upload/main.go new file mode 100644 index 0000000..5513207 --- /dev/null +++ b/examples/api2-devdock-tus-resume-upload/main.go @@ -0,0 +1,297 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadOptions( + scenario map[string]interface{}, + createResponse map[string]interface{}, + storage tusgo.URLStorage, + content []byte, +) (tusgo.URLStorageUploadOptions, error) { + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return tusgo.URLStorageUploadOptions{}, err + } + resume, err := api2devdock.Resume(scenario) + if err != nil { + return tusgo.URLStorageUploadOptions{}, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return tusgo.URLStorageUploadOptions{}, err + } + + return tusgo.URLStorageUploadOptions{ + ChunkSize: chunkSize, + Fingerprint: resume.Fingerprint, + Metadata: metadata, + RemoveFingerprintOnSuccess: resume.RemoveFingerprintOnSuccess, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: storage, + }, nil +} + +func uploadFirstChunkAndAbort( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, + storage tusgo.URLStorage, + content []byte, +) (int, string, error) { + resume, err := api2devdock.Resume(scenario) + if err != nil { + return 0, "", err + } + options, err := uploadOptions(scenario, createResponse, storage, content) + if err != nil { + return 0, "", err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return 0, "", err + } + + abortCtx, cancelAbort := context.WithCancel(ctx) + defer cancelAbort() + + options.Context = abortCtx + options.EventHooks = tusgo.UploadEventHooks{ + OnChunkComplete: func(_ int64, bytesAccepted int64, _ *int64) error { + if int(bytesAccepted) < resume.StopAfterAcceptedBytes { + return nil + } + + cancelAbort() + return nil + }, + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL) + upload, err := client.UploadWithURLStorage(options) + if !errors.Is(err, context.Canceled) { + return 0, "", fmt.Errorf("expected context cancellation, got upload=%#v err=%v", upload, err) + } + if upload == nil { + return 0, "", fmt.Errorf("resume scenario did not return the aborted upload") + } + storedUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return 0, "", err + } + if len(storedUploads) == 0 { + return 0, "", fmt.Errorf("resume scenario did not store the first upload URL") + } + firstUploadURL, ok := storedUploads[0]["uploadUrl"].(string) + if !ok || firstUploadURL == "" { + return 0, "", fmt.Errorf("resume scenario stored upload is missing uploadUrl") + } + + return int(upload.RemoteOffset), firstUploadURL, nil +} + +func resumeStoredUpload( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, + storage tusgo.URLStorage, + content []byte, +) (int, int, string, error) { + resume, err := api2devdock.Resume(scenario) + if err != nil { + return 0, 0, "", err + } + previousUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return 0, 0, "", err + } + if len(previousUploads) != resume.ExpectedPreviousUploadCount { + return 0, 0, "", fmt.Errorf( + "expected %d stored upload(s), got %d", + resume.ExpectedPreviousUploadCount, + len(previousUploads), + ) + } + options, err := uploadOptions(scenario, createResponse, storage, content) + if err != nil { + return 0, 0, "", err + } + options.Context = ctx + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return 0, 0, "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL) + upload, err := client.UploadWithURLStorage(options) + if err != nil { + return 0, 0, "", err + } + if upload == nil || upload.Location == "" { + return 0, 0, "", fmt.Errorf("resumed TUS upload did not expose an upload URL") + } + + remainingUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return 0, 0, "", err + } + if len(remainingUploads) != resume.ExpectedRemainingPreviousUploadCount { + return 0, 0, "", fmt.Errorf( + "expected %d stored upload(s) after success, got %d", + resume.ExpectedRemainingPreviousUploadCount, + len(remainingUploads), + ) + } + + return len(previousUploads), len(remainingUploads), upload.Location, nil +} + +func urlStorageUploadsContainKeyPrefix( + storedUploads []tusgo.URLStorageUpload, + expectedPrefix string, +) bool { + for _, storedUpload := range storedUploads { + storageKey, ok := storedUpload["urlStorageKey"].(string) + if !ok { + continue + } + if strings.HasPrefix(storageKey, expectedPrefix) { + return true + } + } + + return false +} + +func storedUploadKeyPrefixMatched( + scenario map[string]interface{}, + storage tusgo.URLStorage, +) (bool, error) { + backend, err := api2devdock.URLStorageBackend(scenario) + if err != nil || backend == nil { + return false, err + } + resume, err := api2devdock.Resume(scenario) + if err != nil { + return false, err + } + storedUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return false, err + } + + return urlStorageUploadsContainKeyPrefix( + storedUploads, + backend.ExpectedStoredUploadKeyPrefix, + ), nil +} + +func uploadWithStoredResume( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + tempDir, err := os.MkdirTemp("", "api2-tus-go-resume-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tempDir) + + storage := tusgo.NewFileURLStorage(filepath.Join(tempDir, "url-storage.json")) + firstAcceptedBytes, firstUploadURL, err := uploadFirstChunkAndAbort( + ctx, + scenario, + createResponse, + storage, + content, + ) + if err != nil { + return nil, err + } + uploadKeyPrefixMatched, err := storedUploadKeyPrefixMatched(scenario, storage) + if err != nil { + return nil, err + } + previousUploadCount, remainingPreviousUploadCount, uploadURL, err := resumeStoredUpload( + ctx, + scenario, + createResponse, + storage, + content, + ) + if err != nil { + return nil, err + } + storedUploads, err := storage.FindAllUploads() + if err != nil { + return nil, err + } + + result := map[string]interface{}{ + "firstAcceptedBytes": firstAcceptedBytes, + "firstUploadUrl": firstUploadURL, + "previousUploadCount": previousUploadCount, + "remainingPreviousUploadCount": remainingPreviousUploadCount, + "uploadUrl": uploadURL, + } + backend, err := api2devdock.URLStorageBackend(scenario) + if err != nil { + return nil, err + } + if backend != nil { + result["storageFileEntryCount"] = len(storedUploads) + result["storedUploadKeyPrefixMatched"] = uploadKeyPrefixMatched + result["urlStorageBackend"] = backend.Kind + } + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-resume-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithStoredResume(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("resume upload: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s resumed %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-retry-offset-recovery/main.go b/examples/api2-devdock-tus-retry-offset-recovery/main.go new file mode 100644 index 0000000..df25a0c --- /dev/null +++ b/examples/api2-devdock-tus-retry-offset-recovery/main.go @@ -0,0 +1,218 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strconv" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func assertRequestMethods(actual []string, expected []string) error { + if len(actual) != len(expected) { + return fmt.Errorf( + "retry offset recovery expected request methods %v, got %v", + expected, + actual, + ) + } + + for index, method := range expected { + if actual[index] != method { + return fmt.Errorf( + "retry offset recovery expected request method %s at index %d, got %s", + method, + index, + actual[index], + ) + } + } + + return nil +} + +func readOffsetHeader(response *http.Response, headerName string) (int, error) { + value := response.Header.Get(headerName) + offset, err := strconv.Atoi(value) + if err != nil || offset < 0 { + return 0, fmt.Errorf( + "retry offset recovery expected numeric %s response header, got %q", + headerName, + value, + ) + } + + return offset, nil +} + +func uploadWithRetryOffsetRecovery( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + retryOffsetRecovery, err := api2devdock.RetryOffsetRecovery(scenario) + if err != nil { + return nil, err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + recoveredOffsets := []int{} + requestMethods := []string{} + failureCandidateCount := 0 + simulatedFailureCount := 0 + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + if request.Method != http.MethodOptions { + requestMethods = append(requestMethods, request.Method) + } + + return nil + }, + AfterResponse: func(request *http.Request, response *http.Response) error { + if response == nil { + return nil + } + if request.Method == retryOffsetRecovery.RecoveryResponse.Method { + offset, err := readOffsetHeader( + response, + retryOffsetRecovery.RecoveryResponse.OffsetHeader, + ) + if err != nil { + return err + } + recoveredOffsets = append(recoveredOffsets, offset) + } + if request.Method != retryOffsetRecovery.FailAfterResponse.Method { + return nil + } + + failureCandidateCount += 1 + if failureCandidateCount != retryOffsetRecovery.FailAfterResponse.Occurrence { + return nil + } + + simulatedFailureCount += 1 + response.StatusCode = http.StatusInternalServerError + response.Status = "500 " + retryOffsetRecovery.FailAfterResponse.Message + return nil + }, + }, + ).WithContext(ctx) + + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + ChunkSize: chunkSize, + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + OnShouldRetry: func(_ error, _ int) bool { return true }, + }) + if err != nil { + return nil, err + } + if createdUpload == nil || createdUpload.Location == "" { + return nil, fmt.Errorf("retry offset recovery TUS upload did not expose an upload URL") + } + if simulatedFailureCount != retryOffsetRecovery.ExpectedFailureCount { + return nil, fmt.Errorf( + "retry offset recovery expected %d simulated failure(s), got %d", + retryOffsetRecovery.ExpectedFailureCount, + simulatedFailureCount, + ) + } + if len(recoveredOffsets) != retryOffsetRecovery.ExpectedRecoveryRequestCount { + return nil, fmt.Errorf( + "retry offset recovery expected %d recovery request(s), got %d", + retryOffsetRecovery.ExpectedRecoveryRequestCount, + len(recoveredOffsets), + ) + } + if recoveredOffsets[0] != retryOffsetRecovery.ExpectedRecoveredOffset { + return nil, fmt.Errorf( + "retry offset recovery expected recovered offset %d, got %d", + retryOffsetRecovery.ExpectedRecoveredOffset, + recoveredOffsets[0], + ) + } + if err := assertRequestMethods( + requestMethods, + retryOffsetRecovery.ExpectedRequestMethods, + ); err != nil { + return nil, err + } + + return map[string]interface{}{ + "recoveredOffsets": recoveredOffsets, + "recoveryRequestCount": len(recoveredOffsets), + "requestMethods": requestMethods, + "simulatedFailureCount": simulatedFailureCount, + "uploadUrl": createdUpload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-retry-offset-recovery/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithRetryOffsetRecovery(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("retry offset recovery: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s recovered offset for %s\n", + scenarioID, + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-retry-state-transitions/main.go b/examples/api2-devdock-tus-retry-state-transitions/main.go new file mode 100644 index 0000000..f100e4d --- /dev/null +++ b/examples/api2-devdock-tus-retry-state-transitions/main.go @@ -0,0 +1,271 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type retryStateObserver struct { + decisions []api2devdock.TusConformanceRetryDecision + err error + events []map[string]interface{} + index int + retryDelays []time.Duration +} + +func newRetryStateObserver( + decisions []api2devdock.TusConformanceRetryDecision, + retryDelays []time.Duration, +) *retryStateObserver { + return &retryStateObserver{ + decisions: decisions, + events: []map[string]interface{}{}, + retryDelays: retryDelays, + } +} + +func (observer *retryStateObserver) onShouldRetry(_ error, retryAttempt int) bool { + if observer.index >= len(observer.decisions) { + observer.err = fmt.Errorf( + "retry state transition received unexpected retry decision request %d", + observer.index, + ) + return false + } + + decision := observer.decisions[observer.index] + if retryAttempt != decision.RetryAttempt { + observer.err = fmt.Errorf( + "retry state transition expected retry attempt %d, got %d", + decision.RetryAttempt, + retryAttempt, + ) + return false + } + + observer.events = append(observer.events, map[string]interface{}{ + "decision": decision.Decision, + "kind": "should-retry", + "retryAttempt": retryAttempt, + }) + if decision.Decision { + if retryAttempt < 0 || retryAttempt >= len(observer.retryDelays) { + observer.err = fmt.Errorf( + "retry state transition retry attempt %d has no retry delay", + retryAttempt, + ) + return false + } + observer.events = append(observer.events, map[string]interface{}{ + "delay": observer.retryDelays[retryAttempt].Milliseconds(), + "kind": "retry-schedule", + }) + } + observer.index += 1 + + return decision.Decision +} + +func (observer *retryStateObserver) assertComplete() error { + if observer.err != nil { + return observer.err + } + if observer.index != len(observer.decisions) { + return fmt.Errorf( + "retry state transition expected %d retry decision(s), got %d", + len(observer.decisions), + observer.index, + ) + } + + return nil +} + +func tusConformanceRetryDelays( + conformanceScenario map[string]interface{}, +) ([]time.Duration, error) { + options, err := api2devdock.TusConformanceInputOptions(conformanceScenario) + if err != nil { + return nil, err + } + rawRetryDelays, ok := options["retryDelays"] + if !ok { + return nil, fmt.Errorf("conformanceScenario.inputOptionEntries.retryDelays is required") + } + delayValues, err := api2devdock.IntArrayValue( + rawRetryDelays, + "conformanceScenario.inputOptionEntries.retryDelays", + ) + if err != nil { + return nil, err + } + + retryDelays := make([]time.Duration, 0, len(delayValues)) + for _, delayValue := range delayValues { + retryDelays = append(retryDelays, time.Duration(delayValue)*time.Millisecond) + } + + return retryDelays, nil +} + +func uploadWithRetryStateTransitions( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + retryDecisions, err := api2devdock.TusConformanceRetryDecisions(conformanceScenario) + if err != nil { + return nil, err + } + retryDelays, err := tusConformanceRetryDelays(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return nil, err + } + completionKind, err := api2devdock.StringValue( + completion["kind"], + "conformanceScenario.completion.kind", + ) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + observer := newRetryStateObserver(retryDecisions, retryDelays) + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + EventHooks: tusgo.UploadEventHooks{OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + return nil + }}, + Fingerprint: "api2-go-retry-state-conformance-fingerprint", + Metadata: metadata, + OnShouldRetry: observer.onShouldRetry, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if err := observer.assertComplete(); err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("retry state transition TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["completionKind"] = completionKind + result["errorCalled"] = false + result["eventCount"] = len(observer.events) + result["events"] = observer.events + result["successCalled"] = successCalled + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-retry-state-transitions/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithRetryStateTransitions(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("retry state transitions: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s observed %d retry event(s) for %s\n", + scenarioID, + result["eventCount"], + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-start-option-validation/main.go b/examples/api2-devdock-tus-start-option-validation/main.go new file mode 100644 index 0000000..7c3431a --- /dev/null +++ b/examples/api2-devdock-tus-start-option-validation/main.go @@ -0,0 +1,174 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func startOptionValidationCompletion( + conformanceScenario map[string]interface{}, +) (string, string, error) { + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return "", "", err + } + reason, err := api2devdock.StringValue( + completion["reason"], + "conformanceScenario.completion.reason", + ) + if err != nil { + return "", "", err + } + message, err := api2devdock.StringValue( + completion["message"], + "conformanceScenario.completion.message", + ) + if err != nil { + return "", "", err + } + + return reason, message, nil +} + +func localEndpointURLForStartValidation( + conformanceScenario map[string]interface{}, + serverURL string, +) (*url.URL, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointURL, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + localURL, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + localURL.Path = endpointURL.Path + localURL.RawQuery = endpointURL.RawQuery + + return localURL, nil +} + +func validateStartOptions( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + reason, expectedMessage, err := startOptionValidationCompletion(conformanceScenario) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + parallelUploads, err := api2devdock.TusConformanceInputIntOption( + conformanceScenario, + "parallelUploads", + ) + if err != nil { + return nil, err + } + uploadDataDuringCreation, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "uploadDataDuringCreation", + false, + ) + if err != nil { + return nil, err + } + uploadLengthDeferred, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "uploadLengthDeferred", + false, + ) + if err != nil { + return nil, err + } + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + requestCount += 1 + responseWriter.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + localEndpointURL, err := localEndpointURLForStartValidation(conformanceScenario, server.URL) + if err != nil { + return nil, err + } + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-start-option-validation-" + reason, + ParallelUploads: parallelUploads, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadDataDuringCreation: uploadDataDuringCreation, + UploadLengthDeferred: uploadLengthDeferred, + }) + if err == nil { + return nil, fmt.Errorf("start option validation unexpectedly created upload %#v", upload) + } + if err.Error() != expectedMessage { + return nil, fmt.Errorf("expected start validation error %q, got %q", expectedMessage, err.Error()) + } + if upload != nil { + return nil, fmt.Errorf("start option validation returned upload %#v", upload) + } + + return map[string]interface{}{ + "errorCaught": true, + "errorMessage": err.Error(), + "requestCount": requestCount, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-start-option-validation/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := validateStartOptions(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("start option validation: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s rejected conflicting start options\n", scenarioID) +} diff --git a/examples/api2-devdock-tus-terminate-upload/main.go b/examples/api2-devdock-tus-terminate-upload/main.go new file mode 100644 index 0000000..a2f759f --- /dev/null +++ b/examples/api2-devdock-tus-terminate-upload/main.go @@ -0,0 +1,186 @@ +//go:build api2devdock + +package main + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type requestCountingTransport struct { + base http.RoundTripper + methods []string + mu sync.Mutex +} + +func (transport *requestCountingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + transport.mu.Lock() + transport.methods = append(transport.methods, request.Method) + transport.mu.Unlock() + + base := transport.base + if base == nil { + base = http.DefaultTransport + } + + return base.RoundTrip(request) +} + +func (transport *requestCountingTransport) Count(method string) int { + transport.mu.Lock() + defer transport.mu.Unlock() + + count := 0 + for _, candidate := range transport.methods { + if candidate == method { + count += 1 + } + } + + return count +} + +func (transport *requestCountingTransport) Methods() []string { + transport.mu.Lock() + defer transport.mu.Unlock() + + methods := make([]string, len(transport.methods)) + copy(methods, transport.methods) + return methods +} + +func verifyTerminatedUpload( + ctx context.Context, + httpClient *http.Client, + termination api2devdock.TerminationPlan, + uploadURL string, +) (int, error) { + request, err := http.NewRequestWithContext(ctx, termination.VerificationMethod, uploadURL, nil) + if err != nil { + return 0, err + } + for name, value := range tusgo.DefaultProtocolRequestHeaders() { + request.Header.Set(name, value) + } + + response, err := httpClient.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + + return response.StatusCode, nil +} + +func uploadAndTerminate( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return nil, err + } + termination, err := api2devdock.Termination(scenario) + if err != nil { + return nil, err + } + if termination.StopAfterAcceptedBytes > len(content) { + return nil, fmt.Errorf( + "stop-after bytes %d exceeds content length %d", + termination.StopAfterAcceptedBytes, + len(content), + ) + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + + transport := &requestCountingTransport{} + httpClient := &http.Client{Transport: transport} + client := tusgo.NewClient(httpClient, endpointURL).WithContext(ctx) + upload := tusgo.Upload{} + if _, err := client.CreateUpload(&upload, int64(len(content)), false, metadata); err != nil { + return nil, err + } + if upload.Location == "" { + return nil, fmt.Errorf("created upload did not include a Location") + } + + stream := tusgo.NewUploadStream(client, &upload) + stream.ChunkSize = chunkSize + stopAfterAcceptedBytes := termination.StopAfterAcceptedBytes + written, err := stream.Write(content[:stopAfterAcceptedBytes]) + if err != nil { + return nil, err + } + if written != stopAfterAcceptedBytes { + return nil, fmt.Errorf("wrote %d bytes, expected %d", written, stopAfterAcceptedBytes) + } + acceptedBytes := int(upload.RemoteOffset) + if acceptedBytes != stopAfterAcceptedBytes { + return nil, fmt.Errorf("accepted %d bytes, expected %d", acceptedBytes, stopAfterAcceptedBytes) + } + + if _, err := client.TerminateUploadWithRetry(upload, tusgo.TerminateUploadOptions{}); err != nil { + return nil, err + } + verificationStatus, err := verifyTerminatedUpload(ctx, httpClient, termination, upload.Location) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "acceptedBytes": acceptedBytes, + "deleteRequestCount": transport.Count(http.MethodDelete), + "requestMethods": transport.Methods(), + "terminated": true, + "uploadUrl": upload.Location, + "verificationStatus": verificationStatus, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-terminate-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadAndTerminate(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("terminate upload: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s terminated %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-upload-body-headers/main.go b/examples/api2-devdock-tus-upload-body-headers/main.go new file mode 100644 index 0000000..26ae9ea --- /dev/null +++ b/examples/api2-devdock-tus-upload-body-headers/main.go @@ -0,0 +1,163 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "reflect" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func observedBodyHeaders( + request *http.Request, + expectedHeaders map[string]string, +) map[string]string { + observed := map[string]string{} + for name := range expectedHeaders { + observed[name] = request.Header.Get(name) + } + + return observed +} + +func assertObservedBodyHeaders( + label string, + actual map[string]string, + expected map[string]string, +) error { + if !reflect.DeepEqual(actual, expected) { + return fmt.Errorf("%s expected body headers %v, got %v", label, expected, actual) + } + + return nil +} + +func uploadWithBodyHeaders( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + expectedBodyHeadersByMethod, err := api2devdock.UploadBodyHeadersByMethod(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + bodyHeadersByMethod := map[string]map[string]string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + switch request.Method { + case http.MethodPost, http.MethodPatch: + expectedHeaders, ok := expectedBodyHeadersByMethod[request.Method] + if !ok { + return nil + } + bodyHeadersByMethod[request.Method] = observedBodyHeaders(request, expectedHeaders) + } + + return nil + }, + }, + ).WithContext(ctx) + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("upload body headers TUS upload did not expose an upload URL") + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "upload body headers upload accepted %d bytes, expected %d", + upload.RemoteOffset, + len(content), + ) + } + if err := assertObservedBodyHeaders( + "POST", + bodyHeadersByMethod[http.MethodPost], + expectedBodyHeadersByMethod[http.MethodPost], + ); err != nil { + return nil, err + } + if err := assertObservedBodyHeaders( + "PATCH", + bodyHeadersByMethod[http.MethodPatch], + expectedBodyHeadersByMethod[http.MethodPatch], + ); err != nil { + return nil, err + } + + return map[string]interface{}{ + "bodyHeadersByMethod": bodyHeadersByMethod, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-upload-body-headers/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithBodyHeaders(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("upload body headers: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s observed body headers to %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-upload-callbacks/main.go b/examples/api2-devdock-tus-upload-callbacks/main.go new file mode 100644 index 0000000..b7f80ad --- /dev/null +++ b/examples/api2-devdock-tus-upload-callbacks/main.go @@ -0,0 +1,194 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type eventRecordingReadSeeker struct { + *bytes.Reader + callbacks api2devdock.UploadCallbacksPlan + events *[]string +} + +func (source *eventRecordingReadSeeker) Close() error { + *source.events = append( + *source.events, + api2devdock.UploadCallbackEventKey( + source.callbacks, + source.callbacks.EventKinds.SourceClose, + ), + ) + + return nil +} + +func uploadEventHooks( + callbacks api2devdock.UploadCallbacksPlan, + events *[]string, +) tusgo.UploadEventHooks { + return tusgo.UploadEventHooks{ + OnUploadURLAvailable: func() error { + *events = append( + *events, + api2devdock.UploadCallbackEventKey( + callbacks, + callbacks.EventKinds.UploadURLAvailable, + ), + ) + return nil + }, + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + *events = append( + *events, + api2devdock.UploadCallbackEventKey( + callbacks, + callbacks.EventKinds.Progress, + api2devdock.UploadCallbackEventKeyNumber(bytesSent), + api2devdock.UploadCallbackEventKeyTotal(bytesTotal), + ), + ) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + *events = append( + *events, + api2devdock.UploadCallbackEventKey( + callbacks, + callbacks.EventKinds.ChunkComplete, + api2devdock.UploadCallbackEventKeyNumber(chunkSize), + api2devdock.UploadCallbackEventKeyNumber(bytesAccepted), + api2devdock.UploadCallbackEventKeyTotal(bytesTotal), + ), + ) + return nil + }, + OnSuccess: func(payload tusgo.UploadSuccessPayload) error { + if payload.Upload == nil || payload.Upload.Location == "" { + return fmt.Errorf("upload callback success payload did not include an upload URL") + } + *events = append( + *events, + api2devdock.UploadCallbackEventKey(callbacks, callbacks.EventKinds.Success), + ) + return nil + }, + } +} + +func uploadWithCallbacks( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + callbacks, err := api2devdock.UploadCallbacks(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + events := []string{} + source := &eventRecordingReadSeeker{ + Reader: bytes.NewReader(content), + callbacks: callbacks, + events: &events, + } + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + EventHooks: uploadEventHooks(callbacks, &events), + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: source, + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if createdUpload == nil || createdUpload.Location == "" { + return nil, fmt.Errorf("upload callback TUS upload did not expose an upload URL") + } + if createdUpload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "upload callback scenario accepted %d bytes, expected %d", + createdUpload.RemoteOffset, + len(content), + ) + } + matchedEvents, err := api2devdock.MatchUploadCallbackEventKeys(callbacks, events) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "eventKeys": matchedEvents, + "rawEventKeys": events, + "uploadUrl": createdUpload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-upload-callbacks/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithCallbacks(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("upload callbacks: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s observed upload callbacks for %s\n", + scenarioID, + result["uploadUrl"], + ) +} diff --git a/examples/api2devdock/conformance_server.go b/examples/api2devdock/conformance_server.go new file mode 100644 index 0000000..a477888 --- /dev/null +++ b/examples/api2devdock/conformance_server.go @@ -0,0 +1,672 @@ +package api2devdock + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "time" +) + +type TusConformanceObservedRequest struct { + AbsentHeaderPresence map[string]bool + BodySize int + BodyStart *int + Headers map[string]string + Method string + URL string +} + +type tusConformanceRequestGate struct { + GateID string + HeldRequestIndexes map[int]bool + ReleaseAfterRequestIndexes []int + Timeout time.Duration +} + +type TusConformancePlanServer struct { + endpointOrigin *url.URL + errs []error + gates []tusConformanceRequestGate + mu sync.Mutex + observed []*TusConformanceObservedRequest + observedCount int + requests []interface{} + server *httptest.Server + sourceContent string +} + +func NewTusConformancePlanServer( + conformanceScenario map[string]interface{}, + endpointOrigin *url.URL, +) (*TusConformancePlanServer, error) { + requests, err := ArrayValue( + conformanceScenario["requests"], + "conformanceScenario.requests", + ) + if err != nil { + return nil, err + } + gates, err := conformanceServerRequestGates(conformanceScenario) + if err != nil { + return nil, err + } + sourceContent, err := conformanceInputSourceContent(conformanceScenario) + if err != nil { + return nil, err + } + + conformanceServer := &TusConformancePlanServer{ + endpointOrigin: endpointOrigin, + gates: gates, + observed: make([]*TusConformanceObservedRequest, len(requests)), + requests: requests, + sourceContent: sourceContent, + } + conformanceServer.server = httptest.NewServer(conformanceServer) + + return conformanceServer, nil +} + +func (conformanceServer *TusConformancePlanServer) Close() { + conformanceServer.server.Close() +} + +func (conformanceServer *TusConformancePlanServer) EndpointURL() (*url.URL, error) { + localEndpointURL, err := conformanceServer.LocalURL(conformanceServer.endpointOrigin.String()) + if err != nil { + return nil, err + } + + return url.Parse(localEndpointURL) +} + +func (conformanceServer *TusConformancePlanServer) LocalURL(canonicalURL string) (string, error) { + parsedCanonical, err := url.Parse(canonicalURL) + if err != nil { + return "", err + } + if parsedCanonical.Scheme != conformanceServer.endpointOrigin.Scheme || + parsedCanonical.Host != conformanceServer.endpointOrigin.Host { + return canonicalURL, nil + } + + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + localURL := *parsedCanonical + localURL.Scheme = serverURL.Scheme + localURL.Host = serverURL.Host + + return localURL.String(), nil +} + +func (conformanceServer *TusConformancePlanServer) LocalValue(canonicalValue string) (string, error) { + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + + return strings.ReplaceAll( + canonicalValue, + conformanceServer.endpointOrigin.Scheme+"://"+conformanceServer.endpointOrigin.Host, + serverURL.Scheme+"://"+serverURL.Host, + ), nil +} + +func (conformanceServer *TusConformancePlanServer) CanonicalURL(actualURL string) (string, error) { + parsedActual, err := url.Parse(actualURL) + if err != nil { + return "", err + } + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + if parsedActual.Scheme != serverURL.Scheme || parsedActual.Host != serverURL.Host { + return actualURL, nil + } + + canonical := *parsedActual + canonical.Scheme = conformanceServer.endpointOrigin.Scheme + canonical.Host = conformanceServer.endpointOrigin.Host + + return canonical.String(), nil +} + +func (conformanceServer *TusConformancePlanServer) CanonicalValue(actualValue string) (string, error) { + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + + return strings.ReplaceAll( + actualValue, + serverURL.Scheme+"://"+serverURL.Host, + conformanceServer.endpointOrigin.Scheme+"://"+conformanceServer.endpointOrigin.Host, + ), nil +} + +func (conformanceServer *TusConformancePlanServer) AssertExhausted() error { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + if conformanceServer.observedCount != len(conformanceServer.requests) { + return fmt.Errorf( + "expected %d conformance request(s), got %d", + len(conformanceServer.requests), + conformanceServer.observedCount, + ) + } + + return nil +} + +func (conformanceServer *TusConformancePlanServer) Result() (map[string]interface{}, error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + if len(conformanceServer.errs) > 0 { + return nil, conformanceServer.errs[0] + } + + absentHeaderPresence := make([]map[string]bool, 0, len(conformanceServer.observed)) + requestBodySizes := make([]int, 0, len(conformanceServer.observed)) + requestBodyStarts := make([]interface{}, 0, len(conformanceServer.observed)) + requestHeaders := make([]map[string]string, 0, len(conformanceServer.observed)) + requestMethods := make([]string, 0, len(conformanceServer.observed)) + requestURLs := make([]string, 0, len(conformanceServer.observed)) + for _, request := range conformanceServer.observed { + if request == nil { + requestBodySizes = append(requestBodySizes, 0) + requestBodyStarts = append(requestBodyStarts, nil) + requestHeaders = append(requestHeaders, map[string]string{}) + requestMethods = append(requestMethods, "") + requestURLs = append(requestURLs, "") + absentHeaderPresence = append(absentHeaderPresence, map[string]bool{}) + continue + } + + requestBodySizes = append(requestBodySizes, request.BodySize) + if request.BodyStart == nil { + requestBodyStarts = append(requestBodyStarts, nil) + } else { + requestBodyStarts = append(requestBodyStarts, *request.BodyStart) + } + requestHeaders = append(requestHeaders, request.Headers) + requestMethods = append(requestMethods, request.Method) + requestURLs = append(requestURLs, request.URL) + absentHeaderPresence = append(absentHeaderPresence, request.AbsentHeaderPresence) + } + + return map[string]interface{}{ + "absentHeaderPresence": absentHeaderPresence, + "requestBodySizes": requestBodySizes, + "requestBodyStarts": requestBodyStarts, + "requestCount": conformanceServer.observedCount, + "requestHeaders": requestHeaders, + "requestMethods": requestMethods, + "requestUrls": requestURLs, + }, nil +} + +func (conformanceServer *TusConformancePlanServer) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + body, err := io.ReadAll(request.Body) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + + requestIndex, requestPlan, err := conformanceServer.observeMatchingRequest(request, body) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + if err := conformanceServer.waitForRequestGate(requestIndex); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + if err := conformanceServer.writeResponse(responseWriter, requestIndex, requestPlan); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + } +} + +func (conformanceServer *TusConformancePlanServer) observeMatchingRequest( + request *http.Request, + body []byte, +) ( + int, + map[string]interface{}, + error, +) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + mismatches := []string{} + for requestIndex, rawRequestPlan := range conformanceServer.requests { + if conformanceServer.observed[requestIndex] != nil { + continue + } + requestPlan, err := ObjectValue( + rawRequestPlan, + fmt.Sprintf("conformanceScenario.requests[%d]", requestIndex), + ) + if err != nil { + return 0, nil, err + } + observed, err := conformanceServer.observedRequest(requestIndex, requestPlan, request, body) + if err != nil { + mismatches = append(mismatches, fmt.Sprintf("request %d: %v", requestIndex, err)) + continue + } + if err := conformanceServer.validateRequest(requestIndex, requestPlan, observed); err != nil { + mismatches = append(mismatches, fmt.Sprintf("request %d: %v", requestIndex, err)) + continue + } + conformanceServer.observed[requestIndex] = &observed + conformanceServer.observedCount += 1 + + return requestIndex, requestPlan, nil + } + + return 0, nil, fmt.Errorf( + "unexpected request %s %s after %d observed request(s): %s", + request.Method, + conformanceServer.endpointOrigin.ResolveReference(request.URL).String(), + conformanceServer.observedCount, + strings.Join(mismatches, "; "), + ) +} + +func (conformanceServer *TusConformancePlanServer) observedRequest( + requestIndex int, + requestPlan map[string]interface{}, + request *http.Request, + body []byte, +) (TusConformanceObservedRequest, error) { + expectedHeaders, err := conformanceRequestHeaders(requestIndex, requestPlan) + if err != nil { + return TusConformanceObservedRequest{}, err + } + absentHeaders, err := conformanceAbsentRequestHeaders(requestIndex, requestPlan) + if err != nil { + return TusConformanceObservedRequest{}, err + } + + requestHeaders := map[string]string{} + for name := range expectedHeaders { + value, err := conformanceServer.CanonicalValue(request.Header.Get(name)) + if err != nil { + return TusConformanceObservedRequest{}, err + } + requestHeaders[name] = value + } + absentHeaderPresence := map[string]bool{} + for _, name := range absentHeaders { + absentHeaderPresence[name] = request.Header.Get(name) != "" + } + bodyStart, err := conformanceServer.requestBodyStart(requestIndex, requestPlan, body) + if err != nil { + return TusConformanceObservedRequest{}, err + } + + return TusConformanceObservedRequest{ + AbsentHeaderPresence: absentHeaderPresence, + BodySize: len(body), + BodyStart: bodyStart, + Headers: requestHeaders, + Method: request.Method, + URL: conformanceServer.endpointOrigin.ResolveReference(request.URL).String(), + }, nil +} + +func (conformanceServer *TusConformancePlanServer) validateRequest( + requestIndex int, + requestPlan map[string]interface{}, + request TusConformanceObservedRequest, +) error { + expectedURL, err := StringValue( + requestPlan["expectedUrl"], + fmt.Sprintf("conformanceScenario.requests[%d].expectedUrl", requestIndex), + ) + if err != nil { + return err + } + expectedMethod, err := StringValue( + requestPlan["effectiveMethod"], + fmt.Sprintf("conformanceScenario.requests[%d].effectiveMethod", requestIndex), + ) + if err != nil { + return err + } + if request.URL != expectedURL { + return fmt.Errorf("request %d expected URL %s, got %s", requestIndex, expectedURL, request.URL) + } + if request.Method != expectedMethod { + return fmt.Errorf( + "request %d expected method %s, got %s", + requestIndex, + expectedMethod, + request.Method, + ) + } + + expectedHeaders, err := conformanceRequestHeaders(requestIndex, requestPlan) + if err != nil { + return err + } + for name, expectedValue := range expectedHeaders { + if request.Headers[name] == expectedValue { + continue + } + + return fmt.Errorf( + "request %d expected header %s=%q, got %q", + requestIndex, + name, + expectedValue, + request.Headers[name], + ) + } + absentHeaders, err := conformanceAbsentRequestHeaders(requestIndex, requestPlan) + if err != nil { + return err + } + for _, name := range absentHeaders { + if !request.AbsentHeaderPresence[name] { + continue + } + + return fmt.Errorf("request %d expected header %s to be absent", requestIndex, name) + } + + rawBodySize, ok := requestPlan["bodySize"] + if !ok || rawBodySize == nil { + return nil + } + expectedBodySize, err := IntValue( + rawBodySize, + fmt.Sprintf("conformanceScenario.requests[%d].bodySize", requestIndex), + ) + if err != nil { + return err + } + if request.BodySize != expectedBodySize { + return fmt.Errorf( + "request %d expected body size %d, got %d", + requestIndex, + expectedBodySize, + request.BodySize, + ) + } + + return nil +} + +func (conformanceServer *TusConformancePlanServer) recordErr(err error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.errs = append(conformanceServer.errs, err) +} + +func (conformanceServer *TusConformancePlanServer) waitForRequestGate(requestIndex int) error { + for _, gate := range conformanceServer.gates { + if !gate.HeldRequestIndexes[requestIndex] { + continue + } + deadline := time.Now().Add(gate.Timeout) + for { + conformanceServer.mu.Lock() + released := conformanceServer.requestGateReleased(gate) + conformanceServer.mu.Unlock() + if released { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf( + "request %d timed out waiting for conformance gate %s", + requestIndex, + gate.GateID, + ) + } + time.Sleep(10 * time.Millisecond) + } + } + + return nil +} + +func (conformanceServer *TusConformancePlanServer) requestGateReleased( + gate tusConformanceRequestGate, +) bool { + for _, requestIndex := range gate.ReleaseAfterRequestIndexes { + if requestIndex < 0 || requestIndex >= len(conformanceServer.observed) { + return false + } + if conformanceServer.observed[requestIndex] == nil { + return false + } + } + + return true +} + +func (conformanceServer *TusConformancePlanServer) writeResponse( + responseWriter http.ResponseWriter, + requestIndex int, + requestPlan map[string]interface{}, +) error { + responsePlan, err := ObjectValue( + requestPlan["response"], + fmt.Sprintf("conformanceScenario.requests[%d].response", requestIndex), + ) + if err != nil { + return err + } + headers, err := StringMapValue( + responsePlan["effectiveHeaders"], + fmt.Sprintf("conformanceScenario.requests[%d].response.effectiveHeaders", requestIndex), + ) + if err != nil { + return err + } + for name, value := range headers { + localValue, err := conformanceServer.LocalValue(value) + if err != nil { + return err + } + responseWriter.Header().Set(name, localValue) + } + statusCode, err := IntValue( + responsePlan["statusCode"], + fmt.Sprintf("conformanceScenario.requests[%d].response.statusCode", requestIndex), + ) + if err != nil { + return err + } + responseWriter.WriteHeader(statusCode) + + rawBody, ok := responsePlan["body"] + if !ok || rawBody == nil { + return nil + } + body, err := StringValue( + rawBody, + fmt.Sprintf("conformanceScenario.requests[%d].response.body", requestIndex), + ) + if err != nil { + return err + } + _, err = responseWriter.Write([]byte(body)) + + return err +} + +func conformanceRequestHeaders( + requestIndex int, + requestPlan map[string]interface{}, +) (map[string]string, error) { + rawHeaders, ok := requestPlan["effectiveHeaders"] + if !ok { + return map[string]string{}, nil + } + + return StringMapValue( + rawHeaders, + fmt.Sprintf("conformanceScenario.requests[%d].effectiveHeaders", requestIndex), + ) +} + +func conformanceAbsentRequestHeaders( + requestIndex int, + requestPlan map[string]interface{}, +) ([]string, error) { + rawHeaders, ok := requestPlan["absentHeaders"] + if !ok { + return []string{}, nil + } + + return StringArrayValue( + rawHeaders, + fmt.Sprintf("conformanceScenario.requests[%d].absentHeaders", requestIndex), + ) +} + +func conformanceInputSourceContent(conformanceScenario map[string]interface{}) (string, error) { + rawSource, ok := conformanceScenario["inputSource"] + if !ok || rawSource == nil { + return "", nil + } + source, err := ObjectValue(rawSource, "conformanceScenario.inputSource") + if err != nil { + return "", err + } + rawContent, ok := source["content"] + if !ok || rawContent == nil { + return "", nil + } + + return StringValue(rawContent, "conformanceScenario.inputSource.content") +} + +func (conformanceServer *TusConformancePlanServer) requestBodyStart( + requestIndex int, + requestPlan map[string]interface{}, + body []byte, +) (*int, error) { + rawBodyStart, ok := requestPlan["bodyStart"] + if !ok || rawBodyStart == nil { + return nil, nil + } + bodyStart, err := IntValue( + rawBodyStart, + fmt.Sprintf("conformanceScenario.requests[%d].bodyStart", requestIndex), + ) + if err != nil { + return nil, err + } + bodyEnd := bodyStart + len(body) + if bodyStart < 0 || bodyEnd > len(conformanceServer.sourceContent) { + return nil, fmt.Errorf( + "request %d body range [%d:%d] exceeds source content length %d", + requestIndex, + bodyStart, + bodyEnd, + len(conformanceServer.sourceContent), + ) + } + expectedBody := conformanceServer.sourceContent[bodyStart:bodyEnd] + if string(body) != expectedBody { + return nil, fmt.Errorf( + "request %d expected body slice %q, got %q", + requestIndex, + expectedBody, + string(body), + ) + } + + return &bodyStart, nil +} + +func conformanceServerRequestGates( + conformanceScenario map[string]interface{}, +) ([]tusConformanceRequestGate, error) { + rawExecution, ok := conformanceScenario["execution"] + if !ok || rawExecution == nil { + return []tusConformanceRequestGate{}, nil + } + execution, err := ObjectValue(rawExecution, "conformanceScenario.execution") + if err != nil { + return nil, err + } + rawGates, ok := execution["serverRequestGates"] + if !ok || rawGates == nil { + return []tusConformanceRequestGate{}, nil + } + gateItems, err := ArrayValue(rawGates, "conformanceScenario.execution.serverRequestGates") + if err != nil { + return nil, err + } + + gates := make([]tusConformanceRequestGate, 0, len(gateItems)) + for index, rawGate := range gateItems { + label := fmt.Sprintf("conformanceScenario.execution.serverRequestGates[%d]", index) + gate, err := ObjectValue(rawGate, label) + if err != nil { + return nil, err + } + kind, err := StringValue(gate["kind"], label+".kind") + if err != nil { + return nil, err + } + if kind != "release-after-all-started" { + return nil, fmt.Errorf("unsupported conformance server request gate kind %q", kind) + } + gateID, err := StringValue(gate["gateId"], label+".gateId") + if err != nil { + return nil, err + } + heldRequestIndexes, err := IntArrayValue( + gate["heldRequestIndexes"], + label+".heldRequestIndexes", + ) + if err != nil { + return nil, err + } + releaseAfterRequestIndexes, err := IntArrayValue( + gate["releaseAfterRequestIndexes"], + label+".releaseAfterRequestIndexes", + ) + if err != nil { + return nil, err + } + timeoutMs, err := IntValue(gate["timeoutMs"], label+".timeoutMs") + if err != nil { + return nil, err + } + held := map[int]bool{} + for _, requestIndex := range heldRequestIndexes { + held[requestIndex] = true + } + gates = append(gates, tusConformanceRequestGate{ + GateID: gateID, + HeldRequestIndexes: held, + ReleaseAfterRequestIndexes: releaseAfterRequestIndexes, + Timeout: time.Duration(timeoutMs) * time.Millisecond, + }) + } + + return gates, nil +} diff --git a/examples/api2devdock/scenario.go b/examples/api2devdock/scenario.go new file mode 100644 index 0000000..7a8ff7f --- /dev/null +++ b/examples/api2devdock/scenario.go @@ -0,0 +1,1331 @@ +package api2devdock + +import ( + "encoding/json" + "fmt" + "math" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type TerminationPlan struct { + ExpectedVerificationStatus int + MinimumDeleteRequestCount int + StopAfterAcceptedBytes int + VerificationMethod string +} + +type ResumePlan struct { + ExpectedPreviousUploadCount int + ExpectedRemainingPreviousUploadCount int + Fingerprint string + RemoveFingerprintOnSuccess bool + StopAfterAcceptedBytes int +} + +type URLStorageBackendPlan struct { + ExpectedStoredUploadKeyPrefix string + Kind string +} + +type RetryOffsetRecoveryResponsePlan struct { + Method string + OffsetHeader string +} + +type RetryOffsetRecoveryFailurePlan struct { + Message string + Method string + Occurrence int +} + +type RetryOffsetRecoveryPlan struct { + ExpectedFailureCount int + ExpectedRecoveredOffset int + ExpectedRecoveryRequestCount int + ExpectedRequestMethods []string + FailAfterResponse RetryOffsetRecoveryFailurePlan + RecoveryResponse RetryOffsetRecoveryResponsePlan +} + +type TusConformanceRetryDecision struct { + Decision bool + RetryAttempt int +} + +type RequestLifecycleHooksPlan struct { + ExpectedAfterResponseMethods []string + ExpectedAfterResponseStatusCodes []int + ExpectedBeforeRequestMethods []string + IgnoredRequestMethods []string +} + +type UploadCallbackEventKinds struct { + ChunkComplete string + Progress string + SourceClose string + Success string + UploadURLAvailable string +} + +type UploadCallbacksPlan struct { + AllowedExtraEventKeyPrefixes []string + EventKeyAlternativeGroups [][]string + EventKinds UploadCallbackEventKinds + EventKeyPartSeparator string + EventKeys []string + EventPolicyMatching string +} + +type TusConformanceServerCapabilitiesPlan struct { + ExtensionNames []string + ProtocolVersions []string +} + +func Fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func LoadScenario(defaultPath string) (map[string]interface{}, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.FromSlash(defaultPath) + } + + contents, err := os.ReadFile(scenarioPath) + if err != nil { + return nil, err + } + + var scenario map[string]interface{} + if err := json.Unmarshal(contents, &scenario); err != nil { + return nil, err + } + + return scenario, nil +} + +func ObjectValue(value interface{}, label string) (map[string]interface{}, error) { + object, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an object", label) + } + + return object, nil +} + +func ArrayValue(value interface{}, label string) ([]interface{}, error) { + array, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an array", label) + } + + return array, nil +} + +func StringValue(value interface{}, label string) (string, error) { + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", label) + } + + return text, nil +} + +func StringMapValue(value interface{}, label string) (map[string]string, error) { + rawObject, err := ObjectValue(value, label) + if err != nil { + return nil, err + } + + object := map[string]string{} + for name, rawValue := range rawObject { + text, err := StringValue(rawValue, label+"."+name) + if err != nil { + return nil, err + } + object[name] = text + } + + return object, nil +} + +func BoolValue(value interface{}, label string) (bool, error) { + boolean, ok := value.(bool) + if !ok { + return false, fmt.Errorf("%s must be a boolean", label) + } + + return boolean, nil +} + +func IntValue(value interface{}, label string) (int, error) { + number, ok := value.(float64) + if !ok || math.Trunc(number) != number { + return 0, fmt.Errorf("%s must be an integer", label) + } + + return int(number), nil +} + +func StringArrayValue(value interface{}, label string) ([]string, error) { + array, err := ArrayValue(value, label) + if err != nil { + return nil, err + } + + strings := make([]string, 0, len(array)) + for index, item := range array { + text, err := StringValue(item, fmt.Sprintf("%s[%d]", label, index)) + if err != nil { + return nil, err + } + strings = append(strings, text) + } + + return strings, nil +} + +func StringArrayArrayValue(value interface{}, label string) ([][]string, error) { + array, err := ArrayValue(value, label) + if err != nil { + return nil, err + } + + arrays := make([][]string, 0, len(array)) + for index, item := range array { + strings, err := StringArrayValue(item, fmt.Sprintf("%s[%d]", label, index)) + if err != nil { + return nil, err + } + arrays = append(arrays, strings) + } + + return arrays, nil +} + +func IntArrayValue(value interface{}, label string) ([]int, error) { + array, err := ArrayValue(value, label) + if err != nil { + return nil, err + } + + ints := make([]int, 0, len(array)) + for index, item := range array { + number, err := IntValue(item, fmt.Sprintf("%s[%d]", label, index)) + if err != nil { + return nil, err + } + ints = append(ints, number) + } + + return ints, nil +} + +func ScalarString(value interface{}) string { + switch typed := value.(type) { + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case string: + return typed + default: + serialized, err := json.Marshal(typed) + if err != nil { + return fmt.Sprintf("%v", typed) + } + + return string(serialized) + } +} + +func ReadPath(value interface{}, pathParts []interface{}, label string) (interface{}, error) { + current := value + for _, part := range pathParts { + if object, ok := current.(map[string]interface{}); ok { + key, ok := part.(string) + if !ok { + return nil, fmt.Errorf("%s path cannot read non-string key %v from object", label, part) + } + next, ok := object[key] + if !ok { + return nil, fmt.Errorf("%s path is missing key %q", label, key) + } + current = next + continue + } + + if array, ok := current.([]interface{}); ok { + index, err := IntValue(part, label) + if err != nil { + return nil, err + } + if index < 0 || index >= len(array) { + return nil, fmt.Errorf("%s path index %d is out of range", label, index) + } + current = array[index] + continue + } + + return nil, fmt.Errorf("%s path cannot read %v from %v", label, part, current) + } + + return current, nil +} + +func ResolveValue( + valueSpec interface{}, + context map[string]interface{}, + label string, +) (interface{}, error) { + spec, err := ObjectValue(valueSpec, label) + if err != nil { + return nil, err + } + if literal, ok := spec["value"]; ok { + return literal, nil + } + + source, err := ObjectValue(spec["source"], label+".source") + if err != nil { + return nil, err + } + root, err := StringValue(source["root"], label+".source.root") + if err != nil { + return nil, err + } + rootValue, ok := context[root] + if !ok { + return nil, fmt.Errorf("%s source root %q is unavailable", label, root) + } + pathParts, err := ArrayValue(source["path"], label+".source.path") + if err != nil { + return nil, err + } + + return ReadPath(rootValue, pathParts, label) +} + +func CreateResponseFromScenario(scenario map[string]interface{}) (map[string]interface{}, error) { + prepared, err := ObjectValue(scenario["prepared"], "prepared") + if err != nil { + return nil, err + } + + return ObjectValue(prepared["createResponse"], "prepared.createResponse") +} + +func ScenarioBytes(scenario map[string]interface{}) ([]byte, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + source, err := ObjectValue(upload["source"], "upload.source") + if err != nil { + return nil, err + } + kind, err := StringValue(source["kind"], "upload.source.kind") + if err != nil { + return nil, err + } + if kind != "bytes" { + return nil, fmt.Errorf("unsupported scenario source kind %q", kind) + } + encoding, err := StringValue(source["encoding"], "upload.source.encoding") + if err != nil { + return nil, err + } + if encoding != "utf8" { + return nil, fmt.Errorf("unsupported scenario source encoding %q", encoding) + } + value, err := StringValue(source["value"], "upload.source.value") + if err != nil { + return nil, err + } + + return []byte(value), nil +} + +func UploadMetadata( + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + fields, err := ArrayValue(upload["metadata"], "upload.metadata") + if err != nil { + return nil, err + } + + context := map[string]interface{}{ + "createResponse": createResponse, + "scenario": scenario, + } + metadata := map[string]string{} + for index, rawField := range fields { + label := fmt.Sprintf("upload.metadata[%d]", index) + field, err := ObjectValue(rawField, label) + if err != nil { + return nil, err + } + name, err := StringValue(field["name"], label+".name") + if err != nil { + return nil, err + } + value, err := ResolveValue(field["value"], context, label+".value") + if err != nil { + return nil, err + } + metadata[name] = ScalarString(value) + } + + return metadata, nil +} + +func UploadHeaders(scenario map[string]interface{}) (map[string]string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + + return StringMapValue(upload["headers"], "upload.headers") +} + +func UploadBodyHeadersByMethod(scenario map[string]interface{}) (map[string]map[string]string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + rawByMethod, err := ObjectValue(upload["bodyHeadersByMethod"], "upload.bodyHeadersByMethod") + if err != nil { + return nil, err + } + + byMethod := map[string]map[string]string{} + for method, rawHeaders := range rawByMethod { + headers, err := StringMapValue(rawHeaders, "upload.bodyHeadersByMethod."+method) + if err != nil { + return nil, err + } + byMethod[method] = headers + } + + return byMethod, nil +} + +func UploadAddRequestID(scenario map[string]interface{}) (bool, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return false, err + } + + return BoolValue(upload["addRequestId"], "upload.addRequestId") +} + +func UploadRequestIDHeaderName(scenario map[string]interface{}) (string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return "", err + } + + return StringValue(upload["requestIdHeaderName"], "upload.requestIdHeaderName") +} + +func UploadLengthDeferred(scenario map[string]interface{}) (bool, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return false, err + } + + return BoolValue(upload["uploadLengthDeferred"], "upload.uploadLengthDeferred") +} + +func TusURL( + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (*url.URL, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + context := map[string]interface{}{ + "createResponse": createResponse, + "scenario": scenario, + } + endpointValue, err := ResolveValue(upload["tusUrl"], context, "upload.tusUrl") + if err != nil { + return nil, err + } + + return url.Parse(ScalarString(endpointValue)) +} + +func RequireFullFileChunkSize(scenario map[string]interface{}) error { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return err + } + chunkSize, err := StringValue(upload["chunkSize"], "upload.chunkSize") + if err != nil { + return err + } + if chunkSize != "full-file" { + return fmt.Errorf("unsupported chunk size policy %q", chunkSize) + } + + return nil +} + +func FixedChunkSizeBytes(scenario map[string]interface{}) (int64, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return 0, err + } + chunkSize, err := ObjectValue(upload["chunkSize"], "upload.chunkSize") + if err != nil { + return 0, err + } + kind, err := StringValue(chunkSize["kind"], "upload.chunkSize.kind") + if err != nil { + return 0, err + } + if kind != "fixed-bytes" { + return 0, fmt.Errorf("unsupported chunk size kind %q", kind) + } + bytes, err := IntValue(chunkSize["bytes"], "upload.chunkSize.bytes") + if err != nil { + return 0, err + } + if bytes <= 0 { + return 0, fmt.Errorf("upload.chunkSize.bytes must be positive") + } + + return int64(bytes), nil +} + +func Termination(scenario map[string]interface{}) (TerminationPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return TerminationPlan{}, err + } + termination, err := ObjectValue(upload["termination"], "upload.termination") + if err != nil { + return TerminationPlan{}, err + } + expectedVerificationStatus, err := IntValue( + termination["expectedVerificationStatus"], + "upload.termination.expectedVerificationStatus", + ) + if err != nil { + return TerminationPlan{}, err + } + minimumDeleteRequestCount, err := IntValue( + termination["minimumDeleteRequestCount"], + "upload.termination.minimumDeleteRequestCount", + ) + if err != nil { + return TerminationPlan{}, err + } + stopAfterAcceptedBytes, err := IntValue( + termination["stopAfterAcceptedBytes"], + "upload.termination.stopAfterAcceptedBytes", + ) + if err != nil { + return TerminationPlan{}, err + } + verificationMethod, err := StringValue( + termination["verificationMethod"], + "upload.termination.verificationMethod", + ) + if err != nil { + return TerminationPlan{}, err + } + + return TerminationPlan{ + ExpectedVerificationStatus: expectedVerificationStatus, + MinimumDeleteRequestCount: minimumDeleteRequestCount, + StopAfterAcceptedBytes: stopAfterAcceptedBytes, + VerificationMethod: verificationMethod, + }, nil +} + +func Resume(scenario map[string]interface{}) (ResumePlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return ResumePlan{}, err + } + resume, err := ObjectValue(upload["resume"], "upload.resume") + if err != nil { + return ResumePlan{}, err + } + expectedPreviousUploadCount, err := IntValue( + resume["expectedPreviousUploadCount"], + "upload.resume.expectedPreviousUploadCount", + ) + if err != nil { + return ResumePlan{}, err + } + expectedRemainingPreviousUploadCount, err := IntValue( + resume["expectedRemainingPreviousUploadCount"], + "upload.resume.expectedRemainingPreviousUploadCount", + ) + if err != nil { + return ResumePlan{}, err + } + fingerprint, err := StringValue(resume["fingerprint"], "upload.resume.fingerprint") + if err != nil { + return ResumePlan{}, err + } + removeFingerprintOnSuccess, err := BoolValue( + resume["removeFingerprintOnSuccess"], + "upload.resume.removeFingerprintOnSuccess", + ) + if err != nil { + return ResumePlan{}, err + } + stopAfterAcceptedBytes, err := IntValue( + resume["stopAfterAcceptedBytes"], + "upload.resume.stopAfterAcceptedBytes", + ) + if err != nil { + return ResumePlan{}, err + } + + return ResumePlan{ + ExpectedPreviousUploadCount: expectedPreviousUploadCount, + ExpectedRemainingPreviousUploadCount: expectedRemainingPreviousUploadCount, + Fingerprint: fingerprint, + RemoveFingerprintOnSuccess: removeFingerprintOnSuccess, + StopAfterAcceptedBytes: stopAfterAcceptedBytes, + }, nil +} + +func URLStorageBackend(scenario map[string]interface{}) (*URLStorageBackendPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + rawBackend, ok := upload["urlStorageBackend"] + if !ok { + return nil, nil + } + backend, err := ObjectValue(rawBackend, "upload.urlStorageBackend") + if err != nil { + return nil, err + } + expectedStoredUploadKeyPrefix, err := StringValue( + backend["expectedStoredUploadKeyPrefix"], + "upload.urlStorageBackend.expectedStoredUploadKeyPrefix", + ) + if err != nil { + return nil, err + } + kind, err := StringValue(backend["kind"], "upload.urlStorageBackend.kind") + if err != nil { + return nil, err + } + + return &URLStorageBackendPlan{ + ExpectedStoredUploadKeyPrefix: expectedStoredUploadKeyPrefix, + Kind: kind, + }, nil +} + +func RetryDelays(scenario map[string]interface{}) ([]time.Duration, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + retries, err := IntValue(upload["retries"], "upload.retries") + if err != nil { + return nil, err + } + if retries < 0 { + return nil, fmt.Errorf("upload.retries must not be negative") + } + + return make([]time.Duration, retries), nil +} + +func RetryOffsetRecovery(scenario map[string]interface{}) (RetryOffsetRecoveryPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + retryOffsetRecovery, err := ObjectValue( + upload["retryOffsetRecovery"], + "upload.retryOffsetRecovery", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponse, err := ObjectValue( + retryOffsetRecovery["failAfterResponse"], + "upload.retryOffsetRecovery.failAfterResponse", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + recoveryResponse, err := ObjectValue( + retryOffsetRecovery["recoveryResponse"], + "upload.retryOffsetRecovery.recoveryResponse", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + + expectedFailureCount, err := IntValue( + retryOffsetRecovery["expectedFailureCount"], + "upload.retryOffsetRecovery.expectedFailureCount", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + expectedRecoveredOffset, err := IntValue( + retryOffsetRecovery["expectedRecoveredOffset"], + "upload.retryOffsetRecovery.expectedRecoveredOffset", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + expectedRecoveryRequestCount, err := IntValue( + retryOffsetRecovery["expectedRecoveryRequestCount"], + "upload.retryOffsetRecovery.expectedRecoveryRequestCount", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + expectedRequestMethods, err := StringArrayValue( + retryOffsetRecovery["expectedRequestMethods"], + "upload.retryOffsetRecovery.expectedRequestMethods", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponseMessage, err := StringValue( + failAfterResponse["message"], + "upload.retryOffsetRecovery.failAfterResponse.message", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponseMethod, err := StringValue( + failAfterResponse["method"], + "upload.retryOffsetRecovery.failAfterResponse.method", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponseOccurrence, err := IntValue( + failAfterResponse["occurrence"], + "upload.retryOffsetRecovery.failAfterResponse.occurrence", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + recoveryResponseMethod, err := StringValue( + recoveryResponse["method"], + "upload.retryOffsetRecovery.recoveryResponse.method", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + recoveryResponseOffsetHeader, err := StringValue( + recoveryResponse["offsetHeader"], + "upload.retryOffsetRecovery.recoveryResponse.offsetHeader", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + + return RetryOffsetRecoveryPlan{ + ExpectedFailureCount: expectedFailureCount, + ExpectedRecoveredOffset: expectedRecoveredOffset, + ExpectedRecoveryRequestCount: expectedRecoveryRequestCount, + ExpectedRequestMethods: expectedRequestMethods, + FailAfterResponse: RetryOffsetRecoveryFailurePlan{ + Message: failAfterResponseMessage, + Method: failAfterResponseMethod, + Occurrence: failAfterResponseOccurrence, + }, + RecoveryResponse: RetryOffsetRecoveryResponsePlan{ + Method: recoveryResponseMethod, + OffsetHeader: recoveryResponseOffsetHeader, + }, + }, nil +} + +func RequestLifecycleHooks(scenario map[string]interface{}) (RequestLifecycleHooksPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + requestLifecycleHooks, err := ObjectValue( + upload["requestLifecycleHooks"], + "upload.requestLifecycleHooks", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + expectedAfterResponseMethods, err := StringArrayValue( + requestLifecycleHooks["expectedAfterResponseMethods"], + "upload.requestLifecycleHooks.expectedAfterResponseMethods", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + expectedAfterResponseStatusCodes, err := IntArrayValue( + requestLifecycleHooks["expectedAfterResponseStatusCodes"], + "upload.requestLifecycleHooks.expectedAfterResponseStatusCodes", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + expectedBeforeRequestMethods, err := StringArrayValue( + requestLifecycleHooks["expectedBeforeRequestMethods"], + "upload.requestLifecycleHooks.expectedBeforeRequestMethods", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + ignoredRequestMethods, err := StringArrayValue( + requestLifecycleHooks["ignoredRequestMethods"], + "upload.requestLifecycleHooks.ignoredRequestMethods", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + + return RequestLifecycleHooksPlan{ + ExpectedAfterResponseMethods: expectedAfterResponseMethods, + ExpectedAfterResponseStatusCodes: expectedAfterResponseStatusCodes, + ExpectedBeforeRequestMethods: expectedBeforeRequestMethods, + IgnoredRequestMethods: ignoredRequestMethods, + }, nil +} + +func UploadCallbacks(scenario map[string]interface{}) (UploadCallbacksPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return UploadCallbacksPlan{}, err + } + uploadCallbacks, err := ObjectValue(upload["uploadCallbacks"], "upload.uploadCallbacks") + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKinds, err := ObjectValue( + uploadCallbacks["eventKinds"], + "upload.uploadCallbacks.eventKinds", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + + allowedExtraEventKeyPrefixes, err := StringArrayValue( + uploadCallbacks["allowedExtraEventKeyPrefixes"], + "upload.uploadCallbacks.allowedExtraEventKeyPrefixes", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKeyAlternativeGroups, err := StringArrayArrayValue( + uploadCallbacks["eventKeyAlternativeGroups"], + "upload.uploadCallbacks.eventKeyAlternativeGroups", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + chunkComplete, err := StringValue( + eventKinds["chunkComplete"], + "upload.uploadCallbacks.eventKinds.chunkComplete", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + progress, err := StringValue(eventKinds["progress"], "upload.uploadCallbacks.eventKinds.progress") + if err != nil { + return UploadCallbacksPlan{}, err + } + sourceClose, err := StringValue( + eventKinds["sourceClose"], + "upload.uploadCallbacks.eventKinds.sourceClose", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + success, err := StringValue(eventKinds["success"], "upload.uploadCallbacks.eventKinds.success") + if err != nil { + return UploadCallbacksPlan{}, err + } + uploadURLAvailable, err := StringValue( + eventKinds["uploadUrlAvailable"], + "upload.uploadCallbacks.eventKinds.uploadUrlAvailable", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKeyPartSeparator, err := StringValue( + uploadCallbacks["eventKeyPartSeparator"], + "upload.uploadCallbacks.eventKeyPartSeparator", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKeys, err := StringArrayValue( + uploadCallbacks["eventKeys"], + "upload.uploadCallbacks.eventKeys", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventPolicyMatching, err := StringValue( + uploadCallbacks["eventPolicyMatching"], + "upload.uploadCallbacks.eventPolicyMatching", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + + return UploadCallbacksPlan{ + AllowedExtraEventKeyPrefixes: allowedExtraEventKeyPrefixes, + EventKeyAlternativeGroups: eventKeyAlternativeGroups, + EventKinds: UploadCallbackEventKinds{ + ChunkComplete: chunkComplete, + Progress: progress, + SourceClose: sourceClose, + Success: success, + UploadURLAvailable: uploadURLAvailable, + }, + EventKeyPartSeparator: eventKeyPartSeparator, + EventKeys: eventKeys, + EventPolicyMatching: eventPolicyMatching, + }, nil +} + +func UploadCallbackEventKey(plan UploadCallbacksPlan, parts ...string) string { + return strings.Join(parts, plan.EventKeyPartSeparator) +} + +func UploadCallbackEventKeyNumber(value int64) string { + return strconv.FormatInt(value, 10) +} + +func UploadCallbackEventKeyTotal(value *int64) string { + if value == nil { + return "null" + } + + return UploadCallbackEventKeyNumber(*value) +} + +func MatchUploadCallbackEventKeys(plan UploadCallbacksPlan, actual []string) ([]string, error) { + allowedExtraPrefixes := []string{} + switch plan.EventPolicyMatching { + case "exact": + case "exact-except-allowed-extra-events": + allowedExtraPrefixes = plan.AllowedExtraEventKeyPrefixes + default: + return nil, fmt.Errorf("unsupported upload callback event policy %q", plan.EventPolicyMatching) + } + + expectedIndex := 0 + matched := []string{} + for _, event := range actual { + if expectedIndex < len(plan.EventKeys) && + uploadCallbackEventMatchesExpected(plan, expectedIndex, event) { + matched = append(matched, plan.EventKeys[expectedIndex]) + expectedIndex += 1 + continue + } + if hasAllowedUploadCallbackExtraEventPrefix(event, allowedExtraPrefixes) { + continue + } + + return nil, fmt.Errorf( + "upload callback events emitted unexpected extra event %q; allowed prefixes %v; expected %v, got %v", + event, + allowedExtraPrefixes, + plan.EventKeys, + actual, + ) + } + if expectedIndex == len(plan.EventKeys) { + return matched, nil + } + + return nil, fmt.Errorf( + "upload callback events did not emit every expected non-extra event; expected %v, got %v", + plan.EventKeys, + actual, + ) +} + +func uploadCallbackEventMatchesExpected( + plan UploadCallbacksPlan, + expectedIndex int, + actual string, +) bool { + if actual == plan.EventKeys[expectedIndex] { + return true + } + if expectedIndex >= len(plan.EventKeyAlternativeGroups) { + return false + } + + for _, alternative := range plan.EventKeyAlternativeGroups[expectedIndex] { + if actual == alternative { + return true + } + } + + return false +} + +func hasAllowedUploadCallbackExtraEventPrefix(event string, allowedExtraPrefixes []string) bool { + for _, prefix := range allowedExtraPrefixes { + if strings.HasPrefix(event, prefix) { + return true + } + } + + return false +} + +func TusConformanceScenario(scenario map[string]interface{}) (map[string]interface{}, error) { + return ObjectValue(scenario["conformanceScenario"], "conformanceScenario") +} + +func TusConformanceInputOptions( + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + rawEntries, err := ArrayValue( + conformanceScenario["inputOptionEntries"], + "conformanceScenario.inputOptionEntries", + ) + if err != nil { + return nil, err + } + + options := map[string]interface{}{} + for index, rawEntry := range rawEntries { + label := fmt.Sprintf("conformanceScenario.inputOptionEntries[%d]", index) + entry, err := ObjectValue(rawEntry, label) + if err != nil { + return nil, err + } + key, err := StringValue(entry["key"], label+".key") + if err != nil { + return nil, err + } + options[key] = entry["value"] + } + + return options, nil +} + +func TusConformanceInputStringOption( + conformanceScenario map[string]interface{}, + key string, +) (string, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return "", err + } + + return StringValue(options[key], "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputBoolOption( + conformanceScenario map[string]interface{}, + key string, + defaultValue bool, +) (bool, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return false, err + } + value, ok := options[key] + if !ok { + return defaultValue, nil + } + + return BoolValue(value, "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputIntOption( + conformanceScenario map[string]interface{}, + key string, +) (int, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return 0, err + } + + return IntValue(options[key], "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputStringMapOption( + conformanceScenario map[string]interface{}, + key string, +) (map[string]string, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return nil, err + } + value, ok := options[key] + if !ok { + return map[string]string{}, nil + } + + return StringMapValue(value, "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputSourceBytes( + conformanceScenario map[string]interface{}, +) ([]byte, error) { + source, err := ObjectValue( + conformanceScenario["inputSource"], + "conformanceScenario.inputSource", + ) + if err != nil { + return nil, err + } + content, err := StringValue(source["content"], "conformanceScenario.inputSource.content") + if err != nil { + return nil, err + } + + return []byte(content), nil +} + +func TusConformanceInputSourceKind(conformanceScenario map[string]interface{}) (string, error) { + source, err := ObjectValue( + conformanceScenario["inputSource"], + "conformanceScenario.inputSource", + ) + if err != nil { + return "", err + } + + return StringValue(source["kind"], "conformanceScenario.inputSource.kind") +} + +func TusConformanceScenarioWantsEvent( + conformanceScenario map[string]interface{}, + eventKind string, +) (bool, error) { + rawEvents, ok := conformanceScenario["events"] + if !ok || rawEvents == nil { + return false, nil + } + events, err := ArrayValue(rawEvents, "conformanceScenario.events") + if err != nil { + return false, err + } + + for index, rawEvent := range events { + label := fmt.Sprintf("conformanceScenario.events[%d]", index) + event, err := ObjectValue(rawEvent, label) + if err != nil { + return false, err + } + kind, err := StringValue(event["kind"], label+".kind") + if err != nil { + return false, err + } + if kind == eventKind { + return true, nil + } + } + + return false, nil +} + +func TusConformanceRetryDecisions( + conformanceScenario map[string]interface{}, +) ([]TusConformanceRetryDecision, error) { + rawDecisions, ok := conformanceScenario["retryDecisions"] + if !ok || rawDecisions == nil { + return []TusConformanceRetryDecision{}, nil + } + decisions, err := ArrayValue(rawDecisions, "conformanceScenario.retryDecisions") + if err != nil { + return nil, err + } + + parsedDecisions := make([]TusConformanceRetryDecision, 0, len(decisions)) + for index, rawDecision := range decisions { + label := fmt.Sprintf("conformanceScenario.retryDecisions[%d]", index) + decision, err := ObjectValue(rawDecision, label) + if err != nil { + return nil, err + } + decisionValue, err := BoolValue(decision["decision"], label+".decision") + if err != nil { + return nil, err + } + retryAttempt, err := IntValue(decision["retryAttempt"], label+".retryAttempt") + if err != nil { + return nil, err + } + parsedDecisions = append(parsedDecisions, TusConformanceRetryDecision{ + Decision: decisionValue, + RetryAttempt: retryAttempt, + }) + } + + return parsedDecisions, nil +} + +func TusConformanceRuntimeAbortTerminateUpload( + conformanceScenario map[string]interface{}, +) (bool, error) { + runtimeSetup, err := ObjectValue( + conformanceScenario["runtimeSetup"], + "conformanceScenario.runtimeSetup", + ) + if err != nil { + return false, err + } + abort, err := ObjectValue(runtimeSetup["abort"], "conformanceScenario.runtimeSetup.abort") + if err != nil { + return false, err + } + + return BoolValue( + abort["terminateUpload"], + "conformanceScenario.runtimeSetup.abort.terminateUpload", + ) +} + +func TusConformanceRuntimeFingerprint( + conformanceScenario map[string]interface{}, +) (string, error) { + runtimeSetup, err := ObjectValue( + conformanceScenario["runtimeSetup"], + "conformanceScenario.runtimeSetup", + ) + if err != nil { + return "", err + } + fingerprint, err := ObjectValue( + runtimeSetup["fingerprint"], + "conformanceScenario.runtimeSetup.fingerprint", + ) + if err != nil { + return "", err + } + install, err := BoolValue( + fingerprint["install"], + "conformanceScenario.runtimeSetup.fingerprint.install", + ) + if err != nil { + return "", err + } + if !install { + return "", nil + } + + return StringValue( + fingerprint["value"], + "conformanceScenario.runtimeSetup.fingerprint.value", + ) +} + +func TusConformanceServerCapabilities( + conformanceScenario map[string]interface{}, +) (TusConformanceServerCapabilitiesPlan, error) { + serverCapabilities, err := ObjectValue( + conformanceScenario["serverCapabilities"], + "conformanceScenario.serverCapabilities", + ) + if err != nil { + return TusConformanceServerCapabilitiesPlan{}, err + } + extensionNames, err := StringArrayValue( + serverCapabilities["extensionNames"], + "conformanceScenario.serverCapabilities.extensionNames", + ) + if err != nil { + return TusConformanceServerCapabilitiesPlan{}, err + } + protocolVersions, err := StringArrayValue( + serverCapabilities["protocolVersions"], + "conformanceScenario.serverCapabilities.protocolVersions", + ) + if err != nil { + return TusConformanceServerCapabilitiesPlan{}, err + } + + return TusConformanceServerCapabilitiesPlan{ + ExtensionNames: extensionNames, + ProtocolVersions: protocolVersions, + }, nil +} + +func TusConformanceCancelRequestIndexes( + conformanceScenario map[string]interface{}, +) ([]int, error) { + execution, err := ObjectValue( + conformanceScenario["execution"], + "conformanceScenario.execution", + ) + if err != nil { + return nil, err + } + actions, err := ArrayValue( + execution["onRequestStart"], + "conformanceScenario.execution.onRequestStart", + ) + if err != nil { + return nil, err + } + + requestIndexes := []int{} + for index, rawAction := range actions { + label := fmt.Sprintf("conformanceScenario.execution.onRequestStart[%d]", index) + action, err := ObjectValue(rawAction, label) + if err != nil { + return nil, err + } + kind, err := StringValue(action["kind"], label+".kind") + if err != nil { + return nil, err + } + if kind != "cancel-upload" { + continue + } + requestIndex, err := IntValue(action["requestIndex"], label+".requestIndex") + if err != nil { + return nil, err + } + requestIndexes = append(requestIndexes, requestIndex) + } + + return requestIndexes, nil +} + +func ScenarioID(scenario map[string]interface{}) (string, error) { + return StringValue(scenario["scenarioId"], "scenarioId") +} + +func WriteResult(result map[string]interface{}) error { + resultPath := os.Getenv("API2_SDK_EXAMPLE_RESULT") + if resultPath == "" { + return nil + } + + contents, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + return os.WriteFile(resultPath, append(contents, '\n'), 0o644) +} diff --git a/protocol_contract_test.go b/protocol_contract_test.go new file mode 100644 index 0000000..6efc37b --- /dev/null +++ b/protocol_contract_test.go @@ -0,0 +1,199 @@ +package tusgo + +import ( + "net/http" + "net/url" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +func generatedDefaultTusWireVersion() string { + versions := make([]generatedTusWireVersion, 0) + for _, candidate := range generatedTusWireVersions { + if candidate.Default { + versions = append(versions, candidate) + } + } + Ω(versions).Should(HaveLen(1)) + return versions[0].Value +} + +func generatedProtocolOperation(operationID string) generatedTusProtocolOperation { + for _, candidate := range generatedTusProtocolOperations { + if candidate.OperationID == operationID { + return candidate + } + } + Fail("missing generated TUS protocol operation: " + operationID) + return generatedTusProtocolOperation{} +} + +func generatedClientFeature(featureID string) generatedTusClientFeature { + for _, candidate := range generatedTusClientFeatures { + if candidate.FeatureID == featureID { + return candidate + } + } + Fail("missing generated TUS client feature: " + featureID) + return generatedTusClientFeature{} +} + +func generatedResponseFor( + operation generatedTusProtocolOperation, + statusCode int, +) generatedTusResponseContract { + for _, candidate := range operation.Responses { + if candidate.StatusCode == statusCode { + return candidate + } + } + Fail("missing generated response status for " + operation.OperationID) + return generatedTusResponseContract{} +} + +func withGeneratedRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := values[field.DisplayName] + if value == "" { + value = generatedDefaultTusWireVersion() + } + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + return builder +} + +func generatedResponseHeaders( + response generatedTusResponseContract, + overrides map[string]string, +) map[string]string { + headers := make(map[string]string) + variant := response.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := overrides[field.DisplayName] + if value == "" { + value = generatedDefaultTusWireVersion() + } + headers[field.DisplayName] = value + } + return headers +} + +func withGeneratedResponseHeaders( + response *reply.StdReply, + headers map[string]string, +) *reply.StdReply { + for key, value := range headers { + response = response.Header(key, value) + } + return response +} + +var _ = Describe("generated TUS protocol contract", func() { + var srvMock *mocha.Mocha + + BeforeEach(func() { + srvMock = mocha.New(GinkgoT()) + srvMock.Start() + }) + + AfterEach(func() { + if srvMock != nil { + Ω(srvMock.Close()).Should(Succeed()) + srvMock.AssertCalled(GinkgoT()) + } + }) + + It("drives create and patch lifecycle assertions from the generated contract", func() { + Ω(DefaultProtocolVersion).Should(Equal(generatedDefaultTusWireVersion())) + + lifecycle := generatedClientFeature("singleUploadLifecycle") + createOperation := generatedProtocolOperation(lifecycle.OperationIDs[0]) + patchOperation := generatedProtocolOperation(lifecycle.OperationIDs[2]) + + baseURL, err := url.Parse(srvMock.URL() + createOperation.Path) + Ω(err).Should(Succeed()) + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{"creation"}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createReply := withGeneratedResponseHeaders( + reply.Status(createResponse.StatusCode), + generatedResponseHeaders(createResponse, map[string]string{ + "Location": srvMock.URL() + "/resumable/files/generated-contract", + }), + ) + createRequest := withGeneratedRequestHeaders( + mocha.Request().URL(expect.URLPath(createOperation.Path)).Method(createOperation.Method), + createOperation, + map[string]string{ + "Upload-Length": "5", + "Upload-Metadata": "filename aGVsbG8udHh0", + }, + ) + srvMock.AddMocks(createRequest.Reply(createReply)) + + patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchReply := withGeneratedResponseHeaders( + reply.Status(patchResponse.StatusCode), + generatedResponseHeaders(patchResponse, map[string]string{ + "Upload-Offset": "5", + }), + ) + patchRequest := withGeneratedRequestHeaders( + mocha.Request(). + URL(expect.URLPath("/resumable/files/generated-contract")). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte("hello"))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Upload-Offset": "0", + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload := Upload{} + _, err = client.CreateUpload(&upload, 5, false, map[string]string{ + "filename": "hello.txt", + }) + Ω(err).Should(Succeed()) + + stream := NewUploadStream(client, &upload) + stream.ChunkSize = 5 + written, err := stream.Write([]byte("hello")) + Ω(err).Should(Succeed()) + Ω(written).Should(Equal(5)) + Ω(upload.RemoteOffset).Should(Equal(int64(5))) + }) + + It("exposes generated default protocol headers as defensive copies", func() { + Ω(DefaultProtocolRequestHeaders()).Should(Equal(generatedTusDefaultRequestHeaderValues)) + Ω(DefaultProtocolResponseHeaders()).Should(Equal(generatedTusDefaultResponseHeaderValues)) + + requestHeaders := DefaultProtocolRequestHeaders() + for headerName := range requestHeaders { + requestHeaders[headerName] = "mutated" + break + } + + Ω(DefaultProtocolRequestHeaders()).Should(Equal(generatedTusDefaultRequestHeaderValues)) + }) +}) diff --git a/protocol_generated.go b/protocol_generated.go new file mode 100644 index 0000000..4f2577e --- /dev/null +++ b/protocol_generated.go @@ -0,0 +1,110 @@ +// Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +// If it looks wrong, please report the issue instead of editing this file by hand; +// the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +package tusgo + +const ( + // DefaultProtocolVersion is the wire protocol version used by default. + DefaultProtocolVersion = "1.0.0" + + // DefaultClientProtocol is the generated client protocol mode used by default. + DefaultClientProtocol = "tus-v1" + + ProtocolTusV1 = "tus-v1" + ProtocolIetfDraft03 = "ietf-draft-03" + ProtocolIetfDraft05 = "ietf-draft-05" +) + +var defaultProtocolRequestHeaders = map[string]string{"Tus-Resumable": "1.0.0"} +var defaultProtocolResponseHeaders = map[string]string{"Tus-Resumable": "1.0.0"} + +type clientProtocolCompatibilityVersion struct { + RequestHeaders map[string]string + ResponseHeaders map[string]string + UploadBodyContentType string + UploadCompleteHeaderName string + UploadCompleteCompleteValue string + UploadCompleteIncompleteValue string +} + +var clientProtocolCompatibilityVersions = map[string]clientProtocolCompatibilityVersion{ + "tus-v1": { + RequestHeaders: map[string]string{"Tus-Resumable": "1.0.0"}, + ResponseHeaders: map[string]string{"Tus-Resumable": "1.0.0"}, + UploadBodyContentType: "application/offset+octet-stream", + }, + "ietf-draft-03": { + RequestHeaders: map[string]string{"Upload-Draft-Interop-Version": "5"}, + ResponseHeaders: map[string]string{}, + UploadBodyContentType: "", + UploadCompleteHeaderName: "Upload-Complete", + UploadCompleteCompleteValue: "?1", + UploadCompleteIncompleteValue: "?0", + }, + "ietf-draft-05": { + RequestHeaders: map[string]string{"Upload-Draft-Interop-Version": "6"}, + ResponseHeaders: map[string]string{}, + UploadBodyContentType: "application/partial-upload", + UploadCompleteHeaderName: "Upload-Complete", + UploadCompleteCompleteValue: "?1", + UploadCompleteIncompleteValue: "?0", + }, +} + +func copyDefaultProtocolHeaders(headers map[string]string) map[string]string { + copied := make(map[string]string, len(headers)) + for name, value := range headers { + copied[name] = value + } + return copied +} + +func clientProtocolCompatibilityVersionFor(protocolVersion string) (clientProtocolCompatibilityVersion, bool) { + if protocolVersion == "" || protocolVersion == DefaultProtocolVersion { + protocolVersion = DefaultClientProtocol + } + + compatibilityVersion, ok := clientProtocolCompatibilityVersions[protocolVersion] + return compatibilityVersion, ok +} + +func protocolRequestHeaders(protocolVersion string) (map[string]string, bool) { + compatibilityVersion, ok := clientProtocolCompatibilityVersionFor(protocolVersion) + if !ok { + return nil, false + } + + return copyDefaultProtocolHeaders(compatibilityVersion.RequestHeaders), true +} + +func protocolUploadBodyContentType(protocolVersion string) (string, bool) { + compatibilityVersion, ok := clientProtocolCompatibilityVersionFor(protocolVersion) + if !ok || compatibilityVersion.UploadBodyContentType == "" { + return "", false + } + + return compatibilityVersion.UploadBodyContentType, true +} + +func protocolUploadCompleteHeader(protocolVersion string, done bool) (string, string, bool) { + compatibilityVersion, ok := clientProtocolCompatibilityVersionFor(protocolVersion) + if !ok || compatibilityVersion.UploadCompleteHeaderName == "" { + return "", "", false + } + if done { + return compatibilityVersion.UploadCompleteHeaderName, compatibilityVersion.UploadCompleteCompleteValue, true + } + + return compatibilityVersion.UploadCompleteHeaderName, compatibilityVersion.UploadCompleteIncompleteValue, true +} + +// DefaultProtocolRequestHeaders returns the protocol request headers used by default. +func DefaultProtocolRequestHeaders() map[string]string { + return copyDefaultProtocolHeaders(defaultProtocolRequestHeaders) +} + +// DefaultProtocolResponseHeaders returns the protocol response headers used by default. +func DefaultProtocolResponseHeaders() map[string]string { + return copyDefaultProtocolHeaders(defaultProtocolResponseHeaders) +} diff --git a/request_lifecycle_contract_test.go b/request_lifecycle_contract_test.go new file mode 100644 index 0000000..7475389 --- /dev/null +++ b/request_lifecycle_contract_test.go @@ -0,0 +1,218 @@ +package tusgo + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusRequestLifecycleEndpointPath = "/uploads" + generatedTusRequestLifecycleEventPolicy = "exact" + generatedTusRequestLifecycleUploadLength = "11" + generatedTusRequestLifecycleUploadOffset = "11" + generatedTusRequestLifecycleUploadPath = "/uploads/request-hooks-contract" +) + +var generatedTusRequestLifecycleExtraEventPrefixes = []string{} +var generatedTusRequestLifecycleExpectedHookEvents = []string{"before-request:0", "after-response:0"} + +func TestGeneratedRequestLifecycleHooks(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusRequestLifecycleEndpointPath) + if err != nil { + t.Fatal(err) + } + + getOperation := generatedProtocolOperation("getTusUploadOffset") + client := NewClient(http.DefaultClient, baseURL) + createdUploadURL := srvMock.URL() + generatedTusRequestLifecycleUploadPath + + getResponse := generatedResponseFor(getOperation, 200) + getReply := generatedRequestLifecycleResponseHeaders( + reply.Status(getResponse.StatusCode), + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusRequestLifecycleUploadLength, + "Upload-Offset": generatedTusRequestLifecycleUploadOffset, + }, + ) + srvMock.AddMocks( + generatedRequestLifecycleRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRequestLifecycleUploadPath)). + Method(getOperation.Method), + getOperation, + map[string]string{}, + ).Reply(getReply), + ) + + events := []string{} + beforeRequestIndex := 0 + afterResponseIndex := 0 + client = client.WithRequestLifecycleHooks(RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + if request.Method != getOperation.Method { + return fmt.Errorf("expected %s request, got %s", getOperation.Method, request.Method) + } + if request.URL.Path != generatedTusRequestLifecycleUploadPath { + return fmt.Errorf( + "expected request path %s, got %s", + generatedTusRequestLifecycleUploadPath, + request.URL.Path, + ) + } + if err := generatedAssertRequestLifecycleRequestHeaders( + request, + getOperation, + map[string]string{}, + ); err != nil { + return err + } + events = append(events, generatedTusEventKeyBeforeRequest( + generatedTusEventKeyNumber(int64(beforeRequestIndex)), + )) + beforeRequestIndex += 1 + return nil + }, + AfterResponse: func(request *http.Request, response *http.Response) error { + if request.Method != getOperation.Method { + return fmt.Errorf("expected %s request, got %s", getOperation.Method, request.Method) + } + if response.StatusCode != getResponse.StatusCode { + return fmt.Errorf("expected response status %d, got %d", getResponse.StatusCode, response.StatusCode) + } + if err := generatedAssertRequestLifecycleResponseHeaders( + response, + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusRequestLifecycleUploadLength, + "Upload-Offset": generatedTusRequestLifecycleUploadOffset, + }, + ); err != nil { + return err + } + events = append(events, generatedTusEventKeyAfterResponse( + generatedTusEventKeyNumber(int64(afterResponseIndex)), + )) + afterResponseIndex += 1 + return nil + }, + }) + + upload := Upload{} + response, err := client.GetUpload(&upload, createdUploadURL) + if err != nil { + t.Fatal(err) + } + if response == nil || response.StatusCode != getResponse.StatusCode { + t.Fatalf("expected response status %d, got %#v", getResponse.StatusCode, response) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset %s, got %d", generatedTusRequestLifecycleUploadOffset, upload.RemoteOffset) + } + if upload.RemoteSize != 11 { + t.Fatalf("expected upload length %s, got %d", generatedTusRequestLifecycleUploadLength, upload.RemoteSize) + } + generatedTusAssertEvents(t, "requestLifecycleHooks", generatedTusRequestLifecycleEventPolicy, generatedTusRequestLifecycleExtraEventPrefixes, generatedTusRequestLifecycleExpectedHookEvents, events) +} + +func generatedRequestLifecycleRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedRequestLifecycleResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} + +func generatedAssertRequestLifecycleRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedAssertRequestLifecycleResponseHeaders( + response *http.Response, + contract generatedTusResponseContract, + values map[string]string, +) error { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusResponseHeaderValue(values, field.DisplayName) + if actual := response.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected response header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} diff --git a/request_lifecycle_generated.go b/request_lifecycle_generated.go new file mode 100644 index 0000000..855e3fe --- /dev/null +++ b/request_lifecycle_generated.go @@ -0,0 +1,83 @@ +// Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +// If it looks wrong, please report the issue instead of editing this file by hand; +// the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +package tusgo + +import "net/http" + +type RequestLifecycleHooks struct { + BeforeRequest func(*http.Request) error + AfterResponse func(*http.Request, *http.Response) error +} + +type generatedTusRequestLifecycleHookPlan struct { + BeforeRequestHook bool + AfterResponseHook bool +} + +type generatedTusRequestLifecycleTransport struct { + base http.RoundTripper + hooks RequestLifecycleHooks +} + +func (c *Client) WithRequestLifecycleHooks(hooks RequestLifecycleHooks) *Client { + clone := *c + httpClient := c.client + if httpClient == nil { + httpClient = http.DefaultClient + } + + httpClientClone := *httpClient + httpClientClone.Transport = generatedTusRequestLifecycleTransport{ + base: generatedTusRequestLifecycleBaseTransport(httpClient.Transport), + hooks: hooks, + } + clone.client = &httpClientClone + + return &clone +} + +func (transport generatedTusRequestLifecycleTransport) RoundTrip(request *http.Request) (*http.Response, error) { + plan, err := generatedTusPlanRequestLifecycleHooks(transport.hooks) + if err != nil { + return nil, err + } + if plan.BeforeRequestHook { + if err := transport.hooks.BeforeRequest(request); err != nil { + return nil, err + } + } + + response, err := transport.base.RoundTrip(request) + if err != nil { + return response, err + } + if plan.AfterResponseHook { + if err := transport.hooks.AfterResponse(request, response); err != nil { + if response.Body != nil { + response.Body.Close() + } + return nil, err + } + } + + return response, nil +} + +func generatedTusRequestLifecycleBaseTransport(base http.RoundTripper) http.RoundTripper { + if base != nil { + return base + } + + return http.DefaultTransport +} + +func generatedTusPlanRequestLifecycleHooks( + hooks RequestLifecycleHooks, +) (generatedTusRequestLifecycleHookPlan, error) { + return generatedTusRequestLifecycleHookPlan{ + BeforeRequestHook: hooks.BeforeRequest != nil, + AfterResponseHook: hooks.AfterResponse != nil, + }, nil +} diff --git a/stream.go b/stream.go index 1aa2efe..9a1c76f 100644 --- a/stream.go +++ b/stream.go @@ -348,8 +348,16 @@ func (us *UploadStream) uploadChunkImpl(requestURL string, data io.Reader, extra if bytesToUpload != unknownSize { req.ContentLength = bytesToUpload } - req.Header.Set("Content-Type", "application/offset+octet-stream") + if contentType, ok := protocolUploadBodyContentType(us.client.ProtocolVersion); ok { + req.Header.Set("Content-Type", contentType) + } req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10)) + if headerName, value, ok := protocolUploadCompleteHeader( + us.client.ProtocolVersion, + bytesToUpload != unknownSize && offset+bytesToUpload >= us.Upload.RemoteSize, + ); ok { + req.Header.Set(headerName, value) + } if us.SetUploadSize && offset == 0 { req.Header.Set("Upload-Length", strconv.FormatInt(us.Upload.RemoteSize, 10)) diff --git a/termination_generated.go b/termination_generated.go new file mode 100644 index 0000000..f56ff1e --- /dev/null +++ b/termination_generated.go @@ -0,0 +1,47 @@ +// Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +// If it looks wrong, please report the issue instead of editing this file by hand; +// the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +package tusgo + +import ( + "net/http" + "time" +) + +type TerminateUploadOptions struct { + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool +} + +func (c *Client) TerminateUploadWithRetry(upload Upload, options TerminateUploadOptions) (*http.Response, error) { + retryDelays := generatedTusRetryDelays(options.RetryDelays) + retryAttempt := 0 + + for { + response, err := c.DeleteUpload(upload) + if err == nil { + return response, nil + } + + statusCode := 0 + if response != nil { + statusCode = response.StatusCode + } + if !generatedTusShouldScheduleRetry( + options.OnShouldRetry, + err, + statusCode, + retryAttempt, + retryDelays, + ) { + return response, err + } + + delay := retryDelays[retryAttempt] + if delay > 0 { + time.Sleep(delay) + } + retryAttempt = generatedTusNextRetryAttempt(retryAttempt) + } +} diff --git a/termination_retry_contract_test.go b/termination_retry_contract_test.go new file mode 100644 index 0000000..e981db4 --- /dev/null +++ b/termination_retry_contract_test.go @@ -0,0 +1,296 @@ +package tusgo + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/params" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusTerminateFlowChunkCompleteActionKind = "abort-upload" + generatedTusTerminateFlowContent = "hello world" + generatedTusTerminateFlowEndpointPath = "/uploads" + generatedTusTerminateFlowEventPolicy = "exact" + generatedTusTerminateFlowFinalStatus = 204 + generatedTusTerminateFlowPatchAcceptedOffset = "5" + generatedTusTerminateFlowPatchBody = "hello" + generatedTusTerminateFlowPatchOffset = "0" + generatedTusTerminateFlowUploadLength = "11" + generatedTusTerminateFlowUploadPath = "/uploads/terminate-contract" +) + +type generatedTusTerminateRetryDecision struct { + Decision bool + RetryAttempt int +} + +type generatedTusChunkCompleteAction struct { + Kind string + TerminateUpload bool +} + +type generatedTusTerminateAttempt struct { + Status int +} + +var generatedTusTerminateFlowExtraEventPrefixes = []string{} +var generatedTusTerminateFlowExpectedEvents = []string{"should-retry:0:true", "retry-schedule:0"} +var generatedTusTerminateFlowMetadata = map[string]string{"filename": "hello.txt"} +var generatedTusTerminateFlowOnChunkCompleteActions = []generatedTusChunkCompleteAction{ + { + Kind: "abort-upload", + TerminateUpload: true, + }, +} +var generatedTusTerminateFlowRetryDelays = []time.Duration{0 * time.Millisecond, 0 * time.Millisecond} +var generatedTusTerminateFlowTerminateAttempts = []generatedTusTerminateAttempt{ + { + Status: 423, + }, + { + Status: 204, + }, +} +var generatedTusTerminateFlowShouldRetryEvents = []generatedTusTerminateRetryDecision{ + { + Decision: true, + RetryAttempt: 0, + }, +} + +func TestGeneratedTerminationRetryFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusTerminateFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + terminateOperation := generatedProtocolOperation("terminateTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, terminateOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + createdUploadURL := srvMock.URL() + generatedTusTerminateFlowUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusTerminateFlowMetadata) + if err != nil { + t.Fatal(err) + } + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedTerminationRetryResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedTerminationRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusTerminateFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusTerminateFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedTerminationRetryResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusTerminateFlowPatchAcceptedOffset, + }, + ) + srvMock.AddMocks( + generatedTerminationRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusTerminateFlowUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusTerminateFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusTerminateFlowPatchOffset, + }, + ).Reply(patchReply), + ) + + if len(generatedTusTerminateFlowTerminateAttempts) == 0 { + t.Fatal("expected at least one generated termination attempt") + } + terminateReplies := make([]*reply.StdReply, 0, len(generatedTusTerminateFlowTerminateAttempts)) + for _, terminateAttempt := range generatedTusTerminateFlowTerminateAttempts { + terminateResponse := generatedResponseFor(terminateOperation, terminateAttempt.Status) + terminateReply := generatedTerminationRetryResponseHeaders( + reply.Status(terminateAttempt.Status), + terminateResponse, + map[string]string{}, + ) + terminateReplies = append(terminateReplies, terminateReply) + } + terminateReplyIndex := 0 + srvMock.AddMocks( + generatedTerminationRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusTerminateFlowUploadPath)). + Method(terminateOperation.Method), + terminateOperation, + map[string]string{}, + ).Repeat(len(terminateReplies)).ReplyFunction(func(r *http.Request, m reply.M, p params.P) (*reply.Response, error) { + if terminateReplyIndex >= len(terminateReplies) { + t.Fatalf("unexpected termination request %d", terminateReplyIndex) + return nil, nil + } + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + if len(body) != 0 { + t.Fatalf("expected empty termination body, got %q", string(body)) + } + expected := terminateReplies[terminateReplyIndex] + terminateReplyIndex += 1 + return expected.Build(r, m, p) + }), + ) + + upload := &Upload{} + if _, err := client.CreateUpload(upload, 11, false, generatedTusTerminateFlowMetadata); err != nil { + t.Fatal(err) + } + stream := NewUploadStream(client, upload) + stream.ChunkSize = 5 + if _, err := stream.ReadFrom(strings.NewReader(generatedTusTerminateFlowPatchBody)); err != nil { + t.Fatal(err) + } + if upload.RemoteOffset != 5 { + t.Fatalf("expected uploaded offset 5, got %d", upload.RemoteOffset) + } + + events := []string{} + retryDecisionIndex := 0 + response, err := generatedTusRunTerminateFlowChunkCompleteActions(t, client, *upload, generatedTusTerminateFlowOnChunkCompleteActions, TerminateUploadOptions{ + RetryDelays: generatedTusTerminateFlowRetryDelays, + OnShouldRetry: func(err error, retryAttempt int) bool { + if retryDecisionIndex >= len(generatedTusTerminateFlowShouldRetryEvents) { + t.Fatalf("unexpected termination retry decision request %d for %v", retryDecisionIndex, err) + } + expected := generatedTusTerminateFlowShouldRetryEvents[retryDecisionIndex] + if retryAttempt != expected.RetryAttempt { + t.Fatalf("expected termination retry attempt %d, got %d", expected.RetryAttempt, retryAttempt) + } + events = append(events, generatedTusEventKeyShouldRetry( + generatedTusEventKeyNumber(int64(retryAttempt)), + generatedTusEventKeyBool(expected.Decision), + )) + if expected.Decision { + events = append(events, generatedTusEventKeyRetrySchedule( + generatedTusEventKeyNumber(generatedTusTerminateFlowRetryDelays[retryAttempt].Milliseconds()), + )) + } + retryDecisionIndex += 1 + return expected.Decision + }, + }) + if err != nil { + t.Fatal(err) + } + if response == nil || response.StatusCode != generatedTusTerminateFlowFinalStatus { + t.Fatalf("expected termination status %d, got %#v", generatedTusTerminateFlowFinalStatus, response) + } + if terminateReplyIndex != len(terminateReplies) { + t.Fatalf("expected %d termination requests, got %d", len(terminateReplies), terminateReplyIndex) + } + if retryDecisionIndex != len(generatedTusTerminateFlowShouldRetryEvents) { + t.Fatalf("expected %d termination retry decisions, got %d", len(generatedTusTerminateFlowShouldRetryEvents), retryDecisionIndex) + } + generatedTusAssertEvents(t, "terminateWithRetry", generatedTusTerminateFlowEventPolicy, generatedTusTerminateFlowExtraEventPrefixes, generatedTusTerminateFlowExpectedEvents, events) +} + +func generatedTusRunTerminateFlowChunkCompleteActions( + t *testing.T, + client *Client, + upload Upload, + actions []generatedTusChunkCompleteAction, + options TerminateUploadOptions, +) (*http.Response, error) { + t.Helper() + + var response *http.Response + for _, action := range actions { + if action.Kind != generatedTusTerminateFlowChunkCompleteActionKind { + t.Fatalf("unsupported generated onChunkComplete action %s", action.Kind) + } + if !action.TerminateUpload { + continue + } + + var err error + response, err = client.TerminateUploadWithRetry(upload, options) + if err != nil { + return response, err + } + } + + return response, nil +} + +func generatedTerminationRetryRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedTerminationRetryResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/testdata/api2_tus_contract.json b/testdata/api2_tus_contract.json new file mode 100644 index 0000000..6cb387d --- /dev/null +++ b/testdata/api2_tus_contract.json @@ -0,0 +1,8763 @@ +{ + "clientConformanceScenarios": [ + { + "behavior": "single-upload-lifecycle", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/generated-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close" + ], + "eventKinds": [ + "fingerprint", + "upload-url-available", + "url-storage-add", + "progress", + "chunk-complete", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "singleUploadLifecycle", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/generated-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/generated-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/generated-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": true, + "value": "contract-single-fingerprint" + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": true, + "storedUpload": null + } + }, + "scenarioId": "singleUploadLifecycle" + }, + { + "behavior": "creation-with-upload", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/creation-with-upload-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close" + ], + "eventKinds": [ + "progress", + "upload-url-available", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "creationWithUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "uploadDataDuringCreation", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload" + ], + "primitives": [ + "upload-during-creation", + "emit-progress" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/creation-with-upload-contract", + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/creation-with-upload-contract", + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "creationWithUpload" + }, + { + "behavior": "creation-with-upload-partial-chunk", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/creation-with-upload-partial-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "progress:0:11", + "progress:5:11", + "upload-url-available", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close" + ], + "eventKinds": [ + "progress", + "upload-url-available", + "chunk-complete", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "creationWithUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 5 + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "uploadDataDuringCreation", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "upload-during-creation", + "emit-progress" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": 5, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/creation-with-upload-partial-contract", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/creation-with-upload-partial-contract", + "Upload-Offset": "5", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 5, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "10" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "10", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": "https://tus.io/uploads/creation-with-upload-partial-contract", + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/creation-with-upload-partial-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 1, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "10" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-final-chunk", + "uploadUrl": "https://tus.io/uploads/creation-with-upload-partial-contract", + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "10" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/creation-with-upload-partial-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "creationWithUploadPartialChunk" + }, + { + "behavior": "creation-with-upload", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/ietf-draft-05-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close" + ], + "eventKinds": [ + "progress", + "upload-url-available", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "protocolVersionSelection", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "protocol", + "value": "ietf-draft-05" + }, + { + "key": "uploadDataDuringCreation", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload" + ], + "primitives": [ + "select-client-protocol" + ], + "requests": [ + { + "absentHeaders": [ + "Tus-Resumable" + ], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": "exact", + "headers": { + "Upload-Length": "11", + "Upload-Complete": "?1", + "Content-Type": "application/partial-upload", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": "exact", + "headers": { + "Location": "https://tus.io/uploads/ietf-draft-05-contract", + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/ietf-draft-05-contract", + "Upload-Offset": "11" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Upload-Draft-Interop-Version": "6", + "Upload-Length": "11", + "Upload-Complete": "?1", + "Content-Type": "application/partial-upload", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "ietfDraft05CreationWithUpload" + }, + { + "behavior": "upload-body-headers", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/ietf-draft-05-chunked-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "upload-url-available", + "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close" + ], + "eventKinds": [ + "upload-url-available", + "progress", + "chunk-complete", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "protocolVersionSelection", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 5 + }, + { + "key": "protocol", + "value": "ietf-draft-05" + }, + { + "key": "uploadUrl", + "value": "https://tus.io/uploads/ietf-draft-05-chunked-contract" + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "select-client-protocol" + ], + "requests": [ + { + "absentHeaders": [ + "Tus-Resumable" + ], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": "exact", + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "getTusUploadOffset", + "response": { + "body": null, + "headerMode": "exact", + "headers": { + "Upload-Length": "11", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "statusCode": 200, + "effectiveHeaders": { + "Upload-Length": "11", + "Upload-Offset": "0" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 0, + "effectiveHeaders": { + "Upload-Draft-Interop-Version": "6" + }, + "effectiveMethod": "HEAD", + "expectedUrl": "https://tus.io/uploads/ietf-draft-05-chunked-contract" + }, + { + "absentHeaders": [ + "Tus-Resumable" + ], + "abort": false, + "bodySize": 5, + "bodyStart": null, + "errorMessage": null, + "headerMode": "exact", + "headers": { + "Upload-Complete": "?0", + "Content-Type": "application/partial-upload", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": "exact", + "headers": { + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "5" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Upload-Draft-Interop-Version": "6", + "Upload-Complete": "?0", + "Content-Type": "application/partial-upload", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/ietf-draft-05-chunked-contract" + }, + { + "absentHeaders": [ + "Tus-Resumable" + ], + "abort": false, + "bodySize": 5, + "bodyStart": null, + "errorMessage": null, + "headerMode": "exact", + "headers": { + "Upload-Complete": "?0", + "Content-Type": "application/partial-upload", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": "exact", + "headers": { + "Upload-Offset": "10" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "10" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Upload-Draft-Interop-Version": "6", + "Upload-Complete": "?0", + "Content-Type": "application/partial-upload", + "Upload-Offset": "5" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/ietf-draft-05-chunked-contract" + }, + { + "absentHeaders": [ + "Tus-Resumable" + ], + "abort": false, + "bodySize": 1, + "bodyStart": null, + "errorMessage": null, + "headerMode": "exact", + "headers": { + "Upload-Complete": "?1", + "Content-Type": "application/partial-upload", + "Upload-Offset": "10" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": "exact", + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11" + } + }, + "role": "upload-final-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 3, + "effectiveHeaders": { + "Upload-Draft-Interop-Version": "6", + "Upload-Complete": "?1", + "Content-Type": "application/partial-upload", + "Upload-Offset": "10" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/ietf-draft-05-chunked-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "ietfDraft05ChunkedUploadComplete" + }, + { + "behavior": "upload-body-headers", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/ietf-draft-03-resume-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close" + ], + "eventKinds": [ + "upload-url-available", + "progress", + "chunk-complete", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "protocolVersionSelection", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 6 + }, + { + "key": "protocol", + "value": "ietf-draft-03" + }, + { + "key": "uploadUrl", + "value": "https://tus.io/uploads/ietf-draft-03-resume-contract" + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "select-client-protocol" + ], + "requests": [ + { + "absentHeaders": [ + "Tus-Resumable" + ], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": "exact", + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "getTusUploadOffset", + "response": { + "body": null, + "headerMode": "exact", + "headers": { + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 200, + "effectiveHeaders": { + "Upload-Offset": "5" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 0, + "effectiveHeaders": { + "Upload-Draft-Interop-Version": "5" + }, + "effectiveMethod": "HEAD", + "expectedUrl": "https://tus.io/uploads/ietf-draft-03-resume-contract" + }, + { + "absentHeaders": [ + "Content-Type", + "Tus-Resumable" + ], + "abort": false, + "bodySize": 6, + "bodyStart": null, + "errorMessage": null, + "headerMode": "exact", + "headers": { + "Upload-Complete": "?1", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": "exact", + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Upload-Draft-Interop-Version": "5", + "Upload-Complete": "?1", + "Upload-Offset": "5" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/ietf-draft-03-resume-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "ietfDraft03ResumeWithoutKnownLength" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: no file or stream to upload provided", + "completionReason": "missingInput", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + } + ], + "inputSource": { + "content": "", + "kind": "none" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationMissingInput" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: neither an endpoint or an upload URL is provided", + "completionReason": "missingEndpointOrUploadUrl", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationMissingEndpointOrUploadUrl" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: unsupported protocol tus-v9", + "completionReason": "unsupportedProtocol", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "protocol", + "value": "tus-v9" + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationUnsupportedProtocol" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: the `retryDelays` option must either be an array or null", + "completionReason": "retryDelaysNotArray", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "rawOptions", + "value": { + "retryDelays": 44 + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationRetryDelaysNotArray" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: cannot use the `uploadUrl` option when parallelUploads is enabled", + "completionReason": "parallelUploadsWithUploadUrl", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "parallelUploads", + "value": 2 + }, + { + "key": "uploadUrl", + "value": "https://tus.io/uploads/start-validation-upload-url" + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationParallelUploadsWithUploadUrl" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: cannot use the `uploadSize` option when parallelUploads is enabled", + "completionReason": "parallelUploadsWithUploadSize", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "parallelUploads", + "value": 2 + }, + { + "key": "uploadSize", + "value": 11 + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationParallelUploadsWithUploadSize" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled", + "completionReason": "parallelUploadsWithDeferredLength", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "parallelUploads", + "value": 2 + }, + { + "key": "uploadLengthDeferred", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationParallelUploadsWithDeferredLength" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled", + "completionReason": "parallelUploadsWithUploadDataDuringCreation", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "parallelUploads", + "value": 2 + }, + { + "key": "uploadDataDuringCreation", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationParallelUploadsWithUploadDataDuringCreation" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled", + "completionReason": "parallelBoundariesWithoutParallelUploads", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "parallelUploadBoundaries", + "value": [ + { + "end": 5, + "start": 0 + } + ] + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationParallelBoundariesWithoutParallelUploads" + }, + { + "behavior": "start-option-validation", + "completionKind": "error", + "completionMessage": "tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`", + "completionReason": "parallelBoundariesLengthMismatch", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "startOptionValidation", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "parallelUploads", + "value": 2 + }, + { + "key": "parallelUploadBoundaries", + "value": [ + { + "end": 5, + "start": 0 + } + ] + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [], + "primitives": [ + "validate-start-options" + ], + "requests": [], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "startValidationParallelBoundariesLengthMismatch" + }, + { + "behavior": "detailed-error", + "completionKind": "error", + "completionMessage": "tus: unexpected response while creating upload, originated from request (method: POST, url: https://tus.io/uploads, response code: 500, response text: server_error, request id: contract-request-id)", + "completionReason": "unexpectedCreateResponse", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "detailedErrors", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "headers", + "value": { + "X-Request-ID": "contract-request-id" + } + }, + { + "key": "rawOptions", + "value": { + "retryDelays": null + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload" + ], + "primitives": [ + "report-detailed-errors" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Request-ID": "contract-request-id" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": "server_error", + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 500, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Request-ID": "contract-request-id" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "detailedCreateResponseError" + }, + { + "behavior": "detailed-error", + "completionKind": "error", + "completionMessage": "tus: failed to create upload, caused by Error: socket down, originated from request (method: POST, url: https://tus.io/uploads, response code: n/a, response text: n/a, request id: contract-request-id)", + "completionReason": "createUploadRequestFailed", + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "detailedErrors", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "headers", + "value": { + "X-Request-ID": "contract-request-id" + } + }, + { + "key": "rawOptions", + "value": { + "retryDelays": null + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload" + ], + "primitives": [ + "report-detailed-errors" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": "socket down", + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Request-ID": "contract-request-id" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": null, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Request-ID": "contract-request-id" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "detailedCreateRequestError" + }, + { + "behavior": "upload-body-headers", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/upload-body-headers-contract", + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "uploadBodyHeaders", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "send-upload-body-headers" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/upload-body-headers-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/upload-body-headers-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/upload-body-headers-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "uploadBodyHeaders" + }, + { + "behavior": "custom-request-headers", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/custom-headers-contract", + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "customRequestHeaders", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "headers", + "value": { + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "apply-custom-request-headers" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/custom-headers-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/custom-headers-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/custom-headers-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": true, + "value": "contract-custom-headers-fingerprint" + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "customRequestHeaders" + }, + { + "behavior": "request-id-headers", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/request-id-contract", + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "requestIdHeaders", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "headers", + "value": { + "X-Request-ID": "custom-request-id" + } + }, + { + "key": "addRequestId", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "add-request-id-header", + "apply-custom-request-headers" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Request-ID": "00000000-0000-4000-8000-000000000000" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/request-id-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/request-id-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Request-ID": "00000000-0000-4000-8000-000000000000" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Request-ID": "00000000-0000-4000-8000-000000000000" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Request-ID": "00000000-0000-4000-8000-000000000000" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/request-id-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": true, + "generatedRequestId": "00000000-0000-4000-8000-000000000000" + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "requestIdHeaders" + }, + { + "behavior": "resume-from-previous-upload", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/resume-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close" + ], + "eventKinds": [ + "fingerprint", + "url-storage-find", + "upload-url-available", + "progress", + "chunk-complete", + "url-storage-remove", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [ + { + "actions": [ + { + "expectedPreviousUploadCount": 1, + "kind": "resume-from-previous-upload", + "selectedPreviousUploadIndex": 0 + } + ], + "phase": "beforeStart" + } + ], + "featureId": "resumeUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "removeFingerprintOnSuccess", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "getTusUploadOffset", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 200, + "effectiveHeaders": { + "Upload-Length": "11", + "Upload-Offset": "5", + "Tus-Resumable": "1.0.0" + } + }, + "role": "recover-upload-offset", + "uploadUrl": null, + "url": "upload", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + }, + "effectiveMethod": "HEAD", + "expectedUrl": "https://tus.io/uploads/resume-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 6, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/resume-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": true, + "value": "contract-resume-fingerprint" + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": true, + "storedUpload": { + "fingerprint": "contract-resume-fingerprint", + "uploadUrl": "https://tus.io/uploads/resume-contract", + "urlStorageKey": "tus::contract-resume-fingerprint::1337" + } + } + }, + "scenarioId": "resumeFromPreviousUpload" + }, + { + "behavior": "relative-location-resolution", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/files/relative-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close" + ], + "eventKinds": [ + "upload-url-available", + "progress", + "chunk-complete", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "relativeLocationResolution", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/files/" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "resolve-relative-location" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "relative-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "relative-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/files/" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/files/relative-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "relativeLocationResolution" + }, + { + "behavior": "array-buffer-input", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/array-buffer-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "source-open:array-buffer:11", + "success", + "source-close" + ], + "eventKinds": [ + "source-open", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "inputSources", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "array-buffer" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "read-browser-file" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/array-buffer-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/array-buffer-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/array-buffer-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "arrayBufferInput" + }, + { + "behavior": "array-buffer-view-input", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/array-buffer-view-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "source-open:array-buffer-view:11", + "success", + "source-close" + ], + "eventKinds": [ + "source-open", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "inputSources", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "array-buffer-view" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "read-browser-file" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/array-buffer-view-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/array-buffer-view-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/array-buffer-view-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "arrayBufferViewInput" + }, + { + "behavior": "web-readable-stream-input", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/web-stream-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "source-open:web-readable-stream:null", + "success", + "source-close" + ], + "eventKinds": [ + "source-open", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "inputSources", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 100 + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "uploadLengthDeferred", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "web-readable-stream" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "read-web-stream" + ], + "requests": [ + { + "absentHeaders": [ + "Upload-Length" + ], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/web-stream-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/web-stream-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/web-stream-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": true, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "webReadableStreamInput" + }, + { + "behavior": "node-readable-stream-input", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/node-stream-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "source-open:node-readable-stream:null", + "success", + "source-close" + ], + "eventKinds": [ + "source-open", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "inputSources", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 100 + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "uploadLengthDeferred", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "node-readable-stream" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "read-node-stream" + ], + "requests": [ + { + "absentHeaders": [ + "Upload-Length" + ], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/node-stream-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/node-stream-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": null, + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/node-stream-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": true, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "nodeReadableStreamInput", + "runtimes": [ + "node" + ] + }, + { + "behavior": "node-path-input", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/node-path-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "source-open:node-path-reference:11", + "success", + "source-close" + ], + "eventKinds": [ + "source-open", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "inputSources", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "node-path-reference" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "read-node-file" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/node-path-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/node-path-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/node-path-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "nodePathInput", + "runtimes": [ + "node" + ] + }, + { + "behavior": "deferred-length-upload", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/deferred-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close" + ], + "eventKinds": [ + "upload-url-available", + "progress", + "chunk-complete", + "success", + "source-close" + ], + "eventPolicy": { + "deferredLengthBytesTotal": "allow-known-total-before-declaration", + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "deferredLengthUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 100 + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "uploadLengthDeferred", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "web-readable-stream" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "defer-upload-length", + "emit-progress" + ], + "requests": [ + { + "absentHeaders": [ + "Upload-Length" + ], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/deferred-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/deferred-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/deferred-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": true, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "deferredLengthUpload" + }, + { + "behavior": "deferred-length-upload", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/deferred-chunked-contract", + "eventKeyAlternativeGroups": [ + [], + [ + "progress:0:11" + ], + [ + "progress:5:11" + ], + [ + "chunk-complete:5:5:11" + ], + [ + "progress:5:11" + ], + [ + "progress:10:11" + ], + [ + "chunk-complete:5:10:11" + ], + [], + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "upload-url-available", + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close" + ], + "eventKinds": [ + "upload-url-available", + "progress", + "chunk-complete", + "success", + "source-close" + ], + "eventPolicy": { + "deferredLengthBytesTotal": "allow-known-total-before-declaration", + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [], + "featureId": "deferredLengthUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 5 + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "uploadLengthDeferred", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "defer-upload-length", + "emit-chunk-complete", + "emit-progress" + ], + "requests": [ + { + "absentHeaders": [ + "Upload-Length" + ], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/deferred-chunked-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/deferred-chunked-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Defer-Length": "1", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 5, + "bodyStart": 0, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "5", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/deferred-chunked-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 5, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "10" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "10", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/deferred-chunked-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 1, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "10" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-final-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 3, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "10" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/deferred-chunked-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "deferredLengthChunkedUpload" + }, + { + "behavior": "override-patch-method", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/override-contract", + "eventKeyAlternativeGroups": [], + "eventKeyExtraPrefixes": [], + "eventKeys": [], + "eventKinds": [], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "overridePatchMethod", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "overridePatchMethod", + "value": true + }, + { + "key": "uploadUrl", + "value": "https://tus.io/uploads/override-contract" + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "override-patch-method" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "getTusUploadOffset", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Offset": "3" + }, + "headersSpecified": true, + "statusCode": 200, + "effectiveHeaders": { + "Upload-Length": "11", + "Upload-Offset": "3", + "Tus-Resumable": "1.0.0" + } + }, + "role": "recover-upload-offset", + "uploadUrl": "https://tus.io/uploads/override-contract", + "url": "upload", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + }, + "effectiveMethod": "HEAD", + "expectedUrl": "https://tus.io/uploads/override-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 8, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "3" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": "https://tus.io/uploads/override-contract", + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "3", + "X-HTTP-Method-Override": "PATCH" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads/override-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": true, + "value": "contract-override-fingerprint" + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "overridePatchMethod" + }, + { + "behavior": "parallel-upload-concat", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/parallel-final", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [ + "progress:" + ], + "eventKeys": [ + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11" + ], + "eventKinds": [ + "progress", + "chunk-complete" + ], + "eventPolicy": { + "matching": "exact-except-allowed-extra-events", + "progress": "milestone", + "transportProgress": "may-emit-extra-samples" + }, + "executionActionPhases": [ + { + "actions": [ + { + "gateId": "parallel-patches", + "heldRequestIndexes": [ + 2, + 3 + ], + "kind": "release-after-all-started", + "releaseAfterRequestIndexes": [ + 2, + 3 + ], + "timeoutMs": 2000 + } + ], + "phase": "serverRequestGates" + } + ], + "featureId": "parallelUploadConcat", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "foo": "hello" + } + }, + { + "key": "metadataForPartialUploads", + "value": { + "test": "world" + } + }, + { + "key": "parallelUploads", + "value": 2 + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "createTusUpload" + ], + "primitives": [ + "concatenate-partial-uploads", + "emit-progress" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/parallel-part-1" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/parallel-part-1", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-partial-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "5" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "6" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/parallel-part-2" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/parallel-part-2", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-partial-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "6" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 5, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "5", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-partial-chunk", + "uploadUrl": "https://tus.io/uploads/parallel-part-1", + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/parallel-part-1" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 6, + "bodyStart": 5, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "6" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "6", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-partial-chunk", + "uploadUrl": "https://tus.io/uploads/parallel-part-2", + "url": "upload", + "requestIndex": 3, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/parallel-part-2" + }, + { + "absentHeaders": [ + "Upload-Length" + ], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Metadata": "foo aGVsbG8=", + "Upload-Concat": "final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/parallel-final" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/parallel-final", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-final-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 4, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Metadata": "foo aGVsbG8=", + "Upload-Concat": "final;https://tus.io/uploads/parallel-part-1 https://tus.io/uploads/parallel-part-2" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "parallelUploadConcat" + }, + { + "behavior": "parallel-upload-abort-cleanup", + "completionKind": "aborted", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [ + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "request-abort:3" + ], + "eventKinds": [ + "request-abort" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [ + { + "actions": [ + { + "gateId": "parallel-cleanup-patches", + "heldRequestIndexes": [ + 2, + 3 + ], + "kind": "release-after-all-started", + "releaseAfterRequestIndexes": [ + 2, + 3 + ], + "timeoutMs": 2000 + } + ], + "phase": "serverRequestGates" + } + ], + "featureId": "parallelUploadConcat", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadataForPartialUploads", + "value": { + "test": "world" + } + }, + { + "key": "headers", + "value": { + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + } + }, + { + "key": "overridePatchMethod", + "value": true + }, + { + "key": "parallelUploads", + "value": 2 + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload" + ], + "primitives": [ + "abort-current-request", + "terminate-upload", + "concatenate-partial-uploads" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "5", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/parallel-cleanup-part-1" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/parallel-cleanup-part-1", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-partial-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "5", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "6", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/parallel-cleanup-part-2" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/parallel-cleanup-part-2", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-partial-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Metadata": "test d29ybGQ=", + "Upload-Concat": "partial", + "Upload-Length": "6", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 5, + "bodyStart": 0, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 500, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-partial-chunk", + "uploadUrl": "https://tus.io/uploads/parallel-cleanup-part-1", + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + "X-HTTP-Method-Override": "PATCH" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads/parallel-cleanup-part-1" + }, + { + "absentHeaders": [], + "abort": true, + "bodySize": 6, + "bodyStart": 5, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": null, + "role": "upload-partial-chunk", + "uploadUrl": "https://tus.io/uploads/parallel-cleanup-part-2", + "url": "upload", + "requestIndex": 3, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + "X-HTTP-Method-Override": "PATCH" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads/parallel-cleanup-part-2" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "terminateTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 204, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "terminate-upload", + "uploadUrl": "https://tus.io/uploads/parallel-cleanup-part-1", + "url": "upload", + "requestIndex": 4, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "effectiveMethod": "DELETE", + "expectedUrl": "https://tus.io/uploads/parallel-cleanup-part-1" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "terminateTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 204, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "terminate-upload", + "uploadUrl": "https://tus.io/uploads/parallel-cleanup-part-2", + "url": "upload", + "requestIndex": 5, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123" + }, + "effectiveMethod": "DELETE", + "expectedUrl": "https://tus.io/uploads/parallel-cleanup-part-2" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": true + }, + "fingerprint": { + "install": true, + "value": "contract-parallel-cleanup-fingerprint" + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "parallelUploadAbortCleanup" + }, + { + "behavior": "retry-patch-after-offset-recovery", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/retry-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0" + ], + "eventKinds": [ + "should-retry", + "retry-schedule" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "retryOffsetRecovery", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "retryDelays", + "value": [ + 0 + ] + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "retry-with-backoff", + "recover-offset-after-error" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/retry-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/retry-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 500, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/retry-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "getTusUploadOffset", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 200, + "effectiveHeaders": { + "Upload-Length": "11", + "Upload-Offset": "5", + "Tus-Resumable": "1.0.0" + } + }, + "role": "recover-upload-offset", + "uploadUrl": null, + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + }, + "effectiveMethod": "HEAD", + "expectedUrl": "https://tus.io/uploads/retry-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 6, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 500, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "retry-upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 3, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/retry-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "getTusUploadOffset", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 200, + "effectiveHeaders": { + "Upload-Length": "11", + "Upload-Offset": "5", + "Tus-Resumable": "1.0.0" + } + }, + "role": "recover-upload-offset", + "uploadUrl": null, + "url": "upload", + "requestIndex": 4, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + }, + "effectiveMethod": "HEAD", + "expectedUrl": "https://tus.io/uploads/retry-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 6, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-final-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 5, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "5" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/retry-contract" + } + ], + "retryDecisions": [ + { + "decision": true, + "retryAttempt": 0 + }, + { + "decision": true, + "retryAttempt": 0 + } + ], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "retryPatchAfterOffsetRecovery" + }, + { + "behavior": "request-lifecycle-hooks", + "completionKind": "success", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/request-hooks-contract", + "eventKeyAlternativeGroups": [ + [], + [], + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "before-request:0", + "after-response:0", + "success", + "source-close" + ], + "eventKinds": [ + "before-request", + "after-response", + "success", + "source-close" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [], + "featureId": "requestLifecycleHooks", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "uploadUrl", + "value": "https://tus.io/uploads/request-hooks-contract" + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "getTusUploadOffset" + ], + "primitives": [ + "run-request-hooks" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "getTusUploadOffset", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Offset": "11" + }, + "headersSpecified": true, + "statusCode": 200, + "effectiveHeaders": { + "Upload-Length": "11", + "Upload-Offset": "11", + "Tus-Resumable": "1.0.0" + } + }, + "role": "recover-upload-offset", + "uploadUrl": null, + "url": "upload", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + }, + "effectiveMethod": "HEAD", + "expectedUrl": "https://tus.io/uploads/request-hooks-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "requestLifecycleHooks" + }, + { + "behavior": "abort-upload", + "completionKind": "aborted", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": null, + "eventKeyAlternativeGroups": [ + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "request-abort:0" + ], + "eventKinds": [ + "request-abort" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [ + { + "actions": [ + { + "kind": "cancel-upload", + "requestIndex": 0 + } + ], + "phase": "onRequestStart" + } + ], + "featureId": "abortUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload" + ], + "primitives": [ + "abort-current-request" + ], + "requests": [ + { + "absentHeaders": [], + "abort": true, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": null, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "abortUpload" + }, + { + "behavior": "abort-upload-after-stored-url", + "completionKind": "aborted", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/abort-terminate-contract", + "eventKeyAlternativeGroups": [ + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "request-abort:1" + ], + "eventKinds": [ + "request-abort" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [ + { + "actions": [ + { + "kind": "cancel-upload", + "requestIndex": 1 + } + ], + "phase": "onRequestStart" + } + ], + "featureId": "abortUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "headers", + "value": { + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123" + } + }, + { + "key": "overridePatchMethod", + "value": true + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload", + "terminateTusUpload" + ], + "primitives": [ + "abort-current-request", + "terminate-upload" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/abort-terminate-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/abort-terminate-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": true, + "bodySize": 11, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": null, + "role": "abort-upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + "X-HTTP-Method-Override": "PATCH" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads/abort-terminate-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123" + }, + "headersSpecified": true, + "method": null, + "operationId": "terminateTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 204, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "terminate-upload", + "uploadUrl": null, + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123" + }, + "effectiveMethod": "DELETE", + "expectedUrl": "https://tus.io/uploads/abort-terminate-contract" + } + ], + "retryDecisions": [], + "runtimeSetup": { + "abort": { + "terminateUpload": true + }, + "fingerprint": { + "install": true, + "value": "contract-abort-terminate-fingerprint" + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "abortUploadAfterStoredUrl" + }, + { + "behavior": "terminate-with-retry", + "completionKind": "terminated", + "completionMessage": null, + "completionReason": null, + "completionUploadUrl": "https://tus.io/uploads/terminate-contract", + "eventKeyAlternativeGroups": [ + [], + [] + ], + "eventKeyExtraPrefixes": [], + "eventKeys": [ + "should-retry:0:true", + "retry-schedule:0" + ], + "eventKinds": [ + "should-retry", + "retry-schedule" + ], + "eventPolicy": { + "matching": "exact" + }, + "executionActionPhases": [ + { + "actions": [ + { + "kind": "abort-upload", + "terminateUpload": true + } + ], + "phase": "onChunkComplete" + } + ], + "featureId": "terminateUpload", + "inputOptionEntries": [ + { + "key": "endpointUrl", + "value": "https://tus.io/uploads" + }, + { + "key": "chunkSize", + "value": 5 + }, + { + "key": "metadata", + "value": { + "filename": "hello.txt" + } + }, + { + "key": "retryDelays", + "value": [ + 0, + 0 + ] + } + ], + "inputSource": { + "content": "hello world", + "kind": "blob" + }, + "operationIds": [ + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload" + ], + "primitives": [ + "terminate-upload", + "retry-with-backoff" + ], + "requests": [ + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "headersSpecified": true, + "method": null, + "operationId": "createTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Location": "https://tus.io/uploads/terminate-contract" + }, + "headersSpecified": true, + "statusCode": 201, + "effectiveHeaders": { + "Location": "https://tus.io/uploads/terminate-contract", + "Tus-Resumable": "1.0.0" + } + }, + "role": "create-upload", + "uploadUrl": null, + "url": "endpoint", + "requestIndex": 0, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Upload-Length": "11", + "Upload-Metadata": "filename aGVsbG8udHh0" + }, + "effectiveMethod": "POST", + "expectedUrl": "https://tus.io/uploads" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": 5, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "headersSpecified": true, + "method": null, + "operationId": "patchTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": { + "Upload-Offset": "5" + }, + "headersSpecified": true, + "statusCode": 204, + "effectiveHeaders": { + "Upload-Offset": "5", + "Tus-Resumable": "1.0.0" + } + }, + "role": "upload-chunk", + "uploadUrl": null, + "url": "upload", + "requestIndex": 1, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0", + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": "0" + }, + "effectiveMethod": "PATCH", + "expectedUrl": "https://tus.io/uploads/terminate-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "terminateTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 423, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "terminate-upload", + "uploadUrl": null, + "url": "upload", + "requestIndex": 2, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + }, + "effectiveMethod": "DELETE", + "expectedUrl": "https://tus.io/uploads/terminate-contract" + }, + { + "absentHeaders": [], + "abort": false, + "bodySize": null, + "bodyStart": null, + "errorMessage": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "method": null, + "operationId": "terminateTusUpload", + "response": { + "body": null, + "headerMode": null, + "headers": {}, + "headersSpecified": false, + "statusCode": 204, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + } + }, + "role": "retry-terminate-upload", + "uploadUrl": null, + "url": "upload", + "requestIndex": 3, + "effectiveHeaders": { + "Tus-Resumable": "1.0.0" + }, + "effectiveMethod": "DELETE", + "expectedUrl": "https://tus.io/uploads/terminate-contract" + } + ], + "retryDecisions": [ + { + "decision": true, + "retryAttempt": 0 + } + ], + "runtimeSetup": { + "abort": { + "terminateUpload": false + }, + "fingerprint": { + "install": false, + "value": null + }, + "requestId": { + "enabled": false, + "generatedRequestId": null + }, + "urlStorage": { + "install": false, + "storedUpload": null + } + }, + "scenarioId": "terminateWithRetry" + } + ], + "clientFeatures": [ + { + "conformance": { + "scenarioIds": [ + "singleUploadLifecycle" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Create an upload, store its URL, upload bytes, and finish successfully.", + "featureId": "singleUploadLifecycle", + "flow": [ + { + "kind": "primitive", + "primitive": "open-input-source", + "summary": "Open the caller input as a sliceable source." + }, + { + "kind": "operation", + "operationId": "createTusUpload", + "summary": "Create the remote upload resource." + }, + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Upload bytes until the accepted offset reaches the known length." + } + ], + "operationIds": [ + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request" + ], + "sdkPlan": { + "kind": "http-sequence", + "requests": [ + { + "body": { + "kind": "empty" + }, + "headers": [ + { + "kind": "compatibility-version" + }, + { + "contentInput": "content", + "kind": "upload-length" + }, + { + "kind": "metadata", + "metadataInput": "metadata" + }, + { + "kind": "upload-complete", + "state": "incomplete" + } + ], + "kind": "request", + "operationRole": "creation", + "requestId": "create", + "response": { + "captures": [ + { + "captureId": "uploadUrl", + "headerRef": "location", + "kind": "header-url", + "resolveRelativeToInput": "endpointUrl" + } + ] + }, + "url": { + "input": "endpointUrl", + "kind": "input-url" + } + }, + { + "body": { + "contentInput": "content", + "kind": "bytes" + }, + "headers": [ + { + "kind": "compatibility-version" + }, + { + "kind": "upload-offset", + "value": "0" + }, + { + "kind": "upload-body-content-type" + }, + { + "kind": "upload-complete", + "state": "complete" + } + ], + "kind": "request", + "operationRole": "upload-chunk", + "requestId": "upload", + "response": { + "assertions": [ + { + "contentInput": "content", + "headerRef": "uploadOffset", + "kind": "header-int-equals-content-length" + } + ] + }, + "url": { + "captureId": "uploadUrl", + "kind": "captured-url" + } + } + ] + } + }, + { + "conformance": { + "scenarioIds": [ + "resumeFromPreviousUpload" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Resume a stored upload URL by discovering the remote offset before patching.", + "featureId": "resumeUpload", + "flow": [ + { + "kind": "primitive", + "primitive": "resume-from-previous-upload", + "summary": "Load a stored upload URL selected by fingerprint." + }, + { + "kind": "operation", + "operationId": "getTusUploadOffset", + "summary": "Read the server offset for the stored upload URL." + }, + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Continue uploading from the discovered offset." + } + ], + "operationIds": [ + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url" + ], + "sdkPlan": { + "kind": "http-sequence", + "requests": [ + { + "body": { + "kind": "empty" + }, + "headers": [ + { + "kind": "compatibility-version" + } + ], + "kind": "request", + "operationRole": "offset-discovery", + "requestId": "offset", + "response": { + "captures": [ + { + "captureId": "resumeOffset", + "headerRef": "uploadOffset", + "kind": "header-int" + } + ] + }, + "url": { + "input": "storedUploadUrl", + "kind": "input-url" + } + }, + { + "body": { + "contentInput": "content", + "kind": "bytes-from-offset", + "offsetCaptureId": "resumeOffset" + }, + "headers": [ + { + "kind": "compatibility-version" + }, + { + "captureId": "resumeOffset", + "kind": "upload-offset-from-capture" + }, + { + "kind": "upload-body-content-type" + }, + { + "kind": "upload-complete", + "state": "complete" + } + ], + "kind": "request", + "operationRole": "upload-chunk", + "requestId": "upload", + "response": { + "assertions": [ + { + "contentInput": "content", + "headerRef": "uploadOffset", + "kind": "header-int-equals-content-length" + } + ] + }, + "url": { + "input": "storedUploadUrl", + "kind": "input-url" + } + } + ] + } + }, + { + "conformance": { + "scenarioIds": [ + "deferredLengthUpload", + "deferredLengthChunkedUpload" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Create an upload without a known length and declare the length on the final upload request.", + "featureId": "deferredLengthUpload", + "flow": [ + { + "kind": "operation", + "operationId": "createTusUpload", + "summary": "Create the upload with deferred length." + }, + { + "kind": "primitive", + "primitive": "defer-upload-length", + "summary": "Track the source until the final upload request reveals the total size." + }, + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Declare Upload-Length on the final upload request." + } + ], + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "defer-upload-length", + "emit-chunk-complete", + "emit-progress" + ] + }, + { + "conformance": { + "scenarioIds": [ + "creationWithUpload", + "creationWithUploadPartialChunk" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Send the first bytes on the creation request when the server/client support it.", + "featureId": "creationWithUpload", + "flow": [ + { + "kind": "operation", + "operationId": "createTusUpload", + "summary": "Create the upload while streaming the initial body." + }, + { + "kind": "primitive", + "primitive": "upload-during-creation", + "summary": "Interpret the creation response as an accepted offset." + } + ], + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "upload-during-creation", + "emit-progress" + ] + }, + { + "conformance": { + "scenarioIds": [ + "uploadBodyHeaders" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Send protocol-specific upload body headers whenever the client transmits file bytes.", + "featureId": "uploadBodyHeaders", + "flow": [ + { + "kind": "primitive", + "primitive": "send-upload-body-headers", + "summary": "Attach the protocol-specific upload body content type when a request has bytes." + }, + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Upload bytes with the protocol-specific body headers." + } + ], + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "send-upload-body-headers" + ] + }, + { + "conformance": { + "scenarioIds": [ + "customRequestHeaders" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Apply user-provided request headers to every upload request.", + "featureId": "customRequestHeaders", + "flow": [ + { + "kind": "primitive", + "primitive": "apply-custom-request-headers", + "summary": "Merge user-provided headers after protocol headers are prepared." + }, + { + "kind": "operation", + "operationId": "createTusUpload", + "summary": "Create uploads with the configured custom headers." + }, + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Upload bytes with the configured custom headers." + } + ], + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "apply-custom-request-headers" + ] + }, + { + "conformance": { + "scenarioIds": [ + "requestIdHeaders" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Add generated request IDs after protocol and custom request headers.", + "featureId": "requestIdHeaders", + "flow": [ + { + "kind": "primitive", + "primitive": "add-request-id-header", + "summary": "Generate a request ID and apply it after custom request headers so it is authoritative." + }, + { + "kind": "operation", + "operationId": "createTusUpload", + "summary": "Create uploads with a generated request ID." + }, + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Upload bytes with a generated request ID." + } + ], + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "add-request-id-header", + "apply-custom-request-headers" + ] + }, + { + "conformance": { + "scenarioIds": [ + "overridePatchMethod" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Tunnel PATCH through POST with the method-override header.", + "featureId": "overridePatchMethod", + "flow": [ + { + "kind": "operation", + "operationId": "getTusUploadOffset", + "summary": "Resume from the upload URL before sending bytes." + }, + { + "kind": "primitive", + "primitive": "override-patch-method", + "summary": "Replace PATCH with POST while preserving the protocol operation intent." + }, + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Upload bytes through the overridden request." + } + ], + "operationIds": [ + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "override-patch-method" + ] + }, + { + "conformance": { + "scenarioIds": [ + "parallelUploadConcat", + "parallelUploadAbortCleanup" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.", + "featureId": "parallelUploadConcat", + "flow": [ + { + "kind": "primitive", + "primitive": "split-parallel-upload-boundaries", + "summary": "Split the input into stable byte ranges." + }, + { + "kind": "operation", + "operationId": "createTusUpload", + "summary": "Create partial uploads for each range." + }, + { + "kind": "primitive", + "primitive": "concatenate-partial-uploads", + "summary": "Create the final upload from completed partial upload URLs." + } + ], + "operationIds": [ + "createTusUpload", + "patchTusUpload" + ], + "primitives": [ + "abort-current-request", + "concatenate-partial-uploads", + "emit-progress", + "split-parallel-upload-boundaries", + "terminate-upload" + ] + }, + { + "conformance": { + "scenarioIds": [ + "retryPatchAfterOffsetRecovery" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Recover from a failed chunk by reading the server offset before retrying.", + "featureId": "retryOffsetRecovery", + "flow": [ + { + "kind": "operation", + "operationId": "patchTusUpload", + "summary": "Attempt the chunk upload." + }, + { + "kind": "primitive", + "primitive": "recover-offset-after-error", + "summary": "Discover the accepted offset after a retryable failure." + }, + { + "kind": "operation", + "operationId": "getTusUploadOffset", + "summary": "Use HEAD to recover the offset before retrying PATCH." + } + ], + "operationIds": [ + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "retry-with-backoff", + "recover-offset-after-error" + ] + }, + { + "conformance": { + "scenarioIds": [ + "retryPatchAfterOffsetRecovery" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Schedule retry timers and reset retry attempts after accepted progress.", + "featureId": "retryStateTransitions", + "flow": [ + { + "kind": "primitive", + "primitive": "schedule-retry-timer", + "summary": "Consume the current retry delay and restart the upload after that timer fires." + }, + { + "kind": "primitive", + "primitive": "reset-retry-attempt-after-progress", + "summary": "Reset retry attempts once a later retry observes server-side offset progress." + } + ], + "operationIds": [ + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "retry-with-backoff", + "schedule-retry-timer", + "reset-retry-attempt-after-progress" + ] + }, + { + "conformance": { + "scenarioIds": [ + "terminateWithRetry" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Terminate an upload resource and retry retryable termination failures.", + "featureId": "terminateUpload", + "flow": [ + { + "kind": "primitive", + "primitive": "terminate-upload", + "summary": "Choose server-side termination for an upload URL." + }, + { + "kind": "operation", + "operationId": "terminateTusUpload", + "summary": "Delete the upload resource." + } + ], + "operationIds": [ + "terminateTusUpload" + ], + "primitives": [ + "terminate-upload", + "retry-with-backoff" + ] + }, + { + "conformance": { + "scenarioIds": [ + "abortUpload", + "abortUploadAfterStoredUrl" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Abort the active request, pending retry timer, and any partial uploads.", + "featureId": "abortUpload", + "flow": [ + { + "kind": "primitive", + "primitive": "abort-current-request", + "summary": "Cancel in-flight transport work without emitting user callbacks after abort." + } + ], + "operationIds": [ + "terminateTusUpload" + ], + "primitives": [ + "abort-current-request", + "terminate-upload" + ] + }, + { + "conformance": { + "scenarioIds": [ + "singleUploadLifecycle", + "creationWithUpload", + "resumeFromPreviousUpload" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Expose progress and accepted-chunk callbacks from runtime upload activity.", + "featureId": "uploadCallbacks", + "flow": [ + { + "kind": "primitive", + "primitive": "emit-progress", + "summary": "Report bytes sent against known or deferred length." + }, + { + "kind": "primitive", + "primitive": "emit-chunk-complete", + "summary": "Report chunk size, accepted offset, and total size after server acceptance." + }, + { + "kind": "primitive", + "primitive": "emit-upload-url", + "summary": "Notify once a usable upload URL is known." + } + ], + "operationIds": [], + "primitives": [ + "emit-progress", + "emit-chunk-complete", + "emit-upload-url" + ] + }, + { + "conformance": { + "scenarioIds": [ + "requestLifecycleHooks", + "retryPatchAfterOffsetRecovery" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Run before-request, after-response, and custom retry hooks around transport.", + "featureId": "requestLifecycleHooks", + "flow": [ + { + "kind": "primitive", + "primitive": "run-request-hooks", + "summary": "Call user hooks around each HTTP request/response pair." + }, + { + "kind": "primitive", + "primitive": "customize-retry", + "summary": "Let user retry policy override default retry decisions." + } + ], + "operationIds": [], + "primitives": [ + "customize-retry", + "run-request-hooks" + ] + }, + { + "conformance": { + "scenarioIds": [ + "singleUploadLifecycle", + "resumeFromPreviousUpload" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Persist, find, resume, and optionally remove upload URLs by fingerprint.", + "featureId": "resumeUrlStorage", + "flow": [ + { + "kind": "primitive", + "primitive": "fingerprint-input", + "summary": "Derive a stable key for the input when possible." + }, + { + "kind": "primitive", + "primitive": "store-resume-url", + "summary": "Persist upload URLs and partial-upload URLs for future resumption." + }, + { + "kind": "primitive", + "primitive": "remove-stored-url-on-success", + "summary": "Remove stored upload URLs when configured after success or invalidation." + } + ], + "operationIds": [], + "primitives": [ + "fingerprint-input", + "store-resume-url", + "remove-stored-url-on-success" + ] + }, + { + "conformance": { + "scenarioIds": [ + "arrayBufferInput", + "arrayBufferViewInput", + "webReadableStreamInput", + "nodeReadableStreamInput", + "nodePathInput" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Support the reference client input/source families across runtimes.", + "featureId": "inputSources", + "flow": [ + { + "kind": "primitive", + "primitive": "read-browser-file", + "summary": "Read browser Blob/File and ArrayBuffer-family inputs." + }, + { + "kind": "primitive", + "primitive": "read-node-stream", + "summary": "Read Node streams when size and chunk constraints are satisfied." + }, + { + "kind": "primitive", + "primitive": "read-web-stream", + "summary": "Read Web Streams with deferred or configured size." + }, + { + "kind": "primitive", + "primitive": "read-node-file", + "summary": "Read filesystem paths and fs streams, including parallel ranges." + } + ], + "operationIds": [], + "primitives": [ + "read-browser-file", + "read-node-file", + "read-node-stream", + "read-web-stream" + ] + }, + { + "conformance": { + "scenarioIds": [ + "webStorageUrlStorageBackend", + "fileUrlStorageBackend" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Support browser and file-backed URL storage implementations.", + "featureId": "urlStorageBackends", + "flow": [ + { + "kind": "primitive", + "primitive": "store-browser-url", + "summary": "Persist upload records in browser localStorage." + }, + { + "kind": "primitive", + "primitive": "store-file-url", + "summary": "Persist upload records in the Node file store." + } + ], + "operationIds": [], + "primitives": [ + "store-browser-url", + "store-file-url" + ] + }, + { + "conformance": { + "scenarioIds": [ + "ietfDraft05CreationWithUpload", + "ietfDraft05ChunkedUploadComplete", + "ietfDraft03ResumeWithoutKnownLength" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Select between tus v1 and supported IETF draft client protocol modes.", + "featureId": "protocolVersionSelection", + "flow": [ + { + "kind": "primitive", + "primitive": "select-client-protocol", + "summary": "Choose request headers and response expectations for the selected protocol." + } + ], + "operationIds": [ + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload" + ], + "primitives": [ + "select-client-protocol" + ] + }, + { + "conformance": { + "scenarioIds": [ + "relativeLocationResolution" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Normalize relative Location headers against the request endpoint.", + "featureId": "relativeLocationResolution", + "flow": [ + { + "kind": "primitive", + "primitive": "resolve-relative-location", + "summary": "Resolve server Location headers with the creation endpoint as origin." + } + ], + "operationIds": [ + "createTusUpload" + ], + "primitives": [ + "resolve-relative-location" + ] + }, + { + "conformance": { + "scenarioIds": [ + "startValidationMissingInput", + "startValidationMissingEndpointOrUploadUrl", + "startValidationUnsupportedProtocol", + "startValidationRetryDelaysNotArray", + "startValidationParallelUploadsWithUploadUrl", + "startValidationParallelUploadsWithUploadSize", + "startValidationParallelUploadsWithDeferredLength", + "startValidationParallelUploadsWithUploadDataDuringCreation", + "startValidationParallelBoundariesWithoutParallelUploads", + "startValidationParallelBoundariesLengthMismatch" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Validate option combinations before starting runtime work.", + "featureId": "startOptionValidation", + "flow": [ + { + "kind": "primitive", + "primitive": "validate-start-options", + "summary": "Reject missing inputs and incompatible parallel/deferred/resume options." + } + ], + "operationIds": [], + "primitives": [ + "validate-start-options" + ] + }, + { + "conformance": { + "scenarioIds": [ + "detailedCreateResponseError", + "detailedCreateRequestError" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Attach request, response, status, body, and request ID context to errors.", + "featureId": "detailedErrors", + "flow": [ + { + "kind": "primitive", + "primitive": "report-detailed-errors", + "summary": "Return user-facing errors with enough transport context for debugging." + } + ], + "operationIds": [], + "primitives": [ + "report-detailed-errors" + ] + } + ], + "clientFlow": { + "abort": { + "error": { + "message": "Request was aborted", + "name": "AbortError", + "type": "DOMException" + }, + "removeStoredUrlAfterTermination": "after-successful-termination", + "sequence": [ + "mark-aborted", + "abort-parallel-uploads", + "abort-current-request", + "clear-retry-timer", + "terminate-upload-if-requested" + ], + "suppressErrorAfterAbort": true, + "terminateUpload": "when-requested-and-upload-url-known", + "terminateUploadContext": "detached-from-aborted-request" + }, + "creationWithUpload": { + "bodySource": "first-upload-chunk", + "completion": "continue-with-patch-when-offset-less-than-size", + "extensionName": "creation-with-upload", + "responseOffset": "accepted-offset" + }, + "deferredLength": { + "createSize": "size-unknown", + "declareLength": "final-upload-request", + "extensionName": "creation-defer-length" + }, + "detailedErrors": { + "causeStringTemplate": "Error: {message}", + "causedByTemplate": ", caused by {cause}", + "emptyResponseBody": "", + "missingValue": "n/a", + "requestContextTemplate": ", originated from request (method: {method}, url: {url}, response code: {status}, response text: {body}, request id: {requestId})" + }, + "eventHooks": { + "chunkComplete": { + "afterChunkAccepted": "accepted-chunk-size-and-offset" + }, + "progress": { + "afterChunkAccepted": "accepted-offset", + "afterResumeAlreadyComplete": "upload-length", + "beforeRequestBody": "current-offset", + "duringRequest": "start-offset-plus-transmitted-bytes", + "parallelPartProgress": "aggregated-part-progress" + }, + "success": { + "closeSource": "after-hook-when-source-open", + "emit": "after-upload-complete", + "removeStoredUrl": "before-hook-when-option-enabled" + }, + "uploadUrlAvailable": { + "contexts": { + "createUpload": "createUpload", + "parallelFinalUpload": "parallelFinalUpload", + "resumeUpload": "resumeUpload" + }, + "createUpload": "after-url-known-before-storage", + "parallelFinalUpload": "not-emitted", + "resumeUpload": "after-url-known-before-storage" + } + }, + "fileSources": { + "commonTypes": [ + "File", + "Blob", + "ArrayBuffer", + "SharedArrayBuffer", + "ArrayBufferView", + "ReadableStream (Web Streams)" + ], + "messages": { + "cordovaInvalidArrayBufferResult": "invalid result types for readAsArrayBuffer: {resultType}", + "nodeStreamBackwardsRead": "cannot slice from position which we already seeked away", + "nodeStreamChunkSizeRequired": "cannot create source for stream without a finite value for the `chunkSize` option; specify a chunkSize to control the memory consumption", + "nodeStreamStartOutsideBuffer": "slice start is outside of buffer (currently not implemented)", + "unsupportedSourceType": "in this environment the source object may only be an instance of: {supportedTypes}", + "webStreamAlreadyLocked": "Readable stream is already locked to reader. tus-js-client cannot obtain a new reader.", + "webStreamBackwardsRead": "Requested data is before the reader's current offset", + "webStreamChunkSizeRequired": "cannot create source for stream without a finite value for the `chunkSize` option", + "webStreamMissingBuffer": "cannot _getDataFromBuffer because _buffer is unset", + "webStreamUnknownDataType": "Unknown data type" + }, + "nodeExtraTypes": [ + "fs.ReadStream (Node.js)", + "stream.Readable (Node.js)" + ] + }, + "fingerprints": { + "browserBlob": { + "fields": [ + "prefix", + "name", + "type", + "size", + "lastModified", + "endpoint" + ], + "prefix": "tus-br", + "separator": "-" + }, + "nodeBuffer": { + "fields": [ + "prefix", + "contentHash", + "size", + "endpoint" + ], + "hashAlgorithm": "md5", + "prefix": "node-buffer", + "sampleBytes": 65536, + "separator": "-" + }, + "nodeFile": { + "conformanceFixture": { + "absolutePath": "/tmp/tus-contract-file.bin", + "mtimeMs": 1700000000123 + }, + "fields": [ + "prefix", + "absolutePath", + "size", + "mtimeMs", + "endpoint" + ], + "path": "absolute", + "prefix": "node-file", + "separator": "-" + }, + "reactNative": { + "emptyName": "noname", + "emptySize": "nosize", + "exifHash": "javascript-string-hash-code", + "fields": [ + "prefix", + "name", + "size", + "exifHash", + "endpoint" + ], + "noExif": "noexif", + "prefix": "tus-rn", + "separator": "/" + }, + "unsupportedInput": "null" + }, + "httpStacks": { + "browserStackNodeReadableBody": "unsupported", + "messages": { + "browserStackNodeReadableBodyUnsupported": "Using a Node.js readable stream as HTTP request body is not supported using the {stackName} HTTP stack.", + "nodeStackMissingStatusCode": "no status code available yet", + "nodeStackUnsupportedBodyType": "Unsupported HTTP request body type in Node.js HTTP stack: {bodyType} (constructor: {constructorName})" + }, + "nodeStackMissingStatusCode": "throw", + "progressThrottle": { + "leading": true, + "milliseconds": 100, + "trailing": false + }, + "nodeStackUnsupportedBodyType": "throw" + }, + "locationResolution": { + "strategy": "relative-to-creation-request-url" + }, + "messages": { + "configuredUploadSizeMismatch": "upload was configured with a size of {expectedSize} bytes, but the source is done after {actualSize} bytes", + "cannotDeriveUploadSize": "tus: cannot automatically derive upload's size from input. Specify it manually using the `uploadSize` option or use the `uploadLengthDeferred` option", + "createMissingEndpoint": "tus: unable to create upload because no endpoint is provided", + "createMissingSize": "tus: expected _size to be set", + "createUploadRequestFailed": "tus: failed to create upload", + "createdUpload": "Created upload at {uploadUrl}", + "finalUploadMissingPartialUrls": "tus: Expected _parallelUploadUrls to be set", + "finalUploadRequestFailed": "tus: failed to concatenate parallel uploads", + "fingerprintCalculated": "Calculated fingerprint: {fingerprint}", + "fingerprintUnavailable": "tus: unable to calculate fingerprint for this input file", + "fingerprintUnavailableForStorage": "No fingerprint was calculated meaning that the upload cannot be stored in the URL storage.", + "invalidUploadSize": "tus: cannot convert `uploadSize` option into a number", + "invalidChunkOffset": "tus: invalid or missing offset value", + "invalidResumeLength": "tus: invalid or missing length value", + "invalidResumeOffset": "tus: invalid Upload-Offset header", + "lockedUpload": "tus: upload is currently locked; retry later", + "nonErrorThrownValue": "tus: value thrown that is not an error: {value}", + "missingEndpointOrUploadUrl": "tus: neither an endpoint or an upload URL is provided", + "missingInput": "tus: no file or stream to upload provided", + "missingPatchUrl": "tus: Expected url to be set", + "missingResumeOffset": "tus: missing Upload-Offset header", + "removedResumeOption": "tus: The `resume` option has been removed in tus-js-client v2. Please use the URL storage API instead.", + "parallelBoundariesLengthMismatch": "tus: the `parallelUploadBoundaries` must have the same length as the value of `parallelUploads`", + "parallelBoundariesWithoutParallelUploads": "tus: cannot use the `parallelUploadBoundaries` option when `parallelUploads` is disabled", + "parallelUploadMissingSize": "tus: Expected _size to be set", + "parallelUploadsWithDeferredLength": "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled", + "parallelUploadsWithUploadDataDuringCreation": "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled", + "parallelUploadsWithUploadSize": "tus: cannot use the `uploadSize` option when parallelUploads is enabled", + "parallelUploadsWithUploadUrl": "tus: cannot use the `uploadUrl` option when parallelUploads is enabled", + "parallelUploadSliceMissingValue": "tus: no value returned while slicing file for parallel uploads", + "reactNativeUriBlobFetchFailed": "tus: cannot fetch `file.uri` as Blob, make sure the uri is correct and accessible. {error}", + "reactNativeUriUnsupported": "tus: file objects with `uri` property is only supported in React Native", + "resumeUploadRequestFailed": "tus: failed to resume upload", + "resumeWithoutEndpoint": "tus: unable to resume upload (new upload cannot be created without an endpoint)", + "retryDelaysNotArray": "tus: the `retryDelays` option must either be an array or null", + "storageMissingParallelUploadUrls": "tus: cannot store parallel upload because no partial upload URLs are available", + "storageMissingUploadUrl": "tus: cannot store upload because no upload URL is available", + "terminateUploadRequestFailed": "tus: failed to terminate upload", + "unexpectedChunkResponse": "tus: unexpected response while uploading chunk", + "unexpectedCreateResponse": "tus: unexpected response while creating upload", + "unexpectedResumeResponse": "tus: unexpected response while resuming upload", + "unexpectedTerminateResponse": "tus: unexpected response while terminating upload", + "uploadChunkRequestFailed": "tus: failed to upload chunk at offset {offset}", + "uploadLocationMissing": "tus: invalid or missing Location header", + "unsupportedProtocolPrefix": "tus: unsupported protocol " + }, + "minimumParallelUploads": 2, + "optionDefaults": { + "addRequestId": false, + "chunkSize": { + "kind": "unbounded" + }, + "headers": {}, + "metadata": {}, + "metadataForPartialUploads": {}, + "overridePatchMethod": false, + "parallelUploads": 1, + "removeFingerprintOnSuccess": false, + "retryDelays": [ + 0, + 1000, + 3000, + 5000 + ], + "storeFingerprintForResuming": true, + "uploadDataDuringCreation": false, + "uploadLengthDeferred": false + }, + "parallelPartialUpload": { + "headerKind": "partial-upload", + "metadataSource": "metadataForPartialUploads", + "nestedParallelUploads": "disabled", + "urlStorage": "parent-managed" + }, + "parallelUploadCleanup": { + "onPartError": "terminate-created-partials-when-abort-termination-enabled", + "returnedError": "original-error-unless-cleanup-fails" + }, + "parallelUploadExecution": { + "cancelRemainingOnPartError": true, + "resultOrder": "part-index", + "sourceRead": "before-worker-start", + "workerStrategy": "one-worker-per-part" + }, + "parallelUploadSplit": { + "strategy": "contiguous-floor-size-last-remainder" + }, + "requestHeaders": { + "layers": [ + "operation", + "custom", + "request-id" + ], + "requestIdSource": "sdk-generated-uuid" + }, + "requestLifecycle": { + "hooks": { + "afterResponse": "after-successful-transport-response", + "beforeRequest": "before-transport-send" + }, + "retry": { + "attemptCounter": { + "increment": "after-retry-scheduled", + "reset": "when-offset-advanced-since-last-retry" + }, + "customDecision": "custom-callback-before-default-decision", + "delaySource": "retry-delays-indexed-by-attempt", + "defaultDecision": "retryable-status-and-online", + "error": { + "retryableWhen": "request-context-present" + }, + "evaluationTrigger": "generated-plan-evaluate-policy", + "failure": { + "exhaustedDelays": "emit-error", + "nonRetryableError": "emit-error", + "policyRejected": "emit-error" + }, + "onlineSignal": { + "defaultWhenUnavailable": true, + "offlineWhenPlatformOnlineIsFalse": true, + "source": "sdk-platform-online-status" + }, + "timer": { + "restart": "start-upload-after-delay", + "source": "sdk-platform-timer" + } + } + }, + "runtimeSetup": { + "fingerprintOverrideInputKinds": [ + "node-readable-stream", + "web-readable-stream" + ], + "urlStorageEventKinds": [ + "url-storage-add", + "url-storage-find", + "url-storage-remove" + ] + }, + "startValidation": { + "conformanceInputFingerprint": { + "kind": "scenario-id-prefix", + "prefix": "contract-" + }, + "parallelOptionValidationReasons": [ + "parallelUploadsWithDeferredLength", + "parallelUploadsWithUploadDataDuringCreation" + ], + "rules": [ + { + "message": { + "key": "missingInput", + "kind": "client-flow-message" + }, + "predicate": { + "equals": false, + "input": "hasFile", + "kind": "boolean-input" + }, + "reason": "missingInput" + }, + { + "message": { + "input": "protocol", + "key": "unsupportedProtocolPrefix", + "kind": "client-flow-message-with-input-suffix" + }, + "predicate": { + "equals": false, + "input": "protocol", + "kind": "supported-protocol" + }, + "reason": "unsupportedProtocol" + }, + { + "message": { + "key": "missingEndpointOrUploadUrl", + "kind": "client-flow-message" + }, + "predicate": { + "kind": "all", + "predicates": [ + { + "equals": false, + "input": "hasEndpoint", + "kind": "boolean-input" + }, + { + "equals": false, + "input": "hasUploadUrl", + "kind": "boolean-input" + }, + { + "equals": false, + "input": "hasCurrentUrl", + "kind": "boolean-input" + } + ] + }, + "reason": "missingEndpointOrUploadUrl" + }, + { + "message": { + "key": "retryDelaysNotArray", + "kind": "client-flow-message" + }, + "predicate": { + "equals": false, + "input": "retryDelays", + "kind": "array-or-null" + }, + "reason": "retryDelaysNotArray" + }, + { + "message": { + "key": "parallelUploadsWithUploadUrl", + "kind": "client-flow-message" + }, + "predicate": { + "kind": "all", + "predicates": [ + { + "input": "parallelUploads", + "kind": "number-input-gte-client-flow-value", + "value": "minimumParallelUploads" + }, + { + "equals": true, + "input": "hasUploadUrl", + "kind": "boolean-input" + } + ] + }, + "reason": "parallelUploadsWithUploadUrl" + }, + { + "message": { + "key": "parallelUploadsWithUploadSize", + "kind": "client-flow-message" + }, + "predicate": { + "kind": "all", + "predicates": [ + { + "input": "parallelUploads", + "kind": "number-input-gte-client-flow-value", + "value": "minimumParallelUploads" + }, + { + "equals": true, + "input": "hasUploadSize", + "kind": "boolean-input" + } + ] + }, + "reason": "parallelUploadsWithUploadSize" + }, + { + "message": { + "key": "parallelUploadsWithDeferredLength", + "kind": "client-flow-message" + }, + "predicate": { + "kind": "all", + "predicates": [ + { + "input": "parallelUploads", + "kind": "number-input-gte-client-flow-value", + "value": "minimumParallelUploads" + }, + { + "equals": true, + "input": "uploadLengthDeferred", + "kind": "boolean-input" + } + ] + }, + "reason": "parallelUploadsWithDeferredLength" + }, + { + "message": { + "key": "parallelUploadsWithUploadDataDuringCreation", + "kind": "client-flow-message" + }, + "predicate": { + "kind": "all", + "predicates": [ + { + "input": "parallelUploads", + "kind": "number-input-gte-client-flow-value", + "value": "minimumParallelUploads" + }, + { + "equals": true, + "input": "uploadDataDuringCreation", + "kind": "boolean-input" + } + ] + }, + "reason": "parallelUploadsWithUploadDataDuringCreation" + }, + { + "message": { + "key": "parallelBoundariesWithoutParallelUploads", + "kind": "client-flow-message" + }, + "predicate": { + "kind": "all", + "predicates": [ + { + "input": "parallelUploadBoundariesCount", + "kind": "number-input-not-null" + }, + { + "input": "parallelUploads", + "kind": "number-input-lt-client-flow-value", + "value": "minimumParallelUploads" + } + ] + }, + "reason": "parallelBoundariesWithoutParallelUploads" + }, + { + "message": { + "key": "parallelBoundariesLengthMismatch", + "kind": "client-flow-message" + }, + "predicate": { + "kind": "all", + "predicates": [ + { + "input": "parallelUploadBoundariesCount", + "kind": "number-input-not-null" + }, + { + "kind": "number-input-not-equals-number-input", + "left": "parallelUploads", + "right": "parallelUploadBoundariesCount" + } + ] + }, + "reason": "parallelBoundariesLengthMismatch" + } + ] + }, + "urlStorage": { + "id": { + "multiplier": 1000000000000, + "strategy": "rounded-random-number" + }, + "messages": { + "missingItem": "didn't find item for key {key}", + "missingKey": "didn't find key for item {index}" + }, + "namespace": "tus", + "record": { + "creationTime": "sdk-current-date-string", + "fields": { + "creationTime": "creationTime", + "metadata": "metadata", + "size": "size", + "uploadUrl": "uploadUrl", + "urlStorageKey": "urlStorageKey" + }, + "missingUrl": "fail", + "storedUrlKind": "single-or-parallel-upload-url" + }, + "removeOnSuccess": "when-option-enabled", + "separator": "::", + "webStorage": { + "malformedEntry": "ignore", + "probeKey": "tusSupport", + "unavailableDomExceptionNames": [ + "QuotaExceededError", + "SecurityError" + ] + } + } + }, + "clientProtocol": { + "compatibilityVersions": [ + { + "protocolVersion": "tus-v1", + "requiresKnownUploadLengthOnOffsetResponse": true, + "requestHeaders": [ + { + "headerName": "Tus-Resumable", + "value": "1.0.0" + } + ], + "responseHeaders": [ + { + "headerName": "Tus-Resumable", + "value": "1.0.0" + } + ], + "uploadBodyContentType": "application/offset+octet-stream" + }, + { + "protocolVersion": "ietf-draft-03", + "requestHeaders": [ + { + "headerName": "Upload-Draft-Interop-Version", + "value": "5" + } + ], + "responseHeaders": [], + "uploadCompleteHeader": { + "completeValue": "?1", + "headerName": "Upload-Complete", + "incompleteValue": "?0" + } + }, + { + "protocolVersion": "ietf-draft-05", + "requestHeaders": [ + { + "headerName": "Upload-Draft-Interop-Version", + "value": "6" + } + ], + "responseHeaders": [], + "uploadBodyContentType": "application/partial-upload", + "uploadCompleteHeader": { + "completeValue": "?1", + "headerName": "Upload-Complete", + "incompleteValue": "?0" + } + } + ], + "concatenation": { + "extensionName": "concatenation", + "finalPrefix": "final;", + "headerName": "Upload-Concat", + "partialValue": "partial", + "uploadUrlSeparator": " " + }, + "locationHeaderName": "Location", + "metadataEncoding": { + "entrySeparator": ",", + "keyValueSeparator": " ", + "valueEncoding": "base64" + }, + "metadataHeaderName": "Upload-Metadata", + "methodOverrides": [ + { + "headerName": "X-HTTP-Method-Override", + "headerValue": "PATCH", + "inputFlag": "overridePatchMethod", + "method": "POST", + "operationId": "patchTusUpload" + } + ], + "requestIdHeaderName": "X-Request-ID", + "retryPolicy": { + "clientErrorStatusCategory": 400, + "lockedStatusCode": 423, + "retryableClientStatusCodes": [ + 409, + 423 + ], + "successStatusCategory": 200 + }, + "serverCapabilityExtensions": { + "creation": "creation", + "termination": "termination" + }, + "uploadBody": { + "contentTypeHeaderName": "Content-Type" + }, + "uploadLength": { + "deferLengthHeaderName": "Upload-Defer-Length", + "deferLengthHeaderValue": "1", + "lengthHeaderName": "Upload-Length" + }, + "uploadOffsetHeaderName": "Upload-Offset" + }, + "clientUrlStorageConformanceScenarios": [ + { + "actions": [ + { + "kind": "assert-empty" + }, + { + "expectedKeyPrefix": "tus::contract-storage-a::", + "fingerprint": "contract-storage-a", + "keyRef": "a1", + "kind": "add-upload", + "upload": { + "id": 1, + "metadata": { + "filename": "a1.txt" + }, + "size": 11, + "uploadUrl": "https://tus.io/uploads/storage-a1" + } + }, + { + "expectedKeyPrefix": "tus::contract-storage-a::", + "fingerprint": "contract-storage-a", + "keyRef": "a2", + "kind": "add-upload", + "upload": { + "id": 2, + "metadata": { + "filename": "a2.txt" + }, + "size": 12, + "uploadUrl": "https://tus.io/uploads/storage-a2" + } + }, + { + "expectedKeyPrefix": "tus::contract-storage-b::", + "fingerprint": "contract-storage-b", + "keyRef": "b1", + "kind": "add-upload", + "upload": { + "id": 3, + "metadata": { + "filename": "b1.txt" + }, + "size": 13, + "uploadUrl": "https://tus.io/uploads/storage-b1" + } + }, + { + "expectedKeyRefs": [ + "a1", + "a2" + ], + "fingerprint": "contract-storage-a", + "kind": "find-by-fingerprint" + }, + { + "expectedKeyRefs": [ + "b1" + ], + "fingerprint": "contract-storage-b", + "kind": "find-by-fingerprint" + }, + { + "expectedKeyRefs": [ + "a1", + "a2", + "b1" + ], + "kind": "find-all" + }, + { + "keyRef": "a2", + "kind": "remove-upload" + }, + { + "keyRef": "b1", + "kind": "remove-upload" + }, + { + "expectedKeyRefs": [ + "a1" + ], + "fingerprint": "contract-storage-a", + "kind": "find-by-fingerprint" + }, + { + "expectedKeyRefs": [], + "fingerprint": "contract-storage-b", + "kind": "find-by-fingerprint" + } + ], + "backend": "web-storage", + "featureId": "urlStorageBackends", + "runtimes": [ + "browser" + ], + "scenarioId": "webStorageUrlStorageBackend" + }, + { + "actions": [ + { + "kind": "assert-empty" + }, + { + "expectedKeyPrefix": "tus::contract-storage-a::", + "fingerprint": "contract-storage-a", + "keyRef": "a1", + "kind": "add-upload", + "upload": { + "id": 1, + "metadata": { + "filename": "a1.txt" + }, + "size": 11, + "uploadUrl": "https://tus.io/uploads/storage-a1" + } + }, + { + "expectedKeyPrefix": "tus::contract-storage-a::", + "fingerprint": "contract-storage-a", + "keyRef": "a2", + "kind": "add-upload", + "upload": { + "id": 2, + "metadata": { + "filename": "a2.txt" + }, + "size": 12, + "uploadUrl": "https://tus.io/uploads/storage-a2" + } + }, + { + "expectedKeyPrefix": "tus::contract-storage-b::", + "fingerprint": "contract-storage-b", + "keyRef": "b1", + "kind": "add-upload", + "upload": { + "id": 3, + "metadata": { + "filename": "b1.txt" + }, + "size": 13, + "uploadUrl": "https://tus.io/uploads/storage-b1" + } + }, + { + "expectedKeyRefs": [ + "a1", + "a2" + ], + "fingerprint": "contract-storage-a", + "kind": "find-by-fingerprint" + }, + { + "expectedKeyRefs": [ + "b1" + ], + "fingerprint": "contract-storage-b", + "kind": "find-by-fingerprint" + }, + { + "expectedKeyRefs": [ + "a1", + "a2", + "b1" + ], + "kind": "find-all" + }, + { + "keyRef": "a2", + "kind": "remove-upload" + }, + { + "keyRef": "b1", + "kind": "remove-upload" + }, + { + "expectedKeyRefs": [ + "a1" + ], + "fingerprint": "contract-storage-a", + "kind": "find-by-fingerprint" + }, + { + "expectedKeyRefs": [], + "fingerprint": "contract-storage-b", + "kind": "find-by-fingerprint" + } + ], + "backend": "file-storage", + "featureId": "urlStorageBackends", + "runtimes": [ + "deno", + "node" + ], + "scenarioId": "fileUrlStorageBackend" + } + ], + "managedUpload": { + "capabilities": { + "cleanup": { + "policies": [ + "absent-after-source-unavailable", + "remove-owned-source-after-success", + "remove-owned-source-after-cancel", + "retain-owned-source-while-deferred", + "retain-owned-source-after-permanent-failure", + "retain-source-after-retryable-failure", + "remove-managed-state-after-terminal-retention" + ] + }, + "failureClassification": { + "permanentFailures": [ + "source-unavailable", + "unretryable-protocol-error", + "retry-policy-exhausted" + ], + "retryableFailures": [ + "retryable-protocol-error", + "io-error", + "network-unavailable" + ] + }, + "networkConstraints": { + "options": [ + "any-network", + "unmetered-network" + ] + }, + "retryPolicy": { + "controls": [ + "max-attempts", + "deadline", + "progress-sensitive-budget", + "unbounded-until-permanent-failure" + ], + "permanentFailure": "stop-without-retry", + "progressReset": "reset-budget-after-accepted-offset-advances" + }, + "scheduling": { + "strategies": [ + "foreground-task", + "process-lifetime-worker-pool", + "durable-os-scheduler" + ] + }, + "sourceDurability": { + "ownedCopyCleanup": "after-success-or-cancel", + "strategies": [ + "copy-to-owned-storage", + "reference-original-source", + "memory-only" + ] + }, + "stateReporting": { + "states": [ + "pending", + "running", + "succeeded", + "failed" + ], + "terminalRetention": "session-and-next-launch", + "transientRetention": "until-terminal" + } + }, + "conformance": { + "scenarioIds": [ + "managedUploadDurableRetry", + "managedUploadPermanentFailure", + "managedUploadRetryPolicyExhausted", + "managedUploadSourceUnavailable", + "managedUploadNetworkConstraint" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.", + "featureId": "managedUpload", + "flow": [ + { + "kind": "managed-primitive", + "primitive": "accept-upload-submission", + "summary": "Accept source, metadata, headers, endpoint, and retry/scheduling policy." + }, + { + "kind": "managed-primitive", + "primitive": "make-source-durable", + "summary": "Keep the source readable according to the selected runtime durability strategy." + }, + { + "kind": "managed-primitive", + "primitive": "schedule-upload-work", + "summary": "Run upload work according to the runtime scheduler capability." + }, + { + "featureId": "singleUploadLifecycle", + "kind": "protocol-feature", + "summary": "Use the raw protocol upload lifecycle for each execution attempt." + }, + { + "featureId": "retryOffsetRecovery", + "kind": "protocol-feature", + "summary": "Use protocol retry and offset recovery before classifying terminal failure." + }, + { + "kind": "managed-primitive", + "primitive": "publish-upload-state", + "summary": "Expose pending, running, succeeded, and failed state snapshots." + }, + { + "kind": "managed-primitive", + "primitive": "cleanup-managed-upload", + "summary": "Remove owned sources and terminal state according to cleanup policy." + } + ], + "layer": "feature-over-protocol", + "primitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "protocolPrimitives": [ + "store-resume-url", + "resume-from-previous-upload", + "recover-offset-after-error", + "retry-with-backoff", + "emit-progress", + "emit-chunk-complete", + "terminate-upload" + ], + "runtimeProfiles": [ + { + "networkConstraints": [ + "any-network", + "unmetered-network" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source" + ], + "stateBackend": "platform-key-value-store", + "transportProfileId": "java-http-url-connection" + }, + { + "networkConstraints": [ + "any-network", + "unmetered-network" + ], + "runtime": "ios", + "scheduler": "durable-os-scheduler", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source" + ], + "stateBackend": "platform-key-value-store" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "browser", + "scheduler": "foreground-task", + "sourceDurability": [ + "reference-original-source", + "memory-only" + ], + "stateBackend": "web-storage" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source" + ], + "stateBackend": "filesystem", + "transportProfileId": "java-http-url-connection" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "node", + "scheduler": "process-lifetime-worker-pool", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source", + "memory-only" + ], + "stateBackend": "filesystem" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "react-native", + "scheduler": "foreground-task", + "sourceDurability": [ + "reference-original-source", + "memory-only" + ], + "stateBackend": "platform-key-value-store" + } + ], + "scenarios": [ + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "afterAcceptedOffset": 7, + "kind": "io-error", + "phase": "after-accepted-offset" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": { + "Location": "https://tus.io/uploads/managed-durable-retry" + }, + "statusCode": 201 + }, + "url": "endpoint" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "0" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "7" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "requests": [ + { + "headers": {}, + "operationId": "getTusUploadOffset", + "response": { + "headers": { + "Upload-Length": "14", + "Upload-Offset": "7" + }, + "statusCode": 200 + }, + "url": "upload" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "7" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "14" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "succeeded" + } + ], + "cleanup": { + "ownedSource": "remove-owned-source-after-success", + "resumeUrl": "remove-after-success" + }, + "input": { + "chunkSize": 7, + "content": "hello managed!", + "fingerprint": "managed-durable-retry-fingerprint", + "metadata": { + "filename": "managed.txt" + }, + "uploadPath": "managed-durable-retry" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "kind": "terminal", + "state": "succeeded" + }, + "retryDelays": [ + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "succeeded" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "afterAcceptedOffset": 7, + "kind": "io-error", + "phase": "after-accepted-offset" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": { + "Location": "https://tus.io/uploads/managed-durable-retry" + }, + "statusCode": 201 + }, + "url": "endpoint" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "0" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "7" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "requests": [ + { + "headers": {}, + "operationId": "getTusUploadOffset", + "response": { + "headers": { + "Upload-Length": "14", + "Upload-Offset": "7" + }, + "statusCode": 200 + }, + "url": "upload" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "7" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "14" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "succeeded" + } + ], + "cleanup": { + "ownedSource": "remove-owned-source-after-success", + "resumeUrl": "remove-after-success" + }, + "input": { + "chunkSize": 7, + "content": "hello managed!", + "fingerprint": "managed-durable-retry-fingerprint", + "metadata": { + "filename": "managed.txt" + }, + "uploadPath": "managed-durable-retry" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "kind": "terminal", + "state": "succeeded" + }, + "retryDelays": [ + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "succeeded" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadDurableRetry", + "summary": "Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup." + }, + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "unretryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 400 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello failure!", + "fingerprint": "managed-permanent-failure-fingerprint", + "metadata": { + "filename": "managed-permanent-failure.txt" + }, + "uploadPath": "managed-permanent-failure" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "unretryable-protocol-error", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "unretryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 400 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello failure!", + "fingerprint": "managed-permanent-failure-fingerprint", + "metadata": { + "filename": "managed-permanent-failure.txt" + }, + "uploadPath": "managed-permanent-failure" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "unretryable-protocol-error", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadPermanentFailure", + "summary": "Classify unretryable protocol failures as terminal without further retry." + }, + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 2, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello retries!", + "fingerprint": "managed-retry-exhausted-fingerprint", + "metadata": { + "filename": "managed-retry-exhausted.txt" + }, + "uploadPath": "managed-retry-exhausted" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "retry-policy-exhausted", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [ + 0, + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 2, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello retries!", + "fingerprint": "managed-retry-exhausted-fingerprint", + "metadata": { + "filename": "managed-retry-exhausted.txt" + }, + "uploadPath": "managed-retry-exhausted" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "retry-policy-exhausted", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [ + 0, + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadRetryPolicyExhausted", + "summary": "Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed." + }, + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "source-unavailable", + "phase": "before-protocol-request" + }, + "requests": [], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "absent-after-source-unavailable", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello missing!", + "fingerprint": "managed-source-unavailable-fingerprint", + "metadata": { + "filename": "managed-source-unavailable.txt" + }, + "uploadPath": "managed-source-unavailable" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "source-unavailable", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "missing-before-durable-copy", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "source-unavailable", + "phase": "before-protocol-request" + }, + "requests": [], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "absent-after-source-unavailable", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello missing!", + "fingerprint": "managed-source-unavailable-fingerprint", + "metadata": { + "filename": "managed-source-unavailable.txt" + }, + "uploadPath": "managed-source-unavailable" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "source-unavailable", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "missing-before-durable-copy", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadSourceUnavailable", + "summary": "Classify source disappearance before protocol requests as terminal without issuing a TUS request." + }, + { + "proofs": [ + { + "attempts": [], + "cleanup": { + "ownedSource": "retain-owned-source-while-deferred", + "resumeUrl": "absent-while-deferred" + }, + "input": { + "chunkSize": 7, + "content": "hello later!", + "fingerprint": "managed-network-constraint-fingerprint", + "metadata": { + "filename": "managed-network-constraint.txt" + }, + "uploadPath": "managed-network-constraint" + }, + "network": { + "current": "metered-network", + "decision": "defer-until-network-constraint-satisfied", + "required": "unmetered-network" + }, + "outcome": { + "kind": "deferred", + "reason": "network-constraint-unsatisfied", + "state": "pending" + }, + "retryDelays": [], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "publish-upload-state" + ], + "scenarioId": "managedUploadNetworkConstraint", + "summary": "Honor network constraints before starting or resuming upload work." + } + ] + }, + "operations": [ + { + "operationId": "discoverTusCapabilities", + "role": "capability-discovery", + "method": "OPTIONS", + "path": "/resumable/files/", + "request": { + "bodyKind": "empty", + "contentType": null, + "headerVariants": [] + }, + "responses": [ + { + "statusCode": 200, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Extension", + "name": "tus-extension", + "required": true + }, + { + "displayName": "Tus-Max-Size", + "name": "tus-max-size", + "required": true + }, + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Tus-Version", + "name": "tus-version", + "required": true + } + ] + } + ] + } + ] + }, + { + "operationId": "createTusUpload", + "role": "creation", + "method": "POST", + "path": "/resumable/files/", + "request": { + "bodyKind": "empty", + "contentType": null, + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Length", + "name": "upload-length", + "required": true + }, + { + "displayName": "Upload-Metadata", + "name": "upload-metadata", + "required": true + } + ] + }, + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Defer-Length", + "name": "upload-defer-length", + "required": true + }, + { + "displayName": "Upload-Metadata", + "name": "upload-metadata", + "required": true + } + ] + }, + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Concat", + "name": "upload-concat", + "required": true + }, + { + "displayName": "Upload-Length", + "name": "upload-length", + "required": true + }, + { + "displayName": "Upload-Metadata", + "name": "upload-metadata", + "required": false + } + ] + }, + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Concat", + "name": "upload-concat", + "required": true + }, + { + "displayName": "Upload-Metadata", + "name": "upload-metadata", + "required": false + } + ] + } + ] + }, + "responses": [ + { + "statusCode": 201, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Location", + "name": "location", + "required": true + }, + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + }, + { + "statusCode": 413, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + }, + { + "statusCode": 500, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + } + ] + }, + { + "operationId": "getTusUploadOffset", + "role": "offset-discovery", + "method": "HEAD", + "path": "/resumable/files/{upload_id}", + "request": { + "bodyKind": "empty", + "contentType": null, + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + }, + "responses": [ + { + "statusCode": 200, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Length", + "name": "upload-length", + "required": true + }, + { + "displayName": "Upload-Offset", + "name": "upload-offset", + "required": true + } + ] + }, + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Defer-Length", + "name": "upload-defer-length", + "required": true + }, + { + "displayName": "Upload-Offset", + "name": "upload-offset", + "required": true + } + ] + } + ] + } + ] + }, + { + "operationId": "patchTusUpload", + "role": "upload-chunk", + "method": "PATCH", + "path": "/resumable/files/{upload_id}", + "request": { + "bodyKind": "binary", + "contentType": "application/offset+octet-stream", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Content-Type", + "name": "content-type", + "required": true + }, + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Offset", + "name": "upload-offset", + "required": true + } + ] + } + ] + }, + "responses": [ + { + "statusCode": 204, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + }, + { + "displayName": "Upload-Offset", + "name": "upload-offset", + "required": true + } + ] + } + ] + }, + { + "statusCode": 500, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + } + ] + }, + { + "operationId": "terminateTusUpload", + "role": "termination", + "method": "DELETE", + "path": "/resumable/files/{upload_id}", + "request": { + "bodyKind": "empty", + "contentType": null, + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + }, + "responses": [ + { + "statusCode": 204, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + }, + { + "statusCode": 400, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + }, + { + "statusCode": 423, + "bodyKind": "empty", + "headerVariants": [ + { + "fields": [ + { + "displayName": "Tus-Resumable", + "name": "tus-resumable", + "required": true + } + ] + } + ] + } + ] + }, + { + "operationId": "downloadTusUpload", + "role": "download", + "method": "GET", + "path": "/resumable/files/{upload_id}", + "request": { + "bodyKind": "empty", + "contentType": null, + "headerVariants": [] + }, + "responses": [ + { + "statusCode": 200, + "bodyKind": "binary", + "headerVariants": [] + } + ] + } + ], + "versions": [ + { + "constantName": "PROTOCOL_TUS_V1", + "default": true, + "value": "tus-v1" + }, + { + "constantName": "PROTOCOL_IETF_DRAFT_03", + "value": "ietf-draft-03" + }, + { + "constantName": "PROTOCOL_IETF_DRAFT_05", + "value": "ietf-draft-05" + } + ], + "wireVersions": [ + { + "default": true, + "value": "1.0.0" + } + ] +} diff --git a/url_storage_abort_contract_test.go b/url_storage_abort_contract_test.go new file mode 100644 index 0000000..d6d921c --- /dev/null +++ b/url_storage_abort_contract_test.go @@ -0,0 +1,146 @@ +package tusgo + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +const ( + generatedTusAbortCancelRequestIndex = 0 + generatedTusAbortEventPolicy = "exact" + generatedTusAbortContent = "hello world" + generatedTusAbortEndpointPath = "/uploads" + generatedTusAbortUploadLength = "11" +) + +var generatedTusAbortExtraEventPrefixes = []string{} +var generatedTusAbortExpectedEvents = []string{"request-abort:0"} +var generatedTusAbortMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedAbortUploadContext(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusAbortMetadata) + if err != nil { + t.Fatal(err) + } + + requestStarted := make(chan struct{}) + requestDone := make(chan struct{}) + events := []string{} + var requestErr error + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + defer close(requestDone) + if request.URL.Path != generatedTusAbortEndpointPath { + requestErr = fmt.Errorf("expected path %s, got %s", generatedTusAbortEndpointPath, request.URL.Path) + } + if request.Method != createOperation.Method { + requestErr = fmt.Errorf("expected method %s, got %s", createOperation.Method, request.Method) + } + if err := generatedAssertTusAbortRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusAbortUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ); err != nil { + requestErr = err + } + events = append(events, generatedTusEventKeyRequestAbort( + generatedTusEventKeyNumber(int64(generatedTusAbortCancelRequestIndex)), + )) + close(requestStarted) + <-request.Context().Done() + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusAbortEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + storage := NewMemoryURLStorage() + go func() { + _, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Context: ctx, + Storage: storage, + Source: strings.NewReader(generatedTusAbortContent), + Fingerprint: "contract-abort-fingerprint", + Size: 11, + Metadata: generatedTusAbortMetadata, + }) + result <- err + }() + + select { + case <-requestStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort request") + } + if requestErr != nil { + t.Fatal(requestErr) + } + cancel() + + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort result") + } + select { + case <-requestDone: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for server to observe abort") + } + generatedTusAssertEvents(t, "abortUpload", generatedTusAbortEventPolicy, generatedTusAbortExtraEventPrefixes, generatedTusAbortExpectedEvents, events) + + storedUploads, err := storage.FindAllUploads() + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 0 { + t.Fatalf("expected aborted create not to store uploads, got %#v", storedUploads) + } +} + +func generatedAssertTusAbortRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} diff --git a/url_storage_abort_termination_contract_test.go b/url_storage_abort_termination_contract_test.go new file mode 100644 index 0000000..2989c6c --- /dev/null +++ b/url_storage_abort_termination_contract_test.go @@ -0,0 +1,277 @@ +package tusgo + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +const ( + generatedTusAbortTerminationCancelRequestIndex = 1 + generatedTusAbortTerminationEventPolicy = "exact" + generatedTusAbortTerminationContent = "hello world" + generatedTusAbortTerminationContentType = "application/offset+octet-stream" + generatedTusAbortTerminationContentTypeHeader = "Content-Type" + generatedTusAbortTerminationEndpointPath = "/uploads" + generatedTusAbortTerminationFingerprint = "contract-abort-terminate-fingerprint" + generatedTusAbortTerminationMethod = "POST" + generatedTusAbortTerminationOverrideHeader = "X-HTTP-Method-Override" + generatedTusAbortTerminationOverrideValue = "PATCH" + generatedTusAbortTerminationPatchBody = "hello world" + generatedTusAbortTerminationPatchOffset = "0" + generatedTusAbortTerminationOffsetHeader = "Upload-Offset" + generatedTusAbortTerminationUploadLength = "11" + generatedTusAbortTerminationUploadPath = "/uploads/abort-terminate-contract" +) + +var generatedTusAbortTerminationHeaders = map[string]string{"X-Tus-Contract": "abort-policy", "X-Tus-Trace": "abort-trace-123"} +var generatedTusAbortTerminationExtraEventPrefixes = []string{} +var generatedTusAbortTerminationExpectedEvents = []string{"request-abort:1"} +var generatedTusAbortTerminationMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + terminateOperation := generatedProtocolOperation("terminateTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusAbortTerminationMetadata) + if err != nil { + t.Fatal(err) + } + + patchStarted := make(chan struct{}) + patchDone := make(chan struct{}) + terminationDone := make(chan struct{}) + requestErrs := make(chan error, 8) + events := []string{} + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusAbortTerminationEndpointPath && request.Method == createOperation.Method: + recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusAbortTerminationUploadLength, + "Upload-Metadata": encodedMetadata, + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + }, + )) + recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( + request, + generatedTusAbortTerminationHeaders, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusAbortTerminationResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusAbortTerminationUploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(201) + + case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == generatedTusAbortTerminationMethod: + defer close(patchDone) + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusAbortTerminationPatchBody { + recordRequestErr(fmt.Errorf("expected abort patch body %q, got %q", generatedTusAbortTerminationPatchBody, string(body))) + } + recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusAbortTerminationContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusAbortTerminationPatchOffset, + "X-HTTP-Method-Override": generatedTusAbortTerminationOverrideValue, + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + }, + )) + recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( + request, + generatedTusAbortTerminationHeaders, + )) + if actual := request.Header.Get(generatedTusAbortTerminationOverrideHeader); actual != generatedTusAbortTerminationOverrideValue { + recordRequestErr(fmt.Errorf("expected override header %s, got %s", generatedTusAbortTerminationOverrideValue, actual)) + } + events = append(events, generatedTusEventKeyRequestAbort( + generatedTusEventKeyNumber(int64(generatedTusAbortTerminationCancelRequestIndex)), + )) + close(patchStarted) + <-request.Context().Done() + + case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == terminateOperation.Method: + recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( + request, + terminateOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + }, + )) + recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( + request, + generatedTusAbortTerminationHeaders, + )) + if actual := request.Header.Get(generatedTusAbortTerminationOverrideHeader); actual != "" { + recordRequestErr(fmt.Errorf("expected no override header on termination request, got %s", actual)) + } + terminateResponse := generatedResponseFor(terminateOperation, 204) + generatedWriteTusAbortTerminationResponseHeaders( + responseWriter, + terminateResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(204) + close(terminationDone) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusAbortTerminationEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, terminateOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + storage := NewMemoryURLStorage() + go func() { + _, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Context: ctx, + Storage: storage, + Source: strings.NewReader(generatedTusAbortTerminationContent), + Fingerprint: generatedTusAbortTerminationFingerprint, + Size: 11, + Headers: generatedTusAbortTerminationHeaders, + Metadata: generatedTusAbortTerminationMetadata, + OverridePatchMethod: true, + TerminateUploadOnAbort: true, + }) + result <- err + }() + + select { + case <-patchStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort patch request") + } + cancel() + + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort termination result") + } + select { + case <-patchDone: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for server to observe abort") + } + select { + case <-terminationDone: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for termination request") + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "abortUploadAfterStoredUrl", generatedTusAbortTerminationEventPolicy, generatedTusAbortTerminationExtraEventPrefixes, generatedTusAbortTerminationExpectedEvents, events) + + storedUploads, err := storage.FindAllUploads() + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 0 { + t.Fatalf("expected terminated abort to remove stored uploads, got %#v", storedUploads) + } +} + +func generatedAssertTusAbortTerminationRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedAssertTusAbortTerminationCustomHeaders( + request *http.Request, + expected map[string]string, +) error { + for key, value := range expected { + if actual := request.Header.Get(key); actual != value { + return fmt.Errorf("expected custom header %s=%s, got %s", key, value, actual) + } + } + + return nil +} + +func generatedWriteTusAbortTerminationResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_contract_test.go b/url_storage_contract_test.go new file mode 100644 index 0000000..7227581 --- /dev/null +++ b/url_storage_contract_test.go @@ -0,0 +1,180 @@ +package tusgo + +import ( + "fmt" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +func TestGeneratedURLStorageConformance(t *testing.T) { + for _, scenario := range generatedTusClientUrlStorageConformanceScenarios { + scenario := scenario + if scenario.Backend != "file-storage" { + continue + } + + t.Run(scenario.ScenarioID, func(t *testing.T) { + storage := NewFileURLStorage(filepath.Join(t.TempDir(), "url-storage.json")) + generatedAssertURLStorage(t, storage, scenario) + }) + } +} + +func generatedAssertURLStorage( + t *testing.T, + storage URLStorage, + scenario generatedTusClientUrlStorageConformanceScenario, +) { + t.Helper() + + keyRefs := map[string]string{} + expectedUploads := map[string]URLStorageUpload{} + + for _, action := range scenario.Actions { + switch action.Kind { + case "assert-empty": + actual, err := storage.FindAllUploads() + if err != nil { + t.Fatal(err) + } + if len(actual) != 0 { + t.Fatalf("scenario %s expected empty URL storage, got %#v", scenario.ScenarioID, actual) + } + + case "add-upload": + key, err := storage.AddUpload(action.Fingerprint, action.Upload) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(key, action.ExpectedKeyPrefix) { + t.Fatalf( + "scenario %s stored %s under %s, expected prefix %s", + scenario.ScenarioID, + action.KeyRef, + key, + action.ExpectedKeyPrefix, + ) + } + keyRefs[action.KeyRef] = key + expectedUpload, err := cloneURLStorageUpload(action.Upload) + if err != nil { + t.Fatal(err) + } + expectedUpload["urlStorageKey"] = key + expectedUploads[action.KeyRef] = expectedUpload + + case "find-by-fingerprint": + actual, err := storage.FindUploadsByFingerprint(action.Fingerprint) + if err != nil { + t.Fatal(err) + } + generatedAssertStoredUploads( + t, + scenario, + action, + actual, + generatedExpectedUploadsForRefs(t, scenario, action.ExpectedKeyRefs, expectedUploads), + ) + + case "find-all": + actual, err := storage.FindAllUploads() + if err != nil { + t.Fatal(err) + } + generatedAssertStoredUploads( + t, + scenario, + action, + actual, + generatedExpectedUploadsForRefs(t, scenario, action.ExpectedKeyRefs, expectedUploads), + ) + + case "remove-upload": + key, ok := keyRefs[action.KeyRef] + if !ok { + t.Fatalf("scenario %s references unknown keyRef %s", scenario.ScenarioID, action.KeyRef) + } + if err := storage.RemoveUpload(key); err != nil { + t.Fatal(err) + } + delete(expectedUploads, action.KeyRef) + + default: + t.Fatalf( + "scenario %s has unsupported URL-storage action %s", + scenario.ScenarioID, + action.Kind, + ) + } + } +} + +func generatedExpectedUploadsForRefs( + t *testing.T, + scenario generatedTusClientUrlStorageConformanceScenario, + refs []string, + expectedUploads map[string]URLStorageUpload, +) []URLStorageUpload { + t.Helper() + + uploads := make([]URLStorageUpload, 0, len(refs)) + for _, ref := range refs { + upload, ok := expectedUploads[ref] + if !ok { + t.Fatalf("scenario %s references unknown expected upload %s", scenario.ScenarioID, ref) + } + uploads = append(uploads, upload) + } + + return uploads +} + +func generatedAssertStoredUploads( + t *testing.T, + scenario generatedTusClientUrlStorageConformanceScenario, + action generatedTusClientUrlStorageConformanceAction, + actual []URLStorageUpload, + expected []URLStorageUpload, +) { + t.Helper() + + actual = generatedNormalizeStoredUploads(t, actual) + expected = generatedNormalizeStoredUploads(t, expected) + if !reflect.DeepEqual(actual, expected) { + t.Fatalf( + "scenario %s action %s returned %#v, expected %#v", + scenario.ScenarioID, + action.Kind, + actual, + expected, + ) + } +} + +func generatedNormalizeStoredUploads(t *testing.T, uploads []URLStorageUpload) []URLStorageUpload { + t.Helper() + + normalized := make([]URLStorageUpload, 0, len(uploads)) + for _, upload := range uploads { + cloned, err := cloneURLStorageUpload(upload) + if err != nil { + t.Fatal(err) + } + normalized = append(normalized, cloned) + } + + sort.Slice(normalized, func(i int, j int) bool { + leftID := fmt.Sprint(normalized[i]["id"]) + rightID := fmt.Sprint(normalized[j]["id"]) + if leftID != rightID { + return leftID < rightID + } + + return fmt.Sprint(normalized[i]["urlStorageKey"]) < fmt.Sprint(normalized[j]["urlStorageKey"]) + }) + + return normalized +} diff --git a/url_storage_create_contract_test.go b/url_storage_create_contract_test.go new file mode 100644 index 0000000..e103d42 --- /dev/null +++ b/url_storage_create_contract_test.go @@ -0,0 +1,176 @@ +package tusgo + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusCreateFlowContent = "hello world" + generatedTusCreateFlowCreatedUploadPath = "/uploads/generated-contract" + generatedTusCreateFlowEndpointPath = "/uploads" + generatedTusCreateFlowFingerprint = "contract-single-fingerprint" + generatedTusCreateFlowPatchAcceptedOffset = "11" + generatedTusCreateFlowPatchBody = "hello world" + generatedTusCreateFlowPatchOffset = "0" + generatedTusCreateFlowRemoveFingerprintOnSuccess = false + generatedTusCreateFlowUploadLength = "11" +) + +var generatedTusCreateFlowMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageCreateFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusCreateFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + createdUploadURL := srvMock.URL() + generatedTusCreateFlowCreatedUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusCreateFlowMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageCreateResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageCreateRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusCreateFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreateFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageCreateResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreateFlowPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageCreateRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusCreateFlowCreatedUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusCreateFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreateFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusCreateFlowContent), + Fingerprint: generatedTusCreateFlowFingerprint, + Size: 11, + Metadata: generatedTusCreateFlowMetadata, + RemoveFingerprintOnSuccess: generatedTusCreateFlowRemoveFingerprintOnSuccess, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected created upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + + storedUploads, err := storage.FindUploadsByFingerprint(generatedTusCreateFlowFingerprint) + if err != nil { + t.Fatal(err) + } + shouldRemoveStoredUpload := generatedTusShouldRemoveStoredUploadOnSuccess( + generatedTusCreateFlowRemoveFingerprintOnSuccess, + ) + if shouldRemoveStoredUpload { + if len(storedUploads) != 0 { + t.Fatalf("expected successful create flow to remove stored upload, got %#v", storedUploads) + } + return + } + if len(storedUploads) != 1 { + t.Fatalf("expected successful create flow to store one upload, got %#v", storedUploads) + } + storedUploadURL, ok := stringFromURLStorageUpload(storedUploads[0], "uploadUrl") + if !ok || storedUploadURL != createdUploadURL { + t.Fatalf("expected stored upload URL %s, got %#v", createdUploadURL, storedUploads[0]) + } +} + +func generatedURLStorageCreateRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageCreateResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_creation_with_upload_contract_test.go b/url_storage_creation_with_upload_contract_test.go new file mode 100644 index 0000000..72abc4b --- /dev/null +++ b/url_storage_creation_with_upload_contract_test.go @@ -0,0 +1,225 @@ +package tusgo + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const ( + generatedTusCreationWithUploadContent = "hello world" + generatedTusCreationWithUploadContentType = "application/offset+octet-stream" + generatedTusCreationWithUploadContentTypeHeader = "Content-Type" + generatedTusCreationWithUploadEndpointPath = "/uploads" + generatedTusCreationWithUploadEventPolicy = "exact-except-allowed-extra-events" + generatedTusCreationWithUploadExpectedRequests = 1 + generatedTusCreationWithUploadLength = "11" + generatedTusCreationWithUploadLengthHeader = "Upload-Length" + generatedTusCreationWithUploadMetadataHeader = "Upload-Metadata" + generatedTusCreationWithUploadOffset = "11" + generatedTusCreationWithUploadOffsetHeader = "Upload-Offset" + generatedTusCreationWithUploadPath = "/uploads/creation-with-upload-contract" +) + +var generatedTusCreationWithUploadExtraEventPrefixes = []string{"progress:"} +var generatedTusCreationWithUploadExpectedEvents = []string{"progress:0:11", "progress:11:11", "upload-url-available", "success", "source-close"} +var generatedTusCreationWithUploadMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusCreationWithUploadMetadata) + if err != nil { + t.Fatal(err) + } + + requestCount := 0 + requestErrs := make(chan error, 4) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + if request.URL.Path != generatedTusCreationWithUploadEndpointPath || + request.Method != createOperation.Method { + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusCreationWithUploadContent { + recordRequestErr(fmt.Errorf( + "expected creation-with-upload body %q, got %q", + generatedTusCreationWithUploadContent, + string(body), + )) + } + if actual := request.Header.Get(generatedTusCreationWithUploadContentTypeHeader); actual != generatedTusCreationWithUploadContentType { + recordRequestErr(fmt.Errorf( + "expected creation-with-upload content type %s, got %s", + generatedTusCreationWithUploadContentType, + actual, + )) + } + recordRequestErr(generatedAssertTusCreationWithUploadRequestHeaders( + request, + createOperation, + map[string]string{ + "Content-Type": generatedTusCreationWithUploadContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreationWithUploadLength, + "Upload-Metadata": encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCreationWithUploadResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCreationWithUploadPath, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreationWithUploadOffset, + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusCreationWithUploadEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusCreationWithUploadExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + source := &generatedTusCreationWithUploadSource{ + Reader: strings.NewReader(generatedTusCreationWithUploadContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: "contract-creation-with-upload-fingerprint", + Size: int64(len(generatedTusCreationWithUploadContent)), + Metadata: generatedTusCreationWithUploadMetadata, + UploadDataDuringCreation: true, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusCreationWithUploadBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != server.URL+generatedTusCreationWithUploadPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusCreationWithUploadPath, upload.Location) + } + if upload.RemoteOffset != int64(len(generatedTusCreationWithUploadContent)) { + t.Fatalf("expected upload offset %d, got %d", len(generatedTusCreationWithUploadContent), upload.RemoteOffset) + } + if requestCount != generatedTusCreationWithUploadExpectedRequests { + t.Fatalf("expected %d creation-with-upload request(s), got %d", generatedTusCreationWithUploadExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "creationWithUpload", generatedTusCreationWithUploadEventPolicy, generatedTusCreationWithUploadExtraEventPrefixes, generatedTusCreationWithUploadExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected creation-with-upload URL to be stored once, got %#v", storedUploads) + } +} + +type generatedTusCreationWithUploadSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusCreationWithUploadSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedTusCreationWithUploadBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusCreationWithUploadRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusCreationWithUploadResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCreationWithUploadOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCreationWithUploadOffsetHeader, value) + } +} diff --git a/url_storage_creation_with_upload_partial_contract_test.go b/url_storage_creation_with_upload_partial_contract_test.go new file mode 100644 index 0000000..c1765fb --- /dev/null +++ b/url_storage_creation_with_upload_partial_contract_test.go @@ -0,0 +1,325 @@ +package tusgo + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const ( + generatedTusCreationPartialContent = "hello world" + generatedTusCreationPartialContentType = "application/offset+octet-stream" + generatedTusCreationPartialContentTypeHeader = "Content-Type" + generatedTusCreationPartialCreateBodySize = 5 + generatedTusCreationPartialEndpointPath = "/uploads" + generatedTusCreationPartialEventPolicy = "exact-except-allowed-extra-events" + generatedTusCreationPartialExpectedRequests = 3 + generatedTusCreationPartialLength = "11" + generatedTusCreationPartialLengthHeader = "Upload-Length" + generatedTusCreationPartialMetadataHeader = "Upload-Metadata" + generatedTusCreationPartialOffset = "5" + generatedTusCreationPartialOffsetHeader = "Upload-Offset" + generatedTusCreationPartialPath = "/uploads/creation-with-upload-partial-contract" + generatedTusCreationPartialChunkSize = 5 +) + +type generatedTusCreationPartialPatchAttempt struct { + BodySize int + BodyStart int + Offset string + Result string + Status int +} + +var generatedTusCreationPartialExtraEventPrefixes = []string{"progress:"} +var generatedTusCreationPartialExpectedEvents = []string{"progress:0:11", "progress:5:11", "upload-url-available", "chunk-complete:5:5:11", "progress:5:11", "progress:10:11", "chunk-complete:5:10:11", "progress:10:11", "progress:11:11", "chunk-complete:1:11:11", "success", "source-close"} +var generatedTusCreationPartialMetadata = map[string]string{"filename": "hello.txt"} +var generatedTusCreationPartialPatchAttempts = []generatedTusCreationPartialPatchAttempt{ + { + BodySize: 5, + BodyStart: 5, + Offset: "5", + Result: "10", + Status: 204, + }, + { + BodySize: 1, + BodyStart: 10, + Offset: "10", + Result: "11", + Status: 204, + }, +} + +func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusCreationPartialMetadata) + if err != nil { + t.Fatal(err) + } + + requestCount := 0 + patchRequestCount := 0 + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusCreationPartialEndpointPath && + request.Method == createOperation.Method: + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + expectedBody := generatedTusCreationPartialContent[:generatedTusCreationPartialCreateBodySize] + if string(body) != expectedBody { + recordRequestErr(fmt.Errorf( + "expected partial creation body %q, got %q", + expectedBody, + string(body), + )) + } + if actual := request.Header.Get(generatedTusCreationPartialContentTypeHeader); actual != generatedTusCreationPartialContentType { + recordRequestErr(fmt.Errorf( + "expected partial creation content type %s, got %s", + generatedTusCreationPartialContentType, + actual, + )) + } + recordRequestErr(generatedAssertTusCreationPartialRequestHeaders( + request, + createOperation, + map[string]string{ + "Content-Type": generatedTusCreationPartialContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreationPartialLength, + "Upload-Metadata": encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCreationPartialResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCreationPartialPath, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreationPartialOffset, + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusCreationPartialPath && + request.Method == patchOperation.Method: + requestCount += 1 + patchRequestCount += 1 + if patchRequestCount > len(generatedTusCreationPartialPatchAttempts) { + recordRequestErr(fmt.Errorf("unexpected continuation request %d", patchRequestCount)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + attempt := generatedTusCreationPartialPatchAttempts[patchRequestCount-1] + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + expectedBodyEnd := attempt.BodyStart + attempt.BodySize + expectedBody := generatedTusCreationPartialContent[attempt.BodyStart:expectedBodyEnd] + if len(expectedBody) != attempt.BodySize { + recordRequestErr(fmt.Errorf( + "expected configured patch body size %d, got %d", + attempt.BodySize, + len(expectedBody), + )) + } + if string(body) != expectedBody { + recordRequestErr(fmt.Errorf( + "expected continuation body %q, got %q", + expectedBody, + string(body), + )) + } + if actual := request.Header.Get(generatedTusCreationPartialContentTypeHeader); actual != generatedTusCreationPartialContentType { + recordRequestErr(fmt.Errorf( + "expected continuation content type %s, got %s", + generatedTusCreationPartialContentType, + actual, + )) + } + recordRequestErr(generatedAssertTusCreationPartialRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusCreationPartialContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": attempt.Offset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, attempt.Status) + generatedWriteTusCreationPartialResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": attempt.Result, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusCreationPartialEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusCreationWithUploadExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + source := &generatedTusCreationPartialSource{ + Reader: strings.NewReader(generatedTusCreationPartialContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: "contract-creation-with-upload-partial-fingerprint", + Size: int64(len(generatedTusCreationPartialContent)), + Metadata: generatedTusCreationPartialMetadata, + ChunkSize: int64(generatedTusCreationPartialChunkSize), + UploadDataDuringCreation: true, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusCreationPartialBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusCreationPartialBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + select { + case requestErr := <-requestErrs: + t.Fatalf("%v: %v", err, requestErr) + default: + t.Fatal(err) + } + } + if upload.Location != server.URL+generatedTusCreationPartialPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusCreationPartialPath, upload.Location) + } + if upload.RemoteOffset != int64(len(generatedTusCreationPartialContent)) { + t.Fatalf("expected upload offset %d, got %d", len(generatedTusCreationPartialContent), upload.RemoteOffset) + } + if requestCount != generatedTusCreationPartialExpectedRequests { + t.Fatalf("expected %d partial creation request(s), got %d", generatedTusCreationPartialExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "creationWithUploadPartialChunk", generatedTusCreationPartialEventPolicy, generatedTusCreationPartialExtraEventPrefixes, generatedTusCreationPartialExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-partial-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected partial creation URL to be stored once, got %#v", storedUploads) + } +} + +type generatedTusCreationPartialSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusCreationPartialSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedTusCreationPartialBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusCreationPartialRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusCreationPartialResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCreationPartialOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCreationPartialOffsetHeader, value) + } +} diff --git a/url_storage_custom_headers_contract_test.go b/url_storage_custom_headers_contract_test.go new file mode 100644 index 0000000..9244ba3 --- /dev/null +++ b/url_storage_custom_headers_contract_test.go @@ -0,0 +1,216 @@ +package tusgo + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const ( + generatedTusCustomHeadersContent = "hello world" + generatedTusCustomHeadersContentType = "application/offset+octet-stream" + generatedTusCustomHeadersContentTypeHeader = "Content-Type" + generatedTusCustomHeadersEndpointPath = "/uploads" + generatedTusCustomHeadersExpectedRequests = 2 + generatedTusCustomHeadersLength = "11" + generatedTusCustomHeadersLengthHeader = "Upload-Length" + generatedTusCustomHeadersMetadataHeader = "Upload-Metadata" + generatedTusCustomHeadersOffset = "0" + generatedTusCustomHeadersOffsetHeader = "Upload-Offset" + generatedTusCustomHeadersPath = "/uploads/custom-headers-contract" + generatedTusCustomHeadersAcceptedOffset = "11" +) + +var generatedTusCustomHeaders = map[string]string{"X-Tus-Contract": "custom-header", "X-Tus-Trace": "trace-123"} +var generatedTusCustomHeadersMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusCustomHeadersMetadata) + if err != nil { + t.Fatal(err) + } + + requestCount := 0 + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusCustomHeadersEndpointPath && + request.Method == createOperation.Method: + requestCount += 1 + recordRequestErr(generatedAssertTusCustomRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCustomHeadersLength, + "Upload-Metadata": encodedMetadata, + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123", + }, + )) + recordRequestErr(generatedAssertTusCustomHeaderValues(request, generatedTusCustomHeaders)) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCustomResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCustomHeadersPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusCustomHeadersPath && + request.Method == patchOperation.Method: + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusCustomHeadersContent { + recordRequestErr(fmt.Errorf( + "expected custom-header upload body %q, got %q", + generatedTusCustomHeadersContent, + string(body), + )) + } + recordRequestErr(generatedAssertTusCustomRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusCustomHeadersContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCustomHeadersOffset, + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123", + }, + )) + recordRequestErr(generatedAssertTusCustomHeaderValues(request, generatedTusCustomHeaders)) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusCustomResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCustomHeadersAcceptedOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusCustomHeadersEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusCustomHeadersContent), + Fingerprint: "contract-custom-headers-fingerprint", + Size: 11, + Headers: generatedTusCustomHeaders, + Metadata: generatedTusCustomHeadersMetadata, + }) + if err != nil { + select { + case requestErr := <-requestErrs: + t.Fatalf("%v: %v", err, requestErr) + default: + t.Fatal(err) + } + } + if upload.Location != server.URL+generatedTusCustomHeadersPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusCustomHeadersPath, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + if requestCount != generatedTusCustomHeadersExpectedRequests { + t.Fatalf("expected %d custom-header request(s), got %d", generatedTusCustomHeadersExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } +} + +func generatedAssertTusCustomHeaderValues( + request *http.Request, + expected map[string]string, +) error { + for key, value := range expected { + if actual := request.Header.Get(key); actual != value { + return fmt.Errorf("expected custom header %s=%s, got %s", key, value, actual) + } + } + + return nil +} + +func generatedAssertTusCustomRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusCustomResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCustomHeadersOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCustomHeadersOffsetHeader, value) + } +} diff --git a/url_storage_deferred_length_contract_test.go b/url_storage_deferred_length_contract_test.go new file mode 100644 index 0000000..71c647f --- /dev/null +++ b/url_storage_deferred_length_contract_test.go @@ -0,0 +1,295 @@ +package tusgo + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const ( + generatedTusDeferredLengthAcceptedOffset = "11" + generatedTusDeferredLengthContent = "hello world" + generatedTusDeferredLengthContentTypeHeader = "Content-Type" + generatedTusDeferredLengthCreateDeferHeader = "Upload-Defer-Length" + generatedTusDeferredLengthCreateDeferValue = "1" + generatedTusDeferredLengthEndpointPath = "/uploads" + generatedTusDeferredLengthEventPolicy = "exact-except-allowed-extra-events" + generatedTusDeferredLengthExpectedCreates = 1 + generatedTusDeferredLengthExpectedPatches = 1 + generatedTusDeferredLengthMetadataHeader = "Upload-Metadata" + generatedTusDeferredLengthPatchLength = "11" + generatedTusDeferredLengthPatchLengthHeader = "Upload-Length" + generatedTusDeferredLengthPatchOffset = "0" + generatedTusDeferredLengthPatchOffsetHeader = "Upload-Offset" + generatedTusDeferredLengthUploadPath = "/uploads/deferred-contract" +) + +var generatedTusDeferredLengthCreateAbsentHeaders = []string{"Upload-Length"} +var generatedTusDeferredLengthExtraEventPrefixes = []string{"progress:"} +var generatedTusDeferredLengthExpectedEvents = []string{"upload-url-available", "progress:0:11", "progress:11:11", "chunk-complete:11:11:11", "success", "source-close"} +var generatedTusDeferredLengthMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusDeferredLengthMetadata) + if err != nil { + t.Fatal(err) + } + + createCount := 0 + patchCount := 0 + requestErrs := make(chan error, 6) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusDeferredLengthEndpointPath && + request.Method == createOperation.Method: + createCount += 1 + recordRequestErr(generatedAssertTusDeferredLengthAbsentHeaders( + request, + generatedTusDeferredLengthCreateAbsentHeaders, + )) + recordRequestErr(generatedAssertTusDeferredLengthRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Defer-Length": generatedTusDeferredLengthCreateDeferValue, + "Upload-Metadata": encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusDeferredLengthResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusDeferredLengthUploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusDeferredLengthUploadPath && + request.Method == patchOperation.Method: + patchCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusDeferredLengthContent { + recordRequestErr(fmt.Errorf( + "expected deferred upload body %q, got %q", + generatedTusDeferredLengthContent, + string(body), + )) + } + recordRequestErr(generatedAssertTusDeferredLengthRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusDeferredLengthPatchLength, + "Upload-Offset": generatedTusDeferredLengthPatchOffset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusDeferredLengthResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusDeferredLengthAcceptedOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusDeferredLengthEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusDeferredLengthExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + source := &generatedTusDeferredLengthSource{ + Reader: strings.NewReader(generatedTusDeferredLengthContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: "contract-deferred-length-fingerprint", + Size: int64(len(generatedTusDeferredLengthContent)), + Metadata: generatedTusDeferredLengthMetadata, + ChunkSize: 100, + UploadLengthDeferred: true, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusDeferredLengthBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusDeferredLengthBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != server.URL+generatedTusDeferredLengthUploadPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusDeferredLengthUploadPath, upload.Location) + } + if createCount != generatedTusDeferredLengthExpectedCreates || patchCount != generatedTusDeferredLengthExpectedPatches { + t.Fatalf( + "expected create=%d patch=%d, got create=%d patch=%d", + generatedTusDeferredLengthExpectedCreates, + generatedTusDeferredLengthExpectedPatches, + createCount, + patchCount, + ) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "deferredLengthUpload", generatedTusDeferredLengthEventPolicy, generatedTusDeferredLengthExtraEventPrefixes, generatedTusDeferredLengthExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-deferred-length-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected deferred upload URL to be stored once, got %#v", storedUploads) + } +} + +type generatedTusDeferredLengthSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusDeferredLengthSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedTusDeferredLengthBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusDeferredLengthAbsentHeaders( + request *http.Request, + headers []string, +) error { + for _, header := range headers { + if actual := request.Header.Get(header); actual != "" { + return fmt.Errorf("expected request header %s to be absent, got %s", header, actual) + } + } + + return nil +} + +func generatedAssertTusDeferredLengthRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusDeferredLengthRequestHeaderVariant(request, variant, values); err != nil { + failures = append(failures, err.Error()) + continue + } + + return nil + } + + return fmt.Errorf( + "no %s request header variant matched: %s", + operation.OperationID, + strings.Join(failures, "; "), + ) +} + +func generatedAssertTusDeferredLengthRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusDeferredLengthResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_event_hooks_contract_test.go b/url_storage_event_hooks_contract_test.go new file mode 100644 index 0000000..a06fc39 --- /dev/null +++ b/url_storage_event_hooks_contract_test.go @@ -0,0 +1,222 @@ +package tusgo + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusEventHooksContent = "hello world" + generatedTusEventHooksCreatedUploadPath = "/uploads/generated-contract" + generatedTusEventHooksEndpointPath = "/uploads" + generatedTusEventHooksEventPolicy = "exact-except-allowed-extra-events" + generatedTusEventHooksFingerprint = "contract-single-fingerprint" + generatedTusEventHooksPatchAcceptedOffset = "11" + generatedTusEventHooksPatchBody = "hello world" + generatedTusEventHooksPatchOffset = "0" + generatedTusEventHooksUploadLength = "11" +) + +var generatedTusEventHooksExtraEventPrefixes = []string{"progress:"} +var generatedTusEventHooksExpectedEvents = []string{"upload-url-available", "progress:0:11", "progress:11:11", "chunk-complete:11:11:11", "success", "source-close"} +var generatedTusEventHooksMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageEventHooks(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusEventHooksEndpointPath) + if err != nil { + t.Fatal(err) + } + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + createdUploadURL := srvMock.URL() + generatedTusEventHooksCreatedUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusEventHooksMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageEventHooksResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageEventHooksRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusEventHooksEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusEventHooksUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageEventHooksResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusEventHooksPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageEventHooksRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusEventHooksCreatedUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusEventHooksPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusEventHooksPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + events := []string{} + source := &generatedTusEventHooksSource{ + Reader: strings.NewReader(generatedTusEventHooksContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: generatedTusEventHooksFingerprint, + Size: 11, + Metadata: generatedTusEventHooksMetadata, + EventHooks: UploadEventHooks{ + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append( + events, + generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusEventHooksTotal(bytesTotal), + ), + ) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append( + events, + generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusEventHooksTotal(bytesTotal), + ), + ) + return nil + }, + OnSuccess: func(payload UploadSuccessPayload) error { + if payload.Upload == nil || payload.Upload.Location != createdUploadURL { + return fmt.Errorf("expected success upload URL %s, got %#v", createdUploadURL, payload.Upload) + } + if payload.LastResponse == nil || payload.LastResponse.StatusCode != patchResponse.StatusCode { + return fmt.Errorf( + "expected success response status %d, got %#v", + patchResponse.StatusCode, + payload.LastResponse, + ) + } + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected created upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + generatedTusAssertEvents(t, "singleUploadLifecycle", generatedTusEventHooksEventPolicy, generatedTusEventHooksExtraEventPrefixes, generatedTusEventHooksExpectedEvents, events) +} + +func generatedTusEventHooksTotal(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +type generatedTusEventHooksSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusEventHooksSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedURLStorageEventHooksRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageEventHooksResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_file_contract_test.go b/url_storage_file_contract_test.go new file mode 100644 index 0000000..d85a520 --- /dev/null +++ b/url_storage_file_contract_test.go @@ -0,0 +1,211 @@ +package tusgo + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusFileFlowContent = "hello world" + generatedTusFileFlowCreatedUploadPath = "/uploads/node-path-contract" + generatedTusFileFlowEndpointPath = "/uploads" + generatedTusFileFlowFingerprintExpected = "node-file-/tmp/tus-contract-file.bin-11-1700000000123-https://tus.io/uploads" + generatedTusFileFlowFingerprintFixtureEndpoint = "https://tus.io/uploads" + generatedTusFileFlowFingerprintFixtureMtimeMs = 1700000000123 + generatedTusFileFlowFingerprintFixturePath = "/tmp/tus-contract-file.bin" + generatedTusFileFlowPatchAcceptedOffset = "11" + generatedTusFileFlowPatchBody = "hello world" + generatedTusFileFlowPatchOffset = "0" + generatedTusFileFlowRemoveFingerprintOnSuccess = false + generatedTusFileFlowUploadLength = "11" +) + +var generatedTusFileFlowMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedFileFingerprint(t *testing.T) { + fingerprint := FileFingerprint(FileFingerprintInput{ + AbsolutePath: generatedTusFileFlowFingerprintFixturePath, + Endpoint: generatedTusFileFlowFingerprintFixtureEndpoint, + MtimeMs: generatedTusFileFlowFingerprintFixtureMtimeMs, + Size: 11, + }) + if fingerprint != generatedTusFileFlowFingerprintExpected { + t.Fatalf("expected file fingerprint %s, got %s", generatedTusFileFlowFingerprintExpected, fingerprint) + } +} + +func TestGeneratedURLStorageFileFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusFileFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + + filePath := filepath.Join(t.TempDir(), "tus-contract-input.bin") + if err := os.WriteFile(filePath, []byte(generatedTusFileFlowContent), 0o600); err != nil { + t.Fatal(err) + } + mtime := time.Unix(0, generatedTusFileFlowFingerprintFixtureMtimeMs*int64(time.Millisecond)) + if err := os.Chtimes(filePath, mtime, mtime); err != nil { + t.Fatal(err) + } + absolutePath, err := filepath.Abs(filePath) + if err != nil { + t.Fatal(err) + } + expectedFingerprint := FileFingerprint(FileFingerprintInput{ + AbsolutePath: absolutePath, + Endpoint: baseURL.String(), + MtimeMs: generatedTusFileFlowFingerprintFixtureMtimeMs, + Size: 11, + }) + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storagePath := filepath.Join(t.TempDir(), "url-storage.json") + createdUploadURL := srvMock.URL() + generatedTusFileFlowCreatedUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusFileFlowMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageFileResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageFileRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusFileFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusFileFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageFileResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusFileFlowPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageFileRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusFileFlowCreatedUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusFileFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusFileFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload, err := client.UploadFileWithFileBackedURLStorage(FileBackedURLStorageUploadOptions{ + URLStoragePath: storagePath, + Path: filePath, + Metadata: generatedTusFileFlowMetadata, + RemoveFingerprintOnSuccess: generatedTusFileFlowRemoveFingerprintOnSuccess, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected created upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + + storage := NewFileURLStorage(storagePath) + storedUploads, err := storage.FindUploadsByFingerprint(expectedFingerprint) + if err != nil { + t.Fatal(err) + } + shouldRemoveStoredUpload := generatedTusShouldRemoveStoredUploadOnSuccess( + generatedTusFileFlowRemoveFingerprintOnSuccess, + ) + if shouldRemoveStoredUpload { + if len(storedUploads) != 0 { + t.Fatalf("expected successful file flow to remove stored upload, got %#v", storedUploads) + } + return + } + if len(storedUploads) != 1 { + t.Fatalf("expected successful file flow to store one upload, got %#v", storedUploads) + } + storedUploadURL, ok := stringFromURLStorageUpload(storedUploads[0], "uploadUrl") + if !ok || storedUploadURL != createdUploadURL { + t.Fatalf("expected stored upload URL %s, got %#v", createdUploadURL, storedUploads[0]) + } +} + +func generatedURLStorageFileRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageFileResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_generated.go b/url_storage_generated.go new file mode 100644 index 0000000..46f0067 --- /dev/null +++ b/url_storage_generated.go @@ -0,0 +1,1778 @@ +// Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +// If it looks wrong, please report the issue instead of editing this file by hand; +// the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +package tusgo + +import ( + "bytes" + "context" + cryptoRand "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/rand" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + generatedTusNodeFileFingerprintPrefix = "node-file" + generatedTusNodeFileFingerprintSeparator = "-" + generatedTusAbortTerminateRequiresRequest = true + generatedTusAbortTerminateRequiresUploadURL = true + generatedTusAbortTerminateRemovesStoredURL = true + generatedTusDetailedCauseStringTemplate = "Error: {message}" + generatedTusDetailedCausedByTemplate = ", caused by {cause}" + generatedTusDetailedEmptyResponseBody = "" + generatedTusDetailedMissingValue = "n/a" + generatedTusDetailedRequestContextTemplate = ", originated from request (method: {method}, url: {url}, response code: {status}, response text: {body}, request id: {requestId})" + generatedTusCreateUploadRequestFailed = "tus: failed to create upload" + generatedTusUnexpectedCreateResponse = "tus: unexpected response while creating upload" + generatedTusCreationWithUploadExtension = "creation-with-upload" + generatedTusDeferredLengthExtension = "creation-defer-length" + generatedTusDefaultParallelUploads = 1 + generatedTusMinimumParallelUploads = 2 + generatedTusValidationParallelDeferred = "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled" + generatedTusValidationParallelCreateData = "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled" + generatedTusParallelCleanupCreatedPartials = true + generatedTusParallelCleanupRequiresAbort = true + generatedTusParallelExecutionCancelOnError = true + generatedTusRetryClientErrorStatus = 400 + generatedTusRetryStatusCategoryDivisor = 100 + generatedTusRequestIDHeaderName = "X-Request-ID" + generatedTusSuccessCloseSourceAfterHook = true + generatedTusSuccessCloseSourceRequiresSrc = true + generatedTusSuccessEmitAfterUploadComplete = true + generatedTusSuccessRemoveStoredBeforeHook = true + generatedTusSuccessRemoveStoredRequiresOpt = true + generatedTusURLStorageRemoveOnSuccessEnable = true + generatedTusURLStorageRemoveRequiresOpt = true + generatedTusUploadURLAvailableCreate = "after-url-known-before-storage" + generatedTusUploadURLAvailableParallel = "not-emitted" + generatedTusUploadURLAvailableResume = "after-url-known-before-storage" + generatedTusURLStorageIDMultiplier = 1000000000000 + generatedTusURLStorageNamespace = "tus" + generatedTusURLStorageSeparator = "::" +) + +type generatedTusMethodOverride struct { + HeaderName string + HeaderValue string + InputFlag string + Method string + OperationID string + SourceMethod string +} + +var generatedTusMethodOverrides = []generatedTusMethodOverride{ + { + HeaderName: "X-HTTP-Method-Override", + HeaderValue: "PATCH", + InputFlag: "overridePatchMethod", + Method: "POST", + OperationID: "patchTusUpload", + SourceMethod: "PATCH", + }, +} +var generatedTusDefaultRetryDelays = []time.Duration{0 * time.Millisecond, 1000 * time.Millisecond, 3000 * time.Millisecond, 5000 * time.Millisecond} +var generatedTusRetryableClientStatusCodes = []int{409, 423} + +type FileFingerprintInput struct { + AbsolutePath string + Endpoint string + MtimeMs int64 + Size int64 +} + +type URLStorageUpload map[string]any + +type UploadSuccessPayload struct { + Upload *Upload + LastResponse *http.Response +} + +type UploadEventHooks struct { + OnProgress func(bytesSent int64, bytesTotal *int64) error + OnChunkComplete func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error + OnSuccess func(UploadSuccessPayload) error + OnUploadURLAvailable func() error +} + +type URLStorage interface { + FindAllUploads() ([]URLStorageUpload, error) + FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) + RemoveUpload(urlStorageKey string) error + AddUpload(fingerprint string, upload URLStorageUpload) (string, error) +} + +type URLStorageUploadOptions struct { + Context context.Context + Storage URLStorage + Source io.ReadSeeker + Fingerprint string + Size int64 + AddRequestID bool + Headers map[string]string + Metadata map[string]string + MetadataForPartialUploads map[string]string + OverridePatchMethod bool + ParallelUploads int + RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool + ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks +} + +type URLStorageFileUploadOptions struct { + Context context.Context + Storage URLStorage + Path string + AddRequestID bool + Headers map[string]string + Metadata map[string]string + MetadataForPartialUploads map[string]string + OverridePatchMethod bool + ParallelUploads int + RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool + ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks +} + +type FileBackedURLStorageUploadOptions struct { + Context context.Context + URLStoragePath string + Path string + AddRequestID bool + Headers map[string]string + Metadata map[string]string + MetadataForPartialUploads map[string]string + OverridePatchMethod bool + ParallelUploads int + RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool + ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks +} + +type generatedTusSuccessInput struct { + EventHooks UploadEventHooks + LastResponse *http.Response + RemoveFingerprintOnSuccess bool + Source io.ReadSeeker + Storage URLStorage + StorageKey string + Upload *Upload +} + +type generatedTusParallelPartInput struct { + Bytes []byte + Index int + Size int64 +} + +type generatedTusParallelPartResult struct { + Err error + Index int + LastResponse *http.Response + Size int64 + Upload Upload +} + +type MemoryURLStorage struct { + mu sync.Mutex + records map[string]URLStorageUpload +} + +func NewMemoryURLStorage() *MemoryURLStorage { + return &MemoryURLStorage{ + records: map[string]URLStorageUpload{}, + } +} + +func (storage *MemoryURLStorage) FindAllUploads() ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + return urlStorageUploadsWithPrefix(storage.records, URLStorageAllUploadsPrefix()) +} + +func (storage *MemoryURLStorage) FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + return urlStorageUploadsWithPrefix(storage.records, URLStorageFingerprintPrefix(fingerprint)) +} + +func (storage *MemoryURLStorage) RemoveUpload(urlStorageKey string) error { + storage.mu.Lock() + defer storage.mu.Unlock() + + delete(storage.records, urlStorageKey) + return nil +} + +func (storage *MemoryURLStorage) AddUpload(fingerprint string, upload URLStorageUpload) (string, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + key := newURLStorageKey(fingerprint) + cloned, err := cloneURLStorageUpload(upload) + if err != nil { + return "", err + } + + storage.records[key] = cloned + return key, nil +} + +type FileURLStorage struct { + path string + mu sync.Mutex +} + +func NewFileURLStorage(path string) *FileURLStorage { + return &FileURLStorage{path: path} +} + +func (storage *FileURLStorage) FindAllUploads() ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return nil, err + } + + return urlStorageUploadsWithPrefix(records, URLStorageAllUploadsPrefix()) +} + +func (storage *FileURLStorage) FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return nil, err + } + + return urlStorageUploadsWithPrefix(records, URLStorageFingerprintPrefix(fingerprint)) +} + +func (storage *FileURLStorage) RemoveUpload(urlStorageKey string) error { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return err + } + + delete(records, urlStorageKey) + return storage.writeRecords(records) +} + +func (storage *FileURLStorage) AddUpload(fingerprint string, upload URLStorageUpload) (string, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return "", err + } + + key := newURLStorageKey(fingerprint) + cloned, err := cloneURLStorageUpload(upload) + if err != nil { + return "", err + } + + records[key] = cloned + return key, storage.writeRecords(records) +} + +func (storage *FileURLStorage) readRecords() (map[string]URLStorageUpload, error) { + data, err := os.ReadFile(storage.path) + if errors.Is(err, os.ErrNotExist) { + return map[string]URLStorageUpload{}, nil + } + if err != nil { + return nil, err + } + + if len(strings.TrimSpace(string(data))) == 0 { + return map[string]URLStorageUpload{}, nil + } + + records := map[string]URLStorageUpload{} + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&records); err != nil { + return nil, err + } + + return records, nil +} + +func (storage *FileURLStorage) writeRecords(records map[string]URLStorageUpload) error { + data, err := json.Marshal(records) + if err != nil { + return err + } + + return os.WriteFile(storage.path, data, 0o660) +} + +func URLStorageAllUploadsPrefix() string { + return generatedTusURLStorageNamespace + generatedTusURLStorageSeparator +} + +func URLStorageFingerprintPrefix(fingerprint string) string { + return URLStorageAllUploadsPrefix() + fingerprint + generatedTusURLStorageSeparator +} + +func URLStorageKey(fingerprint string, id int64) string { + return fmt.Sprintf("%s%d", URLStorageFingerprintPrefix(fingerprint), id) +} + +func URLStorageID(randomValue float64) int64 { + return int64(math.Round(randomValue * generatedTusURLStorageIDMultiplier)) +} + +func newURLStorageKey(fingerprint string) string { + return URLStorageKey(fingerprint, URLStorageID(rand.Float64())) +} + +func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorageUploadOptions) (*Upload, error) { + return c.UploadFileWithURLStorage(URLStorageFileUploadOptions{ + Context: options.Context, + Storage: NewFileURLStorage(options.URLStoragePath), + Path: options.Path, + AddRequestID: options.AddRequestID, + Headers: options.Headers, + Metadata: options.Metadata, + MetadataForPartialUploads: options.MetadataForPartialUploads, + OverridePatchMethod: options.OverridePatchMethod, + ParallelUploads: options.ParallelUploads, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + TerminateUploadOnAbort: options.TerminateUploadOnAbort, + UploadDataDuringCreation: options.UploadDataDuringCreation, + UploadLengthDeferred: options.UploadLengthDeferred, + ChunkSize: options.ChunkSize, + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + EventHooks: options.EventHooks, + }) +} + +func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) (*Upload, error) { + absolutePath, err := nodeFileFingerprintPath(options.Path) + if err != nil { + return nil, err + } + + file, err := os.Open(absolutePath) + if err != nil { + return nil, err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, err + } + + fingerprint := FileFingerprint(FileFingerprintInput{ + AbsolutePath: absolutePath, + Endpoint: c.BaseURL.String(), + MtimeMs: fileModTimeMilliseconds(info), + Size: info.Size(), + }) + + return c.UploadWithURLStorage(URLStorageUploadOptions{ + Context: options.Context, + Storage: options.Storage, + Source: file, + Fingerprint: fingerprint, + Size: info.Size(), + AddRequestID: options.AddRequestID, + Headers: options.Headers, + Metadata: options.Metadata, + MetadataForPartialUploads: options.MetadataForPartialUploads, + OverridePatchMethod: options.OverridePatchMethod, + ParallelUploads: options.ParallelUploads, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + TerminateUploadOnAbort: options.TerminateUploadOnAbort, + UploadDataDuringCreation: options.UploadDataDuringCreation, + UploadLengthDeferred: options.UploadLengthDeferred, + ChunkSize: options.ChunkSize, + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + EventHooks: options.EventHooks, + }) +} + +func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, error) { + if options.Storage == nil { + return nil, errors.New("tus: URL storage is required") + } + if options.Source == nil { + return nil, errors.New("tus: upload source is required") + } + if options.Fingerprint == "" { + return nil, errors.New("tus: unable to calculate fingerprint for this input file") + } + + uploadClient := generatedTusClientWithUploadContext(c, options.Context) + parallelUploads, err := generatedTusParallelUploadCount(options.ParallelUploads) + if err != nil { + return nil, err + } + if err := generatedTusValidateURLStorageUploadOptions(options, parallelUploads); err != nil { + return nil, err + } + uploadClient, detailedErrorRecorder := generatedTusClientWithURLStorageRequestPolicy( + uploadClient, + options, + ) + if parallelUploads > 1 { + return c.uploadParallelWithURLStorage(options, uploadClient, parallelUploads) + } + + upload, storageKey, err := uploadClient.resumeUploadFromURLStorage(options) + if err != nil { + return upload, err + } + var lastResponse *http.Response + if upload == nil { + upload, storageKey, lastResponse, err = uploadClient.createUploadForURLStorage( + options, + detailedErrorRecorder, + ) + if err != nil { + return upload, err + } + } + + stream := NewUploadStream(uploadClient, upload) + if options.ChunkSize != 0 { + stream.ChunkSize = options.ChunkSize + } + if options.UploadLengthDeferred { + stream.SetUploadSize = true + } + if err := uploadClient.uploadURLStorageSource(options, stream); err != nil { + return upload, uploadClient.generatedTusHandleURLStorageUploadAbort( + options, + upload, + storageKey, + err, + ) + } + if stream.LastResponse != nil { + lastResponse = stream.LastResponse + } + if err := generatedTusEmitSuccess(generatedTusSuccessInput{ + EventHooks: options.EventHooks, + LastResponse: lastResponse, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + Source: options.Source, + Storage: options.Storage, + StorageKey: storageKey, + Upload: upload, + }); err != nil { + return upload, err + } + + return upload, nil +} + +func (c *Client) uploadParallelWithURLStorage( + options URLStorageUploadOptions, + uploadClient *Client, + parallelUploads int, +) (*Upload, error) { + partSizes, err := generatedTusParallelUploadPartSizes(options.Size, parallelUploads) + if err != nil { + return nil, err + } + partInputs, err := generatedTusParallelUploadPartInputs(options.Source, partSizes) + if err != nil { + return nil, err + } + + parallelCtx, cancelParallelUploads := generatedTusParallelUploadContext(options.Context) + defer cancelParallelUploads() + parallelClient := uploadClient.WithContext(parallelCtx) + results := make([]generatedTusParallelPartResult, len(partInputs)) + resultCh := make(chan generatedTusParallelPartResult, len(partInputs)) + var workers sync.WaitGroup + for _, partInput := range partInputs { + workers.Add(1) + go func(partInput generatedTusParallelPartInput) { + defer workers.Done() + result := parallelClient.uploadParallelPartWithURLStorage(options, partInput) + if result.Err != nil && generatedTusParallelExecutionCancelOnError { + cancelParallelUploads() + } + resultCh <- result + }(partInput) + } + workers.Wait() + close(resultCh) + + for result := range resultCh { + results[result.Index] = result + } + if err := generatedTusParallelUploadError(results); err != nil { + return generatedTusFirstCreatedParallelPartialUpload(results), + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) + } + + partials := make([]Upload, 0, len(results)) + acceptedBytes := int64(0) + for _, result := range results { + acceptedBytes += result.Size + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + acceptedBytes, + options.Size, + ); err != nil { + return &result.Upload, + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) + } + if err := generatedTusEmitChunkCompleteAfterChunkAccepted( + options.EventHooks, + result.Size, + acceptedBytes, + options.Size, + ); err != nil { + return &result.Upload, + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) + } + partials = append(partials, result.Upload) + } + + finalUpload := &Upload{} + response, err := uploadClient.ConcatenateUploads(finalUpload, partials, options.Metadata) + if err != nil { + return finalUpload, uploadClient.generatedTusCleanupParallelPartialUploads( + options, + results, + err, + ) + } + if err := uploadClient.generatedTusResolveCreatedUploadLocation(finalUpload); err != nil { + return finalUpload, err + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "parallelFinalUpload"); err != nil { + return finalUpload, err + } + storageKey, err := options.Storage.AddUpload( + options.Fingerprint, + URLStorageUploadFromUpload(*finalUpload), + ) + if err != nil { + return finalUpload, err + } + if err := generatedTusEmitSuccess(generatedTusSuccessInput{ + EventHooks: options.EventHooks, + LastResponse: response, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + Source: options.Source, + Storage: options.Storage, + StorageKey: storageKey, + Upload: finalUpload, + }); err != nil { + return finalUpload, err + } + + return finalUpload, nil +} + +func (c *Client) uploadParallelPartWithURLStorage( + options URLStorageUploadOptions, + partInput generatedTusParallelPartInput, +) generatedTusParallelPartResult { + result := generatedTusParallelPartResult{ + Index: partInput.Index, + Size: partInput.Size, + } + partialUpload := Upload{} + response, err := c.CreateUpload( + &partialUpload, + partInput.Size, + true, + generatedTusParallelPartialUploadMetadata(options), + ) + result.LastResponse = response + result.Upload = partialUpload + if err != nil { + result.Err = err + return result + } + if err := c.generatedTusResolveCreatedUploadLocation(&partialUpload); err != nil { + result.Err = err + return result + } + + stream := NewUploadStream(c, &partialUpload) + stream.ChunkSize = partInput.Size + written, err := stream.Write(partInput.Bytes) + result.LastResponse = stream.LastResponse + result.Upload = partialUpload + if err != nil { + result.Err = err + return result + } + if int64(written) != partInput.Size { + result.Err = fmt.Errorf( + "tus: expected to upload %d parallel bytes, wrote %d", + partInput.Size, + written, + ) + } + + return result +} + +func (c *Client) uploadURLStorageSource( + options URLStorageUploadOptions, + stream *UploadStream, +) error { + retryDelays := generatedTusRetryDelays(options.RetryDelays) + retryAttempt := 0 + offsetBeforeRetry := stream.Upload.RemoteOffset + + for { + if _, err := options.Source.Seek(stream.Upload.RemoteOffset, io.SeekStart); err != nil { + return err + } + chunk, err := readURLStorageUploadChunk( + options.Source, + stream.ChunkSize, + options.Size-stream.Upload.RemoteOffset, + ) + if err != nil { + return err + } + if len(chunk) == 0 { + return nil + } + + startOffset := stream.Upload.RemoteOffset + if err := generatedTusEmitProgressBeforeRequestBody( + options.EventHooks, + startOffset, + options.Size, + ); err != nil { + return err + } + if _, err := stream.Write(chunk); err != nil { + effectiveRetryAttempt := generatedTusEffectiveRetryAttempt( + stream.Upload.RemoteOffset, + offsetBeforeRetry, + retryAttempt, + ) + if !generatedTusShouldScheduleRetry( + options.OnShouldRetry, + err, + stream.lastResponseStatus(), + effectiveRetryAttempt, + retryDelays, + ) { + return err + } + delay := retryDelays[effectiveRetryAttempt] + if delay > 0 { + time.Sleep(delay) + } + retryAttempt = generatedTusNextRetryAttempt(effectiveRetryAttempt) + offsetBeforeRetry = stream.Upload.RemoteOffset + if _, err := stream.Sync(); err != nil { + return err + } + stream.ForceClean() + continue + } + if stream.Upload.RemoteOffset > startOffset { + chunkSize := stream.Upload.RemoteOffset - startOffset + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + stream.Upload.RemoteOffset, + options.Size, + ); err != nil { + return err + } + if err := generatedTusEmitChunkCompleteAfterChunkAccepted( + options.EventHooks, + chunkSize, + stream.Upload.RemoteOffset, + options.Size, + ); err != nil { + return err + } + } + + if stream.Upload.RemoteOffset >= options.Size { + return nil + } + } +} + +func readURLStorageUploadChunk( + source io.Reader, + chunkSize int64, + remaining int64, +) ([]byte, error) { + if remaining <= 0 { + return nil, nil + } + + bytesToRead := remaining + if chunkSize > 0 && chunkSize < bytesToRead { + bytesToRead = chunkSize + } + if bytesToRead > int64(int(bytesToRead)) { + return nil, fmt.Errorf("tus: upload chunk size %d is too large for this platform", bytesToRead) + } + + chunk := make([]byte, int(bytesToRead)) + if _, err := io.ReadFull(source, chunk); err != nil { + return nil, err + } + + return chunk, nil +} + +func (us *UploadStream) lastResponseStatus() int { + if us.LastResponse == nil { + return 0 + } + + return us.LastResponse.StatusCode +} + +func generatedTusClientWithUploadContext(client *Client, ctx context.Context) *Client { + if ctx == nil { + return client + } + + return client.WithContext(ctx) +} + +func generatedTusClientWithURLStorageRequestPolicy( + client *Client, + options URLStorageUploadOptions, +) (*Client, *generatedTusDetailedErrorRecorder) { + result := *client + httpClient := http.DefaultClient + if client.client != nil { + httpClient = client.client + } + resultHTTPClient := *httpClient + baseTransport := resultHTTPClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + detailedErrorRecorder := &generatedTusDetailedErrorRecorder{ + Base: baseTransport, + } + if len(options.Headers) == 0 && !options.OverridePatchMethod && !options.AddRequestID { + resultHTTPClient.Transport = detailedErrorRecorder + } else { + resultHTTPClient.Transport = generatedTusURLStorageRequestPolicyTransport{ + AddRequestID: options.AddRequestID, + Base: detailedErrorRecorder, + Headers: cloneStringMap(options.Headers), + OverridePatchMethod: options.OverridePatchMethod, + } + } + result.client = &resultHTTPClient + + return &result, detailedErrorRecorder +} + +func generatedTusClientWithAbortCleanupContext(client *Client) *Client { + return client.WithContext(context.Background()) +} + +type generatedTusURLStorageRequestPolicyTransport struct { + AddRequestID bool + Base http.RoundTripper + Headers map[string]string + OverridePatchMethod bool +} + +// DetailedError preserves the request/response context for a failed TUS request. +type DetailedError struct { + CausingError error + Err error + Message string + OriginalRequest *http.Request + OriginalResponse *http.Response + OriginalResponseBody string +} + +func (err *DetailedError) Error() string { + return err.Message +} + +func (err *DetailedError) Unwrap() error { + if err.Err != nil { + return err.Err + } + + return err.CausingError +} + +type generatedTusDetailedErrorRecorder struct { + Base http.RoundTripper + Err error + Request *http.Request + Response *http.Response + ResponseBody string + mu sync.Mutex +} + +func (recorder *generatedTusDetailedErrorRecorder) RoundTrip( + request *http.Request, +) (*http.Response, error) { + response, err := recorder.Base.RoundTrip(request) + if err != nil { + recorder.record(request, nil, "", err) + return response, err + } + if response == nil || response.Body == nil { + recorder.record(request, response, "", nil) + return response, nil + } + + bodyBytes, readErr := io.ReadAll(response.Body) + response.Body.Close() + if readErr != nil { + recorder.record(request, response, "", readErr) + return response, readErr + } + + body := string(bodyBytes) + response.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + storedResponse := *response + storedResponse.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + recorder.record(request, &storedResponse, body, nil) + + return response, nil +} + +func (recorder *generatedTusDetailedErrorRecorder) record( + request *http.Request, + response *http.Response, + body string, + err error, +) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + + recorder.Request = request.Clone(request.Context()) + recorder.Response = response + recorder.ResponseBody = body + recorder.Err = err +} + +func (recorder *generatedTusDetailedErrorRecorder) snapshot() generatedTusDetailedErrorSnapshot { + recorder.mu.Lock() + defer recorder.mu.Unlock() + + return generatedTusDetailedErrorSnapshot{ + Err: recorder.Err, + Request: recorder.Request, + Response: recorder.Response, + ResponseBody: recorder.ResponseBody, + } +} + +type generatedTusDetailedErrorSnapshot struct { + Err error + Request *http.Request + Response *http.Response + ResponseBody string +} + +func generatedTusFormatDetailedErrorMessage( + template string, + values map[string]string, +) string { + message := template + for name, value := range values { + message = strings.ReplaceAll(message, "{"+name+"}", value) + } + + return message +} + +func generatedTusDetailedErrorCause(cause error) string { + return generatedTusFormatDetailedErrorMessage( + generatedTusDetailedCauseStringTemplate, + map[string]string{"message": cause.Error()}, + ) +} + +func generatedTusDetailedErrorResponseBody(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Response == nil { + return generatedTusDetailedMissingValue + } + if snapshot.ResponseBody == "" { + return generatedTusDetailedEmptyResponseBody + } + + return snapshot.ResponseBody +} + +func generatedTusDetailedErrorResponseStatus(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Response == nil { + return generatedTusDetailedMissingValue + } + + return strconv.Itoa(snapshot.Response.StatusCode) +} + +func generatedTusDetailedErrorRequestID(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Request == nil { + return generatedTusDetailedMissingValue + } + requestID := snapshot.Request.Header.Get(generatedTusRequestIDHeaderName) + if requestID == "" { + return generatedTusDetailedMissingValue + } + + return requestID +} + +func generatedTusDetailedErrorRequestMethod(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Request == nil { + return generatedTusDetailedMissingValue + } + + return snapshot.Request.Method +} + +func generatedTusDetailedErrorRequestURL(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Request == nil || snapshot.Request.URL == nil { + return generatedTusDetailedMissingValue + } + + return snapshot.Request.URL.String() +} + +func generatedTusDetailedErrorMessage( + baseMessage string, + snapshot generatedTusDetailedErrorSnapshot, +) string { + message := baseMessage + if snapshot.Err != nil { + message += generatedTusFormatDetailedErrorMessage( + generatedTusDetailedCausedByTemplate, + map[string]string{"cause": generatedTusDetailedErrorCause(snapshot.Err)}, + ) + } + message += generatedTusFormatDetailedErrorMessage( + generatedTusDetailedRequestContextTemplate, + map[string]string{ + "body": generatedTusDetailedErrorResponseBody(snapshot), + "method": generatedTusDetailedErrorRequestMethod(snapshot), + "requestId": generatedTusDetailedErrorRequestID(snapshot), + "status": generatedTusDetailedErrorResponseStatus(snapshot), + "url": generatedTusDetailedErrorRequestURL(snapshot), + }, + ) + + return message +} + +func generatedTusCreateUploadDetailedError( + recorder *generatedTusDetailedErrorRecorder, + err error, +) error { + if err == nil || recorder == nil { + return err + } + + snapshot := recorder.snapshot() + if snapshot.Request == nil { + return err + } + + baseMessage := generatedTusUnexpectedCreateResponse + if snapshot.Err != nil { + baseMessage = generatedTusCreateUploadRequestFailed + } + + return &DetailedError{ + CausingError: snapshot.Err, + Err: err, + Message: generatedTusDetailedErrorMessage(baseMessage, snapshot), + OriginalRequest: snapshot.Request, + OriginalResponse: snapshot.Response, + OriginalResponseBody: snapshot.ResponseBody, + } +} + +func (transport generatedTusURLStorageRequestPolicyTransport) RoundTrip( + request *http.Request, +) (*http.Response, error) { + cloned := request.Clone(request.Context()) + for _, methodOverride := range generatedTusMethodOverrides { + enabled, err := transport.methodOverrideEnabled(methodOverride) + if err != nil { + return nil, err + } + if !enabled || cloned.Method != methodOverride.SourceMethod { + continue + } + + cloned.Method = methodOverride.Method + cloned.Header.Set( + methodOverride.HeaderName, + methodOverride.HeaderValue, + ) + break + } + for key, value := range transport.Headers { + cloned.Header.Set(key, value) + } + if transport.AddRequestID { + requestID, err := generatedTusRequestID() + if err != nil { + return nil, err + } + cloned.Header.Set( + generatedTusRequestIDHeaderName, + requestID, + ) + } + + return transport.Base.RoundTrip(cloned) +} + +func generatedTusRequestID() (string, error) { + var bytes [16]byte + if _, err := cryptoRand.Read(bytes[:]); err != nil { + return "", err + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%x-%x-%x-%x-%x", bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:]), nil +} + +func (c *Client) generatedTusResolveCreatedUploadLocation(upload *Upload) error { + if upload == nil || upload.Location == "" { + return nil + } + locationURL, err := url.Parse(upload.Location) + if err != nil { + return err + } + upload.Location = c.BaseURL.ResolveReference(locationURL).String() + return nil +} + +func (transport generatedTusURLStorageRequestPolicyTransport) methodOverrideEnabled( + methodOverride generatedTusMethodOverride, +) (bool, error) { + switch methodOverride.InputFlag { + case "overridePatchMethod": + return transport.OverridePatchMethod, nil + default: + return false, fmt.Errorf("tus: unsupported method override input flag %s", methodOverride.InputFlag) + } +} + +func IsUploadAbortError(err error) bool { + return errors.Is(err, context.Canceled) +} + +func generatedTusParallelUploadCount(parallelUploads int) (int, error) { + if parallelUploads == 0 { + parallelUploads = generatedTusDefaultParallelUploads + } + if parallelUploads == 1 { + return parallelUploads, nil + } + if parallelUploads < generatedTusMinimumParallelUploads { + return 0, fmt.Errorf( + "tus: parallel uploads must be at least %d", + generatedTusMinimumParallelUploads, + ) + } + + return parallelUploads, nil +} + +func generatedTusValidateURLStorageUploadOptions( + options URLStorageUploadOptions, + parallelUploads int, +) error { + if parallelUploads <= 1 { + return nil + } + if options.UploadLengthDeferred { + return errors.New(generatedTusValidationParallelDeferred) + } + if options.UploadDataDuringCreation { + return errors.New(generatedTusValidationParallelCreateData) + } + + return nil +} + +func generatedTusCreationWithUploadChunkSize(options URLStorageUploadOptions) int64 { + if options.ChunkSize > 0 && options.ChunkSize < options.Size { + return options.ChunkSize + } + + return options.Size +} + +func generatedTusParallelUploadPartSizes(uploadSize int64, parallelUploads int) ([]int64, error) { + if uploadSize < 0 { + return nil, fmt.Errorf("tus: parallel upload size must be known") + } + if parallelUploads <= 0 { + return nil, fmt.Errorf("tus: parallel upload count must be positive") + } + partSize := uploadSize / int64(parallelUploads) + if partSize <= 0 { + return nil, fmt.Errorf("tus: parallel upload parts must not be empty") + } + + partSizes := make([]int64, parallelUploads) + for index := range partSizes { + partSizes[index] = partSize + } + partSizes[len(partSizes)-1] += uploadSize - partSize*int64(parallelUploads) + + return partSizes, nil +} + +func generatedTusParallelUploadPartInputs( + source io.ReadSeeker, + partSizes []int64, +) ([]generatedTusParallelPartInput, error) { + partInputs := make([]generatedTusParallelPartInput, len(partSizes)) + offset := int64(0) + for index, partSize := range partSizes { + if _, err := source.Seek(offset, io.SeekStart); err != nil { + return nil, err + } + partBytes, err := readURLStorageUploadChunk(source, partSize, partSize) + if err != nil { + return nil, err + } + partInputs[index] = generatedTusParallelPartInput{ + Bytes: partBytes, + Index: index, + Size: partSize, + } + offset += partSize + } + + return partInputs, nil +} + +func generatedTusParallelUploadContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + + return context.WithCancel(ctx) +} + +func generatedTusParallelPartialUploadMetadata(options URLStorageUploadOptions) map[string]string { + return cloneStringMap(options.MetadataForPartialUploads) +} + +func generatedTusParallelUploadError(results []generatedTusParallelPartResult) error { + for _, result := range results { + if result.Err == nil || IsUploadAbortError(result.Err) { + continue + } + + return result.Err + } + for _, result := range results { + if result.Err != nil { + return result.Err + } + } + + return nil +} + +func generatedTusFirstCreatedParallelPartialUpload( + results []generatedTusParallelPartResult, +) *Upload { + for _, result := range results { + if result.Upload.Location == "" { + continue + } + + upload := result.Upload + return &upload + } + + return nil +} + +func (c *Client) generatedTusCleanupParallelPartialUploads( + options URLStorageUploadOptions, + results []generatedTusParallelPartResult, + originalErr error, +) error { + shouldCleanup := generatedTusShouldCleanupParallelPartialUploads( + options.TerminateUploadOnAbort, + ) + if !shouldCleanup { + return originalErr + } + cleanupClient := generatedTusClientWithAbortCleanupContext(c) + + for _, result := range results { + if result.Upload.Location == "" { + continue + } + if _, err := cleanupClient.TerminateUploadWithRetry(result.Upload, TerminateUploadOptions{ + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + }); err != nil { + return err + } + } + + return originalErr +} + +func (c *Client) generatedTusHandleURLStorageUploadAbort( + options URLStorageUploadOptions, + upload *Upload, + storageKey string, + err error, +) error { + if !IsUploadAbortError(err) { + return err + } + shouldTerminate := generatedTusShouldTerminateKnownUploadOnAbort( + options.TerminateUploadOnAbort, + upload, + ) + if !shouldTerminate { + return err + } + + cleanupClient := generatedTusClientWithAbortCleanupContext(c) + + if _, terminateErr := cleanupClient.TerminateUploadWithRetry(*upload, TerminateUploadOptions{ + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + }); terminateErr != nil { + return terminateErr + } + if generatedTusAbortTerminateRemovesStoredURL && storageKey != "" { + if err := options.Storage.RemoveUpload(storageKey); err != nil { + return err + } + } + + return err +} + +func generatedTusShouldTerminateKnownUploadOnAbort( + terminateUploadOnAbort bool, + upload *Upload, +) bool { + if generatedTusAbortTerminateRequiresRequest && !terminateUploadOnAbort { + return false + } + if generatedTusAbortTerminateRequiresUploadURL && (upload == nil || upload.Location == "") { + return false + } + if upload == nil || upload.Location == "" { + return false + } + return true +} + +func generatedTusRetryDelays(retryDelays []time.Duration) []time.Duration { + if retryDelays == nil { + return append([]time.Duration(nil), generatedTusDefaultRetryDelays...) + } + + return retryDelays +} + +func generatedTusEffectiveRetryAttempt( + offset int64, + offsetBeforeRetry int64, + retryAttempt int, +) int { + if offset > offsetBeforeRetry { + return 0 + } + + return retryAttempt +} + +func generatedTusNextRetryAttempt(retryAttempt int) int { + return retryAttempt + 1 +} + +func generatedTusShouldScheduleRetry( + onShouldRetry func(error, int) bool, + err error, + statusCode int, + retryAttempt int, + retryDelays []time.Duration, +) bool { + if retryAttempt >= len(retryDelays) || !generatedTusShouldRetryStatus(statusCode) { + return false + } + if onShouldRetry != nil { + return onShouldRetry(err, retryAttempt) + } + + return true +} + +func generatedTusShouldRetryStatus(statusCode int) bool { + if statusCode == 0 { + return false + } + if statusCode/generatedTusRetryStatusCategoryDivisor != generatedTusRetryClientErrorStatus/generatedTusRetryStatusCategoryDivisor { + return true + } + for _, retryableStatusCode := range generatedTusRetryableClientStatusCodes { + if statusCode == retryableStatusCode { + return true + } + } + + return false +} + +func generatedTusEmitUploadURLAvailable(hooks UploadEventHooks, context string) error { + if hooks.OnUploadURLAvailable == nil { + return nil + } + + switch context { + case "createUpload": + if generatedTusUploadURLAvailableCreate == "not-emitted" { + return nil + } + case "resumeUpload": + if generatedTusUploadURLAvailableResume == "not-emitted" { + return nil + } + case "parallelFinalUpload": + if generatedTusUploadURLAvailableParallel == "not-emitted" { + return nil + } + default: + return fmt.Errorf("tus: unsupported upload URL available hook context %s", context) + } + + return hooks.OnUploadURLAvailable() +} + +func generatedTusEmitProgressBeforeRequestBody( + hooks UploadEventHooks, + currentOffset int64, + bytesTotal int64, +) error { + if hooks.OnProgress == nil { + return nil + } + + return hooks.OnProgress(currentOffset, generatedTusInt64Pointer(bytesTotal)) +} + +func generatedTusEmitProgressAfterChunkAccepted( + hooks UploadEventHooks, + uploadOffset int64, + bytesTotal int64, +) error { + if hooks.OnProgress == nil { + return nil + } + + return hooks.OnProgress(uploadOffset, generatedTusInt64Pointer(bytesTotal)) +} + +func generatedTusEmitChunkCompleteAfterChunkAccepted( + hooks UploadEventHooks, + chunkSize int64, + bytesAccepted int64, + bytesTotal int64, +) error { + if hooks.OnChunkComplete == nil { + return nil + } + + return hooks.OnChunkComplete( + chunkSize, + bytesAccepted, + generatedTusInt64Pointer(bytesTotal), + ) +} + +func generatedTusEmitSuccess(input generatedTusSuccessInput) error { + shouldRemoveStoredUpload := generatedTusShouldRemoveStoredUploadOnSuccess( + input.RemoveFingerprintOnSuccess, + ) + if shouldRemoveStoredUpload && input.StorageKey != "" { + if err := input.Storage.RemoveUpload(input.StorageKey); err != nil { + return err + } + } + if input.EventHooks.OnSuccess != nil { + if err := input.EventHooks.OnSuccess(UploadSuccessPayload{ + Upload: input.Upload, + LastResponse: input.LastResponse, + }); err != nil { + return err + } + } + + shouldCloseSource := generatedTusShouldCloseSourceOnSuccess(input.Source) + if shouldCloseSource { + closer, ok := input.Source.(io.Closer) + if ok { + return closer.Close() + } + } + + return nil +} + +func generatedTusShouldCloseSourceOnSuccess(source io.ReadSeeker) bool { + if !generatedTusSuccessCloseSourceAfterHook { + return false + } + if generatedTusSuccessCloseSourceRequiresSrc { + return source != nil + } + return true +} + +func generatedTusShouldRemoveStoredUploadOnSuccess( + removeFingerprintOnSuccess bool, +) bool { + if !generatedTusSuccessRemoveStoredBeforeHook { + return false + } + if !generatedTusURLStorageRemoveOnSuccessEnable { + return false + } + if generatedTusSuccessRemoveStoredRequiresOpt || generatedTusURLStorageRemoveRequiresOpt { + return removeFingerprintOnSuccess + } + return true +} + +func generatedTusInt64Pointer(value int64) *int64 { + return &value +} + +func generatedTusShouldCleanupParallelPartialUploads( + terminateUploadOnAbort bool, +) bool { + if !generatedTusParallelCleanupCreatedPartials { + return false + } + if generatedTusParallelCleanupRequiresAbort { + return terminateUploadOnAbort + } + return true +} + +func FileFingerprint(input FileFingerprintInput) string { + parts := []string{ + generatedTusNodeFileFingerprintPrefix, + input.AbsolutePath, + strconv.FormatInt(input.Size, 10), + strconv.FormatInt(input.MtimeMs, 10), + input.Endpoint, + } + + return strings.Join(parts, generatedTusNodeFileFingerprintSeparator) +} + +func nodeFileFingerprintPath(path string) (string, error) { + return filepath.Abs(path) +} + +func fileModTimeMilliseconds(info os.FileInfo) int64 { + return info.ModTime().UnixNano() / int64(time.Millisecond) +} + +func (c *Client) resumeUploadFromURLStorage( + options URLStorageUploadOptions, +) (*Upload, string, error) { + storedUploads, err := options.Storage.FindUploadsByFingerprint(options.Fingerprint) + if err != nil { + return nil, "", err + } + + for _, storedUpload := range storedUploads { + location, ok := stringFromURLStorageUpload(storedUpload, "uploadUrl") + if !ok || location == "" { + continue + } + + upload := &Upload{ + Location: location, + Metadata: cloneStringMap(options.Metadata), + RemoteSize: options.Size, + } + if _, err := c.GetUpload(upload, location); err != nil { + return upload, "", err + } + if upload.RemoteSize == 0 && options.Size > 0 { + upload.RemoteSize = options.Size + } + if upload.Metadata == nil { + upload.Metadata = cloneStringMap(options.Metadata) + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "resumeUpload"); err != nil { + return upload, "", err + } + + storageKey, _ := stringFromURLStorageUpload(storedUpload, "urlStorageKey") + return upload, storageKey, nil + } + + return nil, "", nil +} + +func (c *Client) createUploadForURLStorage( + options URLStorageUploadOptions, + detailedErrorRecorder *generatedTusDetailedErrorRecorder, +) (*Upload, string, *http.Response, error) { + if options.UploadDataDuringCreation { + return c.createUploadWithDataForURLStorage(options, detailedErrorRecorder) + } + + upload := &Upload{} + remoteSize := options.Size + if options.UploadLengthDeferred { + remoteSize = SizeUnknown + } + response, err := c.CreateUpload(upload, remoteSize, false, options.Metadata) + if err != nil { + return upload, "", response, generatedTusCreateUploadDetailedError( + detailedErrorRecorder, + err, + ) + } + if err := c.generatedTusResolveCreatedUploadLocation(upload); err != nil { + return upload, "", response, err + } + if options.UploadLengthDeferred { + upload.RemoteSize = options.Size + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "createUpload"); err != nil { + return upload, "", response, err + } + + storageKey, err := options.Storage.AddUpload( + options.Fingerprint, + URLStorageUploadFromUpload(*upload), + ) + if err != nil { + return upload, "", response, err + } + + return upload, storageKey, response, nil +} + +func (c *Client) createUploadWithDataForURLStorage( + options URLStorageUploadOptions, + detailedErrorRecorder *generatedTusDetailedErrorRecorder, +) (*Upload, string, *http.Response, error) { + if _, err := options.Source.Seek(0, io.SeekStart); err != nil { + return nil, "", nil, err + } + chunkSize := generatedTusCreationWithUploadChunkSize(options) + chunk, err := readURLStorageUploadChunk(options.Source, chunkSize, chunkSize) + if err != nil { + return nil, "", nil, err + } + if err := generatedTusEmitProgressBeforeRequestBody( + options.EventHooks, + 0, + options.Size, + ); err != nil { + return nil, "", nil, err + } + + upload := &Upload{} + uploadedBytes, response, err := c.CreateUploadWithData( + upload, + chunk, + options.Size, + false, + options.Metadata, + ) + if err != nil { + return upload, "", response, generatedTusCreateUploadDetailedError( + detailedErrorRecorder, + err, + ) + } + upload.RemoteSize = options.Size + upload.RemoteOffset = uploadedBytes + if err := c.generatedTusResolveCreatedUploadLocation(upload); err != nil { + return upload, "", response, err + } + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + uploadedBytes, + options.Size, + ); err != nil { + return upload, "", response, err + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "createUpload"); err != nil { + return upload, "", response, err + } + if err := generatedTusEmitChunkCompleteAfterChunkAccepted( + options.EventHooks, + uploadedBytes, + uploadedBytes, + options.Size, + ); err != nil { + return upload, "", response, err + } + + storageKey, err := options.Storage.AddUpload( + options.Fingerprint, + URLStorageUploadFromUpload(*upload), + ) + if err != nil { + return upload, "", response, err + } + + return upload, storageKey, response, nil +} + +func URLStorageUploadFromUpload(upload Upload) URLStorageUpload { + record := URLStorageUpload{ + "creationTime": urlStorageCreationTime(), + "metadata": stringMapToAnyMap(upload.Metadata), + "size": upload.RemoteSize, + } + if upload.Location != "" { + record["uploadUrl"] = upload.Location + } + + return record +} + +func urlStorageCreationTime() string { + return time.Now().String() +} + +func stringFromURLStorageUpload(upload URLStorageUpload, key string) (string, bool) { + value, ok := upload[key] + if !ok { + return "", false + } + + stringValue, ok := value.(string) + return stringValue, ok +} + +func cloneStringMap(input map[string]string) map[string]string { + if input == nil { + return nil + } + + result := make(map[string]string, len(input)) + for key, value := range input { + result[key] = value + } + + return result +} + +func stringMapToAnyMap(input map[string]string) map[string]any { + result := make(map[string]any, len(input)) + for key, value := range input { + result[key] = value + } + + return result +} + +func urlStorageUploadsWithPrefix( + records map[string]URLStorageUpload, + prefix string, +) ([]URLStorageUpload, error) { + keys := make([]string, 0, len(records)) + for key := range records { + if strings.HasPrefix(key, prefix) { + keys = append(keys, key) + } + } + sort.Strings(keys) + + result := make([]URLStorageUpload, 0, len(keys)) + for _, key := range keys { + upload, err := cloneURLStorageUpload(records[key]) + if err != nil { + return nil, err + } + upload["urlStorageKey"] = key + result = append(result, upload) + } + + return result, nil +} + +func cloneURLStorageUpload(upload URLStorageUpload) (URLStorageUpload, error) { + data, err := json.Marshal(upload) + if err != nil { + return nil, err + } + + cloned := URLStorageUpload{} + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&cloned); err != nil { + return nil, err + } + if cloned == nil { + cloned = URLStorageUpload{} + } + + return cloned, nil +} diff --git a/url_storage_option_validation_contract_test.go b/url_storage_option_validation_contract_test.go new file mode 100644 index 0000000..393f1d3 --- /dev/null +++ b/url_storage_option_validation_contract_test.go @@ -0,0 +1,75 @@ +package tusgo + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestGeneratedURLStorageOptionValidation(t *testing.T) { + testCases := []struct { + name string + content string + parallelUploads int + uploadDataDuringCreation bool + uploadLengthDeferred bool + expectedError string + }{ + { + name: "startValidationParallelUploadsWithDeferredLength", + content: "hello world", + parallelUploads: 2, + uploadDataDuringCreation: false, + uploadLengthDeferred: true, + expectedError: "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled", + }, + { + name: "startValidationParallelUploadsWithUploadDataDuringCreation", + content: "hello world", + parallelUploads: 2, + uploadDataDuringCreation: true, + uploadLengthDeferred: false, + expectedError: "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + requestCount += 1 + responseWriter.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: NewMemoryURLStorage(), + Source: strings.NewReader(testCase.content), + Fingerprint: "contract-" + testCase.name, + Size: int64(len(testCase.content)), + ParallelUploads: testCase.parallelUploads, + UploadDataDuringCreation: testCase.uploadDataDuringCreation, + UploadLengthDeferred: testCase.uploadLengthDeferred, + }) + if err == nil { + t.Fatalf("expected validation error %q", testCase.expectedError) + } + if err.Error() != testCase.expectedError { + t.Fatalf("expected validation error %q, got %q", testCase.expectedError, err.Error()) + } + if upload != nil { + t.Fatalf("expected validation to fail before creating an upload, got %#v", upload) + } + if requestCount != 0 { + t.Fatalf("expected validation to fail before any request, got %d request(s)", requestCount) + } + }) + } +} diff --git a/url_storage_override_patch_method_contract_test.go b/url_storage_override_patch_method_contract_test.go new file mode 100644 index 0000000..6e689a8 --- /dev/null +++ b/url_storage_override_patch_method_contract_test.go @@ -0,0 +1,192 @@ +package tusgo + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const ( + generatedTusOverrideContent = "hello world" + generatedTusOverrideContentType = "application/offset+octet-stream" + generatedTusOverrideContentTypeHeader = "Content-Type" + generatedTusOverrideExpectedRequests = 2 + generatedTusOverrideHeaderName = "X-HTTP-Method-Override" + generatedTusOverrideHeaderValue = "PATCH" + generatedTusOverrideMethod = "POST" + generatedTusOverrideOffset = "3" + generatedTusOverrideOffsetHeader = "Upload-Offset" + generatedTusOverridePath = "/uploads/override-contract" + generatedTusOverrideUploadLength = "11" + generatedTusOverrideLengthHeader = "Upload-Length" + generatedTusOverrideFinalOffset = "11" + generatedTusOverridePatchBody = "lo world" +) + +func TestGeneratedURLStorageOverridePatchMethod(t *testing.T) { + getOperation := generatedProtocolOperation("getTusUploadOffset") + patchOperation := generatedProtocolOperation("patchTusUpload") + requestCount := 0 + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusOverridePath && + request.Method == getOperation.Method: + requestCount += 1 + if actual := request.Header.Get(generatedTusOverrideHeaderName); actual != "" { + recordRequestErr(fmt.Errorf("expected no override header on offset request, got %s", actual)) + } + getResponse := generatedResponseFor(getOperation, 200) + generatedWriteTusOverrideResponseHeaders( + responseWriter, + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusOverrideUploadLength, + "Upload-Offset": generatedTusOverrideOffset, + }, + ) + responseWriter.WriteHeader(getResponse.StatusCode) + + case request.URL.Path == generatedTusOverridePath && + request.Method == generatedTusOverrideMethod: + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusOverridePatchBody { + recordRequestErr(fmt.Errorf( + "expected override patch body %q, got %q", + generatedTusOverridePatchBody, + string(body), + )) + } + recordRequestErr(generatedAssertTusOverrideRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusOverrideContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusOverrideOffset, + "X-HTTP-Method-Override": generatedTusOverrideHeaderValue, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusOverrideResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusOverrideFinalOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + ProtocolVersions: []string{DefaultProtocolVersion}, + } + storage := NewMemoryURLStorage() + if _, err := storage.AddUpload( + "contract-override-fingerprint", + URLStorageUpload{"uploadUrl": server.URL + generatedTusOverridePath}, + ); err != nil { + t.Fatal(err) + } + + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusOverrideContent), + Fingerprint: "contract-override-fingerprint", + Size: 11, + OverridePatchMethod: true, + }) + if err != nil { + select { + case requestErr := <-requestErrs: + t.Fatalf("%v: %v", err, requestErr) + default: + t.Fatal(err) + } + } + if upload.Location != server.URL+generatedTusOverridePath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusOverridePath, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + if requestCount != generatedTusOverrideExpectedRequests { + t.Fatalf("expected %d override request(s), got %d", generatedTusOverrideExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } +} + +func generatedAssertTusOverrideRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusOverrideResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusOverrideOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusOverrideOffsetHeader, value) + } +} diff --git a/url_storage_parallel_cleanup_contract_test.go b/url_storage_parallel_cleanup_contract_test.go new file mode 100644 index 0000000..cc786df --- /dev/null +++ b/url_storage_parallel_cleanup_contract_test.go @@ -0,0 +1,451 @@ +package tusgo + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const ( + generatedTusParallelCleanupContent = "hello world" + generatedTusParallelCleanupContentType = "application/offset+octet-stream" + generatedTusParallelCleanupContentTypeHeader = "Content-Type" + generatedTusParallelCleanupEndpointPath = "/uploads" + generatedTusParallelCleanupAbortedPatchEvent = "request-abort:3" + generatedTusParallelCleanupEventPolicy = "exact" + generatedTusParallelCleanupFailurePartIndex = 0 + generatedTusParallelCleanupFailureStatus = 500 + generatedTusParallelCleanupMethod = "POST" + generatedTusParallelCleanupOffsetHeader = "Upload-Offset" + generatedTusParallelCleanupOverrideHeader = "X-HTTP-Method-Override" + generatedTusParallelCleanupOverrideValue = "PATCH" + generatedTusParallelCleanupPatchGateTimeoutMs = 2000 + generatedTusParallelCleanupUploadCount = 2 +) + +var generatedTusParallelCleanupExtraEventPrefixes = []string{} +var generatedTusParallelCleanupExpectedEvents = []string{"request-abort:3"} +var generatedTusParallelCleanupHeaders = map[string]string{"X-Tus-Contract": "parallel-cleanup-policy", "X-Tus-Trace": "parallel-cleanup-trace-123"} +var generatedTusParallelCleanupMetadataForPartialUploads = map[string]string{"test": "world"} +var generatedTusParallelCleanupPatchGateRequestIndexes = []int{2, 3} + +type generatedTusParallelCleanupPartFixture struct { + UploadLength string + UploadPath string + PatchBody string + PatchOffset string + TerminatePath string +} + +var generatedTusParallelCleanupParts = []generatedTusParallelCleanupPartFixture{ + { + UploadLength: "5", + UploadPath: "/uploads/parallel-cleanup-part-1", + PatchBody: "hello", + PatchOffset: "0", + TerminatePath: "/uploads/parallel-cleanup-part-1", + }, + { + UploadLength: "6", + UploadPath: "/uploads/parallel-cleanup-part-2", + PatchBody: " world", + PatchOffset: "0", + TerminatePath: "/uploads/parallel-cleanup-part-2", + }, +} + +func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + terminateOperation := generatedProtocolOperation("terminateTusUpload") + encodedPartialMetadata, err := EncodeMetadata(generatedTusParallelCleanupMetadataForPartialUploads) + if err != nil { + t.Fatal(err) + } + + var requestMu sync.Mutex + createIndex := 0 + patchIndex := 0 + terminateIndex := 0 + terminatedParts := map[int]bool{} + patchArrivals := make(chan int, generatedTusParallelCleanupUploadCount) + releasePatches := make(chan struct{}) + requestErrs := make(chan error, 12) + events := []string{} + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + go generatedTusReleaseParallelCleanupPatchesAfterAllStarted( + patchArrivals, + releasePatches, + requestErrs, + patchOperation.Method, + ) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusParallelCleanupEndpointPath && + request.Method == createOperation.Method && + request.Header.Get("Upload-Concat") == "partial": + partIndex := generatedTusParallelCleanupPartIndexForUploadLength( + request.Header.Get("Upload-Length"), + ) + if partIndex < 0 { + recordRequestErr(fmt.Errorf( + "unexpected cleanup create upload length %s", + request.Header.Get("Upload-Length"), + )) + responseWriter.WriteHeader(http.StatusBadRequest) + return + } + requestMu.Lock() + createIndex += 1 + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Concat": "partial", + "Upload-Length": generatedTusParallelCleanupParts[partIndex].UploadLength, + "Upload-Metadata": encodedPartialMetadata, + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + }, + )) + recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( + request, + generatedTusParallelCleanupHeaders, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusParallelCleanupResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelCleanupParts[partIndex].UploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.Method == generatedTusParallelCleanupMethod: + partIndex := generatedTusParallelCleanupPartIndexForPath(request.URL.Path) + if partIndex < 0 { + recordRequestErr(fmt.Errorf("unexpected cleanup patch path %s", request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + select { + case patchArrivals <- generatedTusParallelCleanupPatchGateRequestIndexes[partIndex]: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + select { + case <-releasePatches: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + requestMu.Lock() + patchIndex += 1 + requestMu.Unlock() + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusParallelCleanupParts[partIndex].PatchBody { + recordRequestErr(fmt.Errorf( + "expected cleanup patch body %q, got %q", + generatedTusParallelCleanupParts[partIndex].PatchBody, + string(body), + )) + } + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusParallelCleanupContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusParallelCleanupParts[partIndex].PatchOffset, + "X-HTTP-Method-Override": generatedTusParallelCleanupOverrideValue, + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + }, + )) + recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( + request, + generatedTusParallelCleanupHeaders, + )) + if actual := request.Header.Get(generatedTusParallelCleanupOverrideHeader); actual != generatedTusParallelCleanupOverrideValue { + recordRequestErr(fmt.Errorf("expected override header %s, got %s", generatedTusParallelCleanupOverrideValue, actual)) + } + if partIndex == generatedTusParallelCleanupFailurePartIndex { + responseWriter.WriteHeader(generatedTusParallelCleanupFailureStatus) + return + } + select { + case <-request.Context().Done(): + requestMu.Lock() + events = append(events, generatedTusParallelCleanupAbortedPatchEvent) + requestMu.Unlock() + return + case <-time.After(2 * time.Second): + recordRequestErr(fmt.Errorf("expected cleanup patch request to be canceled")) + responseWriter.WriteHeader(http.StatusInternalServerError) + } + + case request.Method == terminateOperation.Method: + partIndex := generatedTusParallelCleanupPartIndexForTerminatePath(request.URL.Path) + if partIndex < 0 { + recordRequestErr(fmt.Errorf("unexpected cleanup termination path %s", request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + requestMu.Lock() + terminateIndex += 1 + terminatedParts[partIndex] = true + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + terminateOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + }, + )) + recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( + request, + generatedTusParallelCleanupHeaders, + )) + if actual := request.Header.Get(generatedTusParallelCleanupOverrideHeader); actual != "" { + recordRequestErr(fmt.Errorf("expected no override header on cleanup termination request, got %s", actual)) + } + terminateResponse := generatedResponseFor(terminateOperation, 204) + generatedWriteTusParallelCleanupResponseHeaders( + responseWriter, + terminateResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(terminateResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusParallelCleanupEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, terminateOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + _, err = client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusParallelCleanupContent), + Fingerprint: "contract-parallel-cleanup-fingerprint", + Size: int64(len(generatedTusParallelCleanupContent)), + Headers: generatedTusParallelCleanupHeaders, + MetadataForPartialUploads: generatedTusParallelCleanupMetadataForPartialUploads, + OverridePatchMethod: true, + TerminateUploadOnAbort: true, + ParallelUploads: generatedTusParallelCleanupUploadCount, + }) + if err == nil { + t.Fatal("expected parallel cleanup upload to fail") + } + if errors.Is(err, context.Canceled) { + t.Fatalf("expected original parallel part failure, got %v", err) + } + + requestMu.Lock() + actualCreateIndex := createIndex + actualPatchIndex := patchIndex + actualTerminateIndex := terminateIndex + actualTerminatedParts := len(terminatedParts) + actualEvents := append([]string(nil), events...) + requestMu.Unlock() + if actualCreateIndex != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected %d partial creates, got %d", generatedTusParallelCleanupUploadCount, actualCreateIndex) + } + if actualPatchIndex != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected %d partial patches, got %d", generatedTusParallelCleanupUploadCount, actualPatchIndex) + } + if actualTerminateIndex != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected %d partial terminations, got %d", generatedTusParallelCleanupUploadCount, actualTerminateIndex) + } + if actualTerminatedParts != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected all partial uploads to be terminated, got %#v", terminatedParts) + } + generatedTusAssertEvents(t, "parallelUploadAbortCleanup", generatedTusParallelCleanupEventPolicy, generatedTusParallelCleanupExtraEventPrefixes, generatedTusParallelCleanupExpectedEvents, actualEvents) + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + + storedUploads, err := storage.FindUploadsByFingerprint("contract-parallel-cleanup-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 0 { + t.Fatalf("expected no final parallel upload to be stored, got %#v", storedUploads) + } +} + +func generatedTusParallelCleanupPartIndexForPath(path string) int { + for index, part := range generatedTusParallelCleanupParts { + if path == part.UploadPath { + return index + } + } + + return -1 +} + +func generatedTusParallelCleanupPartIndexForTerminatePath(path string) int { + for index, part := range generatedTusParallelCleanupParts { + if path == part.TerminatePath { + return index + } + } + + return -1 +} + +func generatedTusParallelCleanupPartIndexForUploadLength(uploadLength string) int { + for index, part := range generatedTusParallelCleanupParts { + if uploadLength == part.UploadLength { + return index + } + } + + return -1 +} + +func generatedTusReleaseParallelCleanupPatchesAfterAllStarted( + patchArrivals <-chan int, + releasePatches chan<- struct{}, + requestErrs chan<- error, + patchMethod string, +) { + seen := map[int]bool{} + timer := time.NewTimer(time.Duration(generatedTusParallelCleanupPatchGateTimeoutMs) * time.Millisecond) + defer timer.Stop() + for !generatedTusParallelCleanupPatchGateHasStartedAll(seen) { + select { + case requestIndex := <-patchArrivals: + seen[requestIndex] = true + case <-timer.C: + requestErrs <- fmt.Errorf("expected all cleanup %s requests to be in flight", patchMethod) + close(releasePatches) + return + } + } + + close(releasePatches) +} + +func generatedTusParallelCleanupPatchGateHasStartedAll(seen map[int]bool) bool { + for _, requestIndex := range generatedTusParallelCleanupPatchGateRequestIndexes { + if !seen[requestIndex] { + return false + } + } + + return true +} + +func generatedAssertTusParallelCleanupRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusParallelCleanupRequestHeaderVariant(request, variant, values); err != nil { + failures = append(failures, err.Error()) + continue + } + + return nil + } + + return fmt.Errorf( + "no %s request header variant matched: %s", + operation.OperationID, + strings.Join(failures, "; "), + ) +} + +func generatedAssertTusParallelCleanupRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedAssertTusParallelCleanupCustomHeaders( + request *http.Request, + expected map[string]string, +) error { + for key, value := range expected { + if actual := request.Header.Get(key); actual != value { + return fmt.Errorf("expected custom header %s=%s, got %s", key, value, actual) + } + } + + return nil +} + +func generatedWriteTusParallelCleanupResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_parallel_contract_test.go b/url_storage_parallel_contract_test.go new file mode 100644 index 0000000..cd30e0d --- /dev/null +++ b/url_storage_parallel_contract_test.go @@ -0,0 +1,439 @@ +package tusgo + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const ( + generatedTusParallelConcatExtension = "concatenation" + generatedTusParallelContent = "hello world" + generatedTusParallelEndpointPath = "/uploads" + generatedTusParallelEventPolicy = "exact-except-allowed-extra-events" + generatedTusParallelFinalConcatPrefix = "final;" + generatedTusParallelFinalPath = "/uploads/parallel-final" + generatedTusParallelPatchGateTimeoutMs = 2000 + generatedTusParallelUploadURLSeparator = " " + generatedTusParallelConformanceUploadCount = 2 +) + +var generatedTusParallelExtraEventPrefixes = []string{"progress:"} +var generatedTusParallelExpectedEvents = []string{"progress:5:11", "chunk-complete:5:5:11", "progress:11:11", "chunk-complete:6:11:11"} +var generatedTusParallelFinalAbsentHeaders = []string{"Upload-Length"} +var generatedTusParallelMetadata = map[string]string{"foo": "hello"} +var generatedTusParallelMetadataForPartialUploads = map[string]string{"test": "world"} +var generatedTusParallelPatchGateRequestIndexes = []int{2, 3} + +type generatedTusParallelPartFixture struct { + UploadLength string + UploadPath string + PatchBody string + PatchOffset string + PatchAcceptedOffset string +} + +var generatedTusParallelParts = []generatedTusParallelPartFixture{ + { + UploadLength: "5", + UploadPath: "/uploads/parallel-part-1", + PatchBody: "hello", + PatchOffset: "0", + PatchAcceptedOffset: "5", + }, + { + UploadLength: "6", + UploadPath: "/uploads/parallel-part-2", + PatchBody: " world", + PatchOffset: "0", + PatchAcceptedOffset: "6", + }, +} + +func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusParallelMetadata) + if err != nil { + t.Fatal(err) + } + encodedPartialMetadata, err := EncodeMetadata(generatedTusParallelMetadataForPartialUploads) + if err != nil { + t.Fatal(err) + } + + var requestMu sync.Mutex + createIndex := 0 + patchIndex := 0 + patchArrivals := make(chan int, generatedTusParallelConformanceUploadCount) + releasePatches := make(chan struct{}) + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + go generatedTusReleaseParallelPatchesAfterAllStarted( + patchArrivals, + releasePatches, + requestErrs, + patchOperation.Method, + ) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusParallelEndpointPath && + request.Method == createOperation.Method && + request.Header.Get("Upload-Concat") == "partial": + partIndex := generatedTusParallelPartIndexForUploadLength( + request.Header.Get("Upload-Length"), + ) + if partIndex < 0 { + recordRequestErr(fmt.Errorf( + "unexpected parallel create upload length %s", + request.Header.Get("Upload-Length"), + )) + responseWriter.WriteHeader(http.StatusBadRequest) + return + } + requestMu.Lock() + createIndex += 1 + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Concat": "partial", + "Upload-Length": generatedTusParallelParts[partIndex].UploadLength, + "Upload-Metadata": encodedPartialMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusParallelResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelParts[partIndex].UploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusParallelEndpointPath && + request.Method == createOperation.Method && + strings.HasPrefix( + request.Header.Get("Upload-Concat"), + generatedTusParallelFinalConcatPrefix, + ): + requestMu.Lock() + createIndex += 1 + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelAbsentHeaders( + request, + generatedTusParallelFinalAbsentHeaders, + )) + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Concat": generatedTusParallelFinalConcatHeader(server.URL), + "Upload-Metadata": encodedMetadata, + }, + )) + finalResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusParallelResponseHeaders( + responseWriter, + finalResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelFinalPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(finalResponse.StatusCode) + + case request.Method == patchOperation.Method: + partIndex := generatedTusParallelPartIndexForPath(request.URL.Path) + if partIndex < 0 { + recordRequestErr(fmt.Errorf("unexpected parallel patch path %s", request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + select { + case patchArrivals <- generatedTusParallelPatchGateRequestIndexes[partIndex]: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + select { + case <-releasePatches: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + requestMu.Lock() + patchIndex += 1 + requestMu.Unlock() + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusParallelParts[partIndex].PatchBody { + recordRequestErr(fmt.Errorf( + "expected parallel patch body %q, got %q", + generatedTusParallelParts[partIndex].PatchBody, + string(body), + )) + } + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": "application/offset+octet-stream", + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusParallelParts[partIndex].PatchOffset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusParallelResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusParallelParts[partIndex].PatchAcceptedOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusParallelEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusParallelConcatExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusParallelContent), + Fingerprint: "contract-parallel-fingerprint", + Size: int64(len(generatedTusParallelContent)), + Metadata: generatedTusParallelMetadata, + MetadataForPartialUploads: generatedTusParallelMetadataForPartialUploads, + ParallelUploads: generatedTusParallelConformanceUploadCount, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusParallelBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusParallelBytesTotalString(bytesTotal), + )) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != server.URL+generatedTusParallelFinalPath { + t.Fatalf("expected final upload URL %s, got %s", server.URL+generatedTusParallelFinalPath, upload.Location) + } + requestMu.Lock() + actualCreateIndex := createIndex + actualPatchIndex := patchIndex + requestMu.Unlock() + if actualCreateIndex != len(generatedTusParallelParts)+1 { + t.Fatalf("expected %d create requests, got %d", len(generatedTusParallelParts)+1, actualCreateIndex) + } + if actualPatchIndex != len(generatedTusParallelParts) { + t.Fatalf("expected %d patch requests, got %d", len(generatedTusParallelParts), actualPatchIndex) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "parallelUploadConcat", generatedTusParallelEventPolicy, generatedTusParallelExtraEventPrefixes, generatedTusParallelExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-parallel-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected final parallel upload to be stored once, got %#v", storedUploads) + } + storedUploadURL, ok := stringFromURLStorageUpload(storedUploads[0], "uploadUrl") + if !ok || storedUploadURL != upload.Location { + t.Fatalf("expected stored final upload URL %s, got %#v", upload.Location, storedUploads[0]) + } +} + +func generatedTusParallelFinalConcatHeader(serverURL string) string { + locations := make([]string, 0, len(generatedTusParallelParts)) + for _, part := range generatedTusParallelParts { + locations = append(locations, serverURL+part.UploadPath) + } + + return generatedTusParallelFinalConcatPrefix + + strings.Join(locations, generatedTusParallelUploadURLSeparator) +} + +func generatedTusParallelPartIndexForPath(path string) int { + for index, part := range generatedTusParallelParts { + if path == part.UploadPath { + return index + } + } + + return -1 +} + +func generatedTusParallelPartIndexForUploadLength(uploadLength string) int { + for index, part := range generatedTusParallelParts { + if uploadLength == part.UploadLength { + return index + } + } + + return -1 +} + +func generatedTusReleaseParallelPatchesAfterAllStarted( + patchArrivals <-chan int, + releasePatches chan<- struct{}, + requestErrs chan<- error, + patchMethod string, +) { + seen := map[int]bool{} + timer := time.NewTimer(time.Duration(generatedTusParallelPatchGateTimeoutMs) * time.Millisecond) + defer timer.Stop() + for !generatedTusParallelPatchGateHasStartedAll(seen) { + select { + case requestIndex := <-patchArrivals: + seen[requestIndex] = true + case <-timer.C: + requestErrs <- fmt.Errorf("expected all parallel %s requests to be in flight", patchMethod) + close(releasePatches) + return + } + } + + close(releasePatches) +} + +func generatedTusParallelPatchGateHasStartedAll(seen map[int]bool) bool { + for _, requestIndex := range generatedTusParallelPatchGateRequestIndexes { + if !seen[requestIndex] { + return false + } + } + + return true +} + +func generatedTusParallelBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusParallelAbsentHeaders( + request *http.Request, + headers []string, +) error { + for _, header := range headers { + if actual := request.Header.Get(header); actual != "" { + return fmt.Errorf("expected request header %s to be absent, got %s", header, actual) + } + } + + return nil +} + +func generatedAssertTusParallelRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusParallelRequestHeaderVariant(request, variant, values); err != nil { + failures = append(failures, err.Error()) + continue + } + + return nil + } + + return fmt.Errorf( + "no %s request header variant matched: %s", + operation.OperationID, + strings.Join(failures, "; "), + ) +} + +func generatedAssertTusParallelRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusParallelResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_resume_contract_test.go b/url_storage_resume_contract_test.go new file mode 100644 index 0000000..9cdd06e --- /dev/null +++ b/url_storage_resume_contract_test.go @@ -0,0 +1,184 @@ +package tusgo + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusResumeFlowEndpointPath = "/uploads" + generatedTusResumeFlowContent = "hello world" + generatedTusResumeFlowFingerprint = "contract-resume-fingerprint" + generatedTusResumeFlowPatchAcceptedOffset = "11" + generatedTusResumeFlowPatchBody = " world" + generatedTusResumeFlowPatchOffset = "5" + generatedTusResumeFlowStoredUploadPath = "/uploads/resume-contract" + generatedTusResumeFlowUploadLength = "11" +) + +var generatedTusResumeFlowMetadata = map[string]string{} + +func TestGeneratedURLStorageResumeFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusResumeFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + storedUploadURL := srvMock.URL() + generatedTusResumeFlowStoredUploadPath + if _, err := storage.AddUpload( + generatedTusResumeFlowFingerprint, + URLStorageUpload{ + "metadata": stringMapToAnyMap(generatedTusResumeFlowMetadata), + "size": 11, + "uploadUrl": storedUploadURL, + }, + ); err != nil { + t.Fatal(err) + } + + getOperation := generatedProtocolOperation("getTusUploadOffset") + getResponse := generatedResponseFor(getOperation, 200) + getReply := generatedURLStorageResumeResponseHeaders( + reply.Status(getResponse.StatusCode), + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusResumeFlowUploadLength, + "Upload-Offset": generatedTusResumeFlowPatchOffset, + }, + ) + srvMock.AddMocks( + generatedURLStorageResumeRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusResumeFlowStoredUploadPath)). + Method(getOperation.Method), + getOperation, + map[string]string{}, + ).Reply(getReply), + ) + + patchOperation := generatedProtocolOperation("patchTusUpload") + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageResumeResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusResumeFlowPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageResumeRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusResumeFlowStoredUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusResumeFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusResumeFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + successCalled := false + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusResumeFlowContent), + Fingerprint: generatedTusResumeFlowFingerprint, + Size: 11, + Metadata: generatedTusResumeFlowMetadata, + RemoveFingerprintOnSuccess: true, + EventHooks: UploadEventHooks{ + OnSuccess: func(UploadSuccessPayload) error { + remainingUploads, err := storage.FindUploadsByFingerprint(generatedTusResumeFlowFingerprint) + if err != nil { + return err + } + if len(remainingUploads) != 0 { + return fmt.Errorf( + "expected success hook to run after storage removal, got %#v", + remainingUploads, + ) + } + successCalled = true + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if !successCalled { + t.Fatal("expected success hook to be called") + } + if upload.Location != storedUploadURL { + t.Fatalf("expected resumed upload URL %s, got %s", storedUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + + remainingUploads, err := storage.FindUploadsByFingerprint(generatedTusResumeFlowFingerprint) + if err != nil { + t.Fatal(err) + } + if len(remainingUploads) != 0 { + t.Fatalf("expected successful resume to remove stored upload, got %#v", remainingUploads) + } +} + +func generatedURLStorageResumeRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageResumeResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_retry_contract_test.go b/url_storage_retry_contract_test.go new file mode 100644 index 0000000..bf9680e --- /dev/null +++ b/url_storage_retry_contract_test.go @@ -0,0 +1,344 @@ +package tusgo + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/params" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusRetryFlowContent = "hello world" + generatedTusRetryFlowEndpointPath = "/uploads" + generatedTusRetryFlowEventPolicy = "exact" + generatedTusRetryFlowFingerprint = "retryPatchAfterOffsetRecovery-fingerprint" + generatedTusRetryFlowUploadLength = "11" + generatedTusRetryFlowUploadPath = "/uploads/retry-contract" +) + +type generatedTusRetryOffsetRecoveryAttempt struct { + RecoveredLength string + RecoveredOffset string + Status int +} + +type generatedTusRetryPatchAttempt struct { + AcceptedOffset string + Body string + Offset string + Status int +} + +type generatedTusRetryDecision struct { + Decision bool + RetryAttempt int +} + +var generatedTusRetryFlowExtraEventPrefixes = []string{} +var generatedTusRetryFlowExpectedEvents = []string{"should-retry:0:true", "retry-schedule:0", "should-retry:0:true", "retry-schedule:0"} +var generatedTusRetryFlowMetadata = map[string]string{"filename": "hello.txt"} +var generatedTusRetryFlowOffsetRecoveryAttempts = []generatedTusRetryOffsetRecoveryAttempt{ + { + RecoveredLength: "11", + RecoveredOffset: "5", + Status: 200, + }, + { + RecoveredLength: "11", + RecoveredOffset: "5", + Status: 200, + }, +} +var generatedTusRetryFlowPatchAttempts = []generatedTusRetryPatchAttempt{ + { + AcceptedOffset: "", + Body: "hello world", + Offset: "0", + Status: 500, + }, + { + AcceptedOffset: "", + Body: " world", + Offset: "5", + Status: 500, + }, + { + AcceptedOffset: "11", + Body: " world", + Offset: "5", + Status: 204, + }, +} +var generatedTusRetryFlowRetryDelays = []time.Duration{0 * time.Millisecond} +var generatedTusRetryFlowShouldRetryEvents = []generatedTusRetryDecision{ + { + Decision: true, + RetryAttempt: 0, + }, + { + Decision: true, + RetryAttempt: 0, + }, +} + +func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusRetryFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{generatedProtocolOperation("createTusUpload").Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + getOperation := generatedProtocolOperation("getTusUploadOffset") + createdUploadURL := srvMock.URL() + generatedTusRetryFlowUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusRetryFlowMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageRetryResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRetryFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusRetryFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Repeat(1).Reply(createReply), + ) + + getReplies := make([]*reply.StdReply, 0, len(generatedTusRetryFlowOffsetRecoveryAttempts)) + for _, attempt := range generatedTusRetryFlowOffsetRecoveryAttempts { + getResponse := generatedResponseFor(getOperation, attempt.Status) + getReply := generatedURLStorageRetryResponseHeaders( + reply.Status(attempt.Status), + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": attempt.RecoveredLength, + "Upload-Offset": attempt.RecoveredOffset, + }, + ) + getReplies = append(getReplies, getReply) + } + patchReplies := make([]struct { + Body string + Offset string + Reply *reply.StdReply + }, 0, len(generatedTusRetryFlowPatchAttempts)) + for _, attempt := range generatedTusRetryFlowPatchAttempts { + patchReply := reply.Status(attempt.Status) + if attempt.AcceptedOffset != "" { + patchResponse := generatedResponseFor(patchOperation, attempt.Status) + patchReply = generatedURLStorageRetryResponseHeaders( + reply.Status(attempt.Status), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": attempt.AcceptedOffset, + }, + ) + } + patchReplies = append(patchReplies, struct { + Body string + Offset string + Reply *reply.StdReply + }{ + Body: attempt.Body, + Offset: attempt.Offset, + Reply: patchReply, + }) + } + patchReplyIndex := 0 + srvMock.AddMocks( + generatedURLStorageRetryDynamicRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRetryFlowUploadPath)). + Method(patchOperation.Method), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + }, + map[string]func() string{ + "Upload-Offset": func() string { + if patchReplyIndex >= len(patchReplies) { + return "" + } + return patchReplies[patchReplyIndex].Offset + }, + }, + ).Repeat(len(patchReplies)).ReplyFunction(func(r *http.Request, m reply.M, p params.P) (*reply.Response, error) { + if patchReplyIndex >= len(patchReplies) { + t.Fatalf("unexpected retry %s request %d", patchOperation.Method, patchReplyIndex) + return nil, nil + } + expected := patchReplies[patchReplyIndex] + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + if string(body) != expected.Body { + t.Fatalf("expected %s body %q, got %q", patchOperation.Method, expected.Body, string(body)) + } + patchReplyIndex += 1 + return expected.Reply.Build(r, m, p) + }), + ) + + getReplyIndex := 0 + srvMock.AddMocks( + generatedURLStorageRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRetryFlowUploadPath)). + Method(getOperation.Method), + getOperation, + map[string]string{}, + ).Repeat(len(getReplies)).ReplyFunction(func(r *http.Request, m reply.M, p params.P) (*reply.Response, error) { + if getReplyIndex >= len(getReplies) { + t.Fatalf("unexpected retry %s request %d", getOperation.Method, getReplyIndex) + return nil, nil + } + expected := getReplies[getReplyIndex] + getReplyIndex += 1 + return expected.Build(r, m, p) + }), + ) + + storage := NewMemoryURLStorage() + retryDecisionIndex := 0 + events := []string{} + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusRetryFlowContent), + Fingerprint: generatedTusRetryFlowFingerprint, + Size: 11, + Metadata: generatedTusRetryFlowMetadata, + RetryDelays: generatedTusRetryFlowRetryDelays, + OnShouldRetry: func(err error, retryAttempt int) bool { + if retryDecisionIndex >= len(generatedTusRetryFlowShouldRetryEvents) { + t.Fatalf("unexpected retry decision request %d for %v", retryDecisionIndex, err) + } + expected := generatedTusRetryFlowShouldRetryEvents[retryDecisionIndex] + if retryAttempt != expected.RetryAttempt { + t.Fatalf("expected retry attempt %d, got %d", expected.RetryAttempt, retryAttempt) + } + events = append(events, generatedTusEventKeyShouldRetry( + generatedTusEventKeyNumber(int64(retryAttempt)), + generatedTusEventKeyBool(expected.Decision), + )) + if expected.Decision { + events = append(events, generatedTusEventKeyRetrySchedule( + generatedTusEventKeyNumber(generatedTusRetryFlowRetryDelays[retryAttempt].Milliseconds()), + )) + } + retryDecisionIndex += 1 + return expected.Decision + }, + }) + if err != nil { + t.Fatal(err) + } + if retryDecisionIndex != len(generatedTusRetryFlowShouldRetryEvents) { + t.Fatalf("expected %d retry decisions, got %d", len(generatedTusRetryFlowShouldRetryEvents), retryDecisionIndex) + } + if patchReplyIndex != len(patchReplies) { + t.Fatalf("expected %d %s requests, got %d", len(patchReplies), patchOperation.Method, patchReplyIndex) + } + if getReplyIndex != len(getReplies) { + t.Fatalf("expected %d %s requests, got %d", len(getReplies), getOperation.Method, getReplyIndex) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + generatedTusAssertEvents(t, "retryPatchAfterOffsetRecovery", generatedTusRetryFlowEventPolicy, generatedTusRetryFlowExtraEventPrefixes, generatedTusRetryFlowExpectedEvents, events) +} + +func generatedURLStorageRetryRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + return generatedURLStorageRetryDynamicRequestHeaders(builder, operation, values, nil) +} + +func generatedURLStorageRetryDynamicRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, + dynamicValues map[string]func() string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + if dynamicValue, ok := dynamicValues[field.DisplayName]; ok { + builder = builder.Header(field.DisplayName, expect.Func(func(value any, args expect.Args) (bool, error) { + stringValue, ok := value.(string) + if !ok { + return false, nil + } + return stringValue == dynamicValue(), nil + })) + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageRetryResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +}