Skip to content

Commit e92134d

Browse files
committed
fix project skeleton and add basic functionality for pipelines and pull requests
1 parent bee86ba commit e92134d

18 files changed

+1088
-1174
lines changed

client/client.go

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
package client
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"time"
13+
14+
"github.com/cenkalti/backoff/v4"
15+
"github.com/harness/harness-mcp/client/dto"
16+
"github.com/rs/zerolog/log"
17+
)
18+
19+
var (
20+
defaultBaseURL = "https://app.harness.io/"
21+
22+
defaultPageSize = 5
23+
24+
apiKeyHeader = "x-api-key"
25+
)
26+
27+
var (
28+
ErrBadRequest = fmt.Errorf("bad request")
29+
ErrNotFound = fmt.Errorf("not found")
30+
ErrInternal = fmt.Errorf("internal error")
31+
)
32+
33+
type Client struct {
34+
client *http.Client // HTTP client used for communicating with the Harness API
35+
36+
// Base URL for API requests. Defaults to the public Harness API, but can be
37+
// set to a domain endpoint to use with custom Harness installations
38+
BaseURL *url.URL
39+
40+
// API key for authentication
41+
// TODO: We can abstract out the auth provider
42+
APIKey string
43+
44+
Debug bool
45+
46+
// Services used for talking to different Harness entities
47+
Connectors *ConnectorService
48+
PullRequests *PullRequestService
49+
Pipelines *PipelineService
50+
}
51+
52+
type service struct {
53+
client *Client
54+
}
55+
56+
func defaultHTTPClient() *http.Client {
57+
return &http.Client{
58+
Timeout: 10 * time.Second,
59+
}
60+
}
61+
62+
// NewWithToken creates a new client with the specified base URL and API token
63+
func NewWithToken(uri, apiKey string) (*Client, error) {
64+
parsedURL, err := url.Parse(uri)
65+
if err != nil {
66+
return nil, err
67+
}
68+
c := &Client{
69+
client: defaultHTTPClient(),
70+
BaseURL: parsedURL,
71+
APIKey: apiKey,
72+
}
73+
c.initialize()
74+
return c, nil
75+
}
76+
77+
// SetDebug sets the debug flag. When the debug flag is
78+
// true, the http.Resposne body to stdout which can be
79+
// helpful when debugging.
80+
func (c *Client) SetDebug(debug bool) {
81+
c.Debug = debug
82+
}
83+
84+
func (c *Client) initialize() error {
85+
if c.client == nil {
86+
c.client = defaultHTTPClient()
87+
}
88+
if c.BaseURL == nil {
89+
baseURL, err := url.Parse(defaultBaseURL)
90+
if err != nil {
91+
return err
92+
}
93+
c.BaseURL = baseURL
94+
}
95+
96+
c.Connectors = &ConnectorService{client: c}
97+
c.PullRequests = &PullRequestService{client: c}
98+
c.Pipelines = &PipelineService{client: c}
99+
100+
return nil
101+
}
102+
103+
// Get is a simple helper that builds up the request URL, adding the path and parameters.
104+
// The response from the request is unmarshalled into the data parameter.
105+
func (c *Client) Get(
106+
ctx context.Context,
107+
path string,
108+
params map[string]string,
109+
headers map[string]string,
110+
response interface{},
111+
) error {
112+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, appendPath(c.BaseURL.String(), path), nil)
113+
if err != nil {
114+
return fmt.Errorf("unable to create new http request : %w", err)
115+
}
116+
117+
addQueryParams(httpReq, params)
118+
for key, value := range headers {
119+
httpReq.Header.Add(key, value)
120+
}
121+
// Execute the request
122+
resp, err := c.Do(httpReq)
123+
124+
// ensure the body is closed after we read (independent of status code or error)
125+
if resp != nil && resp.Body != nil {
126+
// Use function to satisfy the linter which complains about unhandled errors otherwise
127+
defer func() { _ = resp.Body.Close() }()
128+
}
129+
130+
if err != nil {
131+
return fmt.Errorf("request execution failed: %w", err)
132+
}
133+
134+
// map the error from the status code
135+
err = mapStatusCodeToError(resp.StatusCode)
136+
137+
// response output is optional
138+
if response == nil || resp == nil || resp.Body == nil {
139+
return err
140+
}
141+
142+
var respBody string
143+
if unmarshalErr := unmarshalResponse(resp, response); unmarshalErr != nil {
144+
body, _ := io.ReadAll(resp.Body)
145+
respBody = string(body)
146+
return fmt.Errorf("response body unmarshal error: %w - original response: %s", unmarshalErr, respBody)
147+
}
148+
149+
if err != nil {
150+
return fmt.Errorf("%w - response body: %s", err, respBody)
151+
}
152+
153+
return err
154+
}
155+
156+
// Post is a simple helper that builds up the request URL, adding the path and parameters.
157+
// The response from the request is unmarshalled into the out parameter.
158+
func (c *Client) Post(
159+
ctx context.Context,
160+
path string,
161+
params map[string]string,
162+
body interface{},
163+
out interface{},
164+
b ...backoff.BackOff,
165+
) error {
166+
bodyBytes, err := json.Marshal(body)
167+
if err != nil {
168+
return fmt.Errorf("failed to serialize body: %w", err)
169+
}
170+
171+
return c.PostRaw(ctx, path, params, bytes.NewBuffer(bodyBytes), nil, out, b...)
172+
}
173+
174+
// PostRaw is a simple helper that builds up the request URL, adding the path and parameters.
175+
// The response from the request is unmarshalled into the out parameter.
176+
func (c *Client) PostRaw(
177+
ctx context.Context,
178+
path string,
179+
params map[string]string,
180+
body io.Reader,
181+
headers map[string]string,
182+
out interface{},
183+
b ...backoff.BackOff,
184+
) error {
185+
var retryCount int
186+
187+
operation := func() error {
188+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, appendPath(c.BaseURL.String(), path), body)
189+
if err != nil {
190+
return backoff.Permanent(fmt.Errorf("unable to create HTTP request: %w", err))
191+
}
192+
193+
req.Header.Set("Content-Type", "application/json")
194+
// Add custom headers from the headers map
195+
for key, value := range headers {
196+
req.Header.Add(key, value)
197+
}
198+
addQueryParams(req, params)
199+
200+
resp, err := c.Do(req)
201+
202+
if resp != nil && resp.Body != nil {
203+
defer resp.Body.Close()
204+
}
205+
206+
if err != nil || resp == nil {
207+
return fmt.Errorf("request failed: %w", err)
208+
}
209+
210+
if isRetryable(resp.StatusCode) {
211+
return fmt.Errorf("retryable status code: %d", resp.StatusCode)
212+
}
213+
214+
if statusErr := mapStatusCodeToError(resp.StatusCode); statusErr != nil {
215+
return backoff.Permanent(statusErr)
216+
}
217+
218+
if out != nil && resp.Body != nil {
219+
if err := unmarshalResponse(resp, out); err != nil {
220+
return fmt.Errorf("unmarshal error: %w", err)
221+
}
222+
}
223+
224+
return nil
225+
}
226+
227+
notify := func(err error, next time.Duration) {
228+
retryCount++
229+
log.Warn().
230+
Int("retry_count", retryCount).
231+
Dur("next_retry_in", next).
232+
Err(err).
233+
Msg("Retrying request due to error")
234+
}
235+
236+
if len(b) > 0 {
237+
if err := backoff.RetryNotify(operation, b[0], notify); err != nil {
238+
return fmt.Errorf("request failed after %d retries: %w", retryCount, err)
239+
}
240+
} else {
241+
if err := operation(); err != nil {
242+
return err
243+
}
244+
}
245+
246+
return nil
247+
}
248+
249+
// Do is a wrapper of http.Client.Do that injects the auth header in the request.
250+
func (c *Client) Do(r *http.Request) (*http.Response, error) {
251+
r.Header.Add(apiKeyHeader, c.APIKey)
252+
253+
return c.client.Do(r)
254+
}
255+
256+
// appendPath appends the provided path to the uri
257+
// any redundant '/' between uri and path will be removed.
258+
func appendPath(uri string, path string) string {
259+
if path == "" {
260+
return uri
261+
}
262+
263+
return strings.TrimRight(uri, "/") + "/" + strings.TrimLeft(path, "/")
264+
}
265+
266+
// nolint:godot
267+
// unmarshalResponse reads the response body and if there are no errors marshall's it into data.
268+
func unmarshalResponse(resp *http.Response, data interface{}) error {
269+
if resp == nil || resp.Body == nil {
270+
return fmt.Errorf("http response body is not available")
271+
}
272+
273+
body, err := io.ReadAll(resp.Body)
274+
if err != nil {
275+
return fmt.Errorf("error reading response body : %w", err)
276+
}
277+
278+
err = json.Unmarshal(body, data)
279+
if err != nil {
280+
return fmt.Errorf("error deserializing response body : %w", err)
281+
}
282+
283+
return nil
284+
}
285+
286+
// helper function.
287+
func isRetryable(status int) bool {
288+
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
289+
}
290+
291+
func mapStatusCodeToError(statusCode int) error {
292+
switch {
293+
case statusCode == 500:
294+
return ErrInternal
295+
case statusCode >= 500:
296+
return fmt.Errorf("received server side error status code %d", statusCode)
297+
case statusCode == 404:
298+
return ErrNotFound
299+
case statusCode == 400:
300+
return ErrBadRequest
301+
case statusCode >= 400:
302+
return fmt.Errorf("received client side error status code %d", statusCode)
303+
case statusCode >= 300:
304+
return fmt.Errorf("received further action required status code %d", statusCode)
305+
default:
306+
// TODO: definitely more things to consider here ...
307+
return nil
308+
}
309+
}
310+
311+
// addQueryParams if the params map is not empty, it adds each key/value pair to
312+
// the request URL.
313+
func addQueryParams(req *http.Request, params map[string]string) {
314+
if len(params) == 0 {
315+
return
316+
}
317+
318+
q := req.URL.Query()
319+
320+
for key, value := range params {
321+
for _, value := range strings.Split(value, ",") {
322+
q.Add(key, value)
323+
}
324+
}
325+
326+
req.URL.RawQuery = q.Encode()
327+
}
328+
329+
func addScope(scope dto.Scope, params map[string]string) {
330+
params["accountIdentifier"] = scope.AccountID
331+
params["orgIdentifier"] = scope.OrgID
332+
params["projectIdentifier"] = scope.ProjectID
333+
}
334+
335+
func setDefaultPagination(opts *dto.PaginationOptions) {
336+
if opts != nil {
337+
opts.Size = defaultPageSize
338+
}
339+
}

client/connectors.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package client
2+
3+
type ConnectorService struct {
4+
client *Client
5+
}

0 commit comments

Comments
 (0)