Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a9736d0
Mark generated endpoint methods
kvz May 25, 2026
ea94d95
Expand generated endpoint markers
kvz May 25, 2026
8577527
Mark WaitForAssembly as generated
kvz May 26, 2026
d4fcdcd
Mark generated blocks as contract-owned
kvz May 26, 2026
326d056
Add generated TUS assembly helper
kvz Jun 1, 2026
6a54d51
Expose TUS fields on assembly info
kvz Jun 1, 2026
d4128bf
Guard API2 model fields
kvz Jun 1, 2026
fe3e2d4
Add devdock template lifecycle example
kvz Jun 1, 2026
12d9bae
Keep devdock example Go 1.15 compatible
kvz Jun 1, 2026
f257b3c
Let API2 assert template lifecycle example
kvz Jun 1, 2026
26d9e11
Support Go 1.15 in devdock example
kvz Jun 1, 2026
ea262bd
Add devdock TUS assembly example
kvz Jun 2, 2026
bd652e8
Keep devdock example Go 1.15 compatible
kvz Jun 2, 2026
16fe26e
Read TUS scenario preparations generically
kvz Jun 2, 2026
c1db0c5
Use generated TUS assembly upload helper
kvz Jun 2, 2026
d0a5c77
Use protocol-plan generated upload helper
kvz Jun 2, 2026
5a4c648
Use header-derived TUS offset variable
kvz Jun 2, 2026
bd10c47
Read TUS example input from SDK feature call
kvz Jun 3, 2026
2950184
Read SDK example input projection
kvz Jun 3, 2026
3311a27
Regenerate required feature value guards
kvz Jun 5, 2026
c54c184
Add assembly lifecycle devdock example
kvz Jun 10, 2026
13f281f
Use SSL Assembly URL for TUS metadata
kvz Jun 10, 2026
5f51cb1
Prove TUS resume upload via generated SDK method
kvz Jun 11, 2026
81dede2
Poll Assembly list until the created Assembly lands
kvz Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions api2_generated_models_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
226 changes: 226 additions & 0 deletions assembly.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package transloadit

import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -86,6 +90,7 @@ type AssemblyInfo struct {
ParentID string `json:"parent_id"`
AssemblyURL string `json:"assembly_url"`
AssemblySSLURL string `json:"assembly_ssl_url"`
TUSURL string `json:"tus_url"`
BytesReceived int `json:"bytes_received"`
BytesExpected Integer `json:"bytes_expected"`
StartDate string `json:"start_date"`
Expand Down Expand Up @@ -135,9 +140,12 @@ type FileInfo struct {
OriginalMd5Hash string `json:"original_md5hash"`
OriginalID string `json:"original_id"`
OriginalBasename string `json:"original_basename"`
IsTUSFile bool `json:"is_tus_file"`
TUSUploadURL string `json:"tus_upload_url"`
URL string `json:"url"`
SSLURL string `json:"ssl_url"`
Meta map[string]interface{} `json:"meta"`
UserMeta map[string]interface{} `json:"user_meta"`
Cost int `json:"cost"`
}

Expand Down Expand Up @@ -233,6 +241,200 @@ func (client *Client) StartAssembly(ctx context.Context, assembly Assembly) (*As
return &info, err
}

// <api2-generated-feature createTusAssembly>

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

// </api2-generated-feature createTusAssembly>

// <api2-generated-feature resumeTusUpload>

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

// </api2-generated-feature resumeTusUpload>

// <api2-generated-feature uploadTusAssembly>

// This block is generated from Transloadit API2 contracts. If it looks wrong,
// please report the issue instead of editing this block by hand; the source fix
// belongs in the contract generator so all SDKs stay in sync.

// UploadTusAssembly creates a TUS-ready Assembly, uploads one file with the TUS protocol, and waits for the Assembly to finish.
func (client *Client) UploadTusAssembly(ctx context.Context, fileCount int, content []byte, fieldname string, filename string, userMeta map[string]string) (*AssemblyInfo, string, error) {
createdAssembly, err := client.CreateTusAssembly(ctx, fileCount)
if err != nil {
return nil, "", err
}

endpointURL, err := url.Parse(createdAssembly.TUSURL)
if err != nil {
return nil, "", err
}

metadataMap := make(map[string]string)
for name, value := range userMeta {
metadataMap[name] = value
}
metadataMap["assembly_url"] = createdAssembly.AssemblySSLURL
metadataMap["fieldname"] = fieldname
metadataMap["filename"] = filename

createRequest, err := http.NewRequestWithContext(ctx, "POST", endpointURL.String(), nil)
if err != nil {
return nil, "", err
}
createRequest.Header.Set("Tus-Resumable", "1.0.0")
createRequest.Header.Set("Upload-Length", strconv.Itoa(len(content)))
metadataParts := make([]string, 0, len(metadataMap))
for name, value := range metadataMap {
metadataParts = append(metadataParts, fmt.Sprintf("%s %s", name, base64.StdEncoding.EncodeToString([]byte(value))))
}
createRequest.Header.Set("Upload-Metadata", strings.Join(metadataParts, ","))

createResponse, err := client.httpClient.Do(createRequest)
if err != nil {
return nil, "", err
}
defer createResponse.Body.Close()
if createResponse.StatusCode != 201 {
return nil, "", fmt.Errorf("TUS create returned HTTP %d, expected 201", createResponse.StatusCode)
}
uploadURLLocation := createResponse.Header.Get("Location")
if uploadURLLocation == "" {
return nil, "", fmt.Errorf("TUS create did not return a Location header")
}
uploadURL, err := endpointURL.Parse(uploadURLLocation)
if err != nil {
return nil, "", err
}
uploadURLText := uploadURL.String()

uploadRequest, err := http.NewRequestWithContext(ctx, "PATCH", uploadURLText, bytes.NewReader(content))
if err != nil {
return nil, "", err
}
uploadRequest.Header.Set("Tus-Resumable", "1.0.0")
uploadRequest.Header.Set("Upload-Offset", "0")
uploadRequest.Header.Set("Content-Type", "application/offset+octet-stream")

uploadResponse, err := client.httpClient.Do(uploadRequest)
if err != nil {
return nil, "", err
}
defer uploadResponse.Body.Close()
if uploadResponse.StatusCode != 204 {
return nil, "", fmt.Errorf("TUS upload returned HTTP %d, expected 204", uploadResponse.StatusCode)
}
uploadOffset, err := strconv.Atoi(uploadResponse.Header.Get("Upload-Offset"))
if err != nil {
return nil, "", err
}
if uploadOffset != len(content) {
return nil, "", fmt.Errorf("TUS upload offset %d, expected %d", uploadOffset, len(content))
}

createdAssemblyAssemblySSLURL := createdAssembly.AssemblySSLURL
if createdAssemblyAssemblySSLURL == "" {
return nil, "", fmt.Errorf("uploadTusAssembly needs createdAssembly.assembly_ssl_url")
}
completedAssembly, err := client.WaitForAssembly(ctx, createdAssembly)
if err != nil {
return nil, "", err
}

return completedAssembly, uploadURLText, nil
}

// </api2-generated-feature uploadTusAssembly>

func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*http.Request, error) {
// TODO: test with huge files
url := client.config.Endpoint + "/assemblies"
Expand Down Expand Up @@ -306,6 +508,12 @@ func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*htt
return req, nil
}

// <api2-generated-endpoint getAssemblyStatus>

// 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
Expand All @@ -316,6 +524,14 @@ func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*Ass
return &info, err
}

// </api2-generated-endpoint getAssemblyStatus>

// <api2-generated-endpoint cancelAssembly>

// 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.
Expand All @@ -328,6 +544,8 @@ func (client *Client) CancelAssembly(ctx context.Context, assemblyURL string) (*
return &info, err
}

// </api2-generated-endpoint cancelAssembly>

// 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:
Expand Down Expand Up @@ -375,10 +593,18 @@ func (client *Client) StartAssemblyReplay(ctx context.Context, assembly Assembly
return &info, nil
}

// <api2-generated-endpoint listAssemblies>

// 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
err := client.listRequest(ctx, "assemblies", options, &assemblies)

return assemblies, err
}

// </api2-generated-endpoint listAssemblies>
47 changes: 47 additions & 0 deletions assembly_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading