From 65932e91dc67e018e4940fc72b081cde990acde4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 11:55:45 +0200 Subject: [PATCH 01/97] Add generated TUS protocol contract canary --- client.go | 2 +- protocol_contract_generated_test.go | 361 ++++++++++++++++++++++++++++ protocol_contract_test.go | 186 ++++++++++++++ protocol_generated.go | 10 + 4 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 protocol_contract_generated_test.go create mode 100644 protocol_contract_test.go create mode 100644 protocol_generated.go diff --git a/client.go b/client.go index 2ddecec..99d198e 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, diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go new file mode 100644 index 0000000..f0def09 --- /dev/null +++ b/protocol_contract_generated_test.go @@ -0,0 +1,361 @@ +// 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 + +type generatedTusWireVersion struct { + Default bool + Value string +} + +type generatedTusHeaderField struct { + DisplayName string + Name string + Required bool +} + +type generatedTusHeaderVariant struct { + Fields []generatedTusHeaderField +} + +type generatedTusRequestContract struct { + BodyKind string + ContentType string + HeaderVariants []generatedTusHeaderVariant +} + +type generatedTusResponseContract struct { + StatusCode int + BodyKind string + HeaderVariants []generatedTusHeaderVariant +} + +type generatedTusProtocolOperation struct { + OperationID string + Role string + Method string + Path string + Request generatedTusRequestContract + Responses []generatedTusResponseContract +} + +type generatedTusClientFeature struct { + FeatureID string + OperationIDs []string + Primitives []string +} + +var generatedTusWireVersions = []generatedTusWireVersion{ + { + Default: true, + Value: "1.0.0", + }, +} + +var generatedTusProtocolOperations = []generatedTusProtocolOperation{ + { + OperationID: "discoverTusCapabilities", + Role: "capability-discovery", + Method: "OPTIONS", + Path: "/resumable/files/", + Request: generatedTusRequestContract{ + BodyKind: "empty", + ContentType: "", + HeaderVariants: nil, + }, + Responses: []generatedTusResponseContract{ + { + StatusCode: 200, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + 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: generatedTusRequestContract{ + BodyKind: "empty", + ContentType: "", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + 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: []generatedTusHeaderField{ + { + 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, + }, + }, + }, + }, + }, + Responses: []generatedTusResponseContract{ + { + StatusCode: 201, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Location", + Name: "location", + Required: true, + }, + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, + }, + }, + { + OperationID: "getTusUploadOffset", + Role: "offset-discovery", + Method: "HEAD", + Path: "/resumable/files/{upload_id}", + Request: generatedTusRequestContract{ + BodyKind: "empty", + ContentType: "", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, + Responses: []generatedTusResponseContract{ + { + StatusCode: 200, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + 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: []generatedTusHeaderField{ + { + 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: generatedTusRequestContract{ + BodyKind: "binary", + ContentType: "application/offset+octet-stream", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + 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: []generatedTusResponseContract{ + { + StatusCode: 204, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + { + DisplayName: "Upload-Offset", + Name: "upload-offset", + Required: true, + }, + }, + }, + }, + }, + }, + }, + { + OperationID: "terminateTusUpload", + Role: "termination", + Method: "DELETE", + Path: "/resumable/files/{upload_id}", + Request: generatedTusRequestContract{ + BodyKind: "empty", + ContentType: "", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, + Responses: []generatedTusResponseContract{ + { + StatusCode: 204, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, + }, + }, + { + OperationID: "downloadTusUpload", + Role: "download", + Method: "GET", + Path: "/resumable/files/{upload_id}", + Request: generatedTusRequestContract{ + BodyKind: "empty", + ContentType: "", + HeaderVariants: nil, + }, + Responses: []generatedTusResponseContract{ + { + StatusCode: 200, + BodyKind: "binary", + HeaderVariants: nil, + }, + }, + }, +} + +var generatedTusClientFeatures = []generatedTusClientFeature{ + { + FeatureID: "singleUploadLifecycle", + OperationIDs: []string{"createTusUpload", "getTusUploadOffset", "patchTusUpload"}, + Primitives: []string{"open-input-source", "fingerprint-input", "store-resume-url", "retry-with-backoff", "emit-progress", "abort-current-request"}, + }, + { + FeatureID: "terminateUpload", + OperationIDs: []string{"terminateTusUpload"}, + Primitives: []string{"retry-with-backoff"}, + }, +} diff --git a/protocol_contract_test.go b/protocol_contract_test.go new file mode 100644 index 0000000..15e94fa --- /dev/null +++ b/protocol_contract_test.go @@ -0,0 +1,186 @@ +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))) + }) +}) diff --git a/protocol_generated.go b/protocol_generated.go new file mode 100644 index 0000000..20cdd5b --- /dev/null +++ b/protocol_generated.go @@ -0,0 +1,10 @@ +// 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 Tus-Resumable value used by default. + DefaultProtocolVersion = "1.0.0" +) From 177b8f882c01d584dfd95270af1eff5b931b05b0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 21:18:33 +0200 Subject: [PATCH 02/97] Regenerate TUS protocol contract fixture --- protocol_contract_generated_test.go | 75 ++++++++++++++++++++++++++++- protocol_generated.go | 2 +- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index f0def09..36bee78 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -144,6 +144,49 @@ var generatedTusProtocolOperations = []generatedTusProtocolOperation{ }, }, }, + { + Fields: []generatedTusHeaderField{ + { + 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: []generatedTusHeaderField{ + { + 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: []generatedTusResponseContract{ @@ -353,9 +396,39 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ OperationIDs: []string{"createTusUpload", "getTusUploadOffset", "patchTusUpload"}, Primitives: []string{"open-input-source", "fingerprint-input", "store-resume-url", "retry-with-backoff", "emit-progress", "abort-current-request"}, }, + { + FeatureID: "resumeUpload", + OperationIDs: []string{"getTusUploadOffset", "patchTusUpload"}, + Primitives: []string{"fingerprint-input", "resume-from-previous-upload", "store-resume-url"}, + }, + { + FeatureID: "deferredLengthUpload", + OperationIDs: []string{"createTusUpload", "patchTusUpload"}, + Primitives: []string{"defer-upload-length", "emit-progress"}, + }, + { + FeatureID: "creationWithUpload", + OperationIDs: []string{"createTusUpload"}, + Primitives: []string{"upload-during-creation", "emit-progress"}, + }, + { + FeatureID: "overridePatchMethod", + OperationIDs: []string{"getTusUploadOffset", "patchTusUpload"}, + Primitives: []string{"override-patch-method"}, + }, + { + FeatureID: "parallelUploadConcat", + OperationIDs: []string{"createTusUpload", "patchTusUpload"}, + Primitives: []string{"concatenate-partial-uploads", "emit-progress"}, + }, + { + FeatureID: "retryOffsetRecovery", + OperationIDs: []string{"createTusUpload", "getTusUploadOffset", "patchTusUpload"}, + Primitives: []string{"retry-with-backoff", "recover-offset-after-error"}, + }, { FeatureID: "terminateUpload", OperationIDs: []string{"terminateTusUpload"}, - Primitives: []string{"retry-with-backoff"}, + Primitives: []string{"terminate-upload", "retry-with-backoff"}, }, } diff --git a/protocol_generated.go b/protocol_generated.go index 20cdd5b..6b766f4 100644 --- a/protocol_generated.go +++ b/protocol_generated.go @@ -5,6 +5,6 @@ package tusgo const ( - // DefaultProtocolVersion is the Tus-Resumable value used by default. + // DefaultProtocolVersion is the wire protocol version used by default. DefaultProtocolVersion = "1.0.0" ) From e91f12af4582b1756bdd7f6b5038d5dd1d9812c9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 22:12:27 +0200 Subject: [PATCH 03/97] Regenerate TUS feature contract fixture --- protocol_contract_generated_test.go | 481 +++++++++++++++++++++++++++- 1 file changed, 480 insertions(+), 1 deletion(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 36bee78..5fbd4e0 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -41,11 +41,27 @@ type generatedTusProtocolOperation struct { } type generatedTusClientFeature struct { + Conformance generatedTusClientFeatureConformance + Description string FeatureID string + Flow []generatedTusClientFeatureFlowStep OperationIDs []string Primitives []string } +type generatedTusClientFeatureConformance struct { + ScenarioIDs []string + Status string +} + +type generatedTusClientFeatureFlowStep struct { + Kind string + OperationID string + Primitive string + Condition string + Summary string +} + var generatedTusWireVersions = []generatedTusWireVersion{ { Default: true, @@ -392,43 +408,506 @@ var generatedTusProtocolOperations = []generatedTusProtocolOperation{ var generatedTusClientFeatures = []generatedTusClientFeature{ { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"singleUploadLifecycle"}, + Status: "covered-by-generated-scenario", + }, + Description: "Create an upload, store its URL, upload bytes, and finish successfully.", FeatureID: "singleUploadLifecycle", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "open-input-source", + Condition: "", + Summary: "Open the caller input as a sliceable source.", + }, + { + Kind: "operation", + OperationID: "createTusUpload", + Primitive: "", + Condition: "", + Summary: "Create the remote upload resource.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Upload bytes until the accepted offset reaches the known length.", + }, + }, OperationIDs: []string{"createTusUpload", "getTusUploadOffset", "patchTusUpload"}, Primitives: []string{"open-input-source", "fingerprint-input", "store-resume-url", "retry-with-backoff", "emit-progress", "abort-current-request"}, }, { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"resumeFromPreviousUpload"}, + Status: "covered-by-generated-scenario", + }, + Description: "Resume a stored upload URL by discovering the remote offset before patching.", FeatureID: "resumeUpload", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "resume-from-previous-upload", + Condition: "", + Summary: "Load a stored upload URL selected by fingerprint.", + }, + { + Kind: "operation", + OperationID: "getTusUploadOffset", + Primitive: "", + Condition: "", + Summary: "Read the server offset for the stored upload URL.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Continue uploading from the discovered offset.", + }, + }, OperationIDs: []string{"getTusUploadOffset", "patchTusUpload"}, Primitives: []string{"fingerprint-input", "resume-from-previous-upload", "store-resume-url"}, }, { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"deferredLengthUpload"}, + Status: "covered-by-generated-scenario", + }, + Description: "Create an upload without a known length and declare the length on final PATCH.", FeatureID: "deferredLengthUpload", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "operation", + OperationID: "createTusUpload", + Primitive: "", + Condition: "", + Summary: "Create the upload with deferred length.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "defer-upload-length", + Condition: "", + Summary: "Track the source until the final chunk reveals the total size.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Declare Upload-Length on the final chunk request.", + }, + }, OperationIDs: []string{"createTusUpload", "patchTusUpload"}, Primitives: []string{"defer-upload-length", "emit-progress"}, }, { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"creationWithUpload"}, + Status: "covered-by-generated-scenario", + }, + Description: "Send the first bytes on the creation request when the server/client support it.", FeatureID: "creationWithUpload", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "operation", + OperationID: "createTusUpload", + Primitive: "", + Condition: "", + Summary: "Create the upload while streaming the initial body.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "upload-during-creation", + Condition: "", + Summary: "Interpret the creation response as an accepted offset.", + }, + }, OperationIDs: []string{"createTusUpload"}, Primitives: []string{"upload-during-creation", "emit-progress"}, }, { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"overridePatchMethod"}, + Status: "covered-by-generated-scenario", + }, + Description: "Tunnel PATCH through POST with the method-override header.", FeatureID: "overridePatchMethod", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "operation", + OperationID: "getTusUploadOffset", + Primitive: "", + Condition: "", + Summary: "Resume from the upload URL before sending bytes.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "override-patch-method", + Condition: "", + Summary: "Replace PATCH with POST while preserving the protocol operation intent.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Upload bytes through the overridden request.", + }, + }, OperationIDs: []string{"getTusUploadOffset", "patchTusUpload"}, Primitives: []string{"override-patch-method"}, }, { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"parallelUploadConcat"}, + Status: "covered-by-generated-scenario", + }, + Description: "Split one input into partial uploads and concatenate their upload URLs.", FeatureID: "parallelUploadConcat", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "split-parallel-upload-boundaries", + Condition: "", + Summary: "Split the input into stable byte ranges.", + }, + { + Kind: "operation", + OperationID: "createTusUpload", + Primitive: "", + Condition: "", + Summary: "Create partial uploads for each range.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "concatenate-partial-uploads", + Condition: "", + Summary: "Create the final upload from completed partial upload URLs.", + }, + }, OperationIDs: []string{"createTusUpload", "patchTusUpload"}, - Primitives: []string{"concatenate-partial-uploads", "emit-progress"}, + Primitives: []string{"concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries"}, }, { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"retryPatchAfterOffsetRecovery"}, + Status: "covered-by-generated-scenario", + }, + Description: "Recover from a failed chunk by reading the server offset before retrying.", FeatureID: "retryOffsetRecovery", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Attempt the chunk upload.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "recover-offset-after-error", + Condition: "", + Summary: "Discover the accepted offset after a retryable failure.", + }, + { + Kind: "operation", + OperationID: "getTusUploadOffset", + Primitive: "", + Condition: "", + Summary: "Use HEAD to recover the offset before retrying PATCH.", + }, + }, OperationIDs: []string{"createTusUpload", "getTusUploadOffset", "patchTusUpload"}, Primitives: []string{"retry-with-backoff", "recover-offset-after-error"}, }, { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"terminateWithRetry"}, + Status: "covered-by-generated-scenario", + }, + Description: "Terminate an upload resource and retry retryable termination failures.", FeatureID: "terminateUpload", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "terminate-upload", + Condition: "", + Summary: "Choose server-side termination for an upload URL.", + }, + { + Kind: "operation", + OperationID: "terminateTusUpload", + Primitive: "", + Condition: "", + Summary: "Delete the upload resource.", + }, + }, OperationIDs: []string{"terminateTusUpload"}, Primitives: []string{"terminate-upload", "retry-with-backoff"}, }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Abort the active request, pending retry timer, and any partial uploads.", + FeatureID: "abortUpload", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "abort-current-request", + Condition: "", + Summary: "Cancel in-flight transport work without emitting user callbacks after abort.", + }, + }, + OperationIDs: nil, + Primitives: []string{"abort-current-request"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Expose progress and accepted-chunk callbacks from runtime upload activity.", + FeatureID: "uploadCallbacks", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "emit-progress", + Condition: "", + Summary: "Report bytes sent against known or deferred length.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "emit-chunk-complete", + Condition: "", + Summary: "Report chunk size, accepted offset, and total size after server acceptance.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "emit-upload-url", + Condition: "", + Summary: "Notify once a usable upload URL is known.", + }, + }, + OperationIDs: nil, + Primitives: []string{"emit-progress", "emit-chunk-complete", "emit-upload-url"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Run before-request, after-response, and custom retry hooks around transport.", + FeatureID: "requestLifecycleHooks", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "run-request-hooks", + Condition: "", + Summary: "Call user hooks around each HTTP request/response pair.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "customize-retry", + Condition: "", + Summary: "Let user retry policy override default retry decisions.", + }, + }, + OperationIDs: nil, + Primitives: []string{"customize-retry", "run-request-hooks"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Persist, find, resume, and optionally remove upload URLs by fingerprint.", + FeatureID: "resumeUrlStorage", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "fingerprint-input", + Condition: "", + Summary: "Derive a stable key for the input when possible.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "store-resume-url", + Condition: "", + Summary: "Persist upload URLs and partial-upload URLs for future resumption.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "remove-stored-url-on-success", + Condition: "", + Summary: "Remove stored upload URLs when configured after success or invalidation.", + }, + }, + OperationIDs: nil, + Primitives: []string{"fingerprint-input", "store-resume-url", "remove-stored-url-on-success"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Support the reference client input/source families across runtimes.", + FeatureID: "inputSources", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "read-browser-file", + Condition: "", + Summary: "Read browser Blob/File and ArrayBuffer-family inputs.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "read-node-stream", + Condition: "", + Summary: "Read Node streams when size and chunk constraints are satisfied.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "read-web-stream", + Condition: "", + Summary: "Read Web Streams with deferred or configured size.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "read-node-file", + Condition: "", + Summary: "Read filesystem paths and fs streams, including parallel ranges.", + }, + }, + OperationIDs: nil, + Primitives: []string{"read-browser-file", "read-node-file", "read-node-stream", "read-web-stream"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Support browser and file-backed URL storage implementations.", + FeatureID: "urlStorageBackends", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "store-browser-url", + Condition: "", + Summary: "Persist upload records in browser localStorage.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "store-file-url", + Condition: "", + Summary: "Persist upload records in the Node file store.", + }, + }, + OperationIDs: nil, + Primitives: []string{"store-browser-url", "store-file-url"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Select between tus v1 and supported IETF draft client protocol modes.", + FeatureID: "protocolVersionSelection", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "select-client-protocol", + Condition: "", + Summary: "Choose request headers and response expectations for the selected protocol.", + }, + }, + OperationIDs: []string{"createTusUpload", "getTusUploadOffset", "patchTusUpload"}, + Primitives: []string{"select-client-protocol"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Normalize relative Location headers against the request endpoint.", + FeatureID: "relativeLocationResolution", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "resolve-relative-location", + Condition: "", + Summary: "Resolve server Location headers with the creation endpoint as origin.", + }, + }, + OperationIDs: []string{"createTusUpload"}, + Primitives: []string{"resolve-relative-location"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Validate option combinations before starting runtime work.", + FeatureID: "startOptionValidation", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "validate-start-options", + Condition: "", + Summary: "Reject missing inputs and incompatible parallel/deferred/resume options.", + }, + }, + OperationIDs: nil, + Primitives: []string{"validate-start-options"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: nil, + Status: "needs-generated-scenario", + }, + Description: "Attach request, response, status, body, and request ID context to errors.", + FeatureID: "detailedErrors", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "report-detailed-errors", + Condition: "", + Summary: "Return user-facing errors with enough transport context for debugging.", + }, + }, + OperationIDs: nil, + Primitives: []string{"report-detailed-errors"}, + }, } From 15d1231d8ce214dece434824b91e7329c4a30c6d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 11:35:33 +0200 Subject: [PATCH 04/97] Regenerate upload body protocol fixture --- protocol_contract_generated_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 5fbd4e0..da49d24 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -532,6 +532,32 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ OperationIDs: []string{"createTusUpload"}, Primitives: []string{"upload-during-creation", "emit-progress"}, }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"uploadBodyHeaders"}, + Status: "covered-by-generated-scenario", + }, + Description: "Send protocol-specific upload body headers whenever the client transmits file bytes.", + FeatureID: "uploadBodyHeaders", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "send-upload-body-headers", + Condition: "", + Summary: "Attach the protocol-specific upload body content type when a request has bytes.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Upload bytes with the protocol-specific body headers.", + }, + }, + OperationIDs: []string{"createTusUpload", "patchTusUpload"}, + Primitives: []string{"send-upload-body-headers"}, + }, { Conformance: generatedTusClientFeatureConformance{ ScenarioIDs: []string{"overridePatchMethod"}, From 4e92795ff8863b27ccf0e3b301288b2ab0ab5947 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 22:39:50 +0200 Subject: [PATCH 05/97] Assert generated TUS upload events --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index da49d24..fc1a53e 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -704,8 +704,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"singleUploadLifecycle", "creationWithUpload", "resumeFromPreviousUpload"}, + Status: "covered-by-generated-scenario", }, Description: "Expose progress and accepted-chunk callbacks from runtime upload activity.", FeatureID: "uploadCallbacks", From deab9a57b52dff47385f62d88203aae91dbe7990 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 23:19:53 +0200 Subject: [PATCH 06/97] Cover TUS request lifecycle conformance --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index fc1a53e..f2d6809 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -737,8 +737,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"requestLifecycleHooks", "retryPatchAfterOffsetRecovery"}, + Status: "covered-by-generated-scenario", }, Description: "Run before-request, after-response, and custom retry hooks around transport.", FeatureID: "requestLifecycleHooks", From 50a702f0f321db866e9bf00dbbfc35e71d39085e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:06:10 +0200 Subject: [PATCH 07/97] Cover TUS abort conformance --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index f2d6809..56e918f 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -685,8 +685,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"abortUpload"}, + Status: "covered-by-generated-scenario", }, Description: "Abort the active request, pending retry timer, and any partial uploads.", FeatureID: "abortUpload", From ed1f367657684f7ca6a8faa6a63d7855e56aa40a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:14:09 +0200 Subject: [PATCH 08/97] Cover TUS URL storage conformance --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 56e918f..dcbe794 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -763,8 +763,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"singleUploadLifecycle", "resumeFromPreviousUpload"}, + Status: "covered-by-generated-scenario", }, Description: "Persist, find, resume, and optionally remove upload URLs by fingerprint.", FeatureID: "resumeUrlStorage", From af4d6ca661f3f6e8a0e818444c80d04eba226f59 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:19:28 +0200 Subject: [PATCH 09/97] Cover TUS relative Location conformance --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index dcbe794..43631ef 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -881,8 +881,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"relativeLocationResolution"}, + Status: "covered-by-generated-scenario", }, Description: "Normalize relative Location headers against the request endpoint.", FeatureID: "relativeLocationResolution", From 907218a4001beaf7fa7bd5974082e12a2ac513d9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:48:41 +0200 Subject: [PATCH 10/97] Refresh TUS input source contract --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 43631ef..e09b5eb 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -796,8 +796,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"arrayBufferInput", "arrayBufferViewInput", "webReadableStreamInput", "nodeReadableStreamInput", "nodePathInput"}, + Status: "covered-by-generated-scenario", }, Description: "Support the reference client input/source families across runtimes.", FeatureID: "inputSources", From d9ca945a67ed8dda1434c4bf2b6f5981f8814b48 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 18:10:38 +0200 Subject: [PATCH 11/97] Refresh TUS retry state contract --- protocol_contract_generated_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index e09b5eb..fc7efcf 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -657,6 +657,32 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ OperationIDs: []string{"createTusUpload", "getTusUploadOffset", "patchTusUpload"}, Primitives: []string{"retry-with-backoff", "recover-offset-after-error"}, }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"retryPatchAfterOffsetRecovery"}, + Status: "covered-by-generated-scenario", + }, + Description: "Schedule retry timers and reset retry attempts after accepted progress.", + FeatureID: "retryStateTransitions", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "schedule-retry-timer", + Condition: "", + Summary: "Consume the current retry delay and restart the upload after that timer fires.", + }, + { + Kind: "primitive", + OperationID: "", + Primitive: "reset-retry-attempt-after-progress", + Condition: "", + Summary: "Reset retry attempts once a later retry observes server-side offset progress.", + }, + }, + OperationIDs: []string{"getTusUploadOffset", "patchTusUpload"}, + Primitives: []string{"retry-with-backoff", "schedule-retry-timer", "reset-retry-attempt-after-progress"}, + }, { Conformance: generatedTusClientFeatureConformance{ ScenarioIDs: []string{"terminateWithRetry"}, From 74cee7c4bff283df5e7d95994741a696bdd066ba Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 20:09:07 +0200 Subject: [PATCH 12/97] Refresh TUS URL storage contract --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index fc7efcf..782ed0c 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -862,8 +862,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"webStorageUrlStorageBackend", "fileUrlStorageBackend"}, + Status: "covered-by-generated-scenario", }, Description: "Support browser and file-backed URL storage implementations.", FeatureID: "urlStorageBackends", From f8a871bddfd8dfd36eb8037fcd7d372edf6cdc27 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 21:06:25 +0200 Subject: [PATCH 13/97] Refresh TUS protocol selection contract --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 782ed0c..69713a0 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -888,8 +888,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"ietfDraft05CreationWithUpload", "ietfDraft03ResumeWithoutKnownLength"}, + Status: "covered-by-generated-scenario", }, Description: "Select between tus v1 and supported IETF draft client protocol modes.", FeatureID: "protocolVersionSelection", From 95952fee76731e0f2aebe852469de80e2964b2cf Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 22:26:26 +0200 Subject: [PATCH 14/97] Refresh TUS start validation contract --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 69713a0..c21b353 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -926,8 +926,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"startValidationMissingInput", "startValidationMissingEndpointOrUploadUrl", "startValidationUnsupportedProtocol", "startValidationRetryDelaysNotArray", "startValidationParallelUploadsWithUploadUrl", "startValidationParallelUploadsWithUploadSize", "startValidationParallelUploadsWithDeferredLength", "startValidationParallelBoundariesWithoutParallelUploads", "startValidationParallelBoundariesLengthMismatch"}, + Status: "covered-by-generated-scenario", }, Description: "Validate option combinations before starting runtime work.", FeatureID: "startOptionValidation", From 8da958d3f699516994339e34dee4baea42f79761 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 23:10:16 +0200 Subject: [PATCH 15/97] Update detailed error conformance --- protocol_contract_generated_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index c21b353..ac19ce3 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -945,8 +945,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: nil, - Status: "needs-generated-scenario", + ScenarioIDs: []string{"detailedCreateResponseError", "detailedCreateRequestError"}, + Status: "covered-by-generated-scenario", }, Description: "Attach request, response, status, body, and request ID context to errors.", FeatureID: "detailedErrors", From 8883866a64f504f161611211b687b963c1fd1453 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 12:33:47 +0200 Subject: [PATCH 16/97] Add generated URL storage backend --- protocol_contract_generated_test.go | 280 +++++++++++++++++++++++++ url_storage_contract_generated_test.go | 184 ++++++++++++++++ url_storage_generated.go | 244 +++++++++++++++++++++ 3 files changed, 708 insertions(+) create mode 100644 url_storage_contract_generated_test.go create mode 100644 url_storage_generated.go diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index ac19ce3..287604a 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -62,6 +62,38 @@ type generatedTusClientFeatureFlowStep struct { Summary string } +type generatedTusClientFlowContract struct { + UrlStorage generatedTusClientUrlStoragePolicy +} + +type generatedTusClientUrlStoragePolicy struct { + ID generatedTusClientUrlStorageIDPolicy + Namespace string + Separator string +} + +type generatedTusClientUrlStorageIDPolicy struct { + Multiplier float64 + Strategy string +} + +type generatedTusClientUrlStorageConformanceScenario struct { + Actions []generatedTusClientUrlStorageConformanceAction + Backend string + FeatureID string + Runtimes []string + ScenarioID string +} + +type generatedTusClientUrlStorageConformanceAction struct { + ExpectedKeyPrefix string + ExpectedKeyRefs []string + Fingerprint string + KeyRef string + Kind string + Upload map[string]any +} + var generatedTusWireVersions = []generatedTusWireVersion{ { Default: true, @@ -963,3 +995,251 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ Primitives: []string{"report-detailed-errors"}, }, } + +var generatedTusClientFlow = generatedTusClientFlowContract{ + UrlStorage: generatedTusClientUrlStoragePolicy{ + ID: generatedTusClientUrlStorageIDPolicy{ + Multiplier: 1000000000000, + Strategy: "rounded-random-number", + }, + Namespace: "tus", + Separator: "::", + }, +} + +var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlStorageConformanceScenario{ + { + Actions: []generatedTusClientUrlStorageConformanceAction{ + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "", + KeyRef: "", + Kind: "assert-empty", + Upload: nil, + }, + { + ExpectedKeyPrefix: "tus::contract-storage-a::", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-a", + KeyRef: "a1", + Kind: "add-upload", + Upload: map[string]any{ + "id": 1.0, + "metadata": map[string]any{ + "filename": "a1.txt", + }, + "size": 11.0, + "uploadUrl": "https://tus.io/uploads/storage-a1", + }, + }, + { + ExpectedKeyPrefix: "tus::contract-storage-a::", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-a", + KeyRef: "a2", + Kind: "add-upload", + Upload: map[string]any{ + "id": 2.0, + "metadata": map[string]any{ + "filename": "a2.txt", + }, + "size": 12.0, + "uploadUrl": "https://tus.io/uploads/storage-a2", + }, + }, + { + ExpectedKeyPrefix: "tus::contract-storage-b::", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-b", + KeyRef: "b1", + Kind: "add-upload", + Upload: map[string]any{ + "id": 3.0, + "metadata": map[string]any{ + "filename": "b1.txt", + }, + "size": 13.0, + "uploadUrl": "https://tus.io/uploads/storage-b1", + }, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"a1", "a2"}, + Fingerprint: "contract-storage-a", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"b1"}, + Fingerprint: "contract-storage-b", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"a1", "a2", "b1"}, + Fingerprint: "", + KeyRef: "", + Kind: "find-all", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "", + KeyRef: "a2", + Kind: "remove-upload", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "", + KeyRef: "b1", + Kind: "remove-upload", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"a1"}, + Fingerprint: "contract-storage-a", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-b", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + }, + Backend: "web-storage", + FeatureID: "urlStorageBackends", + Runtimes: []string{"browser"}, + ScenarioID: "webStorageUrlStorageBackend", + }, + { + Actions: []generatedTusClientUrlStorageConformanceAction{ + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "", + KeyRef: "", + Kind: "assert-empty", + Upload: nil, + }, + { + ExpectedKeyPrefix: "tus::contract-storage-a::", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-a", + KeyRef: "a1", + Kind: "add-upload", + Upload: map[string]any{ + "id": 1.0, + "metadata": map[string]any{ + "filename": "a1.txt", + }, + "size": 11.0, + "uploadUrl": "https://tus.io/uploads/storage-a1", + }, + }, + { + ExpectedKeyPrefix: "tus::contract-storage-a::", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-a", + KeyRef: "a2", + Kind: "add-upload", + Upload: map[string]any{ + "id": 2.0, + "metadata": map[string]any{ + "filename": "a2.txt", + }, + "size": 12.0, + "uploadUrl": "https://tus.io/uploads/storage-a2", + }, + }, + { + ExpectedKeyPrefix: "tus::contract-storage-b::", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-b", + KeyRef: "b1", + Kind: "add-upload", + Upload: map[string]any{ + "id": 3.0, + "metadata": map[string]any{ + "filename": "b1.txt", + }, + "size": 13.0, + "uploadUrl": "https://tus.io/uploads/storage-b1", + }, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"a1", "a2"}, + Fingerprint: "contract-storage-a", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"b1"}, + Fingerprint: "contract-storage-b", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"a1", "a2", "b1"}, + Fingerprint: "", + KeyRef: "", + Kind: "find-all", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "", + KeyRef: "a2", + Kind: "remove-upload", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "", + KeyRef: "b1", + Kind: "remove-upload", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: []string{"a1"}, + Fingerprint: "contract-storage-a", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + { + ExpectedKeyPrefix: "", + ExpectedKeyRefs: nil, + Fingerprint: "contract-storage-b", + KeyRef: "", + Kind: "find-by-fingerprint", + Upload: nil, + }, + }, + Backend: "file-storage", + FeatureID: "urlStorageBackends", + Runtimes: []string{"deno", "node"}, + ScenarioID: "fileUrlStorageBackend", + }, +} diff --git a/url_storage_contract_generated_test.go b/url_storage_contract_generated_test.go new file mode 100644 index 0000000..9fd930b --- /dev/null +++ b/url_storage_contract_generated_test.go @@ -0,0 +1,184 @@ +// 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 ( + "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_generated.go b/url_storage_generated.go new file mode 100644 index 0000000..3577b2e --- /dev/null +++ b/url_storage_generated.go @@ -0,0 +1,244 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "math" + "math/rand" + "os" + "sort" + "strings" + "sync" +) + +const ( + generatedTusURLStorageIDMultiplier = 1000000000000 + generatedTusURLStorageIDStrategy = "rounded-random-number" + generatedTusURLStorageNamespace = "tus" + generatedTusURLStorageSeparator = "::" +) + +type URLStorageUpload map[string]any + +type URLStorage interface { + FindAllUploads() ([]URLStorageUpload, error) + FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) + RemoveUpload(urlStorageKey string) error + AddUpload(fingerprint string, upload URLStorageUpload) (string, error) +} + +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 { + if generatedTusURLStorageIDStrategy != "rounded-random-number" { + panic(fmt.Sprintf("tus: unsupported URL storage ID policy %s", generatedTusURLStorageIDStrategy)) + } + + return int64(math.Round(randomValue * generatedTusURLStorageIDMultiplier)) +} + +func newURLStorageKey(fingerprint string) string { + return URLStorageKey(fingerprint, URLStorageID(rand.Float64())) +} + +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 +} From 290220ea40aa2cf0cfa2aa2dde6f592bca9e54d5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 15:44:23 +0200 Subject: [PATCH 17/97] Add generated URL storage resume flow --- url_storage_generated.go | 163 +++++++++++++++++ url_storage_resume_contract_generated_test.go | 169 ++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 url_storage_resume_contract_generated_test.go diff --git a/url_storage_generated.go b/url_storage_generated.go index 3577b2e..898bcdf 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -9,12 +9,14 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "math/rand" "os" "sort" "strings" "sync" + "time" ) const ( @@ -22,6 +24,7 @@ const ( generatedTusURLStorageIDStrategy = "rounded-random-number" generatedTusURLStorageNamespace = "tus" generatedTusURLStorageSeparator = "::" + generatedTusURLStorageCreationTime = "sdk-current-date-string" ) type URLStorageUpload map[string]any @@ -33,6 +36,16 @@ type URLStorage interface { AddUpload(fingerprint string, upload URLStorageUpload) (string, error) } +type URLStorageUploadOptions struct { + Storage URLStorage + Source io.ReadSeeker + Fingerprint string + Size int64 + Metadata map[string]string + RemoveFingerprintOnSuccess bool + ChunkSize int64 +} + type MemoryURLStorage struct { mu sync.Mutex records map[string]URLStorageUpload @@ -200,6 +213,156 @@ func newURLStorageKey(fingerprint string) string { return URLStorageKey(fingerprint, URLStorageID(rand.Float64())) } +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") + } + + upload, storageKey, err := c.resumeUploadFromURLStorage(options) + if err != nil { + return upload, err + } + if upload == nil { + upload, storageKey, err = c.createUploadForURLStorage(options) + if err != nil { + return upload, err + } + } + + if _, err := options.Source.Seek(upload.RemoteOffset, io.SeekStart); err != nil { + return upload, err + } + + stream := NewUploadStream(c, upload) + if options.ChunkSize != 0 { + stream.ChunkSize = options.ChunkSize + } + if _, err := stream.ReadFrom(options.Source); err != nil { + return upload, err + } + if options.RemoveFingerprintOnSuccess && storageKey != "" { + if err := options.Storage.RemoveUpload(storageKey); err != nil { + return upload, err + } + } + + return upload, nil +} + +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) + } + + storageKey, _ := stringFromURLStorageUpload(storedUpload, "urlStorageKey") + return upload, storageKey, nil + } + + return nil, "", nil +} + +func (c *Client) createUploadForURLStorage( + options URLStorageUploadOptions, +) (*Upload, string, error) { + upload := &Upload{} + if _, err := c.CreateUpload(upload, options.Size, false, options.Metadata); err != nil { + return upload, "", err + } + + storageKey, err := options.Storage.AddUpload( + options.Fingerprint, + URLStorageUploadFromUpload(*upload), + ) + if err != nil { + return upload, "", err + } + + return upload, storageKey, 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 { + if generatedTusURLStorageCreationTime != "sdk-current-date-string" { + panic(fmt.Sprintf("tus: unsupported URL storage creation time policy %s", generatedTusURLStorageCreationTime)) + } + + 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, diff --git a/url_storage_resume_contract_generated_test.go b/url_storage_resume_contract_generated_test.go new file mode 100644 index 0000000..3f5e353 --- /dev/null +++ b/url_storage_resume_contract_generated_test.go @@ -0,0 +1,169 @@ +// 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" + "net/url" + "strings" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + 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() + "/uploads") + 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, http.StatusOK) + getReply := generatedURLStorageResumeResponseHeaders( + reply.Status(getResponse.StatusCode), + getResponse, + map[string]string{ + "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, http.StatusNoContent) + patchReply := generatedURLStorageResumeResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "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, + "Upload-Offset": generatedTusResumeFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusResumeFlowContent), + Fingerprint: generatedTusResumeFlowFingerprint, + Size: 11, + Metadata: generatedTusResumeFlowMetadata, + RemoveFingerprintOnSuccess: true, + }) + if err != nil { + t.Fatal(err) + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + response = response.Header(field.DisplayName, value) + } + + return response +} From f009da6d647d2fa640b8570fc6552282dd50ef43 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 16:04:42 +0200 Subject: [PATCH 18/97] Add generated URL storage create flow --- url_storage_create_contract_generated_test.go | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 url_storage_create_contract_generated_test.go diff --git a/url_storage_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go new file mode 100644 index 0000000..a8a7e76 --- /dev/null +++ b/url_storage_create_contract_generated_test.go @@ -0,0 +1,178 @@ +// 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" + "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" + 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() + "/uploads") + 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, http.StatusCreated) + createReply := generatedURLStorageCreateResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + }, + ) + srvMock.AddMocks( + generatedURLStorageCreateRequestHeaders( + mocha.Request(). + URL(expect.URLPath("/uploads")). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Upload-Metadata": encodedMetadata, + "Upload-Length": generatedTusCreateFlowUploadLength, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchReply := generatedURLStorageCreateResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "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, + "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) + } + if generatedTusCreateFlowRemoveFingerprintOnSuccess { + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + response = response.Header(field.DisplayName, value) + } + + return response +} From 618236d825e2aac22bd147692c27558f86ddd326 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 16:21:21 +0200 Subject: [PATCH 19/97] Add generated file upload helper --- url_storage_file_contract_generated_test.go | 212 ++++++++++++++++++++ url_storage_generated.go | 101 +++++++++- 2 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 url_storage_file_contract_generated_test.go diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go new file mode 100644 index 0000000..262acd9 --- /dev/null +++ b/url_storage_file_contract_generated_test.go @@ -0,0 +1,212 @@ +// 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" + "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" + 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() + "/uploads") + 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}, + } + + storage := NewMemoryURLStorage() + createdUploadURL := srvMock.URL() + generatedTusFileFlowCreatedUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusFileFlowMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createReply := generatedURLStorageFileResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + }, + ) + srvMock.AddMocks( + generatedURLStorageFileRequestHeaders( + mocha.Request(). + URL(expect.URLPath("/uploads")). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Upload-Metadata": encodedMetadata, + "Upload-Length": generatedTusFileFlowUploadLength, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchReply := generatedURLStorageFileResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "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, + "Upload-Offset": generatedTusFileFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload, err := client.UploadFileWithURLStorage(URLStorageFileUploadOptions{ + Storage: storage, + 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) + } + + storedUploads, err := storage.FindUploadsByFingerprint(expectedFingerprint) + if err != nil { + t.Fatal(err) + } + if generatedTusFileFlowRemoveFingerprintOnSuccess { + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_generated.go b/url_storage_generated.go index 898bcdf..668f78c 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -13,20 +13,34 @@ import ( "math" "math/rand" "os" + "path/filepath" "sort" + "strconv" "strings" "sync" "time" ) const ( - generatedTusURLStorageIDMultiplier = 1000000000000 - generatedTusURLStorageIDStrategy = "rounded-random-number" - generatedTusURLStorageNamespace = "tus" - generatedTusURLStorageSeparator = "::" - generatedTusURLStorageCreationTime = "sdk-current-date-string" + generatedTusNodeFileFingerprintPath = "absolute" + generatedTusNodeFileFingerprintPrefix = "node-file" + generatedTusNodeFileFingerprintSeparator = "-" + generatedTusURLStorageIDMultiplier = 1000000000000 + generatedTusURLStorageIDStrategy = "rounded-random-number" + generatedTusURLStorageNamespace = "tus" + generatedTusURLStorageSeparator = "::" + generatedTusURLStorageCreationTime = "sdk-current-date-string" ) +var generatedTusNodeFileFingerprintFields = []string{"prefix", "absolutePath", "size", "mtimeMs", "endpoint"} + +type FileFingerprintInput struct { + AbsolutePath string + Endpoint string + MtimeMs int64 + Size int64 +} + type URLStorageUpload map[string]any type URLStorage interface { @@ -46,6 +60,14 @@ type URLStorageUploadOptions struct { ChunkSize int64 } +type URLStorageFileUploadOptions struct { + Storage URLStorage + Path string + Metadata map[string]string + RemoveFingerprintOnSuccess bool + ChunkSize int64 +} + type MemoryURLStorage struct { mu sync.Mutex records map[string]URLStorageUpload @@ -213,6 +235,41 @@ func newURLStorageKey(fingerprint string) string { return URLStorageKey(fingerprint, URLStorageID(rand.Float64())) } +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{ + Storage: options.Storage, + Source: file, + Fingerprint: fingerprint, + Size: info.Size(), + Metadata: options.Metadata, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + ChunkSize: options.ChunkSize, + }) +} + func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, error) { if options.Storage == nil { return nil, errors.New("tus: URL storage is required") @@ -255,6 +312,40 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, return upload, nil } +func FileFingerprint(input FileFingerprintInput) string { + parts := make([]string, 0, len(generatedTusNodeFileFingerprintFields)) + for _, field := range generatedTusNodeFileFingerprintFields { + switch field { + case "prefix": + parts = append(parts, generatedTusNodeFileFingerprintPrefix) + case "absolutePath": + parts = append(parts, input.AbsolutePath) + case "size": + parts = append(parts, strconv.FormatInt(input.Size, 10)) + case "mtimeMs": + parts = append(parts, strconv.FormatInt(input.MtimeMs, 10)) + case "endpoint": + parts = append(parts, input.Endpoint) + default: + panic(fmt.Sprintf("tus: unsupported Node file fingerprint field %s", field)) + } + } + + return strings.Join(parts, generatedTusNodeFileFingerprintSeparator) +} + +func nodeFileFingerprintPath(path string) (string, error) { + if generatedTusNodeFileFingerprintPath != "absolute" { + return "", fmt.Errorf("tus: unsupported Node file fingerprint path policy %s", generatedTusNodeFileFingerprintPath) + } + + 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) { From 9a30c6ac590a06d83a63a94e15bce855fdb578a2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 16:41:54 +0200 Subject: [PATCH 20/97] Add generated file-backed upload helper --- url_storage_file_contract_generated_test.go | 7 ++++--- url_storage_generated.go | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go index 262acd9..a5bbbe3 100644 --- a/url_storage_file_contract_generated_test.go +++ b/url_storage_file_contract_generated_test.go @@ -87,7 +87,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { ProtocolVersions: []string{DefaultProtocolVersion}, } - storage := NewMemoryURLStorage() + storagePath := filepath.Join(t.TempDir(), "url-storage.json") createdUploadURL := srvMock.URL() + generatedTusFileFlowCreatedUploadPath encodedMetadata, err := EncodeMetadata(generatedTusFileFlowMetadata) if err != nil { @@ -136,8 +136,8 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { ) srvMock.AddMocks(patchRequest.Reply(patchReply)) - upload, err := client.UploadFileWithURLStorage(URLStorageFileUploadOptions{ - Storage: storage, + upload, err := client.UploadFileWithFileBackedURLStorage(FileBackedURLStorageUploadOptions{ + URLStoragePath: storagePath, Path: filePath, Metadata: generatedTusFileFlowMetadata, RemoveFingerprintOnSuccess: generatedTusFileFlowRemoveFingerprintOnSuccess, @@ -152,6 +152,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) } + storage := NewFileURLStorage(storagePath) storedUploads, err := storage.FindUploadsByFingerprint(expectedFingerprint) if err != nil { t.Fatal(err) diff --git a/url_storage_generated.go b/url_storage_generated.go index 668f78c..8158d35 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -68,6 +68,14 @@ type URLStorageFileUploadOptions struct { ChunkSize int64 } +type FileBackedURLStorageUploadOptions struct { + URLStoragePath string + Path string + Metadata map[string]string + RemoveFingerprintOnSuccess bool + ChunkSize int64 +} + type MemoryURLStorage struct { mu sync.Mutex records map[string]URLStorageUpload @@ -235,6 +243,16 @@ func newURLStorageKey(fingerprint string) string { return URLStorageKey(fingerprint, URLStorageID(rand.Float64())) } +func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorageUploadOptions) (*Upload, error) { + return c.UploadFileWithURLStorage(URLStorageFileUploadOptions{ + Storage: NewFileURLStorage(options.URLStoragePath), + Path: options.Path, + Metadata: options.Metadata, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + ChunkSize: options.ChunkSize, + }) +} + func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) (*Upload, error) { absolutePath, err := nodeFileFingerprintPath(options.Path) if err != nil { From 1274cd651853954c8db5af1cf0711f50f940bcb1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 16:57:46 +0200 Subject: [PATCH 21/97] Add generated retry offset recovery flow --- url_storage_generated.go | 115 ++++++- url_storage_retry_contract_generated_test.go | 308 +++++++++++++++++++ 2 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 url_storage_retry_contract_generated_test.go diff --git a/url_storage_generated.go b/url_storage_generated.go index 8158d35..91043e6 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -25,6 +25,8 @@ const ( generatedTusNodeFileFingerprintPath = "absolute" generatedTusNodeFileFingerprintPrefix = "node-file" generatedTusNodeFileFingerprintSeparator = "-" + generatedTusRetryClientErrorStatus = 400 + generatedTusRetryStatusCategoryDivisor = 100 generatedTusURLStorageIDMultiplier = 1000000000000 generatedTusURLStorageIDStrategy = "rounded-random-number" generatedTusURLStorageNamespace = "tus" @@ -33,6 +35,8 @@ const ( ) var generatedTusNodeFileFingerprintFields = []string{"prefix", "absolutePath", "size", "mtimeMs", "endpoint"} +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 @@ -58,6 +62,8 @@ type URLStorageUploadOptions struct { Metadata map[string]string RemoveFingerprintOnSuccess bool ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool } type URLStorageFileUploadOptions struct { @@ -66,6 +72,8 @@ type URLStorageFileUploadOptions struct { Metadata map[string]string RemoveFingerprintOnSuccess bool ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool } type FileBackedURLStorageUploadOptions struct { @@ -74,6 +82,8 @@ type FileBackedURLStorageUploadOptions struct { Metadata map[string]string RemoveFingerprintOnSuccess bool ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool } type MemoryURLStorage struct { @@ -250,6 +260,8 @@ func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorage Metadata: options.Metadata, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, ChunkSize: options.ChunkSize, + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, }) } @@ -285,6 +297,8 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( Metadata: options.Metadata, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, ChunkSize: options.ChunkSize, + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, }) } @@ -310,15 +324,11 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, } } - if _, err := options.Source.Seek(upload.RemoteOffset, io.SeekStart); err != nil { - return upload, err - } - stream := NewUploadStream(c, upload) if options.ChunkSize != 0 { stream.ChunkSize = options.ChunkSize } - if _, err := stream.ReadFrom(options.Source); err != nil { + if err := c.uploadURLStorageSource(options, stream); err != nil { return upload, err } if options.RemoveFingerprintOnSuccess && storageKey != "" { @@ -330,6 +340,101 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, return upload, nil } +func (c *Client) uploadURLStorageSource( + options URLStorageUploadOptions, + stream *UploadStream, +) error { + retryDelays := urlStorageRetryDelays(options) + retryAttempt := 0 + offsetBeforeRetry := stream.Upload.RemoteOffset + + for { + if _, err := options.Source.Seek(stream.Upload.RemoteOffset, io.SeekStart); err != nil { + return err + } + if _, err := stream.ReadFrom(options.Source); err != nil { + effectiveRetryAttempt := urlStorageRetryAttempt( + stream.Upload.RemoteOffset, + offsetBeforeRetry, + retryAttempt, + ) + if !urlStorageShouldScheduleRetry(options, err, stream.lastResponseStatus(), effectiveRetryAttempt, retryDelays) { + return err + } + delay := retryDelays[effectiveRetryAttempt] + if delay > 0 { + time.Sleep(delay) + } + retryAttempt = effectiveRetryAttempt + 1 + offsetBeforeRetry = stream.Upload.RemoteOffset + if _, err := stream.Sync(); err != nil { + return err + } + stream.ForceClean() + continue + } + + return nil + } +} + +func (us *UploadStream) lastResponseStatus() int { + if us.LastResponse == nil { + return 0 + } + + return us.LastResponse.StatusCode +} + +func urlStorageRetryDelays(options URLStorageUploadOptions) []time.Duration { + if options.RetryDelays == nil { + return append([]time.Duration(nil), generatedTusDefaultRetryDelays...) + } + + return options.RetryDelays +} + +func urlStorageRetryAttempt(offset int64, offsetBeforeRetry int64, retryAttempt int) int { + if offset > offsetBeforeRetry { + return 0 + } + + return retryAttempt +} + +func urlStorageShouldScheduleRetry( + options URLStorageUploadOptions, + err error, + statusCode int, + retryAttempt int, + retryDelays []time.Duration, +) bool { + if retryAttempt >= len(retryDelays) || !urlStorageShouldRetryStatus(statusCode) { + return false + } + if options.OnShouldRetry != nil { + return options.OnShouldRetry(err, retryAttempt) + } + + return true +} + +func urlStorageShouldRetryStatus(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 FileFingerprint(input FileFingerprintInput) string { parts := make([]string, 0, len(generatedTusNodeFileFingerprintFields)) for _, field := range generatedTusNodeFileFingerprintFields { diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go new file mode 100644 index 0000000..bdbabb2 --- /dev/null +++ b/url_storage_retry_contract_generated_test.go @@ -0,0 +1,308 @@ +// 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 ( + "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" + generatedTusRetryFlowFinalPatchAcceptedOffset = "11" + generatedTusRetryFlowFinalPatchBody = " world" + generatedTusRetryFlowFinalPatchOffset = "5" + generatedTusRetryFlowFingerprint = "retryPatchAfterOffsetRecovery-fingerprint" + generatedTusRetryFlowFirstPatchBody = "hello world" + generatedTusRetryFlowFirstPatchOffset = "0" + generatedTusRetryFlowFirstRecoveredLength = "11" + generatedTusRetryFlowFirstRecoveredOffset = "5" + generatedTusRetryFlowSecondPatchBody = " world" + generatedTusRetryFlowSecondPatchOffset = "5" + generatedTusRetryFlowSecondRecoveredLength = "11" + generatedTusRetryFlowSecondRecoveredOffset = "5" + generatedTusRetryFlowUploadLength = "11" + generatedTusRetryFlowUploadPath = "/uploads/retry-contract" +) + +type generatedTusRetryDecision struct { + Decision bool + RetryAttempt int +} + +var generatedTusRetryFlowMetadata = map[string]string{"filename": "hello.txt"} +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() + "/uploads") + 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, http.StatusCreated) + createReply := generatedURLStorageRetryResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + }, + ) + srvMock.AddMocks( + generatedURLStorageRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath("/uploads")). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Upload-Metadata": encodedMetadata, + "Upload-Length": generatedTusRetryFlowUploadLength, + }, + ).Repeat(1).Reply(createReply), + ) + + firstGetResponse := generatedResponseFor(getOperation, http.StatusOK) + firstGetReply := generatedURLStorageRetryResponseHeaders( + reply.Status(200), + firstGetResponse, + map[string]string{ + "Upload-Length": generatedTusRetryFlowFirstRecoveredLength, + "Upload-Offset": generatedTusRetryFlowFirstRecoveredOffset, + }, + ) + secondGetResponse := generatedResponseFor(getOperation, http.StatusOK) + secondGetReply := generatedURLStorageRetryResponseHeaders( + reply.Status(200), + secondGetResponse, + map[string]string{ + "Upload-Length": generatedTusRetryFlowSecondRecoveredLength, + "Upload-Offset": generatedTusRetryFlowSecondRecoveredOffset, + }, + ) + finalPatchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + finalPatchReply := generatedURLStorageRetryResponseHeaders( + reply.Status(204), + finalPatchResponse, + map[string]string{ + "Upload-Offset": generatedTusRetryFlowFinalPatchAcceptedOffset, + }, + ) + patchReplies := []struct { + Body string + Offset string + Reply *reply.StdReply + }{ + { + Body: generatedTusRetryFlowFirstPatchBody, + Offset: generatedTusRetryFlowFirstPatchOffset, + Reply: reply.Status(500), + }, + { + Body: generatedTusRetryFlowSecondPatchBody, + Offset: generatedTusRetryFlowSecondPatchOffset, + Reply: reply.Status(500), + }, + { + Body: generatedTusRetryFlowFinalPatchBody, + Offset: generatedTusRetryFlowFinalPatchOffset, + Reply: finalPatchReply, + }, + } + patchReplyIndex := 0 + srvMock.AddMocks( + generatedURLStorageRetryDynamicRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRetryFlowUploadPath)). + Method(patchOperation.Method), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + }, + 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 PATCH request %d", 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 PATCH body %q, got %q", expected.Body, string(body)) + } + patchReplyIndex += 1 + return expected.Reply.Build(r, m, p) + }), + ) + + getReplies := []*reply.StdReply{firstGetReply, secondGetReply} + 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 HEAD request %d", getReplyIndex) + return nil, nil + } + expected := getReplies[getReplyIndex] + getReplyIndex += 1 + return expected.Build(r, m, p) + }), + ) + + storage := NewMemoryURLStorage() + retryDecisionIndex := 0 + 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) + } + 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 PATCH requests, got %d", len(patchReplies), patchReplyIndex) + } + if getReplyIndex != len(getReplies) { + t.Fatalf("expected %d HEAD requests, got %d", len(getReplies), 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) + } +} + +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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + response = response.Header(field.DisplayName, value) + } + + return response +} From e5db73f121ff543a64311f68bf0da319515f6d3e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 17:23:19 +0200 Subject: [PATCH 22/97] Add generated termination retry flow --- termination_generated.go | 47 +++++ termination_retry_contract_generated_test.go | 205 +++++++++++++++++++ url_storage_generated.go | 32 +-- 3 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 termination_generated.go create mode 100644 termination_retry_contract_generated_test.go diff --git a/termination_generated.go b/termination_generated.go new file mode 100644 index 0000000..309f53f --- /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 += 1 + } +} diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go new file mode 100644 index 0000000..4408165 --- /dev/null +++ b/termination_retry_contract_generated_test.go @@ -0,0 +1,205 @@ +// 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 ( + "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 ( + generatedTusTerminateFlowContent = "hello world" + generatedTusTerminateFlowPatchAcceptedOffset = "5" + generatedTusTerminateFlowPatchBody = "hello" + generatedTusTerminateFlowPatchOffset = "0" + generatedTusTerminateFlowUploadLength = "11" + generatedTusTerminateFlowUploadPath = "/uploads/terminate-contract" +) + +var generatedTusTerminateFlowMetadata = map[string]string{"filename": "hello.txt"} +var generatedTusTerminateFlowRetryDelays = []time.Duration{0 * time.Millisecond, 0 * time.Millisecond} + +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() + "/uploads") + 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, http.StatusCreated) + createReply := generatedTerminationRetryResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + }, + ) + srvMock.AddMocks( + generatedTerminationRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath("/uploads")). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Upload-Metadata": encodedMetadata, + "Upload-Length": generatedTusTerminateFlowUploadLength, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchReply := generatedTerminationRetryResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "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, + "Upload-Offset": generatedTusTerminateFlowPatchOffset, + }, + ).Reply(patchReply), + ) + + finalTerminateResponse := generatedResponseFor(terminateOperation, http.StatusNoContent) + finalTerminateReply := generatedTerminationRetryResponseHeaders( + reply.Status(204), + finalTerminateResponse, + map[string]string{}, + ) + terminateReplies := []*reply.StdReply{ + reply.Status(423), + finalTerminateReply, + } + 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) + } + + response, err := client.TerminateUploadWithRetry(*upload, TerminateUploadOptions{ + RetryDelays: generatedTusTerminateFlowRetryDelays, + }) + if err != nil { + t.Fatal(err) + } + if response == nil || response.StatusCode != 204 { + t.Fatalf("expected termination status 204, got %#v", response) + } + if terminateReplyIndex != len(terminateReplies) { + t.Fatalf("expected %d termination requests, got %d", len(terminateReplies), terminateReplyIndex) + } +} + +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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_generated.go b/url_storage_generated.go index 91043e6..19c42be 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -344,7 +344,7 @@ func (c *Client) uploadURLStorageSource( options URLStorageUploadOptions, stream *UploadStream, ) error { - retryDelays := urlStorageRetryDelays(options) + retryDelays := generatedTusRetryDelays(options.RetryDelays) retryAttempt := 0 offsetBeforeRetry := stream.Upload.RemoteOffset @@ -353,12 +353,18 @@ func (c *Client) uploadURLStorageSource( return err } if _, err := stream.ReadFrom(options.Source); err != nil { - effectiveRetryAttempt := urlStorageRetryAttempt( + effectiveRetryAttempt := generatedTusRetryAttempt( stream.Upload.RemoteOffset, offsetBeforeRetry, retryAttempt, ) - if !urlStorageShouldScheduleRetry(options, err, stream.lastResponseStatus(), effectiveRetryAttempt, retryDelays) { + if !generatedTusShouldScheduleRetry( + options.OnShouldRetry, + err, + stream.lastResponseStatus(), + effectiveRetryAttempt, + retryDelays, + ) { return err } delay := retryDelays[effectiveRetryAttempt] @@ -386,15 +392,15 @@ func (us *UploadStream) lastResponseStatus() int { return us.LastResponse.StatusCode } -func urlStorageRetryDelays(options URLStorageUploadOptions) []time.Duration { - if options.RetryDelays == nil { +func generatedTusRetryDelays(retryDelays []time.Duration) []time.Duration { + if retryDelays == nil { return append([]time.Duration(nil), generatedTusDefaultRetryDelays...) } - return options.RetryDelays + return retryDelays } -func urlStorageRetryAttempt(offset int64, offsetBeforeRetry int64, retryAttempt int) int { +func generatedTusRetryAttempt(offset int64, offsetBeforeRetry int64, retryAttempt int) int { if offset > offsetBeforeRetry { return 0 } @@ -402,24 +408,24 @@ func urlStorageRetryAttempt(offset int64, offsetBeforeRetry int64, retryAttempt return retryAttempt } -func urlStorageShouldScheduleRetry( - options URLStorageUploadOptions, +func generatedTusShouldScheduleRetry( + onShouldRetry func(error, int) bool, err error, statusCode int, retryAttempt int, retryDelays []time.Duration, ) bool { - if retryAttempt >= len(retryDelays) || !urlStorageShouldRetryStatus(statusCode) { + if retryAttempt >= len(retryDelays) || !generatedTusShouldRetryStatus(statusCode) { return false } - if options.OnShouldRetry != nil { - return options.OnShouldRetry(err, retryAttempt) + if onShouldRetry != nil { + return onShouldRetry(err, retryAttempt) } return true } -func urlStorageShouldRetryStatus(statusCode int) bool { +func generatedTusShouldRetryStatus(statusCode int) bool { if statusCode == 0 { return false } From 9096f2df5b7bee2e0e24097c4a0c71ced83942ca Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 17:39:26 +0200 Subject: [PATCH 23/97] Add generated request lifecycle hooks --- request_lifecycle_contract_generated_test.go | 232 +++++++++++++++++++ request_lifecycle_generated.go | 112 +++++++++ 2 files changed, 344 insertions(+) create mode 100644 request_lifecycle_contract_generated_test.go create mode 100644 request_lifecycle_generated.go diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go new file mode 100644 index 0000000..01d8f02 --- /dev/null +++ b/request_lifecycle_contract_generated_test.go @@ -0,0 +1,232 @@ +// 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 ( + "fmt" + "net/http" + "net/url" + "reflect" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusRequestLifecycleUploadLength = "11" + generatedTusRequestLifecycleUploadOffset = "11" + generatedTusRequestLifecycleUploadPath = "/uploads/request-hooks-contract" +) + +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() + "/uploads") + 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{ + "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, fmt.Sprintf("before-request:%d", 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{ + "Upload-Length": generatedTusRequestLifecycleUploadLength, + "Upload-Offset": generatedTusRequestLifecycleUploadOffset, + }, + ); err != nil { + return err + } + events = append(events, fmt.Sprintf("after-response:%d", 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) + } + if !reflect.DeepEqual(events, generatedTusRequestLifecycleExpectedHookEvents) { + t.Fatalf( + "expected request lifecycle events %#v, got %#v", + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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..480c612 --- /dev/null +++ b/request_lifecycle_generated.go @@ -0,0 +1,112 @@ +// 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 ( + "fmt" + "net/http" +) + +const ( + generatedTusAfterResponseHookPolicy = "after-successful-transport-response" + generatedTusBeforeRequestHookPolicy = "before-transport-send" +) + +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) { + if err := generatedTusAssertRequestLifecyclePolicySupported(); err != nil { + return generatedTusRequestLifecycleHookPlan{}, err + } + + return generatedTusRequestLifecycleHookPlan{ + BeforeRequestHook: hooks.BeforeRequest != nil, + AfterResponseHook: hooks.AfterResponse != nil, + }, nil +} + +func generatedTusAssertRequestLifecyclePolicySupported() error { + if generatedTusBeforeRequestHookPolicy != "before-transport-send" { + return fmt.Errorf( + "tus: unsupported before-request hook policy %s", + generatedTusBeforeRequestHookPolicy, + ) + } + if generatedTusAfterResponseHookPolicy != "after-successful-transport-response" { + return fmt.Errorf( + "tus: unsupported after-response hook policy %s", + generatedTusAfterResponseHookPolicy, + ) + } + + return nil +} From de17f6211a29086ea5a233815e0d290bf1d44153 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 18:03:47 +0200 Subject: [PATCH 24/97] Add generated upload event hooks --- url_storage_create_contract_generated_test.go | 2 +- ...age_event_hooks_contract_generated_test.go | 198 +++++++++++++ url_storage_generated.go | 264 +++++++++++++++++- 3 files changed, 451 insertions(+), 13 deletions(-) create mode 100644 url_storage_event_hooks_contract_generated_test.go diff --git a/url_storage_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go index a8a7e76..4f96206 100644 --- a/url_storage_create_contract_generated_test.go +++ b/url_storage_create_contract_generated_test.go @@ -47,7 +47,7 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { patchOperation := generatedProtocolOperation("patchTusUpload") client := NewClient(http.DefaultClient, baseURL) client.Capabilities = &ServerCapabilities{ - Extensions: []string{createOperation.Role}, + Extensions: []string{createOperation.Role}, ProtocolVersions: []string{DefaultProtocolVersion}, } diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go new file mode 100644 index 0000000..cdfa7b2 --- /dev/null +++ b/url_storage_event_hooks_contract_generated_test.go @@ -0,0 +1,198 @@ +// 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 ( + "fmt" + "net/http" + "net/url" + "reflect" + "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" + generatedTusEventHooksFingerprint = "contract-single-fingerprint" + generatedTusEventHooksPatchAcceptedOffset = "11" + generatedTusEventHooksPatchBody = "hello world" + generatedTusEventHooksPatchOffset = "0" + generatedTusEventHooksUploadLength = "11" +) + +var generatedTusEventHooksExpectedEvents = []string{"upload-url-available", "progress:0:11", "progress:11:11", "chunk-complete:11:11:11"} +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() + "/uploads") + 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, http.StatusCreated) + createReply := generatedURLStorageEventHooksResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + }, + ) + srvMock.AddMocks( + generatedURLStorageEventHooksRequestHeaders( + mocha.Request(). + URL(expect.URLPath("/uploads")). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Upload-Metadata": encodedMetadata, + "Upload-Length": generatedTusEventHooksUploadLength, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchReply := generatedURLStorageEventHooksResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "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, + "Upload-Offset": generatedTusEventHooksPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + events := []string{} + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusEventHooksContent), + Fingerprint: generatedTusEventHooksFingerprint, + Size: 11, + Metadata: generatedTusEventHooksMetadata, + EventHooks: UploadEventHooks{ + OnUploadURLAvailable: func() error { + events = append(events, "upload-url-available") + return nil + }, + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append( + events, + fmt.Sprintf("progress:%d:%s", bytesSent, generatedTusEventHooksTotal(bytesTotal)), + ) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append( + events, + fmt.Sprintf( + "chunk-complete:%d:%d:%s", + chunkSize, + bytesAccepted, + generatedTusEventHooksTotal(bytesTotal), + ), + ) + 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) + } + if !reflect.DeepEqual(events, generatedTusEventHooksExpectedEvents) { + t.Fatalf("expected event hooks %#v, got %#v", generatedTusEventHooksExpectedEvents, events) + } +} + +func generatedTusEventHooksTotal(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_generated.go b/url_storage_generated.go index 19c42be..d1ed184 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -22,16 +22,25 @@ import ( ) const ( - generatedTusNodeFileFingerprintPath = "absolute" - generatedTusNodeFileFingerprintPrefix = "node-file" - generatedTusNodeFileFingerprintSeparator = "-" - generatedTusRetryClientErrorStatus = 400 - generatedTusRetryStatusCategoryDivisor = 100 - generatedTusURLStorageIDMultiplier = 1000000000000 - generatedTusURLStorageIDStrategy = "rounded-random-number" - generatedTusURLStorageNamespace = "tus" - generatedTusURLStorageSeparator = "::" - generatedTusURLStorageCreationTime = "sdk-current-date-string" + generatedTusNodeFileFingerprintPath = "absolute" + generatedTusNodeFileFingerprintPrefix = "node-file" + generatedTusNodeFileFingerprintSeparator = "-" + generatedTusChunkCompleteAfterChunkAccepted = "accepted-chunk-size-and-offset" + generatedTusProgressAfterChunkAccepted = "accepted-offset" + generatedTusProgressAfterResumeComplete = "upload-length" + generatedTusProgressBeforeRequestBody = "current-offset" + generatedTusProgressDuringRequest = "start-offset-plus-transmitted-bytes" + generatedTusProgressParallelPart = "aggregated-part-progress" + generatedTusRetryClientErrorStatus = 400 + generatedTusRetryStatusCategoryDivisor = 100 + generatedTusUploadURLAvailableCreate = "after-url-known-before-storage" + generatedTusUploadURLAvailableParallel = "not-emitted" + generatedTusUploadURLAvailableResume = "after-url-known-before-storage" + generatedTusURLStorageIDMultiplier = 1000000000000 + generatedTusURLStorageIDStrategy = "rounded-random-number" + generatedTusURLStorageNamespace = "tus" + generatedTusURLStorageSeparator = "::" + generatedTusURLStorageCreationTime = "sdk-current-date-string" ) var generatedTusNodeFileFingerprintFields = []string{"prefix", "absolutePath", "size", "mtimeMs", "endpoint"} @@ -47,6 +56,12 @@ type FileFingerprintInput struct { type URLStorageUpload map[string]any +type UploadEventHooks struct { + OnProgress func(bytesSent int64, bytesTotal *int64) error + OnChunkComplete func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error + OnUploadURLAvailable func() error +} + type URLStorage interface { FindAllUploads() ([]URLStorageUpload, error) FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) @@ -64,6 +79,7 @@ type URLStorageUploadOptions struct { ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks } type URLStorageFileUploadOptions struct { @@ -74,6 +90,7 @@ type URLStorageFileUploadOptions struct { ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks } type FileBackedURLStorageUploadOptions struct { @@ -84,6 +101,7 @@ type FileBackedURLStorageUploadOptions struct { ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks } type MemoryURLStorage struct { @@ -262,6 +280,7 @@ func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorage ChunkSize: options.ChunkSize, RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, + EventHooks: options.EventHooks, }) } @@ -299,6 +318,7 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( ChunkSize: options.ChunkSize, RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, + EventHooks: options.EventHooks, }) } @@ -352,7 +372,27 @@ func (c *Client) uploadURLStorageSource( if _, err := options.Source.Seek(stream.Upload.RemoteOffset, io.SeekStart); err != nil { return err } - if _, err := stream.ReadFrom(options.Source); err != nil { + 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 := generatedTusRetryAttempt( stream.Upload.RemoteOffset, offsetBeforeRetry, @@ -379,11 +419,56 @@ func (c *Client) uploadURLStorageSource( 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 + } + } - return nil + 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 @@ -441,6 +526,155 @@ func generatedTusShouldRetryStatus(statusCode int) bool { return false } +func generatedTusEmitUploadURLAvailable(hooks UploadEventHooks, context string) error { + if hooks.OnUploadURLAvailable == nil { + return nil + } + if err := generatedTusAssertUploadURLAvailableHookPolicySupported(); err != nil { + return err + } + + 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 + } + if err := generatedTusAssertEventHookPolicySupported(); err != nil { + return err + } + + return hooks.OnProgress(currentOffset, generatedTusInt64Pointer(bytesTotal)) +} + +func generatedTusEmitProgressAfterChunkAccepted( + hooks UploadEventHooks, + uploadOffset int64, + bytesTotal int64, +) error { + if hooks.OnProgress == nil { + return nil + } + if err := generatedTusAssertEventHookPolicySupported(); err != nil { + return err + } + + return hooks.OnProgress(uploadOffset, generatedTusInt64Pointer(bytesTotal)) +} + +func generatedTusEmitChunkCompleteAfterChunkAccepted( + hooks UploadEventHooks, + chunkSize int64, + bytesAccepted int64, + bytesTotal int64, +) error { + if hooks.OnChunkComplete == nil { + return nil + } + if err := generatedTusAssertEventHookPolicySupported(); err != nil { + return err + } + + return hooks.OnChunkComplete( + chunkSize, + bytesAccepted, + generatedTusInt64Pointer(bytesTotal), + ) +} + +func generatedTusInt64Pointer(value int64) *int64 { + return &value +} + +func generatedTusAssertUploadURLAvailableHookPolicySupported() error { + if generatedTusUploadURLAvailableCreate != "after-url-known-before-storage" { + return fmt.Errorf( + "tus: unsupported create upload URL hook policy %s", + generatedTusUploadURLAvailableCreate, + ) + } + if generatedTusUploadURLAvailableResume != "after-url-known-before-storage" { + return fmt.Errorf( + "tus: unsupported resume upload URL hook policy %s", + generatedTusUploadURLAvailableResume, + ) + } + if generatedTusUploadURLAvailableParallel != "not-emitted" { + return fmt.Errorf( + "tus: unsupported parallel final upload URL hook policy %s", + generatedTusUploadURLAvailableParallel, + ) + } + + return nil +} + +func generatedTusAssertEventHookPolicySupported() error { + if err := generatedTusAssertUploadURLAvailableHookPolicySupported(); err != nil { + return err + } + if generatedTusProgressAfterChunkAccepted != "accepted-offset" { + return fmt.Errorf( + "tus: unsupported chunk-accepted progress hook policy %s", + generatedTusProgressAfterChunkAccepted, + ) + } + if generatedTusProgressAfterResumeComplete != "upload-length" { + return fmt.Errorf( + "tus: unsupported completed-resume progress hook policy %s", + generatedTusProgressAfterResumeComplete, + ) + } + if generatedTusProgressBeforeRequestBody != "current-offset" { + return fmt.Errorf( + "tus: unsupported request-body progress hook policy %s", + generatedTusProgressBeforeRequestBody, + ) + } + if generatedTusProgressDuringRequest != "start-offset-plus-transmitted-bytes" { + return fmt.Errorf( + "tus: unsupported request progress hook policy %s", + generatedTusProgressDuringRequest, + ) + } + if generatedTusProgressParallelPart != "aggregated-part-progress" { + return fmt.Errorf( + "tus: unsupported parallel progress hook policy %s", + generatedTusProgressParallelPart, + ) + } + if generatedTusChunkCompleteAfterChunkAccepted != "accepted-chunk-size-and-offset" { + return fmt.Errorf( + "tus: unsupported chunk-complete hook policy %s", + generatedTusChunkCompleteAfterChunkAccepted, + ) + } + + return nil +} + func FileFingerprint(input FileFingerprintInput) string { parts := make([]string, 0, len(generatedTusNodeFileFingerprintFields)) for _, field := range generatedTusNodeFileFingerprintFields { @@ -503,6 +737,9 @@ func (c *Client) resumeUploadFromURLStorage( 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 @@ -518,6 +755,9 @@ func (c *Client) createUploadForURLStorage( if _, err := c.CreateUpload(upload, options.Size, false, options.Metadata); err != nil { return upload, "", err } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "createUpload"); err != nil { + return upload, "", err + } storageKey, err := options.Storage.AddUpload( options.Fingerprint, From a03bc15cf3cab14aabff56a6714ca07cf0046089 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 18:35:21 +0200 Subject: [PATCH 25/97] Add generated upload success hooks --- ...age_event_hooks_contract_generated_test.go | 32 +++++++- url_storage_generated.go | 78 ++++++++++++++++++- url_storage_resume_contract_generated_test.go | 21 +++++ 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index cdfa7b2..6c9c414 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -27,7 +27,7 @@ const ( generatedTusEventHooksUploadLength = "11" ) -var generatedTusEventHooksExpectedEvents = []string{"upload-url-available", "progress:0:11", "progress:11:11", "chunk-complete:11:11:11"} +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) { @@ -103,9 +103,13 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { srvMock.AddMocks(patchRequest.Reply(patchReply)) events := []string{} + source := &generatedTusEventHooksSource{ + Reader: strings.NewReader(generatedTusEventHooksContent), + events: &events, + } upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ Storage: storage, - Source: strings.NewReader(generatedTusEventHooksContent), + Source: source, Fingerprint: generatedTusEventHooksFingerprint, Size: 11, Metadata: generatedTusEventHooksMetadata, @@ -133,6 +137,20 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { ) 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, "success") + return nil + }, }, }) if err != nil { @@ -157,6 +175,16 @@ func generatedTusEventHooksTotal(bytesTotal *int64) string { return fmt.Sprintf("%d", *bytesTotal) } +type generatedTusEventHooksSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusEventHooksSource) Close() error { + *source.events = append(*source.events, "source-close") + return nil +} + func generatedURLStorageEventHooksRequestHeaders( builder *mocha.MockBuilder, operation generatedTusProtocolOperation, diff --git a/url_storage_generated.go b/url_storage_generated.go index d1ed184..4f0f20f 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -12,6 +12,7 @@ import ( "io" "math" "math/rand" + "net/http" "os" "path/filepath" "sort" @@ -33,6 +34,9 @@ const ( generatedTusProgressParallelPart = "aggregated-part-progress" generatedTusRetryClientErrorStatus = 400 generatedTusRetryStatusCategoryDivisor = 100 + generatedTusSuccessCloseSource = "after-hook-when-source-open" + generatedTusSuccessEmit = "after-upload-complete" + generatedTusSuccessRemoveStoredURL = "before-hook-when-option-enabled" generatedTusUploadURLAvailableCreate = "after-url-known-before-storage" generatedTusUploadURLAvailableParallel = "not-emitted" generatedTusUploadURLAvailableResume = "after-url-known-before-storage" @@ -56,9 +60,15 @@ type FileFingerprintInput struct { 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 } @@ -104,6 +114,16 @@ type FileBackedURLStorageUploadOptions struct { EventHooks UploadEventHooks } +type generatedTusSuccessInput struct { + EventHooks UploadEventHooks + LastResponse *http.Response + RemoveFingerprintOnSuccess bool + Source io.ReadSeeker + Storage URLStorage + StorageKey string + Upload *Upload +} + type MemoryURLStorage struct { mu sync.Mutex records map[string]URLStorageUpload @@ -351,10 +371,16 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, if err := c.uploadURLStorageSource(options, stream); err != nil { return upload, err } - if options.RemoveFingerprintOnSuccess && storageKey != "" { - if err := options.Storage.RemoveUpload(storageKey); err != nil { - return upload, err - } + if err := generatedTusEmitSuccess(generatedTusSuccessInput{ + EventHooks: options.EventHooks, + LastResponse: stream.LastResponse, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + Source: options.Source, + Storage: options.Storage, + StorageKey: storageKey, + Upload: upload, + }); err != nil { + return upload, err } return upload, nil @@ -604,6 +630,32 @@ func generatedTusEmitChunkCompleteAfterChunkAccepted( ) } +func generatedTusEmitSuccess(input generatedTusSuccessInput) error { + if err := generatedTusAssertEventHookPolicySupported(); err != nil { + return err + } + if input.RemoveFingerprintOnSuccess && 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 + } + } + + closer, ok := input.Source.(io.Closer) + if ok { + return closer.Close() + } + + return nil +} + func generatedTusInt64Pointer(value int64) *int64 { return &value } @@ -671,6 +723,24 @@ func generatedTusAssertEventHookPolicySupported() error { generatedTusChunkCompleteAfterChunkAccepted, ) } + if generatedTusSuccessCloseSource != "after-hook-when-source-open" { + return fmt.Errorf( + "tus: unsupported success source-close policy %s", + generatedTusSuccessCloseSource, + ) + } + if generatedTusSuccessEmit != "after-upload-complete" { + return fmt.Errorf( + "tus: unsupported success hook policy %s", + generatedTusSuccessEmit, + ) + } + if generatedTusSuccessRemoveStoredURL != "before-hook-when-option-enabled" { + return fmt.Errorf( + "tus: unsupported success storage cleanup policy %s", + generatedTusSuccessRemoveStoredURL, + ) + } return nil } diff --git a/url_storage_resume_contract_generated_test.go b/url_storage_resume_contract_generated_test.go index 3f5e353..cb17dcf 100644 --- a/url_storage_resume_contract_generated_test.go +++ b/url_storage_resume_contract_generated_test.go @@ -5,6 +5,7 @@ package tusgo import ( + "fmt" "net/http" "net/url" "strings" @@ -101,6 +102,7 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { ) srvMock.AddMocks(patchRequest.Reply(patchReply)) + successCalled := false upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ Storage: storage, Source: strings.NewReader(generatedTusResumeFlowContent), @@ -108,10 +110,29 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { 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) } From 86eef8077a2f34786f853c040c32b7072f8f7cd4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 19:51:22 +0200 Subject: [PATCH 26/97] Add generated upload abort context --- protocol_contract_generated_test.go | 148 +++++++++--------- termination_retry_contract_generated_test.go | 4 +- url_storage_abort_contract_generated_test.go | 150 +++++++++++++++++++ url_storage_file_contract_generated_test.go | 2 +- url_storage_generated.go | 68 ++++++++- url_storage_retry_contract_generated_test.go | 4 +- 6 files changed, 293 insertions(+), 83 deletions(-) create mode 100644 url_storage_abort_contract_generated_test.go diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 287604a..b10f949 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -41,8 +41,8 @@ type generatedTusProtocolOperation struct { } type generatedTusClientFeature struct { - Conformance generatedTusClientFeatureConformance - Description string + Conformance generatedTusClientFeatureConformance + Description string FeatureID string Flow []generatedTusClientFeatureFlowStep OperationIDs []string @@ -444,9 +444,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"singleUploadLifecycle"}, Status: "covered-by-generated-scenario", }, - Description: "Create an upload, store its URL, upload bytes, and finish successfully.", - FeatureID: "singleUploadLifecycle", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Create an upload, store its URL, upload bytes, and finish successfully.", + FeatureID: "singleUploadLifecycle", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -477,9 +477,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"resumeFromPreviousUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Resume a stored upload URL by discovering the remote offset before patching.", - FeatureID: "resumeUpload", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Resume a stored upload URL by discovering the remote offset before patching.", + FeatureID: "resumeUpload", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -510,9 +510,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"deferredLengthUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Create an upload without a known length and declare the length on final PATCH.", - FeatureID: "deferredLengthUpload", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Create an upload without a known length and declare the length on final PATCH.", + FeatureID: "deferredLengthUpload", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "operation", OperationID: "createTusUpload", @@ -543,9 +543,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"creationWithUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Send the first bytes on the creation request when the server/client support it.", - FeatureID: "creationWithUpload", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Send the first bytes on the creation request when the server/client support it.", + FeatureID: "creationWithUpload", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "operation", OperationID: "createTusUpload", @@ -569,9 +569,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"uploadBodyHeaders"}, Status: "covered-by-generated-scenario", }, - Description: "Send protocol-specific upload body headers whenever the client transmits file bytes.", - FeatureID: "uploadBodyHeaders", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Send protocol-specific upload body headers whenever the client transmits file bytes.", + FeatureID: "uploadBodyHeaders", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -595,9 +595,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"overridePatchMethod"}, Status: "covered-by-generated-scenario", }, - Description: "Tunnel PATCH through POST with the method-override header.", - FeatureID: "overridePatchMethod", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Tunnel PATCH through POST with the method-override header.", + FeatureID: "overridePatchMethod", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "operation", OperationID: "getTusUploadOffset", @@ -628,9 +628,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"parallelUploadConcat"}, Status: "covered-by-generated-scenario", }, - Description: "Split one input into partial uploads and concatenate their upload URLs.", - FeatureID: "parallelUploadConcat", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Split one input into partial uploads and concatenate their upload URLs.", + FeatureID: "parallelUploadConcat", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -661,9 +661,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"retryPatchAfterOffsetRecovery"}, Status: "covered-by-generated-scenario", }, - Description: "Recover from a failed chunk by reading the server offset before retrying.", - FeatureID: "retryOffsetRecovery", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Recover from a failed chunk by reading the server offset before retrying.", + FeatureID: "retryOffsetRecovery", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "operation", OperationID: "patchTusUpload", @@ -694,9 +694,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"retryPatchAfterOffsetRecovery"}, Status: "covered-by-generated-scenario", }, - Description: "Schedule retry timers and reset retry attempts after accepted progress.", - FeatureID: "retryStateTransitions", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Schedule retry timers and reset retry attempts after accepted progress.", + FeatureID: "retryStateTransitions", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -720,9 +720,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"terminateWithRetry"}, Status: "covered-by-generated-scenario", }, - Description: "Terminate an upload resource and retry retryable termination failures.", - FeatureID: "terminateUpload", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Terminate an upload resource and retry retryable termination failures.", + FeatureID: "terminateUpload", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -746,9 +746,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"abortUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Abort the active request, pending retry timer, and any partial uploads.", - FeatureID: "abortUpload", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Abort the active request, pending retry timer, and any partial uploads.", + FeatureID: "abortUpload", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -765,9 +765,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"singleUploadLifecycle", "creationWithUpload", "resumeFromPreviousUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Expose progress and accepted-chunk callbacks from runtime upload activity.", - FeatureID: "uploadCallbacks", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Expose progress and accepted-chunk callbacks from runtime upload activity.", + FeatureID: "uploadCallbacks", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -798,9 +798,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"requestLifecycleHooks", "retryPatchAfterOffsetRecovery"}, Status: "covered-by-generated-scenario", }, - Description: "Run before-request, after-response, and custom retry hooks around transport.", - FeatureID: "requestLifecycleHooks", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Run before-request, after-response, and custom retry hooks around transport.", + FeatureID: "requestLifecycleHooks", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -824,9 +824,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"singleUploadLifecycle", "resumeFromPreviousUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Persist, find, resume, and optionally remove upload URLs by fingerprint.", - FeatureID: "resumeUrlStorage", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Persist, find, resume, and optionally remove upload URLs by fingerprint.", + FeatureID: "resumeUrlStorage", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -857,9 +857,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"arrayBufferInput", "arrayBufferViewInput", "webReadableStreamInput", "nodeReadableStreamInput", "nodePathInput"}, Status: "covered-by-generated-scenario", }, - Description: "Support the reference client input/source families across runtimes.", - FeatureID: "inputSources", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Support the reference client input/source families across runtimes.", + FeatureID: "inputSources", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -897,9 +897,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"webStorageUrlStorageBackend", "fileUrlStorageBackend"}, Status: "covered-by-generated-scenario", }, - Description: "Support browser and file-backed URL storage implementations.", - FeatureID: "urlStorageBackends", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Support browser and file-backed URL storage implementations.", + FeatureID: "urlStorageBackends", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -923,9 +923,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"ietfDraft05CreationWithUpload", "ietfDraft03ResumeWithoutKnownLength"}, Status: "covered-by-generated-scenario", }, - Description: "Select between tus v1 and supported IETF draft client protocol modes.", - FeatureID: "protocolVersionSelection", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Select between tus v1 and supported IETF draft client protocol modes.", + FeatureID: "protocolVersionSelection", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -942,9 +942,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"relativeLocationResolution"}, Status: "covered-by-generated-scenario", }, - Description: "Normalize relative Location headers against the request endpoint.", - FeatureID: "relativeLocationResolution", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Normalize relative Location headers against the request endpoint.", + FeatureID: "relativeLocationResolution", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -961,9 +961,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"startValidationMissingInput", "startValidationMissingEndpointOrUploadUrl", "startValidationUnsupportedProtocol", "startValidationRetryDelaysNotArray", "startValidationParallelUploadsWithUploadUrl", "startValidationParallelUploadsWithUploadSize", "startValidationParallelUploadsWithDeferredLength", "startValidationParallelBoundariesWithoutParallelUploads", "startValidationParallelBoundariesLengthMismatch"}, Status: "covered-by-generated-scenario", }, - Description: "Validate option combinations before starting runtime work.", - FeatureID: "startOptionValidation", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Validate option combinations before starting runtime work.", + FeatureID: "startOptionValidation", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -980,9 +980,9 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"detailedCreateResponseError", "detailedCreateRequestError"}, Status: "covered-by-generated-scenario", }, - Description: "Attach request, response, status, body, and request ID context to errors.", - FeatureID: "detailedErrors", - Flow: []generatedTusClientFeatureFlowStep{ + Description: "Attach request, response, status, body, and request ID context to errors.", + FeatureID: "detailedErrors", + Flow: []generatedTusClientFeatureFlowStep{ { Kind: "primitive", OperationID: "", @@ -1024,12 +1024,12 @@ var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlSt Fingerprint: "contract-storage-a", KeyRef: "a1", Kind: "add-upload", - Upload: map[string]any{ + Upload: map[string]any{ "id": 1.0, "metadata": map[string]any{ "filename": "a1.txt", }, - "size": 11.0, + "size": 11.0, "uploadUrl": "https://tus.io/uploads/storage-a1", }, }, @@ -1039,12 +1039,12 @@ var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlSt Fingerprint: "contract-storage-a", KeyRef: "a2", Kind: "add-upload", - Upload: map[string]any{ + Upload: map[string]any{ "id": 2.0, "metadata": map[string]any{ "filename": "a2.txt", }, - "size": 12.0, + "size": 12.0, "uploadUrl": "https://tus.io/uploads/storage-a2", }, }, @@ -1054,12 +1054,12 @@ var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlSt Fingerprint: "contract-storage-b", KeyRef: "b1", Kind: "add-upload", - Upload: map[string]any{ + Upload: map[string]any{ "id": 3.0, "metadata": map[string]any{ "filename": "b1.txt", }, - "size": 13.0, + "size": 13.0, "uploadUrl": "https://tus.io/uploads/storage-b1", }, }, @@ -1141,12 +1141,12 @@ var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlSt Fingerprint: "contract-storage-a", KeyRef: "a1", Kind: "add-upload", - Upload: map[string]any{ + Upload: map[string]any{ "id": 1.0, "metadata": map[string]any{ "filename": "a1.txt", }, - "size": 11.0, + "size": 11.0, "uploadUrl": "https://tus.io/uploads/storage-a1", }, }, @@ -1156,12 +1156,12 @@ var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlSt Fingerprint: "contract-storage-a", KeyRef: "a2", Kind: "add-upload", - Upload: map[string]any{ + Upload: map[string]any{ "id": 2.0, "metadata": map[string]any{ "filename": "a2.txt", }, - "size": 12.0, + "size": 12.0, "uploadUrl": "https://tus.io/uploads/storage-a2", }, }, @@ -1171,12 +1171,12 @@ var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlSt Fingerprint: "contract-storage-b", KeyRef: "b1", Kind: "add-upload", - Upload: map[string]any{ + Upload: map[string]any{ "id": 3.0, "metadata": map[string]any{ "filename": "b1.txt", }, - "size": 13.0, + "size": 13.0, "uploadUrl": "https://tus.io/uploads/storage-b1", }, }, diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 4408165..8837a15 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -49,7 +49,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { terminateOperation := generatedProtocolOperation("terminateTusUpload") client := NewClient(http.DefaultClient, baseURL) client.Capabilities = &ServerCapabilities{ - Extensions: []string{createOperation.Role, terminateOperation.Role}, + Extensions: []string{createOperation.Role, terminateOperation.Role}, ProtocolVersions: []string{DefaultProtocolVersion}, } @@ -74,7 +74,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { createOperation, map[string]string{ "Upload-Metadata": encodedMetadata, - "Upload-Length": generatedTusTerminateFlowUploadLength, + "Upload-Length": generatedTusTerminateFlowUploadLength, }, ).Reply(createReply), ) diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go new file mode 100644 index 0000000..2f39316 --- /dev/null +++ b/url_storage_abort_contract_generated_test.go @@ -0,0 +1,150 @@ +// 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 ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" +) + +const ( + generatedTusAbortContent = "hello world" + generatedTusAbortEndpointPath = "/uploads" + generatedTusAbortUploadLength = "11" +) + +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{ + "Upload-Metadata": encodedMetadata, + "Upload-Length": generatedTusAbortUploadLength, + }, + ); err != nil { + requestErr = err + } + events = append(events, "request-abort:0") + 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") + } + if !reflect.DeepEqual(events, generatedTusAbortExpectedEvents) { + t.Fatalf("expected abort events %#v, got %#v", 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 := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go index a5bbbe3..597bcc0 100644 --- a/url_storage_file_contract_generated_test.go +++ b/url_storage_file_contract_generated_test.go @@ -83,7 +83,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { patchOperation := generatedProtocolOperation("patchTusUpload") client := NewClient(http.DefaultClient, baseURL) client.Capabilities = &ServerCapabilities{ - Extensions: []string{createOperation.Role}, + Extensions: []string{createOperation.Role}, ProtocolVersions: []string{DefaultProtocolVersion}, } diff --git a/url_storage_generated.go b/url_storage_generated.go index 4f0f20f..9c36c68 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -6,6 +6,7 @@ package tusgo import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -32,6 +33,10 @@ const ( generatedTusProgressBeforeRequestBody = "current-offset" generatedTusProgressDuringRequest = "start-offset-plus-transmitted-bytes" generatedTusProgressParallelPart = "aggregated-part-progress" + generatedTusAbortErrorMessage = "Request was aborted" + generatedTusAbortRemoveStoredURLAfterTerm = "after-successful-termination" + generatedTusAbortSuppressErrorAfterAbort = true + generatedTusAbortTerminateUpload = "when-requested-and-upload-url-known" generatedTusRetryClientErrorStatus = 400 generatedTusRetryStatusCategoryDivisor = 100 generatedTusSuccessCloseSource = "after-hook-when-source-open" @@ -48,6 +53,7 @@ const ( ) var generatedTusNodeFileFingerprintFields = []string{"prefix", "absolutePath", "size", "mtimeMs", "endpoint"} +var generatedTusAbortSequence = []string{"mark-aborted", "abort-parallel-uploads", "abort-current-request", "clear-retry-timer", "terminate-upload-if-requested"} var generatedTusDefaultRetryDelays = []time.Duration{0 * time.Millisecond, 1000 * time.Millisecond, 3000 * time.Millisecond, 5000 * time.Millisecond} var generatedTusRetryableClientStatusCodes = []int{409, 423} @@ -80,6 +86,7 @@ type URLStorage interface { } type URLStorageUploadOptions struct { + Context context.Context Storage URLStorage Source io.ReadSeeker Fingerprint string @@ -93,6 +100,7 @@ type URLStorageUploadOptions struct { } type URLStorageFileUploadOptions struct { + Context context.Context Storage URLStorage Path string Metadata map[string]string @@ -104,6 +112,7 @@ type URLStorageFileUploadOptions struct { } type FileBackedURLStorageUploadOptions struct { + Context context.Context URLStoragePath string Path string Metadata map[string]string @@ -293,6 +302,7 @@ func newURLStorageKey(fingerprint string) string { func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorageUploadOptions) (*Upload, error) { return c.UploadFileWithURLStorage(URLStorageFileUploadOptions{ + Context: options.Context, Storage: NewFileURLStorage(options.URLStoragePath), Path: options.Path, Metadata: options.Metadata, @@ -329,6 +339,7 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( }) return c.UploadWithURLStorage(URLStorageUploadOptions{ + Context: options.Context, Storage: options.Storage, Source: file, Fingerprint: fingerprint, @@ -353,22 +364,27 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, return nil, errors.New("tus: unable to calculate fingerprint for this input file") } - upload, storageKey, err := c.resumeUploadFromURLStorage(options) + uploadClient, err := generatedTusClientWithUploadContext(c, options.Context) + if err != nil { + return nil, err + } + + upload, storageKey, err := uploadClient.resumeUploadFromURLStorage(options) if err != nil { return upload, err } if upload == nil { - upload, storageKey, err = c.createUploadForURLStorage(options) + upload, storageKey, err = uploadClient.createUploadForURLStorage(options) if err != nil { return upload, err } } - stream := NewUploadStream(c, upload) + stream := NewUploadStream(uploadClient, upload) if options.ChunkSize != 0 { stream.ChunkSize = options.ChunkSize } - if err := c.uploadURLStorageSource(options, stream); err != nil { + if err := uploadClient.uploadURLStorageSource(options, stream); err != nil { return upload, err } if err := generatedTusEmitSuccess(generatedTusSuccessInput{ @@ -503,6 +519,21 @@ func (us *UploadStream) lastResponseStatus() int { return us.LastResponse.StatusCode } +func generatedTusClientWithUploadContext(client *Client, ctx context.Context) (*Client, error) { + if ctx == nil { + return client, nil + } + if err := generatedTusAssertAbortPolicySupported(); err != nil { + return nil, err + } + + return client.WithContext(ctx), nil +} + +func IsUploadAbortError(err error) bool { + return errors.Is(err, context.Canceled) +} + func generatedTusRetryDelays(retryDelays []time.Duration) []time.Duration { if retryDelays == nil { return append([]time.Duration(nil), generatedTusDefaultRetryDelays...) @@ -745,6 +776,35 @@ func generatedTusAssertEventHookPolicySupported() error { return nil } +func generatedTusAssertAbortPolicySupported() error { + supportedActions := map[string]bool{ + "abort-current-request": true, + "abort-parallel-uploads": true, + "clear-retry-timer": true, + "mark-aborted": true, + "terminate-upload-if-requested": true, + } + for _, action := range generatedTusAbortSequence { + if !supportedActions[action] { + return fmt.Errorf("tus: unsupported abort sequence action %s", action) + } + } + if generatedTusAbortTerminateUpload != "when-requested-and-upload-url-known" { + return fmt.Errorf( + "tus: unsupported abort termination policy %s", + generatedTusAbortTerminateUpload, + ) + } + if generatedTusAbortRemoveStoredURLAfterTerm != "after-successful-termination" { + return fmt.Errorf( + "tus: unsupported abort storage cleanup policy %s", + generatedTusAbortRemoveStoredURLAfterTerm, + ) + } + + return nil +} + func FileFingerprint(input FileFingerprintInput) string { parts := make([]string, 0, len(generatedTusNodeFileFingerprintFields)) for _, field := range generatedTusNodeFileFingerprintFields { diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index bdbabb2..bc32f3a 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -70,7 +70,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { } client := NewClient(http.DefaultClient, baseURL) client.Capabilities = &ServerCapabilities{ - Extensions: []string{generatedProtocolOperation("createTusUpload").Role}, + Extensions: []string{generatedProtocolOperation("createTusUpload").Role}, ProtocolVersions: []string{DefaultProtocolVersion}, } @@ -99,7 +99,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { createOperation, map[string]string{ "Upload-Metadata": encodedMetadata, - "Upload-Length": generatedTusRetryFlowUploadLength, + "Upload-Length": generatedTusRetryFlowUploadLength, }, ).Repeat(1).Reply(createReply), ) From 7a4045e954dd10b888cfc9e53cbe4cb5d63531a4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 20:46:47 +0200 Subject: [PATCH 27/97] Add generated abort termination flow --- protocol_contract_generated_test.go | 6 +- ...ort_termination_contract_generated_test.go | 231 ++++++++++++++++++ url_storage_generated.go | 38 ++- 3 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 url_storage_abort_termination_contract_generated_test.go diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index b10f949..1f0a3a5 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -743,7 +743,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: []string{"abortUpload"}, + ScenarioIDs: []string{"abortUpload", "abortUploadAfterStoredUrl"}, Status: "covered-by-generated-scenario", }, Description: "Abort the active request, pending retry timer, and any partial uploads.", @@ -757,8 +757,8 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ Summary: "Cancel in-flight transport work without emitting user callbacks after abort.", }, }, - OperationIDs: nil, - Primitives: []string{"abort-current-request"}, + OperationIDs: []string{"terminateTusUpload"}, + Primitives: []string{"abort-current-request", "terminate-upload"}, }, { Conformance: generatedTusClientFeatureConformance{ diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go new file mode 100644 index 0000000..ba92f00 --- /dev/null +++ b/url_storage_abort_termination_contract_generated_test.go @@ -0,0 +1,231 @@ +// 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 ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" +) + +const ( + generatedTusAbortTerminationContent = "hello world" + generatedTusAbortTerminationEndpointPath = "/uploads" + generatedTusAbortTerminationFingerprint = "contract-abort-terminate-fingerprint" + generatedTusAbortTerminationPatchBody = "hello world" + generatedTusAbortTerminationPatchOffset = "0" + generatedTusAbortTerminationUploadLength = "11" + generatedTusAbortTerminationUploadPath = "/uploads/abort-terminate-contract" +) + +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{ + "Upload-Metadata": encodedMetadata, + "Upload-Length": generatedTusAbortTerminationUploadLength, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusAbortTerminationResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusAbortTerminationUploadPath, + }, + ) + responseWriter.WriteHeader(201) + + case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == patchOperation.Method: + 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": patchOperation.Request.ContentType, + "Upload-Offset": generatedTusAbortTerminationPatchOffset, + }, + )) + events = append(events, "request-abort:1") + close(patchStarted) + <-request.Context().Done() + + case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == terminateOperation.Method: + recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( + request, + terminateOperation, + map[string]string{}, + )) + terminateResponse := generatedResponseFor(terminateOperation, 204) + generatedWriteTusAbortTerminationResponseHeaders( + responseWriter, + terminateResponse, + map[string]string{}, + ) + 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, + Metadata: generatedTusAbortTerminationMetadata, + 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: + } + if !reflect.DeepEqual(events, generatedTusAbortTerminationExpectedEvents) { + t.Fatalf("expected abort termination events %#v, got %#v", 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 := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_generated.go b/url_storage_generated.go index 9c36c68..eee860a 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -93,6 +93,7 @@ type URLStorageUploadOptions struct { Size int64 Metadata map[string]string RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool @@ -105,6 +106,7 @@ type URLStorageFileUploadOptions struct { Path string Metadata map[string]string RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool @@ -117,6 +119,7 @@ type FileBackedURLStorageUploadOptions struct { Path string Metadata map[string]string RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool @@ -307,6 +310,7 @@ func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorage Path: options.Path, Metadata: options.Metadata, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + TerminateUploadOnAbort: options.TerminateUploadOnAbort, ChunkSize: options.ChunkSize, RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, @@ -346,6 +350,7 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( Size: info.Size(), Metadata: options.Metadata, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + TerminateUploadOnAbort: options.TerminateUploadOnAbort, ChunkSize: options.ChunkSize, RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, @@ -385,7 +390,7 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, stream.ChunkSize = options.ChunkSize } if err := uploadClient.uploadURLStorageSource(options, stream); err != nil { - return upload, err + return upload, c.generatedTusHandleURLStorageUploadAbort(options, upload, storageKey, err) } if err := generatedTusEmitSuccess(generatedTusSuccessInput{ EventHooks: options.EventHooks, @@ -534,6 +539,37 @@ func IsUploadAbortError(err error) bool { return errors.Is(err, context.Canceled) } +func (c *Client) generatedTusHandleURLStorageUploadAbort( + options URLStorageUploadOptions, + upload *Upload, + storageKey string, + err error, +) error { + if !IsUploadAbortError(err) { + return err + } + if err := generatedTusAssertAbortPolicySupported(); err != nil { + return err + } + if !options.TerminateUploadOnAbort || upload == nil || upload.Location == "" { + return err + } + + if _, terminateErr := c.TerminateUploadWithRetry(*upload, TerminateUploadOptions{ + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + }); terminateErr != nil { + return terminateErr + } + if storageKey != "" { + if err := options.Storage.RemoveUpload(storageKey); err != nil { + return err + } + } + + return err +} + func generatedTusRetryDelays(retryDelays []time.Duration) []time.Duration { if retryDelays == nil { return append([]time.Duration(nil), generatedTusDefaultRetryDelays...) From 90c44a01a2f21f1e9cbeb76309c8eec4986cc5dc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 30 May 2026 21:11:52 +0200 Subject: [PATCH 28/97] Add generated parallel upload flow --- url_storage_generated.go | 198 +++++++++++ ...torage_parallel_contract_generated_test.go | 335 ++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 url_storage_parallel_contract_generated_test.go diff --git a/url_storage_generated.go b/url_storage_generated.go index eee860a..ebfeb95 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -37,6 +37,12 @@ const ( generatedTusAbortRemoveStoredURLAfterTerm = "after-successful-termination" generatedTusAbortSuppressErrorAfterAbort = true generatedTusAbortTerminateUpload = "when-requested-and-upload-url-known" + generatedTusDefaultParallelUploads = 1 + generatedTusMinimumParallelUploads = 2 + generatedTusParallelPartialMetadata = "metadataForPartialUploads" + generatedTusParallelPartialNestedUploads = "disabled" + generatedTusParallelPartialURLStorage = "parent-managed" + generatedTusParallelUploadSplit = "contiguous-floor-size-last-remainder" generatedTusRetryClientErrorStatus = 400 generatedTusRetryStatusCategoryDivisor = 100 generatedTusSuccessCloseSource = "after-hook-when-source-open" @@ -92,6 +98,8 @@ type URLStorageUploadOptions struct { Fingerprint string Size int64 Metadata map[string]string + MetadataForPartialUploads map[string]string + ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool ChunkSize int64 @@ -105,6 +113,8 @@ type URLStorageFileUploadOptions struct { Storage URLStorage Path string Metadata map[string]string + MetadataForPartialUploads map[string]string + ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool ChunkSize int64 @@ -118,6 +128,8 @@ type FileBackedURLStorageUploadOptions struct { URLStoragePath string Path string Metadata map[string]string + MetadataForPartialUploads map[string]string + ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool ChunkSize int64 @@ -309,6 +321,8 @@ func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorage Storage: NewFileURLStorage(options.URLStoragePath), Path: options.Path, Metadata: options.Metadata, + MetadataForPartialUploads: options.MetadataForPartialUploads, + ParallelUploads: options.ParallelUploads, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, TerminateUploadOnAbort: options.TerminateUploadOnAbort, ChunkSize: options.ChunkSize, @@ -349,6 +363,8 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( Fingerprint: fingerprint, Size: info.Size(), Metadata: options.Metadata, + MetadataForPartialUploads: options.MetadataForPartialUploads, + ParallelUploads: options.ParallelUploads, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, TerminateUploadOnAbort: options.TerminateUploadOnAbort, ChunkSize: options.ChunkSize, @@ -373,6 +389,13 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, if err != nil { return nil, err } + parallelUploads, err := generatedTusParallelUploadCount(options.ParallelUploads) + if err != nil { + return nil, err + } + if parallelUploads > 1 { + return uploadClient.uploadParallelWithURLStorage(options, parallelUploads) + } upload, storageKey, err := uploadClient.resumeUploadFromURLStorage(options) if err != nil { @@ -407,6 +430,97 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, return upload, nil } +func (c *Client) uploadParallelWithURLStorage( + options URLStorageUploadOptions, + parallelUploads int, +) (*Upload, error) { + if err := generatedTusAssertParallelUploadPolicySupported(); err != nil { + return nil, err + } + partSizes, err := generatedTusParallelUploadPartSizes(options.Size, parallelUploads) + if err != nil { + return nil, err + } + + partials := make([]Upload, 0, len(partSizes)) + acceptedBytes := int64(0) + for _, partSize := range partSizes { + if _, err := options.Source.Seek(acceptedBytes, io.SeekStart); err != nil { + return nil, err + } + partBytes, err := readURLStorageUploadChunk(options.Source, partSize, partSize) + if err != nil { + return nil, err + } + + partialUpload := Upload{} + if _, err := c.CreateUpload( + &partialUpload, + partSize, + true, + generatedTusParallelPartialUploadMetadata(options), + ); err != nil { + return &partialUpload, err + } + stream := NewUploadStream(c, &partialUpload) + stream.ChunkSize = partSize + written, err := stream.Write(partBytes) + if err != nil { + return &partialUpload, c.generatedTusHandleURLStorageUploadAbort(options, &partialUpload, "", err) + } + if int64(written) != partSize { + return &partialUpload, fmt.Errorf("tus: expected to upload %d parallel bytes, wrote %d", partSize, written) + } + + acceptedBytes += partSize + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + acceptedBytes, + options.Size, + ); err != nil { + return &partialUpload, err + } + if err := generatedTusEmitChunkCompleteAfterChunkAccepted( + options.EventHooks, + partSize, + acceptedBytes, + options.Size, + ); err != nil { + return &partialUpload, err + } + partials = append(partials, partialUpload) + } + + finalUpload := &Upload{} + response, err := c.ConcatenateUploads(finalUpload, partials, options.Metadata) + if 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) uploadURLStorageSource( options URLStorageUploadOptions, stream *UploadStream, @@ -539,6 +653,55 @@ 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 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 generatedTusParallelPartialUploadMetadata(options URLStorageUploadOptions) map[string]string { + if generatedTusParallelPartialMetadata != "metadataForPartialUploads" { + panic(fmt.Sprintf( + "tus: unsupported parallel partial metadata policy %s", + generatedTusParallelPartialMetadata, + )) + } + + return cloneStringMap(options.MetadataForPartialUploads) +} + func (c *Client) generatedTusHandleURLStorageUploadAbort( options URLStorageUploadOptions, upload *Upload, @@ -812,6 +975,41 @@ func generatedTusAssertEventHookPolicySupported() error { return nil } +func generatedTusAssertParallelUploadPolicySupported() error { + if generatedTusParallelUploadSplit != "contiguous-floor-size-last-remainder" { + return fmt.Errorf( + "tus: unsupported parallel upload split policy %s", + generatedTusParallelUploadSplit, + ) + } + if generatedTusParallelPartialMetadata != "metadataForPartialUploads" { + return fmt.Errorf( + "tus: unsupported parallel partial metadata policy %s", + generatedTusParallelPartialMetadata, + ) + } + if generatedTusParallelPartialNestedUploads != "disabled" { + return fmt.Errorf( + "tus: unsupported nested parallel upload policy %s", + generatedTusParallelPartialNestedUploads, + ) + } + if generatedTusParallelPartialURLStorage != "parent-managed" { + return fmt.Errorf( + "tus: unsupported parallel URL storage policy %s", + generatedTusParallelPartialURLStorage, + ) + } + if generatedTusProgressParallelPart != "aggregated-part-progress" { + return fmt.Errorf( + "tus: unsupported parallel progress hook policy %s", + generatedTusProgressParallelPart, + ) + } + + return nil +} + func generatedTusAssertAbortPolicySupported() error { supportedActions := map[string]bool{ "abort-current-request": true, diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go new file mode 100644 index 0000000..de4870f --- /dev/null +++ b/url_storage_parallel_contract_generated_test.go @@ -0,0 +1,335 @@ +// 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 ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +const ( + generatedTusParallelConcatExtension = "concatenation" + generatedTusParallelContent = "hello world" + generatedTusParallelEndpointPath = "/uploads" + generatedTusParallelFinalConcatPrefix = "final;" + generatedTusParallelFinalPath = "/uploads/parallel-final" + generatedTusParallelUploadURLSeparator = " " + generatedTusParallelConformanceUploadCount = 2 +) + +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 generatedTusParallelPartPatchAcceptedOffsets = []string{"5", "6"} +var generatedTusParallelPartPatchBodies = []string{"hello", " world"} +var generatedTusParallelPartPatchOffsets = []string{"0", "0"} +var generatedTusParallelPartUploadLengths = []string{"5", "6"} +var generatedTusParallelPartUploadPaths = []string{"/uploads/parallel-part-1", "/uploads/parallel-part-2"} + +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) + } + + createIndex := 0 + patchIndex := 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 == generatedTusParallelEndpointPath && + request.Method == createOperation.Method && + createIndex < len(generatedTusParallelPartUploadPaths): + partIndex := createIndex + createIndex += 1 + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + createOperation, + map[string]string{ + "Upload-Concat": "partial", + "Upload-Metadata": encodedPartialMetadata, + "Upload-Length": generatedTusParallelPartUploadLengths[partIndex], + }, + )) + createResponse := generatedResponseFor(createOperation, http.StatusCreated) + generatedWriteTusParallelResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelPartUploadPaths[partIndex], + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusParallelEndpointPath && + request.Method == createOperation.Method && + createIndex == len(generatedTusParallelPartUploadPaths): + createIndex += 1 + recordRequestErr(generatedAssertTusParallelAbsentHeaders( + request, + generatedTusParallelFinalAbsentHeaders, + )) + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + createOperation, + map[string]string{ + "Upload-Concat": generatedTusParallelFinalConcatHeader(server.URL), + "Upload-Metadata": encodedMetadata, + }, + )) + finalResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusParallelResponseHeaders( + responseWriter, + finalResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelFinalPath, + }, + ) + 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 + } + patchIndex += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusParallelPartPatchBodies[partIndex] { + recordRequestErr(fmt.Errorf( + "expected parallel patch body %q, got %q", + generatedTusParallelPartPatchBodies[partIndex], + string(body), + )) + } + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Upload-Offset": generatedTusParallelPartPatchOffsets[partIndex], + }, + )) + patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + generatedWriteTusParallelResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Upload-Offset": generatedTusParallelPartPatchAcceptedOffsets[partIndex], + }, + ) + 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, fmt.Sprintf( + "progress:%d:%s", + bytesSent, + generatedTusParallelBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, fmt.Sprintf( + "chunk-complete:%d:%d:%s", + chunkSize, + 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) + } + if createIndex != len(generatedTusParallelPartUploadPaths)+1 { + t.Fatalf("expected %d create requests, got %d", len(generatedTusParallelPartUploadPaths)+1, createIndex) + } + if patchIndex != len(generatedTusParallelPartUploadPaths) { + t.Fatalf("expected %d patch requests, got %d", len(generatedTusParallelPartUploadPaths), patchIndex) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + if !reflect.DeepEqual(events, generatedTusParallelExpectedEvents) { + t.Fatalf("expected parallel events %#v, got %#v", 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(generatedTusParallelPartUploadPaths)) + for _, path := range generatedTusParallelPartUploadPaths { + locations = append(locations, serverURL+path) + } + + return generatedTusParallelFinalConcatPrefix + + strings.Join(locations, generatedTusParallelUploadURLSeparator) +} + +func generatedTusParallelPartIndexForPath(path string) int { + for index, candidate := range generatedTusParallelPartUploadPaths { + if path == candidate { + return index + } + } + + return -1 +} + +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 := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } +} From c002eec6142d219af183699c578dc70b1bef7bd1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 08:47:45 +0200 Subject: [PATCH 29/97] Add generated parallel cleanup flow --- protocol_contract_generated_test.go | 6 +- url_storage_generated.go | 282 ++++++++++++-- ...arallel_cleanup_contract_generated_test.go | 352 ++++++++++++++++++ ...torage_parallel_contract_generated_test.go | 91 ++++- 4 files changed, 685 insertions(+), 46 deletions(-) create mode 100644 url_storage_parallel_cleanup_contract_generated_test.go diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 1f0a3a5..e705291 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -625,10 +625,10 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: []string{"parallelUploadConcat"}, + ScenarioIDs: []string{"parallelUploadConcat", "parallelUploadAbortCleanup"}, Status: "covered-by-generated-scenario", }, - Description: "Split one input into partial uploads and concatenate their upload URLs.", + Description: "Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.", FeatureID: "parallelUploadConcat", Flow: []generatedTusClientFeatureFlowStep{ { @@ -654,7 +654,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, }, OperationIDs: []string{"createTusUpload", "patchTusUpload"}, - Primitives: []string{"concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries"}, + Primitives: []string{"concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries", "terminate-upload"}, }, { Conformance: generatedTusClientFeatureConformance{ diff --git a/url_storage_generated.go b/url_storage_generated.go index ebfeb95..8aeb706 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -42,6 +42,12 @@ const ( generatedTusParallelPartialMetadata = "metadataForPartialUploads" generatedTusParallelPartialNestedUploads = "disabled" generatedTusParallelPartialURLStorage = "parent-managed" + generatedTusParallelCleanupOnPartError = "terminate-created-partials-when-abort-termination-enabled" + generatedTusParallelCleanupReturnedError = "original-error-unless-cleanup-fails" + generatedTusParallelExecutionCancelOnError = true + generatedTusParallelExecutionResultOrder = "part-index" + generatedTusParallelExecutionSourceRead = "before-worker-start" + generatedTusParallelExecutionWorkerStrategy = "one-worker-per-part" generatedTusParallelUploadSplit = "contiguous-floor-size-last-remainder" generatedTusRetryClientErrorStatus = 400 generatedTusRetryStatusCategoryDivisor = 100 @@ -148,6 +154,20 @@ type generatedTusSuccessInput struct { 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 @@ -394,7 +414,7 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, return nil, err } if parallelUploads > 1 { - return uploadClient.uploadParallelWithURLStorage(options, parallelUploads) + return c.uploadParallelWithURLStorage(options, uploadClient, parallelUploads) } upload, storageKey, err := uploadClient.resumeUploadFromURLStorage(options) @@ -432,6 +452,7 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, func (c *Client) uploadParallelWithURLStorage( options URLStorageUploadOptions, + uploadClient *Client, parallelUploads int, ) (*Upload, error) { if err := generatedTusAssertParallelUploadPolicySupported(); err != nil { @@ -441,60 +462,67 @@ func (c *Client) uploadParallelWithURLStorage( if err != nil { return nil, err } + partInputs, err := generatedTusParallelUploadPartInputs(options.Source, partSizes) + if err != nil { + return nil, err + } - partials := make([]Upload, 0, len(partSizes)) - acceptedBytes := int64(0) - for _, partSize := range partSizes { - if _, err := options.Source.Seek(acceptedBytes, io.SeekStart); err != nil { - return nil, err - } - partBytes, err := readURLStorageUploadChunk(options.Source, partSize, partSize) - 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) - partialUpload := Upload{} - if _, err := c.CreateUpload( - &partialUpload, - partSize, - true, - generatedTusParallelPartialUploadMetadata(options), - ); err != nil { - return &partialUpload, err - } - stream := NewUploadStream(c, &partialUpload) - stream.ChunkSize = partSize - written, err := stream.Write(partBytes) - if err != nil { - return &partialUpload, c.generatedTusHandleURLStorageUploadAbort(options, &partialUpload, "", err) - } - if int64(written) != partSize { - return &partialUpload, fmt.Errorf("tus: expected to upload %d parallel bytes, wrote %d", partSize, written) - } + for result := range resultCh { + results[result.Index] = result + } + if err := generatedTusParallelUploadError(results); err != nil { + return generatedTusFirstCreatedParallelPartialUpload(results), + c.generatedTusCleanupParallelPartialUploads(options, results, err) + } - acceptedBytes += partSize + 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 &partialUpload, err + return &result.Upload, + c.generatedTusCleanupParallelPartialUploads(options, results, err) } if err := generatedTusEmitChunkCompleteAfterChunkAccepted( options.EventHooks, - partSize, + result.Size, acceptedBytes, options.Size, ); err != nil { - return &partialUpload, err + return &result.Upload, + c.generatedTusCleanupParallelPartialUploads(options, results, err) } - partials = append(partials, partialUpload) + partials = append(partials, result.Upload) } finalUpload := &Upload{} - response, err := c.ConcatenateUploads(finalUpload, partials, options.Metadata) + response, err := uploadClient.ConcatenateUploads(finalUpload, partials, options.Metadata) if err != nil { - return finalUpload, err + return finalUpload, c.generatedTusCleanupParallelPartialUploads(options, results, err) } if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "parallelFinalUpload"); err != nil { return finalUpload, err @@ -521,6 +549,48 @@ func (c *Client) uploadParallelWithURLStorage( 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 + } + + 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, @@ -691,6 +761,46 @@ func generatedTusParallelUploadPartSizes(uploadSize int64, parallelUploads int) return partSizes, nil } +func generatedTusParallelUploadPartInputs( + source io.ReadSeeker, + partSizes []int64, +) ([]generatedTusParallelPartInput, error) { + if generatedTusParallelExecutionSourceRead != "before-worker-start" { + return nil, fmt.Errorf( + "tus: unsupported parallel source read policy %s", + generatedTusParallelExecutionSourceRead, + ) + } + + 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 { if generatedTusParallelPartialMetadata != "metadataForPartialUploads" { panic(fmt.Sprintf( @@ -702,6 +812,71 @@ func generatedTusParallelPartialUploadMetadata(options URLStorageUploadOptions) return cloneStringMap(options.MetadataForPartialUploads) } +func generatedTusParallelUploadError(results []generatedTusParallelPartResult) error { + if generatedTusParallelExecutionResultOrder != "part-index" { + return fmt.Errorf( + "tus: unsupported parallel result order policy %s", + generatedTusParallelExecutionResultOrder, + ) + } + 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 { + if !options.TerminateUploadOnAbort { + return originalErr + } + if err := generatedTusAssertParallelCleanupPolicySupported(); err != nil { + return err + } + + for _, result := range results { + if result.Upload.Location == "" { + continue + } + if _, err := c.TerminateUploadWithRetry(result.Upload, TerminateUploadOptions{ + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + }); err != nil { + return err + } + } + + return originalErr +} + func (c *Client) generatedTusHandleURLStorageUploadAbort( options URLStorageUploadOptions, upload *Upload, @@ -976,6 +1151,24 @@ func generatedTusAssertEventHookPolicySupported() error { } func generatedTusAssertParallelUploadPolicySupported() error { + if generatedTusParallelExecutionWorkerStrategy != "one-worker-per-part" { + return fmt.Errorf( + "tus: unsupported parallel worker strategy %s", + generatedTusParallelExecutionWorkerStrategy, + ) + } + if generatedTusParallelExecutionResultOrder != "part-index" { + return fmt.Errorf( + "tus: unsupported parallel result order policy %s", + generatedTusParallelExecutionResultOrder, + ) + } + if generatedTusParallelExecutionSourceRead != "before-worker-start" { + return fmt.Errorf( + "tus: unsupported parallel source read policy %s", + generatedTusParallelExecutionSourceRead, + ) + } if generatedTusParallelUploadSplit != "contiguous-floor-size-last-remainder" { return fmt.Errorf( "tus: unsupported parallel upload split policy %s", @@ -1010,6 +1203,23 @@ func generatedTusAssertParallelUploadPolicySupported() error { return nil } +func generatedTusAssertParallelCleanupPolicySupported() error { + if generatedTusParallelCleanupOnPartError != "terminate-created-partials-when-abort-termination-enabled" { + return fmt.Errorf( + "tus: unsupported parallel cleanup policy %s", + generatedTusParallelCleanupOnPartError, + ) + } + if generatedTusParallelCleanupReturnedError != "original-error-unless-cleanup-fails" { + return fmt.Errorf( + "tus: unsupported parallel cleanup error policy %s", + generatedTusParallelCleanupReturnedError, + ) + } + + return nil +} + func generatedTusAssertAbortPolicySupported() error { supportedActions := map[string]bool{ "abort-current-request": true, diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go new file mode 100644 index 0000000..1985b69 --- /dev/null +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -0,0 +1,352 @@ +// 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 ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const ( + generatedTusParallelCleanupContent = "hello world" + generatedTusParallelCleanupEndpointPath = "/uploads" + generatedTusParallelCleanupFailurePartIndex = 0 + generatedTusParallelCleanupFailureStatus = 500 + generatedTusParallelCleanupUploadCount = 2 +) + +var generatedTusParallelCleanupMetadataForPartialUploads = map[string]string{"test": "world"} +var generatedTusParallelCleanupPartPatchBodies = []string{"hello", " world"} +var generatedTusParallelCleanupPartPatchOffsets = []string{"0", "0"} +var generatedTusParallelCleanupPartUploadLengths = []string{"5", "6"} +var generatedTusParallelCleanupPartUploadPaths = []string{"/uploads/parallel-cleanup-part-1", "/uploads/parallel-cleanup-part-2"} +var generatedTusParallelCleanupTerminatePaths = []string{"/uploads/parallel-cleanup-part-1", "/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) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + go generatedTusReleaseParallelCleanupPatchesAfterAllStarted( + patchArrivals, + releasePatches, + requestErrs, + ) + 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{ + "Upload-Concat": "partial", + "Upload-Metadata": encodedPartialMetadata, + "Upload-Length": generatedTusParallelCleanupPartUploadLengths[partIndex], + }, + )) + createResponse := generatedResponseFor(createOperation, http.StatusCreated) + generatedWriteTusParallelCleanupResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelCleanupPartUploadPaths[partIndex], + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.Method == patchOperation.Method: + 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 <- 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) != generatedTusParallelCleanupPartPatchBodies[partIndex] { + recordRequestErr(fmt.Errorf( + "expected cleanup patch body %q, got %q", + generatedTusParallelCleanupPartPatchBodies[partIndex], + string(body), + )) + } + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Upload-Offset": generatedTusParallelCleanupPartPatchOffsets[partIndex], + }, + )) + if partIndex == generatedTusParallelCleanupFailurePartIndex { + responseWriter.WriteHeader(generatedTusParallelCleanupFailureStatus) + return + } + select { + case <-request.Context().Done(): + 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() + responseWriter.WriteHeader(http.StatusNoContent) + + 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)), + MetadataForPartialUploads: generatedTusParallelCleanupMetadataForPartialUploads, + ParallelUploads: generatedTusParallelCleanupUploadCount, + TerminateUploadOnAbort: true, + }) + 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) + 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) + } + 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, candidate := range generatedTusParallelCleanupPartUploadPaths { + if path == candidate { + return index + } + } + + return -1 +} + +func generatedTusParallelCleanupPartIndexForTerminatePath(path string) int { + for index, candidate := range generatedTusParallelCleanupTerminatePaths { + if path == candidate { + return index + } + } + + return -1 +} + +func generatedTusParallelCleanupPartIndexForUploadLength(uploadLength string) int { + for index, candidate := range generatedTusParallelCleanupPartUploadLengths { + if uploadLength == candidate { + return index + } + } + + return -1 +} + +func generatedTusReleaseParallelCleanupPatchesAfterAllStarted( + patchArrivals <-chan int, + releasePatches chan<- struct{}, + requestErrs chan<- error, +) { + seen := map[int]bool{} + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + for len(seen) < generatedTusParallelCleanupUploadCount { + select { + case partIndex := <-patchArrivals: + seen[partIndex] = true + case <-timer.C: + requestErrs <- fmt.Errorf("expected all cleanup PATCH requests to be in flight") + close(releasePatches) + return + } + } + + close(releasePatches) +} + +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 := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index de4870f..be4d1c5 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -12,7 +12,9 @@ import ( "net/url" "reflect" "strings" + "sync" "testing" + "time" ) const ( @@ -47,22 +49,42 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { 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, + ) 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 && - createIndex < len(generatedTusParallelPartUploadPaths): - partIndex := createIndex + 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, @@ -84,8 +106,13 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { case request.URL.Path == generatedTusParallelEndpointPath && request.Method == createOperation.Method && - createIndex == len(generatedTusParallelPartUploadPaths): + strings.HasPrefix( + request.Header.Get("Upload-Concat"), + generatedTusParallelFinalConcatPrefix, + ): + requestMu.Lock() createIndex += 1 + requestMu.Unlock() recordRequestErr(generatedAssertTusParallelAbsentHeaders( request, generatedTusParallelFinalAbsentHeaders, @@ -115,7 +142,21 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { responseWriter.WriteHeader(http.StatusNotFound) return } + select { + case patchArrivals <- 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) != generatedTusParallelPartPatchBodies[partIndex] { @@ -196,11 +237,15 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { if upload.Location != server.URL+generatedTusParallelFinalPath { t.Fatalf("expected final upload URL %s, got %s", server.URL+generatedTusParallelFinalPath, upload.Location) } - if createIndex != len(generatedTusParallelPartUploadPaths)+1 { - t.Fatalf("expected %d create requests, got %d", len(generatedTusParallelPartUploadPaths)+1, createIndex) + requestMu.Lock() + actualCreateIndex := createIndex + actualPatchIndex := patchIndex + requestMu.Unlock() + if actualCreateIndex != len(generatedTusParallelPartUploadPaths)+1 { + t.Fatalf("expected %d create requests, got %d", len(generatedTusParallelPartUploadPaths)+1, actualCreateIndex) } - if patchIndex != len(generatedTusParallelPartUploadPaths) { - t.Fatalf("expected %d patch requests, got %d", len(generatedTusParallelPartUploadPaths), patchIndex) + if actualPatchIndex != len(generatedTusParallelPartUploadPaths) { + t.Fatalf("expected %d patch requests, got %d", len(generatedTusParallelPartUploadPaths), actualPatchIndex) } select { case err := <-requestErrs: @@ -244,6 +289,38 @@ func generatedTusParallelPartIndexForPath(path string) int { return -1 } +func generatedTusParallelPartIndexForUploadLength(uploadLength string) int { + for index, candidate := range generatedTusParallelPartUploadLengths { + if uploadLength == candidate { + return index + } + } + + return -1 +} + +func generatedTusReleaseParallelPatchesAfterAllStarted( + patchArrivals <-chan int, + releasePatches chan<- struct{}, + requestErrs chan<- error, +) { + seen := map[int]bool{} + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + for len(seen) < generatedTusParallelConformanceUploadCount { + select { + case partIndex := <-patchArrivals: + seen[partIndex] = true + case <-timer.C: + requestErrs <- fmt.Errorf("expected all parallel PATCH requests to be in flight") + close(releasePatches) + return + } + } + + close(releasePatches) +} + func generatedTusParallelBytesTotalString(bytesTotal *int64) string { if bytesTotal == nil { return "null" From b305045441ee8c5310d7b7063a39f7ee0b5fd074 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 10:54:33 +0200 Subject: [PATCH 30/97] Add generated creation extension flows --- ...ion_with_upload_contract_generated_test.go | 254 +++++++++++++++ ...deferred_length_contract_generated_test.go | 296 ++++++++++++++++++ url_storage_generated.go | 166 +++++++++- 3 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 url_storage_creation_with_upload_contract_generated_test.go create mode 100644 url_storage_deferred_length_contract_generated_test.go diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go new file mode 100644 index 0000000..d695a7b --- /dev/null +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -0,0 +1,254 @@ +// 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 ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +const ( + generatedTusCreationWithUploadContent = "hello world" + generatedTusCreationWithUploadContentType = "application/offset+octet-stream" + generatedTusCreationWithUploadContentTypeHeader = "Content-Type" + generatedTusCreationWithUploadEndpointPath = "/uploads" + generatedTusCreationWithUploadLength = "11" + generatedTusCreationWithUploadLengthHeader = "Upload-Length" + generatedTusCreationWithUploadMetadataHeader = "Upload-Metadata" + generatedTusCreationWithUploadOffset = "11" + generatedTusCreationWithUploadOffsetHeader = "Upload-Offset" + generatedTusCreationWithUploadPath = "/uploads/creation-with-upload-contract" +) + +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{ + generatedTusCreationWithUploadLengthHeader: generatedTusCreationWithUploadLength, + generatedTusCreationWithUploadMetadataHeader: encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCreationWithUploadResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCreationWithUploadPath, + generatedTusCreationWithUploadOffsetHeader: 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, fmt.Sprintf( + "progress:%d:%s", + bytesSent, + generatedTusCreationWithUploadBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, "upload-url-available") + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, "success") + 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 != 1 { + t.Fatalf("expected exactly one creation-with-upload request, got %d", requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + if !reflect.DeepEqual(events, generatedTusCreationWithUploadExpectedEvents) { + t.Fatalf("expected creation-with-upload events %#v, got %#v", 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, "source-close") + 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 { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusCreationWithUploadRequestHeaderVariant(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 generatedAssertTusCreationWithUploadRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCreationWithUploadOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCreationWithUploadOffsetHeader, value) + } +} diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go new file mode 100644 index 0000000..4c7914f --- /dev/null +++ b/url_storage_deferred_length_contract_generated_test.go @@ -0,0 +1,296 @@ +// 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 ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +const ( + generatedTusDeferredLengthAcceptedOffset = "11" + generatedTusDeferredLengthContent = "hello world" + generatedTusDeferredLengthContentTypeHeader = "Content-Type" + generatedTusDeferredLengthCreateDeferHeader = "Upload-Defer-Length" + generatedTusDeferredLengthCreateDeferValue = "1" + generatedTusDeferredLengthEndpointPath = "/uploads" + generatedTusDeferredLengthMetadataHeader = "Upload-Metadata" + generatedTusDeferredLengthPatchLength = "11" + generatedTusDeferredLengthPatchLengthHeader = "Upload-Length" + generatedTusDeferredLengthPatchOffset = "0" + generatedTusDeferredLengthPatchOffsetHeader = "Upload-Offset" + generatedTusDeferredLengthUploadPath = "/uploads/deferred-contract" +) + +var generatedTusDeferredLengthCreateAbsentHeaders = []string{"Upload-Length"} +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{ + generatedTusDeferredLengthCreateDeferHeader: generatedTusDeferredLengthCreateDeferValue, + generatedTusDeferredLengthMetadataHeader: encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusDeferredLengthResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusDeferredLengthUploadPath, + }, + ) + 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{ + generatedTusDeferredLengthContentTypeHeader: patchOperation.Request.ContentType, + generatedTusDeferredLengthPatchLengthHeader: generatedTusDeferredLengthPatchLength, + generatedTusDeferredLengthPatchOffsetHeader: generatedTusDeferredLengthPatchOffset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusDeferredLengthResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + generatedTusDeferredLengthPatchOffsetHeader: 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, fmt.Sprintf( + "progress:%d:%s", + bytesSent, + generatedTusDeferredLengthBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, fmt.Sprintf( + "chunk-complete:%d:%d:%s", + chunkSize, + bytesAccepted, + generatedTusDeferredLengthBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, "upload-url-available") + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, "success") + 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 != 1 || patchCount != 1 { + t.Fatalf("expected one create and one patch, got create=%d patch=%d", createCount, patchCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + if !reflect.DeepEqual(events, generatedTusDeferredLengthExpectedEvents) { + t.Fatalf("expected deferred length events %#v, got %#v", 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, "source-close") + 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 := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_generated.go b/url_storage_generated.go index 8aeb706..9dc45e9 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -37,6 +37,13 @@ const ( generatedTusAbortRemoveStoredURLAfterTerm = "after-successful-termination" generatedTusAbortSuppressErrorAfterAbort = true generatedTusAbortTerminateUpload = "when-requested-and-upload-url-known" + generatedTusCreationWithUploadBodySource = "first-upload-chunk" + generatedTusCreationWithUploadCompletion = "continue-with-patch-when-offset-less-than-size" + generatedTusCreationWithUploadExtension = "creation-with-upload" + generatedTusCreationWithUploadResponseOff = "accepted-offset" + generatedTusDeferredLengthCreateSize = "size-unknown" + generatedTusDeferredLengthDeclareLength = "first-patch" + generatedTusDeferredLengthExtension = "creation-defer-length" generatedTusDefaultParallelUploads = 1 generatedTusMinimumParallelUploads = 2 generatedTusParallelPartialMetadata = "metadataForPartialUploads" @@ -108,6 +115,8 @@ type URLStorageUploadOptions struct { ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool @@ -123,6 +132,8 @@ type URLStorageFileUploadOptions struct { ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool @@ -138,6 +149,8 @@ type FileBackedURLStorageUploadOptions struct { ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool ChunkSize int64 RetryDelays []time.Duration OnShouldRetry func(error, int) bool @@ -345,6 +358,8 @@ func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorage ParallelUploads: options.ParallelUploads, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, TerminateUploadOnAbort: options.TerminateUploadOnAbort, + UploadDataDuringCreation: options.UploadDataDuringCreation, + UploadLengthDeferred: options.UploadLengthDeferred, ChunkSize: options.ChunkSize, RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, @@ -387,6 +402,8 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( ParallelUploads: options.ParallelUploads, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, TerminateUploadOnAbort: options.TerminateUploadOnAbort, + UploadDataDuringCreation: options.UploadDataDuringCreation, + UploadLengthDeferred: options.UploadLengthDeferred, ChunkSize: options.ChunkSize, RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, @@ -421,8 +438,9 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, if err != nil { return upload, err } + var lastResponse *http.Response if upload == nil { - upload, storageKey, err = uploadClient.createUploadForURLStorage(options) + upload, storageKey, lastResponse, err = uploadClient.createUploadForURLStorage(options) if err != nil { return upload, err } @@ -432,12 +450,18 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*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, c.generatedTusHandleURLStorageUploadAbort(options, upload, storageKey, err) } + if stream.LastResponse != nil { + lastResponse = stream.LastResponse + } if err := generatedTusEmitSuccess(generatedTusSuccessInput{ EventHooks: options.EventHooks, - LastResponse: stream.LastResponse, + LastResponse: lastResponse, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, Source: options.Source, Storage: options.Storage, @@ -740,6 +764,20 @@ func generatedTusParallelUploadCount(parallelUploads int) (int, error) { return parallelUploads, nil } +func generatedTusCreationWithUploadChunkSize(options URLStorageUploadOptions) int64 { + if generatedTusCreationWithUploadBodySource != "first-upload-chunk" { + panic(fmt.Sprintf( + "tus: unsupported creation-with-upload body source %s", + generatedTusCreationWithUploadBodySource, + )) + } + 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") @@ -1203,6 +1241,46 @@ func generatedTusAssertParallelUploadPolicySupported() error { return nil } +func generatedTusAssertCreationWithUploadPolicySupported() error { + if generatedTusCreationWithUploadBodySource != "first-upload-chunk" { + return fmt.Errorf( + "tus: unsupported creation-with-upload body source %s", + generatedTusCreationWithUploadBodySource, + ) + } + if generatedTusCreationWithUploadCompletion != "continue-with-patch-when-offset-less-than-size" { + return fmt.Errorf( + "tus: unsupported creation-with-upload completion policy %s", + generatedTusCreationWithUploadCompletion, + ) + } + if generatedTusCreationWithUploadResponseOff != "accepted-offset" { + return fmt.Errorf( + "tus: unsupported creation-with-upload response offset policy %s", + generatedTusCreationWithUploadResponseOff, + ) + } + + return nil +} + +func generatedTusAssertDeferredLengthPolicySupported() error { + if generatedTusDeferredLengthCreateSize != "size-unknown" { + return fmt.Errorf( + "tus: unsupported deferred length create size policy %s", + generatedTusDeferredLengthCreateSize, + ) + } + if generatedTusDeferredLengthDeclareLength != "first-patch" { + return fmt.Errorf( + "tus: unsupported deferred length declaration policy %s", + generatedTusDeferredLengthDeclareLength, + ) + } + + return nil +} + func generatedTusAssertParallelCleanupPolicySupported() error { if generatedTusParallelCleanupOnPartError != "terminate-created-partials-when-abort-termination-enabled" { return fmt.Errorf( @@ -1324,13 +1402,85 @@ func (c *Client) resumeUploadFromURLStorage( func (c *Client) createUploadForURLStorage( options URLStorageUploadOptions, -) (*Upload, string, error) { +) (*Upload, string, *http.Response, error) { + if options.UploadDataDuringCreation { + return c.createUploadWithDataForURLStorage(options) + } + upload := &Upload{} - if _, err := c.CreateUpload(upload, options.Size, false, options.Metadata); err != nil { - return upload, "", err + remoteSize := options.Size + if options.UploadLengthDeferred { + if err := generatedTusAssertDeferredLengthPolicySupported(); err != nil { + return upload, "", nil, err + } + remoteSize = SizeUnknown + } + response, err := c.CreateUpload(upload, remoteSize, false, options.Metadata) + if 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, +) (*Upload, string, *http.Response, error) { + if err := generatedTusAssertCreationWithUploadPolicySupported(); err != nil { + return nil, "", nil, err + } + 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, err + } + upload.RemoteSize = options.Size + upload.RemoteOffset = uploadedBytes + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + uploadedBytes, + options.Size, + ); err != nil { + return upload, "", response, err } if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "createUpload"); err != nil { - return upload, "", err + return upload, "", response, err } storageKey, err := options.Storage.AddUpload( @@ -1338,10 +1488,10 @@ func (c *Client) createUploadForURLStorage( URLStorageUploadFromUpload(*upload), ) if err != nil { - return upload, "", err + return upload, "", response, err } - return upload, storageKey, nil + return upload, storageKey, response, nil } func URLStorageUploadFromUpload(upload Upload) URLStorageUpload { From 300ee800014f1e30a812de0b8996d8d6a61f12c1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 11:13:28 +0200 Subject: [PATCH 31/97] Add generated creation continuation validation --- protocol_contract_generated_test.go | 6 +- ..._upload_partial_contract_generated_test.go | 345 ++++++++++++++++++ url_storage_generated.go | 22 ++ ...tion_validation_contract_generated_test.go | 79 ++++ 4 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 url_storage_creation_with_upload_partial_contract_generated_test.go create mode 100644 url_storage_option_validation_contract_generated_test.go diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index e705291..64e9882 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -540,7 +540,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: []string{"creationWithUpload"}, + ScenarioIDs: []string{"creationWithUpload", "creationWithUploadPartialChunk"}, Status: "covered-by-generated-scenario", }, Description: "Send the first bytes on the creation request when the server/client support it.", @@ -561,7 +561,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ Summary: "Interpret the creation response as an accepted offset.", }, }, - OperationIDs: []string{"createTusUpload"}, + OperationIDs: []string{"createTusUpload", "patchTusUpload"}, Primitives: []string{"upload-during-creation", "emit-progress"}, }, { @@ -958,7 +958,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: []string{"startValidationMissingInput", "startValidationMissingEndpointOrUploadUrl", "startValidationUnsupportedProtocol", "startValidationRetryDelaysNotArray", "startValidationParallelUploadsWithUploadUrl", "startValidationParallelUploadsWithUploadSize", "startValidationParallelUploadsWithDeferredLength", "startValidationParallelBoundariesWithoutParallelUploads", "startValidationParallelBoundariesLengthMismatch"}, + ScenarioIDs: []string{"startValidationMissingInput", "startValidationMissingEndpointOrUploadUrl", "startValidationUnsupportedProtocol", "startValidationRetryDelaysNotArray", "startValidationParallelUploadsWithUploadUrl", "startValidationParallelUploadsWithUploadSize", "startValidationParallelUploadsWithDeferredLength", "startValidationParallelUploadsWithUploadDataDuringCreation", "startValidationParallelBoundariesWithoutParallelUploads", "startValidationParallelBoundariesLengthMismatch"}, Status: "covered-by-generated-scenario", }, Description: "Validate option combinations before starting runtime work.", diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go new file mode 100644 index 0000000..3511495 --- /dev/null +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -0,0 +1,345 @@ +// 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 ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +const ( + generatedTusCreationPartialContent = "hello world" + generatedTusCreationPartialContentType = "application/offset+octet-stream" + generatedTusCreationPartialContentTypeHeader = "Content-Type" + generatedTusCreationPartialCreateBodySize = 5 + generatedTusCreationPartialEndpointPath = "/uploads" + generatedTusCreationPartialLength = "11" + generatedTusCreationPartialLengthHeader = "Upload-Length" + generatedTusCreationPartialMetadataHeader = "Upload-Metadata" + generatedTusCreationPartialOffset = "5" + generatedTusCreationPartialOffsetHeader = "Upload-Offset" + generatedTusCreationPartialFirstPatchBody = 5 + generatedTusCreationPartialFirstPatchOffset = "5" + generatedTusCreationPartialFirstPatchResult = "10" + generatedTusCreationPartialSecondPatchBody = 1 + generatedTusCreationPartialSecondPatchOffset = "10" + generatedTusCreationPartialPath = "/uploads/creation-with-upload-partial-contract" + generatedTusCreationPartialFinalOffset = "11" + generatedTusCreationPartialChunkSize = 5 +) + +var generatedTusCreationPartialExpectedEvents = []string{"progress:0:11", "progress:5:11", "upload-url-available", "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"} + +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{ + generatedTusCreationPartialLengthHeader: generatedTusCreationPartialLength, + generatedTusCreationPartialMetadataHeader: encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCreationPartialResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCreationPartialPath, + generatedTusCreationPartialOffsetHeader: generatedTusCreationPartialOffset, + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusCreationPartialPath && + request.Method == patchOperation.Method: + requestCount += 1 + patchRequestCount += 1 + expectedBodyStart := generatedTusCreationPartialCreateBodySize + expectedBodySize := generatedTusCreationPartialFirstPatchBody + expectedOffset := generatedTusCreationPartialFirstPatchOffset + responseOffset := generatedTusCreationPartialFirstPatchResult + responseStatus := 204 + if patchRequestCount == 2 { + expectedBodyStart += generatedTusCreationPartialFirstPatchBody + expectedBodySize = generatedTusCreationPartialSecondPatchBody + expectedOffset = generatedTusCreationPartialSecondPatchOffset + responseOffset = generatedTusCreationPartialFinalOffset + responseStatus = 204 + } else if patchRequestCount > 2 { + recordRequestErr(fmt.Errorf("unexpected continuation request %d", patchRequestCount)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + expectedBodyEnd := expectedBodyStart + expectedBodySize + expectedBody := generatedTusCreationPartialContent[expectedBodyStart:expectedBodyEnd] + if len(expectedBody) != expectedBodySize { + recordRequestErr(fmt.Errorf( + "expected configured patch body size %d, got %d", + expectedBodySize, + 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{ + generatedTusCreationPartialContentTypeHeader: generatedTusCreationPartialContentType, + generatedTusCreationPartialOffsetHeader: expectedOffset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, responseStatus) + generatedWriteTusCreationPartialResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + generatedTusCreationPartialOffsetHeader: responseOffset, + }, + ) + 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, fmt.Sprintf( + "progress:%d:%s", + bytesSent, + generatedTusCreationPartialBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, fmt.Sprintf( + "chunk-complete:%d:%d:%s", + chunkSize, + bytesAccepted, + generatedTusCreationPartialBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, "upload-url-available") + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, "success") + 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 != 3 { + t.Fatalf("expected one creation request and two continuation requests, got %d", requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + if !reflect.DeepEqual(events, generatedTusCreationPartialExpectedEvents) { + t.Fatalf("expected partial creation events %#v, got %#v", 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, "source-close") + 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 { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusCreationPartialRequestHeaderVariant(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 generatedAssertTusCreationPartialRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCreationPartialOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCreationPartialOffsetHeader, value) + } +} diff --git a/url_storage_generated.go b/url_storage_generated.go index 9dc45e9..b8e3add 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -46,6 +46,8 @@ const ( 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" generatedTusParallelPartialMetadata = "metadataForPartialUploads" generatedTusParallelPartialNestedUploads = "disabled" generatedTusParallelPartialURLStorage = "parent-managed" @@ -430,6 +432,9 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, if err != nil { return nil, err } + if err := generatedTusValidateURLStorageUploadOptions(options, parallelUploads); err != nil { + return nil, err + } if parallelUploads > 1 { return c.uploadParallelWithURLStorage(options, uploadClient, parallelUploads) } @@ -764,6 +769,23 @@ func generatedTusParallelUploadCount(parallelUploads int) (int, error) { 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 generatedTusCreationWithUploadBodySource != "first-upload-chunk" { panic(fmt.Sprintf( diff --git a/url_storage_option_validation_contract_generated_test.go b/url_storage_option_validation_contract_generated_test.go new file mode 100644 index 0000000..a944760 --- /dev/null +++ b/url_storage_option_validation_contract_generated_test.go @@ -0,0 +1,79 @@ +// 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" + "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) + } + }) + } +} From de35f8756f54adbb2d59a220b23e19d3a09bd7cf Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 11:37:32 +0200 Subject: [PATCH 32/97] Add generated request policy flows --- protocol_contract_generated_test.go | 33 +++ ..._upload_partial_contract_generated_test.go | 2 +- ..._custom_headers_contract_generated_test.go | 238 ++++++++++++++++++ url_storage_generated.go | 68 +++++ ...de_patch_method_contract_generated_test.go | 219 ++++++++++++++++ 5 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 url_storage_custom_headers_contract_generated_test.go create mode 100644 url_storage_override_patch_method_contract_generated_test.go diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 64e9882..6d673f1 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -590,6 +590,39 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ OperationIDs: []string{"createTusUpload", "patchTusUpload"}, Primitives: []string{"send-upload-body-headers"}, }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"customRequestHeaders"}, + Status: "covered-by-generated-scenario", + }, + Description: "Apply user-provided request headers to every upload request.", + FeatureID: "customRequestHeaders", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "apply-custom-request-headers", + Condition: "", + Summary: "Merge user-provided headers after protocol headers are prepared.", + }, + { + Kind: "operation", + OperationID: "createTusUpload", + Primitive: "", + Condition: "", + Summary: "Create uploads with the configured custom headers.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Upload bytes with the configured custom headers.", + }, + }, + OperationIDs: []string{"createTusUpload", "patchTusUpload"}, + Primitives: []string{"apply-custom-request-headers"}, + }, { Conformance: generatedTusClientFeatureConformance{ ScenarioIDs: []string{"overridePatchMethod"}, diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index 3511495..b0aa651 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -147,7 +147,7 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { patchOperation, map[string]string{ generatedTusCreationPartialContentTypeHeader: generatedTusCreationPartialContentType, - generatedTusCreationPartialOffsetHeader: expectedOffset, + generatedTusCreationPartialOffsetHeader: expectedOffset, }, )) patchResponse := generatedResponseFor(patchOperation, responseStatus) diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go new file mode 100644 index 0000000..6e17ea7 --- /dev/null +++ b/url_storage_custom_headers_contract_generated_test.go @@ -0,0 +1,238 @@ +// 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 ( + "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" + 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{ + generatedTusCustomHeadersLengthHeader: generatedTusCustomHeadersLength, + generatedTusCustomHeadersMetadataHeader: encodedMetadata, + }, + )) + recordRequestErr(generatedAssertTusCustomHeaderValues(request, generatedTusCustomHeaders)) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCustomResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCustomHeadersPath, + }, + ) + 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{ + generatedTusCustomHeadersContentTypeHeader: generatedTusCustomHeadersContentType, + generatedTusCustomHeadersOffsetHeader: generatedTusCustomHeadersOffset, + }, + )) + recordRequestErr(generatedAssertTusCustomHeaderValues(request, generatedTusCustomHeaders)) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusCustomResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + generatedTusCustomHeadersOffsetHeader: 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 != 2 { + t.Fatalf("expected custom-header create and patch requests, got %d", 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 { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusCustomRequestHeaderVariant(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 generatedAssertTusCustomRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCustomHeadersOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCustomHeadersOffsetHeader, value) + } +} diff --git a/url_storage_generated.go b/url_storage_generated.go index b8e3add..167c91b 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -46,6 +46,10 @@ const ( generatedTusDeferredLengthExtension = "creation-defer-length" generatedTusDefaultParallelUploads = 1 generatedTusMinimumParallelUploads = 2 + generatedTusMethodOverrideHeaderName = "X-HTTP-Method-Override" + generatedTusMethodOverrideHeaderValue = "PATCH" + generatedTusMethodOverrideMethod = "POST" + generatedTusMethodOverrideSourceMethod = "PATCH" generatedTusValidationParallelDeferred = "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled" generatedTusValidationParallelCreateData = "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled" generatedTusParallelPartialMetadata = "metadataForPartialUploads" @@ -112,8 +116,10 @@ type URLStorageUploadOptions struct { Source io.ReadSeeker Fingerprint string Size int64 + Headers map[string]string Metadata map[string]string MetadataForPartialUploads map[string]string + OverridePatchMethod bool ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool @@ -129,8 +135,10 @@ type URLStorageFileUploadOptions struct { Context context.Context Storage URLStorage Path string + Headers map[string]string Metadata map[string]string MetadataForPartialUploads map[string]string + OverridePatchMethod bool ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool @@ -146,8 +154,10 @@ type FileBackedURLStorageUploadOptions struct { Context context.Context URLStoragePath string Path string + Headers map[string]string Metadata map[string]string MetadataForPartialUploads map[string]string + OverridePatchMethod bool ParallelUploads int RemoveFingerprintOnSuccess bool TerminateUploadOnAbort bool @@ -355,8 +365,10 @@ func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorage Context: options.Context, Storage: NewFileURLStorage(options.URLStoragePath), Path: options.Path, + Headers: options.Headers, Metadata: options.Metadata, MetadataForPartialUploads: options.MetadataForPartialUploads, + OverridePatchMethod: options.OverridePatchMethod, ParallelUploads: options.ParallelUploads, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, TerminateUploadOnAbort: options.TerminateUploadOnAbort, @@ -399,8 +411,10 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( Source: file, Fingerprint: fingerprint, Size: info.Size(), + Headers: options.Headers, Metadata: options.Metadata, MetadataForPartialUploads: options.MetadataForPartialUploads, + OverridePatchMethod: options.OverridePatchMethod, ParallelUploads: options.ParallelUploads, RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, TerminateUploadOnAbort: options.TerminateUploadOnAbort, @@ -435,6 +449,7 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, if err := generatedTusValidateURLStorageUploadOptions(options, parallelUploads); err != nil { return nil, err } + uploadClient = generatedTusClientWithURLStorageRequestPolicy(uploadClient, options) if parallelUploads > 1 { return c.uploadParallelWithURLStorage(options, uploadClient, parallelUploads) } @@ -748,6 +763,59 @@ func generatedTusClientWithUploadContext(client *Client, ctx context.Context) (* return client.WithContext(ctx), nil } +func generatedTusClientWithURLStorageRequestPolicy( + client *Client, + options URLStorageUploadOptions, +) *Client { + if len(options.Headers) == 0 && !options.OverridePatchMethod { + return client + } + + result := *client + httpClient := http.DefaultClient + if client.client != nil { + httpClient = client.client + } + resultHTTPClient := *httpClient + baseTransport := resultHTTPClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + resultHTTPClient.Transport = generatedTusURLStorageRequestPolicyTransport{ + Base: baseTransport, + Headers: cloneStringMap(options.Headers), + OverridePatchMethod: options.OverridePatchMethod, + } + result.client = &resultHTTPClient + + return &result +} + +type generatedTusURLStorageRequestPolicyTransport struct { + Base http.RoundTripper + Headers map[string]string + OverridePatchMethod bool +} + +func (transport generatedTusURLStorageRequestPolicyTransport) RoundTrip( + request *http.Request, +) (*http.Response, error) { + cloned := request.Clone(request.Context()) + for key, value := range transport.Headers { + cloned.Header.Set(key, value) + } + if transport.OverridePatchMethod && + cloned.Method == generatedTusMethodOverrideSourceMethod { + cloned.Method = generatedTusMethodOverrideMethod + cloned.Header.Set( + generatedTusMethodOverrideHeaderName, + generatedTusMethodOverrideHeaderValue, + ) + } + + return transport.Base.RoundTrip(cloned) +} + func IsUploadAbortError(err error) bool { return errors.Is(err, context.Canceled) } diff --git a/url_storage_override_patch_method_contract_generated_test.go b/url_storage_override_patch_method_contract_generated_test.go new file mode 100644 index 0000000..a671032 --- /dev/null +++ b/url_storage_override_patch_method_contract_generated_test.go @@ -0,0 +1,219 @@ +// 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 ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +const ( + generatedTusOverrideContent = "hello world" + generatedTusOverrideContentType = "application/offset+octet-stream" + generatedTusOverrideContentTypeHeader = "Content-Type" + 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{ + generatedTusOverrideLengthHeader: generatedTusOverrideUploadLength, + generatedTusOverrideOffsetHeader: 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{ + generatedTusOverrideContentTypeHeader: generatedTusOverrideContentType, + generatedTusOverrideHeaderName: generatedTusOverrideHeaderValue, + generatedTusOverrideOffsetHeader: generatedTusOverrideOffset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusOverrideResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + generatedTusOverrideOffsetHeader: 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 != 2 { + t.Fatalf("expected one offset request and one overridden patch request, got %d", requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } +} + +func generatedAssertTusOverrideRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusOverrideRequestHeaderVariant(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 generatedAssertTusOverrideRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := values[field.DisplayName] + if expected == "" { + expected = DefaultProtocolVersion + } + 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 := values[field.DisplayName] + if value == "" { + value = DefaultProtocolVersion + } + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusOverrideOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusOverrideOffsetHeader, value) + } +} From a2660c643e33f87feea2f379a8f6f138309a08e1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 12:16:46 +0200 Subject: [PATCH 33/97] Add generated abort request policy --- ...ort_termination_contract_generated_test.go | 61 ++++++++++++++++--- url_storage_generated.go | 49 ++++++++++++--- 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index ba92f00..c04feee 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -19,15 +19,22 @@ import ( ) const ( - generatedTusAbortTerminationContent = "hello world" - generatedTusAbortTerminationEndpointPath = "/uploads" - generatedTusAbortTerminationFingerprint = "contract-abort-terminate-fingerprint" - generatedTusAbortTerminationPatchBody = "hello world" - generatedTusAbortTerminationPatchOffset = "0" - generatedTusAbortTerminationUploadLength = "11" - generatedTusAbortTerminationUploadPath = "/uploads/abort-terminate-contract" + 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 generatedTusAbortTerminationExpectedEvents = []string{"request-abort:1"} var generatedTusAbortTerminationMetadata = map[string]string{"filename": "hello.txt"} @@ -62,6 +69,10 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { "Upload-Length": generatedTusAbortTerminationUploadLength, }, )) + recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( + request, + generatedTusAbortTerminationHeaders, + )) createResponse := generatedResponseFor(createOperation, 201) generatedWriteTusAbortTerminationResponseHeaders( responseWriter, @@ -72,7 +83,7 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { ) responseWriter.WriteHeader(201) - case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == patchOperation.Method: + case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == generatedTusAbortTerminationMethod: defer close(patchDone) body, err := io.ReadAll(request.Body) recordRequestErr(err) @@ -83,10 +94,18 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { request, patchOperation, map[string]string{ - "Content-Type": patchOperation.Request.ContentType, - "Upload-Offset": generatedTusAbortTerminationPatchOffset, + generatedTusAbortTerminationContentTypeHeader: generatedTusAbortTerminationContentType, + generatedTusAbortTerminationOffsetHeader: generatedTusAbortTerminationPatchOffset, + generatedTusAbortTerminationOverrideHeader: generatedTusAbortTerminationOverrideValue, }, )) + 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, "request-abort:1") close(patchStarted) <-request.Context().Done() @@ -97,6 +116,13 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { terminateOperation, map[string]string{}, )) + 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, @@ -133,7 +159,9 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { Source: strings.NewReader(generatedTusAbortTerminationContent), Fingerprint: generatedTusAbortTerminationFingerprint, Size: 11, + Headers: generatedTusAbortTerminationHeaders, Metadata: generatedTusAbortTerminationMetadata, + OverridePatchMethod: true, TerminateUploadOnAbort: true, }) result <- err @@ -209,6 +237,19 @@ func generatedAssertTusAbortTerminationRequestHeaders( 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, diff --git a/url_storage_generated.go b/url_storage_generated.go index 167c91b..2ec9535 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -37,6 +37,7 @@ const ( generatedTusAbortRemoveStoredURLAfterTerm = "after-successful-termination" generatedTusAbortSuppressErrorAfterAbort = true generatedTusAbortTerminateUpload = "when-requested-and-upload-url-known" + generatedTusAbortTerminateUploadContext = "detached-from-aborted-request" generatedTusCreationWithUploadBodySource = "first-upload-chunk" generatedTusCreationWithUploadCompletion = "continue-with-patch-when-offset-less-than-size" generatedTusCreationWithUploadExtension = "creation-with-upload" @@ -474,7 +475,12 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, stream.SetUploadSize = true } if err := uploadClient.uploadURLStorageSource(options, stream); err != nil { - return upload, c.generatedTusHandleURLStorageUploadAbort(options, upload, storageKey, err) + return upload, uploadClient.generatedTusHandleURLStorageUploadAbort( + options, + upload, + storageKey, + err, + ) } if stream.LastResponse != nil { lastResponse = stream.LastResponse @@ -536,7 +542,7 @@ func (c *Client) uploadParallelWithURLStorage( } if err := generatedTusParallelUploadError(results); err != nil { return generatedTusFirstCreatedParallelPartialUpload(results), - c.generatedTusCleanupParallelPartialUploads(options, results, err) + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) } partials := make([]Upload, 0, len(results)) @@ -549,7 +555,7 @@ func (c *Client) uploadParallelWithURLStorage( options.Size, ); err != nil { return &result.Upload, - c.generatedTusCleanupParallelPartialUploads(options, results, err) + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) } if err := generatedTusEmitChunkCompleteAfterChunkAccepted( options.EventHooks, @@ -558,7 +564,7 @@ func (c *Client) uploadParallelWithURLStorage( options.Size, ); err != nil { return &result.Upload, - c.generatedTusCleanupParallelPartialUploads(options, results, err) + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) } partials = append(partials, result.Upload) } @@ -566,7 +572,11 @@ func (c *Client) uploadParallelWithURLStorage( finalUpload := &Upload{} response, err := uploadClient.ConcatenateUploads(finalUpload, partials, options.Metadata) if err != nil { - return finalUpload, c.generatedTusCleanupParallelPartialUploads(options, results, err) + return finalUpload, uploadClient.generatedTusCleanupParallelPartialUploads( + options, + results, + err, + ) } if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "parallelFinalUpload"); err != nil { return finalUpload, err @@ -791,6 +801,17 @@ func generatedTusClientWithURLStorageRequestPolicy( return &result } +func generatedTusClientWithAbortCleanupContext(client *Client) (*Client, error) { + if generatedTusAbortTerminateUploadContext != "detached-from-aborted-request" { + return nil, fmt.Errorf( + "tus: unsupported abort termination context policy %s", + generatedTusAbortTerminateUploadContext, + ) + } + + return client.WithContext(context.Background()), nil +} + type generatedTusURLStorageRequestPolicyTransport struct { Base http.RoundTripper Headers map[string]string @@ -989,12 +1010,16 @@ func (c *Client) generatedTusCleanupParallelPartialUploads( if err := generatedTusAssertParallelCleanupPolicySupported(); err != nil { return err } + cleanupClient, err := generatedTusClientWithAbortCleanupContext(c) + if err != nil { + return err + } for _, result := range results { if result.Upload.Location == "" { continue } - if _, err := c.TerminateUploadWithRetry(result.Upload, TerminateUploadOptions{ + if _, err := cleanupClient.TerminateUploadWithRetry(result.Upload, TerminateUploadOptions{ RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, }); err != nil { @@ -1020,8 +1045,12 @@ func (c *Client) generatedTusHandleURLStorageUploadAbort( if !options.TerminateUploadOnAbort || upload == nil || upload.Location == "" { return err } + cleanupClient, cleanupClientErr := generatedTusClientWithAbortCleanupContext(c) + if cleanupClientErr != nil { + return cleanupClientErr + } - if _, terminateErr := c.TerminateUploadWithRetry(*upload, TerminateUploadOptions{ + if _, terminateErr := cleanupClient.TerminateUploadWithRetry(*upload, TerminateUploadOptions{ RetryDelays: options.RetryDelays, OnShouldRetry: options.OnShouldRetry, }); terminateErr != nil { @@ -1407,6 +1436,12 @@ func generatedTusAssertAbortPolicySupported() error { generatedTusAbortTerminateUpload, ) } + if generatedTusAbortTerminateUploadContext != "detached-from-aborted-request" { + return fmt.Errorf( + "tus: unsupported abort termination context policy %s", + generatedTusAbortTerminateUploadContext, + ) + } if generatedTusAbortRemoveStoredURLAfterTerm != "after-successful-termination" { return fmt.Errorf( "tus: unsupported abort storage cleanup policy %s", From 66248b71b67c082b4ce28478f86a5ece8601f102 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 12:36:57 +0200 Subject: [PATCH 34/97] Add generated parallel cleanup request policy --- ...arallel_cleanup_contract_generated_test.go | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 1985b69..ecb4875 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -19,13 +19,20 @@ import ( ) const ( - generatedTusParallelCleanupContent = "hello world" - generatedTusParallelCleanupEndpointPath = "/uploads" - generatedTusParallelCleanupFailurePartIndex = 0 - generatedTusParallelCleanupFailureStatus = 500 - generatedTusParallelCleanupUploadCount = 2 + generatedTusParallelCleanupContent = "hello world" + generatedTusParallelCleanupContentType = "application/offset+octet-stream" + generatedTusParallelCleanupContentTypeHeader = "Content-Type" + generatedTusParallelCleanupEndpointPath = "/uploads" + generatedTusParallelCleanupFailurePartIndex = 0 + generatedTusParallelCleanupFailureStatus = 500 + generatedTusParallelCleanupMethod = "POST" + generatedTusParallelCleanupOffsetHeader = "Upload-Offset" + generatedTusParallelCleanupOverrideHeader = "X-HTTP-Method-Override" + generatedTusParallelCleanupOverrideValue = "PATCH" + generatedTusParallelCleanupUploadCount = 2 ) +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 generatedTusParallelCleanupPartPatchBodies = []string{"hello", " world"} var generatedTusParallelCleanupPartPatchOffsets = []string{"0", "0"} @@ -89,6 +96,10 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { "Upload-Length": generatedTusParallelCleanupPartUploadLengths[partIndex], }, )) + recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( + request, + generatedTusParallelCleanupHeaders, + )) createResponse := generatedResponseFor(createOperation, http.StatusCreated) generatedWriteTusParallelCleanupResponseHeaders( responseWriter, @@ -99,7 +110,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { ) responseWriter.WriteHeader(createResponse.StatusCode) - case request.Method == patchOperation.Method: + case request.Method == generatedTusParallelCleanupMethod: partIndex := generatedTusParallelCleanupPartIndexForPath(request.URL.Path) if partIndex < 0 { recordRequestErr(fmt.Errorf("unexpected cleanup patch path %s", request.URL.Path)) @@ -134,10 +145,18 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { request, patchOperation, map[string]string{ - "Content-Type": patchOperation.Request.ContentType, - "Upload-Offset": generatedTusParallelCleanupPartPatchOffsets[partIndex], + generatedTusParallelCleanupContentTypeHeader: generatedTusParallelCleanupContentType, + generatedTusParallelCleanupOffsetHeader: generatedTusParallelCleanupPartPatchOffsets[partIndex], + generatedTusParallelCleanupOverrideHeader: generatedTusParallelCleanupOverrideValue, }, )) + 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 @@ -161,6 +180,18 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { terminateIndex += 1 terminatedParts[partIndex] = true requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + terminateOperation, + map[string]string{}, + )) + 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)) + } responseWriter.WriteHeader(http.StatusNoContent) default: @@ -186,7 +217,9 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { Source: strings.NewReader(generatedTusParallelCleanupContent), Fingerprint: "contract-parallel-cleanup-fingerprint", Size: int64(len(generatedTusParallelCleanupContent)), + Headers: generatedTusParallelCleanupHeaders, MetadataForPartialUploads: generatedTusParallelCleanupMetadataForPartialUploads, + OverridePatchMethod: true, ParallelUploads: generatedTusParallelCleanupUploadCount, TerminateUploadOnAbort: true, }) @@ -330,6 +363,19 @@ func generatedAssertTusParallelCleanupRequestHeaderVariant( 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, From 262b456e95dbe3c615a32e4ab4b232d05d61fa31 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 23:23:32 +0200 Subject: [PATCH 35/97] Regenerate TUS protocol fixture --- protocol_contract_generated_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 6d673f1..e82b993 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -687,7 +687,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, }, OperationIDs: []string{"createTusUpload", "patchTusUpload"}, - Primitives: []string{"concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries", "terminate-upload"}, + Primitives: []string{"abort-current-request", "concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries", "terminate-upload"}, }, { Conformance: generatedTusClientFeatureConformance{ From 4b9898bfe053b3a1f738bc6f1030261fbff84934 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 07:57:42 +0200 Subject: [PATCH 36/97] Emit creation upload chunk completion --- ...reation_with_upload_partial_contract_generated_test.go | 2 +- url_storage_generated.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index b0aa651..10f2ec5 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -36,7 +36,7 @@ const ( generatedTusCreationPartialChunkSize = 5 ) -var generatedTusCreationPartialExpectedEvents = []string{"progress:0:11", "progress:5:11", "upload-url-available", "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 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"} func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { diff --git a/url_storage_generated.go b/url_storage_generated.go index 2ec9535..ad4693a 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -1607,6 +1607,14 @@ func (c *Client) createUploadWithDataForURLStorage( 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, From a265686a3027a22be01bb8413c46157a7ccee7bb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 08:35:56 +0200 Subject: [PATCH 37/97] Use generated TUS event policies --- protocol_contract_generated_test.go | 70 +++++++++++++++++++ request_lifecycle_contract_generated_test.go | 10 +-- url_storage_abort_contract_generated_test.go | 6 +- ...ort_termination_contract_generated_test.go | 6 +- ...ion_with_upload_contract_generated_test.go | 6 +- ..._upload_partial_contract_generated_test.go | 6 +- ...deferred_length_contract_generated_test.go | 6 +- ...age_event_hooks_contract_generated_test.go | 6 +- ...torage_parallel_contract_generated_test.go | 6 +- 9 files changed, 86 insertions(+), 36 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index e82b993..2677063 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1276,3 +1276,73 @@ var generatedTusClientUrlStorageConformanceScenarios = []generatedTusClientUrlSt ScenarioID: "fileUrlStorageBackend", }, } + +type generatedTusTestingT interface { + Fatalf(format string, args ...any) + Helper() +} + +func generatedTusAssertEvents( + t generatedTusTestingT, + scenarioID string, + matching 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-extra-progress" { + 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 += 1 + continue + } + if generatedTusIsProgressEventKey(event) { + continue + } + t.Fatalf( + "%s emitted unexpected non-progress event %s; expected %#v, got %#v", + scenarioID, + event, + 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 generatedTusIsProgressEventKey(event string) bool { + const prefix = "progress:" + return len(event) >= len(prefix) && event[:len(prefix)] == prefix +} + +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/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go index 01d8f02..c8e0c65 100644 --- a/request_lifecycle_contract_generated_test.go +++ b/request_lifecycle_contract_generated_test.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" "net/url" - "reflect" "testing" "github.com/vitorsalgado/mocha/v3" @@ -17,6 +16,7 @@ import ( ) const ( + generatedTusRequestLifecycleEventPolicy = "exact" generatedTusRequestLifecycleUploadLength = "11" generatedTusRequestLifecycleUploadOffset = "11" generatedTusRequestLifecycleUploadPath = "/uploads/request-hooks-contract" @@ -128,13 +128,7 @@ func TestGeneratedRequestLifecycleHooks(t *testing.T) { if upload.RemoteSize != 11 { t.Fatalf("expected upload length %s, got %d", generatedTusRequestLifecycleUploadLength, upload.RemoteSize) } - if !reflect.DeepEqual(events, generatedTusRequestLifecycleExpectedHookEvents) { - t.Fatalf( - "expected request lifecycle events %#v, got %#v", - generatedTusRequestLifecycleExpectedHookEvents, - events, - ) - } + generatedTusAssertEvents(t, "requestLifecycleHooks", generatedTusRequestLifecycleEventPolicy, generatedTusRequestLifecycleExpectedHookEvents, events) } func generatedRequestLifecycleRequestHeaders( diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go index 2f39316..ff7cb88 100644 --- a/url_storage_abort_contract_generated_test.go +++ b/url_storage_abort_contract_generated_test.go @@ -11,13 +11,13 @@ import ( "net/http" "net/http/httptest" "net/url" - "reflect" "strings" "testing" "time" ) const ( + generatedTusAbortEventPolicy = "exact" generatedTusAbortContent = "hello world" generatedTusAbortEndpointPath = "/uploads" generatedTusAbortUploadLength = "11" @@ -109,9 +109,7 @@ func TestGeneratedAbortUploadContext(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("timed out waiting for server to observe abort") } - if !reflect.DeepEqual(events, generatedTusAbortExpectedEvents) { - t.Fatalf("expected abort events %#v, got %#v", generatedTusAbortExpectedEvents, events) - } + generatedTusAssertEvents(t, "abortUpload", generatedTusAbortEventPolicy, generatedTusAbortExpectedEvents, events) storedUploads, err := storage.FindAllUploads() if err != nil { diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index c04feee..68bc530 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -12,13 +12,13 @@ import ( "net/http" "net/http/httptest" "net/url" - "reflect" "strings" "testing" "time" ) const ( + generatedTusAbortTerminationEventPolicy = "exact" generatedTusAbortTerminationContent = "hello world" generatedTusAbortTerminationContentType = "application/offset+octet-stream" generatedTusAbortTerminationContentTypeHeader = "Content-Type" @@ -197,9 +197,7 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { t.Fatal(err) default: } - if !reflect.DeepEqual(events, generatedTusAbortTerminationExpectedEvents) { - t.Fatalf("expected abort termination events %#v, got %#v", generatedTusAbortTerminationExpectedEvents, events) - } + generatedTusAssertEvents(t, "abortUploadAfterStoredUrl", generatedTusAbortTerminationEventPolicy, generatedTusAbortTerminationExpectedEvents, events) storedUploads, err := storage.FindAllUploads() if err != nil { diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index d695a7b..f78c2ad 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -10,7 +10,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "reflect" "strings" "testing" ) @@ -20,6 +19,7 @@ const ( generatedTusCreationWithUploadContentType = "application/offset+octet-stream" generatedTusCreationWithUploadContentTypeHeader = "Content-Type" generatedTusCreationWithUploadEndpointPath = "/uploads" + generatedTusCreationWithUploadEventPolicy = "exact-except-extra-progress" generatedTusCreationWithUploadLength = "11" generatedTusCreationWithUploadLengthHeader = "Upload-Length" generatedTusCreationWithUploadMetadataHeader = "Upload-Metadata" @@ -150,9 +150,7 @@ func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { t.Fatal(err) default: } - if !reflect.DeepEqual(events, generatedTusCreationWithUploadExpectedEvents) { - t.Fatalf("expected creation-with-upload events %#v, got %#v", generatedTusCreationWithUploadExpectedEvents, events) - } + generatedTusAssertEvents(t, "creationWithUpload", generatedTusCreationWithUploadEventPolicy, generatedTusCreationWithUploadExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-fingerprint") if err != nil { diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index 10f2ec5..6e5cd01 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -10,7 +10,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "reflect" "strings" "testing" ) @@ -21,6 +20,7 @@ const ( generatedTusCreationPartialContentTypeHeader = "Content-Type" generatedTusCreationPartialCreateBodySize = 5 generatedTusCreationPartialEndpointPath = "/uploads" + generatedTusCreationPartialEventPolicy = "exact-except-extra-progress" generatedTusCreationPartialLength = "11" generatedTusCreationPartialLengthHeader = "Upload-Length" generatedTusCreationPartialMetadataHeader = "Upload-Metadata" @@ -241,9 +241,7 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { t.Fatal(err) default: } - if !reflect.DeepEqual(events, generatedTusCreationPartialExpectedEvents) { - t.Fatalf("expected partial creation events %#v, got %#v", generatedTusCreationPartialExpectedEvents, events) - } + generatedTusAssertEvents(t, "creationWithUploadPartialChunk", generatedTusCreationPartialEventPolicy, generatedTusCreationPartialExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-partial-fingerprint") if err != nil { diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go index 4c7914f..c76301a 100644 --- a/url_storage_deferred_length_contract_generated_test.go +++ b/url_storage_deferred_length_contract_generated_test.go @@ -10,7 +10,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "reflect" "strings" "testing" ) @@ -22,6 +21,7 @@ const ( generatedTusDeferredLengthCreateDeferHeader = "Upload-Defer-Length" generatedTusDeferredLengthCreateDeferValue = "1" generatedTusDeferredLengthEndpointPath = "/uploads" + generatedTusDeferredLengthEventPolicy = "exact-except-extra-progress" generatedTusDeferredLengthMetadataHeader = "Upload-Metadata" generatedTusDeferredLengthPatchLength = "11" generatedTusDeferredLengthPatchLengthHeader = "Upload-Length" @@ -182,9 +182,7 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { t.Fatal(err) default: } - if !reflect.DeepEqual(events, generatedTusDeferredLengthExpectedEvents) { - t.Fatalf("expected deferred length events %#v, got %#v", generatedTusDeferredLengthExpectedEvents, events) - } + generatedTusAssertEvents(t, "deferredLengthUpload", generatedTusDeferredLengthEventPolicy, generatedTusDeferredLengthExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-deferred-length-fingerprint") if err != nil { diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index 6c9c414..c8c1e2d 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" "net/url" - "reflect" "strings" "testing" @@ -20,6 +19,7 @@ import ( const ( generatedTusEventHooksContent = "hello world" generatedTusEventHooksCreatedUploadPath = "/uploads/generated-contract" + generatedTusEventHooksEventPolicy = "exact-except-extra-progress" generatedTusEventHooksFingerprint = "contract-single-fingerprint" generatedTusEventHooksPatchAcceptedOffset = "11" generatedTusEventHooksPatchBody = "hello world" @@ -162,9 +162,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { if upload.RemoteOffset != 11 { t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) } - if !reflect.DeepEqual(events, generatedTusEventHooksExpectedEvents) { - t.Fatalf("expected event hooks %#v, got %#v", generatedTusEventHooksExpectedEvents, events) - } + generatedTusAssertEvents(t, "singleUploadLifecycle", generatedTusEventHooksEventPolicy, generatedTusEventHooksExpectedEvents, events) } func generatedTusEventHooksTotal(bytesTotal *int64) string { diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index be4d1c5..6c978b9 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -10,7 +10,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "reflect" "strings" "sync" "testing" @@ -21,6 +20,7 @@ const ( generatedTusParallelConcatExtension = "concatenation" generatedTusParallelContent = "hello world" generatedTusParallelEndpointPath = "/uploads" + generatedTusParallelEventPolicy = "exact-except-extra-progress" generatedTusParallelFinalConcatPrefix = "final;" generatedTusParallelFinalPath = "/uploads/parallel-final" generatedTusParallelUploadURLSeparator = " " @@ -252,9 +252,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { t.Fatal(err) default: } - if !reflect.DeepEqual(events, generatedTusParallelExpectedEvents) { - t.Fatalf("expected parallel events %#v, got %#v", generatedTusParallelExpectedEvents, events) - } + generatedTusAssertEvents(t, "parallelUploadConcat", generatedTusParallelEventPolicy, generatedTusParallelExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-parallel-fingerprint") if err != nil { From 27d2214bb33cbf20cf791e89caf615c3003160df Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:25:16 +0200 Subject: [PATCH 38/97] Assert generated TUS retry events --- termination_retry_contract_generated_test.go | 35 +++++++++++++++++++ ...arallel_cleanup_contract_generated_test.go | 8 +++++ url_storage_retry_contract_generated_test.go | 9 +++++ 3 files changed, 52 insertions(+) diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 8837a15..79ea9db 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -5,6 +5,7 @@ package tusgo import ( + "fmt" "io" "net/http" "net/url" @@ -20,6 +21,7 @@ import ( const ( generatedTusTerminateFlowContent = "hello world" + generatedTusTerminateFlowEventPolicy = "exact" generatedTusTerminateFlowPatchAcceptedOffset = "5" generatedTusTerminateFlowPatchBody = "hello" generatedTusTerminateFlowPatchOffset = "0" @@ -27,8 +29,20 @@ const ( generatedTusTerminateFlowUploadPath = "/uploads/terminate-contract" ) +type generatedTusTerminateRetryDecision struct { + Decision bool + RetryAttempt int +} + +var generatedTusTerminateFlowExpectedEvents = []string{"should-retry:0:true", "retry-schedule:0"} var generatedTusTerminateFlowMetadata = map[string]string{"filename": "hello.txt"} var generatedTusTerminateFlowRetryDelays = []time.Duration{0 * time.Millisecond, 0 * time.Millisecond} +var generatedTusTerminateFlowShouldRetryEvents = []generatedTusTerminateRetryDecision{ + { + Decision: true, + RetryAttempt: 0, + }, +} func TestGeneratedTerminationRetryFlow(t *testing.T) { srvMock := mocha.New(t) @@ -150,8 +164,25 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { t.Fatalf("expected uploaded offset 5, got %d", upload.RemoteOffset) } + events := []string{} + retryDecisionIndex := 0 response, err := client.TerminateUploadWithRetry(*upload, 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, fmt.Sprintf("should-retry:%d:%t", retryAttempt, expected.Decision)) + if expected.Decision { + events = append(events, fmt.Sprintf("retry-schedule:%d", generatedTusTerminateFlowRetryDelays[retryAttempt].Milliseconds())) + } + retryDecisionIndex += 1 + return expected.Decision + }, }) if err != nil { t.Fatal(err) @@ -162,6 +193,10 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { 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, generatedTusTerminateFlowExpectedEvents, events) } func generatedTerminationRetryRequestHeaders( diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index ecb4875..2c15341 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -23,6 +23,7 @@ const ( generatedTusParallelCleanupContentType = "application/offset+octet-stream" generatedTusParallelCleanupContentTypeHeader = "Content-Type" generatedTusParallelCleanupEndpointPath = "/uploads" + generatedTusParallelCleanupEventPolicy = "exact" generatedTusParallelCleanupFailurePartIndex = 0 generatedTusParallelCleanupFailureStatus = 500 generatedTusParallelCleanupMethod = "POST" @@ -32,6 +33,7 @@ const ( generatedTusParallelCleanupUploadCount = 2 ) +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 generatedTusParallelCleanupPartPatchBodies = []string{"hello", " world"} @@ -57,6 +59,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { 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 @@ -163,6 +166,9 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { } select { case <-request.Context().Done(): + requestMu.Lock() + events = append(events, generatedTusParallelCleanupExpectedEvents[0]) + requestMu.Unlock() return case <-time.After(2 * time.Second): recordRequestErr(fmt.Errorf("expected cleanup patch request to be canceled")) @@ -235,6 +241,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { 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) @@ -248,6 +255,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { if actualTerminatedParts != generatedTusParallelCleanupUploadCount { t.Fatalf("expected all partial uploads to be terminated, got %#v", terminatedParts) } + generatedTusAssertEvents(t, "parallelUploadAbortCleanup", generatedTusParallelCleanupEventPolicy, generatedTusParallelCleanupExpectedEvents, actualEvents) select { case err := <-requestErrs: t.Fatal(err) diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index bc32f3a..caf22d8 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -5,6 +5,7 @@ package tusgo import ( + "fmt" "io" "net/http" "net/url" @@ -20,6 +21,7 @@ import ( const ( generatedTusRetryFlowContent = "hello world" + generatedTusRetryFlowEventPolicy = "exact" generatedTusRetryFlowFinalPatchAcceptedOffset = "11" generatedTusRetryFlowFinalPatchBody = " world" generatedTusRetryFlowFinalPatchOffset = "5" @@ -41,6 +43,7 @@ type generatedTusRetryDecision struct { RetryAttempt int } +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 generatedTusRetryFlowRetryDelays = []time.Duration{0 * time.Millisecond} var generatedTusRetryFlowShouldRetryEvents = []generatedTusRetryDecision{ @@ -209,6 +212,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { storage := NewMemoryURLStorage() retryDecisionIndex := 0 + events := []string{} upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ Storage: storage, Source: strings.NewReader(generatedTusRetryFlowContent), @@ -224,6 +228,10 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { if retryAttempt != expected.RetryAttempt { t.Fatalf("expected retry attempt %d, got %d", expected.RetryAttempt, retryAttempt) } + events = append(events, fmt.Sprintf("should-retry:%d:%t", retryAttempt, expected.Decision)) + if expected.Decision { + events = append(events, fmt.Sprintf("retry-schedule:%d", generatedTusRetryFlowRetryDelays[retryAttempt].Milliseconds())) + } retryDecisionIndex += 1 return expected.Decision }, @@ -246,6 +254,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { if upload.RemoteOffset != 11 { t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) } + generatedTusAssertEvents(t, "retryPatchAfterOffsetRecovery", generatedTusRetryFlowEventPolicy, generatedTusRetryFlowExpectedEvents, events) } func generatedURLStorageRetryRequestHeaders( From a3c66e073e08607296598f00424b6c69329dd3fd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 10:50:48 +0200 Subject: [PATCH 39/97] Use generated TUS termination execution hints --- termination_retry_contract_generated_test.go | 41 +++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 79ea9db..67dd27b 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -34,8 +34,19 @@ type generatedTusTerminateRetryDecision struct { RetryAttempt int } +type generatedTusChunkCompleteAction struct { + Kind string + TerminateUpload bool +} + 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 generatedTusTerminateFlowShouldRetryEvents = []generatedTusTerminateRetryDecision{ { @@ -166,7 +177,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { events := []string{} retryDecisionIndex := 0 - response, err := client.TerminateUploadWithRetry(*upload, TerminateUploadOptions{ + response, err := generatedTusRunTerminateFlowChunkCompleteActions(t, client, *upload, generatedTusTerminateFlowOnChunkCompleteActions, TerminateUploadOptions{ RetryDelays: generatedTusTerminateFlowRetryDelays, OnShouldRetry: func(err error, retryAttempt int) bool { if retryDecisionIndex >= len(generatedTusTerminateFlowShouldRetryEvents) { @@ -199,6 +210,34 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { generatedTusAssertEvents(t, "terminateWithRetry", generatedTusTerminateFlowEventPolicy, 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 != "abort-upload" { + 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, From 30df86e8a9d3b00fbdf54acb8a302149aa670982 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:01:46 +0200 Subject: [PATCH 40/97] Use generated TUS request-start cancellation hints --- url_storage_abort_contract_generated_test.go | 3 ++- url_storage_abort_termination_contract_generated_test.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go index ff7cb88..06c554b 100644 --- a/url_storage_abort_contract_generated_test.go +++ b/url_storage_abort_contract_generated_test.go @@ -17,6 +17,7 @@ import ( ) const ( + generatedTusAbortCancelRequestIndex = 0 generatedTusAbortEventPolicy = "exact" generatedTusAbortContent = "hello world" generatedTusAbortEndpointPath = "/uploads" @@ -55,7 +56,7 @@ func TestGeneratedAbortUploadContext(t *testing.T) { ); err != nil { requestErr = err } - events = append(events, "request-abort:0") + events = append(events, fmt.Sprintf("request-abort:%d", generatedTusAbortCancelRequestIndex)) close(requestStarted) <-request.Context().Done() })) diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index 68bc530..c5f49b3 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -18,6 +18,7 @@ import ( ) const ( + generatedTusAbortTerminationCancelRequestIndex = 1 generatedTusAbortTerminationEventPolicy = "exact" generatedTusAbortTerminationContent = "hello world" generatedTusAbortTerminationContentType = "application/offset+octet-stream" @@ -106,7 +107,7 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { if actual := request.Header.Get(generatedTusAbortTerminationOverrideHeader); actual != generatedTusAbortTerminationOverrideValue { recordRequestErr(fmt.Errorf("expected override header %s, got %s", generatedTusAbortTerminationOverrideValue, actual)) } - events = append(events, "request-abort:1") + events = append(events, fmt.Sprintf("request-abort:%d", generatedTusAbortTerminationCancelRequestIndex)) close(patchStarted) <-request.Context().Done() From cb24023bf918b5cdc33d35a77cdfdaa46ca16116 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:19:12 +0200 Subject: [PATCH 41/97] Use generated TUS parallel request gates --- ...arallel_cleanup_contract_generated_test.go | 46 ++++++++++++------- ...torage_parallel_contract_generated_test.go | 22 +++++++-- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 2c15341..7372048 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -19,18 +19,19 @@ import ( ) const ( - generatedTusParallelCleanupContent = "hello world" - generatedTusParallelCleanupContentType = "application/offset+octet-stream" - generatedTusParallelCleanupContentTypeHeader = "Content-Type" - generatedTusParallelCleanupEndpointPath = "/uploads" - generatedTusParallelCleanupEventPolicy = "exact" - generatedTusParallelCleanupFailurePartIndex = 0 - generatedTusParallelCleanupFailureStatus = 500 - generatedTusParallelCleanupMethod = "POST" - generatedTusParallelCleanupOffsetHeader = "Upload-Offset" - generatedTusParallelCleanupOverrideHeader = "X-HTTP-Method-Override" - generatedTusParallelCleanupOverrideValue = "PATCH" - generatedTusParallelCleanupUploadCount = 2 + generatedTusParallelCleanupContent = "hello world" + generatedTusParallelCleanupContentType = "application/offset+octet-stream" + generatedTusParallelCleanupContentTypeHeader = "Content-Type" + generatedTusParallelCleanupEndpointPath = "/uploads" + generatedTusParallelCleanupEventPolicy = "exact" + generatedTusParallelCleanupFailurePartIndex = 0 + generatedTusParallelCleanupFailureStatus = 500 + generatedTusParallelCleanupMethod = "POST" + generatedTusParallelCleanupOffsetHeader = "Upload-Offset" + generatedTusParallelCleanupOverrideHeader = "X-HTTP-Method-Override" + generatedTusParallelCleanupOverrideValue = "PATCH" + generatedTusParallelCleanupPatchGateTimeoutMs = 2000 + generatedTusParallelCleanupUploadCount = 2 ) var generatedTusParallelCleanupExpectedEvents = []string{"request-abort:3"} @@ -40,6 +41,7 @@ var generatedTusParallelCleanupPartPatchBodies = []string{"hello", " world"} var generatedTusParallelCleanupPartPatchOffsets = []string{"0", "0"} var generatedTusParallelCleanupPartUploadLengths = []string{"5", "6"} var generatedTusParallelCleanupPartUploadPaths = []string{"/uploads/parallel-cleanup-part-1", "/uploads/parallel-cleanup-part-2"} +var generatedTusParallelCleanupPatchGateRequestIndexes = []int{2, 3} var generatedTusParallelCleanupTerminatePaths = []string{"/uploads/parallel-cleanup-part-1", "/uploads/parallel-cleanup-part-2"} func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { @@ -121,7 +123,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { return } select { - case patchArrivals <- partIndex: + case patchArrivals <- generatedTusParallelCleanupPatchGateRequestIndexes[partIndex]: case <-request.Context().Done(): recordRequestErr(request.Context().Err()) return @@ -307,12 +309,12 @@ func generatedTusReleaseParallelCleanupPatchesAfterAllStarted( requestErrs chan<- error, ) { seen := map[int]bool{} - timer := time.NewTimer(2 * time.Second) + timer := time.NewTimer(time.Duration(generatedTusParallelCleanupPatchGateTimeoutMs) * time.Millisecond) defer timer.Stop() - for len(seen) < generatedTusParallelCleanupUploadCount { + for !generatedTusParallelCleanupPatchGateHasStartedAll(seen) { select { - case partIndex := <-patchArrivals: - seen[partIndex] = true + case requestIndex := <-patchArrivals: + seen[requestIndex] = true case <-timer.C: requestErrs <- fmt.Errorf("expected all cleanup PATCH requests to be in flight") close(releasePatches) @@ -323,6 +325,16 @@ func generatedTusReleaseParallelCleanupPatchesAfterAllStarted( 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, diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index 6c978b9..82e16e4 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -23,6 +23,7 @@ const ( generatedTusParallelEventPolicy = "exact-except-extra-progress" generatedTusParallelFinalConcatPrefix = "final;" generatedTusParallelFinalPath = "/uploads/parallel-final" + generatedTusParallelPatchGateTimeoutMs = 2000 generatedTusParallelUploadURLSeparator = " " generatedTusParallelConformanceUploadCount = 2 ) @@ -36,6 +37,7 @@ var generatedTusParallelPartPatchBodies = []string{"hello", " world"} var generatedTusParallelPartPatchOffsets = []string{"0", "0"} var generatedTusParallelPartUploadLengths = []string{"5", "6"} var generatedTusParallelPartUploadPaths = []string{"/uploads/parallel-part-1", "/uploads/parallel-part-2"} +var generatedTusParallelPatchGateRequestIndexes = []int{2, 3} func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { createOperation := generatedProtocolOperation("createTusUpload") @@ -143,7 +145,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { return } select { - case patchArrivals <- partIndex: + case patchArrivals <- generatedTusParallelPatchGateRequestIndexes[partIndex]: case <-request.Context().Done(): recordRequestErr(request.Context().Err()) return @@ -303,12 +305,12 @@ func generatedTusReleaseParallelPatchesAfterAllStarted( requestErrs chan<- error, ) { seen := map[int]bool{} - timer := time.NewTimer(2 * time.Second) + timer := time.NewTimer(time.Duration(generatedTusParallelPatchGateTimeoutMs) * time.Millisecond) defer timer.Stop() - for len(seen) < generatedTusParallelConformanceUploadCount { + for !generatedTusParallelPatchGateHasStartedAll(seen) { select { - case partIndex := <-patchArrivals: - seen[partIndex] = true + case requestIndex := <-patchArrivals: + seen[requestIndex] = true case <-timer.C: requestErrs <- fmt.Errorf("expected all parallel PATCH requests to be in flight") close(releasePatches) @@ -319,6 +321,16 @@ func generatedTusReleaseParallelPatchesAfterAllStarted( 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" From 69eb89ded9801c463b4d9502e6411b3bb0ab05cd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:37:34 +0200 Subject: [PATCH 42/97] Expose TUS managed upload contract --- protocol_contract_generated_test.go | 247 ++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 2677063..b26cd98 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1029,6 +1029,253 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, } +const generatedTusManagedUploadJSON = `{ + "capabilities": { + "cleanup": { + "policies": [ + "remove-owned-source-after-success", + "remove-owned-source-after-cancel", + "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", + "managedUploadNetworkConstraint" + ], + "status": "needs-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" + }, + { + "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" + }, + { + "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": [ + { + "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." + }, + { + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state" + ], + "scenarioId": "managedUploadPermanentFailure", + "summary": "Classify missing sources and unretryable protocol failures as terminal without further retry." + }, + { + "requiredPrimitives": [ + "accept-upload-submission", + "schedule-upload-work", + "publish-upload-state" + ], + "scenarioId": "managedUploadNetworkConstraint", + "summary": "Honor network constraints before starting or resuming upload work." + } + ] +} +` + var generatedTusClientFlow = generatedTusClientFlowContract{ UrlStorage: generatedTusClientUrlStoragePolicy{ ID: generatedTusClientUrlStorageIDPolicy{ From 55a85674a3ca94892b3bdbb6afd8236752679a90 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:52:40 +0200 Subject: [PATCH 43/97] Expose managed upload proof cases --- protocol_contract_generated_test.go | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index b26cd98..4fa1088 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -4,6 +4,8 @@ package tusgo +import "testing" + type generatedTusWireVersion struct { Default bool Value string @@ -94,6 +96,15 @@ type generatedTusClientUrlStorageConformanceAction struct { Upload map[string]any } +type generatedTusManagedUploadProofCase struct { + FeatureID string + Layer string + ScenarioID string + RequiredPrimitives []string + ProtocolFeatureIDs []string + RuntimeProfiles []string +} + var generatedTusWireVersions = []generatedTusWireVersion{ { Default: true, @@ -1276,6 +1287,33 @@ const generatedTusManagedUploadJSON = `{ } ` +var generatedTusManagedUploadProofCases = []generatedTusManagedUploadProofCase{ + { + FeatureID: "managedUpload", + Layer: "feature-over-protocol", + ScenarioID: "managedUploadDurableRetry", + RequiredPrimitives: []string{"accept-upload-submission", "make-source-durable", "schedule-upload-work", "run-protocol-upload", "apply-managed-retry-policy", "publish-upload-state", "cleanup-managed-upload"}, + ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, + RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, + }, + { + FeatureID: "managedUpload", + Layer: "feature-over-protocol", + ScenarioID: "managedUploadPermanentFailure", + RequiredPrimitives: []string{"accept-upload-submission", "make-source-durable", "schedule-upload-work", "classify-failure", "publish-upload-state"}, + ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, + RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, + }, + { + FeatureID: "managedUpload", + Layer: "feature-over-protocol", + ScenarioID: "managedUploadNetworkConstraint", + RequiredPrimitives: []string{"accept-upload-submission", "schedule-upload-work", "publish-upload-state"}, + ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, + RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, + }, +} + var generatedTusClientFlow = generatedTusClientFlowContract{ UrlStorage: generatedTusClientUrlStoragePolicy{ ID: generatedTusClientUrlStorageIDPolicy{ @@ -1577,6 +1615,46 @@ func generatedTusAssertEvents( ) } +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 generatedTusIsProgressEventKey(event string) bool { const prefix = "progress:" return len(event) >= len(prefix) && event[:len(prefix)] == prefix From 5cd3279f24c5bf165963333a23e0d884ffaba97d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:17:07 +0200 Subject: [PATCH 44/97] Update managed upload proof fixture --- protocol_contract_generated_test.go | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 4fa1088..34e71f3 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1251,6 +1251,107 @@ const generatedTusManagedUploadJSON = `{ ], "scenarios": [ { + "proof": { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "afterAcceptedOffset": 7, + "kind": "io-error" + }, + "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" + }, + "retryDelays": [ + 0 + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "sourceDurability": "copy-to-owned-storage", + "stateBackend": "filesystem", + "states": [ + "pending", + "running", + "failed", + "running", + "succeeded" + ] + }, "requiredPrimitives": [ "accept-upload-submission", "make-source-durable", From bf1a839a29019525393fb1bcb7e84fb9266b5ddb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:43:36 +0200 Subject: [PATCH 45/97] Update managed upload proof fixture --- protocol_contract_generated_test.go | 275 +++++++++++++++++++--------- 1 file changed, 189 insertions(+), 86 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 34e71f3..eaad719 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1251,107 +1251,210 @@ const generatedTusManagedUploadJSON = `{ ], "scenarios": [ { - "proof": { - "attempts": [ - { - "attemptIndex": 0, - "failure": { - "afterAcceptedOffset": 7, - "kind": "io-error" - }, - "requests": [ - { - "bodySize": 0, - "headers": { - "Upload-Length": "14" - }, - "operationId": "createTusUpload", - "response": { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "afterAcceptedOffset": 7, + "kind": "io-error" + }, + "requests": [ + { + "bodySize": 0, "headers": { - "Location": "https://tus.io/uploads/managed-durable-retry" + "Upload-Length": "14" }, - "statusCode": 201 - }, - "url": "endpoint" - }, - { - "bodySize": 7, - "headers": { - "Upload-Offset": "0" + "operationId": "createTusUpload", + "response": { + "headers": { + "Location": "https://tus.io/uploads/managed-durable-retry" + }, + "statusCode": 201 + }, + "url": "endpoint" }, - "operationId": "patchTusUpload", - "response": { + { + "bodySize": 7, "headers": { - "Upload-Offset": "7" + "Upload-Offset": "0" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "7" + }, + "statusCode": 204 }, - "statusCode": 204 + "url": "upload" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "requests": [ + { + "headers": {}, + "operationId": "getTusUploadOffset", + "response": { + "headers": { + "Upload-Length": "14", + "Upload-Offset": "7" + }, + "statusCode": 200 + }, + "url": "upload" }, - "url": "upload" - } - ], - "stateAfterAttempt": "failed" - }, - { - "attemptIndex": 1, - "requests": [ - { - "headers": {}, - "operationId": "getTusUploadOffset", - "response": { + { + "bodySize": 7, "headers": { - "Upload-Length": "14", "Upload-Offset": "7" }, - "statusCode": 200 - }, - "url": "upload" + "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" + }, + "retryDelays": [ + 0 + ], + "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" }, - { - "bodySize": 7, - "headers": { - "Upload-Offset": "7" + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": { + "Location": "https://tus.io/uploads/managed-durable-retry" + }, + "statusCode": 201 + }, + "url": "endpoint" }, - "operationId": "patchTusUpload", - "response": { + { + "bodySize": 7, "headers": { - "Upload-Offset": "14" + "Upload-Offset": "0" }, - "statusCode": 204 + "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" }, - "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" + { + "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" }, - "uploadPath": "managed-durable-retry" - }, - "retryDelays": [ - 0 - ], - "runtime": "java", - "scheduler": "process-lifetime-worker-pool", - "sourceDurability": "copy-to-owned-storage", - "stateBackend": "filesystem", - "states": [ - "pending", - "running", - "failed", - "running", - "succeeded" - ] - }, + "input": { + "chunkSize": 7, + "content": "hello managed!", + "fingerprint": "managed-durable-retry-fingerprint", + "metadata": { + "filename": "managed.txt" + }, + "uploadPath": "managed-durable-retry" + }, + "retryDelays": [ + 0 + ], + "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", From 64583ef0ddc8c78bbaadc36866e9a33b1e304c38 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:09:50 +0200 Subject: [PATCH 46/97] Update managed upload proof fixture --- protocol_contract_generated_test.go | 126 +++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 4 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index eaad719..4a45418 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1258,7 +1258,8 @@ const generatedTusManagedUploadJSON = `{ "attemptIndex": 0, "failure": { "afterAcceptedOffset": 7, - "kind": "io-error" + "kind": "io-error", + "phase": "after-accepted-offset" }, "requests": [ { @@ -1349,6 +1350,9 @@ const generatedTusManagedUploadJSON = `{ "running", "succeeded" ], + "terminal": { + "state": "succeeded" + }, "runtime": "java", "scheduler": "process-lifetime-worker-pool", "stateBackend": "filesystem" @@ -1359,7 +1363,8 @@ const generatedTusManagedUploadJSON = `{ "attemptIndex": 0, "failure": { "afterAcceptedOffset": 7, - "kind": "io-error" + "kind": "io-error", + "phase": "after-accepted-offset" }, "requests": [ { @@ -1450,6 +1455,9 @@ const generatedTusManagedUploadJSON = `{ "running", "succeeded" ], + "terminal": { + "state": "succeeded" + }, "runtime": "android", "scheduler": "durable-os-scheduler", "stateBackend": "platform-key-value-store" @@ -1468,12 +1476,122 @@ const generatedTusManagedUploadJSON = `{ "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" + }, + "retryDelays": [], + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "terminal": { + "failure": "unretryable-protocol-error", + "state": "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" + }, + "retryDelays": [], + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "terminal": { + "failure": "unretryable-protocol-error", + "state": "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" + "publish-upload-state", + "cleanup-managed-upload" ], "scenarioId": "managedUploadPermanentFailure", "summary": "Classify missing sources and unretryable protocol failures as terminal without further retry." @@ -1504,7 +1622,7 @@ var generatedTusManagedUploadProofCases = []generatedTusManagedUploadProofCase{ FeatureID: "managedUpload", Layer: "feature-over-protocol", ScenarioID: "managedUploadPermanentFailure", - RequiredPrimitives: []string{"accept-upload-submission", "make-source-durable", "schedule-upload-work", "classify-failure", "publish-upload-state"}, + RequiredPrimitives: []string{"accept-upload-submission", "make-source-durable", "schedule-upload-work", "run-protocol-upload", "classify-failure", "publish-upload-state", "cleanup-managed-upload"}, ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, }, From b812d95ebd5779450c41744651946785bcfcda10 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:32:43 +0200 Subject: [PATCH 47/97] Update managed upload proof fixture --- protocol_contract_generated_test.go | 233 ++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 4a45418..b0b7875 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1109,6 +1109,7 @@ const generatedTusManagedUploadJSON = `{ "scenarioIds": [ "managedUploadDurableRetry", "managedUploadPermanentFailure", + "managedUploadRetryPolicyExhausted", "managedUploadNetworkConstraint" ], "status": "needs-generated-scenario" @@ -1596,6 +1597,230 @@ const generatedTusManagedUploadJSON = `{ "scenarioId": "managedUploadPermanentFailure", "summary": "Classify missing sources and 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" + }, + "retryDelays": [ + 0, + 0 + ], + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed" + ], + "terminal": { + "failure": "retry-policy-exhausted", + "state": "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" + }, + "retryDelays": [ + 0, + 0 + ], + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed" + ], + "terminal": { + "failure": "retry-policy-exhausted", + "state": "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." + }, { "requiredPrimitives": [ "accept-upload-submission", @@ -1626,6 +1851,14 @@ var generatedTusManagedUploadProofCases = []generatedTusManagedUploadProofCase{ ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, }, + { + FeatureID: "managedUpload", + Layer: "feature-over-protocol", + ScenarioID: "managedUploadRetryPolicyExhausted", + RequiredPrimitives: []string{"accept-upload-submission", "make-source-durable", "schedule-upload-work", "run-protocol-upload", "apply-managed-retry-policy", "classify-failure", "publish-upload-state", "cleanup-managed-upload"}, + ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, + RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, + }, { FeatureID: "managedUpload", Layer: "feature-over-protocol", From ab13e22d71007ca4e6bd00e703fe24f3821b27e7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:53:29 +0200 Subject: [PATCH 48/97] Update generated protocol contract fixture --- protocol_contract_generated_test.go | 114 +++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index b0b7875..62cf0bb 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1044,6 +1044,7 @@ const generatedTusManagedUploadJSON = `{ "capabilities": { "cleanup": { "policies": [ + "absent-after-source-unavailable", "remove-owned-source-after-success", "remove-owned-source-after-cancel", "retain-owned-source-after-permanent-failure", @@ -1110,6 +1111,7 @@ const generatedTusManagedUploadJSON = `{ "managedUploadDurableRetry", "managedUploadPermanentFailure", "managedUploadRetryPolicyExhausted", + "managedUploadSourceUnavailable", "managedUploadNetworkConstraint" ], "status": "needs-generated-scenario" @@ -1343,6 +1345,7 @@ const generatedTusManagedUploadJSON = `{ "retryDelays": [ 0 ], + "sourceAvailability": "available", "sourceDurability": "copy-to-owned-storage", "states": [ "pending", @@ -1448,6 +1451,7 @@ const generatedTusManagedUploadJSON = `{ "retryDelays": [ 0 ], + "sourceAvailability": "available", "sourceDurability": "copy-to-owned-storage", "states": [ "pending", @@ -1517,6 +1521,7 @@ const generatedTusManagedUploadJSON = `{ "uploadPath": "managed-permanent-failure" }, "retryDelays": [], + "sourceAvailability": "available", "sourceDurability": "copy-to-owned-storage", "states": [ "pending", @@ -1570,6 +1575,7 @@ const generatedTusManagedUploadJSON = `{ "uploadPath": "managed-permanent-failure" }, "retryDelays": [], + "sourceAvailability": "available", "sourceDurability": "copy-to-owned-storage", "states": [ "pending", @@ -1595,7 +1601,7 @@ const generatedTusManagedUploadJSON = `{ "cleanup-managed-upload" ], "scenarioId": "managedUploadPermanentFailure", - "summary": "Classify missing sources and unretryable protocol failures as terminal without further retry." + "summary": "Classify unretryable protocol failures as terminal without further retry." }, { "proofs": [ @@ -1685,6 +1691,7 @@ const generatedTusManagedUploadJSON = `{ 0, 0 ], + "sourceAvailability": "available", "sourceDurability": "copy-to-owned-storage", "states": [ "pending", @@ -1789,6 +1796,7 @@ const generatedTusManagedUploadJSON = `{ 0, 0 ], + "sourceAvailability": "available", "sourceDurability": "copy-to-owned-storage", "states": [ "pending", @@ -1821,6 +1829,102 @@ const generatedTusManagedUploadJSON = `{ "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" + }, + "retryDelays": [], + "sourceAvailability": "missing-before-durable-copy", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "terminal": { + "failure": "source-unavailable", + "state": "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" + }, + "retryDelays": [], + "sourceAvailability": "missing-before-durable-copy", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "terminal": { + "failure": "source-unavailable", + "state": "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." + }, { "requiredPrimitives": [ "accept-upload-submission", @@ -1859,6 +1963,14 @@ var generatedTusManagedUploadProofCases = []generatedTusManagedUploadProofCase{ ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, }, + { + FeatureID: "managedUpload", + Layer: "feature-over-protocol", + ScenarioID: "managedUploadSourceUnavailable", + RequiredPrimitives: []string{"accept-upload-submission", "make-source-durable", "schedule-upload-work", "classify-failure", "publish-upload-state", "cleanup-managed-upload"}, + ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, + RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, + }, { FeatureID: "managedUpload", Layer: "feature-over-protocol", From ac055058a83fe7906ea7fcc1a396055eabc2a269 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 14:22:41 +0200 Subject: [PATCH 49/97] Update generated managed upload contract --- protocol_contract_generated_test.go | 151 ++++++++++++++++++++++------ 1 file changed, 119 insertions(+), 32 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 62cf0bb..0924759 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1047,6 +1047,7 @@ const generatedTusManagedUploadJSON = `{ "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" @@ -1114,7 +1115,7 @@ const generatedTusManagedUploadJSON = `{ "managedUploadSourceUnavailable", "managedUploadNetworkConstraint" ], - "status": "needs-generated-scenario" + "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", @@ -1342,6 +1343,15 @@ const generatedTusManagedUploadJSON = `{ }, "uploadPath": "managed-durable-retry" }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "kind": "terminal", + "state": "succeeded" + }, "retryDelays": [ 0 ], @@ -1354,9 +1364,6 @@ const generatedTusManagedUploadJSON = `{ "running", "succeeded" ], - "terminal": { - "state": "succeeded" - }, "runtime": "java", "scheduler": "process-lifetime-worker-pool", "stateBackend": "filesystem" @@ -1448,6 +1455,15 @@ const generatedTusManagedUploadJSON = `{ }, "uploadPath": "managed-durable-retry" }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "kind": "terminal", + "state": "succeeded" + }, "retryDelays": [ 0 ], @@ -1460,9 +1476,6 @@ const generatedTusManagedUploadJSON = `{ "running", "succeeded" ], - "terminal": { - "state": "succeeded" - }, "runtime": "android", "scheduler": "durable-os-scheduler", "stateBackend": "platform-key-value-store" @@ -1520,6 +1533,16 @@ const generatedTusManagedUploadJSON = `{ }, "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", @@ -1528,10 +1551,6 @@ const generatedTusManagedUploadJSON = `{ "running", "failed" ], - "terminal": { - "failure": "unretryable-protocol-error", - "state": "failed" - }, "runtime": "java", "scheduler": "process-lifetime-worker-pool", "stateBackend": "filesystem" @@ -1574,6 +1593,16 @@ const generatedTusManagedUploadJSON = `{ }, "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", @@ -1582,10 +1611,6 @@ const generatedTusManagedUploadJSON = `{ "running", "failed" ], - "terminal": { - "failure": "unretryable-protocol-error", - "state": "failed" - }, "runtime": "android", "scheduler": "durable-os-scheduler", "stateBackend": "platform-key-value-store" @@ -1687,6 +1712,16 @@ const generatedTusManagedUploadJSON = `{ }, "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 @@ -1702,10 +1737,6 @@ const generatedTusManagedUploadJSON = `{ "running", "failed" ], - "terminal": { - "failure": "retry-policy-exhausted", - "state": "failed" - }, "runtime": "java", "scheduler": "process-lifetime-worker-pool", "stateBackend": "filesystem" @@ -1792,6 +1823,16 @@ const generatedTusManagedUploadJSON = `{ }, "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 @@ -1807,10 +1848,6 @@ const generatedTusManagedUploadJSON = `{ "running", "failed" ], - "terminal": { - "failure": "retry-policy-exhausted", - "state": "failed" - }, "runtime": "android", "scheduler": "durable-os-scheduler", "stateBackend": "platform-key-value-store" @@ -1856,6 +1893,16 @@ const generatedTusManagedUploadJSON = `{ }, "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", @@ -1864,10 +1911,6 @@ const generatedTusManagedUploadJSON = `{ "running", "failed" ], - "terminal": { - "failure": "source-unavailable", - "state": "failed" - }, "runtime": "java", "scheduler": "process-lifetime-worker-pool", "stateBackend": "filesystem" @@ -1897,6 +1940,16 @@ const generatedTusManagedUploadJSON = `{ }, "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", @@ -1905,10 +1958,6 @@ const generatedTusManagedUploadJSON = `{ "running", "failed" ], - "terminal": { - "failure": "source-unavailable", - "state": "failed" - }, "runtime": "android", "scheduler": "durable-os-scheduler", "stateBackend": "platform-key-value-store" @@ -1926,8 +1975,46 @@ const generatedTusManagedUploadJSON = `{ "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" ], @@ -1975,7 +2062,7 @@ var generatedTusManagedUploadProofCases = []generatedTusManagedUploadProofCase{ FeatureID: "managedUpload", Layer: "feature-over-protocol", ScenarioID: "managedUploadNetworkConstraint", - RequiredPrimitives: []string{"accept-upload-submission", "schedule-upload-work", "publish-upload-state"}, + RequiredPrimitives: []string{"accept-upload-submission", "make-source-durable", "schedule-upload-work", "publish-upload-state"}, ProtocolFeatureIDs: []string{"singleUploadLifecycle", "retryOffsetRecovery"}, RuntimeProfiles: []string{"android", "ios", "browser", "java", "node", "react-native"}, }, From 6f075b92fb3bafe769bd0a38f9f8d4babb1e115a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 21:22:33 +0200 Subject: [PATCH 50/97] Add devdock Transloadit upload example --- .../main.go | 542 ++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 examples/api2-devdock-transloadit-assembly-upload/main.go 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..e2732e9 --- /dev/null +++ b/examples/api2-devdock-transloadit-assembly-upload/main.go @@ -0,0 +1,542 @@ +//go:build api2devdock + +package main + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "strconv" + "time" + + tusgo "github.com/bdragon300/tusgo" + transloadit "github.com/transloadit/go-sdk" +) + +func requiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s must be set", name)) + } + + return value +} + +func fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func loadScenario() (map[string]interface{}, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.Join( + "examples", + "api2-devdock-transloadit-assembly-upload", + "api2-scenario.json", + ) + } + + 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 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 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 emptyValue(value interface{}) bool { + return value == nil || value == "" +} + +func newTransloaditClient() (*transloadit.Client, string) { + endpoint := requiredEnv("TRANSLOADIT_ENDPOINT") + config := transloadit.DefaultConfig + config.AuthKey = requiredEnv("TRANSLOADIT_KEY") + config.AuthSecret = requiredEnv("TRANSLOADIT_SECRET") + config.Endpoint = endpoint + client := transloadit.NewClient(config) + + return &client, endpoint +} + +func assemblyInfoToMap(info *transloadit.AssemblyInfo, label string) (map[string]interface{}, error) { + serialized, err := json.Marshal(info) + if err != nil { + return nil, fmt.Errorf("serialize %s: %w", label, err) + } + + var result map[string]interface{} + if err := json.Unmarshal(serialized, &result); err != nil { + return nil, fmt.Errorf("decode %s: %w", label, err) + } + + return result, nil +} + +func createAssemblyFileCount(scenario map[string]interface{}) (int, error) { + feature, err := objectValue(scenario["createTusAssembly"], "createTusAssembly") + if err != nil { + return 0, err + } + input, err := objectValue(feature["input"], "createTusAssembly.input") + if err != nil { + return 0, err + } + if len(input) != 1 { + return 0, fmt.Errorf("createTusAssembly.input must contain exactly one value") + } + + for _, value := range input { + return intValue(value, "createTusAssembly.input value") + } + + return 0, fmt.Errorf("createTusAssembly.input did not contain a value") +} + +func createAssembly( + ctx context.Context, + client *transloadit.Client, + scenario map[string]interface{}, +) (*transloadit.AssemblyInfo, map[string]interface{}, error) { + fileCount, err := createAssemblyFileCount(scenario) + if err != nil { + return nil, nil, err + } + + assembly, err := client.CreateTusAssembly(ctx, fileCount) + if err != nil { + return nil, nil, err + } + + response, err := assemblyInfoToMap(assembly, "createTusAssembly response") + if err != nil { + return nil, nil, err + } + + if errorText, ok := response["error"].(string); ok && errorText != "" { + return nil, nil, fmt.Errorf("create assembly returned %s: %s", errorText, response["message"]) + } + + feature, err := objectValue(scenario["createTusAssembly"], "createTusAssembly") + if err != nil { + return nil, nil, err + } + + requiredResponsePaths, err := arrayValue( + feature["requiredResponsePaths"], + "createTusAssembly.requiredResponsePaths", + ) + if err != nil { + return nil, nil, err + } + for index, rawPath := range requiredResponsePaths { + pathParts, err := arrayValue(rawPath, fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index)) + if err != nil { + return nil, nil, err + } + value, err := readPath(response, pathParts, fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index)) + if err != nil { + return nil, nil, err + } + if emptyValue(value) { + return nil, nil, fmt.Errorf("create assembly returned an empty value at path %v", pathParts) + } + } + + return assembly, response, nil +} + +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 uploadWithTus( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + uploadConfig, err := objectValue(scenario["upload"], "upload") + if err != nil { + return "", err + } + context := map[string]interface{}{ + "createResponse": createResponse, + "scenario": scenario, + } + endpointValue, err := resolveValue(uploadConfig["tusUrl"], context, "upload.tusUrl") + if err != nil { + return "", err + } + endpointURL, err := url.Parse(scalarString(endpointValue)) + if err != nil { + return "", err + } + content, err := scenarioBytes(scenario) + if err != nil { + return "", err + } + chunkSize, err := stringValue(uploadConfig["chunkSize"], "upload.chunkSize") + if err != nil { + return "", err + } + if chunkSize != "full-file" { + return "", fmt.Errorf("unsupported chunk size policy %q", chunkSize) + } + metadata, err := 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 valueLength(value interface{}, label string) (int, error) { + switch typed := value.(type) { + case []interface{}: + return len(typed), nil + case map[string]interface{}: + return len(typed), nil + case string: + return len(typed), nil + default: + return 0, fmt.Errorf("%s has no length", label) + } +} + +func assertionsError( + scenario map[string]interface{}, + createResponse map[string]interface{}, + status map[string]interface{}, + uploadURL string, +) error { + rawAssertions, err := arrayValue(scenario["assertions"], "assertions") + if err != nil { + return err + } + context := map[string]interface{}{ + "captured": map[string]interface{}{ + "uploadUrl": uploadURL, + }, + "createResponse": createResponse, + "scenario": scenario, + "status": status, + } + + for index, rawAssertion := range rawAssertions { + label := fmt.Sprintf("assertions[%d]", index) + assertion, err := objectValue(rawAssertion, label) + if err != nil { + return err + } + actual, err := resolveValue(assertion["actual"], context, label+".actual") + if err != nil { + return err + } + expected, err := resolveValue(assertion["expected"], context, label+".expected") + if err != nil { + return err + } + kind, err := stringValue(assertion["kind"], label+".kind") + if err != nil { + return err + } + + switch kind { + case "equals": + if !reflect.DeepEqual(actual, expected) { + return fmt.Errorf("%s expected %v, got %v", label, expected, actual) + } + case "length": + actualLength, err := valueLength(actual, label+".actual") + if err != nil { + return err + } + expectedLength, err := intValue(expected, label+".expected") + if err != nil { + return err + } + if actualLength != expectedLength { + return fmt.Errorf("%s expected length %d, got %d", label, expectedLength, actualLength) + } + default: + return fmt.Errorf("%s has unsupported assertion kind %q", label, kind) + } + } + + return nil +} + +func waitForAssembly( + ctx context.Context, + client *transloadit.Client, + assembly *transloadit.AssemblyInfo, +) (map[string]interface{}, error) { + statusInfo, err := client.WaitForAssembly(ctx, assembly) + if err != nil { + return nil, err + } + + status, err := assemblyInfoToMap(statusInfo, "waitForAssembly response") + if err != nil { + return nil, err + } + if errorText, ok := status["error"].(string); ok && errorText != "" { + return status, fmt.Errorf("assembly failed with %s: %s", errorText, status["message"]) + } + + return status, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := loadScenario() + if err != nil { + fail("load scenario: %v", err) + } + + client, endpoint := newTransloaditClient() + assembly, createResponse, err := createAssembly(ctx, client, scenario) + if err != nil { + fail("create assembly: %v", err) + } + + uploadURL, err := uploadWithTus(ctx, scenario, createResponse) + if err != nil { + fail("upload: %v", err) + } + + status, err := waitForAssembly(ctx, client, assembly) + if err != nil { + fail("wait for assembly: %v", err) + } + if err := assertionsError(scenario, createResponse, status, uploadURL); err != nil { + fail("assert scenario: %v", err) + } + + scenarioID, err := stringValue(scenario["scenarioId"], "scenarioId") + if err != nil { + fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s passed for %s\n", scenarioID, endpoint) +} From 2df2b6501b3a3c59b66ebd6faca79f4ded7b2d35 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 22:52:38 +0200 Subject: [PATCH 51/97] Use prepared Assembly in devdock example --- .../main.go | 227 ++---------------- 1 file changed, 20 insertions(+), 207 deletions(-) diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.go b/examples/api2-devdock-transloadit-assembly-upload/main.go index e2732e9..c9dab9e 100644 --- a/examples/api2-devdock-transloadit-assembly-upload/main.go +++ b/examples/api2-devdock-transloadit-assembly-upload/main.go @@ -11,23 +11,12 @@ import ( "net/url" "os" "path/filepath" - "reflect" "strconv" "time" tusgo "github.com/bdragon300/tusgo" - transloadit "github.com/transloadit/go-sdk" ) -func requiredEnv(name string) string { - value := os.Getenv(name) - if value == "" { - panic(fmt.Sprintf("%s must be set", name)) - } - - return value -} - func fail(format string, args ...interface{}) { panic(fmt.Sprintf(format, args...)) } @@ -176,106 +165,13 @@ func resolveValue( return readPath(rootValue, pathParts, label) } -func emptyValue(value interface{}) bool { - return value == nil || value == "" -} - -func newTransloaditClient() (*transloadit.Client, string) { - endpoint := requiredEnv("TRANSLOADIT_ENDPOINT") - config := transloadit.DefaultConfig - config.AuthKey = requiredEnv("TRANSLOADIT_KEY") - config.AuthSecret = requiredEnv("TRANSLOADIT_SECRET") - config.Endpoint = endpoint - client := transloadit.NewClient(config) - - return &client, endpoint -} - -func assemblyInfoToMap(info *transloadit.AssemblyInfo, label string) (map[string]interface{}, error) { - serialized, err := json.Marshal(info) - if err != nil { - return nil, fmt.Errorf("serialize %s: %w", label, err) - } - - var result map[string]interface{} - if err := json.Unmarshal(serialized, &result); err != nil { - return nil, fmt.Errorf("decode %s: %w", label, err) - } - - return result, nil -} - -func createAssemblyFileCount(scenario map[string]interface{}) (int, error) { - feature, err := objectValue(scenario["createTusAssembly"], "createTusAssembly") - if err != nil { - return 0, err - } - input, err := objectValue(feature["input"], "createTusAssembly.input") - if err != nil { - return 0, err - } - if len(input) != 1 { - return 0, fmt.Errorf("createTusAssembly.input must contain exactly one value") - } - - for _, value := range input { - return intValue(value, "createTusAssembly.input value") - } - - return 0, fmt.Errorf("createTusAssembly.input did not contain a value") -} - -func createAssembly( - ctx context.Context, - client *transloadit.Client, - scenario map[string]interface{}, -) (*transloadit.AssemblyInfo, map[string]interface{}, error) { - fileCount, err := createAssemblyFileCount(scenario) - if err != nil { - return nil, nil, err - } - - assembly, err := client.CreateTusAssembly(ctx, fileCount) - if err != nil { - return nil, nil, err - } - - response, err := assemblyInfoToMap(assembly, "createTusAssembly response") - if err != nil { - return nil, nil, err - } - - if errorText, ok := response["error"].(string); ok && errorText != "" { - return nil, nil, fmt.Errorf("create assembly returned %s: %s", errorText, response["message"]) - } - - feature, err := objectValue(scenario["createTusAssembly"], "createTusAssembly") - if err != nil { - return nil, nil, err - } - - requiredResponsePaths, err := arrayValue( - feature["requiredResponsePaths"], - "createTusAssembly.requiredResponsePaths", - ) +func createResponseFromScenario(scenario map[string]interface{}) (map[string]interface{}, error) { + prepared, err := objectValue(scenario["prepared"], "prepared") if err != nil { - return nil, nil, err - } - for index, rawPath := range requiredResponsePaths { - pathParts, err := arrayValue(rawPath, fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index)) - if err != nil { - return nil, nil, err - } - value, err := readPath(response, pathParts, fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index)) - if err != nil { - return nil, nil, err - } - if emptyValue(value) { - return nil, nil, fmt.Errorf("create assembly returned an empty value at path %v", pathParts) - } + return nil, err } - return assembly, response, nil + return objectValue(prepared["createResponse"], "prepared.createResponse") } func scenarioBytes(scenario map[string]interface{}) ([]byte, error) { @@ -409,101 +305,24 @@ func uploadWithTus( return upload.Location, nil } -func valueLength(value interface{}, label string) (int, error) { - switch typed := value.(type) { - case []interface{}: - return len(typed), nil - case map[string]interface{}: - return len(typed), nil - case string: - return len(typed), nil - default: - return 0, fmt.Errorf("%s has no length", label) +func writeResult(uploadURL string) error { + resultPath := os.Getenv("API2_SDK_EXAMPLE_RESULT") + if resultPath == "" { + return nil } -} -func assertionsError( - scenario map[string]interface{}, - createResponse map[string]interface{}, - status map[string]interface{}, - uploadURL string, -) error { - rawAssertions, err := arrayValue(scenario["assertions"], "assertions") - if err != nil { - return err - } - context := map[string]interface{}{ - "captured": map[string]interface{}{ + contents, err := json.MarshalIndent( + map[string]string{ "uploadUrl": uploadURL, }, - "createResponse": createResponse, - "scenario": scenario, - "status": status, - } - - for index, rawAssertion := range rawAssertions { - label := fmt.Sprintf("assertions[%d]", index) - assertion, err := objectValue(rawAssertion, label) - if err != nil { - return err - } - actual, err := resolveValue(assertion["actual"], context, label+".actual") - if err != nil { - return err - } - expected, err := resolveValue(assertion["expected"], context, label+".expected") - if err != nil { - return err - } - kind, err := stringValue(assertion["kind"], label+".kind") - if err != nil { - return err - } - - switch kind { - case "equals": - if !reflect.DeepEqual(actual, expected) { - return fmt.Errorf("%s expected %v, got %v", label, expected, actual) - } - case "length": - actualLength, err := valueLength(actual, label+".actual") - if err != nil { - return err - } - expectedLength, err := intValue(expected, label+".expected") - if err != nil { - return err - } - if actualLength != expectedLength { - return fmt.Errorf("%s expected length %d, got %d", label, expectedLength, actualLength) - } - default: - return fmt.Errorf("%s has unsupported assertion kind %q", label, kind) - } - } - - return nil -} - -func waitForAssembly( - ctx context.Context, - client *transloadit.Client, - assembly *transloadit.AssemblyInfo, -) (map[string]interface{}, error) { - statusInfo, err := client.WaitForAssembly(ctx, assembly) - if err != nil { - return nil, err - } - - status, err := assemblyInfoToMap(statusInfo, "waitForAssembly response") + "", + " ", + ) if err != nil { - return nil, err - } - if errorText, ok := status["error"].(string); ok && errorText != "" { - return status, fmt.Errorf("assembly failed with %s: %s", errorText, status["message"]) + return err } - return status, nil + return os.WriteFile(resultPath, append(contents, '\n'), 0o644) } func main() { @@ -515,28 +334,22 @@ func main() { fail("load scenario: %v", err) } - client, endpoint := newTransloaditClient() - assembly, createResponse, err := createAssembly(ctx, client, scenario) + createResponse, err := createResponseFromScenario(scenario) if err != nil { - fail("create assembly: %v", err) + fail("read prepared create response: %v", err) } uploadURL, err := uploadWithTus(ctx, scenario, createResponse) if err != nil { fail("upload: %v", err) } - - status, err := waitForAssembly(ctx, client, assembly) - if err != nil { - fail("wait for assembly: %v", err) - } - if err := assertionsError(scenario, createResponse, status, uploadURL); err != nil { - fail("assert scenario: %v", err) + if err := writeResult(uploadURL); err != nil { + fail("write result: %v", err) } scenarioID, err := stringValue(scenario["scenarioId"], "scenarioId") if err != nil { fail("read scenario id: %v", err) } - fmt.Printf("Go TUS SDK devdock scenario %s passed for %s\n", scenarioID, endpoint) + fmt.Printf("Go TUS SDK devdock scenario %s uploaded to %s\n", scenarioID, uploadURL) } From 21f47ca39973fb1048f8cabcaa78ee9ab274b7a7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:10:01 +0200 Subject: [PATCH 52/97] Regenerate TUS URL storage proofs --- ...ion_with_upload_contract_generated_test.go | 23 +------------------ ..._upload_partial_contract_generated_test.go | 23 +------------------ ..._custom_headers_contract_generated_test.go | 23 +------------------ ...de_patch_method_contract_generated_test.go | 23 +------------------ ...arallel_cleanup_contract_generated_test.go | 23 +------------------ 5 files changed, 5 insertions(+), 110 deletions(-) diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index f78c2ad..3135349 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -184,28 +184,7 @@ func generatedAssertTusCreationWithUploadRequestHeaders( operation generatedTusProtocolOperation, values map[string]string, ) error { - failures := []string{} - for _, variant := range operation.Request.HeaderVariants { - if err := generatedAssertTusCreationWithUploadRequestHeaderVariant(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 generatedAssertTusCreationWithUploadRequestHeaderVariant( - request *http.Request, - variant generatedTusHeaderVariant, - values map[string]string, -) error { + variant := operation.Request.HeaderVariants[0] for _, field := range variant.Fields { if !field.Required { continue diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index 6e5cd01..f38d92e 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -275,28 +275,7 @@ func generatedAssertTusCreationPartialRequestHeaders( operation generatedTusProtocolOperation, values map[string]string, ) error { - failures := []string{} - for _, variant := range operation.Request.HeaderVariants { - if err := generatedAssertTusCreationPartialRequestHeaderVariant(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 generatedAssertTusCreationPartialRequestHeaderVariant( - request *http.Request, - variant generatedTusHeaderVariant, - values map[string]string, -) error { + variant := operation.Request.HeaderVariants[0] for _, field := range variant.Fields { if !field.Required { continue diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go index 6e17ea7..9fa20b1 100644 --- a/url_storage_custom_headers_contract_generated_test.go +++ b/url_storage_custom_headers_contract_generated_test.go @@ -170,28 +170,7 @@ func generatedAssertTusCustomRequestHeaders( operation generatedTusProtocolOperation, values map[string]string, ) error { - failures := []string{} - for _, variant := range operation.Request.HeaderVariants { - if err := generatedAssertTusCustomRequestHeaderVariant(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 generatedAssertTusCustomRequestHeaderVariant( - request *http.Request, - variant generatedTusHeaderVariant, - values map[string]string, -) error { + variant := operation.Request.HeaderVariants[0] for _, field := range variant.Fields { if !field.Required { continue diff --git a/url_storage_override_patch_method_contract_generated_test.go b/url_storage_override_patch_method_contract_generated_test.go index a671032..699fa58 100644 --- a/url_storage_override_patch_method_contract_generated_test.go +++ b/url_storage_override_patch_method_contract_generated_test.go @@ -151,28 +151,7 @@ func generatedAssertTusOverrideRequestHeaders( operation generatedTusProtocolOperation, values map[string]string, ) error { - failures := []string{} - for _, variant := range operation.Request.HeaderVariants { - if err := generatedAssertTusOverrideRequestHeaderVariant(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 generatedAssertTusOverrideRequestHeaderVariant( - request *http.Request, - variant generatedTusHeaderVariant, - values map[string]string, -) error { + variant := operation.Request.HeaderVariants[0] for _, field := range variant.Fields { if !field.Required { continue diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 7372048..5bba8fd 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -340,28 +340,7 @@ func generatedAssertTusParallelCleanupRequestHeaders( 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 { + variant := operation.Request.HeaderVariants[0] for _, field := range variant.Fields { if !field.Required { continue From fd9cd698e434a7597489fcb2630a6cb26777bd2c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 14:21:41 +0200 Subject: [PATCH 53/97] Use generated TUS header defaults --- protocol_contract_generated_test.go | 19 ++++++++++++++++++ request_lifecycle_contract_generated_test.go | 20 ++++--------------- termination_retry_contract_generated_test.go | 10 ++-------- url_storage_abort_contract_generated_test.go | 5 +---- ...ort_termination_contract_generated_test.go | 10 ++-------- url_storage_create_contract_generated_test.go | 10 ++-------- ...ion_with_upload_contract_generated_test.go | 10 ++-------- ..._upload_partial_contract_generated_test.go | 10 ++-------- ..._custom_headers_contract_generated_test.go | 10 ++-------- ...deferred_length_contract_generated_test.go | 10 ++-------- ...age_event_hooks_contract_generated_test.go | 10 ++-------- url_storage_file_contract_generated_test.go | 10 ++-------- ...de_patch_method_contract_generated_test.go | 10 ++-------- ...arallel_cleanup_contract_generated_test.go | 10 ++-------- ...torage_parallel_contract_generated_test.go | 10 ++-------- url_storage_resume_contract_generated_test.go | 10 ++-------- url_storage_retry_contract_generated_test.go | 10 ++-------- 17 files changed, 52 insertions(+), 132 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 0924759..63c3936 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -79,6 +79,25 @@ type generatedTusClientUrlStorageIDPolicy struct { Strategy string } +var generatedTusDefaultRequestHeaderValues = map[string]string{"Tus-Resumable": "1.0.0"} +var generatedTusDefaultResponseHeaderValues = map[string]string{"Tus-Resumable": "1.0.0"} + +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 generatedTusClientUrlStorageConformanceScenario struct { Actions []generatedTusClientUrlStorageConformanceAction Backend string diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go index c8e0c65..07f0aad 100644 --- a/request_lifecycle_contract_generated_test.go +++ b/request_lifecycle_contract_generated_test.go @@ -141,10 +141,7 @@ func generatedRequestLifecycleRequestHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusRequestHeaderValue(values, field.DisplayName) builder = builder.Header(field.DisplayName, expect.ToEqual(value)) } @@ -161,10 +158,7 @@ func generatedRequestLifecycleResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) response = response.Header(field.DisplayName, value) } @@ -181,10 +175,7 @@ func generatedAssertRequestLifecycleRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -208,10 +199,7 @@ func generatedAssertRequestLifecycleResponseHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusResponseHeaderValue(values, field.DisplayName) if actual := response.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected response header %s=%s, got %s", diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 67dd27b..5ef7121 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -248,10 +248,7 @@ func generatedTerminationRetryRequestHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusRequestHeaderValue(values, field.DisplayName) builder = builder.Header(field.DisplayName, expect.ToEqual(value)) } @@ -268,10 +265,7 @@ func generatedTerminationRetryResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) response = response.Header(field.DisplayName, value) } diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go index 06c554b..ee57ba7 100644 --- a/url_storage_abort_contract_generated_test.go +++ b/url_storage_abort_contract_generated_test.go @@ -131,10 +131,7 @@ func generatedAssertTusAbortRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index c5f49b3..2c01f91 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -219,10 +219,7 @@ func generatedAssertTusAbortTerminationRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -262,10 +259,7 @@ func generatedWriteTusAbortTerminationResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } } diff --git a/url_storage_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go index 4f96206..b9b305f 100644 --- a/url_storage_create_contract_generated_test.go +++ b/url_storage_create_contract_generated_test.go @@ -147,10 +147,7 @@ func generatedURLStorageCreateRequestHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusRequestHeaderValue(values, field.DisplayName) builder = builder.Header(field.DisplayName, expect.ToEqual(value)) } @@ -167,10 +164,7 @@ func generatedURLStorageCreateResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) response = response.Header(field.DisplayName, value) } diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index 3135349..c1de2b5 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -189,10 +189,7 @@ func generatedAssertTusCreationWithUploadRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -219,10 +216,7 @@ func generatedWriteTusCreationWithUploadResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } if value := values[generatedTusCreationWithUploadOffsetHeader]; value != "" { diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index f38d92e..15c94ca 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -280,10 +280,7 @@ func generatedAssertTusCreationPartialRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -310,10 +307,7 @@ func generatedWriteTusCreationPartialResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } if value := values[generatedTusCreationPartialOffsetHeader]; value != "" { diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go index 9fa20b1..1545c42 100644 --- a/url_storage_custom_headers_contract_generated_test.go +++ b/url_storage_custom_headers_contract_generated_test.go @@ -175,10 +175,7 @@ func generatedAssertTusCustomRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -205,10 +202,7 @@ func generatedWriteTusCustomResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } if value := values[generatedTusCustomHeadersOffsetHeader]; value != "" { diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go index c76301a..1898749 100644 --- a/url_storage_deferred_length_contract_generated_test.go +++ b/url_storage_deferred_length_contract_generated_test.go @@ -255,10 +255,7 @@ func generatedAssertTusDeferredLengthRequestHeaderVariant( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -285,10 +282,7 @@ func generatedWriteTusDeferredLengthResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } } diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index c8c1e2d..b3abb40 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -193,10 +193,7 @@ func generatedURLStorageEventHooksRequestHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusRequestHeaderValue(values, field.DisplayName) builder = builder.Header(field.DisplayName, expect.ToEqual(value)) } @@ -213,10 +210,7 @@ func generatedURLStorageEventHooksResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) response = response.Header(field.DisplayName, value) } diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go index 597bcc0..2e3e451 100644 --- a/url_storage_file_contract_generated_test.go +++ b/url_storage_file_contract_generated_test.go @@ -182,10 +182,7 @@ func generatedURLStorageFileRequestHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusRequestHeaderValue(values, field.DisplayName) builder = builder.Header(field.DisplayName, expect.ToEqual(value)) } @@ -202,10 +199,7 @@ func generatedURLStorageFileResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) response = response.Header(field.DisplayName, value) } diff --git a/url_storage_override_patch_method_contract_generated_test.go b/url_storage_override_patch_method_contract_generated_test.go index 699fa58..6c3796b 100644 --- a/url_storage_override_patch_method_contract_generated_test.go +++ b/url_storage_override_patch_method_contract_generated_test.go @@ -156,10 +156,7 @@ func generatedAssertTusOverrideRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -186,10 +183,7 @@ func generatedWriteTusOverrideResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } if value := values[generatedTusOverrideOffsetHeader]; value != "" { diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 5bba8fd..eb458e1 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -345,10 +345,7 @@ func generatedAssertTusParallelCleanupRequestHeaders( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -388,10 +385,7 @@ func generatedWriteTusParallelCleanupResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } } diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index 82e16e4..ce8dcfc 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -383,10 +383,7 @@ func generatedAssertTusParallelRequestHeaderVariant( if !field.Required { continue } - expected := values[field.DisplayName] - if expected == "" { - expected = DefaultProtocolVersion - } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) if actual := request.Header.Get(field.DisplayName); actual != expected { return fmt.Errorf( "expected request header %s=%s, got %s", @@ -413,10 +410,7 @@ func generatedWriteTusParallelResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) responseWriter.Header().Set(field.DisplayName, value) } } diff --git a/url_storage_resume_contract_generated_test.go b/url_storage_resume_contract_generated_test.go index cb17dcf..42e7208 100644 --- a/url_storage_resume_contract_generated_test.go +++ b/url_storage_resume_contract_generated_test.go @@ -159,10 +159,7 @@ func generatedURLStorageResumeRequestHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusRequestHeaderValue(values, field.DisplayName) builder = builder.Header(field.DisplayName, expect.ToEqual(value)) } @@ -179,10 +176,7 @@ func generatedURLStorageResumeResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) response = response.Header(field.DisplayName, value) } diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index caf22d8..ada63b6 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -286,10 +286,7 @@ func generatedURLStorageRetryDynamicRequestHeaders( })) continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusRequestHeaderValue(values, field.DisplayName) builder = builder.Header(field.DisplayName, expect.ToEqual(value)) } @@ -306,10 +303,7 @@ func generatedURLStorageRetryResponseHeaders( if !field.Required { continue } - value := values[field.DisplayName] - if value == "" { - value = DefaultProtocolVersion - } + value := generatedTusResponseHeaderValue(values, field.DisplayName) response = response.Header(field.DisplayName, value) } From f27b23f64e8f7edfd41550e7ca97db250574711e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:55:44 +0200 Subject: [PATCH 54/97] Use generated protocol header defaults --- client.go | 11 ++++++++--- protocol_contract_test.go | 13 +++++++++++++ protocol_generated.go | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 99d198e..0f751d0 100644 --- a/client.go +++ b/client.go @@ -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 @@ -429,8 +429,13 @@ 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 { + for headerName := range defaultProtocolRequestHeaders { + if req.Header.Get(headerName) != "" { + continue + } + req.Header.Set(headerName, c.ProtocolVersion) + } } if ctx != nil { req = req.WithContext(ctx) diff --git a/protocol_contract_test.go b/protocol_contract_test.go index 15e94fa..6efc37b 100644 --- a/protocol_contract_test.go +++ b/protocol_contract_test.go @@ -183,4 +183,17 @@ var _ = Describe("generated TUS protocol contract", func() { Ω(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 index 6b766f4..a832fce 100644 --- a/protocol_generated.go +++ b/protocol_generated.go @@ -8,3 +8,24 @@ const ( // DefaultProtocolVersion is the wire protocol version used by default. DefaultProtocolVersion = "1.0.0" ) + +var defaultProtocolRequestHeaders = map[string]string{"Tus-Resumable": "1.0.0"} +var defaultProtocolResponseHeaders = map[string]string{"Tus-Resumable": "1.0.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 +} + +// 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) +} From 9e80f8544d49a81b2bda2a5eb3f1ef063eb29b20 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 02:09:39 +0200 Subject: [PATCH 55/97] Add generated TUS request ID proof --- protocol_contract_generated_test.go | 33 +++++++++++++++++++ ..._custom_headers_contract_generated_test.go | 4 +-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 63c3936..53d81a6 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -653,6 +653,39 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ OperationIDs: []string{"createTusUpload", "patchTusUpload"}, Primitives: []string{"apply-custom-request-headers"}, }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"requestIdHeaders"}, + Status: "covered-by-generated-scenario", + }, + Description: "Add generated request IDs after protocol and custom request headers.", + FeatureID: "requestIdHeaders", + Flow: []generatedTusClientFeatureFlowStep{ + { + Kind: "primitive", + OperationID: "", + Primitive: "add-request-id-header", + Condition: "", + Summary: "Generate a request ID and apply it after custom request headers so it is authoritative.", + }, + { + Kind: "operation", + OperationID: "createTusUpload", + Primitive: "", + Condition: "", + Summary: "Create uploads with a generated request ID.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Upload bytes with a generated request ID.", + }, + }, + OperationIDs: []string{"createTusUpload", "patchTusUpload"}, + Primitives: []string{"add-request-id-header", "apply-custom-request-headers"}, + }, { Conformance: generatedTusClientFeatureConformance{ ScenarioIDs: []string{"overridePatchMethod"}, diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go index 1545c42..e6583bf 100644 --- a/url_storage_custom_headers_contract_generated_test.go +++ b/url_storage_custom_headers_contract_generated_test.go @@ -16,7 +16,7 @@ import ( const ( generatedTusCustomHeadersContent = "hello world" - generatedTusCustomHeadersContentType = "application/offset+octet-stream" + generatedTusCustomHeadersContentType = "application/x-tus-custom-body" generatedTusCustomHeadersContentTypeHeader = "Content-Type" generatedTusCustomHeadersEndpointPath = "/uploads" generatedTusCustomHeadersLength = "11" @@ -28,7 +28,7 @@ const ( generatedTusCustomHeadersAcceptedOffset = "11" ) -var generatedTusCustomHeaders = map[string]string{"X-Tus-Contract": "custom-header", "X-Tus-Trace": "trace-123"} +var generatedTusCustomHeaders = map[string]string{"Content-Type": "application/x-tus-custom-body", "X-Tus-Contract": "custom-header", "X-Tus-Trace": "trace-123"} var generatedTusCustomHeadersMetadata = map[string]string{"filename": "hello.txt"} func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { From 2371b941dc99b755b7784d2120b51eec31519b60 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 02:52:55 +0200 Subject: [PATCH 56/97] Use generated TUS method override table --- protocol_contract_generated_test.go | 10 +++--- url_storage_generated.go | 52 ++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 53d81a6..de269ab 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -537,10 +537,10 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: []string{"deferredLengthUpload"}, + ScenarioIDs: []string{"deferredLengthUpload", "deferredLengthChunkedUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Create an upload without a known length and declare the length on final PATCH.", + Description: "Create an upload without a known length and declare the length on first PATCH.", FeatureID: "deferredLengthUpload", Flow: []generatedTusClientFeatureFlowStep{ { @@ -555,14 +555,14 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ OperationID: "", Primitive: "defer-upload-length", Condition: "", - Summary: "Track the source until the final chunk reveals the total size.", + Summary: "Track the source so the first PATCH can declare the total size.", }, { Kind: "operation", OperationID: "patchTusUpload", Primitive: "", Condition: "", - Summary: "Declare Upload-Length on the final chunk request.", + Summary: "Declare Upload-Length on the first chunk request.", }, }, OperationIDs: []string{"createTusUpload", "patchTusUpload"}, @@ -1016,7 +1016,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ }, { Conformance: generatedTusClientFeatureConformance{ - ScenarioIDs: []string{"ietfDraft05CreationWithUpload", "ietfDraft03ResumeWithoutKnownLength"}, + ScenarioIDs: []string{"ietfDraft05CreationWithUpload", "ietfDraft05ChunkedUploadComplete", "ietfDraft03ResumeWithoutKnownLength"}, Status: "covered-by-generated-scenario", }, Description: "Select between tus v1 and supported IETF draft client protocol modes.", diff --git a/url_storage_generated.go b/url_storage_generated.go index ad4693a..2e49a9c 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -47,10 +47,6 @@ const ( generatedTusDeferredLengthExtension = "creation-defer-length" generatedTusDefaultParallelUploads = 1 generatedTusMinimumParallelUploads = 2 - generatedTusMethodOverrideHeaderName = "X-HTTP-Method-Override" - generatedTusMethodOverrideHeaderValue = "PATCH" - generatedTusMethodOverrideMethod = "POST" - generatedTusMethodOverrideSourceMethod = "PATCH" generatedTusValidationParallelDeferred = "tus: cannot use the `uploadLengthDeferred` option when parallelUploads is enabled" generatedTusValidationParallelCreateData = "tus: cannot use the `uploadDataDuringCreation` option when parallelUploads is enabled" generatedTusParallelPartialMetadata = "metadataForPartialUploads" @@ -78,6 +74,25 @@ const ( generatedTusURLStorageCreationTime = "sdk-current-date-string" ) +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 generatedTusNodeFileFingerprintFields = []string{"prefix", "absolutePath", "size", "mtimeMs", "endpoint"} var generatedTusAbortSequence = []string{"mark-aborted", "abort-parallel-uploads", "abort-current-request", "clear-retry-timer", "terminate-upload-if-requested"} var generatedTusDefaultRetryDelays = []time.Duration{0 * time.Millisecond, 1000 * time.Millisecond, 3000 * time.Millisecond, 5000 * time.Millisecond} @@ -825,18 +840,37 @@ func (transport generatedTusURLStorageRequestPolicyTransport) RoundTrip( for key, value := range transport.Headers { cloned.Header.Set(key, value) } - if transport.OverridePatchMethod && - cloned.Method == generatedTusMethodOverrideSourceMethod { - cloned.Method = generatedTusMethodOverrideMethod + 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( - generatedTusMethodOverrideHeaderName, - generatedTusMethodOverrideHeaderValue, + methodOverride.HeaderName, + methodOverride.HeaderValue, ) + break } return transport.Base.RoundTrip(cloned) } +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) } From efe9d87162b32c8b821188524f75bae9d5964182 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:00:18 +0200 Subject: [PATCH 57/97] Derive parallel TUS header assertions --- ...arallel_cleanup_contract_generated_test.go | 40 ++++++++++++++++--- ...torage_parallel_contract_generated_test.go | 14 +++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index eb458e1..2d4459c 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -96,9 +96,12 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { request, createOperation, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Concat": "partial", - "Upload-Metadata": encodedPartialMetadata, "Upload-Length": generatedTusParallelCleanupPartUploadLengths[partIndex], + "Upload-Metadata": encodedPartialMetadata, + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", }, )) recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( @@ -110,7 +113,8 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusParallelCleanupPartUploadPaths[partIndex], + "Location": server.URL + generatedTusParallelCleanupPartUploadPaths[partIndex], + "Tus-Resumable": "1.0.0", }, ) responseWriter.WriteHeader(createResponse.StatusCode) @@ -150,9 +154,12 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { request, patchOperation, map[string]string{ - generatedTusParallelCleanupContentTypeHeader: generatedTusParallelCleanupContentType, - generatedTusParallelCleanupOffsetHeader: generatedTusParallelCleanupPartPatchOffsets[partIndex], - generatedTusParallelCleanupOverrideHeader: generatedTusParallelCleanupOverrideValue, + "Content-Type": generatedTusParallelCleanupContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusParallelCleanupPartPatchOffsets[partIndex], + "X-HTTP-Method-Override": generatedTusParallelCleanupOverrideValue, + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", }, )) recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( @@ -340,7 +347,28 @@ func generatedAssertTusParallelCleanupRequestHeaders( operation generatedTusProtocolOperation, values map[string]string, ) error { - variant := operation.Request.HeaderVariants[0] + 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 diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index ce8dcfc..9f898c6 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -91,9 +91,10 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { request, createOperation, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Concat": "partial", - "Upload-Metadata": encodedPartialMetadata, "Upload-Length": generatedTusParallelPartUploadLengths[partIndex], + "Upload-Metadata": encodedPartialMetadata, }, )) createResponse := generatedResponseFor(createOperation, http.StatusCreated) @@ -101,7 +102,8 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusParallelPartUploadPaths[partIndex], + "Location": server.URL + generatedTusParallelPartUploadPaths[partIndex], + "Tus-Resumable": "1.0.0", }, ) responseWriter.WriteHeader(createResponse.StatusCode) @@ -123,6 +125,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { request, createOperation, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Concat": generatedTusParallelFinalConcatHeader(server.URL), "Upload-Metadata": encodedMetadata, }, @@ -132,7 +135,8 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { responseWriter, finalResponse, map[string]string{ - "Location": server.URL + generatedTusParallelFinalPath, + "Location": server.URL + generatedTusParallelFinalPath, + "Tus-Resumable": "1.0.0", }, ) responseWriter.WriteHeader(finalResponse.StatusCode) @@ -172,7 +176,8 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { request, patchOperation, map[string]string{ - "Content-Type": patchOperation.Request.ContentType, + "Content-Type": "application/offset+octet-stream", + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusParallelPartPatchOffsets[partIndex], }, )) @@ -181,6 +186,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { responseWriter, patchResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusParallelPartPatchAcceptedOffsets[partIndex], }, ) From 1e9b633982d7b264b0c883f5e4e3f91fc366ebe2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:06:24 +0200 Subject: [PATCH 58/97] Derive creation and deferred TUS headers --- ..._upload_partial_contract_generated_test.go | 19 ++++++++++++------- ...deferred_length_contract_generated_test.go | 18 +++++++++++------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index 15c94ca..e6ab356 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -82,8 +82,10 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { request, createOperation, map[string]string{ - generatedTusCreationPartialLengthHeader: generatedTusCreationPartialLength, - generatedTusCreationPartialMetadataHeader: encodedMetadata, + "Content-Type": generatedTusCreationPartialContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreationPartialLength, + "Upload-Metadata": encodedMetadata, }, )) createResponse := generatedResponseFor(createOperation, 201) @@ -91,8 +93,9 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusCreationPartialPath, - generatedTusCreationPartialOffsetHeader: generatedTusCreationPartialOffset, + "Location": server.URL + generatedTusCreationPartialPath, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreationPartialOffset, }, ) responseWriter.WriteHeader(createResponse.StatusCode) @@ -146,8 +149,9 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { request, patchOperation, map[string]string{ - generatedTusCreationPartialContentTypeHeader: generatedTusCreationPartialContentType, - generatedTusCreationPartialOffsetHeader: expectedOffset, + "Content-Type": generatedTusCreationPartialContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": expectedOffset, }, )) patchResponse := generatedResponseFor(patchOperation, responseStatus) @@ -155,7 +159,8 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { responseWriter, patchResponse, map[string]string{ - generatedTusCreationPartialOffsetHeader: responseOffset, + "Tus-Resumable": "1.0.0", + "Upload-Offset": responseOffset, }, ) responseWriter.WriteHeader(patchResponse.StatusCode) diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go index 1898749..ef0e604 100644 --- a/url_storage_deferred_length_contract_generated_test.go +++ b/url_storage_deferred_length_contract_generated_test.go @@ -64,8 +64,9 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { request, createOperation, map[string]string{ - generatedTusDeferredLengthCreateDeferHeader: generatedTusDeferredLengthCreateDeferValue, - generatedTusDeferredLengthMetadataHeader: encodedMetadata, + "Tus-Resumable": "1.0.0", + "Upload-Defer-Length": generatedTusDeferredLengthCreateDeferValue, + "Upload-Metadata": encodedMetadata, }, )) createResponse := generatedResponseFor(createOperation, 201) @@ -73,7 +74,8 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusDeferredLengthUploadPath, + "Location": server.URL + generatedTusDeferredLengthUploadPath, + "Tus-Resumable": "1.0.0", }, ) responseWriter.WriteHeader(createResponse.StatusCode) @@ -94,9 +96,10 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { request, patchOperation, map[string]string{ - generatedTusDeferredLengthContentTypeHeader: patchOperation.Request.ContentType, - generatedTusDeferredLengthPatchLengthHeader: generatedTusDeferredLengthPatchLength, - generatedTusDeferredLengthPatchOffsetHeader: generatedTusDeferredLengthPatchOffset, + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusDeferredLengthPatchLength, + "Upload-Offset": generatedTusDeferredLengthPatchOffset, }, )) patchResponse := generatedResponseFor(patchOperation, 204) @@ -104,7 +107,8 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { responseWriter, patchResponse, map[string]string{ - generatedTusDeferredLengthPatchOffsetHeader: generatedTusDeferredLengthAcceptedOffset, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusDeferredLengthAcceptedOffset, }, ) responseWriter.WriteHeader(patchResponse.StatusCode) From 41f8c9ff82d19d55221e6a453ce3aa316c3187e2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:10:42 +0200 Subject: [PATCH 59/97] Derive retry TUS header assertions --- termination_retry_contract_generated_test.go | 8 ++++++-- url_storage_resume_contract_generated_test.go | 3 +++ url_storage_retry_contract_generated_test.go | 12 +++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 5ef7121..308dcc8 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -88,7 +88,8 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { reply.Status(createResponse.StatusCode), createResponse, map[string]string{ - "Location": createdUploadURL, + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", }, ) srvMock.AddMocks( @@ -98,8 +99,9 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { Method(createOperation.Method), createOperation, map[string]string{ - "Upload-Metadata": encodedMetadata, + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusTerminateFlowUploadLength, + "Upload-Metadata": encodedMetadata, }, ).Reply(createReply), ) @@ -109,6 +111,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { reply.Status(patchResponse.StatusCode), patchResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusTerminateFlowPatchAcceptedOffset, }, ) @@ -121,6 +124,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { patchOperation, map[string]string{ "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusTerminateFlowPatchOffset, }, ).Reply(patchReply), diff --git a/url_storage_resume_contract_generated_test.go b/url_storage_resume_contract_generated_test.go index 42e7208..87ff813 100644 --- a/url_storage_resume_contract_generated_test.go +++ b/url_storage_resume_contract_generated_test.go @@ -66,6 +66,7 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { reply.Status(getResponse.StatusCode), getResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusResumeFlowUploadLength, "Upload-Offset": generatedTusResumeFlowPatchOffset, }, @@ -86,6 +87,7 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { reply.Status(patchResponse.StatusCode), patchResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusResumeFlowPatchAcceptedOffset, }, ) @@ -97,6 +99,7 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { patchOperation, map[string]string{ "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusResumeFlowPatchOffset, }, ) diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index ada63b6..88b7f24 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -91,7 +91,8 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { reply.Status(createResponse.StatusCode), createResponse, map[string]string{ - "Location": createdUploadURL, + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", }, ) srvMock.AddMocks( @@ -101,8 +102,9 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { Method(createOperation.Method), createOperation, map[string]string{ - "Upload-Metadata": encodedMetadata, + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusRetryFlowUploadLength, + "Upload-Metadata": encodedMetadata, }, ).Repeat(1).Reply(createReply), ) @@ -112,6 +114,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { reply.Status(200), firstGetResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusRetryFlowFirstRecoveredLength, "Upload-Offset": generatedTusRetryFlowFirstRecoveredOffset, }, @@ -121,6 +124,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { reply.Status(200), secondGetResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusRetryFlowSecondRecoveredLength, "Upload-Offset": generatedTusRetryFlowSecondRecoveredOffset, }, @@ -130,6 +134,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { reply.Status(204), finalPatchResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusRetryFlowFinalPatchAcceptedOffset, }, ) @@ -162,7 +167,8 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { Method(patchOperation.Method), patchOperation, map[string]string{ - "Content-Type": patchOperation.Request.ContentType, + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", }, map[string]func() string{ "Upload-Offset": func() string { From 2ed094f7a199b0cdda879638b74cb8951f91b190 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:13:34 +0200 Subject: [PATCH 60/97] Derive event TUS header assertions --- url_storage_event_hooks_contract_generated_test.go | 8 ++++++-- url_storage_file_contract_generated_test.go | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index b3abb40..3109173 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -65,7 +65,8 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { reply.Status(createResponse.StatusCode), createResponse, map[string]string{ - "Location": createdUploadURL, + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", }, ) srvMock.AddMocks( @@ -75,8 +76,9 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { Method(createOperation.Method), createOperation, map[string]string{ - "Upload-Metadata": encodedMetadata, + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusEventHooksUploadLength, + "Upload-Metadata": encodedMetadata, }, ).Reply(createReply), ) @@ -86,6 +88,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { reply.Status(patchResponse.StatusCode), patchResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusEventHooksPatchAcceptedOffset, }, ) @@ -97,6 +100,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { patchOperation, map[string]string{ "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusEventHooksPatchOffset, }, ) diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go index 2e3e451..e60dfc5 100644 --- a/url_storage_file_contract_generated_test.go +++ b/url_storage_file_contract_generated_test.go @@ -99,7 +99,8 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { reply.Status(createResponse.StatusCode), createResponse, map[string]string{ - "Location": createdUploadURL, + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", }, ) srvMock.AddMocks( @@ -109,8 +110,9 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { Method(createOperation.Method), createOperation, map[string]string{ - "Upload-Metadata": encodedMetadata, + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusFileFlowUploadLength, + "Upload-Metadata": encodedMetadata, }, ).Reply(createReply), ) @@ -120,6 +122,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { reply.Status(patchResponse.StatusCode), patchResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusFileFlowPatchAcceptedOffset, }, ) @@ -131,6 +134,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { patchOperation, map[string]string{ "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusFileFlowPatchOffset, }, ) From e03d996c48c498500ec05e508ebacfdedf3da72c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:17:14 +0200 Subject: [PATCH 61/97] Derive create TUS header assertions --- url_storage_create_contract_generated_test.go | 8 +++++-- ..._custom_headers_contract_generated_test.go | 21 +++++++++++++------ ...de_patch_method_contract_generated_test.go | 15 +++++++------ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/url_storage_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go index b9b305f..bc70791 100644 --- a/url_storage_create_contract_generated_test.go +++ b/url_storage_create_contract_generated_test.go @@ -63,7 +63,8 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { reply.Status(createResponse.StatusCode), createResponse, map[string]string{ - "Location": createdUploadURL, + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", }, ) srvMock.AddMocks( @@ -73,8 +74,9 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { Method(createOperation.Method), createOperation, map[string]string{ - "Upload-Metadata": encodedMetadata, + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusCreateFlowUploadLength, + "Upload-Metadata": encodedMetadata, }, ).Reply(createReply), ) @@ -84,6 +86,7 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { reply.Status(patchResponse.StatusCode), patchResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusCreateFlowPatchAcceptedOffset, }, ) @@ -95,6 +98,7 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { patchOperation, map[string]string{ "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusCreateFlowPatchOffset, }, ) diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go index e6583bf..efb5cf7 100644 --- a/url_storage_custom_headers_contract_generated_test.go +++ b/url_storage_custom_headers_contract_generated_test.go @@ -56,8 +56,12 @@ func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { request, createOperation, map[string]string{ - generatedTusCustomHeadersLengthHeader: generatedTusCustomHeadersLength, - generatedTusCustomHeadersMetadataHeader: encodedMetadata, + "Content-Type": "application/x-tus-custom-body", + "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)) @@ -66,7 +70,8 @@ func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusCustomHeadersPath, + "Location": server.URL + generatedTusCustomHeadersPath, + "Tus-Resumable": "1.0.0", }, ) responseWriter.WriteHeader(createResponse.StatusCode) @@ -87,8 +92,11 @@ func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { request, patchOperation, map[string]string{ - generatedTusCustomHeadersContentTypeHeader: generatedTusCustomHeadersContentType, - generatedTusCustomHeadersOffsetHeader: generatedTusCustomHeadersOffset, + "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)) @@ -97,7 +105,8 @@ func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { responseWriter, patchResponse, map[string]string{ - generatedTusCustomHeadersOffsetHeader: generatedTusCustomHeadersAcceptedOffset, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCustomHeadersAcceptedOffset, }, ) responseWriter.WriteHeader(patchResponse.StatusCode) diff --git a/url_storage_override_patch_method_contract_generated_test.go b/url_storage_override_patch_method_contract_generated_test.go index 6c3796b..5b6d306 100644 --- a/url_storage_override_patch_method_contract_generated_test.go +++ b/url_storage_override_patch_method_contract_generated_test.go @@ -55,8 +55,9 @@ func TestGeneratedURLStorageOverridePatchMethod(t *testing.T) { responseWriter, getResponse, map[string]string{ - generatedTusOverrideLengthHeader: generatedTusOverrideUploadLength, - generatedTusOverrideOffsetHeader: generatedTusOverrideOffset, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusOverrideUploadLength, + "Upload-Offset": generatedTusOverrideOffset, }, ) responseWriter.WriteHeader(getResponse.StatusCode) @@ -77,9 +78,10 @@ func TestGeneratedURLStorageOverridePatchMethod(t *testing.T) { request, patchOperation, map[string]string{ - generatedTusOverrideContentTypeHeader: generatedTusOverrideContentType, - generatedTusOverrideHeaderName: generatedTusOverrideHeaderValue, - generatedTusOverrideOffsetHeader: generatedTusOverrideOffset, + "Content-Type": generatedTusOverrideContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusOverrideOffset, + "X-HTTP-Method-Override": generatedTusOverrideHeaderValue, }, )) patchResponse := generatedResponseFor(patchOperation, 204) @@ -87,7 +89,8 @@ func TestGeneratedURLStorageOverridePatchMethod(t *testing.T) { responseWriter, patchResponse, map[string]string{ - generatedTusOverrideOffsetHeader: generatedTusOverrideFinalOffset, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusOverrideFinalOffset, }, ) responseWriter.WriteHeader(patchResponse.StatusCode) From b0ee935ae739fad826f4c268fb92ac21db7aa06a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:20:28 +0200 Subject: [PATCH 62/97] Derive remaining TUS header assertions --- request_lifecycle_contract_generated_test.go | 2 ++ url_storage_abort_contract_generated_test.go | 3 ++- ...abort_termination_contract_generated_test.go | 17 ++++++++++++----- ...ation_with_upload_contract_generated_test.go | 9 ++++++--- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go index 07f0aad..cc56b66 100644 --- a/request_lifecycle_contract_generated_test.go +++ b/request_lifecycle_contract_generated_test.go @@ -48,6 +48,7 @@ func TestGeneratedRequestLifecycleHooks(t *testing.T) { reply.Status(getResponse.StatusCode), getResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusRequestLifecycleUploadLength, "Upload-Offset": generatedTusRequestLifecycleUploadOffset, }, @@ -99,6 +100,7 @@ func TestGeneratedRequestLifecycleHooks(t *testing.T) { response, getResponse, map[string]string{ + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusRequestLifecycleUploadLength, "Upload-Offset": generatedTusRequestLifecycleUploadOffset, }, diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go index ee57ba7..940435c 100644 --- a/url_storage_abort_contract_generated_test.go +++ b/url_storage_abort_contract_generated_test.go @@ -50,8 +50,9 @@ func TestGeneratedAbortUploadContext(t *testing.T) { request, createOperation, map[string]string{ - "Upload-Metadata": encodedMetadata, + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusAbortUploadLength, + "Upload-Metadata": encodedMetadata, }, ); err != nil { requestErr = err diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index 2c01f91..061fbe5 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -66,8 +66,11 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { request, createOperation, map[string]string{ - "Upload-Metadata": encodedMetadata, + "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusAbortTerminationUploadLength, + "Upload-Metadata": encodedMetadata, + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", }, )) recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( @@ -79,7 +82,8 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusAbortTerminationUploadPath, + "Location": server.URL + generatedTusAbortTerminationUploadPath, + "Tus-Resumable": "1.0.0", }, ) responseWriter.WriteHeader(201) @@ -95,9 +99,12 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { request, patchOperation, map[string]string{ - generatedTusAbortTerminationContentTypeHeader: generatedTusAbortTerminationContentType, - generatedTusAbortTerminationOffsetHeader: generatedTusAbortTerminationPatchOffset, - generatedTusAbortTerminationOverrideHeader: generatedTusAbortTerminationOverrideValue, + "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( diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index c1de2b5..a9d66a8 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -74,8 +74,10 @@ func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { request, createOperation, map[string]string{ - generatedTusCreationWithUploadLengthHeader: generatedTusCreationWithUploadLength, - generatedTusCreationWithUploadMetadataHeader: encodedMetadata, + "Content-Type": generatedTusCreationWithUploadContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreationWithUploadLength, + "Upload-Metadata": encodedMetadata, }, )) createResponse := generatedResponseFor(createOperation, 201) @@ -84,7 +86,8 @@ func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { createResponse, map[string]string{ "Location": server.URL + generatedTusCreationWithUploadPath, - generatedTusCreationWithUploadOffsetHeader: generatedTusCreationWithUploadOffset, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreationWithUploadOffset, }, ) responseWriter.WriteHeader(createResponse.StatusCode) From 9c12c1942b2b3cfcad300888f53d322b2f943464 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:57:59 +0200 Subject: [PATCH 63/97] Regenerate TUS deferred length proofs --- protocol_contract_generated_test.go | 8 ++++---- url_storage_custom_headers_contract_generated_test.go | 5 ++--- url_storage_generated.go | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index de269ab..a69868b 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -540,7 +540,7 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ ScenarioIDs: []string{"deferredLengthUpload", "deferredLengthChunkedUpload"}, Status: "covered-by-generated-scenario", }, - Description: "Create an upload without a known length and declare the length on first PATCH.", + Description: "Create an upload without a known length and declare the length on the final upload request.", FeatureID: "deferredLengthUpload", Flow: []generatedTusClientFeatureFlowStep{ { @@ -555,18 +555,18 @@ var generatedTusClientFeatures = []generatedTusClientFeature{ OperationID: "", Primitive: "defer-upload-length", Condition: "", - Summary: "Track the source so the first PATCH can declare the total size.", + Summary: "Track the source until the final upload request reveals the total size.", }, { Kind: "operation", OperationID: "patchTusUpload", Primitive: "", Condition: "", - Summary: "Declare Upload-Length on the first chunk request.", + Summary: "Declare Upload-Length on the final upload request.", }, }, OperationIDs: []string{"createTusUpload", "patchTusUpload"}, - Primitives: []string{"defer-upload-length", "emit-progress"}, + Primitives: []string{"defer-upload-length", "emit-chunk-complete", "emit-progress"}, }, { Conformance: generatedTusClientFeatureConformance{ diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go index efb5cf7..e25be0d 100644 --- a/url_storage_custom_headers_contract_generated_test.go +++ b/url_storage_custom_headers_contract_generated_test.go @@ -16,7 +16,7 @@ import ( const ( generatedTusCustomHeadersContent = "hello world" - generatedTusCustomHeadersContentType = "application/x-tus-custom-body" + generatedTusCustomHeadersContentType = "application/offset+octet-stream" generatedTusCustomHeadersContentTypeHeader = "Content-Type" generatedTusCustomHeadersEndpointPath = "/uploads" generatedTusCustomHeadersLength = "11" @@ -28,7 +28,7 @@ const ( generatedTusCustomHeadersAcceptedOffset = "11" ) -var generatedTusCustomHeaders = map[string]string{"Content-Type": "application/x-tus-custom-body", "X-Tus-Contract": "custom-header", "X-Tus-Trace": "trace-123"} +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) { @@ -56,7 +56,6 @@ func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { request, createOperation, map[string]string{ - "Content-Type": "application/x-tus-custom-body", "Tus-Resumable": "1.0.0", "Upload-Length": generatedTusCustomHeadersLength, "Upload-Metadata": encodedMetadata, diff --git a/url_storage_generated.go b/url_storage_generated.go index 2e49a9c..c416b45 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -43,7 +43,7 @@ const ( generatedTusCreationWithUploadExtension = "creation-with-upload" generatedTusCreationWithUploadResponseOff = "accepted-offset" generatedTusDeferredLengthCreateSize = "size-unknown" - generatedTusDeferredLengthDeclareLength = "first-patch" + generatedTusDeferredLengthDeclareLength = "final-upload-request" generatedTusDeferredLengthExtension = "creation-defer-length" generatedTusDefaultParallelUploads = 1 generatedTusMinimumParallelUploads = 2 @@ -1424,7 +1424,7 @@ func generatedTusAssertDeferredLengthPolicySupported() error { generatedTusDeferredLengthCreateSize, ) } - if generatedTusDeferredLengthDeclareLength != "first-patch" { + if generatedTusDeferredLengthDeclareLength != "final-upload-request" { return fmt.Errorf( "tus: unsupported deferred length declaration policy %s", generatedTusDeferredLengthDeclareLength, From ac042deb9dd350e5106f63d1131d14cdaca4ddf2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:01:44 +0200 Subject: [PATCH 64/97] Regenerate Go TUS event key helpers --- protocol_contract_generated_test.go | 103 +++++++++++++++++- request_lifecycle_contract_generated_test.go | 8 +- termination_retry_contract_generated_test.go | 10 +- url_storage_abort_contract_generated_test.go | 4 +- ...ort_termination_contract_generated_test.go | 4 +- ...ion_with_upload_contract_generated_test.go | 11 +- ..._upload_partial_contract_generated_test.go | 18 ++- ...deferred_length_contract_generated_test.go | 18 ++- ...age_event_hooks_contract_generated_test.go | 18 +-- ...torage_parallel_contract_generated_test.go | 12 +- url_storage_retry_contract_generated_test.go | 10 +- 11 files changed, 159 insertions(+), 57 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index a69868b..8a4813c 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -4,7 +4,11 @@ package tusgo -import "testing" +import ( + "strconv" + "strings" + "testing" +) type generatedTusWireVersion struct { Default bool @@ -81,6 +85,7 @@ type generatedTusClientUrlStorageIDPolicy struct { var generatedTusDefaultRequestHeaderValues = map[string]string{"Tus-Resumable": "1.0.0"} var generatedTusDefaultResponseHeaderValues = map[string]string{"Tus-Resumable": "1.0.0"} +var generatedTusAllowedExtraEventPrefixes = []string{"progress:"} func generatedTusHeaderValue(defaultValues map[string]string, values map[string]string, name string) string { if value, ok := values[name]; ok { @@ -2399,13 +2404,14 @@ func generatedTusAssertEvents( expectedIndex += 1 continue } - if generatedTusIsProgressEventKey(event) { + if generatedTusHasAllowedExtraEventPrefix(event, generatedTusAllowedExtraEventPrefixes) { continue } t.Fatalf( - "%s emitted unexpected non-progress event %s; expected %#v, got %#v", + "%s emitted unexpected extra event %s; allowed prefixes %#v; expected %#v, got %#v", scenarioID, event, + generatedTusAllowedExtraEventPrefixes, expected, actual, ) @@ -2461,9 +2467,94 @@ func generatedTusFindClientFeature(featureID string) *generatedTusClientFeature return nil } -func generatedTusIsProgressEventKey(event string) bool { - const prefix = "progress:" - return len(event) >= len(prefix) && event[:len(prefix)] == prefix +func generatedTusHasAllowedExtraEventPrefix(event string, allowedExtraPrefixes []string) bool { + for _, prefix := range allowedExtraPrefixes { + if len(event) >= len(prefix) && event[:len(prefix)] == prefix { + return true + } + } + + return false +} + +func generatedTusEventKey(kind string, parts ...string) string { + if len(parts) == 0 { + return kind + } + + return kind + ":" + strings.Join(parts, ":") +} + +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 { diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go index cc56b66..28f82a9 100644 --- a/request_lifecycle_contract_generated_test.go +++ b/request_lifecycle_contract_generated_test.go @@ -85,7 +85,9 @@ func TestGeneratedRequestLifecycleHooks(t *testing.T) { ); err != nil { return err } - events = append(events, fmt.Sprintf("before-request:%d", beforeRequestIndex)) + events = append(events, generatedTusEventKeyBeforeRequest( + generatedTusEventKeyNumber(int64(beforeRequestIndex)), + )) beforeRequestIndex += 1 return nil }, @@ -107,7 +109,9 @@ func TestGeneratedRequestLifecycleHooks(t *testing.T) { ); err != nil { return err } - events = append(events, fmt.Sprintf("after-response:%d", afterResponseIndex)) + events = append(events, generatedTusEventKeyAfterResponse( + generatedTusEventKeyNumber(int64(afterResponseIndex)), + )) afterResponseIndex += 1 return nil }, diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 308dcc8..622b54e 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -5,7 +5,6 @@ package tusgo import ( - "fmt" "io" "net/http" "net/url" @@ -191,9 +190,14 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { if retryAttempt != expected.RetryAttempt { t.Fatalf("expected termination retry attempt %d, got %d", expected.RetryAttempt, retryAttempt) } - events = append(events, fmt.Sprintf("should-retry:%d:%t", retryAttempt, expected.Decision)) + events = append(events, generatedTusEventKeyShouldRetry( + generatedTusEventKeyNumber(int64(retryAttempt)), + generatedTusEventKeyBool(expected.Decision), + )) if expected.Decision { - events = append(events, fmt.Sprintf("retry-schedule:%d", generatedTusTerminateFlowRetryDelays[retryAttempt].Milliseconds())) + events = append(events, generatedTusEventKeyRetrySchedule( + generatedTusEventKeyNumber(generatedTusTerminateFlowRetryDelays[retryAttempt].Milliseconds()), + )) } retryDecisionIndex += 1 return expected.Decision diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go index 940435c..c8a9aa0 100644 --- a/url_storage_abort_contract_generated_test.go +++ b/url_storage_abort_contract_generated_test.go @@ -57,7 +57,9 @@ func TestGeneratedAbortUploadContext(t *testing.T) { ); err != nil { requestErr = err } - events = append(events, fmt.Sprintf("request-abort:%d", generatedTusAbortCancelRequestIndex)) + events = append(events, generatedTusEventKeyRequestAbort( + generatedTusEventKeyNumber(int64(generatedTusAbortCancelRequestIndex)), + )) close(requestStarted) <-request.Context().Done() })) diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index 061fbe5..2c166f6 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -114,7 +114,9 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { if actual := request.Header.Get(generatedTusAbortTerminationOverrideHeader); actual != generatedTusAbortTerminationOverrideValue { recordRequestErr(fmt.Errorf("expected override header %s, got %s", generatedTusAbortTerminationOverrideValue, actual)) } - events = append(events, fmt.Sprintf("request-abort:%d", generatedTusAbortTerminationCancelRequestIndex)) + events = append(events, generatedTusEventKeyRequestAbort( + generatedTusEventKeyNumber(int64(generatedTusAbortTerminationCancelRequestIndex)), + )) close(patchStarted) <-request.Context().Done() diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index a9d66a8..27f915c 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -119,19 +119,18 @@ func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { UploadDataDuringCreation: true, EventHooks: UploadEventHooks{ OnProgress: func(bytesSent int64, bytesTotal *int64) error { - events = append(events, fmt.Sprintf( - "progress:%d:%s", - bytesSent, + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), generatedTusCreationWithUploadBytesTotalString(bytesTotal), )) return nil }, OnUploadURLAvailable: func() error { - events = append(events, "upload-url-available") + events = append(events, generatedTusEventKeyUploadUrlAvailable()) return nil }, OnSuccess: func(UploadSuccessPayload) error { - events = append(events, "success") + events = append(events, generatedTusEventKeySuccess()) return nil }, }, @@ -170,7 +169,7 @@ type generatedTusCreationWithUploadSource struct { } func (source *generatedTusCreationWithUploadSource) Close() error { - *source.events = append(*source.events, "source-close") + *source.events = append(*source.events, generatedTusEventKeySourceClose()) return nil } diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index e6ab356..90b5067 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -198,28 +198,26 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { UploadDataDuringCreation: true, EventHooks: UploadEventHooks{ OnProgress: func(bytesSent int64, bytesTotal *int64) error { - events = append(events, fmt.Sprintf( - "progress:%d:%s", - bytesSent, + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), generatedTusCreationPartialBytesTotalString(bytesTotal), )) return nil }, OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { - events = append(events, fmt.Sprintf( - "chunk-complete:%d:%d:%s", - chunkSize, - bytesAccepted, + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), generatedTusCreationPartialBytesTotalString(bytesTotal), )) return nil }, OnUploadURLAvailable: func() error { - events = append(events, "upload-url-available") + events = append(events, generatedTusEventKeyUploadUrlAvailable()) return nil }, OnSuccess: func(UploadSuccessPayload) error { - events = append(events, "success") + events = append(events, generatedTusEventKeySuccess()) return nil }, }, @@ -263,7 +261,7 @@ type generatedTusCreationPartialSource struct { } func (source *generatedTusCreationPartialSource) Close() error { - *source.events = append(*source.events, "source-close") + *source.events = append(*source.events, generatedTusEventKeySourceClose()) return nil } diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go index ef0e604..67005ca 100644 --- a/url_storage_deferred_length_contract_generated_test.go +++ b/url_storage_deferred_length_contract_generated_test.go @@ -146,28 +146,26 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { UploadLengthDeferred: true, EventHooks: UploadEventHooks{ OnProgress: func(bytesSent int64, bytesTotal *int64) error { - events = append(events, fmt.Sprintf( - "progress:%d:%s", - bytesSent, + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), generatedTusDeferredLengthBytesTotalString(bytesTotal), )) return nil }, OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { - events = append(events, fmt.Sprintf( - "chunk-complete:%d:%d:%s", - chunkSize, - bytesAccepted, + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), generatedTusDeferredLengthBytesTotalString(bytesTotal), )) return nil }, OnUploadURLAvailable: func() error { - events = append(events, "upload-url-available") + events = append(events, generatedTusEventKeyUploadUrlAvailable()) return nil }, OnSuccess: func(UploadSuccessPayload) error { - events = append(events, "success") + events = append(events, generatedTusEventKeySuccess()) return nil }, }, @@ -203,7 +201,7 @@ type generatedTusDeferredLengthSource struct { } func (source *generatedTusDeferredLengthSource) Close() error { - *source.events = append(*source.events, "source-close") + *source.events = append(*source.events, generatedTusEventKeySourceClose()) return nil } diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index 3109173..6b2b4f7 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -119,23 +119,25 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { Metadata: generatedTusEventHooksMetadata, EventHooks: UploadEventHooks{ OnUploadURLAvailable: func() error { - events = append(events, "upload-url-available") + events = append(events, generatedTusEventKeyUploadUrlAvailable()) return nil }, OnProgress: func(bytesSent int64, bytesTotal *int64) error { events = append( events, - fmt.Sprintf("progress:%d:%s", bytesSent, generatedTusEventHooksTotal(bytesTotal)), + generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusEventHooksTotal(bytesTotal), + ), ) return nil }, OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { events = append( events, - fmt.Sprintf( - "chunk-complete:%d:%d:%s", - chunkSize, - bytesAccepted, + generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), generatedTusEventHooksTotal(bytesTotal), ), ) @@ -152,7 +154,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { payload.LastResponse, ) } - events = append(events, "success") + events = append(events, generatedTusEventKeySuccess()) return nil }, }, @@ -183,7 +185,7 @@ type generatedTusEventHooksSource struct { } func (source *generatedTusEventHooksSource) Close() error { - *source.events = append(*source.events, "source-close") + *source.events = append(*source.events, generatedTusEventKeySourceClose()) return nil } diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index 9f898c6..a58dc76 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -221,18 +221,16 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { ParallelUploads: generatedTusParallelConformanceUploadCount, EventHooks: UploadEventHooks{ OnProgress: func(bytesSent int64, bytesTotal *int64) error { - events = append(events, fmt.Sprintf( - "progress:%d:%s", - bytesSent, + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), generatedTusParallelBytesTotalString(bytesTotal), )) return nil }, OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { - events = append(events, fmt.Sprintf( - "chunk-complete:%d:%d:%s", - chunkSize, - bytesAccepted, + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), generatedTusParallelBytesTotalString(bytesTotal), )) return nil diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index 88b7f24..884d90e 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -5,7 +5,6 @@ package tusgo import ( - "fmt" "io" "net/http" "net/url" @@ -234,9 +233,14 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { if retryAttempt != expected.RetryAttempt { t.Fatalf("expected retry attempt %d, got %d", expected.RetryAttempt, retryAttempt) } - events = append(events, fmt.Sprintf("should-retry:%d:%t", retryAttempt, expected.Decision)) + events = append(events, generatedTusEventKeyShouldRetry( + generatedTusEventKeyNumber(int64(retryAttempt)), + generatedTusEventKeyBool(expected.Decision), + )) if expected.Decision { - events = append(events, fmt.Sprintf("retry-schedule:%d", generatedTusRetryFlowRetryDelays[retryAttempt].Milliseconds())) + events = append(events, generatedTusEventKeyRetrySchedule( + generatedTusEventKeyNumber(generatedTusRetryFlowRetryDelays[retryAttempt].Milliseconds()), + )) } retryDecisionIndex += 1 return expected.Decision From 79729d9da133512bf3c65e679e8805f52c0de871 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:07:25 +0200 Subject: [PATCH 65/97] Regenerate Go TUS per-proof event prefixes --- protocol_contract_generated_test.go | 6 +++--- request_lifecycle_contract_generated_test.go | 3 ++- termination_retry_contract_generated_test.go | 3 ++- url_storage_abort_contract_generated_test.go | 3 ++- url_storage_abort_termination_contract_generated_test.go | 3 ++- url_storage_creation_with_upload_contract_generated_test.go | 3 ++- ..._creation_with_upload_partial_contract_generated_test.go | 3 ++- url_storage_deferred_length_contract_generated_test.go | 3 ++- url_storage_event_hooks_contract_generated_test.go | 3 ++- url_storage_parallel_cleanup_contract_generated_test.go | 3 ++- url_storage_parallel_contract_generated_test.go | 3 ++- url_storage_retry_contract_generated_test.go | 3 ++- 12 files changed, 25 insertions(+), 14 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 8a4813c..0c4dba7 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -85,7 +85,6 @@ type generatedTusClientUrlStorageIDPolicy struct { var generatedTusDefaultRequestHeaderValues = map[string]string{"Tus-Resumable": "1.0.0"} var generatedTusDefaultResponseHeaderValues = map[string]string{"Tus-Resumable": "1.0.0"} -var generatedTusAllowedExtraEventPrefixes = []string{"progress:"} func generatedTusHeaderValue(defaultValues map[string]string, values map[string]string, name string) string { if value, ok := values[name]; ok { @@ -2382,6 +2381,7 @@ func generatedTusAssertEvents( t generatedTusTestingT, scenarioID string, matching string, + allowedExtraPrefixes []string, expected []string, actual []string, ) { @@ -2404,14 +2404,14 @@ func generatedTusAssertEvents( expectedIndex += 1 continue } - if generatedTusHasAllowedExtraEventPrefix(event, generatedTusAllowedExtraEventPrefixes) { + if generatedTusHasAllowedExtraEventPrefix(event, allowedExtraPrefixes) { continue } t.Fatalf( "%s emitted unexpected extra event %s; allowed prefixes %#v; expected %#v, got %#v", scenarioID, event, - generatedTusAllowedExtraEventPrefixes, + allowedExtraPrefixes, expected, actual, ) diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go index 28f82a9..e1bf634 100644 --- a/request_lifecycle_contract_generated_test.go +++ b/request_lifecycle_contract_generated_test.go @@ -22,6 +22,7 @@ const ( generatedTusRequestLifecycleUploadPath = "/uploads/request-hooks-contract" ) +var generatedTusRequestLifecycleExtraEventPrefixes = []string{} var generatedTusRequestLifecycleExpectedHookEvents = []string{"before-request:0", "after-response:0"} func TestGeneratedRequestLifecycleHooks(t *testing.T) { @@ -134,7 +135,7 @@ func TestGeneratedRequestLifecycleHooks(t *testing.T) { if upload.RemoteSize != 11 { t.Fatalf("expected upload length %s, got %d", generatedTusRequestLifecycleUploadLength, upload.RemoteSize) } - generatedTusAssertEvents(t, "requestLifecycleHooks", generatedTusRequestLifecycleEventPolicy, generatedTusRequestLifecycleExpectedHookEvents, events) + generatedTusAssertEvents(t, "requestLifecycleHooks", generatedTusRequestLifecycleEventPolicy, generatedTusRequestLifecycleExtraEventPrefixes, generatedTusRequestLifecycleExpectedHookEvents, events) } func generatedRequestLifecycleRequestHeaders( diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 622b54e..5899693 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -38,6 +38,7 @@ type generatedTusChunkCompleteAction struct { TerminateUpload bool } +var generatedTusTerminateFlowExtraEventPrefixes = []string{} var generatedTusTerminateFlowExpectedEvents = []string{"should-retry:0:true", "retry-schedule:0"} var generatedTusTerminateFlowMetadata = map[string]string{"filename": "hello.txt"} var generatedTusTerminateFlowOnChunkCompleteActions = []generatedTusChunkCompleteAction{ @@ -215,7 +216,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { if retryDecisionIndex != len(generatedTusTerminateFlowShouldRetryEvents) { t.Fatalf("expected %d termination retry decisions, got %d", len(generatedTusTerminateFlowShouldRetryEvents), retryDecisionIndex) } - generatedTusAssertEvents(t, "terminateWithRetry", generatedTusTerminateFlowEventPolicy, generatedTusTerminateFlowExpectedEvents, events) + generatedTusAssertEvents(t, "terminateWithRetry", generatedTusTerminateFlowEventPolicy, generatedTusTerminateFlowExtraEventPrefixes, generatedTusTerminateFlowExpectedEvents, events) } func generatedTusRunTerminateFlowChunkCompleteActions( diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go index c8a9aa0..7395d6a 100644 --- a/url_storage_abort_contract_generated_test.go +++ b/url_storage_abort_contract_generated_test.go @@ -24,6 +24,7 @@ const ( generatedTusAbortUploadLength = "11" ) +var generatedTusAbortExtraEventPrefixes = []string{} var generatedTusAbortExpectedEvents = []string{"request-abort:0"} var generatedTusAbortMetadata = map[string]string{"filename": "hello.txt"} @@ -113,7 +114,7 @@ func TestGeneratedAbortUploadContext(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("timed out waiting for server to observe abort") } - generatedTusAssertEvents(t, "abortUpload", generatedTusAbortEventPolicy, generatedTusAbortExpectedEvents, events) + generatedTusAssertEvents(t, "abortUpload", generatedTusAbortEventPolicy, generatedTusAbortExtraEventPrefixes, generatedTusAbortExpectedEvents, events) storedUploads, err := storage.FindAllUploads() if err != nil { diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index 2c166f6..3f8fad4 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -36,6 +36,7 @@ const ( ) 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"} @@ -207,7 +208,7 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { t.Fatal(err) default: } - generatedTusAssertEvents(t, "abortUploadAfterStoredUrl", generatedTusAbortTerminationEventPolicy, generatedTusAbortTerminationExpectedEvents, events) + generatedTusAssertEvents(t, "abortUploadAfterStoredUrl", generatedTusAbortTerminationEventPolicy, generatedTusAbortTerminationExtraEventPrefixes, generatedTusAbortTerminationExpectedEvents, events) storedUploads, err := storage.FindAllUploads() if err != nil { diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index 27f915c..484e79c 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -28,6 +28,7 @@ const ( 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"} @@ -152,7 +153,7 @@ func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { t.Fatal(err) default: } - generatedTusAssertEvents(t, "creationWithUpload", generatedTusCreationWithUploadEventPolicy, generatedTusCreationWithUploadExpectedEvents, events) + generatedTusAssertEvents(t, "creationWithUpload", generatedTusCreationWithUploadEventPolicy, generatedTusCreationWithUploadExtraEventPrefixes, generatedTusCreationWithUploadExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-fingerprint") if err != nil { diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index 90b5067..a5e7624 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -36,6 +36,7 @@ const ( generatedTusCreationPartialChunkSize = 5 ) +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"} @@ -244,7 +245,7 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { t.Fatal(err) default: } - generatedTusAssertEvents(t, "creationWithUploadPartialChunk", generatedTusCreationPartialEventPolicy, generatedTusCreationPartialExpectedEvents, events) + generatedTusAssertEvents(t, "creationWithUploadPartialChunk", generatedTusCreationPartialEventPolicy, generatedTusCreationPartialExtraEventPrefixes, generatedTusCreationPartialExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-partial-fingerprint") if err != nil { diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go index 67005ca..61eb311 100644 --- a/url_storage_deferred_length_contract_generated_test.go +++ b/url_storage_deferred_length_contract_generated_test.go @@ -31,6 +31,7 @@ const ( ) 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"} @@ -184,7 +185,7 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { t.Fatal(err) default: } - generatedTusAssertEvents(t, "deferredLengthUpload", generatedTusDeferredLengthEventPolicy, generatedTusDeferredLengthExpectedEvents, events) + generatedTusAssertEvents(t, "deferredLengthUpload", generatedTusDeferredLengthEventPolicy, generatedTusDeferredLengthExtraEventPrefixes, generatedTusDeferredLengthExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-deferred-length-fingerprint") if err != nil { diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index 6b2b4f7..e7f79df 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -27,6 +27,7 @@ const ( 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"} @@ -168,7 +169,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { if upload.RemoteOffset != 11 { t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) } - generatedTusAssertEvents(t, "singleUploadLifecycle", generatedTusEventHooksEventPolicy, generatedTusEventHooksExpectedEvents, events) + generatedTusAssertEvents(t, "singleUploadLifecycle", generatedTusEventHooksEventPolicy, generatedTusEventHooksExtraEventPrefixes, generatedTusEventHooksExpectedEvents, events) } func generatedTusEventHooksTotal(bytesTotal *int64) string { diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 2d4459c..25353bd 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -34,6 +34,7 @@ const ( 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"} @@ -264,7 +265,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { if actualTerminatedParts != generatedTusParallelCleanupUploadCount { t.Fatalf("expected all partial uploads to be terminated, got %#v", terminatedParts) } - generatedTusAssertEvents(t, "parallelUploadAbortCleanup", generatedTusParallelCleanupEventPolicy, generatedTusParallelCleanupExpectedEvents, actualEvents) + generatedTusAssertEvents(t, "parallelUploadAbortCleanup", generatedTusParallelCleanupEventPolicy, generatedTusParallelCleanupExtraEventPrefixes, generatedTusParallelCleanupExpectedEvents, actualEvents) select { case err := <-requestErrs: t.Fatal(err) diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index a58dc76..bfc7375 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -28,6 +28,7 @@ const ( 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"} @@ -258,7 +259,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { t.Fatal(err) default: } - generatedTusAssertEvents(t, "parallelUploadConcat", generatedTusParallelEventPolicy, generatedTusParallelExpectedEvents, events) + generatedTusAssertEvents(t, "parallelUploadConcat", generatedTusParallelEventPolicy, generatedTusParallelExtraEventPrefixes, generatedTusParallelExpectedEvents, events) storedUploads, err := storage.FindUploadsByFingerprint("contract-parallel-fingerprint") if err != nil { diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index 884d90e..b675f62 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -42,6 +42,7 @@ type generatedTusRetryDecision struct { 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 generatedTusRetryFlowRetryDelays = []time.Duration{0 * time.Millisecond} @@ -264,7 +265,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { if upload.RemoteOffset != 11 { t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) } - generatedTusAssertEvents(t, "retryPatchAfterOffsetRecovery", generatedTusRetryFlowEventPolicy, generatedTusRetryFlowExpectedEvents, events) + generatedTusAssertEvents(t, "retryPatchAfterOffsetRecovery", generatedTusRetryFlowEventPolicy, generatedTusRetryFlowExtraEventPrefixes, generatedTusRetryFlowExpectedEvents, events) } func generatedURLStorageRetryRequestHeaders( From 888a1fb5552b1128a75b44c84fc5edb6982f5fcd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:30:04 +0200 Subject: [PATCH 66/97] Use generic TUS extra event matching policy --- protocol_contract_generated_test.go | 2 +- url_storage_creation_with_upload_contract_generated_test.go | 2 +- ...rage_creation_with_upload_partial_contract_generated_test.go | 2 +- url_storage_deferred_length_contract_generated_test.go | 2 +- url_storage_event_hooks_contract_generated_test.go | 2 +- url_storage_parallel_contract_generated_test.go | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 0c4dba7..1193f7c 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -2394,7 +2394,7 @@ func generatedTusAssertEvents( t.Fatalf("expected %s events %#v, got %#v", scenarioID, expected, actual) } - if matching != "exact-except-extra-progress" { + if matching != "exact-except-allowed-extra-events" { t.Fatalf("unsupported generated event policy %s for %s", matching, scenarioID) } diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index 484e79c..2693928 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -19,7 +19,7 @@ const ( generatedTusCreationWithUploadContentType = "application/offset+octet-stream" generatedTusCreationWithUploadContentTypeHeader = "Content-Type" generatedTusCreationWithUploadEndpointPath = "/uploads" - generatedTusCreationWithUploadEventPolicy = "exact-except-extra-progress" + generatedTusCreationWithUploadEventPolicy = "exact-except-allowed-extra-events" generatedTusCreationWithUploadLength = "11" generatedTusCreationWithUploadLengthHeader = "Upload-Length" generatedTusCreationWithUploadMetadataHeader = "Upload-Metadata" diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index a5e7624..1f7ebcb 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -20,7 +20,7 @@ const ( generatedTusCreationPartialContentTypeHeader = "Content-Type" generatedTusCreationPartialCreateBodySize = 5 generatedTusCreationPartialEndpointPath = "/uploads" - generatedTusCreationPartialEventPolicy = "exact-except-extra-progress" + generatedTusCreationPartialEventPolicy = "exact-except-allowed-extra-events" generatedTusCreationPartialLength = "11" generatedTusCreationPartialLengthHeader = "Upload-Length" generatedTusCreationPartialMetadataHeader = "Upload-Metadata" diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go index 61eb311..9738cf2 100644 --- a/url_storage_deferred_length_contract_generated_test.go +++ b/url_storage_deferred_length_contract_generated_test.go @@ -21,7 +21,7 @@ const ( generatedTusDeferredLengthCreateDeferHeader = "Upload-Defer-Length" generatedTusDeferredLengthCreateDeferValue = "1" generatedTusDeferredLengthEndpointPath = "/uploads" - generatedTusDeferredLengthEventPolicy = "exact-except-extra-progress" + generatedTusDeferredLengthEventPolicy = "exact-except-allowed-extra-events" generatedTusDeferredLengthMetadataHeader = "Upload-Metadata" generatedTusDeferredLengthPatchLength = "11" generatedTusDeferredLengthPatchLengthHeader = "Upload-Length" diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index e7f79df..e172eba 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -19,7 +19,7 @@ import ( const ( generatedTusEventHooksContent = "hello world" generatedTusEventHooksCreatedUploadPath = "/uploads/generated-contract" - generatedTusEventHooksEventPolicy = "exact-except-extra-progress" + generatedTusEventHooksEventPolicy = "exact-except-allowed-extra-events" generatedTusEventHooksFingerprint = "contract-single-fingerprint" generatedTusEventHooksPatchAcceptedOffset = "11" generatedTusEventHooksPatchBody = "hello world" diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index bfc7375..e25107e 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -20,7 +20,7 @@ const ( generatedTusParallelConcatExtension = "concatenation" generatedTusParallelContent = "hello world" generatedTusParallelEndpointPath = "/uploads" - generatedTusParallelEventPolicy = "exact-except-extra-progress" + generatedTusParallelEventPolicy = "exact-except-allowed-extra-events" generatedTusParallelFinalConcatPrefix = "final;" generatedTusParallelFinalPath = "/uploads/parallel-final" generatedTusParallelPatchGateTimeoutMs = 2000 From 06883d9065479a3eacf7826a2a2f755a20c8120b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 06:37:18 +0200 Subject: [PATCH 67/97] Regenerate TUS event key helpers --- protocol_contract_generated_test.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 1193f7c..b72e59a 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -2477,12 +2477,10 @@ func generatedTusHasAllowedExtraEventPrefix(event string, allowedExtraPrefixes [ return false } -func generatedTusEventKey(kind string, parts ...string) string { - if len(parts) == 0 { - return kind - } +const generatedTusEventKeyPartSeparator = ":" - return kind + ":" + strings.Join(parts, ":") +func generatedTusEventKey(parts ...string) string { + return strings.Join(parts, generatedTusEventKeyPartSeparator) } func generatedTusEventKeyBool(value bool) string { From f48657e02e69a41dff053a08bc530227d53fc835 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 22:54:57 +0200 Subject: [PATCH 68/97] Regenerate contract TUS flow tests --- termination_retry_contract_generated_test.go | 6 +++--- url_storage_create_contract_generated_test.go | 4 ++-- ...rage_event_hooks_contract_generated_test.go | 4 ++-- url_storage_file_contract_generated_test.go | 4 ++-- ...parallel_cleanup_contract_generated_test.go | 8 +++++--- ...storage_parallel_contract_generated_test.go | 8 +++++--- url_storage_resume_contract_generated_test.go | 4 ++-- url_storage_retry_contract_generated_test.go | 18 +++++++++--------- 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 5899693..dd63437 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -83,7 +83,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { if err != nil { t.Fatal(err) } - createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createResponse := generatedResponseFor(createOperation, 201) createReply := generatedTerminationRetryResponseHeaders( reply.Status(createResponse.StatusCode), createResponse, @@ -106,7 +106,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { ).Reply(createReply), ) - patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchResponse := generatedResponseFor(patchOperation, 204) patchReply := generatedTerminationRetryResponseHeaders( reply.Status(patchResponse.StatusCode), patchResponse, @@ -130,7 +130,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { ).Reply(patchReply), ) - finalTerminateResponse := generatedResponseFor(terminateOperation, http.StatusNoContent) + finalTerminateResponse := generatedResponseFor(terminateOperation, 204) finalTerminateReply := generatedTerminationRetryResponseHeaders( reply.Status(204), finalTerminateResponse, diff --git a/url_storage_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go index bc70791..2e48e3d 100644 --- a/url_storage_create_contract_generated_test.go +++ b/url_storage_create_contract_generated_test.go @@ -58,7 +58,7 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { t.Fatal(err) } - createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createResponse := generatedResponseFor(createOperation, 201) createReply := generatedURLStorageCreateResponseHeaders( reply.Status(createResponse.StatusCode), createResponse, @@ -81,7 +81,7 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { ).Reply(createReply), ) - patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchResponse := generatedResponseFor(patchOperation, 204) patchReply := generatedURLStorageCreateResponseHeaders( reply.Status(patchResponse.StatusCode), patchResponse, diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index e172eba..fed84d7 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -61,7 +61,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { t.Fatal(err) } - createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createResponse := generatedResponseFor(createOperation, 201) createReply := generatedURLStorageEventHooksResponseHeaders( reply.Status(createResponse.StatusCode), createResponse, @@ -84,7 +84,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { ).Reply(createReply), ) - patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchResponse := generatedResponseFor(patchOperation, 204) patchReply := generatedURLStorageEventHooksResponseHeaders( reply.Status(patchResponse.StatusCode), patchResponse, diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go index e60dfc5..9fedfa0 100644 --- a/url_storage_file_contract_generated_test.go +++ b/url_storage_file_contract_generated_test.go @@ -94,7 +94,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { t.Fatal(err) } - createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createResponse := generatedResponseFor(createOperation, 201) createReply := generatedURLStorageFileResponseHeaders( reply.Status(createResponse.StatusCode), createResponse, @@ -117,7 +117,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { ).Reply(createReply), ) - patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchResponse := generatedResponseFor(patchOperation, 204) patchReply := generatedURLStorageFileResponseHeaders( reply.Status(patchResponse.StatusCode), patchResponse, diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 25353bd..51a80bf 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -72,6 +72,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { patchArrivals, releasePatches, requestErrs, + patchOperation.Method, ) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { @@ -109,7 +110,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { request, generatedTusParallelCleanupHeaders, )) - createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createResponse := generatedResponseFor(createOperation, 201) generatedWriteTusParallelCleanupResponseHeaders( responseWriter, createResponse, @@ -208,7 +209,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { if actual := request.Header.Get(generatedTusParallelCleanupOverrideHeader); actual != "" { recordRequestErr(fmt.Errorf("expected no override header on cleanup termination request, got %s", actual)) } - responseWriter.WriteHeader(http.StatusNoContent) + responseWriter.WriteHeader(204) default: recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) @@ -315,6 +316,7 @@ 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) @@ -324,7 +326,7 @@ func generatedTusReleaseParallelCleanupPatchesAfterAllStarted( case requestIndex := <-patchArrivals: seen[requestIndex] = true case <-timer.C: - requestErrs <- fmt.Errorf("expected all cleanup PATCH requests to be in flight") + requestErrs <- fmt.Errorf("expected all cleanup %s requests to be in flight", patchMethod) close(releasePatches) return } diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index e25107e..614261a 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -67,6 +67,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { patchArrivals, releasePatches, requestErrs, + patchOperation.Method, ) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { @@ -98,7 +99,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { "Upload-Metadata": encodedPartialMetadata, }, )) - createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createResponse := generatedResponseFor(createOperation, 201) generatedWriteTusParallelResponseHeaders( responseWriter, createResponse, @@ -182,7 +183,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { "Upload-Offset": generatedTusParallelPartPatchOffsets[partIndex], }, )) - patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchResponse := generatedResponseFor(patchOperation, 204) generatedWriteTusParallelResponseHeaders( responseWriter, patchResponse, @@ -308,6 +309,7 @@ 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) @@ -317,7 +319,7 @@ func generatedTusReleaseParallelPatchesAfterAllStarted( case requestIndex := <-patchArrivals: seen[requestIndex] = true case <-timer.C: - requestErrs <- fmt.Errorf("expected all parallel PATCH requests to be in flight") + requestErrs <- fmt.Errorf("expected all parallel %s requests to be in flight", patchMethod) close(releasePatches) return } diff --git a/url_storage_resume_contract_generated_test.go b/url_storage_resume_contract_generated_test.go index 87ff813..9c07c58 100644 --- a/url_storage_resume_contract_generated_test.go +++ b/url_storage_resume_contract_generated_test.go @@ -61,7 +61,7 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { } getOperation := generatedProtocolOperation("getTusUploadOffset") - getResponse := generatedResponseFor(getOperation, http.StatusOK) + getResponse := generatedResponseFor(getOperation, 200) getReply := generatedURLStorageResumeResponseHeaders( reply.Status(getResponse.StatusCode), getResponse, @@ -82,7 +82,7 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { ) patchOperation := generatedProtocolOperation("patchTusUpload") - patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchResponse := generatedResponseFor(patchOperation, 204) patchReply := generatedURLStorageResumeResponseHeaders( reply.Status(patchResponse.StatusCode), patchResponse, diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index b675f62..62ada2a 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -86,7 +86,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { t.Fatal(err) } - createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createResponse := generatedResponseFor(createOperation, 201) createReply := generatedURLStorageRetryResponseHeaders( reply.Status(createResponse.StatusCode), createResponse, @@ -109,7 +109,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { ).Repeat(1).Reply(createReply), ) - firstGetResponse := generatedResponseFor(getOperation, http.StatusOK) + firstGetResponse := generatedResponseFor(getOperation, 200) firstGetReply := generatedURLStorageRetryResponseHeaders( reply.Status(200), firstGetResponse, @@ -119,7 +119,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { "Upload-Offset": generatedTusRetryFlowFirstRecoveredOffset, }, ) - secondGetResponse := generatedResponseFor(getOperation, http.StatusOK) + secondGetResponse := generatedResponseFor(getOperation, 200) secondGetReply := generatedURLStorageRetryResponseHeaders( reply.Status(200), secondGetResponse, @@ -129,7 +129,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { "Upload-Offset": generatedTusRetryFlowSecondRecoveredOffset, }, ) - finalPatchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + finalPatchResponse := generatedResponseFor(patchOperation, 204) finalPatchReply := generatedURLStorageRetryResponseHeaders( reply.Status(204), finalPatchResponse, @@ -180,7 +180,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { }, ).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 PATCH request %d", patchReplyIndex) + t.Fatalf("unexpected retry %s request %d", patchOperation.Method, patchReplyIndex) return nil, nil } expected := patchReplies[patchReplyIndex] @@ -189,7 +189,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { return nil, err } if string(body) != expected.Body { - t.Fatalf("expected PATCH body %q, got %q", expected.Body, string(body)) + t.Fatalf("expected %s body %q, got %q", patchOperation.Method, expected.Body, string(body)) } patchReplyIndex += 1 return expected.Reply.Build(r, m, p) @@ -207,7 +207,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { 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 HEAD request %d", getReplyIndex) + t.Fatalf("unexpected retry %s request %d", getOperation.Method, getReplyIndex) return nil, nil } expected := getReplies[getReplyIndex] @@ -254,10 +254,10 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { t.Fatalf("expected %d retry decisions, got %d", len(generatedTusRetryFlowShouldRetryEvents), retryDecisionIndex) } if patchReplyIndex != len(patchReplies) { - t.Fatalf("expected %d PATCH requests, got %d", len(patchReplies), patchReplyIndex) + t.Fatalf("expected %d %s requests, got %d", len(patchReplies), patchOperation.Method, patchReplyIndex) } if getReplyIndex != len(getReplies) { - t.Fatalf("expected %d HEAD requests, got %d", len(getReplies), getReplyIndex) + 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) From 0ae7f6048829e0b4c09e6428ee0829bbef3bdd1b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 07:47:33 +0200 Subject: [PATCH 69/97] Regenerate TUS termination retry contract test --- termination_retry_contract_generated_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index dd63437..b6d9c20 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -19,6 +19,7 @@ import ( ) const ( + generatedTusTerminateFlowChunkCompleteActionKind = "abort-upload" generatedTusTerminateFlowContent = "hello world" generatedTusTerminateFlowEventPolicy = "exact" generatedTusTerminateFlowPatchAcceptedOffset = "5" @@ -230,7 +231,7 @@ func generatedTusRunTerminateFlowChunkCompleteActions( var response *http.Response for _, action := range actions { - if action.Kind != "abort-upload" { + if action.Kind != generatedTusTerminateFlowChunkCompleteActionKind { t.Fatalf("unsupported generated onChunkComplete action %s", action.Kind) } if !action.TerminateUpload { From ae0b4a7578a0e2b3e790b2047119749f8368f9c8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 09:16:50 +0200 Subject: [PATCH 70/97] Assert abort termination headers --- ...torage_abort_termination_contract_generated_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index 3f8fad4..483ec17 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -125,7 +125,11 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( request, terminateOperation, - map[string]string{}, + map[string]string{ + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + }, )) recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( request, @@ -138,7 +142,9 @@ func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { generatedWriteTusAbortTerminationResponseHeaders( responseWriter, terminateResponse, - map[string]string{}, + map[string]string{ + "Tus-Resumable": "1.0.0", + }, ) responseWriter.WriteHeader(204) close(terminationDone) From 81605c6fd15005599b2dc05c13018a5689e2795d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 09:37:13 +0200 Subject: [PATCH 71/97] Assert parallel cleanup termination headers --- ...e_parallel_cleanup_contract_generated_test.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 51a80bf..66f880b 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -200,7 +200,11 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( request, terminateOperation, - map[string]string{}, + map[string]string{ + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + }, )) recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( request, @@ -209,7 +213,15 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { if actual := request.Header.Get(generatedTusParallelCleanupOverrideHeader); actual != "" { recordRequestErr(fmt.Errorf("expected no override header on cleanup termination request, got %s", actual)) } - responseWriter.WriteHeader(204) + 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)) From 03f40b32748b24d9d26b07c619c2d9665e69615f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 11:13:21 +0200 Subject: [PATCH 72/97] Regenerate TUS transport fixture --- protocol_contract_generated_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index b72e59a..299302f 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -1244,7 +1244,8 @@ const generatedTusManagedUploadJSON = `{ "copy-to-owned-storage", "reference-original-source" ], - "stateBackend": "platform-key-value-store" + "stateBackend": "platform-key-value-store", + "transportProfileId": "java-http-url-connection" }, { "networkConstraints": [ @@ -1281,7 +1282,8 @@ const generatedTusManagedUploadJSON = `{ "copy-to-owned-storage", "reference-original-source" ], - "stateBackend": "filesystem" + "stateBackend": "filesystem", + "transportProfileId": "java-http-url-connection" }, { "networkConstraints": [ From b1d0e4e8fdca448cc4b1e13f95bd26584d9982e9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:39:01 +0200 Subject: [PATCH 73/97] Regenerate TUS protocol response fixtures --- protocol_contract_generated_test.go | 45 +++++ termination_generated.go | 6 +- termination_retry_contract_generated_test.go | 38 ++-- url_storage_create_contract_generated_test.go | 8 +- ..._upload_partial_contract_generated_test.go | 58 +++--- url_storage_file_contract_generated_test.go | 8 +- url_storage_generated.go | 181 ++++++++++++++++-- ...arallel_cleanup_contract_generated_test.go | 57 ++++-- ...torage_parallel_contract_generated_test.go | 64 ++++--- url_storage_retry_contract_generated_test.go | 151 +++++++++------ 10 files changed, 454 insertions(+), 162 deletions(-) diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go index 299302f..c87e06a 100644 --- a/protocol_contract_generated_test.go +++ b/protocol_contract_generated_test.go @@ -292,6 +292,21 @@ var generatedTusProtocolOperations = []generatedTusProtocolOperation{ }, }, }, + { + StatusCode: 500, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, }, }, { @@ -412,6 +427,21 @@ var generatedTusProtocolOperations = []generatedTusProtocolOperation{ }, }, }, + { + StatusCode: 500, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, }, }, { @@ -450,6 +480,21 @@ var generatedTusProtocolOperations = []generatedTusProtocolOperation{ }, }, }, + { + StatusCode: 423, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, }, }, { diff --git a/termination_generated.go b/termination_generated.go index 309f53f..3386d6b 100644 --- a/termination_generated.go +++ b/termination_generated.go @@ -42,6 +42,10 @@ func (c *Client) TerminateUploadWithRetry(upload Upload, options TerminateUpload if delay > 0 { time.Sleep(delay) } - retryAttempt += 1 + nextRetryAttempt, retryAttemptErr := generatedTusNextRetryAttempt(retryAttempt) + if retryAttemptErr != nil { + return response, retryAttemptErr + } + retryAttempt = nextRetryAttempt } } diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index b6d9c20..173eb54 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -22,6 +22,7 @@ const ( generatedTusTerminateFlowChunkCompleteActionKind = "abort-upload" generatedTusTerminateFlowContent = "hello world" generatedTusTerminateFlowEventPolicy = "exact" + generatedTusTerminateFlowFinalStatus = 204 generatedTusTerminateFlowPatchAcceptedOffset = "5" generatedTusTerminateFlowPatchBody = "hello" generatedTusTerminateFlowPatchOffset = "0" @@ -39,6 +40,10 @@ type generatedTusChunkCompleteAction struct { 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"} @@ -49,6 +54,14 @@ var generatedTusTerminateFlowOnChunkCompleteActions = []generatedTusChunkComplet }, } var generatedTusTerminateFlowRetryDelays = []time.Duration{0 * time.Millisecond, 0 * time.Millisecond} +var generatedTusTerminateFlowTerminateAttempts = []generatedTusTerminateAttempt{ + { + Status: 423, + }, + { + Status: 204, + }, +} var generatedTusTerminateFlowShouldRetryEvents = []generatedTusTerminateRetryDecision{ { Decision: true, @@ -131,15 +144,18 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { ).Reply(patchReply), ) - finalTerminateResponse := generatedResponseFor(terminateOperation, 204) - finalTerminateReply := generatedTerminationRetryResponseHeaders( - reply.Status(204), - finalTerminateResponse, - map[string]string{}, - ) - terminateReplies := []*reply.StdReply{ - reply.Status(423), - finalTerminateReply, + 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( @@ -208,8 +224,8 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { if err != nil { t.Fatal(err) } - if response == nil || response.StatusCode != 204 { - t.Fatalf("expected termination status 204, got %#v", response) + 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) diff --git a/url_storage_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go index 2e48e3d..9fe056c 100644 --- a/url_storage_create_contract_generated_test.go +++ b/url_storage_create_contract_generated_test.go @@ -126,7 +126,13 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { if err != nil { t.Fatal(err) } - if generatedTusCreateFlowRemoveFingerprintOnSuccess { + shouldRemoveStoredUpload, shouldRemoveStoredUploadErr := generatedTusShouldRemoveStoredUploadOnSuccess( + generatedTusCreateFlowRemoveFingerprintOnSuccess, + ) + if shouldRemoveStoredUploadErr != nil { + t.Fatal(shouldRemoveStoredUploadErr) + } + if shouldRemoveStoredUpload { if len(storedUploads) != 0 { t.Fatalf("expected successful create flow to remove stored upload, got %#v", storedUploads) } diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index 1f7ebcb..8d28f43 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -26,19 +26,37 @@ const ( generatedTusCreationPartialMetadataHeader = "Upload-Metadata" generatedTusCreationPartialOffset = "5" generatedTusCreationPartialOffsetHeader = "Upload-Offset" - generatedTusCreationPartialFirstPatchBody = 5 - generatedTusCreationPartialFirstPatchOffset = "5" - generatedTusCreationPartialFirstPatchResult = "10" - generatedTusCreationPartialSecondPatchBody = 1 - generatedTusCreationPartialSecondPatchOffset = "10" generatedTusCreationPartialPath = "/uploads/creation-with-upload-partial-contract" - generatedTusCreationPartialFinalOffset = "11" 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") @@ -105,30 +123,20 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { request.Method == patchOperation.Method: requestCount += 1 patchRequestCount += 1 - expectedBodyStart := generatedTusCreationPartialCreateBodySize - expectedBodySize := generatedTusCreationPartialFirstPatchBody - expectedOffset := generatedTusCreationPartialFirstPatchOffset - responseOffset := generatedTusCreationPartialFirstPatchResult - responseStatus := 204 - if patchRequestCount == 2 { - expectedBodyStart += generatedTusCreationPartialFirstPatchBody - expectedBodySize = generatedTusCreationPartialSecondPatchBody - expectedOffset = generatedTusCreationPartialSecondPatchOffset - responseOffset = generatedTusCreationPartialFinalOffset - responseStatus = 204 - } else if patchRequestCount > 2 { + 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 := expectedBodyStart + expectedBodySize - expectedBody := generatedTusCreationPartialContent[expectedBodyStart:expectedBodyEnd] - if len(expectedBody) != expectedBodySize { + 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", - expectedBodySize, + attempt.BodySize, len(expectedBody), )) } @@ -152,16 +160,16 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { map[string]string{ "Content-Type": generatedTusCreationPartialContentType, "Tus-Resumable": "1.0.0", - "Upload-Offset": expectedOffset, + "Upload-Offset": attempt.Offset, }, )) - patchResponse := generatedResponseFor(patchOperation, responseStatus) + patchResponse := generatedResponseFor(patchOperation, attempt.Status) generatedWriteTusCreationPartialResponseHeaders( responseWriter, patchResponse, map[string]string{ "Tus-Resumable": "1.0.0", - "Upload-Offset": responseOffset, + "Upload-Offset": attempt.Result, }, ) responseWriter.WriteHeader(patchResponse.StatusCode) diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go index 9fedfa0..1d0b2c0 100644 --- a/url_storage_file_contract_generated_test.go +++ b/url_storage_file_contract_generated_test.go @@ -161,7 +161,13 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { if err != nil { t.Fatal(err) } - if generatedTusFileFlowRemoveFingerprintOnSuccess { + shouldRemoveStoredUpload, shouldRemoveStoredUploadErr := generatedTusShouldRemoveStoredUploadOnSuccess( + generatedTusFileFlowRemoveFingerprintOnSuccess, + ) + if shouldRemoveStoredUploadErr != nil { + t.Fatal(shouldRemoveStoredUploadErr) + } + if shouldRemoveStoredUpload { if len(storedUploads) != 0 { t.Fatalf("expected successful file flow to remove stored upload, got %#v", storedUploads) } diff --git a/url_storage_generated.go b/url_storage_generated.go index c416b45..973d1b1 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -36,6 +36,9 @@ const ( generatedTusAbortErrorMessage = "Request was aborted" generatedTusAbortRemoveStoredURLAfterTerm = "after-successful-termination" generatedTusAbortSuppressErrorAfterAbort = true + generatedTusAbortTerminateRequiresRequest = true + generatedTusAbortTerminateRequiresUploadURL = true + generatedTusAbortTerminateRemovesStoredURL = true generatedTusAbortTerminateUpload = "when-requested-and-upload-url-known" generatedTusAbortTerminateUploadContext = "detached-from-aborted-request" generatedTusCreationWithUploadBodySource = "first-upload-chunk" @@ -53,17 +56,29 @@ const ( generatedTusParallelPartialNestedUploads = "disabled" generatedTusParallelPartialURLStorage = "parent-managed" generatedTusParallelCleanupOnPartError = "terminate-created-partials-when-abort-termination-enabled" + generatedTusParallelCleanupCreatedPartials = true + generatedTusParallelCleanupRequiresAbort = true generatedTusParallelCleanupReturnedError = "original-error-unless-cleanup-fails" generatedTusParallelExecutionCancelOnError = true generatedTusParallelExecutionResultOrder = "part-index" generatedTusParallelExecutionSourceRead = "before-worker-start" generatedTusParallelExecutionWorkerStrategy = "one-worker-per-part" generatedTusParallelUploadSplit = "contiguous-floor-size-last-remainder" + generatedTusRetryAttemptIncrementPolicy = "after-retry-scheduled" + generatedTusRetryAttemptResetPolicy = "when-offset-advanced-since-last-retry" generatedTusRetryClientErrorStatus = 400 generatedTusRetryStatusCategoryDivisor = 100 + generatedTusSuccessCloseSourceAfterHook = true + generatedTusSuccessCloseSourceRequiresSrc = true generatedTusSuccessCloseSource = "after-hook-when-source-open" + generatedTusSuccessEmitAfterUploadComplete = true generatedTusSuccessEmit = "after-upload-complete" + generatedTusSuccessRemoveStoredBeforeHook = true + generatedTusSuccessRemoveStoredRequiresOpt = true generatedTusSuccessRemoveStoredURL = "before-hook-when-option-enabled" + generatedTusURLStorageRemoveOnSuccessEnable = true + generatedTusURLStorageRemoveOnSuccess = "when-option-enabled" + generatedTusURLStorageRemoveRequiresOpt = true generatedTusUploadURLAvailableCreate = "after-url-known-before-storage" generatedTusUploadURLAvailableParallel = "not-emitted" generatedTusUploadURLAvailableResume = "after-url-known-before-storage" @@ -693,11 +708,14 @@ func (c *Client) uploadURLStorageSource( return err } if _, err := stream.Write(chunk); err != nil { - effectiveRetryAttempt := generatedTusRetryAttempt( + effectiveRetryAttempt, retryAttemptErr := generatedTusEffectiveRetryAttempt( stream.Upload.RemoteOffset, offsetBeforeRetry, retryAttempt, ) + if retryAttemptErr != nil { + return retryAttemptErr + } if !generatedTusShouldScheduleRetry( options.OnShouldRetry, err, @@ -711,7 +729,10 @@ func (c *Client) uploadURLStorageSource( if delay > 0 { time.Sleep(delay) } - retryAttempt = effectiveRetryAttempt + 1 + retryAttempt, retryAttemptErr = generatedTusNextRetryAttempt(effectiveRetryAttempt) + if retryAttemptErr != nil { + return retryAttemptErr + } offsetBeforeRetry = stream.Upload.RemoteOffset if _, err := stream.Sync(); err != nil { return err @@ -1038,12 +1059,15 @@ func (c *Client) generatedTusCleanupParallelPartialUploads( results []generatedTusParallelPartResult, originalErr error, ) error { - if !options.TerminateUploadOnAbort { - return originalErr - } - if err := generatedTusAssertParallelCleanupPolicySupported(); err != nil { + shouldCleanup, err := generatedTusShouldCleanupParallelPartialUploads( + options.TerminateUploadOnAbort, + ) + if err != nil { return err } + if !shouldCleanup { + return originalErr + } cleanupClient, err := generatedTusClientWithAbortCleanupContext(c) if err != nil { return err @@ -1073,12 +1097,17 @@ func (c *Client) generatedTusHandleURLStorageUploadAbort( if !IsUploadAbortError(err) { return err } - if err := generatedTusAssertAbortPolicySupported(); err != nil { - return err + shouldTerminate, shouldTerminateErr := generatedTusShouldTerminateKnownUploadOnAbort( + options.TerminateUploadOnAbort, + upload, + ) + if shouldTerminateErr != nil { + return shouldTerminateErr } - if !options.TerminateUploadOnAbort || upload == nil || upload.Location == "" { + if !shouldTerminate { return err } + cleanupClient, cleanupClientErr := generatedTusClientWithAbortCleanupContext(c) if cleanupClientErr != nil { return cleanupClientErr @@ -1090,7 +1119,7 @@ func (c *Client) generatedTusHandleURLStorageUploadAbort( }); terminateErr != nil { return terminateErr } - if storageKey != "" { + if generatedTusAbortTerminateRemovesStoredURL && storageKey != "" { if err := options.Storage.RemoveUpload(storageKey); err != nil { return err } @@ -1099,6 +1128,25 @@ func (c *Client) generatedTusHandleURLStorageUploadAbort( return err } +func generatedTusShouldTerminateKnownUploadOnAbort( + terminateUploadOnAbort bool, + upload *Upload, +) (bool, error) { + if err := generatedTusAssertAbortPolicySupported(); err != nil { + return false, err + } + if generatedTusAbortTerminateRequiresRequest && !terminateUploadOnAbort { + return false, nil + } + if generatedTusAbortTerminateRequiresUploadURL && (upload == nil || upload.Location == "") { + return false, nil + } + if upload == nil || upload.Location == "" { + return false, nil + } + return true, nil +} + func generatedTusRetryDelays(retryDelays []time.Duration) []time.Duration { if retryDelays == nil { return append([]time.Duration(nil), generatedTusDefaultRetryDelays...) @@ -1107,12 +1155,36 @@ func generatedTusRetryDelays(retryDelays []time.Duration) []time.Duration { return retryDelays } -func generatedTusRetryAttempt(offset int64, offsetBeforeRetry int64, retryAttempt int) int { - if offset > offsetBeforeRetry { - return 0 +func generatedTusEffectiveRetryAttempt( + offset int64, + offsetBeforeRetry int64, + retryAttempt int, +) (int, error) { + switch generatedTusRetryAttemptResetPolicy { + case "when-offset-advanced-since-last-retry": + if offset > offsetBeforeRetry { + return 0, nil + } + + return retryAttempt, nil + default: + return 0, fmt.Errorf( + "tus: unsupported retry attempt reset policy %s", + generatedTusRetryAttemptResetPolicy, + ) } +} - return retryAttempt +func generatedTusNextRetryAttempt(retryAttempt int) (int, error) { + switch generatedTusRetryAttemptIncrementPolicy { + case "after-retry-scheduled": + return retryAttempt + 1, nil + default: + return 0, fmt.Errorf( + "tus: unsupported retry attempt increment policy %s", + generatedTusRetryAttemptIncrementPolicy, + ) + } } func generatedTusShouldScheduleRetry( @@ -1227,10 +1299,13 @@ func generatedTusEmitChunkCompleteAfterChunkAccepted( } func generatedTusEmitSuccess(input generatedTusSuccessInput) error { - if err := generatedTusAssertEventHookPolicySupported(); err != nil { - return err + shouldRemoveStoredUpload, shouldRemoveStoredUploadErr := generatedTusShouldRemoveStoredUploadOnSuccess( + input.RemoveFingerprintOnSuccess, + ) + if shouldRemoveStoredUploadErr != nil { + return shouldRemoveStoredUploadErr } - if input.RemoveFingerprintOnSuccess && input.StorageKey != "" { + if shouldRemoveStoredUpload && input.StorageKey != "" { if err := input.Storage.RemoveUpload(input.StorageKey); err != nil { return err } @@ -1244,14 +1319,54 @@ func generatedTusEmitSuccess(input generatedTusSuccessInput) error { } } - closer, ok := input.Source.(io.Closer) - if ok { - return closer.Close() + shouldCloseSource, shouldCloseSourceErr := generatedTusShouldCloseSourceOnSuccess(input.Source) + if shouldCloseSourceErr != nil { + return shouldCloseSourceErr + } + if shouldCloseSource { + closer, ok := input.Source.(io.Closer) + if ok { + return closer.Close() + } } return nil } +func generatedTusShouldCloseSourceOnSuccess(source io.ReadSeeker) (bool, error) { + if err := generatedTusAssertEventHookPolicySupported(); err != nil { + return false, err + } + if !generatedTusSuccessCloseSourceAfterHook { + return false, nil + } + if generatedTusSuccessCloseSourceRequiresSrc { + return source != nil, nil + } + return true, nil +} + +func generatedTusShouldRemoveStoredUploadOnSuccess( + removeFingerprintOnSuccess bool, +) (bool, error) { + if err := generatedTusAssertEventHookPolicySupported(); err != nil { + return false, err + } + if err := generatedTusAssertURLStorageCleanupPolicySupported(); err != nil { + return false, err + } + if !generatedTusSuccessRemoveStoredBeforeHook { + return false, nil + } + if !generatedTusURLStorageRemoveOnSuccessEnable { + return false, nil + } + if generatedTusSuccessRemoveStoredRequiresOpt || generatedTusURLStorageRemoveRequiresOpt { + return removeFingerprintOnSuccess, nil + } + return true, nil +} + func generatedTusInt64Pointer(value int64) *int64 { return &value } @@ -1341,6 +1456,17 @@ func generatedTusAssertEventHookPolicySupported() error { return nil } +func generatedTusAssertURLStorageCleanupPolicySupported() error { + if generatedTusURLStorageRemoveOnSuccess != "when-option-enabled" { + return fmt.Errorf( + "tus: unsupported URL storage success cleanup policy %s", + generatedTusURLStorageRemoveOnSuccess, + ) + } + + return nil +} + func generatedTusAssertParallelUploadPolicySupported() error { if generatedTusParallelExecutionWorkerStrategy != "one-worker-per-part" { return fmt.Errorf( @@ -1451,6 +1577,21 @@ func generatedTusAssertParallelCleanupPolicySupported() error { return nil } +func generatedTusShouldCleanupParallelPartialUploads( + terminateUploadOnAbort bool, +) (bool, error) { + if err := generatedTusAssertParallelCleanupPolicySupported(); err != nil { + return false, err + } + if !generatedTusParallelCleanupCreatedPartials { + return false, nil + } + if generatedTusParallelCleanupRequiresAbort { + return terminateUploadOnAbort, nil + } + return true, nil +} + func generatedTusAssertAbortPolicySupported() error { supportedActions := map[string]bool{ "abort-current-request": true, diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 66f880b..9254429 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -23,6 +23,7 @@ const ( generatedTusParallelCleanupContentType = "application/offset+octet-stream" generatedTusParallelCleanupContentTypeHeader = "Content-Type" generatedTusParallelCleanupEndpointPath = "/uploads" + generatedTusParallelCleanupAbortedPatchEvent = "request-abort:3" generatedTusParallelCleanupEventPolicy = "exact" generatedTusParallelCleanupFailurePartIndex = 0 generatedTusParallelCleanupFailureStatus = 500 @@ -38,12 +39,32 @@ 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 generatedTusParallelCleanupPartPatchBodies = []string{"hello", " world"} -var generatedTusParallelCleanupPartPatchOffsets = []string{"0", "0"} -var generatedTusParallelCleanupPartUploadLengths = []string{"5", "6"} -var generatedTusParallelCleanupPartUploadPaths = []string{"/uploads/parallel-cleanup-part-1", "/uploads/parallel-cleanup-part-2"} var generatedTusParallelCleanupPatchGateRequestIndexes = []int{2, 3} -var generatedTusParallelCleanupTerminatePaths = []string{"/uploads/parallel-cleanup-part-1", "/uploads/parallel-cleanup-part-2"} + +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") @@ -100,7 +121,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { map[string]string{ "Tus-Resumable": "1.0.0", "Upload-Concat": "partial", - "Upload-Length": generatedTusParallelCleanupPartUploadLengths[partIndex], + "Upload-Length": generatedTusParallelCleanupParts[partIndex].UploadLength, "Upload-Metadata": encodedPartialMetadata, "X-Tus-Contract": "parallel-cleanup-policy", "X-Tus-Trace": "parallel-cleanup-trace-123", @@ -115,7 +136,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusParallelCleanupPartUploadPaths[partIndex], + "Location": server.URL + generatedTusParallelCleanupParts[partIndex].UploadPath, "Tus-Resumable": "1.0.0", }, ) @@ -145,10 +166,10 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { requestMu.Unlock() body, err := io.ReadAll(request.Body) recordRequestErr(err) - if string(body) != generatedTusParallelCleanupPartPatchBodies[partIndex] { + if string(body) != generatedTusParallelCleanupParts[partIndex].PatchBody { recordRequestErr(fmt.Errorf( "expected cleanup patch body %q, got %q", - generatedTusParallelCleanupPartPatchBodies[partIndex], + generatedTusParallelCleanupParts[partIndex].PatchBody, string(body), )) } @@ -158,7 +179,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { map[string]string{ "Content-Type": generatedTusParallelCleanupContentType, "Tus-Resumable": "1.0.0", - "Upload-Offset": generatedTusParallelCleanupPartPatchOffsets[partIndex], + "Upload-Offset": generatedTusParallelCleanupParts[partIndex].PatchOffset, "X-HTTP-Method-Override": generatedTusParallelCleanupOverrideValue, "X-Tus-Contract": "parallel-cleanup-policy", "X-Tus-Trace": "parallel-cleanup-trace-123", @@ -178,7 +199,7 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { select { case <-request.Context().Done(): requestMu.Lock() - events = append(events, generatedTusParallelCleanupExpectedEvents[0]) + events = append(events, generatedTusParallelCleanupAbortedPatchEvent) requestMu.Unlock() return case <-time.After(2 * time.Second): @@ -249,8 +270,8 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { Headers: generatedTusParallelCleanupHeaders, MetadataForPartialUploads: generatedTusParallelCleanupMetadataForPartialUploads, OverridePatchMethod: true, - ParallelUploads: generatedTusParallelCleanupUploadCount, TerminateUploadOnAbort: true, + ParallelUploads: generatedTusParallelCleanupUploadCount, }) if err == nil { t.Fatal("expected parallel cleanup upload to fail") @@ -295,8 +316,8 @@ func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { } func generatedTusParallelCleanupPartIndexForPath(path string) int { - for index, candidate := range generatedTusParallelCleanupPartUploadPaths { - if path == candidate { + for index, part := range generatedTusParallelCleanupParts { + if path == part.UploadPath { return index } } @@ -305,8 +326,8 @@ func generatedTusParallelCleanupPartIndexForPath(path string) int { } func generatedTusParallelCleanupPartIndexForTerminatePath(path string) int { - for index, candidate := range generatedTusParallelCleanupTerminatePaths { - if path == candidate { + for index, part := range generatedTusParallelCleanupParts { + if path == part.TerminatePath { return index } } @@ -315,8 +336,8 @@ func generatedTusParallelCleanupPartIndexForTerminatePath(path string) int { } func generatedTusParallelCleanupPartIndexForUploadLength(uploadLength string) int { - for index, candidate := range generatedTusParallelCleanupPartUploadLengths { - if uploadLength == candidate { + for index, part := range generatedTusParallelCleanupParts { + if uploadLength == part.UploadLength { return index } } diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index 614261a..338aab3 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -33,13 +33,33 @@ var generatedTusParallelExpectedEvents = []string{"progress:5:11", "chunk-comple var generatedTusParallelFinalAbsentHeaders = []string{"Upload-Length"} var generatedTusParallelMetadata = map[string]string{"foo": "hello"} var generatedTusParallelMetadataForPartialUploads = map[string]string{"test": "world"} -var generatedTusParallelPartPatchAcceptedOffsets = []string{"5", "6"} -var generatedTusParallelPartPatchBodies = []string{"hello", " world"} -var generatedTusParallelPartPatchOffsets = []string{"0", "0"} -var generatedTusParallelPartUploadLengths = []string{"5", "6"} -var generatedTusParallelPartUploadPaths = []string{"/uploads/parallel-part-1", "/uploads/parallel-part-2"} 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") @@ -95,7 +115,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { map[string]string{ "Tus-Resumable": "1.0.0", "Upload-Concat": "partial", - "Upload-Length": generatedTusParallelPartUploadLengths[partIndex], + "Upload-Length": generatedTusParallelParts[partIndex].UploadLength, "Upload-Metadata": encodedPartialMetadata, }, )) @@ -104,7 +124,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusParallelPartUploadPaths[partIndex], + "Location": server.URL + generatedTusParallelParts[partIndex].UploadPath, "Tus-Resumable": "1.0.0", }, ) @@ -167,10 +187,10 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { requestMu.Unlock() body, err := io.ReadAll(request.Body) recordRequestErr(err) - if string(body) != generatedTusParallelPartPatchBodies[partIndex] { + if string(body) != generatedTusParallelParts[partIndex].PatchBody { recordRequestErr(fmt.Errorf( "expected parallel patch body %q, got %q", - generatedTusParallelPartPatchBodies[partIndex], + generatedTusParallelParts[partIndex].PatchBody, string(body), )) } @@ -180,7 +200,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { map[string]string{ "Content-Type": "application/offset+octet-stream", "Tus-Resumable": "1.0.0", - "Upload-Offset": generatedTusParallelPartPatchOffsets[partIndex], + "Upload-Offset": generatedTusParallelParts[partIndex].PatchOffset, }, )) patchResponse := generatedResponseFor(patchOperation, 204) @@ -189,7 +209,7 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { patchResponse, map[string]string{ "Tus-Resumable": "1.0.0", - "Upload-Offset": generatedTusParallelPartPatchAcceptedOffsets[partIndex], + "Upload-Offset": generatedTusParallelParts[partIndex].PatchAcceptedOffset, }, ) responseWriter.WriteHeader(patchResponse.StatusCode) @@ -249,11 +269,11 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { actualCreateIndex := createIndex actualPatchIndex := patchIndex requestMu.Unlock() - if actualCreateIndex != len(generatedTusParallelPartUploadPaths)+1 { - t.Fatalf("expected %d create requests, got %d", len(generatedTusParallelPartUploadPaths)+1, actualCreateIndex) + if actualCreateIndex != len(generatedTusParallelParts)+1 { + t.Fatalf("expected %d create requests, got %d", len(generatedTusParallelParts)+1, actualCreateIndex) } - if actualPatchIndex != len(generatedTusParallelPartUploadPaths) { - t.Fatalf("expected %d patch requests, got %d", len(generatedTusParallelPartUploadPaths), actualPatchIndex) + if actualPatchIndex != len(generatedTusParallelParts) { + t.Fatalf("expected %d patch requests, got %d", len(generatedTusParallelParts), actualPatchIndex) } select { case err := <-requestErrs: @@ -276,9 +296,9 @@ func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { } func generatedTusParallelFinalConcatHeader(serverURL string) string { - locations := make([]string, 0, len(generatedTusParallelPartUploadPaths)) - for _, path := range generatedTusParallelPartUploadPaths { - locations = append(locations, serverURL+path) + locations := make([]string, 0, len(generatedTusParallelParts)) + for _, part := range generatedTusParallelParts { + locations = append(locations, serverURL+part.UploadPath) } return generatedTusParallelFinalConcatPrefix + @@ -286,8 +306,8 @@ func generatedTusParallelFinalConcatHeader(serverURL string) string { } func generatedTusParallelPartIndexForPath(path string) int { - for index, candidate := range generatedTusParallelPartUploadPaths { - if path == candidate { + for index, part := range generatedTusParallelParts { + if path == part.UploadPath { return index } } @@ -296,8 +316,8 @@ func generatedTusParallelPartIndexForPath(path string) int { } func generatedTusParallelPartIndexForUploadLength(uploadLength string) int { - for index, candidate := range generatedTusParallelPartUploadLengths { - if uploadLength == candidate { + for index, part := range generatedTusParallelParts { + if uploadLength == part.UploadLength { return index } } diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index 62ada2a..2dd6ca1 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -19,24 +19,26 @@ import ( ) const ( - generatedTusRetryFlowContent = "hello world" - generatedTusRetryFlowEventPolicy = "exact" - generatedTusRetryFlowFinalPatchAcceptedOffset = "11" - generatedTusRetryFlowFinalPatchBody = " world" - generatedTusRetryFlowFinalPatchOffset = "5" - generatedTusRetryFlowFingerprint = "retryPatchAfterOffsetRecovery-fingerprint" - generatedTusRetryFlowFirstPatchBody = "hello world" - generatedTusRetryFlowFirstPatchOffset = "0" - generatedTusRetryFlowFirstRecoveredLength = "11" - generatedTusRetryFlowFirstRecoveredOffset = "5" - generatedTusRetryFlowSecondPatchBody = " world" - generatedTusRetryFlowSecondPatchOffset = "5" - generatedTusRetryFlowSecondRecoveredLength = "11" - generatedTusRetryFlowSecondRecoveredOffset = "5" - generatedTusRetryFlowUploadLength = "11" - generatedTusRetryFlowUploadPath = "/uploads/retry-contract" + generatedTusRetryFlowContent = "hello world" + 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 @@ -45,6 +47,38 @@ type generatedTusRetryDecision struct { 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{ { @@ -109,55 +143,47 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { ).Repeat(1).Reply(createReply), ) - firstGetResponse := generatedResponseFor(getOperation, 200) - firstGetReply := generatedURLStorageRetryResponseHeaders( - reply.Status(200), - firstGetResponse, - map[string]string{ - "Tus-Resumable": "1.0.0", - "Upload-Length": generatedTusRetryFlowFirstRecoveredLength, - "Upload-Offset": generatedTusRetryFlowFirstRecoveredOffset, - }, - ) - secondGetResponse := generatedResponseFor(getOperation, 200) - secondGetReply := generatedURLStorageRetryResponseHeaders( - reply.Status(200), - secondGetResponse, - map[string]string{ - "Tus-Resumable": "1.0.0", - "Upload-Length": generatedTusRetryFlowSecondRecoveredLength, - "Upload-Offset": generatedTusRetryFlowSecondRecoveredOffset, - }, - ) - finalPatchResponse := generatedResponseFor(patchOperation, 204) - finalPatchReply := generatedURLStorageRetryResponseHeaders( - reply.Status(204), - finalPatchResponse, - map[string]string{ - "Tus-Resumable": "1.0.0", - "Upload-Offset": generatedTusRetryFlowFinalPatchAcceptedOffset, - }, - ) - patchReplies := []struct { + 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 - }{ - { - Body: generatedTusRetryFlowFirstPatchBody, - Offset: generatedTusRetryFlowFirstPatchOffset, - Reply: reply.Status(500), - }, - { - Body: generatedTusRetryFlowSecondPatchBody, - Offset: generatedTusRetryFlowSecondPatchOffset, - Reply: reply.Status(500), - }, - { - Body: generatedTusRetryFlowFinalPatchBody, - Offset: generatedTusRetryFlowFinalPatchOffset, - Reply: finalPatchReply, - }, +}, 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( @@ -196,7 +222,6 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { }), ) - getReplies := []*reply.StdReply{firstGetReply, secondGetReply} getReplyIndex := 0 srvMock.AddMocks( generatedURLStorageRetryRequestHeaders( From f7a43f0415b2795c1d72ce4380b611f2c617bf03 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 01:15:37 +0200 Subject: [PATCH 74/97] Add termination devdock example --- .../main.go | 297 +------------- .../api2-devdock-tus-terminate-upload/main.go | 186 +++++++++ examples/api2devdock/scenario.go | 367 ++++++++++++++++++ 3 files changed, 569 insertions(+), 281 deletions(-) create mode 100644 examples/api2-devdock-tus-terminate-upload/main.go create mode 100644 examples/api2devdock/scenario.go diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.go b/examples/api2-devdock-transloadit-assembly-upload/main.go index c9dab9e..c5d432c 100644 --- a/examples/api2-devdock-transloadit-assembly-upload/main.go +++ b/examples/api2-devdock-transloadit-assembly-upload/main.go @@ -4,278 +4,31 @@ package main import ( "context" - "encoding/json" "fmt" - "math" "net/http" - "net/url" - "os" - "path/filepath" - "strconv" "time" tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" ) -func fail(format string, args ...interface{}) { - panic(fmt.Sprintf(format, args...)) -} - -func loadScenario() (map[string]interface{}, error) { - scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") - if scenarioPath == "" { - scenarioPath = filepath.Join( - "examples", - "api2-devdock-transloadit-assembly-upload", - "api2-scenario.json", - ) - } - - 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 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 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 uploadWithTus( ctx context.Context, scenario map[string]interface{}, createResponse map[string]interface{}, ) (string, error) { - uploadConfig, err := objectValue(scenario["upload"], "upload") + endpointURL, err := api2devdock.TusURL(scenario, createResponse) if err != nil { return "", err } - context := map[string]interface{}{ - "createResponse": createResponse, - "scenario": scenario, - } - endpointValue, err := resolveValue(uploadConfig["tusUrl"], context, "upload.tusUrl") - if err != nil { - return "", err - } - endpointURL, err := url.Parse(scalarString(endpointValue)) + content, err := api2devdock.ScenarioBytes(scenario) if err != nil { return "", err } - content, err := scenarioBytes(scenario) - if err != nil { + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { return "", err } - chunkSize, err := stringValue(uploadConfig["chunkSize"], "upload.chunkSize") - if err != nil { - return "", err - } - if chunkSize != "full-file" { - return "", fmt.Errorf("unsupported chunk size policy %q", chunkSize) - } - metadata, err := uploadMetadata(scenario, createResponse) + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) if err != nil { return "", err } @@ -305,51 +58,33 @@ func uploadWithTus( return upload.Location, nil } -func writeResult(uploadURL string) error { - resultPath := os.Getenv("API2_SDK_EXAMPLE_RESULT") - if resultPath == "" { - return nil - } - - contents, err := json.MarshalIndent( - map[string]string{ - "uploadUrl": uploadURL, - }, - "", - " ", - ) - if err != nil { - return err - } - - return os.WriteFile(resultPath, append(contents, '\n'), 0o644) -} - func main() { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - scenario, err := loadScenario() + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-transloadit-assembly-upload/api2-scenario.json", + ) if err != nil { - fail("load scenario: %v", err) + api2devdock.Fail("load scenario: %v", err) } - createResponse, err := createResponseFromScenario(scenario) + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) if err != nil { - fail("read prepared create response: %v", err) + api2devdock.Fail("read prepared create response: %v", err) } uploadURL, err := uploadWithTus(ctx, scenario, createResponse) if err != nil { - fail("upload: %v", err) + api2devdock.Fail("upload: %v", err) } - if err := writeResult(uploadURL); err != nil { - fail("write result: %v", err) + if err := api2devdock.WriteResult(map[string]interface{}{"uploadUrl": uploadURL}); err != nil { + api2devdock.Fail("write result: %v", err) } - scenarioID, err := stringValue(scenario["scenarioId"], "scenarioId") + scenarioID, err := api2devdock.ScenarioID(scenario) if err != nil { - fail("read scenario id: %v", err) + 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-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/api2devdock/scenario.go b/examples/api2devdock/scenario.go new file mode 100644 index 0000000..3690112 --- /dev/null +++ b/examples/api2devdock/scenario.go @@ -0,0 +1,367 @@ +package api2devdock + +import ( + "encoding/json" + "fmt" + "math" + "net/url" + "os" + "path/filepath" + "strconv" +) + +type TerminationPlan struct { + ExpectedVerificationStatus int + MinimumDeleteRequestCount int + StopAfterAcceptedBytes int + VerificationMethod 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 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 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 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 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) +} From c149ad7263c8572705cc80bfbe8fd9b697f397e8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 01:24:10 +0200 Subject: [PATCH 75/97] Add resume devdock example --- .../api2-devdock-tus-resume-upload/main.go | 245 ++++++++++++++++++ examples/api2devdock/scenario.go | 68 +++++ 2 files changed, 313 insertions(+) create mode 100644 examples/api2-devdock-tus-resume-upload/main.go 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..d590304 --- /dev/null +++ b/examples/api2-devdock-tus-resume-upload/main.go @@ -0,0 +1,245 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "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() + + var firstUploadURL string + options.Context = abortCtx + options.EventHooks = tusgo.UploadEventHooks{ + OnChunkComplete: func(_ int64, bytesAccepted int64, _ *int64) error { + if int(bytesAccepted) < resume.StopAfterAcceptedBytes { + return nil + } + + cancelAbort() + return nil + }, + OnUploadURLAvailable: func() error { + storedUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return err + } + if len(storedUploads) == 0 { + return fmt.Errorf("resume scenario did not store the first upload URL") + } + uploadURL, ok := storedUploads[0]["uploadUrl"].(string) + if !ok || uploadURL == "" { + return fmt.Errorf("resume scenario stored upload is missing uploadUrl") + } + firstUploadURL = uploadURL + 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 firstUploadURL == "" { + return 0, "", fmt.Errorf("resume scenario did not capture the first upload URL") + } + if upload == nil { + return 0, "", fmt.Errorf("resume scenario did not return the aborted upload") + } + + 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 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 + } + previousUploadCount, remainingPreviousUploadCount, uploadURL, err := resumeStoredUpload( + ctx, + scenario, + createResponse, + storage, + content, + ) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "firstAcceptedBytes": firstAcceptedBytes, + "firstUploadUrl": firstUploadURL, + "previousUploadCount": previousUploadCount, + "remainingPreviousUploadCount": remainingPreviousUploadCount, + "uploadUrl": uploadURL, + }, 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index 3690112..b695f50 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -17,6 +17,14 @@ type TerminationPlan struct { VerificationMethod string } +type ResumePlan struct { + ExpectedPreviousUploadCount int + ExpectedRemainingPreviousUploadCount int + Fingerprint string + RemoveFingerprintOnSuccess bool + StopAfterAcceptedBytes int +} + func Fail(format string, args ...interface{}) { panic(fmt.Sprintf(format, args...)) } @@ -67,6 +75,15 @@ func StringValue(value interface{}, label string) (string, error) { return text, 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 { @@ -348,6 +365,57 @@ func Termination(scenario map[string]interface{}) (TerminationPlan, error) { }, 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 ScenarioID(scenario map[string]interface{}) (string, error) { return StringValue(scenario["scenarioId"], "scenarioId") } From 112299de0e68cc7bbceeaeea8535a61287dee90e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 01:30:52 +0200 Subject: [PATCH 76/97] Fix resume devdock URL storage proof --- .../api2-devdock-tus-resume-upload/main.go | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/examples/api2-devdock-tus-resume-upload/main.go b/examples/api2-devdock-tus-resume-upload/main.go index d590304..0ca9a68 100644 --- a/examples/api2-devdock-tus-resume-upload/main.go +++ b/examples/api2-devdock-tus-resume-upload/main.go @@ -69,7 +69,6 @@ func uploadFirstChunkAndAbort( abortCtx, cancelAbort := context.WithCancel(ctx) defer cancelAbort() - var firstUploadURL string options.Context = abortCtx options.EventHooks = tusgo.UploadEventHooks{ OnChunkComplete: func(_ int64, bytesAccepted int64, _ *int64) error { @@ -80,21 +79,6 @@ func uploadFirstChunkAndAbort( cancelAbort() return nil }, - OnUploadURLAvailable: func() error { - storedUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) - if err != nil { - return err - } - if len(storedUploads) == 0 { - return fmt.Errorf("resume scenario did not store the first upload URL") - } - uploadURL, ok := storedUploads[0]["uploadUrl"].(string) - if !ok || uploadURL == "" { - return fmt.Errorf("resume scenario stored upload is missing uploadUrl") - } - firstUploadURL = uploadURL - return nil - }, } client := tusgo.NewClient(http.DefaultClient, endpointURL) @@ -102,12 +86,20 @@ func uploadFirstChunkAndAbort( if !errors.Is(err, context.Canceled) { return 0, "", fmt.Errorf("expected context cancellation, got upload=%#v err=%v", upload, err) } - if firstUploadURL == "" { - return 0, "", fmt.Errorf("resume scenario did not capture the first upload URL") - } 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 } From b1eb7124f364c0193ed5380089bf65b1f89ec887 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 04:45:20 +0200 Subject: [PATCH 77/97] Add Go TUS devdock coverage for creation and retry --- .../main.go | 115 +++++++++ .../main.go | 218 ++++++++++++++++++ examples/api2devdock/scenario.go | 163 +++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 examples/api2-devdock-tus-creation-with-upload/main.go create mode 100644 examples/api2-devdock-tus-retry-offset-recovery/main.go 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-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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index b695f50..260c294 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strconv" + "time" ) type TerminationPlan struct { @@ -25,6 +26,26 @@ type ResumePlan struct { StopAfterAcceptedBytes int } +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 +} + func Fail(format string, args ...interface{}) { panic(fmt.Sprintf(format, args...)) } @@ -93,6 +114,24 @@ func IntValue(value interface{}, label string) (int, error) { 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 ScalarString(value interface{}) string { switch typed := value.(type) { case bool: @@ -416,6 +455,130 @@ func Resume(scenario map[string]interface{}) (ResumePlan, error) { }, 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 ScenarioID(scenario map[string]interface{}) (string, error) { return StringValue(scenario["scenarioId"], "scenarioId") } From 828c82f127a8d4ad50a512d63bad8c112b7041b8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 07:20:21 +0200 Subject: [PATCH 78/97] Add request lifecycle devdock proof --- .../main.go | 199 ++++++++++++++++++ examples/api2devdock/scenario.go | 74 +++++++ 2 files changed, 273 insertions(+) create mode 100644 examples/api2-devdock-tus-request-lifecycle-hooks/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index 260c294..655eef9 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -46,6 +46,13 @@ type RetryOffsetRecoveryPlan struct { RecoveryResponse RetryOffsetRecoveryResponsePlan } +type RequestLifecycleHooksPlan struct { + ExpectedAfterResponseMethods []string + ExpectedAfterResponseStatusCodes []int + ExpectedBeforeRequestMethods []string + IgnoredRequestMethods []string +} + func Fail(format string, args ...interface{}) { panic(fmt.Sprintf(format, args...)) } @@ -132,6 +139,24 @@ func StringArrayValue(value interface{}, label string) ([]string, error) { return strings, 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: @@ -579,6 +604,55 @@ func RetryOffsetRecovery(scenario map[string]interface{}) (RetryOffsetRecoveryPl }, 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 ScenarioID(scenario map[string]interface{}) (string, error) { return StringValue(scenario["scenarioId"], "scenarioId") } From 5f8f15c5ec8c258edb50001206422a978b46279f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 07:47:13 +0200 Subject: [PATCH 79/97] Add API2 upload callback proof --- .../api2-devdock-tus-upload-callbacks/main.go | 194 +++++++++++++++ examples/api2devdock/scenario.go | 223 ++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 examples/api2-devdock-tus-upload-callbacks/main.go 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/scenario.go b/examples/api2devdock/scenario.go index 655eef9..270451b 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "time" ) @@ -53,6 +54,23 @@ type RequestLifecycleHooksPlan struct { 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 +} + func Fail(format string, args ...interface{}) { panic(fmt.Sprintf(format, args...)) } @@ -139,6 +157,24 @@ func StringArrayValue(value interface{}, label string) ([]string, error) { 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 { @@ -653,6 +689,193 @@ func RequestLifecycleHooks(scenario map[string]interface{}) (RequestLifecycleHoo }, 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 ScenarioID(scenario map[string]interface{}) (string, error) { return StringValue(scenario["scenarioId"], "scenarioId") } From f8bf320ecd114eecedeea948797ed3cea9073fdc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 10:56:03 +0200 Subject: [PATCH 80/97] Add API2 custom request headers proof --- .../main.go | 152 ++++++++++++++++++ examples/api2devdock/scenario.go | 22 +++ 2 files changed, 174 insertions(+) create mode 100644 examples/api2-devdock-tus-custom-request-headers/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index 270451b..ef04b27 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -356,6 +356,28 @@ func UploadMetadata( 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 + } + rawHeaders, err := ObjectValue(upload["headers"], "upload.headers") + if err != nil { + return nil, err + } + + headers := map[string]string{} + for name, value := range rawHeaders { + text, err := StringValue(value, "upload.headers."+name) + if err != nil { + return nil, err + } + headers[name] = text + } + + return headers, nil +} + func TusURL( scenario map[string]interface{}, createResponse map[string]interface{}, From 309646e4687617c8f1a9972aba25fe68cf77b026 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 12:19:57 +0200 Subject: [PATCH 81/97] Add API2 request ID headers proof --- .../main.go | 149 ++++++++++++++++++ examples/api2devdock/scenario.go | 18 +++ url_storage_generated.go | 38 ++++- 3 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 examples/api2-devdock-tus-request-id-headers/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index ef04b27..a4b76de 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -378,6 +378,24 @@ func UploadHeaders(scenario map[string]interface{}) (map[string]string, error) { return headers, 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 TusURL( scenario map[string]interface{}, createResponse map[string]interface{}, diff --git a/url_storage_generated.go b/url_storage_generated.go index 973d1b1..de96c85 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -7,6 +7,7 @@ package tusgo import ( "bytes" "context" + cryptoRand "crypto/rand" "encoding/json" "errors" "fmt" @@ -68,6 +69,7 @@ const ( generatedTusRetryAttemptResetPolicy = "when-offset-advanced-since-last-retry" generatedTusRetryClientErrorStatus = 400 generatedTusRetryStatusCategoryDivisor = 100 + generatedTusRequestIDHeaderName = "X-Request-ID" generatedTusSuccessCloseSourceAfterHook = true generatedTusSuccessCloseSourceRequiresSrc = true generatedTusSuccessCloseSource = "after-hook-when-source-open" @@ -147,6 +149,7 @@ type URLStorageUploadOptions struct { Source io.ReadSeeker Fingerprint string Size int64 + AddRequestID bool Headers map[string]string Metadata map[string]string MetadataForPartialUploads map[string]string @@ -166,6 +169,7 @@ type URLStorageFileUploadOptions struct { Context context.Context Storage URLStorage Path string + AddRequestID bool Headers map[string]string Metadata map[string]string MetadataForPartialUploads map[string]string @@ -185,6 +189,7 @@ type FileBackedURLStorageUploadOptions struct { Context context.Context URLStoragePath string Path string + AddRequestID bool Headers map[string]string Metadata map[string]string MetadataForPartialUploads map[string]string @@ -396,6 +401,7 @@ func (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorage Context: options.Context, Storage: NewFileURLStorage(options.URLStoragePath), Path: options.Path, + AddRequestID: options.AddRequestID, Headers: options.Headers, Metadata: options.Metadata, MetadataForPartialUploads: options.MetadataForPartialUploads, @@ -442,6 +448,7 @@ func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) ( Source: file, Fingerprint: fingerprint, Size: info.Size(), + AddRequestID: options.AddRequestID, Headers: options.Headers, Metadata: options.Metadata, MetadataForPartialUploads: options.MetadataForPartialUploads, @@ -813,7 +820,7 @@ func generatedTusClientWithURLStorageRequestPolicy( client *Client, options URLStorageUploadOptions, ) *Client { - if len(options.Headers) == 0 && !options.OverridePatchMethod { + if len(options.Headers) == 0 && !options.OverridePatchMethod && !options.AddRequestID { return client } @@ -828,6 +835,7 @@ func generatedTusClientWithURLStorageRequestPolicy( baseTransport = http.DefaultTransport } resultHTTPClient.Transport = generatedTusURLStorageRequestPolicyTransport{ + AddRequestID: options.AddRequestID, Base: baseTransport, Headers: cloneStringMap(options.Headers), OverridePatchMethod: options.OverridePatchMethod, @@ -849,6 +857,7 @@ func generatedTusClientWithAbortCleanupContext(client *Client) (*Client, error) } type generatedTusURLStorageRequestPolicyTransport struct { + AddRequestID bool Base http.RoundTripper Headers map[string]string OverridePatchMethod bool @@ -858,9 +867,6 @@ func (transport generatedTusURLStorageRequestPolicyTransport) RoundTrip( request *http.Request, ) (*http.Response, error) { cloned := request.Clone(request.Context()) - for key, value := range transport.Headers { - cloned.Header.Set(key, value) - } for _, methodOverride := range generatedTusMethodOverrides { enabled, err := transport.methodOverrideEnabled(methodOverride) if err != nil { @@ -877,10 +883,34 @@ func (transport generatedTusURLStorageRequestPolicyTransport) RoundTrip( ) 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 (transport generatedTusURLStorageRequestPolicyTransport) methodOverrideEnabled( methodOverride generatedTusMethodOverride, ) (bool, error) { From a1b364f0a996e139954d671aa9e13b25530b6fd0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 15:55:58 +0200 Subject: [PATCH 82/97] Add API2 upload body headers proof --- .../main.go | 163 ++++++++++++++++++ examples/api2devdock/scenario.go | 39 ++++- 2 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 examples/api2-devdock-tus-upload-body-headers/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index a4b76de..fc264f6 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -121,6 +121,24 @@ func StringValue(value interface{}, label string) (string, error) { 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 { @@ -361,21 +379,30 @@ func UploadHeaders(scenario map[string]interface{}) (map[string]string, error) { if err != nil { return nil, err } - rawHeaders, err := ObjectValue(upload["headers"], "upload.headers") + + 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 } - headers := map[string]string{} - for name, value := range rawHeaders { - text, err := StringValue(value, "upload.headers."+name) + byMethod := map[string]map[string]string{} + for method, rawHeaders := range rawByMethod { + headers, err := StringMapValue(rawHeaders, "upload.bodyHeadersByMethod."+method) if err != nil { return nil, err } - headers[name] = text + byMethod[method] = headers } - return headers, nil + return byMethod, nil } func UploadAddRequestID(scenario map[string]interface{}) (bool, error) { From f6c53c3ac5599e346974b34913bbf734dfbcb00c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 21:14:58 +0200 Subject: [PATCH 83/97] Add deferred-length TUS devdock proof --- .../main.go | 117 ++++++++++++++++++ examples/api2devdock/scenario.go | 9 ++ 2 files changed, 126 insertions(+) create mode 100644 examples/api2-devdock-tus-deferred-length-upload/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index fc264f6..bd200a4 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -423,6 +423,15 @@ func UploadRequestIDHeaderName(scenario map[string]interface{}) (string, error) 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{}, From 73cf8f4aff1d1fd99e569dd0c9d051c32c5deb19 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 11:12:05 +0200 Subject: [PATCH 84/97] Add API2 file URL storage proof --- .../api2-devdock-tus-resume-upload/main.go | 64 ++++++++++++++++++- examples/api2devdock/scenario.go | 36 +++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/examples/api2-devdock-tus-resume-upload/main.go b/examples/api2-devdock-tus-resume-upload/main.go index 0ca9a68..5513207 100644 --- a/examples/api2-devdock-tus-resume-upload/main.go +++ b/examples/api2-devdock-tus-resume-upload/main.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" tusgo "github.com/bdragon300/tusgo" @@ -160,6 +161,46 @@ func resumeStoredUpload( 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{}, @@ -186,6 +227,10 @@ func uploadWithStoredResume( if err != nil { return nil, err } + uploadKeyPrefixMatched, err := storedUploadKeyPrefixMatched(scenario, storage) + if err != nil { + return nil, err + } previousUploadCount, remainingPreviousUploadCount, uploadURL, err := resumeStoredUpload( ctx, scenario, @@ -196,14 +241,29 @@ func uploadWithStoredResume( if err != nil { return nil, err } + storedUploads, err := storage.FindAllUploads() + if err != nil { + return nil, err + } - return map[string]interface{}{ + result := map[string]interface{}{ "firstAcceptedBytes": firstAcceptedBytes, "firstUploadUrl": firstUploadURL, "previousUploadCount": previousUploadCount, "remainingPreviousUploadCount": remainingPreviousUploadCount, "uploadUrl": uploadURL, - }, nil + } + 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() { diff --git a/examples/api2devdock/scenario.go b/examples/api2devdock/scenario.go index bd200a4..71b69a5 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -27,6 +27,11 @@ type ResumePlan struct { StopAfterAcceptedBytes int } +type URLStorageBackendPlan struct { + ExpectedStoredUploadKeyPrefix string + Kind string +} + type RetryOffsetRecoveryResponsePlan struct { Method string OffsetHeader string @@ -592,6 +597,37 @@ func Resume(scenario map[string]interface{}) (ResumePlan, error) { }, 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 { From c4fae1b0138d5f674591b891ba8a54c3cdfe60fc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 14:26:39 +0200 Subject: [PATCH 85/97] Add API2 abort upload proof --- .../api2-devdock-tus-abort-upload/main.go | 467 ++++++++++++++++++ examples/api2devdock/scenario.go | 235 +++++++++ 2 files changed, 702 insertions(+) create mode 100644 examples/api2-devdock-tus-abort-upload/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index 71b69a5..5375321 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -76,6 +76,11 @@ type UploadCallbacksPlan struct { EventPolicyMatching string } +type TusConformanceServerCapabilitiesPlan struct { + ExtensionNames []string + ProtocolVersions []string +} + func Fail(format string, args ...interface{}) { panic(fmt.Sprintf(format, args...)) } @@ -988,6 +993,236 @@ func hasAllowedUploadCallbackExtraEventPrefix(event string, allowedExtraPrefixes 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 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 + } + kind, err := StringValue(source["kind"], "conformanceScenario.inputSource.kind") + if err != nil { + return nil, err + } + if kind != "blob" { + return nil, fmt.Errorf("unsupported conformance input source kind %q", kind) + } + content, err := StringValue(source["content"], "conformanceScenario.inputSource.content") + if err != nil { + return nil, err + } + + return []byte(content), 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") } From 172a7ec3e9e25a53d8fe174263fa9f13d0492560 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 15:59:26 +0200 Subject: [PATCH 86/97] Add API2 override PATCH proof --- .../main.go | 152 +++++++ examples/api2devdock/conformance_server.go | 382 ++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 examples/api2-devdock-tus-override-patch-method/main.go create mode 100644 examples/api2devdock/conformance_server.go 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/api2devdock/conformance_server.go b/examples/api2devdock/conformance_server.go new file mode 100644 index 0000000..4a19c53 --- /dev/null +++ b/examples/api2devdock/conformance_server.go @@ -0,0 +1,382 @@ +package api2devdock + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" +) + +type TusConformanceObservedRequest struct { + BodySize int + Headers map[string]string + Method string + URL string +} + +type TusConformancePlanServer struct { + endpointOrigin *url.URL + errs []error + mu sync.Mutex + observed []TusConformanceObservedRequest + requests []interface{} + server *httptest.Server +} + +func NewTusConformancePlanServer( + conformanceScenario map[string]interface{}, + endpointOrigin *url.URL, +) (*TusConformancePlanServer, error) { + requests, err := ArrayValue( + conformanceScenario["requests"], + "conformanceScenario.requests", + ) + if err != nil { + return nil, err + } + + conformanceServer := &TusConformancePlanServer{ + endpointOrigin: endpointOrigin, + requests: requests, + } + 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) 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) AssertExhausted() error { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + if len(conformanceServer.observed) != len(conformanceServer.requests) { + return fmt.Errorf( + "expected %d conformance request(s), got %d", + len(conformanceServer.requests), + len(conformanceServer.observed), + ) + } + + 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] + } + + requestBodySizes := make([]int, 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 { + requestBodySizes = append(requestBodySizes, request.BodySize) + requestHeaders = append(requestHeaders, request.Headers) + requestMethods = append(requestMethods, request.Method) + requestURLs = append(requestURLs, request.URL) + } + + return map[string]interface{}{ + "requestBodySizes": requestBodySizes, + "requestCount": len(conformanceServer.observed), + "requestHeaders": requestHeaders, + "requestMethods": requestMethods, + "requestUrls": requestURLs, + }, nil +} + +func (conformanceServer *TusConformancePlanServer) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + requestIndex, requestPlan, err := conformanceServer.nextRequestPlan() + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + + body, err := io.ReadAll(request.Body) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + + observed, err := conformanceServer.observedRequest(requestIndex, requestPlan, request, body) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + conformanceServer.observeRequest(observed) + + if err := conformanceServer.validateRequest(requestIndex, requestPlan, observed); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusBadRequest) + return + } + if err := conformanceServer.writeResponse(responseWriter, requestIndex, requestPlan); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + } +} + +func (conformanceServer *TusConformancePlanServer) 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 := ObjectValue( + conformanceServer.requests[requestIndex], + fmt.Sprintf("conformanceScenario.requests[%d]", requestIndex), + ) + if err != nil { + return 0, nil, err + } + + return requestIndex, requestPlan, nil +} + +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 + } + + requestHeaders := map[string]string{} + for name := range expectedHeaders { + requestHeaders[name] = request.Header.Get(name) + } + + return TusConformanceObservedRequest{ + BodySize: len(body), + 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], + ) + } + + 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) observeRequest( + request TusConformanceObservedRequest, +) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.observed = append(conformanceServer.observed, request) +} + +func (conformanceServer *TusConformancePlanServer) recordErr(err error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.errs = append(conformanceServer.errs, err) +} + +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.LocalURL(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), + ) +} From 6e623713281c5d76d64f89b7ce6a59f720c966cc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 17:06:46 +0200 Subject: [PATCH 87/97] Add API2 relative Location proof --- .../main.go | 125 ++++++++++++++++++ url_storage_generated.go | 35 +++++ 2 files changed, 160 insertions(+) create mode 100644 examples/api2-devdock-tus-relative-location-resolution/main.go 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/url_storage_generated.go b/url_storage_generated.go index de96c85..4ae6ae5 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -15,6 +15,7 @@ import ( "math" "math/rand" "net/http" + "net/url" "os" "path/filepath" "sort" @@ -65,6 +66,7 @@ const ( generatedTusParallelExecutionSourceRead = "before-worker-start" generatedTusParallelExecutionWorkerStrategy = "one-worker-per-part" generatedTusParallelUploadSplit = "contiguous-floor-size-last-remainder" + generatedTusLocationResolutionStrategy = "relative-to-creation-request-url" generatedTusRetryAttemptIncrementPolicy = "after-retry-scheduled" generatedTusRetryAttemptResetPolicy = "when-offset-advanced-since-last-retry" generatedTusRetryClientErrorStatus = 400 @@ -615,6 +617,9 @@ func (c *Client) uploadParallelWithURLStorage( err, ) } + if err := uploadClient.generatedTusResolveCreatedUploadLocation(finalUpload); err != nil { + return finalUpload, err + } if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "parallelFinalUpload"); err != nil { return finalUpload, err } @@ -661,6 +666,10 @@ func (c *Client) uploadParallelPartWithURLStorage( 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 @@ -911,6 +920,26 @@ func generatedTusRequestID() (string, error) { 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 + } + switch generatedTusLocationResolutionStrategy { + case "relative-to-creation-request-url": + locationURL, err := url.Parse(upload.Location) + if err != nil { + return err + } + upload.Location = c.BaseURL.ResolveReference(locationURL).String() + return nil + default: + return fmt.Errorf( + "tus: unsupported location resolution policy %s", + generatedTusLocationResolutionStrategy, + ) + } +} + func (transport generatedTusURLStorageRequestPolicyTransport) methodOverrideEnabled( methodOverride generatedTusMethodOverride, ) (bool, error) { @@ -1749,6 +1778,9 @@ func (c *Client) createUploadForURLStorage( if err != nil { return upload, "", response, err } + if err := c.generatedTusResolveCreatedUploadLocation(upload); err != nil { + return upload, "", response, err + } if options.UploadLengthDeferred { upload.RemoteSize = options.Size } @@ -1802,6 +1834,9 @@ func (c *Client) createUploadWithDataForURLStorage( } upload.RemoteSize = options.Size upload.RemoteOffset = uploadedBytes + if err := c.generatedTusResolveCreatedUploadLocation(upload); err != nil { + return upload, "", response, err + } if err := generatedTusEmitProgressAfterChunkAccepted( options.EventHooks, uploadedBytes, From cd429da154878e31c103a92b67cac1c072ebdd30 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 19:13:30 +0200 Subject: [PATCH 88/97] Add API2 parallel upload proof --- .../main.go | 198 ++++++++++ examples/api2devdock/conformance_server.go | 349 ++++++++++++++++-- examples/api2devdock/scenario.go | 12 + 3 files changed, 538 insertions(+), 21 deletions(-) create mode 100644 examples/api2-devdock-tus-parallel-upload-concat/main.go 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/api2devdock/conformance_server.go b/examples/api2devdock/conformance_server.go index 4a19c53..5fc664e 100644 --- a/examples/api2devdock/conformance_server.go +++ b/examples/api2devdock/conformance_server.go @@ -6,23 +6,38 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "sync" + "time" ) type TusConformanceObservedRequest struct { - BodySize int - Headers map[string]string - Method string - URL string + 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 + nextRequest int + observed []*TusConformanceObservedRequest + observedCount int requests []interface{} server *httptest.Server + sourceContent string } func NewTusConformancePlanServer( @@ -36,10 +51,21 @@ func NewTusConformancePlanServer( 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) @@ -80,6 +106,19 @@ func (conformanceServer *TusConformancePlanServer) LocalURL(canonicalURL string) 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 { @@ -100,15 +139,28 @@ func (conformanceServer *TusConformancePlanServer) CanonicalURL(actualURL string 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 len(conformanceServer.observed) != len(conformanceServer.requests) { + if conformanceServer.observedCount != len(conformanceServer.requests) { return fmt.Errorf( "expected %d conformance request(s), got %d", len(conformanceServer.requests), - len(conformanceServer.observed), + conformanceServer.observedCount, ) } @@ -123,23 +175,43 @@ func (conformanceServer *TusConformancePlanServer) Result() (map[string]interfac 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{}{ - "requestBodySizes": requestBodySizes, - "requestCount": len(conformanceServer.observed), - "requestHeaders": requestHeaders, - "requestMethods": requestMethods, - "requestUrls": requestURLs, + "absentHeaderPresence": absentHeaderPresence, + "requestBodySizes": requestBodySizes, + "requestBodyStarts": requestBodyStarts, + "requestCount": conformanceServer.observedCount, + "requestHeaders": requestHeaders, + "requestMethods": requestMethods, + "requestUrls": requestURLs, }, nil } @@ -167,13 +239,18 @@ func (conformanceServer *TusConformancePlanServer) ServeHTTP( responseWriter.WriteHeader(http.StatusInternalServerError) return } - conformanceServer.observeRequest(observed) + conformanceServer.observeRequest(requestIndex, observed) if err := conformanceServer.validateRequest(requestIndex, requestPlan, observed); err != nil { conformanceServer.recordErr(err) responseWriter.WriteHeader(http.StatusBadRequest) 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) @@ -188,7 +265,8 @@ func (conformanceServer *TusConformancePlanServer) nextRequestPlan() ( conformanceServer.mu.Lock() defer conformanceServer.mu.Unlock() - requestIndex := len(conformanceServer.observed) + requestIndex := conformanceServer.nextRequest + conformanceServer.nextRequest += 1 if requestIndex >= len(conformanceServer.requests) { return 0, nil, fmt.Errorf("unexpected request %d", requestIndex) } @@ -213,17 +291,35 @@ func (conformanceServer *TusConformancePlanServer) observedRequest( 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 { - requestHeaders[name] = request.Header.Get(name) + 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{ - BodySize: len(body), - Headers: requestHeaders, - Method: request.Method, - URL: conformanceServer.endpointOrigin.ResolveReference(request.URL).String(), + AbsentHeaderPresence: absentHeaderPresence, + BodySize: len(body), + BodyStart: bodyStart, + Headers: requestHeaders, + Method: request.Method, + URL: conformanceServer.endpointOrigin.ResolveReference(request.URL).String(), }, nil } @@ -275,6 +371,17 @@ func (conformanceServer *TusConformancePlanServer) validateRequest( 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 { @@ -300,12 +407,28 @@ func (conformanceServer *TusConformancePlanServer) validateRequest( } func (conformanceServer *TusConformancePlanServer) observeRequest( + requestIndex int, request TusConformanceObservedRequest, ) { conformanceServer.mu.Lock() defer conformanceServer.mu.Unlock() - conformanceServer.observed = append(conformanceServer.observed, request) + if requestIndex < 0 || requestIndex >= len(conformanceServer.observed) { + conformanceServer.errs = append( + conformanceServer.errs, + fmt.Errorf("request observation index %d is out of range", requestIndex), + ) + return + } + if conformanceServer.observed[requestIndex] != nil { + conformanceServer.errs = append( + conformanceServer.errs, + fmt.Errorf("request %d was observed more than once", requestIndex), + ) + return + } + conformanceServer.observed[requestIndex] = &request + conformanceServer.observedCount += 1 } func (conformanceServer *TusConformancePlanServer) recordErr(err error) { @@ -315,6 +438,48 @@ func (conformanceServer *TusConformancePlanServer) recordErr(err error) { 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, @@ -335,7 +500,7 @@ func (conformanceServer *TusConformancePlanServer) writeResponse( return err } for name, value := range headers { - localValue, err := conformanceServer.LocalURL(value) + localValue, err := conformanceServer.LocalValue(value) if err != nil { return err } @@ -380,3 +545,145 @@ func conformanceRequestHeaders( 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 index 5375321..3968f31 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -1054,6 +1054,18 @@ func TusConformanceInputBoolOption( 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, From 7b5703a2cbf0dee30217c03ef336d4af164c9ac8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 19:36:34 +0200 Subject: [PATCH 89/97] Add API2 retry state proof --- .../main.go | 271 ++++++++++++++++++ examples/api2devdock/scenario.go | 41 +++ 2 files changed, 312 insertions(+) create mode 100644 examples/api2-devdock-tus-retry-state-transitions/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index 3968f31..766c62b 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -52,6 +52,11 @@ type RetryOffsetRecoveryPlan struct { RecoveryResponse RetryOffsetRecoveryResponsePlan } +type TusConformanceRetryDecision struct { + Decision bool + RetryAttempt int +} + type RequestLifecycleHooksPlan struct { ExpectedAfterResponseMethods []string ExpectedAfterResponseStatusCodes []int @@ -1107,6 +1112,42 @@ func TusConformanceInputSourceBytes( return []byte(content), 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) { From 14c4ecd91acca11c52976f34f33331b189524458 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 19:55:47 +0200 Subject: [PATCH 90/97] Match conformance requests by facts --- examples/api2devdock/conformance_server.go | 91 +++++++++------------- 1 file changed, 37 insertions(+), 54 deletions(-) diff --git a/examples/api2devdock/conformance_server.go b/examples/api2devdock/conformance_server.go index 5fc664e..a477888 100644 --- a/examples/api2devdock/conformance_server.go +++ b/examples/api2devdock/conformance_server.go @@ -32,7 +32,6 @@ type TusConformancePlanServer struct { errs []error gates []tusConformanceRequestGate mu sync.Mutex - nextRequest int observed []*TusConformanceObservedRequest observedCount int requests []interface{} @@ -219,13 +218,6 @@ func (conformanceServer *TusConformancePlanServer) ServeHTTP( responseWriter http.ResponseWriter, request *http.Request, ) { - requestIndex, requestPlan, err := conformanceServer.nextRequestPlan() - if err != nil { - conformanceServer.recordErr(err) - responseWriter.WriteHeader(http.StatusInternalServerError) - return - } - body, err := io.ReadAll(request.Body) if err != nil { conformanceServer.recordErr(err) @@ -233,19 +225,12 @@ func (conformanceServer *TusConformancePlanServer) ServeHTTP( return } - observed, err := conformanceServer.observedRequest(requestIndex, requestPlan, request, body) + requestIndex, requestPlan, err := conformanceServer.observeMatchingRequest(request, body) if err != nil { conformanceServer.recordErr(err) responseWriter.WriteHeader(http.StatusInternalServerError) return } - conformanceServer.observeRequest(requestIndex, observed) - - if err := conformanceServer.validateRequest(requestIndex, requestPlan, observed); err != nil { - conformanceServer.recordErr(err) - responseWriter.WriteHeader(http.StatusBadRequest) - return - } if err := conformanceServer.waitForRequestGate(requestIndex); err != nil { conformanceServer.recordErr(err) responseWriter.WriteHeader(http.StatusInternalServerError) @@ -257,7 +242,10 @@ func (conformanceServer *TusConformancePlanServer) ServeHTTP( } } -func (conformanceServer *TusConformancePlanServer) nextRequestPlan() ( +func (conformanceServer *TusConformancePlanServer) observeMatchingRequest( + request *http.Request, + body []byte, +) ( int, map[string]interface{}, error, @@ -265,20 +253,40 @@ func (conformanceServer *TusConformancePlanServer) nextRequestPlan() ( conformanceServer.mu.Lock() defer conformanceServer.mu.Unlock() - requestIndex := conformanceServer.nextRequest - conformanceServer.nextRequest += 1 - if requestIndex >= len(conformanceServer.requests) { - return 0, nil, fmt.Errorf("unexpected request %d", requestIndex) - } - requestPlan, err := ObjectValue( - conformanceServer.requests[requestIndex], - fmt.Sprintf("conformanceScenario.requests[%d]", requestIndex), - ) - if err != nil { - return 0, nil, err + 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 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( @@ -406,31 +414,6 @@ func (conformanceServer *TusConformancePlanServer) validateRequest( return nil } -func (conformanceServer *TusConformancePlanServer) observeRequest( - requestIndex int, - request TusConformanceObservedRequest, -) { - conformanceServer.mu.Lock() - defer conformanceServer.mu.Unlock() - - if requestIndex < 0 || requestIndex >= len(conformanceServer.observed) { - conformanceServer.errs = append( - conformanceServer.errs, - fmt.Errorf("request observation index %d is out of range", requestIndex), - ) - return - } - if conformanceServer.observed[requestIndex] != nil { - conformanceServer.errs = append( - conformanceServer.errs, - fmt.Errorf("request %d was observed more than once", requestIndex), - ) - return - } - conformanceServer.observed[requestIndex] = &request - conformanceServer.observedCount += 1 -} - func (conformanceServer *TusConformancePlanServer) recordErr(err error) { conformanceServer.mu.Lock() defer conformanceServer.mu.Unlock() From 3370dc3555efe560f05c605220c38fcdcf336217 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 21:09:11 +0200 Subject: [PATCH 91/97] Add API2 start validation proof --- .../main.go | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 examples/api2-devdock-tus-start-option-validation/main.go 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) +} From 2321ad3ba1b4deea7b45eec6a6195a3a9a2f7acc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 23:12:11 +0200 Subject: [PATCH 92/97] Add API2 protocol version proof --- client.go | 12 +- .../main.go | 164 ++++++++++++++++++ protocol_generated.go | 79 +++++++++ stream.go | 10 +- 4 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 examples/api2-devdock-tus-protocol-version-selection/main.go diff --git a/client.go b/client.go index 0f751d0..22c9145 100644 --- a/client.go +++ b/client.go @@ -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" } @@ -430,11 +433,16 @@ 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 { - for headerName := range defaultProtocolRequestHeaders { + 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, c.ProtocolVersion) + req.Header.Set(headerName, value) } } if ctx != nil { 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/protocol_generated.go b/protocol_generated.go index a832fce..7ffe24f 100644 --- a/protocol_generated.go +++ b/protocol_generated.go @@ -7,11 +7,51 @@ 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 { @@ -20,6 +60,45 @@ func copyDefaultProtocolHeaders(headers map[string]string) map[string]string { 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) 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)) From 6c42eaf2a768df57b68c0fa8e3b369f6b031f011 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 01:34:59 +0200 Subject: [PATCH 93/97] Add Go node path input source proof --- .../main.go | 237 ++++++++++++++++++ examples/api2devdock/scenario.go | 48 +++- 2 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 examples/api2-devdock-tus-node-path-input-source/main.go 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/api2devdock/scenario.go b/examples/api2devdock/scenario.go index 766c62b..7a8ff7f 100644 --- a/examples/api2devdock/scenario.go +++ b/examples/api2devdock/scenario.go @@ -1097,19 +1097,55 @@ func TusConformanceInputSourceBytes( if err != nil { return nil, err } - kind, err := StringValue(source["kind"], "conformanceScenario.inputSource.kind") + content, err := StringValue(source["content"], "conformanceScenario.inputSource.content") if err != nil { return nil, err } - if kind != "blob" { - return nil, fmt.Errorf("unsupported conformance input source kind %q", kind) + + return []byte(content), nil +} + +func TusConformanceInputSourceKind(conformanceScenario map[string]interface{}) (string, error) { + source, err := ObjectValue( + conformanceScenario["inputSource"], + "conformanceScenario.inputSource", + ) + if err != nil { + return "", err } - content, err := StringValue(source["content"], "conformanceScenario.inputSource.content") + + 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 nil, err + return false, err } - return []byte(content), nil + 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( From 009fbb93aa9018c139ec610bb38d6eaefa21afb3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 9 Jun 2026 02:33:36 +0200 Subject: [PATCH 94/97] Add Go detailed error proof --- .../api2-devdock-tus-detailed-error/main.go | 307 ++++++++++++++++++ url_storage_generated.go | 269 ++++++++++++++- 2 files changed, 560 insertions(+), 16 deletions(-) create mode 100644 examples/api2-devdock-tus-detailed-error/main.go 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/url_storage_generated.go b/url_storage_generated.go index 4ae6ae5..bce3dec 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -43,6 +43,13 @@ const ( generatedTusAbortTerminateRemovesStoredURL = true generatedTusAbortTerminateUpload = "when-requested-and-upload-url-known" generatedTusAbortTerminateUploadContext = "detached-from-aborted-request" + 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" generatedTusCreationWithUploadBodySource = "first-upload-chunk" generatedTusCreationWithUploadCompletion = "continue-with-patch-when-offset-less-than-size" generatedTusCreationWithUploadExtension = "creation-with-upload" @@ -489,7 +496,10 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, if err := generatedTusValidateURLStorageUploadOptions(options, parallelUploads); err != nil { return nil, err } - uploadClient = generatedTusClientWithURLStorageRequestPolicy(uploadClient, options) + uploadClient, detailedErrorRecorder := generatedTusClientWithURLStorageRequestPolicy( + uploadClient, + options, + ) if parallelUploads > 1 { return c.uploadParallelWithURLStorage(options, uploadClient, parallelUploads) } @@ -500,7 +510,10 @@ func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, } var lastResponse *http.Response if upload == nil { - upload, storageKey, lastResponse, err = uploadClient.createUploadForURLStorage(options) + upload, storageKey, lastResponse, err = uploadClient.createUploadForURLStorage( + options, + detailedErrorRecorder, + ) if err != nil { return upload, err } @@ -828,11 +841,7 @@ func generatedTusClientWithUploadContext(client *Client, ctx context.Context) (* func generatedTusClientWithURLStorageRequestPolicy( client *Client, options URLStorageUploadOptions, -) *Client { - if len(options.Headers) == 0 && !options.OverridePatchMethod && !options.AddRequestID { - return client - } - +) (*Client, *generatedTusDetailedErrorRecorder) { result := *client httpClient := http.DefaultClient if client.client != nil { @@ -843,15 +852,22 @@ func generatedTusClientWithURLStorageRequestPolicy( if baseTransport == nil { baseTransport = http.DefaultTransport } - resultHTTPClient.Transport = generatedTusURLStorageRequestPolicyTransport{ - AddRequestID: options.AddRequestID, - Base: baseTransport, - Headers: cloneStringMap(options.Headers), - OverridePatchMethod: options.OverridePatchMethod, + 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 + return &result, detailedErrorRecorder } func generatedTusClientWithAbortCleanupContext(client *Client) (*Client, error) { @@ -872,6 +888,219 @@ type generatedTusURLStorageRequestPolicyTransport struct { 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) { @@ -1761,9 +1990,10 @@ func (c *Client) resumeUploadFromURLStorage( func (c *Client) createUploadForURLStorage( options URLStorageUploadOptions, + detailedErrorRecorder *generatedTusDetailedErrorRecorder, ) (*Upload, string, *http.Response, error) { if options.UploadDataDuringCreation { - return c.createUploadWithDataForURLStorage(options) + return c.createUploadWithDataForURLStorage(options, detailedErrorRecorder) } upload := &Upload{} @@ -1776,7 +2006,10 @@ func (c *Client) createUploadForURLStorage( } response, err := c.CreateUpload(upload, remoteSize, false, options.Metadata) if err != nil { - return upload, "", response, err + return upload, "", response, generatedTusCreateUploadDetailedError( + detailedErrorRecorder, + err, + ) } if err := c.generatedTusResolveCreatedUploadLocation(upload); err != nil { return upload, "", response, err @@ -1801,6 +2034,7 @@ func (c *Client) createUploadForURLStorage( func (c *Client) createUploadWithDataForURLStorage( options URLStorageUploadOptions, + detailedErrorRecorder *generatedTusDetailedErrorRecorder, ) (*Upload, string, *http.Response, error) { if err := generatedTusAssertCreationWithUploadPolicySupported(); err != nil { return nil, "", nil, err @@ -1830,7 +2064,10 @@ func (c *Client) createUploadWithDataForURLStorage( options.Metadata, ) if err != nil { - return upload, "", response, err + return upload, "", response, generatedTusCreateUploadDetailedError( + detailedErrorRecorder, + err, + ) } upload.RemoteSize = options.Size upload.RemoteOffset = uploadedBytes From f2a16802b269953776ef3a2721e62722bcfbf62a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 13 Jun 2026 02:54:10 +0200 Subject: [PATCH 95/97] Make repo gofmt-clean Generated files now come from gofmt-clean emitters (column alignment in const blocks, struct types, and composite literals; one struct-make closing-brace indent). The handwritten checksum/hash_test.go loses its trailing blank line. Co-Authored-By: Claude Fable 5 --- checksum/hash_test.go | 1 - protocol_generated.go | 32 ++++++++-------- request_lifecycle_contract_generated_test.go | 2 +- termination_retry_contract_generated_test.go | 16 ++++---- url_storage_abort_contract_generated_test.go | 8 ++-- ...ort_termination_contract_generated_test.go | 28 +++++++------- ...ion_with_upload_contract_generated_test.go | 2 +- url_storage_generated.go | 38 +++++++++---------- ...arallel_cleanup_contract_generated_test.go | 16 ++++---- ...torage_parallel_contract_generated_test.go | 24 ++++++------ url_storage_retry_contract_generated_test.go | 2 +- 11 files changed, 84 insertions(+), 85 deletions(-) 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/protocol_generated.go b/protocol_generated.go index 7ffe24f..4f2577e 100644 --- a/protocol_generated.go +++ b/protocol_generated.go @@ -11,7 +11,7 @@ const ( // DefaultClientProtocol is the generated client protocol mode used by default. DefaultClientProtocol = "tus-v1" - ProtocolTusV1 = "tus-v1" + ProtocolTusV1 = "tus-v1" ProtocolIetfDraft03 = "ietf-draft-03" ProtocolIetfDraft05 = "ietf-draft-05" ) @@ -20,32 +20,32 @@ 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 + 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", + 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: "", + 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", + RequestHeaders: map[string]string{"Upload-Draft-Interop-Version": "6"}, + ResponseHeaders: map[string]string{}, + UploadBodyContentType: "application/partial-upload", UploadCompleteHeaderName: "Upload-Complete", UploadCompleteCompleteValue: "?1", UploadCompleteIncompleteValue: "?0", diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go index e1bf634..b793df6 100644 --- a/request_lifecycle_contract_generated_test.go +++ b/request_lifecycle_contract_generated_test.go @@ -16,7 +16,7 @@ import ( ) const ( - generatedTusRequestLifecycleEventPolicy = "exact" + generatedTusRequestLifecycleEventPolicy = "exact" generatedTusRequestLifecycleUploadLength = "11" generatedTusRequestLifecycleUploadOffset = "11" generatedTusRequestLifecycleUploadPath = "/uploads/request-hooks-contract" diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index 173eb54..b9d52b6 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -20,14 +20,14 @@ import ( const ( generatedTusTerminateFlowChunkCompleteActionKind = "abort-upload" - generatedTusTerminateFlowContent = "hello world" - generatedTusTerminateFlowEventPolicy = "exact" - generatedTusTerminateFlowFinalStatus = 204 - generatedTusTerminateFlowPatchAcceptedOffset = "5" - generatedTusTerminateFlowPatchBody = "hello" - generatedTusTerminateFlowPatchOffset = "0" - generatedTusTerminateFlowUploadLength = "11" - generatedTusTerminateFlowUploadPath = "/uploads/terminate-contract" + generatedTusTerminateFlowContent = "hello world" + generatedTusTerminateFlowEventPolicy = "exact" + generatedTusTerminateFlowFinalStatus = 204 + generatedTusTerminateFlowPatchAcceptedOffset = "5" + generatedTusTerminateFlowPatchBody = "hello" + generatedTusTerminateFlowPatchOffset = "0" + generatedTusTerminateFlowUploadLength = "11" + generatedTusTerminateFlowUploadPath = "/uploads/terminate-contract" ) type generatedTusTerminateRetryDecision struct { diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go index 7395d6a..780ba5d 100644 --- a/url_storage_abort_contract_generated_test.go +++ b/url_storage_abort_contract_generated_test.go @@ -18,10 +18,10 @@ import ( const ( generatedTusAbortCancelRequestIndex = 0 - generatedTusAbortEventPolicy = "exact" - generatedTusAbortContent = "hello world" - generatedTusAbortEndpointPath = "/uploads" - generatedTusAbortUploadLength = "11" + generatedTusAbortEventPolicy = "exact" + generatedTusAbortContent = "hello world" + generatedTusAbortEndpointPath = "/uploads" + generatedTusAbortUploadLength = "11" ) var generatedTusAbortExtraEventPrefixes = []string{} diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go index 483ec17..bfa1861 100644 --- a/url_storage_abort_termination_contract_generated_test.go +++ b/url_storage_abort_termination_contract_generated_test.go @@ -19,20 +19,20 @@ import ( 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" + 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"} diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index 2693928..2e31533 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -86,7 +86,7 @@ func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { responseWriter, createResponse, map[string]string{ - "Location": server.URL + generatedTusCreationWithUploadPath, + "Location": server.URL + generatedTusCreationWithUploadPath, "Tus-Resumable": "1.0.0", "Upload-Offset": generatedTusCreationWithUploadOffset, }, diff --git a/url_storage_generated.go b/url_storage_generated.go index bce3dec..16fe428 100644 --- a/url_storage_generated.go +++ b/url_storage_generated.go @@ -74,11 +74,11 @@ const ( generatedTusParallelExecutionWorkerStrategy = "one-worker-per-part" generatedTusParallelUploadSplit = "contiguous-floor-size-last-remainder" generatedTusLocationResolutionStrategy = "relative-to-creation-request-url" - generatedTusRetryAttemptIncrementPolicy = "after-retry-scheduled" - generatedTusRetryAttemptResetPolicy = "when-offset-advanced-since-last-retry" + generatedTusRetryAttemptIncrementPolicy = "after-retry-scheduled" + generatedTusRetryAttemptResetPolicy = "when-offset-advanced-since-last-retry" generatedTusRetryClientErrorStatus = 400 generatedTusRetryStatusCategoryDivisor = 100 - generatedTusRequestIDHeaderName = "X-Request-ID" + generatedTusRequestIDHeaderName = "X-Request-ID" generatedTusSuccessCloseSourceAfterHook = true generatedTusSuccessCloseSourceRequiresSrc = true generatedTusSuccessCloseSource = "after-hook-when-source-open" @@ -111,12 +111,12 @@ type generatedTusMethodOverride struct { var generatedTusMethodOverrides = []generatedTusMethodOverride{ { - HeaderName: "X-HTTP-Method-Override", - HeaderValue: "PATCH", - InputFlag: "overridePatchMethod", - Method: "POST", - OperationID: "patchTusUpload", - SourceMethod: "PATCH", + HeaderName: "X-HTTP-Method-Override", + HeaderValue: "PATCH", + InputFlag: "overridePatchMethod", + Method: "POST", + OperationID: "patchTusUpload", + SourceMethod: "PATCH", }, } var generatedTusNodeFileFingerprintFields = []string{"prefix", "absolutePath", "size", "mtimeMs", "endpoint"} @@ -890,11 +890,11 @@ type generatedTusURLStorageRequestPolicyTransport struct { // 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 + CausingError error + Err error + Message string + OriginalRequest *http.Request + OriginalResponse *http.Response OriginalResponseBody string } @@ -1092,11 +1092,11 @@ func generatedTusCreateUploadDetailedError( } return &DetailedError{ - CausingError: snapshot.Err, - Err: err, - Message: generatedTusDetailedErrorMessage(baseMessage, snapshot), - OriginalRequest: snapshot.Request, - OriginalResponse: snapshot.Response, + CausingError: snapshot.Err, + Err: err, + Message: generatedTusDetailedErrorMessage(baseMessage, snapshot), + OriginalRequest: snapshot.Request, + OriginalResponse: snapshot.Response, OriginalResponseBody: snapshot.ResponseBody, } } diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go index 9254429..cd30d4f 100644 --- a/url_storage_parallel_cleanup_contract_generated_test.go +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -51,17 +51,17 @@ type generatedTusParallelCleanupPartFixture struct { var generatedTusParallelCleanupParts = []generatedTusParallelCleanupPartFixture{ { - UploadLength: "5", - UploadPath: "/uploads/parallel-cleanup-part-1", - PatchBody: "hello", - PatchOffset: "0", + 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", + UploadLength: "6", + UploadPath: "/uploads/parallel-cleanup-part-2", + PatchBody: " world", + PatchOffset: "0", TerminatePath: "/uploads/parallel-cleanup-part-2", }, } diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go index 338aab3..0cae7ea 100644 --- a/url_storage_parallel_contract_generated_test.go +++ b/url_storage_parallel_contract_generated_test.go @@ -36,26 +36,26 @@ var generatedTusParallelMetadataForPartialUploads = map[string]string{"test": "w var generatedTusParallelPatchGateRequestIndexes = []int{2, 3} type generatedTusParallelPartFixture struct { - UploadLength string - UploadPath string - PatchBody string - PatchOffset string + UploadLength string + UploadPath string + PatchBody string + PatchOffset string PatchAcceptedOffset string } var generatedTusParallelParts = []generatedTusParallelPartFixture{ { - UploadLength: "5", - UploadPath: "/uploads/parallel-part-1", - PatchBody: "hello", - PatchOffset: "0", + UploadLength: "5", + UploadPath: "/uploads/parallel-part-1", + PatchBody: "hello", + PatchOffset: "0", PatchAcceptedOffset: "5", }, { - UploadLength: "6", - UploadPath: "/uploads/parallel-part-2", - PatchBody: " world", - PatchOffset: "0", + UploadLength: "6", + UploadPath: "/uploads/parallel-part-2", + PatchBody: " world", + PatchOffset: "0", PatchAcceptedOffset: "6", }, } diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index 2dd6ca1..1f917fa 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -161,7 +161,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { Body string Offset string Reply *reply.StdReply -}, 0, len(generatedTusRetryFlowPatchAttempts)) + }, 0, len(generatedTusRetryFlowPatchAttempts)) for _, attempt := range generatedTusRetryFlowPatchAttempts { patchReply := reply.Status(attempt.Status) if attempt.AcceptedOffset != "" { From ff0526bc07782b965d337a5f7798d6d72fa54b39 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 13 Jun 2026 10:23:59 +0200 Subject: [PATCH 96/97] Use generated Go TUS endpoint paths --- request_lifecycle_contract_generated_test.go | 3 ++- termination_retry_contract_generated_test.go | 5 +++-- url_storage_create_contract_generated_test.go | 5 +++-- url_storage_event_hooks_contract_generated_test.go | 5 +++-- url_storage_file_contract_generated_test.go | 5 +++-- url_storage_resume_contract_generated_test.go | 3 ++- url_storage_retry_contract_generated_test.go | 5 +++-- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go index b793df6..ad780e5 100644 --- a/request_lifecycle_contract_generated_test.go +++ b/request_lifecycle_contract_generated_test.go @@ -16,6 +16,7 @@ import ( ) const ( + generatedTusRequestLifecycleEndpointPath = "/uploads" generatedTusRequestLifecycleEventPolicy = "exact" generatedTusRequestLifecycleUploadLength = "11" generatedTusRequestLifecycleUploadOffset = "11" @@ -35,7 +36,7 @@ func TestGeneratedRequestLifecycleHooks(t *testing.T) { srvMock.AssertCalled(t) }() - baseURL, err := url.Parse(srvMock.URL() + "/uploads") + baseURL, err := url.Parse(srvMock.URL() + generatedTusRequestLifecycleEndpointPath) if err != nil { t.Fatal(err) } diff --git a/termination_retry_contract_generated_test.go b/termination_retry_contract_generated_test.go index b9d52b6..520afab 100644 --- a/termination_retry_contract_generated_test.go +++ b/termination_retry_contract_generated_test.go @@ -21,6 +21,7 @@ import ( const ( generatedTusTerminateFlowChunkCompleteActionKind = "abort-upload" generatedTusTerminateFlowContent = "hello world" + generatedTusTerminateFlowEndpointPath = "/uploads" generatedTusTerminateFlowEventPolicy = "exact" generatedTusTerminateFlowFinalStatus = 204 generatedTusTerminateFlowPatchAcceptedOffset = "5" @@ -79,7 +80,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { srvMock.AssertCalled(t) }() - baseURL, err := url.Parse(srvMock.URL() + "/uploads") + baseURL, err := url.Parse(srvMock.URL() + generatedTusTerminateFlowEndpointPath) if err != nil { t.Fatal(err) } @@ -109,7 +110,7 @@ func TestGeneratedTerminationRetryFlow(t *testing.T) { srvMock.AddMocks( generatedTerminationRetryRequestHeaders( mocha.Request(). - URL(expect.URLPath("/uploads")). + URL(expect.URLPath(generatedTusTerminateFlowEndpointPath)). Method(createOperation.Method), createOperation, map[string]string{ diff --git a/url_storage_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go index 9fe056c..4e02344 100644 --- a/url_storage_create_contract_generated_test.go +++ b/url_storage_create_contract_generated_test.go @@ -18,6 +18,7 @@ import ( const ( generatedTusCreateFlowContent = "hello world" generatedTusCreateFlowCreatedUploadPath = "/uploads/generated-contract" + generatedTusCreateFlowEndpointPath = "/uploads" generatedTusCreateFlowFingerprint = "contract-single-fingerprint" generatedTusCreateFlowPatchAcceptedOffset = "11" generatedTusCreateFlowPatchBody = "hello world" @@ -38,7 +39,7 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { srvMock.AssertCalled(t) }() - baseURL, err := url.Parse(srvMock.URL() + "/uploads") + baseURL, err := url.Parse(srvMock.URL() + generatedTusCreateFlowEndpointPath) if err != nil { t.Fatal(err) } @@ -70,7 +71,7 @@ func TestGeneratedURLStorageCreateFlow(t *testing.T) { srvMock.AddMocks( generatedURLStorageCreateRequestHeaders( mocha.Request(). - URL(expect.URLPath("/uploads")). + URL(expect.URLPath(generatedTusCreateFlowEndpointPath)). Method(createOperation.Method), createOperation, map[string]string{ diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go index fed84d7..5afdcb1 100644 --- a/url_storage_event_hooks_contract_generated_test.go +++ b/url_storage_event_hooks_contract_generated_test.go @@ -19,6 +19,7 @@ import ( const ( generatedTusEventHooksContent = "hello world" generatedTusEventHooksCreatedUploadPath = "/uploads/generated-contract" + generatedTusEventHooksEndpointPath = "/uploads" generatedTusEventHooksEventPolicy = "exact-except-allowed-extra-events" generatedTusEventHooksFingerprint = "contract-single-fingerprint" generatedTusEventHooksPatchAcceptedOffset = "11" @@ -41,7 +42,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { srvMock.AssertCalled(t) }() - baseURL, err := url.Parse(srvMock.URL() + "/uploads") + baseURL, err := url.Parse(srvMock.URL() + generatedTusEventHooksEndpointPath) if err != nil { t.Fatal(err) } @@ -73,7 +74,7 @@ func TestGeneratedURLStorageEventHooks(t *testing.T) { srvMock.AddMocks( generatedURLStorageEventHooksRequestHeaders( mocha.Request(). - URL(expect.URLPath("/uploads")). + URL(expect.URLPath(generatedTusEventHooksEndpointPath)). Method(createOperation.Method), createOperation, map[string]string{ diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go index 1d0b2c0..361deee 100644 --- a/url_storage_file_contract_generated_test.go +++ b/url_storage_file_contract_generated_test.go @@ -20,6 +20,7 @@ import ( 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 @@ -55,7 +56,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { srvMock.AssertCalled(t) }() - baseURL, err := url.Parse(srvMock.URL() + "/uploads") + baseURL, err := url.Parse(srvMock.URL() + generatedTusFileFlowEndpointPath) if err != nil { t.Fatal(err) } @@ -106,7 +107,7 @@ func TestGeneratedURLStorageFileFlow(t *testing.T) { srvMock.AddMocks( generatedURLStorageFileRequestHeaders( mocha.Request(). - URL(expect.URLPath("/uploads")). + URL(expect.URLPath(generatedTusFileFlowEndpointPath)). Method(createOperation.Method), createOperation, map[string]string{ diff --git a/url_storage_resume_contract_generated_test.go b/url_storage_resume_contract_generated_test.go index 9c07c58..5444491 100644 --- a/url_storage_resume_contract_generated_test.go +++ b/url_storage_resume_contract_generated_test.go @@ -17,6 +17,7 @@ import ( ) const ( + generatedTusResumeFlowEndpointPath = "/uploads" generatedTusResumeFlowContent = "hello world" generatedTusResumeFlowFingerprint = "contract-resume-fingerprint" generatedTusResumeFlowPatchAcceptedOffset = "11" @@ -38,7 +39,7 @@ func TestGeneratedURLStorageResumeFlow(t *testing.T) { srvMock.AssertCalled(t) }() - baseURL, err := url.Parse(srvMock.URL() + "/uploads") + baseURL, err := url.Parse(srvMock.URL() + generatedTusResumeFlowEndpointPath) if err != nil { t.Fatal(err) } diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go index 1f917fa..c6c8c1f 100644 --- a/url_storage_retry_contract_generated_test.go +++ b/url_storage_retry_contract_generated_test.go @@ -20,6 +20,7 @@ import ( const ( generatedTusRetryFlowContent = "hello world" + generatedTusRetryFlowEndpointPath = "/uploads" generatedTusRetryFlowEventPolicy = "exact" generatedTusRetryFlowFingerprint = "retryPatchAfterOffsetRecovery-fingerprint" generatedTusRetryFlowUploadLength = "11" @@ -101,7 +102,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { srvMock.AssertCalled(t) }() - baseURL, err := url.Parse(srvMock.URL() + "/uploads") + baseURL, err := url.Parse(srvMock.URL() + generatedTusRetryFlowEndpointPath) if err != nil { t.Fatal(err) } @@ -132,7 +133,7 @@ func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { srvMock.AddMocks( generatedURLStorageRetryRequestHeaders( mocha.Request(). - URL(expect.URLPath("/uploads")). + URL(expect.URLPath(generatedTusRetryFlowEndpointPath)). Method(createOperation.Method), createOperation, map[string]string{ From cde1474c4edc3efa5da456108cb9d9af6fb31ed4 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sat, 13 Jun 2026 10:37:38 +0200 Subject: [PATCH 97/97] Use generated Go TUS request counts --- ...e_creation_with_upload_contract_generated_test.go | 5 +++-- ...on_with_upload_partial_contract_generated_test.go | 5 +++-- ...storage_custom_headers_contract_generated_test.go | 5 +++-- ...torage_deferred_length_contract_generated_test.go | 12 ++++++++++-- ..._override_patch_method_contract_generated_test.go | 5 +++-- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go index 2e31533..f48fee2 100644 --- a/url_storage_creation_with_upload_contract_generated_test.go +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -20,6 +20,7 @@ const ( generatedTusCreationWithUploadContentTypeHeader = "Content-Type" generatedTusCreationWithUploadEndpointPath = "/uploads" generatedTusCreationWithUploadEventPolicy = "exact-except-allowed-extra-events" + generatedTusCreationWithUploadExpectedRequests = 1 generatedTusCreationWithUploadLength = "11" generatedTusCreationWithUploadLengthHeader = "Upload-Length" generatedTusCreationWithUploadMetadataHeader = "Upload-Metadata" @@ -145,8 +146,8 @@ func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { if upload.RemoteOffset != int64(len(generatedTusCreationWithUploadContent)) { t.Fatalf("expected upload offset %d, got %d", len(generatedTusCreationWithUploadContent), upload.RemoteOffset) } - if requestCount != 1 { - t.Fatalf("expected exactly one creation-with-upload request, got %d", requestCount) + if requestCount != generatedTusCreationWithUploadExpectedRequests { + t.Fatalf("expected %d creation-with-upload request(s), got %d", generatedTusCreationWithUploadExpectedRequests, requestCount) } select { case err := <-requestErrs: diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go index 8d28f43..727475f 100644 --- a/url_storage_creation_with_upload_partial_contract_generated_test.go +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -21,6 +21,7 @@ const ( generatedTusCreationPartialCreateBodySize = 5 generatedTusCreationPartialEndpointPath = "/uploads" generatedTusCreationPartialEventPolicy = "exact-except-allowed-extra-events" + generatedTusCreationPartialExpectedRequests = 3 generatedTusCreationPartialLength = "11" generatedTusCreationPartialLengthHeader = "Upload-Length" generatedTusCreationPartialMetadataHeader = "Upload-Metadata" @@ -245,8 +246,8 @@ func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { if upload.RemoteOffset != int64(len(generatedTusCreationPartialContent)) { t.Fatalf("expected upload offset %d, got %d", len(generatedTusCreationPartialContent), upload.RemoteOffset) } - if requestCount != 3 { - t.Fatalf("expected one creation request and two continuation requests, got %d", requestCount) + if requestCount != generatedTusCreationPartialExpectedRequests { + t.Fatalf("expected %d partial creation request(s), got %d", generatedTusCreationPartialExpectedRequests, requestCount) } select { case err := <-requestErrs: diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go index e25be0d..8104a1e 100644 --- a/url_storage_custom_headers_contract_generated_test.go +++ b/url_storage_custom_headers_contract_generated_test.go @@ -19,6 +19,7 @@ const ( generatedTusCustomHeadersContentType = "application/offset+octet-stream" generatedTusCustomHeadersContentTypeHeader = "Content-Type" generatedTusCustomHeadersEndpointPath = "/uploads" + generatedTusCustomHeadersExpectedRequests = 2 generatedTusCustomHeadersLength = "11" generatedTusCustomHeadersLengthHeader = "Upload-Length" generatedTusCustomHeadersMetadataHeader = "Upload-Metadata" @@ -150,8 +151,8 @@ func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { if upload.RemoteOffset != 11 { t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) } - if requestCount != 2 { - t.Fatalf("expected custom-header create and patch requests, got %d", requestCount) + if requestCount != generatedTusCustomHeadersExpectedRequests { + t.Fatalf("expected %d custom-header request(s), got %d", generatedTusCustomHeadersExpectedRequests, requestCount) } select { case err := <-requestErrs: diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go index 9738cf2..29dd0a6 100644 --- a/url_storage_deferred_length_contract_generated_test.go +++ b/url_storage_deferred_length_contract_generated_test.go @@ -22,6 +22,8 @@ const ( generatedTusDeferredLengthCreateDeferValue = "1" generatedTusDeferredLengthEndpointPath = "/uploads" generatedTusDeferredLengthEventPolicy = "exact-except-allowed-extra-events" + generatedTusDeferredLengthExpectedCreates = 1 + generatedTusDeferredLengthExpectedPatches = 1 generatedTusDeferredLengthMetadataHeader = "Upload-Metadata" generatedTusDeferredLengthPatchLength = "11" generatedTusDeferredLengthPatchLengthHeader = "Upload-Length" @@ -177,8 +179,14 @@ func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { if upload.Location != server.URL+generatedTusDeferredLengthUploadPath { t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusDeferredLengthUploadPath, upload.Location) } - if createCount != 1 || patchCount != 1 { - t.Fatalf("expected one create and one patch, got create=%d patch=%d", createCount, patchCount) + 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: diff --git a/url_storage_override_patch_method_contract_generated_test.go b/url_storage_override_patch_method_contract_generated_test.go index 5b6d306..dd46e41 100644 --- a/url_storage_override_patch_method_contract_generated_test.go +++ b/url_storage_override_patch_method_contract_generated_test.go @@ -18,6 +18,7 @@ const ( generatedTusOverrideContent = "hello world" generatedTusOverrideContentType = "application/offset+octet-stream" generatedTusOverrideContentTypeHeader = "Content-Type" + generatedTusOverrideExpectedRequests = 2 generatedTusOverrideHeaderName = "X-HTTP-Method-Override" generatedTusOverrideHeaderValue = "PATCH" generatedTusOverrideMethod = "POST" @@ -139,8 +140,8 @@ func TestGeneratedURLStorageOverridePatchMethod(t *testing.T) { if upload.RemoteOffset != 11 { t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) } - if requestCount != 2 { - t.Fatalf("expected one offset request and one overridden patch request, got %d", requestCount) + if requestCount != generatedTusOverrideExpectedRequests { + t.Fatalf("expected %d override request(s), got %d", generatedTusOverrideExpectedRequests, requestCount) } select { case err := <-requestErrs: