├── bin └── .empty ├── go.mod ├── fixtures ├── lol_cat.jpg ├── mona_lisa.jpg ├── sample_iPod.m4v └── sample_mpeg4.mp4 ├── version.go ├── .vscode └── go-sdk.code-workspace ├── .gitignore ├── Makefile ├── examples ├── smart-cdn-signature │ └── main.go ├── template │ └── main.go └── image-resize │ └── main.go ├── wait.go ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── notification_test.go ├── wait_test.go ├── notification.go ├── README.md ├── scripts ├── semver.sh └── bump.sh ├── template_credentials_test.go ├── template_credentials.go ├── transloadit_test.go ├── template_test.go ├── template.go ├── assembly_test.go ├── transloadit.go └── assembly.go /bin/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/transloadit/go-sdk 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /fixtures/lol_cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transloadit/go-sdk/HEAD/fixtures/lol_cat.jpg -------------------------------------------------------------------------------- /fixtures/mona_lisa.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transloadit/go-sdk/HEAD/fixtures/mona_lisa.jpg -------------------------------------------------------------------------------- /fixtures/sample_iPod.m4v: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transloadit/go-sdk/HEAD/fixtures/sample_iPod.m4v -------------------------------------------------------------------------------- /fixtures/sample_mpeg4.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transloadit/go-sdk/HEAD/fixtures/sample_mpeg4.mp4 -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | // Version specifies the version of the Go SDK. 4 | var Version = "v1.6.0" 5 | -------------------------------------------------------------------------------- /.vscode/go-sdk.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".." 5 | } 6 | ], 7 | "settings": {} 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | 25 | bin/ 26 | 27 | # Test files 28 | fixtures/output/* 29 | 30 | debug.sh 31 | 32 | builds/ 33 | 34 | demo-session.sh 35 | 36 | .vagrant/ 37 | env.sh 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /usr/bin/env bash 2 | 3 | test-examples: 4 | cd ./examples && find . -type f | xargs -i sh -c "go build {} && go clean" \; 5 | 6 | test-package: 7 | go test -v -coverprofile=coverage.out -covermode=atomic . 8 | 9 | test: test-package test-examples 10 | 11 | release: 12 | #$(MAKE) build 13 | $(MAKE) test 14 | git diff --quiet HEAD || (echo "--> Please first commit your work" && false) 15 | ./scripts/bump.sh ./version.go $(bump) 16 | git commit ./version.go -m "Release $$(./scripts/bump.sh ./version.go)" 17 | git tag $$(./scripts/bump.sh ./version.go) 18 | git push --tags || true 19 | 20 | .PHONY: \ 21 | release \ 22 | test \ 23 | test-package \ 24 | test-examples 25 | -------------------------------------------------------------------------------- /examples/smart-cdn-signature/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | transloadit "github.com/transloadit/go-sdk" 8 | ) 9 | 10 | func main() { 11 | url := GetSmartCDNUrl() 12 | fmt.Println(url) 13 | } 14 | 15 | func GetSmartCDNUrl() string { 16 | options := transloadit.DefaultConfig 17 | options.AuthKey = "YOUR_TRANSLOADIT_KEY" 18 | options.AuthSecret = "YOUR_TRANSLOADIT_SECRET" 19 | client := transloadit.NewClient(options) 20 | 21 | params := url.Values{} 22 | params.Add("height", "100") 23 | params.Add("width", "100") 24 | 25 | url := client.CreateSignedSmartCDNUrl(transloadit.SignedSmartCDNUrlOptions{ 26 | Workspace: "YOUR_WORKSPACE", 27 | Template: "YOUR_TEMPLATE", 28 | Input: "image.png", 29 | URLParams: params, 30 | }) 31 | 32 | return url 33 | } 34 | -------------------------------------------------------------------------------- /wait.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // WaitForAssembly fetches continuously the assembly status until it has 9 | // finished uploading and executing or until an assembly error occurs. 10 | // If you want to end this loop prematurely, you can cancel the supplied context. 11 | func (client *Client) WaitForAssembly(ctx context.Context, assembly *AssemblyInfo) (*AssemblyInfo, error) { 12 | for { 13 | res, err := client.GetAssembly(ctx, assembly.AssemblySSLURL) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | // Abort polling if the assembly has entered an error state 19 | if res.Error != "" { 20 | return res, nil 21 | } 22 | 23 | // The polling is done if the assembly is not uploading or executing anymore. 24 | if res.Ok != "ASSEMBLY_UPLOADING" && res.Ok != "ASSEMBLY_EXECUTING" { 25 | return res, nil 26 | } 27 | 28 | select { 29 | case <-ctx.Done(): 30 | return nil, ctx.Err() 31 | case <-time.After(time.Second): 32 | continue 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Test on Go ${{ matrix.go_version }} and ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | go_version: ['1.11', '1.12', '1.13', '1.14', '1.15', '1.16', '1.17', '1.18', '1.19', '1.20', '1.21', '1.22', '1.23', '1.24'] 17 | os: [ubuntu-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Set up Go ${{ matrix.go_version }} 23 | uses: actions/setup-go@v2 24 | with: 25 | go-version: ${{ matrix.go_version }} 26 | 27 | - name: Test 28 | run: make test 29 | env: 30 | TRANSLOADIT_KEY: ${{ secrets.TRANSLOADIT_KEY }} 31 | TRANSLOADIT_SECRET: ${{ secrets.TRANSLOADIT_SECRET }} 32 | 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v5 35 | with: 36 | fail_ci_if_error: true 37 | files: ./coverage.out 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Transloadit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /examples/template/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/transloadit/go-sdk" 8 | ) 9 | 10 | func main() { 11 | 12 | // Create client 13 | options := transloadit.DefaultConfig 14 | options.AuthKey = "TRANSLOADIT_KEY" 15 | options.AuthSecret = "TRANSLOADIT_SECRET" 16 | client := transloadit.NewClient(options) 17 | 18 | // Initialize new assembly 19 | assembly := transloadit.NewAssembly() 20 | 21 | // Add a file to upload 22 | assembly.AddFile("image", "../../fixtures/lol_cat.jpg") 23 | 24 | // Instructions will be read from the template 25 | // with specified id stored on Transloadit's servers. 26 | assembly.TemplateID = "TRANSLOADIT_TEMPLATE_ID" 27 | 28 | // Start the upload 29 | info, err := client.StartAssembly(context.Background(), assembly) 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | // All files have now been uploaded and the assembly has started but no 35 | // results are available yet since the conversion has not finished. 36 | // WaitForAssembly provides functionality for polling until the assembly 37 | // has ended. 38 | info, err = client.WaitForAssembly(context.Background(), info) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | fmt.Printf("You can view the result at: %s\n", info.Results["resize"][0].SSLURL) 44 | } 45 | -------------------------------------------------------------------------------- /notification_test.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestListNotifications(t *testing.T) { 9 | t.Parallel() 10 | 11 | client := setup(t) 12 | _, err := client.ListNotifications(ctx, &ListOptions{ 13 | PageSize: 3, 14 | }) 15 | 16 | if err == nil { 17 | t.Fatal("expected an error but got nil") 18 | } 19 | 20 | if !strings.Contains(err.Error(), "no longer available") { 21 | t.Fatalf("unexpected error message: %v", err) 22 | } 23 | } 24 | 25 | func TestReplayNotification(t *testing.T) { 26 | t.Parallel() 27 | 28 | client := setup(t) 29 | 30 | // Create a Assembly to later replay its notifications 31 | assembly := NewAssembly() 32 | assembly.AddFile("image", "./fixtures/lol_cat.jpg") 33 | assembly.AddStep("resize", map[string]interface{}{ 34 | "robot": "/image/resize", 35 | "width": 75, 36 | "height": 75, 37 | }) 38 | assembly.NotifyURL = "https://transloadit.com/notify-url/" 39 | 40 | info, err := client.StartAssembly(ctx, assembly) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | info, err = client.WaitForAssembly(ctx, info) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | // Test replay notification with custom notify URL 51 | if err = client.ReplayNotification(ctx, info.AssemblyID, "https://transloadit.com/custom-notify"); err != nil { 52 | t.Fatal(err) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/image-resize/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/transloadit/go-sdk" 8 | ) 9 | 10 | func main() { 11 | // Create client 12 | options := transloadit.DefaultConfig 13 | options.AuthKey = "TRANSLOADIT_KEY" 14 | options.AuthSecret = "TRANSLOADIT_SECRET" 15 | client := transloadit.NewClient(options) 16 | 17 | // Initialize new assembly 18 | assembly := transloadit.NewAssembly() 19 | 20 | // Add a file to upload 21 | assembly.AddFile("image", "../../fixtures/lol_cat.jpg") 22 | 23 | // Add instructions, e.g. resize image to 75x75px 24 | assembly.AddStep("resize", map[string]interface{}{ 25 | "robot": "/image/resize", 26 | "width": 75, 27 | "height": 75, 28 | "resize_strategy": "pad", 29 | "background": "#000000", 30 | }) 31 | 32 | // Start the upload 33 | info, err := client.StartAssembly(context.Background(), assembly) 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | // All files have now been uploaded and the assembly has started but no 39 | // results are available yet since the conversion has not finished. 40 | // WaitForAssembly provides functionality for polling until the assembly 41 | // has ended. 42 | info, err = client.WaitForAssembly(context.Background(), info) 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | fmt.Printf("You can view the result at: %s\n", info.Results["resize"][0].SSLURL) 48 | } 49 | -------------------------------------------------------------------------------- /wait_test.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestWaitForAssembly(t *testing.T) { 11 | t.Parallel() 12 | 13 | client := setup(t) 14 | 15 | assembly := NewAssembly() 16 | 17 | assembly.AddStep("convert", map[string]interface{}{ 18 | "robot": "/html/convert", 19 | "url": "https://transloadit.com/", 20 | }) 21 | 22 | info, err := client.StartAssembly(ctx, assembly) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | if info.AssemblyURL == "" { 28 | t.Fatal("response doesn't contain assembly_url") 29 | } 30 | 31 | finishedInfo, err := client.WaitForAssembly(ctx, info) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | // Assembly completed 37 | if finishedInfo.AssemblyID != info.AssemblyID { 38 | t.Fatal("unmatching assembly ids") 39 | } 40 | } 41 | 42 | func TestWaitForAssembly_Cancel(t *testing.T) { 43 | t.Parallel() 44 | client := setup(t) 45 | 46 | ctx, cancel := context.WithTimeout(ctx, 100*time.Nanosecond) 47 | defer cancel() 48 | 49 | _, err := client.WaitForAssembly(ctx, &AssemblyInfo{ 50 | AssemblySSLURL: "https://api2.transloadit.com/assemblies/foo", 51 | }) 52 | 53 | // Go 1.8 and Go 1.7 have different error messages if a request get canceled. 54 | // Therefore we test for both cases. 55 | // Sometimes, a "dial tcp: i/o timeout" error is thrown if the context times 56 | // out shortly before the dialing is started, see: 57 | // https://sourcegraph.com/github.com/golang/go@d6a27e8edcd992b36446c5021a3c7560d983e9a6/-/blob/src/net/dial.go#L123-125 58 | // Therefore we also accept i/o timeouts as errors here. 59 | if !strings.Contains(err.Error(), "context deadline exceeded") && !strings.Contains(err.Error(), "request canceled") && !strings.Contains(err.Error(), "i/o timeout") { 60 | t.Fatalf("operation's deadline should be exceeded: %s", err) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /notification.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | // NotificationList contains a list of notifications. 10 | type NotificationList struct { 11 | Notifications []Notification `json:"items"` 12 | Count int `json:"count"` 13 | } 14 | 15 | // Notification contains details about a notification. 16 | type Notification struct { 17 | ID string `json:"id"` 18 | AssemblyID string `json:"assembly_id"` 19 | AccountID string `json:"account_id"` 20 | URL string `json:"url"` 21 | ResponseCode int `json:"response_code"` 22 | ResponseData string `json:"response_data"` 23 | Duration float32 `json:"duration"` 24 | Created time.Time `json:"created"` 25 | Error string `json:"error"` 26 | } 27 | 28 | // ListNotifications will return a list containing all notifications matching 29 | // the criteria defined using the ListOptions structure. 30 | // 31 | // Deprecated: As of December 2021, the List Notifications API endpoint from 32 | // Transloadit has been removed. This function will now always return an error. 33 | func (client *Client) ListNotifications(ctx context.Context, options *ListOptions) (list NotificationList, err error) { 34 | return list, errors.New("transloadit: listing assembly notifications is no longer available") 35 | } 36 | 37 | // ReplayNotification instructs the endpoint to replay the notification 38 | // corresponding to the provided assembly ID. 39 | // If notifyURL is not empty it will override the notify URL used in the 40 | // assembly instructions. 41 | func (client *Client) ReplayNotification(ctx context.Context, assemblyID string, notifyURL string) error { 42 | params := make(map[string]interface{}) 43 | 44 | if notifyURL != "" { 45 | params["notify_url"] = notifyURL 46 | } 47 | 48 | return client.request(ctx, "POST", "assembly_notifications/"+assemblyID+"/replay", params, nil) 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-sdk 2 | 3 | A **Go** Integration for [Transloadit](https://transloadit.com)'s file uploading and encoding service 4 | 5 | ## Intro 6 | 7 | [Transloadit](https://transloadit.com) is a service that helps you handle file uploads, resize, crop and watermark your images, make GIFs, transcode your videos, extract thumbnails, generate audio waveforms, and so much more. In short, [Transloadit](https://transloadit.com) is the Swiss Army Knife for your files. 8 | 9 | This is a **Go** SDK to make it easy to talk to the [Transloadit](https://transloadit.com) REST API. 10 | 11 | ## Install 12 | 13 | ```bash 14 | go get github.com/transloadit/go-sdk 15 | ``` 16 | 17 | The Go SDK is confirmed to work with Go 1.11 or higher. 18 | 19 | ## Usage 20 | 21 | ```go 22 | package main 23 | 24 | import ( 25 | "context" 26 | "fmt" 27 | 28 | "github.com/transloadit/go-sdk" 29 | ) 30 | 31 | func main() { 32 | // Create client 33 | options := transloadit.DefaultConfig 34 | options.AuthKey = "YOUR_TRANSLOADIT_KEY" 35 | options.AuthSecret = "YOUR_TRANSLOADIT_SECRET" 36 | client := transloadit.NewClient(options) 37 | 38 | // Initialize new assembly 39 | assembly := transloadit.NewAssembly() 40 | 41 | // Add a file to upload 42 | assembly.AddFile("image", "/PATH/TO/FILE.jpg") 43 | 44 | // Add Instructions, e.g. resize image to 75x75px 45 | assembly.AddStep("resize", map[string]interface{}{ 46 | "robot": "/image/resize", 47 | "width": 75, 48 | "height": 75, 49 | "resize_strategy": "pad", 50 | "background": "#000000", 51 | }) 52 | 53 | // Start the upload 54 | info, err := client.StartAssembly(context.Background(), assembly) 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | // All files have now been uploaded and the assembly has started but no 60 | // results are available yet since the conversion has not finished. 61 | // WaitForAssembly provides functionality for polling until the assembly 62 | // has ended. 63 | info, err = client.WaitForAssembly(context.Background(), info) 64 | if err != nil { 65 | panic(err) 66 | } 67 | 68 | fmt.Printf("You can view the result at: %s\n", info.Results["resize"][0].SSLURL) 69 | } 70 | ``` 71 | 72 | ## Example 73 | 74 | For fully working examples on how to use templates, non-blocking processing and more, take a look at [`examples/`](https://github.com/transloadit/go-sdk/tree/main/examples). 75 | 76 | ## Documentation 77 | 78 | See Godoc for full API documentation. 79 | 80 | ## License 81 | 82 | [MIT Licensed](LICENSE) 83 | -------------------------------------------------------------------------------- /scripts/semver.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # From: https://github.com/cloudflare/semver_bash 3 | # https://raw.githubusercontent.com/cloudflare/semver_bash/master/semver.sh 4 | 5 | function semverParseInto() { 6 | local RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z-]*\)' 7 | #MAJOR 8 | eval $2=`echo $1 | sed -e "s#$RE#\1#"` 9 | #MINOR 10 | eval $3=`echo $1 | sed -e "s#$RE#\2#"` 11 | #MINOR 12 | eval $4=`echo $1 | sed -e "s#$RE#\3#"` 13 | #SPECIAL 14 | eval $5=`echo $1 | sed -e "s#$RE#\4#"` 15 | } 16 | 17 | function semverEQ() { 18 | local MAJOR_A=0 19 | local MINOR_A=0 20 | local PATCH_A=0 21 | local SPECIAL_A=0 22 | 23 | local MAJOR_B=0 24 | local MINOR_B=0 25 | local PATCH_B=0 26 | local SPECIAL_B=0 27 | 28 | semverParseInto $1 MAJOR_A MINOR_A PATCH_A SPECIAL_A 29 | semverParseInto $2 MAJOR_B MINOR_B PATCH_B SPECIAL_B 30 | 31 | if [ $MAJOR_A -ne $MAJOR_B ]; then 32 | return 1 33 | fi 34 | 35 | if [ $MINOR_A -ne $MINOR_B ]; then 36 | return 1 37 | fi 38 | 39 | if [ $PATCH_A -ne $PATCH_B ]; then 40 | return 1 41 | fi 42 | 43 | if [[ "_$SPECIAL_A" != "_$SPECIAL_B" ]]; then 44 | return 1 45 | fi 46 | 47 | 48 | return 0 49 | 50 | } 51 | 52 | function semverLT() { 53 | local MAJOR_A=0 54 | local MINOR_A=0 55 | local PATCH_A=0 56 | local SPECIAL_A=0 57 | 58 | local MAJOR_B=0 59 | local MINOR_B=0 60 | local PATCH_B=0 61 | local SPECIAL_B=0 62 | 63 | semverParseInto $1 MAJOR_A MINOR_A PATCH_A SPECIAL_A 64 | semverParseInto $2 MAJOR_B MINOR_B PATCH_B SPECIAL_B 65 | 66 | if [ $MAJOR_A -lt $MAJOR_B ]; then 67 | return 0 68 | fi 69 | 70 | if [[ $MAJOR_A -le $MAJOR_B && $MINOR_A -lt $MINOR_B ]]; then 71 | return 0 72 | fi 73 | 74 | if [[ $MAJOR_A -le $MAJOR_B && $MINOR_A -le $MINOR_B && $PATCH_A -lt $PATCH_B ]]; then 75 | return 0 76 | fi 77 | 78 | if [[ "_$SPECIAL_A" == "_" ]] && [[ "_$SPECIAL_B" == "_" ]] ; then 79 | return 1 80 | fi 81 | if [[ "_$SPECIAL_A" == "_" ]] && [[ "_$SPECIAL_B" != "_" ]] ; then 82 | return 1 83 | fi 84 | if [[ "_$SPECIAL_A" != "_" ]] && [[ "_$SPECIAL_B" == "_" ]] ; then 85 | return 0 86 | fi 87 | 88 | if [[ "_$SPECIAL_A" < "_$SPECIAL_B" ]]; then 89 | return 0 90 | fi 91 | 92 | return 1 93 | 94 | } 95 | 96 | function semverGT() { 97 | semverEQ $1 $2 98 | local EQ=$? 99 | 100 | semverLT $1 $2 101 | local LT=$? 102 | 103 | if [ $EQ -ne 0 ] && [ $LT -ne 0 ]; then 104 | return 0 105 | else 106 | return 1 107 | fi 108 | } 109 | 110 | if [ "___semver.sh" == "___`basename $0`" ]; then 111 | 112 | MAJOR=0 113 | MINOR=0 114 | PATCH=0 115 | SPECIAL="" 116 | 117 | semverParseInto $1 MAJOR MINOR PATCH SPECIAL 118 | echo "$1 -> M: $MAJOR m:$MINOR p:$PATCH s:$SPECIAL" 119 | 120 | semverParseInto $2 MAJOR MINOR PATCH SPECIAL 121 | echo "$2 -> M: $MAJOR m:$MINOR p:$PATCH s:$SPECIAL" 122 | 123 | semverEQ $1 $2 124 | echo "$1 == $2 -> $?." 125 | 126 | semverLT $1 $2 127 | echo "$1 < $2 -> $?." 128 | 129 | semverGT $1 $2 130 | echo "$1 > $2 -> $?." 131 | 132 | fi 133 | -------------------------------------------------------------------------------- /scripts/bump.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) 2014, Transloadit Ltd. 3 | # 4 | # This file: 5 | # 6 | # - Bumps a semantic version as specified in first argument 7 | # - Or: Bumps a semantic version in a file as specified in first argument 8 | # - Returns the version if no levelName is provided in second argument 9 | # - Only supports Go files ending in 'var Version = ...' 10 | # 11 | # Run as: 12 | # 13 | # ./bump.sh 0.0.1 patch 14 | # ./bump.sh ./VERSION patch 15 | # ./bump.sh ./VERSION patch 16 | # ./bump.sh ./VERSION major 1 17 | # ./bump.sh ./version.go patch 2 18 | # 19 | # Returns: 20 | # 21 | # v0.0.1 22 | # 23 | # Requires: 24 | # 25 | # - gsed on OSX (brew install gnu-sed) 26 | # 27 | # Authors: 28 | # 29 | # - Kevin van Zonneveld 30 | 31 | set -o pipefail 32 | set -o errexit 33 | set -o nounset 34 | # set -o xtrace 35 | 36 | # Set magic variables for current FILE & DIR 37 | __dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 38 | __root="$(cd "$(dirname "${__dir}")" && pwd)" 39 | __file="${__dir}/$(basename "${BASH_SOURCE[0]}")" 40 | __base="$(basename ${__file} .sh)" 41 | 42 | gsed="" 43 | [ -n "$(which sed)" ] && gsed="$(which sed)" 44 | [ -n "$(which gsed)" ] && gsed="$(which gsed)" 45 | 46 | 47 | . ${__dir}/semver.sh 48 | 49 | function readFromFile() { 50 | local filepath="${1}" 51 | local extension="${filepath##*.}" 52 | 53 | if [ "${extension}" = "go" ]; then 54 | curVersion="$(awk -F'"' '/^var Version = / {print $2}' "${filepath}" | tail -n1)" || true 55 | else 56 | curVersion="$(echo $(cat "${filepath}"))" || true 57 | fi 58 | 59 | if [ -z "${curVersion}" ]; then 60 | curVersion="v0.0.0" 61 | fi 62 | 63 | echo "${curVersion}" 64 | } 65 | 66 | function writeToFile() { 67 | local filepath="${1}" 68 | local newVersion="${2}" 69 | local extension="${filepath##*.}" 70 | 71 | if [ "${extension}" = "go" ]; then 72 | buf="$(cat "${filepath}" |egrep -v '^var Version = ')" || true 73 | echo -e "${buf}\nvar Version = \"${newVersion}\"" > "${filepath}" 74 | else 75 | echo "${newVersion}" > "${filepath}" 76 | fi 77 | } 78 | 79 | function bump() { 80 | local version="${1}" 81 | local levelName="${2}" 82 | local bump="${3}" 83 | 84 | local major=0 85 | local minor=0 86 | local patch=0 87 | local special="" 88 | 89 | local newVersion="" 90 | 91 | semverParseInto "${version}" major minor patch special 92 | 93 | if [ "${levelName}" = "major" ]; then 94 | let "major = major + ${bump}" 95 | minor=0 96 | patch=0 97 | special="" 98 | fi 99 | if [ "${levelName}" = "minor" ]; then 100 | let "minor = minor + ${bump}" 101 | patch=0 102 | special="" 103 | fi 104 | if [ "${levelName}" = "patch" ]; then 105 | let "patch = patch + ${bump}" 106 | special="" 107 | fi 108 | if [ "${levelName}" = "special" ]; then 109 | special="${bump}" 110 | fi 111 | 112 | newVersion="v${major}.${minor}.${patch}" 113 | if [ -n "${special}" ]; then 114 | newVersion=".${newVersion}" 115 | fi 116 | echo "${newVersion}" 117 | } 118 | 119 | if [ -f "${1}" ]; then 120 | filepath="${1}" 121 | curVersion="$(readFromFile "${filepath}")" 122 | else 123 | curVersion="${1}" 124 | fi 125 | 126 | newVersion=$(bump "${curVersion}" "${2:-}" "${3:-1}") 127 | echo "${newVersion}" 128 | 129 | if [ -n "${filepath}" ]; then 130 | writeToFile "${filepath}" "${newVersion}" 131 | fi 132 | -------------------------------------------------------------------------------- /template_credentials_test.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestTemplateCredentials(t *testing.T) { 9 | t.Parallel() 10 | 11 | client := setup(t) 12 | templateCredentialName := generateTemplateName() 13 | 14 | templateCredentialPost := NewTemplateCredential() 15 | templateCredentialPost.Name = templateCredentialName 16 | templateCredentialPost.Type = "s3" 17 | templateCredentialContent := map[string]interface{}{ 18 | "key": "xyxy", 19 | "secret": "xyxyxyxy", 20 | "bucket": "mybucket.example.com", 21 | "bucket_region": "us-east-1", 22 | } 23 | templateCredentialPost.Content = templateCredentialContent 24 | 25 | // Step 1: Create a brand new templateCredential 26 | id, err := client.CreateTemplateCredential(ctx, templateCredentialPost) 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | if id == "" { 31 | t.Error("no templateCredentialPost id returned") 32 | } 33 | 34 | // Step 2: Retrieve new templateCredential created and assert its properties 35 | var templateCredential TemplateCredential 36 | if templateCredential, err = client.GetTemplateCredential(ctx, id); err != nil { 37 | t.Error(err) 38 | } 39 | checkTemplateCredential(t, templateCredential, templateCredentialName, templateCredentialContent, "s3") 40 | 41 | // Step 3: List all Template credentials and assume that the created templateCredential is present 42 | list, err := client.ListTemplateCredential(ctx, nil) 43 | if err != nil { 44 | t.Error(err) 45 | } 46 | found := false 47 | for _, cred := range list.TemplateCredential { 48 | if cred.ID == id { 49 | checkTemplateCredential(t, cred, templateCredentialName, templateCredentialContent, "s3") 50 | found = true 51 | } 52 | } 53 | if !found { 54 | t.Errorf("Created TemplateCredential not found id=%s", id) 55 | } 56 | // Step 4 : Update the Template credential 57 | newTemplateCredentialPost := NewTemplateCredential() 58 | newtemplateCredentialName := templateCredentialName + "updated" 59 | newTemplateCredentialPost.Name = newtemplateCredentialName 60 | newTemplateCredentialPost.Type = "backblaze" 61 | newtemplateCredentialContent := map[string]interface{}{ 62 | "bucket": "mybucket", 63 | "app_key_id": "mykeyid", 64 | "app_key": "mykey", 65 | } 66 | newTemplateCredentialPost.Content = newtemplateCredentialContent 67 | err = client.UpdateTemplateCredential(ctx, id, newTemplateCredentialPost) 68 | if err != nil { 69 | t.Error(err) 70 | } 71 | 72 | // Step 5 : Check the updated Template credential 73 | var newTemplateCredential TemplateCredential 74 | if newTemplateCredential, err = client.GetTemplateCredential(ctx, id); err != nil { 75 | t.Error(err) 76 | } 77 | checkTemplateCredential(t, newTemplateCredential, newtemplateCredentialName, newtemplateCredentialContent, "backblaze") 78 | 79 | // Step 6: Delete test templateCredential 80 | if err := client.DeleteTemplateCredential(ctx, id); err != nil { 81 | t.Error(err) 82 | } 83 | 84 | // Step 7: Assert templateCredential has been deleted 85 | _, err = client.GetTemplateCredential(ctx, id) 86 | if err.(RequestError).Code != "TEMPLATE_CREDENTIALS_NOT_READ" { 87 | t.Error("templateCredentialPost has not been deleted") 88 | } 89 | } 90 | 91 | func checkTemplateCredential(t *testing.T, cred TemplateCredential, templateCredentialName string, expected map[string]interface{}, expectedType string) { 92 | if cred.Name != templateCredentialName { 93 | t.Error("wrong templateCredentialPost name") 94 | } 95 | if cred.Type != expectedType { 96 | t.Error("wrong templateCredentialPost type") 97 | } 98 | if !reflect.DeepEqual(cred.Content, expected) { 99 | t.Errorf("Different in content expected=%+v . In response : %+v", expected, cred.Content) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /template_credentials.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // TemplateCredential contains details about a single template credential. 8 | type TemplateCredential struct { 9 | ID string `json:"id"` 10 | Name string `json:"name"` 11 | Type string `json:"type"` 12 | Content map[string]interface{} `json:"content"` 13 | Created string `json:"created,omitempty"` 14 | Modified string `json:"modified,omitempty"` 15 | } 16 | 17 | type templateCredentialResponseBody struct { 18 | Credential TemplateCredential `json:"credential"` 19 | OK string `json:"ok"` 20 | Message string `json:"message"` 21 | } 22 | 23 | // TemplateCredentialList contains a list of template credentials. 24 | type TemplateCredentialList struct { 25 | TemplateCredential []TemplateCredential `json:"credentials"` 26 | OK string `json:"ok"` 27 | Message string `json:"message"` 28 | } 29 | 30 | // NewTemplateCredential returns a new TemplateCredential struct with initialized values. This 31 | // template credential will not be saved to Transloadit. To do so, please use the 32 | // Client.CreateTemplateCredential function. 33 | func NewTemplateCredential() TemplateCredential { 34 | return TemplateCredential{ 35 | Content: make(map[string]interface{}), 36 | } 37 | } 38 | 39 | var templateCredentialPrefix = "template_credentials" 40 | 41 | // CreateTemplateCredential will save the provided template credential struct to the server 42 | // and return the ID of the new template credential. 43 | func (client *Client) CreateTemplateCredential(ctx context.Context, templateCredential TemplateCredential) (string, error) { 44 | content := map[string]interface{}{ 45 | "name": templateCredential.Name, 46 | "type": templateCredential.Type, 47 | "content": templateCredential.Content, 48 | } 49 | var response templateCredentialResponseBody 50 | if err := client.request(ctx, "POST", templateCredentialPrefix, content, &response); err != nil { 51 | return "", err 52 | } 53 | return response.Credential.ID, nil 54 | } 55 | 56 | // GetTemplateCredential will retrieve details about the template credential associated with the 57 | // provided template credential ID. 58 | func (client *Client) GetTemplateCredential(ctx context.Context, templateCredentialID string) (TemplateCredential, error) { 59 | var response templateCredentialResponseBody 60 | err := client.request(ctx, "GET", templateCredentialPrefix+"/"+templateCredentialID, nil, &response) 61 | return response.Credential, err 62 | } 63 | 64 | // DeleteTemplateCredential will delete the template credential associated with the provided 65 | // template ID. 66 | func (client *Client) DeleteTemplateCredential(ctx context.Context, templateCredentialID string) error { 67 | return client.request(ctx, "DELETE", templateCredentialPrefix+"/"+templateCredentialID, nil, nil) 68 | } 69 | 70 | // ListTemplateCredential will retrieve all templates credential matching the criteria. 71 | func (client *Client) ListTemplateCredential(ctx context.Context, options *ListOptions) (list TemplateCredentialList, err error) { 72 | err = client.listRequest(ctx, templateCredentialPrefix, options, &list) 73 | return list, err 74 | } 75 | 76 | // UpdateTemplateCredential will update the template credential associated with the provided 77 | // template credential ID to match the new name and new content. 78 | func (client *Client) UpdateTemplateCredential(ctx context.Context, templateCredentialID string, templateCredential TemplateCredential) error { 79 | content := map[string]interface{}{ 80 | "name": templateCredential.Name, 81 | "type": templateCredential.Type, 82 | "content": templateCredential.Content, 83 | } 84 | return client.request(ctx, "PUT", templateCredentialPrefix+"/"+templateCredentialID, content, nil) 85 | } 86 | -------------------------------------------------------------------------------- /transloadit_test.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "net/url" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | var ctx = context.Background() 15 | var templatesSetup bool 16 | var templateIDOptimizeResize string 17 | 18 | func TestNewClient_MissingAuthKey(t *testing.T) { 19 | t.Parallel() 20 | 21 | defer func() { 22 | err := recover().(string) 23 | if !strings.Contains(err, "missing AuthKey") { 24 | t.Fatal("error should contain message") 25 | } 26 | }() 27 | 28 | _ = NewClient(DefaultConfig) 29 | } 30 | 31 | func TestNewClient_MissingAuthSecret(t *testing.T) { 32 | t.Parallel() 33 | 34 | defer func() { 35 | err := recover().(string) 36 | if !strings.Contains(err, "missing AuthSecret") { 37 | t.Fatal("error should contain message") 38 | } 39 | }() 40 | 41 | config := DefaultConfig 42 | config.AuthKey = "fooo" 43 | _ = NewClient(config) 44 | } 45 | 46 | func TestNewClient_Success(t *testing.T) { 47 | t.Parallel() 48 | 49 | config := DefaultConfig 50 | config.AuthKey = "fooo" 51 | config.AuthSecret = "bar" 52 | _ = NewClient(config) 53 | } 54 | 55 | func setup(t *testing.T) Client { 56 | config := DefaultConfig 57 | config.AuthKey = os.Getenv("TRANSLOADIT_KEY") 58 | config.AuthSecret = os.Getenv("TRANSLOADIT_SECRET") 59 | 60 | client := NewClient(config) 61 | 62 | return client 63 | } 64 | 65 | func setupTemplates(t *testing.T) { 66 | if templatesSetup { 67 | return 68 | } 69 | 70 | client := setup(t) 71 | 72 | template := NewTemplate() 73 | template.Name = generateTemplateName() 74 | 75 | template.AddStep("optimize", map[string]interface{}{ 76 | "robot": "/image/optimize", 77 | "use": ":original", 78 | }) 79 | template.AddStep("image/resize", map[string]interface{}{ 80 | "background": "#000000", 81 | "height": 75, 82 | "resize_strategy": "pad", 83 | "robot": "/image/resize", 84 | "width": 75, 85 | "use": "optimize", 86 | "imagemagick_stack": "v3.0.0", 87 | }) 88 | 89 | id, err := client.CreateTemplate(ctx, template) 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | fmt.Printf("Created template '%s' (%s) for testing.\n", template.Name, id) 95 | 96 | templateIDOptimizeResize = id 97 | templatesSetup = true 98 | } 99 | 100 | func tearDownTemplate(t *testing.T) { 101 | if !templatesSetup { 102 | return 103 | } 104 | 105 | client := setup(t) 106 | if err := client.DeleteTemplate(ctx, templateIDOptimizeResize); err != nil { 107 | t.Fatalf("Error to delete template %s: %s", templateIDOptimizeResize, err) 108 | } 109 | 110 | templateIDOptimizeResize = "" 111 | templatesSetup = false 112 | } 113 | 114 | var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano())) 115 | var letters = []rune("abcdefghijklmnopqrstuvwxyz0123456789") 116 | 117 | func generateTemplateName() string { 118 | b := make([]rune, 16) 119 | for i := range b { 120 | b[i] = letters[seededRand.Intn(len(letters))] 121 | } 122 | return "gosdk-" + string(b) 123 | } 124 | 125 | func TestCreateSignedSmartCDNUrl(t *testing.T) { 126 | client := NewClient(Config{ 127 | AuthKey: "foo_key", 128 | AuthSecret: "foo_secret", 129 | }) 130 | 131 | params := url.Values{} 132 | params.Add("foo", "bar") 133 | params.Add("aaa", "42") // This must be sorted before `foo` 134 | params.Add("aaa", "21") 135 | 136 | url := client.CreateSignedSmartCDNUrl(SignedSmartCDNUrlOptions{ 137 | Workspace: "foo_workspace", 138 | Template: "foo_template", 139 | Input: "foo/input", 140 | URLParams: params, 141 | ExpiresAt: time.Date(2024, 5, 1, 1, 0, 0, 0, time.UTC), 142 | }) 143 | 144 | expected := "https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519" 145 | 146 | if url != expected { 147 | t.Errorf("Expected URL:\n%s\nGot:\n%s", expected, url) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /template_test.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestTemplate(t *testing.T) { 10 | t.Parallel() 11 | 12 | client := setup(t) 13 | templateName := generateTemplateName() 14 | 15 | template := NewTemplate() 16 | template.Name = templateName 17 | template.RequireSignatureAuth = true 18 | template.AddStep("resize", map[string]interface{}{ 19 | "robot": "/image/resize", 20 | "width": 75, 21 | "height": 75, 22 | "resize_strategy": "pad", 23 | "background": "#000000", 24 | "imagemagick_stack": "v3.0.0", 25 | }) 26 | template.AddStep("optimize", map[string]interface{}{ 27 | "robot": "/image/optimize", 28 | }) 29 | template.Content.AdditionalProperties["notify_url"] = "https://example.com" 30 | 31 | // Step 1: Create a brand new template 32 | id, err := client.CreateTemplate(ctx, template) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | if id == "" { 37 | t.Error("no template id returned") 38 | } 39 | 40 | // Step 2: Retrieve new template and assert it's properties 41 | if template, err = client.GetTemplate(ctx, id); err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | if template.Name != templateName { 46 | t.Error("wrong template name") 47 | } 48 | if !template.RequireSignatureAuth { 49 | t.Error("require_signature_auth is not enabled") 50 | } 51 | if _, found := template.Content.Steps["resize"]; !found { 52 | t.Error("resize step missing") 53 | } 54 | if _, found := template.Content.Steps["optimize"]; !found { 55 | t.Error("optimize step missing") 56 | } 57 | if template.Content.AdditionalProperties["notify_url"] != "https://example.com" { 58 | t.Error("missing notify_url") 59 | } 60 | 61 | newTemplateName := generateTemplateName() 62 | template = NewTemplate() 63 | template.Name = newTemplateName 64 | template.AddStep("bar", map[string]interface{}{}) 65 | template.AddStep("baz", map[string]interface{}{}) 66 | template.Content.AdditionalProperties["allow_steps_override"] = true 67 | template.RequireSignatureAuth = false 68 | 69 | // Step 3: Update previously created template 70 | if err := client.UpdateTemplate(ctx, id, template); err != nil { 71 | t.Fatal(err) 72 | } 73 | 74 | // Step 4: Retrieve template again and assert edited properties 75 | if template, err = client.GetTemplate(ctx, id); err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | if template.Name != newTemplateName { 80 | t.Error("wrong template name") 81 | } 82 | if _, found := template.Content.Steps["resize"]; found { 83 | t.Error("resize step not removed") 84 | } 85 | if _, found := template.Content.Steps["bar"]; !found { 86 | t.Error("bar step missing") 87 | } 88 | if _, found := template.Content.Steps["baz"]; !found { 89 | t.Error("baz step missing") 90 | } 91 | if template.RequireSignatureAuth { 92 | t.Error("require_signature_auth was not disabled after an update") 93 | } 94 | if template.Content.AdditionalProperties["allow_steps_override"] != true { 95 | t.Error("missing allow_steps_override") 96 | } 97 | 98 | // Step 5: Delete template 99 | if err := client.DeleteTemplate(ctx, id); err != nil { 100 | t.Fatal(err) 101 | } 102 | 103 | // Step 6: Assert template has been deleted 104 | _, err = client.GetTemplate(ctx, id) 105 | if err.(RequestError).Code != "TEMPLATE_NOT_FOUND" { 106 | t.Error("template has not been deleted") 107 | } 108 | } 109 | 110 | func TestListTemplates(t *testing.T) { 111 | t.Parallel() 112 | 113 | client := setup(t) 114 | 115 | templates, err := client.ListTemplates(ctx, &ListOptions{ 116 | PageSize: 3, 117 | }) 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | if len(templates.Templates) != 3 { 123 | t.Fatal("wrong number of templates") 124 | } 125 | 126 | if templates.Count == 0 { 127 | t.Fatal("wrong count") 128 | } 129 | 130 | if templates.Templates[0].Name == "" { 131 | t.Fatal("wrong template name") 132 | } 133 | 134 | if templates.Templates[0].Content.Steps == nil { 135 | t.Fatal("empty template content") 136 | } 137 | } 138 | 139 | func TestTemplateContent_MarshalJSON(t *testing.T) { 140 | content := TemplateContent{ 141 | Steps: map[string]interface{}{ 142 | ":original": map[string]interface{}{ 143 | "robot": "/upload/handle", 144 | }, 145 | "resize": map[string]interface{}{ 146 | "robot": "/image/resize", 147 | }, 148 | }, 149 | AdditionalProperties: map[string]interface{}{ 150 | "notify_url": "https://example.com", 151 | "allow_steps_override": false, 152 | }, 153 | } 154 | 155 | result, err := json.MarshalIndent(content, "", " ") 156 | if err != nil { 157 | t.Fatal(err) 158 | } 159 | 160 | // Go orders the keys of the JSON object lexicographically 161 | if string(result) != `{ 162 | "allow_steps_override": false, 163 | "notify_url": "https://example.com", 164 | "steps": { 165 | ":original": { 166 | "robot": "/upload/handle" 167 | }, 168 | "resize": { 169 | "robot": "/image/resize" 170 | } 171 | } 172 | }` { 173 | t.Fatal("wrong JSON for template content") 174 | } 175 | } 176 | 177 | func TestTemplateContent_UnmarshalJSON(t *testing.T) { 178 | var content TemplateContent 179 | 180 | err := json.Unmarshal([]byte(`{ 181 | "steps": { 182 | ":original": { 183 | "robot": "/upload/handle" 184 | }, 185 | "resize": { 186 | "robot": "/image/resize" 187 | } 188 | }, 189 | "allow_steps_override": false, 190 | "notify_url": "https://example.com" 191 | }`), &content) 192 | if err != nil { 193 | t.Fatal(err) 194 | } 195 | 196 | if !reflect.DeepEqual( 197 | content, 198 | TemplateContent{ 199 | Steps: map[string]interface{}{ 200 | ":original": map[string]interface{}{ 201 | "robot": "/upload/handle", 202 | }, 203 | "resize": map[string]interface{}{ 204 | "robot": "/image/resize", 205 | }, 206 | }, 207 | AdditionalProperties: map[string]interface{}{ 208 | "allow_steps_override": false, 209 | "notify_url": "https://example.com", 210 | }, 211 | }) { 212 | t.Fatal("wrong template content for JSON") 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | // Template contains details about a single template. 10 | type Template struct { 11 | ID string 12 | Name string 13 | Content TemplateContent 14 | RequireSignatureAuth bool 15 | } 16 | 17 | // TemplateContent contains details about the content of a single template. 18 | // The Steps fields maps to the `steps` key in the JSON format. The AdditionalProperties 19 | // field allows you to store additional keys (such as `notify_url`) on the same 20 | // level as the `steps` key. 21 | // For example, the following instance 22 | // 23 | // TemplateContent{ 24 | // Steps: map[string]interface{}{ 25 | // ":original": map[string]interface{}{ 26 | // "robot": "/upload/handle", 27 | // }, 28 | // "resize": map[string]interface{}{ 29 | // "robot": "/image/resize", 30 | // }, 31 | // }, 32 | // AdditionalProperties: map[string]interface{}{ 33 | // "notify_url": "https://example.com", 34 | // "allow_steps_override": false, 35 | // }, 36 | // } 37 | // 38 | // is represented by following JSON: 39 | // 40 | // { 41 | // "steps": { 42 | // ":original": { 43 | // "robot": "/upload/handle" 44 | // }, 45 | // "resize": { 46 | // "robot": "/image/resize" 47 | // } 48 | // }, 49 | // "allow_steps_override": false, 50 | // "notify_url": "https://example.com" 51 | // } 52 | type TemplateContent struct { 53 | Steps map[string]interface{} 54 | AdditionalProperties map[string]interface{} 55 | } 56 | 57 | func (content *TemplateContent) UnmarshalJSON(b []byte) error { 58 | var data map[string]interface{} 59 | if err := json.Unmarshal(b, &data); err != nil { 60 | return err 61 | } 62 | 63 | if stepsRaw, ok := data["steps"]; ok { 64 | steps, ok := stepsRaw.(map[string]interface{}) 65 | if !ok { 66 | return fmt.Errorf("transloadit: steps property in template content is not an object but %v", stepsRaw) 67 | } 68 | 69 | content.Steps = steps 70 | delete(data, "steps") 71 | } 72 | 73 | if content.AdditionalProperties == nil { 74 | content.AdditionalProperties = make(map[string]interface{}, len(data)) 75 | } 76 | 77 | for key, val := range data { 78 | content.AdditionalProperties[key] = val 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func (content TemplateContent) MarshalJSON() ([]byte, error) { 85 | // Add a hint for the size of the map to reduce the number of necessary allocations 86 | // when filling the map. 87 | numKeys := len(content.AdditionalProperties) + 1 88 | data := make(map[string]interface{}, numKeys) 89 | 90 | data["steps"] = content.Steps 91 | 92 | for key, val := range content.AdditionalProperties { 93 | data[key] = val 94 | } 95 | 96 | return json.Marshal(data) 97 | } 98 | 99 | // TemplateList contains a list of templates. 100 | type TemplateList struct { 101 | Templates []Template `json:"items"` 102 | Count int `json:"count"` 103 | } 104 | 105 | // NewTemplate returns a new Template struct with initialized values. This 106 | // template will not be saved to Transloadit. To do so, please use the 107 | // Client.CreateTemplate function. 108 | func NewTemplate() Template { 109 | return Template{ 110 | Content: TemplateContent{ 111 | make(map[string]interface{}), 112 | make(map[string]interface{}), 113 | }, 114 | } 115 | } 116 | 117 | // AddStep will add the provided step to the Template.Content.Steps map. 118 | func (template *Template) AddStep(name string, step map[string]interface{}) { 119 | template.Content.Steps[name] = step 120 | } 121 | 122 | // templateInternal is the struct we use for encoding/decoding the Template 123 | // JSON since we need to convert between boolean and integer. 124 | type templateInternal struct { 125 | ID string `json:"id"` 126 | Name string `json:"name"` 127 | Content TemplateContent `json:"content"` 128 | RequireSignatureAuth int `json:"require_signature_auth"` 129 | } 130 | 131 | func (template *Template) UnmarshalJSON(b []byte) error { 132 | var internal templateInternal 133 | if err := json.Unmarshal(b, &internal); err != nil { 134 | return err 135 | } 136 | 137 | template.Name = internal.Name 138 | template.Content = internal.Content 139 | template.ID = internal.ID 140 | if internal.RequireSignatureAuth == 1 { 141 | template.RequireSignatureAuth = true 142 | } else { 143 | template.RequireSignatureAuth = false 144 | } 145 | 146 | return nil 147 | } 148 | 149 | // CreateTemplate will save the provided template struct as a new template 150 | // and return the ID of the new template. 151 | func (client *Client) CreateTemplate(ctx context.Context, template Template) (string, error) { 152 | content := map[string]interface{}{ 153 | "name": template.Name, 154 | "template": template.Content, 155 | } 156 | if template.RequireSignatureAuth { 157 | content["require_signature_auth"] = 1 158 | } 159 | 160 | if err := client.request(ctx, "POST", "templates", content, &template); err != nil { 161 | return "", err 162 | } 163 | 164 | return template.ID, nil 165 | } 166 | 167 | // GetTemplate will retrieve details about the template associated with the 168 | // provided template ID. 169 | func (client *Client) GetTemplate(ctx context.Context, templateID string) (template Template, err error) { 170 | err = client.request(ctx, "GET", "templates/"+templateID, nil, &template) 171 | return template, err 172 | } 173 | 174 | // DeleteTemplate will delete the template associated with the provided 175 | // template ID. 176 | func (client *Client) DeleteTemplate(ctx context.Context, templateID string) error { 177 | return client.request(ctx, "DELETE", "templates/"+templateID, nil, nil) 178 | } 179 | 180 | // UpdateTemplate will update the template associated with the provided 181 | // template ID to match the new name and new content. Please be aware that you 182 | // are not able to change a template's ID. 183 | func (client *Client) UpdateTemplate(ctx context.Context, templateID string, newTemplate Template) error { 184 | // Create signature 185 | content := map[string]interface{}{ 186 | "name": newTemplate.Name, 187 | "template": newTemplate.Content, 188 | } 189 | if newTemplate.RequireSignatureAuth { 190 | content["require_signature_auth"] = 1 191 | } else { 192 | content["require_signature_auth"] = 0 193 | } 194 | 195 | return client.request(ctx, "PUT", "templates/"+templateID, content, nil) 196 | } 197 | 198 | // ListTemplates will retrieve all templates matching the criteria. 199 | func (client *Client) ListTemplates(ctx context.Context, options *ListOptions) (list TemplateList, err error) { 200 | err = client.listRequest(ctx, "templates", options, &list) 201 | return list, err 202 | } 203 | -------------------------------------------------------------------------------- /assembly_test.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | var assemblyURL string 11 | 12 | func TestStartAssembly_Success(t *testing.T) { 13 | client := setup(t) 14 | assembly := NewAssembly() 15 | 16 | file, err := os.Open("./fixtures/lol_cat.jpg") 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | assembly.AddReader("image", "lol_cat.jpg", file) 22 | assembly.AddFile("image2", "./fixtures/mona_lisa.jpg") 23 | 24 | assembly.AddStep("resize", map[string]interface{}{ 25 | "robot": "/image/resize", 26 | "width": 75, 27 | "height": 75, 28 | "resize_strategy": "pad", 29 | "background": "#000000", 30 | "imagemagick_stack": "v3.0.0", 31 | }) 32 | 33 | assembly.NotifyURL = "https://example.com/" 34 | assembly.Fields["string_test"] = "foo" 35 | assembly.Fields["number_test"] = 100 36 | 37 | info, err := client.StartAssembly(ctx, assembly) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | if info.AssemblyID == "" { 43 | t.Fatal("response doesn't contain assembly_id") 44 | } 45 | 46 | if info.NotifyURL != "https://example.com/" { 47 | t.Fatal("wrong notify url") 48 | } 49 | 50 | if info.Fields["string_test"] != "foo" { 51 | t.Fatal("wrong field string_test") 52 | } 53 | 54 | // Go's JSON package parses numbers as a float by default, so we 55 | // need 100 to be a float for comparison. 56 | if info.Fields["number_test"] != float64(100) { 57 | t.Fatal("wrong field number_test") 58 | } 59 | 60 | if len(info.Uploads) != 2 { 61 | t.Fatal("wrong number of uploads") 62 | } 63 | 64 | if info.Uploads[0].Name == "lol_cat.jpg" { 65 | if info.Uploads[0].Field != "image" { 66 | t.Fatal("wrong field name") 67 | } 68 | } else if info.Uploads[1].Name == "lol_cat.jpg" { 69 | if info.Uploads[1].Field != "image" { 70 | t.Fatal("wrong field name") 71 | } 72 | } else { 73 | t.Fatal("lol_cat.jpg not found in uploads") 74 | } 75 | 76 | assemblyURL = info.AssemblyURL 77 | } 78 | 79 | func TestGetAssembly(t *testing.T) { 80 | client := setup(t) 81 | assembly, err := client.GetAssembly(ctx, assemblyURL) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | if assembly.AssemblyID == "" { 87 | t.Fatal("assembly id not contained") 88 | } 89 | 90 | if assembly.AssemblyURL != assemblyURL { 91 | t.Fatal("assembly urls don't match") 92 | } 93 | } 94 | 95 | func TestStartAssembly_Failure(t *testing.T) { 96 | t.Parallel() 97 | 98 | config := DefaultConfig 99 | config.AuthKey = "does not exist" 100 | config.AuthSecret = "does not matter" 101 | 102 | client := NewClient(config) 103 | 104 | assembly := NewAssembly() 105 | 106 | file, err := os.Open("./fixtures/lol_cat.jpg") 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | assembly.AddReader("image", "lol_cat.jpg", file) 112 | 113 | assembly.AddStep("resize", map[string]interface{}{ 114 | "robot": "/image/resize", 115 | "width": 75, 116 | "height": 75, 117 | "resize_strategy": "pad", 118 | "background": "#000000", 119 | "imagemagick_stack": "v3.0.0", 120 | }) 121 | 122 | _, err = client.StartAssembly(ctx, assembly) 123 | reqErr := err.(RequestError) 124 | if reqErr.Code != "GET_ACCOUNT_UNKNOWN_AUTH_KEY" { 125 | t.Fatal("wrong error code in response") 126 | } 127 | if reqErr.Message == "" { 128 | t.Fatal("error message should not be empty") 129 | } 130 | } 131 | 132 | func TestStartAssembly_Template(t *testing.T) { 133 | setupTemplates(t) 134 | // Delete Template 135 | defer tearDownTemplate(t) 136 | client := setup(t) 137 | assembly := NewAssembly() 138 | 139 | assembly.TemplateID = templateIDOptimizeResize 140 | 141 | info, err := client.StartAssembly(ctx, assembly) 142 | if err != nil { 143 | t.Fatal(err) 144 | } 145 | 146 | if info.AssemblyID == "" { 147 | t.Fatalf("response doesn't contain assembly_id. %s", info.Error) 148 | } 149 | 150 | if !strings.Contains(info.Params, templateIDOptimizeResize) { 151 | t.Fatal("template id not as parameter submitted") 152 | } 153 | } 154 | 155 | func TestStartAssemblyReplay(t *testing.T) { 156 | t.Parallel() 157 | 158 | client := setup(t) 159 | assembly := NewAssemblyReplay(assemblyURL) 160 | 161 | assembly.NotifyURL = "https://example.com/" 162 | assembly.ReparseTemplate = true 163 | 164 | assembly.AddStep("convert", map[string]interface{}{ 165 | "robot": "/html/convert", 166 | "url": "https://transloadit.com/", 167 | }) 168 | 169 | info, err := client.StartAssemblyReplay(ctx, assembly) 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | 174 | if info.Ok != "ASSEMBLY_REPLAYING" { 175 | t.Fatal("wrong status code returned") 176 | } 177 | 178 | if info.NotifyURL != "https://example.com/" { 179 | t.Fatal("wrong notify url") 180 | } 181 | } 182 | 183 | func TestCancelAssembly(t *testing.T) { 184 | t.Parallel() 185 | 186 | client := setup(t) 187 | assembly := NewAssembly() 188 | 189 | assembly.AddStep("convert", map[string]interface{}{ 190 | "robot": "/html/convert", 191 | "url": "https://transloadit.com/", 192 | }) 193 | 194 | info, err := client.StartAssembly(ctx, assembly) 195 | if err != nil { 196 | t.Fatal(err) 197 | } 198 | 199 | if info.AssemblyURL == "" { 200 | t.Fatal("response doesn't contain assembly_url") 201 | } 202 | 203 | info, err = client.CancelAssembly(ctx, info.AssemblyURL) 204 | if err != nil { 205 | t.Fatal(err) 206 | } 207 | 208 | if info.Ok != "ASSEMBLY_CANCELED" { 209 | t.Fatal("incorrect assembly status") 210 | } 211 | } 212 | 213 | func TestListAssemblies(t *testing.T) { 214 | t.Parallel() 215 | 216 | client := setup(t) 217 | 218 | assemblies, err := client.ListAssemblies(ctx, &ListOptions{ 219 | PageSize: 3, 220 | }) 221 | if err != nil { 222 | t.Fatal(err) 223 | } 224 | 225 | if len(assemblies.Assemblies) < 3 { 226 | t.Fatal("wrong number of assemblies") 227 | } 228 | 229 | if assemblies.Count == 0 { 230 | t.Fatal("wrong count") 231 | } 232 | 233 | if assemblies.Assemblies[0].AssemblyID == "" { 234 | t.Fatal("wrong template name") 235 | } 236 | } 237 | 238 | func TestInteger_MarshalJSON(t *testing.T) { 239 | var info AssemblyInfo 240 | err := json.Unmarshal([]byte(`{"bytes_expected":55}`), &info) 241 | if err != nil { 242 | t.Fatal(err) 243 | } 244 | 245 | if info.BytesExpected != 55 { 246 | t.Fatal("wrong integer parsed") 247 | } 248 | 249 | err = json.Unmarshal([]byte(`{"bytes_expected":null}`), &info) 250 | if err != nil { 251 | t.Fatal(err) 252 | } 253 | 254 | if info.BytesExpected != 0 { 255 | t.Fatal("wrong default value for null") 256 | } 257 | 258 | err = json.Unmarshal([]byte(`{"bytes_expected":""}`), &info) 259 | if err != nil { 260 | t.Fatal(err) 261 | } 262 | 263 | if info.BytesExpected != 0 { 264 | t.Fatal("wrong default value for string") 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /transloadit.go: -------------------------------------------------------------------------------- 1 | // Package transloadit provides a client to interact with the Transloadt API. 2 | package transloadit 3 | 4 | import ( 5 | "context" 6 | "crypto/hmac" 7 | "crypto/sha1" 8 | "crypto/sha256" 9 | "crypto/sha512" 10 | "encoding/hex" 11 | "encoding/json" 12 | "fmt" 13 | "io" 14 | "io/ioutil" 15 | "math/rand" 16 | "net/http" 17 | "net/url" 18 | "sort" 19 | "strconv" 20 | "strings" 21 | "time" 22 | ) 23 | 24 | // Config defines the configuration options for a client. 25 | type Config struct { 26 | AuthKey string 27 | AuthSecret string 28 | Endpoint string 29 | } 30 | 31 | // DefaultConfig is the recommended base configuration. 32 | var DefaultConfig = Config{ 33 | Endpoint: "https://api2.transloadit.com", 34 | } 35 | 36 | // Client provides an interface to the Transloadit REST API bound to a specific 37 | // account. 38 | type Client struct { 39 | config Config 40 | httpClient *http.Client 41 | random *rand.Rand 42 | } 43 | 44 | // ListOptions defines criteria used when a list is being retrieved. Details 45 | // about each property can be found at https://transloadit.com/docs/api-docs#retrieve-assembly-list. 46 | type ListOptions struct { 47 | Page int `json:"page,omitempty"` 48 | PageSize int `json:"pagesize,omitempty"` 49 | Sort string `json:"sort,omitempty"` 50 | Order string `json:"order,omitempty"` 51 | Fields []string `json:"fields,omitempty"` 52 | Type string `json:"type,omitempty"` 53 | Keywords []string `json:"keyword,omitempty"` 54 | AssemblyID string `json:"assembly_id,omitempty"` 55 | FromDate *time.Time `json:"fromdate,omitempty"` 56 | ToDate *time.Time `json:"todate,omitempty"` 57 | } 58 | 59 | type authParams struct { 60 | Key string `json:"key"` 61 | Expires string `json:"expires"` 62 | } 63 | 64 | type authListOptions struct { 65 | *ListOptions 66 | 67 | // For internal use only! 68 | Auth authParams `json:"auth"` 69 | } 70 | 71 | // RequestError represents an error returned by the Transloadit API alongside 72 | // additional service-specific information. 73 | type RequestError struct { 74 | Code string `json:"error"` 75 | Message string `json:"message"` 76 | } 77 | 78 | // Error return a formatted message describing the error. 79 | func (err RequestError) Error() string { 80 | return fmt.Sprintf("request failed due to %s: %s", err.Code, err.Message) 81 | } 82 | 83 | // NewClient creates a new client using the provided configuration struct. 84 | // It will panic if no Config.AuthKey or Config.AuthSecret are empty. 85 | func NewClient(config Config) Client { 86 | if config.AuthKey == "" { 87 | panic("failed to create Transloadit client: missing AuthKey") 88 | } 89 | 90 | if config.AuthSecret == "" { 91 | panic("failed to create Transloadit client: missing AuthSecret") 92 | } 93 | 94 | client := Client{ 95 | config: config, 96 | httpClient: &http.Client{}, 97 | random: rand.New(rand.NewSource(time.Now().UnixNano())), 98 | } 99 | 100 | return client 101 | } 102 | 103 | func (client *Client) sign(params map[string]interface{}) (string, string, error) { 104 | params["auth"] = authParams{ 105 | Key: client.config.AuthKey, 106 | Expires: getExpireString(), 107 | } 108 | // Add a random nonce to make signatures unique and prevent error about 109 | // signature reuse: https://github.com/transloadit/go-sdk/pull/35 110 | params["nonce"] = client.random.Int() 111 | contentToSign, err := json.Marshal(params) 112 | if err != nil { 113 | return "", "", fmt.Errorf("unable to create signature: %s", err) 114 | } 115 | 116 | hash := hmac.New(sha512.New384, []byte(client.config.AuthSecret)) 117 | hash.Write(contentToSign) 118 | signature := "sha384:" + hex.EncodeToString(hash.Sum(nil)) 119 | 120 | return string(contentToSign), signature, nil 121 | } 122 | 123 | func (client *Client) doRequest(req *http.Request, result interface{}) error { 124 | req.Header.Set("Transloadit-Client", "go-sdk:"+Version) 125 | 126 | res, err := client.httpClient.Do(req) 127 | if err != nil { 128 | return fmt.Errorf("failed execute http request: %s", err) 129 | } 130 | defer res.Body.Close() 131 | 132 | // Limit response to 128MB 133 | reader := io.LimitReader(res.Body, 128*1024*1024) 134 | body, err := ioutil.ReadAll(reader) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | if !(res.StatusCode >= 200 && res.StatusCode < 300) { 140 | var reqErr RequestError 141 | if err := json.Unmarshal(body, &reqErr); err != nil { 142 | return fmt.Errorf("failed unmarshal http request: %s", err) 143 | } 144 | 145 | return reqErr 146 | } 147 | 148 | if result != nil { 149 | if err := json.Unmarshal(body, result); err != nil { 150 | fmt.Println(string(body)) 151 | return fmt.Errorf("failed unmarshal http request: %s", err) 152 | } 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func (client *Client) request(ctx context.Context, method string, path string, content map[string]interface{}, result interface{}) error { 159 | uri := path 160 | // Don't add host for absolute urls 161 | if u, err := url.Parse(path); err == nil && u.Scheme == "" { 162 | uri = client.config.Endpoint + "/" + path 163 | } 164 | 165 | // Ensure content is a map 166 | if content == nil { 167 | content = make(map[string]interface{}) 168 | } 169 | 170 | // Create signature 171 | params, signature, err := client.sign(content) 172 | if err != nil { 173 | return fmt.Errorf("request: %s", err) 174 | } 175 | 176 | v := url.Values{} 177 | v.Set("params", params) 178 | v.Set("signature", signature) 179 | 180 | var body io.Reader 181 | if method == "GET" { 182 | uri += "?" + v.Encode() 183 | } else { 184 | body = strings.NewReader(v.Encode()) 185 | } 186 | req, err := http.NewRequest(method, uri, body) 187 | if err != nil { 188 | return fmt.Errorf("request: %s", err) 189 | } 190 | req = req.WithContext(ctx) 191 | 192 | if method != "GET" { 193 | // Add content type header 194 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 195 | } 196 | 197 | return client.doRequest(req, result) 198 | } 199 | 200 | func (client *Client) listRequest(ctx context.Context, path string, listOptions *ListOptions, result interface{}) error { 201 | uri := client.config.Endpoint + "/" + path 202 | 203 | options := authListOptions{ 204 | ListOptions: listOptions, 205 | Auth: authParams{ 206 | Key: client.config.AuthKey, 207 | Expires: getExpireString(), 208 | }, 209 | } 210 | 211 | b, err := json.Marshal(options) 212 | if err != nil { 213 | return fmt.Errorf("unable to create signature: %s", err) 214 | } 215 | 216 | hash := hmac.New(sha1.New, []byte(client.config.AuthSecret)) 217 | hash.Write(b) 218 | 219 | params := string(b) 220 | signature := hex.EncodeToString(hash.Sum(nil)) 221 | 222 | v := url.Values{} 223 | v.Set("params", params) 224 | v.Set("signature", signature) 225 | 226 | uri += "?" + v.Encode() 227 | 228 | req, err := http.NewRequest("GET", uri, nil) 229 | if err != nil { 230 | return fmt.Errorf("request: %s", err) 231 | } 232 | req = req.WithContext(ctx) 233 | 234 | return client.doRequest(req, result) 235 | } 236 | 237 | func getExpireString() string { 238 | // Expires in 1 hour 239 | expires := time.Now().UTC().Add(time.Hour) 240 | expiresStr := fmt.Sprintf("%04d/%02d/%02d %02d:%02d:%02d+00:00", expires.Year(), expires.Month(), expires.Day(), expires.Hour(), expires.Minute(), expires.Second()) 241 | return string(expiresStr) 242 | } 243 | 244 | // SignedSmartCDNUrlOptions contains options for creating a signed Smart CDN URL 245 | type SignedSmartCDNUrlOptions struct { 246 | // Workspace slug 247 | Workspace string 248 | // Template slug or template ID 249 | Template string 250 | // Input value that is provided as `${fields.input}` in the template 251 | Input string 252 | // Additional parameters for the URL query string. Can be nil. 253 | URLParams url.Values 254 | // Expiration timestamp of the signature. Defaults to 1 hour from now if left unset. 255 | ExpiresAt time.Time 256 | } 257 | 258 | // CreateSignedSmartCDNUrl constructs a signed Smart CDN URL. 259 | // See https://transloadit.com/docs/topics/signature-authentication/#smart-cdn 260 | func (client *Client) CreateSignedSmartCDNUrl(opts SignedSmartCDNUrlOptions) string { 261 | workspaceSlug := url.PathEscape(opts.Workspace) 262 | templateSlug := url.PathEscape(opts.Template) 263 | inputField := url.PathEscape(opts.Input) 264 | 265 | var expiresAt int64 266 | if !opts.ExpiresAt.IsZero() { 267 | expiresAt = opts.ExpiresAt.Unix() * 1000 268 | } else { 269 | expiresAt = time.Now().Add(time.Hour).Unix() * 1000 // 1 hour 270 | } 271 | 272 | queryParams := make(url.Values, len(opts.URLParams)+2) 273 | for key, values := range opts.URLParams { 274 | queryParams[key] = values 275 | } 276 | 277 | queryParams.Set("auth_key", client.config.AuthKey) 278 | queryParams.Set("exp", strconv.FormatInt(expiresAt, 10)) 279 | 280 | // Build query string with sorted keys 281 | queryParamsKeys := make([]string, 0, len(queryParams)) 282 | for k := range queryParams { 283 | queryParamsKeys = append(queryParamsKeys, k) 284 | } 285 | sort.Strings(queryParamsKeys) 286 | 287 | var queryParts []string 288 | for _, k := range queryParamsKeys { 289 | for _, v := range queryParams[k] { 290 | queryParts = append(queryParts, url.QueryEscape(k)+"="+url.QueryEscape(v)) 291 | } 292 | } 293 | queryString := strings.Join(queryParts, "&") 294 | 295 | stringToSign := fmt.Sprintf("%s/%s/%s?%s", workspaceSlug, templateSlug, inputField, queryString) 296 | 297 | // Create signature using SHA-256 298 | hash := hmac.New(sha256.New, []byte(client.config.AuthSecret)) 299 | hash.Write([]byte(stringToSign)) 300 | signature := url.QueryEscape("sha256:" + hex.EncodeToString(hash.Sum(nil))) 301 | 302 | signedURL := fmt.Sprintf("https://%s.tlcdn.com/%s/%s?%s&sig=%s", 303 | workspaceSlug, templateSlug, inputField, queryString, signature) 304 | 305 | return signedURL 306 | } 307 | -------------------------------------------------------------------------------- /assembly.go: -------------------------------------------------------------------------------- 1 | package transloadit 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "mime/multipart" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | // Assembly contains instructions used for starting assemblies. 15 | type Assembly struct { 16 | // NotifiyURL specifies a URL to which a request will be sent once the 17 | // assembly finishes. 18 | // See https://transloadit.com/docs#notifications 19 | NotifyURL string 20 | // TemplateID specifies a optional template from which the encoding 21 | // instructions will be fetched. 22 | // See https://transloadit.com/docs/topics/templates/ 23 | TemplateID string 24 | // Fields specifies additional key-value pairs that can be accessed by 25 | // Assembly Instructions to allow customizing steps on a per-assembly basis. 26 | // See https://transloadit.com/docs/topics/assembly-instructions/#assembly-variables 27 | Fields map[string]interface{} 28 | 29 | steps map[string]map[string]interface{} 30 | readers []*upload 31 | } 32 | 33 | type upload struct { 34 | Field string 35 | Name string 36 | Reader io.ReadCloser 37 | } 38 | 39 | // AssemblyReplay contains instructions used for replaying assemblies. 40 | type AssemblyReplay struct { 41 | // NotifiyURL specifies a URL to which a request will be sent once the 42 | // assembly finishes. This overwrites the notify url from the original 43 | // assembly instructions. 44 | // See https://transloadit.com/docs#notifications. 45 | NotifyURL string 46 | // ReparseTemplate specifies whether the template should be fetched again 47 | // before the assembly is replayed. This can be used if the template has 48 | // changed since the original assembly was created. 49 | ReparseTemplate bool 50 | 51 | assemblyURL string 52 | steps map[string]map[string]interface{} 53 | } 54 | 55 | // AssemblyList contains a list of assemblies. 56 | type AssemblyList struct { 57 | Assemblies []*AssemblyListItem `json:"items"` 58 | Count int `json:"count"` 59 | } 60 | 61 | // AssemblyListItem contains reduced details about an assembly. 62 | type AssemblyListItem struct { 63 | Ok string `json:"ok"` 64 | Error string `json:"error"` 65 | 66 | AssemblyID string `json:"id"` 67 | AccountID string `json:"account_id"` 68 | TemplateID string `json:"template_id"` 69 | Instance string `json:"instance"` 70 | NotifyURL string `json:"notify_url"` 71 | RedirectURL string `json:"redirect_url"` 72 | ExecutionDuration float32 `json:"execution_duration"` 73 | ExecutionStart *time.Time `json:"execution_start"` 74 | Created time.Time `json:"created"` 75 | Files string `json:"files"` 76 | } 77 | 78 | // AssemblyInfo contains details about an assemblies current status. Details 79 | // about each value can be found at https://transloadit.com/docs/api-docs/#assembly-status-response 80 | type AssemblyInfo struct { 81 | Ok string `json:"ok"` 82 | Error string `json:"error"` 83 | Message string `json:"message"` 84 | 85 | AssemblyID string `json:"assembly_id"` 86 | ParentID string `json:"parent_id"` 87 | AssemblyURL string `json:"assembly_url"` 88 | AssemblySSLURL string `json:"assembly_ssl_url"` 89 | BytesReceived int `json:"bytes_received"` 90 | BytesExpected Integer `json:"bytes_expected"` 91 | StartDate string `json:"start_date"` 92 | IsInfinite bool `json:"is_infinite"` 93 | HasDupeJobs bool `json:"has_dupe_jobs"` 94 | UploadDuration float32 `json:"upload_duration"` 95 | NotifyURL string `json:"notify_url"` 96 | NotifyStart string `json:"notify_start"` 97 | NotifyStatus string `json:"notify_status"` 98 | NotifyDuation float32 `json:"notify_duration"` 99 | LastJobCompleted string `json:"last_job_completed"` 100 | ExecutionDuration float32 `json:"execution_duration"` 101 | ExecutionStart string `json:"execution_start"` 102 | Created string `json:"created"` 103 | Files string `json:"files"` 104 | Fields map[string]interface{} `json:"fields"` 105 | BytesUsage int `json:"bytes_usage"` 106 | FilesToStoreOnS3 int `json:"files_to_store_on_s3"` 107 | QueuedFilesToStoreOnS3 int `json:"queued_files_to_store_on_s3"` 108 | ExecutingJobs []string `json:"executing_jobs"` 109 | StartedJobs []string `json:"started_jobs"` 110 | ParentAssemblyStatus *AssemblyInfo `json:"parent_assembly_status"` 111 | Uploads []*FileInfo `json:"uploads"` 112 | Results map[string][]*FileInfo `json:"results"` 113 | Params string `json:"params"` 114 | 115 | // Since 7 March 2018, the user agent, IP and referer are no longer 116 | // stored by Transloadit (see https://transloadit.com/blog/2018/03/gdpr/) 117 | // Therefore, these properties will always hold empty strings. 118 | ClientAgent string 119 | ClientIp string 120 | ClientReferer string 121 | } 122 | 123 | // FileInfo contains details about a file which was either uploaded or is the 124 | // result of an executed assembly. 125 | type FileInfo struct { 126 | ID string `json:"id"` 127 | Name string `json:"name"` 128 | Basename string `json:"basename"` 129 | Ext string `json:"ext"` 130 | Size int `json:"size"` 131 | Mime string `json:"mime"` 132 | Type string `json:"type"` 133 | Field string `json:"field"` 134 | Md5Hash string `json:"md5hash"` 135 | OriginalMd5Hash string `json:"original_md5hash"` 136 | OriginalID string `json:"original_id"` 137 | OriginalBasename string `json:"original_basename"` 138 | URL string `json:"url"` 139 | SSLURL string `json:"ssl_url"` 140 | Meta map[string]interface{} `json:"meta"` 141 | Cost int `json:"cost"` 142 | } 143 | 144 | // Integer is a warpper around a normal int but has softer JSON parsing requirements. 145 | // It can be used in situations where a JSON value is not always a number. Then parsing 146 | // will not fail and a default value of 0 will be returned. 147 | // For more details see: https://github.com/transloadit/go-sdk/issues/26 148 | type Integer int 149 | 150 | func (i *Integer) UnmarshalJSON(text []byte) error { 151 | // Try parsing as an integer and default to 0, if it fails. 152 | n, err := strconv.Atoi(string(text)) 153 | if err != nil { 154 | *i = 0 155 | } 156 | 157 | *i = Integer(n) 158 | return nil 159 | } 160 | 161 | // NewAssembly will create a new Assembly struct which can be used to start 162 | // an assembly using Client.StartAssembly. 163 | func NewAssembly() Assembly { 164 | return Assembly{ 165 | Fields: make(map[string]interface{}), 166 | steps: make(map[string]map[string]interface{}), 167 | readers: make([]*upload, 0), 168 | } 169 | } 170 | 171 | // AddReader will add the provided io.Reader to the list which will be uploaded 172 | // once Client.StartAssembly is invoked. The corresponding field name can be 173 | // used to reference the file in the assembly instructions. 174 | func (assembly *Assembly) AddReader(fieldname, filename string, reader io.ReadCloser) { 175 | assembly.readers = append(assembly.readers, &upload{ 176 | Field: fieldname, 177 | Name: filename, 178 | Reader: reader, 179 | }) 180 | } 181 | 182 | // AddFile will open the provided file path and add it to the list which will be 183 | // uploaded once Client.StartAssembly is invoked. The corresponding field name 184 | // can be used to reference the file in the assembly instructions. 185 | func (assembly *Assembly) AddFile(fieldname, filepath string) error { 186 | file, err := os.Open(filepath) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | assembly.AddReader(fieldname, filepath, file) 192 | return nil 193 | } 194 | 195 | // AddStep will add the provided step to the assembly instructions. Details 196 | // about possible values can be found at https://transloadit.com/docs/topics/assembly-instructions/ 197 | func (assembly *Assembly) AddStep(name string, details map[string]interface{}) { 198 | assembly.steps[name] = details 199 | } 200 | 201 | // StartAssembly will upload all provided files and instruct the endpoint to 202 | // start executing it. The function will return after all uploads complete and 203 | // the remote server received the instructions (or the provided context times 204 | // out). It won't wait until the execution has finished and results are 205 | // available, which can be achieved using WaitForAssembly. 206 | // 207 | // When an error is returned you should also check AssemblyInfo.Error for more 208 | // information about the error sent by the Transloadit API: 209 | // 210 | // info, err := assembly.Upload() 211 | // if err != nil { 212 | // if info != nil && info.Error != "" { 213 | // // See info.Error 214 | // } 215 | // panic(err) 216 | // } 217 | func (client *Client) StartAssembly(ctx context.Context, assembly Assembly) (*AssemblyInfo, error) { 218 | req, err := assembly.makeRequest(ctx, client) 219 | if err != nil { 220 | return nil, fmt.Errorf("failed to create assembly request: %s", err) 221 | } 222 | 223 | var info AssemblyInfo 224 | // TODO: add context.Context 225 | if err = client.doRequest(req, &info); err != nil { 226 | return nil, err 227 | } 228 | 229 | if info.Error != "" { 230 | return &info, fmt.Errorf("failed to create assembly: %s", info.Error) 231 | } 232 | 233 | return &info, err 234 | } 235 | 236 | func (assembly *Assembly) makeRequest(ctx context.Context, client *Client) (*http.Request, error) { 237 | // TODO: test with huge files 238 | url := client.config.Endpoint + "/assemblies" 239 | bodyReader, bodyWriter := io.Pipe() 240 | multiWriter := multipart.NewWriter(bodyWriter) 241 | 242 | options := make(map[string]interface{}) 243 | 244 | if len(assembly.steps) != 0 { 245 | options["steps"] = assembly.steps 246 | } 247 | 248 | if len(assembly.Fields) != 0 { 249 | options["fields"] = assembly.Fields 250 | } 251 | 252 | if assembly.TemplateID != "" { 253 | options["template_id"] = assembly.TemplateID 254 | } 255 | 256 | if assembly.NotifyURL != "" { 257 | options["notify_url"] = assembly.NotifyURL 258 | } 259 | 260 | params, signature, err := client.sign(options) 261 | if err != nil { 262 | return nil, fmt.Errorf("unable to create upload request: %s", err) 263 | } 264 | 265 | // All writes to the multipart.Writer multiWriter _must_ happen inside this 266 | // goroutine because the writer is connected to the HTTP requst using an 267 | // in-memory pipe. Therefore a write to the multipart.Writer will block until 268 | // a corresponding read is happening from the HTTP request. The gist is that 269 | // the writes and reads must not occur sequentially but in parallel. 270 | go func() { 271 | defer bodyWriter.Close() 272 | defer multiWriter.Close() 273 | // Add additional keys and values 274 | 275 | if err := multiWriter.WriteField("params", params); err != nil { 276 | fmt.Println(fmt.Errorf("unable to write params field: %s", err)) 277 | } 278 | if err := multiWriter.WriteField("signature", signature); err != nil { 279 | fmt.Println(fmt.Errorf("unable to write signature field: %s", err)) 280 | } 281 | 282 | // Add files to upload 283 | for _, reader := range assembly.readers { 284 | defer reader.Reader.Close() 285 | 286 | part, err := multiWriter.CreateFormFile(reader.Field, reader.Name) 287 | if err != nil { 288 | fmt.Println(fmt.Errorf("unable to create form field: %s", err)) 289 | } 290 | 291 | if _, err := io.Copy(part, reader.Reader); err != nil { 292 | fmt.Println(fmt.Errorf("unable to create upload request: %s", err)) 293 | } 294 | } 295 | }() 296 | 297 | // Create HTTP request 298 | req, err := http.NewRequest("POST", url, bodyReader) 299 | if err != nil { 300 | return nil, fmt.Errorf("unable to create upload request: %s", err) 301 | } 302 | 303 | req = req.WithContext(ctx) 304 | req.Header.Set("Content-Type", multiWriter.FormDataContentType()) 305 | 306 | return req, nil 307 | } 308 | 309 | // GetAssembly fetches the full assembly status from the provided URL. 310 | // The assembly URL must be absolute, for example: 311 | // https://api2-amberly.transloadit.com/assemblies/15a6b3701d3811e78d7bfba4db1b053e 312 | func (client *Client) GetAssembly(ctx context.Context, assemblyURL string) (*AssemblyInfo, error) { 313 | var info AssemblyInfo 314 | err := client.request(ctx, "GET", assemblyURL, nil, &info) 315 | 316 | return &info, err 317 | } 318 | 319 | // CancelAssembly cancels an assembly which will result in all corresponding 320 | // uploads and encoding jobs to be aborted. Finally, the updated assembly 321 | // information after the cancellation will be returned. 322 | // The assembly URL must be absolute, for example: 323 | // https://api2-amberly.transloadit.com/assemblies/15a6b3701d3811e78d7bfba4db1b053e 324 | func (client *Client) CancelAssembly(ctx context.Context, assemblyURL string) (*AssemblyInfo, error) { 325 | var info AssemblyInfo 326 | err := client.request(ctx, "DELETE", assemblyURL, nil, &info) 327 | 328 | return &info, err 329 | } 330 | 331 | // NewAssemblyReplay will create a new AssemblyReplay struct which can be used 332 | // to replay an assemblie's execution using Client.StartAssemblyReplay. 333 | // The assembly URL must be absolute, for example: 334 | // https://api2-amberly.transloadit.com/assemblies/15a6b3701d3811e78d7bfba4db1b053e 335 | func NewAssemblyReplay(assemblyURL string) AssemblyReplay { 336 | return AssemblyReplay{ 337 | steps: make(map[string]map[string]interface{}), 338 | assemblyURL: assemblyURL, 339 | } 340 | } 341 | 342 | // AddStep will add the provided step to the new assembly instructions. When the 343 | // assembly is replayed, those new steps will be used instead of the original 344 | // ones. Details about possible values can be found at 345 | // https://transloadit.com/docs/topics/assembly-instructions/. 346 | func (assembly *AssemblyReplay) AddStep(name string, details map[string]interface{}) { 347 | assembly.steps[name] = details 348 | } 349 | 350 | // StartAssemblyReplay will instruct the endpoint to replay the entire assembly 351 | // execution. 352 | func (client *Client) StartAssemblyReplay(ctx context.Context, assembly AssemblyReplay) (*AssemblyInfo, error) { 353 | options := map[string]interface{}{ 354 | "steps": assembly.steps, 355 | } 356 | 357 | if assembly.ReparseTemplate { 358 | options["reparse_template"] = 1 359 | } 360 | 361 | if assembly.NotifyURL != "" { 362 | options["notify_url"] = assembly.NotifyURL 363 | } 364 | 365 | var info AssemblyInfo 366 | err := client.request(ctx, "POST", assembly.assemblyURL+"/replay", options, &info) 367 | if err != nil { 368 | return nil, err 369 | } 370 | 371 | if info.Error != "" { 372 | return &info, fmt.Errorf("failed to start assembly replay: %s", info.Error) 373 | } 374 | 375 | return &info, nil 376 | } 377 | 378 | // ListAssemblies will fetch all assemblies matching the provided criteria. 379 | func (client *Client) ListAssemblies(ctx context.Context, options *ListOptions) (AssemblyList, error) { 380 | var assemblies AssemblyList 381 | err := client.listRequest(ctx, "assemblies", options, &assemblies) 382 | 383 | return assemblies, err 384 | } 385 | --------------------------------------------------------------------------------