Skip to content

test(contract): assert pkg/types request shapes against backend swagger #97

Description

@omattsson

Problem

Stackctl's unit / integration / e2e tests all stub the backend with httptest.NewServer and decode request bodies into stackctl's own types. That means a request body whose JSON tag has drifted from the backend's expectation decodes successfully in the test (it's the same type round-tripping) but fails 400 the moment a real backend reads it.

This blind spot was the root cause of four shipped wire-shape bugs uncovered while testing the kvk-k8s-dev script migration:

Type What we sent What backend wants
CreateClusterRequest (no field) registry_password (stackctl#95)
CreateTemplateRequest charts: [...] accepted but silently dropped server-side Backend now honors it via k8s-stack-manager#264
BulkRequest {"ids": [...]} {"instance_ids": [...]} or {"template_ids": [...]} (fix/bulk-wire-contract)
BulkOperationResult {id, success} {instance_id, status: "success"|"error", log_id} (same PR)

Each of these is a 5-line backend change that nobody saw because we never assert request/response shapes against a source-of-truth.

Goal

Add a unit-test-only contract layer that validates every pkg/types/ request/response struct against the backend's published OpenAPI schema. Catches drift at PR-time without needing a live backend.

Sketch

Backend already publishes backend/docs/swagger.json (generated by swag init). Embed that file in stackctl (vendored copy refreshed by a small script when backend ships a new tag).

// cli/test/contract/contract_test.go
//go:embed swagger.json
var swaggerJSON []byte

func TestRequestSchemas_MatchBackend(t *testing.T) {
    schema := loadSwagger(t)  // parsed once

    cases := []struct {
        goType  any
        swagger string // schema $ref path
    }{
        {types.CreateClusterRequest{}, "handlers.CreateClusterRequest"},
        {types.BulkInstancesRequest{}, "handlers.BulkOperationRequest"},
        // … one entry per pkg/types request struct
    }
    for _, tc := range cases {
        t.Run(reflect.TypeOf(tc.goType).Name(), func(t *testing.T) {
            assertFieldsMatch(t, tc.goType, schema.Definitions[tc.swagger])
        })
    }
}

assertFieldsMatch walks the Go struct fields via reflection, reads the json: tag on each, and checks that:

  1. Every required field on the swagger side has a matching JSON tag on the Go side.
  2. No JSON tag exists on the Go side that the swagger schema doesn't know about (catches typos like registry_pass instead of registry_password).
  3. Field types align (string→string, int→integer, []T→array, struct→object).

The codebase already imports gojsonschema (see items_test.go), so the schema-loading machinery exists.

Open design questions

  • Where to vendor swagger.json. Options: (a) commit it to stackctl alongside test code and refresh manually, (b) fetch it from github.com/omattsson/k8s-stack-manager at go generate time. (a) is simpler and pins the contract; (b) is automatic but adds CI dependency on the upstream repo being reachable.
  • What to do about polymorphic responses. Some endpoints return different shapes for the same status code (e.g. GET /api/v1/templates/:id returns models.StackTemplate OR TemplateDetailResponse depending on whether it's the new or old handler). Need to express "may match either".
  • What about endpoints that don't bind? GET endpoints with no request body still have response-shape drift. Cover those by validating actual response bytes from the live tests (test(live): run cli/test/live/ in CI against a booted backend #96) once that exists.

Non-goals

  • Replacing the live tests — this is the static / offline counterpart, not a substitute. Both should exist.
  • Generating stackctl's types from swagger. That's the big-hammer version (codegen) and a different conversation.

Definition of done

  • go test ./cli/test/contract/... runs as part of the default suite
  • Failure messages clearly identify the drifting field (Go side vs swagger side)
  • Documented refresh procedure for the vendored swagger.json
  • All current pkg/types/ request structs pass (would fail on pre-fix/bulk-wire-contract)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttestingTests and coverage

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions