diff --git a/api2_generated_models_test.go b/api2_generated_models_test.go new file mode 100644 index 0000000..1076144 --- /dev/null +++ b/api2_generated_models_test.go @@ -0,0 +1,44 @@ +package transloadit + +// This file is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +import ( + "reflect" + "strings" + "testing" +) + +func TestGeneratedApi2ContractModelFields(t *testing.T) { + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "AssemblyID", "assembly_id", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "AssemblySSLURL", "assembly_ssl_url", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "AssemblyURL", "assembly_url", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "Error", "error", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "Ok", "ok", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "Results", "results", reflect.TypeOf((*map[string][]*FileInfo)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "TUSURL", "tus_url", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(AssemblyInfo{}), "Uploads", "uploads", reflect.TypeOf((*[]*FileInfo)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(FileInfo{}), "Field", "field", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(FileInfo{}), "IsTUSFile", "is_tus_file", reflect.TypeOf((*bool)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(FileInfo{}), "Name", "name", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(FileInfo{}), "TUSUploadURL", "tus_upload_url", reflect.TypeOf((*string)(nil)).Elem()) + assertGeneratedApi2ContractModelField(t, reflect.TypeOf(FileInfo{}), "UserMeta", "user_meta", reflect.TypeOf((*map[string]interface{})(nil)).Elem()) +} + +func assertGeneratedApi2ContractModelField(t *testing.T, modelType reflect.Type, fieldName string, jsonField string, expectedType reflect.Type) { + t.Helper() + + field, ok := modelType.FieldByName(fieldName) + if !ok { + t.Fatalf("%s.%s is missing", modelType.Name(), fieldName) + } + if field.Type != expectedType { + t.Fatalf("%s.%s has type %s, expected %s", modelType.Name(), fieldName, field.Type, expectedType) + } + + jsonTag := field.Tag.Get("json") + if jsonTag != jsonField && !strings.HasPrefix(jsonTag, jsonField+",") { + t.Fatalf("%s.%s has json tag %q, expected %q", modelType.Name(), fieldName, jsonTag, jsonField) + } +} diff --git a/api2_generated_path.go b/api2_generated_path.go new file mode 100644 index 0000000..8a4c236 --- /dev/null +++ b/api2_generated_path.go @@ -0,0 +1,22 @@ +package transloadit + +// This file is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this file by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +import ( + "errors" + "net/url" +) + +func validatePathSegment(value string) error { + if value == "." || value == ".." { + return errors.New("path parameters cannot be dot segments") + } + + return nil +} + +func escapePathSegment(value string) string { + return url.PathEscape(value) +} diff --git a/assembly.go b/assembly.go index bc7ce92..80ec241 100644 --- a/assembly.go +++ b/assembly.go @@ -1,13 +1,17 @@ package transloadit import ( + "bytes" "context" + "encoding/base64" "fmt" "io" "mime/multipart" "net/http" + "net/url" "os" "strconv" + "strings" "time" ) @@ -86,6 +90,7 @@ type AssemblyInfo struct { ParentID string `json:"parent_id"` AssemblyURL string `json:"assembly_url"` AssemblySSLURL string `json:"assembly_ssl_url"` + TUSURL string `json:"tus_url"` BytesReceived int `json:"bytes_received"` BytesExpected Integer `json:"bytes_expected"` StartDate string `json:"start_date"` @@ -135,9 +140,12 @@ type FileInfo struct { OriginalMd5Hash string `json:"original_md5hash"` OriginalID string `json:"original_id"` OriginalBasename string `json:"original_basename"` + IsTUSFile bool `json:"is_tus_file"` + TUSUploadURL string `json:"tus_upload_url"` URL string `json:"url"` SSLURL string `json:"ssl_url"` Meta map[string]interface{} `json:"meta"` + UserMeta map[string]interface{} `json:"user_meta"` Cost int `json:"cost"` } @@ -233,6 +241,200 @@ func (client *Client) StartAssembly(ctx context.Context, assembly Assembly) (*As return &info, err } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +// CreateTusAssembly creates a TUS-ready Assembly that waits for the requested number of resumable uploads before execution continues. +func (client *Client) CreateTusAssembly(ctx context.Context, fileCount int) (*AssemblyInfo, error) { + content := map[string]interface{}{ + "await": false, + "steps": map[string]interface{}{ + ":original": map[string]interface{}{ + "output_meta": true, + "result": "debug", + "robot": "/upload/handle", + }, + }, + } + formFields := map[string]interface{}{ + "num_expected_upload_files": fileCount, + } + + var assembly AssemblyInfo + err := client.requestWithFormFields(ctx, "POST", "assemblies", content, formFields, &assembly) + + return &assembly, err +} + +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +// ResumeTusUpload resumes an interrupted TUS upload from the server-reported offset and waits for the Assembly to finish. +func (client *Client) ResumeTusUpload(ctx context.Context, uploadUrl string, content []byte, assembly *AssemblyInfo) (*AssemblyInfo, error) { + storedUploadURL, err := url.Parse(uploadUrl) + if err != nil { + return nil, err + } + + offsetRequest, err := http.NewRequestWithContext(ctx, "HEAD", storedUploadURL.String(), nil) + if err != nil { + return nil, err + } + offsetRequest.Header.Set("Tus-Resumable", "1.0.0") + + offsetResponse, err := client.httpClient.Do(offsetRequest) + if err != nil { + return nil, err + } + defer offsetResponse.Body.Close() + if offsetResponse.StatusCode != 200 { + return nil, fmt.Errorf("TUS offset returned HTTP %d, expected 200", offsetResponse.StatusCode) + } + resumeOffsetHeader := offsetResponse.Header.Get("Upload-Offset") + if resumeOffsetHeader == "" { + return nil, fmt.Errorf("TUS offset did not return a Upload-Offset header") + } + resumeOffset, err := strconv.Atoi(resumeOffsetHeader) + if err != nil { + return nil, fmt.Errorf("TUS offset returned an invalid Upload-Offset header") + } + + uploadRequest, err := http.NewRequestWithContext(ctx, "PATCH", storedUploadURL.String(), bytes.NewReader(content[resumeOffset:])) + if err != nil { + return nil, err + } + uploadRequest.Header.Set("Tus-Resumable", "1.0.0") + uploadRequest.Header.Set("Upload-Offset", strconv.Itoa(resumeOffset)) + uploadRequest.Header.Set("Content-Type", "application/offset+octet-stream") + + uploadResponse, err := client.httpClient.Do(uploadRequest) + if err != nil { + return nil, err + } + defer uploadResponse.Body.Close() + if uploadResponse.StatusCode != 204 { + return nil, fmt.Errorf("TUS upload returned HTTP %d, expected 204", uploadResponse.StatusCode) + } + uploadOffset, err := strconv.Atoi(uploadResponse.Header.Get("Upload-Offset")) + if err != nil { + return nil, err + } + if uploadOffset != len(content) { + return nil, fmt.Errorf("TUS upload offset %d, expected %d", uploadOffset, len(content)) + } + + completedAssembly, err := client.WaitForAssembly(ctx, assembly) + if err != nil { + return nil, err + } + + return completedAssembly, nil +} + +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +// UploadTusAssembly creates a TUS-ready Assembly, uploads one file with the TUS protocol, and waits for the Assembly to finish. +func (client *Client) UploadTusAssembly(ctx context.Context, content []byte, fieldname string, filename string, userMeta map[string]string) (*AssemblyInfo, string, error) { + createdAssembly, err := client.CreateTusAssembly(ctx, 1) + if err != nil { + return nil, "", err + } + + endpointURL, err := url.Parse(createdAssembly.TUSURL) + if err != nil { + return nil, "", err + } + + metadataMap := make(map[string]string) + for name, value := range userMeta { + metadataMap[name] = value + } + metadataMap["assembly_url"] = createdAssembly.AssemblySSLURL + metadataMap["fieldname"] = fieldname + metadataMap["filename"] = filename + + createRequest, err := http.NewRequestWithContext(ctx, "POST", endpointURL.String(), nil) + if err != nil { + return nil, "", err + } + createRequest.Header.Set("Tus-Resumable", "1.0.0") + createRequest.Header.Set("Upload-Length", strconv.Itoa(len(content))) + metadataParts := make([]string, 0, len(metadataMap)) + for name, value := range metadataMap { + metadataParts = append(metadataParts, fmt.Sprintf("%s %s", name, base64.StdEncoding.EncodeToString([]byte(value)))) + } + createRequest.Header.Set("Upload-Metadata", strings.Join(metadataParts, ",")) + + createResponse, err := client.httpClient.Do(createRequest) + if err != nil { + return nil, "", err + } + defer createResponse.Body.Close() + if createResponse.StatusCode != 201 { + return nil, "", fmt.Errorf("TUS create returned HTTP %d, expected 201", createResponse.StatusCode) + } + uploadURLLocation := createResponse.Header.Get("Location") + if uploadURLLocation == "" { + return nil, "", fmt.Errorf("TUS create did not return a Location header") + } + uploadURL, err := endpointURL.Parse(uploadURLLocation) + if err != nil { + return nil, "", err + } + uploadURLText := uploadURL.String() + + uploadRequest, err := http.NewRequestWithContext(ctx, "PATCH", uploadURLText, bytes.NewReader(content)) + if err != nil { + return nil, "", err + } + uploadRequest.Header.Set("Tus-Resumable", "1.0.0") + uploadRequest.Header.Set("Upload-Offset", "0") + uploadRequest.Header.Set("Content-Type", "application/offset+octet-stream") + + uploadResponse, err := client.httpClient.Do(uploadRequest) + if err != nil { + return nil, "", err + } + defer uploadResponse.Body.Close() + if uploadResponse.StatusCode != 204 { + return nil, "", fmt.Errorf("TUS upload returned HTTP %d, expected 204", uploadResponse.StatusCode) + } + uploadOffset, err := strconv.Atoi(uploadResponse.Header.Get("Upload-Offset")) + if err != nil { + return nil, "", err + } + if uploadOffset != len(content) { + return nil, "", fmt.Errorf("TUS upload offset %d, expected %d", uploadOffset, len(content)) + } + + createdAssemblyAssemblySSLURL := createdAssembly.AssemblySSLURL + if createdAssemblyAssemblySSLURL == "" { + return nil, "", fmt.Errorf("uploadTusAssembly needs createdAssembly.assembly_ssl_url") + } + completedAssembly, err := client.WaitForAssembly(ctx, createdAssembly) + if err != nil { + return nil, "", err + } + + return completedAssembly, uploadURLText, nil +} + +// + func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*http.Request, error) { // TODO: test with huge files url := client.config.Endpoint + "/assemblies" @@ -306,16 +508,30 @@ func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*htt return req, nil } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // GetAssembly fetches the full assembly status from the provided URL. // The assembly URL must be absolute, for example: // https://api2-amberly.transloadit.com/assemblies/15a6b3701d3811e78d7bfba4db1b053e func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*AssemblyInfo, error) { var info AssemblyInfo - err := client.request(ctx, "GET", assemblyURL, nil, &info) + err := client.requestAssemblyURL(ctx, "GET", assemblyURL, &info) return &info, err } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // CancelAssembly cancels an assembly which will result in all corresponding // uploads and encoding jobs to be aborted. Finally, the updated assembly // information after the cancellation will be returned. @@ -323,11 +539,13 @@ func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*Ass // https://api2-amberly.transloadit.com/assemblies/15a6b3701d3811e78d7bfba4db1b053e func (client *Client) CancelAssembly(ctx context.Context, assemblyURL string) (*AssemblyInfo, error) { var info AssemblyInfo - err := client.request(ctx, "DELETE", assemblyURL, nil, &info) + err := client.requestAssemblyURL(ctx, "DELETE", assemblyURL, &info) return &info, err } +// + // NewAssemblyReplay will create a new AssemblyReplay struct which can be used // to replay an assemblie's execution using Client.StartAssemblyReplay. // The assembly URL must be absolute, for example: @@ -375,6 +593,12 @@ func (client *Client) StartAssemblyReplay(ctx context.Context, assembly Assembly return &info, nil } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // ListAssemblies will fetch all assemblies matching the provided criteria. func (client *Client) ListAssemblies(ctx context.Context, options *ListOptions) (AssemblyList, error) { var assemblies AssemblyList @@ -382,3 +606,5 @@ func (client *Client) ListAssemblies(ctx context.Context, options *ListOptions) return assemblies, err } + +// diff --git a/assembly_test.go b/assembly_test.go index a755514..e6eeb52 100644 --- a/assembly_test.go +++ b/assembly_test.go @@ -2,6 +2,9 @@ package transloadit import ( "encoding/json" + "io" + "net/http" + "net/http/httptest" "os" "strings" "testing" @@ -92,6 +95,115 @@ func TestGetAssembly(t *testing.T) { } } +func TestGetAssemblyRejectsUntrustedURLWithoutSendingCredentials(t *testing.T) { + requestReceived := make(chan struct{}, 1) + untrustedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requestReceived <- struct{}{} + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"assembly_id":"assembly-id"}`)) + })) + defer untrustedServer.Close() + + configuredServer := httptest.NewServer(http.NotFoundHandler()) + defer configuredServer.Close() + + config := DefaultConfig + config.AuthKey = "key" + config.AuthSecret = "secret" + config.Endpoint = configuredServer.URL + client := NewClient(config) + + _, err := client.GetAssembly(ctx, untrustedServer.URL+"/assemblies/assembly-id") + if err == nil { + t.Fatal("expected an untrusted Assembly URL error") + } + select { + case <-requestReceived: + t.Fatal("sent a request to an untrusted Assembly URL") + default: + } +} + +func TestCancelAssemblyDoesNotFollowRedirects(t *testing.T) { + redirectedRequestMethod := make(chan string, 1) + redirectedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + redirectedRequestMethod <- request.Method + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"assembly_id":"assembly-id"}`)) + })) + defer redirectedServer.Close() + + configuredServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + http.Redirect(w, request, redirectedServer.URL+"/assemblies/assembly-id", http.StatusFound) + })) + defer configuredServer.Close() + + config := DefaultConfig + config.AuthKey = "key" + config.AuthSecret = "secret" + config.Endpoint = configuredServer.URL + client := NewClient(config) + + _, err := client.CancelAssembly(ctx, configuredServer.URL+"/assemblies/assembly-id") + if err == nil { + t.Error("expected a redirect response error") + } + select { + case method := <-redirectedRequestMethod: + t.Fatalf("followed an Assembly URL redirect using %s", method) + default: + } +} + +func TestGetAssemblyDoesNotAuthenticateNoAuthRequest(t *testing.T) { + query := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + query <- request.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"assembly_id":"assembly-id"}`)) + })) + defer server.Close() + + config := DefaultConfig + config.AuthKey = "key" + config.AuthSecret = "secret" + config.Endpoint = server.URL + client := NewClient(config) + + _, err := client.GetAssembly(ctx, server.URL+"/assemblies/assembly-id") + if err != nil { + t.Fatal(err) + } + if rawQuery := <-query; rawQuery != "" { + t.Fatalf("expected an unauthenticated request, got query %q", rawQuery) + } +} + +func TestGetAssemblyAllowsHTTPSForConfiguredHostname(t *testing.T) { + requested := false + config := DefaultConfig + config.AuthKey = "key" + config.AuthSecret = "secret" + config.Endpoint = "http://api2-devdock.transloadit.dev" + client := NewClient(config) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + requested = true + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"assembly_id":"assembly-id"}`)), + }, nil + }) + + _, err := client.GetAssembly(ctx, "https://api2-devdock.transloadit.dev/assemblies/assembly-id") + if err != nil { + t.Fatal(err) + } + if !requested { + t.Fatal("expected an HTTPS request to the configured hostname") + } +} + func TestStartAssembly_Failure(t *testing.T) { t.Parallel() @@ -264,3 +376,50 @@ func TestInteger_MarshalJSON(t *testing.T) { t.Fatal("wrong default value for string") } } + +func TestAssemblyInfo_TusFields(t *testing.T) { + t.Parallel() + + var info AssemblyInfo + err := json.Unmarshal([]byte(`{ + "tus_url": "https://api2.example/resumable/files/", + "uploads": [ + { + "is_tus_file": true, + "tus_upload_url": "https://api2.example/resumable/files/upload-id", + "user_meta": { + "hello": "world" + } + } + ], + "results": { + ":original": [ + { + "is_tus_file": false, + "user_meta": { + "hello": "world" + } + } + ] + } + }`), &info) + if err != nil { + t.Fatal(err) + } + + if info.TUSURL != "https://api2.example/resumable/files/" { + t.Fatal("wrong tus url") + } + if len(info.Uploads) != 1 || !info.Uploads[0].IsTUSFile { + t.Fatal("wrong TUS upload marker") + } + if info.Uploads[0].TUSUploadURL != "https://api2.example/resumable/files/upload-id" { + t.Fatal("wrong TUS upload url") + } + if info.Uploads[0].UserMeta["hello"] != "world" { + t.Fatal("wrong upload user meta") + } + if info.Results[":original"][0].UserMeta["hello"] != "world" { + t.Fatal("wrong result user meta") + } +} diff --git a/examples/api2-devdock-assembly-lifecycle/main.go b/examples/api2-devdock-assembly-lifecycle/main.go new file mode 100644 index 0000000..4804ee5 --- /dev/null +++ b/examples/api2-devdock-assembly-lifecycle/main.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + transloadit "github.com/transloadit/go-sdk" +) + +type assemblyLifecycleScenario struct { + Assembly struct { + FileCount int `json:"fileCount"` + } `json:"assembly"` + List struct { + PageSize int `json:"pageSize"` + } `json:"list"` + ScenarioID string `json:"scenarioId"` +} + +func requiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s must be set", name)) + } + + return value +} + +func fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func loadScenario() (assemblyLifecycleScenario, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.Join("examples", "api2-devdock-assembly-lifecycle", "api2-scenario.json") + } + + contents, err := ioutil.ReadFile(scenarioPath) + if err != nil { + return assemblyLifecycleScenario{}, err + } + + var scenario assemblyLifecycleScenario + if err := json.Unmarshal(contents, &scenario); err != nil { + return assemblyLifecycleScenario{}, err + } + + return scenario, nil +} + +func assemblyResult(info *transloadit.AssemblyInfo) map[string]interface{} { + return map[string]interface{}{ + "assemblyId": info.AssemblyID, + "assemblySslUrl": info.AssemblySSLURL, + "assemblyUrl": info.AssemblyURL, + "ok": info.Ok, + } +} + +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 ioutil.WriteFile(resultPath, append(contents, '\n'), 0o644) +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + scenario, err := loadScenario() + if err != nil { + fail("load scenario: %v", err) + } + + client := transloadit.NewClient(transloadit.Config{ + AuthKey: requiredEnv("TRANSLOADIT_KEY"), + AuthSecret: requiredEnv("TRANSLOADIT_SECRET"), + Endpoint: requiredEnv("TRANSLOADIT_ENDPOINT"), + }) + + created, err := client.CreateTusAssembly(ctx, scenario.Assembly.FileCount) + if err != nil { + fail("create TUS assembly: %v", err) + } + + cancelOnExit := true + defer func() { + if cancelOnExit { + _, _ = client.CancelAssembly(context.Background(), created.AssemblySSLURL) + } + }() + + fetched, err := client.GetAssembly(ctx, created.AssemblySSLURL) + if err != nil { + fail("get assembly: %v", err) + } + + // The Assembly list is eventually consistent: the API acknowledges creation before the + // list storage row lands, so poll briefly until the created Assembly shows up. + var assemblies transloadit.AssemblyList + listContainsCreated := false + for attempt := 0; attempt < 20; attempt++ { + assemblies, err = client.ListAssemblies(ctx, &transloadit.ListOptions{ + AssemblyID: created.AssemblyID, + PageSize: scenario.List.PageSize, + }) + if err != nil { + fail("list assemblies: %v", err) + } + + for _, assembly := range assemblies.Assemblies { + if assembly.AssemblyID == created.AssemblyID { + listContainsCreated = true + } + } + if listContainsCreated { + break + } + time.Sleep(500 * time.Millisecond) + } + + cancelled, err := client.CancelAssembly(ctx, created.AssemblySSLURL) + if err != nil { + fail("cancel assembly: %v", err) + } + cancelOnExit = false + + if err := writeResult(map[string]interface{}{ + "cancelled": assemblyResult(cancelled), + "created": assemblyResult(created), + "fetched": assemblyResult(fetched), + "listContainsCreated": listContainsCreated, + "listCount": assemblies.Count, + }); err != nil { + fail("write result: %v", err) + } + + fmt.Printf( + "Go Transloadit SDK devdock scenario %s canceled Assembly %s\n", + scenario.ScenarioID, + created.AssemblyID, + ) +} diff --git a/examples/api2-devdock-template-lifecycle/main.go b/examples/api2-devdock-template-lifecycle/main.go new file mode 100644 index 0000000..7bc0451 --- /dev/null +++ b/examples/api2-devdock-template-lifecycle/main.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + transloadit "github.com/transloadit/go-sdk" +) + +type scenarioContent struct { + AdditionalProperties map[string]interface{} `json:"additionalProperties"` + Steps map[string]map[string]interface{} `json:"steps"` +} + +type templateLifecycleScenario struct { + Delete struct { + ErrorCodeIncludes string `json:"errorCodeIncludes"` + } `json:"delete"` + List struct { + MinimumCount int `json:"minimumCount"` + PageSize int `json:"pageSize"` + } `json:"list"` + ScenarioID string `json:"scenarioId"` + Template struct { + Content scenarioContent `json:"content"` + NamePrefix string `json:"namePrefix"` + RequireSignatureAuth bool `json:"requireSignatureAuth"` + } `json:"template"` + Update struct { + Content scenarioContent `json:"content"` + NameSuffix string `json:"nameSuffix"` + RequireSignatureAuth bool `json:"requireSignatureAuth"` + } `json:"update"` +} + +func requiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s must be set", name)) + } + + return value +} + +func fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func loadScenario() (templateLifecycleScenario, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.Join( + "examples", + "api2-devdock-template-lifecycle", + "api2-scenario.json", + ) + } + + contents, err := ioutil.ReadFile(scenarioPath) + if err != nil { + return templateLifecycleScenario{}, err + } + + var scenario templateLifecycleScenario + if err := json.Unmarshal(contents, &scenario); err != nil { + return templateLifecycleScenario{}, err + } + + return scenario, nil +} + +func applyTemplateContent(template *transloadit.Template, content scenarioContent) { + for stepName, step := range content.Steps { + template.AddStep(stepName, step) + } + + for name, value := range content.AdditionalProperties { + template.Content.AdditionalProperties[name] = value + } +} + +func newTemplate(name string, requireSignatureAuth bool, content scenarioContent) transloadit.Template { + template := transloadit.NewTemplate() + template.Name = name + template.RequireSignatureAuth = requireSignatureAuth + applyTemplateContent(&template, content) + + return template +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + scenario, err := loadScenario() + if err != nil { + fail("load scenario: %v", err) + } + + client := transloadit.NewClient(transloadit.Config{ + AuthKey: requiredEnv("TRANSLOADIT_KEY"), + AuthSecret: requiredEnv("TRANSLOADIT_SECRET"), + Endpoint: requiredEnv("TRANSLOADIT_ENDPOINT"), + }) + + templateName := fmt.Sprintf("%s-%d", scenario.Template.NamePrefix, time.Now().UnixNano()) + template := newTemplate( + templateName, + scenario.Template.RequireSignatureAuth, + scenario.Template.Content, + ) + + templateID, err := client.CreateTemplate(ctx, template) + if err != nil { + fail("create template: %v", err) + } + if templateID == "" { + fail("create template returned an empty id") + } + + deleteTemplate := true + defer func() { + if deleteTemplate { + _ = client.DeleteTemplate(context.Background(), templateID) + } + }() + + fetched, err := client.GetTemplate(ctx, templateID) + if err != nil { + fail("get template: %v", err) + } + + templateList, err := client.ListTemplates(ctx, &transloadit.ListOptions{ + PageSize: scenario.List.PageSize, + }) + if err != nil { + fail("list templates: %v", err) + } + + updatedTemplate := newTemplate( + templateName+scenario.Update.NameSuffix, + scenario.Update.RequireSignatureAuth, + scenario.Update.Content, + ) + + if err := client.UpdateTemplate(ctx, templateID, updatedTemplate); err != nil { + fail("update template: %v", err) + } + + fetchedUpdated, err := client.GetTemplate(ctx, templateID) + if err != nil { + fail("get updated template: %v", err) + } + + if err := client.DeleteTemplate(ctx, templateID); err != nil { + fail("delete template: %v", err) + } + deleteTemplate = false + + _, err = client.GetTemplate(ctx, templateID) + deletedGetSucceeded := err == nil + deletedErrorCode := "" + var requestErr transloadit.RequestError + if err != nil && !errors.As(err, &requestErr) { + fail("get deleted template returned %T, expected transloadit.RequestError", err) + } + if err != nil { + deletedErrorCode = requestErr.Code + } + + result := map[string]interface{}{ + "deletedErrorCode": deletedErrorCode, + "deletedGetSucceeded": deletedGetSucceeded, + "fetched": templateResult(fetched), + "listCount": templateList.Count, + "templateId": templateID, + "templateName": templateName, + "updated": templateResult(fetchedUpdated), + "updatedTemplateName": updatedTemplate.Name, + } + if err := writeResult(result); err != nil { + fail("write result: %v", err) + } + + fmt.Printf( + "Go Transloadit SDK devdock scenario %s passed for %s\n", + scenario.ScenarioID, + requiredEnv("TRANSLOADIT_ENDPOINT"), + ) +} + +func templateResult(template transloadit.Template) map[string]interface{} { + content := map[string]interface{}{ + "steps": template.Content.Steps, + } + for name, value := range template.Content.AdditionalProperties { + content[name] = value + } + + return map[string]interface{}{ + "content": content, + "id": template.ID, + "name": template.Name, + "requireSignatureAuth": template.RequireSignatureAuth, + } +} + +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 ioutil.WriteFile(resultPath, append(contents, '\n'), 0o644) +} diff --git a/examples/api2-devdock-tus-assembly/main.go b/examples/api2-devdock-tus-assembly/main.go new file mode 100644 index 0000000..d219175 --- /dev/null +++ b/examples/api2-devdock-tus-assembly/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + transloadit "github.com/transloadit/go-sdk" +) + +type tusAssemblyScenario struct { + ExampleInput struct { + ScenarioID string `json:"scenarioId"` + SdkFeatureInputs struct { + UploadTusAssembly uploadTusAssemblyInput `json:"uploadTusAssembly"` + } `json:"sdkFeatureInputs"` + } `json:"exampleInput"` +} + +type uploadTusAssemblyInput struct { + FileCount int `json:"file_count"` + Upload uploadConfig `json:"upload"` +} + +type uploadConfig struct { + Content string `json:"content"` + Field string `json:"fieldname"` + Filename string `json:"filename"` + UserMeta map[string]string `json:"user_meta"` +} + +func requiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s must be set", name)) + } + + return value +} + +func fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func loadScenario() (tusAssemblyScenario, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.Join("examples", "api2-devdock-tus-assembly", "api2-scenario.json") + } + + contents, err := ioutil.ReadFile(scenarioPath) + if err != nil { + return tusAssemblyScenario{}, err + } + + var scenario tusAssemblyScenario + if err := json.Unmarshal(contents, &scenario); err != nil { + return tusAssemblyScenario{}, err + } + + return scenario, nil +} + +func asJsonObject(value interface{}, label string) (map[string]interface{}, error) { + contents, err := json.Marshal(value) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(contents, &result); err != nil { + return nil, err + } + + return result, nil +} + +func writeResult( + status map[string]interface{}, + uploadURL string, +) error { + resultPath := os.Getenv("API2_SDK_EXAMPLE_RESULT") + if resultPath == "" { + return nil + } + + contents, err := json.MarshalIndent( + map[string]interface{}{ + "createResponse": status, + "status": status, + "uploadUrl": uploadURL, + }, + "", + " ", + ) + if err != nil { + return err + } + + return ioutil.WriteFile(resultPath, append(contents, '\n'), 0o644) +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := loadScenario() + if err != nil { + fail("load scenario: %v", err) + } + input := scenario.ExampleInput.SdkFeatureInputs.UploadTusAssembly + + client := transloadit.NewClient(transloadit.Config{ + AuthKey: requiredEnv("TRANSLOADIT_KEY"), + AuthSecret: requiredEnv("TRANSLOADIT_SECRET"), + Endpoint: requiredEnv("TRANSLOADIT_ENDPOINT"), + }) + + userMeta := input.Upload.UserMeta + if userMeta == nil { + userMeta = map[string]string{} + } + + statusInfo, uploadURL, err := client.UploadTusAssembly( + ctx, + []byte(input.Upload.Content), + input.Upload.Field, + input.Upload.Filename, + userMeta, + ) + if err != nil { + fail("upload TUS assembly: %v", err) + } + status, err := asJsonObject(statusInfo, "assembly status") + if err != nil { + fail("serialize assembly status: %v", err) + } + if err := writeResult(status, uploadURL); err != nil { + fail("write result: %v", err) + } + + fmt.Printf( + "Go Transloadit SDK devdock scenario %s uploaded to %s\n", + scenario.ExampleInput.ScenarioID, + 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..7d25bd8 --- /dev/null +++ b/examples/api2-devdock-tus-resume-upload/main.go @@ -0,0 +1,329 @@ +// Run the API2 contract TUS resume scenario against a devdock API2 server. +// +// This example is intentionally checked into the SDK repository: it reads the +// API/TUS facts from API2's injected scenario JSON, interrupts an upload like +// an unlucky user would, and resumes it through the public SDK method. +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + transloadit "github.com/transloadit/go-sdk" +) + +type resumeUploadScenario struct { + ExampleInput struct { + ScenarioID string `json:"scenarioId"` + } `json:"exampleInput"` + Prepared struct { + CreateResponse map[string]interface{} `json:"createResponse"` + } `json:"prepared"` + Upload struct { + Metadata []metadataField `json:"metadata"` + Resume resumePlan `json:"resume"` + Source uploadSource `json:"source"` + TusURL valueSpec `json:"tusUrl"` + } `json:"upload"` +} + +type metadataField struct { + Name string `json:"name"` + Value valueSpec `json:"value"` +} + +type resumePlan struct { + Fingerprint string `json:"fingerprint"` + RemoveFingerprintOnSuccess bool `json:"removeFingerprintOnSuccess"` + StopAfterAcceptedBytes int `json:"stopAfterAcceptedBytes"` +} + +type uploadSource struct { + Encoding string `json:"encoding"` + Kind string `json:"kind"` + Value string `json:"value"` +} + +type valueSpec struct { + Source *valueSpecSource `json:"source"` + Value interface{} `json:"value"` +} + +type valueSpecSource struct { + Path []string `json:"path"` + Root string `json:"root"` +} + +func requiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s must be set", name)) + } + + return value +} + +func fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func loadScenario() (resumeUploadScenario, map[string]interface{}, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.Join("examples", "api2-devdock-tus-resume-upload", "api2-scenario.json") + } + + contents, err := ioutil.ReadFile(scenarioPath) + if err != nil { + return resumeUploadScenario{}, nil, err + } + + var scenario resumeUploadScenario + if err := json.Unmarshal(contents, &scenario); err != nil { + return resumeUploadScenario{}, nil, err + } + + var rawScenario map[string]interface{} + if err := json.Unmarshal(contents, &rawScenario); err != nil { + return resumeUploadScenario{}, nil, err + } + + return scenario, rawScenario, nil +} + +func resolveValue(spec valueSpec, context map[string]interface{}, label string) interface{} { + if spec.Source == nil { + return spec.Value + } + + current, ok := context[spec.Source.Root] + if !ok { + fail("%s value source root is unavailable", label) + } + for _, part := range spec.Source.Path { + record, ok := current.(map[string]interface{}) + if !ok { + fail("%s value source cannot read %s", label, part) + } + current, ok = record[part] + if !ok { + fail("%s value source cannot read %s", label, part) + } + } + + return current +} + +func resolveString(spec valueSpec, context map[string]interface{}, label string) string { + value, ok := resolveValue(spec, context, label).(string) + if !ok { + fail("%s must be a string", label) + } + + return value +} + +func scenarioBytes(source uploadSource) []byte { + if source.Kind != "bytes" { + fail("upload.source.kind must be bytes") + } + if source.Encoding != "utf8" { + fail("upload.source.encoding must be utf8") + } + + return []byte(source.Value) +} + +func uploadMetadata(fields []metadataField, context map[string]interface{}) map[string]string { + metadata := make(map[string]string, len(fields)) + for _, field := range fields { + metadata[field.Name] = fmt.Sprintf("%v", resolveValue(field.Value, context, field.Name)) + } + + return metadata +} + +// createInterruptedUpload creates a TUS upload and only sends the first chunk, +// leaving the upload interrupted the way a dropped connection would. +func createInterruptedUpload( + ctx context.Context, + tusURL string, + content []byte, + metadata map[string]string, + stopAfterAcceptedBytes int, +) string { + metadataNames := make([]string, 0, len(metadata)) + for name := range metadata { + metadataNames = append(metadataNames, name) + } + sort.Strings(metadataNames) + metadataParts := make([]string, 0, len(metadata)) + for _, name := range metadataNames { + encodedValue := base64.StdEncoding.EncodeToString([]byte(metadata[name])) + metadataParts = append(metadataParts, fmt.Sprintf("%s %s", name, encodedValue)) + } + + createRequest, err := http.NewRequestWithContext(ctx, "POST", tusURL, nil) + if err != nil { + fail("TUS create request: %v", err) + } + createRequest.Header.Set("Tus-Resumable", "1.0.0") + createRequest.Header.Set("Upload-Length", strconv.Itoa(len(content))) + createRequest.Header.Set("Upload-Metadata", strings.Join(metadataParts, ",")) + createResponse, err := http.DefaultClient.Do(createRequest) + if err != nil { + fail("TUS create request failed: %v", err) + } + defer createResponse.Body.Close() + if createResponse.StatusCode != 201 { + fail("TUS create returned HTTP %d, expected 201", createResponse.StatusCode) + } + location := createResponse.Header.Get("Location") + if location == "" { + fail("TUS create did not return a Location header") + } + tusBase, err := url.Parse(tusURL) + if err != nil { + fail("parse TUS URL: %v", err) + } + uploadURL, err := tusBase.Parse(location) + if err != nil { + fail("resolve upload URL: %v", err) + } + uploadURLText := uploadURL.String() + + patchRequest, err := http.NewRequestWithContext( + ctx, + "PATCH", + uploadURLText, + bytes.NewReader(content[:stopAfterAcceptedBytes]), + ) + if err != nil { + fail("TUS first chunk request: %v", err) + } + patchRequest.Header.Set("Tus-Resumable", "1.0.0") + patchRequest.Header.Set("Upload-Offset", "0") + patchRequest.Header.Set("Content-Type", "application/offset+octet-stream") + patchResponse, err := http.DefaultClient.Do(patchRequest) + if err != nil { + fail("TUS first chunk request failed: %v", err) + } + defer patchResponse.Body.Close() + if patchResponse.StatusCode != 204 { + fail("TUS first chunk returned HTTP %d, expected 204", patchResponse.StatusCode) + } + acceptedBytes, err := strconv.Atoi(patchResponse.Header.Get("Upload-Offset")) + if err != nil || acceptedBytes != stopAfterAcceptedBytes { + fail("TUS first chunk accepted %d bytes, expected %d", acceptedBytes, stopAfterAcceptedBytes) + } + + return uploadURLText +} + +func writeResult( + firstUploadURL string, + previousUploadCount int, + remainingPreviousUploadCount int, +) error { + resultPath := os.Getenv("API2_SDK_EXAMPLE_RESULT") + if resultPath == "" { + return nil + } + + contents, err := json.MarshalIndent( + map[string]interface{}{ + "firstUploadUrl": firstUploadURL, + "previousUploadCount": previousUploadCount, + "remainingPreviousUploadCount": remainingPreviousUploadCount, + "uploadUrl": firstUploadURL, + }, + "", + " ", + ) + if err != nil { + return err + } + + return ioutil.WriteFile(resultPath, append(contents, '\n'), 0o644) +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, rawScenario, err := loadScenario() + if err != nil { + fail("load scenario: %v", err) + } + resume := scenario.Upload.Resume + + client := transloadit.NewClient(transloadit.Config{ + AuthKey: requiredEnv("TRANSLOADIT_KEY"), + AuthSecret: requiredEnv("TRANSLOADIT_SECRET"), + Endpoint: requiredEnv("TRANSLOADIT_ENDPOINT"), + }) + + valueContext := map[string]interface{}{ + "createResponse": scenario.Prepared.CreateResponse, + "scenario": rawScenario, + } + content := scenarioBytes(scenario.Upload.Source) + tusURL := resolveString(scenario.Upload.TusURL, valueContext, "upload.tusUrl") + metadata := uploadMetadata(scenario.Upload.Metadata, valueContext) + + firstUploadURL := createInterruptedUpload( + ctx, + tusURL, + content, + metadata, + resume.StopAfterAcceptedBytes, + ) + + // Remember the interrupted upload by fingerprint, like a TUS client URL storage would. + storedUploads := map[string]string{resume.Fingerprint: firstUploadURL} + previousUploadCount := len(storedUploads) + + assemblySSLURL, ok := scenario.Prepared.CreateResponse["assembly_ssl_url"].(string) + if !ok || assemblySSLURL == "" { + fail("prepared.createResponse.assembly_ssl_url must be a string") + } + completedAssembly, err := client.ResumeTusUpload( + ctx, + storedUploads[resume.Fingerprint], + content, + &transloadit.AssemblyInfo{AssemblySSLURL: assemblySSLURL}, + ) + if err != nil { + fail("resume TUS upload: %v", err) + } + if completedAssembly.Error != "" { + fail("resumeTusUpload returned %s: %s", completedAssembly.Error, completedAssembly.Message) + } + + if resume.RemoveFingerprintOnSuccess { + delete(storedUploads, resume.Fingerprint) + } + remainingPreviousUploadCount := len(storedUploads) + + if err := writeResult(firstUploadURL, previousUploadCount, remainingPreviousUploadCount); err != nil { + fail("write result: %v", err) + } + + fmt.Printf( + "Go Transloadit SDK devdock scenario %s resumed %s\n", + scenario.ExampleInput.ScenarioID, + firstUploadURL, + ) +} diff --git a/notification.go b/notification.go index 5e14999..d2b8846 100644 --- a/notification.go +++ b/notification.go @@ -34,16 +34,28 @@ func (client *Client) ListNotifications(ctx context.Context, options *ListOption return list, errors.New("transloadit: listing assembly notifications is no longer available") } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // ReplayNotification instructs the endpoint to replay the notification // corresponding to the provided assembly ID. // If notifyURL is not empty it will override the notify URL used in the // assembly instructions. func (client *Client) ReplayNotification(ctx context.Context, assemblyID string, notifyURL string) error { + if err := validatePathSegment(assemblyID); err != nil { + return err + } + params := make(map[string]interface{}) if notifyURL != "" { params["notify_url"] = notifyURL } - return client.request(ctx, "POST", "assembly_notifications/"+assemblyID+"/replay", params, nil) + return client.request(ctx, "POST", "assembly_notifications/"+escapePathSegment(assemblyID)+"/replay", params, nil) } + +// diff --git a/template.go b/template.go index 2c4f658..b59b129 100644 --- a/template.go +++ b/template.go @@ -146,6 +146,12 @@ func (template *Template) UnmarshalJSON(b []byte) error { return nil } +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // CreateTemplate will save the provided template struct as a new template // and return the ID of the new template. func (client *Client) CreateTemplate(ctx context.Context, template Template) (string, error) { @@ -164,23 +170,59 @@ func (client *Client) CreateTemplate(ctx context.Context, template Template) (st return template.ID, nil } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // GetTemplate will retrieve details about the template associated with the // provided template ID. func (client *Client) GetTemplate(ctx context.Context, templateID string) (template Template, err error) { - err = client.request(ctx, "GET", "templates/"+templateID, nil, &template) + if err := validatePathSegment(templateID); err != nil { + return Template{}, err + } + + err = client.request(ctx, "GET", "templates/"+escapePathSegment(templateID), nil, &template) return template, err } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // DeleteTemplate will delete the template associated with the provided // template ID. func (client *Client) DeleteTemplate(ctx context.Context, templateID string) error { - return client.request(ctx, "DELETE", "templates/"+templateID, nil, nil) + if err := validatePathSegment(templateID); err != nil { + return err + } + + return client.request(ctx, "DELETE", "templates/"+escapePathSegment(templateID), nil, nil) } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // UpdateTemplate will update the template associated with the provided // template ID to match the new name and new content. Please be aware that you // are not able to change a template's ID. func (client *Client) UpdateTemplate(ctx context.Context, templateID string, newTemplate Template) error { + if err := validatePathSegment(templateID); err != nil { + return err + } + // Create signature content := map[string]interface{}{ "name": newTemplate.Name, @@ -192,11 +234,21 @@ func (client *Client) UpdateTemplate(ctx context.Context, templateID string, new content["require_signature_auth"] = 0 } - return client.request(ctx, "PUT", "templates/"+templateID, content, nil) + return client.request(ctx, "PUT", "templates/"+escapePathSegment(templateID), content, nil) } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // ListTemplates will retrieve all templates matching the criteria. func (client *Client) ListTemplates(ctx context.Context, options *ListOptions) (list TemplateList, err error) { err = client.listRequest(ctx, "templates", options, &list) return list, err } + +// diff --git a/template_credentials.go b/template_credentials.go index 5c43765..13270ae 100644 --- a/template_credentials.go +++ b/template_credentials.go @@ -38,6 +38,12 @@ func NewTemplateCredential() TemplateCredential { var templateCredentialPrefix = "template_credentials" +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // CreateTemplateCredential will save the provided template credential struct to the server // and return the ID of the new template credential. func (client *Client) CreateTemplateCredential(ctx context.Context, templateCredential TemplateCredential) (string, error) { @@ -53,33 +59,79 @@ func (client *Client) CreateTemplateCredential(ctx context.Context, templateCred return response.Credential.ID, nil } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // GetTemplateCredential will retrieve details about the template credential associated with the // provided template credential ID. func (client *Client) GetTemplateCredential(ctx context.Context, templateCredentialID string) (TemplateCredential, error) { + if err := validatePathSegment(templateCredentialID); err != nil { + return TemplateCredential{}, err + } + var response templateCredentialResponseBody - err := client.request(ctx, "GET", templateCredentialPrefix+"/"+templateCredentialID, nil, &response) + err := client.request(ctx, "GET", templateCredentialPrefix+"/"+escapePathSegment(templateCredentialID), nil, &response) return response.Credential, err } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // DeleteTemplateCredential will delete the template credential associated with the provided // template ID. func (client *Client) DeleteTemplateCredential(ctx context.Context, templateCredentialID string) error { - return client.request(ctx, "DELETE", templateCredentialPrefix+"/"+templateCredentialID, nil, nil) + if err := validatePathSegment(templateCredentialID); err != nil { + return err + } + + return client.request(ctx, "DELETE", templateCredentialPrefix+"/"+escapePathSegment(templateCredentialID), nil, nil) } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // ListTemplateCredential will retrieve all templates credential matching the criteria. func (client *Client) ListTemplateCredential(ctx context.Context, options *ListOptions) (list TemplateCredentialList, err error) { err = client.listRequest(ctx, templateCredentialPrefix, options, &list) return list, err } +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + // UpdateTemplateCredential will update the template credential associated with the provided // template credential ID to match the new name and new content. func (client *Client) UpdateTemplateCredential(ctx context.Context, templateCredentialID string, templateCredential TemplateCredential) error { + if err := validatePathSegment(templateCredentialID); err != nil { + return err + } + content := map[string]interface{}{ "name": templateCredential.Name, "type": templateCredential.Type, "content": templateCredential.Content, } - return client.request(ctx, "PUT", templateCredentialPrefix+"/"+templateCredentialID, content, nil) + return client.request(ctx, "PUT", templateCredentialPrefix+"/"+escapePathSegment(templateCredentialID), content, nil) } + +// diff --git a/template_test.go b/template_test.go index 6e0f482..33d278f 100644 --- a/template_test.go +++ b/template_test.go @@ -2,10 +2,35 @@ package transloadit import ( "encoding/json" + "net/http" + "net/http/httptest" "reflect" "testing" ) +func TestGetTemplateRejectsDotPathSegments(t *testing.T) { + for _, segment := range []string{".", ".."} { + t.Run(segment, func(t *testing.T) { + requested := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requested = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewClient(Config{AuthKey: "key", AuthSecret: "secret", Endpoint: server.URL}) + _, err := client.GetTemplate(ctx, segment) + if err == nil { + t.Error("expected a dot path segment error") + } + if requested { + t.Error("sent a request for a dot path segment") + } + }) + } +} + func TestTemplate(t *testing.T) { t.Parallel() diff --git a/token.go b/token.go new file mode 100644 index 0000000..59327df --- /dev/null +++ b/token.go @@ -0,0 +1,84 @@ +// Code generated from Transloadit API2 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 contract generator so all SDKs stay in sync. + +package transloadit + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" +) + +type BearerTokenOptions struct { + Aud string `json:"aud,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type BearerTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +func (client *Client) IssueBearerToken(ctx context.Context, options BearerTokenOptions) (BearerTokenResponse, error) { + form := url.Values{} + if options.Aud != "" { + form.Set("aud", options.Aud) + } + form.Set("grant_type", "client_credentials") + if options.Scope != "" { + form.Set("scope", options.Scope) + } + + endpoint := strings.TrimRight(client.config.Endpoint, "/") + "/token" + endpointURL, err := url.Parse(endpoint) + if err != nil { + return BearerTokenResponse{}, fmt.Errorf("parse bearer token endpoint: %w", err) + } + hostname := endpointURL.Hostname() + ip := net.ParseIP(hostname) + loopback := hostname == "localhost" || (ip != nil && ip.IsLoopback()) + if endpointURL.User != nil || (endpointURL.Scheme != "https" && !(endpointURL.Scheme == "http" && loopback)) { + return BearerTokenResponse{}, fmt.Errorf("refusing to send credentials to bearer token endpoint %q", endpointURL.Redacted()) + } + request, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode())) + if err != nil { + return BearerTokenResponse{}, fmt.Errorf("create bearer token request: %w", err) + } + request.SetBasicAuth(client.config.AuthKey, client.config.AuthSecret) + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Transloadit-Client", "go-sdk:"+Version) + + httpClient := *client.httpClient + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + response, err := httpClient.Do(request) + if err != nil { + return BearerTokenResponse{}, fmt.Errorf("issue bearer token: %w", err) + } + defer response.Body.Close() + + body := io.LimitReader(response.Body, 128*1024*1024) + if response.StatusCode < 200 || response.StatusCode >= 300 { + var requestError RequestError + if err := json.NewDecoder(body).Decode(&requestError); err != nil { + return BearerTokenResponse{}, fmt.Errorf("decode bearer token error: %w", err) + } + return BearerTokenResponse{}, requestError + } + + var token BearerTokenResponse + if err := json.NewDecoder(body).Decode(&token); err != nil { + return BearerTokenResponse{}, fmt.Errorf("decode bearer token response: %w", err) + } + return token, nil +} diff --git a/token_test.go b/token_test.go new file mode 100644 index 0000000..e348d52 --- /dev/null +++ b/token_test.go @@ -0,0 +1,111 @@ +package transloadit + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestIssueBearerToken(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/token" { + t.Errorf("expected POST /token, got %s %s", request.Method, request.URL.Path) + } + expectedAuthorization := "Basic " + base64.StdEncoding.EncodeToString([]byte("key:secret")) + if request.Header.Get("Authorization") != expectedAuthorization { + t.Errorf("unexpected Authorization header %q", request.Header.Get("Authorization")) + } + if request.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("unexpected Content-Type %q", request.Header.Get("Content-Type")) + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Errorf("read token request: %v", err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("parse token request: %v", err) + } + if form.Get("grant_type") != "client_credentials" || form.Get("scope") != "assemblies:read" { + t.Errorf("unexpected token form %q", form.Encode()) + } + if _, present := form["aud"]; present { + t.Errorf("aud should be omitted, got %q", form.Get("aud")) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"abc","expires_in":21600,"scope":"assemblies:read","token_type":"Bearer"}`) + })) + defer server.Close() + + client := NewClient(Config{AuthKey: "key", AuthSecret: "secret", Endpoint: server.URL}) + token, err := client.IssueBearerToken( + context.Background(), + BearerTokenOptions{Scope: "assemblies:read"}, + ) + if err != nil { + t.Fatalf("IssueBearerToken failed: %v", err) + } + if token.AccessToken != "abc" || token.ExpiresIn != 21600 || token.TokenType != "Bearer" { + t.Fatalf("unexpected token response: %#v", token) + } +} + +func TestIssueBearerTokenDoesNotFollowRedirects(t *testing.T) { + t.Parallel() + + redirected := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/redirected" { + redirected = true + w.WriteHeader(http.StatusNoContent) + return + } + http.Redirect(w, request, "/redirected", http.StatusFound) + })) + defer server.Close() + + client := NewClient(Config{AuthKey: "key", AuthSecret: "secret", Endpoint: server.URL}) + _, err := client.IssueBearerToken(context.Background(), BearerTokenOptions{}) + if err == nil { + t.Fatal("IssueBearerToken should reject a redirect response") + } + if redirected { + t.Fatal("IssueBearerToken followed a redirect with Basic credentials") + } +} + +func TestIssueBearerTokenRejects127PrefixedDomain(t *testing.T) { + t.Parallel() + + requested := false + client := NewClient(Config{ + AuthKey: "key", + AuthSecret: "secret", + Endpoint: "http://127.attacker.com", + }) + client.httpClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + requested = true + return nil, nil + }) + + _, err := client.IssueBearerToken(context.Background(), BearerTokenOptions{}) + if err == nil { + t.Fatal("IssueBearerToken should reject a 127-prefixed domain") + } + if requested { + t.Fatal("IssueBearerToken sent credentials to a 127-prefixed domain") + } +} diff --git a/transloadit.go b/transloadit.go index acdb038..191137d 100755 --- a/transloadit.go +++ b/transloadit.go @@ -155,6 +155,32 @@ func (client *Client) doRequest(req *http.Request, result interface{}) error { } func (client *Client) request(ctx context.Context, method string, path string, content map[string]interface{}, result interface{}) error { + return client.requestWithFormFields(ctx, method, path, content, nil, result) +} + +func formFieldValue(value interface{}) string { + switch typed := value.(type) { + case nil: + return "" + case bool: + return strconv.FormatBool(typed) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 32) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case string: + return typed + } + + serialized, err := json.Marshal(value) + if err == nil { + return string(serialized) + } + + return fmt.Sprint(value) +} + +func (client *Client) requestWithFormFields(ctx context.Context, method string, path string, content map[string]interface{}, formFields map[string]interface{}, result interface{}) error { uri := path // Don't add host for absolute urls if u, err := url.Parse(path); err == nil && u.Scheme == "" { @@ -175,6 +201,9 @@ func (client *Client) request(ctx context.Context, method string, path string, c v := url.Values{} v.Set("params", params) v.Set("signature", signature) + for name, value := range formFields { + v.Set(name, formFieldValue(value)) + } var body io.Reader if method == "GET" { @@ -303,3 +332,92 @@ func (client *Client) CreateSignedSmartCDNUrl(opts SignedSmartCDNUrlOptions) str return signedURL } + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +func (client *Client) GetBill(ctx context.Context, month string) (map[string]interface{}, error) { + if err := validatePathSegment(month); err != nil { + return nil, err + } + + var bill map[string]interface{} + err := client.request(ctx, "GET", "bill/"+escapePathSegment(month), nil, &bill) + return bill, err +} + +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +func (client *Client) GetBillForInvoice(ctx context.Context, month string, invoiceID string) (map[string]interface{}, error) { + if err := validatePathSegment(month); err != nil { + return nil, err + } + if err := validatePathSegment(invoiceID); err != nil { + return nil, err + } + + var bill map[string]interface{} + err := client.request(ctx, "GET", "bill/"+escapePathSegment(month)+"/"+escapePathSegment(invoiceID), nil, &bill) + return bill, err +} + +// + +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +func (client *Client) requestAssemblyURL(ctx context.Context, method string, assemblyURL string, result interface{}) error { + candidate, err := url.Parse(assemblyURL) + if err != nil || !candidate.IsAbs() || candidate.User != nil { + return fmt.Errorf("untrusted Assembly URL: %s", assemblyURL) + } + + configured, err := url.Parse(client.config.Endpoint) + if err != nil { + return fmt.Errorf("invalid configured endpoint: %s", err) + } + + sameOrigin := strings.EqualFold(candidate.Scheme, configured.Scheme) && + strings.EqualFold(candidate.Hostname(), configured.Hostname()) && + candidate.Port() == configured.Port() + sameHTTPSHost := strings.EqualFold(candidate.Scheme, "https") && + (candidate.Port() == "" || candidate.Port() == "443") && + candidate.Hostname() != "" && + strings.EqualFold(candidate.Hostname(), configured.Hostname()) + hostname := strings.ToLower(candidate.Hostname()) + isTransloaditAPI2Cell := strings.EqualFold(candidate.Scheme, "https") && + (candidate.Port() == "" || candidate.Port() == "443") && + strings.HasPrefix(hostname, "api2-") && + strings.HasSuffix(hostname, ".transloadit.com") + if !sameOrigin && !sameHTTPSHost && !isTransloaditAPI2Cell { + return fmt.Errorf("untrusted Assembly URL: %s", assemblyURL) + } + + req, err := http.NewRequestWithContext(ctx, method, assemblyURL, nil) + if err != nil { + return fmt.Errorf("request: %s", err) + } + + requestClient := *client + httpClient := *client.httpClient + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + requestClient.httpClient = &httpClient + + return requestClient.doRequest(req, result) +} + +// diff --git a/transloadit_test.go b/transloadit_test.go index d4eb73b..61f890b 100755 --- a/transloadit_test.go +++ b/transloadit_test.go @@ -52,6 +52,29 @@ func TestNewClient_Success(t *testing.T) { _ = NewClient(config) } +func TestFormFieldValue(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + input interface{} + expected string + }{ + "bool": {input: true, expected: "true"}, + "int": {input: 3, expected: "3"}, + "nil": {input: nil, expected: ""}, + "object": {input: map[string]interface{}{"field": "value"}, expected: `{"field":"value"}`}, + "string": {input: "file", expected: "file"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if actual := formFieldValue(tc.input); actual != tc.expected { + t.Fatalf("expected %q, got %q", tc.expected, actual) + } + }) + } +} + func setup(t *testing.T) Client { config := DefaultConfig config.AuthKey = os.Getenv("TRANSLOADIT_KEY") diff --git a/wait.go b/wait.go index d7118cd..b7990a2 100644 --- a/wait.go +++ b/wait.go @@ -5,9 +5,14 @@ import ( "time" ) -// WaitForAssembly fetches continuously the assembly status until it has -// finished uploading and executing or until an assembly error occurs. -// If you want to end this loop prematurely, you can cancel the supplied context. +// + +// This block is generated from Transloadit API2 contracts. If it looks wrong, +// please report the issue instead of editing this block by hand; the source fix +// belongs in the contract generator so all SDKs stay in sync. + +// WaitForAssembly waits for an Assembly to finish uploading and executing. +// Use the returned assembly_ssl_url as the assembly URL. func (client *Client) WaitForAssembly(ctx context.Context, assembly *AssemblyInfo) (*AssemblyInfo, error) { for { res, err := client.GetAssembly(ctx, assembly.AssemblySSLURL) @@ -33,3 +38,5 @@ func (client *Client) WaitForAssembly(ctx context.Context, assembly *AssemblyInf } } } + +//