diff --git a/.build/server/server b/.build/server/server new file mode 100755 index 0000000..a2aadce Binary files /dev/null and b/.build/server/server differ diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..41f1543 --- /dev/null +++ b/.env.dist @@ -0,0 +1,6 @@ +LOG_FORMAT=text + +# STORAGE_TYPE=redis +# REDIS_HOST=localhost +# REDIS_USERNAME= +# REDIS_PASSWORD= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..aa3145a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: daily + commit-message: + prefix: "deps" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3af5eee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + branches: "**" + +permissions: read-all + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.10.1 + args: --timeout 5m + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - run: go mod download && go mod tidy && go mod verify + - run: git --no-pager diff --exit-code + + - run: go vet ./... + - run: git --no-pager diff --exit-code + + - run: go fmt ./... + - run: git --no-pager diff --exit-code + + - name: Check generate produces no diff + run: | + go generate ./... + go run github.com/mockzilla/connexions/v2/cmd/gen/discover pkg + git --no-pager diff --exit-code + + - name: Test + run: go test -race -count=1 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d46f8f2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +on: + push: + branches: [main, master] + +env: + BINARY_NAME: ${{ github.event.repository.name }} + +permissions: + contents: write + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Generate and discover + run: | + go generate ./... + go run github.com/mockzilla/connexions/v2/cmd/gen/discover pkg + + - uses: actions/upload-artifact@v4 + with: + name: generated-sources + path: | + pkg/*/gen.go + pkg/*/service.go + pkg/*/setup/openapi.yml + cmd/server/services_gen.go + + build: + needs: generate + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: actions/download-artifact@v4 + with: + name: generated-sources + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + ext="" + if [ "$GOOS" = "windows" ]; then ext=".exe"; fi + go build -o ${BINARY_NAME}-${GOOS}-${GOARCH}${ext} ./cmd/server/ + + - uses: actions/upload-artifact@v4 + with: + name: ${{ env.BINARY_NAME }}-${{ matrix.goos }}-${{ matrix.goarch }} + path: ${{ env.BINARY_NAME }}-* + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: ${{ env.BINARY_NAME }}-* + merge-multiple: true + + - name: Delete existing latest release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release delete latest --yes 2>/dev/null || true + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh release create latest artifacts/* \ + --title "Latest build" \ + --notes "Built from $(echo ${GITHUB_SHA} | cut -c1-7)" \ + --latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af42b37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.build diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..6ae36e3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,26 @@ +version: "2" + +run: + timeout: 3m + tests: false + +issues: + max-same-issues: 0 + +linters: + enable: + - wastedassign + - goconst + - cyclop + disable: + - unused + - revive + exclusions: + paths: + - pkg/.*/handler + - pkg/.*/types + - "generate\\.go" + +formatters: + enable: + - goimports diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..85b33ea --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +SHELL = /bin/bash +VERSION ?= "latest" +build_dir := ./.build + +.PHONY: clean +clean: + rm -rf ${build_dir} + +.PHONY: clean-cache +clean-cache: + go clean -cache -modcache -i -r + +.PHONY: build +build: clean generate discover + @echo "Go version: $(GO_VERSION)" + @go mod download + @go build $(GO_BUILD_FLAGS) -o ${build_dir}/server/server ./cmd/server/ + +.PHONY: mod +mod: + go mod download && go mod tidy && go mod verify + +.PHONY: vet +vet: + go vet ./... + +.PHONY: fmt +fmt: + go fmt ./... + +.PHONY: lint +lint: mod vet fmt + echo "Running golangci-lint..." + @golangci-lint run -c .golangci.yml --timeout=5m + +.PHONY: test +test: + go test -race -count=1 ./... + +.PHONY: service-from-static +service-from-static: + @echo "Generating new static service..." + @go run github.com/mockzilla/connexions/v2/cmd/gen/service -type static -name=$(name) -output=pkg/$(name) + +.PHONY: service +service: + @echo "Generating new OpenAPI service..." + @go run github.com/mockzilla/connexions/v2/cmd/gen/service -type openapi -name=$(name) -output=pkg/$(name) + +.PHONY: discover +discover: + @echo "Discovering services to generate service imports..." + @go run github.com/mockzilla/connexions/v2/cmd/gen/discover pkg + +#.PHONY: generate +generate: + @go generate ./... diff --git a/README.md b/README.md index e69de29..4f3f729 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,86 @@ +# Connexions Codegen Template + +Generate a Go mock server from OpenAPI specs with [Connexions](https://github.com/mockzilla/connexions). + +Each service is a Go package with generated handlers, embedded OpenAPI specs, and optional custom logic. +Includes an API Explorer UI at `/api-explorer`. + +## Quick start + +1. Click [**Use this template**](https://github.com/mockzilla/connexions-codegen-template/generate) to create your own repository +2. Add services (see below) +3. Regenerate and discover: + ```bash + make generate && make discover + ``` +4. Push to main - binaries for Linux, macOS, and Windows are built automatically and published to **Releases** + +## Adding services + +### From an OpenAPI spec + +```bash +make service name=my-api +``` + +This creates `pkg/my_api/` with a scaffold. Replace `pkg/my_api/setup/openapi.yml` with your spec, then run: + +```bash +make generate && make discover +``` + +### From static responses + +```bash +make service-from-static name=my-api +``` + +Add response files under `pkg/my_api/setup/data/` organized by method and path: + +``` +pkg/my_api/setup/data/ + get/ + users/ + index.json -> GET /users + users/{id}/ + index.json -> GET /users/{id} + post/ + users/ + index.json -> POST /users +``` + +Then regenerate: + +```bash +make generate && make discover +``` + +## Service structure + +Each service lives in `pkg/{service_name}/` with: + +``` +pkg/petstore/ + setup/ + openapi.yml # OpenAPI specification + config.yml # Latency, errors, upstream, caching + codegen.yml # Code generation settings + context.yml # Custom values for mock data + generate.go # go:generate directive + gen.go # Generated handler (do not edit) + service.go # Custom logic (optional overrides) +``` + +## API Explorer UI + +The built-in UI is available at `/` (configurable in `resources/data/app.yml`). +It lets you browse services, view specs, test endpoints, and inspect request/response history. + +## Local development + +```bash +make build +.build/server/server +``` + +See the [Connexions docs](https://github.com/mockzilla/connexions) for more options. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..3fc6f8e --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,108 @@ +// Package main is the entry point for the mocks server. +package main + +import ( + "context" + "errors" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "runtime" + "syscall" + "time" + + "github.com/joho/godotenv" + "github.com/lmittmann/tint" + "github.com/mockzilla/connexions/v2/pkg/api" + "github.com/mockzilla/connexions/v2/pkg/loader" +) + +func main() { + appDir := getAppDir() + _ = godotenv.Load(fmt.Sprintf("%s/.env", appDir), fmt.Sprintf("%s/.env.dist", appDir)) + + initLogger() + + router := initRouter() + + addr := fmt.Sprintf(":%s", getEnv("PORT", "2200")) + server := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("Starting Mock Server on %s", addr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Server failed to start: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} + +func getAppDir() string { + if appDir := os.Getenv("APP_DIR"); appDir != "" { + return appDir + } + _, b, _, _ := runtime.Caller(0) + return filepath.Dir(filepath.Dir(filepath.Dir(b))) +} + +func initLogger() { + var handler slog.Handler + if os.Getenv("LOG_FORMAT") == "text" { + handler = tint.NewHandler(os.Stdout, &tint.Options{ + Level: slog.LevelInfo, + TimeFormat: time.Kitchen, + }) + } else { + handler = slog.NewJSONHandler(os.Stdout, nil) + } + slog.SetDefault(slog.New(handler)) +} + +func initRouter() *api.Router { + router := api.NewRouter() + _ = api.CreateHealthRoutes(router) + _ = api.CreateHomeRoutes(router) + _ = api.CreateServiceRoutes(router) + _ = api.CreateHistoryRoutes(router) + loader.LoadAll(router) + + services := loader.DefaultRegistry.List() + if len(services) == 0 { + log.Println("WARNING: No services discovered!") + } else { + log.Printf("Discovered %d service(s): %v", len(services), services) + } + + return router +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/cmd/server/services_gen.go b/cmd/server/services_gen.go new file mode 100644 index 0000000..9b4d777 --- /dev/null +++ b/cmd/server/services_gen.go @@ -0,0 +1,8 @@ +// Code generated by connexions. DO NOT EDIT. + +package main + +import ( + _ "github.com/mockzilla/connexions-codegen-template/pkg/hello_world" + _ "github.com/mockzilla/connexions-codegen-template/pkg/petstore" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..651dd13 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module github.com/mockzilla/connexions-codegen-template + +go 1.25.3 + +require ( + github.com/doordash-oss/oapi-codegen-dd/v3 v3.72.12 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-playground/validator/v10 v10.30.1 + github.com/joho/godotenv v1.5.1 + github.com/lmittmann/tint v1.1.2 + github.com/mockzilla/connexions/v2 v2.1.81 + go.yaml.in/yaml/v4 v4.0.0-rc.4 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/caarlos0/env/v11 v11.4.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/jaswdr/faker/v2 v2.9.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/pb33f/jsonpath v0.8.1 // indirect + github.com/pb33f/libopenapi v0.33.11 // indirect + github.com/pb33f/ordered-map/v2 v2.3.0 // indirect + github.com/redis/go-redis/v9 v9.13.0 // indirect + github.com/sony/gobreaker/v2 v2.3.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.41.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8dc1e54 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= +github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/doordash-oss/oapi-codegen-dd/v3 v3.72.12 h1:1AvafyUW3YiR8+M5xaX41jwFF6s/864FlGourL3OhoI= +github.com/doordash-oss/oapi-codegen-dd/v3 v3.72.12/go.mod h1:U+atIM4Z/WgUzCcaktXwMHsvvLMTLsXoPlj+wq0ObMM= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +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= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/jaswdr/faker/v2 v2.9.1 h1:J0Rjqb2/FquZnoZplzkGVL5LmhNkeIpvsSMoJKzn+8E= +github.com/jaswdr/faker/v2 v2.9.1/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/mockzilla/connexions/v2 v2.1.81 h1:lk2YOuJSEcK1e6OsI482Pkx64IwF6wob2QIM3Fdfue8= +github.com/mockzilla/connexions/v2 v2.1.81/go.mod h1:MJRU6E4R1X36DtRoi31uVr1B3i2GaWrbtxSdVz7yzO8= +github.com/pb33f/jsonpath v0.8.1 h1:84C6QRyx6HcSm6PZnsMpcqYot3IsZ+m0n95+0NbBbvs= +github.com/pb33f/jsonpath v0.8.1/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= +github.com/pb33f/libopenapi v0.33.11 h1:ro0FgEvkpdw1zq7T2kXRHh0efrdX27FQ8McT6E0RsYo= +github.com/pb33f/libopenapi v0.33.11/go.mod h1:YOP20KzYe3mhE5301aQzJtzQ9MnvhABBGO7RMttA4V4= +github.com/pb33f/ordered-map/v2 v2.3.0 h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwKBZ5WQ= +github.com/pb33f/ordered-map/v2 v2.3.0/go.mod h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.13.0 h1:PpmlVykE0ODh8P43U0HqC+2NXHXwG+GUtQyz+MPKGRg= +github.com/redis/go-redis/v9 v9.13.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/sony/gobreaker/v2 v2.3.0 h1:7VYxZ69QXRQ2Q4eEawHn6eU4FiuwovzJwsUMA03Lu4I= +github.com/sony/gobreaker/v2 v2.3.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= +go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/hello_world/gen.go b/pkg/hello_world/gen.go new file mode 100644 index 0000000..17f4ab7 --- /dev/null +++ b/pkg/hello_world/gen.go @@ -0,0 +1,523 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package hello_world + +import ( + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + + "sync" + + oapicodegen "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/codegen" + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + "github.com/mockzilla/connexions/v2/pkg/api" + "github.com/mockzilla/connexions/v2/pkg/config" + "github.com/mockzilla/connexions/v2/pkg/db" + "github.com/mockzilla/connexions/v2/pkg/factory" + "github.com/mockzilla/connexions/v2/pkg/generator" + "github.com/mockzilla/connexions/v2/pkg/loader" + "github.com/mockzilla/connexions/v2/pkg/schema" + "github.com/mockzilla/connexions/v2/pkg/typedef" + yamlv4 "go.yaml.in/yaml/v4" +) + +// OapiErrorKind represents the type of error that occurred during request processing. +type OapiErrorKind int + +const ( + // OapiErrorKindParse indicates a parameter parsing error (invalid path/query/header parameter). + OapiErrorKindParse OapiErrorKind = iota + + // OapiErrorKindDecode indicates a request body decoding error (invalid JSON, form data, etc.). + OapiErrorKindDecode + + // OapiErrorKindValidation indicates a request validation error (failed schema validation). + OapiErrorKindValidation + + // OapiErrorKindService indicates a service/business logic error returned by the service implementation. + OapiErrorKindService +) + +// OapiHandlerError represents an error that occurred during request handling (parse, decode, validation). +// When no typed error response is configured in the OpenAPI spec, this error type is used. +// Custom error handlers can type-assert to this type to access error details. +type OapiHandlerError struct { + Kind OapiErrorKind + OperationID string + Message string + ParamName string + ParamLocation string +} + +func (e OapiHandlerError) Error() string { + return e.Message +} + +// OapiErrorResponse is the default JSON error response structure used by OapiDefaultErrorHandler. +type OapiErrorResponse struct { + Error string `json:"error"` + OperationID string `json:"operation_id,omitempty"` + ParamName string `json:"param_name,omitempty"` + ParamLocation string `json:"param_location,omitempty"` +} + +// OapiErrorHandler handles errors that occur during request processing. +// Implement this interface to customize error responses, logging, and metrics. +type OapiErrorHandler interface { + // HandleError writes an error response to w with the given status code. + // The err is either an OapiHandlerError (for parse/decode/validation errors) + // or a typed error matching the OpenAPI spec's error response schema. + HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) +} + +// OapiDefaultErrorHandler provides the default error handling behavior. +// It writes JSON error responses. For OapiHandlerError, it uses OapiErrorResponse. +// For typed errors (from OpenAPI spec), it encodes them directly. +type OapiDefaultErrorHandler struct{} + +// HandleError implements OapiErrorHandler with default JSON error responses. +func (h *OapiDefaultErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if handlerErr, ok := err.(OapiHandlerError); ok { + _ = json.NewEncoder(w).Encode(OapiErrorResponse{ + Error: handlerErr.Message, + OperationID: handlerErr.OperationID, + ParamName: handlerErr.ParamName, + ParamLocation: handlerErr.ParamLocation, + }) + return + } + + // Typed error from OpenAPI spec - encode directly + _ = json.NewEncoder(w).Encode(err) +} + +// ServiceInterface defines the service interface for business logic. +type ServiceInterface interface { + PostHello(ctx context.Context) (*PostHelloResponseData, error) +} + +// HTTPAdapter adapts the ServiceInterface to HTTP handlers. +// This struct is generated and should not be modified. +type HTTPAdapter struct { + svc ServiceInterface + errHandler OapiErrorHandler +} + +// NewHTTPAdapter creates a new HTTPAdapter wrapping the given service. +// If errHandler is nil, OapiDefaultErrorHandler is used. +func NewHTTPAdapter(svc ServiceInterface, errHandler OapiErrorHandler) *HTTPAdapter { + if errHandler == nil { + errHandler = &OapiDefaultErrorHandler{} + } + return &HTTPAdapter{svc: svc, errHandler: errHandler} +} + +// PostHello handles POST /hello +func (a *HTTPAdapter) PostHello(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Call business logic + resp, err := a.svc.PostHello(ctx) + if err != nil { + code := http.StatusInternalServerError + a.errHandler.HandleError(w, r, code, err) + return + } + + // Validate response + if resp != nil && resp.Body != nil { + if v, ok := any(resp.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusInternalServerError, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "PostHello", + Message: fmt.Sprintf("response validation failed: %v", err), + }) + return + } + } + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 200 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(status) + if resp != nil && resp.Body != nil { + _ = json.NewEncoder(w).Encode(resp.Body) + } +} + +// RouterOption is a function that configures the router. +type RouterOption func(*routerConfig) + +type routerConfig struct { + middlewares []func(http.Handler) http.Handler + errHandler OapiErrorHandler +} + +// WithMiddleware adds middleware to the router. +func WithMiddleware(mw func(http.Handler) http.Handler) RouterOption { + return func(cfg *routerConfig) { + cfg.middlewares = append(cfg.middlewares, mw) + } +} + +// WithErrorHandler sets a custom error handler for the router. +// If not set, OapiOapiDefaultErrorHandler is used. +func WithErrorHandler(h OapiErrorHandler) RouterOption { + return func(cfg *routerConfig) { + cfg.errHandler = h + } +} + +// NewRouter creates a new chi.Router with the given service implementation. +func NewRouter(svc ServiceInterface, opts ...RouterOption) chi.Router { + cfg := &routerConfig{} + for _, opt := range opts { + opt(cfg) + } + + r := chi.NewRouter() + r.Use(api.ContextReplacementsMiddleware) + for _, mw := range cfg.middlewares { + r.Use(mw) + } + adapter := NewHTTPAdapter(svc, cfg.errHandler) + r.Method("POST", "/hello", http.HandlerFunc(adapter.PostHello)) + + return r +} + +// ============================================================================ +// Connexions Service Registration +// ============================================================================ + +//go:embed setup/config.yml +var configSrc []byte + +//go:embed setup/openapi.* +var openapiSpecFS embed.FS + +//go:embed setup/codegen.yml +var codegenConfigSrc []byte + +//go:embed setup/context.yml +var contextSrc []byte + +var cfg *config.ServiceConfig + +func init() { + var err error + cfg, err = config.NewServiceConfigFromBytes(configSrc) + if err != nil { + slog.Error("Failed to parse service config", "error", err) + return + } + loader.Register(cfg.Name, RegisterAPIRouter) +} + +// Register registers the service with the central router. +func RegisterAPIRouter(router *api.Router) { + serviceName := cfg.Name + + // Read OpenAPI spec from embedded FS + openapiSpec, err := readFirstEmbeddedFile(openapiSpecFS) + if err != nil { + slog.Error(fmt.Sprintf("Failed to read OpenAPI spec for %s", serviceName), + "error", err, + "service", serviceName, + ) + return + } + + // Load codegen config + var codegenCfg oapicodegen.Configuration + if err := yamlv4.Unmarshal(codegenConfigSrc, &codegenCfg); err != nil { + slog.Error(fmt.Sprintf("Failed to parse codegen config for %s", serviceName), + "error", err, + "service", serviceName, + ) + return + } + codegenCfg = codegenCfg.Merge(oapicodegen.NewDefaultConfiguration()) + + // Create the typedef registry from the OpenAPI spec + registry := typedef.NewRegistryFromSpec(openapiSpec, codegenCfg, cfg.SpecOptions) + + // Create the generator with service contexts + orderedCtx := generator.LoadServiceContext(contextSrc, router.GetContexts()) + gen, err := generator.NewGenerator(orderedCtx, router.GetContexts()) + if err != nil { + slog.Error(fmt.Sprintf("Failed to create generator for %s", serviceName), + "error", err, + "service", serviceName, + ) + return + } + + // Register with connexions using handler factory + router.RegisterHTTPHandler(cfg, func(serviceDB db.DB) api.Handler { + userSvc := newService(&api.ServiceParams{ + AppConfig: router.Config(), + ServiceConfig: cfg, + DB: serviceDB, + }) + genSvc := &generatorService{service: userSvc, generator: gen, registry: registry} + return newServiceHandler(genSvc, gen, registry) + }) + + slog.Info(fmt.Sprintf("Registered %s service", serviceName), + "service", serviceName, + ) +} + +// readFirstEmbeddedFile reads the first file from an embedded filesystem. +func readFirstEmbeddedFile(fsys embed.FS) ([]byte, error) { + entries, err := fsys.ReadDir("setup") + if err != nil { + return nil, fmt.Errorf("reading embedded directory: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() { + return fsys.ReadFile(path.Join("setup", entry.Name())) + } + } + return nil, errors.New("no file found in embedded filesystem") +} + +// ============================================================================ +// Service Handler +// ============================================================================ + +// serviceHandler wraps the chi router and service to implement api.Handler. +type serviceHandler struct { + router chi.Router + service ServiceInterface + gen generator.Generate + registry typedef.OperationRegistry +} + +// newServiceHandler creates a new serviceHandler. +func newServiceHandler(svc ServiceInterface, gen generator.Generate, registry typedef.OperationRegistry) api.Handler { + return &serviceHandler{ + router: NewRouter(svc), + service: svc, + gen: gen, + registry: registry, + } +} + +func (h *serviceHandler) Routes() api.RouteDescriptions { + routes := h.router.Routes() + descriptions := make(api.RouteDescriptions, 0, len(routes)) + for _, route := range routes { + for method := range route.Handlers { + descriptions = append(descriptions, &api.RouteDescription{ + Method: method, + Path: route.Pattern, + }) + } + } + return descriptions +} + +func (h *serviceHandler) RegisterRoutes(router chi.Router) { + router.Mount("/", h.router) +} + +func (h *serviceHandler) Generate(w http.ResponseWriter, r *http.Request) { + var req api.GenerateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + message := err.Error() + if errors.Is(err, io.EOF) { + message = "request body is empty or incomplete" + } + slog.Error("Failed to decode request", "error", err) + http.Error(w, message, http.StatusBadRequest) + return + } + + op := h.registry.FindOperation(req.Path, req.Method) + res := h.gen.Request(&req, op, req.Context) + api.NewJSONResponse(w).Send(res) +} + +// ============================================================================ +// Generator Service (fallback to mock responses) +// ============================================================================ + +// generatorService implements ServiceInterface with generator fallback. +// It delegates to the user's service first; if that returns nil, it generates a mock response. +type generatorService struct { + service *service + generator generator.Generate + registry typedef.OperationRegistry +} + +// Ensure generatorService implements ServiceInterface. +var _ ServiceInterface = (*generatorService)(nil) + +// PostHello handles POST /hello +func (s *generatorService) PostHello(ctx context.Context) (*PostHelloResponseData, error) { + // Call user's service first + if resp, err := s.service.PostHello(ctx); resp != nil || err != nil { + return resp, err + } + + // Fallback to generator + respSchema := s.registry.GetResponseSchema("/hello", "POST") + if respSchema == nil { + return NewPostHelloResponseData(nil), nil + } + + res := s.generator.Response(respSchema, api.UserContextFromGoContext(ctx)) + var body PostHelloResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewPostHelloResponseData(&body).WithHeaders(res.Headers), nil +} + +// ============================================================================ +// Factory (programmatic request/response generation) +// ============================================================================ + +var ( + defaultFactory *factory.Factory + defaultFactoryOnce sync.Once + defaultFactoryErr error +) + +// NewFactory creates a Factory pre-configured with this service's OpenAPI spec, +// codegen config, spec options, and context. +// Use it to generate mock requests and responses programmatically without running the server. +func NewFactory(opts ...factory.FactoryOption) (*factory.Factory, error) { + openapiSpec, err := readFirstEmbeddedFile(openapiSpecFS) + if err != nil { + return nil, err + } + + var codegenCfg oapicodegen.Configuration + if err := yamlv4.Unmarshal(codegenConfigSrc, &codegenCfg); err != nil { + return nil, err + } + + allOpts := []factory.FactoryOption{ + factory.WithServiceContext(contextSrc), + factory.WithCodegenConfig(codegenCfg), + } + if cfg != nil && cfg.SpecOptions != nil { + allOpts = append(allOpts, factory.WithSpecOptions(cfg.SpecOptions)) + } + allOpts = append(allOpts, opts...) + return factory.NewFactory(openapiSpec, allOpts...) +} + +// GetFactory returns a singleton Factory instance. +// The factory is initialized on first call and reused for subsequent calls. +func GetFactory() (*factory.Factory, error) { + defaultFactoryOnce.Do(func() { + defaultFactory, defaultFactoryErr = NewFactory() + }) + return defaultFactory, defaultFactoryErr +} + +// GeneratePostHelloResponse generates a mock response for POST /hello. +// ctx is an optional replacement context for controlling generated values. +func GeneratePostHelloResponse(ctx map[string]any) (*PostHelloResponseData, error) { + f, err := GetFactory() + if err != nil { + return nil, err + } + res, err := f.Response("/hello", "POST", ctx) + if err != nil { + return nil, err + } + var body PostHelloResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewPostHelloResponseData(&body).WithHeaders(res.Headers), nil +} + +// GeneratePostHelloResponseBody generates a mock response body for POST /hello. +// Returns just the typed body without headers or status. +// ctx is an optional replacement context for controlling generated values. +func GeneratePostHelloResponseBody(ctx map[string]any) (*PostHelloResponse, error) { + res, err := GeneratePostHelloResponse(ctx) + if err != nil { + return nil, err + } + return res.Body, nil +} + +// GeneratePostHelloRequest generates a mock request for POST /hello. +// ctx is an optional replacement context for controlling generated values. +func GeneratePostHelloRequest(ctx map[string]any) (schema.GeneratedRequest, error) { + f, err := GetFactory() + if err != nil { + return schema.GeneratedRequest{}, err + } + return f.Request("/hello", "POST", ctx) +} + +// PostHelloResponseData wraps the success response with optional headers and status override. +type PostHelloResponseData struct { + Body *PostHelloResponse + Headers http.Header + Status int // 0 = use default (200) +} + +// NewPostHelloResponseData creates a new PostHelloResponseData with the given body. +func NewPostHelloResponseData(body *PostHelloResponse) *PostHelloResponseData { + return &PostHelloResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *PostHelloResponseData) WithHeaders(h http.Header) *PostHelloResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *PostHelloResponseData) WithStatus(code int) *PostHelloResponseData { + r.Status = code + return r +} + +type PostHelloResponse struct { + Hellow *string `json:"hellow,omitempty"` +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/pkg/hello_world/generate.go b/pkg/hello_world/generate.go new file mode 100644 index 0000000..5ed3b1c --- /dev/null +++ b/pkg/hello_world/generate.go @@ -0,0 +1,10 @@ +package hello_world + +// Generates types, handlers, register.go, and middleware.go from OpenAPI spec. +// All generated code (except middleware.go) is auto-generated and will be overwritten. +// middleware.go is only generated once and can be edited to add custom middleware. +// +// Run after any OpenAPI spec changes: go generate +// +// The command automatically uses setup/codegen.yml and setup/openapi.yml from the current directory. +//go:generate go run github.com/mockzilla/connexions/v2/cmd/gen/service -type static ./setup/data diff --git a/pkg/hello_world/service.go b/pkg/hello_world/service.go new file mode 100644 index 0000000..97607ee --- /dev/null +++ b/pkg/hello_world/service.go @@ -0,0 +1,33 @@ +// Package hello_world This file is generated ONCE as a starting point and will NOT be overwritten. +// Modify it freely to add your business logic. +// To regenerate, delete this file or set generate.handler.output.overwrite: true in config. +package hello_world + +import ( + "context" + + "github.com/mockzilla/connexions/v2/pkg/api" +) + +// service implements the ServiceInterface with your business logic. +// Return nil, nil to fall back to the generator for mock responses. +// Return a response to override the generated response. +// Return an error to return an error response. +type service struct { + params *api.ServiceParams +} + +// Ensure service implements ServiceInterface. +var _ ServiceInterface = (*service)(nil) + +// newService creates a new service instance. +func newService(params *api.ServiceParams) *service { + return &service{params: params} +} + +// PostHello handles POST /hello +func (s *service) PostHello(ctx context.Context) (*PostHelloResponseData, error) { + // TODO: Implement your business logic here. + // Return nil, nil to use the generated mock response. + return nil, nil +} diff --git a/pkg/hello_world/setup/codegen.yml b/pkg/hello_world/setup/codegen.yml new file mode 100644 index 0000000..0203472 --- /dev/null +++ b/pkg/hello_world/setup/codegen.yml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/doordash-oss/oapi-codegen-dd/main/configuration-schema.json +package: hello_world +output: + use-single-file: true + skip-fmt: true + directory: ../ +generate: + omit-description: true + default-int-type: int64 + always-prefix-enum-values: true + validation: + response: true + handler: + kind: chi + output: + overwrite: true + service: {} + # middleware: {} + validation: + request: true + response: true +error-mapping: diff --git a/pkg/hello_world/setup/config.yml b/pkg/hello_world/setup/config.yml new file mode 100644 index 0000000..b6edf28 --- /dev/null +++ b/pkg/hello_world/setup/config.yml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/mockzilla/connexions/refs/heads/master/resources/json-schema-service.json +name: hello-world +latency: 0ms +cache: + requests: true +#upstream: +# url: "https://hello-world.example" +# timeout: 10s +# circuit-breaker: +# timeout: 60s # Time in open state before half-open +# max-requests: 1 # Max requests allowed in half-open state +# interval: 0s # Interval to clear counts in closed state (0 = never) +# min-requests: 3 # Min requests before evaluating failure ratio +# failure-ratio: 0.6 # Failure ratio threshold to trip (0.0-1.0) +# trip-on-status: +# - range: "500-599" diff --git a/pkg/hello_world/setup/context.yml b/pkg/hello_world/setup/context.yml new file mode 100644 index 0000000..bcca29e --- /dev/null +++ b/pkg/hello_world/setup/context.yml @@ -0,0 +1,3 @@ +# Context definitions for mock data generation +# Edit this file to define custom context values + diff --git a/pkg/hello_world/setup/data/post/hello/index.json b/pkg/hello_world/setup/data/post/hello/index.json new file mode 100644 index 0000000..c26fa42 --- /dev/null +++ b/pkg/hello_world/setup/data/post/hello/index.json @@ -0,0 +1,3 @@ +{ + "hellow": "world" +} diff --git a/pkg/hello_world/setup/openapi.yml b/pkg/hello_world/setup/openapi.yml new file mode 100644 index 0000000..00fdded --- /dev/null +++ b/pkg/hello_world/setup/openapi.yml @@ -0,0 +1,19 @@ +info: + title: hello_world + version: 1.0.0 +openapi: 3.1.0 +paths: + /hello: + post: + operationId: postHello + responses: + '200': + content: + application/json: + schema: + properties: + hellow: + type: string + type: object + x-static-response: "{\r\n \"hellow\": \"world\"\r\n}" + description: Success diff --git a/pkg/petstore/gen.go b/pkg/petstore/gen.go new file mode 100644 index 0000000..0ca3c9a --- /dev/null +++ b/pkg/petstore/gen.go @@ -0,0 +1,1251 @@ +// Code generated by oapi-codegen. DO NOT EDIT. + +package petstore + +import ( + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + + "sync" + + oapicodegen "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/codegen" + "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + "github.com/mockzilla/connexions/v2/pkg/api" + "github.com/mockzilla/connexions/v2/pkg/config" + "github.com/mockzilla/connexions/v2/pkg/db" + "github.com/mockzilla/connexions/v2/pkg/factory" + "github.com/mockzilla/connexions/v2/pkg/generator" + "github.com/mockzilla/connexions/v2/pkg/loader" + "github.com/mockzilla/connexions/v2/pkg/schema" + "github.com/mockzilla/connexions/v2/pkg/typedef" + yamlv4 "go.yaml.in/yaml/v4" +) + +// OapiErrorKind represents the type of error that occurred during request processing. +type OapiErrorKind int + +const ( + // OapiErrorKindParse indicates a parameter parsing error (invalid path/query/header parameter). + OapiErrorKindParse OapiErrorKind = iota + + // OapiErrorKindDecode indicates a request body decoding error (invalid JSON, form data, etc.). + OapiErrorKindDecode + + // OapiErrorKindValidation indicates a request validation error (failed schema validation). + OapiErrorKindValidation + + // OapiErrorKindService indicates a service/business logic error returned by the service implementation. + OapiErrorKindService +) + +// OapiHandlerError represents an error that occurred during request handling (parse, decode, validation). +// When no typed error response is configured in the OpenAPI spec, this error type is used. +// Custom error handlers can type-assert to this type to access error details. +type OapiHandlerError struct { + Kind OapiErrorKind + OperationID string + Message string + ParamName string + ParamLocation string +} + +func (e OapiHandlerError) Error() string { + return e.Message +} + +// OapiErrorResponse is the default JSON error response structure used by OapiDefaultErrorHandler. +type OapiErrorResponse struct { + Error string `json:"error"` + OperationID string `json:"operation_id,omitempty"` + ParamName string `json:"param_name,omitempty"` + ParamLocation string `json:"param_location,omitempty"` +} + +// OapiErrorHandler handles errors that occur during request processing. +// Implement this interface to customize error responses, logging, and metrics. +type OapiErrorHandler interface { + // HandleError writes an error response to w with the given status code. + // The err is either an OapiHandlerError (for parse/decode/validation errors) + // or a typed error matching the OpenAPI spec's error response schema. + HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) +} + +// OapiDefaultErrorHandler provides the default error handling behavior. +// It writes JSON error responses. For OapiHandlerError, it uses OapiErrorResponse. +// For typed errors (from OpenAPI spec), it encodes them directly. +type OapiDefaultErrorHandler struct{} + +// HandleError implements OapiErrorHandler with default JSON error responses. +func (h *OapiDefaultErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if handlerErr, ok := err.(OapiHandlerError); ok { + _ = json.NewEncoder(w).Encode(OapiErrorResponse{ + Error: handlerErr.Message, + OperationID: handlerErr.OperationID, + ParamName: handlerErr.ParamName, + ParamLocation: handlerErr.ParamLocation, + }) + return + } + + // Typed error from OpenAPI spec - encode directly + _ = json.NewEncoder(w).Encode(err) +} + +// ServiceInterface defines the service interface for business logic. +type ServiceInterface interface { + FindPets(ctx context.Context, opts *FindPetsServiceRequestOptions) (*FindPetsResponseData, error) + + AddPet(ctx context.Context, opts *AddPetServiceRequestOptions) (*AddPetResponseData, error) + + FindPetByID(ctx context.Context, opts *FindPetByIDServiceRequestOptions) (*FindPetByIDResponseData, error) + + DeletePet(ctx context.Context, opts *DeletePetServiceRequestOptions) (*DeletePetResponseData, error) +} + +// HTTPAdapter adapts the ServiceInterface to HTTP handlers. +// This struct is generated and should not be modified. +type HTTPAdapter struct { + svc ServiceInterface + errHandler OapiErrorHandler +} + +// NewHTTPAdapter creates a new HTTPAdapter wrapping the given service. +// If errHandler is nil, OapiDefaultErrorHandler is used. +func NewHTTPAdapter(svc ServiceInterface, errHandler OapiErrorHandler) *HTTPAdapter { + if errHandler == nil { + errHandler = &OapiDefaultErrorHandler{} + } + return &HTTPAdapter{svc: svc, errHandler: errHandler} +} + +// FindPets handles GET /pets +func (a *HTTPAdapter) FindPets(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := &FindPetsServiceRequestOptions{} + opts.RawRequest = r + + // Parse query parameters + queryParams := &FindPetsQuery{} + query := r.URL.Query() + + if values, ok := query["tags"]; ok { + queryParams.Tags = values + } + if queryParamLimitStr := query.Get("limit"); queryParamLimitStr != "" { + queryParamLimit, err := runtime.ParseString[int32](queryParamLimitStr, "int32") + if err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindParse, + OperationID: "FindPets", + Message: err.Error(), + ParamName: "limit", + ParamLocation: "query", + }) + return + } + queryParams.Limit = &queryParamLimit + } + opts.Query = queryParams + // Validate request + if err := opts.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "FindPets", + Message: err.Error(), + }) + return + } + + // Call business logic + resp, err := a.svc.FindPets(ctx, opts) + if err != nil { + code := http.StatusInternalServerError + a.errHandler.HandleError(w, r, code, err) + return + } + + // Validate response + if resp != nil && resp.Body != nil { + if v, ok := any(resp.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusInternalServerError, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "FindPets", + Message: fmt.Sprintf("response validation failed: %v", err), + }) + return + } + } + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 200 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(status) + if resp != nil && resp.Body != nil { + _ = json.NewEncoder(w).Encode(resp.Body) + } +} + +// AddPet handles POST /pets +func (a *HTTPAdapter) AddPet(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := &AddPetServiceRequestOptions{} + opts.RawRequest = r + + // Parse request body + defer r.Body.Close() + var body AddPetBody + if err := runtime.DecodeJSONBody(r.Body, &body); err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindDecode, + OperationID: "AddPet", + Message: err.Error(), + }) + return + } + opts.Body = &body + // Validate request + if err := opts.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "AddPet", + Message: err.Error(), + }) + return + } + + // Call business logic + resp, err := a.svc.AddPet(ctx, opts) + if err != nil { + code := http.StatusInternalServerError + a.errHandler.HandleError(w, r, code, err) + return + } + + // Validate response + if resp != nil && resp.Body != nil { + if v, ok := any(resp.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusInternalServerError, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "AddPet", + Message: fmt.Sprintf("response validation failed: %v", err), + }) + return + } + } + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 200 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(status) + if resp != nil && resp.Body != nil { + _ = json.NewEncoder(w).Encode(resp.Body) + } +} + +// FindPetByID handles GET /pets/{id} +func (a *HTTPAdapter) FindPetByID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := &FindPetByIDServiceRequestOptions{} + opts.RawRequest = r + + // Parse path parameters + pathParams := &FindPetByIDPath{} + pathParamIDStr := chi.URLParam(r, "id") + + pathParamID, err := runtime.ParseString[int64](pathParamIDStr, "int64") + if err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindParse, + OperationID: "FindPetByID", + Message: err.Error(), + ParamName: "id", + ParamLocation: "path", + }) + return + } + pathParams.ID = pathParamID + opts.PathParams = pathParams + // Validate request + if err := opts.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "FindPetByID", + Message: err.Error(), + }) + return + } + + // Call business logic + resp, err := a.svc.FindPetByID(ctx, opts) + if err != nil { + code := http.StatusInternalServerError + a.errHandler.HandleError(w, r, code, err) + return + } + + // Validate response + if resp != nil && resp.Body != nil { + if v, ok := any(resp.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusInternalServerError, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "FindPetByID", + Message: fmt.Sprintf("response validation failed: %v", err), + }) + return + } + } + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 200 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(status) + if resp != nil && resp.Body != nil { + _ = json.NewEncoder(w).Encode(resp.Body) + } +} + +// DeletePet handles DELETE /pets/{id} +func (a *HTTPAdapter) DeletePet(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := &DeletePetServiceRequestOptions{} + opts.RawRequest = r + + // Parse path parameters + pathParams := &DeletePetPath{} + pathParamIDStr := chi.URLParam(r, "id") + + pathParamID, err := runtime.ParseString[int64](pathParamIDStr, "int64") + if err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindParse, + OperationID: "DeletePet", + Message: err.Error(), + ParamName: "id", + ParamLocation: "path", + }) + return + } + pathParams.ID = pathParamID + opts.PathParams = pathParams + // Validate request + if err := opts.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "DeletePet", + Message: err.Error(), + }) + return + } + + // Call business logic + resp, err := a.svc.DeletePet(ctx, opts) + if err != nil { + code := http.StatusInternalServerError + a.errHandler.HandleError(w, r, code, err) + return + } + + // Validate response + if resp != nil && resp.Body != nil { + if v, ok := any(resp.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + a.errHandler.HandleError(w, r, http.StatusInternalServerError, OapiHandlerError{ + Kind: OapiErrorKindValidation, + OperationID: "DeletePet", + Message: fmt.Sprintf("response validation failed: %v", err), + }) + return + } + } + } + + // Apply custom headers from response + if resp != nil && resp.Headers != nil { + for k, v := range resp.Headers { + for _, val := range v { + w.Header().Add(k, val) + } + } + } + + // Determine status code + status := 204 + if resp != nil && resp.Status != 0 { + status = resp.Status + } + w.WriteHeader(status) +} + +// RouterOption is a function that configures the router. +type RouterOption func(*routerConfig) + +type routerConfig struct { + middlewares []func(http.Handler) http.Handler + errHandler OapiErrorHandler +} + +// WithMiddleware adds middleware to the router. +func WithMiddleware(mw func(http.Handler) http.Handler) RouterOption { + return func(cfg *routerConfig) { + cfg.middlewares = append(cfg.middlewares, mw) + } +} + +// WithErrorHandler sets a custom error handler for the router. +// If not set, OapiOapiDefaultErrorHandler is used. +func WithErrorHandler(h OapiErrorHandler) RouterOption { + return func(cfg *routerConfig) { + cfg.errHandler = h + } +} + +// NewRouter creates a new chi.Router with the given service implementation. +func NewRouter(svc ServiceInterface, opts ...RouterOption) chi.Router { + cfg := &routerConfig{} + for _, opt := range opts { + opt(cfg) + } + + r := chi.NewRouter() + r.Use(api.ContextReplacementsMiddleware) + for _, mw := range cfg.middlewares { + r.Use(mw) + } + adapter := NewHTTPAdapter(svc, cfg.errHandler) + r.Method("GET", "/pets", http.HandlerFunc(adapter.FindPets)) + r.Method("POST", "/pets", http.HandlerFunc(adapter.AddPet)) + r.Method("GET", "/pets/{id}", http.HandlerFunc(adapter.FindPetByID)) + r.Method("DELETE", "/pets/{id}", http.HandlerFunc(adapter.DeletePet)) + + return r +} + +// ============================================================================ +// Connexions Service Registration +// ============================================================================ + +//go:embed setup/config.yml +var configSrc []byte + +//go:embed setup/openapi.* +var openapiSpecFS embed.FS + +//go:embed setup/codegen.yml +var codegenConfigSrc []byte + +//go:embed setup/context.yml +var contextSrc []byte + +var cfg *config.ServiceConfig + +func init() { + var err error + cfg, err = config.NewServiceConfigFromBytes(configSrc) + if err != nil { + slog.Error("Failed to parse service config", "error", err) + return + } + loader.Register(cfg.Name, RegisterAPIRouter) +} + +// Register registers the service with the central router. +func RegisterAPIRouter(router *api.Router) { + serviceName := cfg.Name + + // Read OpenAPI spec from embedded FS + openapiSpec, err := readFirstEmbeddedFile(openapiSpecFS) + if err != nil { + slog.Error(fmt.Sprintf("Failed to read OpenAPI spec for %s", serviceName), + "error", err, + "service", serviceName, + ) + return + } + + // Load codegen config + var codegenCfg oapicodegen.Configuration + if err := yamlv4.Unmarshal(codegenConfigSrc, &codegenCfg); err != nil { + slog.Error(fmt.Sprintf("Failed to parse codegen config for %s", serviceName), + "error", err, + "service", serviceName, + ) + return + } + codegenCfg = codegenCfg.Merge(oapicodegen.NewDefaultConfiguration()) + + // Create the typedef registry from the OpenAPI spec + registry := typedef.NewRegistryFromSpec(openapiSpec, codegenCfg, cfg.SpecOptions) + + // Create the generator with service contexts + orderedCtx := generator.LoadServiceContext(contextSrc, router.GetContexts()) + gen, err := generator.NewGenerator(orderedCtx, router.GetContexts()) + if err != nil { + slog.Error(fmt.Sprintf("Failed to create generator for %s", serviceName), + "error", err, + "service", serviceName, + ) + return + } + + // Register with connexions using handler factory + router.RegisterHTTPHandler(cfg, func(serviceDB db.DB) api.Handler { + userSvc := newService(&api.ServiceParams{ + AppConfig: router.Config(), + ServiceConfig: cfg, + DB: serviceDB, + }) + genSvc := &generatorService{service: userSvc, generator: gen, registry: registry} + return newServiceHandler(genSvc, gen, registry) + }) + + slog.Info(fmt.Sprintf("Registered %s service", serviceName), + "service", serviceName, + ) +} + +// readFirstEmbeddedFile reads the first file from an embedded filesystem. +func readFirstEmbeddedFile(fsys embed.FS) ([]byte, error) { + entries, err := fsys.ReadDir("setup") + if err != nil { + return nil, fmt.Errorf("reading embedded directory: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() { + return fsys.ReadFile(path.Join("setup", entry.Name())) + } + } + return nil, errors.New("no file found in embedded filesystem") +} + +// ============================================================================ +// Service Handler +// ============================================================================ + +// serviceHandler wraps the chi router and service to implement api.Handler. +type serviceHandler struct { + router chi.Router + service ServiceInterface + gen generator.Generate + registry typedef.OperationRegistry +} + +// newServiceHandler creates a new serviceHandler. +func newServiceHandler(svc ServiceInterface, gen generator.Generate, registry typedef.OperationRegistry) api.Handler { + return &serviceHandler{ + router: NewRouter(svc), + service: svc, + gen: gen, + registry: registry, + } +} + +func (h *serviceHandler) Routes() api.RouteDescriptions { + routes := h.router.Routes() + descriptions := make(api.RouteDescriptions, 0, len(routes)) + for _, route := range routes { + for method := range route.Handlers { + descriptions = append(descriptions, &api.RouteDescription{ + Method: method, + Path: route.Pattern, + }) + } + } + return descriptions +} + +func (h *serviceHandler) RegisterRoutes(router chi.Router) { + router.Mount("/", h.router) +} + +func (h *serviceHandler) Generate(w http.ResponseWriter, r *http.Request) { + var req api.GenerateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + message := err.Error() + if errors.Is(err, io.EOF) { + message = "request body is empty or incomplete" + } + slog.Error("Failed to decode request", "error", err) + http.Error(w, message, http.StatusBadRequest) + return + } + + op := h.registry.FindOperation(req.Path, req.Method) + res := h.gen.Request(&req, op, req.Context) + api.NewJSONResponse(w).Send(res) +} + +// ============================================================================ +// Generator Service (fallback to mock responses) +// ============================================================================ + +// generatorService implements ServiceInterface with generator fallback. +// It delegates to the user's service first; if that returns nil, it generates a mock response. +type generatorService struct { + service *service + generator generator.Generate + registry typedef.OperationRegistry +} + +// Ensure generatorService implements ServiceInterface. +var _ ServiceInterface = (*generatorService)(nil) + +// FindPets handles GET /pets +func (s *generatorService) FindPets(ctx context.Context, opts *FindPetsServiceRequestOptions) (*FindPetsResponseData, error) { + // Inject GenerateResponse so user service can call it + opts.GenerateResponse = func() (*FindPetsResponseData, error) { + respSchema := s.registry.GetResponseSchema("/pets", "GET") + if respSchema == nil { + return NewFindPetsResponseData(nil), nil + } + res := s.generator.Response(respSchema, api.UserContextFromGoContext(ctx)) + var body FindPetsResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewFindPetsResponseData(&body).WithHeaders(res.Headers), nil + } + + // Call user's service + if resp, err := s.service.FindPets(ctx, opts); resp != nil || err != nil { + return resp, err + } + + // Fallback to generator + return opts.GenerateResponse() +} + +// AddPet handles POST /pets +func (s *generatorService) AddPet(ctx context.Context, opts *AddPetServiceRequestOptions) (*AddPetResponseData, error) { + // Inject GenerateResponse so user service can call it + opts.GenerateResponse = func() (*AddPetResponseData, error) { + respSchema := s.registry.GetResponseSchema("/pets", "POST") + if respSchema == nil { + return NewAddPetResponseData(nil), nil + } + res := s.generator.Response(respSchema, api.UserContextFromGoContext(ctx)) + var body AddPetResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewAddPetResponseData(&body).WithHeaders(res.Headers), nil + } + + // Call user's service + if resp, err := s.service.AddPet(ctx, opts); resp != nil || err != nil { + return resp, err + } + + // Fallback to generator + return opts.GenerateResponse() +} + +// FindPetByID handles GET /pets/{id} +func (s *generatorService) FindPetByID(ctx context.Context, opts *FindPetByIDServiceRequestOptions) (*FindPetByIDResponseData, error) { + // Inject GenerateResponse so user service can call it + opts.GenerateResponse = func() (*FindPetByIDResponseData, error) { + respSchema := s.registry.GetResponseSchema("/pets/{id}", "GET") + if respSchema == nil { + return NewFindPetByIDResponseData(nil), nil + } + res := s.generator.Response(respSchema, api.UserContextFromGoContext(ctx)) + var body FindPetByIDResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewFindPetByIDResponseData(&body).WithHeaders(res.Headers), nil + } + + // Call user's service + if resp, err := s.service.FindPetByID(ctx, opts); resp != nil || err != nil { + return resp, err + } + + // Fallback to generator + return opts.GenerateResponse() +} + +// DeletePet handles DELETE /pets/{id} +func (s *generatorService) DeletePet(ctx context.Context, opts *DeletePetServiceRequestOptions) (*DeletePetResponseData, error) { + // Inject GenerateResponse so user service can call it + opts.GenerateResponse = func() (*DeletePetResponseData, error) { + respSchema := s.registry.GetResponseSchema("/pets/{id}", "DELETE") + if respSchema == nil { + return NewDeletePetResponseData(nil), nil + } + res := s.generator.Response(respSchema, api.UserContextFromGoContext(ctx)) + return NewDeletePetResponseData(nil).WithHeaders(res.Headers), nil + } + + // Call user's service + if resp, err := s.service.DeletePet(ctx, opts); resp != nil || err != nil { + return resp, err + } + + // Fallback to generator + return opts.GenerateResponse() +} + +// ============================================================================ +// Factory (programmatic request/response generation) +// ============================================================================ + +var ( + defaultFactory *factory.Factory + defaultFactoryOnce sync.Once + defaultFactoryErr error +) + +// NewFactory creates a Factory pre-configured with this service's OpenAPI spec, +// codegen config, spec options, and context. +// Use it to generate mock requests and responses programmatically without running the server. +func NewFactory(opts ...factory.FactoryOption) (*factory.Factory, error) { + openapiSpec, err := readFirstEmbeddedFile(openapiSpecFS) + if err != nil { + return nil, err + } + + var codegenCfg oapicodegen.Configuration + if err := yamlv4.Unmarshal(codegenConfigSrc, &codegenCfg); err != nil { + return nil, err + } + + allOpts := []factory.FactoryOption{ + factory.WithServiceContext(contextSrc), + factory.WithCodegenConfig(codegenCfg), + } + if cfg != nil && cfg.SpecOptions != nil { + allOpts = append(allOpts, factory.WithSpecOptions(cfg.SpecOptions)) + } + allOpts = append(allOpts, opts...) + return factory.NewFactory(openapiSpec, allOpts...) +} + +// GetFactory returns a singleton Factory instance. +// The factory is initialized on first call and reused for subsequent calls. +func GetFactory() (*factory.Factory, error) { + defaultFactoryOnce.Do(func() { + defaultFactory, defaultFactoryErr = NewFactory() + }) + return defaultFactory, defaultFactoryErr +} + +// GenerateFindPetsResponse generates a mock response for GET /pets. +// ctx is an optional replacement context for controlling generated values. +func GenerateFindPetsResponse(ctx map[string]any) (*FindPetsResponseData, error) { + f, err := GetFactory() + if err != nil { + return nil, err + } + res, err := f.Response("/pets", "GET", ctx) + if err != nil { + return nil, err + } + var body FindPetsResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewFindPetsResponseData(&body).WithHeaders(res.Headers), nil +} + +// GenerateFindPetsResponseBody generates a mock response body for GET /pets. +// Returns just the typed body without headers or status. +// ctx is an optional replacement context for controlling generated values. +func GenerateFindPetsResponseBody(ctx map[string]any) (*FindPetsResponse, error) { + res, err := GenerateFindPetsResponse(ctx) + if err != nil { + return nil, err + } + return res.Body, nil +} + +// GenerateFindPetsRequest generates a mock request for GET /pets. +// ctx is an optional replacement context for controlling generated values. +func GenerateFindPetsRequest(ctx map[string]any) (schema.GeneratedRequest, error) { + f, err := GetFactory() + if err != nil { + return schema.GeneratedRequest{}, err + } + return f.Request("/pets", "GET", ctx) +} + +// GenerateAddPetResponse generates a mock response for POST /pets. +// ctx is an optional replacement context for controlling generated values. +func GenerateAddPetResponse(ctx map[string]any) (*AddPetResponseData, error) { + f, err := GetFactory() + if err != nil { + return nil, err + } + res, err := f.Response("/pets", "POST", ctx) + if err != nil { + return nil, err + } + var body AddPetResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewAddPetResponseData(&body).WithHeaders(res.Headers), nil +} + +// GenerateAddPetResponseBody generates a mock response body for POST /pets. +// Returns just the typed body without headers or status. +// ctx is an optional replacement context for controlling generated values. +func GenerateAddPetResponseBody(ctx map[string]any) (*AddPetResponse, error) { + res, err := GenerateAddPetResponse(ctx) + if err != nil { + return nil, err + } + return res.Body, nil +} + +// GenerateAddPetRequest generates a mock request for POST /pets. +// ctx is an optional replacement context for controlling generated values. +func GenerateAddPetRequest(ctx map[string]any) (schema.GeneratedRequest, error) { + f, err := GetFactory() + if err != nil { + return schema.GeneratedRequest{}, err + } + return f.Request("/pets", "POST", ctx) +} + +// GenerateAddPetRequestBody generates a typed mock request body for POST /pets. +// ctx is an optional replacement context for controlling generated values. +func GenerateAddPetRequestBody(ctx map[string]any) (*AddPetBody, error) { + req, err := GenerateAddPetRequest(ctx) + if err != nil { + return nil, err + } + var body AddPetBody + if err := json.Unmarshal(req.Body, &body); err != nil { + return nil, err + } + return &body, nil +} + +// GenerateFindPetByIDResponse generates a mock response for GET /pets/{id}. +// ctx is an optional replacement context for controlling generated values. +func GenerateFindPetByIDResponse(ctx map[string]any) (*FindPetByIDResponseData, error) { + f, err := GetFactory() + if err != nil { + return nil, err + } + res, err := f.Response("/pets/{id}", "GET", ctx) + if err != nil { + return nil, err + } + var body FindPetByIDResponse + if err := api.UnmarshalResponseInto(res.Body, "application/json", &body); err != nil { + return nil, err + } + return NewFindPetByIDResponseData(&body).WithHeaders(res.Headers), nil +} + +// GenerateFindPetByIDResponseBody generates a mock response body for GET /pets/{id}. +// Returns just the typed body without headers or status. +// ctx is an optional replacement context for controlling generated values. +func GenerateFindPetByIDResponseBody(ctx map[string]any) (*FindPetByIDResponse, error) { + res, err := GenerateFindPetByIDResponse(ctx) + if err != nil { + return nil, err + } + return res.Body, nil +} + +// GenerateFindPetByIDRequest generates a mock request for GET /pets/{id}. +// ctx is an optional replacement context for controlling generated values. +func GenerateFindPetByIDRequest(ctx map[string]any) (schema.GeneratedRequest, error) { + f, err := GetFactory() + if err != nil { + return schema.GeneratedRequest{}, err + } + return f.Request("/pets/{id}", "GET", ctx) +} + +// GenerateDeletePetResponse generates a mock response for DELETE /pets/{id}. +// ctx is an optional replacement context for controlling generated values. +func GenerateDeletePetResponse(ctx map[string]any) (*DeletePetResponseData, error) { + f, err := GetFactory() + if err != nil { + return nil, err + } + res, err := f.Response("/pets/{id}", "DELETE", ctx) + if err != nil { + return nil, err + } + return NewDeletePetResponseData(nil).WithHeaders(res.Headers), nil +} + +// GenerateDeletePetResponseBody generates a mock response body for DELETE /pets/{id}. +// Returns just the typed body without headers or status. +// ctx is an optional replacement context for controlling generated values. +func GenerateDeletePetResponseBody(ctx map[string]any) error { + _, err := GenerateDeletePetResponse(ctx) + return err +} + +// GenerateDeletePetRequest generates a mock request for DELETE /pets/{id}. +// ctx is an optional replacement context for controlling generated values. +func GenerateDeletePetRequest(ctx map[string]any) (schema.GeneratedRequest, error) { + f, err := GetFactory() + if err != nil { + return schema.GeneratedRequest{}, err + } + return f.Request("/pets/{id}", "DELETE", ctx) +} + +type FindPetByIDPath struct { + ID int64 `json:"id" validate:"required"` +} + +func (f FindPetByIDPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type DeletePetPath struct { + ID int64 `json:"id" validate:"required"` +} + +func (d DeletePetPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type AddPetBody = NewPet + +type FindPetsQuery struct { + Tags []string `json:"tags,omitempty"` + Limit *int32 `json:"limit,omitempty"` +} + +// FindPetsResponseData wraps the success response with optional headers and status override. +type FindPetsResponseData struct { + Body *FindPetsResponse + Headers http.Header + Status int // 0 = use default (200) +} + +// NewFindPetsResponseData creates a new FindPetsResponseData with the given body. +func NewFindPetsResponseData(body *FindPetsResponse) *FindPetsResponseData { + return &FindPetsResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *FindPetsResponseData) WithHeaders(h http.Header) *FindPetsResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *FindPetsResponseData) WithStatus(code int) *FindPetsResponseData { + r.Status = code + return r +} + +// AddPetResponseData wraps the success response with optional headers and status override. +type AddPetResponseData struct { + Body *AddPetResponse + Headers http.Header + Status int // 0 = use default (200) +} + +// NewAddPetResponseData creates a new AddPetResponseData with the given body. +func NewAddPetResponseData(body *AddPetResponse) *AddPetResponseData { + return &AddPetResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *AddPetResponseData) WithHeaders(h http.Header) *AddPetResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *AddPetResponseData) WithStatus(code int) *AddPetResponseData { + r.Status = code + return r +} + +// FindPetByIDResponseData wraps the success response with optional headers and status override. +type FindPetByIDResponseData struct { + Body *FindPetByIDResponse + Headers http.Header + Status int // 0 = use default (200) +} + +// NewFindPetByIDResponseData creates a new FindPetByIDResponseData with the given body. +func NewFindPetByIDResponseData(body *FindPetByIDResponse) *FindPetByIDResponseData { + return &FindPetByIDResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *FindPetByIDResponseData) WithHeaders(h http.Header) *FindPetByIDResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *FindPetByIDResponseData) WithStatus(code int) *FindPetByIDResponseData { + r.Status = code + return r +} + +// DeletePetResponseData wraps the success response with optional headers and status override. +type DeletePetResponseData struct { + Body *struct{} + Headers http.Header + Status int // 0 = use default (204) +} + +// NewDeletePetResponseData creates a new DeletePetResponseData with the given body. +func NewDeletePetResponseData(body *struct{}) *DeletePetResponseData { + return &DeletePetResponseData{Body: body} +} + +// WithHeaders sets custom headers on the response. +func (r *DeletePetResponseData) WithHeaders(h http.Header) *DeletePetResponseData { + r.Headers = h + return r +} + +// WithStatus overrides the default status code. +func (r *DeletePetResponseData) WithStatus(code int) *DeletePetResponseData { + r.Status = code + return r +} + +type FindPetsResponse []Pet + +func (f FindPetsResponse) Validate() error { + if f == nil { + return nil + } + var errors runtime.ValidationErrors + for i, item := range f { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FindPetsErrorResponse = Error + +type AddPetResponse = Pet + +type AddPetErrorResponse = Error + +type FindPetByIDResponse = Pet + +type FindPetByIDErrorResponse = Error + +type DeletePetErrorResponse = Error + +// FindPetsServiceRequestOptions holds all parameters for the FindPets operation. +type FindPetsServiceRequestOptions struct { + Query *FindPetsQuery + // RawRequest provides access to the underlying HTTP request for custom content type handling. + RawRequest *http.Request + // GenerateResponse generates a sample response with random data satisfying the OpenAPI schema. + GenerateResponse func() (*FindPetsResponseData, error) +} + +// Validate validates all the fields in the options. +func (o *FindPetsServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// AddPetServiceRequestOptions holds all parameters for the AddPet operation. +type AddPetServiceRequestOptions struct { + Body *AddPetBody + // RawRequest provides access to the underlying HTTP request for custom content type handling. + RawRequest *http.Request + // GenerateResponse generates a sample response with random data satisfying the OpenAPI schema. + GenerateResponse func() (*AddPetResponseData, error) +} + +// Validate validates all the fields in the options. +func (o *AddPetServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// FindPetByIDServiceRequestOptions holds all parameters for the FindPetByID operation. +type FindPetByIDServiceRequestOptions struct { + PathParams *FindPetByIDPath + // RawRequest provides access to the underlying HTTP request for custom content type handling. + RawRequest *http.Request + // GenerateResponse generates a sample response with random data satisfying the OpenAPI schema. + GenerateResponse func() (*FindPetByIDResponseData, error) +} + +// Validate validates all the fields in the options. +func (o *FindPetByIDServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// DeletePetServiceRequestOptions holds all parameters for the DeletePet operation. +type DeletePetServiceRequestOptions struct { + PathParams *DeletePetPath + // RawRequest provides access to the underlying HTTP request for custom content type handling. + RawRequest *http.Request + // GenerateResponse generates a sample response with random data satisfying the OpenAPI schema. + GenerateResponse func() (*DeletePetResponseData, error) +} + +// Validate validates all the fields in the options. +func (o *DeletePetServiceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +type Pet struct { + Name string `json:"name" validate:"required"` + Tag *string `json:"tag,omitempty"` + ID int64 `json:"id" validate:"required"` +} + +func (p Pet) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type NewPet struct { + Name string `json:"name" validate:"required"` + Tag *string `json:"tag,omitempty"` +} + +func (n NewPet) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(n)) +} + +type Error struct { + Code int32 `json:"code" validate:"required"` + Message string `json:"message" validate:"required"` +} + +func (e Error) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(e)) +} + +func (s Error) Error() string { + return "unmapped client error" +} + +var typesValidator *validator.Validate + +func init() { + typesValidator = validator.New(validator.WithRequiredStructEnabled()) + runtime.RegisterCustomTypeFunc(typesValidator) +} diff --git a/pkg/petstore/generate.go b/pkg/petstore/generate.go new file mode 100644 index 0000000..446423a --- /dev/null +++ b/pkg/petstore/generate.go @@ -0,0 +1,10 @@ +package petstore + +// Generates types, handlers, register.go, and middleware.go from OpenAPI spec. +// All generated code (except middleware.go) is auto-generated and will be overwritten. +// middleware.go is only generated once and can be edited to add custom middleware. +// +// Run after any OpenAPI spec changes: go generate +// +// The command automatically uses setup/codegen.yml and setup/openapi.yml from the current directory. +//go:generate go run github.com/mockzilla/connexions/v2/cmd/gen/service diff --git a/pkg/petstore/service.go b/pkg/petstore/service.go new file mode 100644 index 0000000..c1cc4c6 --- /dev/null +++ b/pkg/petstore/service.go @@ -0,0 +1,54 @@ +// Package petstore This file is generated ONCE as a starting point and will NOT be overwritten. +// Modify it freely to add your business logic. +// To regenerate, delete this file or set generate.handler.output.overwrite: true in config. +package petstore + +import ( + "context" + + "github.com/mockzilla/connexions/v2/pkg/api" +) + +// service implements the ServiceInterface with your business logic. +// Return nil, nil to fall back to the generator for mock responses. +// Return a response to override the generated response. +// Return an error to return an error response. +type service struct { + params *api.ServiceParams +} + +// Ensure service implements ServiceInterface. +var _ ServiceInterface = (*service)(nil) + +// newService creates a new service instance. +func newService(params *api.ServiceParams) *service { + return &service{params: params} +} + +// FindPets handles GET /pets +func (s *service) FindPets(ctx context.Context, opts *FindPetsServiceRequestOptions) (*FindPetsResponseData, error) { + // TODO: Implement your business logic here. + // Return nil, nil to use the generated mock response. + return nil, nil +} + +// AddPet handles POST /pets +func (s *service) AddPet(ctx context.Context, opts *AddPetServiceRequestOptions) (*AddPetResponseData, error) { + // TODO: Implement your business logic here. + // Return nil, nil to use the generated mock response. + return nil, nil +} + +// FindPetByID handles GET /pets/{id} +func (s *service) FindPetByID(ctx context.Context, opts *FindPetByIDServiceRequestOptions) (*FindPetByIDResponseData, error) { + // TODO: Implement your business logic here. + // Return nil, nil to use the generated mock response. + return nil, nil +} + +// DeletePet handles DELETE /pets/{id} +func (s *service) DeletePet(ctx context.Context, opts *DeletePetServiceRequestOptions) (*DeletePetResponseData, error) { + // TODO: Implement your business logic here. + // Return nil, nil to use the generated mock response. + return nil, nil +} diff --git a/pkg/petstore/setup/codegen.yml b/pkg/petstore/setup/codegen.yml new file mode 100644 index 0000000..c03bf7b --- /dev/null +++ b/pkg/petstore/setup/codegen.yml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/doordash-oss/oapi-codegen-dd/main/configuration-schema.json +package: petstore +output: + use-single-file: true + skip-fmt: true + directory: ../ +generate: + omit-description: true + default-int-type: int64 + always-prefix-enum-values: true + validation: + response: true + handler: + kind: chi + output: + overwrite: false + service: {} + # middleware: {} + validation: + request: true + response: true +error-mapping: diff --git a/pkg/petstore/setup/config.yml b/pkg/petstore/setup/config.yml new file mode 100644 index 0000000..7f470bb --- /dev/null +++ b/pkg/petstore/setup/config.yml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/mockzilla/connexions/refs/heads/master/resources/json-schema-service.json +name: petstore +latency: 0ms +cache: + requests: true +#upstream: +# url: https://petstore3.swagger.io/api/v3 +# timeout: 10s +# circuit-breaker: +# timeout: 60s # Time in open state before half-open +# max-requests: 1 # Max requests allowed in half-open state +# interval: 0s # Interval to clear counts in closed state (0 = never) +# min-requests: 3 # Min requests before evaluating failure ratio +# failure-ratio: 0.6 # Failure ratio threshold to trip (0.0-1.0) +# trip-on-status: +# - range: "500-599" diff --git a/pkg/petstore/setup/context.yml b/pkg/petstore/setup/context.yml new file mode 100644 index 0000000..9099b71 --- /dev/null +++ b/pkg/petstore/setup/context.yml @@ -0,0 +1,4 @@ +# Context definitions for mock data generation +# Edit this file to define custom context values + +name: ["Fluffy", "Spot", "Rover", "Bella", "Charlie", "Max", "Luna", "Rocky", "Daisy", "Molly"] diff --git a/pkg/petstore/setup/openapi.yml b/pkg/petstore/setup/openapi.yml new file mode 100644 index 0000000..d283552 --- /dev/null +++ b/pkg/petstore/setup/openapi.yml @@ -0,0 +1,159 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification + termsOfService: http://swagger.io/terms/ + contact: + name: Swagger API Team + email: apiteam@swagger.io + url: http://swagger.io + license: + name: Apache 2.0 + url: https://apache.org/licenses/LICENSE-2.0.html +servers: + - url: https://petstore.swagger.io/v2 +paths: + /pets: + get: + description: | + Returns all pets from the system that the user has access to + Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. + + Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. + operationId: findPets + parameters: + - name: tags + in: query + description: tags to filter by + required: false + style: form + schema: + type: array + items: + type: string + - name: limit + in: query + description: maximum number of results to return + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + description: Creates a new pet in the store. Duplicates are allowed + operationId: addPet + requestBody: + description: Pet to add to the store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{id}: + get: + description: Returns a user based on a single ID, if the user does not have access to the pet + operationId: find pet by id + parameters: + - name: id + in: path + description: ID of pet to fetch + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + description: deletes a single pet based on the ID supplied + operationId: deletePet + parameters: + - name: id + in: path + description: ID of pet to delete + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: pet deleted + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + allOf: + - $ref: '#/components/schemas/NewPet' + - type: object + required: + - id + properties: + id: + type: integer + format: int64 + + NewPet: + type: object + required: + - name + properties: + name: + type: string + example: doggie + tag: + type: string + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/resources/data/app.yml b/resources/data/app.yml new file mode 100644 index 0000000..05a28be --- /dev/null +++ b/resources/data/app.yml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/mockzilla/connexions/refs/heads/master/resources/json-schema.json +title: Mocks API Explorer +port: 2200 + +editor: + theme: chrome + darkTheme: cobalt + fontSize: 14 + +historyDuration: 8h +homeURL: / + +## Storage backend: defaults to memory for local development. +## Override with env vars for deployment: +## STORAGE_TYPE=redis +## REDIS_HOST=host +## REDIS_USERNAME=... +## REDIS_PASSWORD=... +storage: + type: memory + redis: + host: localhost