├── 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 |
--------------------------------------------------------------------------------