From e860d6ed0aa4c6e9a9665568f906562841f003cc Mon Sep 17 00:00:00 2001 From: Soule BA Date: Fri, 10 Sep 2021 12:12:03 +0200 Subject: [PATCH] adds first service to the gostash client UserService permits retrieving indivudual users and listing them. Signed-off-by: Soule BA --- {http => go-stash}/client.go | 58 ++++++--- {http => go-stash}/client_test.go | 28 +++-- go-stash/resources.go | 105 +++++++++++++++++ go-stash/users.go | 188 ++++++++++++++++++++++++++++++ go-stash/users_test.go | 155 ++++++++++++++++++++++++ 5 files changed, 514 insertions(+), 20 deletions(-) rename {http => go-stash}/client.go (88%) rename {http => go-stash}/client_test.go (91%) create mode 100644 go-stash/resources.go create mode 100644 go-stash/users.go create mode 100644 go-stash/users_test.go diff --git a/http/client.go b/go-stash/client.go similarity index 88% rename from http/client.go rename to go-stash/client.go index 7f365597..0b1c3400 100644 --- a/http/client.go +++ b/go-stash/client.go @@ -1,11 +1,28 @@ -package http +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gostash import ( + "bytes" "context" "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "math/rand" "net/http" "net/url" @@ -54,8 +71,8 @@ type Doer interface { Do(req *http.Request) ([]byte, *http.Response, error) } -// ClientOptions are options for the Client. -// It can be used for exemple to setup a custom http Client. +// ClientOptionsFunc are options for the Client. +// It can be used for example to setup a custom http Client. type ClientOptionsFunc func(Client *Client) // A Client is a retryable HTTP Client. @@ -79,6 +96,9 @@ type Client struct { HeaderFields *http.Header // Logger is the logger used to log the request and response. Logger *logr.Logger + + // Services are used to communicate with the different stash endpoints. + Users *UsersService } // RateLimiter is the interface that wraps the basic Wait method. @@ -149,6 +169,11 @@ func NewClient(httpClient *http.Client, host string, header *http.Header, logger c.HeaderFields = header + c.Users = &UsersService{ + Client: c, + log: *c.Logger, + } + return c, nil } @@ -281,12 +306,12 @@ func (c *Client) configureLimiter() error { return nil } -// NewRequest creates a request, and returns an retryablehttp.Request and an error, +// NewRequest creates a request, and returns an http.Request and an error, // given a path and optional method, query, body, and header. // A relative URL path can be provided in path, in which case it is 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(ctx context.Context, method string, path string, query url.Values, body interface{}, header http.Header) (*retryablehttp.Request, error) { // nolint:funlen,gocognit,gocyclo // ok +func (c *Client) NewRequest(ctx context.Context, method string, path string, query url.Values, body interface{}, header http.Header) (*http.Request, error) { u := *c.BaseURL unescaped, err := url.PathUnescape(path) if err != nil { @@ -301,13 +326,15 @@ func (c *Client) NewRequest(ctx context.Context, method string, path string, que method = http.MethodGet } - var jsonBody interface{} + var bodyReader io.ReadCloser if (method == http.MethodPost || method == http.MethodPut) && body != nil { jsonBody, e := json.Marshal(body) if e != nil { return nil, fmt.Errorf("failed to marshall request body, %w", e) } + bodyReader = io.NopCloser(bytes.NewReader(jsonBody)) + c.Logger.V(2).Info("request", "body", string(jsonBody)) } @@ -317,7 +344,7 @@ func (c *Client) NewRequest(ctx context.Context, method string, path string, que u.RawQuery = query.Encode() - req, err := retryablehttp.NewRequest(method, u.String(), jsonBody) + req, err := http.NewRequest(method, u.String(), bodyReader) if err != nil { return req, fmt.Errorf("failed create request for %s %s, %w", method, u.String(), err) } @@ -343,11 +370,11 @@ func (c *Client) NewRequest(ctx context.Context, method string, path string, que return req, nil } -// Do performs a request, and returns an http.Response and an error given an retryablehttp.Request. +// Do performs a request, and returns an http.Response and an error given an http.Request. // For an outgoing Client request, the context controls the entire lifetime of a reques: // obtaining a connection, sending the request, checking errors and retrying. // The response body is not closed. -func (c *Client) Do(request *retryablehttp.Request) ([]byte, *http.Response, error) { +func (c *Client) Do(request *http.Request) ([]byte, *http.Response, error) { // If not yet configured, try to configure the rate limiter. Fail // silently as the limiter will be disabled in case of an error. c.configureLimiterOnce.Do(func() { c.configureLimiter() }) @@ -360,7 +387,12 @@ func (c *Client) Do(request *retryablehttp.Request) ([]byte, *http.Response, err c.Logger.V(2).Info("request", "method", request.Method, "url", request.URL) - resp, err := c.Client.Do(request) + req, err := retryablehttp.FromRequest(request) + if err != nil { + return nil, nil, err + } + + resp, err := c.Client.Do(req) if err != nil { return nil, nil, err } @@ -382,9 +414,9 @@ func (c *Client) Do(request *retryablehttp.Request) ([]byte, *http.Response, err return nil, resp, fmt.Errorf("request %s %s returned status code: %s, %w", request.Method, request.URL, resp.Status, ErrorUnexpectedStatusCode) } -// getRespBody is used to obtain the response body as a string. +// getRespBody is used to obtain the response body as a []byte. func getRespBody(resp *http.Response) ([]byte, error) { - data, err := ioutil.ReadAll(resp.Body) + data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } diff --git a/http/client_test.go b/go-stash/client_test.go similarity index 91% rename from http/client_test.go rename to go-stash/client_test.go index 10f7577f..9034fd69 100644 --- a/http/client_test.go +++ b/go-stash/client_test.go @@ -1,11 +1,27 @@ -package http +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gostash import ( "bytes" "context" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" "net/url" @@ -222,9 +238,7 @@ func Test_Do(t *testing.T) { if tt.method == http.MethodGet { user := user{} - // Restore the io.ReadCloser to its original state - resp.Body = ioutil.NopCloser(bytes.NewBuffer(res)) - err := json.NewDecoder(resp.Body).Decode(&user) + err := json.Unmarshal(res, &user) if err != nil { t.Fatalf("%s users failed, unable to obtain response body: %v", tt.method, err) } @@ -273,11 +287,11 @@ func Test_DoWithRetry(t *testing.T) { c := NewTestClient(t, func(req *http.Request) (*http.Response, error) { if retries < tt.retries { retries++ - return nil, fmt.Errorf("connection refused, please retry.") + return nil, fmt.Errorf("connection refused, please retry") } return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(bytes.NewBufferString(fmt.Sprint(retries))), + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprint(retries))), Header: make(http.Header), }, nil }, func(c *Client) { diff --git a/go-stash/resources.go b/go-stash/resources.go new file mode 100644 index 00000000..b6e56626 --- /dev/null +++ b/go-stash/resources.go @@ -0,0 +1,105 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gostash + +import ( + "net/http" +) + +const ( + contextKey = "context" + filterKey = "filter" + stashURIprefix = "/rest/api/1.0" + stashURIkeys = "/rest/keys/1.0" +) + +// Session keeps a record of a request for a given user. +type Session struct { + // UserID is the ID of the user making the request. + UserID string `json:"userID,omitempty"` + // UserName is the name of the user making the request. + UserName string `json:"userName,omitempty"` + // SessionID is the ID of the session. + SessionID string `json:"sessionID,omitempty"` + // RequestID is the ID of the request. + RequestID string `json:"requestID,omitempty"` +} + +func (s *Session) set(resp *http.Response) { + s.UserID = resp.Header.Get("X-Auserid") + s.UserName = resp.Header.Get("X-Ausername") + s.SessionID = resp.Header.Get("X-Asessionid") + s.RequestID = resp.Header.Get("X-Arequestid") +} + +func (s *Session) copy(p *Session) { + s.UserID = p.UserID + s.UserName = p.UserName + s.SessionID = p.SessionID + s.RequestID = p.RequestID +} + +// Paging is the paging information. +type Paging struct { + // IsLastPage indicates whether another page of items exists. + IsLastPage bool `json:"isLastPage,omitempty"` + // Limit indicates how many results to return per page. + Limit int64 `json:"limit,omitempty"` + // Size indicates the total number of results.. + Size int64 `json:"size,omitempty"` + // Start indicates which item should be used as the first item in the page of results. + Start int64 `json:"start,omitempty"` + // NexPageStart must be used by the client as the start parameter on the next request. + // Identifiers of adjacent objects in a page may not be contiguous, + // so the start of the next page is not necessarily the start of the last page plus the last page's size. + // Always use nextPageStart to avoid unexpected results from a paged API. + NextPageStart int64 `json:"nextPageStart,omitempty"` +} + +// IsLast returns true if the paging information indicates that there are no more pages. +func (p *Paging) IsLast() bool { + return p.IsLastPage +} + +// PagingOptions is the options for paging. +type PagingOptions struct { + // Start indicates which item should be used as the first item in the page of results. + Start int64 + // Limit indicates how many results to return per page. + Limit int64 +} + +// Self indicates the hyperlink to a REST resource. +type Self struct { + Href string `json:"href,omitempty"` +} + +// Clone is a hyperlink to another REST resource. +type Clone struct { + // Href is the hyperlink to the resource. + Href string `json:"href,omitempty"` + // Name is the name of the resource. + Name string `json:"name,omitempty"` +} + +// Links is a set of hyperlinks that link to other related resources. +type Links struct { + // Self is the hyperlink to the resource. + Self []Self `json:"self,omitempty"` + // Clone is a set of hyperlinks to other REST resources. + Clone []Clone `json:"clone,omitempty"` +} diff --git a/go-stash/users.go b/go-stash/users.go new file mode 100644 index 00000000..b07dc348 --- /dev/null +++ b/go-stash/users.go @@ -0,0 +1,188 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gostash + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-logr/logr" +) + +const ( + usersURI = "users" +) + +var ( + // ErrNotFound is returned when a resource is not found. + ErrNotFound = fmt.Errorf("not found") +) + +// UsersService is a client for communicating with stash users endpoint +// Stash API docs: https://docs.atlassian.com/DAC/rest/stash/3.11.3/stash-rest.html +type UsersService struct { + // Client is the client used to communicate with the Stash API. + Client *Client + log logr.Logger +} + +// User represents a Stash user. +type User struct { + // Session is the session information for the user. + Session Session `json:"sessionInfo,omitempty"` + // Active is true if the user is active. + Active bool `json:"active,omitempty"` + // Deletable is true if the user is deletable. + Deletable bool `json:"deletable,omitempty"` + // DirectoryName is the directory name where the user is saved. + DirectoryName string `json:"directoryName,omitempty"` + // DisplayName is the display name of the user. + DisplayName string `json:"displayName,omitempty"` + // EmailAddress is the email address of the user. + EmailAddress string `json:"emailAddress,omitempty"` + // ID is the unique identifier of the user. + ID int64 `json:"id,omitempty"` + // LastAuthenticationTimestamp is the last authentication timestamp of the user. + LastAuthenticationTimestamp int64 `json:"lastAuthenticationTimestamp,omitempty"` + // Links is the links to other resources. + Links `json:"links,omitempty"` + // MutableDetails is true if the user is mutable. + MutableDetails bool `json:"mutableDetails,omitempty"` + // MutableGroups is true if the groups are mutable. + MutableGroups bool `json:"mutableGroups,omitempty"` + // Name is the name of the user. + Name string `json:"name,omitempty"` + // Slug is the slug of the user. + Slug string `json:"slug,omitempty"` + // Type is the type of the user. + Type string `json:"type,omitempty"` +} + +// UserList is a list of users. +type UserList struct { + // Paging is the paging information. + Paging + // Users is the list of Stash Users. + Users []*User `json:"values,omitempty"` +} + +// GetUsers retrieves a list of users. +func (u *UserList) GetUsers() []*User { + return u.Users +} + +// List retrieves a list of users. +// Paging is optional and is enabled by providing a PagingOptions struct. +// A pointer to a paging struct is returned to retrieve the next page of results. +// List uses the endpoint "GET /rest/api/1.0/users". +func (s *UsersService) List(ctx context.Context, opts *PagingOptions) (*UserList, error) { + var query *url.Values + query = addPaging(query, opts) + req, err := s.Client.NewRequest(ctx, http.MethodGet, newURI(usersURI), *query, nil, nil) + if err != nil { + return nil, fmt.Errorf("list users request creation failed, %w", err) + } + + res, resp, err := s.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("list users failed, %w", err) + } + + // As nothing is done with the response body, it is safe to close here + // to avoid leaking connections + defer resp.Body.Close() + + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, ErrNotFound + } + + u := &UserList{ + Users: []*User{}, + } + if err := json.Unmarshal(res, u); err != nil { + return nil, fmt.Errorf("list users failed, unable to unmarshal repository list json, %w", err) + } + + for _, r := range u.GetUsers() { + r.Session.set(resp) + } + + return u, nil +} + +// GetUser retrieves a user by name. +// Get uses the endpoint "GET /rest/api/1.0/users/{userSlug}". +func (s *UsersService) GetUser(ctx context.Context, userSlug string) (*User, error) { + var query *url.Values + query = addPaging(query, &PagingOptions{}) + req, err := s.Client.NewRequest(ctx, http.MethodGet, newURI(usersURI, userSlug), *query, nil, nil) + if err != nil { + return nil, fmt.Errorf("get user request creation failed, %w", err) + } + res, resp, err := s.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("get user failed, %w", err) + } + + // As nothing is done with the response body, it is safe to close here + // to avoid leaking connections + defer resp.Body.Close() + + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, ErrNotFound + } + + var user User + + if err := json.Unmarshal(res, &user); err != nil { + return nil, fmt.Errorf("get user failed, unable to unmarshal repository list json, %w", err) + } + + user.Session.set(resp) + return &user, nil + +} + +// addPaging adds paging elements to URI query +func addPaging(query *url.Values, opts *PagingOptions) *url.Values { + if query == nil { + query = &url.Values{} + } + + if opts == nil { + return query + } + + if opts.Limit != 0 { + query.Add("limit", strconv.Itoa(int(opts.Limit))) + } + + if opts.Start != 0 { + query.Add("start", strconv.Itoa(int(opts.Start))) + } + return query +} + +// newURI builds stash URI +func newURI(elements ...string) string { + return strings.Join(append([]string{stashURIprefix}, elements...), "/") +} diff --git a/go-stash/users_test.go b/go-stash/users_test.go new file mode 100644 index 00000000..238691fa --- /dev/null +++ b/go-stash/users_test.go @@ -0,0 +1,155 @@ +/* +Copyright 2021 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gostash + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// setup sets up a test HTTP server along with a Client configured to talk to that test server. +// Tests should register handlers on mux which provide mock responses for the API method being tested. +func setup(t *testing.T) (*http.ServeMux, *httptest.Server, *Client) { + mux := http.NewServeMux() + // Start a local HTTP server + server := httptest.NewServer(mux) + // declare a Client + berearHeader := &http.Header{ + "WWW-Authenticate": []string{"Bearer"}, + } + client, err := NewClient(nil, server.URL, berearHeader, initLogger(t)) + if err != nil { + server.Close() + t.Errorf("unexpected error while declaring a client: %v", err) + } + return mux, server, client +} + +// teardown closes the test HTTP server. +func teardown(server *httptest.Server) { + server.Close() +} + +func TestGetUser(t *testing.T) { + tests := []struct { + name string + slug string + output string + }{ + { + name: "test user does not exist", + slug: "admin", + }, + { + name: "test a user", + slug: "jcitizen", + }, + } + + validSlugs := []string{"jcitizen"} + + mux, server, client := setup(t) + defer teardown(server) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("%s/users/%s", stashURIprefix, tt.slug) + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + for _, substr := range validSlugs { + if strings.Contains(r.URL.Path, substr) { + w.WriteHeader(http.StatusOK) + u := &User{ + Slug: substr, + } + json.NewEncoder(w).Encode(u) + return + } + } + + http.Error(w, "The specified user does not exist", http.StatusNotFound) + + return + + }) + + ctx := context.Background() + user, err := client.Users.GetUser(ctx, tt.slug) + if err != nil { + if err != ErrNotFound { + t.Fatalf("Users.GetUser returned error: %v", err) + } + } else { + if user.Slug != tt.slug { + t.Errorf("Users.GetUser returned user %s, want %s", user.Slug, tt.slug) + } + } + + }) + } +} + +func TestUserList(t *testing.T) { + slugs := []string{"jcitizen", "tstark", "rrichards", "rwilliams"} + + mux, server, client := setup(t) + defer teardown(server) + + path := fmt.Sprintf("%s/users", stashURIprefix) + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + + w.WriteHeader(http.StatusOK) + u := struct { + Users []User `json:"values"` + }{[]User{ + { + Name: "John Citizen", + Slug: slugs[0], + }, + { + Name: "Tony Stark", + Slug: slugs[1], + }, + { + Name: "Reed Richards", + Slug: slugs[2], + }, + { + Name: "Riri Williams", + Slug: slugs[3], + }, + }} + json.NewEncoder(w).Encode(u) + return + }) + + ctx := context.Background() + list, err := client.Users.List(ctx, nil) + if err != nil { + if err != ErrNotFound { + t.Fatalf("Users.GetUser returned error: %v", err) + } + } + + if len(list.Users) != len(slugs) { + t.Fatalf("Users.GetUser returned %d users, want %d", len(list.Users), len(slugs)) + } +}