Repository: xanzy/go-gitlab
Pull request #1965 (2024-06-30 17:51:47)

Add Users.UploadAvatar() and add Avatar to UserOptions
First time committing to external github-repo, so I hope this is the correct way.
This fixes xanzy/go-gitlab#1871
I have not added feature for downloading avatar since this is not part of the gitlab api. 


Patch:
diff --git a/users.go b/users.go
index 5ad87ca..224dc42 100644
--- a/users.go
+++ b/users.go
@@ -17,11 +17,15 @@
 package gitlab
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net"
 	"net/http"
 	"time"
+
+	"github.com/hashicorp/go-retryablehttp"
 )
 
 // List a couple of standard errors.
@@ -112,6 +116,23 @@ type UserIdentity struct {
 	ExternUID string `json:"extern_uid"`
 }
 
+// UserAvatar represents a GitLab user avatar.
+//
+// GitLab API docs: https://docs.gitlab.com/ee/api/users.html
+type UserAvatar struct {
+	Filename string
+	Image    io.Reader
+}
+
+// MarshalJSON implements the json.Marshaler interface.
+func (a *UserAvatar) MarshalJSON() ([]byte, error) {
+	if a.Filename == "" && a.Image == nil {
+		return []byte(`""`), nil
+	}
+	type alias UserAvatar
+	return json.Marshal((*alias)(a))
+}
+
 // ListUsersOptions represents the available ListUsers() options.
 //
 // GitLab API docs: https://docs.gitlab.com/ee/api/users.html#list-users
@@ -188,37 +209,53 @@ func (s *UsersService) GetUser(user int, opt GetUsersOptions, options ...Request
 //
 // GitLab API docs: https://docs.gitlab.com/ee/api/users.html#user-creation
 type CreateUserOptions struct {
-	Email               *string `url:"email,omitempty" json:"email,omitempty"`
-	Password            *string `url:"password,omitempty" json:"password,omitempty"`
-	ResetPassword       *bool   `url:"reset_password,omitempty" json:"reset_password,omitempty"`
-	ForceRandomPassword *bool   `url:"force_random_password,omitempty" json:"force_random_password,omitempty"`
-	Username            *string `url:"username,omitempty" json:"username,omitempty"`
-	Name                *string `url:"name,omitempty" json:"name,omitempty"`
-	Skype               *string `url:"skype,omitempty" json:"skype,omitempty"`
-	Linkedin            *string `url:"linkedin,omitempty" json:"linkedin,omitempty"`
-	Twitter             *string `url:"twitter,omitempty" json:"twitter,omitempty"`
-	WebsiteURL          *string `url:"website_url,omitempty" json:"website_url,omitempty"`
-	Organization        *string `url:"organization,omitempty" json:"organization,omitempty"`
-	JobTitle            *string `url:"job_title,omitempty" json:"job_title,omitempty"`
-	ProjectsLimit       *int    `url:"projects_limit,omitempty" json:"projects_limit,omitempty"`
-	ExternUID           *string `url:"extern_uid,omitempty" json:"extern_uid,omitempty"`
-	Provider            *string `url:"provider,omitempty" json:"provider,omitempty"`
-	Bio                 *string `url:"bio,omitempty" json:"bio,omitempty"`
-	Location            *string `url:"location,omitempty" json:"location,omitempty"`
-	Admin               *bool   `url:"admin,omitempty" json:"admin,omitempty"`
-	CanCreateGroup      *bool   `url:"can_create_group,omitempty" json:"can_create_group,omitempty"`
-	SkipConfirmation    *bool   `url:"skip_confirmation,omitempty" json:"skip_confirmation,omitempty"`
-	External            *bool   `url:"external,omitempty" json:"external,omitempty"`
-	PrivateProfile      *bool   `url:"private_profile,omitempty" json:"private_profile,omitempty"`
-	Note                *string `url:"note,omitempty" json:"note,omitempty"`
-	ThemeID             *int    `url:"theme_id,omitempty" json:"theme_id,omitempty"`
+	Admin               *bool       `url:"admin,omitempty" json:"admin,omitempty"`
+	Avatar              *UserAvatar `url:"-" json:"-"`
+	Bio                 *string     `url:"bio,omitempty" json:"bio,omitempty"`
+	CanCreateGroup      *bool       `url:"can_create_group,omitempty" json:"can_create_group,omitempty"`
+	Email               *string     `url:"email,omitempty" json:"email,omitempty"`
+	External            *bool       `url:"external,omitempty" json:"external,omitempty"`
+	ExternUID           *string     `url:"extern_uid,omitempty" json:"extern_uid,omitempty"`
+	ForceRandomPassword *bool       `url:"force_random_password,omitempty" json:"force_random_password,omitempty"`
+	JobTitle            *string     `url:"job_title,omitempty" json:"job_title,omitempty"`
+	Linkedin            *string     `url:"linkedin,omitempty" json:"linkedin,omitempty"`
+	Location            *string     `url:"location,omitempty" json:"location,omitempty"`
+	Name                *string     `url:"name,omitempty" json:"name,omitempty"`
+	Note                *string     `url:"note,omitempty" json:"note,omitempty"`
+	Organization        *string     `url:"organization,omitempty" json:"organization,omitempty"`
+	Password            *string     `url:"password,omitempty" json:"password,omitempty"`
+	PrivateProfile      *bool       `url:"private_profile,omitempty" json:"private_profile,omitempty"`
+	ProjectsLimit       *int        `url:"projects_limit,omitempty" json:"projects_limit,omitempty"`
+	Provider            *string     `url:"provider,omitempty" json:"provider,omitempty"`
+	ResetPassword       *bool       `url:"reset_password,omitempty" json:"reset_password,omitempty"`
+	SkipConfirmation    *bool       `url:"skip_confirmation,omitempty" json:"skip_confirmation,omitempty"`
+	Skype               *string     `url:"skype,omitempty" json:"skype,omitempty"`
+	ThemeID             *int        `url:"theme_id,omitempty" json:"theme_id,omitempty"`
+	Twitter             *string     `url:"twitter,omitempty" json:"twitter,omitempty"`
+	Username            *string     `url:"username,omitempty" json:"username,omitempty"`
+	WebsiteURL          *string     `url:"website_url,omitempty" json:"website_url,omitempty"`
 }
 
 // CreateUser creates a new user. Note only administrators can create new users.
 //
 // GitLab API docs: https://docs.gitlab.com/ee/api/users.html#user-creation
 func (s *UsersService) CreateUser(opt *CreateUserOptions, options ...RequestOptionFunc) (*User, *Response, error) {
-	req, err := s.client.NewRequest(http.MethodPost, "users", opt, options)
+	var err error
+	var req *retryablehttp.Request
+
+	if opt.Avatar == nil {
+		req, err = s.client.NewRequest(http.MethodPost, "users", opt, options)
+	} else {
+		req, err = s.client.UploadRequest(
+			http.MethodPost,
+			"users",
+			opt.Avatar.Image,
+			opt.Avatar.Filename,
+			UploadAvatar,
+			opt,
+			options,
+		)
+	}
 	if err != nil {
 		return nil, nil, err
 	}
@@ -236,30 +273,31 @@ func (s *UsersService) CreateUser(opt *CreateUserOptions, options ...RequestOpti
 //
 // GitLab API docs: https://docs.gitlab.com/ee/api/users.html#user-modification
 type ModifyUserOptions struct {
-	Email              *string `url:"email,omitempty" json:"email,omitempty"`
-	Password           *string `url:"password,omitempty" json:"password,omitempty"`
-	Username           *string `url:"username,omitempty" json:"username,omitempty"`
-	Name               *string `url:"name,omitempty" json:"name,omitempty"`
-	Skype              *string `url:"skype,omitempty" json:"skype,omitempty"`
-	Linkedin           *string `url:"linkedin,omitempty" json:"linkedin,omitempty"`
-	Twitter            *string `url:"twitter,omitempty" json:"twitter,omitempty"`
-	WebsiteURL         *string `url:"website_url,omitempty" json:"website_url,omitempty"`
-	Organization       *string `url:"organization,omitempty" json:"organization,omitempty"`
-	JobTitle           *string `url:"job_title,omitempty" json:"job_title,omitempty"`
-	ProjectsLimit      *int    `url:"projects_limit,omitempty" json:"projects_limit,omitempty"`
-	ExternUID          *string `url:"extern_uid,omitempty" json:"extern_uid,omitempty"`
-	Provider           *string `url:"provider,omitempty" json:"provider,omitempty"`
-	Bio                *string `url:"bio,omitempty" json:"bio,omitempty"`
-	Location           *string `url:"location,omitempty" json:"location,omitempty"`
-	Admin              *bool   `url:"admin,omitempty" json:"admin,omitempty"`
-	CanCreateGroup     *bool   `url:"can_create_group,omitempty" json:"can_create_group,omitempty"`
-	SkipReconfirmation *bool   `url:"skip_reconfirmation,omitempty" json:"skip_reconfirmation,omitempty"`
-	External           *bool   `url:"external,omitempty" json:"external,omitempty"`
-	PrivateProfile     *bool   `url:"private_profile,omitempty" json:"private_profile,omitempty"`
-	Note               *string `url:"note,omitempty" json:"note,omitempty"`
-	ThemeID            *int    `url:"theme_id,omitempty" json:"theme_id,omitempty"`
-	PublicEmail        *string `url:"public_email,omitempty" json:"public_email,omitempty"`
-	CommitEmail        *string `url:"commit_email,omitempty" json:"commit_email,omitempty"`
+	Admin              *bool       `url:"admin,omitempty" json:"admin,omitempty"`
+	Avatar             *UserAvatar `url:"-" json:"avatar,omitempty"`
+	Bio                *string     `url:"bio,omitempty" json:"bio,omitempty"`
+	CanCreateGroup     *bool       `url:"can_create_group,omitempty" json:"can_create_group,omitempty"`
+	CommitEmail        *string     `url:"commit_email,omitempty" json:"commit_email,omitempty"`
+	Email              *string     `url:"email,omitempty" json:"email,omitempty"`
+	External           *bool       `url:"external,omitempty" json:"external,omitempty"`
+	ExternUID          *string     `url:"extern_uid,omitempty" json:"extern_uid,omitempty"`
+	JobTitle           *string     `url:"job_title,omitempty" json:"job_title,omitempty"`
+	Linkedin           *string     `url:"linkedin,omitempty" json:"linkedin,omitempty"`
+	Location           *string     `url:"location,omitempty" json:"location,omitempty"`
+	Name               *string     `url:"name,omitempty" json:"name,omitempty"`
+	Note               *string     `url:"note,omitempty" json:"note,omitempty"`
+	Organization       *string     `url:"organization,omitempty" json:"organization,omitempty"`
+	Password           *string     `url:"password,omitempty" json:"password,omitempty"`
+	PrivateProfile     *bool       `url:"private_profile,omitempty" json:"private_profile,omitempty"`
+	ProjectsLimit      *int        `url:"projects_limit,omitempty" json:"projects_limit,omitempty"`
+	Provider           *string     `url:"provider,omitempty" json:"provider,omitempty"`
+	PublicEmail        *string     `url:"public_email,omitempty" json:"public_email,omitempty"`
+	SkipReconfirmation *bool       `url:"skip_reconfirmation,omitempty" json:"skip_reconfirmation,omitempty"`
+	Skype              *string     `url:"skype,omitempty" json:"skype,omitempty"`
+	ThemeID            *int        `url:"theme_id,omitempty" json:"theme_id,omitempty"`
+	Twitter            *string     `url:"twitter,omitempty" json:"twitter,omitempty"`
+	Username           *string     `url:"username,omitempty" json:"username,omitempty"`
+	WebsiteURL         *string     `url:"website_url,omitempty" json:"website_url,omitempty"`
 }
 
 // ModifyUser modifies an existing user. Only administrators can change attributes
@@ -267,9 +305,23 @@ type ModifyUserOptions struct {
 //
 // GitLab API docs: https://docs.gitlab.com/ee/api/users.html#user-modification
 func (s *UsersService) ModifyUser(user int, opt *ModifyUserOptions, options ...RequestOptionFunc) (*User, *Response, error) {
+	var err error
+	var req *retryablehttp.Request
 	u := fmt.Sprintf("users/%d", user)
 
-	req, err := s.client.NewRequest(http.MethodPut, u, opt, options)
+	if opt.Avatar == nil || (opt.Avatar.Filename == "" && opt.Avatar.Image == nil) {
+		req, err = s.client.NewRequest(http.MethodPut, u, opt, options)
+	} else {
+		req, err = s.client.UploadRequest(
+			http.MethodPut,
+			u,
+			opt.Avatar.Image,
+			opt.Avatar.Filename,
+			UploadAvatar,
+			opt,
+			options,
+		)
+	}
 	if err != nil {
 		return nil, nil, err
 	}
@@ -1507,3 +1559,32 @@ func (s *UsersService) CreateServiceAccountUser(options ...RequestOptionFunc) (*
 
 	return usr, resp, nil
 }
+
+// UploadAvatar uploads an avatar to the current user.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/users.html#upload-a-current-user-avatar
+func (s *UsersService) UploadAvatar(avatar io.Reader, filename string, options ...RequestOptionFunc) (*User, *Response, error) {
+	u := "user/avatar"
+
+	req, err := s.client.UploadRequest(
+		http.MethodPut,
+		u,
+		avatar,
+		filename,
+		UploadAvatar,
+		nil,
+		options,
+	)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	usr := new(User)
+	resp, err := s.client.Do(req, usr)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return usr, resp, nil
+}


Test patch:
diff --git a/users_test.go b/users_test.go
index 9a2afcc..566c6e1 100644
--- a/users_test.go
+++ b/users_test.go
@@ -17,11 +17,13 @@
 package gitlab
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
 	"net"
 	"net/http"
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 
@@ -741,3 +743,152 @@ func TestCreateServiceAccountUser(t *testing.T) {
 	}
 	require.Equal(t, want, user)
 }
+
+func TestCreateUser(t *testing.T) {
+	mux, client := setup(t)
+
+	path := "/api/v4/users"
+
+	mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodPost)
+		if !strings.Contains(r.Header.Get("Content-Type"), "application/json") {
+			t.Fatalf("Users.CreateUser request content-type %+v want application/json;", r.Header.Get("Content-Type"))
+		}
+		if r.ContentLength == -1 {
+			t.Fatalf("Users.CreateUser request content-length is -1")
+		}
+		w.WriteHeader(http.StatusCreated)
+		w.Write([]byte(`
+    {
+      "email": "user999@example.com",
+      "id": 999,
+      "name":"Firstname Lastname",
+      "username":"user"
+    }`))
+	})
+
+	user, _, err := client.Users.CreateUser(&CreateUserOptions{
+		Email:    Ptr("user999@example.com"),
+		Name:     Ptr("Firstname Lastname"),
+		Username: Ptr("user"),
+	})
+	require.NoError(t, err)
+
+	want := &User{
+		Email:    "user999@example.com",
+		ID:       999,
+		Name:     "Firstname Lastname",
+		Username: "user",
+	}
+	require.Equal(t, want, user)
+}
+
+func TestCreateUserAvatar(t *testing.T) {
+	mux, client := setup(t)
+
+	path := "/api/v4/users"
+
+	mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodPost)
+		if !strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") {
+			t.Fatalf("Users.CreateUser request content-type %+v want multipart/form-data;", r.Header.Get("Content-Type"))
+		}
+		if r.ContentLength == -1 {
+			t.Fatalf("Users.CreateUser request content-length is -1")
+		}
+		w.WriteHeader(http.StatusCreated)
+		w.Write([]byte(`
+    {
+      "avatar_url":"http://localhost:3000/uploads/-/system/user/avatar/999/avatar.png",
+      "email": "user999@example.com",
+      "id": 999,
+      "name":"Firstname Lastname",
+      "username":"user"
+    }`))
+	})
+	avatar := new(bytes.Buffer)
+	userAvatar := &UserAvatar{
+		Image:    avatar,
+		Filename: "avatar.png",
+	}
+	user, _, err := client.Users.CreateUser(&CreateUserOptions{
+		Avatar:   userAvatar,
+		Email:    Ptr("user999@example.com"),
+		Name:     Ptr("Firstname Lastname"),
+		Username: Ptr("user"),
+	})
+	require.NoError(t, err)
+
+	want := &User{
+		AvatarURL: "http://localhost:3000/uploads/-/system/user/avatar/999/avatar.png",
+		Email:     "user999@example.com",
+		ID:        999,
+		Name:      "Firstname Lastname",
+		Username:  "user",
+	}
+	require.Equal(t, want, user)
+}
+
+func TestModifyUser(t *testing.T) {
+	mux, client := setup(t)
+
+	path := "/api/v4/users/1"
+
+	mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodPut)
+		if !strings.Contains(r.Header.Get("Content-Type"), "application/json") {
+			t.Fatalf("Users.ModifyUser request content-type %+v want application/json;", r.Header.Get("Content-Type"))
+		}
+		if r.ContentLength == -1 {
+			t.Fatalf("Users.ModifyUser request content-length is -1")
+		}
+		fmt.Fprint(w, `{}`)
+	})
+	_, _, err := client.Users.ModifyUser(1, &ModifyUserOptions{})
+	require.NoError(t, err)
+}
+
+func TestModifyUserAvatar(t *testing.T) {
+	mux, client := setup(t)
+
+	path := "/api/v4/users/1"
+
+	mux.HandleFunc(path, 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("Users.ModifyUser request content-type %+v want multipart/form-data;", r.Header.Get("Content-Type"))
+		}
+		if r.ContentLength == -1 {
+			t.Fatalf("Users.ModifyUser request content-length is -1")
+		}
+		fmt.Fprint(w, `{}`)
+	})
+	avatar := new(bytes.Buffer)
+	userAvatar := &UserAvatar{
+		Image:    avatar,
+		Filename: "avatar.png",
+	}
+	_, _, err := client.Users.ModifyUser(1, &ModifyUserOptions{Avatar: userAvatar})
+	require.NoError(t, err)
+}
+
+func TestUploadAvatarUser(t *testing.T) {
+	mux, client := setup(t)
+
+	mux.HandleFunc("/api/v4/user/avatar", 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("Users.UploadAvatar request content-type %+v want multipart/form-data;", r.Header.Get("Content-Type"))
+		}
+		if r.ContentLength == -1 {
+			t.Fatalf("Users.UploadAvatar request content-length is -1")
+		}
+		fmt.Fprint(w, `{}`)
+	})
+
+	avatar := new(bytes.Buffer)
+	_, _, err := client.Users.UploadAvatar(avatar, "avatar.png")
+	if err != nil {
+		t.Fatalf("Users.UploadAvatar returns an error: %v", err)
+	}
+}