From 797af8ec42e3980f1e6e9d2704c94882477dc12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:36:06 +0200 Subject: [PATCH 01/11] refactor(kosli): add a Kosli API client that sends one request and returns its raw body --- internal/kosli/client.go | 48 +++++++++++++++++++++++++ internal/kosli/client_test.go | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 internal/kosli/client.go create mode 100644 internal/kosli/client_test.go diff --git a/internal/kosli/client.go b/internal/kosli/client.go new file mode 100644 index 000000000..e1c7f760a --- /dev/null +++ b/internal/kosli/client.go @@ -0,0 +1,48 @@ +// Package kosli is a client for the Kosli API, with one file per tag of the +// API's OpenAPI spec. +package kosli + +import ( + "context" + "net/url" + + "github.com/kosli-dev/cli/internal/requests" +) + +// Sender sends one request to the Kosli API. *requests.Client satisfies it. +type Sender interface { + Do(*requests.RequestParams) (*requests.HTTPResponse, error) +} + +// Client calls the Kosli API for one organization on one host. +type Client struct { + sender Sender + host string + org string + token string +} + +// New returns a Client that sends its requests through sender. +func New(sender Sender, host, org, token string) *Client { + return &Client{sender: sender, host: host, org: org, token: token} +} + +// Result is what a call to the Kosli API returned. +type Result struct { + // Raw is the response body exactly as the server sent it, so a command + // can print it for --output json without re-encoding. + Raw []byte +} + +func (c *Client) endpoint(segments ...string) (string, error) { + return url.JoinPath(c.host, append([]string{"api/v2"}, segments...)...) +} + +func (c *Client) send(ctx context.Context, params *requests.RequestParams) (*Result, error) { + params.Token = c.token + response, err := c.sender.Do(params) + if err != nil { + return nil, err + } + return &Result{Raw: []byte(response.Body)}, nil +} diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go new file mode 100644 index 000000000..c75cb3e7b --- /dev/null +++ b/internal/kosli/client_test.go @@ -0,0 +1,66 @@ +package kosli + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kosli-dev/cli/internal/logger" + "github.com/kosli-dev/cli/internal/requests" + "github.com/stretchr/testify/require" +) + +// received is what the fake server saw, so a test can assert on the request +// rather than only on what came back. +type received struct { + hits int + method string + rawPath string + authHeader string +} + +func newFakeServer(t *testing.T, status int, responseBody string) (*httptest.Server, *received) { + t.Helper() + seen := &received{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen.hits++ + seen.method = r.Method + seen.rawPath = r.URL.EscapedPath() + seen.authHeader = r.Header.Get("Authorization") + w.WriteHeader(status) + _, err := w.Write([]byte(responseBody)) + require.NoError(t, err) + })) + t.Cleanup(server.Close) + return server, seen +} + +func newHTTPClient(t *testing.T) *requests.Client { + t.Helper() + // No retries, so a 409 or 5xx surfaces as itself instead of being retried away. + httpClient, err := requests.NewKosliClient("", 0, false, logger.NewLogger(io.Discard, io.Discard, false)) + require.NoError(t, err) + return httpClient +} + +func newTestClient(t *testing.T, host string) *Client { + t.Helper() + return New(newHTTPClient(t), host, "test-org", "test-token") +} + +func TestSendRequestsTheEndpointWithTheTokenAndReturnsTheRawBody(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{"name":"trail-1"}`) + client := newTestClient(t, server.URL) + + endpoint, err := client.endpoint("trails", "test-org", "flow-1", "trail-1") + require.NoError(t, err) + result, err := client.send(context.Background(), &requests.RequestParams{Method: http.MethodGet, URL: endpoint}) + + require.NoError(t, err) + require.Equal(t, http.MethodGet, seen.method) + require.Equal(t, "/api/v2/trails/test-org/flow-1/trail-1", seen.rawPath) + require.Equal(t, "Bearer test-token", seen.authHeader) + require.JSONEq(t, `{"name":"trail-1"}`, string(result.Raw)) +} From e04a62ef3f4327b25136698df0034d57b61665aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:36:18 +0200 Subject: [PATCH 02/11] refactor(kosli): report whether a call created the resource --- internal/kosli/client.go | 9 ++++++++- internal/kosli/client_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/kosli/client.go b/internal/kosli/client.go index e1c7f760a..e869a50db 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -4,6 +4,7 @@ package kosli import ( "context" + "net/http" "net/url" "github.com/kosli-dev/cli/internal/requests" @@ -32,6 +33,9 @@ type Result struct { // Raw is the response body exactly as the server sent it, so a command // can print it for --output json without re-encoding. Raw []byte + // Created is true when the server created the resource (201) rather than + // updating one that already existed (200). + Created bool } func (c *Client) endpoint(segments ...string) (string, error) { @@ -44,5 +48,8 @@ func (c *Client) send(ctx context.Context, params *requests.RequestParams) (*Res if err != nil { return nil, err } - return &Result{Raw: []byte(response.Body)}, nil + return &Result{ + Raw: []byte(response.Body), + Created: response.Resp.StatusCode == http.StatusCreated, + }, nil } diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index c75cb3e7b..c6c40f834 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -64,3 +64,25 @@ func TestSendRequestsTheEndpointWithTheTokenAndReturnsTheRawBody(t *testing.T) { require.Equal(t, "Bearer test-token", seen.authHeader) require.JSONEq(t, `{"name":"trail-1"}`, string(result.Raw)) } + +func TestSendReportsA201AsCreated(t *testing.T) { + for _, tc := range []struct { + status int + created bool + }{ + {http.StatusCreated, true}, + {http.StatusOK, false}, + } { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + server, _ := newFakeServer(t, tc.status, `"OK"`) + client := newTestClient(t, server.URL) + + endpoint, err := client.endpoint("flows", "test-org") + require.NoError(t, err) + result, err := client.send(context.Background(), &requests.RequestParams{Method: http.MethodPut, URL: endpoint}) + + require.NoError(t, err) + require.Equal(t, tc.created, result.Created) + }) + } +} From a741c94d6d3ba429be03e43dfd9ff8991a8b1247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:36:35 +0200 Subject: [PATCH 03/11] test(kosli): pin that endpoints escape each path segment --- internal/kosli/client_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index c6c40f834..29b8fe31b 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -86,3 +86,12 @@ func TestSendReportsA201AsCreated(t *testing.T) { }) } } + +func TestEndpointEscapesEachPathSegment(t *testing.T) { + client := New(nil, "https://app.kosli.com", "test-org", "test-token") + + endpoint, err := client.endpoint("trails", "test-org", "my flow", "trail?#1") + + require.NoError(t, err) + require.Equal(t, "https://app.kosli.com/api/v2/trails/test-org/my%20flow/trail%3F%231", endpoint) +} From 20571097eeb008d410fc0b166ab991f6d35f7113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:36:43 +0200 Subject: [PATCH 04/11] refactor(kosli): add status helpers for API errors --- internal/kosli/client_test.go | 41 +++++++++++++++++++++++++++++++++++ internal/kosli/errors.go | 32 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 internal/kosli/errors.go diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index 29b8fe31b..bf85fd864 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -2,6 +2,7 @@ package kosli import ( "context" + "errors" "io" "net/http" "net/http/httptest" @@ -95,3 +96,43 @@ func TestEndpointEscapesEachPathSegment(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://app.kosli.com/api/v2/trails/test-org/my%20flow/trail%3F%231", endpoint) } + +func TestSendReturnsAnAPIErrorThatTheStatusHelpersRecognise(t *testing.T) { + for _, tc := range []struct { + status int + is func(error) bool + }{ + {http.StatusNotFound, IsNotFound}, + // A 409 is retried until retries run out, which ends in a plain error, + // so only a request that opts out of conflict retries sees it. + {http.StatusConflict, IsConflict}, + {http.StatusForbidden, IsForbidden}, + } { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + server, _ := newFakeServer(t, tc.status, `{"message":"nope"}`) + client := newTestClient(t, server.URL) + + endpoint, err := client.endpoint("flows", "test-org", "missing") + require.NoError(t, err) + _, err = client.send(context.Background(), &requests.RequestParams{ + Method: http.MethodGet, + URL: endpoint, + DisableConflictRetry: true, + }) + + var apiErr *APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, tc.status, apiErr.StatusCode) + require.True(t, tc.is(err)) + }) + } +} + +func TestStatusHelpersDoNotMatchOtherStatusesOrOtherErrors(t *testing.T) { + serverError := &APIError{StatusCode: http.StatusInternalServerError} + for _, is := range []func(error) bool{IsNotFound, IsConflict, IsForbidden} { + require.False(t, is(serverError)) + require.False(t, is(errors.New("connection refused"))) + require.False(t, is(nil)) + } +} diff --git a/internal/kosli/errors.go b/internal/kosli/errors.go new file mode 100644 index 000000000..399cd83b2 --- /dev/null +++ b/internal/kosli/errors.go @@ -0,0 +1,32 @@ +package kosli + +import ( + "errors" + "net/http" + + "github.com/kosli-dev/cli/internal/requests" +) + +// APIError is the error for a response with a status other than 200 or 201. +type APIError = requests.APIError + +// IsNotFound reports whether err is an APIError for a 404. +func IsNotFound(err error) bool { + return hasStatus(err, http.StatusNotFound) +} + +// IsConflict reports whether err is an APIError for a 409. +func IsConflict(err error) bool { + return hasStatus(err, http.StatusConflict) +} + +// IsForbidden reports whether err is an APIError for a 403, which is also what +// a beta endpoint answers to an organization without the feature enabled. +func IsForbidden(err error) bool { + return hasStatus(err, http.StatusForbidden) +} + +func hasStatus(err error, status int) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == status +} From f50d5c284b28123f9bf26254fc4eccc1c782bdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:37:23 +0200 Subject: [PATCH 05/11] refactor(kosli): add a dry-run sender that reads but does not write --- internal/kosli/client.go | 4 ++++ internal/kosli/client_test.go | 34 ++++++++++++++++++++++++++++++++++ internal/kosli/dryrun.go | 24 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 internal/kosli/dryrun.go diff --git a/internal/kosli/client.go b/internal/kosli/client.go index e869a50db..eeea2af99 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -48,6 +48,10 @@ func (c *Client) send(ctx context.Context, params *requests.RequestParams) (*Res if err != nil { return nil, err } + if response == nil { + // A dry run logs the request instead of sending it. + return &Result{}, nil + } return &Result{ Raw: []byte(response.Body), Created: response.Resp.StatusCode == http.StatusCreated, diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index bf85fd864..9c0a16d68 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -136,3 +136,37 @@ func TestStatusHelpersDoNotMatchOtherStatusesOrOtherErrors(t *testing.T) { require.False(t, is(nil)) } } + +func TestDryRunStillSendsAGet(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{"name":"trail-1"}`) + client := New(DryRun(newHTTPClient(t)), server.URL, "test-org", "test-token") + + endpoint, err := client.endpoint("trails", "test-org", "flow-1", "trail-1") + require.NoError(t, err) + result, err := client.send(context.Background(), &requests.RequestParams{Method: http.MethodGet, URL: endpoint}) + + require.NoError(t, err) + require.Equal(t, 1, seen.hits) + require.JSONEq(t, `{"name":"trail-1"}`, string(result.Raw)) +} + +func TestDryRunDoesNotSendAWriteAndReturnsAnEmptyResult(t *testing.T) { + for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + server, seen := newFakeServer(t, http.StatusCreated, `"OK"`) + client := New(DryRun(newHTTPClient(t)), server.URL, "test-org", "test-token") + + endpoint, err := client.endpoint("flows", "test-org") + require.NoError(t, err) + result, err := client.send(context.Background(), &requests.RequestParams{ + Method: method, + URL: endpoint, + Payload: map[string]string{"name": "flow-1"}, + }) + + require.NoError(t, err) + require.Equal(t, 0, seen.hits) + require.Equal(t, &Result{}, result) + }) + } +} diff --git a/internal/kosli/dryrun.go b/internal/kosli/dryrun.go new file mode 100644 index 000000000..7184c68ba --- /dev/null +++ b/internal/kosli/dryrun.go @@ -0,0 +1,24 @@ +package kosli + +import ( + "net/http" + + "github.com/kosli-dev/cli/internal/requests" +) + +// DryRun wraps next so that reads still reach the server but writes are only +// logged, with the payload that would have been sent. +func DryRun(next Sender) Sender { + return dryRunSender{next: next} +} + +type dryRunSender struct { + next Sender +} + +func (s dryRunSender) Do(params *requests.RequestParams) (*requests.HTTPResponse, error) { + if params.Method != http.MethodGet { + params.DryRun = true + } + return s.next.Do(params) +} From f446e5defa945f940bb9bebe64d175be4141e4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:37:35 +0200 Subject: [PATCH 06/11] refactor(kosli): decode a result into a typed response --- internal/kosli/client.go | 19 +++++++++++++++++++ internal/kosli/client_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/kosli/client.go b/internal/kosli/client.go index eeea2af99..aecca1088 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -4,6 +4,8 @@ package kosli import ( "context" + "encoding/json" + "fmt" "net/http" "net/url" @@ -38,6 +40,23 @@ type Result struct { Created bool } +// Response is a Result with its body decoded. +type Response[T any] struct { + Result + Value T +} + +func decode[T any](result *Result) (*Response[T], error) { + response := &Response[T]{Result: *result} + if len(result.Raw) == 0 { + return response, nil + } + if err := json.Unmarshal(result.Raw, &response.Value); err != nil { + return nil, fmt.Errorf("failed to decode the Kosli API response: %w", err) + } + return response, nil +} + func (c *Client) endpoint(segments ...string) (string, error) { return url.JoinPath(c.host, append([]string{"api/v2"}, segments...)...) } diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index 9c0a16d68..c3a8de649 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -170,3 +170,30 @@ func TestDryRunDoesNotSendAWriteAndReturnsAnEmptyResult(t *testing.T) { }) } } + +type namedThing struct { + Name string `json:"name"` +} + +func TestDecodeFillsTheValueAndKeepsTheResult(t *testing.T) { + result := &Result{Raw: []byte(`{"name":"trail-1","unread":true}`), Created: true} + + response, err := decode[namedThing](result) + + require.NoError(t, err) + require.Equal(t, namedThing{Name: "trail-1"}, response.Value) + require.Equal(t, *result, response.Result) +} + +func TestDecodeOfADryRunResultLeavesTheValueZero(t *testing.T) { + response, err := decode[namedThing](&Result{}) + + require.NoError(t, err) + require.Equal(t, namedThing{}, response.Value) +} + +func TestDecodeOfABodyThatDoesNotMatchIsAnError(t *testing.T) { + _, err := decode[namedThing](&Result{Raw: []byte(`["not","an","object"]`)}) + + require.Error(t, err) +} From c16de8fd29903074cb0e5c4e560ac031251d8dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:38:38 +0200 Subject: [PATCH 07/11] refactor(kosli): carry a context through to the request so it can be cancelled --- internal/kosli/client.go | 4 ++++ internal/kosli/client_test.go | 27 +++++++++++++++++++++++++++ internal/requests/requests.go | 11 +++++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/kosli/client.go b/internal/kosli/client.go index aecca1088..94a2ee660 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -62,6 +62,10 @@ func (c *Client) endpoint(segments ...string) (string, error) { } func (c *Client) send(ctx context.Context, params *requests.RequestParams) (*Result, error) { + if ctx == nil { + ctx = context.Background() + } + params.Context = ctx params.Token = c.token response, err := c.sender.Do(params) if err != nil { diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index c3a8de649..8e9c0f095 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -197,3 +197,30 @@ func TestDecodeOfABodyThatDoesNotMatchIsAnError(t *testing.T) { require.Error(t, err) } + +func TestACancelledContextStopsTheRequestBeforeItReachesTheServer(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{}`) + client := newTestClient(t, server.URL) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + endpoint, err := client.endpoint("flows", "test-org") + require.NoError(t, err) + _, err = client.send(ctx, &requests.RequestParams{Method: http.MethodGet, URL: endpoint}) + + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 0, seen.hits) +} + +func TestANilContextIsTreatedAsBackground(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, `{}`) + client := newTestClient(t, server.URL) + + endpoint, err := client.endpoint("flows", "test-org") + require.NoError(t, err) + //nolint:staticcheck // a command's run called directly by a test has no context + _, err = client.send(nil, &requests.RequestParams{Method: http.MethodGet, URL: endpoint}) + + require.NoError(t, err) + require.Equal(t, 1, seen.hits) +} diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 8e7ebcc03..cea1f93cb 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -127,6 +127,9 @@ type RequestParams struct { Password string Token string DryRun bool + // Context cancels the request, including its retries. Nil means + // context.Background(). + Context context.Context // DisableConflictRetry stops the client retrying on HTTP 409 for this // request. By default 409 is retried (it signals a transient lock // conflict), but some endpoints use 409 for a permanent client error @@ -166,7 +169,11 @@ func (p *RequestParams) newHTTPRequest() (*http.Request, map[string]any, error) } } - req, err := http.NewRequest(p.Method, p.URL, body) + ctx := p.Context + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, p.Method, p.URL, body) if err != nil { return nil, nil, fmt.Errorf("failed to create %s request to %s : %v", p.Method, p.URL, err) } @@ -301,7 +308,7 @@ func (c *Client) Do(p *RequestParams) (*HTTPResponse, error) { resp, err := c.HttpClient.Do(req) if err != nil { // err from retryable client is detailed enough - return nil, fmt.Errorf("%v", err) + return nil, fmt.Errorf("%w", err) } defer func() { From fe3cbe41f19d86a23d9df408ac69ef90b904c7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:39:54 +0200 Subject: [PATCH 08/11] refactor(kosli): leave a nil context to requests, which already defaults it --- internal/kosli/client.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/kosli/client.go b/internal/kosli/client.go index 94a2ee660..f63068bd5 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -62,9 +62,6 @@ func (c *Client) endpoint(segments ...string) (string, error) { } func (c *Client) send(ctx context.Context, params *requests.RequestParams) (*Result, error) { - if ctx == nil { - ctx = context.Background() - } params.Context = ctx params.Token = c.token response, err := c.sender.Do(params) From c95f8051b8a0f6436fe17e8ae9aa8446454d1e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:49:23 +0200 Subject: [PATCH 09/11] test(kosli): assert from the fake server's handler goroutine instead of require --- internal/kosli/client_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index 8e9c0f095..d0ffa0ae9 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -10,6 +10,7 @@ import ( "github.com/kosli-dev/cli/internal/logger" "github.com/kosli-dev/cli/internal/requests" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -32,7 +33,8 @@ func newFakeServer(t *testing.T, status int, responseBody string) (*httptest.Ser seen.authHeader = r.Header.Get("Authorization") w.WriteHeader(status) _, err := w.Write([]byte(responseBody)) - require.NoError(t, err) + // assert, not require: FailNow must not be called off the test goroutine. + assert.NoError(t, err) })) t.Cleanup(server.Close) return server, seen From 21c70882642c91065d6106b1488b8a88f73c26ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Mon, 5 Oct 2026 15:49:34 +0200 Subject: [PATCH 10/11] refactor(kosli): send every safe method in a dry run and leave the caller's params alone --- internal/kosli/client_test.go | 37 +++++++++++++++++++++++++++++++++++ internal/kosli/dryrun.go | 9 ++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index d0ffa0ae9..1e083393b 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -152,6 +152,43 @@ func TestDryRunStillSendsAGet(t *testing.T) { require.JSONEq(t, `{"name":"trail-1"}`, string(result.Raw)) } +func TestDryRunStillSendsTheOtherSafeMethods(t *testing.T) { + for _, method := range []string{http.MethodHead, http.MethodOptions} { + t.Run(method, func(t *testing.T) { + server, seen := newFakeServer(t, http.StatusOK, ``) + client := New(DryRun(newHTTPClient(t)), server.URL, "test-org", "test-token") + + endpoint, err := client.endpoint("flows", "test-org") + require.NoError(t, err) + _, err = client.send(context.Background(), &requests.RequestParams{Method: method, URL: endpoint}) + + require.NoError(t, err) + require.Equal(t, 1, seen.hits) + }) + } +} + +func TestDryRunLeavesTheCallersParamsUnchanged(t *testing.T) { + recorder := &recordingSender{} + params := &requests.RequestParams{Method: http.MethodPut, URL: "https://app.kosli.com/api/v2/flows/test-org"} + + _, err := DryRun(recorder).Do(params) + + require.NoError(t, err) + require.False(t, params.DryRun) + require.True(t, recorder.got.DryRun) +} + +// recordingSender keeps the params it was given instead of sending them. +type recordingSender struct { + got *requests.RequestParams +} + +func (s *recordingSender) Do(params *requests.RequestParams) (*requests.HTTPResponse, error) { + s.got = params + return nil, nil +} + func TestDryRunDoesNotSendAWriteAndReturnsAnEmptyResult(t *testing.T) { for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete} { t.Run(method, func(t *testing.T) { diff --git a/internal/kosli/dryrun.go b/internal/kosli/dryrun.go index 7184c68ba..19b4e3031 100644 --- a/internal/kosli/dryrun.go +++ b/internal/kosli/dryrun.go @@ -17,8 +17,11 @@ type dryRunSender struct { } func (s dryRunSender) Do(params *requests.RequestParams) (*requests.HTTPResponse, error) { - if params.Method != http.MethodGet { - params.DryRun = true + switch params.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return s.next.Do(params) } - return s.next.Do(params) + write := *params + write.DryRun = true + return s.next.Do(&write) } From 44547df021531cede41eb380f2bfd0930f577d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Gr=C3=B8ndahl?= Date: Thu, 15 Oct 2026 16:33:51 +0200 Subject: [PATCH 11/11] refactor(kosli): decode a Response only when its Value is asked for --- internal/kosli/client.go | 20 +++++++++++--------- internal/kosli/client_test.go | 29 ++++++++++++++++++----------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/internal/kosli/client.go b/internal/kosli/client.go index f63068bd5..c51a380da 100644 --- a/internal/kosli/client.go +++ b/internal/kosli/client.go @@ -40,21 +40,23 @@ type Result struct { Created bool } -// Response is a Result with its body decoded. +// Response is the Result of a read whose body decodes to T. The body is decoded +// only when Value is called, so a caller that prints Raw, as --output json +// does, is not failed by a body that T does not fit. type Response[T any] struct { Result - Value T } -func decode[T any](result *Result) (*Response[T], error) { - response := &Response[T]{Result: *result} - if len(result.Raw) == 0 { - return response, nil +// Value decodes the body. An empty body, as from a dry run, is T's zero value. +func (r *Response[T]) Value() (T, error) { + var value T + if len(r.Raw) == 0 { + return value, nil } - if err := json.Unmarshal(result.Raw, &response.Value); err != nil { - return nil, fmt.Errorf("failed to decode the Kosli API response: %w", err) + if err := json.Unmarshal(r.Raw, &value); err != nil { + return value, fmt.Errorf("failed to decode the Kosli API response: %w", err) } - return response, nil + return value, nil } func (c *Client) endpoint(segments ...string) (string, error) { diff --git a/internal/kosli/client_test.go b/internal/kosli/client_test.go index 1e083393b..5a32ae7b9 100644 --- a/internal/kosli/client_test.go +++ b/internal/kosli/client_test.go @@ -214,27 +214,34 @@ type namedThing struct { Name string `json:"name"` } -func TestDecodeFillsTheValueAndKeepsTheResult(t *testing.T) { - result := &Result{Raw: []byte(`{"name":"trail-1","unread":true}`), Created: true} +func TestValueDecodesTheBody(t *testing.T) { + response := &Response[namedThing]{Result: Result{Raw: []byte(`{"name":"trail-1","unread":true}`)}} - response, err := decode[namedThing](result) + value, err := response.Value() require.NoError(t, err) - require.Equal(t, namedThing{Name: "trail-1"}, response.Value) - require.Equal(t, *result, response.Result) + require.Equal(t, namedThing{Name: "trail-1"}, value) } -func TestDecodeOfADryRunResultLeavesTheValueZero(t *testing.T) { - response, err := decode[namedThing](&Result{}) +func TestValueOfADryRunResultIsZero(t *testing.T) { + response := &Response[namedThing]{} + + value, err := response.Value() require.NoError(t, err) - require.Equal(t, namedThing{}, response.Value) + require.Equal(t, namedThing{}, value) } -func TestDecodeOfABodyThatDoesNotMatchIsAnError(t *testing.T) { - _, err := decode[namedThing](&Result{Raw: []byte(`["not","an","object"]`)}) +func TestValueOfABodyThatDoesNotMatchIsAnErrorButRawIsStillThere(t *testing.T) { + // a caller printing Raw, as --output json does, must not be failed by a + // body the type does not fit + raw := []byte(`["not","an","object"]`) + response := &Response[namedThing]{Result: Result{Raw: raw}} + + _, err := response.Value() - require.Error(t, err) + require.ErrorContains(t, err, "failed to decode the Kosli API response") + require.Equal(t, raw, response.Raw) } func TestACancelledContextStopsTheRequestBeforeItReachesTheServer(t *testing.T) {