From 4b1f704e192e2af22ddc2861b58c3d28b29c4de1 Mon Sep 17 00:00:00 2001 From: ShocOne <62835948+ShocOne@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:23:35 +0100 Subject: [PATCH] fix(pilot): send plain JSON request bodies from the kiota client The first live acceptance run of the kiota pilot failed on its very first create with a bare 400 and no explanation. The body was not the problem -- it serialises byte-for-byte to the shape the recorded probe evidence shows the API accepting -- the transport was: kiota-http-go's default middleware gzips every request body and sets Content-Encoding, and the ThousandEyes API refuses compressed bodies without saying so. The 400 carried no detail the client could surface because the spec declares no response body for POST /tags 400, so kiota has no error type to map it onto. The client now builds its adapter with the default middleware minus request compression, matching the wire behaviour every recording was gathered under, and a wire regression test pins the contract against a capture server: POST /v7/tags, Content-Type application/json, no Content-Encoding, the bearer header, and the exact JSON fields -- so the compression middleware coming back fails a unit test instead of a live tenant run. Co-Authored-By: Claude Fable 5 --- .../internal/client/client.go | 17 +++- .../internal/client/client_wire_test.go | 86 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 pilot/thousandeyes-kiota/internal/client/client_wire_test.go diff --git a/pilot/thousandeyes-kiota/internal/client/client.go b/pilot/thousandeyes-kiota/internal/client/client.go index 9db1bb31..8ac34cac 100644 --- a/pilot/thousandeyes-kiota/internal/client/client.go +++ b/pilot/thousandeyes-kiota/internal/client/client.go @@ -79,7 +79,22 @@ func New(cfg Config) (*sdk.ThousandEyesClient, error) { validator: validator, }) - adapter, err := kiotahttp.NewNetHttpRequestAdapter(authProvider) + // Request-body compression is off, and this is load-bearing rather than a + // preference: kiota's default middleware gzips every request body and sets + // Content-Encoding, which the ThousandEyes API answers with a bare 400 -- + // the first live acceptance run of this pilot found exactly that. The + // resty pilot has always sent plain JSON; this client must match the wire + // behaviour the recorded evidence was gathered under. + middlewares, err := kiotahttp.GetDefaultMiddlewaresWithOptions( + kiotahttp.NewCompressionOptionsReference(false), + ) + if err != nil { + return nil, fmt.Errorf("building the HTTP middleware: %w", err) + } + + adapter, err := kiotahttp.NewNetHttpRequestAdapterWithParseNodeFactoryAndSerializationWriterFactoryAndHttpClient( + authProvider, nil, nil, kiotahttp.GetDefaultClient(middlewares...), + ) if err != nil { return nil, fmt.Errorf("building the ThousandEyes client: %w", err) } diff --git a/pilot/thousandeyes-kiota/internal/client/client_wire_test.go b/pilot/thousandeyes-kiota/internal/client/client_wire_test.go new file mode 100644 index 00000000..c404ab96 --- /dev/null +++ b/pilot/thousandeyes-kiota/internal/client/client_wire_test.go @@ -0,0 +1,86 @@ +package client + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/pilot/thousandeyes-kiota/internal/sdk/models" +) + +// TestUnit_Client_SendsPlainJSONBodies pins the wire shape of a write. +// +// Kiota's default middleware gzips every request body and sets +// Content-Encoding, and the ThousandEyes API answers that with a bare 400 -- +// which is exactly how the first live acceptance run of this pilot failed, +// with nothing in the error naming the cause. The recorded probe evidence was +// gathered over plain JSON, so plain JSON is the contract this client must +// keep; this test fails if the compression middleware ever comes back. +func TestUnit_Client_SendsPlainJSONBodies(t *testing.T) { + t.Parallel() + + type captured struct { + method, path, contentType, encoding, auth string + body []byte + } + var got captured + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + got = captured{ + method: r.Method, + path: r.URL.String(), + contentType: r.Header.Get("Content-Type"), + encoding: r.Header.Get("Content-Encoding"), + auth: r.Header.Get("Authorization"), + body: b, + } + w.Header().Set("Content-Type", "application/hal+json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","key":"k","value":"v"}`)) + })) + defer srv.Close() + + c, err := New(Config{BearerToken: "tok", APIEndpoint: srv.URL + "/v7"}) + if err != nil { + t.Fatal(err) + } + + body := models.NewTags_API_TagInfo() + k, v := "tfacc-key", "tfacc-value" + body.SetKey(&k) + body.SetValue(&v) + ot, _ := models.ParseTags_API_ObjectType("test") + body.SetObjectType(ot.(*models.Tags_API_ObjectType)) + + if _, err := c.Tags().Post(context.Background(), body, nil); err != nil { + t.Fatalf("Post: %v", err) + } + + if got.method != http.MethodPost || got.path != "/v7/tags" { + t.Errorf("request = %s %s, want POST /v7/tags", got.method, got.path) + } + if got.auth != "Bearer tok" { + t.Errorf("Authorization = %q", got.auth) + } + if got.contentType != "application/json" { + t.Errorf("Content-Type = %q", got.contentType) + } + if got.encoding != "" { + t.Errorf("Content-Encoding = %q; the body must go uncompressed", got.encoding) + } + + var decoded map[string]any + if err := json.Unmarshal(got.body, &decoded); err != nil { + t.Fatalf("the body is not plain JSON (%v):\n%q", err, got.body) + } + want := map[string]any{"key": "tfacc-key", "objectType": "test", "value": "tfacc-value"} + for field, wantVal := range want { + if decoded[field] != wantVal { + t.Errorf("body[%s] = %v, want %v (full body: %s)", field, decoded[field], wantVal, got.body) + } + } +}