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
}
}
}
+
+//