diff --git a/checksum/hash_test.go b/checksum/hash_test.go index cf41e51..8ae8211 100644 --- a/checksum/hash_test.go +++ b/checksum/hash_test.go @@ -75,4 +75,3 @@ var _ = Describe("HashBase64ReadWriter", func() { }) }) }) - diff --git a/client.go b/client.go index 2ddecec..22c9145 100644 --- a/client.go +++ b/client.go @@ -17,7 +17,7 @@ import ( // headed to func NewClient(client *http.Client, baseURL *url.URL) *Client { c := &Client{ - ProtocolVersion: "1.0.0", + ProtocolVersion: DefaultProtocolVersion, GetRequest: newRequest, client: client, BaseURL: baseURL, @@ -49,7 +49,7 @@ type Client struct { // BaseURL is base url the client making queries to. For example, "http://example.com/files" BaseURL *url.URL - // ProtocolVersion is TUS protocol version will be used in requests. Default is "1.0.0" + // ProtocolVersion is TUS protocol version will be used in requests. Default is DefaultProtocolVersion. ProtocolVersion string // Server capabilities and settings. Use UpdateCapabilities to query the capabilities from a server @@ -237,6 +237,9 @@ func (c *Client) CreateUploadWithData(u *Upload, data []byte, remoteSize int64, s.ChunkSize = int64(len(data)) // Data must be uploaded in one request s.uploadMethod = http.MethodPost headers := map[string]string{"Upload-Length": strconv.Itoa(int(remoteSize)), "Upload-Offset": ""} + if headerName, value, ok := protocolUploadCompleteHeader(c.ProtocolVersion, true); ok { + headers[headerName] = value + } if partial { headers["Upload-Concat"] = "partial" } @@ -429,8 +432,18 @@ func (c *Client) UpdateCapabilities() (response *http.Response, err error) { } func (c *Client) tusRequest(ctx context.Context, req *http.Request) (response *http.Response, err error) { - if req.Method != http.MethodOptions && req.Header.Get("Tus-Resumable") == "" { - req.Header.Set("Tus-Resumable", c.ProtocolVersion) + if req.Method != http.MethodOptions { + requestHeaders, ok := protocolRequestHeaders(c.ProtocolVersion) + if !ok { + err = ErrProtocol.WithText(fmt.Sprintf("unsupported protocol version %q", c.ProtocolVersion)) + return + } + for headerName, value := range requestHeaders { + if req.Header.Get(headerName) != "" { + continue + } + req.Header.Set(headerName, value) + } } if ctx != nil { req = req.WithContext(ctx) diff --git a/examples/api2-devdock-transloadit-assembly-upload/main.go b/examples/api2-devdock-transloadit-assembly-upload/main.go new file mode 100644 index 0000000..c5d432c --- /dev/null +++ b/examples/api2-devdock-transloadit-assembly-upload/main.go @@ -0,0 +1,90 @@ +//go:build api2devdock + +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithTus( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return "", err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return "", err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return "", err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + upload := tusgo.Upload{} + if _, err := client.CreateUpload(&upload, int64(len(content)), false, metadata); err != nil { + return "", err + } + if upload.Location == "" { + return "", fmt.Errorf("created upload did not include a Location") + } + + stream := tusgo.NewUploadStream(client, &upload) + stream.ChunkSize = tusgo.NoChunked + written, err := stream.Write(content) + if err != nil { + return "", err + } + if written != len(content) { + return "", fmt.Errorf("wrote %d bytes, expected %d", written, len(content)) + } + if upload.RemoteOffset != int64(len(content)) { + return "", fmt.Errorf("remote offset %d, expected %d", upload.RemoteOffset, len(content)) + } + + return upload.Location, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-transloadit-assembly-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + uploadURL, err := uploadWithTus(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("upload: %v", err) + } + if err := api2devdock.WriteResult(map[string]interface{}{"uploadUrl": uploadURL}); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s uploaded to %s\n", scenarioID, uploadURL) +} diff --git a/examples/api2-devdock-tus-abort-upload/main.go b/examples/api2-devdock-tus-abort-upload/main.go new file mode 100644 index 0000000..4f34d51 --- /dev/null +++ b/examples/api2-devdock-tus-abort-upload/main.go @@ -0,0 +1,467 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type observedAbortRequest struct { + Method string + URL string +} + +type abortConformanceServer struct { + cancelUpload context.CancelFunc + endpointOrigin *url.URL + errs []error + mu sync.Mutex + observed []observedAbortRequest + requests []interface{} + server *httptest.Server + events []map[string]interface{} +} + +func newAbortConformanceServer( + conformanceScenario map[string]interface{}, + endpointOrigin *url.URL, + cancelUpload context.CancelFunc, +) (*abortConformanceServer, error) { + requests, err := api2devdock.ArrayValue( + conformanceScenario["requests"], + "conformanceScenario.requests", + ) + if err != nil { + return nil, err + } + + conformanceServer := &abortConformanceServer{ + cancelUpload: cancelUpload, + endpointOrigin: endpointOrigin, + requests: requests, + } + conformanceServer.server = httptest.NewServer(conformanceServer) + + return conformanceServer, nil +} + +func (conformanceServer *abortConformanceServer) Close() { + conformanceServer.server.Close() +} + +func (conformanceServer *abortConformanceServer) EndpointURL() (*url.URL, error) { + endpointURL := *conformanceServer.endpointOrigin + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return nil, err + } + endpointURL.Scheme = serverURL.Scheme + endpointURL.Host = serverURL.Host + + return &endpointURL, nil +} + +func (conformanceServer *abortConformanceServer) CanonicalURL(actualURL string) (string, error) { + parsedActual, err := url.Parse(actualURL) + if err != nil { + return "", err + } + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + if parsedActual.Scheme != serverURL.Scheme || parsedActual.Host != serverURL.Host { + return actualURL, nil + } + + canonical := *parsedActual + canonical.Scheme = conformanceServer.endpointOrigin.Scheme + canonical.Host = conformanceServer.endpointOrigin.Host + + return canonical.String(), nil +} + +func (conformanceServer *abortConformanceServer) Result() (map[string]interface{}, error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + if len(conformanceServer.errs) > 0 { + return nil, conformanceServer.errs[0] + } + + requestMethods := make([]string, 0, len(conformanceServer.observed)) + requestURLs := make([]string, 0, len(conformanceServer.observed)) + for _, request := range conformanceServer.observed { + requestMethods = append(requestMethods, request.Method) + requestURLs = append(requestURLs, request.URL) + } + + return map[string]interface{}{ + "events": conformanceServer.events, + "requestCount": len(conformanceServer.observed), + "requestMethods": requestMethods, + "requestUrls": requestURLs, + }, nil +} + +func (conformanceServer *abortConformanceServer) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + requestIndex, requestPlan, err := conformanceServer.nextRequestPlan() + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + + expectedURL, err := api2devdock.StringValue( + requestPlan["expectedUrl"], + fmt.Sprintf("conformanceScenario.requests[%d].expectedUrl", requestIndex), + ) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + expectedMethod, err := api2devdock.StringValue( + requestPlan["effectiveMethod"], + fmt.Sprintf("conformanceScenario.requests[%d].effectiveMethod", requestIndex), + ) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + requestURL := conformanceServer.endpointOrigin.ResolveReference(request.URL).String() + conformanceServer.observeRequest(observedAbortRequest{ + Method: request.Method, + URL: requestURL, + }) + if requestURL != expectedURL { + conformanceServer.recordErr( + fmt.Errorf("request %d expected URL %s, got %s", requestIndex, expectedURL, requestURL), + ) + } + if request.Method != expectedMethod { + conformanceServer.recordErr( + fmt.Errorf("request %d expected method %s, got %s", requestIndex, expectedMethod, request.Method), + ) + } + + shouldAbort, err := api2devdock.BoolValue( + requestPlan["abort"], + fmt.Sprintf("conformanceScenario.requests[%d].abort", requestIndex), + ) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + if shouldAbort { + conformanceServer.recordAbortEvent(requestIndex, request.Method, requestURL) + conformanceServer.cancelUpload() + select { + case <-request.Context().Done(): + case <-time.After(2 * time.Second): + conformanceServer.recordErr( + fmt.Errorf("request %d did not observe cancellation", requestIndex), + ) + } + return + } + + if err := conformanceServer.writeResponse(responseWriter, requestIndex, requestPlan); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + } +} + +func (conformanceServer *abortConformanceServer) nextRequestPlan() ( + int, + map[string]interface{}, + error, +) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + requestIndex := len(conformanceServer.observed) + if requestIndex >= len(conformanceServer.requests) { + return 0, nil, fmt.Errorf("unexpected request %d", requestIndex) + } + requestPlan, err := api2devdock.ObjectValue( + conformanceServer.requests[requestIndex], + fmt.Sprintf("conformanceScenario.requests[%d]", requestIndex), + ) + if err != nil { + return 0, nil, err + } + + return requestIndex, requestPlan, nil +} + +func (conformanceServer *abortConformanceServer) observeRequest(request observedAbortRequest) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.observed = append(conformanceServer.observed, request) +} + +func (conformanceServer *abortConformanceServer) recordAbortEvent( + requestIndex int, + method string, + requestURL string, +) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.events = append(conformanceServer.events, map[string]interface{}{ + "kind": "request-abort", + "method": method, + "requestIndex": requestIndex, + "url": requestURL, + }) +} + +func (conformanceServer *abortConformanceServer) recordErr(err error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.errs = append(conformanceServer.errs, err) +} + +func (conformanceServer *abortConformanceServer) writeResponse( + responseWriter http.ResponseWriter, + requestIndex int, + requestPlan map[string]interface{}, +) error { + responsePlan, err := api2devdock.ObjectValue( + requestPlan["response"], + fmt.Sprintf("conformanceScenario.requests[%d].response", requestIndex), + ) + if err != nil { + return err + } + headers, err := api2devdock.StringMapValue( + responsePlan["effectiveHeaders"], + fmt.Sprintf("conformanceScenario.requests[%d].response.effectiveHeaders", requestIndex), + ) + if err != nil { + return err + } + for name, value := range headers { + if name == "Location" { + value, err = conformanceServer.localResponseURL(value) + if err != nil { + return err + } + } + responseWriter.Header().Set(name, value) + } + statusCode, err := api2devdock.IntValue( + responsePlan["statusCode"], + fmt.Sprintf("conformanceScenario.requests[%d].response.statusCode", requestIndex), + ) + if err != nil { + return err + } + responseWriter.WriteHeader(statusCode) + + return nil +} + +func (conformanceServer *abortConformanceServer) localResponseURL(value string) (string, error) { + canonicalURL, err := url.Parse(value) + if err != nil { + return "", err + } + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + if canonicalURL.Scheme != conformanceServer.endpointOrigin.Scheme || + canonicalURL.Host != conformanceServer.endpointOrigin.Host { + return value, nil + } + + localURL := *canonicalURL + localURL.Scheme = serverURL.Scheme + localURL.Host = serverURL.Host + + return localURL.String(), nil +} + +func uploadAndAbort( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption(conformanceScenario, "metadata") + if err != nil { + return nil, err + } + headers, err := api2devdock.TusConformanceInputStringMapOption(conformanceScenario, "headers") + if err != nil { + return nil, err + } + overridePatchMethod, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "overridePatchMethod", + false, + ) + if err != nil { + return nil, err + } + terminateUploadOnAbort, err := api2devdock.TusConformanceRuntimeAbortTerminateUpload( + conformanceScenario, + ) + if err != nil { + return nil, err + } + fingerprint, err := api2devdock.TusConformanceRuntimeFingerprint(conformanceScenario) + if err != nil { + return nil, err + } + if fingerprint == "" { + fingerprint = "api2-go-abort-conformance-fingerprint" + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + capabilities := &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + + uploadCtx, cancelUpload := context.WithCancel(ctx) + defer cancelUpload() + conformanceServer, err := newAbortConformanceServer( + conformanceScenario, + endpointOrigin, + cancelUpload, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = capabilities + + type uploadResult struct { + err error + upload *tusgo.Upload + } + result := make(chan uploadResult, 1) + go func() { + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: uploadCtx, + Fingerprint: fingerprint, + Headers: headers, + Metadata: metadata, + OverridePatchMethod: overridePatchMethod, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + TerminateUploadOnAbort: terminateUploadOnAbort, + EventHooks: tusgo.UploadEventHooks{ + OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + return nil + }, + }, + }) + result <- uploadResult{err: err, upload: upload} + }() + + var upload *tusgo.Upload + select { + case uploadResult := <-result: + if !errors.Is(uploadResult.err, context.Canceled) { + return nil, fmt.Errorf("expected upload abort, got upload=%#v err=%v", uploadResult.upload, uploadResult.err) + } + upload = uploadResult.upload + case <-time.After(5 * time.Second): + return nil, fmt.Errorf("timed out waiting for upload abort") + } + + serverResult, err := conformanceServer.Result() + if err != nil { + return nil, err + } + uploadURL := interface{}(nil) + if upload != nil && upload.Location != "" { + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + uploadURL = canonicalUploadURL + } + serverResult["completionKind"] = "aborted" + serverResult["errorCalled"] = false + serverResult["successCalled"] = successCalled + serverResult["uploadUrl"] = uploadURL + + return serverResult, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-abort-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadAndAbort(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("abort upload: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s aborted the upload\n", scenarioID) +} diff --git a/examples/api2-devdock-tus-creation-with-upload/main.go b/examples/api2-devdock-tus-creation-with-upload/main.go new file mode 100644 index 0000000..125c8a6 --- /dev/null +++ b/examples/api2-devdock-tus-creation-with-upload/main.go @@ -0,0 +1,115 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithCreationData( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + upload, err := api2devdock.ObjectValue(scenario["upload"], "upload") + if err != nil { + return "", err + } + uploadDataDuringCreation, err := api2devdock.BoolValue( + upload["uploadDataDuringCreation"], + "upload.uploadDataDuringCreation", + ) + if err != nil { + return "", err + } + if !uploadDataDuringCreation { + return "", fmt.Errorf("creation-with-upload scenario must set uploadDataDuringCreation") + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return "", err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return "", err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return "", err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return "", err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return "", err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadDataDuringCreation: true, + }) + if err != nil { + return "", err + } + if createdUpload == nil || createdUpload.Location == "" { + return "", fmt.Errorf("creation-with-upload TUS upload did not expose an upload URL") + } + if createdUpload.RemoteOffset != int64(len(content)) { + return "", fmt.Errorf( + "creation-with-upload accepted %d bytes, expected %d", + createdUpload.RemoteOffset, + len(content), + ) + } + + return createdUpload.Location, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-creation-with-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + uploadURL, err := uploadWithCreationData(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("creation-with-upload: %v", err) + } + if err := api2devdock.WriteResult(map[string]interface{}{"uploadUrl": uploadURL}); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s uploaded to %s\n", scenarioID, uploadURL) +} diff --git a/examples/api2-devdock-tus-custom-request-headers/main.go b/examples/api2-devdock-tus-custom-request-headers/main.go new file mode 100644 index 0000000..e70f48b --- /dev/null +++ b/examples/api2-devdock-tus-custom-request-headers/main.go @@ -0,0 +1,152 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "reflect" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func observedCustomHeaders( + request *http.Request, + expectedHeaders map[string]string, +) map[string]string { + observed := map[string]string{} + for name := range expectedHeaders { + observed[name] = request.Header.Get(name) + } + + return observed +} + +func assertObservedCustomHeaders( + label string, + actual map[string]string, + expected map[string]string, +) error { + if !reflect.DeepEqual(actual, expected) { + return fmt.Errorf("%s expected headers %v, got %v", label, expected, actual) + } + + return nil +} + +func uploadWithCustomHeaders( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + headers, err := api2devdock.UploadHeaders(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + headersByMethod := map[string]map[string]string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + switch request.Method { + case http.MethodPost, http.MethodPatch: + headersByMethod[request.Method] = observedCustomHeaders(request, headers) + } + + return nil + }, + }, + ).WithContext(ctx) + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Headers: headers, + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("custom request headers TUS upload did not expose an upload URL") + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "custom request headers upload accepted %d bytes, expected %d", + upload.RemoteOffset, + len(content), + ) + } + if err := assertObservedCustomHeaders("POST", headersByMethod[http.MethodPost], headers); err != nil { + return nil, err + } + if err := assertObservedCustomHeaders("PATCH", headersByMethod[http.MethodPatch], headers); err != nil { + return nil, err + } + + return map[string]interface{}{ + "headersByMethod": headersByMethod, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-custom-request-headers/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithCustomHeaders(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("custom request headers: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s sent custom headers to %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-deferred-length-upload/main.go b/examples/api2-devdock-tus-deferred-length-upload/main.go new file mode 100644 index 0000000..ec3fb06 --- /dev/null +++ b/examples/api2-devdock-tus-deferred-length-upload/main.go @@ -0,0 +1,117 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithDeferredLength( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + uploadLengthDeferred, err := api2devdock.UploadLengthDeferred(scenario) + if err != nil { + return "", err + } + if !uploadLengthDeferred { + return "", fmt.Errorf("deferred-length scenario must set uploadLengthDeferred") + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return "", err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return "", err + } + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return "", err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return "", err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return "", err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + ChunkSize: chunkSize, + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadLengthDeferred: uploadLengthDeferred, + }) + if err != nil { + return "", err + } + if createdUpload == nil || createdUpload.Location == "" { + return "", fmt.Errorf("deferred-length TUS upload did not expose an upload URL") + } + if createdUpload.RemoteSize != int64(len(content)) { + return "", fmt.Errorf( + "deferred-length upload size is %d, expected %d", + createdUpload.RemoteSize, + len(content), + ) + } + if createdUpload.RemoteOffset != int64(len(content)) { + return "", fmt.Errorf( + "deferred-length upload accepted %d bytes, expected %d", + createdUpload.RemoteOffset, + len(content), + ) + } + + return createdUpload.Location, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-deferred-length-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + uploadURL, err := uploadWithDeferredLength(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("deferred-length upload: %v", err) + } + if err := api2devdock.WriteResult(map[string]interface{}{"uploadUrl": uploadURL}); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s deferred length to %s\n", scenarioID, uploadURL) +} diff --git a/examples/api2-devdock-tus-detailed-error/main.go b/examples/api2-devdock-tus-detailed-error/main.go new file mode 100644 index 0000000..07ef99c --- /dev/null +++ b/examples/api2-devdock-tus-detailed-error/main.go @@ -0,0 +1,307 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type detailedErrorTransport struct { + requestCount int + requestMethods []string + requestPlan map[string]interface{} + requestURLs []string +} + +func (transport *detailedErrorTransport) RoundTrip( + request *http.Request, +) (*http.Response, error) { + transport.requestCount += 1 + transport.requestMethods = append(transport.requestMethods, request.Method) + transport.requestURLs = append(transport.requestURLs, request.URL.String()) + + errorPlan, ok, err := optionalObject( + transport.requestPlan, + "error", + "conformanceScenario.requests[0].error", + ) + if err != nil { + return nil, err + } + if ok { + message, err := api2devdock.StringValue( + errorPlan["message"], + "conformanceScenario.requests[0].error.message", + ) + if err != nil { + return nil, err + } + + return nil, errors.New(message) + } + if rawErrorMessage, ok := transport.requestPlan["errorMessage"]; ok && rawErrorMessage != nil { + message, err := api2devdock.StringValue( + rawErrorMessage, + "conformanceScenario.requests[0].errorMessage", + ) + if err != nil { + return nil, err + } + + return nil, errors.New(message) + } + + responsePlan, ok, err := optionalObject( + transport.requestPlan, + "response", + "conformanceScenario.requests[0].response", + ) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("detailed error scenario did not provide a response or error plan") + } + + headers, err := api2devdock.StringMapValue( + responsePlan["effectiveHeaders"], + "conformanceScenario.requests[0].response.effectiveHeaders", + ) + if err != nil { + return nil, err + } + statusCode, err := api2devdock.IntValue( + responsePlan["statusCode"], + "conformanceScenario.requests[0].response.statusCode", + ) + if err != nil { + return nil, err + } + body := "" + if rawBody, ok := responsePlan["body"]; ok && rawBody != nil { + body, err = api2devdock.StringValue( + rawBody, + "conformanceScenario.requests[0].response.body", + ) + if err != nil { + return nil, err + } + } + + responseHeaders := http.Header{} + for name, value := range headers { + responseHeaders.Set(name, value) + } + + return &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + Header: responseHeaders, + Request: request, + StatusCode: statusCode, + }, nil +} + +func optionalObject( + object map[string]interface{}, + key string, + label string, +) (map[string]interface{}, bool, error) { + rawValue, ok := object[key] + if !ok || rawValue == nil { + return nil, false, nil + } + value, err := api2devdock.ObjectValue(rawValue, label) + if err != nil { + return nil, false, err + } + + return value, true, nil +} + +func firstDetailedErrorRequestPlan( + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + requests, err := api2devdock.ArrayValue( + conformanceScenario["requests"], + "conformanceScenario.requests", + ) + if err != nil { + return nil, err + } + if len(requests) != 1 { + return nil, fmt.Errorf("detailed error scenario expected one request, got %d", len(requests)) + } + + return api2devdock.ObjectValue(requests[0], "conformanceScenario.requests[0]") +} + +func detailedErrorRequestIDHeaderName( + conformanceScenario map[string]interface{}, + requestPlan map[string]interface{}, +) (string, error) { + inputHeaders, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "headers", + ) + if err != nil { + return "", err + } + expectedHeaders, err := api2devdock.StringMapValue( + requestPlan["effectiveHeaders"], + "conformanceScenario.requests[0].effectiveHeaders", + ) + if err != nil { + return "", err + } + + matchingHeaderNames := []string{} + for name, value := range inputHeaders { + if expectedHeaders[name] == value { + matchingHeaderNames = append(matchingHeaderNames, name) + } + } + if len(matchingHeaderNames) != 1 { + return "", fmt.Errorf( + "detailed error scenario expected one request ID header candidate, got %d", + len(matchingHeaderNames), + ) + } + + return matchingHeaderNames[0], nil +} + +func uploadExpectingDetailedError( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointURL, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + headers, err := api2devdock.TusConformanceInputStringMapOption(conformanceScenario, "headers") + if err != nil { + return nil, err + } + requestPlan, err := firstDetailedErrorRequestPlan(conformanceScenario) + if err != nil { + return nil, err + } + requestIDHeaderName, err := detailedErrorRequestIDHeaderName(conformanceScenario, requestPlan) + if err != nil { + return nil, err + } + + transport := &detailedErrorTransport{ + requestPlan: requestPlan, + } + client := tusgo.NewClient(&http.Client{Transport: transport}, endpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: []string{"creation"}, + ProtocolVersions: []string{tusgo.DefaultProtocolVersion}, + } + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-detailed-error-conformance-fingerprint", + Headers: headers, + Metadata: metadata, + RetryDelays: []time.Duration{}, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err == nil { + return nil, fmt.Errorf("detailed error scenario unexpectedly created upload %#v", upload) + } + var detailedError *tusgo.DetailedError + errorIsDetailed := errors.As(err, &detailedError) + result := map[string]interface{}{ + "errorCaught": true, + "errorMessage": err.Error(), + "errorIsDetailed": errorIsDetailed, + "requestCount": transport.requestCount, + "requestMethods": transport.requestMethods, + "requestUrls": transport.requestURLs, + } + if !errorIsDetailed { + return result, nil + } + + result["causingErrorPresent"] = detailedError.CausingError != nil + if detailedError.CausingError != nil { + result["causingErrorMessage"] = detailedError.CausingError.Error() + } + if detailedError.OriginalRequest != nil { + result["originalRequestMethod"] = detailedError.OriginalRequest.Method + result["originalRequestRequestId"] = detailedError.OriginalRequest.Header.Get( + requestIDHeaderName, + ) + result["originalRequestUrl"] = detailedError.OriginalRequest.URL.String() + } + result["originalResponsePresent"] = detailedError.OriginalResponse != nil + if detailedError.OriginalResponse != nil { + result["originalResponseBody"] = detailedError.OriginalResponseBody + result["originalResponseStatus"] = detailedError.OriginalResponse.StatusCode + } + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-detailed-error/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadExpectingDetailedError(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("detailed error: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s reported a detailed error\n", scenarioID) +} diff --git a/examples/api2-devdock-tus-node-path-input-source/main.go b/examples/api2-devdock-tus-node-path-input-source/main.go new file mode 100644 index 0000000..05834ec --- /dev/null +++ b/examples/api2-devdock-tus-node-path-input-source/main.go @@ -0,0 +1,237 @@ +//go:build api2devdock + +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func appendSourceOpenEvent( + events []map[string]interface{}, + conformanceScenario map[string]interface{}, + inputKind string, + size int, +) ([]map[string]interface{}, error) { + wantsSourceOpen, err := api2devdock.TusConformanceScenarioWantsEvent( + conformanceScenario, + "source-open", + ) + if err != nil { + return nil, err + } + if !wantsSourceOpen { + return events, nil + } + + return append(events, map[string]interface{}{ + "inputKind": inputKind, + "kind": "source-open", + "size": size, + }), nil +} + +func appendSourceCloseEvent( + events []map[string]interface{}, + conformanceScenario map[string]interface{}, +) ([]map[string]interface{}, error) { + wantsSourceClose, err := api2devdock.TusConformanceScenarioWantsEvent( + conformanceScenario, + "source-close", + ) + if err != nil { + return nil, err + } + if !wantsSourceClose { + return events, nil + } + + return append(events, map[string]interface{}{"kind": "source-close"}), nil +} + +func appendSuccessEvent( + events []map[string]interface{}, + conformanceScenario map[string]interface{}, +) ([]map[string]interface{}, error) { + wantsSuccess, err := api2devdock.TusConformanceScenarioWantsEvent( + conformanceScenario, + "success", + ) + if err != nil { + return nil, err + } + if !wantsSuccess { + return events, nil + } + + return append(events, map[string]interface{}{"kind": "success"}), nil +} + +func uploadWithNodePathInputSource( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + inputKind, err := api2devdock.TusConformanceInputSourceKind(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + + tmpDir, err := os.MkdirTemp("", "api2-go-tus-node-path-input-source-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + inputPath := filepath.Join(tmpDir, "input.txt") + if err := os.WriteFile(inputPath, content, 0o600); err != nil { + return nil, err + } + source, err := os.Open(inputPath) + if err != nil { + return nil, err + } + + events := []map[string]interface{}{} + events, err = appendSourceOpenEvent(events, conformanceScenario, inputKind, len(content)) + if err != nil { + source.Close() + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + source.Close() + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + source.Close() + return nil, err + } + + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-node-path-input-source-conformance-fingerprint", + Metadata: metadata, + Size: int64(len(content)), + Source: source, + Storage: tusgo.NewMemoryURLStorage(), + EventHooks: tusgo.UploadEventHooks{ + OnSuccess: func(tusgo.UploadSuccessPayload) error { + var eventErr error + events, eventErr = appendSuccessEvent(events, conformanceScenario) + return eventErr + }, + }, + }) + closeErr := source.Close() + if err != nil { + return nil, err + } + if closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { + return nil, closeErr + } + events, err = appendSourceCloseEvent(events, conformanceScenario) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("node-path TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["events"] = events + result["inputKind"] = inputKind + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-node-path-input-source/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithNodePathInputSource(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("node path input source: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s read %s for %s\n", + scenarioID, + result["inputKind"], + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-override-patch-method/main.go b/examples/api2-devdock-tus-override-patch-method/main.go new file mode 100644 index 0000000..5951c53 --- /dev/null +++ b/examples/api2-devdock-tus-override-patch-method/main.go @@ -0,0 +1,152 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithOverridePatchMethod( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + uploadURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "uploadUrl", + ) + if err != nil { + return nil, err + } + overridePatchMethod, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "overridePatchMethod", + false, + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + fingerprint, err := api2devdock.TusConformanceRuntimeFingerprint(conformanceScenario) + if err != nil { + return nil, err + } + if fingerprint == "" { + fingerprint = "api2-go-override-patch-conformance-fingerprint" + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + localUploadURL, err := conformanceServer.LocalURL(uploadURLValue) + if err != nil { + return nil, err + } + + storage := tusgo.NewMemoryURLStorage() + if _, err := storage.AddUpload( + fingerprint, + tusgo.URLStorageUpload{"uploadUrl": localUploadURL}, + ); err != nil { + return nil, err + } + + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: fingerprint, + OverridePatchMethod: overridePatchMethod, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: storage, + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("override-PATCH TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-override-patch-method/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithOverridePatchMethod(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("override PATCH method: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s overrode PATCH for %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-parallel-upload-concat/main.go b/examples/api2-devdock-tus-parallel-upload-concat/main.go new file mode 100644 index 0000000..083b3ac --- /dev/null +++ b/examples/api2-devdock-tus-parallel-upload-concat/main.go @@ -0,0 +1,198 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithParallelConcat( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + metadataForPartialUploads, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadataForPartialUploads", + ) + if err != nil { + return nil, err + } + parallelUploads, err := api2devdock.TusConformanceInputIntOption( + conformanceScenario, + "parallelUploads", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return nil, err + } + completionKind, err := api2devdock.StringValue( + completion["kind"], + "conformanceScenario.completion.kind", + ) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + events := []map[string]interface{}{} + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-parallel-concat-conformance-fingerprint", + Metadata: metadata, + MetadataForPartialUploads: metadataForPartialUploads, + ParallelUploads: parallelUploads, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + EventHooks: tusgo.UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + event := map[string]interface{}{ + "bytesSent": bytesSent, + "kind": "progress", + } + if bytesTotal != nil { + event["bytesTotal"] = *bytesTotal + } + events = append(events, event) + + return nil + }, + OnChunkComplete: func( + chunkSize int64, + bytesAccepted int64, + bytesTotal *int64, + ) error { + event := map[string]interface{}{ + "bytesAccepted": bytesAccepted, + "chunkSize": chunkSize, + "kind": "chunk-complete", + } + if bytesTotal != nil { + event["bytesTotal"] = *bytesTotal + } + events = append(events, event) + + return nil + }, + OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + + return nil + }, + }, + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("parallel TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["completionKind"] = completionKind + result["errorCalled"] = false + result["eventCount"] = len(events) + result["events"] = events + result["successCalled"] = successCalled + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-parallel-upload-concat/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithParallelConcat(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("parallel upload concat: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s concatenated %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-protocol-version-selection/main.go b/examples/api2-devdock-tus-protocol-version-selection/main.go new file mode 100644 index 0000000..d1def96 --- /dev/null +++ b/examples/api2-devdock-tus-protocol-version-selection/main.go @@ -0,0 +1,164 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithProtocolVersionSelection( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + protocol, err := api2devdock.TusConformanceInputStringOption(conformanceScenario, "protocol") + if err != nil { + return nil, err + } + uploadDataDuringCreation, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "uploadDataDuringCreation", + false, + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return nil, err + } + completionKind, err := api2devdock.StringValue( + completion["kind"], + "conformanceScenario.completion.kind", + ) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.ProtocolVersion = protocol + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-protocol-version-conformance-fingerprint", + Metadata: metadata, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadDataDuringCreation: uploadDataDuringCreation, + EventHooks: tusgo.UploadEventHooks{ + OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + + return nil + }, + }, + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("protocol-version TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["completionKind"] = completionKind + result["errorCalled"] = false + result["successCalled"] = successCalled + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-protocol-version-selection/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithProtocolVersionSelection(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("protocol version selection: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s selected protocol for %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-relative-location-resolution/main.go b/examples/api2-devdock-tus-relative-location-resolution/main.go new file mode 100644 index 0000000..1361620 --- /dev/null +++ b/examples/api2-devdock-tus-relative-location-resolution/main.go @@ -0,0 +1,125 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadWithRelativeLocationResolution( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-relative-location-conformance-fingerprint", + Metadata: metadata, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("relative-location TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-relative-location-resolution/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithRelativeLocationResolution(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("relative Location resolution: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s resolved %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-request-id-headers/main.go b/examples/api2-devdock-tus-request-id-headers/main.go new file mode 100644 index 0000000..5735e06 --- /dev/null +++ b/examples/api2-devdock-tus-request-id-headers/main.go @@ -0,0 +1,149 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func observedRequestIDHeader(request *http.Request, headerName string) (string, error) { + value := request.Header.Get(headerName) + if value == "" { + return "", fmt.Errorf( + "request ID headers scenario did not observe %s on %s", + headerName, + request.Method, + ) + } + + return value, nil +} + +func uploadWithRequestIDHeaders( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + addRequestID, err := api2devdock.UploadAddRequestID(scenario) + if err != nil { + return nil, err + } + requestIDHeaderName, err := api2devdock.UploadRequestIDHeaderName(scenario) + if err != nil { + return nil, err + } + headers, err := api2devdock.UploadHeaders(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + headersByMethod := map[string]map[string]string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + switch request.Method { + case http.MethodPost, http.MethodPatch: + value, err := observedRequestIDHeader(request, requestIDHeaderName) + if err != nil { + return err + } + headersByMethod[request.Method] = map[string]string{ + requestIDHeaderName: value, + } + } + + return nil + }, + }, + ).WithContext(ctx) + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + AddRequestID: addRequestID, + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Headers: headers, + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("request ID headers TUS upload did not expose an upload URL") + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "request ID headers upload accepted %d bytes, expected %d", + upload.RemoteOffset, + len(content), + ) + } + + return map[string]interface{}{ + "headersByMethod": headersByMethod, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-request-id-headers/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithRequestIDHeaders(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("request ID headers: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s observed request ID headers to %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-request-lifecycle-hooks/main.go b/examples/api2-devdock-tus-request-lifecycle-hooks/main.go new file mode 100644 index 0000000..facfa9a --- /dev/null +++ b/examples/api2-devdock-tus-request-lifecycle-hooks/main.go @@ -0,0 +1,199 @@ +//go:build api2devdock + +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func assertRequestMethods(label string, actual []string, expected []string) error { + if len(actual) != len(expected) { + return fmt.Errorf("%s expected request methods %v, got %v", label, expected, actual) + } + + for index, method := range expected { + if actual[index] != method { + return fmt.Errorf( + "%s expected request method %s at index %d, got %s", + label, + method, + index, + actual[index], + ) + } + } + + return nil +} + +func assertStatusCodes(actual []int, expected []int) error { + if len(actual) != len(expected) { + return fmt.Errorf( + "request lifecycle hooks expected status codes %v, got %v", + expected, + actual, + ) + } + + for index, statusCode := range expected { + if actual[index] != statusCode { + return fmt.Errorf( + "request lifecycle hooks expected status code %d at index %d, got %d", + statusCode, + index, + actual[index], + ) + } + } + + return nil +} + +func shouldIgnoreRequestMethod(method string, ignoredMethods []string) bool { + for _, ignoredMethod := range ignoredMethods { + if method == ignoredMethod { + return true + } + } + + return false +} + +func uploadWithLifecycleHooks( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + requestLifecycleHooks, err := api2devdock.RequestLifecycleHooks(scenario) + if err != nil { + return nil, err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + + afterResponseMethods := []string{} + afterResponseStatusCodes := []int{} + beforeRequestMethods := []string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + if shouldIgnoreRequestMethod(request.Method, requestLifecycleHooks.IgnoredRequestMethods) { + return nil + } + beforeRequestMethods = append(beforeRequestMethods, request.Method) + + return nil + }, + AfterResponse: func(request *http.Request, response *http.Response) error { + if shouldIgnoreRequestMethod(request.Method, requestLifecycleHooks.IgnoredRequestMethods) { + return nil + } + afterResponseMethods = append(afterResponseMethods, request.Method) + afterResponseStatusCodes = append(afterResponseStatusCodes, response.StatusCode) + + return nil + }, + }, + ).WithContext(ctx) + + upload := tusgo.Upload{} + if _, err := client.CreateUpload(&upload, int64(len(content)), false, metadata); err != nil { + return nil, err + } + if upload.Location == "" { + return nil, fmt.Errorf("request lifecycle hooks TUS upload did not expose an upload URL") + } + + stream := tusgo.NewUploadStream(client, &upload) + stream.ChunkSize = tusgo.NoChunked + written, err := stream.Write(content) + if err != nil { + return nil, err + } + if written != len(content) { + return nil, fmt.Errorf("wrote %d bytes, expected %d", written, len(content)) + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf("remote offset %d, expected %d", upload.RemoteOffset, len(content)) + } + if err := assertRequestMethods( + "before request lifecycle hooks", + beforeRequestMethods, + requestLifecycleHooks.ExpectedBeforeRequestMethods, + ); err != nil { + return nil, err + } + if err := assertRequestMethods( + "after response lifecycle hooks", + afterResponseMethods, + requestLifecycleHooks.ExpectedAfterResponseMethods, + ); err != nil { + return nil, err + } + if err := assertStatusCodes( + afterResponseStatusCodes, + requestLifecycleHooks.ExpectedAfterResponseStatusCodes, + ); err != nil { + return nil, err + } + + return map[string]interface{}{ + "afterResponseMethods": afterResponseMethods, + "afterResponseStatusCodes": afterResponseStatusCodes, + "beforeRequestMethods": beforeRequestMethods, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-request-lifecycle-hooks/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithLifecycleHooks(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("request lifecycle hooks: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s observed lifecycle hooks for %s\n", + scenarioID, + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-resume-upload/main.go b/examples/api2-devdock-tus-resume-upload/main.go new file mode 100644 index 0000000..5513207 --- /dev/null +++ b/examples/api2-devdock-tus-resume-upload/main.go @@ -0,0 +1,297 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func uploadOptions( + scenario map[string]interface{}, + createResponse map[string]interface{}, + storage tusgo.URLStorage, + content []byte, +) (tusgo.URLStorageUploadOptions, error) { + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return tusgo.URLStorageUploadOptions{}, err + } + resume, err := api2devdock.Resume(scenario) + if err != nil { + return tusgo.URLStorageUploadOptions{}, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return tusgo.URLStorageUploadOptions{}, err + } + + return tusgo.URLStorageUploadOptions{ + ChunkSize: chunkSize, + Fingerprint: resume.Fingerprint, + Metadata: metadata, + RemoveFingerprintOnSuccess: resume.RemoveFingerprintOnSuccess, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: storage, + }, nil +} + +func uploadFirstChunkAndAbort( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, + storage tusgo.URLStorage, + content []byte, +) (int, string, error) { + resume, err := api2devdock.Resume(scenario) + if err != nil { + return 0, "", err + } + options, err := uploadOptions(scenario, createResponse, storage, content) + if err != nil { + return 0, "", err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return 0, "", err + } + + abortCtx, cancelAbort := context.WithCancel(ctx) + defer cancelAbort() + + options.Context = abortCtx + options.EventHooks = tusgo.UploadEventHooks{ + OnChunkComplete: func(_ int64, bytesAccepted int64, _ *int64) error { + if int(bytesAccepted) < resume.StopAfterAcceptedBytes { + return nil + } + + cancelAbort() + return nil + }, + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL) + upload, err := client.UploadWithURLStorage(options) + if !errors.Is(err, context.Canceled) { + return 0, "", fmt.Errorf("expected context cancellation, got upload=%#v err=%v", upload, err) + } + if upload == nil { + return 0, "", fmt.Errorf("resume scenario did not return the aborted upload") + } + storedUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return 0, "", err + } + if len(storedUploads) == 0 { + return 0, "", fmt.Errorf("resume scenario did not store the first upload URL") + } + firstUploadURL, ok := storedUploads[0]["uploadUrl"].(string) + if !ok || firstUploadURL == "" { + return 0, "", fmt.Errorf("resume scenario stored upload is missing uploadUrl") + } + + return int(upload.RemoteOffset), firstUploadURL, nil +} + +func resumeStoredUpload( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, + storage tusgo.URLStorage, + content []byte, +) (int, int, string, error) { + resume, err := api2devdock.Resume(scenario) + if err != nil { + return 0, 0, "", err + } + previousUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return 0, 0, "", err + } + if len(previousUploads) != resume.ExpectedPreviousUploadCount { + return 0, 0, "", fmt.Errorf( + "expected %d stored upload(s), got %d", + resume.ExpectedPreviousUploadCount, + len(previousUploads), + ) + } + options, err := uploadOptions(scenario, createResponse, storage, content) + if err != nil { + return 0, 0, "", err + } + options.Context = ctx + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return 0, 0, "", err + } + + client := tusgo.NewClient(http.DefaultClient, endpointURL) + upload, err := client.UploadWithURLStorage(options) + if err != nil { + return 0, 0, "", err + } + if upload == nil || upload.Location == "" { + return 0, 0, "", fmt.Errorf("resumed TUS upload did not expose an upload URL") + } + + remainingUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return 0, 0, "", err + } + if len(remainingUploads) != resume.ExpectedRemainingPreviousUploadCount { + return 0, 0, "", fmt.Errorf( + "expected %d stored upload(s) after success, got %d", + resume.ExpectedRemainingPreviousUploadCount, + len(remainingUploads), + ) + } + + return len(previousUploads), len(remainingUploads), upload.Location, nil +} + +func urlStorageUploadsContainKeyPrefix( + storedUploads []tusgo.URLStorageUpload, + expectedPrefix string, +) bool { + for _, storedUpload := range storedUploads { + storageKey, ok := storedUpload["urlStorageKey"].(string) + if !ok { + continue + } + if strings.HasPrefix(storageKey, expectedPrefix) { + return true + } + } + + return false +} + +func storedUploadKeyPrefixMatched( + scenario map[string]interface{}, + storage tusgo.URLStorage, +) (bool, error) { + backend, err := api2devdock.URLStorageBackend(scenario) + if err != nil || backend == nil { + return false, err + } + resume, err := api2devdock.Resume(scenario) + if err != nil { + return false, err + } + storedUploads, err := storage.FindUploadsByFingerprint(resume.Fingerprint) + if err != nil { + return false, err + } + + return urlStorageUploadsContainKeyPrefix( + storedUploads, + backend.ExpectedStoredUploadKeyPrefix, + ), nil +} + +func uploadWithStoredResume( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + tempDir, err := os.MkdirTemp("", "api2-tus-go-resume-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tempDir) + + storage := tusgo.NewFileURLStorage(filepath.Join(tempDir, "url-storage.json")) + firstAcceptedBytes, firstUploadURL, err := uploadFirstChunkAndAbort( + ctx, + scenario, + createResponse, + storage, + content, + ) + if err != nil { + return nil, err + } + uploadKeyPrefixMatched, err := storedUploadKeyPrefixMatched(scenario, storage) + if err != nil { + return nil, err + } + previousUploadCount, remainingPreviousUploadCount, uploadURL, err := resumeStoredUpload( + ctx, + scenario, + createResponse, + storage, + content, + ) + if err != nil { + return nil, err + } + storedUploads, err := storage.FindAllUploads() + if err != nil { + return nil, err + } + + result := map[string]interface{}{ + "firstAcceptedBytes": firstAcceptedBytes, + "firstUploadUrl": firstUploadURL, + "previousUploadCount": previousUploadCount, + "remainingPreviousUploadCount": remainingPreviousUploadCount, + "uploadUrl": uploadURL, + } + backend, err := api2devdock.URLStorageBackend(scenario) + if err != nil { + return nil, err + } + if backend != nil { + result["storageFileEntryCount"] = len(storedUploads) + result["storedUploadKeyPrefixMatched"] = uploadKeyPrefixMatched + result["urlStorageBackend"] = backend.Kind + } + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-resume-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithStoredResume(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("resume upload: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s resumed %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-retry-offset-recovery/main.go b/examples/api2-devdock-tus-retry-offset-recovery/main.go new file mode 100644 index 0000000..df25a0c --- /dev/null +++ b/examples/api2-devdock-tus-retry-offset-recovery/main.go @@ -0,0 +1,218 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strconv" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func assertRequestMethods(actual []string, expected []string) error { + if len(actual) != len(expected) { + return fmt.Errorf( + "retry offset recovery expected request methods %v, got %v", + expected, + actual, + ) + } + + for index, method := range expected { + if actual[index] != method { + return fmt.Errorf( + "retry offset recovery expected request method %s at index %d, got %s", + method, + index, + actual[index], + ) + } + } + + return nil +} + +func readOffsetHeader(response *http.Response, headerName string) (int, error) { + value := response.Header.Get(headerName) + offset, err := strconv.Atoi(value) + if err != nil || offset < 0 { + return 0, fmt.Errorf( + "retry offset recovery expected numeric %s response header, got %q", + headerName, + value, + ) + } + + return offset, nil +} + +func uploadWithRetryOffsetRecovery( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + retryOffsetRecovery, err := api2devdock.RetryOffsetRecovery(scenario) + if err != nil { + return nil, err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + recoveredOffsets := []int{} + requestMethods := []string{} + failureCandidateCount := 0 + simulatedFailureCount := 0 + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + if request.Method != http.MethodOptions { + requestMethods = append(requestMethods, request.Method) + } + + return nil + }, + AfterResponse: func(request *http.Request, response *http.Response) error { + if response == nil { + return nil + } + if request.Method == retryOffsetRecovery.RecoveryResponse.Method { + offset, err := readOffsetHeader( + response, + retryOffsetRecovery.RecoveryResponse.OffsetHeader, + ) + if err != nil { + return err + } + recoveredOffsets = append(recoveredOffsets, offset) + } + if request.Method != retryOffsetRecovery.FailAfterResponse.Method { + return nil + } + + failureCandidateCount += 1 + if failureCandidateCount != retryOffsetRecovery.FailAfterResponse.Occurrence { + return nil + } + + simulatedFailureCount += 1 + response.StatusCode = http.StatusInternalServerError + response.Status = "500 " + retryOffsetRecovery.FailAfterResponse.Message + return nil + }, + }, + ).WithContext(ctx) + + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + ChunkSize: chunkSize, + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + OnShouldRetry: func(_ error, _ int) bool { return true }, + }) + if err != nil { + return nil, err + } + if createdUpload == nil || createdUpload.Location == "" { + return nil, fmt.Errorf("retry offset recovery TUS upload did not expose an upload URL") + } + if simulatedFailureCount != retryOffsetRecovery.ExpectedFailureCount { + return nil, fmt.Errorf( + "retry offset recovery expected %d simulated failure(s), got %d", + retryOffsetRecovery.ExpectedFailureCount, + simulatedFailureCount, + ) + } + if len(recoveredOffsets) != retryOffsetRecovery.ExpectedRecoveryRequestCount { + return nil, fmt.Errorf( + "retry offset recovery expected %d recovery request(s), got %d", + retryOffsetRecovery.ExpectedRecoveryRequestCount, + len(recoveredOffsets), + ) + } + if recoveredOffsets[0] != retryOffsetRecovery.ExpectedRecoveredOffset { + return nil, fmt.Errorf( + "retry offset recovery expected recovered offset %d, got %d", + retryOffsetRecovery.ExpectedRecoveredOffset, + recoveredOffsets[0], + ) + } + if err := assertRequestMethods( + requestMethods, + retryOffsetRecovery.ExpectedRequestMethods, + ); err != nil { + return nil, err + } + + return map[string]interface{}{ + "recoveredOffsets": recoveredOffsets, + "recoveryRequestCount": len(recoveredOffsets), + "requestMethods": requestMethods, + "simulatedFailureCount": simulatedFailureCount, + "uploadUrl": createdUpload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-retry-offset-recovery/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithRetryOffsetRecovery(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("retry offset recovery: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s recovered offset for %s\n", + scenarioID, + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-retry-state-transitions/main.go b/examples/api2-devdock-tus-retry-state-transitions/main.go new file mode 100644 index 0000000..f100e4d --- /dev/null +++ b/examples/api2-devdock-tus-retry-state-transitions/main.go @@ -0,0 +1,271 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type retryStateObserver struct { + decisions []api2devdock.TusConformanceRetryDecision + err error + events []map[string]interface{} + index int + retryDelays []time.Duration +} + +func newRetryStateObserver( + decisions []api2devdock.TusConformanceRetryDecision, + retryDelays []time.Duration, +) *retryStateObserver { + return &retryStateObserver{ + decisions: decisions, + events: []map[string]interface{}{}, + retryDelays: retryDelays, + } +} + +func (observer *retryStateObserver) onShouldRetry(_ error, retryAttempt int) bool { + if observer.index >= len(observer.decisions) { + observer.err = fmt.Errorf( + "retry state transition received unexpected retry decision request %d", + observer.index, + ) + return false + } + + decision := observer.decisions[observer.index] + if retryAttempt != decision.RetryAttempt { + observer.err = fmt.Errorf( + "retry state transition expected retry attempt %d, got %d", + decision.RetryAttempt, + retryAttempt, + ) + return false + } + + observer.events = append(observer.events, map[string]interface{}{ + "decision": decision.Decision, + "kind": "should-retry", + "retryAttempt": retryAttempt, + }) + if decision.Decision { + if retryAttempt < 0 || retryAttempt >= len(observer.retryDelays) { + observer.err = fmt.Errorf( + "retry state transition retry attempt %d has no retry delay", + retryAttempt, + ) + return false + } + observer.events = append(observer.events, map[string]interface{}{ + "delay": observer.retryDelays[retryAttempt].Milliseconds(), + "kind": "retry-schedule", + }) + } + observer.index += 1 + + return decision.Decision +} + +func (observer *retryStateObserver) assertComplete() error { + if observer.err != nil { + return observer.err + } + if observer.index != len(observer.decisions) { + return fmt.Errorf( + "retry state transition expected %d retry decision(s), got %d", + len(observer.decisions), + observer.index, + ) + } + + return nil +} + +func tusConformanceRetryDelays( + conformanceScenario map[string]interface{}, +) ([]time.Duration, error) { + options, err := api2devdock.TusConformanceInputOptions(conformanceScenario) + if err != nil { + return nil, err + } + rawRetryDelays, ok := options["retryDelays"] + if !ok { + return nil, fmt.Errorf("conformanceScenario.inputOptionEntries.retryDelays is required") + } + delayValues, err := api2devdock.IntArrayValue( + rawRetryDelays, + "conformanceScenario.inputOptionEntries.retryDelays", + ) + if err != nil { + return nil, err + } + + retryDelays := make([]time.Duration, 0, len(delayValues)) + for _, delayValue := range delayValues { + retryDelays = append(retryDelays, time.Duration(delayValue)*time.Millisecond) + } + + return retryDelays, nil +} + +func uploadWithRetryStateTransitions( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointOrigin, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + metadata, err := api2devdock.TusConformanceInputStringMapOption( + conformanceScenario, + "metadata", + ) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + retryDecisions, err := api2devdock.TusConformanceRetryDecisions(conformanceScenario) + if err != nil { + return nil, err + } + retryDelays, err := tusConformanceRetryDelays(conformanceScenario) + if err != nil { + return nil, err + } + capabilityPlan, err := api2devdock.TusConformanceServerCapabilities(conformanceScenario) + if err != nil { + return nil, err + } + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return nil, err + } + completionKind, err := api2devdock.StringValue( + completion["kind"], + "conformanceScenario.completion.kind", + ) + if err != nil { + return nil, err + } + + conformanceServer, err := api2devdock.NewTusConformancePlanServer( + conformanceScenario, + endpointOrigin, + ) + if err != nil { + return nil, err + } + defer conformanceServer.Close() + localEndpointURL, err := conformanceServer.EndpointURL() + if err != nil { + return nil, err + } + + observer := newRetryStateObserver(retryDecisions, retryDelays) + successCalled := false + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + client.Capabilities = &tusgo.ServerCapabilities{ + Extensions: capabilityPlan.ExtensionNames, + ProtocolVersions: capabilityPlan.ProtocolVersions, + } + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + EventHooks: tusgo.UploadEventHooks{OnSuccess: func(tusgo.UploadSuccessPayload) error { + successCalled = true + return nil + }}, + Fingerprint: "api2-go-retry-state-conformance-fingerprint", + Metadata: metadata, + OnShouldRetry: observer.onShouldRetry, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if err := observer.assertComplete(); err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("retry state transition TUS upload did not expose an upload URL") + } + if err := conformanceServer.AssertExhausted(); err != nil { + return nil, err + } + + result, err := conformanceServer.Result() + if err != nil { + return nil, err + } + canonicalUploadURL, err := conformanceServer.CanonicalURL(upload.Location) + if err != nil { + return nil, err + } + result["completionKind"] = completionKind + result["errorCalled"] = false + result["eventCount"] = len(observer.events) + result["events"] = observer.events + result["successCalled"] = successCalled + result["uploadUrl"] = canonicalUploadURL + + return result, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-retry-state-transitions/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := uploadWithRetryStateTransitions(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("retry state transitions: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s observed %d retry event(s) for %s\n", + scenarioID, + result["eventCount"], + result["uploadUrl"], + ) +} diff --git a/examples/api2-devdock-tus-start-option-validation/main.go b/examples/api2-devdock-tus-start-option-validation/main.go new file mode 100644 index 0000000..7c3431a --- /dev/null +++ b/examples/api2-devdock-tus-start-option-validation/main.go @@ -0,0 +1,174 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func startOptionValidationCompletion( + conformanceScenario map[string]interface{}, +) (string, string, error) { + completion, err := api2devdock.ObjectValue( + conformanceScenario["completion"], + "conformanceScenario.completion", + ) + if err != nil { + return "", "", err + } + reason, err := api2devdock.StringValue( + completion["reason"], + "conformanceScenario.completion.reason", + ) + if err != nil { + return "", "", err + } + message, err := api2devdock.StringValue( + completion["message"], + "conformanceScenario.completion.message", + ) + if err != nil { + return "", "", err + } + + return reason, message, nil +} + +func localEndpointURLForStartValidation( + conformanceScenario map[string]interface{}, + serverURL string, +) (*url.URL, error) { + endpointURLValue, err := api2devdock.TusConformanceInputStringOption( + conformanceScenario, + "endpointUrl", + ) + if err != nil { + return nil, err + } + endpointURL, err := url.Parse(endpointURLValue) + if err != nil { + return nil, err + } + localURL, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + localURL.Path = endpointURL.Path + localURL.RawQuery = endpointURL.RawQuery + + return localURL, nil +} + +func validateStartOptions( + ctx context.Context, + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + reason, expectedMessage, err := startOptionValidationCompletion(conformanceScenario) + if err != nil { + return nil, err + } + content, err := api2devdock.TusConformanceInputSourceBytes(conformanceScenario) + if err != nil { + return nil, err + } + parallelUploads, err := api2devdock.TusConformanceInputIntOption( + conformanceScenario, + "parallelUploads", + ) + if err != nil { + return nil, err + } + uploadDataDuringCreation, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "uploadDataDuringCreation", + false, + ) + if err != nil { + return nil, err + } + uploadLengthDeferred, err := api2devdock.TusConformanceInputBoolOption( + conformanceScenario, + "uploadLengthDeferred", + false, + ) + if err != nil { + return nil, err + } + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + requestCount += 1 + responseWriter.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + localEndpointURL, err := localEndpointURLForStartValidation(conformanceScenario, server.URL) + if err != nil { + return nil, err + } + client := tusgo.NewClient(http.DefaultClient, localEndpointURL).WithContext(ctx) + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: "api2-go-start-option-validation-" + reason, + ParallelUploads: parallelUploads, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + UploadDataDuringCreation: uploadDataDuringCreation, + UploadLengthDeferred: uploadLengthDeferred, + }) + if err == nil { + return nil, fmt.Errorf("start option validation unexpectedly created upload %#v", upload) + } + if err.Error() != expectedMessage { + return nil, fmt.Errorf("expected start validation error %q, got %q", expectedMessage, err.Error()) + } + if upload != nil { + return nil, fmt.Errorf("start option validation returned upload %#v", upload) + } + + return map[string]interface{}{ + "errorCaught": true, + "errorMessage": err.Error(), + "requestCount": requestCount, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-start-option-validation/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + conformanceScenario, err := api2devdock.TusConformanceScenario(scenario) + if err != nil { + api2devdock.Fail("read conformance scenario: %v", err) + } + + result, err := validateStartOptions(ctx, conformanceScenario) + if err != nil { + api2devdock.Fail("start option validation: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s rejected conflicting start options\n", scenarioID) +} diff --git a/examples/api2-devdock-tus-terminate-upload/main.go b/examples/api2-devdock-tus-terminate-upload/main.go new file mode 100644 index 0000000..a2f759f --- /dev/null +++ b/examples/api2-devdock-tus-terminate-upload/main.go @@ -0,0 +1,186 @@ +//go:build api2devdock + +package main + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type requestCountingTransport struct { + base http.RoundTripper + methods []string + mu sync.Mutex +} + +func (transport *requestCountingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + transport.mu.Lock() + transport.methods = append(transport.methods, request.Method) + transport.mu.Unlock() + + base := transport.base + if base == nil { + base = http.DefaultTransport + } + + return base.RoundTrip(request) +} + +func (transport *requestCountingTransport) Count(method string) int { + transport.mu.Lock() + defer transport.mu.Unlock() + + count := 0 + for _, candidate := range transport.methods { + if candidate == method { + count += 1 + } + } + + return count +} + +func (transport *requestCountingTransport) Methods() []string { + transport.mu.Lock() + defer transport.mu.Unlock() + + methods := make([]string, len(transport.methods)) + copy(methods, transport.methods) + return methods +} + +func verifyTerminatedUpload( + ctx context.Context, + httpClient *http.Client, + termination api2devdock.TerminationPlan, + uploadURL string, +) (int, error) { + request, err := http.NewRequestWithContext(ctx, termination.VerificationMethod, uploadURL, nil) + if err != nil { + return 0, err + } + for name, value := range tusgo.DefaultProtocolRequestHeaders() { + request.Header.Set(name, value) + } + + response, err := httpClient.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + + return response.StatusCode, nil +} + +func uploadAndTerminate( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + chunkSize, err := api2devdock.FixedChunkSizeBytes(scenario) + if err != nil { + return nil, err + } + termination, err := api2devdock.Termination(scenario) + if err != nil { + return nil, err + } + if termination.StopAfterAcceptedBytes > len(content) { + return nil, fmt.Errorf( + "stop-after bytes %d exceeds content length %d", + termination.StopAfterAcceptedBytes, + len(content), + ) + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + + transport := &requestCountingTransport{} + httpClient := &http.Client{Transport: transport} + client := tusgo.NewClient(httpClient, endpointURL).WithContext(ctx) + upload := tusgo.Upload{} + if _, err := client.CreateUpload(&upload, int64(len(content)), false, metadata); err != nil { + return nil, err + } + if upload.Location == "" { + return nil, fmt.Errorf("created upload did not include a Location") + } + + stream := tusgo.NewUploadStream(client, &upload) + stream.ChunkSize = chunkSize + stopAfterAcceptedBytes := termination.StopAfterAcceptedBytes + written, err := stream.Write(content[:stopAfterAcceptedBytes]) + if err != nil { + return nil, err + } + if written != stopAfterAcceptedBytes { + return nil, fmt.Errorf("wrote %d bytes, expected %d", written, stopAfterAcceptedBytes) + } + acceptedBytes := int(upload.RemoteOffset) + if acceptedBytes != stopAfterAcceptedBytes { + return nil, fmt.Errorf("accepted %d bytes, expected %d", acceptedBytes, stopAfterAcceptedBytes) + } + + if _, err := client.TerminateUploadWithRetry(upload, tusgo.TerminateUploadOptions{}); err != nil { + return nil, err + } + verificationStatus, err := verifyTerminatedUpload(ctx, httpClient, termination, upload.Location) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "acceptedBytes": acceptedBytes, + "deleteRequestCount": transport.Count(http.MethodDelete), + "requestMethods": transport.Methods(), + "terminated": true, + "uploadUrl": upload.Location, + "verificationStatus": verificationStatus, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-terminate-upload/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadAndTerminate(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("terminate upload: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s terminated %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-upload-body-headers/main.go b/examples/api2-devdock-tus-upload-body-headers/main.go new file mode 100644 index 0000000..26ae9ea --- /dev/null +++ b/examples/api2-devdock-tus-upload-body-headers/main.go @@ -0,0 +1,163 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "reflect" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +func observedBodyHeaders( + request *http.Request, + expectedHeaders map[string]string, +) map[string]string { + observed := map[string]string{} + for name := range expectedHeaders { + observed[name] = request.Header.Get(name) + } + + return observed +} + +func assertObservedBodyHeaders( + label string, + actual map[string]string, + expected map[string]string, +) error { + if !reflect.DeepEqual(actual, expected) { + return fmt.Errorf("%s expected body headers %v, got %v", label, expected, actual) + } + + return nil +} + +func uploadWithBodyHeaders( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + expectedBodyHeadersByMethod, err := api2devdock.UploadBodyHeadersByMethod(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + bodyHeadersByMethod := map[string]map[string]string{} + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithRequestLifecycleHooks( + tusgo.RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + switch request.Method { + case http.MethodPost, http.MethodPatch: + expectedHeaders, ok := expectedBodyHeadersByMethod[request.Method] + if !ok { + return nil + } + bodyHeadersByMethod[request.Method] = observedBodyHeaders(request, expectedHeaders) + } + + return nil + }, + }, + ).WithContext(ctx) + + upload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: bytes.NewReader(content), + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if upload == nil || upload.Location == "" { + return nil, fmt.Errorf("upload body headers TUS upload did not expose an upload URL") + } + if upload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "upload body headers upload accepted %d bytes, expected %d", + upload.RemoteOffset, + len(content), + ) + } + if err := assertObservedBodyHeaders( + "POST", + bodyHeadersByMethod[http.MethodPost], + expectedBodyHeadersByMethod[http.MethodPost], + ); err != nil { + return nil, err + } + if err := assertObservedBodyHeaders( + "PATCH", + bodyHeadersByMethod[http.MethodPatch], + expectedBodyHeadersByMethod[http.MethodPatch], + ); err != nil { + return nil, err + } + + return map[string]interface{}{ + "bodyHeadersByMethod": bodyHeadersByMethod, + "uploadUrl": upload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-upload-body-headers/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithBodyHeaders(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("upload body headers: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf("Go TUS SDK devdock scenario %s observed body headers to %s\n", scenarioID, result["uploadUrl"]) +} diff --git a/examples/api2-devdock-tus-upload-callbacks/main.go b/examples/api2-devdock-tus-upload-callbacks/main.go new file mode 100644 index 0000000..b7f80ad --- /dev/null +++ b/examples/api2-devdock-tus-upload-callbacks/main.go @@ -0,0 +1,194 @@ +//go:build api2devdock + +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + tusgo "github.com/bdragon300/tusgo" + "github.com/bdragon300/tusgo/examples/api2devdock" +) + +type eventRecordingReadSeeker struct { + *bytes.Reader + callbacks api2devdock.UploadCallbacksPlan + events *[]string +} + +func (source *eventRecordingReadSeeker) Close() error { + *source.events = append( + *source.events, + api2devdock.UploadCallbackEventKey( + source.callbacks, + source.callbacks.EventKinds.SourceClose, + ), + ) + + return nil +} + +func uploadEventHooks( + callbacks api2devdock.UploadCallbacksPlan, + events *[]string, +) tusgo.UploadEventHooks { + return tusgo.UploadEventHooks{ + OnUploadURLAvailable: func() error { + *events = append( + *events, + api2devdock.UploadCallbackEventKey( + callbacks, + callbacks.EventKinds.UploadURLAvailable, + ), + ) + return nil + }, + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + *events = append( + *events, + api2devdock.UploadCallbackEventKey( + callbacks, + callbacks.EventKinds.Progress, + api2devdock.UploadCallbackEventKeyNumber(bytesSent), + api2devdock.UploadCallbackEventKeyTotal(bytesTotal), + ), + ) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + *events = append( + *events, + api2devdock.UploadCallbackEventKey( + callbacks, + callbacks.EventKinds.ChunkComplete, + api2devdock.UploadCallbackEventKeyNumber(chunkSize), + api2devdock.UploadCallbackEventKeyNumber(bytesAccepted), + api2devdock.UploadCallbackEventKeyTotal(bytesTotal), + ), + ) + return nil + }, + OnSuccess: func(payload tusgo.UploadSuccessPayload) error { + if payload.Upload == nil || payload.Upload.Location == "" { + return fmt.Errorf("upload callback success payload did not include an upload URL") + } + *events = append( + *events, + api2devdock.UploadCallbackEventKey(callbacks, callbacks.EventKinds.Success), + ) + return nil + }, + } +} + +func uploadWithCallbacks( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]interface{}, error) { + callbacks, err := api2devdock.UploadCallbacks(scenario) + if err != nil { + return nil, err + } + if err := api2devdock.RequireFullFileChunkSize(scenario); err != nil { + return nil, err + } + endpointURL, err := api2devdock.TusURL(scenario, createResponse) + if err != nil { + return nil, err + } + content, err := api2devdock.ScenarioBytes(scenario) + if err != nil { + return nil, err + } + metadata, err := api2devdock.UploadMetadata(scenario, createResponse) + if err != nil { + return nil, err + } + retryDelays, err := api2devdock.RetryDelays(scenario) + if err != nil { + return nil, err + } + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + return nil, err + } + + events := []string{} + source := &eventRecordingReadSeeker{ + Reader: bytes.NewReader(content), + callbacks: callbacks, + events: &events, + } + client := tusgo.NewClient(http.DefaultClient, endpointURL).WithContext(ctx) + createdUpload, err := client.UploadWithURLStorage(tusgo.URLStorageUploadOptions{ + Context: ctx, + EventHooks: uploadEventHooks(callbacks, &events), + Fingerprint: scenarioID + "-fingerprint", + Metadata: metadata, + RetryDelays: retryDelays, + Size: int64(len(content)), + Source: source, + Storage: tusgo.NewMemoryURLStorage(), + }) + if err != nil { + return nil, err + } + if createdUpload == nil || createdUpload.Location == "" { + return nil, fmt.Errorf("upload callback TUS upload did not expose an upload URL") + } + if createdUpload.RemoteOffset != int64(len(content)) { + return nil, fmt.Errorf( + "upload callback scenario accepted %d bytes, expected %d", + createdUpload.RemoteOffset, + len(content), + ) + } + matchedEvents, err := api2devdock.MatchUploadCallbackEventKeys(callbacks, events) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "eventKeys": matchedEvents, + "rawEventKeys": events, + "uploadUrl": createdUpload.Location, + }, nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := api2devdock.LoadScenario( + "examples/api2-devdock-tus-upload-callbacks/api2-scenario.json", + ) + if err != nil { + api2devdock.Fail("load scenario: %v", err) + } + createResponse, err := api2devdock.CreateResponseFromScenario(scenario) + if err != nil { + api2devdock.Fail("read prepared create response: %v", err) + } + + result, err := uploadWithCallbacks(ctx, scenario, createResponse) + if err != nil { + api2devdock.Fail("upload callbacks: %v", err) + } + if err := api2devdock.WriteResult(result); err != nil { + api2devdock.Fail("write result: %v", err) + } + + scenarioID, err := api2devdock.ScenarioID(scenario) + if err != nil { + api2devdock.Fail("read scenario id: %v", err) + } + fmt.Printf( + "Go TUS SDK devdock scenario %s observed upload callbacks for %s\n", + scenarioID, + result["uploadUrl"], + ) +} diff --git a/examples/api2devdock/conformance_server.go b/examples/api2devdock/conformance_server.go new file mode 100644 index 0000000..a477888 --- /dev/null +++ b/examples/api2devdock/conformance_server.go @@ -0,0 +1,672 @@ +package api2devdock + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "time" +) + +type TusConformanceObservedRequest struct { + AbsentHeaderPresence map[string]bool + BodySize int + BodyStart *int + Headers map[string]string + Method string + URL string +} + +type tusConformanceRequestGate struct { + GateID string + HeldRequestIndexes map[int]bool + ReleaseAfterRequestIndexes []int + Timeout time.Duration +} + +type TusConformancePlanServer struct { + endpointOrigin *url.URL + errs []error + gates []tusConformanceRequestGate + mu sync.Mutex + observed []*TusConformanceObservedRequest + observedCount int + requests []interface{} + server *httptest.Server + sourceContent string +} + +func NewTusConformancePlanServer( + conformanceScenario map[string]interface{}, + endpointOrigin *url.URL, +) (*TusConformancePlanServer, error) { + requests, err := ArrayValue( + conformanceScenario["requests"], + "conformanceScenario.requests", + ) + if err != nil { + return nil, err + } + gates, err := conformanceServerRequestGates(conformanceScenario) + if err != nil { + return nil, err + } + sourceContent, err := conformanceInputSourceContent(conformanceScenario) + if err != nil { + return nil, err + } + + conformanceServer := &TusConformancePlanServer{ + endpointOrigin: endpointOrigin, + gates: gates, + observed: make([]*TusConformanceObservedRequest, len(requests)), + requests: requests, + sourceContent: sourceContent, + } + conformanceServer.server = httptest.NewServer(conformanceServer) + + return conformanceServer, nil +} + +func (conformanceServer *TusConformancePlanServer) Close() { + conformanceServer.server.Close() +} + +func (conformanceServer *TusConformancePlanServer) EndpointURL() (*url.URL, error) { + localEndpointURL, err := conformanceServer.LocalURL(conformanceServer.endpointOrigin.String()) + if err != nil { + return nil, err + } + + return url.Parse(localEndpointURL) +} + +func (conformanceServer *TusConformancePlanServer) LocalURL(canonicalURL string) (string, error) { + parsedCanonical, err := url.Parse(canonicalURL) + if err != nil { + return "", err + } + if parsedCanonical.Scheme != conformanceServer.endpointOrigin.Scheme || + parsedCanonical.Host != conformanceServer.endpointOrigin.Host { + return canonicalURL, nil + } + + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + localURL := *parsedCanonical + localURL.Scheme = serverURL.Scheme + localURL.Host = serverURL.Host + + return localURL.String(), nil +} + +func (conformanceServer *TusConformancePlanServer) LocalValue(canonicalValue string) (string, error) { + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + + return strings.ReplaceAll( + canonicalValue, + conformanceServer.endpointOrigin.Scheme+"://"+conformanceServer.endpointOrigin.Host, + serverURL.Scheme+"://"+serverURL.Host, + ), nil +} + +func (conformanceServer *TusConformancePlanServer) CanonicalURL(actualURL string) (string, error) { + parsedActual, err := url.Parse(actualURL) + if err != nil { + return "", err + } + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + if parsedActual.Scheme != serverURL.Scheme || parsedActual.Host != serverURL.Host { + return actualURL, nil + } + + canonical := *parsedActual + canonical.Scheme = conformanceServer.endpointOrigin.Scheme + canonical.Host = conformanceServer.endpointOrigin.Host + + return canonical.String(), nil +} + +func (conformanceServer *TusConformancePlanServer) CanonicalValue(actualValue string) (string, error) { + serverURL, err := url.Parse(conformanceServer.server.URL) + if err != nil { + return "", err + } + + return strings.ReplaceAll( + actualValue, + serverURL.Scheme+"://"+serverURL.Host, + conformanceServer.endpointOrigin.Scheme+"://"+conformanceServer.endpointOrigin.Host, + ), nil +} + +func (conformanceServer *TusConformancePlanServer) AssertExhausted() error { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + if conformanceServer.observedCount != len(conformanceServer.requests) { + return fmt.Errorf( + "expected %d conformance request(s), got %d", + len(conformanceServer.requests), + conformanceServer.observedCount, + ) + } + + return nil +} + +func (conformanceServer *TusConformancePlanServer) Result() (map[string]interface{}, error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + if len(conformanceServer.errs) > 0 { + return nil, conformanceServer.errs[0] + } + + absentHeaderPresence := make([]map[string]bool, 0, len(conformanceServer.observed)) + requestBodySizes := make([]int, 0, len(conformanceServer.observed)) + requestBodyStarts := make([]interface{}, 0, len(conformanceServer.observed)) + requestHeaders := make([]map[string]string, 0, len(conformanceServer.observed)) + requestMethods := make([]string, 0, len(conformanceServer.observed)) + requestURLs := make([]string, 0, len(conformanceServer.observed)) + for _, request := range conformanceServer.observed { + if request == nil { + requestBodySizes = append(requestBodySizes, 0) + requestBodyStarts = append(requestBodyStarts, nil) + requestHeaders = append(requestHeaders, map[string]string{}) + requestMethods = append(requestMethods, "") + requestURLs = append(requestURLs, "") + absentHeaderPresence = append(absentHeaderPresence, map[string]bool{}) + continue + } + + requestBodySizes = append(requestBodySizes, request.BodySize) + if request.BodyStart == nil { + requestBodyStarts = append(requestBodyStarts, nil) + } else { + requestBodyStarts = append(requestBodyStarts, *request.BodyStart) + } + requestHeaders = append(requestHeaders, request.Headers) + requestMethods = append(requestMethods, request.Method) + requestURLs = append(requestURLs, request.URL) + absentHeaderPresence = append(absentHeaderPresence, request.AbsentHeaderPresence) + } + + return map[string]interface{}{ + "absentHeaderPresence": absentHeaderPresence, + "requestBodySizes": requestBodySizes, + "requestBodyStarts": requestBodyStarts, + "requestCount": conformanceServer.observedCount, + "requestHeaders": requestHeaders, + "requestMethods": requestMethods, + "requestUrls": requestURLs, + }, nil +} + +func (conformanceServer *TusConformancePlanServer) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + body, err := io.ReadAll(request.Body) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + + requestIndex, requestPlan, err := conformanceServer.observeMatchingRequest(request, body) + if err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + if err := conformanceServer.waitForRequestGate(requestIndex); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + return + } + if err := conformanceServer.writeResponse(responseWriter, requestIndex, requestPlan); err != nil { + conformanceServer.recordErr(err) + responseWriter.WriteHeader(http.StatusInternalServerError) + } +} + +func (conformanceServer *TusConformancePlanServer) observeMatchingRequest( + request *http.Request, + body []byte, +) ( + int, + map[string]interface{}, + error, +) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + mismatches := []string{} + for requestIndex, rawRequestPlan := range conformanceServer.requests { + if conformanceServer.observed[requestIndex] != nil { + continue + } + requestPlan, err := ObjectValue( + rawRequestPlan, + fmt.Sprintf("conformanceScenario.requests[%d]", requestIndex), + ) + if err != nil { + return 0, nil, err + } + observed, err := conformanceServer.observedRequest(requestIndex, requestPlan, request, body) + if err != nil { + mismatches = append(mismatches, fmt.Sprintf("request %d: %v", requestIndex, err)) + continue + } + if err := conformanceServer.validateRequest(requestIndex, requestPlan, observed); err != nil { + mismatches = append(mismatches, fmt.Sprintf("request %d: %v", requestIndex, err)) + continue + } + conformanceServer.observed[requestIndex] = &observed + conformanceServer.observedCount += 1 + + return requestIndex, requestPlan, nil + } + + return 0, nil, fmt.Errorf( + "unexpected request %s %s after %d observed request(s): %s", + request.Method, + conformanceServer.endpointOrigin.ResolveReference(request.URL).String(), + conformanceServer.observedCount, + strings.Join(mismatches, "; "), + ) +} + +func (conformanceServer *TusConformancePlanServer) observedRequest( + requestIndex int, + requestPlan map[string]interface{}, + request *http.Request, + body []byte, +) (TusConformanceObservedRequest, error) { + expectedHeaders, err := conformanceRequestHeaders(requestIndex, requestPlan) + if err != nil { + return TusConformanceObservedRequest{}, err + } + absentHeaders, err := conformanceAbsentRequestHeaders(requestIndex, requestPlan) + if err != nil { + return TusConformanceObservedRequest{}, err + } + + requestHeaders := map[string]string{} + for name := range expectedHeaders { + value, err := conformanceServer.CanonicalValue(request.Header.Get(name)) + if err != nil { + return TusConformanceObservedRequest{}, err + } + requestHeaders[name] = value + } + absentHeaderPresence := map[string]bool{} + for _, name := range absentHeaders { + absentHeaderPresence[name] = request.Header.Get(name) != "" + } + bodyStart, err := conformanceServer.requestBodyStart(requestIndex, requestPlan, body) + if err != nil { + return TusConformanceObservedRequest{}, err + } + + return TusConformanceObservedRequest{ + AbsentHeaderPresence: absentHeaderPresence, + BodySize: len(body), + BodyStart: bodyStart, + Headers: requestHeaders, + Method: request.Method, + URL: conformanceServer.endpointOrigin.ResolveReference(request.URL).String(), + }, nil +} + +func (conformanceServer *TusConformancePlanServer) validateRequest( + requestIndex int, + requestPlan map[string]interface{}, + request TusConformanceObservedRequest, +) error { + expectedURL, err := StringValue( + requestPlan["expectedUrl"], + fmt.Sprintf("conformanceScenario.requests[%d].expectedUrl", requestIndex), + ) + if err != nil { + return err + } + expectedMethod, err := StringValue( + requestPlan["effectiveMethod"], + fmt.Sprintf("conformanceScenario.requests[%d].effectiveMethod", requestIndex), + ) + if err != nil { + return err + } + if request.URL != expectedURL { + return fmt.Errorf("request %d expected URL %s, got %s", requestIndex, expectedURL, request.URL) + } + if request.Method != expectedMethod { + return fmt.Errorf( + "request %d expected method %s, got %s", + requestIndex, + expectedMethod, + request.Method, + ) + } + + expectedHeaders, err := conformanceRequestHeaders(requestIndex, requestPlan) + if err != nil { + return err + } + for name, expectedValue := range expectedHeaders { + if request.Headers[name] == expectedValue { + continue + } + + return fmt.Errorf( + "request %d expected header %s=%q, got %q", + requestIndex, + name, + expectedValue, + request.Headers[name], + ) + } + absentHeaders, err := conformanceAbsentRequestHeaders(requestIndex, requestPlan) + if err != nil { + return err + } + for _, name := range absentHeaders { + if !request.AbsentHeaderPresence[name] { + continue + } + + return fmt.Errorf("request %d expected header %s to be absent", requestIndex, name) + } + + rawBodySize, ok := requestPlan["bodySize"] + if !ok || rawBodySize == nil { + return nil + } + expectedBodySize, err := IntValue( + rawBodySize, + fmt.Sprintf("conformanceScenario.requests[%d].bodySize", requestIndex), + ) + if err != nil { + return err + } + if request.BodySize != expectedBodySize { + return fmt.Errorf( + "request %d expected body size %d, got %d", + requestIndex, + expectedBodySize, + request.BodySize, + ) + } + + return nil +} + +func (conformanceServer *TusConformancePlanServer) recordErr(err error) { + conformanceServer.mu.Lock() + defer conformanceServer.mu.Unlock() + + conformanceServer.errs = append(conformanceServer.errs, err) +} + +func (conformanceServer *TusConformancePlanServer) waitForRequestGate(requestIndex int) error { + for _, gate := range conformanceServer.gates { + if !gate.HeldRequestIndexes[requestIndex] { + continue + } + deadline := time.Now().Add(gate.Timeout) + for { + conformanceServer.mu.Lock() + released := conformanceServer.requestGateReleased(gate) + conformanceServer.mu.Unlock() + if released { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf( + "request %d timed out waiting for conformance gate %s", + requestIndex, + gate.GateID, + ) + } + time.Sleep(10 * time.Millisecond) + } + } + + return nil +} + +func (conformanceServer *TusConformancePlanServer) requestGateReleased( + gate tusConformanceRequestGate, +) bool { + for _, requestIndex := range gate.ReleaseAfterRequestIndexes { + if requestIndex < 0 || requestIndex >= len(conformanceServer.observed) { + return false + } + if conformanceServer.observed[requestIndex] == nil { + return false + } + } + + return true +} + +func (conformanceServer *TusConformancePlanServer) writeResponse( + responseWriter http.ResponseWriter, + requestIndex int, + requestPlan map[string]interface{}, +) error { + responsePlan, err := ObjectValue( + requestPlan["response"], + fmt.Sprintf("conformanceScenario.requests[%d].response", requestIndex), + ) + if err != nil { + return err + } + headers, err := StringMapValue( + responsePlan["effectiveHeaders"], + fmt.Sprintf("conformanceScenario.requests[%d].response.effectiveHeaders", requestIndex), + ) + if err != nil { + return err + } + for name, value := range headers { + localValue, err := conformanceServer.LocalValue(value) + if err != nil { + return err + } + responseWriter.Header().Set(name, localValue) + } + statusCode, err := IntValue( + responsePlan["statusCode"], + fmt.Sprintf("conformanceScenario.requests[%d].response.statusCode", requestIndex), + ) + if err != nil { + return err + } + responseWriter.WriteHeader(statusCode) + + rawBody, ok := responsePlan["body"] + if !ok || rawBody == nil { + return nil + } + body, err := StringValue( + rawBody, + fmt.Sprintf("conformanceScenario.requests[%d].response.body", requestIndex), + ) + if err != nil { + return err + } + _, err = responseWriter.Write([]byte(body)) + + return err +} + +func conformanceRequestHeaders( + requestIndex int, + requestPlan map[string]interface{}, +) (map[string]string, error) { + rawHeaders, ok := requestPlan["effectiveHeaders"] + if !ok { + return map[string]string{}, nil + } + + return StringMapValue( + rawHeaders, + fmt.Sprintf("conformanceScenario.requests[%d].effectiveHeaders", requestIndex), + ) +} + +func conformanceAbsentRequestHeaders( + requestIndex int, + requestPlan map[string]interface{}, +) ([]string, error) { + rawHeaders, ok := requestPlan["absentHeaders"] + if !ok { + return []string{}, nil + } + + return StringArrayValue( + rawHeaders, + fmt.Sprintf("conformanceScenario.requests[%d].absentHeaders", requestIndex), + ) +} + +func conformanceInputSourceContent(conformanceScenario map[string]interface{}) (string, error) { + rawSource, ok := conformanceScenario["inputSource"] + if !ok || rawSource == nil { + return "", nil + } + source, err := ObjectValue(rawSource, "conformanceScenario.inputSource") + if err != nil { + return "", err + } + rawContent, ok := source["content"] + if !ok || rawContent == nil { + return "", nil + } + + return StringValue(rawContent, "conformanceScenario.inputSource.content") +} + +func (conformanceServer *TusConformancePlanServer) requestBodyStart( + requestIndex int, + requestPlan map[string]interface{}, + body []byte, +) (*int, error) { + rawBodyStart, ok := requestPlan["bodyStart"] + if !ok || rawBodyStart == nil { + return nil, nil + } + bodyStart, err := IntValue( + rawBodyStart, + fmt.Sprintf("conformanceScenario.requests[%d].bodyStart", requestIndex), + ) + if err != nil { + return nil, err + } + bodyEnd := bodyStart + len(body) + if bodyStart < 0 || bodyEnd > len(conformanceServer.sourceContent) { + return nil, fmt.Errorf( + "request %d body range [%d:%d] exceeds source content length %d", + requestIndex, + bodyStart, + bodyEnd, + len(conformanceServer.sourceContent), + ) + } + expectedBody := conformanceServer.sourceContent[bodyStart:bodyEnd] + if string(body) != expectedBody { + return nil, fmt.Errorf( + "request %d expected body slice %q, got %q", + requestIndex, + expectedBody, + string(body), + ) + } + + return &bodyStart, nil +} + +func conformanceServerRequestGates( + conformanceScenario map[string]interface{}, +) ([]tusConformanceRequestGate, error) { + rawExecution, ok := conformanceScenario["execution"] + if !ok || rawExecution == nil { + return []tusConformanceRequestGate{}, nil + } + execution, err := ObjectValue(rawExecution, "conformanceScenario.execution") + if err != nil { + return nil, err + } + rawGates, ok := execution["serverRequestGates"] + if !ok || rawGates == nil { + return []tusConformanceRequestGate{}, nil + } + gateItems, err := ArrayValue(rawGates, "conformanceScenario.execution.serverRequestGates") + if err != nil { + return nil, err + } + + gates := make([]tusConformanceRequestGate, 0, len(gateItems)) + for index, rawGate := range gateItems { + label := fmt.Sprintf("conformanceScenario.execution.serverRequestGates[%d]", index) + gate, err := ObjectValue(rawGate, label) + if err != nil { + return nil, err + } + kind, err := StringValue(gate["kind"], label+".kind") + if err != nil { + return nil, err + } + if kind != "release-after-all-started" { + return nil, fmt.Errorf("unsupported conformance server request gate kind %q", kind) + } + gateID, err := StringValue(gate["gateId"], label+".gateId") + if err != nil { + return nil, err + } + heldRequestIndexes, err := IntArrayValue( + gate["heldRequestIndexes"], + label+".heldRequestIndexes", + ) + if err != nil { + return nil, err + } + releaseAfterRequestIndexes, err := IntArrayValue( + gate["releaseAfterRequestIndexes"], + label+".releaseAfterRequestIndexes", + ) + if err != nil { + return nil, err + } + timeoutMs, err := IntValue(gate["timeoutMs"], label+".timeoutMs") + if err != nil { + return nil, err + } + held := map[int]bool{} + for _, requestIndex := range heldRequestIndexes { + held[requestIndex] = true + } + gates = append(gates, tusConformanceRequestGate{ + GateID: gateID, + HeldRequestIndexes: held, + ReleaseAfterRequestIndexes: releaseAfterRequestIndexes, + Timeout: time.Duration(timeoutMs) * time.Millisecond, + }) + } + + return gates, nil +} diff --git a/examples/api2devdock/scenario.go b/examples/api2devdock/scenario.go new file mode 100644 index 0000000..7a8ff7f --- /dev/null +++ b/examples/api2devdock/scenario.go @@ -0,0 +1,1331 @@ +package api2devdock + +import ( + "encoding/json" + "fmt" + "math" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type TerminationPlan struct { + ExpectedVerificationStatus int + MinimumDeleteRequestCount int + StopAfterAcceptedBytes int + VerificationMethod string +} + +type ResumePlan struct { + ExpectedPreviousUploadCount int + ExpectedRemainingPreviousUploadCount int + Fingerprint string + RemoveFingerprintOnSuccess bool + StopAfterAcceptedBytes int +} + +type URLStorageBackendPlan struct { + ExpectedStoredUploadKeyPrefix string + Kind string +} + +type RetryOffsetRecoveryResponsePlan struct { + Method string + OffsetHeader string +} + +type RetryOffsetRecoveryFailurePlan struct { + Message string + Method string + Occurrence int +} + +type RetryOffsetRecoveryPlan struct { + ExpectedFailureCount int + ExpectedRecoveredOffset int + ExpectedRecoveryRequestCount int + ExpectedRequestMethods []string + FailAfterResponse RetryOffsetRecoveryFailurePlan + RecoveryResponse RetryOffsetRecoveryResponsePlan +} + +type TusConformanceRetryDecision struct { + Decision bool + RetryAttempt int +} + +type RequestLifecycleHooksPlan struct { + ExpectedAfterResponseMethods []string + ExpectedAfterResponseStatusCodes []int + ExpectedBeforeRequestMethods []string + IgnoredRequestMethods []string +} + +type UploadCallbackEventKinds struct { + ChunkComplete string + Progress string + SourceClose string + Success string + UploadURLAvailable string +} + +type UploadCallbacksPlan struct { + AllowedExtraEventKeyPrefixes []string + EventKeyAlternativeGroups [][]string + EventKinds UploadCallbackEventKinds + EventKeyPartSeparator string + EventKeys []string + EventPolicyMatching string +} + +type TusConformanceServerCapabilitiesPlan struct { + ExtensionNames []string + ProtocolVersions []string +} + +func Fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func LoadScenario(defaultPath string) (map[string]interface{}, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.FromSlash(defaultPath) + } + + contents, err := os.ReadFile(scenarioPath) + if err != nil { + return nil, err + } + + var scenario map[string]interface{} + if err := json.Unmarshal(contents, &scenario); err != nil { + return nil, err + } + + return scenario, nil +} + +func ObjectValue(value interface{}, label string) (map[string]interface{}, error) { + object, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an object", label) + } + + return object, nil +} + +func ArrayValue(value interface{}, label string) ([]interface{}, error) { + array, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an array", label) + } + + return array, nil +} + +func StringValue(value interface{}, label string) (string, error) { + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", label) + } + + return text, nil +} + +func StringMapValue(value interface{}, label string) (map[string]string, error) { + rawObject, err := ObjectValue(value, label) + if err != nil { + return nil, err + } + + object := map[string]string{} + for name, rawValue := range rawObject { + text, err := StringValue(rawValue, label+"."+name) + if err != nil { + return nil, err + } + object[name] = text + } + + return object, nil +} + +func BoolValue(value interface{}, label string) (bool, error) { + boolean, ok := value.(bool) + if !ok { + return false, fmt.Errorf("%s must be a boolean", label) + } + + return boolean, nil +} + +func IntValue(value interface{}, label string) (int, error) { + number, ok := value.(float64) + if !ok || math.Trunc(number) != number { + return 0, fmt.Errorf("%s must be an integer", label) + } + + return int(number), nil +} + +func StringArrayValue(value interface{}, label string) ([]string, error) { + array, err := ArrayValue(value, label) + if err != nil { + return nil, err + } + + strings := make([]string, 0, len(array)) + for index, item := range array { + text, err := StringValue(item, fmt.Sprintf("%s[%d]", label, index)) + if err != nil { + return nil, err + } + strings = append(strings, text) + } + + return strings, nil +} + +func StringArrayArrayValue(value interface{}, label string) ([][]string, error) { + array, err := ArrayValue(value, label) + if err != nil { + return nil, err + } + + arrays := make([][]string, 0, len(array)) + for index, item := range array { + strings, err := StringArrayValue(item, fmt.Sprintf("%s[%d]", label, index)) + if err != nil { + return nil, err + } + arrays = append(arrays, strings) + } + + return arrays, nil +} + +func IntArrayValue(value interface{}, label string) ([]int, error) { + array, err := ArrayValue(value, label) + if err != nil { + return nil, err + } + + ints := make([]int, 0, len(array)) + for index, item := range array { + number, err := IntValue(item, fmt.Sprintf("%s[%d]", label, index)) + if err != nil { + return nil, err + } + ints = append(ints, number) + } + + return ints, nil +} + +func ScalarString(value interface{}) string { + switch typed := value.(type) { + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case string: + return typed + default: + serialized, err := json.Marshal(typed) + if err != nil { + return fmt.Sprintf("%v", typed) + } + + return string(serialized) + } +} + +func ReadPath(value interface{}, pathParts []interface{}, label string) (interface{}, error) { + current := value + for _, part := range pathParts { + if object, ok := current.(map[string]interface{}); ok { + key, ok := part.(string) + if !ok { + return nil, fmt.Errorf("%s path cannot read non-string key %v from object", label, part) + } + next, ok := object[key] + if !ok { + return nil, fmt.Errorf("%s path is missing key %q", label, key) + } + current = next + continue + } + + if array, ok := current.([]interface{}); ok { + index, err := IntValue(part, label) + if err != nil { + return nil, err + } + if index < 0 || index >= len(array) { + return nil, fmt.Errorf("%s path index %d is out of range", label, index) + } + current = array[index] + continue + } + + return nil, fmt.Errorf("%s path cannot read %v from %v", label, part, current) + } + + return current, nil +} + +func ResolveValue( + valueSpec interface{}, + context map[string]interface{}, + label string, +) (interface{}, error) { + spec, err := ObjectValue(valueSpec, label) + if err != nil { + return nil, err + } + if literal, ok := spec["value"]; ok { + return literal, nil + } + + source, err := ObjectValue(spec["source"], label+".source") + if err != nil { + return nil, err + } + root, err := StringValue(source["root"], label+".source.root") + if err != nil { + return nil, err + } + rootValue, ok := context[root] + if !ok { + return nil, fmt.Errorf("%s source root %q is unavailable", label, root) + } + pathParts, err := ArrayValue(source["path"], label+".source.path") + if err != nil { + return nil, err + } + + return ReadPath(rootValue, pathParts, label) +} + +func CreateResponseFromScenario(scenario map[string]interface{}) (map[string]interface{}, error) { + prepared, err := ObjectValue(scenario["prepared"], "prepared") + if err != nil { + return nil, err + } + + return ObjectValue(prepared["createResponse"], "prepared.createResponse") +} + +func ScenarioBytes(scenario map[string]interface{}) ([]byte, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + source, err := ObjectValue(upload["source"], "upload.source") + if err != nil { + return nil, err + } + kind, err := StringValue(source["kind"], "upload.source.kind") + if err != nil { + return nil, err + } + if kind != "bytes" { + return nil, fmt.Errorf("unsupported scenario source kind %q", kind) + } + encoding, err := StringValue(source["encoding"], "upload.source.encoding") + if err != nil { + return nil, err + } + if encoding != "utf8" { + return nil, fmt.Errorf("unsupported scenario source encoding %q", encoding) + } + value, err := StringValue(source["value"], "upload.source.value") + if err != nil { + return nil, err + } + + return []byte(value), nil +} + +func UploadMetadata( + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + fields, err := ArrayValue(upload["metadata"], "upload.metadata") + if err != nil { + return nil, err + } + + context := map[string]interface{}{ + "createResponse": createResponse, + "scenario": scenario, + } + metadata := map[string]string{} + for index, rawField := range fields { + label := fmt.Sprintf("upload.metadata[%d]", index) + field, err := ObjectValue(rawField, label) + if err != nil { + return nil, err + } + name, err := StringValue(field["name"], label+".name") + if err != nil { + return nil, err + } + value, err := ResolveValue(field["value"], context, label+".value") + if err != nil { + return nil, err + } + metadata[name] = ScalarString(value) + } + + return metadata, nil +} + +func UploadHeaders(scenario map[string]interface{}) (map[string]string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + + return StringMapValue(upload["headers"], "upload.headers") +} + +func UploadBodyHeadersByMethod(scenario map[string]interface{}) (map[string]map[string]string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + rawByMethod, err := ObjectValue(upload["bodyHeadersByMethod"], "upload.bodyHeadersByMethod") + if err != nil { + return nil, err + } + + byMethod := map[string]map[string]string{} + for method, rawHeaders := range rawByMethod { + headers, err := StringMapValue(rawHeaders, "upload.bodyHeadersByMethod."+method) + if err != nil { + return nil, err + } + byMethod[method] = headers + } + + return byMethod, nil +} + +func UploadAddRequestID(scenario map[string]interface{}) (bool, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return false, err + } + + return BoolValue(upload["addRequestId"], "upload.addRequestId") +} + +func UploadRequestIDHeaderName(scenario map[string]interface{}) (string, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return "", err + } + + return StringValue(upload["requestIdHeaderName"], "upload.requestIdHeaderName") +} + +func UploadLengthDeferred(scenario map[string]interface{}) (bool, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return false, err + } + + return BoolValue(upload["uploadLengthDeferred"], "upload.uploadLengthDeferred") +} + +func TusURL( + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (*url.URL, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + context := map[string]interface{}{ + "createResponse": createResponse, + "scenario": scenario, + } + endpointValue, err := ResolveValue(upload["tusUrl"], context, "upload.tusUrl") + if err != nil { + return nil, err + } + + return url.Parse(ScalarString(endpointValue)) +} + +func RequireFullFileChunkSize(scenario map[string]interface{}) error { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return err + } + chunkSize, err := StringValue(upload["chunkSize"], "upload.chunkSize") + if err != nil { + return err + } + if chunkSize != "full-file" { + return fmt.Errorf("unsupported chunk size policy %q", chunkSize) + } + + return nil +} + +func FixedChunkSizeBytes(scenario map[string]interface{}) (int64, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return 0, err + } + chunkSize, err := ObjectValue(upload["chunkSize"], "upload.chunkSize") + if err != nil { + return 0, err + } + kind, err := StringValue(chunkSize["kind"], "upload.chunkSize.kind") + if err != nil { + return 0, err + } + if kind != "fixed-bytes" { + return 0, fmt.Errorf("unsupported chunk size kind %q", kind) + } + bytes, err := IntValue(chunkSize["bytes"], "upload.chunkSize.bytes") + if err != nil { + return 0, err + } + if bytes <= 0 { + return 0, fmt.Errorf("upload.chunkSize.bytes must be positive") + } + + return int64(bytes), nil +} + +func Termination(scenario map[string]interface{}) (TerminationPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return TerminationPlan{}, err + } + termination, err := ObjectValue(upload["termination"], "upload.termination") + if err != nil { + return TerminationPlan{}, err + } + expectedVerificationStatus, err := IntValue( + termination["expectedVerificationStatus"], + "upload.termination.expectedVerificationStatus", + ) + if err != nil { + return TerminationPlan{}, err + } + minimumDeleteRequestCount, err := IntValue( + termination["minimumDeleteRequestCount"], + "upload.termination.minimumDeleteRequestCount", + ) + if err != nil { + return TerminationPlan{}, err + } + stopAfterAcceptedBytes, err := IntValue( + termination["stopAfterAcceptedBytes"], + "upload.termination.stopAfterAcceptedBytes", + ) + if err != nil { + return TerminationPlan{}, err + } + verificationMethod, err := StringValue( + termination["verificationMethod"], + "upload.termination.verificationMethod", + ) + if err != nil { + return TerminationPlan{}, err + } + + return TerminationPlan{ + ExpectedVerificationStatus: expectedVerificationStatus, + MinimumDeleteRequestCount: minimumDeleteRequestCount, + StopAfterAcceptedBytes: stopAfterAcceptedBytes, + VerificationMethod: verificationMethod, + }, nil +} + +func Resume(scenario map[string]interface{}) (ResumePlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return ResumePlan{}, err + } + resume, err := ObjectValue(upload["resume"], "upload.resume") + if err != nil { + return ResumePlan{}, err + } + expectedPreviousUploadCount, err := IntValue( + resume["expectedPreviousUploadCount"], + "upload.resume.expectedPreviousUploadCount", + ) + if err != nil { + return ResumePlan{}, err + } + expectedRemainingPreviousUploadCount, err := IntValue( + resume["expectedRemainingPreviousUploadCount"], + "upload.resume.expectedRemainingPreviousUploadCount", + ) + if err != nil { + return ResumePlan{}, err + } + fingerprint, err := StringValue(resume["fingerprint"], "upload.resume.fingerprint") + if err != nil { + return ResumePlan{}, err + } + removeFingerprintOnSuccess, err := BoolValue( + resume["removeFingerprintOnSuccess"], + "upload.resume.removeFingerprintOnSuccess", + ) + if err != nil { + return ResumePlan{}, err + } + stopAfterAcceptedBytes, err := IntValue( + resume["stopAfterAcceptedBytes"], + "upload.resume.stopAfterAcceptedBytes", + ) + if err != nil { + return ResumePlan{}, err + } + + return ResumePlan{ + ExpectedPreviousUploadCount: expectedPreviousUploadCount, + ExpectedRemainingPreviousUploadCount: expectedRemainingPreviousUploadCount, + Fingerprint: fingerprint, + RemoveFingerprintOnSuccess: removeFingerprintOnSuccess, + StopAfterAcceptedBytes: stopAfterAcceptedBytes, + }, nil +} + +func URLStorageBackend(scenario map[string]interface{}) (*URLStorageBackendPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + rawBackend, ok := upload["urlStorageBackend"] + if !ok { + return nil, nil + } + backend, err := ObjectValue(rawBackend, "upload.urlStorageBackend") + if err != nil { + return nil, err + } + expectedStoredUploadKeyPrefix, err := StringValue( + backend["expectedStoredUploadKeyPrefix"], + "upload.urlStorageBackend.expectedStoredUploadKeyPrefix", + ) + if err != nil { + return nil, err + } + kind, err := StringValue(backend["kind"], "upload.urlStorageBackend.kind") + if err != nil { + return nil, err + } + + return &URLStorageBackendPlan{ + ExpectedStoredUploadKeyPrefix: expectedStoredUploadKeyPrefix, + Kind: kind, + }, nil +} + +func RetryDelays(scenario map[string]interface{}) ([]time.Duration, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + retries, err := IntValue(upload["retries"], "upload.retries") + if err != nil { + return nil, err + } + if retries < 0 { + return nil, fmt.Errorf("upload.retries must not be negative") + } + + return make([]time.Duration, retries), nil +} + +func RetryOffsetRecovery(scenario map[string]interface{}) (RetryOffsetRecoveryPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + retryOffsetRecovery, err := ObjectValue( + upload["retryOffsetRecovery"], + "upload.retryOffsetRecovery", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponse, err := ObjectValue( + retryOffsetRecovery["failAfterResponse"], + "upload.retryOffsetRecovery.failAfterResponse", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + recoveryResponse, err := ObjectValue( + retryOffsetRecovery["recoveryResponse"], + "upload.retryOffsetRecovery.recoveryResponse", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + + expectedFailureCount, err := IntValue( + retryOffsetRecovery["expectedFailureCount"], + "upload.retryOffsetRecovery.expectedFailureCount", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + expectedRecoveredOffset, err := IntValue( + retryOffsetRecovery["expectedRecoveredOffset"], + "upload.retryOffsetRecovery.expectedRecoveredOffset", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + expectedRecoveryRequestCount, err := IntValue( + retryOffsetRecovery["expectedRecoveryRequestCount"], + "upload.retryOffsetRecovery.expectedRecoveryRequestCount", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + expectedRequestMethods, err := StringArrayValue( + retryOffsetRecovery["expectedRequestMethods"], + "upload.retryOffsetRecovery.expectedRequestMethods", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponseMessage, err := StringValue( + failAfterResponse["message"], + "upload.retryOffsetRecovery.failAfterResponse.message", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponseMethod, err := StringValue( + failAfterResponse["method"], + "upload.retryOffsetRecovery.failAfterResponse.method", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + failAfterResponseOccurrence, err := IntValue( + failAfterResponse["occurrence"], + "upload.retryOffsetRecovery.failAfterResponse.occurrence", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + recoveryResponseMethod, err := StringValue( + recoveryResponse["method"], + "upload.retryOffsetRecovery.recoveryResponse.method", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + recoveryResponseOffsetHeader, err := StringValue( + recoveryResponse["offsetHeader"], + "upload.retryOffsetRecovery.recoveryResponse.offsetHeader", + ) + if err != nil { + return RetryOffsetRecoveryPlan{}, err + } + + return RetryOffsetRecoveryPlan{ + ExpectedFailureCount: expectedFailureCount, + ExpectedRecoveredOffset: expectedRecoveredOffset, + ExpectedRecoveryRequestCount: expectedRecoveryRequestCount, + ExpectedRequestMethods: expectedRequestMethods, + FailAfterResponse: RetryOffsetRecoveryFailurePlan{ + Message: failAfterResponseMessage, + Method: failAfterResponseMethod, + Occurrence: failAfterResponseOccurrence, + }, + RecoveryResponse: RetryOffsetRecoveryResponsePlan{ + Method: recoveryResponseMethod, + OffsetHeader: recoveryResponseOffsetHeader, + }, + }, nil +} + +func RequestLifecycleHooks(scenario map[string]interface{}) (RequestLifecycleHooksPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + requestLifecycleHooks, err := ObjectValue( + upload["requestLifecycleHooks"], + "upload.requestLifecycleHooks", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + expectedAfterResponseMethods, err := StringArrayValue( + requestLifecycleHooks["expectedAfterResponseMethods"], + "upload.requestLifecycleHooks.expectedAfterResponseMethods", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + expectedAfterResponseStatusCodes, err := IntArrayValue( + requestLifecycleHooks["expectedAfterResponseStatusCodes"], + "upload.requestLifecycleHooks.expectedAfterResponseStatusCodes", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + expectedBeforeRequestMethods, err := StringArrayValue( + requestLifecycleHooks["expectedBeforeRequestMethods"], + "upload.requestLifecycleHooks.expectedBeforeRequestMethods", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + ignoredRequestMethods, err := StringArrayValue( + requestLifecycleHooks["ignoredRequestMethods"], + "upload.requestLifecycleHooks.ignoredRequestMethods", + ) + if err != nil { + return RequestLifecycleHooksPlan{}, err + } + + return RequestLifecycleHooksPlan{ + ExpectedAfterResponseMethods: expectedAfterResponseMethods, + ExpectedAfterResponseStatusCodes: expectedAfterResponseStatusCodes, + ExpectedBeforeRequestMethods: expectedBeforeRequestMethods, + IgnoredRequestMethods: ignoredRequestMethods, + }, nil +} + +func UploadCallbacks(scenario map[string]interface{}) (UploadCallbacksPlan, error) { + upload, err := ObjectValue(scenario["upload"], "upload") + if err != nil { + return UploadCallbacksPlan{}, err + } + uploadCallbacks, err := ObjectValue(upload["uploadCallbacks"], "upload.uploadCallbacks") + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKinds, err := ObjectValue( + uploadCallbacks["eventKinds"], + "upload.uploadCallbacks.eventKinds", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + + allowedExtraEventKeyPrefixes, err := StringArrayValue( + uploadCallbacks["allowedExtraEventKeyPrefixes"], + "upload.uploadCallbacks.allowedExtraEventKeyPrefixes", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKeyAlternativeGroups, err := StringArrayArrayValue( + uploadCallbacks["eventKeyAlternativeGroups"], + "upload.uploadCallbacks.eventKeyAlternativeGroups", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + chunkComplete, err := StringValue( + eventKinds["chunkComplete"], + "upload.uploadCallbacks.eventKinds.chunkComplete", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + progress, err := StringValue(eventKinds["progress"], "upload.uploadCallbacks.eventKinds.progress") + if err != nil { + return UploadCallbacksPlan{}, err + } + sourceClose, err := StringValue( + eventKinds["sourceClose"], + "upload.uploadCallbacks.eventKinds.sourceClose", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + success, err := StringValue(eventKinds["success"], "upload.uploadCallbacks.eventKinds.success") + if err != nil { + return UploadCallbacksPlan{}, err + } + uploadURLAvailable, err := StringValue( + eventKinds["uploadUrlAvailable"], + "upload.uploadCallbacks.eventKinds.uploadUrlAvailable", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKeyPartSeparator, err := StringValue( + uploadCallbacks["eventKeyPartSeparator"], + "upload.uploadCallbacks.eventKeyPartSeparator", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventKeys, err := StringArrayValue( + uploadCallbacks["eventKeys"], + "upload.uploadCallbacks.eventKeys", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + eventPolicyMatching, err := StringValue( + uploadCallbacks["eventPolicyMatching"], + "upload.uploadCallbacks.eventPolicyMatching", + ) + if err != nil { + return UploadCallbacksPlan{}, err + } + + return UploadCallbacksPlan{ + AllowedExtraEventKeyPrefixes: allowedExtraEventKeyPrefixes, + EventKeyAlternativeGroups: eventKeyAlternativeGroups, + EventKinds: UploadCallbackEventKinds{ + ChunkComplete: chunkComplete, + Progress: progress, + SourceClose: sourceClose, + Success: success, + UploadURLAvailable: uploadURLAvailable, + }, + EventKeyPartSeparator: eventKeyPartSeparator, + EventKeys: eventKeys, + EventPolicyMatching: eventPolicyMatching, + }, nil +} + +func UploadCallbackEventKey(plan UploadCallbacksPlan, parts ...string) string { + return strings.Join(parts, plan.EventKeyPartSeparator) +} + +func UploadCallbackEventKeyNumber(value int64) string { + return strconv.FormatInt(value, 10) +} + +func UploadCallbackEventKeyTotal(value *int64) string { + if value == nil { + return "null" + } + + return UploadCallbackEventKeyNumber(*value) +} + +func MatchUploadCallbackEventKeys(plan UploadCallbacksPlan, actual []string) ([]string, error) { + allowedExtraPrefixes := []string{} + switch plan.EventPolicyMatching { + case "exact": + case "exact-except-allowed-extra-events": + allowedExtraPrefixes = plan.AllowedExtraEventKeyPrefixes + default: + return nil, fmt.Errorf("unsupported upload callback event policy %q", plan.EventPolicyMatching) + } + + expectedIndex := 0 + matched := []string{} + for _, event := range actual { + if expectedIndex < len(plan.EventKeys) && + uploadCallbackEventMatchesExpected(plan, expectedIndex, event) { + matched = append(matched, plan.EventKeys[expectedIndex]) + expectedIndex += 1 + continue + } + if hasAllowedUploadCallbackExtraEventPrefix(event, allowedExtraPrefixes) { + continue + } + + return nil, fmt.Errorf( + "upload callback events emitted unexpected extra event %q; allowed prefixes %v; expected %v, got %v", + event, + allowedExtraPrefixes, + plan.EventKeys, + actual, + ) + } + if expectedIndex == len(plan.EventKeys) { + return matched, nil + } + + return nil, fmt.Errorf( + "upload callback events did not emit every expected non-extra event; expected %v, got %v", + plan.EventKeys, + actual, + ) +} + +func uploadCallbackEventMatchesExpected( + plan UploadCallbacksPlan, + expectedIndex int, + actual string, +) bool { + if actual == plan.EventKeys[expectedIndex] { + return true + } + if expectedIndex >= len(plan.EventKeyAlternativeGroups) { + return false + } + + for _, alternative := range plan.EventKeyAlternativeGroups[expectedIndex] { + if actual == alternative { + return true + } + } + + return false +} + +func hasAllowedUploadCallbackExtraEventPrefix(event string, allowedExtraPrefixes []string) bool { + for _, prefix := range allowedExtraPrefixes { + if strings.HasPrefix(event, prefix) { + return true + } + } + + return false +} + +func TusConformanceScenario(scenario map[string]interface{}) (map[string]interface{}, error) { + return ObjectValue(scenario["conformanceScenario"], "conformanceScenario") +} + +func TusConformanceInputOptions( + conformanceScenario map[string]interface{}, +) (map[string]interface{}, error) { + rawEntries, err := ArrayValue( + conformanceScenario["inputOptionEntries"], + "conformanceScenario.inputOptionEntries", + ) + if err != nil { + return nil, err + } + + options := map[string]interface{}{} + for index, rawEntry := range rawEntries { + label := fmt.Sprintf("conformanceScenario.inputOptionEntries[%d]", index) + entry, err := ObjectValue(rawEntry, label) + if err != nil { + return nil, err + } + key, err := StringValue(entry["key"], label+".key") + if err != nil { + return nil, err + } + options[key] = entry["value"] + } + + return options, nil +} + +func TusConformanceInputStringOption( + conformanceScenario map[string]interface{}, + key string, +) (string, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return "", err + } + + return StringValue(options[key], "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputBoolOption( + conformanceScenario map[string]interface{}, + key string, + defaultValue bool, +) (bool, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return false, err + } + value, ok := options[key] + if !ok { + return defaultValue, nil + } + + return BoolValue(value, "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputIntOption( + conformanceScenario map[string]interface{}, + key string, +) (int, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return 0, err + } + + return IntValue(options[key], "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputStringMapOption( + conformanceScenario map[string]interface{}, + key string, +) (map[string]string, error) { + options, err := TusConformanceInputOptions(conformanceScenario) + if err != nil { + return nil, err + } + value, ok := options[key] + if !ok { + return map[string]string{}, nil + } + + return StringMapValue(value, "conformanceScenario.inputOptionEntries."+key) +} + +func TusConformanceInputSourceBytes( + conformanceScenario map[string]interface{}, +) ([]byte, error) { + source, err := ObjectValue( + conformanceScenario["inputSource"], + "conformanceScenario.inputSource", + ) + if err != nil { + return nil, err + } + content, err := StringValue(source["content"], "conformanceScenario.inputSource.content") + if err != nil { + return nil, err + } + + return []byte(content), nil +} + +func TusConformanceInputSourceKind(conformanceScenario map[string]interface{}) (string, error) { + source, err := ObjectValue( + conformanceScenario["inputSource"], + "conformanceScenario.inputSource", + ) + if err != nil { + return "", err + } + + return StringValue(source["kind"], "conformanceScenario.inputSource.kind") +} + +func TusConformanceScenarioWantsEvent( + conformanceScenario map[string]interface{}, + eventKind string, +) (bool, error) { + rawEvents, ok := conformanceScenario["events"] + if !ok || rawEvents == nil { + return false, nil + } + events, err := ArrayValue(rawEvents, "conformanceScenario.events") + if err != nil { + return false, err + } + + for index, rawEvent := range events { + label := fmt.Sprintf("conformanceScenario.events[%d]", index) + event, err := ObjectValue(rawEvent, label) + if err != nil { + return false, err + } + kind, err := StringValue(event["kind"], label+".kind") + if err != nil { + return false, err + } + if kind == eventKind { + return true, nil + } + } + + return false, nil +} + +func TusConformanceRetryDecisions( + conformanceScenario map[string]interface{}, +) ([]TusConformanceRetryDecision, error) { + rawDecisions, ok := conformanceScenario["retryDecisions"] + if !ok || rawDecisions == nil { + return []TusConformanceRetryDecision{}, nil + } + decisions, err := ArrayValue(rawDecisions, "conformanceScenario.retryDecisions") + if err != nil { + return nil, err + } + + parsedDecisions := make([]TusConformanceRetryDecision, 0, len(decisions)) + for index, rawDecision := range decisions { + label := fmt.Sprintf("conformanceScenario.retryDecisions[%d]", index) + decision, err := ObjectValue(rawDecision, label) + if err != nil { + return nil, err + } + decisionValue, err := BoolValue(decision["decision"], label+".decision") + if err != nil { + return nil, err + } + retryAttempt, err := IntValue(decision["retryAttempt"], label+".retryAttempt") + if err != nil { + return nil, err + } + parsedDecisions = append(parsedDecisions, TusConformanceRetryDecision{ + Decision: decisionValue, + RetryAttempt: retryAttempt, + }) + } + + return parsedDecisions, nil +} + +func TusConformanceRuntimeAbortTerminateUpload( + conformanceScenario map[string]interface{}, +) (bool, error) { + runtimeSetup, err := ObjectValue( + conformanceScenario["runtimeSetup"], + "conformanceScenario.runtimeSetup", + ) + if err != nil { + return false, err + } + abort, err := ObjectValue(runtimeSetup["abort"], "conformanceScenario.runtimeSetup.abort") + if err != nil { + return false, err + } + + return BoolValue( + abort["terminateUpload"], + "conformanceScenario.runtimeSetup.abort.terminateUpload", + ) +} + +func TusConformanceRuntimeFingerprint( + conformanceScenario map[string]interface{}, +) (string, error) { + runtimeSetup, err := ObjectValue( + conformanceScenario["runtimeSetup"], + "conformanceScenario.runtimeSetup", + ) + if err != nil { + return "", err + } + fingerprint, err := ObjectValue( + runtimeSetup["fingerprint"], + "conformanceScenario.runtimeSetup.fingerprint", + ) + if err != nil { + return "", err + } + install, err := BoolValue( + fingerprint["install"], + "conformanceScenario.runtimeSetup.fingerprint.install", + ) + if err != nil { + return "", err + } + if !install { + return "", nil + } + + return StringValue( + fingerprint["value"], + "conformanceScenario.runtimeSetup.fingerprint.value", + ) +} + +func TusConformanceServerCapabilities( + conformanceScenario map[string]interface{}, +) (TusConformanceServerCapabilitiesPlan, error) { + serverCapabilities, err := ObjectValue( + conformanceScenario["serverCapabilities"], + "conformanceScenario.serverCapabilities", + ) + if err != nil { + return TusConformanceServerCapabilitiesPlan{}, err + } + extensionNames, err := StringArrayValue( + serverCapabilities["extensionNames"], + "conformanceScenario.serverCapabilities.extensionNames", + ) + if err != nil { + return TusConformanceServerCapabilitiesPlan{}, err + } + protocolVersions, err := StringArrayValue( + serverCapabilities["protocolVersions"], + "conformanceScenario.serverCapabilities.protocolVersions", + ) + if err != nil { + return TusConformanceServerCapabilitiesPlan{}, err + } + + return TusConformanceServerCapabilitiesPlan{ + ExtensionNames: extensionNames, + ProtocolVersions: protocolVersions, + }, nil +} + +func TusConformanceCancelRequestIndexes( + conformanceScenario map[string]interface{}, +) ([]int, error) { + execution, err := ObjectValue( + conformanceScenario["execution"], + "conformanceScenario.execution", + ) + if err != nil { + return nil, err + } + actions, err := ArrayValue( + execution["onRequestStart"], + "conformanceScenario.execution.onRequestStart", + ) + if err != nil { + return nil, err + } + + requestIndexes := []int{} + for index, rawAction := range actions { + label := fmt.Sprintf("conformanceScenario.execution.onRequestStart[%d]", index) + action, err := ObjectValue(rawAction, label) + if err != nil { + return nil, err + } + kind, err := StringValue(action["kind"], label+".kind") + if err != nil { + return nil, err + } + if kind != "cancel-upload" { + continue + } + requestIndex, err := IntValue(action["requestIndex"], label+".requestIndex") + if err != nil { + return nil, err + } + requestIndexes = append(requestIndexes, requestIndex) + } + + return requestIndexes, nil +} + +func ScenarioID(scenario map[string]interface{}) (string, error) { + return StringValue(scenario["scenarioId"], "scenarioId") +} + +func WriteResult(result map[string]interface{}) error { + resultPath := os.Getenv("API2_SDK_EXAMPLE_RESULT") + if resultPath == "" { + return nil + } + + contents, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + return os.WriteFile(resultPath, append(contents, '\n'), 0o644) +} diff --git a/protocol_contract_generated_test.go b/protocol_contract_generated_test.go new file mode 100644 index 0000000..c87e06a --- /dev/null +++ b/protocol_contract_generated_test.go @@ -0,0 +1,2615 @@ +// 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 ( + "strconv" + "strings" + "testing" +) + +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 { + 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 +} + +type generatedTusClientFlowContract struct { + UrlStorage generatedTusClientUrlStoragePolicy +} + +type generatedTusClientUrlStoragePolicy struct { + ID generatedTusClientUrlStorageIDPolicy + Namespace string + Separator string +} + +type generatedTusClientUrlStorageIDPolicy struct { + Multiplier float64 + 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 + FeatureID string + Runtimes []string + ScenarioID string +} + +type generatedTusClientUrlStorageConformanceAction struct { + ExpectedKeyPrefix string + ExpectedKeyRefs []string + Fingerprint string + KeyRef string + Kind string + Upload map[string]any +} + +type generatedTusManagedUploadProofCase struct { + FeatureID string + Layer string + ScenarioID string + RequiredPrimitives []string + ProtocolFeatureIDs []string + RuntimeProfiles []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, + }, + }, + }, + { + 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{ + { + StatusCode: 201, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Location", + Name: "location", + Required: true, + }, + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + Required: true, + }, + }, + }, + }, + }, + { + StatusCode: 500, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + 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, + }, + }, + }, + }, + }, + { + StatusCode: 500, + BodyKind: "empty", + HeaderVariants: []generatedTusHeaderVariant{ + { + Fields: []generatedTusHeaderField{ + { + DisplayName: "Tus-Resumable", + Name: "tus-resumable", + 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, + }, + }, + }, + }, + }, + { + StatusCode: 423, + 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{ + { + 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", "deferredLengthChunkedUpload"}, + Status: "covered-by-generated-scenario", + }, + Description: "Create an upload without a known length and declare the length on the final upload request.", + FeatureID: "deferredLengthUpload", + Flow: []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 upload request reveals the total size.", + }, + { + Kind: "operation", + OperationID: "patchTusUpload", + Primitive: "", + Condition: "", + Summary: "Declare Upload-Length on the final upload request.", + }, + }, + OperationIDs: []string{"createTusUpload", "patchTusUpload"}, + Primitives: []string{"defer-upload-length", "emit-chunk-complete", "emit-progress"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + ScenarioIDs: []string{"creationWithUpload", "creationWithUploadPartialChunk"}, + Status: "covered-by-generated-scenario", + }, + Description: "Send the first bytes on the creation request when the server/client support it.", + FeatureID: "creationWithUpload", + Flow: []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", "patchTusUpload"}, + 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{"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{"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"}, + 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", "parallelUploadAbortCleanup"}, + Status: "covered-by-generated-scenario", + }, + Description: "Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.", + FeatureID: "parallelUploadConcat", + Flow: []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{"abort-current-request", "concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries", "terminate-upload"}, + }, + { + 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{"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"}, + 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: []string{"abortUpload", "abortUploadAfterStoredUrl"}, + Status: "covered-by-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: []string{"terminateTusUpload"}, + Primitives: []string{"abort-current-request", "terminate-upload"}, + }, + { + Conformance: generatedTusClientFeatureConformance{ + 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{ + { + 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: []string{"requestLifecycleHooks", "retryPatchAfterOffsetRecovery"}, + Status: "covered-by-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: []string{"singleUploadLifecycle", "resumeFromPreviousUpload"}, + Status: "covered-by-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: []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{ + { + 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: []string{"webStorageUrlStorageBackend", "fileUrlStorageBackend"}, + Status: "covered-by-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: []string{"ietfDraft05CreationWithUpload", "ietfDraft05ChunkedUploadComplete", "ietfDraft03ResumeWithoutKnownLength"}, + Status: "covered-by-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: []string{"relativeLocationResolution"}, + Status: "covered-by-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: []string{"startValidationMissingInput", "startValidationMissingEndpointOrUploadUrl", "startValidationUnsupportedProtocol", "startValidationRetryDelaysNotArray", "startValidationParallelUploadsWithUploadUrl", "startValidationParallelUploadsWithUploadSize", "startValidationParallelUploadsWithDeferredLength", "startValidationParallelUploadsWithUploadDataDuringCreation", "startValidationParallelBoundariesWithoutParallelUploads", "startValidationParallelBoundariesLengthMismatch"}, + Status: "covered-by-generated-scenario", + }, + Description: "Validate option combinations before starting runtime work.", + FeatureID: "startOptionValidation", + Flow: []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: []string{"detailedCreateResponseError", "detailedCreateRequestError"}, + Status: "covered-by-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"}, + }, +} + +const generatedTusManagedUploadJSON = `{ + "capabilities": { + "cleanup": { + "policies": [ + "absent-after-source-unavailable", + "remove-owned-source-after-success", + "remove-owned-source-after-cancel", + "retain-owned-source-while-deferred", + "retain-owned-source-after-permanent-failure", + "retain-source-after-retryable-failure", + "remove-managed-state-after-terminal-retention" + ] + }, + "failureClassification": { + "permanentFailures": [ + "source-unavailable", + "unretryable-protocol-error", + "retry-policy-exhausted" + ], + "retryableFailures": [ + "retryable-protocol-error", + "io-error", + "network-unavailable" + ] + }, + "networkConstraints": { + "options": [ + "any-network", + "unmetered-network" + ] + }, + "retryPolicy": { + "controls": [ + "max-attempts", + "deadline", + "progress-sensitive-budget", + "unbounded-until-permanent-failure" + ], + "permanentFailure": "stop-without-retry", + "progressReset": "reset-budget-after-accepted-offset-advances" + }, + "scheduling": { + "strategies": [ + "foreground-task", + "process-lifetime-worker-pool", + "durable-os-scheduler" + ] + }, + "sourceDurability": { + "ownedCopyCleanup": "after-success-or-cancel", + "strategies": [ + "copy-to-owned-storage", + "reference-original-source", + "memory-only" + ] + }, + "stateReporting": { + "states": [ + "pending", + "running", + "succeeded", + "failed" + ], + "terminalRetention": "session-and-next-launch", + "transientRetention": "until-terminal" + } + }, + "conformance": { + "scenarioIds": [ + "managedUploadDurableRetry", + "managedUploadPermanentFailure", + "managedUploadRetryPolicyExhausted", + "managedUploadSourceUnavailable", + "managedUploadNetworkConstraint" + ], + "status": "covered-by-generated-scenario" + }, + "description": "Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.", + "featureId": "managedUpload", + "flow": [ + { + "kind": "managed-primitive", + "primitive": "accept-upload-submission", + "summary": "Accept source, metadata, headers, endpoint, and retry/scheduling policy." + }, + { + "kind": "managed-primitive", + "primitive": "make-source-durable", + "summary": "Keep the source readable according to the selected runtime durability strategy." + }, + { + "kind": "managed-primitive", + "primitive": "schedule-upload-work", + "summary": "Run upload work according to the runtime scheduler capability." + }, + { + "featureId": "singleUploadLifecycle", + "kind": "protocol-feature", + "summary": "Use the raw protocol upload lifecycle for each execution attempt." + }, + { + "featureId": "retryOffsetRecovery", + "kind": "protocol-feature", + "summary": "Use protocol retry and offset recovery before classifying terminal failure." + }, + { + "kind": "managed-primitive", + "primitive": "publish-upload-state", + "summary": "Expose pending, running, succeeded, and failed state snapshots." + }, + { + "kind": "managed-primitive", + "primitive": "cleanup-managed-upload", + "summary": "Remove owned sources and terminal state according to cleanup policy." + } + ], + "layer": "feature-over-protocol", + "primitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "protocolPrimitives": [ + "store-resume-url", + "resume-from-previous-upload", + "recover-offset-after-error", + "retry-with-backoff", + "emit-progress", + "emit-chunk-complete", + "terminate-upload" + ], + "runtimeProfiles": [ + { + "networkConstraints": [ + "any-network", + "unmetered-network" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source" + ], + "stateBackend": "platform-key-value-store", + "transportProfileId": "java-http-url-connection" + }, + { + "networkConstraints": [ + "any-network", + "unmetered-network" + ], + "runtime": "ios", + "scheduler": "durable-os-scheduler", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source" + ], + "stateBackend": "platform-key-value-store" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "browser", + "scheduler": "foreground-task", + "sourceDurability": [ + "reference-original-source", + "memory-only" + ], + "stateBackend": "web-storage" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source" + ], + "stateBackend": "filesystem", + "transportProfileId": "java-http-url-connection" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "node", + "scheduler": "process-lifetime-worker-pool", + "sourceDurability": [ + "copy-to-owned-storage", + "reference-original-source", + "memory-only" + ], + "stateBackend": "filesystem" + }, + { + "networkConstraints": [ + "any-network" + ], + "runtime": "react-native", + "scheduler": "foreground-task", + "sourceDurability": [ + "reference-original-source", + "memory-only" + ], + "stateBackend": "platform-key-value-store" + } + ], + "scenarios": [ + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "afterAcceptedOffset": 7, + "kind": "io-error", + "phase": "after-accepted-offset" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": { + "Location": "https://tus.io/uploads/managed-durable-retry" + }, + "statusCode": 201 + }, + "url": "endpoint" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "0" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "7" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "requests": [ + { + "headers": {}, + "operationId": "getTusUploadOffset", + "response": { + "headers": { + "Upload-Length": "14", + "Upload-Offset": "7" + }, + "statusCode": 200 + }, + "url": "upload" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "7" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "14" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "succeeded" + } + ], + "cleanup": { + "ownedSource": "remove-owned-source-after-success", + "resumeUrl": "remove-after-success" + }, + "input": { + "chunkSize": 7, + "content": "hello managed!", + "fingerprint": "managed-durable-retry-fingerprint", + "metadata": { + "filename": "managed.txt" + }, + "uploadPath": "managed-durable-retry" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "kind": "terminal", + "state": "succeeded" + }, + "retryDelays": [ + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "succeeded" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "afterAcceptedOffset": 7, + "kind": "io-error", + "phase": "after-accepted-offset" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": { + "Location": "https://tus.io/uploads/managed-durable-retry" + }, + "statusCode": 201 + }, + "url": "endpoint" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "0" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "7" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "requests": [ + { + "headers": {}, + "operationId": "getTusUploadOffset", + "response": { + "headers": { + "Upload-Length": "14", + "Upload-Offset": "7" + }, + "statusCode": 200 + }, + "url": "upload" + }, + { + "bodySize": 7, + "headers": { + "Upload-Offset": "7" + }, + "operationId": "patchTusUpload", + "response": { + "headers": { + "Upload-Offset": "14" + }, + "statusCode": 204 + }, + "url": "upload" + } + ], + "stateAfterAttempt": "succeeded" + } + ], + "cleanup": { + "ownedSource": "remove-owned-source-after-success", + "resumeUrl": "remove-after-success" + }, + "input": { + "chunkSize": 7, + "content": "hello managed!", + "fingerprint": "managed-durable-retry-fingerprint", + "metadata": { + "filename": "managed.txt" + }, + "uploadPath": "managed-durable-retry" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "kind": "terminal", + "state": "succeeded" + }, + "retryDelays": [ + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "succeeded" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadDurableRetry", + "summary": "Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup." + }, + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "unretryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 400 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello failure!", + "fingerprint": "managed-permanent-failure-fingerprint", + "metadata": { + "filename": "managed-permanent-failure.txt" + }, + "uploadPath": "managed-permanent-failure" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "unretryable-protocol-error", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "unretryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 400 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello failure!", + "fingerprint": "managed-permanent-failure-fingerprint", + "metadata": { + "filename": "managed-permanent-failure.txt" + }, + "uploadPath": "managed-permanent-failure" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "unretryable-protocol-error", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadPermanentFailure", + "summary": "Classify unretryable protocol failures as terminal without further retry." + }, + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 2, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello retries!", + "fingerprint": "managed-retry-exhausted-fingerprint", + "metadata": { + "filename": "managed-retry-exhausted.txt" + }, + "uploadPath": "managed-retry-exhausted" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "retry-policy-exhausted", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [ + 0, + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 1, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + }, + { + "attemptIndex": 2, + "failure": { + "kind": "retryable-protocol-error", + "phase": "during-protocol-request" + }, + "requests": [ + { + "bodySize": 0, + "headers": { + "Upload-Length": "14" + }, + "operationId": "createTusUpload", + "response": { + "headers": {}, + "statusCode": 500 + }, + "url": "endpoint" + } + ], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "retain-owned-source-after-permanent-failure", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello retries!", + "fingerprint": "managed-retry-exhausted-fingerprint", + "metadata": { + "filename": "managed-retry-exhausted.txt" + }, + "uploadPath": "managed-retry-exhausted" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "retry-policy-exhausted", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [ + 0, + 0 + ], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadRetryPolicyExhausted", + "summary": "Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed." + }, + { + "proofs": [ + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "source-unavailable", + "phase": "before-protocol-request" + }, + "requests": [], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "absent-after-source-unavailable", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello missing!", + "fingerprint": "managed-source-unavailable-fingerprint", + "metadata": { + "filename": "managed-source-unavailable.txt" + }, + "uploadPath": "managed-source-unavailable" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "source-unavailable", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "missing-before-durable-copy", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "java", + "scheduler": "process-lifetime-worker-pool", + "stateBackend": "filesystem" + }, + { + "attempts": [ + { + "attemptIndex": 0, + "failure": { + "kind": "source-unavailable", + "phase": "before-protocol-request" + }, + "requests": [], + "stateAfterAttempt": "failed" + } + ], + "cleanup": { + "ownedSource": "absent-after-source-unavailable", + "resumeUrl": "absent-after-permanent-failure" + }, + "input": { + "chunkSize": 7, + "content": "hello missing!", + "fingerprint": "managed-source-unavailable-fingerprint", + "metadata": { + "filename": "managed-source-unavailable.txt" + }, + "uploadPath": "managed-source-unavailable" + }, + "network": { + "current": "unmetered-network", + "decision": "start-upload-work", + "required": "any-network" + }, + "outcome": { + "failure": "source-unavailable", + "kind": "terminal", + "state": "failed" + }, + "retryDelays": [], + "sourceAvailability": "missing-before-durable-copy", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending", + "running", + "failed" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload" + ], + "scenarioId": "managedUploadSourceUnavailable", + "summary": "Classify source disappearance before protocol requests as terminal without issuing a TUS request." + }, + { + "proofs": [ + { + "attempts": [], + "cleanup": { + "ownedSource": "retain-owned-source-while-deferred", + "resumeUrl": "absent-while-deferred" + }, + "input": { + "chunkSize": 7, + "content": "hello later!", + "fingerprint": "managed-network-constraint-fingerprint", + "metadata": { + "filename": "managed-network-constraint.txt" + }, + "uploadPath": "managed-network-constraint" + }, + "network": { + "current": "metered-network", + "decision": "defer-until-network-constraint-satisfied", + "required": "unmetered-network" + }, + "outcome": { + "kind": "deferred", + "reason": "network-constraint-unsatisfied", + "state": "pending" + }, + "retryDelays": [], + "sourceAvailability": "available", + "sourceDurability": "copy-to-owned-storage", + "states": [ + "pending" + ], + "runtime": "android", + "scheduler": "durable-os-scheduler", + "stateBackend": "platform-key-value-store" + } + ], + "requiredPrimitives": [ + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "publish-upload-state" + ], + "scenarioId": "managedUploadNetworkConstraint", + "summary": "Honor network constraints before starting or resuming upload work." + } + ] +} +` + +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", "run-protocol-upload", "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", + 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", + 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", + ScenarioID: "managedUploadNetworkConstraint", + 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"}, + }, +} + +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", + }, +} + +type generatedTusTestingT interface { + Fatalf(format string, args ...any) + Helper() +} + +func generatedTusAssertEvents( + t generatedTusTestingT, + scenarioID string, + matching string, + allowedExtraPrefixes []string, + expected []string, + actual []string, +) { + t.Helper() + + if matching == "exact" { + if generatedTusStringSlicesEqual(expected, actual) { + return + } + t.Fatalf("expected %s events %#v, got %#v", scenarioID, expected, actual) + } + + if matching != "exact-except-allowed-extra-events" { + t.Fatalf("unsupported generated event policy %s for %s", matching, scenarioID) + } + + expectedIndex := 0 + for _, event := range actual { + if expectedIndex < len(expected) && event == expected[expectedIndex] { + expectedIndex += 1 + continue + } + if generatedTusHasAllowedExtraEventPrefix(event, allowedExtraPrefixes) { + continue + } + t.Fatalf( + "%s emitted unexpected extra event %s; allowed prefixes %#v; expected %#v, got %#v", + scenarioID, + event, + allowedExtraPrefixes, + expected, + actual, + ) + } + if expectedIndex == len(expected) { + return + } + t.Fatalf( + "%s did not emit every expected non-extra event; expected %#v, got %#v", + scenarioID, + expected, + actual, + ) +} + +func TestGeneratedTusManagedUploadProofCases(t *testing.T) { + if len(generatedTusManagedUploadProofCases) == 0 { + t.Fatal("expected generated managed upload proof cases") + } + + for _, testCase := range generatedTusManagedUploadProofCases { + if testCase.FeatureID != "managedUpload" { + t.Fatalf("expected managed upload feature ID, got %s", testCase.FeatureID) + } + if testCase.Layer != "feature-over-protocol" { + t.Fatalf("expected managed upload feature-over-protocol layer, got %s", testCase.Layer) + } + if len(testCase.RequiredPrimitives) == 0 { + t.Fatalf("expected %s required primitives", testCase.ScenarioID) + } + if len(testCase.RuntimeProfiles) == 0 { + t.Fatalf("expected %s runtime profiles", testCase.ScenarioID) + } + for _, featureID := range testCase.ProtocolFeatureIDs { + if generatedTusFindClientFeature(featureID) == nil { + t.Fatalf( + "managed upload proof case %s references missing feature %s", + testCase.ScenarioID, + featureID, + ) + } + } + } +} + +func generatedTusFindClientFeature(featureID string) *generatedTusClientFeature { + for index := range generatedTusClientFeatures { + if generatedTusClientFeatures[index].FeatureID == featureID { + return &generatedTusClientFeatures[index] + } + } + + return nil +} + +func generatedTusHasAllowedExtraEventPrefix(event string, allowedExtraPrefixes []string) bool { + for _, prefix := range allowedExtraPrefixes { + if len(event) >= len(prefix) && event[:len(prefix)] == prefix { + return true + } + } + + return false +} + +const generatedTusEventKeyPartSeparator = ":" + +func generatedTusEventKey(parts ...string) string { + return strings.Join(parts, generatedTusEventKeyPartSeparator) +} + +func generatedTusEventKeyBool(value bool) string { + if value { + return "true" + } + + return "false" +} + +func generatedTusEventKeyNumber(value int64) string { + return strconv.FormatInt(value, 10) +} + +func generatedTusEventKeyAfterResponse(requestIndex string) string { + return generatedTusEventKey("after-response", requestIndex) +} + +func generatedTusEventKeyBeforeRequest(requestIndex string) string { + return generatedTusEventKey("before-request", requestIndex) +} + +func generatedTusEventKeyChunkComplete(chunkSize string, bytesAccepted string, bytesTotal string) string { + return generatedTusEventKey("chunk-complete", chunkSize, bytesAccepted, bytesTotal) +} + +func generatedTusEventKeyFingerprint(fingerprint string) string { + return generatedTusEventKey("fingerprint", fingerprint) +} + +func generatedTusEventKeyProgress(bytesSent string, bytesTotal string) string { + return generatedTusEventKey("progress", bytesSent, bytesTotal) +} + +func generatedTusEventKeyRequestAbort(requestIndex string) string { + return generatedTusEventKey("request-abort", requestIndex) +} + +func generatedTusEventKeyRetrySchedule(delay string) string { + return generatedTusEventKey("retry-schedule", delay) +} + +func generatedTusEventKeyShouldRetry(retryAttempt string, decision string) string { + return generatedTusEventKey("should-retry", retryAttempt, decision) +} + +func generatedTusEventKeySourceClose() string { + return generatedTusEventKey("source-close") +} + +func generatedTusEventKeySourceOpen(inputKind string, size string) string { + return generatedTusEventKey("source-open", inputKind, size) +} + +func generatedTusEventKeySuccess() string { + return generatedTusEventKey("success") +} + +func generatedTusEventKeyUploadUrlAvailable() string { + return generatedTusEventKey("upload-url-available") +} + +func generatedTusEventKeyUrlStorageAdd(fingerprint string, uploadUrl string) string { + return generatedTusEventKey("url-storage-add", fingerprint, uploadUrl) +} + +func generatedTusEventKeyUrlStorageFind(fingerprint string, count string) string { + return generatedTusEventKey("url-storage-find", fingerprint, count) +} + +func generatedTusEventKeyUrlStorageRemove(urlStorageKey string) string { + return generatedTusEventKey("url-storage-remove", urlStorageKey) +} + +func generatedTusStringSlicesEqual(expected []string, actual []string) bool { + if len(expected) != len(actual) { + return false + } + for index, expectedValue := range expected { + if actual[index] != expectedValue { + return false + } + } + return true +} diff --git a/protocol_contract_test.go b/protocol_contract_test.go new file mode 100644 index 0000000..6efc37b --- /dev/null +++ b/protocol_contract_test.go @@ -0,0 +1,199 @@ +package tusgo + +import ( + "net/http" + "net/url" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +func generatedDefaultTusWireVersion() string { + versions := make([]generatedTusWireVersion, 0) + for _, candidate := range generatedTusWireVersions { + if candidate.Default { + versions = append(versions, candidate) + } + } + Ω(versions).Should(HaveLen(1)) + return versions[0].Value +} + +func generatedProtocolOperation(operationID string) generatedTusProtocolOperation { + for _, candidate := range generatedTusProtocolOperations { + if candidate.OperationID == operationID { + return candidate + } + } + Fail("missing generated TUS protocol operation: " + operationID) + return generatedTusProtocolOperation{} +} + +func generatedClientFeature(featureID string) generatedTusClientFeature { + for _, candidate := range generatedTusClientFeatures { + if candidate.FeatureID == featureID { + return candidate + } + } + Fail("missing generated TUS client feature: " + featureID) + return generatedTusClientFeature{} +} + +func generatedResponseFor( + operation generatedTusProtocolOperation, + statusCode int, +) generatedTusResponseContract { + for _, candidate := range operation.Responses { + if candidate.StatusCode == statusCode { + return candidate + } + } + Fail("missing generated response status for " + operation.OperationID) + return generatedTusResponseContract{} +} + +func withGeneratedRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := values[field.DisplayName] + if value == "" { + value = generatedDefaultTusWireVersion() + } + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + return builder +} + +func generatedResponseHeaders( + response generatedTusResponseContract, + overrides map[string]string, +) map[string]string { + headers := make(map[string]string) + variant := response.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := overrides[field.DisplayName] + if value == "" { + value = generatedDefaultTusWireVersion() + } + headers[field.DisplayName] = value + } + return headers +} + +func withGeneratedResponseHeaders( + response *reply.StdReply, + headers map[string]string, +) *reply.StdReply { + for key, value := range headers { + response = response.Header(key, value) + } + return response +} + +var _ = Describe("generated TUS protocol contract", func() { + var srvMock *mocha.Mocha + + BeforeEach(func() { + srvMock = mocha.New(GinkgoT()) + srvMock.Start() + }) + + AfterEach(func() { + if srvMock != nil { + Ω(srvMock.Close()).Should(Succeed()) + srvMock.AssertCalled(GinkgoT()) + } + }) + + It("drives create and patch lifecycle assertions from the generated contract", func() { + Ω(DefaultProtocolVersion).Should(Equal(generatedDefaultTusWireVersion())) + + lifecycle := generatedClientFeature("singleUploadLifecycle") + createOperation := generatedProtocolOperation(lifecycle.OperationIDs[0]) + patchOperation := generatedProtocolOperation(lifecycle.OperationIDs[2]) + + baseURL, err := url.Parse(srvMock.URL() + createOperation.Path) + Ω(err).Should(Succeed()) + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{"creation"}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + createResponse := generatedResponseFor(createOperation, http.StatusCreated) + createReply := withGeneratedResponseHeaders( + reply.Status(createResponse.StatusCode), + generatedResponseHeaders(createResponse, map[string]string{ + "Location": srvMock.URL() + "/resumable/files/generated-contract", + }), + ) + createRequest := withGeneratedRequestHeaders( + mocha.Request().URL(expect.URLPath(createOperation.Path)).Method(createOperation.Method), + createOperation, + map[string]string{ + "Upload-Length": "5", + "Upload-Metadata": "filename aGVsbG8udHh0", + }, + ) + srvMock.AddMocks(createRequest.Reply(createReply)) + + patchResponse := generatedResponseFor(patchOperation, http.StatusNoContent) + patchReply := withGeneratedResponseHeaders( + reply.Status(patchResponse.StatusCode), + generatedResponseHeaders(patchResponse, map[string]string{ + "Upload-Offset": "5", + }), + ) + patchRequest := withGeneratedRequestHeaders( + mocha.Request(). + URL(expect.URLPath("/resumable/files/generated-contract")). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte("hello"))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Upload-Offset": "0", + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload := Upload{} + _, err = client.CreateUpload(&upload, 5, false, map[string]string{ + "filename": "hello.txt", + }) + Ω(err).Should(Succeed()) + + stream := NewUploadStream(client, &upload) + stream.ChunkSize = 5 + written, err := stream.Write([]byte("hello")) + Ω(err).Should(Succeed()) + Ω(written).Should(Equal(5)) + Ω(upload.RemoteOffset).Should(Equal(int64(5))) + }) + + It("exposes generated default protocol headers as defensive copies", func() { + Ω(DefaultProtocolRequestHeaders()).Should(Equal(generatedTusDefaultRequestHeaderValues)) + Ω(DefaultProtocolResponseHeaders()).Should(Equal(generatedTusDefaultResponseHeaderValues)) + + requestHeaders := DefaultProtocolRequestHeaders() + for headerName := range requestHeaders { + requestHeaders[headerName] = "mutated" + break + } + + Ω(DefaultProtocolRequestHeaders()).Should(Equal(generatedTusDefaultRequestHeaderValues)) + }) +}) diff --git a/protocol_generated.go b/protocol_generated.go new file mode 100644 index 0000000..4f2577e --- /dev/null +++ b/protocol_generated.go @@ -0,0 +1,110 @@ +// Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +// If it looks wrong, please report the issue instead of editing this file by hand; +// the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +package tusgo + +const ( + // DefaultProtocolVersion is the wire protocol version used by default. + DefaultProtocolVersion = "1.0.0" + + // DefaultClientProtocol is the generated client protocol mode used by default. + DefaultClientProtocol = "tus-v1" + + ProtocolTusV1 = "tus-v1" + ProtocolIetfDraft03 = "ietf-draft-03" + ProtocolIetfDraft05 = "ietf-draft-05" +) + +var defaultProtocolRequestHeaders = map[string]string{"Tus-Resumable": "1.0.0"} +var defaultProtocolResponseHeaders = map[string]string{"Tus-Resumable": "1.0.0"} + +type clientProtocolCompatibilityVersion struct { + RequestHeaders map[string]string + ResponseHeaders map[string]string + UploadBodyContentType string + UploadCompleteHeaderName string + UploadCompleteCompleteValue string + UploadCompleteIncompleteValue string +} + +var clientProtocolCompatibilityVersions = map[string]clientProtocolCompatibilityVersion{ + "tus-v1": { + RequestHeaders: map[string]string{"Tus-Resumable": "1.0.0"}, + ResponseHeaders: map[string]string{"Tus-Resumable": "1.0.0"}, + UploadBodyContentType: "application/offset+octet-stream", + }, + "ietf-draft-03": { + RequestHeaders: map[string]string{"Upload-Draft-Interop-Version": "5"}, + ResponseHeaders: map[string]string{}, + UploadBodyContentType: "", + UploadCompleteHeaderName: "Upload-Complete", + UploadCompleteCompleteValue: "?1", + UploadCompleteIncompleteValue: "?0", + }, + "ietf-draft-05": { + RequestHeaders: map[string]string{"Upload-Draft-Interop-Version": "6"}, + ResponseHeaders: map[string]string{}, + UploadBodyContentType: "application/partial-upload", + UploadCompleteHeaderName: "Upload-Complete", + UploadCompleteCompleteValue: "?1", + UploadCompleteIncompleteValue: "?0", + }, +} + +func copyDefaultProtocolHeaders(headers map[string]string) map[string]string { + copied := make(map[string]string, len(headers)) + for name, value := range headers { + copied[name] = value + } + return copied +} + +func clientProtocolCompatibilityVersionFor(protocolVersion string) (clientProtocolCompatibilityVersion, bool) { + if protocolVersion == "" || protocolVersion == DefaultProtocolVersion { + protocolVersion = DefaultClientProtocol + } + + compatibilityVersion, ok := clientProtocolCompatibilityVersions[protocolVersion] + return compatibilityVersion, ok +} + +func protocolRequestHeaders(protocolVersion string) (map[string]string, bool) { + compatibilityVersion, ok := clientProtocolCompatibilityVersionFor(protocolVersion) + if !ok { + return nil, false + } + + return copyDefaultProtocolHeaders(compatibilityVersion.RequestHeaders), true +} + +func protocolUploadBodyContentType(protocolVersion string) (string, bool) { + compatibilityVersion, ok := clientProtocolCompatibilityVersionFor(protocolVersion) + if !ok || compatibilityVersion.UploadBodyContentType == "" { + return "", false + } + + return compatibilityVersion.UploadBodyContentType, true +} + +func protocolUploadCompleteHeader(protocolVersion string, done bool) (string, string, bool) { + compatibilityVersion, ok := clientProtocolCompatibilityVersionFor(protocolVersion) + if !ok || compatibilityVersion.UploadCompleteHeaderName == "" { + return "", "", false + } + if done { + return compatibilityVersion.UploadCompleteHeaderName, compatibilityVersion.UploadCompleteCompleteValue, true + } + + return compatibilityVersion.UploadCompleteHeaderName, compatibilityVersion.UploadCompleteIncompleteValue, true +} + +// DefaultProtocolRequestHeaders returns the protocol request headers used by default. +func DefaultProtocolRequestHeaders() map[string]string { + return copyDefaultProtocolHeaders(defaultProtocolRequestHeaders) +} + +// DefaultProtocolResponseHeaders returns the protocol response headers used by default. +func DefaultProtocolResponseHeaders() map[string]string { + return copyDefaultProtocolHeaders(defaultProtocolResponseHeaders) +} diff --git a/request_lifecycle_contract_generated_test.go b/request_lifecycle_contract_generated_test.go new file mode 100644 index 0000000..ad780e5 --- /dev/null +++ b/request_lifecycle_contract_generated_test.go @@ -0,0 +1,222 @@ +// 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" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusRequestLifecycleEndpointPath = "/uploads" + generatedTusRequestLifecycleEventPolicy = "exact" + generatedTusRequestLifecycleUploadLength = "11" + generatedTusRequestLifecycleUploadOffset = "11" + generatedTusRequestLifecycleUploadPath = "/uploads/request-hooks-contract" +) + +var generatedTusRequestLifecycleExtraEventPrefixes = []string{} +var generatedTusRequestLifecycleExpectedHookEvents = []string{"before-request:0", "after-response:0"} + +func TestGeneratedRequestLifecycleHooks(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusRequestLifecycleEndpointPath) + if err != nil { + t.Fatal(err) + } + + getOperation := generatedProtocolOperation("getTusUploadOffset") + client := NewClient(http.DefaultClient, baseURL) + createdUploadURL := srvMock.URL() + generatedTusRequestLifecycleUploadPath + + getResponse := generatedResponseFor(getOperation, 200) + getReply := generatedRequestLifecycleResponseHeaders( + reply.Status(getResponse.StatusCode), + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusRequestLifecycleUploadLength, + "Upload-Offset": generatedTusRequestLifecycleUploadOffset, + }, + ) + srvMock.AddMocks( + generatedRequestLifecycleRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRequestLifecycleUploadPath)). + Method(getOperation.Method), + getOperation, + map[string]string{}, + ).Reply(getReply), + ) + + events := []string{} + beforeRequestIndex := 0 + afterResponseIndex := 0 + client = client.WithRequestLifecycleHooks(RequestLifecycleHooks{ + BeforeRequest: func(request *http.Request) error { + if request.Method != getOperation.Method { + return fmt.Errorf("expected %s request, got %s", getOperation.Method, request.Method) + } + if request.URL.Path != generatedTusRequestLifecycleUploadPath { + return fmt.Errorf( + "expected request path %s, got %s", + generatedTusRequestLifecycleUploadPath, + request.URL.Path, + ) + } + if err := generatedAssertRequestLifecycleRequestHeaders( + request, + getOperation, + map[string]string{}, + ); err != nil { + return err + } + events = append(events, generatedTusEventKeyBeforeRequest( + generatedTusEventKeyNumber(int64(beforeRequestIndex)), + )) + beforeRequestIndex += 1 + return nil + }, + AfterResponse: func(request *http.Request, response *http.Response) error { + if request.Method != getOperation.Method { + return fmt.Errorf("expected %s request, got %s", getOperation.Method, request.Method) + } + if response.StatusCode != getResponse.StatusCode { + return fmt.Errorf("expected response status %d, got %d", getResponse.StatusCode, response.StatusCode) + } + if err := generatedAssertRequestLifecycleResponseHeaders( + response, + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusRequestLifecycleUploadLength, + "Upload-Offset": generatedTusRequestLifecycleUploadOffset, + }, + ); err != nil { + return err + } + events = append(events, generatedTusEventKeyAfterResponse( + generatedTusEventKeyNumber(int64(afterResponseIndex)), + )) + afterResponseIndex += 1 + return nil + }, + }) + + upload := Upload{} + response, err := client.GetUpload(&upload, createdUploadURL) + if err != nil { + t.Fatal(err) + } + if response == nil || response.StatusCode != getResponse.StatusCode { + t.Fatalf("expected response status %d, got %#v", getResponse.StatusCode, response) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset %s, got %d", generatedTusRequestLifecycleUploadOffset, upload.RemoteOffset) + } + if upload.RemoteSize != 11 { + t.Fatalf("expected upload length %s, got %d", generatedTusRequestLifecycleUploadLength, upload.RemoteSize) + } + generatedTusAssertEvents(t, "requestLifecycleHooks", generatedTusRequestLifecycleEventPolicy, generatedTusRequestLifecycleExtraEventPrefixes, generatedTusRequestLifecycleExpectedHookEvents, events) +} + +func generatedRequestLifecycleRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedRequestLifecycleResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} + +func generatedAssertRequestLifecycleRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedAssertRequestLifecycleResponseHeaders( + response *http.Response, + contract generatedTusResponseContract, + values map[string]string, +) error { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusResponseHeaderValue(values, field.DisplayName) + if actual := response.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected response header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} diff --git a/request_lifecycle_generated.go b/request_lifecycle_generated.go new file mode 100644 index 0000000..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 +} diff --git a/stream.go b/stream.go index 1aa2efe..9a1c76f 100644 --- a/stream.go +++ b/stream.go @@ -348,8 +348,16 @@ func (us *UploadStream) uploadChunkImpl(requestURL string, data io.Reader, extra if bytesToUpload != unknownSize { req.ContentLength = bytesToUpload } - req.Header.Set("Content-Type", "application/offset+octet-stream") + if contentType, ok := protocolUploadBodyContentType(us.client.ProtocolVersion); ok { + req.Header.Set("Content-Type", contentType) + } req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10)) + if headerName, value, ok := protocolUploadCompleteHeader( + us.client.ProtocolVersion, + bytesToUpload != unknownSize && offset+bytesToUpload >= us.Upload.RemoteSize, + ); ok { + req.Header.Set(headerName, value) + } if us.SetUploadSize && offset == 0 { req.Header.Set("Upload-Length", strconv.FormatInt(us.Upload.RemoteSize, 10)) diff --git a/termination_generated.go b/termination_generated.go new file mode 100644 index 0000000..3386d6b --- /dev/null +++ b/termination_generated.go @@ -0,0 +1,51 @@ +// 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) + } + 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 new file mode 100644 index 0000000..520afab --- /dev/null +++ b/termination_retry_contract_generated_test.go @@ -0,0 +1,300 @@ +// 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 ( + generatedTusTerminateFlowChunkCompleteActionKind = "abort-upload" + generatedTusTerminateFlowContent = "hello world" + generatedTusTerminateFlowEndpointPath = "/uploads" + generatedTusTerminateFlowEventPolicy = "exact" + generatedTusTerminateFlowFinalStatus = 204 + generatedTusTerminateFlowPatchAcceptedOffset = "5" + generatedTusTerminateFlowPatchBody = "hello" + generatedTusTerminateFlowPatchOffset = "0" + generatedTusTerminateFlowUploadLength = "11" + generatedTusTerminateFlowUploadPath = "/uploads/terminate-contract" +) + +type generatedTusTerminateRetryDecision struct { + Decision bool + RetryAttempt int +} + +type generatedTusChunkCompleteAction struct { + Kind string + TerminateUpload bool +} + +type generatedTusTerminateAttempt struct { + Status int +} + +var generatedTusTerminateFlowExtraEventPrefixes = []string{} +var generatedTusTerminateFlowExpectedEvents = []string{"should-retry:0:true", "retry-schedule:0"} +var generatedTusTerminateFlowMetadata = map[string]string{"filename": "hello.txt"} +var generatedTusTerminateFlowOnChunkCompleteActions = []generatedTusChunkCompleteAction{ + { + Kind: "abort-upload", + TerminateUpload: true, + }, +} +var generatedTusTerminateFlowRetryDelays = []time.Duration{0 * time.Millisecond, 0 * time.Millisecond} +var generatedTusTerminateFlowTerminateAttempts = []generatedTusTerminateAttempt{ + { + Status: 423, + }, + { + Status: 204, + }, +} +var generatedTusTerminateFlowShouldRetryEvents = []generatedTusTerminateRetryDecision{ + { + Decision: true, + RetryAttempt: 0, + }, +} + +func TestGeneratedTerminationRetryFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusTerminateFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + terminateOperation := generatedProtocolOperation("terminateTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, terminateOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + createdUploadURL := srvMock.URL() + generatedTusTerminateFlowUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusTerminateFlowMetadata) + if err != nil { + t.Fatal(err) + } + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedTerminationRetryResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedTerminationRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusTerminateFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusTerminateFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedTerminationRetryResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusTerminateFlowPatchAcceptedOffset, + }, + ) + srvMock.AddMocks( + generatedTerminationRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusTerminateFlowUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusTerminateFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusTerminateFlowPatchOffset, + }, + ).Reply(patchReply), + ) + + if len(generatedTusTerminateFlowTerminateAttempts) == 0 { + t.Fatal("expected at least one generated termination attempt") + } + terminateReplies := make([]*reply.StdReply, 0, len(generatedTusTerminateFlowTerminateAttempts)) + for _, terminateAttempt := range generatedTusTerminateFlowTerminateAttempts { + terminateResponse := generatedResponseFor(terminateOperation, terminateAttempt.Status) + terminateReply := generatedTerminationRetryResponseHeaders( + reply.Status(terminateAttempt.Status), + terminateResponse, + map[string]string{}, + ) + terminateReplies = append(terminateReplies, terminateReply) + } + terminateReplyIndex := 0 + srvMock.AddMocks( + generatedTerminationRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusTerminateFlowUploadPath)). + Method(terminateOperation.Method), + terminateOperation, + map[string]string{}, + ).Repeat(len(terminateReplies)).ReplyFunction(func(r *http.Request, m reply.M, p params.P) (*reply.Response, error) { + if terminateReplyIndex >= len(terminateReplies) { + t.Fatalf("unexpected termination request %d", terminateReplyIndex) + return nil, nil + } + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + if len(body) != 0 { + t.Fatalf("expected empty termination body, got %q", string(body)) + } + expected := terminateReplies[terminateReplyIndex] + terminateReplyIndex += 1 + return expected.Build(r, m, p) + }), + ) + + upload := &Upload{} + if _, err := client.CreateUpload(upload, 11, false, generatedTusTerminateFlowMetadata); err != nil { + t.Fatal(err) + } + stream := NewUploadStream(client, upload) + stream.ChunkSize = 5 + if _, err := stream.ReadFrom(strings.NewReader(generatedTusTerminateFlowPatchBody)); err != nil { + t.Fatal(err) + } + if upload.RemoteOffset != 5 { + t.Fatalf("expected uploaded offset 5, got %d", upload.RemoteOffset) + } + + events := []string{} + retryDecisionIndex := 0 + response, err := generatedTusRunTerminateFlowChunkCompleteActions(t, client, *upload, generatedTusTerminateFlowOnChunkCompleteActions, TerminateUploadOptions{ + RetryDelays: generatedTusTerminateFlowRetryDelays, + OnShouldRetry: func(err error, retryAttempt int) bool { + if retryDecisionIndex >= len(generatedTusTerminateFlowShouldRetryEvents) { + t.Fatalf("unexpected termination retry decision request %d for %v", retryDecisionIndex, err) + } + expected := generatedTusTerminateFlowShouldRetryEvents[retryDecisionIndex] + if retryAttempt != expected.RetryAttempt { + t.Fatalf("expected termination retry attempt %d, got %d", expected.RetryAttempt, retryAttempt) + } + events = append(events, generatedTusEventKeyShouldRetry( + generatedTusEventKeyNumber(int64(retryAttempt)), + generatedTusEventKeyBool(expected.Decision), + )) + if expected.Decision { + events = append(events, generatedTusEventKeyRetrySchedule( + generatedTusEventKeyNumber(generatedTusTerminateFlowRetryDelays[retryAttempt].Milliseconds()), + )) + } + retryDecisionIndex += 1 + return expected.Decision + }, + }) + if err != nil { + t.Fatal(err) + } + if response == nil || response.StatusCode != generatedTusTerminateFlowFinalStatus { + t.Fatalf("expected termination status %d, got %#v", generatedTusTerminateFlowFinalStatus, response) + } + if terminateReplyIndex != len(terminateReplies) { + t.Fatalf("expected %d termination requests, got %d", len(terminateReplies), terminateReplyIndex) + } + if retryDecisionIndex != len(generatedTusTerminateFlowShouldRetryEvents) { + t.Fatalf("expected %d termination retry decisions, got %d", len(generatedTusTerminateFlowShouldRetryEvents), retryDecisionIndex) + } + generatedTusAssertEvents(t, "terminateWithRetry", generatedTusTerminateFlowEventPolicy, generatedTusTerminateFlowExtraEventPrefixes, generatedTusTerminateFlowExpectedEvents, events) +} + +func generatedTusRunTerminateFlowChunkCompleteActions( + t *testing.T, + client *Client, + upload Upload, + actions []generatedTusChunkCompleteAction, + options TerminateUploadOptions, +) (*http.Response, error) { + t.Helper() + + var response *http.Response + for _, action := range actions { + if action.Kind != generatedTusTerminateFlowChunkCompleteActionKind { + t.Fatalf("unsupported generated onChunkComplete action %s", action.Kind) + } + if !action.TerminateUpload { + continue + } + + var err error + response, err = client.TerminateUploadWithRetry(upload, options) + if err != nil { + return response, err + } + } + + return response, nil +} + +func generatedTerminationRetryRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedTerminationRetryResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_abort_contract_generated_test.go b/url_storage_abort_contract_generated_test.go new file mode 100644 index 0000000..780ba5d --- /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" + "strings" + "testing" + "time" +) + +const ( + generatedTusAbortCancelRequestIndex = 0 + generatedTusAbortEventPolicy = "exact" + generatedTusAbortContent = "hello world" + generatedTusAbortEndpointPath = "/uploads" + generatedTusAbortUploadLength = "11" +) + +var generatedTusAbortExtraEventPrefixes = []string{} +var generatedTusAbortExpectedEvents = []string{"request-abort:0"} +var generatedTusAbortMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedAbortUploadContext(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusAbortMetadata) + if err != nil { + t.Fatal(err) + } + + requestStarted := make(chan struct{}) + requestDone := make(chan struct{}) + events := []string{} + var requestErr error + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + defer close(requestDone) + if request.URL.Path != generatedTusAbortEndpointPath { + requestErr = fmt.Errorf("expected path %s, got %s", generatedTusAbortEndpointPath, request.URL.Path) + } + if request.Method != createOperation.Method { + requestErr = fmt.Errorf("expected method %s, got %s", createOperation.Method, request.Method) + } + if err := generatedAssertTusAbortRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusAbortUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ); err != nil { + requestErr = err + } + events = append(events, generatedTusEventKeyRequestAbort( + generatedTusEventKeyNumber(int64(generatedTusAbortCancelRequestIndex)), + )) + close(requestStarted) + <-request.Context().Done() + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusAbortEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + storage := NewMemoryURLStorage() + go func() { + _, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Context: ctx, + Storage: storage, + Source: strings.NewReader(generatedTusAbortContent), + Fingerprint: "contract-abort-fingerprint", + Size: 11, + Metadata: generatedTusAbortMetadata, + }) + result <- err + }() + + select { + case <-requestStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort request") + } + if requestErr != nil { + t.Fatal(requestErr) + } + cancel() + + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort result") + } + select { + case <-requestDone: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for server to observe abort") + } + generatedTusAssertEvents(t, "abortUpload", generatedTusAbortEventPolicy, generatedTusAbortExtraEventPrefixes, generatedTusAbortExpectedEvents, events) + + storedUploads, err := storage.FindAllUploads() + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 0 { + t.Fatalf("expected aborted create not to store uploads, got %#v", storedUploads) + } +} + +func generatedAssertTusAbortRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} diff --git a/url_storage_abort_termination_contract_generated_test.go b/url_storage_abort_termination_contract_generated_test.go new file mode 100644 index 0000000..bfa1861 --- /dev/null +++ b/url_storage_abort_termination_contract_generated_test.go @@ -0,0 +1,281 @@ +// 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" + "testing" + "time" +) + +const ( + generatedTusAbortTerminationCancelRequestIndex = 1 + generatedTusAbortTerminationEventPolicy = "exact" + generatedTusAbortTerminationContent = "hello world" + generatedTusAbortTerminationContentType = "application/offset+octet-stream" + generatedTusAbortTerminationContentTypeHeader = "Content-Type" + generatedTusAbortTerminationEndpointPath = "/uploads" + generatedTusAbortTerminationFingerprint = "contract-abort-terminate-fingerprint" + generatedTusAbortTerminationMethod = "POST" + generatedTusAbortTerminationOverrideHeader = "X-HTTP-Method-Override" + generatedTusAbortTerminationOverrideValue = "PATCH" + generatedTusAbortTerminationPatchBody = "hello world" + generatedTusAbortTerminationPatchOffset = "0" + generatedTusAbortTerminationOffsetHeader = "Upload-Offset" + generatedTusAbortTerminationUploadLength = "11" + generatedTusAbortTerminationUploadPath = "/uploads/abort-terminate-contract" +) + +var generatedTusAbortTerminationHeaders = map[string]string{"X-Tus-Contract": "abort-policy", "X-Tus-Trace": "abort-trace-123"} +var generatedTusAbortTerminationExtraEventPrefixes = []string{} +var generatedTusAbortTerminationExpectedEvents = []string{"request-abort:1"} +var generatedTusAbortTerminationMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedAbortTerminatesKnownUpload(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + terminateOperation := generatedProtocolOperation("terminateTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusAbortTerminationMetadata) + if err != nil { + t.Fatal(err) + } + + patchStarted := make(chan struct{}) + patchDone := make(chan struct{}) + terminationDone := make(chan struct{}) + requestErrs := make(chan error, 8) + events := []string{} + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusAbortTerminationEndpointPath && request.Method == createOperation.Method: + recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusAbortTerminationUploadLength, + "Upload-Metadata": encodedMetadata, + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + }, + )) + recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( + request, + generatedTusAbortTerminationHeaders, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusAbortTerminationResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusAbortTerminationUploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(201) + + case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == generatedTusAbortTerminationMethod: + defer close(patchDone) + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusAbortTerminationPatchBody { + recordRequestErr(fmt.Errorf("expected abort patch body %q, got %q", generatedTusAbortTerminationPatchBody, string(body))) + } + recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusAbortTerminationContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusAbortTerminationPatchOffset, + "X-HTTP-Method-Override": generatedTusAbortTerminationOverrideValue, + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + }, + )) + recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( + request, + generatedTusAbortTerminationHeaders, + )) + if actual := request.Header.Get(generatedTusAbortTerminationOverrideHeader); actual != generatedTusAbortTerminationOverrideValue { + recordRequestErr(fmt.Errorf("expected override header %s, got %s", generatedTusAbortTerminationOverrideValue, actual)) + } + events = append(events, generatedTusEventKeyRequestAbort( + generatedTusEventKeyNumber(int64(generatedTusAbortTerminationCancelRequestIndex)), + )) + close(patchStarted) + <-request.Context().Done() + + case request.URL.Path == generatedTusAbortTerminationUploadPath && request.Method == terminateOperation.Method: + recordRequestErr(generatedAssertTusAbortTerminationRequestHeaders( + request, + terminateOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "abort-policy", + "X-Tus-Trace": "abort-trace-123", + }, + )) + recordRequestErr(generatedAssertTusAbortTerminationCustomHeaders( + request, + generatedTusAbortTerminationHeaders, + )) + if actual := request.Header.Get(generatedTusAbortTerminationOverrideHeader); actual != "" { + recordRequestErr(fmt.Errorf("expected no override header on termination request, got %s", actual)) + } + terminateResponse := generatedResponseFor(terminateOperation, 204) + generatedWriteTusAbortTerminationResponseHeaders( + responseWriter, + terminateResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(204) + close(terminationDone) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusAbortTerminationEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, terminateOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + storage := NewMemoryURLStorage() + go func() { + _, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Context: ctx, + Storage: storage, + Source: strings.NewReader(generatedTusAbortTerminationContent), + Fingerprint: generatedTusAbortTerminationFingerprint, + Size: 11, + Headers: generatedTusAbortTerminationHeaders, + Metadata: generatedTusAbortTerminationMetadata, + OverridePatchMethod: true, + TerminateUploadOnAbort: true, + }) + result <- err + }() + + select { + case <-patchStarted: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort patch request") + } + cancel() + + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for abort termination result") + } + select { + case <-patchDone: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for server to observe abort") + } + select { + case <-terminationDone: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for termination request") + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "abortUploadAfterStoredUrl", generatedTusAbortTerminationEventPolicy, generatedTusAbortTerminationExtraEventPrefixes, generatedTusAbortTerminationExpectedEvents, events) + + storedUploads, err := storage.FindAllUploads() + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 0 { + t.Fatalf("expected terminated abort to remove stored uploads, got %#v", storedUploads) + } +} + +func generatedAssertTusAbortTerminationRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedAssertTusAbortTerminationCustomHeaders( + request *http.Request, + expected map[string]string, +) error { + for key, value := range expected { + if actual := request.Header.Get(key); actual != value { + return fmt.Errorf("expected custom header %s=%s, got %s", key, value, actual) + } + } + + return nil +} + +func generatedWriteTusAbortTerminationResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_contract_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_create_contract_generated_test.go b/url_storage_create_contract_generated_test.go new file mode 100644 index 0000000..4e02344 --- /dev/null +++ b/url_storage_create_contract_generated_test.go @@ -0,0 +1,183 @@ +// 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" + generatedTusCreateFlowEndpointPath = "/uploads" + generatedTusCreateFlowFingerprint = "contract-single-fingerprint" + generatedTusCreateFlowPatchAcceptedOffset = "11" + generatedTusCreateFlowPatchBody = "hello world" + generatedTusCreateFlowPatchOffset = "0" + generatedTusCreateFlowRemoveFingerprintOnSuccess = false + generatedTusCreateFlowUploadLength = "11" +) + +var generatedTusCreateFlowMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageCreateFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusCreateFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + createdUploadURL := srvMock.URL() + generatedTusCreateFlowCreatedUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusCreateFlowMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageCreateResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageCreateRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusCreateFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreateFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageCreateResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreateFlowPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageCreateRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusCreateFlowCreatedUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusCreateFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreateFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusCreateFlowContent), + Fingerprint: generatedTusCreateFlowFingerprint, + Size: 11, + Metadata: generatedTusCreateFlowMetadata, + RemoveFingerprintOnSuccess: generatedTusCreateFlowRemoveFingerprintOnSuccess, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected created upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + + storedUploads, err := storage.FindUploadsByFingerprint(generatedTusCreateFlowFingerprint) + if err != nil { + t.Fatal(err) + } + shouldRemoveStoredUpload, 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) + } + return + } + if len(storedUploads) != 1 { + t.Fatalf("expected successful create flow to store one upload, got %#v", storedUploads) + } + storedUploadURL, ok := stringFromURLStorageUpload(storedUploads[0], "uploadUrl") + if !ok || storedUploadURL != createdUploadURL { + t.Fatalf("expected stored upload URL %s, got %#v", createdUploadURL, storedUploads[0]) + } +} + +func generatedURLStorageCreateRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageCreateResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_creation_with_upload_contract_generated_test.go b/url_storage_creation_with_upload_contract_generated_test.go new file mode 100644 index 0000000..f48fee2 --- /dev/null +++ b/url_storage_creation_with_upload_contract_generated_test.go @@ -0,0 +1,229 @@ +// 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 ( + generatedTusCreationWithUploadContent = "hello world" + generatedTusCreationWithUploadContentType = "application/offset+octet-stream" + generatedTusCreationWithUploadContentTypeHeader = "Content-Type" + generatedTusCreationWithUploadEndpointPath = "/uploads" + generatedTusCreationWithUploadEventPolicy = "exact-except-allowed-extra-events" + generatedTusCreationWithUploadExpectedRequests = 1 + generatedTusCreationWithUploadLength = "11" + generatedTusCreationWithUploadLengthHeader = "Upload-Length" + generatedTusCreationWithUploadMetadataHeader = "Upload-Metadata" + generatedTusCreationWithUploadOffset = "11" + generatedTusCreationWithUploadOffsetHeader = "Upload-Offset" + generatedTusCreationWithUploadPath = "/uploads/creation-with-upload-contract" +) + +var generatedTusCreationWithUploadExtraEventPrefixes = []string{"progress:"} +var generatedTusCreationWithUploadExpectedEvents = []string{"progress:0:11", "progress:11:11", "upload-url-available", "success", "source-close"} +var generatedTusCreationWithUploadMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageCreationWithUpload(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusCreationWithUploadMetadata) + if err != nil { + t.Fatal(err) + } + + requestCount := 0 + requestErrs := make(chan error, 4) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + if request.URL.Path != generatedTusCreationWithUploadEndpointPath || + request.Method != createOperation.Method { + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusCreationWithUploadContent { + recordRequestErr(fmt.Errorf( + "expected creation-with-upload body %q, got %q", + generatedTusCreationWithUploadContent, + string(body), + )) + } + if actual := request.Header.Get(generatedTusCreationWithUploadContentTypeHeader); actual != generatedTusCreationWithUploadContentType { + recordRequestErr(fmt.Errorf( + "expected creation-with-upload content type %s, got %s", + generatedTusCreationWithUploadContentType, + actual, + )) + } + recordRequestErr(generatedAssertTusCreationWithUploadRequestHeaders( + request, + createOperation, + map[string]string{ + "Content-Type": generatedTusCreationWithUploadContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreationWithUploadLength, + "Upload-Metadata": encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCreationWithUploadResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCreationWithUploadPath, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreationWithUploadOffset, + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusCreationWithUploadEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusCreationWithUploadExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + source := &generatedTusCreationWithUploadSource{ + Reader: strings.NewReader(generatedTusCreationWithUploadContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: "contract-creation-with-upload-fingerprint", + Size: int64(len(generatedTusCreationWithUploadContent)), + Metadata: generatedTusCreationWithUploadMetadata, + UploadDataDuringCreation: true, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusCreationWithUploadBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != server.URL+generatedTusCreationWithUploadPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusCreationWithUploadPath, upload.Location) + } + if upload.RemoteOffset != int64(len(generatedTusCreationWithUploadContent)) { + t.Fatalf("expected upload offset %d, got %d", len(generatedTusCreationWithUploadContent), upload.RemoteOffset) + } + if requestCount != generatedTusCreationWithUploadExpectedRequests { + t.Fatalf("expected %d creation-with-upload request(s), got %d", generatedTusCreationWithUploadExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "creationWithUpload", generatedTusCreationWithUploadEventPolicy, generatedTusCreationWithUploadExtraEventPrefixes, generatedTusCreationWithUploadExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected creation-with-upload URL to be stored once, got %#v", storedUploads) + } +} + +type generatedTusCreationWithUploadSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusCreationWithUploadSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedTusCreationWithUploadBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusCreationWithUploadRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusCreationWithUploadResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCreationWithUploadOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCreationWithUploadOffsetHeader, value) + } +} diff --git a/url_storage_creation_with_upload_partial_contract_generated_test.go b/url_storage_creation_with_upload_partial_contract_generated_test.go new file mode 100644 index 0000000..727475f --- /dev/null +++ b/url_storage_creation_with_upload_partial_contract_generated_test.go @@ -0,0 +1,329 @@ +// 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 ( + generatedTusCreationPartialContent = "hello world" + generatedTusCreationPartialContentType = "application/offset+octet-stream" + generatedTusCreationPartialContentTypeHeader = "Content-Type" + generatedTusCreationPartialCreateBodySize = 5 + generatedTusCreationPartialEndpointPath = "/uploads" + generatedTusCreationPartialEventPolicy = "exact-except-allowed-extra-events" + generatedTusCreationPartialExpectedRequests = 3 + generatedTusCreationPartialLength = "11" + generatedTusCreationPartialLengthHeader = "Upload-Length" + generatedTusCreationPartialMetadataHeader = "Upload-Metadata" + generatedTusCreationPartialOffset = "5" + generatedTusCreationPartialOffsetHeader = "Upload-Offset" + generatedTusCreationPartialPath = "/uploads/creation-with-upload-partial-contract" + generatedTusCreationPartialChunkSize = 5 +) + +type generatedTusCreationPartialPatchAttempt struct { + BodySize int + BodyStart int + Offset string + Result string + Status int +} + +var generatedTusCreationPartialExtraEventPrefixes = []string{"progress:"} +var generatedTusCreationPartialExpectedEvents = []string{"progress:0:11", "progress:5:11", "upload-url-available", "chunk-complete:5:5:11", "progress:5:11", "progress:10:11", "chunk-complete:5:10:11", "progress:10:11", "progress:11:11", "chunk-complete:1:11:11", "success", "source-close"} +var generatedTusCreationPartialMetadata = map[string]string{"filename": "hello.txt"} +var generatedTusCreationPartialPatchAttempts = []generatedTusCreationPartialPatchAttempt{ + { + BodySize: 5, + BodyStart: 5, + Offset: "5", + Result: "10", + Status: 204, + }, + { + BodySize: 1, + BodyStart: 10, + Offset: "10", + Result: "11", + Status: 204, + }, +} + +func TestGeneratedURLStorageCreationWithUploadPartialChunk(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusCreationPartialMetadata) + if err != nil { + t.Fatal(err) + } + + requestCount := 0 + patchRequestCount := 0 + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusCreationPartialEndpointPath && + request.Method == createOperation.Method: + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + expectedBody := generatedTusCreationPartialContent[:generatedTusCreationPartialCreateBodySize] + if string(body) != expectedBody { + recordRequestErr(fmt.Errorf( + "expected partial creation body %q, got %q", + expectedBody, + string(body), + )) + } + if actual := request.Header.Get(generatedTusCreationPartialContentTypeHeader); actual != generatedTusCreationPartialContentType { + recordRequestErr(fmt.Errorf( + "expected partial creation content type %s, got %s", + generatedTusCreationPartialContentType, + actual, + )) + } + recordRequestErr(generatedAssertTusCreationPartialRequestHeaders( + request, + createOperation, + map[string]string{ + "Content-Type": generatedTusCreationPartialContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCreationPartialLength, + "Upload-Metadata": encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCreationPartialResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCreationPartialPath, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCreationPartialOffset, + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusCreationPartialPath && + request.Method == patchOperation.Method: + requestCount += 1 + patchRequestCount += 1 + if patchRequestCount > len(generatedTusCreationPartialPatchAttempts) { + recordRequestErr(fmt.Errorf("unexpected continuation request %d", patchRequestCount)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + attempt := generatedTusCreationPartialPatchAttempts[patchRequestCount-1] + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + expectedBodyEnd := attempt.BodyStart + attempt.BodySize + expectedBody := generatedTusCreationPartialContent[attempt.BodyStart:expectedBodyEnd] + if len(expectedBody) != attempt.BodySize { + recordRequestErr(fmt.Errorf( + "expected configured patch body size %d, got %d", + attempt.BodySize, + len(expectedBody), + )) + } + if string(body) != expectedBody { + recordRequestErr(fmt.Errorf( + "expected continuation body %q, got %q", + expectedBody, + string(body), + )) + } + if actual := request.Header.Get(generatedTusCreationPartialContentTypeHeader); actual != generatedTusCreationPartialContentType { + recordRequestErr(fmt.Errorf( + "expected continuation content type %s, got %s", + generatedTusCreationPartialContentType, + actual, + )) + } + recordRequestErr(generatedAssertTusCreationPartialRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusCreationPartialContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": attempt.Offset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, attempt.Status) + generatedWriteTusCreationPartialResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": attempt.Result, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusCreationPartialEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusCreationWithUploadExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + source := &generatedTusCreationPartialSource{ + Reader: strings.NewReader(generatedTusCreationPartialContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: "contract-creation-with-upload-partial-fingerprint", + Size: int64(len(generatedTusCreationPartialContent)), + Metadata: generatedTusCreationPartialMetadata, + ChunkSize: int64(generatedTusCreationPartialChunkSize), + UploadDataDuringCreation: true, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusCreationPartialBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusCreationPartialBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + select { + case requestErr := <-requestErrs: + t.Fatalf("%v: %v", err, requestErr) + default: + t.Fatal(err) + } + } + if upload.Location != server.URL+generatedTusCreationPartialPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusCreationPartialPath, upload.Location) + } + if upload.RemoteOffset != int64(len(generatedTusCreationPartialContent)) { + t.Fatalf("expected upload offset %d, got %d", len(generatedTusCreationPartialContent), upload.RemoteOffset) + } + if requestCount != generatedTusCreationPartialExpectedRequests { + t.Fatalf("expected %d partial creation request(s), got %d", generatedTusCreationPartialExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "creationWithUploadPartialChunk", generatedTusCreationPartialEventPolicy, generatedTusCreationPartialExtraEventPrefixes, generatedTusCreationPartialExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-creation-with-upload-partial-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected partial creation URL to be stored once, got %#v", storedUploads) + } +} + +type generatedTusCreationPartialSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusCreationPartialSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedTusCreationPartialBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusCreationPartialRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusCreationPartialResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCreationPartialOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCreationPartialOffsetHeader, value) + } +} diff --git a/url_storage_custom_headers_contract_generated_test.go b/url_storage_custom_headers_contract_generated_test.go new file mode 100644 index 0000000..8104a1e --- /dev/null +++ b/url_storage_custom_headers_contract_generated_test.go @@ -0,0 +1,220 @@ +// 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" + generatedTusCustomHeadersExpectedRequests = 2 + generatedTusCustomHeadersLength = "11" + generatedTusCustomHeadersLengthHeader = "Upload-Length" + generatedTusCustomHeadersMetadataHeader = "Upload-Metadata" + generatedTusCustomHeadersOffset = "0" + generatedTusCustomHeadersOffsetHeader = "Upload-Offset" + generatedTusCustomHeadersPath = "/uploads/custom-headers-contract" + generatedTusCustomHeadersAcceptedOffset = "11" +) + +var generatedTusCustomHeaders = map[string]string{"X-Tus-Contract": "custom-header", "X-Tus-Trace": "trace-123"} +var generatedTusCustomHeadersMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageCustomRequestHeaders(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusCustomHeadersMetadata) + if err != nil { + t.Fatal(err) + } + + requestCount := 0 + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusCustomHeadersEndpointPath && + request.Method == createOperation.Method: + requestCount += 1 + recordRequestErr(generatedAssertTusCustomRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusCustomHeadersLength, + "Upload-Metadata": encodedMetadata, + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123", + }, + )) + recordRequestErr(generatedAssertTusCustomHeaderValues(request, generatedTusCustomHeaders)) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusCustomResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusCustomHeadersPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusCustomHeadersPath && + request.Method == patchOperation.Method: + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusCustomHeadersContent { + recordRequestErr(fmt.Errorf( + "expected custom-header upload body %q, got %q", + generatedTusCustomHeadersContent, + string(body), + )) + } + recordRequestErr(generatedAssertTusCustomRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusCustomHeadersContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCustomHeadersOffset, + "X-Tus-Contract": "custom-header", + "X-Tus-Trace": "trace-123", + }, + )) + recordRequestErr(generatedAssertTusCustomHeaderValues(request, generatedTusCustomHeaders)) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusCustomResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusCustomHeadersAcceptedOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusCustomHeadersEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusCustomHeadersContent), + Fingerprint: "contract-custom-headers-fingerprint", + Size: 11, + Headers: generatedTusCustomHeaders, + Metadata: generatedTusCustomHeadersMetadata, + }) + if err != nil { + select { + case requestErr := <-requestErrs: + t.Fatalf("%v: %v", err, requestErr) + default: + t.Fatal(err) + } + } + if upload.Location != server.URL+generatedTusCustomHeadersPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusCustomHeadersPath, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + if requestCount != generatedTusCustomHeadersExpectedRequests { + t.Fatalf("expected %d custom-header request(s), got %d", generatedTusCustomHeadersExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } +} + +func generatedAssertTusCustomHeaderValues( + request *http.Request, + expected map[string]string, +) error { + for key, value := range expected { + if actual := request.Header.Get(key); actual != value { + return fmt.Errorf("expected custom header %s=%s, got %s", key, value, actual) + } + } + + return nil +} + +func generatedAssertTusCustomRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusCustomResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusCustomHeadersOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusCustomHeadersOffsetHeader, value) + } +} diff --git a/url_storage_deferred_length_contract_generated_test.go b/url_storage_deferred_length_contract_generated_test.go new file mode 100644 index 0000000..29dd0a6 --- /dev/null +++ b/url_storage_deferred_length_contract_generated_test.go @@ -0,0 +1,299 @@ +// 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 ( + generatedTusDeferredLengthAcceptedOffset = "11" + generatedTusDeferredLengthContent = "hello world" + generatedTusDeferredLengthContentTypeHeader = "Content-Type" + generatedTusDeferredLengthCreateDeferHeader = "Upload-Defer-Length" + generatedTusDeferredLengthCreateDeferValue = "1" + generatedTusDeferredLengthEndpointPath = "/uploads" + generatedTusDeferredLengthEventPolicy = "exact-except-allowed-extra-events" + generatedTusDeferredLengthExpectedCreates = 1 + generatedTusDeferredLengthExpectedPatches = 1 + generatedTusDeferredLengthMetadataHeader = "Upload-Metadata" + generatedTusDeferredLengthPatchLength = "11" + generatedTusDeferredLengthPatchLengthHeader = "Upload-Length" + generatedTusDeferredLengthPatchOffset = "0" + generatedTusDeferredLengthPatchOffsetHeader = "Upload-Offset" + generatedTusDeferredLengthUploadPath = "/uploads/deferred-contract" +) + +var generatedTusDeferredLengthCreateAbsentHeaders = []string{"Upload-Length"} +var generatedTusDeferredLengthExtraEventPrefixes = []string{"progress:"} +var generatedTusDeferredLengthExpectedEvents = []string{"upload-url-available", "progress:0:11", "progress:11:11", "chunk-complete:11:11:11", "success", "source-close"} +var generatedTusDeferredLengthMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageDeferredLengthUpload(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusDeferredLengthMetadata) + if err != nil { + t.Fatal(err) + } + + createCount := 0 + patchCount := 0 + requestErrs := make(chan error, 6) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusDeferredLengthEndpointPath && + request.Method == createOperation.Method: + createCount += 1 + recordRequestErr(generatedAssertTusDeferredLengthAbsentHeaders( + request, + generatedTusDeferredLengthCreateAbsentHeaders, + )) + recordRequestErr(generatedAssertTusDeferredLengthRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Defer-Length": generatedTusDeferredLengthCreateDeferValue, + "Upload-Metadata": encodedMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusDeferredLengthResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusDeferredLengthUploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusDeferredLengthUploadPath && + request.Method == patchOperation.Method: + patchCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusDeferredLengthContent { + recordRequestErr(fmt.Errorf( + "expected deferred upload body %q, got %q", + generatedTusDeferredLengthContent, + string(body), + )) + } + recordRequestErr(generatedAssertTusDeferredLengthRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusDeferredLengthPatchLength, + "Upload-Offset": generatedTusDeferredLengthPatchOffset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusDeferredLengthResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusDeferredLengthAcceptedOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusDeferredLengthEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusDeferredLengthExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + source := &generatedTusDeferredLengthSource{ + Reader: strings.NewReader(generatedTusDeferredLengthContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: "contract-deferred-length-fingerprint", + Size: int64(len(generatedTusDeferredLengthContent)), + Metadata: generatedTusDeferredLengthMetadata, + ChunkSize: 100, + UploadLengthDeferred: true, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusDeferredLengthBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusDeferredLengthBytesTotalString(bytesTotal), + )) + return nil + }, + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnSuccess: func(UploadSuccessPayload) error { + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != server.URL+generatedTusDeferredLengthUploadPath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusDeferredLengthUploadPath, upload.Location) + } + if createCount != generatedTusDeferredLengthExpectedCreates || patchCount != generatedTusDeferredLengthExpectedPatches { + t.Fatalf( + "expected create=%d patch=%d, got create=%d patch=%d", + generatedTusDeferredLengthExpectedCreates, + generatedTusDeferredLengthExpectedPatches, + createCount, + patchCount, + ) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "deferredLengthUpload", generatedTusDeferredLengthEventPolicy, generatedTusDeferredLengthExtraEventPrefixes, generatedTusDeferredLengthExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-deferred-length-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected deferred upload URL to be stored once, got %#v", storedUploads) + } +} + +type generatedTusDeferredLengthSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusDeferredLengthSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedTusDeferredLengthBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusDeferredLengthAbsentHeaders( + request *http.Request, + headers []string, +) error { + for _, header := range headers { + if actual := request.Header.Get(header); actual != "" { + return fmt.Errorf("expected request header %s to be absent, got %s", header, actual) + } + } + + return nil +} + +func generatedAssertTusDeferredLengthRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusDeferredLengthRequestHeaderVariant(request, variant, values); err != nil { + failures = append(failures, err.Error()) + continue + } + + return nil + } + + return fmt.Errorf( + "no %s request header variant matched: %s", + operation.OperationID, + strings.Join(failures, "; "), + ) +} + +func generatedAssertTusDeferredLengthRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusDeferredLengthResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_event_hooks_contract_generated_test.go b/url_storage_event_hooks_contract_generated_test.go new file mode 100644 index 0000000..5afdcb1 --- /dev/null +++ b/url_storage_event_hooks_contract_generated_test.go @@ -0,0 +1,226 @@ +// 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" + "strings" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusEventHooksContent = "hello world" + generatedTusEventHooksCreatedUploadPath = "/uploads/generated-contract" + generatedTusEventHooksEndpointPath = "/uploads" + generatedTusEventHooksEventPolicy = "exact-except-allowed-extra-events" + generatedTusEventHooksFingerprint = "contract-single-fingerprint" + generatedTusEventHooksPatchAcceptedOffset = "11" + generatedTusEventHooksPatchBody = "hello world" + generatedTusEventHooksPatchOffset = "0" + generatedTusEventHooksUploadLength = "11" +) + +var generatedTusEventHooksExtraEventPrefixes = []string{"progress:"} +var generatedTusEventHooksExpectedEvents = []string{"upload-url-available", "progress:0:11", "progress:11:11", "chunk-complete:11:11:11", "success", "source-close"} +var generatedTusEventHooksMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedURLStorageEventHooks(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusEventHooksEndpointPath) + if err != nil { + t.Fatal(err) + } + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + createdUploadURL := srvMock.URL() + generatedTusEventHooksCreatedUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusEventHooksMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageEventHooksResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageEventHooksRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusEventHooksEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusEventHooksUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageEventHooksResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusEventHooksPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageEventHooksRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusEventHooksCreatedUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusEventHooksPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusEventHooksPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + events := []string{} + source := &generatedTusEventHooksSource{ + Reader: strings.NewReader(generatedTusEventHooksContent), + events: &events, + } + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: source, + Fingerprint: generatedTusEventHooksFingerprint, + Size: 11, + Metadata: generatedTusEventHooksMetadata, + EventHooks: UploadEventHooks{ + OnUploadURLAvailable: func() error { + events = append(events, generatedTusEventKeyUploadUrlAvailable()) + return nil + }, + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append( + events, + generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusEventHooksTotal(bytesTotal), + ), + ) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append( + events, + generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusEventHooksTotal(bytesTotal), + ), + ) + return nil + }, + OnSuccess: func(payload UploadSuccessPayload) error { + if payload.Upload == nil || payload.Upload.Location != createdUploadURL { + return fmt.Errorf("expected success upload URL %s, got %#v", createdUploadURL, payload.Upload) + } + if payload.LastResponse == nil || payload.LastResponse.StatusCode != patchResponse.StatusCode { + return fmt.Errorf( + "expected success response status %d, got %#v", + patchResponse.StatusCode, + payload.LastResponse, + ) + } + events = append(events, generatedTusEventKeySuccess()) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected created upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + generatedTusAssertEvents(t, "singleUploadLifecycle", generatedTusEventHooksEventPolicy, generatedTusEventHooksExtraEventPrefixes, generatedTusEventHooksExpectedEvents, events) +} + +func generatedTusEventHooksTotal(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +type generatedTusEventHooksSource struct { + *strings.Reader + events *[]string +} + +func (source *generatedTusEventHooksSource) Close() error { + *source.events = append(*source.events, generatedTusEventKeySourceClose()) + return nil +} + +func generatedURLStorageEventHooksRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageEventHooksResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_file_contract_generated_test.go b/url_storage_file_contract_generated_test.go new file mode 100644 index 0000000..361deee --- /dev/null +++ b/url_storage_file_contract_generated_test.go @@ -0,0 +1,218 @@ +// 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" + generatedTusFileFlowEndpointPath = "/uploads" + generatedTusFileFlowFingerprintExpected = "node-file-/tmp/tus-contract-file.bin-11-1700000000123-https://tus.io/uploads" + generatedTusFileFlowFingerprintFixtureEndpoint = "https://tus.io/uploads" + generatedTusFileFlowFingerprintFixtureMtimeMs = 1700000000123 + generatedTusFileFlowFingerprintFixturePath = "/tmp/tus-contract-file.bin" + generatedTusFileFlowPatchAcceptedOffset = "11" + generatedTusFileFlowPatchBody = "hello world" + generatedTusFileFlowPatchOffset = "0" + generatedTusFileFlowRemoveFingerprintOnSuccess = false + generatedTusFileFlowUploadLength = "11" +) + +var generatedTusFileFlowMetadata = map[string]string{"filename": "hello.txt"} + +func TestGeneratedFileFingerprint(t *testing.T) { + fingerprint := FileFingerprint(FileFingerprintInput{ + AbsolutePath: generatedTusFileFlowFingerprintFixturePath, + Endpoint: generatedTusFileFlowFingerprintFixtureEndpoint, + MtimeMs: generatedTusFileFlowFingerprintFixtureMtimeMs, + Size: 11, + }) + if fingerprint != generatedTusFileFlowFingerprintExpected { + t.Fatalf("expected file fingerprint %s, got %s", generatedTusFileFlowFingerprintExpected, fingerprint) + } +} + +func TestGeneratedURLStorageFileFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusFileFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + + filePath := filepath.Join(t.TempDir(), "tus-contract-input.bin") + if err := os.WriteFile(filePath, []byte(generatedTusFileFlowContent), 0o600); err != nil { + t.Fatal(err) + } + mtime := time.Unix(0, generatedTusFileFlowFingerprintFixtureMtimeMs*int64(time.Millisecond)) + if err := os.Chtimes(filePath, mtime, mtime); err != nil { + t.Fatal(err) + } + absolutePath, err := filepath.Abs(filePath) + if err != nil { + t.Fatal(err) + } + expectedFingerprint := FileFingerprint(FileFingerprintInput{ + AbsolutePath: absolutePath, + Endpoint: baseURL.String(), + MtimeMs: generatedTusFileFlowFingerprintFixtureMtimeMs, + Size: 11, + }) + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storagePath := filepath.Join(t.TempDir(), "url-storage.json") + createdUploadURL := srvMock.URL() + generatedTusFileFlowCreatedUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusFileFlowMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageFileResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageFileRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusFileFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusFileFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Reply(createReply), + ) + + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageFileResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusFileFlowPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageFileRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusFileFlowCreatedUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusFileFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusFileFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + upload, err := client.UploadFileWithFileBackedURLStorage(FileBackedURLStorageUploadOptions{ + URLStoragePath: storagePath, + Path: filePath, + Metadata: generatedTusFileFlowMetadata, + RemoveFingerprintOnSuccess: generatedTusFileFlowRemoveFingerprintOnSuccess, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected created upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + + storage := NewFileURLStorage(storagePath) + storedUploads, err := storage.FindUploadsByFingerprint(expectedFingerprint) + if err != nil { + t.Fatal(err) + } + shouldRemoveStoredUpload, 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) + } + return + } + if len(storedUploads) != 1 { + t.Fatalf("expected successful file flow to store one upload, got %#v", storedUploads) + } + storedUploadURL, ok := stringFromURLStorageUpload(storedUploads[0], "uploadUrl") + if !ok || storedUploadURL != createdUploadURL { + t.Fatalf("expected stored upload URL %s, got %#v", createdUploadURL, storedUploads[0]) + } +} + +func generatedURLStorageFileRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageFileResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_generated.go b/url_storage_generated.go new file mode 100644 index 0000000..16fe428 --- /dev/null +++ b/url_storage_generated.go @@ -0,0 +1,2201 @@ +// Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. +// If it looks wrong, please report the issue instead of editing this file by hand; +// the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + +package tusgo + +import ( + "bytes" + "context" + cryptoRand "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/rand" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + 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" + 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" + 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" + generatedTusCreationWithUploadResponseOff = "accepted-offset" + generatedTusDeferredLengthCreateSize = "size-unknown" + generatedTusDeferredLengthDeclareLength = "final-upload-request" + 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" + 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" + generatedTusLocationResolutionStrategy = "relative-to-creation-request-url" + generatedTusRetryAttemptIncrementPolicy = "after-retry-scheduled" + 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" + 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" + generatedTusURLStorageIDMultiplier = 1000000000000 + generatedTusURLStorageIDStrategy = "rounded-random-number" + generatedTusURLStorageNamespace = "tus" + generatedTusURLStorageSeparator = "::" + 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} +var generatedTusRetryableClientStatusCodes = []int{409, 423} + +type FileFingerprintInput struct { + AbsolutePath string + Endpoint string + MtimeMs int64 + Size int64 +} + +type URLStorageUpload map[string]any + +type UploadSuccessPayload struct { + Upload *Upload + LastResponse *http.Response +} + +type UploadEventHooks struct { + OnProgress func(bytesSent int64, bytesTotal *int64) error + OnChunkComplete func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error + OnSuccess func(UploadSuccessPayload) error + OnUploadURLAvailable func() error +} + +type URLStorage interface { + FindAllUploads() ([]URLStorageUpload, error) + FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) + RemoveUpload(urlStorageKey string) error + AddUpload(fingerprint string, upload URLStorageUpload) (string, error) +} + +type URLStorageUploadOptions struct { + Context context.Context + Storage URLStorage + Source io.ReadSeeker + Fingerprint string + Size int64 + AddRequestID bool + Headers map[string]string + Metadata map[string]string + MetadataForPartialUploads map[string]string + OverridePatchMethod bool + ParallelUploads int + RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool + ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks +} + +type URLStorageFileUploadOptions struct { + Context context.Context + Storage URLStorage + Path string + AddRequestID bool + Headers map[string]string + Metadata map[string]string + MetadataForPartialUploads map[string]string + OverridePatchMethod bool + ParallelUploads int + RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool + ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks +} + +type FileBackedURLStorageUploadOptions struct { + Context context.Context + URLStoragePath string + Path string + AddRequestID bool + Headers map[string]string + Metadata map[string]string + MetadataForPartialUploads map[string]string + OverridePatchMethod bool + ParallelUploads int + RemoveFingerprintOnSuccess bool + TerminateUploadOnAbort bool + UploadDataDuringCreation bool + UploadLengthDeferred bool + ChunkSize int64 + RetryDelays []time.Duration + OnShouldRetry func(error, int) bool + EventHooks UploadEventHooks +} + +type generatedTusSuccessInput struct { + EventHooks UploadEventHooks + LastResponse *http.Response + RemoveFingerprintOnSuccess bool + Source io.ReadSeeker + Storage URLStorage + StorageKey string + Upload *Upload +} + +type generatedTusParallelPartInput struct { + Bytes []byte + Index int + Size int64 +} + +type generatedTusParallelPartResult struct { + Err error + Index int + LastResponse *http.Response + Size int64 + Upload Upload +} + +type MemoryURLStorage struct { + mu sync.Mutex + records map[string]URLStorageUpload +} + +func NewMemoryURLStorage() *MemoryURLStorage { + return &MemoryURLStorage{ + records: map[string]URLStorageUpload{}, + } +} + +func (storage *MemoryURLStorage) FindAllUploads() ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + return urlStorageUploadsWithPrefix(storage.records, URLStorageAllUploadsPrefix()) +} + +func (storage *MemoryURLStorage) FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + return urlStorageUploadsWithPrefix(storage.records, URLStorageFingerprintPrefix(fingerprint)) +} + +func (storage *MemoryURLStorage) RemoveUpload(urlStorageKey string) error { + storage.mu.Lock() + defer storage.mu.Unlock() + + delete(storage.records, urlStorageKey) + return nil +} + +func (storage *MemoryURLStorage) AddUpload(fingerprint string, upload URLStorageUpload) (string, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + key := newURLStorageKey(fingerprint) + cloned, err := cloneURLStorageUpload(upload) + if err != nil { + return "", err + } + + storage.records[key] = cloned + return key, nil +} + +type FileURLStorage struct { + path string + mu sync.Mutex +} + +func NewFileURLStorage(path string) *FileURLStorage { + return &FileURLStorage{path: path} +} + +func (storage *FileURLStorage) FindAllUploads() ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return nil, err + } + + return urlStorageUploadsWithPrefix(records, URLStorageAllUploadsPrefix()) +} + +func (storage *FileURLStorage) FindUploadsByFingerprint(fingerprint string) ([]URLStorageUpload, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return nil, err + } + + return urlStorageUploadsWithPrefix(records, URLStorageFingerprintPrefix(fingerprint)) +} + +func (storage *FileURLStorage) RemoveUpload(urlStorageKey string) error { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return err + } + + delete(records, urlStorageKey) + return storage.writeRecords(records) +} + +func (storage *FileURLStorage) AddUpload(fingerprint string, upload URLStorageUpload) (string, error) { + storage.mu.Lock() + defer storage.mu.Unlock() + + records, err := storage.readRecords() + if err != nil { + return "", err + } + + key := newURLStorageKey(fingerprint) + cloned, err := cloneURLStorageUpload(upload) + if err != nil { + return "", err + } + + records[key] = cloned + return key, storage.writeRecords(records) +} + +func (storage *FileURLStorage) readRecords() (map[string]URLStorageUpload, error) { + data, err := os.ReadFile(storage.path) + if errors.Is(err, os.ErrNotExist) { + return map[string]URLStorageUpload{}, nil + } + if err != nil { + return nil, err + } + + if len(strings.TrimSpace(string(data))) == 0 { + return map[string]URLStorageUpload{}, nil + } + + records := map[string]URLStorageUpload{} + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&records); err != nil { + return nil, err + } + + return records, nil +} + +func (storage *FileURLStorage) writeRecords(records map[string]URLStorageUpload) error { + data, err := json.Marshal(records) + if err != nil { + return err + } + + return os.WriteFile(storage.path, data, 0o660) +} + +func URLStorageAllUploadsPrefix() string { + return generatedTusURLStorageNamespace + generatedTusURLStorageSeparator +} + +func URLStorageFingerprintPrefix(fingerprint string) string { + return URLStorageAllUploadsPrefix() + fingerprint + generatedTusURLStorageSeparator +} + +func URLStorageKey(fingerprint string, id int64) string { + return fmt.Sprintf("%s%d", URLStorageFingerprintPrefix(fingerprint), id) +} + +func URLStorageID(randomValue float64) int64 { + 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 (c *Client) UploadFileWithFileBackedURLStorage(options FileBackedURLStorageUploadOptions) (*Upload, error) { + return c.UploadFileWithURLStorage(URLStorageFileUploadOptions{ + Context: options.Context, + Storage: NewFileURLStorage(options.URLStoragePath), + Path: options.Path, + AddRequestID: options.AddRequestID, + Headers: options.Headers, + Metadata: options.Metadata, + MetadataForPartialUploads: options.MetadataForPartialUploads, + OverridePatchMethod: options.OverridePatchMethod, + ParallelUploads: options.ParallelUploads, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + TerminateUploadOnAbort: options.TerminateUploadOnAbort, + UploadDataDuringCreation: options.UploadDataDuringCreation, + UploadLengthDeferred: options.UploadLengthDeferred, + ChunkSize: options.ChunkSize, + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + EventHooks: options.EventHooks, + }) +} + +func (c *Client) UploadFileWithURLStorage(options URLStorageFileUploadOptions) (*Upload, error) { + absolutePath, err := nodeFileFingerprintPath(options.Path) + if err != nil { + return nil, err + } + + file, err := os.Open(absolutePath) + if err != nil { + return nil, err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, err + } + + fingerprint := FileFingerprint(FileFingerprintInput{ + AbsolutePath: absolutePath, + Endpoint: c.BaseURL.String(), + MtimeMs: fileModTimeMilliseconds(info), + Size: info.Size(), + }) + + return c.UploadWithURLStorage(URLStorageUploadOptions{ + Context: options.Context, + Storage: options.Storage, + Source: file, + Fingerprint: fingerprint, + Size: info.Size(), + AddRequestID: options.AddRequestID, + Headers: options.Headers, + Metadata: options.Metadata, + MetadataForPartialUploads: options.MetadataForPartialUploads, + OverridePatchMethod: options.OverridePatchMethod, + ParallelUploads: options.ParallelUploads, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + TerminateUploadOnAbort: options.TerminateUploadOnAbort, + UploadDataDuringCreation: options.UploadDataDuringCreation, + UploadLengthDeferred: options.UploadLengthDeferred, + ChunkSize: options.ChunkSize, + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + EventHooks: options.EventHooks, + }) +} + +func (c *Client) UploadWithURLStorage(options URLStorageUploadOptions) (*Upload, error) { + if options.Storage == nil { + return nil, errors.New("tus: URL storage is required") + } + if options.Source == nil { + return nil, errors.New("tus: upload source is required") + } + if options.Fingerprint == "" { + return nil, errors.New("tus: unable to calculate fingerprint for this input file") + } + + uploadClient, err := generatedTusClientWithUploadContext(c, options.Context) + if err != nil { + return nil, err + } + parallelUploads, err := generatedTusParallelUploadCount(options.ParallelUploads) + if err != nil { + return nil, err + } + if err := generatedTusValidateURLStorageUploadOptions(options, parallelUploads); err != nil { + return nil, err + } + uploadClient, detailedErrorRecorder := generatedTusClientWithURLStorageRequestPolicy( + uploadClient, + options, + ) + if parallelUploads > 1 { + return c.uploadParallelWithURLStorage(options, uploadClient, parallelUploads) + } + + upload, storageKey, err := uploadClient.resumeUploadFromURLStorage(options) + if err != nil { + return upload, err + } + var lastResponse *http.Response + if upload == nil { + upload, storageKey, lastResponse, err = uploadClient.createUploadForURLStorage( + options, + detailedErrorRecorder, + ) + if err != nil { + return upload, err + } + } + + stream := NewUploadStream(uploadClient, upload) + if options.ChunkSize != 0 { + stream.ChunkSize = options.ChunkSize + } + if options.UploadLengthDeferred { + stream.SetUploadSize = true + } + if err := uploadClient.uploadURLStorageSource(options, stream); err != nil { + return upload, uploadClient.generatedTusHandleURLStorageUploadAbort( + options, + upload, + storageKey, + err, + ) + } + if stream.LastResponse != nil { + lastResponse = stream.LastResponse + } + if err := generatedTusEmitSuccess(generatedTusSuccessInput{ + EventHooks: options.EventHooks, + LastResponse: lastResponse, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + Source: options.Source, + Storage: options.Storage, + StorageKey: storageKey, + Upload: upload, + }); err != nil { + return upload, err + } + + return upload, nil +} + +func (c *Client) uploadParallelWithURLStorage( + options URLStorageUploadOptions, + uploadClient *Client, + parallelUploads int, +) (*Upload, error) { + if err := generatedTusAssertParallelUploadPolicySupported(); err != nil { + return nil, err + } + partSizes, err := generatedTusParallelUploadPartSizes(options.Size, parallelUploads) + if err != nil { + return nil, err + } + partInputs, err := generatedTusParallelUploadPartInputs(options.Source, partSizes) + if err != nil { + return nil, err + } + + parallelCtx, cancelParallelUploads := generatedTusParallelUploadContext(options.Context) + defer cancelParallelUploads() + parallelClient := uploadClient.WithContext(parallelCtx) + results := make([]generatedTusParallelPartResult, len(partInputs)) + resultCh := make(chan generatedTusParallelPartResult, len(partInputs)) + var workers sync.WaitGroup + for _, partInput := range partInputs { + workers.Add(1) + go func(partInput generatedTusParallelPartInput) { + defer workers.Done() + result := parallelClient.uploadParallelPartWithURLStorage(options, partInput) + if result.Err != nil && generatedTusParallelExecutionCancelOnError { + cancelParallelUploads() + } + resultCh <- result + }(partInput) + } + workers.Wait() + close(resultCh) + + for result := range resultCh { + results[result.Index] = result + } + if err := generatedTusParallelUploadError(results); err != nil { + return generatedTusFirstCreatedParallelPartialUpload(results), + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) + } + + partials := make([]Upload, 0, len(results)) + acceptedBytes := int64(0) + for _, result := range results { + acceptedBytes += result.Size + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + acceptedBytes, + options.Size, + ); err != nil { + return &result.Upload, + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) + } + if err := generatedTusEmitChunkCompleteAfterChunkAccepted( + options.EventHooks, + result.Size, + acceptedBytes, + options.Size, + ); err != nil { + return &result.Upload, + uploadClient.generatedTusCleanupParallelPartialUploads(options, results, err) + } + partials = append(partials, result.Upload) + } + + finalUpload := &Upload{} + response, err := uploadClient.ConcatenateUploads(finalUpload, partials, options.Metadata) + if err != nil { + return finalUpload, uploadClient.generatedTusCleanupParallelPartialUploads( + options, + results, + err, + ) + } + if err := uploadClient.generatedTusResolveCreatedUploadLocation(finalUpload); err != nil { + return finalUpload, err + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "parallelFinalUpload"); err != nil { + return finalUpload, err + } + storageKey, err := options.Storage.AddUpload( + options.Fingerprint, + URLStorageUploadFromUpload(*finalUpload), + ) + if err != nil { + return finalUpload, err + } + if err := generatedTusEmitSuccess(generatedTusSuccessInput{ + EventHooks: options.EventHooks, + LastResponse: response, + RemoveFingerprintOnSuccess: options.RemoveFingerprintOnSuccess, + Source: options.Source, + Storage: options.Storage, + StorageKey: storageKey, + Upload: finalUpload, + }); err != nil { + return finalUpload, err + } + + return finalUpload, nil +} + +func (c *Client) uploadParallelPartWithURLStorage( + options URLStorageUploadOptions, + partInput generatedTusParallelPartInput, +) generatedTusParallelPartResult { + result := generatedTusParallelPartResult{ + Index: partInput.Index, + Size: partInput.Size, + } + partialUpload := Upload{} + response, err := c.CreateUpload( + &partialUpload, + partInput.Size, + true, + generatedTusParallelPartialUploadMetadata(options), + ) + result.LastResponse = response + result.Upload = partialUpload + if err != nil { + result.Err = err + return result + } + if err := c.generatedTusResolveCreatedUploadLocation(&partialUpload); err != nil { + result.Err = err + return result + } + + stream := NewUploadStream(c, &partialUpload) + stream.ChunkSize = partInput.Size + written, err := stream.Write(partInput.Bytes) + result.LastResponse = stream.LastResponse + result.Upload = partialUpload + if err != nil { + result.Err = err + return result + } + if int64(written) != partInput.Size { + result.Err = fmt.Errorf( + "tus: expected to upload %d parallel bytes, wrote %d", + partInput.Size, + written, + ) + } + + return result +} + +func (c *Client) uploadURLStorageSource( + options URLStorageUploadOptions, + stream *UploadStream, +) error { + retryDelays := generatedTusRetryDelays(options.RetryDelays) + retryAttempt := 0 + offsetBeforeRetry := stream.Upload.RemoteOffset + + for { + if _, err := options.Source.Seek(stream.Upload.RemoteOffset, io.SeekStart); err != nil { + return err + } + chunk, err := readURLStorageUploadChunk( + options.Source, + stream.ChunkSize, + options.Size-stream.Upload.RemoteOffset, + ) + if err != nil { + return err + } + if len(chunk) == 0 { + return nil + } + + startOffset := stream.Upload.RemoteOffset + if err := generatedTusEmitProgressBeforeRequestBody( + options.EventHooks, + startOffset, + options.Size, + ); err != nil { + return err + } + if _, err := stream.Write(chunk); err != nil { + effectiveRetryAttempt, retryAttemptErr := generatedTusEffectiveRetryAttempt( + stream.Upload.RemoteOffset, + offsetBeforeRetry, + retryAttempt, + ) + if retryAttemptErr != nil { + return retryAttemptErr + } + if !generatedTusShouldScheduleRetry( + options.OnShouldRetry, + err, + stream.lastResponseStatus(), + effectiveRetryAttempt, + retryDelays, + ) { + return err + } + delay := retryDelays[effectiveRetryAttempt] + if delay > 0 { + time.Sleep(delay) + } + retryAttempt, retryAttemptErr = generatedTusNextRetryAttempt(effectiveRetryAttempt) + if retryAttemptErr != nil { + return retryAttemptErr + } + offsetBeforeRetry = stream.Upload.RemoteOffset + if _, err := stream.Sync(); err != nil { + return err + } + stream.ForceClean() + continue + } + if stream.Upload.RemoteOffset > startOffset { + chunkSize := stream.Upload.RemoteOffset - startOffset + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + stream.Upload.RemoteOffset, + options.Size, + ); err != nil { + return err + } + if err := generatedTusEmitChunkCompleteAfterChunkAccepted( + options.EventHooks, + chunkSize, + stream.Upload.RemoteOffset, + options.Size, + ); err != nil { + return err + } + } + + if stream.Upload.RemoteOffset >= options.Size { + return nil + } + } +} + +func readURLStorageUploadChunk( + source io.Reader, + chunkSize int64, + remaining int64, +) ([]byte, error) { + if remaining <= 0 { + return nil, nil + } + + bytesToRead := remaining + if chunkSize > 0 && chunkSize < bytesToRead { + bytesToRead = chunkSize + } + if bytesToRead > int64(int(bytesToRead)) { + return nil, fmt.Errorf("tus: upload chunk size %d is too large for this platform", bytesToRead) + } + + chunk := make([]byte, int(bytesToRead)) + if _, err := io.ReadFull(source, chunk); err != nil { + return nil, err + } + + return chunk, nil +} + +func (us *UploadStream) lastResponseStatus() int { + if us.LastResponse == nil { + return 0 + } + + return us.LastResponse.StatusCode +} + +func generatedTusClientWithUploadContext(client *Client, ctx context.Context) (*Client, error) { + if ctx == nil { + return client, nil + } + if err := generatedTusAssertAbortPolicySupported(); err != nil { + return nil, err + } + + return client.WithContext(ctx), nil +} + +func generatedTusClientWithURLStorageRequestPolicy( + client *Client, + options URLStorageUploadOptions, +) (*Client, *generatedTusDetailedErrorRecorder) { + result := *client + httpClient := http.DefaultClient + if client.client != nil { + httpClient = client.client + } + resultHTTPClient := *httpClient + baseTransport := resultHTTPClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + detailedErrorRecorder := &generatedTusDetailedErrorRecorder{ + Base: baseTransport, + } + if len(options.Headers) == 0 && !options.OverridePatchMethod && !options.AddRequestID { + resultHTTPClient.Transport = detailedErrorRecorder + } else { + resultHTTPClient.Transport = generatedTusURLStorageRequestPolicyTransport{ + AddRequestID: options.AddRequestID, + Base: detailedErrorRecorder, + Headers: cloneStringMap(options.Headers), + OverridePatchMethod: options.OverridePatchMethod, + } + } + result.client = &resultHTTPClient + + return &result, detailedErrorRecorder +} + +func generatedTusClientWithAbortCleanupContext(client *Client) (*Client, 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 { + AddRequestID bool + Base http.RoundTripper + Headers map[string]string + OverridePatchMethod bool +} + +// DetailedError preserves the request/response context for a failed TUS request. +type DetailedError struct { + CausingError error + Err error + Message string + OriginalRequest *http.Request + OriginalResponse *http.Response + OriginalResponseBody string +} + +func (err *DetailedError) Error() string { + return err.Message +} + +func (err *DetailedError) Unwrap() error { + if err.Err != nil { + return err.Err + } + + return err.CausingError +} + +type generatedTusDetailedErrorRecorder struct { + Base http.RoundTripper + Err error + Request *http.Request + Response *http.Response + ResponseBody string + mu sync.Mutex +} + +func (recorder *generatedTusDetailedErrorRecorder) RoundTrip( + request *http.Request, +) (*http.Response, error) { + response, err := recorder.Base.RoundTrip(request) + if err != nil { + recorder.record(request, nil, "", err) + return response, err + } + if response == nil || response.Body == nil { + recorder.record(request, response, "", nil) + return response, nil + } + + bodyBytes, readErr := io.ReadAll(response.Body) + response.Body.Close() + if readErr != nil { + recorder.record(request, response, "", readErr) + return response, readErr + } + + body := string(bodyBytes) + response.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + storedResponse := *response + storedResponse.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + recorder.record(request, &storedResponse, body, nil) + + return response, nil +} + +func (recorder *generatedTusDetailedErrorRecorder) record( + request *http.Request, + response *http.Response, + body string, + err error, +) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + + recorder.Request = request.Clone(request.Context()) + recorder.Response = response + recorder.ResponseBody = body + recorder.Err = err +} + +func (recorder *generatedTusDetailedErrorRecorder) snapshot() generatedTusDetailedErrorSnapshot { + recorder.mu.Lock() + defer recorder.mu.Unlock() + + return generatedTusDetailedErrorSnapshot{ + Err: recorder.Err, + Request: recorder.Request, + Response: recorder.Response, + ResponseBody: recorder.ResponseBody, + } +} + +type generatedTusDetailedErrorSnapshot struct { + Err error + Request *http.Request + Response *http.Response + ResponseBody string +} + +func generatedTusFormatDetailedErrorMessage( + template string, + values map[string]string, +) string { + message := template + for name, value := range values { + message = strings.ReplaceAll(message, "{"+name+"}", value) + } + + return message +} + +func generatedTusDetailedErrorCause(cause error) string { + return generatedTusFormatDetailedErrorMessage( + generatedTusDetailedCauseStringTemplate, + map[string]string{"message": cause.Error()}, + ) +} + +func generatedTusDetailedErrorResponseBody(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Response == nil { + return generatedTusDetailedMissingValue + } + if snapshot.ResponseBody == "" { + return generatedTusDetailedEmptyResponseBody + } + + return snapshot.ResponseBody +} + +func generatedTusDetailedErrorResponseStatus(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Response == nil { + return generatedTusDetailedMissingValue + } + + return strconv.Itoa(snapshot.Response.StatusCode) +} + +func generatedTusDetailedErrorRequestID(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Request == nil { + return generatedTusDetailedMissingValue + } + requestID := snapshot.Request.Header.Get(generatedTusRequestIDHeaderName) + if requestID == "" { + return generatedTusDetailedMissingValue + } + + return requestID +} + +func generatedTusDetailedErrorRequestMethod(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Request == nil { + return generatedTusDetailedMissingValue + } + + return snapshot.Request.Method +} + +func generatedTusDetailedErrorRequestURL(snapshot generatedTusDetailedErrorSnapshot) string { + if snapshot.Request == nil || snapshot.Request.URL == nil { + return generatedTusDetailedMissingValue + } + + return snapshot.Request.URL.String() +} + +func generatedTusDetailedErrorMessage( + baseMessage string, + snapshot generatedTusDetailedErrorSnapshot, +) string { + message := baseMessage + if snapshot.Err != nil { + message += generatedTusFormatDetailedErrorMessage( + generatedTusDetailedCausedByTemplate, + map[string]string{"cause": generatedTusDetailedErrorCause(snapshot.Err)}, + ) + } + message += generatedTusFormatDetailedErrorMessage( + generatedTusDetailedRequestContextTemplate, + map[string]string{ + "body": generatedTusDetailedErrorResponseBody(snapshot), + "method": generatedTusDetailedErrorRequestMethod(snapshot), + "requestId": generatedTusDetailedErrorRequestID(snapshot), + "status": generatedTusDetailedErrorResponseStatus(snapshot), + "url": generatedTusDetailedErrorRequestURL(snapshot), + }, + ) + + return message +} + +func generatedTusCreateUploadDetailedError( + recorder *generatedTusDetailedErrorRecorder, + err error, +) error { + if err == nil || recorder == nil { + return err + } + + snapshot := recorder.snapshot() + if snapshot.Request == nil { + return err + } + + baseMessage := generatedTusUnexpectedCreateResponse + if snapshot.Err != nil { + baseMessage = generatedTusCreateUploadRequestFailed + } + + return &DetailedError{ + CausingError: snapshot.Err, + Err: err, + Message: generatedTusDetailedErrorMessage(baseMessage, snapshot), + OriginalRequest: snapshot.Request, + OriginalResponse: snapshot.Response, + OriginalResponseBody: snapshot.ResponseBody, + } +} + +func (transport generatedTusURLStorageRequestPolicyTransport) RoundTrip( + request *http.Request, +) (*http.Response, error) { + cloned := request.Clone(request.Context()) + for _, methodOverride := range generatedTusMethodOverrides { + enabled, err := transport.methodOverrideEnabled(methodOverride) + if err != nil { + return nil, err + } + if !enabled || cloned.Method != methodOverride.SourceMethod { + continue + } + + cloned.Method = methodOverride.Method + cloned.Header.Set( + methodOverride.HeaderName, + methodOverride.HeaderValue, + ) + break + } + for key, value := range transport.Headers { + cloned.Header.Set(key, value) + } + if transport.AddRequestID { + requestID, err := generatedTusRequestID() + if err != nil { + return nil, err + } + cloned.Header.Set( + generatedTusRequestIDHeaderName, + requestID, + ) + } + + return transport.Base.RoundTrip(cloned) +} + +func generatedTusRequestID() (string, error) { + var bytes [16]byte + if _, err := cryptoRand.Read(bytes[:]); err != nil { + return "", err + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%x-%x-%x-%x-%x", bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:]), nil +} + +func (c *Client) generatedTusResolveCreatedUploadLocation(upload *Upload) error { + if upload == nil || upload.Location == "" { + return nil + } + 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) { + switch methodOverride.InputFlag { + case "overridePatchMethod": + return transport.OverridePatchMethod, nil + default: + return false, fmt.Errorf("tus: unsupported method override input flag %s", methodOverride.InputFlag) + } +} + +func IsUploadAbortError(err error) bool { + return errors.Is(err, context.Canceled) +} + +func generatedTusParallelUploadCount(parallelUploads int) (int, error) { + if parallelUploads == 0 { + parallelUploads = generatedTusDefaultParallelUploads + } + if parallelUploads == 1 { + return parallelUploads, nil + } + if parallelUploads < generatedTusMinimumParallelUploads { + return 0, fmt.Errorf( + "tus: parallel uploads must be at least %d", + generatedTusMinimumParallelUploads, + ) + } + + return parallelUploads, nil +} + +func generatedTusValidateURLStorageUploadOptions( + options URLStorageUploadOptions, + parallelUploads int, +) error { + if parallelUploads <= 1 { + return nil + } + if options.UploadLengthDeferred { + return errors.New(generatedTusValidationParallelDeferred) + } + if options.UploadDataDuringCreation { + return errors.New(generatedTusValidationParallelCreateData) + } + + return nil +} + +func generatedTusCreationWithUploadChunkSize(options URLStorageUploadOptions) int64 { + if 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") + } + if parallelUploads <= 0 { + return nil, fmt.Errorf("tus: parallel upload count must be positive") + } + partSize := uploadSize / int64(parallelUploads) + if partSize <= 0 { + return nil, fmt.Errorf("tus: parallel upload parts must not be empty") + } + + partSizes := make([]int64, parallelUploads) + for index := range partSizes { + partSizes[index] = partSize + } + partSizes[len(partSizes)-1] += uploadSize - partSize*int64(parallelUploads) + + return partSizes, nil +} + +func generatedTusParallelUploadPartInputs( + source io.ReadSeeker, + partSizes []int64, +) ([]generatedTusParallelPartInput, error) { + 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( + "tus: unsupported parallel partial metadata policy %s", + generatedTusParallelPartialMetadata, + )) + } + + 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 { + shouldCleanup, err := generatedTusShouldCleanupParallelPartialUploads( + options.TerminateUploadOnAbort, + ) + if err != nil { + return err + } + if !shouldCleanup { + return originalErr + } + cleanupClient, err := generatedTusClientWithAbortCleanupContext(c) + if err != nil { + return err + } + + for _, result := range results { + if result.Upload.Location == "" { + continue + } + if _, err := cleanupClient.TerminateUploadWithRetry(result.Upload, TerminateUploadOptions{ + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + }); err != nil { + return err + } + } + + return originalErr +} + +func (c *Client) generatedTusHandleURLStorageUploadAbort( + options URLStorageUploadOptions, + upload *Upload, + storageKey string, + err error, +) error { + if !IsUploadAbortError(err) { + return err + } + shouldTerminate, shouldTerminateErr := generatedTusShouldTerminateKnownUploadOnAbort( + options.TerminateUploadOnAbort, + upload, + ) + if shouldTerminateErr != nil { + return shouldTerminateErr + } + if !shouldTerminate { + return err + } + + cleanupClient, cleanupClientErr := generatedTusClientWithAbortCleanupContext(c) + if cleanupClientErr != nil { + return cleanupClientErr + } + + if _, terminateErr := cleanupClient.TerminateUploadWithRetry(*upload, TerminateUploadOptions{ + RetryDelays: options.RetryDelays, + OnShouldRetry: options.OnShouldRetry, + }); terminateErr != nil { + return terminateErr + } + if generatedTusAbortTerminateRemovesStoredURL && storageKey != "" { + if err := options.Storage.RemoveUpload(storageKey); err != nil { + return err + } + } + + return err +} + +func generatedTusShouldTerminateKnownUploadOnAbort( + terminateUploadOnAbort bool, + upload *Upload, +) (bool, 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...) + } + + return retryDelays +} + +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, + ) + } +} + +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( + onShouldRetry func(error, int) bool, + err error, + statusCode int, + retryAttempt int, + retryDelays []time.Duration, +) bool { + if retryAttempt >= len(retryDelays) || !generatedTusShouldRetryStatus(statusCode) { + return false + } + if onShouldRetry != nil { + return onShouldRetry(err, retryAttempt) + } + + return true +} + +func generatedTusShouldRetryStatus(statusCode int) bool { + if statusCode == 0 { + return false + } + if statusCode/generatedTusRetryStatusCategoryDivisor != generatedTusRetryClientErrorStatus/generatedTusRetryStatusCategoryDivisor { + return true + } + for _, retryableStatusCode := range generatedTusRetryableClientStatusCodes { + if statusCode == retryableStatusCode { + return true + } + } + + return false +} + +func generatedTusEmitUploadURLAvailable(hooks UploadEventHooks, context string) error { + if hooks.OnUploadURLAvailable == nil { + return nil + } + 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 generatedTusEmitSuccess(input generatedTusSuccessInput) error { + shouldRemoveStoredUpload, shouldRemoveStoredUploadErr := generatedTusShouldRemoveStoredUploadOnSuccess( + input.RemoveFingerprintOnSuccess, + ) + if shouldRemoveStoredUploadErr != nil { + return shouldRemoveStoredUploadErr + } + if shouldRemoveStoredUpload && input.StorageKey != "" { + if err := input.Storage.RemoveUpload(input.StorageKey); err != nil { + return err + } + } + if input.EventHooks.OnSuccess != nil { + if err := input.EventHooks.OnSuccess(UploadSuccessPayload{ + Upload: input.Upload, + LastResponse: input.LastResponse, + }); err != nil { + return err + } + } + + shouldCloseSource, 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 +} + +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, + ) + } + 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 +} + +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( + "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", + 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 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 != "final-upload-request" { + 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( + "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 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, + "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 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", + generatedTusAbortRemoveStoredURLAfterTerm, + ) + } + + return 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) { + storedUploads, err := options.Storage.FindUploadsByFingerprint(options.Fingerprint) + if err != nil { + return nil, "", err + } + + for _, storedUpload := range storedUploads { + location, ok := stringFromURLStorageUpload(storedUpload, "uploadUrl") + if !ok || location == "" { + continue + } + + upload := &Upload{ + Location: location, + Metadata: cloneStringMap(options.Metadata), + RemoteSize: options.Size, + } + if _, err := c.GetUpload(upload, location); err != nil { + return upload, "", err + } + if upload.RemoteSize == 0 && options.Size > 0 { + upload.RemoteSize = options.Size + } + if upload.Metadata == nil { + upload.Metadata = cloneStringMap(options.Metadata) + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "resumeUpload"); err != nil { + return upload, "", err + } + + storageKey, _ := stringFromURLStorageUpload(storedUpload, "urlStorageKey") + return upload, storageKey, nil + } + + return nil, "", nil +} + +func (c *Client) createUploadForURLStorage( + options URLStorageUploadOptions, + detailedErrorRecorder *generatedTusDetailedErrorRecorder, +) (*Upload, string, *http.Response, error) { + if options.UploadDataDuringCreation { + return c.createUploadWithDataForURLStorage(options, detailedErrorRecorder) + } + + upload := &Upload{} + remoteSize := options.Size + if options.UploadLengthDeferred { + 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, generatedTusCreateUploadDetailedError( + detailedErrorRecorder, + err, + ) + } + if err := c.generatedTusResolveCreatedUploadLocation(upload); err != nil { + return upload, "", response, err + } + if options.UploadLengthDeferred { + upload.RemoteSize = options.Size + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "createUpload"); err != nil { + return upload, "", response, err + } + + storageKey, err := options.Storage.AddUpload( + options.Fingerprint, + URLStorageUploadFromUpload(*upload), + ) + if err != nil { + return upload, "", response, err + } + + return upload, storageKey, response, nil +} + +func (c *Client) createUploadWithDataForURLStorage( + options URLStorageUploadOptions, + detailedErrorRecorder *generatedTusDetailedErrorRecorder, +) (*Upload, string, *http.Response, error) { + if err := 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, generatedTusCreateUploadDetailedError( + detailedErrorRecorder, + err, + ) + } + upload.RemoteSize = options.Size + upload.RemoteOffset = uploadedBytes + if err := c.generatedTusResolveCreatedUploadLocation(upload); err != nil { + return upload, "", response, err + } + if err := generatedTusEmitProgressAfterChunkAccepted( + options.EventHooks, + uploadedBytes, + options.Size, + ); err != nil { + return upload, "", response, err + } + if err := generatedTusEmitUploadURLAvailable(options.EventHooks, "createUpload"); err != nil { + return upload, "", response, err + } + if err := generatedTusEmitChunkCompleteAfterChunkAccepted( + options.EventHooks, + uploadedBytes, + uploadedBytes, + options.Size, + ); err != nil { + return upload, "", response, err + } + + storageKey, err := options.Storage.AddUpload( + options.Fingerprint, + URLStorageUploadFromUpload(*upload), + ) + if err != nil { + return upload, "", response, err + } + + return upload, storageKey, response, nil +} + +func URLStorageUploadFromUpload(upload Upload) URLStorageUpload { + record := URLStorageUpload{ + "creationTime": urlStorageCreationTime(), + "metadata": stringMapToAnyMap(upload.Metadata), + "size": upload.RemoteSize, + } + if upload.Location != "" { + record["uploadUrl"] = upload.Location + } + + return record +} + +func urlStorageCreationTime() string { + 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, +) ([]URLStorageUpload, error) { + keys := make([]string, 0, len(records)) + for key := range records { + if strings.HasPrefix(key, prefix) { + keys = append(keys, key) + } + } + sort.Strings(keys) + + result := make([]URLStorageUpload, 0, len(keys)) + for _, key := range keys { + upload, err := cloneURLStorageUpload(records[key]) + if err != nil { + return nil, err + } + upload["urlStorageKey"] = key + result = append(result, upload) + } + + return result, nil +} + +func cloneURLStorageUpload(upload URLStorageUpload) (URLStorageUpload, error) { + data, err := json.Marshal(upload) + if err != nil { + return nil, err + } + + cloned := URLStorageUpload{} + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&cloned); err != nil { + return nil, err + } + if cloned == nil { + cloned = URLStorageUpload{} + } + + return cloned, nil +} diff --git a/url_storage_option_validation_contract_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) + } + }) + } +} 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..dd46e41 --- /dev/null +++ b/url_storage_override_patch_method_contract_generated_test.go @@ -0,0 +1,196 @@ +// 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" + generatedTusOverrideExpectedRequests = 2 + generatedTusOverrideHeaderName = "X-HTTP-Method-Override" + generatedTusOverrideHeaderValue = "PATCH" + generatedTusOverrideMethod = "POST" + generatedTusOverrideOffset = "3" + generatedTusOverrideOffsetHeader = "Upload-Offset" + generatedTusOverridePath = "/uploads/override-contract" + generatedTusOverrideUploadLength = "11" + generatedTusOverrideLengthHeader = "Upload-Length" + generatedTusOverrideFinalOffset = "11" + generatedTusOverridePatchBody = "lo world" +) + +func TestGeneratedURLStorageOverridePatchMethod(t *testing.T) { + getOperation := generatedProtocolOperation("getTusUploadOffset") + patchOperation := generatedProtocolOperation("patchTusUpload") + requestCount := 0 + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusOverridePath && + request.Method == getOperation.Method: + requestCount += 1 + if actual := request.Header.Get(generatedTusOverrideHeaderName); actual != "" { + recordRequestErr(fmt.Errorf("expected no override header on offset request, got %s", actual)) + } + getResponse := generatedResponseFor(getOperation, 200) + generatedWriteTusOverrideResponseHeaders( + responseWriter, + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusOverrideUploadLength, + "Upload-Offset": generatedTusOverrideOffset, + }, + ) + responseWriter.WriteHeader(getResponse.StatusCode) + + case request.URL.Path == generatedTusOverridePath && + request.Method == generatedTusOverrideMethod: + requestCount += 1 + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusOverridePatchBody { + recordRequestErr(fmt.Errorf( + "expected override patch body %q, got %q", + generatedTusOverridePatchBody, + string(body), + )) + } + recordRequestErr(generatedAssertTusOverrideRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusOverrideContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusOverrideOffset, + "X-HTTP-Method-Override": generatedTusOverrideHeaderValue, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusOverrideResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusOverrideFinalOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + ProtocolVersions: []string{DefaultProtocolVersion}, + } + storage := NewMemoryURLStorage() + if _, err := storage.AddUpload( + "contract-override-fingerprint", + URLStorageUpload{"uploadUrl": server.URL + generatedTusOverridePath}, + ); err != nil { + t.Fatal(err) + } + + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusOverrideContent), + Fingerprint: "contract-override-fingerprint", + Size: 11, + OverridePatchMethod: true, + }) + if err != nil { + select { + case requestErr := <-requestErrs: + t.Fatalf("%v: %v", err, requestErr) + default: + t.Fatal(err) + } + } + if upload.Location != server.URL+generatedTusOverridePath { + t.Fatalf("expected upload URL %s, got %s", server.URL+generatedTusOverridePath, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + if requestCount != generatedTusOverrideExpectedRequests { + t.Fatalf("expected %d override request(s), got %d", generatedTusOverrideExpectedRequests, requestCount) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } +} + +func generatedAssertTusOverrideRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusOverrideResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } + if value := values[generatedTusOverrideOffsetHeader]; value != "" { + responseWriter.Header().Set(generatedTusOverrideOffsetHeader, value) + } +} diff --git a/url_storage_parallel_cleanup_contract_generated_test.go b/url_storage_parallel_cleanup_contract_generated_test.go new file mode 100644 index 0000000..cd30d4f --- /dev/null +++ b/url_storage_parallel_cleanup_contract_generated_test.go @@ -0,0 +1,455 @@ +// 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" + generatedTusParallelCleanupContentType = "application/offset+octet-stream" + generatedTusParallelCleanupContentTypeHeader = "Content-Type" + generatedTusParallelCleanupEndpointPath = "/uploads" + generatedTusParallelCleanupAbortedPatchEvent = "request-abort:3" + generatedTusParallelCleanupEventPolicy = "exact" + generatedTusParallelCleanupFailurePartIndex = 0 + generatedTusParallelCleanupFailureStatus = 500 + generatedTusParallelCleanupMethod = "POST" + generatedTusParallelCleanupOffsetHeader = "Upload-Offset" + generatedTusParallelCleanupOverrideHeader = "X-HTTP-Method-Override" + generatedTusParallelCleanupOverrideValue = "PATCH" + generatedTusParallelCleanupPatchGateTimeoutMs = 2000 + generatedTusParallelCleanupUploadCount = 2 +) + +var generatedTusParallelCleanupExtraEventPrefixes = []string{} +var generatedTusParallelCleanupExpectedEvents = []string{"request-abort:3"} +var generatedTusParallelCleanupHeaders = map[string]string{"X-Tus-Contract": "parallel-cleanup-policy", "X-Tus-Trace": "parallel-cleanup-trace-123"} +var generatedTusParallelCleanupMetadataForPartialUploads = map[string]string{"test": "world"} +var generatedTusParallelCleanupPatchGateRequestIndexes = []int{2, 3} + +type generatedTusParallelCleanupPartFixture struct { + UploadLength string + UploadPath string + PatchBody string + PatchOffset string + TerminatePath string +} + +var generatedTusParallelCleanupParts = []generatedTusParallelCleanupPartFixture{ + { + UploadLength: "5", + UploadPath: "/uploads/parallel-cleanup-part-1", + PatchBody: "hello", + PatchOffset: "0", + TerminatePath: "/uploads/parallel-cleanup-part-1", + }, + { + UploadLength: "6", + UploadPath: "/uploads/parallel-cleanup-part-2", + PatchBody: " world", + PatchOffset: "0", + TerminatePath: "/uploads/parallel-cleanup-part-2", + }, +} + +func TestGeneratedURLStorageParallelUploadCleanup(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + terminateOperation := generatedProtocolOperation("terminateTusUpload") + encodedPartialMetadata, err := EncodeMetadata(generatedTusParallelCleanupMetadataForPartialUploads) + if err != nil { + t.Fatal(err) + } + + var requestMu sync.Mutex + createIndex := 0 + patchIndex := 0 + terminateIndex := 0 + terminatedParts := map[int]bool{} + patchArrivals := make(chan int, generatedTusParallelCleanupUploadCount) + releasePatches := make(chan struct{}) + requestErrs := make(chan error, 12) + events := []string{} + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + go generatedTusReleaseParallelCleanupPatchesAfterAllStarted( + patchArrivals, + releasePatches, + requestErrs, + patchOperation.Method, + ) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusParallelCleanupEndpointPath && + request.Method == createOperation.Method && + request.Header.Get("Upload-Concat") == "partial": + partIndex := generatedTusParallelCleanupPartIndexForUploadLength( + request.Header.Get("Upload-Length"), + ) + if partIndex < 0 { + recordRequestErr(fmt.Errorf( + "unexpected cleanup create upload length %s", + request.Header.Get("Upload-Length"), + )) + responseWriter.WriteHeader(http.StatusBadRequest) + return + } + requestMu.Lock() + createIndex += 1 + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Concat": "partial", + "Upload-Length": generatedTusParallelCleanupParts[partIndex].UploadLength, + "Upload-Metadata": encodedPartialMetadata, + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + }, + )) + recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( + request, + generatedTusParallelCleanupHeaders, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusParallelCleanupResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelCleanupParts[partIndex].UploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.Method == generatedTusParallelCleanupMethod: + partIndex := generatedTusParallelCleanupPartIndexForPath(request.URL.Path) + if partIndex < 0 { + recordRequestErr(fmt.Errorf("unexpected cleanup patch path %s", request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + select { + case patchArrivals <- generatedTusParallelCleanupPatchGateRequestIndexes[partIndex]: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + select { + case <-releasePatches: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + requestMu.Lock() + patchIndex += 1 + requestMu.Unlock() + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusParallelCleanupParts[partIndex].PatchBody { + recordRequestErr(fmt.Errorf( + "expected cleanup patch body %q, got %q", + generatedTusParallelCleanupParts[partIndex].PatchBody, + string(body), + )) + } + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": generatedTusParallelCleanupContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusParallelCleanupParts[partIndex].PatchOffset, + "X-HTTP-Method-Override": generatedTusParallelCleanupOverrideValue, + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + }, + )) + recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( + request, + generatedTusParallelCleanupHeaders, + )) + if actual := request.Header.Get(generatedTusParallelCleanupOverrideHeader); actual != generatedTusParallelCleanupOverrideValue { + recordRequestErr(fmt.Errorf("expected override header %s, got %s", generatedTusParallelCleanupOverrideValue, actual)) + } + if partIndex == generatedTusParallelCleanupFailurePartIndex { + responseWriter.WriteHeader(generatedTusParallelCleanupFailureStatus) + return + } + select { + case <-request.Context().Done(): + requestMu.Lock() + events = append(events, generatedTusParallelCleanupAbortedPatchEvent) + requestMu.Unlock() + return + case <-time.After(2 * time.Second): + recordRequestErr(fmt.Errorf("expected cleanup patch request to be canceled")) + responseWriter.WriteHeader(http.StatusInternalServerError) + } + + case request.Method == terminateOperation.Method: + partIndex := generatedTusParallelCleanupPartIndexForTerminatePath(request.URL.Path) + if partIndex < 0 { + recordRequestErr(fmt.Errorf("unexpected cleanup termination path %s", request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + requestMu.Lock() + terminateIndex += 1 + terminatedParts[partIndex] = true + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelCleanupRequestHeaders( + request, + terminateOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "X-Tus-Contract": "parallel-cleanup-policy", + "X-Tus-Trace": "parallel-cleanup-trace-123", + }, + )) + recordRequestErr(generatedAssertTusParallelCleanupCustomHeaders( + request, + generatedTusParallelCleanupHeaders, + )) + if actual := request.Header.Get(generatedTusParallelCleanupOverrideHeader); actual != "" { + recordRequestErr(fmt.Errorf("expected no override header on cleanup termination request, got %s", actual)) + } + terminateResponse := generatedResponseFor(terminateOperation, 204) + generatedWriteTusParallelCleanupResponseHeaders( + responseWriter, + terminateResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(terminateResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusParallelCleanupEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, terminateOperation.Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + _, err = client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusParallelCleanupContent), + Fingerprint: "contract-parallel-cleanup-fingerprint", + Size: int64(len(generatedTusParallelCleanupContent)), + Headers: generatedTusParallelCleanupHeaders, + MetadataForPartialUploads: generatedTusParallelCleanupMetadataForPartialUploads, + OverridePatchMethod: true, + TerminateUploadOnAbort: true, + ParallelUploads: generatedTusParallelCleanupUploadCount, + }) + if err == nil { + t.Fatal("expected parallel cleanup upload to fail") + } + if errors.Is(err, context.Canceled) { + t.Fatalf("expected original parallel part failure, got %v", err) + } + + requestMu.Lock() + actualCreateIndex := createIndex + actualPatchIndex := patchIndex + actualTerminateIndex := terminateIndex + actualTerminatedParts := len(terminatedParts) + actualEvents := append([]string(nil), events...) + requestMu.Unlock() + if actualCreateIndex != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected %d partial creates, got %d", generatedTusParallelCleanupUploadCount, actualCreateIndex) + } + if actualPatchIndex != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected %d partial patches, got %d", generatedTusParallelCleanupUploadCount, actualPatchIndex) + } + if actualTerminateIndex != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected %d partial terminations, got %d", generatedTusParallelCleanupUploadCount, actualTerminateIndex) + } + if actualTerminatedParts != generatedTusParallelCleanupUploadCount { + t.Fatalf("expected all partial uploads to be terminated, got %#v", terminatedParts) + } + generatedTusAssertEvents(t, "parallelUploadAbortCleanup", generatedTusParallelCleanupEventPolicy, generatedTusParallelCleanupExtraEventPrefixes, generatedTusParallelCleanupExpectedEvents, actualEvents) + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + + storedUploads, err := storage.FindUploadsByFingerprint("contract-parallel-cleanup-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 0 { + t.Fatalf("expected no final parallel upload to be stored, got %#v", storedUploads) + } +} + +func generatedTusParallelCleanupPartIndexForPath(path string) int { + for index, part := range generatedTusParallelCleanupParts { + if path == part.UploadPath { + return index + } + } + + return -1 +} + +func generatedTusParallelCleanupPartIndexForTerminatePath(path string) int { + for index, part := range generatedTusParallelCleanupParts { + if path == part.TerminatePath { + return index + } + } + + return -1 +} + +func generatedTusParallelCleanupPartIndexForUploadLength(uploadLength string) int { + for index, part := range generatedTusParallelCleanupParts { + if uploadLength == part.UploadLength { + return index + } + } + + return -1 +} + +func generatedTusReleaseParallelCleanupPatchesAfterAllStarted( + patchArrivals <-chan int, + releasePatches chan<- struct{}, + requestErrs chan<- error, + patchMethod string, +) { + seen := map[int]bool{} + timer := time.NewTimer(time.Duration(generatedTusParallelCleanupPatchGateTimeoutMs) * time.Millisecond) + defer timer.Stop() + for !generatedTusParallelCleanupPatchGateHasStartedAll(seen) { + select { + case requestIndex := <-patchArrivals: + seen[requestIndex] = true + case <-timer.C: + requestErrs <- fmt.Errorf("expected all cleanup %s requests to be in flight", patchMethod) + close(releasePatches) + return + } + } + + close(releasePatches) +} + +func generatedTusParallelCleanupPatchGateHasStartedAll(seen map[int]bool) bool { + for _, requestIndex := range generatedTusParallelCleanupPatchGateRequestIndexes { + if !seen[requestIndex] { + return false + } + } + + return true +} + +func generatedAssertTusParallelCleanupRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusParallelCleanupRequestHeaderVariant(request, variant, values); err != nil { + failures = append(failures, err.Error()) + continue + } + + return nil + } + + return fmt.Errorf( + "no %s request header variant matched: %s", + operation.OperationID, + strings.Join(failures, "; "), + ) +} + +func generatedAssertTusParallelCleanupRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedAssertTusParallelCleanupCustomHeaders( + request *http.Request, + expected map[string]string, +) error { + for key, value := range expected { + if actual := request.Header.Get(key); actual != value { + return fmt.Errorf("expected custom header %s=%s, got %s", key, value, actual) + } + } + + return nil +} + +func generatedWriteTusParallelCleanupResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_parallel_contract_generated_test.go b/url_storage_parallel_contract_generated_test.go new file mode 100644 index 0000000..0cae7ea --- /dev/null +++ b/url_storage_parallel_contract_generated_test.go @@ -0,0 +1,443 @@ +// 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" + "sync" + "testing" + "time" +) + +const ( + generatedTusParallelConcatExtension = "concatenation" + generatedTusParallelContent = "hello world" + generatedTusParallelEndpointPath = "/uploads" + generatedTusParallelEventPolicy = "exact-except-allowed-extra-events" + generatedTusParallelFinalConcatPrefix = "final;" + generatedTusParallelFinalPath = "/uploads/parallel-final" + generatedTusParallelPatchGateTimeoutMs = 2000 + generatedTusParallelUploadURLSeparator = " " + generatedTusParallelConformanceUploadCount = 2 +) + +var generatedTusParallelExtraEventPrefixes = []string{"progress:"} +var generatedTusParallelExpectedEvents = []string{"progress:5:11", "chunk-complete:5:5:11", "progress:11:11", "chunk-complete:6:11:11"} +var generatedTusParallelFinalAbsentHeaders = []string{"Upload-Length"} +var generatedTusParallelMetadata = map[string]string{"foo": "hello"} +var generatedTusParallelMetadataForPartialUploads = map[string]string{"test": "world"} +var generatedTusParallelPatchGateRequestIndexes = []int{2, 3} + +type generatedTusParallelPartFixture struct { + UploadLength string + UploadPath string + PatchBody string + PatchOffset string + PatchAcceptedOffset string +} + +var generatedTusParallelParts = []generatedTusParallelPartFixture{ + { + UploadLength: "5", + UploadPath: "/uploads/parallel-part-1", + PatchBody: "hello", + PatchOffset: "0", + PatchAcceptedOffset: "5", + }, + { + UploadLength: "6", + UploadPath: "/uploads/parallel-part-2", + PatchBody: " world", + PatchOffset: "0", + PatchAcceptedOffset: "6", + }, +} + +func TestGeneratedURLStorageParallelUploadConcatFlow(t *testing.T) { + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + encodedMetadata, err := EncodeMetadata(generatedTusParallelMetadata) + if err != nil { + t.Fatal(err) + } + encodedPartialMetadata, err := EncodeMetadata(generatedTusParallelMetadataForPartialUploads) + if err != nil { + t.Fatal(err) + } + + var requestMu sync.Mutex + createIndex := 0 + patchIndex := 0 + patchArrivals := make(chan int, generatedTusParallelConformanceUploadCount) + releasePatches := make(chan struct{}) + requestErrs := make(chan error, 8) + recordRequestErr := func(err error) { + if err != nil { + requestErrs <- err + } + } + go generatedTusReleaseParallelPatchesAfterAllStarted( + patchArrivals, + releasePatches, + requestErrs, + patchOperation.Method, + ) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == generatedTusParallelEndpointPath && + request.Method == createOperation.Method && + request.Header.Get("Upload-Concat") == "partial": + partIndex := generatedTusParallelPartIndexForUploadLength( + request.Header.Get("Upload-Length"), + ) + if partIndex < 0 { + recordRequestErr(fmt.Errorf( + "unexpected parallel create upload length %s", + request.Header.Get("Upload-Length"), + )) + responseWriter.WriteHeader(http.StatusBadRequest) + return + } + requestMu.Lock() + createIndex += 1 + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Concat": "partial", + "Upload-Length": generatedTusParallelParts[partIndex].UploadLength, + "Upload-Metadata": encodedPartialMetadata, + }, + )) + createResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusParallelResponseHeaders( + responseWriter, + createResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelParts[partIndex].UploadPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(createResponse.StatusCode) + + case request.URL.Path == generatedTusParallelEndpointPath && + request.Method == createOperation.Method && + strings.HasPrefix( + request.Header.Get("Upload-Concat"), + generatedTusParallelFinalConcatPrefix, + ): + requestMu.Lock() + createIndex += 1 + requestMu.Unlock() + recordRequestErr(generatedAssertTusParallelAbsentHeaders( + request, + generatedTusParallelFinalAbsentHeaders, + )) + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Concat": generatedTusParallelFinalConcatHeader(server.URL), + "Upload-Metadata": encodedMetadata, + }, + )) + finalResponse := generatedResponseFor(createOperation, 201) + generatedWriteTusParallelResponseHeaders( + responseWriter, + finalResponse, + map[string]string{ + "Location": server.URL + generatedTusParallelFinalPath, + "Tus-Resumable": "1.0.0", + }, + ) + responseWriter.WriteHeader(finalResponse.StatusCode) + + case request.Method == patchOperation.Method: + partIndex := generatedTusParallelPartIndexForPath(request.URL.Path) + if partIndex < 0 { + recordRequestErr(fmt.Errorf("unexpected parallel patch path %s", request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + return + } + select { + case patchArrivals <- generatedTusParallelPatchGateRequestIndexes[partIndex]: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + select { + case <-releasePatches: + case <-request.Context().Done(): + recordRequestErr(request.Context().Err()) + return + } + requestMu.Lock() + patchIndex += 1 + requestMu.Unlock() + body, err := io.ReadAll(request.Body) + recordRequestErr(err) + if string(body) != generatedTusParallelParts[partIndex].PatchBody { + recordRequestErr(fmt.Errorf( + "expected parallel patch body %q, got %q", + generatedTusParallelParts[partIndex].PatchBody, + string(body), + )) + } + recordRequestErr(generatedAssertTusParallelRequestHeaders( + request, + patchOperation, + map[string]string{ + "Content-Type": "application/offset+octet-stream", + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusParallelParts[partIndex].PatchOffset, + }, + )) + patchResponse := generatedResponseFor(patchOperation, 204) + generatedWriteTusParallelResponseHeaders( + responseWriter, + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusParallelParts[partIndex].PatchAcceptedOffset, + }, + ) + responseWriter.WriteHeader(patchResponse.StatusCode) + + default: + recordRequestErr(fmt.Errorf("unexpected request %s %s", request.Method, request.URL.Path)) + responseWriter.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + generatedTusParallelEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{createOperation.Role, generatedTusParallelConcatExtension}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + events := []string{} + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusParallelContent), + Fingerprint: "contract-parallel-fingerprint", + Size: int64(len(generatedTusParallelContent)), + Metadata: generatedTusParallelMetadata, + MetadataForPartialUploads: generatedTusParallelMetadataForPartialUploads, + ParallelUploads: generatedTusParallelConformanceUploadCount, + EventHooks: UploadEventHooks{ + OnProgress: func(bytesSent int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyProgress( + generatedTusEventKeyNumber(bytesSent), + generatedTusParallelBytesTotalString(bytesTotal), + )) + return nil + }, + OnChunkComplete: func(chunkSize int64, bytesAccepted int64, bytesTotal *int64) error { + events = append(events, generatedTusEventKeyChunkComplete( + generatedTusEventKeyNumber(chunkSize), + generatedTusEventKeyNumber(bytesAccepted), + generatedTusParallelBytesTotalString(bytesTotal), + )) + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if upload.Location != server.URL+generatedTusParallelFinalPath { + t.Fatalf("expected final upload URL %s, got %s", server.URL+generatedTusParallelFinalPath, upload.Location) + } + requestMu.Lock() + actualCreateIndex := createIndex + actualPatchIndex := patchIndex + requestMu.Unlock() + if actualCreateIndex != len(generatedTusParallelParts)+1 { + t.Fatalf("expected %d create requests, got %d", len(generatedTusParallelParts)+1, actualCreateIndex) + } + if actualPatchIndex != len(generatedTusParallelParts) { + t.Fatalf("expected %d patch requests, got %d", len(generatedTusParallelParts), actualPatchIndex) + } + select { + case err := <-requestErrs: + t.Fatal(err) + default: + } + generatedTusAssertEvents(t, "parallelUploadConcat", generatedTusParallelEventPolicy, generatedTusParallelExtraEventPrefixes, generatedTusParallelExpectedEvents, events) + + storedUploads, err := storage.FindUploadsByFingerprint("contract-parallel-fingerprint") + if err != nil { + t.Fatal(err) + } + if len(storedUploads) != 1 { + t.Fatalf("expected final parallel upload to be stored once, got %#v", storedUploads) + } + storedUploadURL, ok := stringFromURLStorageUpload(storedUploads[0], "uploadUrl") + if !ok || storedUploadURL != upload.Location { + t.Fatalf("expected stored final upload URL %s, got %#v", upload.Location, storedUploads[0]) + } +} + +func generatedTusParallelFinalConcatHeader(serverURL string) string { + locations := make([]string, 0, len(generatedTusParallelParts)) + for _, part := range generatedTusParallelParts { + locations = append(locations, serverURL+part.UploadPath) + } + + return generatedTusParallelFinalConcatPrefix + + strings.Join(locations, generatedTusParallelUploadURLSeparator) +} + +func generatedTusParallelPartIndexForPath(path string) int { + for index, part := range generatedTusParallelParts { + if path == part.UploadPath { + return index + } + } + + return -1 +} + +func generatedTusParallelPartIndexForUploadLength(uploadLength string) int { + for index, part := range generatedTusParallelParts { + if uploadLength == part.UploadLength { + return index + } + } + + return -1 +} + +func generatedTusReleaseParallelPatchesAfterAllStarted( + patchArrivals <-chan int, + releasePatches chan<- struct{}, + requestErrs chan<- error, + patchMethod string, +) { + seen := map[int]bool{} + timer := time.NewTimer(time.Duration(generatedTusParallelPatchGateTimeoutMs) * time.Millisecond) + defer timer.Stop() + for !generatedTusParallelPatchGateHasStartedAll(seen) { + select { + case requestIndex := <-patchArrivals: + seen[requestIndex] = true + case <-timer.C: + requestErrs <- fmt.Errorf("expected all parallel %s requests to be in flight", patchMethod) + close(releasePatches) + return + } + } + + close(releasePatches) +} + +func generatedTusParallelPatchGateHasStartedAll(seen map[int]bool) bool { + for _, requestIndex := range generatedTusParallelPatchGateRequestIndexes { + if !seen[requestIndex] { + return false + } + } + + return true +} + +func generatedTusParallelBytesTotalString(bytesTotal *int64) string { + if bytesTotal == nil { + return "null" + } + + return fmt.Sprintf("%d", *bytesTotal) +} + +func generatedAssertTusParallelAbsentHeaders( + request *http.Request, + headers []string, +) error { + for _, header := range headers { + if actual := request.Header.Get(header); actual != "" { + return fmt.Errorf("expected request header %s to be absent, got %s", header, actual) + } + } + + return nil +} + +func generatedAssertTusParallelRequestHeaders( + request *http.Request, + operation generatedTusProtocolOperation, + values map[string]string, +) error { + failures := []string{} + for _, variant := range operation.Request.HeaderVariants { + if err := generatedAssertTusParallelRequestHeaderVariant(request, variant, values); err != nil { + failures = append(failures, err.Error()) + continue + } + + return nil + } + + return fmt.Errorf( + "no %s request header variant matched: %s", + operation.OperationID, + strings.Join(failures, "; "), + ) +} + +func generatedAssertTusParallelRequestHeaderVariant( + request *http.Request, + variant generatedTusHeaderVariant, + values map[string]string, +) error { + for _, field := range variant.Fields { + if !field.Required { + continue + } + expected := generatedTusRequestHeaderValue(values, field.DisplayName) + if actual := request.Header.Get(field.DisplayName); actual != expected { + return fmt.Errorf( + "expected request header %s=%s, got %s", + field.DisplayName, + expected, + actual, + ) + } + } + + return nil +} + +func generatedWriteTusParallelResponseHeaders( + responseWriter http.ResponseWriter, + contract generatedTusResponseContract, + values map[string]string, +) { + if len(contract.HeaderVariants) == 0 { + return + } + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + responseWriter.Header().Set(field.DisplayName, value) + } +} diff --git a/url_storage_resume_contract_generated_test.go b/url_storage_resume_contract_generated_test.go new file mode 100644 index 0000000..5444491 --- /dev/null +++ b/url_storage_resume_contract_generated_test.go @@ -0,0 +1,188 @@ +// 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" + "strings" + "testing" + + "github.com/vitorsalgado/mocha/v3" + "github.com/vitorsalgado/mocha/v3/expect" + "github.com/vitorsalgado/mocha/v3/reply" +) + +const ( + generatedTusResumeFlowEndpointPath = "/uploads" + generatedTusResumeFlowContent = "hello world" + generatedTusResumeFlowFingerprint = "contract-resume-fingerprint" + generatedTusResumeFlowPatchAcceptedOffset = "11" + generatedTusResumeFlowPatchBody = " world" + generatedTusResumeFlowPatchOffset = "5" + generatedTusResumeFlowStoredUploadPath = "/uploads/resume-contract" + generatedTusResumeFlowUploadLength = "11" +) + +var generatedTusResumeFlowMetadata = map[string]string{} + +func TestGeneratedURLStorageResumeFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusResumeFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + storage := NewMemoryURLStorage() + storedUploadURL := srvMock.URL() + generatedTusResumeFlowStoredUploadPath + if _, err := storage.AddUpload( + generatedTusResumeFlowFingerprint, + URLStorageUpload{ + "metadata": stringMapToAnyMap(generatedTusResumeFlowMetadata), + "size": 11, + "uploadUrl": storedUploadURL, + }, + ); err != nil { + t.Fatal(err) + } + + getOperation := generatedProtocolOperation("getTusUploadOffset") + getResponse := generatedResponseFor(getOperation, 200) + getReply := generatedURLStorageResumeResponseHeaders( + reply.Status(getResponse.StatusCode), + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusResumeFlowUploadLength, + "Upload-Offset": generatedTusResumeFlowPatchOffset, + }, + ) + srvMock.AddMocks( + generatedURLStorageResumeRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusResumeFlowStoredUploadPath)). + Method(getOperation.Method), + getOperation, + map[string]string{}, + ).Reply(getReply), + ) + + patchOperation := generatedProtocolOperation("patchTusUpload") + patchResponse := generatedResponseFor(patchOperation, 204) + patchReply := generatedURLStorageResumeResponseHeaders( + reply.Status(patchResponse.StatusCode), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusResumeFlowPatchAcceptedOffset, + }, + ) + patchRequest := generatedURLStorageResumeRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusResumeFlowStoredUploadPath)). + Method(patchOperation.Method). + Body(expect.ToEqual([]byte(generatedTusResumeFlowPatchBody))), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + "Upload-Offset": generatedTusResumeFlowPatchOffset, + }, + ) + srvMock.AddMocks(patchRequest.Reply(patchReply)) + + successCalled := false + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusResumeFlowContent), + Fingerprint: generatedTusResumeFlowFingerprint, + Size: 11, + Metadata: generatedTusResumeFlowMetadata, + RemoveFingerprintOnSuccess: true, + EventHooks: UploadEventHooks{ + OnSuccess: func(UploadSuccessPayload) error { + remainingUploads, err := storage.FindUploadsByFingerprint(generatedTusResumeFlowFingerprint) + if err != nil { + return err + } + if len(remainingUploads) != 0 { + return fmt.Errorf( + "expected success hook to run after storage removal, got %#v", + remainingUploads, + ) + } + successCalled = true + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if !successCalled { + t.Fatal("expected success hook to be called") + } + if upload.Location != storedUploadURL { + t.Fatalf("expected resumed upload URL %s, got %s", storedUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + + remainingUploads, err := storage.FindUploadsByFingerprint(generatedTusResumeFlowFingerprint) + if err != nil { + t.Fatal(err) + } + if len(remainingUploads) != 0 { + t.Fatalf("expected successful resume to remove stored upload, got %#v", remainingUploads) + } +} + +func generatedURLStorageResumeRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageResumeResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +} diff --git a/url_storage_retry_contract_generated_test.go b/url_storage_retry_contract_generated_test.go new file mode 100644 index 0000000..c6c8c1f --- /dev/null +++ b/url_storage_retry_contract_generated_test.go @@ -0,0 +1,348 @@ +// 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" + generatedTusRetryFlowEndpointPath = "/uploads" + generatedTusRetryFlowEventPolicy = "exact" + generatedTusRetryFlowFingerprint = "retryPatchAfterOffsetRecovery-fingerprint" + generatedTusRetryFlowUploadLength = "11" + generatedTusRetryFlowUploadPath = "/uploads/retry-contract" +) + +type generatedTusRetryOffsetRecoveryAttempt struct { + RecoveredLength string + RecoveredOffset string + Status int +} + +type generatedTusRetryPatchAttempt struct { + AcceptedOffset string + Body string + Offset string + Status int +} + +type generatedTusRetryDecision struct { + Decision bool + RetryAttempt int +} + +var generatedTusRetryFlowExtraEventPrefixes = []string{} +var generatedTusRetryFlowExpectedEvents = []string{"should-retry:0:true", "retry-schedule:0", "should-retry:0:true", "retry-schedule:0"} +var generatedTusRetryFlowMetadata = map[string]string{"filename": "hello.txt"} +var generatedTusRetryFlowOffsetRecoveryAttempts = []generatedTusRetryOffsetRecoveryAttempt{ + { + RecoveredLength: "11", + RecoveredOffset: "5", + Status: 200, + }, + { + RecoveredLength: "11", + RecoveredOffset: "5", + Status: 200, + }, +} +var generatedTusRetryFlowPatchAttempts = []generatedTusRetryPatchAttempt{ + { + AcceptedOffset: "", + Body: "hello world", + Offset: "0", + Status: 500, + }, + { + AcceptedOffset: "", + Body: " world", + Offset: "5", + Status: 500, + }, + { + AcceptedOffset: "11", + Body: " world", + Offset: "5", + Status: 204, + }, +} +var generatedTusRetryFlowRetryDelays = []time.Duration{0 * time.Millisecond} +var generatedTusRetryFlowShouldRetryEvents = []generatedTusRetryDecision{ + { + Decision: true, + RetryAttempt: 0, + }, + { + Decision: true, + RetryAttempt: 0, + }, +} + +func TestGeneratedURLStorageRetryOffsetRecoveryFlow(t *testing.T) { + srvMock := mocha.New(t) + srvMock.Start() + defer func() { + if err := srvMock.Close(); err != nil { + t.Fatal(err) + } + srvMock.AssertCalled(t) + }() + + baseURL, err := url.Parse(srvMock.URL() + generatedTusRetryFlowEndpointPath) + if err != nil { + t.Fatal(err) + } + client := NewClient(http.DefaultClient, baseURL) + client.Capabilities = &ServerCapabilities{ + Extensions: []string{generatedProtocolOperation("createTusUpload").Role}, + ProtocolVersions: []string{DefaultProtocolVersion}, + } + + createOperation := generatedProtocolOperation("createTusUpload") + patchOperation := generatedProtocolOperation("patchTusUpload") + getOperation := generatedProtocolOperation("getTusUploadOffset") + createdUploadURL := srvMock.URL() + generatedTusRetryFlowUploadPath + encodedMetadata, err := EncodeMetadata(generatedTusRetryFlowMetadata) + if err != nil { + t.Fatal(err) + } + + createResponse := generatedResponseFor(createOperation, 201) + createReply := generatedURLStorageRetryResponseHeaders( + reply.Status(createResponse.StatusCode), + createResponse, + map[string]string{ + "Location": createdUploadURL, + "Tus-Resumable": "1.0.0", + }, + ) + srvMock.AddMocks( + generatedURLStorageRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRetryFlowEndpointPath)). + Method(createOperation.Method), + createOperation, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": generatedTusRetryFlowUploadLength, + "Upload-Metadata": encodedMetadata, + }, + ).Repeat(1).Reply(createReply), + ) + + getReplies := make([]*reply.StdReply, 0, len(generatedTusRetryFlowOffsetRecoveryAttempts)) + for _, attempt := range generatedTusRetryFlowOffsetRecoveryAttempts { + getResponse := generatedResponseFor(getOperation, attempt.Status) + getReply := generatedURLStorageRetryResponseHeaders( + reply.Status(attempt.Status), + getResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Length": attempt.RecoveredLength, + "Upload-Offset": attempt.RecoveredOffset, + }, + ) + getReplies = append(getReplies, getReply) + } + patchReplies := make([]struct { + Body string + Offset string + Reply *reply.StdReply + }, 0, len(generatedTusRetryFlowPatchAttempts)) + for _, attempt := range generatedTusRetryFlowPatchAttempts { + patchReply := reply.Status(attempt.Status) + if attempt.AcceptedOffset != "" { + patchResponse := generatedResponseFor(patchOperation, attempt.Status) + patchReply = generatedURLStorageRetryResponseHeaders( + reply.Status(attempt.Status), + patchResponse, + map[string]string{ + "Tus-Resumable": "1.0.0", + "Upload-Offset": attempt.AcceptedOffset, + }, + ) + } + patchReplies = append(patchReplies, struct { + Body string + Offset string + Reply *reply.StdReply + }{ + Body: attempt.Body, + Offset: attempt.Offset, + Reply: patchReply, + }) + } + patchReplyIndex := 0 + srvMock.AddMocks( + generatedURLStorageRetryDynamicRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRetryFlowUploadPath)). + Method(patchOperation.Method), + patchOperation, + map[string]string{ + "Content-Type": patchOperation.Request.ContentType, + "Tus-Resumable": "1.0.0", + }, + map[string]func() string{ + "Upload-Offset": func() string { + if patchReplyIndex >= len(patchReplies) { + return "" + } + return patchReplies[patchReplyIndex].Offset + }, + }, + ).Repeat(len(patchReplies)).ReplyFunction(func(r *http.Request, m reply.M, p params.P) (*reply.Response, error) { + if patchReplyIndex >= len(patchReplies) { + t.Fatalf("unexpected retry %s request %d", patchOperation.Method, patchReplyIndex) + return nil, nil + } + expected := patchReplies[patchReplyIndex] + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + if string(body) != expected.Body { + t.Fatalf("expected %s body %q, got %q", patchOperation.Method, expected.Body, string(body)) + } + patchReplyIndex += 1 + return expected.Reply.Build(r, m, p) + }), + ) + + getReplyIndex := 0 + srvMock.AddMocks( + generatedURLStorageRetryRequestHeaders( + mocha.Request(). + URL(expect.URLPath(generatedTusRetryFlowUploadPath)). + Method(getOperation.Method), + getOperation, + map[string]string{}, + ).Repeat(len(getReplies)).ReplyFunction(func(r *http.Request, m reply.M, p params.P) (*reply.Response, error) { + if getReplyIndex >= len(getReplies) { + t.Fatalf("unexpected retry %s request %d", getOperation.Method, getReplyIndex) + return nil, nil + } + expected := getReplies[getReplyIndex] + getReplyIndex += 1 + return expected.Build(r, m, p) + }), + ) + + storage := NewMemoryURLStorage() + retryDecisionIndex := 0 + events := []string{} + upload, err := client.UploadWithURLStorage(URLStorageUploadOptions{ + Storage: storage, + Source: strings.NewReader(generatedTusRetryFlowContent), + Fingerprint: generatedTusRetryFlowFingerprint, + Size: 11, + Metadata: generatedTusRetryFlowMetadata, + RetryDelays: generatedTusRetryFlowRetryDelays, + OnShouldRetry: func(err error, retryAttempt int) bool { + if retryDecisionIndex >= len(generatedTusRetryFlowShouldRetryEvents) { + t.Fatalf("unexpected retry decision request %d for %v", retryDecisionIndex, err) + } + expected := generatedTusRetryFlowShouldRetryEvents[retryDecisionIndex] + if retryAttempt != expected.RetryAttempt { + t.Fatalf("expected retry attempt %d, got %d", expected.RetryAttempt, retryAttempt) + } + events = append(events, generatedTusEventKeyShouldRetry( + generatedTusEventKeyNumber(int64(retryAttempt)), + generatedTusEventKeyBool(expected.Decision), + )) + if expected.Decision { + events = append(events, generatedTusEventKeyRetrySchedule( + generatedTusEventKeyNumber(generatedTusRetryFlowRetryDelays[retryAttempt].Milliseconds()), + )) + } + retryDecisionIndex += 1 + return expected.Decision + }, + }) + if err != nil { + t.Fatal(err) + } + if retryDecisionIndex != len(generatedTusRetryFlowShouldRetryEvents) { + t.Fatalf("expected %d retry decisions, got %d", len(generatedTusRetryFlowShouldRetryEvents), retryDecisionIndex) + } + if patchReplyIndex != len(patchReplies) { + t.Fatalf("expected %d %s requests, got %d", len(patchReplies), patchOperation.Method, patchReplyIndex) + } + if getReplyIndex != len(getReplies) { + t.Fatalf("expected %d %s requests, got %d", len(getReplies), getOperation.Method, getReplyIndex) + } + if upload.Location != createdUploadURL { + t.Fatalf("expected upload URL %s, got %s", createdUploadURL, upload.Location) + } + if upload.RemoteOffset != 11 { + t.Fatalf("expected upload offset 11, got %d", upload.RemoteOffset) + } + generatedTusAssertEvents(t, "retryPatchAfterOffsetRecovery", generatedTusRetryFlowEventPolicy, generatedTusRetryFlowExtraEventPrefixes, generatedTusRetryFlowExpectedEvents, events) +} + +func generatedURLStorageRetryRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, +) *mocha.MockBuilder { + return generatedURLStorageRetryDynamicRequestHeaders(builder, operation, values, nil) +} + +func generatedURLStorageRetryDynamicRequestHeaders( + builder *mocha.MockBuilder, + operation generatedTusProtocolOperation, + values map[string]string, + dynamicValues map[string]func() string, +) *mocha.MockBuilder { + variant := operation.Request.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + if dynamicValue, ok := dynamicValues[field.DisplayName]; ok { + builder = builder.Header(field.DisplayName, expect.Func(func(value any, args expect.Args) (bool, error) { + stringValue, ok := value.(string) + if !ok { + return false, nil + } + return stringValue == dynamicValue(), nil + })) + continue + } + value := generatedTusRequestHeaderValue(values, field.DisplayName) + builder = builder.Header(field.DisplayName, expect.ToEqual(value)) + } + + return builder +} + +func generatedURLStorageRetryResponseHeaders( + response *reply.StdReply, + contract generatedTusResponseContract, + values map[string]string, +) *reply.StdReply { + variant := contract.HeaderVariants[0] + for _, field := range variant.Fields { + if !field.Required { + continue + } + value := generatedTusResponseHeaderValue(values, field.DisplayName) + response = response.Header(field.DisplayName, value) + } + + return response +}