Skip to content
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
8 changes: 2 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,8 @@ jobs:
runs-on: ubuntu-latest
# The Enterprise Edition image exposes enterprise-only API surface without
# a license (most endpoints work unlicensed; analysis and a few
# license-gated operations do not). This job never blocks the pipeline:
# if the image can't be pulled in this environment, or the suite finds
# itself pointed at a non-Enterprise instance, it skips cleanly instead
# of failing. Set the SONAR_ENTERPRISE_LICENSE repo secret to also
# exercise license-dependent behavior.
continue-on-error: true
# license-gated operations do not). Set the SONAR_ENTERPRISE_LICENSE repo
# secret to also exercise license-dependent behavior.
services:
sonarqube-enterprise:
image: docker.io/library/sonarqube:2026.3.1-enterprise
Expand Down
18 changes: 15 additions & 3 deletions integration_testing/helpers/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,21 @@ func NormalizeBaseURL(baseURL string) string {
return baseURL
}

// NewClient creates a new SonarQube client for e2e tests using the provided config.
// NewClient creates a new SonarQube client for e2e tests using the provided
// config. Every client is wired with RecordSchemaMismatches so that all API
// responses observed during the e2e run are checked against their modeled Go
// struct, catching fields the SDK's structures have drifted away from (see
// SchemaMismatches).
func NewClient(cfg *Config) (*sonar.Client, error) {
baseURL := NormalizeBaseURL(cfg.BaseURL)

// Prefer token auth if available
if cfg.Token != "" {
client, err := sonar.NewClient(nil, sonar.WithBaseURL(baseURL), sonar.WithToken(cfg.Token))
client, err := sonar.NewClient(nil,
sonar.WithBaseURL(baseURL),
sonar.WithToken(cfg.Token),
sonar.WithSchemaObserver(RecordSchemaMismatches),
)
if err != nil {
return nil, fmt.Errorf("failed to create client with token: %w", err)
}
Expand All @@ -96,7 +104,11 @@ func NewClient(cfg *Config) (*sonar.Client, error) {
}

// Fall back to basic auth
client, err := sonar.NewClient(nil, sonar.WithBaseURL(baseURL), sonar.WithBasicAuth(cfg.Username, cfg.Password))
client, err := sonar.NewClient(nil,
sonar.WithBaseURL(baseURL),
sonar.WithBasicAuth(cfg.Username, cfg.Password),
sonar.WithSchemaObserver(RecordSchemaMismatches),
)
if err != nil {
return nil, fmt.Errorf("failed to create client with basic auth: %w", err)
}
Expand Down
50 changes: 50 additions & 0 deletions integration_testing/helpers/schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package helpers

import (
"slices"
"sync"

"github.com/boxboxjason/sonarqube-client-go/v2/sonar"
)

//nolint:gochecknoglobals // process-wide recorder shared by every e2e client created via NewClient
var (
schemaMismatchesMu sync.Mutex
schemaMismatchesSeen = make(map[string]struct{})
schemaMismatches []sonar.SchemaMismatch
)

// RecordSchemaMismatches is a sonar.SchemaObserver that accumulates every
// distinct SchemaMismatch observed across the whole e2e run. It is wired into
// every client returned by NewClient/NewDefaultClient so that any API call
// made by any e2e test contributes to strict schema validation, without each
// test needing to assert on it individually. Retrieve the accumulated results
// with SchemaMismatches.
func RecordSchemaMismatches(_ string, mismatches []sonar.SchemaMismatch) {
if len(mismatches) == 0 {
return
}

schemaMismatchesMu.Lock()
defer schemaMismatchesMu.Unlock()

for _, mismatch := range mismatches {
key := mismatch.GoType + "|" + mismatch.Path
if _, seen := schemaMismatchesSeen[key]; seen {
continue
}

schemaMismatchesSeen[key] = struct{}{}

schemaMismatches = append(schemaMismatches, mismatch)
}
}

// SchemaMismatches returns every distinct SchemaMismatch recorded so far
// across the whole e2e run.
func SchemaMismatches() []sonar.SchemaMismatch {
schemaMismatchesMu.Lock()
defer schemaMismatchesMu.Unlock()

return slices.Clone(schemaMismatches)
}
25 changes: 25 additions & 0 deletions integration_testing/suite_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package integration_testing_test

import (
"fmt"
"strings"
"testing"
"time"

Expand All @@ -19,6 +21,29 @@ var _ = BeforeSuite(func() {
Expect(err).NotTo(HaveOccurred())
})

// AfterSuite performs strict schema validation across every API response
// observed during the run: every e2e client is wired to record any JSON field
// with no corresponding field on its destination Go struct (see
// helpers.RecordSchemaMismatches). Failing here, rather than in the
// individual spec that happened to trigger it, gives one consolidated report
// of every struct that has drifted from the real SonarQube API.
var _ = AfterSuite(func() {
mismatches := helpers.SchemaMismatches()
if len(mismatches) == 0 {
return
}

lines := make([]string, 0, len(mismatches))
for _, mismatch := range mismatches {
lines = append(lines, mismatch.String())
}

Fail(fmt.Sprintf(
"strict schema validation found %d field(s) in API responses with no match in their Go struct:\n%s",
len(mismatches), strings.Join(lines, "\n"),
))
})

func TestIntegrationTesting(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "SonarQube SDK E2E Test Suite")
Expand Down
34 changes: 33 additions & 1 deletion sonar/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Client struct {
retryOptions *RetryOptions
transportConfig *TransportConfig
middlewares []Middleware
schemaObserver SchemaObserver
userAgent string
timeout time.Duration

Expand Down Expand Up @@ -429,6 +430,24 @@ func WithTimeout(timeout time.Duration) ClientOptionFunc {
}
}

// WithSchemaObserver is a ClientOptionFunc that registers a callback invoked
// after every successfully decoded API response with any SchemaMismatch found
// between the raw response body and the destination struct. It is intended
// for test suites that want strict validation that the SDK's modeled response
// types still match what the SonarQube API actually returns; it has no effect
// on decoding itself and is nil (disabled) by default.
func WithSchemaObserver(observer SchemaObserver) ClientOptionFunc {
return func(c *Client) error {
if observer == nil {
return errors.New("WithSchemaObserver: observer must not be nil")
}

c.schemaObserver = observer

return nil
}
}

// =============================================
// SETTERS
// =============================================
Expand Down Expand Up @@ -720,7 +739,20 @@ func (c *Client) Do(req *http.Request, dest any) (*http.Response, error) {
httpClient := c.httpClient
c.mu.RUnlock()

return Do(httpClient, req, dest)
if c.schemaObserver == nil {
return Do(httpClient, req, dest)
}

endpoint := fmt.Sprintf("%s %s", req.Method, requestEndpoint(req))

return doWithBodyObserver(httpClient, req, dest, func(data []byte) {
mismatches, err := CheckSchema(endpoint, data, dest)
if err != nil {
return
}

c.schemaObserver(endpoint, mismatches)
})
}

// =============================================
Expand Down
84 changes: 84 additions & 0 deletions sonar/client_schema_observer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package sonar

import (
"context"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWithSchemaObserver_ReportsUnknownField(t *testing.T) {
t.Parallel()

ts := newTestServer(t, mockHandler(t, http.MethodGet, "/projects/search", http.StatusOK,
`{"components":[{"key":"k","ghostField":"boo"}],"paging":{"pageIndex":1,"pageSize":10,"total":1}}`))

var (
gotEndpoint string
gotMismatches []SchemaMismatch
)

client, err := NewClient(nil, WithBaseURL(ts.url()), WithSchemaObserver(func(endpoint string, mismatches []SchemaMismatch) {
gotEndpoint = endpoint
gotMismatches = mismatches
}))
require.NoError(t, err)

result, _, err := client.Projects.Search(context.Background(), &ProjectsSearchOptions{})
require.NoError(t, err)
require.NotNil(t, result)

require.Len(t, gotMismatches, 1)
assert.Equal(t, "components[].ghostField", gotMismatches[0].Path)
assert.Contains(t, gotEndpoint, "/projects/search")
}

func TestWithSchemaObserver_NoMismatchesWhenSchemaMatches(t *testing.T) {
t.Parallel()

ts := newTestServer(t, mockHandler(t, http.MethodGet, "/projects/search", http.StatusOK,
`{"components":[{"key":"k"}],"paging":{"pageIndex":1,"pageSize":10,"total":1}}`))

called := false

var gotMismatches []SchemaMismatch

client, err := NewClient(nil, WithBaseURL(ts.url()), WithSchemaObserver(func(_ string, mismatches []SchemaMismatch) {
called = true
gotMismatches = mismatches
}))
require.NoError(t, err)

_, _, err = client.Projects.Search(context.Background(), &ProjectsSearchOptions{})
require.NoError(t, err)

assert.True(t, called, "schema observer should have been invoked")
assert.Empty(t, gotMismatches)
}

func TestWithSchemaObserver_NilObserverRejected(t *testing.T) {
t.Parallel()

_, err := NewClient(nil, WithBaseURL("http://example.com/"), WithSchemaObserver(nil))
require.Error(t, err)
}

// TestDo_WithoutSchemaObserverBehavesLikeBefore verifies that a client with no
// schema observer configured decodes normally, ignoring unknown fields, since
// strict schema validation must stay fully opt-in.
func TestDo_WithoutSchemaObserverBehavesLikeBefore(t *testing.T) {
t.Parallel()

ts := newTestServer(t, mockHandler(t, http.MethodGet, "/projects/search", http.StatusOK,
`{"components":[{"key":"k","ghostField":"boo"}]}`))

client, err := NewClient(nil, WithBaseURL(ts.url()))
require.NoError(t, err)

result, _, err := client.Projects.Search(context.Background(), &ProjectsSearchOptions{})
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "k", result.Components[0].Key)
}
61 changes: 50 additions & 11 deletions sonar/client_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,19 @@ const maxErrorResponseBodyBytes = 1 << 20
// error if an API error has occurred. If v implements the io.Writer
// interface, the raw response body will be written to v, without attempting to
// first decode it.
func Do(httpClient *http.Client, req *http.Request, dest any) (*http.Response, error) {
return doWithBodyObserver(httpClient, req, dest, nil)
}

// doWithBodyObserver implements Do, additionally invoking onBody, when
// non-nil, with the raw JSON response body right before it is decoded into
// dest. onBody is only called for the plain-JSON decode path (not for
// io.Writer or text destinations), and only once the response has been
// confirmed successful. Passing a nil onBody makes this behave identically to
// Do, decoding straight from the response stream rather than buffering it.
//
//nolint:wrapcheck // error context is clear from call site
func Do(httpClient *http.Client, req *http.Request, dest any) (*http.Response, error) {
func doWithBodyObserver(httpClient *http.Client, req *http.Request, dest any, onBody func([]byte)) (*http.Response, error) {
isText := false

if _, ok := dest.(*string); ok {
Expand Down Expand Up @@ -56,24 +66,53 @@ func Do(httpClient *http.Client, req *http.Request, dest any) (*http.Response, e
}

if isText {
data, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return resp, readErr
}
return resp, decodeTextBody(resp, dest)
}

return resp, decodeJSONBody(req, resp, dest, onBody)
}

// decodeTextBody reads the response body and, if dest is a *string, stores it
// verbatim.
func decodeTextBody(resp *http.Response, dest any) error {
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}

if strPtr, ok := dest.(*string); ok {
*strPtr = string(data)
if strPtr, ok := dest.(*string); ok {
*strPtr = string(data)
}

return nil
}

// decodeJSONBody JSON-decodes the response body into dest. When onBody is
// nil, it decodes straight from the response stream; otherwise it buffers the
// full body first, hands it to onBody, then unmarshals from the buffer.
func decodeJSONBody(req *http.Request, resp *http.Response, dest any, onBody func([]byte)) error {
if onBody == nil {
err := json.NewDecoder(resp.Body).Decode(dest)
if err != nil {
return fmt.Errorf("failed to decode %s %s response body: %w", req.Method, requestEndpoint(req), err)
}

return resp, nil
return nil
}

err = json.NewDecoder(resp.Body).Decode(dest)
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}

onBody(data)

err = json.Unmarshal(data, dest)
if err != nil {
return resp, fmt.Errorf("failed to decode %s %s response body: %w", req.Method, requestEndpoint(req), err)
return fmt.Errorf("failed to decode %s %s response body: %w", req.Method, requestEndpoint(req), err)
}

return resp, nil
return nil
}

// requestEndpoint formats a request's endpoint as scheme://host/path, with the
Expand Down
Loading