├── forms.go
├── test-site
├── folder
│ ├── .gitignore
│ ├── __MACOSX
│ ├── style.css
│ └── index.html
└── archive.zip
├── users.go
├── submissions.go
├── .gitignore
├── script
├── test.sh
└── ci.sh
├── glide.yaml
├── deploy_keys.go
├── Jenkinsfile
├── LICENSE.md
├── glide.lock
├── README.md
├── timestamp.go
├── sites_test.go
├── doc.go
├── netlify_test.go
├── deploys_test.go
├── sites.go
├── netlify.go
└── deploys.go
/forms.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
--------------------------------------------------------------------------------
/test-site/folder/.gitignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test-site/folder/__MACOSX:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/users.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
--------------------------------------------------------------------------------
/submissions.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | target/
2 | vendor/
3 | *.iml
4 |
--------------------------------------------------------------------------------
/test-site/folder/style.css:
--------------------------------------------------------------------------------
1 | h1 {
2 | font-size: 80px;
3 | }
4 |
--------------------------------------------------------------------------------
/test-site/archive.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netlify/go-client/HEAD/test-site/archive.zip
--------------------------------------------------------------------------------
/script/test.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 | set -x
5 |
6 | mkdir -p vendor
7 | glide --no-color install
8 | go test $(go list ./... | grep -v /vendor/)
9 |
--------------------------------------------------------------------------------
/glide.yaml:
--------------------------------------------------------------------------------
1 | package: github.com/netlify/netlify-go
2 | import:
3 | - package: github.com/cenkalti/backoff
4 | version: v1.0.0
5 | - package: github.com/sirupsen/logrus
6 | version: v1.0.0
7 | - package: golang.org/x/oauth2
8 |
--------------------------------------------------------------------------------
/test-site/folder/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Test
5 |
6 |
7 |
8 | Test site
9 |
10 |
11 |
--------------------------------------------------------------------------------
/script/ci.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 | set -x
5 |
6 | WORKSPACE=/go/src/github.com/netlify/$1
7 |
8 | docker run \
9 | --volume $(pwd):$WORKSPACE \
10 | --workdir $WORKSPACE \
11 | --rm \
12 | calavera/go-glide:v0.12.3 script/test.sh $1
13 |
--------------------------------------------------------------------------------
/deploy_keys.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
3 | // DeployKey for use with continuous deployment setups
4 | type DeployKey struct {
5 | Id string `json:"id"`
6 | PublicKey string `json:"public_key"`
7 |
8 | CreatedAt Timestamp `json:"created_at"`
9 | }
10 |
11 | // DeployKeysService is used to access all DeployKey related API methods
12 | type DeployKeysService struct {
13 | site *Site
14 | client *Client
15 | }
16 |
17 | // Create a new deploy key for use with continuous deployment
18 | func (d *DeployKeysService) Create() (*DeployKey, *Response, error) {
19 | deployKey := &DeployKey{}
20 |
21 | resp, err := d.client.Request("POST", "/deploy_keys", &RequestOptions{}, deployKey)
22 |
23 | return deployKey, resp, err
24 | }
25 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | node {
2 | def err = null
3 | def project = "netlify-go"
4 |
5 | stage "Checkout code"
6 | checkout scm
7 |
8 | stage "Run CI script"
9 | try {
10 | sh "script/ci.sh ${project}"
11 | } catch (Exception e) {
12 | currentBuild.result = "FAILURE"
13 | err = e
14 | }
15 |
16 | stage "Notify"
17 | def message = "succeeded"
18 | def color = "good"
19 |
20 | if (currentBuild.result == "FAILURE") {
21 | message = "failed"
22 | color = "danger"
23 | }
24 | slackSend message: "Build ${message} - ${env.JOB_NAME} ${env.BUILD_NUMBER} (<${env.BUILD_URL}/console|Open>)", color: color
25 |
26 | if (err) {
27 | // throw error again to propagate build failure.
28 | // Otherwise Jenkins thinks that the pipeline completed successfully.
29 | throw err
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014 Mathias Biilmann Christensen
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a
4 | copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included
12 | in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/glide.lock:
--------------------------------------------------------------------------------
1 | hash: fabe8181e5f585b01f4f10362f93194dd68c3d24796695f1db6bdbbabc29dfb3
2 | updated: 2017-07-05T14:28:10.756432266-07:00
3 | imports:
4 | - name: github.com/cenkalti/backoff
5 | version: 32cd0c5b3aef12c76ed64aaf678f6c79736be7dc
6 | - name: github.com/golang/protobuf
7 | version: 6a1fa9404c0aebf36c879bc50152edcc953910d2
8 | subpackages:
9 | - proto
10 | - name: github.com/sirupsen/logrus
11 | version: 202f25545ea4cf9b191ff7f846df5d87c9382c2b
12 | - name: golang.org/x/net
13 | version: 570fa1c91359c1869590e9cedf3b53162a51a167
14 | subpackages:
15 | - context
16 | - context/ctxhttp
17 | - name: golang.org/x/oauth2
18 | version: cce311a261e6fcf29de72ca96827bdb0b7d9c9e6
19 | subpackages:
20 | - internal
21 | - name: golang.org/x/sys
22 | version: 6faef541c73732f438fb660a212750a9ba9f9362
23 | subpackages:
24 | - unix
25 | - name: google.golang.org/appengine
26 | version: d11fc8a8dccf41ab5bd3c51a3ea52051240e325c
27 | subpackages:
28 | - urlfetch
29 | - internal
30 | - internal/urlfetch
31 | - internal/base
32 | - internal/datastore
33 | - internal/log
34 | - internal/remote_api
35 | testImports: []
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # BitBallon API Client in Go
2 |
3 | See [the netlify package on godoc](http://godoc.org/github.com/netlify/netlify-go) for full library documentation.
4 |
5 | ## Quick Start
6 |
7 | First `go get github.com/netlify/netlify-go` then use in your go project.
8 |
9 | ```go
10 | import "github.com/netlify/netlify-go"
11 |
12 | client := netlify.NewClient(&netlify.Config{AccessToken: AccessToken})
13 |
14 | // Create a new site
15 | site, resp, err := client.Sites.Create(&SiteAttributes{
16 | Name: "site-subdomain",
17 | CustomDomain: "www.example.com",
18 | Password: "secret",
19 | NotificationEmail: "me@example.com",
20 | })
21 |
22 | // Deploy a directory
23 | deploy, resp, err := site.Deploys.Create("/path/to/directory")
24 |
25 | // Wait for the deploy to process
26 | err := deploy.WaitForReady(0)
27 |
28 | // Get a single site
29 | site, resp, err := client.Sites.Get("my-site-id")
30 |
31 | // Set the domain of the site
32 | site.CustomDomain = "www.example.com"
33 |
34 | // Update the site
35 | resp, err := site.Update()
36 |
37 | // Deploy a new version of the site from a zip file
38 | deploy, resp, err := site.Deploys.Create("/path/to/file.zip")
39 | deploy.WaitForReady(0)
40 |
41 | // Delete the site
42 | resp, err := site.Destroy()
43 | ```
44 |
--------------------------------------------------------------------------------
/timestamp.go:
--------------------------------------------------------------------------------
1 | // Copyright 2013 The go-github AUTHORS. All rights reserved.
2 | //
3 | // Use of this source code is governed by a BSD-style
4 | // license that can be found in the LICENSE file.
5 |
6 | package netlify
7 |
8 | import (
9 | "strconv"
10 | "time"
11 | )
12 |
13 | // Timestamp represents a time that can be unmarshalled from a JSON string
14 | // formatted as either an RFC3339 or Unix timestamp. This is necessary for some
15 | // fields since the GitHub API is inconsistent in how it represents times. All
16 | // exported methods of time.Time can be called on Timestamp.
17 | type Timestamp struct {
18 | time.Time
19 | }
20 |
21 | func (t Timestamp) String() string {
22 | return t.Time.String()
23 | }
24 |
25 | // UnmarshalJSON implements the json.Unmarshaler interface.
26 | // Time is expected in RFC3339 or Unix format.
27 | func (t *Timestamp) UnmarshalJSON(data []byte) (err error) {
28 | str := string(data)
29 | i, err := strconv.ParseInt(str, 10, 64)
30 | if err == nil {
31 | (*t).Time = time.Unix(i, 0)
32 | } else {
33 | (*t).Time, err = time.Parse(`"`+time.RFC3339+`"`, str)
34 | }
35 | return
36 | }
37 |
38 | // Equal reports whether t and u are equal based on time.Equal
39 | func (t Timestamp) Equal(u Timestamp) bool {
40 | return t.Time.Equal(u.Time)
41 | }
42 |
--------------------------------------------------------------------------------
/sites_test.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "reflect"
7 | "testing"
8 | )
9 |
10 | func TestSitesService_List(t *testing.T) {
11 | setup()
12 | defer teardown()
13 |
14 | mux.HandleFunc("/api/v1/sites", func(w http.ResponseWriter, r *http.Request) {
15 | testMethod(t, r, "GET")
16 | fmt.Fprint(w, `[{"id":"first"},{"id":"second"}]`)
17 | })
18 |
19 | sites, _, err := client.Sites.List(&ListOptions{})
20 | if err != nil {
21 | t.Errorf("Sites.List returned an error: %v", err)
22 | }
23 |
24 | expected := []Site{{Id: "first"}, {Id: "second"}}
25 | if !reflect.DeepEqual(sites, expected) {
26 | t.Errorf("Expected Sites.List to return %v, returned %v", expected, sites)
27 | }
28 | }
29 |
30 | func TestSitesService_List_With_Pagination(t *testing.T) {
31 | setup()
32 | defer teardown()
33 |
34 | mux.HandleFunc("/api/v1/sites", func(w http.ResponseWriter, r *http.Request) {
35 | testMethod(t, r, "GET")
36 | testFormValues(t, r, map[string]string{"page": "2", "per_page": "10"})
37 | fmt.Fprint(w, `[{"id":"first"},{"id":"second"}]`)
38 | })
39 |
40 | sites, _, err := client.Sites.List(&ListOptions{Page: 2, PerPage: 10})
41 | if err != nil {
42 | t.Errorf("Sites.List returned an error: %v", err)
43 | }
44 |
45 | expected := []Site{{Id: "first"}, {Id: "second"}}
46 | if !reflect.DeepEqual(sites, expected) {
47 | t.Errorf("Expected Sites.List to return %v, returned %v", expected, sites)
48 | }
49 | }
50 |
51 | func TestSitesService_Get(t *testing.T) {
52 | setup()
53 | defer teardown()
54 |
55 | mux.HandleFunc("/api/v1/sites/my-site", func(w http.ResponseWriter, r *http.Request) {
56 | testMethod(t, r, "GET")
57 | fmt.Fprint(w, `{"id":"my-site"}`)
58 | })
59 |
60 | site, _, err := client.Sites.Get("my-site")
61 | if err != nil {
62 | t.Errorf("Sites.Get returned an error: %v", err)
63 | }
64 |
65 | if site.Id != "my-site" {
66 | t.Errorf("Expected Sites.Get to return my-site, returned %v", site.Id)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package netlify provides a client for using the netlify API.
3 |
4 | To work with the netlify API, start by instantiating a client:
5 |
6 | client := netlify.NewClient(&netlify.Config{AccessToken: AccessToken})
7 |
8 | // List sites
9 | sites, resp, err := client.Sites.List(&netlify.ListOptions{Page: 1})
10 |
11 | // Create a new site
12 | site, resp, err := client.Sites.Create(&SiteAttributes{
13 | Name: "site-subdomain",
14 | CustomDomain: "www.example.com",
15 | Password: "secret",
16 | NotificationEmail: "me@example.com",
17 | })
18 |
19 | // Deploy a directory
20 | deploy, resp, err := site.Deploys.Create("/path/to/directory")
21 |
22 | // Wait for the deploy to process
23 | err := deploy.WaitForReady(0)
24 |
25 | // Get a single site
26 | site, resp, err := client.Sites.Get("my-site-id")
27 |
28 | // Set the domain of the site
29 | site.CustomDomain = "www.example.com"
30 |
31 | // Update the site
32 | resp, err := site.Update()
33 |
34 | // Deploy a new version of the site from a zip file
35 | deploy, resp, err := site.Deploys.Create("/path/to/file.zip")
36 | deploy.WaitForReady(0)
37 |
38 | // Configure Continuous Deployment for a site
39 |
40 | // First get a deploy key
41 | deployKey, resp, err := site.DeployKeys.Create()
42 | // Then make sure the public key (deployKey.PublicKey)
43 | // has access to the repository
44 |
45 | // Configure the repo
46 | resp, err = site.ContinuousDeployment(&nefliy.RepoOptions{
47 | Repo: "netlify/netlify-home",
48 | Provider: "github",
49 | Dir: "_site",
50 | Cmd: "gulp build",
51 | Branch: "master",
52 | DeployKeyId: deployKey.Id
53 | })
54 | if err != nil {
55 | // Now make sure to add this URL as a POST webhook to your
56 | // repository:
57 | site.DeployHook
58 | }
59 |
60 |
61 | // Deleting a site
62 | resp, err := site.Destroy()
63 | */
64 | package netlify
65 |
--------------------------------------------------------------------------------
/netlify_test.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "net/url"
7 | "reflect"
8 | "testing"
9 | )
10 |
11 | var (
12 | mux *http.ServeMux
13 | client *Client
14 | server *httptest.Server
15 | )
16 |
17 | func setup() {
18 | mux = http.NewServeMux()
19 | server = httptest.NewServer(mux)
20 |
21 | client = NewClient(&Config{HttpClient: http.DefaultClient, BaseUrl: server.URL})
22 | }
23 |
24 | func teardown() {
25 | server.Close()
26 | }
27 |
28 | func testMethod(t *testing.T, r *http.Request, expected string) {
29 | if expected != r.Method {
30 | t.Errorf("Request method = %v, expected %v", r.Method, expected)
31 | }
32 | }
33 |
34 | type values map[string]string
35 |
36 | func testFormValues(t *testing.T, r *http.Request, values values) {
37 | want := url.Values{}
38 | for k, v := range values {
39 | want.Add(k, v)
40 | }
41 |
42 | r.ParseForm()
43 | if !reflect.DeepEqual(want, r.Form) {
44 | t.Errorf("Request parameters = %v, want %v", r.Form, want)
45 | }
46 | }
47 |
48 | func TestResponse_populatePageValues(t *testing.T) {
49 | r := http.Response{
50 | Header: http.Header{
51 | "Link": {`; rel="first",` +
52 | ` ; rel="prev",` +
53 | ` ; rel="next",` +
54 | ` ; rel="last"`,
55 | },
56 | },
57 | }
58 |
59 | response := newResponse(&r)
60 | if expected, got := 1, response.FirstPage; expected != got {
61 | t.Errorf("response.FirstPage: %v, expected %v", got, expected)
62 | }
63 | if expected, got := 2, response.PrevPage; expected != got {
64 | t.Errorf("response.PrevPage: %v, expected %v", got, expected)
65 | }
66 | if expected, got := 4, response.NextPage; expected != got {
67 | t.Errorf("response.NextPage: %v, expected %v", got, expected)
68 | }
69 | if expected, got := 5, response.LastPage; expected != got {
70 | t.Errorf("response.LastPage: %v, expected %v", got, expected)
71 | }
72 | }
73 |
74 | func TestListOptionsToQueryParams(t *testing.T) {
75 | cases := []struct {
76 | o *ListOptions
77 | e string
78 | }{
79 | {nil, ""},
80 | {&ListOptions{}, ""},
81 | {&ListOptions{Page: 0, PerPage: 0}, ""},
82 | {&ListOptions{Page: 0, PerPage: 1}, "per_page=1"},
83 | {&ListOptions{Page: 1, PerPage: 0}, "page=1"},
84 | {&ListOptions{Page: 1, PerPage: 1}, "page=1&per_page=1"},
85 | }
86 |
87 | for _, cs := range cases {
88 | g := cs.o.toQueryParamsMap()
89 | if g.Encode() != cs.e {
90 | t.Errorf("expected %s, got %s\n", cs.e, g.Encode())
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/deploys_test.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "net/http"
7 | "reflect"
8 | "strings"
9 | "testing"
10 | )
11 |
12 | func TestDeploysService_List(t *testing.T) {
13 | setup()
14 | defer teardown()
15 |
16 | mux.HandleFunc("/api/v1/deploys", func(w http.ResponseWriter, r *http.Request) {
17 | testMethod(t, r, "GET")
18 | fmt.Fprint(w, `[{"id":"first"},{"id":"second"}]`)
19 | })
20 |
21 | deploys, _, err := client.Deploys.List(&ListOptions{})
22 | if err != nil {
23 | t.Errorf("Deploys.List returned an error: %v", err)
24 | }
25 |
26 | expected := []Deploy{{Id: "first"}, {Id: "second"}}
27 | if !reflect.DeepEqual(deploys, expected) {
28 | t.Errorf("Expected Deploys.List to return %v, returned %v", expected, deploys)
29 | }
30 | }
31 |
32 | func TestDeploysService_List_For_Site(t *testing.T) {
33 | setup()
34 | defer teardown()
35 |
36 | mux.HandleFunc("/api/v1/sites/first-site/deploys", func(w http.ResponseWriter, r *http.Request) {
37 | testMethod(t, r, "GET")
38 | fmt.Fprint(w, `[{"id":"first"},{"id":"second"}]`)
39 | })
40 |
41 | site := &Site{Id: "first-site", client: client}
42 | site.Deploys = &DeploysService{client: client, site: site}
43 |
44 | deploys, _, err := site.Deploys.List(&ListOptions{})
45 | if err != nil {
46 | t.Errorf("Deploys.List returned an error: %v", err)
47 | }
48 |
49 | expected := []Deploy{{Id: "first"}, {Id: "second"}}
50 | if !reflect.DeepEqual(deploys, expected) {
51 | t.Errorf("Expected Deploys.List to return %v, returned %v", expected, deploys)
52 | }
53 | }
54 |
55 | func TestDeploysService_Get(t *testing.T) {
56 | setup()
57 | defer teardown()
58 |
59 | mux.HandleFunc("/api/v1/deploys/my-deploy", func(w http.ResponseWriter, r *http.Request) {
60 | testMethod(t, r, "GET")
61 | fmt.Fprint(w, `{"id":"my-deploy"}`)
62 | })
63 |
64 | deploy, _, err := client.Deploys.Get("my-deploy")
65 | if err != nil {
66 | t.Errorf("Sites.Get returned an error: %v", err)
67 | }
68 |
69 | if deploy.Id != "my-deploy" {
70 | t.Errorf("Expected Sites.Get to return my-deploy, returned %v", deploy.Id)
71 | }
72 | }
73 |
74 | func TestDeploysService_Create(t *testing.T) {
75 | setup()
76 | defer teardown()
77 |
78 | mux.HandleFunc("/api/v1/sites/my-site/deploys", func(w http.ResponseWriter, r *http.Request) {
79 | testMethod(t, r, "POST")
80 |
81 | r.ParseForm()
82 | if _, ok := r.Form["draft"]; ok {
83 | t.Errorf("Draft should not be a query parameter for a normal deploy")
84 | }
85 |
86 | fmt.Fprint(w, `{"id":"my-deploy"})`)
87 | })
88 |
89 | mux.HandleFunc("/api/v1/deploys/my-deploy", func(w http.ResponseWriter, r *http.Request) {
90 | testMethod(t, r, "PUT")
91 |
92 | buf := new(bytes.Buffer)
93 | buf.ReadFrom(r.Body)
94 |
95 | expected := `{"files":{"index.html":"3c7d0500e11e9eb9954ad3d9c2a1bd8b0fa06d88","style.css":"7b797fc1c66448cd8685c5914a571763e8a213da"},"async":false}`
96 | if expected != strings.TrimSpace(buf.String()) {
97 | t.Errorf("Expected JSON: %v\nGot JSON: %v", expected, buf.String())
98 | }
99 |
100 | fmt.Fprint(w, `{"id":"my-deploy"}`)
101 | })
102 |
103 | site := &Site{Id: "my-site"}
104 | deploys := &DeploysService{client: client, site: site}
105 | deploy, _, err := deploys.Create("test-site/folder")
106 |
107 | if err != nil {
108 | t.Errorf("Deploys.Create returned and error: %v", err)
109 | }
110 |
111 | if deploy.Id != "my-deploy" {
112 | t.Errorf("Expected Deploys.Create to return my-deploy, returned %v", deploy.Id)
113 | }
114 | }
115 |
116 | func TestDeploysService_CreateDraft(t *testing.T) {
117 | setup()
118 | defer teardown()
119 |
120 | mux.HandleFunc("/api/v1/sites/my-site/deploys", func(w http.ResponseWriter, r *http.Request) {
121 | testMethod(t, r, "POST")
122 |
123 | r.ParseForm()
124 |
125 | value := r.Form["draft"]
126 | if len(value) == 0 {
127 | t.Errorf("No draft query parameter, should be specified")
128 | return
129 | }
130 |
131 | draft := value[0]
132 | if draft != "true" {
133 | t.Errorf("Draft should be true but was %v", r.Form["draft"])
134 | return
135 | }
136 |
137 | fmt.Fprint(w, `{"id":"my-deploy"})`)
138 | })
139 |
140 | mux.HandleFunc("/api/v1/deploys/my-deploy", func(w http.ResponseWriter, r *http.Request) {
141 | buf := new(bytes.Buffer)
142 | buf.ReadFrom(r.Body)
143 |
144 | expected := `{"files":{"index.html":"3c7d0500e11e9eb9954ad3d9c2a1bd8b0fa06d88","style.css":"7b797fc1c66448cd8685c5914a571763e8a213da"},"async":false}`
145 | if expected != strings.TrimSpace(buf.String()) {
146 | t.Errorf("Expected JSON: %v\nGot JSON: %v", expected, buf.String())
147 | }
148 |
149 | fmt.Fprint(w, `{"id":"my-deploy"}`)
150 | })
151 |
152 | site := &Site{Id: "my-site"}
153 | deploys := &DeploysService{client: client, site: site}
154 | deploy, _, err := deploys.CreateDraft("test-site/folder")
155 |
156 | if err != nil {
157 | t.Errorf("Deploys.Create returned and error: %v", err)
158 | }
159 |
160 | if deploy.Id != "my-deploy" {
161 | t.Errorf("Expected Deploys.Create to return my-deploy, returned %v", deploy.Id)
162 | }
163 | }
164 |
165 | func TestDeploysService_Create_Zip(t *testing.T) {
166 | setup()
167 | defer teardown()
168 |
169 | mux.HandleFunc("/api/v1/sites/my-site/deploys", func(w http.ResponseWriter, r *http.Request) {
170 | testMethod(t, r, "POST")
171 | r.ParseForm()
172 | if _, ok := r.Form["draft"]; ok {
173 | t.Errorf("Draft should not be a query parameter for a normal deploy")
174 | }
175 |
176 | fmt.Fprint(w, `{"id":"my-deploy"})`)
177 | })
178 |
179 | mux.HandleFunc("/api/v1/deploys/my-deploy", func(w http.ResponseWriter, r *http.Request) {
180 | testMethod(t, r, "PUT")
181 |
182 | if r.Header["Content-Type"][0] != "application/zip" {
183 | t.Errorf("Deploying a zip should set the content type to application/zip")
184 | return
185 | }
186 |
187 | fmt.Fprint(w, `{"id":"my-deploy"}`)
188 | })
189 |
190 | site := &Site{Id: "my-site"}
191 | deploys := &DeploysService{client: client, site: site}
192 | deploy, _, err := deploys.Create("test-site/archive.zip")
193 |
194 | if err != nil {
195 | t.Errorf("Deploys.Create returned and error: %v", err)
196 | }
197 |
198 | if deploy.Id != "my-deploy" {
199 | t.Errorf("Expected Deploys.Create to return my-deploy, returned %v", deploy.Id)
200 | }
201 | }
202 |
203 | func TestDeploysService_CreateDraft_Zip(t *testing.T) {
204 | setup()
205 | defer teardown()
206 |
207 | mux.HandleFunc("/api/v1/sites/my-site/deploys", func(w http.ResponseWriter, r *http.Request) {
208 | testMethod(t, r, "POST")
209 | r.ParseForm()
210 | if val, ok := r.Form["draft"]; ok == false || val[0] != "true" {
211 | t.Errorf("Draft should be a true parameter for a draft deploy")
212 | }
213 | fmt.Fprint(w, `{"id":"my-deploy"})`)
214 | })
215 |
216 | mux.HandleFunc("/api/v1/deploys/my-deploy", func(w http.ResponseWriter, r *http.Request) {
217 | testMethod(t, r, "PUT")
218 |
219 | if r.Header["Content-Type"][0] != "application/zip" {
220 | t.Errorf("Deploying a zip should set the content type to application/zip")
221 | return
222 | }
223 |
224 | fmt.Fprint(w, `{"id":"my-deploy"}`)
225 | })
226 |
227 | site := &Site{Id: "my-site"}
228 | deploys := &DeploysService{client: client, site: site}
229 | deploy, _, err := deploys.CreateDraft("test-site/archive.zip")
230 |
231 | if err != nil {
232 | t.Errorf("Deploys.Create returned an error: %v", err)
233 | }
234 |
235 | if deploy.Id != "my-deploy" {
236 | t.Errorf("Expected Deploys.Create to return my-deploy, returned %v", deploy.Id)
237 | }
238 | }
239 |
--------------------------------------------------------------------------------
/sites.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
3 | import (
4 | "errors"
5 | "path"
6 | "time"
7 | )
8 |
9 | var (
10 | defaultTimeout time.Duration = 5 * 60 // 5 minutes
11 | )
12 |
13 | // SitesService is used to access all Site related API methods
14 | type SitesService struct {
15 | client *Client
16 | }
17 |
18 | // Site represents a netlify Site
19 | type Site struct {
20 | Id string `json:"id"`
21 | UserId string `json:"user_id"`
22 |
23 | // These fields can be updated through the API
24 | Name string `json:"name"`
25 | CustomDomain string `json:"custom_domain"`
26 | DomainAliases []string `json:"domain_aliases"`
27 | Password string `json:"password"`
28 | NotificationEmail string `json:"notification_email"`
29 |
30 | State string `json:"state"`
31 | Plan string `json:"plan"`
32 | SSLPlan string `json:"ssl_plan"`
33 | Premium bool `json:"premium"`
34 | Claimed bool `json:"claimed"`
35 |
36 | Url string `json:"url"`
37 | AdminUrl string `json:"admin_url"`
38 | DeployUrl string `json:"deploy_url"`
39 | ScreenshotUrl string `json:"screenshot_url"`
40 |
41 | SSL bool `json:"ssl"`
42 | ForceSSL bool `json:"force_ssl"`
43 |
44 | BuildSettings *BuildSettings `json:"build_settings"`
45 | ProcessingSettings *ProcessingSettings `json:"processing_settings"`
46 |
47 | DeployHook string `json:"deploy_hook"`
48 |
49 | CreatedAt Timestamp `json:"created_at"`
50 | UpdatedAt Timestamp `json:"updated_at"`
51 |
52 | // Access deploys for this site
53 | Deploys *DeploysService
54 |
55 | client *Client
56 | }
57 |
58 | // Info returned when creating a new deploy
59 | type DeployInfo struct {
60 | Id string `json:"id"`
61 | DeployId string `json:"deploy_id"`
62 | Required []string `json:"required"`
63 | }
64 |
65 | // Settings for continuous deployment
66 | type BuildSettings struct {
67 | RepoType string `json:"repo_type"`
68 | RepoURL string `json:"repo_url"`
69 | RepoBranch string `json:"repo_branch"`
70 | Cmd string `json:"cmd"`
71 | Dir string `json:"dir"`
72 | Env map[string]string `json:"env"`
73 |
74 | CreatedAt Timestamp `json:"created_at"`
75 | UpdatedAt Timestamp `json:"updated_at"`
76 | }
77 |
78 | // Settings for post processing
79 | type ProcessingSettings struct {
80 | CSS struct {
81 | Minify bool `json:"minify"`
82 | Bundle bool `json:"bundle"`
83 | } `json:"css"`
84 | JS struct {
85 | Minify bool `json:"minify"`
86 | Bundle bool `json:"bundle"`
87 | } `json:"js"`
88 | HTML struct {
89 | PrettyURLs bool `json:"pretty_urls"`
90 | } `json:"html"`
91 | Images struct {
92 | Optimize bool `json:"optimize"`
93 | } `json:"images"`
94 | Skip bool `json:"skip"`
95 | }
96 |
97 | // Attributes for Sites.Create
98 | type SiteAttributes struct {
99 | Name string `json:"name"`
100 | CustomDomain string `json:"custom_domain"`
101 | Password string `json:"password"`
102 | NotificationEmail string `json:"notification_email"`
103 |
104 | ForceSSL bool `json:"force_ssl"`
105 |
106 | ProcessingSettings bool `json:"processing_options"`
107 |
108 | Repo *RepoOptions `json:"repo"`
109 | }
110 |
111 | // Attributes for site.ProvisionCert
112 | type CertOptions struct {
113 | Certificate string `json:"certificate"`
114 | Key string `json:"key"`
115 | CaCertificates []string `json:"ca_certificates"`
116 | }
117 |
118 | // Attributes for setting up continuous deployment
119 | type RepoOptions struct {
120 | // GitHub API ID or similar unique repo ID
121 | Id string `json:"id"`
122 |
123 | // Repo path. Full ssh based path for manual repos,
124 | // username/reponame for GitHub or BitBucket
125 | Repo string `json:"repo"`
126 |
127 | // Currently "github", "bitbucket" or "manual"
128 | Provider string `json:"provider"`
129 |
130 | // Directory to deploy after building
131 | Dir string `json:"dir"`
132 |
133 | // Build command
134 | Cmd string `json:"cmd"`
135 |
136 | // Branch to pull from
137 | Branch string `json:"branch"`
138 |
139 | // Build environment variables
140 | Env *map[string]string `json:"env"`
141 |
142 | // ID of a netlify deploy key used to access the repo
143 | DeployKeyID string `json:"deploy_key_id"`
144 | }
145 |
146 | // Get a single Site from the API. The id can be either a site Id or the domain
147 | // of a site (ie. site.Get("mysite.netlify.com"))
148 | func (s *SitesService) Get(id string) (*Site, *Response, error) {
149 | site := &Site{Id: id, client: s.client}
150 | site.Deploys = &DeploysService{client: s.client, site: site}
151 | resp, err := site.Reload()
152 |
153 | return site, resp, err
154 | }
155 |
156 | // Create a new empty site.
157 | func (s *SitesService) Create(attributes *SiteAttributes) (*Site, *Response, error) {
158 | site := &Site{client: s.client}
159 | site.Deploys = &DeploysService{client: s.client, site: site}
160 |
161 | reqOptions := &RequestOptions{JsonBody: attributes}
162 |
163 | resp, err := s.client.Request("POST", "/sites", reqOptions, site)
164 |
165 | return site, resp, err
166 | }
167 |
168 | // List all sites you have access to. Takes ListOptions to control pagination.
169 | func (s *SitesService) List(options *ListOptions) ([]Site, *Response, error) {
170 | sites := new([]Site)
171 |
172 | reqOptions := &RequestOptions{QueryParams: options.toQueryParamsMap()}
173 |
174 | resp, err := s.client.Request("GET", "/sites", reqOptions, sites)
175 |
176 | for _, site := range *sites {
177 | site.client = s.client
178 | site.Deploys = &DeploysService{client: s.client, site: &site}
179 | }
180 |
181 | return *sites, resp, err
182 | }
183 |
184 | func (site *Site) apiPath() string {
185 | return path.Join("/sites", site.Id)
186 | }
187 |
188 | func (site *Site) Reload() (*Response, error) {
189 | if site.Id == "" {
190 | return nil, errors.New("Cannot fetch site without an ID")
191 | }
192 | return site.client.Request("GET", site.apiPath(), nil, site)
193 | }
194 |
195 | // Update will update the fields that can be updated through the API
196 | func (site *Site) Update() (*Response, error) {
197 | options := &RequestOptions{JsonBody: site.mutableParams()}
198 |
199 | return site.client.Request("PUT", site.apiPath(), options, site)
200 | }
201 |
202 | // Configure Continuous Deployment for a site
203 | func (site *Site) ContinuousDeployment(repoOptions *RepoOptions) (*Response, error) {
204 | options := &RequestOptions{JsonBody: map[string]*RepoOptions{"repo": repoOptions}}
205 |
206 | return site.client.Request("PUT", site.apiPath(), options, site)
207 | }
208 |
209 | // Provision SSL Certificate for a site. Takes optional CertOptions to set a custom cert/chain/key.
210 | // Without this netlify will generate the certificate automatically.
211 | func (site *Site) ProvisionCert(certOptions *CertOptions) (*Response, error) {
212 | options := &RequestOptions{JsonBody: certOptions}
213 |
214 | return site.client.Request("POST", path.Join(site.apiPath(), "ssl"), options, nil)
215 | }
216 |
217 | // Destroy deletes a site permanently
218 | func (site *Site) Destroy() (*Response, error) {
219 | resp, err := site.client.Request("DELETE", site.apiPath(), nil, nil)
220 | if resp != nil && resp.Body != nil {
221 | resp.Body.Close()
222 | }
223 | return resp, err
224 | }
225 |
226 | func (site *Site) mutableParams() *SiteAttributes {
227 | return &SiteAttributes{
228 | Name: site.Name,
229 | CustomDomain: site.CustomDomain,
230 | Password: site.Password,
231 | NotificationEmail: site.NotificationEmail,
232 | ForceSSL: site.ForceSSL,
233 | }
234 | }
235 |
--------------------------------------------------------------------------------
/netlify.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "io"
8 | "io/ioutil"
9 | "net/http"
10 | "net/url"
11 | "path"
12 | "strconv"
13 | "strings"
14 | "time"
15 |
16 | "github.com/sirupsen/logrus"
17 |
18 | oauth "golang.org/x/oauth2"
19 | )
20 |
21 | const (
22 | libraryVersion = "0.1"
23 | defaultBaseURL = "https://api.netlify.com"
24 | apiVersion = "v1"
25 |
26 | userAgent = "netlify-go/" + libraryVersion
27 |
28 | DefaultMaxConcurrentUploads = 10
29 | )
30 |
31 | // Config is used to configure the netlify client.
32 | // Typically you'll just want to set an AccessToken
33 | type Config struct {
34 | AccessToken string
35 |
36 | ClientId string
37 | ClientSecret string
38 |
39 | BaseUrl string
40 | UserAgent string
41 |
42 | HttpClient *http.Client
43 | RequestTimeout time.Duration
44 |
45 | MaxConcurrentUploads int
46 | }
47 |
48 | func (c *Config) Token() (*oauth.Token, error) {
49 | return &oauth.Token{AccessToken: c.AccessToken}, nil
50 | }
51 |
52 | // The netlify Client
53 | type Client struct {
54 | client *http.Client
55 | log *logrus.Entry
56 |
57 | BaseUrl *url.URL
58 | UserAgent string
59 |
60 | Sites *SitesService
61 | Deploys *DeploysService
62 | DeployKeys *DeployKeysService
63 |
64 | MaxConcurrentUploads int
65 | }
66 |
67 | // netlify API Response.
68 | // All API methods on the different client services will return a Response object.
69 | // For any list operation this object will hold pagination information
70 | type Response struct {
71 | *http.Response
72 |
73 | NextPage int
74 | PrevPage int
75 | FirstPage int
76 | LastPage int
77 | }
78 |
79 | // RequestOptions for doing raw requests to the netlify API
80 | type RequestOptions struct {
81 | JsonBody interface{}
82 | RawBody io.Reader
83 | RawBodyLength int64
84 | QueryParams *url.Values
85 | Headers *map[string]string
86 | }
87 |
88 | // ErrorResponse is returned when a request to the API fails
89 | type ErrorResponse struct {
90 | Response *http.Response
91 | Message string
92 | }
93 |
94 | func (r *ErrorResponse) Error() string {
95 | return r.Message
96 | }
97 |
98 | // All List methods takes a ListOptions object controlling pagination
99 | type ListOptions struct {
100 | Page int
101 | PerPage int
102 | }
103 |
104 | func (o *ListOptions) toQueryParamsMap() *url.Values {
105 | params := url.Values{}
106 | if o != nil {
107 | if o.Page > 0 {
108 | params.Set("page", strconv.Itoa(o.Page))
109 | }
110 | if o.PerPage > 0 {
111 | params.Set("per_page", strconv.Itoa(o.PerPage))
112 | }
113 | }
114 | return ¶ms
115 | }
116 |
117 | // NewClient returns a new netlify API client
118 | func NewClient(config *Config) *Client {
119 | client := &Client{}
120 |
121 | if config.BaseUrl != "" {
122 | client.BaseUrl, _ = url.Parse(config.BaseUrl)
123 | } else {
124 | client.BaseUrl, _ = url.Parse(defaultBaseURL)
125 | }
126 |
127 | if config.HttpClient != nil {
128 | client.client = config.HttpClient
129 | } else if config.AccessToken != "" {
130 | client.client = oauth.NewClient(oauth.NoContext, config)
131 | if config.RequestTimeout > 0 {
132 | client.client.Timeout = config.RequestTimeout
133 | }
134 | }
135 |
136 | if &config.UserAgent != nil {
137 | client.UserAgent = config.UserAgent
138 | } else {
139 | client.UserAgent = userAgent
140 | }
141 |
142 | if config.MaxConcurrentUploads != 0 {
143 | client.MaxConcurrentUploads = config.MaxConcurrentUploads
144 | } else {
145 | client.MaxConcurrentUploads = DefaultMaxConcurrentUploads
146 | }
147 |
148 | log := logrus.New()
149 | log.Out = ioutil.Discard
150 | client.log = logrus.NewEntry(log)
151 |
152 | client.Sites = &SitesService{client: client}
153 | client.Deploys = &DeploysService{client: client}
154 |
155 | return client
156 | }
157 |
158 | func (c *Client) SetLogger(log *logrus.Entry) {
159 | if log != nil {
160 | c.log = logrus.NewEntry(logrus.StandardLogger())
161 | }
162 | c.log = log
163 | }
164 |
165 | func (c *Client) newRequest(method, apiPath string, options *RequestOptions) (*http.Request, error) {
166 | if c.client == nil {
167 | return nil, errors.New("Client has not been authenticated")
168 | }
169 |
170 | urlPath := path.Join("api", apiVersion, apiPath)
171 | if options != nil && options.QueryParams != nil && len(*options.QueryParams) > 0 {
172 | urlPath = urlPath + "?" + options.QueryParams.Encode()
173 | }
174 | rel, err := url.Parse(urlPath)
175 | if err != nil {
176 | return nil, err
177 | }
178 |
179 | u := c.BaseUrl.ResolveReference(rel)
180 |
181 | buf := new(bytes.Buffer)
182 |
183 | if options != nil && options.JsonBody != nil {
184 | err := json.NewEncoder(buf).Encode(options.JsonBody)
185 | if err != nil {
186 | return nil, err
187 | }
188 | }
189 |
190 | var req *http.Request
191 |
192 | if options != nil && options.RawBody != nil {
193 | req, err = http.NewRequest(method, u.String(), options.RawBody)
194 | req.ContentLength = options.RawBodyLength
195 | } else {
196 | req, err = http.NewRequest(method, u.String(), buf)
197 | }
198 | if err != nil {
199 | return nil, err
200 | }
201 |
202 | req.Close = true
203 |
204 | req.TransferEncoding = []string{"identity"}
205 |
206 | req.Header.Add("Accept", "application/json")
207 | req.Header.Add("User-Agent", c.UserAgent)
208 |
209 | if options != nil && options.JsonBody != nil {
210 | req.Header.Set("Content-Type", "application/json")
211 | }
212 |
213 | if options != nil && options.Headers != nil {
214 | for key, value := range *options.Headers {
215 | req.Header.Set(key, value)
216 | }
217 | }
218 |
219 | return req, nil
220 | }
221 |
222 | // Request sends an authenticated HTTP request to the netlify API
223 | //
224 | // When error is nil, resp always contains a non-nil Response object
225 | //
226 | // Generally methods on the various services should be used over raw API requests
227 | func (c *Client) Request(method, path string, options *RequestOptions, decodeTo interface{}) (*Response, error) {
228 | var httpResponse *http.Response
229 | req, err := c.newRequest(method, path, options)
230 | if err != nil {
231 | return nil, err
232 | }
233 |
234 | if c.idempotent(req) && (options == nil || options.RawBody == nil) {
235 | httpResponse, err = c.doWithRetry(req, 3)
236 | } else {
237 | httpResponse, err = c.client.Do(req)
238 | }
239 |
240 | resp := newResponse(httpResponse)
241 |
242 | if err != nil {
243 | return resp, err
244 | }
245 |
246 | if err = checkResponse(httpResponse); err != nil {
247 | return resp, err
248 | }
249 |
250 | if decodeTo != nil {
251 | defer httpResponse.Body.Close()
252 | if writer, ok := decodeTo.(io.Writer); ok {
253 | io.Copy(writer, httpResponse.Body)
254 | } else {
255 | err = json.NewDecoder(httpResponse.Body).Decode(decodeTo)
256 | }
257 | }
258 | return resp, err
259 | }
260 |
261 | func (c *Client) idempotent(req *http.Request) bool {
262 | switch req.Method {
263 | case "GET", "PUT", "DELETE":
264 | return true
265 | default:
266 | return false
267 | }
268 | }
269 |
270 | func (c *Client) rewindRequestBody(req *http.Request) error {
271 | if req.Body == nil {
272 | return nil
273 | }
274 | body, ok := req.Body.(io.Seeker)
275 | if ok {
276 | _, err := body.Seek(0, 0)
277 | return err
278 | }
279 | return errors.New("Body is not a seeker")
280 | }
281 |
282 | func (c *Client) doWithRetry(req *http.Request, tries int) (*http.Response, error) {
283 | httpResponse, err := c.client.Do(req)
284 |
285 | tries--
286 |
287 | if tries > 0 && (err != nil || httpResponse.StatusCode >= 400) {
288 | if err := c.rewindRequestBody(req); err != nil {
289 | return c.doWithRetry(req, tries)
290 | }
291 | }
292 |
293 | return httpResponse, err
294 | }
295 |
296 | func newResponse(r *http.Response) *Response {
297 | response := &Response{Response: r}
298 | if r != nil {
299 | response.populatePageValues()
300 | }
301 | return response
302 | }
303 |
304 | func checkResponse(r *http.Response) error {
305 | if c := r.StatusCode; 200 <= c && c <= 299 {
306 | return nil
307 | }
308 | errorResponse := &ErrorResponse{Response: r}
309 | if r.StatusCode == 403 || r.StatusCode == 401 {
310 | errorResponse.Message = "Access Denied"
311 | return errorResponse
312 | }
313 |
314 | data, err := ioutil.ReadAll(r.Body)
315 | if err == nil && data != nil {
316 | errorResponse.Message = string(data)
317 | } else {
318 | errorResponse.Message = r.Status
319 | }
320 |
321 | return errorResponse
322 | }
323 |
324 | // populatePageValues parses the HTTP Link response headers and populates the
325 | // various pagination link values in the Reponse.
326 | func (r *Response) populatePageValues() {
327 | if links, ok := r.Response.Header["Link"]; ok && len(links) > 0 {
328 | for _, link := range strings.Split(links[0], ",") {
329 | segments := strings.Split(strings.TrimSpace(link), ";")
330 |
331 | // link must at least have href and rel
332 | if len(segments) < 2 {
333 | continue
334 | }
335 |
336 | // ensure href is properly formatted
337 | if !strings.HasPrefix(segments[0], "<") || !strings.HasSuffix(segments[0], ">") {
338 | continue
339 | }
340 |
341 | // try to pull out page parameter
342 | url, err := url.Parse(segments[0][1 : len(segments[0])-1])
343 | if err != nil {
344 | continue
345 | }
346 | page := url.Query().Get("page")
347 | if page == "" {
348 | continue
349 | }
350 |
351 | for _, segment := range segments[1:] {
352 | switch strings.TrimSpace(segment) {
353 | case `rel="next"`:
354 | r.NextPage, _ = strconv.Atoi(page)
355 | case `rel="prev"`:
356 | r.PrevPage, _ = strconv.Atoi(page)
357 | case `rel="first"`:
358 | r.FirstPage, _ = strconv.Atoi(page)
359 | case `rel="last"`:
360 | r.LastPage, _ = strconv.Atoi(page)
361 | }
362 |
363 | }
364 | }
365 | }
366 | }
367 |
--------------------------------------------------------------------------------
/deploys.go:
--------------------------------------------------------------------------------
1 | package netlify
2 |
3 | import (
4 | "crypto/sha1"
5 | "encoding/hex"
6 | "errors"
7 | "io/ioutil"
8 | "net/url"
9 | "os"
10 | "path"
11 | "path/filepath"
12 | "strings"
13 | "sync"
14 | "time"
15 |
16 | "github.com/cenkalti/backoff"
17 | "github.com/sirupsen/logrus"
18 | )
19 |
20 | const MaxFilesForSyncDeploy = 1000
21 | const PreProcessingTimeout = time.Minute * 5
22 |
23 | // Deploy represents a specific deploy of a site
24 | type Deploy struct {
25 | Id string `json:"id"`
26 | SiteId string `json:"site_id"`
27 | UserId string `json:"user_id"`
28 |
29 | // State of the deploy (uploading/uploaded/processing/ready/error)
30 | State string `json:"state"`
31 |
32 | // Cause of error if State is "error"
33 | ErrorMessage string `json:"error_message"`
34 |
35 | // Shas of files that needs to be uploaded before the deploy is ready
36 | Required []string `json:"required"`
37 |
38 | DeployUrl string `json:"deploy_url"`
39 | SiteUrl string `json:"url"`
40 | ScreenshotUrl string `json:"screenshot_url"`
41 |
42 | CreatedAt Timestamp `json:"created_at"`
43 | UpdatedAt Timestamp `json:"updated_at"`
44 |
45 | Branch string `json:"branch,omitempty"`
46 | CommitRef string `json:"commit_ref,omitempty"`
47 |
48 | client *Client
49 | logger *logrus.Entry
50 | }
51 |
52 | func (d Deploy) log() *logrus.Entry {
53 | if d.logger == nil {
54 | d.logger = d.client.log.WithFields(logrus.Fields{
55 | "function": "deploy",
56 | "id": d.Id,
57 | "site_id": d.SiteId,
58 | "user_id": d.UserId,
59 | })
60 | }
61 |
62 | return d.logger.WithField("state", d.State)
63 | }
64 |
65 | // DeploysService is used to access all Deploy related API methods
66 | type DeploysService struct {
67 | site *Site
68 | client *Client
69 | }
70 |
71 | type uploadError struct {
72 | err error
73 | mutex *sync.Mutex
74 | }
75 |
76 | func (u *uploadError) Set(err error) {
77 | if err != nil {
78 | u.mutex.Lock()
79 | defer u.mutex.Unlock()
80 | if u.err == nil {
81 | u.err = err
82 | }
83 | }
84 | }
85 |
86 | func (u *uploadError) Empty() bool {
87 | u.mutex.Lock()
88 | defer u.mutex.Unlock()
89 | return u.err == nil
90 | }
91 |
92 | type deployFiles struct {
93 | Files *map[string]string `json:"files"`
94 | Async bool `json:"async"`
95 | Branch string `json:"branch,omitempty"`
96 | CommitRef string `json:"commit_ref,omitempty"`
97 | }
98 |
99 | func (s *DeploysService) apiPath() string {
100 | if s.site != nil {
101 | return path.Join(s.site.apiPath(), "deploys")
102 | }
103 | return "/deploys"
104 | }
105 |
106 | // Create a new deploy
107 | //
108 | // Example: site.Deploys.Create("/path/to/site-dir", true)
109 | // If the target is a zip file, it must have the extension .zip
110 | func (s *DeploysService) Create(dirOrZip string) (*Deploy, *Response, error) {
111 | return s.create(dirOrZip, false)
112 | }
113 |
114 | // CreateDraft a new draft deploy. Draft deploys will be uploaded and processed, but
115 | // won't affect the active deploy for a site.
116 | func (s *DeploysService) CreateDraft(dirOrZip string) (*Deploy, *Response, error) {
117 | return s.create(dirOrZip, true)
118 | }
119 |
120 | func (s *DeploysService) create(dirOrZip string, draft bool) (*Deploy, *Response, error) {
121 | if s.site == nil {
122 | return nil, nil, errors.New("You can only create a new deploy for an existing site (site.Deploys.Create(dirOrZip)))")
123 | }
124 |
125 | params := url.Values{}
126 | if draft {
127 | params["draft"] = []string{"true"}
128 | }
129 | options := &RequestOptions{QueryParams: ¶ms}
130 | deploy := &Deploy{client: s.client}
131 | resp, err := s.client.Request("POST", s.apiPath(), options, deploy)
132 |
133 | if err != nil {
134 | return deploy, resp, err
135 | }
136 |
137 | resp, err = deploy.Deploy(dirOrZip)
138 | return deploy, resp, err
139 | }
140 |
141 | // List all deploys. Takes ListOptions to control pagination.
142 | func (s *DeploysService) List(options *ListOptions) ([]Deploy, *Response, error) {
143 | deploys := new([]Deploy)
144 |
145 | reqOptions := &RequestOptions{QueryParams: options.toQueryParamsMap()}
146 |
147 | resp, err := s.client.Request("GET", s.apiPath(), reqOptions, deploys)
148 |
149 | for _, deploy := range *deploys {
150 | deploy.client = s.client
151 | }
152 |
153 | return *deploys, resp, err
154 | }
155 |
156 | // Get a specific deploy.
157 | func (d *DeploysService) Get(id string) (*Deploy, *Response, error) {
158 | deploy := &Deploy{Id: id, client: d.client}
159 | resp, err := deploy.Reload()
160 |
161 | return deploy, resp, err
162 | }
163 |
164 | func (deploy *Deploy) apiPath() string {
165 | return path.Join("/deploys", deploy.Id)
166 | }
167 |
168 | func (deploy *Deploy) Deploy(dirOrZip string) (*Response, error) {
169 | if strings.HasSuffix(dirOrZip, ".zip") {
170 | return deploy.deployZip(dirOrZip)
171 | } else {
172 | return deploy.deployDir(dirOrZip)
173 | }
174 | }
175 |
176 | // Reload a deploy from the API
177 | func (deploy *Deploy) Reload() (*Response, error) {
178 | if deploy.Id == "" {
179 | return nil, errors.New("Cannot fetch deploy without an ID")
180 | }
181 | return deploy.client.Request("GET", deploy.apiPath(), nil, deploy)
182 | }
183 |
184 | // Restore an old deploy. Sets the deploy as the active deploy for a site
185 | func (deploy *Deploy) Restore() (*Response, error) {
186 | return deploy.client.Request("POST", path.Join(deploy.apiPath(), "restore"), nil, deploy)
187 | }
188 |
189 | // Alias for restore. Published a specific deploy.
190 | func (deploy *Deploy) Publish() (*Response, error) {
191 | return deploy.Restore()
192 | }
193 |
194 | func (deploy *Deploy) uploadFile(dir, path string, sharedError *uploadError) error {
195 | if !sharedError.Empty() {
196 | return errors.New("Canceled because upload has already failed")
197 | }
198 |
199 | log := deploy.log().WithFields(logrus.Fields{
200 | "dir": dir,
201 | "path": path,
202 | })
203 |
204 | log.Infof("Uploading file: %v", path)
205 | file, err := os.Open(filepath.Join(dir, path))
206 | defer file.Close()
207 |
208 | if err != nil {
209 | log.Warnf("Error opening file %v: %v", path, err)
210 | return err
211 | }
212 |
213 | info, err := file.Stat()
214 |
215 | if err != nil {
216 | log.Warnf("Error getting file size %v: %v", path, err)
217 | return err
218 | }
219 |
220 | options := &RequestOptions{
221 | RawBody: file,
222 | RawBodyLength: info.Size(),
223 | Headers: &map[string]string{"Content-Type": "application/octet-stream"},
224 | }
225 |
226 | fileUrl, err := url.Parse(path)
227 | if err != nil {
228 | log.Warnf("Error parsing url %v: %v", path, err)
229 | return err
230 | }
231 |
232 | resp, err := deploy.client.Request("PUT", filepath.Join(deploy.apiPath(), "files", fileUrl.Path), options, nil)
233 | if resp != nil && resp.Response != nil && resp.Body != nil {
234 | resp.Body.Close()
235 | }
236 | if err != nil {
237 | log.Warnf("Error while uploading %v: %v", path, err)
238 | return err
239 | }
240 |
241 | log.Infof("Finished uploading file: %s", path)
242 | return err
243 | }
244 |
245 | // deployDir scans the given directory and deploys the files
246 | // that have changed on Netlify.
247 | func (deploy *Deploy) deployDir(dir string) (*Response, error) {
248 | return deploy.DeployDirWithGitInfo(dir, "", "")
249 | }
250 |
251 | // DeployDirWithGitInfo scans the given directory and deploys the files
252 | // that have changed on Netlify.
253 | //
254 | // This function allows you to supply git information about the deploy
255 | // when it hasn't been set previously be a Continuous Deployment process.
256 | func (deploy *Deploy) DeployDirWithGitInfo(dir, branch, commitRef string) (*Response, error) {
257 | files := map[string]string{}
258 | log := deploy.log().WithFields(logrus.Fields{
259 | "dir": dir,
260 | "branch": branch,
261 | "commit_ref": commitRef,
262 | })
263 | defer log.Infof("Finished deploying directory %s", dir)
264 |
265 | log.Infof("Starting deploy of directory %s", dir)
266 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
267 | if err != nil {
268 | return err
269 | }
270 | if info.IsDir() == false && info.Mode().IsRegular() {
271 | rel, err := filepath.Rel(dir, path)
272 | if err != nil {
273 | return err
274 | }
275 |
276 | if ignoreFile(rel) {
277 | return nil
278 | }
279 |
280 | sha := sha1.New()
281 | data, err := ioutil.ReadFile(path)
282 |
283 | if err != nil {
284 | return err
285 | }
286 |
287 | sha.Write(data)
288 |
289 | files[rel] = hex.EncodeToString(sha.Sum(nil))
290 | }
291 |
292 | return nil
293 | })
294 | if err != nil {
295 | log.WithError(err).Warn("Failed to walk directory structure")
296 | return nil, err
297 | }
298 |
299 | fileOptions := &deployFiles{
300 | Files: &files,
301 | Branch: branch,
302 | CommitRef: commitRef,
303 | }
304 |
305 | if len(files) > MaxFilesForSyncDeploy {
306 | log.Debugf("More files than sync can deploy %d vs %d", len(files), MaxFilesForSyncDeploy)
307 | fileOptions.Async = true
308 | }
309 |
310 | options := &RequestOptions{
311 | JsonBody: fileOptions,
312 | }
313 |
314 | log.Debug("Starting to do PUT to origin")
315 | resp, err := deploy.client.Request("PUT", deploy.apiPath(), options, deploy)
316 | if err != nil {
317 | return resp, err
318 | }
319 |
320 | if len(files) > MaxFilesForSyncDeploy {
321 | start := time.Now()
322 | log.Debug("Starting to poll for the deploy to get into ready || prepared state")
323 | for {
324 | resp, err := deploy.client.Request("GET", deploy.apiPath(), nil, deploy)
325 | if err != nil {
326 | log.WithError(err).Warnf("Error fetching deploy, waiting for 5 seconds before retry: %v", err)
327 | time.Sleep(5 * time.Second)
328 | }
329 | resp.Body.Close()
330 |
331 | log.Debugf("Deploy state: %v\n", deploy.State)
332 | if deploy.State == "prepared" || deploy.State == "ready" {
333 | break
334 | }
335 | if deploy.State == "error" {
336 | log.Warnf("deploy is in state error")
337 | return resp, errors.New("Error: preprocessing deploy failed")
338 | }
339 | if start.Add(PreProcessingTimeout).Before(time.Now()) {
340 | log.Warnf("Deploy timed out waiting for preprocessing")
341 | return resp, errors.New("Error: preprocessing deploy timed out")
342 | }
343 | log.Debug("Waiting for 2 seconds to retry getting deploy")
344 | time.Sleep(2 * time.Second)
345 | }
346 | }
347 |
348 | lookup := map[string]bool{}
349 |
350 | for _, sha := range deploy.Required {
351 | lookup[sha] = true
352 | }
353 |
354 | log.Infof("Going to deploy the %d required files", len(lookup))
355 |
356 | // Use a channel as a semaphore to limit # of parallel uploads
357 | sem := make(chan int, deploy.client.MaxConcurrentUploads)
358 | var wg sync.WaitGroup
359 |
360 | sharedErr := uploadError{err: nil, mutex: &sync.Mutex{}}
361 | for path, sha := range files {
362 | if lookup[sha] == true && sharedErr.Empty() {
363 | wg.Add(1)
364 | sem <- 1
365 | go func(path string) {
366 | defer func() {
367 | <-sem
368 | wg.Done()
369 | }()
370 | log.Debugf("Starting to upload %s/%s", path, sha)
371 | if !sharedErr.Empty() {
372 | return
373 | }
374 |
375 | b := backoff.NewExponentialBackOff()
376 | b.MaxElapsedTime = 2 * time.Minute
377 | err := backoff.Retry(func() error { return deploy.uploadFile(dir, path, &sharedErr) }, b)
378 | if err != nil {
379 | log.WithError(err).Warnf("Error while uploading file %s: %v", path, err)
380 | sharedErr.Set(err)
381 | }
382 | }(path)
383 | }
384 | }
385 |
386 | log.Debugf("Waiting for required files to upload")
387 | wg.Wait()
388 |
389 | if !sharedErr.Empty() {
390 | return resp, sharedErr.err
391 | }
392 |
393 | return resp, err
394 | }
395 |
396 | // deployZip uploads a Zip file to Netlify and deploys the files
397 | // that have changed.
398 | func (deploy *Deploy) deployZip(zip string) (*Response, error) {
399 | log := deploy.log().WithFields(logrus.Fields{
400 | "function": "zip",
401 | "zip_path": zip,
402 | })
403 | log.Infof("Starting to deploy zip file %s", zip)
404 | zipPath, err := filepath.Abs(zip)
405 | if err != nil {
406 | return nil, err
407 | }
408 |
409 | log.Debugf("Opening zip file at %s", zipPath)
410 | zipFile, err := os.Open(zipPath)
411 | if err != nil {
412 | return nil, err
413 | }
414 | defer zipFile.Close()
415 |
416 | info, err := zipFile.Stat()
417 | if err != nil {
418 | return nil, err
419 | }
420 |
421 | log.WithFields(logrus.Fields{
422 | "name": info.Name(),
423 | "size": info.Size(),
424 | "mode": info.Mode(),
425 | }).Debugf("Opened file %s of %s bytes", info.Name(), info.Size())
426 |
427 | options := &RequestOptions{
428 | RawBody: zipFile,
429 | RawBodyLength: info.Size(),
430 | Headers: &map[string]string{"Content-Type": "application/zip"},
431 | }
432 |
433 | log.Debug("Excuting PUT request for zip file")
434 | resp, err := deploy.client.Request("PUT", deploy.apiPath(), options, deploy)
435 | if err != nil {
436 | log.WithError(err).Warn("Error while uploading zip file")
437 | }
438 |
439 | log.Info("Finished uploading zip file")
440 | return resp, err
441 | }
442 |
443 | func (deploy *Deploy) WaitForReady(timeout time.Duration) error {
444 | if deploy.State == "ready" {
445 | return nil
446 | }
447 |
448 | if timeout == 0 {
449 | timeout = defaultTimeout
450 | }
451 |
452 | timedOut := false
453 | time.AfterFunc(timeout*time.Second, func() {
454 | timedOut = true
455 | })
456 |
457 | done := make(chan error)
458 |
459 | go func() {
460 | for {
461 | time.Sleep(1 * time.Second)
462 |
463 | if timedOut {
464 | done <- errors.New("Timeout while waiting for processing")
465 | break
466 | }
467 |
468 | _, err := deploy.Reload()
469 | if err != nil || (deploy.State == "ready") {
470 | done <- err
471 | break
472 | }
473 | }
474 | }()
475 |
476 | err := <-done
477 | return err
478 | }
479 |
480 | func ignoreFile(rel string) bool {
481 | if strings.HasPrefix(rel, ".") || strings.Contains(rel, "/.") || strings.HasPrefix(rel, "__MACOS") {
482 | if strings.HasPrefix(rel, ".well-known/") {
483 | return false
484 | }
485 | return true
486 | }
487 | return false
488 | }
489 |
--------------------------------------------------------------------------------