From 0f60f369d550ab5e8635e520e64976cfa93a7f7f Mon Sep 17 00:00:00 2001 From: Miguel Sanchez Gonzalez Date: Tue, 24 Mar 2026 22:44:15 +0100 Subject: [PATCH 1/5] feat(mockapi): add OpenAPI validation draft --- .gitignore | 3 + Makefile | 14 ++ go.mod | 9 ++ go.sum | 18 +++ pkg/mockapi/api_server.go | 11 ++ pkg/mockapi/openapi_download.go | 107 ++++++++++++++++ pkg/mockapi/openapi_download_test.go | 98 ++++++++++++++ pkg/mockapi/openapi_validation.go | 129 +++++++++++++++++++ pkg/mockapi/openapi_validation_test.go | 170 +++++++++++++++++++++++++ 9 files changed, 559 insertions(+) create mode 100644 pkg/mockapi/openapi_download.go create mode 100644 pkg/mockapi/openapi_download_test.go create mode 100644 pkg/mockapi/openapi_validation.go create mode 100644 pkg/mockapi/openapi_validation_test.go diff --git a/.gitignore b/.gitignore index ce600154a..c288cf1fe 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ completion # vendor/ /.goreleaser.vendor.yaml legacy/box.phar + +# OpenAPI specification (auto-downloaded) +pkg/mockapi/testdata/upsun-openapi.json diff --git a/Makefile b/Makefile index e05d2f7f6..5d2220e6a 100644 --- a/Makefile +++ b/Makefile @@ -165,3 +165,17 @@ vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy .PHONY: goreleaser-check goreleaser-check: goreleaser ## Check the goreleaser configs PHP_VERSION=$(PHP_VERSION) goreleaser check --config=.goreleaser.yaml + +# OpenAPI Specification +OPENAPI_URL ?= https://developer.upsun.com/openapi.json +OPENAPI_FILE = pkg/mockapi/testdata/upsun-openapi.json + +.PHONY: download-openapi +download-openapi: ## Download the Upsun OpenAPI specification + @mkdir -p pkg/mockapi/testdata + @echo "Downloading OpenAPI spec from $(OPENAPI_URL)..." + @curl -fSL "$(OPENAPI_URL)" -o $(OPENAPI_FILE) + @echo "OpenAPI spec downloaded to $(OPENAPI_FILE)" + +.PHONY: update-openapi +update-openapi: download-openapi ## Alias for download-openapi (forces refresh) diff --git a/go.mod b/go.mod index 6c5c820ff..cc1b0a428 100644 --- a/go.mod +++ b/go.mod @@ -56,11 +56,14 @@ require ( github.com/fatih/semgroup v1.2.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/getkin/kin-openapi v0.134.0 // indirect github.com/getsentry/sentry-go v0.28.1 // indirect github.com/gitleaks/go-gitdiff v0.9.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -76,6 +79,7 @@ require ( github.com/itchyny/gojq v0.12.17 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.17.11 // indirect @@ -91,10 +95,14 @@ require ( github.com/minio/minlz v1.0.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 // indirect github.com/muesli/termenv v0.15.1 // indirect github.com/nwaples/rardecode/v2 v2.1.0 // indirect + github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect + github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -118,6 +126,7 @@ require ( github.com/ulikunitz/xz v0.5.12 // indirect github.com/wasilibs/go-re2 v1.9.0 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/go.sum b/go.sum index 9f64acbc3..2338dd27b 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= +github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= github.com/gitleaks/go-gitdiff v0.9.1 h1:ni6z6/3i9ODT685OLCTf+s/ERlWUNWQF4x1pvoNICw0= @@ -132,6 +134,10 @@ github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77 github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -207,6 +213,8 @@ github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/my github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -258,6 +266,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= @@ -265,6 +275,10 @@ github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= @@ -272,6 +286,8 @@ github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlR github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -359,6 +375,8 @@ github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8S github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= diff --git a/pkg/mockapi/api_server.go b/pkg/mockapi/api_server.go index a054f1c2c..acb596178 100644 --- a/pkg/mockapi/api_server.go +++ b/pkg/mockapi/api_server.go @@ -28,6 +28,17 @@ func NewHandler(t *testing.T) *Handler { h.Use(middleware.DefaultLogger) } + // Set Content-Type header for all JSON responses + h.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, req) + }) + }) + + // Add OpenAPI validation middleware (enabled with VALIDATE_OPENAPI=1) + h.Use(OpenAPIValidationMiddleware(t)) + h.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { authHeader := req.Header.Get("Authorization") diff --git a/pkg/mockapi/openapi_download.go b/pkg/mockapi/openapi_download.go new file mode 100644 index 000000000..35c33c0e1 --- /dev/null +++ b/pkg/mockapi/openapi_download.go @@ -0,0 +1,107 @@ +package mockapi + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +const ( + // DefaultOpenAPIURL is the default URL for the Upsun OpenAPI specification + DefaultOpenAPIURL = "https://developer.upsun.com/openapi.json" + + // OpenAPIURLEnvVar is the environment variable to override the OpenAPI URL + OpenAPIURLEnvVar = "UPSUN_OPENAPI_URL" + + // OpenAPISpecFilename is the filename for the downloaded OpenAPI spec + OpenAPISpecFilename = "upsun-openapi.json" +) + +// downloadOpenAPISpec downloads the OpenAPI specification from the given URL +// and saves it to the specified path. +func downloadOpenAPISpec(url, destPath string) error { + // Create directory if it doesn't exist + dir := filepath.Dir(destPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Download the spec + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download OpenAPI spec from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download OpenAPI spec: HTTP %d", resp.StatusCode) + } + + // Create the destination file + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + defer out.Close() + + // Write the response body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("failed to write OpenAPI spec to %s: %w", destPath, err) + } + + return nil +} + +// ensureOpenAPISpec ensures the OpenAPI spec file exists, downloading it if necessary. +// It returns the path to the spec file. +func ensureOpenAPISpec() (string, error) { + // Try multiple possible paths for the spec file + possiblePaths := []string{ + "pkg/mockapi/testdata/upsun-openapi.json", // from repo root + "testdata/upsun-openapi.json", // from pkg/mockapi + "../pkg/mockapi/testdata/upsun-openapi.json", // from integration-tests + "../../pkg/mockapi/testdata/upsun-openapi.json", // from nested dirs + } + + // Check if file already exists + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + // File doesn't exist, download it + url := os.Getenv(OpenAPIURLEnvVar) + if url == "" { + url = DefaultOpenAPIURL + } + + // Determine the correct download path based on current working directory + // If we can find testdata/ relative to current dir, use that; otherwise use full path + destPath := "testdata/upsun-openapi.json" + if info, err := os.Stat("testdata"); err != nil || !info.IsDir() { + // testdata doesn't exist in current dir, try creating from repo root + destPath = "pkg/mockapi/testdata/upsun-openapi.json" + } + + if err := downloadOpenAPISpec(url, destPath); err != nil { + return "", err + } + + return destPath, nil +} + +// refreshOpenAPISpec forces a re-download of the OpenAPI specification. +// This is useful when the spec has been updated upstream. +func refreshOpenAPISpec() error { + url := os.Getenv(OpenAPIURLEnvVar) + if url == "" { + url = DefaultOpenAPIURL + } + + destPath := "pkg/mockapi/testdata/upsun-openapi.json" + return downloadOpenAPISpec(url, destPath) +} diff --git a/pkg/mockapi/openapi_download_test.go b/pkg/mockapi/openapi_download_test.go new file mode 100644 index 000000000..efc95aa43 --- /dev/null +++ b/pkg/mockapi/openapi_download_test.go @@ -0,0 +1,98 @@ +package mockapi + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnsureOpenAPISpec(t *testing.T) { + if testing.Short() { + t.Skip("Skipping download test in short mode") + } + + // Test with existing file + t.Run("existing file", func(t *testing.T) { + path, err := ensureOpenAPISpec() + require.NoError(t, err) + assert.NotEmpty(t, path) + + // Verify file exists and is valid JSON + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "openapi") + }) + + // Test downloading to temp directory + t.Run("download to temp", func(t *testing.T) { + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "openapi.json") + + err := downloadOpenAPISpec(DefaultOpenAPIURL, destPath) + require.NoError(t, err) + + // Verify downloaded file exists + info, err := os.Stat(destPath) + require.NoError(t, err) + assert.Greater(t, info.Size(), int64(1000), "Downloaded file should be > 1KB") + + // Verify it's valid JSON with OpenAPI content + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Contains(t, string(data), "openapi") + assert.Contains(t, string(data), "paths") + }) + + // Test with custom URL via environment variable + t.Run("custom URL via env var", func(t *testing.T) { + // Save original env var + originalURL := os.Getenv(OpenAPIURLEnvVar) + defer func() { + if originalURL != "" { + os.Setenv(OpenAPIURLEnvVar, originalURL) + } else { + os.Unsetenv(OpenAPIURLEnvVar) + } + }() + + // Set custom URL (use the same URL for testing) + os.Setenv(OpenAPIURLEnvVar, DefaultOpenAPIURL) + + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "custom-openapi.json") + + // Get URL from env var and download + url := os.Getenv(OpenAPIURLEnvVar) + err := downloadOpenAPISpec(url, destPath) + require.NoError(t, err) + + // Verify file was downloaded + _, err = os.Stat(destPath) + require.NoError(t, err) + }) +} + +func TestDownloadOpenAPISpec_InvalidURL(t *testing.T) { + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "openapi.json") + + err := downloadOpenAPISpec("https://invalid-url-that-does-not-exist.example.com/openapi.json", destPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to download") +} + +func TestDownloadOpenAPISpec_404(t *testing.T) { + if testing.Short() { + t.Skip("Skipping network test in short mode") + } + + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "openapi.json") + + err := downloadOpenAPISpec("https://developer.upsun.com/does-not-exist.json", destPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 404") +} diff --git a/pkg/mockapi/openapi_validation.go b/pkg/mockapi/openapi_validation.go new file mode 100644 index 000000000..2a616cf11 --- /dev/null +++ b/pkg/mockapi/openapi_validation.go @@ -0,0 +1,129 @@ +package mockapi + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "sync" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/legacy" +) + +var ( + openAPIDoc *openapi3.T + router routers.Router + loadOnce sync.Once + loadErr error +) + +// loadOpenAPISpec loads and validates the OpenAPI spec (once per test run) +func loadOpenAPISpec() error { + loadOnce.Do(func() { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + + // Ensure the OpenAPI spec exists (download if necessary) + specPath, loadErr := ensureOpenAPISpec() + if loadErr != nil { + return + } + + openAPIDoc, loadErr = loader.LoadFromFile(specPath) + if loadErr != nil { + return + } + + loadErr = openAPIDoc.Validate(loader.Context) + if loadErr != nil { + return + } + + // Remove servers section for mock testing + // The spec defines servers as "https://api.upsun.com" but our mock runs on localhost + openAPIDoc.Servers = nil + + router, loadErr = legacy.NewRouter(openAPIDoc) + }) + return loadErr +} + +// OpenAPIValidationMiddleware validates mock API responses against OpenAPI spec +// Enable by setting VALIDATE_OPENAPI=1 environment variable +func OpenAPIValidationMiddleware(t testing.TB) func(http.Handler) http.Handler { + // Only validate if explicitly enabled + if os.Getenv("VALIDATE_OPENAPI") == "" { + return func(next http.Handler) http.Handler { + return next + } + } + + if err := loadOpenAPISpec(); err != nil { + t.Logf("Warning: OpenAPI validation disabled - failed to load spec: %v", err) + return func(next http.Handler) http.Handler { + return next + } + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Find route in OpenAPI spec + route, pathParams, err := router.FindRoute(r) + if err != nil { + t.Logf("Warning: Route not in OpenAPI spec: %s %s - %v", r.Method, r.URL.Path, err) + next.ServeHTTP(w, r) + return + } + + // Capture response + rec := &responseCapture{ + ResponseWriter: w, + body: &bytes.Buffer{}, + statusCode: http.StatusOK, // Default status + } + next.ServeHTTP(rec, r) + + // Validate response against OpenAPI schema + responseValidationInput := &openapi3filter.ResponseValidationInput{ + RequestValidationInput: &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + }, + Status: rec.statusCode, + Header: rec.Header(), + Body: io.NopCloser(bytes.NewReader(rec.body.Bytes())), + Options: &openapi3filter.Options{ + IncludeResponseStatus: true, + }, + } + + if err := openapi3filter.ValidateResponse(context.Background(), responseValidationInput); err != nil { + t.Errorf("OpenAPI validation failed for %s %s (status %d):\n%v\nResponse body:\n%s", + r.Method, r.URL.Path, rec.statusCode, err, rec.body.String()) + } + }) + } +} + +// responseCapture captures the response for validation +type responseCapture struct { + http.ResponseWriter + body *bytes.Buffer + statusCode int +} + +func (r *responseCapture) Write(b []byte) (int, error) { + r.body.Write(b) + return r.ResponseWriter.Write(b) +} + +func (r *responseCapture) WriteHeader(statusCode int) { + r.statusCode = statusCode + r.ResponseWriter.WriteHeader(statusCode) +} diff --git a/pkg/mockapi/openapi_validation_test.go b/pkg/mockapi/openapi_validation_test.go new file mode 100644 index 000000000..4e3bc5afd --- /dev/null +++ b/pkg/mockapi/openapi_validation_test.go @@ -0,0 +1,170 @@ +package mockapi_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/upsun/cli/pkg/mockapi" +) + +func TestOpenAPIValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping OpenAPI validation in short mode") + } + + // Enable OpenAPI validation for this test + os.Setenv("VALIDATE_OPENAPI", "1") + defer os.Unsetenv("VALIDATE_OPENAPI") + + handler := mockapi.NewHandler(t) + + // Setup some test data + projectID := mockapi.ProjectID() + orgID := "org-" + mockapi.NumericID() + + handler.SetMyUser(&mockapi.User{ + ID: "user-123", + Username: "testuser", + Email: "test@example.com", + FirstName: "Test", + LastName: "User", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + + handler.SetOrgs([]*mockapi.Org{ + { + ID: orgID, + Name: "test-org", + Label: "Test Organization", + Owner: "user-123", + Type: "organization", + Links: mockapi.MakeHALLinks( + "self=/organizations/"+orgID, + ), + }, + }) + + handler.SetProjects([]*mockapi.Project{ + { + ID: projectID, + Title: "Test Project", + Region: "us-1", + Organization: orgID, + DefaultBranch: "main", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + ), + }, + }) + + handler.SetEnvironments([]*mockapi.Environment{ + { + ID: "main", + Name: "main", + MachineName: "main", + Title: "Main", + Type: "production", + Status: "active", + Project: projectID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/environments/main", + ), + }, + }) + + server := httptest.NewServer(handler) + defer server.Close() + + // Test cases that should validate against OpenAPI spec + testCases := []struct { + name string + method string + path string + wantStatus int + }{ + { + name: "get current user", + method: "GET", + path: "/users/me", + wantStatus: http.StatusOK, + }, + { + name: "list organizations", + method: "GET", + path: "/organizations", + wantStatus: http.StatusOK, + }, + { + name: "get organization", + method: "GET", + path: "/organizations/" + orgID, + wantStatus: http.StatusOK, + }, + { + name: "get project", + method: "GET", + path: "/projects/" + projectID, + wantStatus: http.StatusOK, + }, + { + name: "list environments", + method: "GET", + path: "/projects/" + projectID + "/environments", + wantStatus: http.StatusOK, + }, + { + name: "get environment", + method: "GET", + path: "/projects/" + projectID + "/environments/main", + wantStatus: http.StatusOK, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(tc.method, server.URL+tc.path, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer test-token") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, tc.wantStatus, resp.StatusCode) + + // Verify we got valid JSON + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err, "Response should be valid JSON") + }) + } +} + +// TestOpenAPIValidationDisabledByDefault ensures validation is opt-in +func TestOpenAPIValidationDisabledByDefault(t *testing.T) { + // Ensure VALIDATE_OPENAPI is not set + os.Unsetenv("VALIDATE_OPENAPI") + + handler := mockapi.NewHandler(t) + + // This should work without OpenAPI validation + req := httptest.NewRequest("GET", "/users/me", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + // Should succeed even without OpenAPI spec validation + assert.Equal(t, http.StatusOK, rec.Code) +} From 7d0d678ebcf42b0f06c9c5d4070d490856604a74 Mon Sep 17 00:00:00 2001 From: Miguel Sanchez Gonzalez Date: Tue, 24 Mar 2026 22:59:11 +0100 Subject: [PATCH 2/5] chore: run go mod tidy --- go.mod | 2 +- go.sum | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cc1b0a428..f23dd492c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/alecthomas/chroma/v2 v2.20.0 github.com/briandowns/spinner v1.23.2 github.com/fatih/color v1.18.0 + github.com/getkin/kin-openapi v0.134.0 github.com/go-chi/chi/v5 v5.2.3 github.com/go-playground/validator/v10 v10.27.0 github.com/gofrs/flock v0.12.1 @@ -56,7 +57,6 @@ require ( github.com/fatih/semgroup v1.2.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect - github.com/getkin/kin-openapi v0.134.0 // indirect github.com/getsentry/sentry-go v0.28.1 // indirect github.com/gitleaks/go-gitdiff v0.9.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect diff --git a/go.sum b/go.sum index 2338dd27b..a1143aeeb 100644 --- a/go.sum +++ b/go.sum @@ -146,6 +146,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -187,6 +189,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -362,6 +366,8 @@ github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+x github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= From 0d40a86afa7f86fb5cdebdbf278ae806779ff75b Mon Sep 17 00:00:00 2001 From: Miguel Sanchez Gonzalez Date: Wed, 25 Mar 2026 16:52:09 +0100 Subject: [PATCH 3/5] refactor(mockapi): remove auto-download logic, use fixed OpenAPI spec path Remove automatic OpenAPI spec download functionality from test runtime. Spec will now be downloaded via Makefile only. Changes: - Delete openapi_download.go and openapi_download_test.go (~200 lines) - Update loadOpenAPISpec() to use fixed path: pkg/mockapi/testdata/upsun-openapi.json - Remove ensureOpenAPISpec() path-hunting logic - Validation gracefully disables when spec missing (shows warning) Part of CLI-124: OpenAPI validation implementation Co-Authored-By: Claude Sonnet 4.5 --- pkg/mockapi/openapi_download.go | 107 --------------------------- pkg/mockapi/openapi_download_test.go | 98 ------------------------ pkg/mockapi/openapi_validation.go | 7 +- 3 files changed, 2 insertions(+), 210 deletions(-) delete mode 100644 pkg/mockapi/openapi_download.go delete mode 100644 pkg/mockapi/openapi_download_test.go diff --git a/pkg/mockapi/openapi_download.go b/pkg/mockapi/openapi_download.go deleted file mode 100644 index 35c33c0e1..000000000 --- a/pkg/mockapi/openapi_download.go +++ /dev/null @@ -1,107 +0,0 @@ -package mockapi - -import ( - "fmt" - "io" - "net/http" - "os" - "path/filepath" -) - -const ( - // DefaultOpenAPIURL is the default URL for the Upsun OpenAPI specification - DefaultOpenAPIURL = "https://developer.upsun.com/openapi.json" - - // OpenAPIURLEnvVar is the environment variable to override the OpenAPI URL - OpenAPIURLEnvVar = "UPSUN_OPENAPI_URL" - - // OpenAPISpecFilename is the filename for the downloaded OpenAPI spec - OpenAPISpecFilename = "upsun-openapi.json" -) - -// downloadOpenAPISpec downloads the OpenAPI specification from the given URL -// and saves it to the specified path. -func downloadOpenAPISpec(url, destPath string) error { - // Create directory if it doesn't exist - dir := filepath.Dir(destPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - - // Download the spec - resp, err := http.Get(url) - if err != nil { - return fmt.Errorf("failed to download OpenAPI spec from %s: %w", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download OpenAPI spec: HTTP %d", resp.StatusCode) - } - - // Create the destination file - out, err := os.Create(destPath) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", destPath, err) - } - defer out.Close() - - // Write the response body to file - _, err = io.Copy(out, resp.Body) - if err != nil { - return fmt.Errorf("failed to write OpenAPI spec to %s: %w", destPath, err) - } - - return nil -} - -// ensureOpenAPISpec ensures the OpenAPI spec file exists, downloading it if necessary. -// It returns the path to the spec file. -func ensureOpenAPISpec() (string, error) { - // Try multiple possible paths for the spec file - possiblePaths := []string{ - "pkg/mockapi/testdata/upsun-openapi.json", // from repo root - "testdata/upsun-openapi.json", // from pkg/mockapi - "../pkg/mockapi/testdata/upsun-openapi.json", // from integration-tests - "../../pkg/mockapi/testdata/upsun-openapi.json", // from nested dirs - } - - // Check if file already exists - for _, path := range possiblePaths { - if _, err := os.Stat(path); err == nil { - return path, nil - } - } - - // File doesn't exist, download it - url := os.Getenv(OpenAPIURLEnvVar) - if url == "" { - url = DefaultOpenAPIURL - } - - // Determine the correct download path based on current working directory - // If we can find testdata/ relative to current dir, use that; otherwise use full path - destPath := "testdata/upsun-openapi.json" - if info, err := os.Stat("testdata"); err != nil || !info.IsDir() { - // testdata doesn't exist in current dir, try creating from repo root - destPath = "pkg/mockapi/testdata/upsun-openapi.json" - } - - if err := downloadOpenAPISpec(url, destPath); err != nil { - return "", err - } - - return destPath, nil -} - -// refreshOpenAPISpec forces a re-download of the OpenAPI specification. -// This is useful when the spec has been updated upstream. -func refreshOpenAPISpec() error { - url := os.Getenv(OpenAPIURLEnvVar) - if url == "" { - url = DefaultOpenAPIURL - } - - destPath := "pkg/mockapi/testdata/upsun-openapi.json" - return downloadOpenAPISpec(url, destPath) -} diff --git a/pkg/mockapi/openapi_download_test.go b/pkg/mockapi/openapi_download_test.go deleted file mode 100644 index efc95aa43..000000000 --- a/pkg/mockapi/openapi_download_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package mockapi - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEnsureOpenAPISpec(t *testing.T) { - if testing.Short() { - t.Skip("Skipping download test in short mode") - } - - // Test with existing file - t.Run("existing file", func(t *testing.T) { - path, err := ensureOpenAPISpec() - require.NoError(t, err) - assert.NotEmpty(t, path) - - // Verify file exists and is valid JSON - data, err := os.ReadFile(path) - require.NoError(t, err) - assert.Contains(t, string(data), "openapi") - }) - - // Test downloading to temp directory - t.Run("download to temp", func(t *testing.T) { - tempDir := t.TempDir() - destPath := filepath.Join(tempDir, "openapi.json") - - err := downloadOpenAPISpec(DefaultOpenAPIURL, destPath) - require.NoError(t, err) - - // Verify downloaded file exists - info, err := os.Stat(destPath) - require.NoError(t, err) - assert.Greater(t, info.Size(), int64(1000), "Downloaded file should be > 1KB") - - // Verify it's valid JSON with OpenAPI content - data, err := os.ReadFile(destPath) - require.NoError(t, err) - assert.Contains(t, string(data), "openapi") - assert.Contains(t, string(data), "paths") - }) - - // Test with custom URL via environment variable - t.Run("custom URL via env var", func(t *testing.T) { - // Save original env var - originalURL := os.Getenv(OpenAPIURLEnvVar) - defer func() { - if originalURL != "" { - os.Setenv(OpenAPIURLEnvVar, originalURL) - } else { - os.Unsetenv(OpenAPIURLEnvVar) - } - }() - - // Set custom URL (use the same URL for testing) - os.Setenv(OpenAPIURLEnvVar, DefaultOpenAPIURL) - - tempDir := t.TempDir() - destPath := filepath.Join(tempDir, "custom-openapi.json") - - // Get URL from env var and download - url := os.Getenv(OpenAPIURLEnvVar) - err := downloadOpenAPISpec(url, destPath) - require.NoError(t, err) - - // Verify file was downloaded - _, err = os.Stat(destPath) - require.NoError(t, err) - }) -} - -func TestDownloadOpenAPISpec_InvalidURL(t *testing.T) { - tempDir := t.TempDir() - destPath := filepath.Join(tempDir, "openapi.json") - - err := downloadOpenAPISpec("https://invalid-url-that-does-not-exist.example.com/openapi.json", destPath) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to download") -} - -func TestDownloadOpenAPISpec_404(t *testing.T) { - if testing.Short() { - t.Skip("Skipping network test in short mode") - } - - tempDir := t.TempDir() - destPath := filepath.Join(tempDir, "openapi.json") - - err := downloadOpenAPISpec("https://developer.upsun.com/does-not-exist.json", destPath) - assert.Error(t, err) - assert.Contains(t, err.Error(), "HTTP 404") -} diff --git a/pkg/mockapi/openapi_validation.go b/pkg/mockapi/openapi_validation.go index 2a616cf11..e93f72d01 100644 --- a/pkg/mockapi/openapi_validation.go +++ b/pkg/mockapi/openapi_validation.go @@ -28,11 +28,8 @@ func loadOpenAPISpec() error { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true - // Ensure the OpenAPI spec exists (download if necessary) - specPath, loadErr := ensureOpenAPISpec() - if loadErr != nil { - return - } + // Load OpenAPI spec from fixed path + specPath := "pkg/mockapi/testdata/upsun-openapi.json" openAPIDoc, loadErr = loader.LoadFromFile(specPath) if loadErr != nil { From 7ac6ac88de6b0095228dcf68e582389c001ac417 Mon Sep 17 00:00:00 2001 From: Miguel Sanchez Gonzalez Date: Wed, 25 Mar 2026 17:28:59 +0100 Subject: [PATCH 4/5] refactor(makefile): integrate OpenAPI spec download into build process Update Makefile to automatically download OpenAPI spec as dependency of integration tests. Add module root resolution for reliable path handling across different test contexts. Changes: - Add pkg/mockapi/testdata/upsun-openapi.json file target in Makefile - Make integration-test depend on spec file (downloads if missing) - Add findModuleRoot() to locate spec from any test working directory - Works from both pkg/mockapi tests and integration-tests Part of CLI-124: OpenAPI validation implementation Co-Authored-By: Claude Sonnet 4.5 --- Makefile | 14 +++++-------- pkg/mockapi/openapi_validation.go | 33 +++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 5d2220e6a..17af5db66 100644 --- a/Makefile +++ b/Makefile @@ -128,7 +128,7 @@ test: ## Run unit tests (excludes integration tests) GOEXPERIMENT=jsonv2 go test -v -race -cover -count=1 $$(go list ./... | grep -v /integration-tests) .PHONY: integration-test -integration-test: single ## Run integration tests (requires built CLI) +integration-test: single pkg/mockapi/testdata/upsun-openapi.json ## Run integration tests (requires built CLI and OpenAPI spec) cd integration-tests && GOEXPERIMENT=jsonv2 go test -v -count=1 ./... .PHONY: lint @@ -168,14 +168,10 @@ goreleaser-check: goreleaser ## Check the goreleaser configs # OpenAPI Specification OPENAPI_URL ?= https://developer.upsun.com/openapi.json -OPENAPI_FILE = pkg/mockapi/testdata/upsun-openapi.json -.PHONY: download-openapi -download-openapi: ## Download the Upsun OpenAPI specification +# Download OpenAPI spec (only if missing, use 'rm' to force refresh) +pkg/mockapi/testdata/upsun-openapi.json: @mkdir -p pkg/mockapi/testdata @echo "Downloading OpenAPI spec from $(OPENAPI_URL)..." - @curl -fSL "$(OPENAPI_URL)" -o $(OPENAPI_FILE) - @echo "OpenAPI spec downloaded to $(OPENAPI_FILE)" - -.PHONY: update-openapi -update-openapi: download-openapi ## Alias for download-openapi (forces refresh) + @curl -fSL "$(OPENAPI_URL)" -o $@ + @echo "OpenAPI spec downloaded to $@" diff --git a/pkg/mockapi/openapi_validation.go b/pkg/mockapi/openapi_validation.go index e93f72d01..319abaa4f 100644 --- a/pkg/mockapi/openapi_validation.go +++ b/pkg/mockapi/openapi_validation.go @@ -3,9 +3,11 @@ package mockapi import ( "bytes" "context" + "fmt" "io" "net/http" "os" + "path/filepath" "sync" "testing" @@ -22,14 +24,40 @@ var ( loadErr error ) +// findModuleRoot walks up the directory tree to find go.mod +func findModuleRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found") + } + dir = parent + } +} + // loadOpenAPISpec loads and validates the OpenAPI spec (once per test run) func loadOpenAPISpec() error { loadOnce.Do(func() { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true - // Load OpenAPI spec from fixed path - specPath := "pkg/mockapi/testdata/upsun-openapi.json" + // Find module root and construct path to spec + moduleRoot, err := findModuleRoot() + if err != nil { + loadErr = fmt.Errorf("failed to find module root: %w", err) + return + } + + specPath := filepath.Join(moduleRoot, "pkg/mockapi/testdata/upsun-openapi.json") openAPIDoc, loadErr = loader.LoadFromFile(specPath) if loadErr != nil { @@ -124,3 +152,4 @@ func (r *responseCapture) WriteHeader(statusCode int) { r.statusCode = statusCode r.ResponseWriter.WriteHeader(statusCode) } + From 523a1902af57ae9c98ea9ce67ede4223e574332b Mon Sep 17 00:00:00 2001 From: Miguel Sanchez Gonzalez Date: Wed, 25 Mar 2026 17:30:21 +0100 Subject: [PATCH 5/5] ci: add OpenAPI spec download to integration test workflow Add explicit step to download OpenAPI specification before running integration tests. Spec downloads once per CI run (no caching). Part of CLI-124: OpenAPI validation implementation Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4cd0803e..c32c533c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,5 +137,8 @@ jobs: - name: Build CLI run: make single + - name: Download OpenAPI spec + run: make pkg/mockapi/testdata/upsun-openapi.json + - name: Run integration tests run: make integration-test