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/assembly.go b/assembly.go index bc7ce92..a037b8d 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, fileCount int, content []byte, fieldname string, filename string, userMeta map[string]string) (*AssemblyInfo, string, error) { + createdAssembly, err := client.CreateTusAssembly(ctx, fileCount) + 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,6 +508,12 @@ 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 @@ -316,6 +524,14 @@ func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*Ass 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. @@ -328,6 +544,8 @@ func (client *Client) CancelAssembly(ctx context.Context, assemblyURL string) (* 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..b61ce30 100644 --- a/assembly_test.go +++ b/assembly_test.go @@ -264,3 +264,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..c3996d7 --- /dev/null +++ b/examples/api2-devdock-tus-assembly/main.go @@ -0,0 +1,152 @@ +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, + input.FileCount, + []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..0532f7a 100644 --- a/notification.go +++ b/notification.go @@ -34,6 +34,12 @@ 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 @@ -47,3 +53,5 @@ func (client *Client) ReplayNotification(ctx context.Context, assemblyID string, return client.request(ctx, "POST", "assembly_notifications/"+assemblyID+"/replay", params, nil) } + +// diff --git a/template.go b/template.go index 2c4f658..8549224 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,6 +170,14 @@ 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) { @@ -171,12 +185,28 @@ func (client *Client) GetTemplate(ctx context.Context, templateID string) (templ 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) } +// + +// + +// 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. @@ -195,8 +225,18 @@ func (client *Client) UpdateTemplate(ctx context.Context, templateID string, new return client.request(ctx, "PUT", "templates/"+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..4417bac 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,6 +59,14 @@ 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) { @@ -61,18 +75,42 @@ func (client *Client) GetTemplateCredential(ctx context.Context, templateCredent 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) } +// + +// + +// 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 { @@ -83,3 +121,5 @@ func (client *Client) UpdateTemplateCredential(ctx context.Context, templateCred } return client.request(ctx, "PUT", templateCredentialPrefix+"/"+templateCredentialID, content, nil) } + +// diff --git a/transloadit.go b/transloadit.go index acdb038..cba640b 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" { 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 } } } + +//