Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use workspace client [IDE-195] #24

Merged
merged 2 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ jobs:
- run:
name: Run unit tests
command: make test
smoke_test:
executor: default
steps:
- checkout
- run:
name: Install tools
command: make tools
- run:
name: Run smoke tests
command: make smoke-test
build:
executor: default
steps:
Expand Down Expand Up @@ -67,9 +77,15 @@ workflows:
name: Unit tests
requires:
- Lint & Format
- smoke_test:
name: Smoke tests
context:
- code-client-go-smoke-tests-token
requires:
- Unit tests
- build:
name: Build
requires:
- Unit tests
- Smoke tests
- Security Scans
- Scan repository for secrets
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ make test

If writing unit tests, use the mocks generated by [GoMock](https://github.com/golang/mock) by running `make generate`.

If writing `pact` or integration tests, use the test implementations in [./internal/util/testutil](./internal/util/testutil).
If writing `pact`, integration, or smoke tests, use the test implementations in [./internal/util/testutil](./internal/util/testutil).

The organisation used by the smoke tests is `ide-consistent-ignores-test` in [https://app.dev.snyk.io](https://app.dev.snyk.io) and we are authenticating using a service account api key.

If you've changed any of the interfaces you may need to re-run `make generate` to generate the mocks again.

Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ test:
.PHONY: testv
testv:
@echo "Testing verbosely..."
@go test -v ./...
@go test -v

.PHONY: smoke-test
smoke-test:
@go test -run="Test_SmokeScan"

.PHONY: generate
generate: $(TOOLS_BIN)/go/mockgen $(TOOLS_BIN)/go/oapi-codegen
Expand Down
46 changes: 16 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,44 +38,30 @@ The HTTP client exposes a `DoCall` function.

Implement the `http.Config` interface to configure the Snyk Code API client from applications.

### Snyk Code Client

Use the Snyk Code Client to make calls to the DeepCode API using the `httpClient` HTTP client created above.

```go
snykCode := deepcode.NewSnykCodeClient(logger, httpClient, testutil.NewTestInstrumentor())
```

The Snyk Code Client exposes the following functions:
- `GetFilters`
- `CreateBundle`
- `ExtendBundle`

### Bundle Manager

Use the Bundle Manager to create bundles using the `snykCode` Snyk Code Client created above and then to extend it by uploading more files to it.

```go
bundleManager := bundle.NewBundleManager(logger, snykCode, testutil.NewTestInstrumentor(), testutil.NewTestCodeInstrumentor())
```

The Bundle Manager exposes the following functions:
- `Create`
- `Upload`

### Code Scanner

Use the Code Scanner to trigger a scan for a Snyk Code workspace using the Bundle Manager created above.
The Code Scanner exposes a `UploadAndAnalyze` function, which can be used like this:

```go
codeScanner := codeclient.NewCodeScanner(
bundleManager,
testutil.NewTestInstrumentor(),
testutil.NewTestErrorReporter(),
import (
"net/http"

"github.com/rs/zerolog"
code "github.com/snyk/code-client-go"
)

logger := zerlog.NewLogger(...)
config := newConfigForMyApp()

codeScanner := code.NewCodeScanner(
httpClient,
config,
codeInstrumentor,
codeErrorReporter,
logger,
)
codeScanner.UploadAndAnalyze(context.Background(), "path/to/workspace", channelForWalkingFiles, changedFiles)
code.UploadAndAnalyze(context.Background(), requestId, "path/to/workspace", channelForWalkingFiles, changedFiles)
```


Expand Down
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ type Config interface {
// SnykCodeApi returns the Snyk Code API URL configured to run against, which could be
// the one used by the Local Code Engine.
SnykCodeApi() string

// SnykApi returns the Snyk REST API URL configured to run against,
SnykApi() string
}
14 changes: 14 additions & 0 deletions config/mocks/config.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 19 additions & 19 deletions http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
package http

import (
"errors"
"bytes"
"io"
"net/http"
"time"

Expand Down Expand Up @@ -78,19 +79,14 @@ func (s *httpClient) Do(req *http.Request) (response *http.Response, err error)
return nil, err // no retries for errors
}

err = s.checkResponseCode(response)
if err != nil {
if retryErrorCodes[response.StatusCode] {
Copy link
Contributor Author

@teodora-sandu teodora-sandu Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of throwing away the response (which may have useful information for the customer) and wrapping the status code in an error, we're going to return the original response and leave it up to the user of the HTTP client (deepcode vs workspace, for now) to decide what kind of error to return.

s.logger.Debug().Err(err).Str("method", req.Method).Int("attempts done", i+1).Msg("retrying")
if i < retryCount-1 {
time.Sleep(5 * time.Second)
continue
}
// return the error on last try
return nil, err
if retryErrorCodes[response.StatusCode] {
s.logger.Debug().Err(err).Str("method", req.Method).Int("attempts done", i+1).Msg("retrying")
if i < retryCount-1 {
time.Sleep(5 * time.Second)
continue
}
return nil, err
}

// no error, we can break the retry loop
break
}
Expand All @@ -99,7 +95,18 @@ func (s *httpClient) Do(req *http.Request) (response *http.Response, err error)

func (s *httpClient) httpCall(req *http.Request) (*http.Response, error) {
log := s.logger.With().Str("method", "http.httpCall").Logger()

// store the request body so that after retrying it can be read again
var copyReqBody io.ReadCloser
Copy link
Contributor Author

@teodora-sandu teodora-sandu Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed so that after every attempt we still have the request body. This code was refactored in #22 and before that change the body was built in every retry. I introduced a regression there so I decided to add a test as well to make sure this continues to work.

if req.Body != nil {
buf, _ := io.ReadAll(req.Body)
reqBody := io.NopCloser(bytes.NewBuffer(buf))
copyReqBody = io.NopCloser(bytes.NewBuffer(buf))
req.Body = reqBody
}
response, err := s.clientFactory().Do(req)
req.Body = copyReqBody

if err != nil {
log.Error().Err(err).Msg("got http error")
s.errorReporter.CaptureError(err, observability.ErrorReporterOptions{ErrorDiagnosticPath: req.RequestURI})
Expand All @@ -108,10 +115,3 @@ func (s *httpClient) httpCall(req *http.Request) (*http.Response, error) {

return response, nil
}

func (s *httpClient) checkResponseCode(r *http.Response) error {
if r.StatusCode >= 200 && r.StatusCode <= 299 {
return nil
}
return errors.New("Unexpected response code: " + r.Status)
}
45 changes: 42 additions & 3 deletions http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
package http_test

import (
"fmt"
"io"
"net/http"
"strings"
"testing"

"github.com/golang/mock/gomock"
Expand All @@ -36,8 +39,17 @@ type dummyTransport struct {
calls int
}

func (d *dummyTransport) RoundTrip(_ *http.Request) (*http.Response, error) {
func (d *dummyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
d.calls++
if req.Body != nil {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
if string(body) == "" {
return nil, fmt.Errorf("body is empty")
}
}
return &http.Response{
StatusCode: d.responseCode,
Status: d.status,
Expand All @@ -64,8 +76,35 @@ func TestSnykCodeBackendService_DoCall_shouldRetry(t *testing.T) {
require.NoError(t, err)

s := codeClientHTTP.NewHTTPClient(newLogger(t), dummyClientFactory, mockInstrumentor, mockErrorReporter)
_, err = s.Do(req)
assert.Error(t, err)
res, err := s.Do(req)
assert.NoError(t, err)
assert.NotNil(t, res)
assert.Equal(t, 3, d.calls)
}

func TestSnykCodeBackendService_DoCall_shouldRetryWithARequestBody(t *testing.T) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the test that verifies that the request body is still populated after a retry attempt.

d := &dummyTransport{responseCode: 502, status: "502 Bad Gateway"}
dummyClientFactory := func() *http.Client {
return &http.Client{
Transport: d,
}
}

ctrl := gomock.NewController(t)
mockSpan := mocks.NewMockSpan(ctrl)
mockSpan.EXPECT().GetTraceId().AnyTimes()
mockInstrumentor := mocks.NewMockInstrumentor(ctrl)
mockInstrumentor.EXPECT().StartSpan(gomock.Any(), gomock.Any()).Return(mockSpan).Times(1)
mockInstrumentor.EXPECT().Finish(gomock.Any()).Times(1)
mockErrorReporter := mocks.NewMockErrorReporter(ctrl)

req, err := http.NewRequest(http.MethodGet, "https://httpstat.us/500", io.NopCloser(strings.NewReader("body")))
require.NoError(t, err)

s := codeClientHTTP.NewHTTPClient(newLogger(t), dummyClientFactory, mockInstrumentor, mockErrorReporter)
res, err := s.Do(req)
assert.NoError(t, err)
assert.NotNil(t, res)
assert.Equal(t, 3, d.calls)
}

Expand Down
Loading
Loading