├── .gitignore ├── Jenkinsfile ├── LICENSE.md ├── README.md ├── deploy_keys.go ├── deploys.go ├── deploys_test.go ├── doc.go ├── forms.go ├── glide.lock ├── glide.yaml ├── netlify.go ├── netlify_test.go ├── script ├── ci.sh └── test.sh ├── sites.go ├── sites_test.go ├── submissions.go ├── test-site ├── archive.zip └── folder │ ├── .gitignore │ ├── __MACOSX │ ├── index.html │ └── style.css ├── timestamp.go └── users.go /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | vendor/ 3 | *.iml 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /forms.go: -------------------------------------------------------------------------------- 1 | package netlify 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /submissions.go: -------------------------------------------------------------------------------- 1 | package netlify 2 | -------------------------------------------------------------------------------- /test-site/archive.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netlify/go-client/3f96614ba1bc40077610b7a7994475b172c8df5f/test-site/archive.zip -------------------------------------------------------------------------------- /test-site/folder/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netlify/go-client/3f96614ba1bc40077610b7a7994475b172c8df5f/test-site/folder/.gitignore -------------------------------------------------------------------------------- /test-site/folder/__MACOSX: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netlify/go-client/3f96614ba1bc40077610b7a7994475b172c8df5f/test-site/folder/__MACOSX -------------------------------------------------------------------------------- /test-site/folder/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 5 | 6 | 7 | 8 |

Test site

9 | 10 | 11 | -------------------------------------------------------------------------------- /test-site/folder/style.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | font-size: 80px; 3 | } 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /users.go: -------------------------------------------------------------------------------- 1 | package netlify 2 | --------------------------------------------------------------------------------