From a9736d0b642c6a200733493dd71fa4a01b255db9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 25 May 2026 11:54:13 +0200 Subject: [PATCH 01/24] Mark generated endpoint methods --- assembly.go | 9 +++++++++ template.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/assembly.go b/assembly.go index bc7ce92..083dd04 100644 --- a/assembly.go +++ b/assembly.go @@ -306,6 +306,7 @@ func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*htt return req, nil } +// // 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 +317,9 @@ func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*Ass return &info, err } +// + +// // 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 +332,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 +381,7 @@ func (client *Client) StartAssemblyReplay(ctx context.Context, assembly Assembly return &info, nil } +// // 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 +389,5 @@ func (client *Client) ListAssemblies(ctx context.Context, options *ListOptions) return assemblies, err } + +// diff --git a/template.go b/template.go index 2c4f658..ea7c228 100644 --- a/template.go +++ b/template.go @@ -164,6 +164,7 @@ func (client *Client) CreateTemplate(ctx context.Context, template Template) (st return template.ID, nil } +// // 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 +172,17 @@ func (client *Client) GetTemplate(ctx context.Context, templateID string) (templ return template, err } +// + +// // 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) } +// + // 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 +201,11 @@ func (client *Client) UpdateTemplate(ctx context.Context, templateID string, new return client.request(ctx, "PUT", "templates/"+templateID, content, nil) } +// // 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 } + +// From ea94d950cbdb27d65e39eb4b6696a49b67a49014 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 25 May 2026 22:21:44 +0200 Subject: [PATCH 02/24] Expand generated endpoint markers --- assembly.go | 3 +++ notification.go | 4 ++++ template.go | 11 +++++++++++ template_credentials.go | 20 ++++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/assembly.go b/assembly.go index 083dd04..4d01e63 100644 --- a/assembly.go +++ b/assembly.go @@ -307,6 +307,7 @@ func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*htt } // + // 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 @@ -320,6 +321,7 @@ func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*Ass // // + // 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. @@ -382,6 +384,7 @@ func (client *Client) StartAssemblyReplay(ctx context.Context, assembly Assembly } // + // ListAssemblies will fetch all assemblies matching the provided criteria. func (client *Client) ListAssemblies(ctx context.Context, options *ListOptions) (AssemblyList, error) { var assemblies AssemblyList diff --git a/notification.go b/notification.go index 5e14999..4b2448e 100644 --- a/notification.go +++ b/notification.go @@ -34,6 +34,8 @@ func (client *Client) ListNotifications(ctx context.Context, options *ListOption return list, errors.New("transloadit: listing assembly notifications is no longer available") } +// + // 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 +49,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 ea7c228..cee56e1 100644 --- a/template.go +++ b/template.go @@ -146,6 +146,8 @@ func (template *Template) UnmarshalJSON(b []byte) error { return nil } +// + // 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,7 +166,10 @@ func (client *Client) CreateTemplate(ctx context.Context, template Template) (st return template.ID, nil } +// + // + // 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) { @@ -175,6 +180,7 @@ func (client *Client) GetTemplate(ctx context.Context, templateID string) (templ // // + // DeleteTemplate will delete the template associated with the provided // template ID. func (client *Client) DeleteTemplate(ctx context.Context, templateID string) error { @@ -183,6 +189,8 @@ func (client *Client) DeleteTemplate(ctx context.Context, templateID string) err // +// + // 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. @@ -201,7 +209,10 @@ func (client *Client) UpdateTemplate(ctx context.Context, templateID string, new return client.request(ctx, "PUT", "templates/"+templateID, content, nil) } +// + // + // 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) diff --git a/template_credentials.go b/template_credentials.go index 5c43765..d1b7bc4 100644 --- a/template_credentials.go +++ b/template_credentials.go @@ -38,6 +38,8 @@ func NewTemplateCredential() TemplateCredential { var templateCredentialPrefix = "template_credentials" +// + // 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 +55,10 @@ func (client *Client) CreateTemplateCredential(ctx context.Context, templateCred return response.Credential.ID, nil } +// + +// + // 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 +67,30 @@ func (client *Client) GetTemplateCredential(ctx context.Context, templateCredent return response.Credential, err } +// + +// + // 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) } +// + +// + // 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 } +// + +// + // 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 +101,5 @@ func (client *Client) UpdateTemplateCredential(ctx context.Context, templateCred } return client.request(ctx, "PUT", templateCredentialPrefix+"/"+templateCredentialID, content, nil) } + +// From 8577527fa6f7787ba86ef6fe2bbce18f6b15d773 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 08:15:04 +0200 Subject: [PATCH 03/24] Mark WaitForAssembly as generated --- wait.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wait.go b/wait.go index d7118cd..edc325f 100644 --- a/wait.go +++ b/wait.go @@ -5,6 +5,8 @@ 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. @@ -33,3 +35,5 @@ func (client *Client) WaitForAssembly(ctx context.Context, assembly *AssemblyInf } } } + +// From d4fcdcdbf668f528da50752b64cdd2fcc522226f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 09:21:38 +0200 Subject: [PATCH 04/24] Mark generated blocks as contract-owned --- assembly.go | 12 ++++++++++++ notification.go | 4 ++++ template.go | 20 ++++++++++++++++++++ template_credentials.go | 20 ++++++++++++++++++++ wait.go | 4 ++++ 5 files changed, 60 insertions(+) diff --git a/assembly.go b/assembly.go index 4d01e63..b81c2e8 100644 --- a/assembly.go +++ b/assembly.go @@ -308,6 +308,10 @@ func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*htt // +// 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 @@ -322,6 +326,10 @@ func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*Ass // +// 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. @@ -385,6 +393,10 @@ func (client *Client) StartAssemblyReplay(ctx context.Context, assembly Assembly // +// 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 diff --git a/notification.go b/notification.go index 4b2448e..0532f7a 100644 --- a/notification.go +++ b/notification.go @@ -36,6 +36,10 @@ func (client *Client) ListNotifications(ctx context.Context, options *ListOption // +// 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 diff --git a/template.go b/template.go index cee56e1..8549224 100644 --- a/template.go +++ b/template.go @@ -148,6 +148,10 @@ func (template *Template) UnmarshalJSON(b []byte) error { // +// 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) { @@ -170,6 +174,10 @@ func (client *Client) CreateTemplate(ctx context.Context, template Template) (st // +// 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) { @@ -181,6 +189,10 @@ func (client *Client) GetTemplate(ctx context.Context, templateID string) (templ // +// 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 { @@ -191,6 +203,10 @@ func (client *Client) DeleteTemplate(ctx context.Context, templateID string) 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. + // 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. @@ -213,6 +229,10 @@ func (client *Client) UpdateTemplate(ctx context.Context, templateID string, new // +// 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) diff --git a/template_credentials.go b/template_credentials.go index d1b7bc4..4417bac 100644 --- a/template_credentials.go +++ b/template_credentials.go @@ -40,6 +40,10 @@ 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) { @@ -59,6 +63,10 @@ func (client *Client) CreateTemplateCredential(ctx context.Context, templateCred // +// 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) { @@ -71,6 +79,10 @@ func (client *Client) GetTemplateCredential(ctx context.Context, templateCredent // +// 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 { @@ -81,6 +93,10 @@ func (client *Client) DeleteTemplateCredential(ctx context.Context, templateCred // +// 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) @@ -91,6 +107,10 @@ func (client *Client) ListTemplateCredential(ctx context.Context, options *ListO // +// 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 { diff --git a/wait.go b/wait.go index edc325f..f8c5cb2 100644 --- a/wait.go +++ b/wait.go @@ -7,6 +7,10 @@ import ( // +// 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 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. From 326d056c6b911c8273742f41ab8d32b3e8be6608 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 20:16:34 +0200 Subject: [PATCH 05/24] Add generated TUS assembly helper --- assembly.go | 30 ++++++++++++++++++++++++++++++ transloadit.go | 29 +++++++++++++++++++++++++++++ transloadit_test.go | 23 +++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/assembly.go b/assembly.go index b81c2e8..90efa91 100644 --- a/assembly.go +++ b/assembly.go @@ -233,6 +233,36 @@ 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 +} + +// + func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*http.Request, error) { // TODO: test with huge files url := client.config.Endpoint + "/assemblies" 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") From 6a54d513495389c4ee311840255e3a568e73c103 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 20:25:59 +0200 Subject: [PATCH 06/24] Expose TUS fields on assembly info --- assembly.go | 4 ++++ assembly_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/assembly.go b/assembly.go index 90efa91..590631b 100644 --- a/assembly.go +++ b/assembly.go @@ -86,6 +86,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 +136,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"` } 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") + } +} From d4128bf85aae3de64bfbb1bf108d3cd75aee4963 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 21:04:43 +0200 Subject: [PATCH 07/24] Guard API2 model fields --- api2_generated_models_test.go | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 api2_generated_models_test.go 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) + } +} From fe3e2d4967b64f4d96b44d37b3d447c0c195eca3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 21:14:55 +0200 Subject: [PATCH 08/24] Add devdock template lifecycle example --- .../api2-devdock-template-lifecycle/main.go | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 examples/api2-devdock-template-lifecycle/main.go diff --git a/examples/api2-devdock-template-lifecycle/main.go b/examples/api2-devdock-template-lifecycle/main.go new file mode 100644 index 0000000..c53c3b6 --- /dev/null +++ b/examples/api2-devdock-template-lifecycle/main.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "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 := os.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 assertTemplateContent(label string, template transloadit.Template, expected scenarioContent) { + for stepName, expectedStep := range expected.Steps { + actualStep, ok := template.Content.Steps[stepName] + if !ok { + fail("%s response did not include step %q", label, stepName) + } + if !reflect.DeepEqual(actualStep, expectedStep) { + fail("%s response step %q was %v, expected %v", label, stepName, actualStep, expectedStep) + } + } + + for name, expectedValue := range expected.AdditionalProperties { + actualValue, ok := template.Content.AdditionalProperties[name] + if !ok { + fail("%s response did not include content property %q", label, name) + } + if !reflect.DeepEqual(actualValue, expectedValue) { + fail( + "%s response content property %q was %v, expected %v", + label, + name, + actualValue, + expectedValue, + ) + } + } +} + +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) + } + if fetched.ID != templateID { + fail("get template returned id %q, expected %q", fetched.ID, templateID) + } + if fetched.Name != templateName { + fail("get template returned name %q, expected %q", fetched.Name, templateName) + } + if fetched.RequireSignatureAuth != scenario.Template.RequireSignatureAuth { + fail( + "get template returned RequireSignatureAuth=%v, expected %v", + fetched.RequireSignatureAuth, + scenario.Template.RequireSignatureAuth, + ) + } + assertTemplateContent("get template", fetched, scenario.Template.Content) + + templateList, err := client.ListTemplates(ctx, &transloadit.ListOptions{ + PageSize: scenario.List.PageSize, + }) + if err != nil { + fail("list templates: %v", err) + } + if templateList.Count < scenario.List.MinimumCount { + fail( + "list templates returned count=%d, expected at least %d", + templateList.Count, + scenario.List.MinimumCount, + ) + } + + 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 fetchedUpdated.Name != updatedTemplate.Name { + fail("updated template returned name %q, expected %q", fetchedUpdated.Name, updatedTemplate.Name) + } + if fetchedUpdated.RequireSignatureAuth != scenario.Update.RequireSignatureAuth { + fail( + "updated template returned RequireSignatureAuth=%v, expected %v", + fetchedUpdated.RequireSignatureAuth, + scenario.Update.RequireSignatureAuth, + ) + } + assertTemplateContent("updated template", fetchedUpdated, scenario.Update.Content) + + if err := client.DeleteTemplate(ctx, templateID); err != nil { + fail("delete template: %v", err) + } + deleteTemplate = false + + _, err = client.GetTemplate(ctx, templateID) + if err == nil { + fail("get deleted template succeeded unexpectedly") + } + var requestErr transloadit.RequestError + if !errors.As(err, &requestErr) { + fail("get deleted template returned %T, expected transloadit.RequestError", err) + } + if !strings.Contains(requestErr.Code, scenario.Delete.ErrorCodeIncludes) { + fail("get deleted template returned unexpected error code %q", requestErr.Code) + } + + fmt.Printf( + "Go Transloadit SDK devdock scenario %s passed for %s\n", + scenario.ScenarioID, + requiredEnv("TRANSLOADIT_ENDPOINT"), + ) +} From 12d9bae579d39e58b71111babc72dffec8d48942 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 21:29:21 +0200 Subject: [PATCH 09/24] Keep devdock example Go 1.15 compatible --- examples/api2-devdock-template-lifecycle/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/api2-devdock-template-lifecycle/main.go b/examples/api2-devdock-template-lifecycle/main.go index c53c3b6..8eea78a 100644 --- a/examples/api2-devdock-template-lifecycle/main.go +++ b/examples/api2-devdock-template-lifecycle/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "os" "path/filepath" "reflect" @@ -63,7 +64,7 @@ func loadScenario() (templateLifecycleScenario, error) { ) } - contents, err := os.ReadFile(scenarioPath) + contents, err := ioutil.ReadFile(scenarioPath) if err != nil { return templateLifecycleScenario{}, err } From f257b3ce933985d81eec36af78d737ab8944d9b9 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 23:10:06 +0200 Subject: [PATCH 10/24] Let API2 assert template lifecycle example --- .../api2-devdock-template-lifecycle/main.go | 120 ++++++++---------- 1 file changed, 50 insertions(+), 70 deletions(-) diff --git a/examples/api2-devdock-template-lifecycle/main.go b/examples/api2-devdock-template-lifecycle/main.go index 8eea78a..436159e 100644 --- a/examples/api2-devdock-template-lifecycle/main.go +++ b/examples/api2-devdock-template-lifecycle/main.go @@ -5,11 +5,8 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" "os" "path/filepath" - "reflect" - "strings" "time" transloadit "github.com/transloadit/go-sdk" @@ -64,7 +61,7 @@ func loadScenario() (templateLifecycleScenario, error) { ) } - contents, err := ioutil.ReadFile(scenarioPath) + contents, err := os.ReadFile(scenarioPath) if err != nil { return templateLifecycleScenario{}, err } @@ -96,34 +93,6 @@ func newTemplate(name string, requireSignatureAuth bool, content scenarioContent return template } -func assertTemplateContent(label string, template transloadit.Template, expected scenarioContent) { - for stepName, expectedStep := range expected.Steps { - actualStep, ok := template.Content.Steps[stepName] - if !ok { - fail("%s response did not include step %q", label, stepName) - } - if !reflect.DeepEqual(actualStep, expectedStep) { - fail("%s response step %q was %v, expected %v", label, stepName, actualStep, expectedStep) - } - } - - for name, expectedValue := range expected.AdditionalProperties { - actualValue, ok := template.Content.AdditionalProperties[name] - if !ok { - fail("%s response did not include content property %q", label, name) - } - if !reflect.DeepEqual(actualValue, expectedValue) { - fail( - "%s response content property %q was %v, expected %v", - label, - name, - actualValue, - expectedValue, - ) - } - } -} - func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -165,20 +134,6 @@ func main() { if err != nil { fail("get template: %v", err) } - if fetched.ID != templateID { - fail("get template returned id %q, expected %q", fetched.ID, templateID) - } - if fetched.Name != templateName { - fail("get template returned name %q, expected %q", fetched.Name, templateName) - } - if fetched.RequireSignatureAuth != scenario.Template.RequireSignatureAuth { - fail( - "get template returned RequireSignatureAuth=%v, expected %v", - fetched.RequireSignatureAuth, - scenario.Template.RequireSignatureAuth, - ) - } - assertTemplateContent("get template", fetched, scenario.Template.Content) templateList, err := client.ListTemplates(ctx, &transloadit.ListOptions{ PageSize: scenario.List.PageSize, @@ -186,13 +141,6 @@ func main() { if err != nil { fail("list templates: %v", err) } - if templateList.Count < scenario.List.MinimumCount { - fail( - "list templates returned count=%d, expected at least %d", - templateList.Count, - scenario.List.MinimumCount, - ) - } updatedTemplate := newTemplate( templateName+scenario.Update.NameSuffix, @@ -208,17 +156,6 @@ func main() { if err != nil { fail("get updated template: %v", err) } - if fetchedUpdated.Name != updatedTemplate.Name { - fail("updated template returned name %q, expected %q", fetchedUpdated.Name, updatedTemplate.Name) - } - if fetchedUpdated.RequireSignatureAuth != scenario.Update.RequireSignatureAuth { - fail( - "updated template returned RequireSignatureAuth=%v, expected %v", - fetchedUpdated.RequireSignatureAuth, - scenario.Update.RequireSignatureAuth, - ) - } - assertTemplateContent("updated template", fetchedUpdated, scenario.Update.Content) if err := client.DeleteTemplate(ctx, templateID); err != nil { fail("delete template: %v", err) @@ -226,15 +163,28 @@ func main() { deleteTemplate = false _, err = client.GetTemplate(ctx, templateID) - if err == nil { - fail("get deleted template succeeded unexpectedly") - } + deletedGetSucceeded := err == nil + deletedErrorCode := "" var requestErr transloadit.RequestError - if !errors.As(err, &requestErr) { + if err != nil && !errors.As(err, &requestErr) { fail("get deleted template returned %T, expected transloadit.RequestError", err) } - if !strings.Contains(requestErr.Code, scenario.Delete.ErrorCodeIncludes) { - fail("get deleted template returned unexpected error code %q", requestErr.Code) + 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( @@ -243,3 +193,33 @@ func main() { 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 os.WriteFile(resultPath, append(contents, '\n'), 0o644) +} From 26d9e11b25f3bf7fa1fd1f074884323a689c3a1a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 23:17:06 +0200 Subject: [PATCH 11/24] Support Go 1.15 in devdock example --- examples/api2-devdock-template-lifecycle/main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/api2-devdock-template-lifecycle/main.go b/examples/api2-devdock-template-lifecycle/main.go index 436159e..7bc0451 100644 --- a/examples/api2-devdock-template-lifecycle/main.go +++ b/examples/api2-devdock-template-lifecycle/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "os" "path/filepath" "time" @@ -61,7 +62,7 @@ func loadScenario() (templateLifecycleScenario, error) { ) } - contents, err := os.ReadFile(scenarioPath) + contents, err := ioutil.ReadFile(scenarioPath) if err != nil { return templateLifecycleScenario{}, err } @@ -221,5 +222,5 @@ func writeResult(result map[string]interface{}) error { return err } - return os.WriteFile(resultPath, append(contents, '\n'), 0o644) + return ioutil.WriteFile(resultPath, append(contents, '\n'), 0o644) } From ea262bd1a077bf7bf20fee5225994ff9fc439014 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 03:07:09 +0200 Subject: [PATCH 12/24] Add devdock TUS assembly example --- examples/api2-devdock-tus-assembly/main.go | 508 +++++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 examples/api2-devdock-tus-assembly/main.go diff --git a/examples/api2-devdock-tus-assembly/main.go b/examples/api2-devdock-tus-assembly/main.go new file mode 100644 index 0000000..5175beb --- /dev/null +++ b/examples/api2-devdock-tus-assembly/main.go @@ -0,0 +1,508 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + transloadit "github.com/transloadit/go-sdk" +) + +func requiredEnv(name string) string { + value := os.Getenv(name) + if value == "" { + panic(fmt.Sprintf("%s must be set", name)) + } + + return value +} + +func fail(format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) +} + +func loadScenario() (map[string]interface{}, error) { + scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") + if scenarioPath == "" { + scenarioPath = filepath.Join("examples", "api2-devdock-tus-assembly", "api2-scenario.json") + } + + contents, err := os.ReadFile(scenarioPath) + if err != nil { + return nil, err + } + + var scenario map[string]interface{} + if err := json.Unmarshal(contents, &scenario); err != nil { + return nil, err + } + + return scenario, nil +} + +func objectValue(value interface{}, label string) (map[string]interface{}, error) { + object, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an object", label) + } + + return object, nil +} + +func arrayValue(value interface{}, label string) ([]interface{}, error) { + array, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be an array", label) + } + + return array, nil +} + +func stringValue(value interface{}, label string) (string, error) { + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", label) + } + + return text, nil +} + +func intValue(value interface{}, label string) (int, error) { + switch number := value.(type) { + case float64: + if float64(int(number)) != number { + return 0, fmt.Errorf("%s must be an integer", label) + } + + return int(number), nil + case int: + return number, nil + case string: + parsed, err := strconv.Atoi(number) + if err != nil { + return 0, fmt.Errorf("%s must be an integer", label) + } + + return parsed, nil + default: + return 0, fmt.Errorf("%s must be an integer", label) + } +} + +func scalarString(value interface{}) string { + switch typed := value.(type) { + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case string: + return typed + default: + serialized, err := json.Marshal(typed) + if err != nil { + return fmt.Sprintf("%v", typed) + } + + return string(serialized) + } +} + +func readPath(value interface{}, pathParts []interface{}, label string) (interface{}, error) { + current := value + for _, part := range pathParts { + if object, ok := current.(map[string]interface{}); ok { + key, ok := part.(string) + if !ok { + return nil, fmt.Errorf("%s path cannot read non-string key %v from object", label, part) + } + next, ok := object[key] + if !ok { + return nil, fmt.Errorf("%s path is missing key %q", label, key) + } + current = next + continue + } + + if array, ok := current.([]interface{}); ok { + index, err := intValue(part, label) + if err != nil { + return nil, err + } + if index < 0 || index >= len(array) { + return nil, fmt.Errorf("%s path index %d is out of range", label, index) + } + current = array[index] + continue + } + + return nil, fmt.Errorf("%s path cannot read %v from %v", label, part, current) + } + + return current, nil +} + +func resolveValue( + valueSpec interface{}, + context map[string]interface{}, + label string, +) (interface{}, error) { + spec, err := objectValue(valueSpec, label) + if err != nil { + return nil, err + } + if literal, ok := spec["value"]; ok { + return literal, nil + } + + source, err := objectValue(spec["source"], label+".source") + if err != nil { + return nil, err + } + root, err := stringValue(source["root"], label+".source.root") + if err != nil { + return nil, err + } + rootValue, ok := context[root] + if !ok { + return nil, fmt.Errorf("%s source root %q is unavailable", label, root) + } + pathParts, err := arrayValue(source["path"], label+".source.path") + if err != nil { + return nil, err + } + + return readPath(rootValue, pathParts, label) +} + +func 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 createAssembly( + ctx context.Context, + client transloadit.Client, + scenario map[string]interface{}, +) (*transloadit.AssemblyInfo, map[string]interface{}, error) { + createConfig, err := objectValue(scenario["createTusAssembly"], "createTusAssembly") + if err != nil { + return nil, nil, err + } + input, err := objectValue(createConfig["input"], "createTusAssembly.input") + if err != nil { + return nil, nil, err + } + fileCount, err := intValue(input["file_count"], "createTusAssembly.input.file_count") + if err != nil { + return nil, nil, err + } + + info, err := client.CreateTusAssembly(ctx, fileCount) + if err != nil { + return nil, nil, err + } + createResponse, err := asJsonObject(info, "create response") + if err != nil { + return nil, nil, err + } + + requiredPaths, err := arrayValue( + createConfig["requiredResponsePaths"], + "createTusAssembly.requiredResponsePaths", + ) + if err != nil { + return nil, nil, err + } + for index, rawPath := range requiredPaths { + pathParts, err := arrayValue( + rawPath, + fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index), + ) + if err != nil { + return nil, nil, err + } + value, err := readPath( + createResponse, + pathParts, + fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index), + ) + if err != nil { + return nil, nil, err + } + if scalarString(value) == "" { + return nil, nil, fmt.Errorf("create response path %v is empty", pathParts) + } + } + + return info, createResponse, nil +} + +func scenarioBytes(scenario map[string]interface{}) ([]byte, error) { + upload, err := objectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + source, err := objectValue(upload["source"], "upload.source") + if err != nil { + return nil, err + } + kind, err := stringValue(source["kind"], "upload.source.kind") + if err != nil { + return nil, err + } + if kind != "bytes" { + return nil, fmt.Errorf("unsupported scenario source kind %q", kind) + } + encoding, err := stringValue(source["encoding"], "upload.source.encoding") + if err != nil { + return nil, err + } + if encoding != "utf8" { + return nil, fmt.Errorf("unsupported scenario source encoding %q", encoding) + } + value, err := stringValue(source["value"], "upload.source.value") + if err != nil { + return nil, err + } + + return []byte(value), nil +} + +func uploadMetadata( + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (map[string]string, error) { + upload, err := objectValue(scenario["upload"], "upload") + if err != nil { + return nil, err + } + fields, err := arrayValue(upload["metadata"], "upload.metadata") + if err != nil { + return nil, err + } + + context := map[string]interface{}{ + "createResponse": createResponse, + "scenario": scenario, + } + metadata := map[string]string{} + for index, rawField := range fields { + label := fmt.Sprintf("upload.metadata[%d]", index) + field, err := objectValue(rawField, label) + if err != nil { + return nil, err + } + name, err := stringValue(field["name"], label+".name") + if err != nil { + return nil, err + } + value, err := resolveValue(field["value"], context, label+".value") + if err != nil { + return nil, err + } + metadata[name] = scalarString(value) + } + + return metadata, nil +} + +func tusMetadataHeader(metadata map[string]string) string { + parts := make([]string, 0, len(metadata)) + for name, value := range metadata { + encoded := base64.StdEncoding.EncodeToString([]byte(value)) + parts = append(parts, fmt.Sprintf("%s %s", name, encoded)) + } + + return strings.Join(parts, ",") +} + +func checkedResponse(response *http.Response, expectedStatus int, label string) error { + defer response.Body.Close() + if response.StatusCode == expectedStatus { + return nil + } + + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("%s returned HTTP %d: %s", label, response.StatusCode, string(body)) +} + +func uploadWithTus( + ctx context.Context, + scenario map[string]interface{}, + createResponse map[string]interface{}, +) (string, error) { + uploadConfig, err := objectValue(scenario["upload"], "upload") + if err != nil { + return "", err + } + context := map[string]interface{}{ + "createResponse": createResponse, + "scenario": scenario, + } + endpointValue, err := resolveValue(uploadConfig["tusUrl"], context, "upload.tusUrl") + if err != nil { + return "", err + } + endpointURL, err := url.Parse(scalarString(endpointValue)) + if err != nil { + return "", err + } + content, err := scenarioBytes(scenario) + if err != nil { + return "", err + } + chunkSize, err := stringValue(uploadConfig["chunkSize"], "upload.chunkSize") + if err != nil { + return "", err + } + if chunkSize != "full-file" { + return "", fmt.Errorf("unsupported chunk size policy %q", chunkSize) + } + metadata, err := uploadMetadata(scenario, createResponse) + if err != nil { + return "", err + } + + createRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL.String(), nil) + if err != nil { + return "", err + } + createRequest.Header.Set("Tus-Resumable", "1.0.0") + createRequest.Header.Set("Upload-Length", strconv.Itoa(len(content))) + createRequest.Header.Set("Upload-Metadata", tusMetadataHeader(metadata)) + + createResponseHttp, err := http.DefaultClient.Do(createRequest) + if err != nil { + return "", err + } + if err := checkedResponse(createResponseHttp, http.StatusCreated, "TUS create"); err != nil { + return "", err + } + location := createResponseHttp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("TUS create did not return a Location header") + } + uploadURL, err := endpointURL.Parse(location) + if err != nil { + return "", err + } + + patchRequest, err := http.NewRequestWithContext( + ctx, + http.MethodPatch, + uploadURL.String(), + bytes.NewReader(content), + ) + if err != nil { + return "", 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 { + return "", err + } + if err := checkedResponse(patchResponse, http.StatusNoContent, "TUS upload"); err != nil { + return "", err + } + remoteOffset, err := intValue(patchResponse.Header.Get("Upload-Offset"), "Upload-Offset") + if err != nil { + return "", err + } + if remoteOffset != len(content) { + return "", fmt.Errorf("TUS upload offset %d, expected %d", remoteOffset, len(content)) + } + + return uploadURL.String(), nil +} + +func writeResult( + createResponse map[string]interface{}, + 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": createResponse, + "status": status, + "uploadUrl": uploadURL, + }, + "", + " ", + ) + if err != nil { + return err + } + + return os.WriteFile(resultPath, append(contents, '\n'), 0o644) +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + scenario, err := loadScenario() + 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"), + }) + + info, createResponse, err := createAssembly(ctx, client, scenario) + if err != nil { + fail("create TUS assembly: %v", err) + } + uploadURL, err := uploadWithTus(ctx, scenario, createResponse) + if err != nil { + fail("upload: %v", err) + } + statusInfo, err := client.WaitForAssembly(ctx, info) + if err != nil { + fail("wait for assembly: %v", err) + } + status, err := asJsonObject(statusInfo, "assembly status") + if err != nil { + fail("serialize assembly status: %v", err) + } + if err := writeResult(createResponse, status, uploadURL); err != nil { + fail("write result: %v", err) + } + + scenarioID, err := stringValue(scenario["scenarioId"], "scenarioId") + if err != nil { + fail("read scenario id: %v", err) + } + fmt.Printf("Go Transloadit SDK devdock scenario %s uploaded to %s\n", scenarioID, uploadURL) +} From bd652e8485cff6a086751dd8060580d3c5d892d5 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 03:15:12 +0200 Subject: [PATCH 13/24] Keep devdock example Go 1.15 compatible --- examples/api2-devdock-tus-assembly/main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/api2-devdock-tus-assembly/main.go b/examples/api2-devdock-tus-assembly/main.go index 5175beb..c58bf72 100644 --- a/examples/api2-devdock-tus-assembly/main.go +++ b/examples/api2-devdock-tus-assembly/main.go @@ -6,7 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" + "io/ioutil" "net/http" "net/url" "os" @@ -37,7 +37,7 @@ func loadScenario() (map[string]interface{}, error) { scenarioPath = filepath.Join("examples", "api2-devdock-tus-assembly", "api2-scenario.json") } - contents, err := os.ReadFile(scenarioPath) + contents, err := ioutil.ReadFile(scenarioPath) if err != nil { return nil, err } @@ -343,7 +343,7 @@ func checkedResponse(response *http.Response, expectedStatus int, label string) return nil } - body, _ := io.ReadAll(response.Body) + body, _ := ioutil.ReadAll(response.Body) return fmt.Errorf("%s returned HTTP %d: %s", label, response.StatusCode, string(body)) } @@ -462,7 +462,7 @@ func writeResult( return err } - return os.WriteFile(resultPath, append(contents, '\n'), 0o644) + return ioutil.WriteFile(resultPath, append(contents, '\n'), 0o644) } func main() { From 16fe26e2ac80e02e8624233d2c27b541ed7ed652 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 05:29:05 +0200 Subject: [PATCH 14/24] Read TUS scenario preparations generically --- examples/api2-devdock-tus-assembly/main.go | 48 +++++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/examples/api2-devdock-tus-assembly/main.go b/examples/api2-devdock-tus-assembly/main.go index c58bf72..5c0b3a8 100644 --- a/examples/api2-devdock-tus-assembly/main.go +++ b/examples/api2-devdock-tus-assembly/main.go @@ -186,6 +186,42 @@ func resolveValue( return readPath(rootValue, pathParts, label) } +func featurePreparation( + scenario map[string]interface{}, + featureID string, +) (map[string]interface{}, string, error) { + preparations, err := arrayValue(scenario["preparations"], "preparations") + if err != nil { + return nil, "", err + } + + for index, rawPreparation := range preparations { + label := fmt.Sprintf("preparations[%d]", index) + preparation, err := objectValue(rawPreparation, label) + if err != nil { + return nil, "", err + } + currentFeatureID, err := stringValue(preparation["featureId"], label+".featureId") + if err != nil { + return nil, "", err + } + if currentFeatureID != featureID { + continue + } + kind, err := stringValue(preparation["kind"], label+".kind") + if err != nil { + return nil, "", err + } + if kind != "feature-call" { + return nil, "", fmt.Errorf("%s must be a feature-call preparation", label) + } + + return preparation, label, nil + } + + return nil, "", fmt.Errorf("scenario has no preparation for feature %q", featureID) +} + func asJsonObject(value interface{}, label string) (map[string]interface{}, error) { contents, err := json.Marshal(value) if err != nil { @@ -205,15 +241,15 @@ func createAssembly( client transloadit.Client, scenario map[string]interface{}, ) (*transloadit.AssemblyInfo, map[string]interface{}, error) { - createConfig, err := objectValue(scenario["createTusAssembly"], "createTusAssembly") + createConfig, createConfigLabel, err := featurePreparation(scenario, "createTusAssembly") if err != nil { return nil, nil, err } - input, err := objectValue(createConfig["input"], "createTusAssembly.input") + input, err := objectValue(createConfig["input"], createConfigLabel+".input") if err != nil { return nil, nil, err } - fileCount, err := intValue(input["file_count"], "createTusAssembly.input.file_count") + fileCount, err := intValue(input["file_count"], createConfigLabel+".input.file_count") if err != nil { return nil, nil, err } @@ -229,7 +265,7 @@ func createAssembly( requiredPaths, err := arrayValue( createConfig["requiredResponsePaths"], - "createTusAssembly.requiredResponsePaths", + createConfigLabel+".requiredResponsePaths", ) if err != nil { return nil, nil, err @@ -237,7 +273,7 @@ func createAssembly( for index, rawPath := range requiredPaths { pathParts, err := arrayValue( rawPath, - fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index), + fmt.Sprintf("%s.requiredResponsePaths[%d]", createConfigLabel, index), ) if err != nil { return nil, nil, err @@ -245,7 +281,7 @@ func createAssembly( value, err := readPath( createResponse, pathParts, - fmt.Sprintf("createTusAssembly.requiredResponsePaths[%d]", index), + fmt.Sprintf("%s.requiredResponsePaths[%d]", createConfigLabel, index), ) if err != nil { return nil, nil, err From c1db0c5d3ab730137f9164b8f6e0c12debe55e9d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 06:47:52 +0200 Subject: [PATCH 15/24] Use generated TUS assembly upload helper --- assembly.go | 94 ++++++ examples/api2-devdock-tus-assembly/main.go | 328 +++------------------ 2 files changed, 139 insertions(+), 283 deletions(-) diff --git a/assembly.go b/assembly.go index 590631b..b307330 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" ) @@ -267,6 +271,96 @@ func (client *Client) CreateTusAssembly(ctx context.Context, fileCount int) (*As // +// + +// 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 + } + + uploadMetadata := make(map[string]string) + for name, value := range userMeta { + uploadMetadata[name] = value + } + uploadMetadata["assembly_url"] = createdAssembly.AssemblyURL + uploadMetadata["fieldname"] = fieldname + uploadMetadata["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(uploadMetadata)) + for name, value := range uploadMetadata { + 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) + } + location := createResponse.Header.Get("Location") + if location == "" { + return nil, "", fmt.Errorf("TUS create did not return a Location header") + } + uploadURL, err := endpointURL.Parse(location) + if err != nil { + return nil, "", err + } + uploadURLText := uploadURL.String() + + patchRequest, err := http.NewRequestWithContext(ctx, "PATCH", uploadURLText, bytes.NewReader(content)) + if err != nil { + return nil, "", 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 := client.httpClient.Do(patchRequest) + if err != nil { + return nil, "", err + } + defer patchResponse.Body.Close() + if patchResponse.StatusCode != 204 { + return nil, "", fmt.Errorf("TUS upload returned HTTP %d, expected 204", patchResponse.StatusCode) + } + remoteOffset, err := strconv.Atoi(patchResponse.Header.Get("Upload-Offset")) + if err != nil { + return nil, "", err + } + if remoteOffset != len(content) { + return nil, "", fmt.Errorf("TUS upload offset %d, expected %d", remoteOffset, len(content)) + } + + 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" diff --git a/examples/api2-devdock-tus-assembly/main.go b/examples/api2-devdock-tus-assembly/main.go index 5c0b3a8..0242a12 100644 --- a/examples/api2-devdock-tus-assembly/main.go +++ b/examples/api2-devdock-tus-assembly/main.go @@ -1,18 +1,13 @@ package main import ( - "bytes" "context" - "encoding/base64" "encoding/json" "fmt" "io/ioutil" - "net/http" - "net/url" "os" "path/filepath" "strconv" - "strings" "time" transloadit "github.com/transloadit/go-sdk" @@ -99,93 +94,6 @@ func intValue(value interface{}, label string) (int, error) { } } -func scalarString(value interface{}) string { - switch typed := value.(type) { - case bool: - return strconv.FormatBool(typed) - case float64: - return strconv.FormatFloat(typed, 'f', -1, 64) - case int: - return strconv.Itoa(typed) - case string: - return typed - default: - serialized, err := json.Marshal(typed) - if err != nil { - return fmt.Sprintf("%v", typed) - } - - return string(serialized) - } -} - -func readPath(value interface{}, pathParts []interface{}, label string) (interface{}, error) { - current := value - for _, part := range pathParts { - if object, ok := current.(map[string]interface{}); ok { - key, ok := part.(string) - if !ok { - return nil, fmt.Errorf("%s path cannot read non-string key %v from object", label, part) - } - next, ok := object[key] - if !ok { - return nil, fmt.Errorf("%s path is missing key %q", label, key) - } - current = next - continue - } - - if array, ok := current.([]interface{}); ok { - index, err := intValue(part, label) - if err != nil { - return nil, err - } - if index < 0 || index >= len(array) { - return nil, fmt.Errorf("%s path index %d is out of range", label, index) - } - current = array[index] - continue - } - - return nil, fmt.Errorf("%s path cannot read %v from %v", label, part, current) - } - - return current, nil -} - -func resolveValue( - valueSpec interface{}, - context map[string]interface{}, - label string, -) (interface{}, error) { - spec, err := objectValue(valueSpec, label) - if err != nil { - return nil, err - } - if literal, ok := spec["value"]; ok { - return literal, nil - } - - source, err := objectValue(spec["source"], label+".source") - if err != nil { - return nil, err - } - root, err := stringValue(source["root"], label+".source.root") - if err != nil { - return nil, err - } - rootValue, ok := context[root] - if !ok { - return nil, fmt.Errorf("%s source root %q is unavailable", label, root) - } - pathParts, err := arrayValue(source["path"], label+".source.path") - if err != nil { - return nil, err - } - - return readPath(rootValue, pathParts, label) -} - func featurePreparation( scenario map[string]interface{}, featureID string, @@ -236,62 +144,17 @@ func asJsonObject(value interface{}, label string) (map[string]interface{}, erro return result, nil } -func createAssembly( - ctx context.Context, - client transloadit.Client, - scenario map[string]interface{}, -) (*transloadit.AssemblyInfo, map[string]interface{}, error) { +func scenarioFileCount(scenario map[string]interface{}) (int, error) { createConfig, createConfigLabel, err := featurePreparation(scenario, "createTusAssembly") if err != nil { - return nil, nil, err + return 0, err } input, err := objectValue(createConfig["input"], createConfigLabel+".input") if err != nil { - return nil, nil, err - } - fileCount, err := intValue(input["file_count"], createConfigLabel+".input.file_count") - if err != nil { - return nil, nil, err - } - - info, err := client.CreateTusAssembly(ctx, fileCount) - if err != nil { - return nil, nil, err - } - createResponse, err := asJsonObject(info, "create response") - if err != nil { - return nil, nil, err - } - - requiredPaths, err := arrayValue( - createConfig["requiredResponsePaths"], - createConfigLabel+".requiredResponsePaths", - ) - if err != nil { - return nil, nil, err - } - for index, rawPath := range requiredPaths { - pathParts, err := arrayValue( - rawPath, - fmt.Sprintf("%s.requiredResponsePaths[%d]", createConfigLabel, index), - ) - if err != nil { - return nil, nil, err - } - value, err := readPath( - createResponse, - pathParts, - fmt.Sprintf("%s.requiredResponsePaths[%d]", createConfigLabel, index), - ) - if err != nil { - return nil, nil, err - } - if scalarString(value) == "" { - return nil, nil, fmt.Errorf("create response path %v is empty", pathParts) - } + return 0, err } - return info, createResponse, nil + return intValue(input["file_count"], createConfigLabel+".input.file_count") } func scenarioBytes(scenario map[string]interface{}) ([]byte, error) { @@ -325,158 +188,45 @@ func scenarioBytes(scenario map[string]interface{}) ([]byte, error) { return []byte(value), nil } -func uploadMetadata( - scenario map[string]interface{}, - createResponse map[string]interface{}, -) (map[string]string, error) { - upload, err := objectValue(scenario["upload"], "upload") - if err != nil { - return nil, err - } - fields, err := arrayValue(upload["metadata"], "upload.metadata") - if err != nil { - return nil, err - } - - context := map[string]interface{}{ - "createResponse": createResponse, - "scenario": scenario, - } - metadata := map[string]string{} - for index, rawField := range fields { - label := fmt.Sprintf("upload.metadata[%d]", index) - field, err := objectValue(rawField, label) - if err != nil { - return nil, err - } - name, err := stringValue(field["name"], label+".name") - if err != nil { - return nil, err - } - value, err := resolveValue(field["value"], context, label+".value") - if err != nil { - return nil, err - } - metadata[name] = scalarString(value) - } - - return metadata, nil -} - -func tusMetadataHeader(metadata map[string]string) string { - parts := make([]string, 0, len(metadata)) - for name, value := range metadata { - encoded := base64.StdEncoding.EncodeToString([]byte(value)) - parts = append(parts, fmt.Sprintf("%s %s", name, encoded)) - } - - return strings.Join(parts, ",") -} - -func checkedResponse(response *http.Response, expectedStatus int, label string) error { - defer response.Body.Close() - if response.StatusCode == expectedStatus { - return nil - } - - body, _ := ioutil.ReadAll(response.Body) - return fmt.Errorf("%s returned HTTP %d: %s", label, response.StatusCode, string(body)) -} - -func uploadWithTus( - ctx context.Context, - scenario map[string]interface{}, - createResponse map[string]interface{}, -) (string, error) { +func uploadInfo(scenario map[string]interface{}) (string, string, map[string]string, error) { uploadConfig, err := objectValue(scenario["upload"], "upload") if err != nil { - return "", err - } - context := map[string]interface{}{ - "createResponse": createResponse, - "scenario": scenario, - } - endpointValue, err := resolveValue(uploadConfig["tusUrl"], context, "upload.tusUrl") - if err != nil { - return "", err - } - endpointURL, err := url.Parse(scalarString(endpointValue)) - if err != nil { - return "", err - } - content, err := scenarioBytes(scenario) - if err != nil { - return "", err + return "", "", nil, err } chunkSize, err := stringValue(uploadConfig["chunkSize"], "upload.chunkSize") if err != nil { - return "", err + return "", "", nil, err } if chunkSize != "full-file" { - return "", fmt.Errorf("unsupported chunk size policy %q", chunkSize) - } - metadata, err := uploadMetadata(scenario, createResponse) - if err != nil { - return "", err - } - - createRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL.String(), nil) - if err != nil { - return "", err + return "", "", nil, fmt.Errorf("unsupported chunk size policy %q", chunkSize) } - createRequest.Header.Set("Tus-Resumable", "1.0.0") - createRequest.Header.Set("Upload-Length", strconv.Itoa(len(content))) - createRequest.Header.Set("Upload-Metadata", tusMetadataHeader(metadata)) - - createResponseHttp, err := http.DefaultClient.Do(createRequest) - if err != nil { - return "", err - } - if err := checkedResponse(createResponseHttp, http.StatusCreated, "TUS create"); err != nil { - return "", err - } - location := createResponseHttp.Header.Get("Location") - if location == "" { - return "", fmt.Errorf("TUS create did not return a Location header") - } - uploadURL, err := endpointURL.Parse(location) + fieldName, err := stringValue(uploadConfig["fieldName"], "upload.fieldName") if err != nil { - return "", err + return "", "", nil, err } - - patchRequest, err := http.NewRequestWithContext( - ctx, - http.MethodPatch, - uploadURL.String(), - bytes.NewReader(content), - ) + fileName, err := stringValue(uploadConfig["fileName"], "upload.fileName") if err != nil { - return "", err + return "", "", nil, 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 { - return "", err - } - if err := checkedResponse(patchResponse, http.StatusNoContent, "TUS upload"); err != nil { - return "", err - } - remoteOffset, err := intValue(patchResponse.Header.Get("Upload-Offset"), "Upload-Offset") - if err != nil { - return "", err - } - if remoteOffset != len(content) { - return "", fmt.Errorf("TUS upload offset %d, expected %d", remoteOffset, len(content)) + userMeta := map[string]string{} + if rawUserMeta, ok := uploadConfig["userMeta"]; ok { + userMetaObject, err := objectValue(rawUserMeta, "upload.userMeta") + if err != nil { + return "", "", nil, err + } + for name, value := range userMetaObject { + userMeta[name], err = stringValue(value, "upload.userMeta."+name) + if err != nil { + return "", "", nil, err + } + } } - return uploadURL.String(), nil + return fieldName, fileName, userMeta, nil } func writeResult( - createResponse map[string]interface{}, status map[string]interface{}, uploadURL string, ) error { @@ -487,7 +237,7 @@ func writeResult( contents, err := json.MarshalIndent( map[string]interface{}{ - "createResponse": createResponse, + "createResponse": status, "status": status, "uploadUrl": uploadURL, }, @@ -516,23 +266,35 @@ func main() { Endpoint: requiredEnv("TRANSLOADIT_ENDPOINT"), }) - info, createResponse, err := createAssembly(ctx, client, scenario) + fileCount, err := scenarioFileCount(scenario) + if err != nil { + fail("read file count: %v", err) + } + content, err := scenarioBytes(scenario) if err != nil { - fail("create TUS assembly: %v", err) + fail("read upload bytes: %v", err) } - uploadURL, err := uploadWithTus(ctx, scenario, createResponse) + fieldName, fileName, userMeta, err := uploadInfo(scenario) if err != nil { - fail("upload: %v", err) + fail("read upload info: %v", err) } - statusInfo, err := client.WaitForAssembly(ctx, info) + + statusInfo, uploadURL, err := client.UploadTusAssembly( + ctx, + fileCount, + content, + fieldName, + fileName, + userMeta, + ) if err != nil { - fail("wait for assembly: %v", err) + fail("upload TUS assembly: %v", err) } status, err := asJsonObject(statusInfo, "assembly status") if err != nil { fail("serialize assembly status: %v", err) } - if err := writeResult(createResponse, status, uploadURL); err != nil { + if err := writeResult(status, uploadURL); err != nil { fail("write result: %v", err) } From d0a5c7751d576ee215b8f3e34b28f70e4808de4b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 07:06:00 +0200 Subject: [PATCH 16/24] Use protocol-plan generated upload helper --- assembly.go | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/assembly.go b/assembly.go index b307330..9693c57 100644 --- a/assembly.go +++ b/assembly.go @@ -289,13 +289,13 @@ func (client *Client) UploadTusAssembly(ctx context.Context, fileCount int, cont return nil, "", err } - uploadMetadata := make(map[string]string) + metadataMap := make(map[string]string) for name, value := range userMeta { - uploadMetadata[name] = value + metadataMap[name] = value } - uploadMetadata["assembly_url"] = createdAssembly.AssemblyURL - uploadMetadata["fieldname"] = fieldname - uploadMetadata["filename"] = filename + metadataMap["assembly_url"] = createdAssembly.AssemblyURL + metadataMap["fieldname"] = fieldname + metadataMap["filename"] = filename createRequest, err := http.NewRequestWithContext(ctx, "POST", endpointURL.String(), nil) if err != nil { @@ -303,8 +303,8 @@ func (client *Client) UploadTusAssembly(ctx context.Context, fileCount int, cont } createRequest.Header.Set("Tus-Resumable", "1.0.0") createRequest.Header.Set("Upload-Length", strconv.Itoa(len(content))) - metadataParts := make([]string, 0, len(uploadMetadata)) - for name, value := range uploadMetadata { + 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, ",")) @@ -317,33 +317,33 @@ func (client *Client) UploadTusAssembly(ctx context.Context, fileCount int, cont if createResponse.StatusCode != 201 { return nil, "", fmt.Errorf("TUS create returned HTTP %d, expected 201", createResponse.StatusCode) } - location := createResponse.Header.Get("Location") - if location == "" { + uploadURLLocation := createResponse.Header.Get("Location") + if uploadURLLocation == "" { return nil, "", fmt.Errorf("TUS create did not return a Location header") } - uploadURL, err := endpointURL.Parse(location) + uploadURL, err := endpointURL.Parse(uploadURLLocation) if err != nil { return nil, "", err } uploadURLText := uploadURL.String() - patchRequest, err := http.NewRequestWithContext(ctx, "PATCH", uploadURLText, bytes.NewReader(content)) + uploadRequest, err := http.NewRequestWithContext(ctx, "PATCH", uploadURLText, bytes.NewReader(content)) if err != nil { return nil, "", err } - patchRequest.Header.Set("Tus-Resumable", "1.0.0") - patchRequest.Header.Set("Upload-Offset", "0") - patchRequest.Header.Set("Content-Type", "application/offset+octet-stream") + uploadRequest.Header.Set("Tus-Resumable", "1.0.0") + uploadRequest.Header.Set("Upload-Offset", "0") + uploadRequest.Header.Set("Content-Type", "application/offset+octet-stream") - patchResponse, err := client.httpClient.Do(patchRequest) + uploadResponse, err := client.httpClient.Do(uploadRequest) if err != nil { return nil, "", err } - defer patchResponse.Body.Close() - if patchResponse.StatusCode != 204 { - return nil, "", fmt.Errorf("TUS upload returned HTTP %d, expected 204", patchResponse.StatusCode) + defer uploadResponse.Body.Close() + if uploadResponse.StatusCode != 204 { + return nil, "", fmt.Errorf("TUS upload returned HTTP %d, expected 204", uploadResponse.StatusCode) } - remoteOffset, err := strconv.Atoi(patchResponse.Header.Get("Upload-Offset")) + remoteOffset, err := strconv.Atoi(uploadResponse.Header.Get("Upload-Offset")) if err != nil { return nil, "", err } From 5a4c648538a445e314c989d148f3e779551b551c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 01:01:35 +0200 Subject: [PATCH 17/24] Use header-derived TUS offset variable --- assembly.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assembly.go b/assembly.go index 9693c57..8d6bde5 100644 --- a/assembly.go +++ b/assembly.go @@ -343,12 +343,12 @@ func (client *Client) UploadTusAssembly(ctx context.Context, fileCount int, cont if uploadResponse.StatusCode != 204 { return nil, "", fmt.Errorf("TUS upload returned HTTP %d, expected 204", uploadResponse.StatusCode) } - remoteOffset, err := strconv.Atoi(uploadResponse.Header.Get("Upload-Offset")) + uploadOffset, err := strconv.Atoi(uploadResponse.Header.Get("Upload-Offset")) if err != nil { return nil, "", err } - if remoteOffset != len(content) { - return nil, "", fmt.Errorf("TUS upload offset %d, expected %d", remoteOffset, len(content)) + if uploadOffset != len(content) { + return nil, "", fmt.Errorf("TUS upload offset %d, expected %d", uploadOffset, len(content)) } completedAssembly, err := client.WaitForAssembly(ctx, createdAssembly) From bd10c4765a7866d0a887b8b0d9343a333053c72a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 05:05:48 +0200 Subject: [PATCH 18/24] Read TUS example input from SDK feature call --- examples/api2-devdock-tus-assembly/main.go | 126 +++++++++++---------- 1 file changed, 65 insertions(+), 61 deletions(-) diff --git a/examples/api2-devdock-tus-assembly/main.go b/examples/api2-devdock-tus-assembly/main.go index 0242a12..0012a35 100644 --- a/examples/api2-devdock-tus-assembly/main.go +++ b/examples/api2-devdock-tus-assembly/main.go @@ -94,40 +94,40 @@ func intValue(value interface{}, label string) (int, error) { } } -func featurePreparation( +func sdkFeatureCall( scenario map[string]interface{}, featureID string, ) (map[string]interface{}, string, error) { - preparations, err := arrayValue(scenario["preparations"], "preparations") + featureCalls, err := arrayValue(scenario["sdkFeatureCalls"], "sdkFeatureCalls") if err != nil { return nil, "", err } - for index, rawPreparation := range preparations { - label := fmt.Sprintf("preparations[%d]", index) - preparation, err := objectValue(rawPreparation, label) + for index, rawFeatureCall := range featureCalls { + label := fmt.Sprintf("sdkFeatureCalls[%d]", index) + featureCall, err := objectValue(rawFeatureCall, label) if err != nil { return nil, "", err } - currentFeatureID, err := stringValue(preparation["featureId"], label+".featureId") + currentFeatureID, err := stringValue(featureCall["featureId"], label+".featureId") if err != nil { return nil, "", err } if currentFeatureID != featureID { continue } - kind, err := stringValue(preparation["kind"], label+".kind") + kind, err := stringValue(featureCall["kind"], label+".kind") if err != nil { return nil, "", err } - if kind != "feature-call" { - return nil, "", fmt.Errorf("%s must be a feature-call preparation", label) + if kind != "sdk-feature-call" { + return nil, "", fmt.Errorf("%s must be an sdk-feature-call", label) } - return preparation, label, nil + return featureCall, label, nil } - return nil, "", fmt.Errorf("scenario has no preparation for feature %q", featureID) + return nil, "", fmt.Errorf("scenario has no SDK feature call for feature %q", featureID) } func asJsonObject(value interface{}, label string) (map[string]interface{}, error) { @@ -144,79 +144,72 @@ func asJsonObject(value interface{}, label string) (map[string]interface{}, erro return result, nil } -func scenarioFileCount(scenario map[string]interface{}) (int, error) { - createConfig, createConfigLabel, err := featurePreparation(scenario, "createTusAssembly") +func uploadTusAssemblyInput(scenario map[string]interface{}) (map[string]interface{}, error) { + featureCall, featureCallLabel, err := sdkFeatureCall(scenario, "uploadTusAssembly") if err != nil { - return 0, err + return nil, err } - input, err := objectValue(createConfig["input"], createConfigLabel+".input") + input, err := objectValue(featureCall["input"], featureCallLabel+".input") if err != nil { - return 0, err + return nil, err } - return intValue(input["file_count"], createConfigLabel+".input.file_count") + return input, nil } -func scenarioBytes(scenario map[string]interface{}) ([]byte, error) { - upload, err := objectValue(scenario["upload"], "upload") - if err != nil { - return nil, err - } - source, err := objectValue(upload["source"], "upload.source") - if err != nil { - return nil, err - } - kind, err := stringValue(source["kind"], "upload.source.kind") - if err != nil { - return nil, err - } - if kind != "bytes" { - return nil, fmt.Errorf("unsupported scenario source kind %q", kind) - } - encoding, err := stringValue(source["encoding"], "upload.source.encoding") - if err != nil { - return nil, err - } - if encoding != "utf8" { - return nil, fmt.Errorf("unsupported scenario source encoding %q", encoding) - } - value, err := stringValue(source["value"], "upload.source.value") +func scenarioFileCount(input map[string]interface{}) (int, error) { + return intValue(input["file_count"], "sdkFeatureCalls.uploadTusAssembly.input.file_count") +} + +func scenarioBytes(uploadConfig map[string]interface{}) ([]byte, error) { + content, err := stringValue( + uploadConfig["content"], + "sdkFeatureCalls.uploadTusAssembly.input.upload.content", + ) if err != nil { return nil, err } - return []byte(value), nil + return []byte(content), nil } -func uploadInfo(scenario map[string]interface{}) (string, string, map[string]string, error) { - uploadConfig, err := objectValue(scenario["upload"], "upload") - if err != nil { - return "", "", nil, err - } - chunkSize, err := stringValue(uploadConfig["chunkSize"], "upload.chunkSize") +func uploadInfo(input map[string]interface{}) (string, string, map[string]string, error) { + uploadConfig, err := objectValue( + input["upload"], + "sdkFeatureCalls.uploadTusAssembly.input.upload", + ) if err != nil { return "", "", nil, err } - if chunkSize != "full-file" { - return "", "", nil, fmt.Errorf("unsupported chunk size policy %q", chunkSize) - } - fieldName, err := stringValue(uploadConfig["fieldName"], "upload.fieldName") + fieldName, err := stringValue( + uploadConfig["fieldname"], + "sdkFeatureCalls.uploadTusAssembly.input.upload.fieldname", + ) if err != nil { return "", "", nil, err } - fileName, err := stringValue(uploadConfig["fileName"], "upload.fileName") + fileName, err := stringValue( + uploadConfig["filename"], + "sdkFeatureCalls.uploadTusAssembly.input.upload.filename", + ) if err != nil { return "", "", nil, err } userMeta := map[string]string{} - if rawUserMeta, ok := uploadConfig["userMeta"]; ok { - userMetaObject, err := objectValue(rawUserMeta, "upload.userMeta") + if rawUserMeta, ok := uploadConfig["user_meta"]; ok { + userMetaObject, err := objectValue( + rawUserMeta, + "sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta", + ) if err != nil { return "", "", nil, err } for name, value := range userMetaObject { - userMeta[name], err = stringValue(value, "upload.userMeta."+name) + userMeta[name], err = stringValue( + value, + "sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta."+name, + ) if err != nil { return "", "", nil, err } @@ -259,6 +252,10 @@ func main() { if err != nil { fail("load scenario: %v", err) } + input, err := uploadTusAssemblyInput(scenario) + if err != nil { + fail("read SDK feature call input: %v", err) + } client := transloadit.NewClient(transloadit.Config{ AuthKey: requiredEnv("TRANSLOADIT_KEY"), @@ -266,17 +263,24 @@ func main() { Endpoint: requiredEnv("TRANSLOADIT_ENDPOINT"), }) - fileCount, err := scenarioFileCount(scenario) + fileCount, err := scenarioFileCount(input) if err != nil { fail("read file count: %v", err) } - content, err := scenarioBytes(scenario) + fieldName, fileName, userMeta, err := uploadInfo(input) if err != nil { - fail("read upload bytes: %v", err) + fail("read upload info: %v", err) + } + uploadConfig, err := objectValue( + input["upload"], + "sdkFeatureCalls.uploadTusAssembly.input.upload", + ) + if err != nil { + fail("read upload config: %v", err) } - fieldName, fileName, userMeta, err := uploadInfo(scenario) + content, err := scenarioBytes(uploadConfig) if err != nil { - fail("read upload info: %v", err) + fail("read upload bytes: %v", err) } statusInfo, uploadURL, err := client.UploadTusAssembly( From 29501840234110c8a0807d06358282d57a24a596 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 20:33:16 +0200 Subject: [PATCH 19/24] Read SDK example input projection --- examples/api2-devdock-tus-assembly/main.go | 234 ++++----------------- 1 file changed, 38 insertions(+), 196 deletions(-) diff --git a/examples/api2-devdock-tus-assembly/main.go b/examples/api2-devdock-tus-assembly/main.go index 0012a35..c3996d7 100644 --- a/examples/api2-devdock-tus-assembly/main.go +++ b/examples/api2-devdock-tus-assembly/main.go @@ -7,12 +7,32 @@ import ( "io/ioutil" "os" "path/filepath" - "strconv" "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 == "" { @@ -26,7 +46,7 @@ func fail(format string, args ...interface{}) { panic(fmt.Sprintf(format, args...)) } -func loadScenario() (map[string]interface{}, error) { +func loadScenario() (tusAssemblyScenario, error) { scenarioPath := os.Getenv("API2_SDK_EXAMPLE_SCENARIO") if scenarioPath == "" { scenarioPath = filepath.Join("examples", "api2-devdock-tus-assembly", "api2-scenario.json") @@ -34,102 +54,17 @@ func loadScenario() (map[string]interface{}, error) { contents, err := ioutil.ReadFile(scenarioPath) if err != nil { - return nil, err + return tusAssemblyScenario{}, err } - var scenario map[string]interface{} + var scenario tusAssemblyScenario if err := json.Unmarshal(contents, &scenario); err != nil { - return nil, err + return tusAssemblyScenario{}, err } return scenario, nil } -func objectValue(value interface{}, label string) (map[string]interface{}, error) { - object, ok := value.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("%s must be an object", label) - } - - return object, nil -} - -func arrayValue(value interface{}, label string) ([]interface{}, error) { - array, ok := value.([]interface{}) - if !ok { - return nil, fmt.Errorf("%s must be an array", label) - } - - return array, nil -} - -func stringValue(value interface{}, label string) (string, error) { - text, ok := value.(string) - if !ok { - return "", fmt.Errorf("%s must be a string", label) - } - - return text, nil -} - -func intValue(value interface{}, label string) (int, error) { - switch number := value.(type) { - case float64: - if float64(int(number)) != number { - return 0, fmt.Errorf("%s must be an integer", label) - } - - return int(number), nil - case int: - return number, nil - case string: - parsed, err := strconv.Atoi(number) - if err != nil { - return 0, fmt.Errorf("%s must be an integer", label) - } - - return parsed, nil - default: - return 0, fmt.Errorf("%s must be an integer", label) - } -} - -func sdkFeatureCall( - scenario map[string]interface{}, - featureID string, -) (map[string]interface{}, string, error) { - featureCalls, err := arrayValue(scenario["sdkFeatureCalls"], "sdkFeatureCalls") - if err != nil { - return nil, "", err - } - - for index, rawFeatureCall := range featureCalls { - label := fmt.Sprintf("sdkFeatureCalls[%d]", index) - featureCall, err := objectValue(rawFeatureCall, label) - if err != nil { - return nil, "", err - } - currentFeatureID, err := stringValue(featureCall["featureId"], label+".featureId") - if err != nil { - return nil, "", err - } - if currentFeatureID != featureID { - continue - } - kind, err := stringValue(featureCall["kind"], label+".kind") - if err != nil { - return nil, "", err - } - if kind != "sdk-feature-call" { - return nil, "", fmt.Errorf("%s must be an sdk-feature-call", label) - } - - return featureCall, label, nil - } - - return nil, "", fmt.Errorf("scenario has no SDK feature call for feature %q", featureID) -} - func asJsonObject(value interface{}, label string) (map[string]interface{}, error) { contents, err := json.Marshal(value) if err != nil { @@ -144,81 +79,6 @@ func asJsonObject(value interface{}, label string) (map[string]interface{}, erro return result, nil } -func uploadTusAssemblyInput(scenario map[string]interface{}) (map[string]interface{}, error) { - featureCall, featureCallLabel, err := sdkFeatureCall(scenario, "uploadTusAssembly") - if err != nil { - return nil, err - } - input, err := objectValue(featureCall["input"], featureCallLabel+".input") - if err != nil { - return nil, err - } - - return input, nil -} - -func scenarioFileCount(input map[string]interface{}) (int, error) { - return intValue(input["file_count"], "sdkFeatureCalls.uploadTusAssembly.input.file_count") -} - -func scenarioBytes(uploadConfig map[string]interface{}) ([]byte, error) { - content, err := stringValue( - uploadConfig["content"], - "sdkFeatureCalls.uploadTusAssembly.input.upload.content", - ) - if err != nil { - return nil, err - } - - return []byte(content), nil -} - -func uploadInfo(input map[string]interface{}) (string, string, map[string]string, error) { - uploadConfig, err := objectValue( - input["upload"], - "sdkFeatureCalls.uploadTusAssembly.input.upload", - ) - if err != nil { - return "", "", nil, err - } - fieldName, err := stringValue( - uploadConfig["fieldname"], - "sdkFeatureCalls.uploadTusAssembly.input.upload.fieldname", - ) - if err != nil { - return "", "", nil, err - } - fileName, err := stringValue( - uploadConfig["filename"], - "sdkFeatureCalls.uploadTusAssembly.input.upload.filename", - ) - if err != nil { - return "", "", nil, err - } - - userMeta := map[string]string{} - if rawUserMeta, ok := uploadConfig["user_meta"]; ok { - userMetaObject, err := objectValue( - rawUserMeta, - "sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta", - ) - if err != nil { - return "", "", nil, err - } - for name, value := range userMetaObject { - userMeta[name], err = stringValue( - value, - "sdkFeatureCalls.uploadTusAssembly.input.upload.user_meta."+name, - ) - if err != nil { - return "", "", nil, err - } - } - } - - return fieldName, fileName, userMeta, nil -} - func writeResult( status map[string]interface{}, uploadURL string, @@ -252,10 +112,7 @@ func main() { if err != nil { fail("load scenario: %v", err) } - input, err := uploadTusAssemblyInput(scenario) - if err != nil { - fail("read SDK feature call input: %v", err) - } + input := scenario.ExampleInput.SdkFeatureInputs.UploadTusAssembly client := transloadit.NewClient(transloadit.Config{ AuthKey: requiredEnv("TRANSLOADIT_KEY"), @@ -263,32 +120,17 @@ func main() { Endpoint: requiredEnv("TRANSLOADIT_ENDPOINT"), }) - fileCount, err := scenarioFileCount(input) - if err != nil { - fail("read file count: %v", err) - } - fieldName, fileName, userMeta, err := uploadInfo(input) - if err != nil { - fail("read upload info: %v", err) - } - uploadConfig, err := objectValue( - input["upload"], - "sdkFeatureCalls.uploadTusAssembly.input.upload", - ) - if err != nil { - fail("read upload config: %v", err) - } - content, err := scenarioBytes(uploadConfig) - if err != nil { - fail("read upload bytes: %v", err) + userMeta := input.Upload.UserMeta + if userMeta == nil { + userMeta = map[string]string{} } statusInfo, uploadURL, err := client.UploadTusAssembly( ctx, - fileCount, - content, - fieldName, - fileName, + input.FileCount, + []byte(input.Upload.Content), + input.Upload.Field, + input.Upload.Filename, userMeta, ) if err != nil { @@ -302,9 +144,9 @@ func main() { fail("write result: %v", err) } - scenarioID, err := stringValue(scenario["scenarioId"], "scenarioId") - if err != nil { - fail("read scenario id: %v", err) - } - fmt.Printf("Go Transloadit SDK devdock scenario %s uploaded to %s\n", scenarioID, uploadURL) + fmt.Printf( + "Go Transloadit SDK devdock scenario %s uploaded to %s\n", + scenario.ExampleInput.ScenarioID, + uploadURL, + ) } From 3311a274eadcd4c08523e9fa136b0370ae9bf30d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 18:29:58 +0200 Subject: [PATCH 20/24] Regenerate required feature value guards --- assembly.go | 4 ++++ wait.go | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/assembly.go b/assembly.go index 8d6bde5..2592c88 100644 --- a/assembly.go +++ b/assembly.go @@ -351,6 +351,10 @@ func (client *Client) UploadTusAssembly(ctx context.Context, fileCount int, cont 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 diff --git a/wait.go b/wait.go index f8c5cb2..b7990a2 100644 --- a/wait.go +++ b/wait.go @@ -11,9 +11,8 @@ import ( // 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 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. +// 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) From c54c1847608da415067c8afbfd45260119c12f4d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 03:24:27 +0200 Subject: [PATCH 21/24] Add assembly lifecycle devdock example --- .../api2-devdock-assembly-lifecycle/main.go | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 examples/api2-devdock-assembly-lifecycle/main.go diff --git a/examples/api2-devdock-assembly-lifecycle/main.go b/examples/api2-devdock-assembly-lifecycle/main.go new file mode 100644 index 0000000..ff84182 --- /dev/null +++ b/examples/api2-devdock-assembly-lifecycle/main.go @@ -0,0 +1,148 @@ +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) + } + + assemblies, err := client.ListAssemblies(ctx, &transloadit.ListOptions{ + AssemblyID: created.AssemblyID, + PageSize: scenario.List.PageSize, + }) + if err != nil { + fail("list assemblies: %v", err) + } + + listContainsCreated := false + for _, assembly := range assemblies.Assemblies { + if assembly.AssemblyID == created.AssemblyID { + listContainsCreated = true + } + } + + 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, + ) +} From 13f281f77d79730280f4d6a4d3dc0604bed62a27 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 10 Jun 2026 08:47:53 +0200 Subject: [PATCH 22/24] Use SSL Assembly URL for TUS metadata Co-Authored-By: Claude Fable 5 --- assembly.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assembly.go b/assembly.go index 2592c88..8f25454 100644 --- a/assembly.go +++ b/assembly.go @@ -293,7 +293,7 @@ func (client *Client) UploadTusAssembly(ctx context.Context, fileCount int, cont for name, value := range userMeta { metadataMap[name] = value } - metadataMap["assembly_url"] = createdAssembly.AssemblyURL + metadataMap["assembly_url"] = createdAssembly.AssemblySSLURL metadataMap["fieldname"] = fieldname metadataMap["filename"] = filename From 5f51cb1ec8862e329f5e6ecc23ca0c424a14d5ff Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 11 Jun 2026 09:02:34 +0200 Subject: [PATCH 23/24] Prove TUS resume upload via generated SDK method ResumeTusUpload() is generated from the API2 resumeUpload TUS protocol contract: it discovers the server offset with a HEAD request, PATCHes the remaining bytes from that offset, asserts the final offset matches the content length, and waits for the Assembly to finish. The new api2-devdock-tus-resume-upload example interrupts an upload after the first chunk like a dropped connection would, then resumes it through the public SDK method. Co-Authored-By: Claude Fable 5 --- assembly.go | 70 ++++ .../api2-devdock-tus-resume-upload/main.go | 329 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 examples/api2-devdock-tus-resume-upload/main.go diff --git a/assembly.go b/assembly.go index 8f25454..a037b8d 100644 --- a/assembly.go +++ b/assembly.go @@ -271,6 +271,76 @@ func (client *Client) CreateTusAssembly(ctx context.Context, fileCount int) (*As // +// + +// 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, 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, + ) +} From 81dede24aea43814f983eff555712174ed061f0a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 11 Jun 2026 09:21:30 +0200 Subject: [PATCH 24/24] Poll Assembly list until the created Assembly lands The API acknowledges Assembly creation before the list storage row is inserted (the SQL save is fire-and-forget at identification time), so an immediate list can miss the just-created Assembly. Poll briefly so the lifecycle proof asserts list contents deterministically instead of racing the insert. Co-Authored-By: Claude Fable 5 --- .../api2-devdock-assembly-lifecycle/main.go | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/examples/api2-devdock-assembly-lifecycle/main.go b/examples/api2-devdock-assembly-lifecycle/main.go index ff84182..4804ee5 100644 --- a/examples/api2-devdock-assembly-lifecycle/main.go +++ b/examples/api2-devdock-assembly-lifecycle/main.go @@ -109,19 +109,28 @@ func main() { fail("get assembly: %v", err) } - assemblies, err := client.ListAssemblies(ctx, &transloadit.ListOptions{ - AssemblyID: created.AssemblyID, - PageSize: scenario.List.PageSize, - }) - if err != nil { - fail("list assemblies: %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 _, assembly := range assemblies.Assemblies { - if assembly.AssemblyID == created.AssemblyID { - listContainsCreated = true + 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)