diff --git a/gitlab.go b/gitlab.go index 1ccf5ddbd..7c6277712 100644 --- a/gitlab.go +++ b/gitlab.go @@ -18,12 +18,14 @@ package gitlab import ( + "bytes" "context" "encoding/json" "fmt" "io" "io/ioutil" "math/rand" + "mime/multipart" "net/http" "net/url" "sort" @@ -520,11 +522,11 @@ func (c *Client) setBaseURL(urlStr string) error { return nil } -// NewRequest creates an API request. An optional relative URL path can be -// provided in path, in which case it is resolved relative to the base URL -// of the Client. -// Paths should always be specified without a preceding slash. If specified, -// the value pointed to by body is JSON encoded and included as request body. +// NewRequest creates a new API request. The method expects a relative URL +// path that will be resolved relative to the base URL of the Client. +// Relative URL paths should always be specified without a preceding slash. +// If specified, the value pointed to by body is JSON encoded and included +// as the request body. func (c *Client) NewRequest(method, path string, opt interface{}, options []RequestOptionFunc) (*retryablehttp.Request, error) { u := *c.baseURL unescaped, err := url.PathUnescape(path) @@ -585,6 +587,82 @@ func (c *Client) NewRequest(method, path string, opt interface{}, options []Requ return req, nil } +// UploadRequest creates an API request for uploading a file. The method +// expects a relative URL path that will be resolved relative to the base +// URL of the Client. Relative URL paths should always be specified without +// a preceding slash. If specified, the value pointed to by body is JSON +// encoded and included as the request body. +func (c *Client) UploadRequest(method, path string, content io.Reader, filename string, uploadType UploadType, opt interface{}, options []RequestOptionFunc) (*retryablehttp.Request, error) { + u := *c.baseURL + unescaped, err := url.PathUnescape(path) + if err != nil { + return nil, err + } + + // Set the encoded path data + u.RawPath = c.baseURL.Path + path + u.Path = c.baseURL.Path + unescaped + + // Create a request specific headers map. + reqHeaders := make(http.Header) + reqHeaders.Set("Accept", "application/json") + + if c.UserAgent != "" { + reqHeaders.Set("User-Agent", c.UserAgent) + } + + b := new(bytes.Buffer) + w := multipart.NewWriter(b) + + fw, err := w.CreateFormFile(string(uploadType), filename) + if err != nil { + return nil, err + } + + if _, err := io.Copy(fw, content); err != nil { + return nil, err + } + + if opt != nil { + fields, err := query.Values(opt) + if err != nil { + return nil, err + } + for name := range fields { + if err = w.WriteField(name, fmt.Sprintf("%v", fields.Get(name))); err != nil { + return nil, err + } + } + } + + if err = w.Close(); err != nil { + return nil, err + } + + reqHeaders.Set("Content-Type", w.FormDataContentType()) + + req, err := retryablehttp.NewRequest(method, u.String(), b) + if err != nil { + return nil, err + } + + for _, fn := range options { + if fn == nil { + continue + } + if err := fn(req); err != nil { + return nil, err + } + } + + // Set the request specific headers. + for k, v := range reqHeaders { + req.Header[k] = v + } + + return req, nil +} + // Response is a GitLab API response. This wraps the standard http.Response // returned from GitLab and provides convenient access to things like // pagination links. diff --git a/project_import_export.go b/project_import_export.go index 6caef3923..67f8a620a 100644 --- a/project_import_export.go +++ b/project_import_export.go @@ -19,6 +19,7 @@ package gitlab import ( "bytes" "fmt" + "io" "net/http" "time" ) @@ -162,18 +163,26 @@ func (s *ProjectImportExportService) ExportDownload(pid interface{}, options ... // https://docs.gitlab.com/ce/api/project_import_export.html#import-a-file type ImportFileOptions struct { Namespace *string `url:"namespace,omitempty" json:"namespace,omitempty"` - File *string `url:"file,omitempty" json:"file,omitempty"` + Name *string `url:"name,omitempty" json:"name,omitempty"` Path *string `url:"path,omitempty" json:"path,omitempty"` Overwrite *bool `url:"overwrite,omitempty" json:"overwrite,omitempty"` OverrideParams *CreateProjectOptions `url:"override_params,omitempty" json:"override_params,omitempty"` } -// ImportFile import a file. +// Import a project from an archive file. // // GitLab API docs: // https://docs.gitlab.com/ce/api/project_import_export.html#import-a-file -func (s *ProjectImportExportService) ImportFile(opt *ImportFileOptions, options ...RequestOptionFunc) (*ImportStatus, *Response, error) { - req, err := s.client.NewRequest(http.MethodPost, "projects/import", opt, options) +func (s *ProjectImportExportService) ImportFromFile(archive io.Reader, opt *ImportFileOptions, options ...RequestOptionFunc) (*ImportStatus, *Response, error) { + req, err := s.client.UploadRequest( + http.MethodPost, + "projects/import", + archive, + "archive.tar.gz", + UploadFile, + opt, + options, + ) if err != nil { return nil, nil, err } diff --git a/project_import_export_test.go b/project_import_export_test.go index c497b4cd8..723eefd90 100644 --- a/project_import_export_test.go +++ b/project_import_export_test.go @@ -1,6 +1,7 @@ package gitlab import ( + "bytes" "fmt" "net/http" "testing" @@ -159,32 +160,18 @@ func TestProjectImportExportService_ImportFile(t *testing.T) { ImportStatus: "scheduled", } - es, resp, err := client.ProjectImportExport.ImportFile(nil, nil) + file := bytes.NewBufferString("dummy") + es, resp, err := client.ProjectImportExport.ImportFromFile(file, nil, nil) require.NoError(t, err) require.NotNil(t, resp) require.Equal(t, want, es) - es, resp, err = client.ProjectImportExport.ImportFile(nil, errorOption) + es, resp, err = client.ProjectImportExport.ImportFromFile(file, nil, errorOption) require.EqualError(t, err, "RequestOptionFunc returns an error") require.Nil(t, resp) require.Nil(t, es) } -func TestProjectImportExportService_ImportFile_NotFound(t *testing.T) { - mux, server, client := setup(t) - defer teardown(server) - - mux.HandleFunc("/api/v4/projects/import", func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, http.MethodPost) - w.WriteHeader(http.StatusNotFound) - }) - - es, resp, err := client.ProjectImportExport.ImportFile(nil, nil) - require.Error(t, err) - require.Nil(t, es) - require.Equal(t, http.StatusNotFound, resp.StatusCode) -} - func TestProjectImportExportService_ImportStatus(t *testing.T) { mux, server, client := setup(t) defer teardown(server) diff --git a/projects.go b/projects.go index 877a9d37d..b52470026 100644 --- a/projects.go +++ b/projects.go @@ -17,14 +17,12 @@ package gitlab import ( - "bytes" "fmt" "io" - "mime/multipart" "net/http" - "os" - "path/filepath" "time" + + retryablehttp "github.com/hashicorp/go-retryablehttp" ) // ProjectsService handles communication with the repositories related methods @@ -593,6 +591,7 @@ type CreateProjectOptions struct { AutoDevopsDeployStrategy *string `url:"auto_devops_deploy_strategy,omitempty" json:"auto_devops_deploy_strategy,omitempty"` AutoDevopsEnabled *bool `url:"auto_devops_enabled,omitempty" json:"auto_devops_enabled,omitempty"` AutocloseReferencedIssues *bool `url:"autoclose_referenced_issues,omitempty" json:"autoclose_referenced_issues,omitempty"` + Avatar *ProjectAvatar `url:"-" json:"-"` BuildCoverageRegex *string `url:"build_coverage_regex,omitempty" json:"build_coverage_regex,omitempty"` BuildGitStrategy *string `url:"build_git_strategy,omitempty" json:"build_git_strategy,omitempty"` BuildTimeout *int `url:"build_timeout,omitempty" json:"build_timeout,omitempty"` @@ -672,6 +671,11 @@ type ContainerExpirationPolicyAttributes struct { NameRegex *string `url:"name_regex,omitempty" json:"name_regex,omitempty"` } +type ProjectAvatar struct { + Filename string + Image io.Reader +} + // CreateProject creates a new project owned by the authenticated user. // // GitLab API docs: https://docs.gitlab.com/ce/api/projects.html#create-project @@ -683,7 +687,22 @@ func (s *ProjectsService) CreateProject(opt *CreateProjectOptions, options ...Re opt.ContainerExpirationPolicyAttributes.NameRegexDelete } - req, err := s.client.NewRequest(http.MethodPost, "projects", opt, options) + var err error + var req *retryablehttp.Request + + if opt.Avatar == nil { + req, err = s.client.NewRequest(http.MethodPost, "projects", opt, options) + } else { + req, err = s.client.UploadRequest( + http.MethodPost, + "projects", + opt.Avatar.Image, + opt.Avatar.Filename, + UploadAvatar, + opt, + options, + ) + } if err != nil { return nil, nil, err } @@ -717,8 +736,23 @@ func (s *ProjectsService) CreateProjectForUser(user int, opt *CreateProjectForUs opt.ContainerExpirationPolicyAttributes.NameRegexDelete } + var err error + var req *retryablehttp.Request u := fmt.Sprintf("projects/user/%d", user) - req, err := s.client.NewRequest(http.MethodPost, u, opt, options) + + if opt.Avatar == nil { + req, err = s.client.NewRequest(http.MethodPost, u, opt, options) + } else { + req, err = s.client.UploadRequest( + http.MethodPost, + u, + opt.Avatar.Image, + opt.Avatar.Filename, + UploadAvatar, + opt, + options, + ) + } if err != nil { return nil, nil, err } @@ -742,6 +776,7 @@ type EditProjectOptions struct { AutoDevopsDeployStrategy *string `url:"auto_devops_deploy_strategy,omitempty" json:"auto_devops_deploy_strategy,omitempty"` AutoDevopsEnabled *bool `url:"auto_devops_enabled,omitempty" json:"auto_devops_enabled,omitempty"` AutocloseReferencedIssues *bool `url:"autoclose_referenced_issues,omitempty" json:"autoclose_referenced_issues,omitempty"` + Avatar *ProjectAvatar `url:"-" json:"-"` BuildCoverageRegex *string `url:"build_coverage_regex,omitempty" json:"build_coverage_regex,omitempty"` BuildGitStrategy *string `url:"build_git_strategy,omitempty" json:"build_git_strategy,omitempty"` BuildTimeout *int `url:"build_timeout,omitempty" json:"build_timeout,omitempty"` @@ -819,7 +854,21 @@ func (s *ProjectsService) EditProject(pid interface{}, opt *EditProjectOptions, } u := fmt.Sprintf("projects/%s", PathEscape(project)) - req, err := s.client.NewRequest(http.MethodPut, u, opt, options) + var req *retryablehttp.Request + + if opt.Avatar == nil { + req, err = s.client.NewRequest(http.MethodPost, u, opt, options) + } else { + req, err = s.client.UploadRequest( + http.MethodPost, + u, + opt.Avatar.Image, + opt.Avatar.Filename, + UploadAvatar, + opt, + options, + ) + } if err != nil { return nil, nil, err } @@ -1303,7 +1352,7 @@ func (s *ProjectsService) DeleteProjectForkRelation(pid interface{}, options ... return s.client.Do(req, nil) } -// ProjectFile represents an uploaded project file +// ProjectFile represents an uploaded project file. // // GitLab API docs: https://docs.gitlab.com/ce/api/projects.html#upload-a-file type ProjectFile struct { @@ -1312,57 +1361,69 @@ type ProjectFile struct { Markdown string `json:"markdown"` } -// UploadFile upload a file from disk +// UploadFile uploads a file. // // GitLab API docs: https://docs.gitlab.com/ce/api/projects.html#upload-a-file -func (s *ProjectsService) UploadFile(pid interface{}, file string, options ...RequestOptionFunc) (*ProjectFile, *Response, error) { +func (s *ProjectsService) UploadFile(pid interface{}, content io.Reader, filename string, options ...RequestOptionFunc) (*ProjectFile, *Response, error) { project, err := parseID(pid) if err != nil { return nil, nil, err } u := fmt.Sprintf("projects/%s/uploads", PathEscape(project)) - f, err := os.Open(file) + req, err := s.client.UploadRequest( + http.MethodPost, + u, + content, + filename, + UploadFile, + nil, + options, + ) if err != nil { return nil, nil, err } - defer f.Close() - b := &bytes.Buffer{} - w := multipart.NewWriter(b) - - _, filename := filepath.Split(file) - fw, err := w.CreateFormFile("file", filename) + pf := new(ProjectFile) + resp, err := s.client.Do(req, pf) if err != nil { - return nil, nil, err + return nil, resp, err } - _, err = io.Copy(fw, f) - if err != nil { - return nil, nil, err - } - w.Close() + return pf, resp, nil +} - req, err := s.client.NewRequest(http.MethodPost, u, nil, options) +// UploadAvatar uploads an avatar. +// +// GitLab API docs: +// https://docs.gitlab.com/ee/api/projects.html#upload-a-project-avatar +func (s *ProjectsService) UploadAvatar(pid interface{}, avatar io.Reader, filename string, options ...RequestOptionFunc) (*Project, *Response, error) { + project, err := parseID(pid) if err != nil { return nil, nil, err } + u := fmt.Sprintf("projects/%s", PathEscape(project)) - // Set the buffer as the request body. - if err = req.SetBody(b); err != nil { + req, err := s.client.UploadRequest( + http.MethodPut, + u, + avatar, + filename, + UploadAvatar, + nil, + options, + ) + if err != nil { return nil, nil, err } - // Overwrite the default content type. - req.Header.Set("Content-Type", w.FormDataContentType()) - - uf := &ProjectFile{} - resp, err := s.client.Do(req, uf) + p := new(Project) + resp, err := s.client.Do(req, p) if err != nil { return nil, resp, err } - return uf, resp, nil + return p, resp, err } // ListProjectForks gets a list of project forks. diff --git a/projects_test.go b/projects_test.go index 5ab4e6dc5..0da18e986 100644 --- a/projects_test.go +++ b/projects_test.go @@ -17,6 +17,7 @@ package gitlab import ( + "bytes" "fmt" "io/ioutil" "net/http" @@ -414,12 +415,9 @@ func TestUploadFile(t *testing.T) { mux, server, client := setup(t) defer teardown(server) - tf, _ := ioutil.TempFile(os.TempDir(), "test") - defer os.Remove(tf.Name()) - mux.HandleFunc("/api/v4/projects/1/uploads", func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, http.MethodPost) - if false == strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data;") { + if !strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data;") { t.Fatalf("Projects.UploadFile request content-type %+v want multipart/form-data;", r.Header.Get("Content-Type")) } if r.ContentLength == -1 { @@ -438,14 +436,15 @@ func TestUploadFile(t *testing.T) { Markdown: "![dk](/uploads/66dbcd21ec5d24ed6ea225176098d52b/dk.png)", } - file, _, err := client.Projects.UploadFile(1, tf.Name()) + file := bytes.NewBufferString("dummy") + projectFile, _, err := client.Projects.UploadFile(1, file, "test.txt") if err != nil { t.Fatalf("Projects.UploadFile returns an error: %v", err) } - if !reflect.DeepEqual(want, file) { - t.Errorf("Projects.UploadFile returned %+v, want %+v", file, want) + if !reflect.DeepEqual(want, projectFile) { + t.Errorf("Projects.UploadFile returned %+v, want %+v", projectFile, want) } } @@ -463,7 +462,7 @@ func TestUploadFile_Retry(t *testing.T) { isFirstRequest = false return } - if false == strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data;") { + if !strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data;") { t.Fatalf("Projects.UploadFile request content-type %+v want multipart/form-data;", r.Header.Get("Content-Type")) } if r.ContentLength == -1 { @@ -482,14 +481,66 @@ func TestUploadFile_Retry(t *testing.T) { Markdown: "![dk](/uploads/66dbcd21ec5d24ed6ea225176098d52b/dk.png)", } - file, _, err := client.Projects.UploadFile(1, tf.Name()) + file := bytes.NewBufferString("dummy") + projectFile, _, err := client.Projects.UploadFile(1, file, "test.txt") if err != nil { t.Fatalf("Projects.UploadFile returns an error: %v", err) } - if !reflect.DeepEqual(want, file) { - t.Errorf("Projects.UploadFile returned %+v, want %+v", file, want) + if !reflect.DeepEqual(want, projectFile) { + t.Errorf("Projects.UploadFile returned %+v, want %+v", projectFile, want) + } +} + +func TestUploadAvatar(t *testing.T) { + mux, server, client := setup(t) + defer teardown(server) + + mux.HandleFunc("/api/v4/projects/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPut) + if !strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data;") { + t.Fatalf("Projects.UploadAvatar request content-type %+v want multipart/form-data;", r.Header.Get("Content-Type")) + } + if r.ContentLength == -1 { + t.Fatalf("Projects.UploadAvatar request content-length is -1") + } + fmt.Fprint(w, `{}`) + }) + + avatar := new(bytes.Buffer) + _, _, err := client.Projects.UploadAvatar(1, avatar, "avatar.png") + + if err != nil { + t.Fatalf("Projects.UploadAvatar returns an error: %v", err) + } +} + +func TestUploadAvatar_Retry(t *testing.T) { + mux, server, client := setup(t) + defer teardown(server) + + isFirstRequest := true + mux.HandleFunc("/api/v4/projects/1", func(w http.ResponseWriter, r *http.Request) { + if isFirstRequest { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + isFirstRequest = false + return + } + if !strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data;") { + t.Fatalf("Projects.UploadAvatar request content-type %+v want multipart/form-data;", r.Header.Get("Content-Type")) + } + if r.ContentLength == -1 { + t.Fatalf("Projects.UploadAvatar request content-length is -1") + } + fmt.Fprint(w, `{}`) + }) + + avatar := new(bytes.Buffer) + _, _, err := client.Projects.UploadAvatar(1, avatar, "avatar.png") + + if err != nil { + t.Fatalf("Projects.UploadAvatar returns an error: %v", err) } } diff --git a/types.go b/types.go index 2d90c415e..e4c7083a0 100644 --- a/types.go +++ b/types.go @@ -603,6 +603,15 @@ const ( TodoTargetMergeRequest TodoTargetType = "MergeRequest" ) +// UploadType represents the available upload types. +type UploadType string + +// The available upload types. +const ( + UploadAvatar UploadType = "avatar" + UploadFile UploadType = "file" +) + // VariableTypeValue represents a variable type within GitLab. // // GitLab API docs: https://docs.gitlab.com/ce/api/