// Copyright 2023 Harness, Inc. // // 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 githook import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "github.com/harness/gitness/app/api/request" "github.com/harness/gitness/git/hook" "github.com/harness/gitness/types" "github.com/harness/gitness/version" ) const ( // HTTPRequestPathPreReceive is the subpath under the provided base url the client uses to call pre-receive. HTTPRequestPathPreReceive = "pre-receive" // HTTPRequestPathPostReceive is the subpath under the provided base url the client uses to call post-receive. HTTPRequestPathPostReceive = "post-receive" // HTTPRequestPathUpdate is the subpath under the provided base url the client uses to call update. HTTPRequestPathUpdate = "update" ) // RestClientFactory creates clients that make rest api calls to gitness to execute githooks. type RestClientFactory struct{} func (f *RestClientFactory) NewClient(envVars map[string]string) (hook.Client, error) { payload, err := hook.LoadPayloadFromMap[Payload](envVars) if err != nil { return nil, fmt.Errorf("failed to load payload from provided map of environment variables: %w", err) } // ensure we return disabled message in case it's explicitly disabled if payload.Disabled { return hook.NewNoopClient([]string{"hook disabled"}), nil } if err := payload.Validate(); err != nil { return nil, fmt.Errorf("payload validation failed: %w", err) } return NewRestClient(payload), nil } // RestClient is the hook.Client used to call the githooks api of gitness api server. type RestClient struct { httpClient *http.Client baseURL string requestID string baseInput types.GithookInputBase } func NewRestClient( payload Payload, ) hook.Client { return &RestClient{ httpClient: http.DefaultClient, baseURL: strings.TrimRight(payload.BaseURL, "/"), requestID: payload.RequestID, baseInput: GetInputBaseFromPayload(payload), } } // PreReceive calls the pre-receive githook api of the gitness api server. func (c *RestClient) PreReceive( ctx context.Context, in hook.PreReceiveInput, ) (hook.Output, error) { return c.githook(ctx, HTTPRequestPathPreReceive, types.GithookPreReceiveInput{ GithookInputBase: c.baseInput, PreReceiveInput: in, }) } // Update calls the update githook api of the gitness api server. func (c *RestClient) Update( ctx context.Context, in hook.UpdateInput, ) (hook.Output, error) { return c.githook(ctx, HTTPRequestPathUpdate, types.GithookUpdateInput{ GithookInputBase: c.baseInput, UpdateInput: in, }) } // PostReceive calls the post-receive githook api of the gitness api server. func (c *RestClient) PostReceive( ctx context.Context, in hook.PostReceiveInput, ) (hook.Output, error) { return c.githook(ctx, HTTPRequestPathPostReceive, types.GithookPostReceiveInput{ GithookInputBase: c.baseInput, PostReceiveInput: in, }) } // githook executes the requested githook type using the provided input. func (c *RestClient) githook(ctx context.Context, githookType string, payload interface{}) (hook.Output, error) { uri := c.baseURL + "/" + githookType bodyBytes, err := json.Marshal(payload) if err != nil { return hook.Output{}, fmt.Errorf("failed to serialize input: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri, bytes.NewBuffer(bodyBytes)) if err != nil { return hook.Output{}, fmt.Errorf("failed to create new http request: %w", err) } req.Header.Add("Content-Type", "application/json") req.Header.Add(request.HeaderUserAgent, fmt.Sprintf("Gitness/%s", version.Version)) req.Header.Add(request.HeaderRequestID, c.requestID) // Execute the request resp, err := c.httpClient.Do(req) // ensure the body is closed after we read (independent of status code or error) if resp != nil && resp.Body != nil { // Use function to satisfy the linter which complains about unhandled errors otherwise defer func() { _ = resp.Body.Close() }() } if err != nil { return hook.Output{}, fmt.Errorf("request execution failed: %w", err) } return unmarshalResponse[hook.Output](resp) } // unmarshalResponse reads the response body and if there are no errors marshall's it into // the data struct. func unmarshalResponse[T any](resp *http.Response) (T, error) { var body T if resp == nil { return body, errors.New("http response is empty") } if resp.StatusCode == http.StatusNotFound { return body, hook.ErrNotFound } if resp.StatusCode != http.StatusOK { return body, fmt.Errorf("expected response code 200 but got: %s", resp.Status) } // ensure we actually got a body returned. if resp.Body == nil { return body, errors.New("http response body is empty") } rawBody, err := io.ReadAll(resp.Body) if err != nil { return body, fmt.Errorf("error reading response body : %w", err) } err = json.Unmarshal(rawBody, &body) if err != nil { return body, fmt.Errorf("error deserializing response body: %w", err) } return body, nil }