From 23df5c7c661806ecfbeb4d3c788781091fb6e951 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 10 Jan 2025 10:19:58 -0500 Subject: [PATCH 01/30] In progress --- .github/workflows/framework-golden-tests.yml | 4 +- .github/workflows/framework.yml | 4 +- .github/workflows/lint.yaml | 2 + .github/workflows/parrotserver-release.yml | 33 +++ .github/workflows/rc-breaking-changes.yaml | 4 +- .github/workflows/release-go-module.yml | 2 +- .github/workflows/test.yaml | 2 + .gitignore | 4 +- parrotserver/.dockerignore | 13 + parrotserver/.goreleaser.yaml | 58 +++++ parrotserver/Dockerfile | 3 + parrotserver/Makefile | 11 + parrotserver/README.md | 84 +++++++ parrotserver/cmd/main.go | 63 +++++ parrotserver/go.mod | 17 ++ parrotserver/go.sum | 25 ++ parrotserver/server.go | 235 ++++++++++++++++++ parrotserver/server_test.go | 237 +++++++++++++++++++ 18 files changed, 793 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/parrotserver-release.yml create mode 100644 parrotserver/.dockerignore create mode 100644 parrotserver/.goreleaser.yaml create mode 100644 parrotserver/Dockerfile create mode 100644 parrotserver/Makefile create mode 100644 parrotserver/README.md create mode 100644 parrotserver/cmd/main.go create mode 100644 parrotserver/go.mod create mode 100644 parrotserver/go.sum create mode 100644 parrotserver/server.go create mode 100644 parrotserver/server_test.go diff --git a/.github/workflows/framework-golden-tests.yml b/.github/workflows/framework-golden-tests.yml index 54feabf9a..77a61b81c 100644 --- a/.github/workflows/framework-golden-tests.yml +++ b/.github/workflows/framework-golden-tests.yml @@ -65,9 +65,9 @@ jobs: - 'framework/**' - '.github/workflows/framework-golden-tests.yml' - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: 1.22.8 + go-version: 1.23 - name: Cache Go modules uses: actions/cache@v3 with: diff --git a/.github/workflows/framework.yml b/.github/workflows/framework.yml index cf5227020..97ea9ea85 100644 --- a/.github/workflows/framework.yml +++ b/.github/workflows/framework.yml @@ -36,9 +36,9 @@ jobs: src: - 'framework/**' - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: 1.22.8 + go-version: 1.23 - name: Cache Go modules uses: actions/cache@v4 with: diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 829fa2793..a20faa1f9 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -56,6 +56,8 @@ jobs: path: ./tools/asciitable/ - name: workflowresultparser path: ./tools/workflowresultparser/ + - name: parrotserver + path: ./parrotserver/ steps: - name: Check out Code uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 diff --git a/.github/workflows/parrotserver-release.yml b/.github/workflows/parrotserver-release.yml new file mode 100644 index 000000000..088cd0913 --- /dev/null +++ b/.github/workflows/parrotserver-release.yml @@ -0,0 +1,33 @@ +name: Parrotserver Release + +on: + push: + tags: + - parrotserver/v* + +jobs: + release: + name: Build and Release + runs-on: ubuntu-latest + environment: integration + steps: + - name: Checkout repo + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + aws-region: ${{ secrets.QA_AWS_REGION }} + role-to-assume: ${{ secrets.QA_AWS_ROLE_TO_ASSUME }} + role-duration-seconds: 600 + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 + with: + mask-password: 'true' + env: + AWS_REGION: ${{ secrets.QA_AWS_REGION }} + - name: Build and release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGE_PREFIX: ${{ secrets.QA_AWS_ACCOUNT_NUMBER }}.dkr.ecr.${{ secrets.QA_AWS_REGION }}.amazonaws.com/parrotserver + IMAGE_TAG: ${{ github.ref_name}} + run: goreleaser release --clean -f ./parrotserver/.goreleaser.yml diff --git a/.github/workflows/rc-breaking-changes.yaml b/.github/workflows/rc-breaking-changes.yaml index a36fcc26d..d19702925 100644 --- a/.github/workflows/rc-breaking-changes.yaml +++ b/.github/workflows/rc-breaking-changes.yaml @@ -16,10 +16,10 @@ jobs: with: fetch-depth: 0 fetch-tags: true - - name: Set up Go 1.22.6 + - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.22.6' + go-version: 1.23 - name: Install gorelease tool run: | go install golang.org/x/exp/cmd/gorelease@latest diff --git a/.github/workflows/release-go-module.yml b/.github/workflows/release-go-module.yml index 0af28dba8..1a85dbd2a 100644 --- a/.github/workflows/release-go-module.yml +++ b/.github/workflows/release-go-module.yml @@ -69,7 +69,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.22.6' + go-version: 1.23 - name: Install gorelease tool run: | go install golang.org/x/exp/cmd/gorelease@latest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d595356b5..97ff662e3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -26,6 +26,8 @@ jobs: path: ./tools/flakeguard/ - name: workflowresultparser path: ./tools/workflowresultparser/ + - name: parrotserver + path: ./parrotserver/ runs-on: ubuntu-latest name: ${{ matrix.project.name }} unit tests steps: diff --git a/.gitignore b/.gitignore index 4073b8808..288e6a1d4 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,6 @@ __debug* .tool-versions import_keys_test.go -tag.py \ No newline at end of file +tag.py + +parrotserver/*.json \ No newline at end of file diff --git a/parrotserver/.dockerignore b/parrotserver/.dockerignore new file mode 100644 index 000000000..fdca67dcb --- /dev/null +++ b/parrotserver/.dockerignore @@ -0,0 +1,13 @@ +Dockerfile +*.md +*.log +.gitignore +.golangci-lint.yml +.goreleaser.yml +.pre-commit-config.yaml +*_test.go +LICENSE +.vscode/ +dist/ +.github/ +save.json \ No newline at end of file diff --git a/parrotserver/.goreleaser.yaml b/parrotserver/.goreleaser.yaml new file mode 100644 index 000000000..f663c839b --- /dev/null +++ b/parrotserver/.goreleaser.yaml @@ -0,0 +1,58 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json +version: 1 +project_name: parrotserver + +monorepo: + tag_prefix: parrotserver/ + dir: parrotserver + +env: + - IMG_PRE={{ if index .Env "IMAGE_PREFIX" }}{{ .Env.IMAGE_PREFIX }}{{ else }}local{{ end }} + - TAG={{ if index .Env "IMAGE_TAG" }}{{ .Env.IMAGE_TAG }}{{ else }}latest{{ end }} + +# Build settings for binaries +builds: + - id: parrotserver + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - '-s -w' + +archives: + - format: binary + +dockers: + - id: linux-amd64-parrotserver + goos: linux + goarch: amd64 + image_templates: + - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}' + - '{{ .Env.IMG_PRE }}/parrotserver:latest' + build_flag_templates: + - --platform=linux/amd64 + - --pull + - --label=org.opencontainers.image.created={{.Date}} + - --label=org.opencontainers.image.title={{.ProjectName}} + - --label=org.opencontainers.image.revision={{.FullCommit}} + - --label=org.opencontainers.image.version={{.Version}} + - id: linux-arm64-parrotserver + goos: linux + goarch: arm64 + image_templates: + - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}-arm64' + - '{{ .Env.IMG_PRE }}/parrotserver:latest-arm64' + build_flag_templates: + - --platform=linux/arm64 + - --pull + - --label=org.opencontainers.image.created={{.Date}} + - --label=org.opencontainers.image.title={{.ProjectName}} + - --label=org.opencontainers.image.revision={{.FullCommit}} + - --label=org.opencontainers.image.version={{.Version}} + +before: + hooks: + - cd parrotserver && go mod tidy \ No newline at end of file diff --git a/parrotserver/Dockerfile b/parrotserver/Dockerfile new file mode 100644 index 000000000..0c4dfee03 --- /dev/null +++ b/parrotserver/Dockerfile @@ -0,0 +1,3 @@ +FROM scratch +COPY parrotserver /parrotserver +ENTRYPOINT [ "parrotserver" ] \ No newline at end of file diff --git a/parrotserver/Makefile b/parrotserver/Makefile new file mode 100644 index 000000000..01a41d64f --- /dev/null +++ b/parrotserver/Makefile @@ -0,0 +1,11 @@ +.PHONY: lint +lint: + golangci-lint --color=always run ./... --fix -v + +.PHONY: test_unit +test_unit: + go test ./... + +.PHONY: bench +bench: + go test -bench=. -run=^$$ ./... \ No newline at end of file diff --git a/parrotserver/README.md b/parrotserver/README.md new file mode 100644 index 000000000..3bcbfc4da --- /dev/null +++ b/parrotserver/README.md @@ -0,0 +1,84 @@ +# Parrot Server + +A simple, high-performing mockserver that can dynamically build new routes with customized responses, parroting back whatever you tell it to. + +## Use + +Call the `/register` endpoint to define a route. + +### Curl + +```sh +curl -X POST http://localhost:8080/register -d '{ + "method": "GET", + "path": "/hello", + "response": "{\"message\": \"Hello, world!\"}", + "status_code": 200, + "content_type": "application/json" +}' -H "Content-Type: application/json" +``` + +### Go and [Resty](https://github.com/go-resty/resty) + +```go +client := resty.New() + +route := map[string]any{ + "method": "GET", + "path": "/hello", + "response": "{\"message\":\"Hello, world!\"}", + "status_code": 200, + "content_type": "application/json", +} + +resp, _ := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(route). + Post("http://localhost:8080/register") +``` + +You can now call your endpoint and receive the JSON response back. + +```sh +curl -X GET http://localhost:8080/hello -H "Content-Type: application/json" +# {"message":"Hello, world!"} +``` + +## Configure + +Config is through environment variables. + +| **Environment Variable** | **Description** | **Default Value** | +| ------------------------ | -------------------------------------------------------------- | ----------------- | +| `LOG_LEVEL` | Controls the logging level (`debug`, `info`, `warn`, `error`). | `debug` | +| `SAVE_FILE` | Path to the file where routes are saved and loaded. | `save.json` | + +## Run + +```sh +go run . +``` + +## Test + +```sh +go test -cover -race ./... +``` + +## Benchmark + +```sh +LOG_LEVEL=disabled go test -bench=. -benchmem -run=^$ +``` + +Benchmark run on an Apple M3 Max. + +```sh +goos: darwin +goarch: arm64 +pkg: github.com/smartcontractkit/chainlink-testing-framework/parrotserver +BenchmarkRegisterRoute-14 604978 1967 ns/op 6263 B/op 29 allocs/op +BenchmarkRouteResponse-14 16561670 70.62 ns/op 80 B/op 1 allocs/op +BenchmarkSaveRoutes-14 1245 956784 ns/op 636042 B/op 2014 allocs/op +BenchmarkLoadRoutes-14 1020 1185990 ns/op 348919 B/op 9020 allocs/op +``` \ No newline at end of file diff --git a/parrotserver/cmd/main.go b/parrotserver/cmd/main.go new file mode 100644 index 000000000..e085e53c7 --- /dev/null +++ b/parrotserver/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/smartcontractkit/parrotserver" +) + +var config *parrotserver.Config + +func init() { + zerolog.TimeFieldFormat = time.RFC3339Nano + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: "15:04:05.00", // hh:mm:ss.ss format + }) + config = readConfig() + level := zerolog.DebugLevel + if config.LogLevel != "" { + l, err := zerolog.ParseLevel(config.LogLevel) + if err != nil { + fmt.Printf("Invalid log level '%s'\n", config.LogLevel) + os.Exit(1) + } + level = l + } + log.Logger = log.Logger.Level(level).With().Timestamp().Logger() +} + +func start() int { + defer func() { + if err := save(); err != nil { + log.Error().Err(err).Msg("Failed to save configuration") + } + }() + + http.HandleFunc("/register", registerRouteHandler) + http.HandleFunc("/", dynamicHandler) + + server := &http.Server{ + Addr: ":8080", + Handler: nil, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + } + + log.Info().Int("port", 8080).Interface("config", config).Msg("Parrot server started") + if err := server.ListenAndServe(); err != nil { + log.Error().Err(err).Msg("Server stopped") + return 1 + } + return 0 +} + +func main() { + os.Exit(start()) +} diff --git a/parrotserver/go.mod b/parrotserver/go.mod new file mode 100644 index 000000000..c7e08a806 --- /dev/null +++ b/parrotserver/go.mod @@ -0,0 +1,17 @@ +module github.com/smartcontractkit/parrotserver + +go 1.23.4 + +require ( + github.com/rs/zerolog v1.33.0 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/parrotserver/go.sum b/parrotserver/go.sum new file mode 100644 index 000000000..dc4f49555 --- /dev/null +++ b/parrotserver/go.sum @@ -0,0 +1,25 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/parrotserver/server.go b/parrotserver/server.go new file mode 100644 index 000000000..06848f0e4 --- /dev/null +++ b/parrotserver/server.go @@ -0,0 +1,235 @@ +package parrotserver + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// Route holds information about the mock route configuration +type Route struct { + // Method is the HTTP method to match + Method string `json:"method"` + // Path is the URL path to match + Path string `json:"path"` + // Response is the static JSON response to return when called + Response any `json:"response"` + // Handler is the dynamic handler function to use when called + Handler http.HandlerFunc `json:"-"` + // StatusCode is the HTTP status code to return when called + StatusCode int `json:"status_code"` + // ContentType is the Content-Type header to return the response with + ContentType string `json:"content_type"` +} + +// ParrotServer is a mock HTTP server that can register and respond to dynamic routes +type ParrotServer struct { + port int + saveFile string + l zerolog.Logger + + server *http.Server + routes map[string]Route // Store routes based on "Method:Path" keys + routesMu sync.RWMutex +} + +// ParrotServerOption defines functional options for configuring the ParrotServer +type ParrotServerOption func(*ParrotServer) error + +// WithPort sets the port for the ParrotServer to run on +func WithPort(port int) ParrotServerOption { + return func(s *ParrotServer) error { + if port <= 0 { + return fmt.Errorf("invalid port: %d", port) + } + s.port = port + return nil + } +} + +func WithLogLevel(logLevel string) ParrotServerOption { + return func(s *ParrotServer) error { + level, err := zerolog.ParseLevel(logLevel) + if err != nil { + return fmt.Errorf("invalid log level: %s", logLevel) + } + s.l = s.l.Level(level) + return nil + } +} + +// WithSaveFile sets the file to save the routes to +func WithSaveFile(saveFile string) ParrotServerOption { + return func(s *ParrotServer) error { + if saveFile == "" { + return fmt.Errorf("invalid save file name: %s", saveFile) + } + s.saveFile = saveFile + return nil + } +} + +// WithRoutes sets the initial routes for the ParrotServer +func WithRoutes(routes map[string]Route) ParrotServerOption { + return func(s *ParrotServer) error { + for k, v := range routes { + if v.Path == "" || v.Path == "/" || v.Path == "/register" { + return fmt.Errorf("invalid route path: %s", v.Path) + } + if v.Method == "" { + return fmt.Errorf("invalid route method: %s", v.Method) + } + s.routes[k] = v + } + return nil + } +} + +// New creates a new HTTP server with the dynamic route handling. +func New(options ...ParrotServerOption) (*ParrotServer, error) { + p := &ParrotServer{ + port: 8080, + saveFile: "routes.json", + l: zerolog.New(os.Stderr).With(). + Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}), + + routes: make(map[string]Route), + routesMu: sync.RWMutex{}, + } + + for _, option := range options { + if err := option(p); err != nil { + return nil, err + } + } + + mux := http.NewServeMux() + mux.HandleFunc("/register", p.registerRouteHandler) + mux.HandleFunc("/", p.dynamicHandler) + + p.server = &http.Server{ + Addr: fmt.Sprintf(":%d", p.port), + Handler: mux, + } + + log.Info().Int("port", p.port).Str("saveFile", p.saveFile).Msg("Parrot server started") + return p, nil +} + +// registerRouteHandler handles the dynamic route registration. +func (p *ParrotServer) registerRouteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) + return + } + + var route Route + if err := json.NewDecoder(r.Body).Decode(&route); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if route.Method == "" || route.Path == "" { + http.Error(w, "Method and Path are required", http.StatusBadRequest) + return + } + + p.routesMu.Lock() + p.routes[route.Method+":"+route.Path] = route + p.routesMu.Unlock() + + w.WriteHeader(http.StatusCreated) + log.Info().Str("Path", route.Path).Str("Method", route.Method).Msg("Route registered") +} + +// dynamicHandler handles all incoming requests and responds based on the registered routes. +func (p *ParrotServer) dynamicHandler(w http.ResponseWriter, r *http.Request) { + p.routesMu.RLock() + route, exists := p.routes[r.Method+":"+r.URL.Path] + p.routesMu.RUnlock() + + if !exists { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", route.ContentType) + w.WriteHeader(route.StatusCode) + _, err := io.WriteString(w, route.Response) + if err != nil { + http.Error(w, "Failed to write response", http.StatusInternalServerError) + } +} + +// load loads all registered routes from a file. +func load() error { + if _, err := os.Stat(config.SaveFile); os.IsNotExist(err) { + log.Debug().Str("Save File", config.SaveFile).Msg("No routes to load") + return nil + } + + start := time.Now() + log.Debug().Str("Save File", config.SaveFile).Msg("Loading routes") + + routesMu.Lock() + defer routesMu.Unlock() + + data, err := os.ReadFile(config.SaveFile) + if err != nil { + return fmt.Errorf("failed to read routes from file: %w", err) + } + + if err = json.Unmarshal(data, &routes); err != nil { + return fmt.Errorf("failed to unmarshal routes: %w", err) + } + + log.Debug().Str("Save File", config.SaveFile).Int("Number", len(routes)).Str("Duration", time.Since(start).String()).Msg("Routes loaded") + return nil +} + +// save saves all registered routes to a file. +func save() error { + start := time.Now() + log.Debug().Str("Save File", config.SaveFile).Msg("Saving routes") + + routesMu.Lock() + defer routesMu.Unlock() + + jsonData, err := json.MarshalIndent(routes, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal routes: %w", err) + } + + if err = os.WriteFile(config.SaveFile, jsonData, 0644); err != nil { //nolint:gosec + return fmt.Errorf("failed to write routes to file: %w", err) + } + + log.Debug().Str("Save File", config.SaveFile).Str("Duration", time.Since(start).String()).Msg("Routes saved") + return nil +} + +func (p *ParrotServer) RegisterRoute(route Route) { + routesMu.Lock() + routes[route.Method+":"+route.Path] = route + routesMu.Unlock() +} + +func CallRoute(method, path string) (int, string, string) { + routesMu.RLock() + route, exists := routes[method+":"+path] + routesMu.RUnlock() + + if !exists { + return http.StatusNotFound, "", "" + } + + return route.StatusCode, route.ContentType, route.Response +} diff --git a/parrotserver/server_test.go b/parrotserver/server_test.go new file mode 100644 index 000000000..90ad4d30c --- /dev/null +++ b/parrotserver/server_test.go @@ -0,0 +1,237 @@ +package parrotserver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper function to register a new route +func registerRoute(tb testing.TB, route Route) { + tb.Helper() + + body, _ := json.Marshal(route) + req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + registerRouteHandler(rec, req) + + resp := rec.Result() + tb.Cleanup(func() { + resp.Body.Close() + }) + require.Equal(tb, http.StatusCreated, resp.StatusCode) +} + +func TestRegisterRoute(t *testing.T) { + t.Parallel() + + route := Route{ + Method: http.MethodGet, + Path: "/test", + Response: "{\"message\":\"Test successful\"}", + StatusCode: 200, + ContentType: "application/json", + } + + registerRoute(t, route) +} + +func TestRegisteredRoute(t *testing.T) { + t.Parallel() + + routes := []Route{ + { + Method: http.MethodPost, + Path: "/hello", + Response: "{\"message\":\"Hello, world!\"}", + StatusCode: 200, + ContentType: "application/json", + }, + { + Method: http.MethodPost, + Path: "/goodbye", + Response: "{\"message\":\"Goodbye, world!\"}", + StatusCode: 201, + ContentType: "application/json", + }, + } + for _, r := range routes { + route := r + t.Run(route.Method+":"+route.Path, func(t *testing.T) { + t.Parallel() + + registerRoute(t, route) + + req := httptest.NewRequest(route.Method, route.Path, nil) + rec := httptest.NewRecorder() + dynamicHandler(rec, req) + resp := rec.Result() + + assert.Equal(t, resp.StatusCode, route.StatusCode) + assert.Equal(t, resp.Header.Get("Content-Type"), route.ContentType) + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, string(body), route.Response) + resp.Body.Close() + }) + } +} + +func TestUnregisteredRoute(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/unregistered", nil) + rec := httptest.NewRecorder() + + dynamicHandler(rec, req) + resp := rec.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestSaveLoad(t *testing.T) { + routes := []Route{ + { + Method: "GET", + Path: "/hello", + Response: "{\"message\":\"Hello, world!\"}", + StatusCode: 200, + ContentType: "application/json", + }, + { + Method: "Post", + Path: "/goodbye", + Response: "{\"message\":\"Goodbye, world!\"}", + StatusCode: 201, + ContentType: "application/json", + }, + } + + for _, route := range routes { + registerRoute(t, route) + } + + t.Cleanup(func() { + os.Remove("save.json") + }) + + err := save() + require.NoError(t, err) + require.FileExists(t, "save.json") + + err = load() + require.NoError(t, err) + + for _, route := range routes { + req := httptest.NewRequest(route.Method, route.Path, nil) + rec := httptest.NewRecorder() + + dynamicHandler(rec, req) + resp := rec.Result() + + assert.Equal(t, resp.StatusCode, route.StatusCode) + assert.Equal(t, resp.Header.Get("Content-Type"), route.ContentType) + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, string(body), route.Response) + resp.Body.Close() + } +} + +func BenchmarkRegisterRoute(b *testing.B) { + route := Route{ + Method: "GET", + Path: "/bench", + Response: "Benchmark Response", + StatusCode: 200, + ContentType: "text/plain", + } + + for i := 0; i < b.N; i++ { + body, _ := json.Marshal(route) + req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewReader(body)) + rec := httptest.NewRecorder() + registerRouteHandler(rec, req) + } +} + +func BenchmarkRouteResponse(b *testing.B) { + route := Route{ + Method: "GET", + Path: "/bench", + Response: "Benchmark Response", + StatusCode: 200, + ContentType: "text/plain", + } + registerRoute(b, route) + req := httptest.NewRequest(route.Method, route.Path, nil) + rec := httptest.NewRecorder() + b.ResetTimer() + for i := 0; i < b.N; i++ { + dynamicHandler(rec, req) + } +} + +func BenchmarkSaveRoutes(b *testing.B) { + for i := 0; i < 1000; i++ { + route := Route{ + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + Response: fmt.Sprintf("{\"message\":\"Response %d\"}", i), + StatusCode: 200, + ContentType: "text/plain", + } + routes[route.Method+":"+route.Path] = route + } + + filename := "bench_routes.json" + b.Cleanup(func() { + os.Remove(filename) + }) + + b.ResetTimer() // Start measuring time + for i := 0; i < b.N; i++ { + if err := save(); err != nil { + b.Fatalf("SaveRoutes failed: %v", err) + } + } +} + +func BenchmarkLoadRoutes(b *testing.B) { + filename := "bench_routes.json" + b.Cleanup(func() { + os.Remove(filename) + }) + + benchmarkRoutes := make(map[string]Route) + for i := 0; i < 1000; i++ { + route := Route{ + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + Response: fmt.Sprintf("{\"message\":\"Response %d\"}", i), + StatusCode: 200, + ContentType: "text/plain", + } + benchmarkRoutes[route.Method+":"+route.Path] = route + } + data, _ := json.MarshalIndent(benchmarkRoutes, "", " ") + if err := os.WriteFile(filename, data, 0644); err != nil { //nolint:gosec + b.Fatalf("Failed to write benchmark file: %v", err) + } + + b.ResetTimer() // Start measuring time + for i := 0; i < b.N; i++ { + if err := load(); err != nil { + b.Fatalf("Load failed: %v", err) + } + } +} From 44293249bc5ac0ae544869d18d2f6d25980272e2 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 10 Jan 2025 16:18:52 -0500 Subject: [PATCH 02/30] WIP --- parrotserver/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parrotserver/server.go b/parrotserver/server.go index 06848f0e4..def9b7fb8 100644 --- a/parrotserver/server.go +++ b/parrotserver/server.go @@ -217,8 +217,8 @@ func save() error { } func (p *ParrotServer) RegisterRoute(route Route) { - routesMu.Lock() - routes[route.Method+":"+route.Path] = route + p.routesMu.Lock() + p.routes[route.Method+":"+route.Path] = route routesMu.Unlock() } From 0da43591935dd073260f08388d9d3782eb57f89d Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Tue, 14 Jan 2025 16:09:44 -0500 Subject: [PATCH 03/30] First draft and rename --- parrot/.dockerignore | 13 ++ parrot/.goreleaser.yaml | 58 +++++++ parrot/Dockerfile | 3 + parrot/Makefile | 23 +++ parrot/README.md | 84 ++++++++++ parrot/cmd/main.go | 70 ++++++++ parrot/go.mod | 20 +++ parrot/go.sum | 33 ++++ parrot/parrot.go | 355 ++++++++++++++++++++++++++++++++++++++++ parrot/parrot_test.go | 244 +++++++++++++++++++++++++++ 10 files changed, 903 insertions(+) create mode 100644 parrot/.dockerignore create mode 100644 parrot/.goreleaser.yaml create mode 100644 parrot/Dockerfile create mode 100644 parrot/Makefile create mode 100644 parrot/README.md create mode 100644 parrot/cmd/main.go create mode 100644 parrot/go.mod create mode 100644 parrot/go.sum create mode 100644 parrot/parrot.go create mode 100644 parrot/parrot_test.go diff --git a/parrot/.dockerignore b/parrot/.dockerignore new file mode 100644 index 000000000..fdca67dcb --- /dev/null +++ b/parrot/.dockerignore @@ -0,0 +1,13 @@ +Dockerfile +*.md +*.log +.gitignore +.golangci-lint.yml +.goreleaser.yml +.pre-commit-config.yaml +*_test.go +LICENSE +.vscode/ +dist/ +.github/ +save.json \ No newline at end of file diff --git a/parrot/.goreleaser.yaml b/parrot/.goreleaser.yaml new file mode 100644 index 000000000..f663c839b --- /dev/null +++ b/parrot/.goreleaser.yaml @@ -0,0 +1,58 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json +version: 1 +project_name: parrotserver + +monorepo: + tag_prefix: parrotserver/ + dir: parrotserver + +env: + - IMG_PRE={{ if index .Env "IMAGE_PREFIX" }}{{ .Env.IMAGE_PREFIX }}{{ else }}local{{ end }} + - TAG={{ if index .Env "IMAGE_TAG" }}{{ .Env.IMAGE_TAG }}{{ else }}latest{{ end }} + +# Build settings for binaries +builds: + - id: parrotserver + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - '-s -w' + +archives: + - format: binary + +dockers: + - id: linux-amd64-parrotserver + goos: linux + goarch: amd64 + image_templates: + - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}' + - '{{ .Env.IMG_PRE }}/parrotserver:latest' + build_flag_templates: + - --platform=linux/amd64 + - --pull + - --label=org.opencontainers.image.created={{.Date}} + - --label=org.opencontainers.image.title={{.ProjectName}} + - --label=org.opencontainers.image.revision={{.FullCommit}} + - --label=org.opencontainers.image.version={{.Version}} + - id: linux-arm64-parrotserver + goos: linux + goarch: arm64 + image_templates: + - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}-arm64' + - '{{ .Env.IMG_PRE }}/parrotserver:latest-arm64' + build_flag_templates: + - --platform=linux/arm64 + - --pull + - --label=org.opencontainers.image.created={{.Date}} + - --label=org.opencontainers.image.title={{.ProjectName}} + - --label=org.opencontainers.image.revision={{.FullCommit}} + - --label=org.opencontainers.image.version={{.Version}} + +before: + hooks: + - cd parrotserver && go mod tidy \ No newline at end of file diff --git a/parrot/Dockerfile b/parrot/Dockerfile new file mode 100644 index 000000000..0c4dfee03 --- /dev/null +++ b/parrot/Dockerfile @@ -0,0 +1,3 @@ +FROM scratch +COPY parrotserver /parrotserver +ENTRYPOINT [ "parrotserver" ] \ No newline at end of file diff --git a/parrot/Makefile b/parrot/Makefile new file mode 100644 index 000000000..2397487b2 --- /dev/null +++ b/parrot/Makefile @@ -0,0 +1,23 @@ +.PHONY: lint +lint: + golangci-lint --color=always run ./... --fix -v + +.PHONY: test +test: + go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest + set -euo pipefail + go test -json -cover -coverprofile cover.out -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt + +.PHONY: test_race +test_race: + go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest + set -euo pipefail + go test -json -cover -count=1 -race -coverprofile cover.out -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt + +.PHONY: test_unit +test_unit: + go test -coverprofile cover.out ./... + +.PHONY: bench +bench: + go test -bench=. -run=^$$ ./... \ No newline at end of file diff --git a/parrot/README.md b/parrot/README.md new file mode 100644 index 000000000..3bcbfc4da --- /dev/null +++ b/parrot/README.md @@ -0,0 +1,84 @@ +# Parrot Server + +A simple, high-performing mockserver that can dynamically build new routes with customized responses, parroting back whatever you tell it to. + +## Use + +Call the `/register` endpoint to define a route. + +### Curl + +```sh +curl -X POST http://localhost:8080/register -d '{ + "method": "GET", + "path": "/hello", + "response": "{\"message\": \"Hello, world!\"}", + "status_code": 200, + "content_type": "application/json" +}' -H "Content-Type: application/json" +``` + +### Go and [Resty](https://github.com/go-resty/resty) + +```go +client := resty.New() + +route := map[string]any{ + "method": "GET", + "path": "/hello", + "response": "{\"message\":\"Hello, world!\"}", + "status_code": 200, + "content_type": "application/json", +} + +resp, _ := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(route). + Post("http://localhost:8080/register") +``` + +You can now call your endpoint and receive the JSON response back. + +```sh +curl -X GET http://localhost:8080/hello -H "Content-Type: application/json" +# {"message":"Hello, world!"} +``` + +## Configure + +Config is through environment variables. + +| **Environment Variable** | **Description** | **Default Value** | +| ------------------------ | -------------------------------------------------------------- | ----------------- | +| `LOG_LEVEL` | Controls the logging level (`debug`, `info`, `warn`, `error`). | `debug` | +| `SAVE_FILE` | Path to the file where routes are saved and loaded. | `save.json` | + +## Run + +```sh +go run . +``` + +## Test + +```sh +go test -cover -race ./... +``` + +## Benchmark + +```sh +LOG_LEVEL=disabled go test -bench=. -benchmem -run=^$ +``` + +Benchmark run on an Apple M3 Max. + +```sh +goos: darwin +goarch: arm64 +pkg: github.com/smartcontractkit/chainlink-testing-framework/parrotserver +BenchmarkRegisterRoute-14 604978 1967 ns/op 6263 B/op 29 allocs/op +BenchmarkRouteResponse-14 16561670 70.62 ns/op 80 B/op 1 allocs/op +BenchmarkSaveRoutes-14 1245 956784 ns/op 636042 B/op 2014 allocs/op +BenchmarkLoadRoutes-14 1020 1185990 ns/op 348919 B/op 9020 allocs/op +``` \ No newline at end of file diff --git a/parrot/cmd/main.go b/parrot/cmd/main.go new file mode 100644 index 000000000..c8195a331 --- /dev/null +++ b/parrot/cmd/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/rs/zerolog/log" + "github.com/smartcontractkit/chainlink-testing-framework/parrot" + "github.com/spf13/cobra" +) + +func main() { + var ( + port int + debug bool + trace bool + silent bool + json bool + ) + + rootCmd := &cobra.Command{ + Use: "parrotserver", + Short: "a server that can set and parrrot back dynamic requests", + RunE: func(cmd *cobra.Command, args []string) error { + options := []parrot.ServerOption{parrot.WithPort(port)} + if debug { + options = append(options, parrot.WithDebug()) + } + if trace { + options = append(options, parrot.WithTrace()) + } + if silent { + options = append(options, parrot.Silent()) + } + if json { + options = append(options, parrot.WithJSONLogs()) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + p, err := parrot.Wake(options...) + if err != nil { + return err + } + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + <-c + err = p.Shutdown(ctx) + if err != nil { + log.Error().Err(err).Msg("error shutting down server") + } + return nil + }, + } + + rootCmd.Flags().IntVarP(&port, "port", "p", 0, "Port to run the parrot on") + rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug output") + rootCmd.Flags().BoolVarP(&trace, "trace", "t", false, "Enable trace and debug output") + rootCmd.Flags().BoolVarP(&silent, "silent", "s", false, "Disable all output") + rootCmd.Flags().BoolVarP(&json, "json", "j", false, "Output logs in JSON format") + + if err := rootCmd.Execute(); err != nil { + log.Error().Err(err).Msg("error executing command") + os.Exit(1) + } +} diff --git a/parrot/go.mod b/parrot/go.mod new file mode 100644 index 000000000..51a8e99c9 --- /dev/null +++ b/parrot/go.mod @@ -0,0 +1,20 @@ +module github.com/smartcontractkit/chainlink-testing-framework/parrot + +go 1.23.4 + +require ( + github.com/rs/zerolog v1.33.0 + github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/parrot/go.sum b/parrot/go.sum new file mode 100644 index 000000000..3e73915d5 --- /dev/null +++ b/parrot/go.sum @@ -0,0 +1,33 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/parrot/parrot.go b/parrot/parrot.go new file mode 100644 index 000000000..c547616bb --- /dev/null +++ b/parrot/parrot.go @@ -0,0 +1,355 @@ +package parrot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/rs/zerolog" +) + +// Route holds information about the mock route configuration +type Route struct { + // Method is the HTTP method to match + Method string `json:"method"` + // Path is the URL path to match + Path string `json:"path"` + // Handler is the dynamic handler function to use when called + Handler http.HandlerFunc `json:"-"` + // ResponseBody is the static JSON response to return when called + ResponseBody any `json:"response_body"` + // ResponseStatusCode is the HTTP status code to return when called + ResponseStatusCode int `json:"response_status_code"` + // ResponseContentType is the Content-Type header to return the response with + ResponseContentType string `json:"response_content_type"` +} + +// Server is a mock HTTP server that can register and respond to dynamic routes +type Server struct { + port int + host string + address string + saveFile string + log zerolog.Logger + + server *http.Server + routes map[string]*Route // Store routes based on "Method:Path" keys + routesMu sync.RWMutex +} + +// ServerOption defines functional options for configuring the ParrotServer +type ServerOption func(*Server) error + +// WithPort sets the port for the ParrotServer to run on +func WithPort(port int) ServerOption { + return func(s *Server) error { + if port == 0 { + s.log.Debug().Msg("No port specified, using random port") + } else if port < 0 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } + s.port = port + s.log.Debug().Int("port", port).Msg("Setting port") + return nil + } +} + +// WithDebug sets the log level to debug +func WithDebug() ServerOption { + return func(s *Server) error { + s.log = s.log.Level(zerolog.DebugLevel) + s.log.Debug().Msg("Setting log level to debug") + return nil + } +} + +// WithTrace sets the log level to trace +func WithTrace() ServerOption { + return func(s *Server) error { + s.log = s.log.Level(zerolog.TraceLevel) + s.log.Debug().Msg("Setting log level to trace") + return nil + } +} + +// WithLogger sets the logger for the ParrotServer +func WithLogger(l zerolog.Logger) ServerOption { + return func(s *Server) error { + s.log = l + s.log.Debug().Msg("Setting custom logger") + return nil + } +} + +// Silent sets the logger to a no-op logger +func Silent() ServerOption { + return func(s *Server) error { + s.log = zerolog.Nop() + return nil + } +} + +// WithJSONLogs sets the logger to output JSON logs +func WithJSONLogs() ServerOption { + return func(s *Server) error { + s.log = s.log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}) + s.log.Debug().Msg("Setting log output to JSON") + return nil + } +} + +// WithSaveFile sets the file to save the routes to +func WithSaveFile(saveFile string) ServerOption { + return func(s *Server) error { + if saveFile == "" { + return fmt.Errorf("invalid save file name: %s", saveFile) + } + s.saveFile = saveFile + s.log.Debug().Str("file", saveFile).Msg("Setting save file") + return nil + } +} + +// WithRoutes sets the initial routes for the Parrot +func WithRoutes(routes []*Route) ServerOption { + return func(s *Server) error { + for _, route := range routes { + if err := s.Register(route); err != nil { + return fmt.Errorf("failed to register route: %w", err) + } + s.log.Debug().Str("path", route.Path).Str("method", route.Method).Msg("Pre-registered route") + } + return nil + } +} + +// Wake creates a new Parrot server with dynamic route handling +func Wake(options ...ServerOption) (*Server, error) { + p := &Server{ + port: 0, + saveFile: "save.json", + log: zerolog.New(os.Stderr).Level(zerolog.InfoLevel).With(). + Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}), + + routes: make(map[string]*Route), + routesMu: sync.RWMutex{}, + } + + for _, option := range options { + if err := option(p); err != nil { + return nil, err + } + } + + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", p.port)) + if err != nil { + return nil, fmt.Errorf("failed to start listener: %w", err) + } + host, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + return nil, fmt.Errorf("failed to split host and port: %w", err) + } + p.host = host + p.address = listener.Addr().String() + p.port, err = strconv.Atoi(port) + if err != nil { + return nil, fmt.Errorf("failed to parse port: %w", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/register", p.registerRouteHandler) + mux.HandleFunc("/", p.dynamicHandler) + + p.server = &http.Server{ + ReadHeaderTimeout: 5 * time.Second, + Addr: fmt.Sprintf(":%d", p.port), + Handler: mux, + } + + if err = p.load(); err != nil { + return nil, fmt.Errorf("failed to load saved routes: %w", err) + } + + go func() { + defer func() { + if err = p.save(); err != nil { + p.log.Error().Err(err).Msg("Failed to save routes") + } + }() + + p.log.Info().Int("port", p.Port()).Str("address", p.address).Str("save file", p.saveFile).Msg("Parrot awake and ready to squawk") + if err = p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + p.log.Fatal().Err(err).Msg("Error while running server") + } + }() + + return p, nil +} + +// Shutdown gracefully shuts down the parrot server +func (p *Server) Shutdown(ctx context.Context) error { + p.log.Info().Msg("Putting cloth over the parrot's cage...") + return p.server.Shutdown(ctx) +} + +// Host returns the host the parrot is running on +func (p *Server) Host() string { + return p.host +} + +// Port returns the port the parrot is running on +func (p *Server) Port() int { + return p.port +} + +// Address returns the address the parrot is running on +func (p *Server) Address() string { + return p.address +} + +// Register adds a new route to the parrot +func (p *Server) Register(route *Route) error { + if route.Path == "" || route.Path == "/" || route.Path == "/register" { + return fmt.Errorf("invalid route path: %s", route.Path) + } + if route.Method == "" { + return fmt.Errorf("invalid route method: %s", route.Method) + } + if route.Handler == nil && route.ResponseBody == nil { + return fmt.Errorf("route must have a handler or response body") + } + if route.ResponseBody != nil { + if _, err := json.Marshal(route.ResponseBody); err != nil { + return fmt.Errorf("failed to marshal response body: %w", err) + } + } + p.routesMu.Lock() + defer p.routesMu.Unlock() + p.routes[route.Method+":"+route.Path] = route + + return nil +} + +// Unregister removes a route from the parrot +func (p *Server) Unregister(method, path string) { + p.routesMu.Lock() + defer p.routesMu.Unlock() + delete(p.routes, method+":"+path) +} + +// Call makes a request to the parrot server +func (p *Server) Call(method, path string) (*http.Response, error) { + req, err := http.NewRequest(method, filepath.Join(p.server.Addr, path), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{} + return client.Do(req) +} + +// registerRouteHandler handles the dynamic route registration. +func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) + return + } + + var route Route + if err := json.NewDecoder(r.Body).Decode(&route); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if route.Method == "" || route.Path == "" { + http.Error(w, "Method and Path are required", http.StatusBadRequest) + return + } + + p.routesMu.Lock() + p.routes[route.Method+":"+route.Path] = &route + p.routesMu.Unlock() + + w.WriteHeader(http.StatusCreated) + p.log.Info().Str("Path", route.Path).Str("Method", route.Method).Msg("Route registered") +} + +// dynamicHandler handles all incoming requests and responds based on the registered routes. +func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { + p.routesMu.RLock() + route, exists := p.routes[r.Method+":"+r.URL.Path] + p.routesMu.RUnlock() + + if !exists { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", route.ResponseContentType) + w.WriteHeader(route.ResponseStatusCode) + if err := json.NewEncoder(w).Encode(route.ResponseBody); err != nil { + http.Error(w, "Failed to marshal response into json", http.StatusInternalServerError) + } +} + +// load loads all registered routes from a file. +func (p *Server) load() error { + if _, err := os.Stat(p.saveFile); os.IsNotExist(err) { + p.log.Trace().Str("file", p.saveFile).Msg("No routes to load") + return nil + } + + p.log.Debug().Str("file", p.saveFile).Msg("Loading routes") + + data, err := os.ReadFile(p.saveFile) + if err != nil { + return fmt.Errorf("failed to read routes from file: %w", err) + } + if len(data) == 0 { + p.log.Trace().Str("file", p.saveFile).Msg("No routes to load") + return nil + } + + p.routesMu.Lock() + defer p.routesMu.Unlock() + + if err = json.Unmarshal(data, &p.routes); err != nil { + return fmt.Errorf("failed to unmarshal routes: %w", err) + } + + p.log.Info().Str("file", p.saveFile).Int("number", len(p.routes)).Msg("Loaded routes") + return nil +} + +// save saves all registered routes to a file. +func (p *Server) save() error { + if len(p.routes) == 0 { + p.log.Debug().Msg("No routes to save") + return nil + } + p.log.Debug().Str("file", p.saveFile).Msg("Saving routes") + + p.routesMu.RLock() + defer p.routesMu.RUnlock() + + jsonData, err := json.Marshal(p.routes) + if err != nil { + return fmt.Errorf("failed to marshal routes: %w", err) + } + + if err = os.WriteFile(p.saveFile, jsonData, 0644); err != nil { //nolint:gosec + return fmt.Errorf("failed to write routes to file: %w", err) + } + + p.log.Info().Str("file", p.saveFile).Msg("Saved routes") + return nil +} diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go new file mode 100644 index 000000000..d2fff6bc0 --- /dev/null +++ b/parrot/parrot_test.go @@ -0,0 +1,244 @@ +package parrot + +import ( + "fmt" + "io" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWake(t *testing.T) { + t.Parallel() + p, err := Wake() + require.NoError(t, err, "error waking parrot") + require.NotNil(t, p) +} + +func TestRegisterRoute(t *testing.T) { + t.Parallel() + p, err := Wake() + require.NoError(t, err) + + route := &Route{ + Method: http.MethodGet, + Path: "/test", + ResponseBody: "{\"message\":\"Test successful\"}", + ResponseStatusCode: 200, + ResponseContentType: "application/json", + } + + err = p.Register(route) + require.NoError(t, err, "error registering route") +} + +func TestRegisteredRoute(t *testing.T) { + t.Parallel() + + p, err := Wake() + require.NoError(t, err, "error waking parrot") + + routes := []*Route{ + { + Method: http.MethodPost, + Path: "/hello", + ResponseBody: "{\"message\":\"Hello, world!\"}", + ResponseStatusCode: 200, + ResponseContentType: "application/json", + }, + { + Method: http.MethodPost, + Path: "/goodbye", + ResponseBody: "{\"message\":\"Goodbye, world!\"}", + ResponseStatusCode: 201, + ResponseContentType: "application/json", + }, + } + + for _, r := range routes { + route := r + t.Run(route.Method+":"+route.Path, func(t *testing.T) { + t.Parallel() + + err = p.Register(route) + require.NoError(t, err, "error registering route") + + resp, err := p.Call(route.Method, route.Path) + require.NoError(t, err, "error calling parrot") + defer resp.Body.Close() + + assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) + assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, string(body), route.ResponseBody) + resp.Body.Close() + }) + } +} + +func TestUnregisteredRoute(t *testing.T) { + t.Parallel() + + p, err := Wake() + require.NoError(t, err, "error waking parrot") + + resp, err := p.Call(http.MethodGet, "/unregistered") + require.Error(t, err, "error calling parrot") + require.NotNil(t, resp, "response should not be nil") + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestSaveLoad(t *testing.T) { + t.Parallel() + + saveFile := "save_test.json" + p, err := Wake(WithSaveFile(saveFile)) + require.NoError(t, err, "error waking parrot") + + routes := []*Route{ + { + Method: "GET", + Path: "/hello", + ResponseBody: "{\"message\":\"Hello, world!\"}", + ResponseStatusCode: 200, + ResponseContentType: "application/json", + }, + { + Method: "Post", + Path: "/goodbye", + ResponseBody: "{\"message\":\"Goodbye, world!\"}", + ResponseStatusCode: 201, + ResponseContentType: "application/json", + }, + } + + for _, route := range routes { + err = p.Register(route) + require.NoError(t, err, "error registering route") + } + + t.Cleanup(func() { + os.Remove(saveFile) + }) + + err = p.save() + require.NoError(t, err) + + require.FileExists(t, saveFile) + err = p.load() + require.NoError(t, err) + + for _, route := range routes { + resp, err := p.Call(route.Method, route.Path) + require.NoError(t, err, "error calling parrot") + + assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) + assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, string(body), route.ResponseBody) + resp.Body.Close() + } +} + +func BenchmarkRegisterRoute(b *testing.B) { + p, err := Wake() + require.NoError(b, err) + + route := &Route{ + Method: "GET", + Path: "/bench", + ResponseBody: "Benchmark Response", + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := p.Register(route) + require.NoError(b, err) + } +} + +func BenchmarkRouteResponse(b *testing.B) { + p, err := Wake() + require.NoError(b, err) + + route := &Route{ + Method: "GET", + Path: "/bench", + ResponseBody: "Benchmark Response", + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + } + err = p.Register(route) + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := p.Call(route.Method, route.Path) + require.NoError(b, err) + } +} + +func BenchmarkSaveRoutes(b *testing.B) { + var ( + routes = []*Route{} + saveFile = "bench_routes.json" + ) + + for i := 0; i < 1000; i++ { + routes = append(routes, &Route{ + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + ResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + }) + } + p, err := Wake(WithRoutes(routes)) + require.NoError(b, err) + + b.Cleanup(func() { + os.Remove(saveFile) + }) + + b.ResetTimer() // Start measuring time + for i := 0; i < b.N; i++ { + err := p.save() + require.NoError(b, err) + } +} + +func BenchmarkLoadRoutes(b *testing.B) { + var ( + routes = []*Route{} + saveFile = "bench_routes.json" + ) + b.Cleanup(func() { + os.Remove(saveFile) + }) + for i := 0; i < 1000; i++ { + routes = append(routes, &Route{ + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + ResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + }) + } + p, err := Wake(WithRoutes(routes)) + require.NoError(b, err, "error waking parrot") + err = p.save() + require.NoError(b, err, "error saving routes") + + b.ResetTimer() // Start measuring time + for i := 0; i < b.N; i++ { + err := p.load() + require.NoError(b, err) + } +} From e2f838fca9569a69327b2f70aac06c2194387ebb Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Tue, 14 Jan 2025 16:10:20 -0500 Subject: [PATCH 04/30] Rename --- .gitignore | 2 +- parrotserver/.dockerignore | 13 -- parrotserver/.goreleaser.yaml | 58 --------- parrotserver/Dockerfile | 3 - parrotserver/Makefile | 11 -- parrotserver/README.md | 84 ------------ parrotserver/cmd/main.go | 63 --------- parrotserver/go.mod | 17 --- parrotserver/go.sum | 25 ---- parrotserver/server.go | 235 --------------------------------- parrotserver/server_test.go | 237 ---------------------------------- 11 files changed, 1 insertion(+), 747 deletions(-) delete mode 100644 parrotserver/.dockerignore delete mode 100644 parrotserver/.goreleaser.yaml delete mode 100644 parrotserver/Dockerfile delete mode 100644 parrotserver/Makefile delete mode 100644 parrotserver/README.md delete mode 100644 parrotserver/cmd/main.go delete mode 100644 parrotserver/go.mod delete mode 100644 parrotserver/go.sum delete mode 100644 parrotserver/server.go delete mode 100644 parrotserver/server_test.go diff --git a/.gitignore b/.gitignore index 288e6a1d4..5d806284e 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,4 @@ __debug* import_keys_test.go tag.py -parrotserver/*.json \ No newline at end of file +parrot/*.json \ No newline at end of file diff --git a/parrotserver/.dockerignore b/parrotserver/.dockerignore deleted file mode 100644 index fdca67dcb..000000000 --- a/parrotserver/.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -Dockerfile -*.md -*.log -.gitignore -.golangci-lint.yml -.goreleaser.yml -.pre-commit-config.yaml -*_test.go -LICENSE -.vscode/ -dist/ -.github/ -save.json \ No newline at end of file diff --git a/parrotserver/.goreleaser.yaml b/parrotserver/.goreleaser.yaml deleted file mode 100644 index f663c839b..000000000 --- a/parrotserver/.goreleaser.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json -version: 1 -project_name: parrotserver - -monorepo: - tag_prefix: parrotserver/ - dir: parrotserver - -env: - - IMG_PRE={{ if index .Env "IMAGE_PREFIX" }}{{ .Env.IMAGE_PREFIX }}{{ else }}local{{ end }} - - TAG={{ if index .Env "IMAGE_TAG" }}{{ .Env.IMAGE_TAG }}{{ else }}latest{{ end }} - -# Build settings for binaries -builds: - - id: parrotserver - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: - - '-s -w' - -archives: - - format: binary - -dockers: - - id: linux-amd64-parrotserver - goos: linux - goarch: amd64 - image_templates: - - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}' - - '{{ .Env.IMG_PRE }}/parrotserver:latest' - build_flag_templates: - - --platform=linux/amd64 - - --pull - - --label=org.opencontainers.image.created={{.Date}} - - --label=org.opencontainers.image.title={{.ProjectName}} - - --label=org.opencontainers.image.revision={{.FullCommit}} - - --label=org.opencontainers.image.version={{.Version}} - - id: linux-arm64-parrotserver - goos: linux - goarch: arm64 - image_templates: - - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}-arm64' - - '{{ .Env.IMG_PRE }}/parrotserver:latest-arm64' - build_flag_templates: - - --platform=linux/arm64 - - --pull - - --label=org.opencontainers.image.created={{.Date}} - - --label=org.opencontainers.image.title={{.ProjectName}} - - --label=org.opencontainers.image.revision={{.FullCommit}} - - --label=org.opencontainers.image.version={{.Version}} - -before: - hooks: - - cd parrotserver && go mod tidy \ No newline at end of file diff --git a/parrotserver/Dockerfile b/parrotserver/Dockerfile deleted file mode 100644 index 0c4dfee03..000000000 --- a/parrotserver/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM scratch -COPY parrotserver /parrotserver -ENTRYPOINT [ "parrotserver" ] \ No newline at end of file diff --git a/parrotserver/Makefile b/parrotserver/Makefile deleted file mode 100644 index 01a41d64f..000000000 --- a/parrotserver/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -.PHONY: lint -lint: - golangci-lint --color=always run ./... --fix -v - -.PHONY: test_unit -test_unit: - go test ./... - -.PHONY: bench -bench: - go test -bench=. -run=^$$ ./... \ No newline at end of file diff --git a/parrotserver/README.md b/parrotserver/README.md deleted file mode 100644 index 3bcbfc4da..000000000 --- a/parrotserver/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Parrot Server - -A simple, high-performing mockserver that can dynamically build new routes with customized responses, parroting back whatever you tell it to. - -## Use - -Call the `/register` endpoint to define a route. - -### Curl - -```sh -curl -X POST http://localhost:8080/register -d '{ - "method": "GET", - "path": "/hello", - "response": "{\"message\": \"Hello, world!\"}", - "status_code": 200, - "content_type": "application/json" -}' -H "Content-Type: application/json" -``` - -### Go and [Resty](https://github.com/go-resty/resty) - -```go -client := resty.New() - -route := map[string]any{ - "method": "GET", - "path": "/hello", - "response": "{\"message\":\"Hello, world!\"}", - "status_code": 200, - "content_type": "application/json", -} - -resp, _ := client.R(). - SetHeader("Content-Type", "application/json"). - SetBody(route). - Post("http://localhost:8080/register") -``` - -You can now call your endpoint and receive the JSON response back. - -```sh -curl -X GET http://localhost:8080/hello -H "Content-Type: application/json" -# {"message":"Hello, world!"} -``` - -## Configure - -Config is through environment variables. - -| **Environment Variable** | **Description** | **Default Value** | -| ------------------------ | -------------------------------------------------------------- | ----------------- | -| `LOG_LEVEL` | Controls the logging level (`debug`, `info`, `warn`, `error`). | `debug` | -| `SAVE_FILE` | Path to the file where routes are saved and loaded. | `save.json` | - -## Run - -```sh -go run . -``` - -## Test - -```sh -go test -cover -race ./... -``` - -## Benchmark - -```sh -LOG_LEVEL=disabled go test -bench=. -benchmem -run=^$ -``` - -Benchmark run on an Apple M3 Max. - -```sh -goos: darwin -goarch: arm64 -pkg: github.com/smartcontractkit/chainlink-testing-framework/parrotserver -BenchmarkRegisterRoute-14 604978 1967 ns/op 6263 B/op 29 allocs/op -BenchmarkRouteResponse-14 16561670 70.62 ns/op 80 B/op 1 allocs/op -BenchmarkSaveRoutes-14 1245 956784 ns/op 636042 B/op 2014 allocs/op -BenchmarkLoadRoutes-14 1020 1185990 ns/op 348919 B/op 9020 allocs/op -``` \ No newline at end of file diff --git a/parrotserver/cmd/main.go b/parrotserver/cmd/main.go deleted file mode 100644 index e085e53c7..000000000 --- a/parrotserver/cmd/main.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "os" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/smartcontractkit/parrotserver" -) - -var config *parrotserver.Config - -func init() { - zerolog.TimeFieldFormat = time.RFC3339Nano - log.Logger = log.Output(zerolog.ConsoleWriter{ - Out: os.Stderr, - TimeFormat: "15:04:05.00", // hh:mm:ss.ss format - }) - config = readConfig() - level := zerolog.DebugLevel - if config.LogLevel != "" { - l, err := zerolog.ParseLevel(config.LogLevel) - if err != nil { - fmt.Printf("Invalid log level '%s'\n", config.LogLevel) - os.Exit(1) - } - level = l - } - log.Logger = log.Logger.Level(level).With().Timestamp().Logger() -} - -func start() int { - defer func() { - if err := save(); err != nil { - log.Error().Err(err).Msg("Failed to save configuration") - } - }() - - http.HandleFunc("/register", registerRouteHandler) - http.HandleFunc("/", dynamicHandler) - - server := &http.Server{ - Addr: ":8080", - Handler: nil, - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 15 * time.Second, - } - - log.Info().Int("port", 8080).Interface("config", config).Msg("Parrot server started") - if err := server.ListenAndServe(); err != nil { - log.Error().Err(err).Msg("Server stopped") - return 1 - } - return 0 -} - -func main() { - os.Exit(start()) -} diff --git a/parrotserver/go.mod b/parrotserver/go.mod deleted file mode 100644 index c7e08a806..000000000 --- a/parrotserver/go.mod +++ /dev/null @@ -1,17 +0,0 @@ -module github.com/smartcontractkit/parrotserver - -go 1.23.4 - -require ( - github.com/rs/zerolog v1.33.0 - github.com/stretchr/testify v1.9.0 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.12.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/parrotserver/go.sum b/parrotserver/go.sum deleted file mode 100644 index dc4f49555..000000000 --- a/parrotserver/go.sum +++ /dev/null @@ -1,25 +0,0 @@ -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -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/parrotserver/server.go b/parrotserver/server.go deleted file mode 100644 index def9b7fb8..000000000 --- a/parrotserver/server.go +++ /dev/null @@ -1,235 +0,0 @@ -package parrotserver - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "sync" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -// Route holds information about the mock route configuration -type Route struct { - // Method is the HTTP method to match - Method string `json:"method"` - // Path is the URL path to match - Path string `json:"path"` - // Response is the static JSON response to return when called - Response any `json:"response"` - // Handler is the dynamic handler function to use when called - Handler http.HandlerFunc `json:"-"` - // StatusCode is the HTTP status code to return when called - StatusCode int `json:"status_code"` - // ContentType is the Content-Type header to return the response with - ContentType string `json:"content_type"` -} - -// ParrotServer is a mock HTTP server that can register and respond to dynamic routes -type ParrotServer struct { - port int - saveFile string - l zerolog.Logger - - server *http.Server - routes map[string]Route // Store routes based on "Method:Path" keys - routesMu sync.RWMutex -} - -// ParrotServerOption defines functional options for configuring the ParrotServer -type ParrotServerOption func(*ParrotServer) error - -// WithPort sets the port for the ParrotServer to run on -func WithPort(port int) ParrotServerOption { - return func(s *ParrotServer) error { - if port <= 0 { - return fmt.Errorf("invalid port: %d", port) - } - s.port = port - return nil - } -} - -func WithLogLevel(logLevel string) ParrotServerOption { - return func(s *ParrotServer) error { - level, err := zerolog.ParseLevel(logLevel) - if err != nil { - return fmt.Errorf("invalid log level: %s", logLevel) - } - s.l = s.l.Level(level) - return nil - } -} - -// WithSaveFile sets the file to save the routes to -func WithSaveFile(saveFile string) ParrotServerOption { - return func(s *ParrotServer) error { - if saveFile == "" { - return fmt.Errorf("invalid save file name: %s", saveFile) - } - s.saveFile = saveFile - return nil - } -} - -// WithRoutes sets the initial routes for the ParrotServer -func WithRoutes(routes map[string]Route) ParrotServerOption { - return func(s *ParrotServer) error { - for k, v := range routes { - if v.Path == "" || v.Path == "/" || v.Path == "/register" { - return fmt.Errorf("invalid route path: %s", v.Path) - } - if v.Method == "" { - return fmt.Errorf("invalid route method: %s", v.Method) - } - s.routes[k] = v - } - return nil - } -} - -// New creates a new HTTP server with the dynamic route handling. -func New(options ...ParrotServerOption) (*ParrotServer, error) { - p := &ParrotServer{ - port: 8080, - saveFile: "routes.json", - l: zerolog.New(os.Stderr).With(). - Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}), - - routes: make(map[string]Route), - routesMu: sync.RWMutex{}, - } - - for _, option := range options { - if err := option(p); err != nil { - return nil, err - } - } - - mux := http.NewServeMux() - mux.HandleFunc("/register", p.registerRouteHandler) - mux.HandleFunc("/", p.dynamicHandler) - - p.server = &http.Server{ - Addr: fmt.Sprintf(":%d", p.port), - Handler: mux, - } - - log.Info().Int("port", p.port).Str("saveFile", p.saveFile).Msg("Parrot server started") - return p, nil -} - -// registerRouteHandler handles the dynamic route registration. -func (p *ParrotServer) registerRouteHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) - return - } - - var route Route - if err := json.NewDecoder(r.Body).Decode(&route); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - if route.Method == "" || route.Path == "" { - http.Error(w, "Method and Path are required", http.StatusBadRequest) - return - } - - p.routesMu.Lock() - p.routes[route.Method+":"+route.Path] = route - p.routesMu.Unlock() - - w.WriteHeader(http.StatusCreated) - log.Info().Str("Path", route.Path).Str("Method", route.Method).Msg("Route registered") -} - -// dynamicHandler handles all incoming requests and responds based on the registered routes. -func (p *ParrotServer) dynamicHandler(w http.ResponseWriter, r *http.Request) { - p.routesMu.RLock() - route, exists := p.routes[r.Method+":"+r.URL.Path] - p.routesMu.RUnlock() - - if !exists { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Type", route.ContentType) - w.WriteHeader(route.StatusCode) - _, err := io.WriteString(w, route.Response) - if err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - } -} - -// load loads all registered routes from a file. -func load() error { - if _, err := os.Stat(config.SaveFile); os.IsNotExist(err) { - log.Debug().Str("Save File", config.SaveFile).Msg("No routes to load") - return nil - } - - start := time.Now() - log.Debug().Str("Save File", config.SaveFile).Msg("Loading routes") - - routesMu.Lock() - defer routesMu.Unlock() - - data, err := os.ReadFile(config.SaveFile) - if err != nil { - return fmt.Errorf("failed to read routes from file: %w", err) - } - - if err = json.Unmarshal(data, &routes); err != nil { - return fmt.Errorf("failed to unmarshal routes: %w", err) - } - - log.Debug().Str("Save File", config.SaveFile).Int("Number", len(routes)).Str("Duration", time.Since(start).String()).Msg("Routes loaded") - return nil -} - -// save saves all registered routes to a file. -func save() error { - start := time.Now() - log.Debug().Str("Save File", config.SaveFile).Msg("Saving routes") - - routesMu.Lock() - defer routesMu.Unlock() - - jsonData, err := json.MarshalIndent(routes, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal routes: %w", err) - } - - if err = os.WriteFile(config.SaveFile, jsonData, 0644); err != nil { //nolint:gosec - return fmt.Errorf("failed to write routes to file: %w", err) - } - - log.Debug().Str("Save File", config.SaveFile).Str("Duration", time.Since(start).String()).Msg("Routes saved") - return nil -} - -func (p *ParrotServer) RegisterRoute(route Route) { - p.routesMu.Lock() - p.routes[route.Method+":"+route.Path] = route - routesMu.Unlock() -} - -func CallRoute(method, path string) (int, string, string) { - routesMu.RLock() - route, exists := routes[method+":"+path] - routesMu.RUnlock() - - if !exists { - return http.StatusNotFound, "", "" - } - - return route.StatusCode, route.ContentType, route.Response -} diff --git a/parrotserver/server_test.go b/parrotserver/server_test.go deleted file mode 100644 index 90ad4d30c..000000000 --- a/parrotserver/server_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package parrotserver - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Helper function to register a new route -func registerRoute(tb testing.TB, route Route) { - tb.Helper() - - body, _ := json.Marshal(route) - req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - - rec := httptest.NewRecorder() - registerRouteHandler(rec, req) - - resp := rec.Result() - tb.Cleanup(func() { - resp.Body.Close() - }) - require.Equal(tb, http.StatusCreated, resp.StatusCode) -} - -func TestRegisterRoute(t *testing.T) { - t.Parallel() - - route := Route{ - Method: http.MethodGet, - Path: "/test", - Response: "{\"message\":\"Test successful\"}", - StatusCode: 200, - ContentType: "application/json", - } - - registerRoute(t, route) -} - -func TestRegisteredRoute(t *testing.T) { - t.Parallel() - - routes := []Route{ - { - Method: http.MethodPost, - Path: "/hello", - Response: "{\"message\":\"Hello, world!\"}", - StatusCode: 200, - ContentType: "application/json", - }, - { - Method: http.MethodPost, - Path: "/goodbye", - Response: "{\"message\":\"Goodbye, world!\"}", - StatusCode: 201, - ContentType: "application/json", - }, - } - for _, r := range routes { - route := r - t.Run(route.Method+":"+route.Path, func(t *testing.T) { - t.Parallel() - - registerRoute(t, route) - - req := httptest.NewRequest(route.Method, route.Path, nil) - rec := httptest.NewRecorder() - dynamicHandler(rec, req) - resp := rec.Result() - - assert.Equal(t, resp.StatusCode, route.StatusCode) - assert.Equal(t, resp.Header.Get("Content-Type"), route.ContentType) - body, _ := io.ReadAll(resp.Body) - assert.Equal(t, string(body), route.Response) - resp.Body.Close() - }) - } -} - -func TestUnregisteredRoute(t *testing.T) { - t.Parallel() - - req := httptest.NewRequest(http.MethodGet, "/unregistered", nil) - rec := httptest.NewRecorder() - - dynamicHandler(rec, req) - resp := rec.Result() - defer resp.Body.Close() - - assert.Equal(t, http.StatusNotFound, resp.StatusCode) -} - -func TestSaveLoad(t *testing.T) { - routes := []Route{ - { - Method: "GET", - Path: "/hello", - Response: "{\"message\":\"Hello, world!\"}", - StatusCode: 200, - ContentType: "application/json", - }, - { - Method: "Post", - Path: "/goodbye", - Response: "{\"message\":\"Goodbye, world!\"}", - StatusCode: 201, - ContentType: "application/json", - }, - } - - for _, route := range routes { - registerRoute(t, route) - } - - t.Cleanup(func() { - os.Remove("save.json") - }) - - err := save() - require.NoError(t, err) - require.FileExists(t, "save.json") - - err = load() - require.NoError(t, err) - - for _, route := range routes { - req := httptest.NewRequest(route.Method, route.Path, nil) - rec := httptest.NewRecorder() - - dynamicHandler(rec, req) - resp := rec.Result() - - assert.Equal(t, resp.StatusCode, route.StatusCode) - assert.Equal(t, resp.Header.Get("Content-Type"), route.ContentType) - body, _ := io.ReadAll(resp.Body) - assert.Equal(t, string(body), route.Response) - resp.Body.Close() - } -} - -func BenchmarkRegisterRoute(b *testing.B) { - route := Route{ - Method: "GET", - Path: "/bench", - Response: "Benchmark Response", - StatusCode: 200, - ContentType: "text/plain", - } - - for i := 0; i < b.N; i++ { - body, _ := json.Marshal(route) - req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewReader(body)) - rec := httptest.NewRecorder() - registerRouteHandler(rec, req) - } -} - -func BenchmarkRouteResponse(b *testing.B) { - route := Route{ - Method: "GET", - Path: "/bench", - Response: "Benchmark Response", - StatusCode: 200, - ContentType: "text/plain", - } - registerRoute(b, route) - req := httptest.NewRequest(route.Method, route.Path, nil) - rec := httptest.NewRecorder() - b.ResetTimer() - for i := 0; i < b.N; i++ { - dynamicHandler(rec, req) - } -} - -func BenchmarkSaveRoutes(b *testing.B) { - for i := 0; i < 1000; i++ { - route := Route{ - Method: "GET", - Path: fmt.Sprintf("/bench%d", i), - Response: fmt.Sprintf("{\"message\":\"Response %d\"}", i), - StatusCode: 200, - ContentType: "text/plain", - } - routes[route.Method+":"+route.Path] = route - } - - filename := "bench_routes.json" - b.Cleanup(func() { - os.Remove(filename) - }) - - b.ResetTimer() // Start measuring time - for i := 0; i < b.N; i++ { - if err := save(); err != nil { - b.Fatalf("SaveRoutes failed: %v", err) - } - } -} - -func BenchmarkLoadRoutes(b *testing.B) { - filename := "bench_routes.json" - b.Cleanup(func() { - os.Remove(filename) - }) - - benchmarkRoutes := make(map[string]Route) - for i := 0; i < 1000; i++ { - route := Route{ - Method: "GET", - Path: fmt.Sprintf("/bench%d", i), - Response: fmt.Sprintf("{\"message\":\"Response %d\"}", i), - StatusCode: 200, - ContentType: "text/plain", - } - benchmarkRoutes[route.Method+":"+route.Path] = route - } - data, _ := json.MarshalIndent(benchmarkRoutes, "", " ") - if err := os.WriteFile(filename, data, 0644); err != nil { //nolint:gosec - b.Fatalf("Failed to write benchmark file: %v", err) - } - - b.ResetTimer() // Start measuring time - for i := 0; i < b.N; i++ { - if err := load(); err != nil { - b.Fatalf("Load failed: %v", err) - } - } -} From da8385e8713c08a38b9a18a4a4bc883e9c62c5c3 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Tue, 14 Jan 2025 16:11:19 -0500 Subject: [PATCH 05/30] Rename workflows --- .github/workflows/lint.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index aead1e0fb..2d0ab282d 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -76,8 +76,8 @@ jobs: path: ./tools/asciitable/ - name: workflowresultparser path: ./tools/workflowresultparser/ - - name: parrotserver - path: ./parrotserver/ + - name: parrot + path: ./parrot/ steps: - name: Check out Code uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 97ff662e3..e05ce1a9c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -26,8 +26,8 @@ jobs: path: ./tools/flakeguard/ - name: workflowresultparser path: ./tools/workflowresultparser/ - - name: parrotserver - path: ./parrotserver/ + - name: parrot + path: ./parrot/ runs-on: ubuntu-latest name: ${{ matrix.project.name }} unit tests steps: From c6218da7380fe5cb9cda08e4e4b5afcaa3184880 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 15 Jan 2025 15:09:41 -0500 Subject: [PATCH 06/30] Fixes tests adds examples --- parrot/Makefile | 14 +++- parrot/cmd/main.go | 9 ++- parrot/parrot.go | 142 +++++++++++++++++++++++---------- parrot/parrot_examples_test.go | 45 +++++++++++ parrot/parrot_test.go | 79 +++++++++++------- 5 files changed, 210 insertions(+), 79 deletions(-) create mode 100644 parrot/parrot_examples_test.go diff --git a/parrot/Makefile b/parrot/Makefile index 2397487b2..de48535e3 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -1,3 +1,9 @@ +# Default test log level (can be overridden) +TEST_LOG_LEVEL ?= "" + +# Pass TEST_LOG_LEVEL as a flag to go test +TEST_ARGS ?= -testLogLevel=$(TEST_LOG_LEVEL) + .PHONY: lint lint: golangci-lint --color=always run ./... --fix -v @@ -6,18 +12,18 @@ lint: test: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest set -euo pipefail - go test -json -cover -coverprofile cover.out -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt + go test $(TEST_ARGS) -json -cover -coverprofile cover.out -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt .PHONY: test_race test_race: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest set -euo pipefail - go test -json -cover -count=1 -race -coverprofile cover.out -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt + go test $(TEST_ARGS) -json -cover -count=1 -race -coverprofile cover.out -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt .PHONY: test_unit test_unit: - go test -coverprofile cover.out ./... + go test $(TEST_ARGS) -coverprofile cover.out ./... .PHONY: bench bench: - go test -bench=. -run=^$$ ./... \ No newline at end of file + go test $(TEST_ARGS) -bench=. -run=^$$ ./... \ No newline at end of file diff --git a/parrot/cmd/main.go b/parrot/cmd/main.go index c8195a331..07c92128d 100644 --- a/parrot/cmd/main.go +++ b/parrot/cmd/main.go @@ -6,6 +6,7 @@ import ( "os/signal" "syscall" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/smartcontractkit/chainlink-testing-framework/parrot" "github.com/spf13/cobra" @@ -25,15 +26,17 @@ func main() { Short: "a server that can set and parrrot back dynamic requests", RunE: func(cmd *cobra.Command, args []string) error { options := []parrot.ServerOption{parrot.WithPort(port)} + logLevel := zerolog.InfoLevel if debug { - options = append(options, parrot.WithDebug()) + logLevel = zerolog.DebugLevel } if trace { - options = append(options, parrot.WithTrace()) + logLevel = zerolog.TraceLevel } if silent { - options = append(options, parrot.Silent()) + logLevel = zerolog.Disabled } + options = append(options, parrot.WithLogLevel(logLevel)) if json { options = append(options, parrot.WithJSONLogs()) } diff --git a/parrot/parrot.go b/parrot/parrot.go index c547616bb..130a1287e 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -19,12 +19,15 @@ import ( // Route holds information about the mock route configuration type Route struct { // Method is the HTTP method to match - Method string `json:"method"` + Method string `json:"Method"` // Path is the URL path to match - Path string `json:"path"` + Path string `json:"Path"` // Handler is the dynamic handler function to use when called + // Can only be set upon creation of the server Handler http.HandlerFunc `json:"-"` - // ResponseBody is the static JSON response to return when called + // RawResponseBody is the static, raw string response to return when called + RawResponseBody string `json:"raw_response_body"` + // ResponseBody will be marshalled to JSON and returned when called ResponseBody any `json:"response_body"` // ResponseStatusCode is the HTTP status code to return when called ResponseStatusCode int `json:"response_status_code"` @@ -52,30 +55,20 @@ type ServerOption func(*Server) error func WithPort(port int) ServerOption { return func(s *Server) error { if port == 0 { - s.log.Debug().Msg("No port specified, using random port") + s.log.Debug().Msg("Configuring Parrot: No port specified, using random port") } else if port < 0 || port > 65535 { return fmt.Errorf("invalid port: %d", port) } s.port = port - s.log.Debug().Int("port", port).Msg("Setting port") + s.log.Debug().Int("port", port).Msg("Configuring Parrot: Setting port") return nil } } -// WithDebug sets the log level to debug -func WithDebug() ServerOption { +func WithLogLevel(level zerolog.Level) ServerOption { return func(s *Server) error { - s.log = s.log.Level(zerolog.DebugLevel) - s.log.Debug().Msg("Setting log level to debug") - return nil - } -} - -// WithTrace sets the log level to trace -func WithTrace() ServerOption { - return func(s *Server) error { - s.log = s.log.Level(zerolog.TraceLevel) - s.log.Debug().Msg("Setting log level to trace") + s.log = s.log.Level(level) + s.log.Debug().Str("log level", level.String()).Msg("Configuring Parrot: Setting log level") return nil } } @@ -84,15 +77,7 @@ func WithTrace() ServerOption { func WithLogger(l zerolog.Logger) ServerOption { return func(s *Server) error { s.log = l - s.log.Debug().Msg("Setting custom logger") - return nil - } -} - -// Silent sets the logger to a no-op logger -func Silent() ServerOption { - return func(s *Server) error { - s.log = zerolog.Nop() + s.log.Debug().Msg("Configuring Parrot: Setting custom logger") return nil } } @@ -101,7 +86,7 @@ func Silent() ServerOption { func WithJSONLogs() ServerOption { return func(s *Server) error { s.log = s.log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}) - s.log.Debug().Msg("Setting log output to JSON") + s.log.Debug().Msg("Configuring Parrot: Setting log output to JSON") return nil } } @@ -113,7 +98,7 @@ func WithSaveFile(saveFile string) ServerOption { return fmt.Errorf("invalid save file name: %s", saveFile) } s.saveFile = saveFile - s.log.Debug().Str("file", saveFile).Msg("Setting save file") + s.log.Debug().Str("file", saveFile).Msg("Configuring Parrot: Setting save file") return nil } } @@ -125,7 +110,7 @@ func WithRoutes(routes []*Route) ServerOption { if err := s.Register(route); err != nil { return fmt.Errorf("failed to register route: %w", err) } - s.log.Debug().Str("path", route.Path).Str("method", route.Method).Msg("Pre-registered route") + s.log.Debug().Str("Path", route.Path).Str("Method", route.Method).Msg("Configuring Parrot: Pre-registered route") } return nil } @@ -223,14 +208,21 @@ func (p *Server) Register(route *Route) error { if route.Method == "" { return fmt.Errorf("invalid route method: %s", route.Method) } - if route.Handler == nil && route.ResponseBody == nil { + if route.Handler == nil && route.ResponseBody == nil && route.RawResponseBody == "" { return fmt.Errorf("route must have a handler or response body") } + if route.Handler != nil && (route.ResponseBody != nil || route.RawResponseBody != "") { + return fmt.Errorf("route cannot have both a handler and response body") + } + if route.ResponseBody != nil && route.RawResponseBody != "" { + return fmt.Errorf("route cannot have both a response body and raw response body") + } if route.ResponseBody != nil { if _, err := json.Marshal(route.ResponseBody); err != nil { - return fmt.Errorf("failed to marshal response body: %w", err) + return fmt.Errorf("response body is unable to be marshalled into JSON: %w", err) } } + p.routesMu.Lock() defer p.routesMu.Unlock() p.routes[route.Method+":"+route.Path] = route @@ -238,6 +230,13 @@ func (p *Server) Register(route *Route) error { return nil } +// Routes returns all registered routes +func (p *Server) Routes() map[string]*Route { + p.routesMu.RLock() + defer p.routesMu.RUnlock() + return p.routes +} + // Unregister removes a route from the parrot func (p *Server) Unregister(method, path string) { p.routesMu.Lock() @@ -247,7 +246,7 @@ func (p *Server) Unregister(method, path string) { // Call makes a request to the parrot server func (p *Server) Call(method, path string) (*http.Response, error) { - req, err := http.NewRequest(method, filepath.Join(p.server.Addr, path), nil) + req, err := http.NewRequest(method, "http://"+filepath.Join(p.Address(), path), nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -260,10 +259,11 @@ func (p *Server) Call(method, path string) (*http.Response, error) { func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) + p.log.Trace().Str("Method", r.Method).Msg("Invalid method") return } - var route Route + var route *Route if err := json.NewDecoder(r.Body).Decode(&route); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return @@ -271,13 +271,17 @@ func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() if route.Method == "" || route.Path == "" { - http.Error(w, "Method and Path are required", http.StatusBadRequest) + err := errors.New("Method and path are required") + http.Error(w, err.Error(), http.StatusBadRequest) return } - p.routesMu.Lock() - p.routes[route.Method+":"+route.Path] = &route - p.routesMu.Unlock() + err := p.Register(route) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + p.log.Trace().Err(err).Msg("Failed to register route") + return + } w.WriteHeader(http.StatusCreated) p.log.Info().Str("Path", route.Path).Str("Method", route.Method).Msg("Route registered") @@ -291,13 +295,65 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { if !exists { http.NotFound(w, r) + p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route not found") return } - w.Header().Set("Content-Type", route.ResponseContentType) + if route.ResponseContentType != "" { + w.Header().Set("Content-Type", route.ResponseContentType) + } w.WriteHeader(route.ResponseStatusCode) - if err := json.NewEncoder(w).Encode(route.ResponseBody); err != nil { - http.Error(w, "Failed to marshal response into json", http.StatusInternalServerError) + + if route.Handler != nil { + p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Calling route handler") + route.Handler(w, r) + } else if route.RawResponseBody != "" { + if route.ResponseContentType == "" { + w.Header().Set("Content-Type", "text/plain") + } + if _, err := w.Write([]byte(route.RawResponseBody)); err != nil { + p.log.Trace().Err(err).Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Failed to write response") + http.Error(w, "Failed to write response", http.StatusInternalServerError) + return + } + p.log.Trace(). + Str("Remote Addr", r.RemoteAddr). + Str("Response", route.RawResponseBody). + Str("Path", r.URL.Path). + Str("Method", r.Method). + Msg("Returned raw response") + } else if route.ResponseBody != nil { + if route.ResponseContentType == "" { + w.Header().Set("Content-Type", "application/json") + } + rawJSON, err := json.Marshal(route.ResponseBody) + if err != nil { + p.log.Trace().Err(err). + Str("Remote Addr", r.RemoteAddr). + Str("Path", r.URL.Path). + Str("Method", r.Method). + Msg("Failed to marshal JSON response") + http.Error(w, "Failed to marshal response into json", http.StatusInternalServerError) + return + } + if _, err = w.Write(rawJSON); err != nil { + p.log.Trace().Err(err). + RawJSON("Response", rawJSON). + Str("Remote Addr", r.RemoteAddr). + Str("Path", r.URL.Path). + Str("Method", r.Method). + Msg("Failed to write response") + http.Error(w, "Failed to write JSON response", http.StatusInternalServerError) + return + } + p.log.Trace(). + Str("Remote Addr", r.RemoteAddr). + RawJSON("Response", rawJSON). + Str("Path", r.URL.Path). + Str("Method", r.Method). + Msg("Returned JSON response") + } else { + p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route has no response") } } @@ -336,7 +392,7 @@ func (p *Server) save() error { p.log.Debug().Msg("No routes to save") return nil } - p.log.Debug().Str("file", p.saveFile).Msg("Saving routes") + p.log.Trace().Str("file", p.saveFile).Msg("Saving routes") p.routesMu.RLock() defer p.routesMu.RUnlock() @@ -350,6 +406,6 @@ func (p *Server) save() error { return fmt.Errorf("failed to write routes to file: %w", err) } - p.log.Info().Str("file", p.saveFile).Msg("Saved routes") + p.log.Trace().Str("file", p.saveFile).Msg("Saved routes") return nil } diff --git a/parrot/parrot_examples_test.go b/parrot/parrot_examples_test.go new file mode 100644 index 000000000..3e136d287 --- /dev/null +++ b/parrot/parrot_examples_test.go @@ -0,0 +1,45 @@ +package parrot_test + +import ( + "fmt" + "io" + "net/http" + + "github.com/smartcontractkit/chainlink-testing-framework/parrot" +) + +func ExampleServer_Register() { + p, err := parrot.Wake() + if err != nil { + panic(err) + } + + route := &parrot.Route{ + Method: http.MethodGet, + Path: "/test", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + } + + err = p.Register(route) + if err != nil { + panic(err) + } + + resp, err := p.Call(http.MethodGet, "/test") + if err != nil { + panic(err) + } + defer resp.Body.Close() + + fmt.Println(resp.StatusCode) + fmt.Println(resp.Header.Get("Content-Type")) + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + + // Output: + // 200 + // text/plain + // Squawk +} diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index d2fff6bc0..4f6b0e5d1 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -1,34 +1,54 @@ package parrot import ( + "flag" "fmt" "io" "net/http" "os" "testing" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var testLogLevel = zerolog.NoLevel + +func TestMain(m *testing.M) { + testLogLevelFlag := "" + flag.StringVar(&testLogLevelFlag, "testLogLevel", "", "a zerolog log level to use for tests") + flag.Parse() + var err error + testLogLevel, err = zerolog.ParseLevel(testLogLevelFlag) + if err != nil { + fmt.Println("error parsing test log level:", err) + os.Exit(1) + } + + os.Exit(m.Run()) +} + func TestWake(t *testing.T) { t.Parallel() - p, err := Wake() + + p, err := Wake(WithLogLevel(testLogLevel)) require.NoError(t, err, "error waking parrot") require.NotNil(t, p) } -func TestRegisterRoute(t *testing.T) { +func TestNativeRegisterRoute(t *testing.T) { t.Parallel() - p, err := Wake() + + p, err := Wake(WithLogLevel(testLogLevel)) require.NoError(t, err) route := &Route{ Method: http.MethodGet, Path: "/test", - ResponseBody: "{\"message\":\"Test successful\"}", + RawResponseBody: "Squawk", ResponseStatusCode: 200, - ResponseContentType: "application/json", + ResponseContentType: "text/plain", } err = p.Register(route) @@ -38,23 +58,23 @@ func TestRegisterRoute(t *testing.T) { func TestRegisteredRoute(t *testing.T) { t.Parallel() - p, err := Wake() + p, err := Wake(WithLogLevel(testLogLevel)) require.NoError(t, err, "error waking parrot") routes := []*Route{ { Method: http.MethodPost, Path: "/hello", - ResponseBody: "{\"message\":\"Hello, world!\"}", + RawResponseBody: "Squawk", ResponseStatusCode: 200, - ResponseContentType: "application/json", + ResponseContentType: "text/plain", }, { Method: http.MethodPost, Path: "/goodbye", - ResponseBody: "{\"message\":\"Goodbye, world!\"}", + RawResponseBody: "Squeak", ResponseStatusCode: 201, - ResponseContentType: "application/json", + ResponseContentType: "text/plain", }, } @@ -73,7 +93,7 @@ func TestRegisteredRoute(t *testing.T) { assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) body, _ := io.ReadAll(resp.Body) - assert.Equal(t, string(body), route.ResponseBody) + assert.Equal(t, route.RawResponseBody, string(body)) resp.Body.Close() }) } @@ -82,11 +102,11 @@ func TestRegisteredRoute(t *testing.T) { func TestUnregisteredRoute(t *testing.T) { t.Parallel() - p, err := Wake() + p, err := Wake(WithLogLevel(testLogLevel)) require.NoError(t, err, "error waking parrot") resp, err := p.Call(http.MethodGet, "/unregistered") - require.Error(t, err, "error calling parrot") + require.NoError(t, err, "error calling parrot") require.NotNil(t, resp, "response should not be nil") defer resp.Body.Close() @@ -97,23 +117,23 @@ func TestSaveLoad(t *testing.T) { t.Parallel() saveFile := "save_test.json" - p, err := Wake(WithSaveFile(saveFile)) + p, err := Wake(WithLogLevel(testLogLevel), WithSaveFile(saveFile)) require.NoError(t, err, "error waking parrot") routes := []*Route{ { Method: "GET", Path: "/hello", - ResponseBody: "{\"message\":\"Hello, world!\"}", + RawResponseBody: "Squawk", ResponseStatusCode: 200, - ResponseContentType: "application/json", + ResponseContentType: "text/plain", }, { Method: "Post", Path: "/goodbye", - ResponseBody: "{\"message\":\"Goodbye, world!\"}", + RawResponseBody: "Squeak", ResponseStatusCode: 201, - ResponseContentType: "application/json", + ResponseContentType: "text/plain", }, } @@ -140,19 +160,19 @@ func TestSaveLoad(t *testing.T) { assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) body, _ := io.ReadAll(resp.Body) - assert.Equal(t, string(body), route.ResponseBody) + assert.Equal(t, route.RawResponseBody, string(body)) resp.Body.Close() } } func BenchmarkRegisterRoute(b *testing.B) { - p, err := Wake() + p, err := Wake(WithLogLevel(zerolog.Disabled)) require.NoError(b, err) route := &Route{ Method: "GET", Path: "/bench", - ResponseBody: "Benchmark Response", + RawResponseBody: "Benchmark Response", ResponseStatusCode: 200, ResponseContentType: "text/plain", } @@ -165,13 +185,13 @@ func BenchmarkRegisterRoute(b *testing.B) { } func BenchmarkRouteResponse(b *testing.B) { - p, err := Wake() + p, err := Wake(WithLogLevel(zerolog.Disabled)) require.NoError(b, err) route := &Route{ Method: "GET", Path: "/bench", - ResponseBody: "Benchmark Response", + RawResponseBody: "Benchmark Response", ResponseStatusCode: 200, ResponseContentType: "text/plain", } @@ -188,19 +208,19 @@ func BenchmarkRouteResponse(b *testing.B) { func BenchmarkSaveRoutes(b *testing.B) { var ( routes = []*Route{} - saveFile = "bench_routes.json" + saveFile = "bench_save_routes.json" ) for i := 0; i < 1000; i++ { routes = append(routes, &Route{ Method: "GET", Path: fmt.Sprintf("/bench%d", i), - ResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), + RawResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), ResponseStatusCode: 200, ResponseContentType: "text/plain", }) } - p, err := Wake(WithRoutes(routes)) + p, err := Wake(WithRoutes(routes), WithLogLevel(zerolog.Disabled), WithSaveFile(saveFile)) require.NoError(b, err) b.Cleanup(func() { @@ -217,21 +237,22 @@ func BenchmarkSaveRoutes(b *testing.B) { func BenchmarkLoadRoutes(b *testing.B) { var ( routes = []*Route{} - saveFile = "bench_routes.json" + saveFile = "bench_load_routes.json" ) b.Cleanup(func() { os.Remove(saveFile) }) + for i := 0; i < 1000; i++ { routes = append(routes, &Route{ Method: "GET", Path: fmt.Sprintf("/bench%d", i), - ResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), + RawResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), ResponseStatusCode: 200, ResponseContentType: "text/plain", }) } - p, err := Wake(WithRoutes(routes)) + p, err := Wake(WithRoutes(routes), WithLogLevel(zerolog.Disabled), WithSaveFile(saveFile)) require.NoError(b, err, "error waking parrot") err = p.save() require.NoError(b, err, "error saving routes") From 60c05003b88e53f4cf7a2f468e844649318e45b4 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 15 Jan 2025 15:59:35 -0500 Subject: [PATCH 07/30] Docs and race test --- parrot/Makefile | 4 +- parrot/README.md | 77 +++------------------------------- parrot/cmd/main.go | 4 +- parrot/parrot_examples_test.go | 2 +- parrot/parrot_test.go | 54 ++++++++++++++++++++++-- 5 files changed, 61 insertions(+), 80 deletions(-) diff --git a/parrot/Makefile b/parrot/Makefile index de48535e3..50da0b8d8 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -1,8 +1,8 @@ # Default test log level (can be overridden) -TEST_LOG_LEVEL ?= "" +PARROT_TEST_LOG_LEVEL ?= "" # Pass TEST_LOG_LEVEL as a flag to go test -TEST_ARGS ?= -testLogLevel=$(TEST_LOG_LEVEL) +TEST_ARGS ?= -testLogLevel=$(PARROT_TEST_LOG_LEVEL) .PHONY: lint lint: diff --git a/parrot/README.md b/parrot/README.md index 3bcbfc4da..0a4b13965 100644 --- a/parrot/README.md +++ b/parrot/README.md @@ -2,83 +2,18 @@ A simple, high-performing mockserver that can dynamically build new routes with customized responses, parroting back whatever you tell it to. -## Use - -Call the `/register` endpoint to define a route. - -### Curl - -```sh -curl -X POST http://localhost:8080/register -d '{ - "method": "GET", - "path": "/hello", - "response": "{\"message\": \"Hello, world!\"}", - "status_code": 200, - "content_type": "application/json" -}' -H "Content-Type: application/json" -``` - -### Go and [Resty](https://github.com/go-resty/resty) - -```go -client := resty.New() - -route := map[string]any{ - "method": "GET", - "path": "/hello", - "response": "{\"message\":\"Hello, world!\"}", - "status_code": 200, - "content_type": "application/json", -} - -resp, _ := client.R(). - SetHeader("Content-Type", "application/json"). - SetBody(route). - Post("http://localhost:8080/register") -``` - -You can now call your endpoint and receive the JSON response back. - -```sh -curl -X GET http://localhost:8080/hello -H "Content-Type: application/json" -# {"message":"Hello, world!"} -``` - -## Configure - -Config is through environment variables. - -| **Environment Variable** | **Description** | **Default Value** | -| ------------------------ | -------------------------------------------------------------- | ----------------- | -| `LOG_LEVEL` | Controls the logging level (`debug`, `info`, `warn`, `error`). | `debug` | -| `SAVE_FILE` | Path to the file where routes are saved and loaded. | `save.json` | - ## Run ```sh -go run . +go run ./cmd +go run ./cmd -h # See all config options ``` ## Test ```sh -go test -cover -race ./... -``` - -## Benchmark - -```sh -LOG_LEVEL=disabled go test -bench=. -benchmem -run=^$ -``` - -Benchmark run on an Apple M3 Max. - -```sh -goos: darwin -goarch: arm64 -pkg: github.com/smartcontractkit/chainlink-testing-framework/parrotserver -BenchmarkRegisterRoute-14 604978 1967 ns/op 6263 B/op 29 allocs/op -BenchmarkRouteResponse-14 16561670 70.62 ns/op 80 B/op 1 allocs/op -BenchmarkSaveRoutes-14 1245 956784 ns/op 636042 B/op 2014 allocs/op -BenchmarkLoadRoutes-14 1020 1185990 ns/op 348919 B/op 9020 allocs/op +make test +make test PARROT_TEST_LOG_LEVEL=trace # Set log level for tests +make test_race # Test with -race flag enabled +make bench # Benchmark ``` \ No newline at end of file diff --git a/parrot/cmd/main.go b/parrot/cmd/main.go index 07c92128d..1a0afb72f 100644 --- a/parrot/cmd/main.go +++ b/parrot/cmd/main.go @@ -22,8 +22,8 @@ func main() { ) rootCmd := &cobra.Command{ - Use: "parrotserver", - Short: "a server that can set and parrrot back dynamic requests", + Use: "parrot", + Short: "A server that can register and parrot back dynamic requests", RunE: func(cmd *cobra.Command, args []string) error { options := []parrot.ServerOption{parrot.WithPort(port)} logLevel := zerolog.InfoLevel diff --git a/parrot/parrot_examples_test.go b/parrot/parrot_examples_test.go index 3e136d287..78fa25392 100644 --- a/parrot/parrot_examples_test.go +++ b/parrot/parrot_examples_test.go @@ -8,7 +8,7 @@ import ( "github.com/smartcontractkit/chainlink-testing-framework/parrot" ) -func ExampleServer_Register() { +func ExampleServer() { p, err := parrot.Wake() if err != nil { panic(err) diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index 4f6b0e5d1..314ace872 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -1,6 +1,7 @@ package parrot import ( + "encoding/json" "flag" "fmt" "io" @@ -76,14 +77,20 @@ func TestRegisteredRoute(t *testing.T) { ResponseStatusCode: 201, ResponseContentType: "text/plain", }, + { + Method: http.MethodGet, + Path: "/json", + ResponseBody: map[string]any{"message": "Squawk"}, + ResponseStatusCode: 200, + ResponseContentType: "application/json", + }, } - for _, r := range routes { - route := r + for _, route := range routes { t.Run(route.Method+":"+route.Path, func(t *testing.T) { t.Parallel() - err = p.Register(route) + err := p.Register(route) require.NoError(t, err, "error registering route") resp, err := p.Call(route.Method, route.Path) @@ -93,7 +100,13 @@ func TestRegisteredRoute(t *testing.T) { assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) body, _ := io.ReadAll(resp.Body) - assert.Equal(t, route.RawResponseBody, string(body)) + if route.ResponseBody != nil { + jsonBody, err := json.Marshal(route.ResponseBody) + require.NoError(t, err) + assert.JSONEq(t, string(jsonBody), string(body)) + } else { + assert.Equal(t, route.RawResponseBody, string(body)) + } resp.Body.Close() }) } @@ -113,6 +126,39 @@ func TestUnregisteredRoute(t *testing.T) { assert.Equal(t, http.StatusNotFound, resp.StatusCode) } +func TestUnregister(t *testing.T) { + t.Parallel() + + p, err := Wake(WithLogLevel(testLogLevel)) + require.NoError(t, err, "error waking parrot") + + route := &Route{ + Method: http.MethodPost, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + } + + err = p.Register(route) + require.NoError(t, err, "error registering route") + + resp, err := p.Call(route.Method, route.Path) + require.NoError(t, err, "error calling parrot") + + assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) + assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, route.RawResponseBody, string(body)) + resp.Body.Close() + + p.Unregister(route.Method, route.Path) + + resp, err = p.Call(route.Method, route.Path) + require.NoError(t, err, "error calling parrot") + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + func TestSaveLoad(t *testing.T) { t.Parallel() From ae774918ab4ccb8ed9c0ceffadc50826f5dae6a3 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 15 Jan 2025 18:07:24 -0500 Subject: [PATCH 08/30] Beginning of recorder --- parrot/errors.go | 38 +++ ...rrot_examples_test.go => examples_test.go} | 8 +- parrot/parrot.go | 246 +++++++++++++++--- parrot/parrot_test.go | 186 +++++++++---- parrot/recorder.go | 13 + 5 files changed, 402 insertions(+), 89 deletions(-) create mode 100644 parrot/errors.go rename parrot/{parrot_examples_test.go => examples_test.go} (70%) create mode 100644 parrot/recorder.go diff --git a/parrot/errors.go b/parrot/errors.go new file mode 100644 index 000000000..ef3ecdbe1 --- /dev/null +++ b/parrot/errors.go @@ -0,0 +1,38 @@ +package parrot + +import ( + "errors" + "fmt" +) + +var ( + ErrNilRoute = errors.New("route is nil") + ErrNoMethod = errors.New("no method specified") + ErrInvalidPath = errors.New("invalid path") + ErrNoResponse = errors.New("route must have a handler or some response") + ErrOnlyOneResponse = errors.New("route can only have one response type") + ErrResponseMarshal = errors.New("unable to marshal response body to JSON") + ErrRouteNotFound = errors.New("route not found") + ErrRecorderNotFound = errors.New("recorder not found") +) + +// Custom error type to help add more detail to base errors +type dynamicError struct { + Base error // Base error for comparison + Extra string // Dynamic context (e.g., method name) +} + +func (e *dynamicError) Error() string { + return fmt.Sprintf("%s: %s", e.Base.Error(), e.Extra) +} + +func (e *dynamicError) Unwrap() error { + return e.Base +} + +func newDynamicError(base error, detail string) error { + return &dynamicError{ + Base: base, + Extra: detail, + } +} diff --git a/parrot/parrot_examples_test.go b/parrot/examples_test.go similarity index 70% rename from parrot/parrot_examples_test.go rename to parrot/examples_test.go index 78fa25392..eca84faa6 100644 --- a/parrot/parrot_examples_test.go +++ b/parrot/examples_test.go @@ -5,15 +5,18 @@ import ( "io" "net/http" + "github.com/rs/zerolog" "github.com/smartcontractkit/chainlink-testing-framework/parrot" ) func ExampleServer() { - p, err := parrot.Wake() + // Create a new parrot instance with no logging + p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel)) if err != nil { panic(err) } + // Create a new route /test that will return a 200 status code with a text/plain response body of "Squawk" route := &parrot.Route{ Method: http.MethodGet, Path: "/test", @@ -22,11 +25,13 @@ func ExampleServer() { ResponseContentType: "text/plain", } + // Register the route with the parrot instance err = p.Register(route) if err != nil { panic(err) } + // Call the route resp, err := p.Call(http.MethodGet, "/test") if err != nil { panic(err) @@ -37,7 +42,6 @@ func ExampleServer() { fmt.Println(resp.Header.Get("Content-Type")) body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) - // Output: // 200 // text/plain diff --git a/parrot/parrot.go b/parrot/parrot.go index 130a1287e..6e11d32d0 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -7,9 +7,12 @@ import ( "fmt" "net" "net/http" + "net/url" "os" "path/filepath" + "regexp" "strconv" + "strings" "sync" "time" @@ -35,6 +38,12 @@ type Route struct { ResponseContentType string `json:"response_content_type"` } +// RouteRequestBody is the request body for querying the server on a specific route +type RouteRequestBody struct { + Method string `json:"method"` + Path string `json:"path"` +} + // Server is a mock HTTP server that can register and respond to dynamic routes type Server struct { port int @@ -46,6 +55,9 @@ type Server struct { server *http.Server routes map[string]*Route // Store routes based on "Method:Path" keys routesMu sync.RWMutex + + recorders map[string]*Recorder + recordersMu sync.RWMutex } // ServerOption defines functional options for configuring the ParrotServer @@ -73,6 +85,21 @@ func WithLogLevel(level zerolog.Level) ServerOption { } } +func WithLogFile(logFile string) ServerOption { + return func(s *Server) error { + if logFile == "" { + return fmt.Errorf("invalid log file name: %s", logFile) + } + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + s.log = s.log.Output(file) + s.log.Debug().Str("file", logFile).Msg("Configuring Parrot: Setting log file") + return nil + } +} + // WithLogger sets the logger for the ParrotServer func WithLogger(l zerolog.Logger) ServerOption { return func(s *Server) error { @@ -151,6 +178,7 @@ func Wake(options ...ServerOption) (*Server, error) { mux := http.NewServeMux() mux.HandleFunc("/register", p.registerRouteHandler) + mux.HandleFunc("/record", p.recordHandler) mux.HandleFunc("/", p.dynamicHandler) p.server = &http.Server{ @@ -202,24 +230,30 @@ func (p *Server) Address() string { // Register adds a new route to the parrot func (p *Server) Register(route *Route) error { - if route.Path == "" || route.Path == "/" || route.Path == "/register" { - return fmt.Errorf("invalid route path: %s", route.Path) + if route == nil { + return ErrNilRoute + } + if !isValidPath(route.Path) { + return newDynamicError(ErrInvalidPath, fmt.Sprintf("'%s'", route.Path)) + } + if _, err := url.Parse(route.Path); err != nil { + return newDynamicError(ErrInvalidPath, fmt.Sprintf("%s: '%s'", err.Error(), route.Path)) } if route.Method == "" { - return fmt.Errorf("invalid route method: %s", route.Method) + return ErrNoMethod } if route.Handler == nil && route.ResponseBody == nil && route.RawResponseBody == "" { - return fmt.Errorf("route must have a handler or response body") + return ErrNoResponse } if route.Handler != nil && (route.ResponseBody != nil || route.RawResponseBody != "") { - return fmt.Errorf("route cannot have both a handler and response body") + return newDynamicError(ErrOnlyOneResponse, "handler and another response type provided") } if route.ResponseBody != nil && route.RawResponseBody != "" { - return fmt.Errorf("route cannot have both a response body and raw response body") + return ErrOnlyOneResponse } if route.ResponseBody != nil { if _, err := json.Marshal(route.ResponseBody); err != nil { - return fmt.Errorf("response body is unable to be marshalled into JSON: %w", err) + return newDynamicError(ErrResponseMarshal, err.Error()) } } @@ -230,6 +264,139 @@ func (p *Server) Register(route *Route) error { return nil } +// registerRouteHandler handles the dynamic route registration. +func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { + const parrotPath = "/register" + if r.Method == http.MethodDelete { + var routeRequestBody *RouteRequestBody + if err := json.NewDecoder(r.Body).Decode(&routeRequestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if routeRequestBody.Method == "" || routeRequestBody.Path == "" { + err := errors.New("Method and path are required") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err := p.Unregister(routeRequestBody.Method, routeRequestBody.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + p.log.Trace().Err(err).Str("Path", parrotPath).Msg("Failed to unregister route") + return + } + + w.WriteHeader(http.StatusNoContent) + p.log.Info(). + Str("Route Path", routeRequestBody.Path). + Str("Parrot Path", parrotPath). + Str("Method", routeRequestBody.Method). + Msg("Route unregistered") + } else if r.Method == http.MethodPost { + var route *Route + if err := json.NewDecoder(r.Body).Decode(&route); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if route.Method == "" || route.Path == "" { + err := errors.New("Method and path are required") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err := p.Register(route) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + p.log.Trace().Err(err).Msg("Failed to register route") + return + } + + w.WriteHeader(http.StatusCreated) + p.log.Info(). + Str("Parrot Path", parrotPath). + Str("Route Path", route.Path). + Str("Method", route.Method). + Msg("Route registered") + } else { + http.Error(w, "Invalid method, only use POST or DELETE", http.StatusMethodNotAllowed) + p.log.Trace().Str("Method", r.Method).Msg("Invalid method") + return + } +} + +// Record registers a new recorder with the parrot. All incoming requests to the parrot will be recorded at that webhook. +func (p *Server) Record(url *url.URL) error { + p.recordersMu.Lock() + defer p.recordersMu.Unlock() + p.recorders[url.String()] = NewRecorder(url) + return nil +} + +func (p *Server) recordHandler(w http.ResponseWriter, r *http.Request) { + const parrotPath = "/record" + if r.Method == http.MethodPost { + var recorder *Recorder + if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to decode request body") + return + } + + err := p.Record(recorder.URL) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to add recorder") + return + } + + w.WriteHeader(http.StatusCreated) + p.log.Info().Str("Recorder URL", recorder.URL.String()).Str("Parrot Path", parrotPath).Msg("Recorder added") + } else if r.Method == http.MethodDelete { + var recorder *Recorder + if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to decode request body") + return + } + + err := p.StopRecord(recorder.URL) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + p.log.Trace(). + Err(err). + Str("Parrot Path", parrotPath). + Str("Recorder", recorder.URL.String()). + Msg("Failed to stop recording") + return + } + p.log.Info().Str("Recorder URL", recorder.URL.String()).Str("Parrot Path", parrotPath).Msg("Recorder removed") + } else { + http.Error(w, "Invalid method, only use POST or DELETE", http.StatusMethodNotAllowed) + p.log.Trace().Str("Method", r.Method).Msg("Invalid method") + return + } +} + +// Record registers a new recorder with the parrot. All incoming requests to the parrot will be recorded at that webhook. +func (p *Server) StopRecord(url *url.URL) error { + p.recordersMu.RLock() + _, exists := p.recorders[url.String()] + p.recordersMu.RUnlock() + + if !exists { + return newDynamicError(ErrRecorderNotFound, fmt.Sprintf("'%s'", url.String())) + } + + p.recordersMu.Lock() + defer p.recordersMu.Unlock() + delete(p.recorders, url.String()) + return nil +} + // Routes returns all registered routes func (p *Server) Routes() map[string]*Route { p.routesMu.RLock() @@ -238,10 +405,18 @@ func (p *Server) Routes() map[string]*Route { } // Unregister removes a route from the parrot -func (p *Server) Unregister(method, path string) { +func (p *Server) Unregister(method, path string) error { + p.routesMu.RLock() + _, exists := p.routes[method+":"+path] + p.routesMu.RUnlock() + + if !exists { + return newDynamicError(ErrRouteNotFound, fmt.Sprintf("%s %s", method, path)) + } p.routesMu.Lock() defer p.routesMu.Unlock() delete(p.routes, method+":"+path) + return nil } // Call makes a request to the parrot server @@ -255,38 +430,6 @@ func (p *Server) Call(method, path string) (*http.Response, error) { return client.Do(req) } -// registerRouteHandler handles the dynamic route registration. -func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) - p.log.Trace().Str("Method", r.Method).Msg("Invalid method") - return - } - - var route *Route - if err := json.NewDecoder(r.Body).Decode(&route); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - if route.Method == "" || route.Path == "" { - err := errors.New("Method and path are required") - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - err := p.Register(route) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - p.log.Trace().Err(err).Msg("Failed to register route") - return - } - - w.WriteHeader(http.StatusCreated) - p.log.Info().Str("Path", route.Path).Str("Method", route.Method).Msg("Route registered") -} - // dynamicHandler handles all incoming requests and responds based on the registered routes. func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { p.routesMu.RLock() @@ -409,3 +552,26 @@ func (p *Server) save() error { p.log.Trace().Str("file", p.saveFile).Msg("Saved routes") return nil } + +var pathRegex = regexp.MustCompile(`^\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@\/]*$`) + +func isValidPath(path string) bool { + switch path { + case "", "/", "//", "/register", "/.", "/..": + return false + } + if !strings.HasPrefix(path, "/") { + return false + } + if strings.HasPrefix(path, "/register") { + return false + } + if strings.HasPrefix(path, "/unregister") { + return false + } + u, err := url.Parse(path) + if err != nil || u.Path != path { + return false + } + return pathRegex.MatchString(u.Path) +} diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index 314ace872..4bc5a159c 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -38,80 +38,171 @@ func TestWake(t *testing.T) { require.NotNil(t, p) } -func TestNativeRegisterRoute(t *testing.T) { - t.Parallel() - - p, err := Wake(WithLogLevel(testLogLevel)) - require.NoError(t, err) - - route := &Route{ - Method: http.MethodGet, - Path: "/test", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", - } - - err = p.Register(route) - require.NoError(t, err, "error registering route") -} - -func TestRegisteredRoute(t *testing.T) { +func TestRegister(t *testing.T) { t.Parallel() p, err := Wake(WithLogLevel(testLogLevel)) require.NoError(t, err, "error waking parrot") - routes := []*Route{ - { - Method: http.MethodPost, - Path: "/hello", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", - }, + testCases := []struct { + name string + route *Route + }{ { - Method: http.MethodPost, - Path: "/goodbye", - RawResponseBody: "Squeak", - ResponseStatusCode: 201, - ResponseContentType: "text/plain", + name: "simple route", + route: &Route{ + Method: http.MethodPost, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + }, }, { - Method: http.MethodGet, - Path: "/json", - ResponseBody: map[string]any{"message": "Squawk"}, - ResponseStatusCode: 200, - ResponseContentType: "application/json", + name: "json route", + route: &Route{ + Method: http.MethodGet, + Path: "/json", + ResponseBody: map[string]any{"message": "Squawk"}, + ResponseStatusCode: 200, + ResponseContentType: "application/json", + }, }, } - for _, route := range routes { - t.Run(route.Method+":"+route.Path, func(t *testing.T) { + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := p.Register(route) + err := p.Register(tc.route) require.NoError(t, err, "error registering route") - resp, err := p.Call(route.Method, route.Path) + resp, err := p.Call(tc.route.Method, tc.route.Path) require.NoError(t, err, "error calling parrot") defer resp.Body.Close() - assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) - assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) + assert.Equal(t, resp.StatusCode, tc.route.ResponseStatusCode) + assert.Equal(t, resp.Header.Get("Content-Type"), tc.route.ResponseContentType) body, _ := io.ReadAll(resp.Body) - if route.ResponseBody != nil { - jsonBody, err := json.Marshal(route.ResponseBody) + if tc.route.ResponseBody != nil { + jsonBody, err := json.Marshal(tc.route.ResponseBody) require.NoError(t, err) assert.JSONEq(t, string(jsonBody), string(body)) } else { - assert.Equal(t, route.RawResponseBody, string(body)) + assert.Equal(t, tc.route.RawResponseBody, string(body)) } resp.Body.Close() }) } } +func TestIsValidPath(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + paths []string + valid bool + }{ + { + name: "valid paths", + paths: []string{"/hello"}, + valid: true, + }, + { + name: "no register", + paths: []string{"/register", "/register/", "/register//", "/register/other_stuff"}, + valid: false, + }, + { + name: "invalid paths", + paths: []string{"", "/", " ", " /", "/ ", " / ", "invalid path"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + for _, path := range tc.paths { + valid := isValidPath(path) + assert.Equal(t, tc.valid, valid) + } + }) + } +} + +func TestBadRegisterRoute(t *testing.T) { + t.Parallel() + + p, err := Wake(WithLogLevel(testLogLevel)) + require.NoError(t, err, "error waking parrot") + + testCases := []struct { + name string + err error + route *Route + }{ + { + name: "nil route", + err: ErrNilRoute, + route: nil, + }, + { + name: "no method", + err: ErrNoMethod, + route: &Route{ + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + ResponseContentType: "text/plain", + }, + }, + { + name: "no path", + err: ErrInvalidPath, + route: &Route{ + Method: http.MethodGet, + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + ResponseContentType: "application/json", + }, + }, + { + name: "base path", + err: ErrInvalidPath, + route: &Route{ + Method: http.MethodGet, + Path: "/", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + ResponseContentType: "application/json", + }, + }, + { + name: "invalid path", + err: ErrInvalidPath, + route: &Route{ + Method: http.MethodGet, + Path: "invalid path", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + ResponseContentType: "application/json", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := p.Register(tc.route) + require.Error(t, err, "expected error registering route") + assert.ErrorIs(t, err, tc.err) + }) + } +} + func TestUnregisteredRoute(t *testing.T) { t.Parallel() @@ -152,7 +243,8 @@ func TestUnregister(t *testing.T) { assert.Equal(t, route.RawResponseBody, string(body)) resp.Body.Close() - p.Unregister(route.Method, route.Path) + err = p.Unregister(route.Method, route.Path) + require.NoError(t, err, "error unregistering route") resp, err = p.Call(route.Method, route.Path) require.NoError(t, err, "error calling parrot") diff --git a/parrot/recorder.go b/parrot/recorder.go new file mode 100644 index 000000000..67ff906fa --- /dev/null +++ b/parrot/recorder.go @@ -0,0 +1,13 @@ +package parrot + +import "net/url" + +type Recorder struct { + URL *url.URL +} + +func NewRecorder(url *url.URL) *Recorder { + return &Recorder{ + URL: url, + } +} From 79453fae7540eaae6f5b0d50d5ebeaeac3c23590 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 16 Jan 2025 14:44:30 -0500 Subject: [PATCH 09/30] Better logging --- .gitignore | 3 +- parrot/parrot.go | 229 ++++++++++++++++++++-------------------- parrot/recorder.go | 83 ++++++++++++++- parrot/recorder_test.go | 1 + 4 files changed, 195 insertions(+), 121 deletions(-) create mode 100644 parrot/recorder_test.go diff --git a/.gitignore b/.gitignore index 5d806284e..69873e7f2 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,5 @@ __debug* import_keys_test.go tag.py -parrot/*.json \ No newline at end of file +parrot/*.json +parrot/*.log \ No newline at end of file diff --git a/parrot/parrot.go b/parrot/parrot.go index 6e11d32d0..d405b1ce9 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/url" @@ -46,18 +47,24 @@ type RouteRequestBody struct { // Server is a mock HTTP server that can register and respond to dynamic routes type Server struct { - port int - host string - address string - saveFile string - log zerolog.Logger + port int + host string + address string + + saveFileName string + useCustomLogger bool + logFileName string + logFile *os.File + logLevel zerolog.Level + jsonLogs bool + log zerolog.Logger server *http.Server routes map[string]*Route // Store routes based on "Method:Path" keys routesMu sync.RWMutex - recorders map[string]*Recorder - recordersMu sync.RWMutex + recorderHooks []string + recordersMu sync.RWMutex } // ServerOption defines functional options for configuring the ParrotServer @@ -66,36 +73,18 @@ type ServerOption func(*Server) error // WithPort sets the port for the ParrotServer to run on func WithPort(port int) ServerOption { return func(s *Server) error { - if port == 0 { - s.log.Debug().Msg("Configuring Parrot: No port specified, using random port") - } else if port < 0 || port > 65535 { + if port < 0 || port > 65535 { return fmt.Errorf("invalid port: %d", port) } s.port = port - s.log.Debug().Int("port", port).Msg("Configuring Parrot: Setting port") return nil } } +// WithLogLevel sets the visible log level of the default logger func WithLogLevel(level zerolog.Level) ServerOption { return func(s *Server) error { - s.log = s.log.Level(level) - s.log.Debug().Str("log level", level.String()).Msg("Configuring Parrot: Setting log level") - return nil - } -} - -func WithLogFile(logFile string) ServerOption { - return func(s *Server) error { - if logFile == "" { - return fmt.Errorf("invalid log file name: %s", logFile) - } - file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("failed to open log file: %w", err) - } - s.log = s.log.Output(file) - s.log.Debug().Str("file", logFile).Msg("Configuring Parrot: Setting log file") + s.logLevel = level return nil } } @@ -104,7 +93,7 @@ func WithLogFile(logFile string) ServerOption { func WithLogger(l zerolog.Logger) ServerOption { return func(s *Server) error { s.log = l - s.log.Debug().Msg("Configuring Parrot: Setting custom logger") + s.useCustomLogger = true return nil } } @@ -112,8 +101,7 @@ func WithLogger(l zerolog.Logger) ServerOption { // WithJSONLogs sets the logger to output JSON logs func WithJSONLogs() ServerOption { return func(s *Server) error { - s.log = s.log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}) - s.log.Debug().Msg("Configuring Parrot: Setting log output to JSON") + s.jsonLogs = true return nil } } @@ -124,8 +112,18 @@ func WithSaveFile(saveFile string) ServerOption { if saveFile == "" { return fmt.Errorf("invalid save file name: %s", saveFile) } - s.saveFile = saveFile - s.log.Debug().Str("file", saveFile).Msg("Configuring Parrot: Setting save file") + s.saveFileName = saveFile + return nil + } +} + +// WithLogFile sets the file to save the logs to +func WithLogFile(logFile string) ServerOption { + return func(s *Server) error { + if logFile == "" { + return fmt.Errorf("invalid log file name: %s", logFile) + } + s.logFileName = logFile return nil } } @@ -137,7 +135,6 @@ func WithRoutes(routes []*Route) ServerOption { if err := s.Register(route); err != nil { return fmt.Errorf("failed to register route: %w", err) } - s.log.Debug().Str("Path", route.Path).Str("Method", route.Method).Msg("Configuring Parrot: Pre-registered route") } return nil } @@ -146,10 +143,10 @@ func WithRoutes(routes []*Route) ServerOption { // Wake creates a new Parrot server with dynamic route handling func Wake(options ...ServerOption) (*Server, error) { p := &Server{ - port: 0, - saveFile: "save.json", - log: zerolog.New(os.Stderr).Level(zerolog.InfoLevel).With(). - Timestamp().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano}), + port: 0, + saveFileName: "parrot_save.json", + logLevel: zerolog.InfoLevel, + logFileName: "parrot.log", routes: make(map[string]*Route), routesMu: sync.RWMutex{}, @@ -161,7 +158,31 @@ func Wake(options ...ServerOption) (*Server, error) { } } - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", p.port)) + var err error + p.logFile, err = os.Create(p.logFileName) + if err != nil { + return nil, fmt.Errorf("failed to create log file: %w", err) + } + + if !p.useCustomLogger { // Build default logger + var writers []io.Writer + + if p.jsonLogs { + writers = append(writers, os.Stderr) + } else { + consoleOut := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano} + writers = append(writers, consoleOut) + } + + if p.logFile != nil { + writers = append(writers, p.logFile) + } + + multiWriter := zerolog.MultiLevelWriter(writers...) + p.log = zerolog.New(multiWriter).Level(p.logLevel).With().Timestamp().Logger() + } + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p.port)) if err != nil { return nil, fmt.Errorf("failed to start listener: %w", err) } @@ -183,7 +204,7 @@ func Wake(options ...ServerOption) (*Server, error) { p.server = &http.Server{ ReadHeaderTimeout: 5 * time.Second, - Addr: fmt.Sprintf(":%d", p.port), + Addr: listener.Addr().String(), Handler: mux, } @@ -191,20 +212,26 @@ func Wake(options ...ServerOption) (*Server, error) { return nil, fmt.Errorf("failed to load saved routes: %w", err) } - go func() { - defer func() { - if err = p.save(); err != nil { - p.log.Error().Err(err).Msg("Failed to save routes") - } - }() + go p.run(listener) + + return p, nil +} - p.log.Info().Int("port", p.Port()).Str("address", p.address).Str("save file", p.saveFile).Msg("Parrot awake and ready to squawk") - if err = p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - p.log.Fatal().Err(err).Msg("Error while running server") +func (p *Server) run(listener net.Listener) { + defer func() { + if err := p.save(); err != nil { + p.log.Error().Err(err).Msg("Failed to save routes") + } + if err := p.logFile.Close(); err != nil { + p.log.Error().Err(err).Msg("Failed to close log file") } }() - return p, nil + p.log.Info().Int("Port", p.Port()).Str("Address", p.address).Msg("Parrot awake and ready to squawk") + p.log.Debug().Str("Save File", p.saveFileName).Str("Log File", p.logFileName).Msg("Configuration") + if err := p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + p.log.Fatal().Err(err).Msg("Error while running server") + } } // Shutdown gracefully shuts down the parrot server @@ -328,73 +355,38 @@ func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { } } -// Record registers a new recorder with the parrot. All incoming requests to the parrot will be recorded at that webhook. -func (p *Server) Record(url *url.URL) error { +// Record registers a new recorder with the parrot. All incoming requests to the parrot will be sent to the recorder. +func (p *Server) Record(recorder *Recorder) error { p.recordersMu.Lock() defer p.recordersMu.Unlock() - p.recorders[url.String()] = NewRecorder(url) + p.recorderHooks = append(p.recorderHooks, recorder.URL) return nil } func (p *Server) recordHandler(w http.ResponseWriter, r *http.Request) { const parrotPath = "/record" - if r.Method == http.MethodPost { - var recorder *Recorder - if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to decode request body") - return - } - - err := p.Record(recorder.URL) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to add recorder") - return - } - - w.WriteHeader(http.StatusCreated) - p.log.Info().Str("Recorder URL", recorder.URL.String()).Str("Parrot Path", parrotPath).Msg("Recorder added") - } else if r.Method == http.MethodDelete { - var recorder *Recorder - if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to decode request body") - return - } - - err := p.StopRecord(recorder.URL) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - p.log.Trace(). - Err(err). - Str("Parrot Path", parrotPath). - Str("Recorder", recorder.URL.String()). - Msg("Failed to stop recording") - return - } - p.log.Info().Str("Recorder URL", recorder.URL.String()).Str("Parrot Path", parrotPath).Msg("Recorder removed") - } else { + if r.Method != http.MethodPost { http.Error(w, "Invalid method, only use POST or DELETE", http.StatusMethodNotAllowed) p.log.Trace().Str("Method", r.Method).Msg("Invalid method") return } -} -// Record registers a new recorder with the parrot. All incoming requests to the parrot will be recorded at that webhook. -func (p *Server) StopRecord(url *url.URL) error { - p.recordersMu.RLock() - _, exists := p.recorders[url.String()] - p.recordersMu.RUnlock() + var recorder *Recorder + if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to decode request body") + return + } - if !exists { - return newDynamicError(ErrRecorderNotFound, fmt.Sprintf("'%s'", url.String())) + err := p.Record(recorder) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to add recorder") + return } - p.recordersMu.Lock() - defer p.recordersMu.Unlock() - delete(p.recorders, url.String()) - return nil + w.WriteHeader(http.StatusCreated) + p.log.Info().Str("Recorder URL", recorder.URL).Str("Parrot Path", parrotPath).Msg("Recorder added") } // Routes returns all registered routes @@ -450,7 +442,10 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { if route.Handler != nil { p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Calling route handler") route.Handler(w, r) - } else if route.RawResponseBody != "" { + return + } + + if route.RawResponseBody != "" { if route.ResponseContentType == "" { w.Header().Set("Content-Type", "text/plain") } @@ -465,7 +460,10 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { Str("Path", r.URL.Path). Str("Method", r.Method). Msg("Returned raw response") - } else if route.ResponseBody != nil { + return + } + + if route.ResponseBody != nil { if route.ResponseContentType == "" { w.Header().Set("Content-Type", "application/json") } @@ -495,26 +493,27 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { Str("Path", r.URL.Path). Str("Method", r.Method). Msg("Returned JSON response") - } else { - p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route has no response") + return } + + p.log.Error().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route has no response") } // load loads all registered routes from a file. func (p *Server) load() error { - if _, err := os.Stat(p.saveFile); os.IsNotExist(err) { - p.log.Trace().Str("file", p.saveFile).Msg("No routes to load") + if _, err := os.Stat(p.saveFileName); os.IsNotExist(err) { + p.log.Trace().Str("file", p.saveFileName).Msg("No routes to load") return nil } - p.log.Debug().Str("file", p.saveFile).Msg("Loading routes") + p.log.Debug().Str("file", p.saveFileName).Msg("Loading routes") - data, err := os.ReadFile(p.saveFile) + data, err := os.ReadFile(p.saveFileName) if err != nil { return fmt.Errorf("failed to read routes from file: %w", err) } if len(data) == 0 { - p.log.Trace().Str("file", p.saveFile).Msg("No routes to load") + p.log.Trace().Str("file", p.saveFileName).Msg("No routes to load") return nil } @@ -525,7 +524,7 @@ func (p *Server) load() error { return fmt.Errorf("failed to unmarshal routes: %w", err) } - p.log.Info().Str("file", p.saveFile).Int("number", len(p.routes)).Msg("Loaded routes") + p.log.Info().Str("file", p.saveFileName).Int("number", len(p.routes)).Msg("Loaded routes") return nil } @@ -535,7 +534,7 @@ func (p *Server) save() error { p.log.Debug().Msg("No routes to save") return nil } - p.log.Trace().Str("file", p.saveFile).Msg("Saving routes") + p.log.Trace().Str("file", p.saveFileName).Msg("Saving routes") p.routesMu.RLock() defer p.routesMu.RUnlock() @@ -545,11 +544,11 @@ func (p *Server) save() error { return fmt.Errorf("failed to marshal routes: %w", err) } - if err = os.WriteFile(p.saveFile, jsonData, 0644); err != nil { //nolint:gosec + if err = os.WriteFile(p.saveFileName, jsonData, 0644); err != nil { //nolint:gosec return fmt.Errorf("failed to write routes to file: %w", err) } - p.log.Trace().Str("file", p.saveFile).Msg("Saved routes") + p.log.Trace().Str("file", p.saveFileName).Msg("Saved routes") return nil } diff --git a/parrot/recorder.go b/parrot/recorder.go index 67ff906fa..b02115ca9 100644 --- a/parrot/recorder.go +++ b/parrot/recorder.go @@ -1,13 +1,86 @@ package parrot -import "net/url" +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "time" +) type Recorder struct { - URL *url.URL + URL string `json:"url"` + server *http.Server + + recordChan chan *RouteCall + errChan chan error +} + +// RouteCall records that a route was called +type RouteCall struct { + RouteID string `json:"route_id"` + Request http.Request `json:"request"` } -func NewRecorder(url *url.URL) *Recorder { - return &Recorder{ - URL: url, +type RecorderOption func(*Recorder) error + +func NewRecorder(opts ...RecorderOption) (*Recorder, error) { + r := &Recorder{ + recordChan: make(chan *RouteCall), + errChan: make(chan error), + } + + for _, opt := range opts { + err := opt(r) + if err != nil { + return nil, err + } + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to start listener: %w", err) + } + r.URL = listener.Addr().String() + + mux := http.NewServeMux() + mux.Handle("/record", r.defaultRecordHandler()) + r.server = &http.Server{ + ReadHeaderTimeout: 5 * time.Second, + Addr: listener.Addr().String(), + Handler: mux, + } + + go func() { + if err := r.server.Serve(listener); err != nil { + r.errChan <- err + } + }() + return r, nil +} + +// Record receives recorded calls +func (r *Recorder) Record() chan *RouteCall { + return r.recordChan +} + +func (r *Recorder) Close() error { + return r.server.Close() +} + +func (r *Recorder) Error() chan error { + return r.errChan +} + +func (r *Recorder) defaultRecordHandler() http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + var recordedCall *RouteCall + if err := json.NewDecoder(req.Body).Decode(&recordedCall); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer req.Body.Close() + + r.recordChan <- recordedCall } } diff --git a/parrot/recorder_test.go b/parrot/recorder_test.go new file mode 100644 index 000000000..0b162feeb --- /dev/null +++ b/parrot/recorder_test.go @@ -0,0 +1 @@ +package parrot From b5237068f12ff6be9313aeea33890376a26eb2dc Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 16 Jan 2025 18:48:11 -0500 Subject: [PATCH 10/30] Adds recorder --- parrot/examples_test.go | 11 ++-- parrot/go.mod | 4 +- parrot/go.sum | 9 ++- parrot/parrot.go | 110 +++++++++++++++++++++++-------------- parrot/parrot_test.go | 118 +++++++++++++++++----------------------- parrot/recorder.go | 50 ++++++++++++++++- parrot/recorder_test.go | 82 ++++++++++++++++++++++++++++ 7 files changed, 263 insertions(+), 121 deletions(-) diff --git a/parrot/examples_test.go b/parrot/examples_test.go index eca84faa6..b7b7dd67f 100644 --- a/parrot/examples_test.go +++ b/parrot/examples_test.go @@ -18,11 +18,10 @@ func ExampleServer() { // Create a new route /test that will return a 200 status code with a text/plain response body of "Squawk" route := &parrot.Route{ - Method: http.MethodGet, - Path: "/test", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: http.MethodGet, + Path: "/test", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, } // Register the route with the parrot instance @@ -39,11 +38,9 @@ func ExampleServer() { defer resp.Body.Close() fmt.Println(resp.StatusCode) - fmt.Println(resp.Header.Get("Content-Type")) body, _ := io.ReadAll(resp.Body) fmt.Println(string(body)) // Output: // 200 - // text/plain // Squawk } diff --git a/parrot/go.mod b/parrot/go.mod index 51a8e99c9..5d6240d64 100644 --- a/parrot/go.mod +++ b/parrot/go.mod @@ -3,6 +3,7 @@ module github.com/smartcontractkit/chainlink-testing-framework/parrot go 1.23.4 require ( + github.com/go-resty/resty/v2 v2.16.3 github.com/rs/zerolog v1.33.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 @@ -15,6 +16,7 @@ require ( github.com/mattn/go-isatty v0.0.19 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/parrot/go.sum b/parrot/go.sum index 3e73915d5..72cc68587 100644 --- a/parrot/go.sum +++ b/parrot/go.sum @@ -2,6 +2,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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/go-resty/resty/v2 v2.16.3 h1:zacNT7lt4b8M/io2Ahj6yPypL7bqx9n1iprfQuodV+E= +github.com/go-resty/resty/v2 v2.16.3/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -23,10 +25,15 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/parrot/parrot.go b/parrot/parrot.go index d405b1ce9..979213e2f 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -17,6 +17,7 @@ import ( "sync" "time" + "github.com/go-resty/resty/v2" "github.com/rs/zerolog" ) @@ -35,14 +36,16 @@ type Route struct { ResponseBody any `json:"response_body"` // ResponseStatusCode is the HTTP status code to return when called ResponseStatusCode int `json:"response_status_code"` - // ResponseContentType is the Content-Type header to return the response with - ResponseContentType string `json:"response_content_type"` } -// RouteRequestBody is the request body for querying the server on a specific route -type RouteRequestBody struct { - Method string `json:"method"` - Path string `json:"path"` +// ID returns the unique identifier for the route +func (r *Route) ID() string { + return r.Method + ":" + r.Path +} + +// RouteRequest is the request body for querying the server on a specific route +type RouteRequest struct { + ID string `json:"id"` } // Server is a mock HTTP server that can register and respond to dynamic routes @@ -286,7 +289,7 @@ func (p *Server) Register(route *Route) error { p.routesMu.Lock() defer p.routesMu.Unlock() - p.routes[route.Method+":"+route.Path] = route + p.routes[route.ID()] = route return nil } @@ -295,20 +298,20 @@ func (p *Server) Register(route *Route) error { func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { const parrotPath = "/register" if r.Method == http.MethodDelete { - var routeRequestBody *RouteRequestBody - if err := json.NewDecoder(r.Body).Decode(&routeRequestBody); err != nil { + var routeRequest *RouteRequest + if err := json.NewDecoder(r.Body).Decode(&routeRequest); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } defer r.Body.Close() - if routeRequestBody.Method == "" || routeRequestBody.Path == "" { - err := errors.New("Method and path are required") + if routeRequest.ID == "" { + err := errors.New("ID required") http.Error(w, err.Error(), http.StatusBadRequest) return } - err := p.Unregister(routeRequestBody.Method, routeRequestBody.Path) + err := p.Unregister(routeRequest.ID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) p.log.Trace().Err(err).Str("Path", parrotPath).Msg("Failed to unregister route") @@ -317,9 +320,8 @@ func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) p.log.Info(). - Str("Route Path", routeRequestBody.Path). + Str("Route ID", routeRequest.ID). Str("Parrot Path", parrotPath). - Str("Method", routeRequestBody.Method). Msg("Route unregistered") } else if r.Method == http.MethodPost { var route *Route @@ -389,25 +391,18 @@ func (p *Server) recordHandler(w http.ResponseWriter, r *http.Request) { p.log.Info().Str("Recorder URL", recorder.URL).Str("Parrot Path", parrotPath).Msg("Recorder added") } -// Routes returns all registered routes -func (p *Server) Routes() map[string]*Route { - p.routesMu.RLock() - defer p.routesMu.RUnlock() - return p.routes -} - // Unregister removes a route from the parrot -func (p *Server) Unregister(method, path string) error { +func (p *Server) Unregister(routeID string) error { p.routesMu.RLock() - _, exists := p.routes[method+":"+path] + _, exists := p.routes[routeID] p.routesMu.RUnlock() if !exists { - return newDynamicError(ErrRouteNotFound, fmt.Sprintf("%s %s", method, path)) + return newDynamicError(ErrRouteNotFound, routeID) } p.routesMu.Lock() defer p.routesMu.Unlock() - delete(p.routes, method+":"+path) + delete(p.routes, routeID) return nil } @@ -428,30 +423,36 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { route, exists := p.routes[r.Method+":"+r.URL.Path] p.routesMu.RUnlock() - if !exists { - http.NotFound(w, r) - p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route not found") - return + routeCall := &RouteCall{ + RouteID: r.Method + ":" + r.URL.Path, + Request: r, } + recordingWriter := newResponseWriterRecorder(w) + + defer func() { + routeCall.Response = recordingWriter.Result() + p.sendToRecorders(routeCall) + }() - if route.ResponseContentType != "" { - w.Header().Set("Content-Type", route.ResponseContentType) + if !exists { // Route not found + http.NotFound(recordingWriter, r) + p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route not found") + return } - w.WriteHeader(route.ResponseStatusCode) + // Let the custom handler take over if it exists if route.Handler != nil { p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Calling route handler") - route.Handler(w, r) + route.Handler(recordingWriter, r) return } + recordingWriter.WriteHeader(route.ResponseStatusCode) + if route.RawResponseBody != "" { - if route.ResponseContentType == "" { - w.Header().Set("Content-Type", "text/plain") - } if _, err := w.Write([]byte(route.RawResponseBody)); err != nil { p.log.Trace().Err(err).Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Failed to write response") - http.Error(w, "Failed to write response", http.StatusInternalServerError) + http.Error(recordingWriter, "Failed to write response", http.StatusInternalServerError) return } p.log.Trace(). @@ -464,9 +465,6 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { } if route.ResponseBody != nil { - if route.ResponseContentType == "" { - w.Header().Set("Content-Type", "application/json") - } rawJSON, err := json.Marshal(route.ResponseBody) if err != nil { p.log.Trace().Err(err). @@ -474,7 +472,7 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { Str("Path", r.URL.Path). Str("Method", r.Method). Msg("Failed to marshal JSON response") - http.Error(w, "Failed to marshal response into json", http.StatusInternalServerError) + http.Error(recordingWriter, "Failed to marshal response into json", http.StatusInternalServerError) return } if _, err = w.Write(rawJSON); err != nil { @@ -484,7 +482,7 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { Str("Path", r.URL.Path). Str("Method", r.Method). Msg("Failed to write response") - http.Error(w, "Failed to write JSON response", http.StatusInternalServerError) + http.Error(recordingWriter, "Failed to write JSON response", http.StatusInternalServerError) return } p.log.Trace(). @@ -552,6 +550,34 @@ func (p *Server) save() error { return nil } +// sendToRecorders sends the route call to all registered recorders +func (p *Server) sendToRecorders(routeCall *RouteCall) { + p.recordersMu.RLock() + defer p.recordersMu.RUnlock() + + client := resty.New() + + for _, hook := range p.recorderHooks { + go func(hook string) { + p.log.Trace().Str("Recorder Hook", hook).Msg("Sending route call to recorder") + resp, err := client.R().SetBody(routeCall).Post(hook) + if err != nil { + p.log.Error().Err(err).Str("Recorder Hook", hook).Msg("Failed to send route call to recorder") + return + } + if resp.IsError() { + p.log.Error(). + Str("Recorder Hook", hook). + Int("Code", resp.StatusCode()). + Str("Response", resp.String()). + Msg("Failed to send route call to recorder") + return + } + p.log.Debug().Str("Recorder Hook", hook).Msg("Route call sent to recorder") + }(hook) + } +} + var pathRegex = regexp.MustCompile(`^\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@\/]*$`) func isValidPath(path string) bool { diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index 4bc5a159c..d8dbe65be 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -51,21 +51,19 @@ func TestRegister(t *testing.T) { { name: "simple route", route: &Route{ - Method: http.MethodPost, - Path: "/hello", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: http.MethodPost, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, }, }, { name: "json route", route: &Route{ - Method: http.MethodGet, - Path: "/json", - ResponseBody: map[string]any{"message": "Squawk"}, - ResponseStatusCode: 200, - ResponseContentType: "application/json", + Method: http.MethodGet, + Path: "/json", + ResponseBody: map[string]any{"message": "Squawk"}, + ResponseStatusCode: 200, }, }, } @@ -82,7 +80,6 @@ func TestRegister(t *testing.T) { defer resp.Body.Close() assert.Equal(t, resp.StatusCode, tc.route.ResponseStatusCode) - assert.Equal(t, resp.Header.Get("Content-Type"), tc.route.ResponseContentType) body, _ := io.ReadAll(resp.Body) if tc.route.ResponseBody != nil { jsonBody, err := json.Marshal(tc.route.ResponseBody) @@ -152,42 +149,38 @@ func TestBadRegisterRoute(t *testing.T) { name: "no method", err: ErrNoMethod, route: &Route{ - Path: "/hello", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, }, }, { name: "no path", err: ErrInvalidPath, route: &Route{ - Method: http.MethodGet, - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "application/json", + Method: http.MethodGet, + RawResponseBody: "Squawk", + ResponseStatusCode: 200, }, }, { name: "base path", err: ErrInvalidPath, route: &Route{ - Method: http.MethodGet, - Path: "/", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "application/json", + Method: http.MethodGet, + Path: "/", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, }, }, { name: "invalid path", err: ErrInvalidPath, route: &Route{ - Method: http.MethodGet, - Path: "invalid path", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "application/json", + Method: http.MethodGet, + Path: "invalid path", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, }, }, } @@ -224,11 +217,10 @@ func TestUnregister(t *testing.T) { require.NoError(t, err, "error waking parrot") route := &Route{ - Method: http.MethodPost, - Path: "/hello", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: http.MethodPost, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, } err = p.Register(route) @@ -238,12 +230,11 @@ func TestUnregister(t *testing.T) { require.NoError(t, err, "error calling parrot") assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) - assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) body, _ := io.ReadAll(resp.Body) assert.Equal(t, route.RawResponseBody, string(body)) resp.Body.Close() - err = p.Unregister(route.Method, route.Path) + err = p.Unregister(route.ID()) require.NoError(t, err, "error unregistering route") resp, err = p.Call(route.Method, route.Path) @@ -260,18 +251,16 @@ func TestSaveLoad(t *testing.T) { routes := []*Route{ { - Method: "GET", - Path: "/hello", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: "GET", + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, }, { - Method: "Post", - Path: "/goodbye", - RawResponseBody: "Squeak", - ResponseStatusCode: 201, - ResponseContentType: "text/plain", + Method: "Post", + Path: "/goodbye", + RawResponseBody: "Squeak", + ResponseStatusCode: 201, }, } @@ -296,7 +285,6 @@ func TestSaveLoad(t *testing.T) { require.NoError(t, err, "error calling parrot") assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) - assert.Equal(t, resp.Header.Get("Content-Type"), route.ResponseContentType) body, _ := io.ReadAll(resp.Body) assert.Equal(t, route.RawResponseBody, string(body)) resp.Body.Close() @@ -308,11 +296,10 @@ func BenchmarkRegisterRoute(b *testing.B) { require.NoError(b, err) route := &Route{ - Method: "GET", - Path: "/bench", - RawResponseBody: "Benchmark Response", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: "GET", + Path: "/bench", + RawResponseBody: "Benchmark Response", + ResponseStatusCode: 200, } b.ResetTimer() @@ -327,11 +314,10 @@ func BenchmarkRouteResponse(b *testing.B) { require.NoError(b, err) route := &Route{ - Method: "GET", - Path: "/bench", - RawResponseBody: "Benchmark Response", - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: "GET", + Path: "/bench", + RawResponseBody: "Benchmark Response", + ResponseStatusCode: 200, } err = p.Register(route) require.NoError(b, err) @@ -351,11 +337,10 @@ func BenchmarkSaveRoutes(b *testing.B) { for i := 0; i < 1000; i++ { routes = append(routes, &Route{ - Method: "GET", - Path: fmt.Sprintf("/bench%d", i), - RawResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + RawResponseBody: fmt.Sprintf("Squawk %d", i), + ResponseStatusCode: 200, }) } p, err := Wake(WithRoutes(routes), WithLogLevel(zerolog.Disabled), WithSaveFile(saveFile)) @@ -383,11 +368,10 @@ func BenchmarkLoadRoutes(b *testing.B) { for i := 0; i < 1000; i++ { routes = append(routes, &Route{ - Method: "GET", - Path: fmt.Sprintf("/bench%d", i), - RawResponseBody: fmt.Sprintf("{\"message\":\"Response %d\"}", i), - ResponseStatusCode: 200, - ResponseContentType: "text/plain", + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + RawResponseBody: fmt.Sprintf("Squawk %d", i), + ResponseStatusCode: 200, }) } p, err := Wake(WithRoutes(routes), WithLogLevel(zerolog.Disabled), WithSaveFile(saveFile)) diff --git a/parrot/recorder.go b/parrot/recorder.go index b02115ca9..0513b8d33 100644 --- a/parrot/recorder.go +++ b/parrot/recorder.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "net/http/httptest" "time" ) @@ -18,8 +19,9 @@ type Recorder struct { // RouteCall records that a route was called type RouteCall struct { - RouteID string `json:"route_id"` - Request http.Request `json:"request"` + RouteID string `json:"route_id"` + Request *http.Request `json:"request"` + Response *http.Response `json:"response"` } type RecorderOption func(*Recorder) error @@ -44,7 +46,7 @@ func NewRecorder(opts ...RecorderOption) (*Recorder, error) { r.URL = listener.Addr().String() mux := http.NewServeMux() - mux.Handle("/record", r.defaultRecordHandler()) + mux.Handle("/", r.defaultRecordHandler()) r.server = &http.Server{ ReadHeaderTimeout: 5 * time.Second, Addr: listener.Addr().String(), @@ -84,3 +86,45 @@ func (r *Recorder) defaultRecordHandler() http.HandlerFunc { r.recordChan <- recordedCall } } + +// httpResponseRecorder is a wrapper around http.ResponseWriter that records the response +// for later inspection while still writing to the original writer. +// WARNING: If you mutate after calling Header(), the changes will not be reflected in the recorded response. +type responseWriterRecorder struct { + originalWriter http.ResponseWriter + record *httptest.ResponseRecorder +} + +func newResponseWriterRecorder(w http.ResponseWriter) *responseWriterRecorder { + return &responseWriterRecorder{ + originalWriter: w, + record: httptest.NewRecorder(), + } +} + +// SetWriter sets a new writer to record and write to, flushing any previous record +func (rr *responseWriterRecorder) SetWriter(w http.ResponseWriter) { + rr.originalWriter = w + rr.record = httptest.NewRecorder() +} + +func (rr *responseWriterRecorder) WriteHeader(code int) { + rr.originalWriter.WriteHeader(code) + rr.record.WriteHeader(code) +} + +func (rr *responseWriterRecorder) Write(data []byte) (int, error) { + _, _ = rr.record.Write(data) // ignore error as we still want to write to the original writer + return rr.originalWriter.Write(data) +} + +func (rr *responseWriterRecorder) Header() http.Header { + for k, v := range rr.originalWriter.Header() { + rr.record.Header()[k] = v + } + return rr.originalWriter.Header() +} + +func (rr *responseWriterRecorder) Result() *http.Response { + return rr.record.Result() +} diff --git a/parrot/recorder_test.go b/parrot/recorder_test.go index 0b162feeb..7be28b733 100644 --- a/parrot/recorder_test.go +++ b/parrot/recorder_test.go @@ -1 +1,83 @@ package parrot + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResponseWriterRecorder(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + responseFunc http.HandlerFunc + expectedRespCode int + expectedRespBody string + expectedRespHeader http.Header + }{ + { + name: "good response", + responseFunc: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("Squawk")) + require.NoError(t, err, "error writing response") + }, + expectedRespCode: http.StatusOK, + expectedRespBody: "Squawk", + expectedRespHeader: http.Header{ + "Content-Type": []string{"text/plain"}, + }, + }, + { + name: "error response", + responseFunc: func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Squawk", http.StatusInternalServerError) + }, + expectedRespCode: http.StatusInternalServerError, + expectedRespBody: "Squawk\n", // http.Error adds a newline + expectedRespHeader: http.Header{ + "Content-Type": []string{"text/plain"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + writerRecorder := newResponseWriterRecorder(recorder) + req := httptest.NewRequest(http.MethodGet, "/", nil) + + handler := http.HandlerFunc(tc.responseFunc) + handler.ServeHTTP(writerRecorder, req) + + actualResp := recorder.Result() + recordedResp := writerRecorder.Result() + t.Cleanup(func() { + _ = actualResp.Body.Close() + _ = recordedResp.Body.Close() + }) + + actualBody, err := io.ReadAll(actualResp.Body) + require.NoError(t, err, "error reading actual response body") + recordedBody, err := io.ReadAll(recordedResp.Body) + require.NoError(t, err, "error reading recorded response body") + + assert.Equal(t, tc.expectedRespCode, actualResp.StatusCode, "actual response has unexpected status code") + assert.Equal(t, tc.expectedRespCode, recordedResp.StatusCode, "recorded response has unexpected status code") + assert.Equal(t, tc.expectedRespBody, string(actualBody), "actual response has unexpected body") + assert.Equal(t, tc.expectedRespBody, string(recordedBody), "recorded response has unexpected body") + }) + } +} + +func TestRecorder(t *testing.T) { + +} From 42dda5f8315e60c14ed2238e5149e593afa101f9 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 22 Jan 2025 21:12:20 -0500 Subject: [PATCH 11/30] Better middleware --- parrot/errors.go | 17 +++-- parrot/go.mod | 2 + parrot/go.sum | 3 + parrot/parrot.go | 160 +++++++++++++++++++++++++++------------- parrot/parrot_test.go | 53 ++++++++++++- parrot/recorder.go | 42 +++++++---- parrot/recorder_test.go | 54 ++++++++++++++ 7 files changed, 253 insertions(+), 78 deletions(-) diff --git a/parrot/errors.go b/parrot/errors.go index ef3ecdbe1..767cd5a50 100644 --- a/parrot/errors.go +++ b/parrot/errors.go @@ -6,13 +6,16 @@ import ( ) var ( - ErrNilRoute = errors.New("route is nil") - ErrNoMethod = errors.New("no method specified") - ErrInvalidPath = errors.New("invalid path") - ErrNoResponse = errors.New("route must have a handler or some response") - ErrOnlyOneResponse = errors.New("route can only have one response type") - ErrResponseMarshal = errors.New("unable to marshal response body to JSON") - ErrRouteNotFound = errors.New("route not found") + ErrNilRoute = errors.New("route is nil") + ErrNoMethod = errors.New("no method specified") + ErrInvalidPath = errors.New("invalid path") + ErrNoResponse = errors.New("route must have a handler or some response") + ErrOnlyOneResponse = errors.New("route can only have one response type") + ErrResponseMarshal = errors.New("unable to marshal response body to JSON") + ErrRouteNotFound = errors.New("route not found") + + ErrNoRecorderURL = errors.New("no recorder URL specified") + ErrNilRecorder = errors.New("recorder is nil") ErrRecorderNotFound = errors.New("recorder not found") ) diff --git a/parrot/go.mod b/parrot/go.mod index 5d6240d64..18fc13a2e 100644 --- a/parrot/go.mod +++ b/parrot/go.mod @@ -4,6 +4,7 @@ go 1.23.4 require ( github.com/go-resty/resty/v2 v2.16.3 + github.com/google/uuid v1.6.0 github.com/rs/zerolog v1.33.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 @@ -15,6 +16,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.5.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sys v0.28.0 // indirect diff --git a/parrot/go.sum b/parrot/go.sum index 72cc68587..da5bd8c5f 100644 --- a/parrot/go.sum +++ b/parrot/go.sum @@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/go-resty/resty/v2 v2.16.3 h1:zacNT7lt4b8M/io2Ahj6yPypL7bqx9n1iprfQuodV+E= github.com/go-resty/resty/v2 v2.16.3/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -15,6 +17,7 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= diff --git a/parrot/parrot.go b/parrot/parrot.go index 979213e2f..abf029664 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -18,7 +18,9 @@ import ( "time" "github.com/go-resty/resty/v2" + "github.com/google/uuid" "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" ) // Route holds information about the mock route configuration @@ -173,7 +175,7 @@ func Wake(options ...ServerOption) (*Server, error) { if p.jsonLogs { writers = append(writers, os.Stderr) } else { - consoleOut := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano} + consoleOut := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "2006-01-02T15:04:05.000"} writers = append(writers, consoleOut) } @@ -208,7 +210,7 @@ func Wake(options ...ServerOption) (*Server, error) { p.server = &http.Server{ ReadHeaderTimeout: 5 * time.Second, Addr: listener.Addr().String(), - Handler: mux, + Handler: p.loggingMiddleware(mux), } if err = p.load(); err != nil { @@ -290,43 +292,49 @@ func (p *Server) Register(route *Route) error { p.routesMu.Lock() defer p.routesMu.Unlock() p.routes[route.ID()] = route + p.log.Info(). + Str("Route ID", route.ID()). + Str("Path", route.Path). + Str("Method", route.Method). + Msg("Route registered") return nil } // registerRouteHandler handles the dynamic route registration. func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { - const parrotPath = "/register" + registerLogger := zerolog.Ctx(r.Context()) if r.Method == http.MethodDelete { var routeRequest *RouteRequest if err := json.NewDecoder(r.Body).Decode(&routeRequest); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) + registerLogger.Debug().Err(err).Msg("Failed to decode request body") return } defer r.Body.Close() if routeRequest.ID == "" { - err := errors.New("ID required") - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, "Route ID required", http.StatusBadRequest) + registerLogger.Debug().Msg("No Route ID provided") return } err := p.Unregister(routeRequest.ID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) - p.log.Trace().Err(err).Str("Path", parrotPath).Msg("Failed to unregister route") + registerLogger.Debug().Err(err).Msg("Failed to unregister route") return } w.WriteHeader(http.StatusNoContent) - p.log.Info(). + registerLogger.Info(). Str("Route ID", routeRequest.ID). - Str("Parrot Path", parrotPath). Msg("Route unregistered") } else if r.Method == http.MethodPost { var route *Route if err := json.NewDecoder(r.Body).Decode(&route); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) + registerLogger.Debug().Err(err).Msg("Failed to decode request body") return } defer r.Body.Close() @@ -334,25 +342,21 @@ func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { if route.Method == "" || route.Path == "" { err := errors.New("Method and path are required") http.Error(w, err.Error(), http.StatusBadRequest) + registerLogger.Debug().Err(err).Msg("Method and path are required") return } err := p.Register(route) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) - p.log.Trace().Err(err).Msg("Failed to register route") + registerLogger.Debug().Err(err).Msg("Failed to register route") return } w.WriteHeader(http.StatusCreated) - p.log.Info(). - Str("Parrot Path", parrotPath). - Str("Route Path", route.Path). - Str("Method", route.Method). - Msg("Route registered") } else { http.Error(w, "Invalid method, only use POST or DELETE", http.StatusMethodNotAllowed) - p.log.Trace().Str("Method", r.Method).Msg("Invalid method") + registerLogger.Debug().Msg("Invalid method") return } } @@ -361,34 +365,44 @@ func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { func (p *Server) Record(recorder *Recorder) error { p.recordersMu.Lock() defer p.recordersMu.Unlock() + if recorder == nil { + return ErrNilRecorder + } + if recorder.URL == "" { + return ErrNoRecorderURL + } + _, err := url.Parse(recorder.URL) + if err != nil { + return fmt.Errorf("failed to parse recorder URL: %w", err) + } p.recorderHooks = append(p.recorderHooks, recorder.URL) return nil } func (p *Server) recordHandler(w http.ResponseWriter, r *http.Request) { - const parrotPath = "/record" + recordLogger := zerolog.Ctx(r.Context()) if r.Method != http.MethodPost { http.Error(w, "Invalid method, only use POST or DELETE", http.StatusMethodNotAllowed) - p.log.Trace().Str("Method", r.Method).Msg("Invalid method") + recordLogger.Debug().Msg("Invalid method") return } var recorder *Recorder if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) - p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to decode request body") + recordLogger.Err(err).Msg("Failed to decode request body") return } err := p.Record(recorder) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) - p.log.Trace().Err(err).Str("Parrot Path", parrotPath).Msg("Failed to add recorder") + recordLogger.Debug().Err(err).Msg("Failed to add recorder") return } w.WriteHeader(http.StatusCreated) - p.log.Info().Str("Recorder URL", recorder.URL).Str("Parrot Path", parrotPath).Msg("Recorder added") + recordLogger.Info().Str("Recorder URL", recorder.URL).Msg("Recorder added") } // Unregister removes a route from the parrot @@ -423,78 +437,98 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { route, exists := p.routes[r.Method+":"+r.URL.Path] p.routesMu.RUnlock() + dynamicLogger := zerolog.Ctx(r.Context()) + if !exists { + http.NotFound(w, r) + dynamicLogger.Debug().Msg("Route not found") + return + } + + requestID := uuid.New().String()[0:8] + dynamicLogger.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("Request ID", requestID).Str("Route ID", route.ID()) + }) + + requestBody, err := io.ReadAll(r.Body) + if err != nil { + dynamicLogger.Debug(). + Err(err). + Msg("Failed to read request body") + http.Error(w, "Failed to read request body", http.StatusInternalServerError) + return + } + routeCall := &RouteCall{ RouteID: r.Method + ":" + r.URL.Path, - Request: r, + Request: &RouteCallRequest{ + Method: r.Method, + URL: r.URL, + Header: r.Header, + Body: requestBody, + }, } recordingWriter := newResponseWriterRecorder(w) defer func() { - routeCall.Response = recordingWriter.Result() + res := recordingWriter.Result() + resBody, err := io.ReadAll(res.Body) + if err != nil { + dynamicLogger.Debug().Err(err).Msg("Failed to read response body") + http.Error(w, "Failed to read response body", http.StatusInternalServerError) + return + } + + routeCall.Response = &RouteCallResponse{ + StatusCode: res.StatusCode, + Header: res.Header, + Body: resBody, + } p.sendToRecorders(routeCall) }() - if !exists { // Route not found - http.NotFound(recordingWriter, r) - p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route not found") - return - } - // Let the custom handler take over if it exists if route.Handler != nil { - p.log.Trace().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Calling route handler") + dynamicLogger.Debug().Msg("Calling route handler") route.Handler(recordingWriter, r) return } - recordingWriter.WriteHeader(route.ResponseStatusCode) - if route.RawResponseBody != "" { if _, err := w.Write([]byte(route.RawResponseBody)); err != nil { - p.log.Trace().Err(err).Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Failed to write response") + dynamicLogger.Debug().Err(err).Msg("Failed to write response") http.Error(recordingWriter, "Failed to write response", http.StatusInternalServerError) return } - p.log.Trace(). - Str("Remote Addr", r.RemoteAddr). + dynamicLogger.Debug(). Str("Response", route.RawResponseBody). - Str("Path", r.URL.Path). - Str("Method", r.Method). Msg("Returned raw response") + recordingWriter.WriteHeader(route.ResponseStatusCode) return } if route.ResponseBody != nil { rawJSON, err := json.Marshal(route.ResponseBody) if err != nil { - p.log.Trace().Err(err). - Str("Remote Addr", r.RemoteAddr). - Str("Path", r.URL.Path). - Str("Method", r.Method). - Msg("Failed to marshal JSON response") + dynamicLogger.Debug().Err(err).Msg("Failed to marshal JSON response") http.Error(recordingWriter, "Failed to marshal response into json", http.StatusInternalServerError) return } if _, err = w.Write(rawJSON); err != nil { - p.log.Trace().Err(err). + dynamicLogger.Debug().Err(err). RawJSON("Response", rawJSON). - Str("Remote Addr", r.RemoteAddr). - Str("Path", r.URL.Path). - Str("Method", r.Method). Msg("Failed to write response") http.Error(recordingWriter, "Failed to write JSON response", http.StatusInternalServerError) return } - p.log.Trace(). - Str("Remote Addr", r.RemoteAddr). + dynamicLogger.Debug(). RawJSON("Response", rawJSON). - Str("Path", r.URL.Path). - Str("Method", r.Method). Msg("Returned JSON response") + recordingWriter.WriteHeader(route.ResponseStatusCode) return } - p.log.Error().Str("Remote Addr", r.RemoteAddr).Str("Path", r.URL.Path).Str("Method", r.Method).Msg("Route has no response") + dynamicLogger.Error().Msg("Route has no response") + http.Error(recordingWriter, "Route has no response", http.StatusInternalServerError) } // load loads all registered routes from a file. @@ -554,12 +588,15 @@ func (p *Server) save() error { func (p *Server) sendToRecorders(routeCall *RouteCall) { p.recordersMu.RLock() defer p.recordersMu.RUnlock() + if len(p.recorderHooks) == 0 { + return + } client := resty.New() + p.log.Trace().Strs("Recorders", p.recorderHooks).Str("Route ID", routeCall.RouteID).Msg("Sending route call to recorders") for _, hook := range p.recorderHooks { go func(hook string) { - p.log.Trace().Str("Recorder Hook", hook).Msg("Sending route call to recorder") resp, err := client.R().SetBody(routeCall).Post(hook) if err != nil { p.log.Error().Err(err).Str("Recorder Hook", hook).Msg("Failed to send route call to recorder") @@ -573,11 +610,30 @@ func (p *Server) sendToRecorders(routeCall *RouteCall) { Msg("Failed to send route call to recorder") return } - p.log.Debug().Str("Recorder Hook", hook).Msg("Route call sent to recorder") + p.log.Trace().Str("Route ID", routeCall.RouteID).Str("Recorder Hook", hook).Msg("Route call sent to recorder") }(hook) } } +func (p *Server) loggingMiddleware(next http.Handler) http.Handler { + h := hlog.NewHandler(p.log) + + accessHandler := hlog.AccessHandler( + func(r *http.Request, status, size int, duration time.Duration) { + hlog.FromRequest(r).Trace(). + Str("Method", r.Method). + Stringer("URL", r.URL). + Int("Status Code", status). + Int("Response Size Bytes", size). + Str("Duration", duration.String()). + Str("Remote Addr", r.RemoteAddr). + Msg("Handled request") + }, + ) + + return h(accessHandler(next)) +} + var pathRegex = regexp.MustCompile(`^\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@\/]*$`) func isValidPath(path string) bool { diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index d8dbe65be..eaa35875d 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -49,9 +49,9 @@ func TestRegister(t *testing.T) { route *Route }{ { - name: "simple route", + name: "get route", route: &Route{ - Method: http.MethodPost, + Method: http.MethodGet, Path: "/hello", RawResponseBody: "Squawk", ResponseStatusCode: 200, @@ -66,6 +66,51 @@ func TestRegister(t *testing.T) { ResponseStatusCode: 200, }, }, + { + name: "post route", + route: &Route{ + Method: http.MethodPost, + Path: "/post", + RawResponseBody: "Squawk", + ResponseStatusCode: 201, + }, + }, + { + name: "put route", + route: &Route{ + Method: http.MethodPut, + Path: "/put", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + }, + { + name: "delete route", + route: &Route{ + Method: http.MethodDelete, + Path: "/delete", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + }, + { + name: "patch route", + route: &Route{ + Method: http.MethodPatch, + Path: "/patch", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + }, + { + name: "error route", + route: &Route{ + Method: http.MethodGet, + Path: "/error", + RawResponseBody: "Squawk", + ResponseStatusCode: 500, + }, + }, } for _, tc := range testCases { @@ -79,7 +124,7 @@ func TestRegister(t *testing.T) { require.NoError(t, err, "error calling parrot") defer resp.Body.Close() - assert.Equal(t, resp.StatusCode, tc.route.ResponseStatusCode) + assert.Equal(t, tc.route.ResponseStatusCode, resp.StatusCode) body, _ := io.ReadAll(resp.Body) if tc.route.ResponseBody != nil { jsonBody, err := json.Marshal(tc.route.ResponseBody) @@ -284,7 +329,7 @@ func TestSaveLoad(t *testing.T) { resp, err := p.Call(route.Method, route.Path) require.NoError(t, err, "error calling parrot") - assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) + assert.Equal(t, route.ResponseStatusCode, resp.StatusCode, "unexpected status code for route %s", route.ID()) body, _ := io.ReadAll(resp.Body) assert.Equal(t, route.RawResponseBody, string(body)) resp.Body.Close() diff --git a/parrot/recorder.go b/parrot/recorder.go index 0513b8d33..aa0cb7b27 100644 --- a/parrot/recorder.go +++ b/parrot/recorder.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "time" ) @@ -19,31 +20,38 @@ type Recorder struct { // RouteCall records that a route was called type RouteCall struct { - RouteID string `json:"route_id"` - Request *http.Request `json:"request"` - Response *http.Response `json:"response"` + RouteID string `json:"route_id"` + Request *RouteCallRequest `json:"request"` + Response *RouteCallResponse `json:"response"` } -type RecorderOption func(*Recorder) error +// RouteCallRequest records the request made to a route +type RouteCallRequest struct { + Method string `json:"method"` + URL *url.URL `json:"url"` + Header http.Header `json:"header"` + Body []byte `json:"body"` +} + +// RouteCallResponse records the response from a route +type RouteCallResponse struct { + StatusCode int `json:"status_code"` + Header http.Header `json:"header"` + Body []byte `json:"body"` +} -func NewRecorder(opts ...RecorderOption) (*Recorder, error) { +func NewRecorder() (*Recorder, error) { r := &Recorder{ recordChan: make(chan *RouteCall), errChan: make(chan error), } - for _, opt := range opts { - err := opt(r) - if err != nil { - return nil, err - } - } - + // TODO: Will need a way to send out the URL to an external service (e.g. Parrotserver running in a docker container) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("failed to start listener: %w", err) } - r.URL = listener.Addr().String() + r.URL = "http://" + listener.Addr().String() mux := http.NewServeMux() mux.Handle("/", r.defaultRecordHandler()) @@ -55,7 +63,9 @@ func NewRecorder(opts ...RecorderOption) (*Recorder, error) { go func() { if err := r.server.Serve(listener); err != nil { - r.errChan <- err + if err != http.ErrServerClosed { + r.errChan <- fmt.Errorf("error serving recorder: %w", err) + } } }() return r, nil @@ -67,10 +77,12 @@ func (r *Recorder) Record() chan *RouteCall { } func (r *Recorder) Close() error { + close(r.recordChan) + close(r.errChan) return r.server.Close() } -func (r *Recorder) Error() chan error { +func (r *Recorder) Err() chan error { return r.errChan } diff --git a/parrot/recorder_test.go b/parrot/recorder_test.go index 7be28b733..091c39794 100644 --- a/parrot/recorder_test.go +++ b/parrot/recorder_test.go @@ -79,5 +79,59 @@ func TestResponseWriterRecorder(t *testing.T) { } func TestRecorder(t *testing.T) { + p, err := Wake(WithLogLevel(testLogLevel)) + require.NoError(t, err, "error waking parrot") + recorder, err := NewRecorder() + require.NoError(t, err, "error creating recorder") + + err = p.Record(recorder) + require.NoError(t, err, "error recording parrot") + t.Cleanup(func() { + require.NoError(t, recorder.Close()) + }) + + route := &Route{ + Method: http.MethodGet, + Path: "/test", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, + } + err = p.Register(route) + require.NoError(t, err, "error registering route") + + var ( + responseCount = 5 + recordedCalls = 0 + ) + + go func() { + for i := 0; i < responseCount; i++ { + resp, err := p.Call(http.MethodGet, "/test") + require.NoError(t, err, "error calling parrot") + + t.Cleanup(func() { + _ = resp.Body.Close() + }) + } + }() + + for { + select { + case recordedRouteCall := <-recorder.Record(): + assert.Equal(t, route.ID(), recordedRouteCall.RouteID, "recorded response has unexpected route ID") + + assert.Equal(t, http.StatusOK, recordedRouteCall.Response.StatusCode, "recorded response has unexpected status code") + assert.Equal(t, "Squawk", string(recordedRouteCall.Response.Body), "recorded response has unexpected body") + + assert.Equal(t, "/test", recordedRouteCall.Request.URL.Path, "recorded request has unexpected path") + assert.Equal(t, http.MethodGet, recordedRouteCall.Request.Method, "recorded request has unexpected method") + recordedCalls++ + if recordedCalls == responseCount { + return + } + case err := <-recorder.Err(): + require.NoError(t, err, "error recording route call") + } + } } From 9598edae0304ec94edc0bb579143f8102bf89be9 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 22 Jan 2025 21:26:08 -0500 Subject: [PATCH 12/30] Fixes header writing --- parrot/parrot.go | 6 ++- parrot/parrot_test.go | 108 +++++++++++++++++++++--------------------- 2 files changed, 58 insertions(+), 56 deletions(-) diff --git a/parrot/parrot.go b/parrot/parrot.go index abf029664..2772a2a96 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -493,8 +493,10 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { return } + recordingWriter.WriteHeader(route.ResponseStatusCode) + if route.RawResponseBody != "" { - if _, err := w.Write([]byte(route.RawResponseBody)); err != nil { + if _, err := recordingWriter.Write([]byte(route.RawResponseBody)); err != nil { dynamicLogger.Debug().Err(err).Msg("Failed to write response") http.Error(recordingWriter, "Failed to write response", http.StatusInternalServerError) return @@ -513,7 +515,7 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { http.Error(recordingWriter, "Failed to marshal response into json", http.StatusInternalServerError) return } - if _, err = w.Write(rawJSON); err != nil { + if _, err = recordingWriter.Write(rawJSON); err != nil { dynamicLogger.Debug().Err(err). RawJSON("Response", rawJSON). Msg("Failed to write response") diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index eaa35875d..925a00291 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -48,60 +48,60 @@ func TestRegister(t *testing.T) { name string route *Route }{ - { - name: "get route", - route: &Route{ - Method: http.MethodGet, - Path: "/hello", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - }, - }, - { - name: "json route", - route: &Route{ - Method: http.MethodGet, - Path: "/json", - ResponseBody: map[string]any{"message": "Squawk"}, - ResponseStatusCode: 200, - }, - }, - { - name: "post route", - route: &Route{ - Method: http.MethodPost, - Path: "/post", - RawResponseBody: "Squawk", - ResponseStatusCode: 201, - }, - }, - { - name: "put route", - route: &Route{ - Method: http.MethodPut, - Path: "/put", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - }, - }, - { - name: "delete route", - route: &Route{ - Method: http.MethodDelete, - Path: "/delete", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - }, - }, - { - name: "patch route", - route: &Route{ - Method: http.MethodPatch, - Path: "/patch", - RawResponseBody: "Squawk", - ResponseStatusCode: 200, - }, - }, + // { + // name: "get route", + // route: &Route{ + // Method: http.MethodGet, + // Path: "/hello", + // RawResponseBody: "Squawk", + // ResponseStatusCode: 200, + // }, + // }, + // { + // name: "json route", + // route: &Route{ + // Method: http.MethodGet, + // Path: "/json", + // ResponseBody: map[string]any{"message": "Squawk"}, + // ResponseStatusCode: 200, + // }, + // }, + // { + // name: "post route", + // route: &Route{ + // Method: http.MethodPost, + // Path: "/post", + // RawResponseBody: "Squawk", + // ResponseStatusCode: 201, + // }, + // }, + // { + // name: "put route", + // route: &Route{ + // Method: http.MethodPut, + // Path: "/put", + // RawResponseBody: "Squawk", + // ResponseStatusCode: 200, + // }, + // }, + // { + // name: "delete route", + // route: &Route{ + // Method: http.MethodDelete, + // Path: "/delete", + // RawResponseBody: "Squawk", + // ResponseStatusCode: 200, + // }, + // }, + // { + // name: "patch route", + // route: &Route{ + // Method: http.MethodPatch, + // Path: "/patch", + // RawResponseBody: "Squawk", + // ResponseStatusCode: 200, + // }, + // }, { name: "error route", route: &Route{ From 93ad4a120c35f052305914fdb6fb00fc51bbc917 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 22 Jan 2025 21:41:02 -0500 Subject: [PATCH 13/30] Fixes recorders --- parrot/cmd/main.go | 19 ++++++++++++++----- parrot/errors.go | 6 +++--- parrot/parrot.go | 15 ++++++--------- parrot/recorder_test.go | 2 +- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/parrot/cmd/main.go b/parrot/cmd/main.go index 1a0afb72f..45caa26e1 100644 --- a/parrot/cmd/main.go +++ b/parrot/cmd/main.go @@ -14,11 +14,12 @@ import ( func main() { var ( - port int - debug bool - trace bool - silent bool - json bool + port int + debug bool + trace bool + silent bool + json bool + recorders []string ) rootCmd := &cobra.Command{ @@ -49,6 +50,13 @@ func main() { return err } + for _, r := range recorders { + err = p.Record(r) + if err != nil { + return err + } + } + c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) <-c @@ -65,6 +73,7 @@ func main() { rootCmd.Flags().BoolVarP(&trace, "trace", "t", false, "Enable trace and debug output") rootCmd.Flags().BoolVarP(&silent, "silent", "s", false, "Disable all output") rootCmd.Flags().BoolVarP(&json, "json", "j", false, "Output logs in JSON format") + rootCmd.Flags().StringSliceVarP(&recorders, "recorders", "r", nil, "Existing recorders to use") if err := rootCmd.Execute(); err != nil { log.Error().Err(err).Msg("error executing command") diff --git a/parrot/errors.go b/parrot/errors.go index 767cd5a50..5643fb2a7 100644 --- a/parrot/errors.go +++ b/parrot/errors.go @@ -14,9 +14,9 @@ var ( ErrResponseMarshal = errors.New("unable to marshal response body to JSON") ErrRouteNotFound = errors.New("route not found") - ErrNoRecorderURL = errors.New("no recorder URL specified") - ErrNilRecorder = errors.New("recorder is nil") - ErrRecorderNotFound = errors.New("recorder not found") + ErrNoRecorderURL = errors.New("no recorder URL specified") + ErrInvalidRecorderURL = errors.New("invalid recorder URL") + ErrRecorderNotFound = errors.New("recorder not found") ) // Custom error type to help add more detail to base errors diff --git a/parrot/parrot.go b/parrot/parrot.go index 2772a2a96..2a0ad23e0 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -362,20 +362,17 @@ func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { } // Record registers a new recorder with the parrot. All incoming requests to the parrot will be sent to the recorder. -func (p *Server) Record(recorder *Recorder) error { +func (p *Server) Record(recorderURL string) error { p.recordersMu.Lock() defer p.recordersMu.Unlock() - if recorder == nil { - return ErrNilRecorder - } - if recorder.URL == "" { + if recorderURL == "" { return ErrNoRecorderURL } - _, err := url.Parse(recorder.URL) + _, err := url.Parse(recorderURL) if err != nil { - return fmt.Errorf("failed to parse recorder URL: %w", err) + return ErrInvalidRecorderURL } - p.recorderHooks = append(p.recorderHooks, recorder.URL) + p.recorderHooks = append(p.recorderHooks, recorderURL) return nil } @@ -394,7 +391,7 @@ func (p *Server) recordHandler(w http.ResponseWriter, r *http.Request) { return } - err := p.Record(recorder) + err := p.Record(recorder.URL) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) recordLogger.Debug().Err(err).Msg("Failed to add recorder") diff --git a/parrot/recorder_test.go b/parrot/recorder_test.go index 091c39794..0872e53b4 100644 --- a/parrot/recorder_test.go +++ b/parrot/recorder_test.go @@ -85,7 +85,7 @@ func TestRecorder(t *testing.T) { recorder, err := NewRecorder() require.NoError(t, err, "error creating recorder") - err = p.Record(recorder) + err = p.Record(recorder.URL) require.NoError(t, err, "error recording parrot") t.Cleanup(func() { require.NoError(t, recorder.Close()) From c25306f92f8d2ce3d0b6239a9caeb083c16402a0 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 22 Jan 2025 22:25:21 -0500 Subject: [PATCH 14/30] Examples --- parrot/errors.go | 2 + parrot/examples_test.go | 147 ++++++++++++++++++++++++++++++- parrot/parrot.go | 93 +++++++++++--------- parrot/parrot_test.go | 189 +++++++++++++++++++++++----------------- 4 files changed, 306 insertions(+), 125 deletions(-) diff --git a/parrot/errors.go b/parrot/errors.go index 5643fb2a7..7da56a04d 100644 --- a/parrot/errors.go +++ b/parrot/errors.go @@ -17,6 +17,8 @@ var ( ErrNoRecorderURL = errors.New("no recorder URL specified") ErrInvalidRecorderURL = errors.New("invalid recorder URL") ErrRecorderNotFound = errors.New("recorder not found") + + ErrParrotAsleep = errors.New("parrot is asleep") ) // Custom error type to help add more detail to base errors diff --git a/parrot/examples_test.go b/parrot/examples_test.go index b7b7dd67f..d8333ba68 100644 --- a/parrot/examples_test.go +++ b/parrot/examples_test.go @@ -1,20 +1,28 @@ package parrot_test import ( + "context" "fmt" "io" "net/http" + "github.com/go-resty/resty/v2" "github.com/rs/zerolog" "github.com/smartcontractkit/chainlink-testing-framework/parrot" ) -func ExampleServer() { +func ExampleRegister() { // Create a new parrot instance with no logging p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel)) if err != nil { panic(err) } + defer func() { + err = p.Shutdown(context.Background()) + if err != nil { + panic(err) + } + }() // Create a new route /test that will return a 200 status code with a text/plain response body of "Squawk" route := &parrot.Route{ @@ -44,3 +52,140 @@ func ExampleServer() { // 200 // Squawk } + +func ExampleRoute() { + // Run the parrot server as a separate instance, like in a Docker container + p, err := parrot.Wake(parrot.WithPort(9090), parrot.WithLogLevel(zerolog.NoLevel)) + if err != nil { + panic(err) + } + defer func() { + err = p.Shutdown(context.Background()) + if err != nil { + panic(err) + } + }() + + // Code that calls the parrot server from another service + // Use resty to make HTTP calls to the parrot server + client := resty.New() + + // Register a new route /test that will return a 200 status code with a text/plain response body of "Squawk" + route := &parrot.Route{ + Method: http.MethodGet, + Path: "/test", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + } + resp, err := client.R().SetBody(route).Post("http://localhost:9090/routes") + if err != nil { + panic(err) + } + defer resp.RawResponse.Body.Close() + fmt.Println(resp.StatusCode()) + + // Get all routes from the parrot server + routes := make([]*parrot.Route, 0) + resp, err = client.R().SetResult(&routes).Get("http://localhost:9090/routes") + if err != nil { + panic(err) + } + defer resp.RawResponse.Body.Close() + fmt.Println(resp.StatusCode()) + fmt.Println(len(routes)) + + // Delete the route + req := &parrot.RouteRequest{ + ID: route.ID(), + } + resp, err = client.R().SetBody(req).Delete("http://localhost:9090/routes") + if err != nil { + panic(err) + } + defer resp.RawResponse.Body.Close() + fmt.Println(resp.StatusCode()) + + // Get all routes from the parrot server + routes = make([]*parrot.Route, 0) + resp, err = client.R().SetResult(&routes).Get("http://localhost:9090/routes") + if err != nil { + panic(err) + } + defer resp.RawResponse.Body.Close() + fmt.Println(len(routes)) + + // Output: + // 201 + // 200 + // 1 + // 204 + // 0 +} + +func ExampleRecorder() { + p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel)) + if err != nil { + panic(err) + } + defer func() { + err = p.Shutdown(context.Background()) + if err != nil { + panic(err) + } + }() + + // Create a new recorder + recorder, err := parrot.NewRecorder() + if err != nil { + panic(err) + } + + // Register the recorder with the parrot instance + err = p.Record(recorder.URL) + if err != nil { + panic(err) + } + defer recorder.Close() + + // Register a new route /test that will return a 200 status code with a text/plain response body of "Squawk" + route := &parrot.Route{ + Method: http.MethodGet, + Path: "/test", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, + } + err = p.Register(route) + if err != nil { + panic(err) + } + + // Call the route + go func() { + resp, err := p.Call(http.MethodGet, "/test") + if err != nil { + panic(err) + } + defer resp.Body.Close() + }() + + // Record the route call + for { + select { + case recordedRouteCall := <-recorder.Record(): + if recordedRouteCall.RouteID == route.ID() { + fmt.Println(recordedRouteCall.RouteID) + fmt.Println(recordedRouteCall.Request.Method) + fmt.Println(recordedRouteCall.Response.StatusCode) + fmt.Println(string(recordedRouteCall.Response.Body)) + return + } + case err := <-recorder.Err(): + panic(err) + } + } + // Output: + // GET:/test + // GET + // 200 + // Squawk +} diff --git a/parrot/parrot.go b/parrot/parrot.go index 2a0ad23e0..92ed55b33 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -56,6 +56,7 @@ type Server struct { host string address string + shutDown bool saveFileName string useCustomLogger bool logFileName string @@ -203,8 +204,8 @@ func Wake(options ...ServerOption) (*Server, error) { } mux := http.NewServeMux() - mux.HandleFunc("/register", p.registerRouteHandler) - mux.HandleFunc("/record", p.recordHandler) + // TODO: Add a route to + mux.HandleFunc("/routes", p.routesHandler) mux.HandleFunc("/", p.dynamicHandler) p.server = &http.Server{ @@ -241,6 +242,7 @@ func (p *Server) run(listener net.Listener) { // Shutdown gracefully shuts down the parrot server func (p *Server) Shutdown(ctx context.Context) error { + p.shutDown = true p.log.Info().Msg("Putting cloth over the parrot's cage...") return p.server.Shutdown(ctx) } @@ -301,40 +303,43 @@ func (p *Server) Register(route *Route) error { return nil } -// registerRouteHandler handles the dynamic route registration. -func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { - registerLogger := zerolog.Ctx(r.Context()) +// routesHandler handles the dynamic route registration. +func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { + routesLogger := zerolog.Ctx(r.Context()) if r.Method == http.MethodDelete { var routeRequest *RouteRequest if err := json.NewDecoder(r.Body).Decode(&routeRequest); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) - registerLogger.Debug().Err(err).Msg("Failed to decode request body") + routesLogger.Debug().Err(err).Msg("Failed to decode request body") return } defer r.Body.Close() if routeRequest.ID == "" { http.Error(w, "Route ID required", http.StatusBadRequest) - registerLogger.Debug().Msg("No Route ID provided") + routesLogger.Debug().Msg("No Route ID provided") return } err := p.Unregister(routeRequest.ID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) - registerLogger.Debug().Err(err).Msg("Failed to unregister route") + routesLogger.Debug().Err(err).Msg("Failed to unregister route") return } w.WriteHeader(http.StatusNoContent) - registerLogger.Info(). + routesLogger.Info(). Str("Route ID", routeRequest.ID). Msg("Route unregistered") - } else if r.Method == http.MethodPost { + return + } + + if r.Method == http.MethodPost { var route *Route if err := json.NewDecoder(r.Body).Decode(&route); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) - registerLogger.Debug().Err(err).Msg("Failed to decode request body") + routesLogger.Debug().Err(err).Msg("Failed to decode request body") return } defer r.Body.Close() @@ -342,23 +347,42 @@ func (p *Server) registerRouteHandler(w http.ResponseWriter, r *http.Request) { if route.Method == "" || route.Path == "" { err := errors.New("Method and path are required") http.Error(w, err.Error(), http.StatusBadRequest) - registerLogger.Debug().Err(err).Msg("Method and path are required") + routesLogger.Debug().Err(err).Msg("Method and path are required") return } err := p.Register(route) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) - registerLogger.Debug().Err(err).Msg("Failed to register route") + routesLogger.Debug().Err(err).Msg("Failed to register route") return } w.WriteHeader(http.StatusCreated) - } else { - http.Error(w, "Invalid method, only use POST or DELETE", http.StatusMethodNotAllowed) - registerLogger.Debug().Msg("Invalid method") return } + + if r.Method == http.MethodGet { + jsonRoutes, err := json.Marshal(p.Routes()) + if err != nil { + http.Error(w, "Failed to marshal routes", http.StatusInternalServerError) + routesLogger.Debug().Err(err).Msg("Failed to marshal routes") + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err = w.Write(jsonRoutes); err != nil { + http.Error(w, "Failed to write response", http.StatusInternalServerError) + routesLogger.Debug().Err(err).Msg("Failed to write response") + return + } + + routesLogger.Debug().Msg("Returned routes") + return + } + + http.Error(w, "Invalid method", http.StatusMethodNotAllowed) + routesLogger.Debug().Msg("Invalid method") } // Record registers a new recorder with the parrot. All incoming requests to the parrot will be sent to the recorder. @@ -376,32 +400,6 @@ func (p *Server) Record(recorderURL string) error { return nil } -func (p *Server) recordHandler(w http.ResponseWriter, r *http.Request) { - recordLogger := zerolog.Ctx(r.Context()) - if r.Method != http.MethodPost { - http.Error(w, "Invalid method, only use POST or DELETE", http.StatusMethodNotAllowed) - recordLogger.Debug().Msg("Invalid method") - return - } - - var recorder *Recorder - if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - recordLogger.Err(err).Msg("Failed to decode request body") - return - } - - err := p.Record(recorder.URL) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - recordLogger.Debug().Err(err).Msg("Failed to add recorder") - return - } - - w.WriteHeader(http.StatusCreated) - recordLogger.Info().Str("Recorder URL", recorder.URL).Msg("Recorder added") -} - // Unregister removes a route from the parrot func (p *Server) Unregister(routeID string) error { p.routesMu.RLock() @@ -428,6 +426,17 @@ func (p *Server) Call(method, path string) (*http.Response, error) { return client.Do(req) } +func (p *Server) Routes() []*Route { + p.routesMu.RLock() + defer p.routesMu.RUnlock() + + routes := make([]*Route, 0, len(p.routes)) + for _, route := range p.routes { + routes = append(routes, route) + } + return routes +} + // dynamicHandler handles all incoming requests and responds based on the registered routes. func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { p.routesMu.RLock() diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index 925a00291..dfb9a6cc5 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -1,6 +1,7 @@ package parrot import ( + "context" "encoding/json" "flag" "fmt" @@ -30,78 +31,69 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -func TestWake(t *testing.T) { +func TestRegisterRoutes(t *testing.T) { t.Parallel() - p, err := Wake(WithLogLevel(testLogLevel)) - require.NoError(t, err, "error waking parrot") - require.NotNil(t, p) -} - -func TestRegister(t *testing.T) { - t.Parallel() - - p, err := Wake(WithLogLevel(testLogLevel)) - require.NoError(t, err, "error waking parrot") + p := newParrot(t) testCases := []struct { name string route *Route }{ - // { - // name: "get route", - // route: &Route{ - // Method: http.MethodGet, - // Path: "/hello", - // RawResponseBody: "Squawk", - // ResponseStatusCode: 200, - // }, - // }, - // { - // name: "json route", - // route: &Route{ - // Method: http.MethodGet, - // Path: "/json", - // ResponseBody: map[string]any{"message": "Squawk"}, - // ResponseStatusCode: 200, - // }, - // }, - // { - // name: "post route", - // route: &Route{ - // Method: http.MethodPost, - // Path: "/post", - // RawResponseBody: "Squawk", - // ResponseStatusCode: 201, - // }, - // }, - // { - // name: "put route", - // route: &Route{ - // Method: http.MethodPut, - // Path: "/put", - // RawResponseBody: "Squawk", - // ResponseStatusCode: 200, - // }, - // }, - // { - // name: "delete route", - // route: &Route{ - // Method: http.MethodDelete, - // Path: "/delete", - // RawResponseBody: "Squawk", - // ResponseStatusCode: 200, - // }, - // }, - // { - // name: "patch route", - // route: &Route{ - // Method: http.MethodPatch, - // Path: "/patch", - // RawResponseBody: "Squawk", - // ResponseStatusCode: 200, - // }, - // }, + { + name: "get route", + route: &Route{ + Method: http.MethodGet, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + }, + { + name: "json route", + route: &Route{ + Method: http.MethodGet, + Path: "/json", + ResponseBody: map[string]any{"message": "Squawk"}, + ResponseStatusCode: 200, + }, + }, + { + name: "post route", + route: &Route{ + Method: http.MethodPost, + Path: "/post", + RawResponseBody: "Squawk", + ResponseStatusCode: 201, + }, + }, + { + name: "put route", + route: &Route{ + Method: http.MethodPut, + Path: "/put", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + }, + { + name: "delete route", + route: &Route{ + Method: http.MethodDelete, + Path: "/delete", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + }, + { + name: "patch route", + route: &Route{ + Method: http.MethodPatch, + Path: "/patch", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + }, { name: "error route", route: &Route{ @@ -138,6 +130,35 @@ func TestRegister(t *testing.T) { } } +func TestGetRoutes(t *testing.T) { + t.Parallel() + + p := newParrot(t) + + routes := []*Route{ + { + Method: http.MethodGet, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: 200, + }, + { + Method: http.MethodPost, + Path: "/goodbye", + RawResponseBody: "Squeak", + ResponseStatusCode: 201, + }, + } + + for _, route := range routes { + err := p.Register(route) + require.NoError(t, err, "error registering route") + } + + registeredRoutes := p.Routes() + require.Len(t, registeredRoutes, len(routes)) +} + func TestIsValidPath(t *testing.T) { t.Parallel() @@ -177,8 +198,7 @@ func TestIsValidPath(t *testing.T) { func TestBadRegisterRoute(t *testing.T) { t.Parallel() - p, err := Wake(WithLogLevel(testLogLevel)) - require.NoError(t, err, "error waking parrot") + p := newParrot(t) testCases := []struct { name string @@ -244,8 +264,7 @@ func TestBadRegisterRoute(t *testing.T) { func TestUnregisteredRoute(t *testing.T) { t.Parallel() - p, err := Wake(WithLogLevel(testLogLevel)) - require.NoError(t, err, "error waking parrot") + p := newParrot(t) resp, err := p.Call(http.MethodGet, "/unregistered") require.NoError(t, err, "error calling parrot") @@ -258,8 +277,7 @@ func TestUnregisteredRoute(t *testing.T) { func TestUnregister(t *testing.T) { t.Parallel() - p, err := Wake(WithLogLevel(testLogLevel)) - require.NoError(t, err, "error waking parrot") + p := newParrot(t) route := &Route{ Method: http.MethodPost, @@ -268,7 +286,7 @@ func TestUnregister(t *testing.T) { ResponseStatusCode: 200, } - err = p.Register(route) + err := p.Register(route) require.NoError(t, err, "error registering route") resp, err := p.Call(route.Method, route.Path) @@ -290,9 +308,7 @@ func TestUnregister(t *testing.T) { func TestSaveLoad(t *testing.T) { t.Parallel() - saveFile := "save_test.json" - p, err := Wake(WithLogLevel(testLogLevel), WithSaveFile(saveFile)) - require.NoError(t, err, "error waking parrot") + p := newParrot(t) routes := []*Route{ { @@ -310,18 +326,14 @@ func TestSaveLoad(t *testing.T) { } for _, route := range routes { - err = p.Register(route) + err := p.Register(route) require.NoError(t, err, "error registering route") } - t.Cleanup(func() { - os.Remove(saveFile) - }) - - err = p.save() + err := p.save() require.NoError(t, err) - require.FileExists(t, saveFile) + require.FileExists(t, t.Name()) err = p.load() require.NoError(t, err) @@ -430,3 +442,16 @@ func BenchmarkLoadRoutes(b *testing.B) { require.NoError(b, err) } } + +func newParrot(t *testing.T) *Server { + t.Helper() + + p, err := Wake(WithSaveFile(t.Name()), WithLogLevel(testLogLevel)) + require.NoError(t, err, "error waking parrot") + t.Cleanup(func() { + err := p.Shutdown(context.Background()) + assert.NoError(t, err, "error shutting down parrot") + os.Remove(t.Name()) + }) + return p +} From ed2e7a84c2d1fbd2f670652dc19a9522f358ce7a Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Wed, 22 Jan 2025 22:31:07 -0500 Subject: [PATCH 15/30] Better save files --- parrot/parrot_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index dfb9a6cc5..29ca643a4 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -333,7 +333,7 @@ func TestSaveLoad(t *testing.T) { err := p.save() require.NoError(t, err) - require.FileExists(t, t.Name()) + require.FileExists(t, t.Name()+".json") err = p.load() require.NoError(t, err) @@ -446,12 +446,13 @@ func BenchmarkLoadRoutes(b *testing.B) { func newParrot(t *testing.T) *Server { t.Helper() - p, err := Wake(WithSaveFile(t.Name()), WithLogLevel(testLogLevel)) + fileName := t.Name() + ".json" + p, err := Wake(WithSaveFile(fileName), WithLogLevel(testLogLevel)) require.NoError(t, err, "error waking parrot") t.Cleanup(func() { err := p.Shutdown(context.Background()) assert.NoError(t, err, "error shutting down parrot") - os.Remove(t.Name()) + os.Remove(fileName) }) return p } From 5c2f4bd4eeed3227b4e2ef8a5b19dab121df4cd0 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 13:14:13 -0500 Subject: [PATCH 16/30] More tests on recorders --- .gitignore | 4 +- parrot/Makefile | 6 +- parrot/cmd/main.go | 5 +- parrot/errors.go | 2 +- parrot/examples_test.go | 45 +++--- parrot/parrot.go | 132 +++++++++++----- parrot/parrot_benchmark_test.go | 145 +++++++++++++++++ parrot/parrot_test.go | 269 ++++++++++++++++++-------------- parrot/recorder.go | 9 +- parrot/recorder_test.go | 80 +++++++++- 10 files changed, 508 insertions(+), 189 deletions(-) create mode 100644 parrot/parrot_benchmark_test.go diff --git a/.gitignore b/.gitignore index 69873e7f2..2f70527c0 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,6 @@ import_keys_test.go tag.py parrot/*.json -parrot/*.log \ No newline at end of file +parrot/*.log +# Executable +parrot/parrot \ No newline at end of file diff --git a/parrot/Makefile b/parrot/Makefile index 50da0b8d8..f708951de 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -26,4 +26,8 @@ test_unit: .PHONY: bench bench: - go test $(TEST_ARGS) -bench=. -run=^$$ ./... \ No newline at end of file + go test $(TEST_ARGS) -bench=. -run=^$$ ./... + +.PHONY: build +build: + go build -o ./parrot ./cmd \ No newline at end of file diff --git a/parrot/cmd/main.go b/parrot/cmd/main.go index 45caa26e1..245fcb69f 100644 --- a/parrot/cmd/main.go +++ b/parrot/cmd/main.go @@ -5,6 +5,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -42,7 +43,7 @@ func main() { options = append(options, parrot.WithJSONLogs()) } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() p, err := parrot.Wake(options...) @@ -62,7 +63,7 @@ func main() { <-c err = p.Shutdown(ctx) if err != nil { - log.Error().Err(err).Msg("error shutting down server") + log.Error().Err(err).Msg("Error putting parrot to sleep") } return nil }, diff --git a/parrot/errors.go b/parrot/errors.go index 7da56a04d..50032b700 100644 --- a/parrot/errors.go +++ b/parrot/errors.go @@ -18,7 +18,7 @@ var ( ErrInvalidRecorderURL = errors.New("invalid recorder URL") ErrRecorderNotFound = errors.New("recorder not found") - ErrParrotAsleep = errors.New("parrot is asleep") + ErrServerShutdown = errors.New("parrot is already asleep") ) // Custom error type to help add more detail to base errors diff --git a/parrot/examples_test.go b/parrot/examples_test.go index d8333ba68..9c92f16a2 100644 --- a/parrot/examples_test.go +++ b/parrot/examples_test.go @@ -3,8 +3,8 @@ package parrot_test import ( "context" "fmt" - "io" "net/http" + "os" "github.com/go-resty/resty/v2" "github.com/rs/zerolog" @@ -12,16 +12,19 @@ import ( ) func ExampleRegister() { - // Create a new parrot instance with no logging - p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel)) + // Create a new parrot instance with no logging and a custom save file + saveFile := "register_example.json" + p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) if err != nil { panic(err) } - defer func() { - err = p.Shutdown(context.Background()) + defer func() { // Cleanup the parrot instance + err = p.Shutdown(context.Background()) // Gracefully shutdown the parrot instance if err != nil { panic(err) } + p.WaitShutdown() // Wait for the parrot instance to shutdown. Usually unnecessary, but we want to clean up the save file + os.Remove(saveFile) // Cleanup the save file for the example }() // Create a new route /test that will return a 200 status code with a text/plain response body of "Squawk" @@ -29,7 +32,7 @@ func ExampleRegister() { Method: http.MethodGet, Path: "/test", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, } // Register the route with the parrot instance @@ -43,11 +46,8 @@ func ExampleRegister() { if err != nil { panic(err) } - defer resp.Body.Close() - - fmt.Println(resp.StatusCode) - body, _ := io.ReadAll(resp.Body) - fmt.Println(string(body)) + fmt.Println(resp.StatusCode()) + fmt.Println(string(resp.Body())) // Output: // 200 // Squawk @@ -55,15 +55,18 @@ func ExampleRegister() { func ExampleRoute() { // Run the parrot server as a separate instance, like in a Docker container - p, err := parrot.Wake(parrot.WithPort(9090), parrot.WithLogLevel(zerolog.NoLevel)) + saveFile := "route_example.json" + p, err := parrot.Wake(parrot.WithPort(9090), parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) if err != nil { panic(err) } - defer func() { - err = p.Shutdown(context.Background()) + defer func() { // Cleanup the parrot instance + err = p.Shutdown(context.Background()) // Gracefully shutdown the parrot instance if err != nil { panic(err) } + p.WaitShutdown() // Wait for the parrot instance to shutdown. Usually unnecessary, but we want to clean up the save file + os.Remove(saveFile) // Cleanup the save file for the example }() // Code that calls the parrot server from another service @@ -75,7 +78,7 @@ func ExampleRoute() { Method: http.MethodGet, Path: "/test", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, } resp, err := client.R().SetBody(route).Post("http://localhost:9090/routes") if err != nil { @@ -123,15 +126,18 @@ func ExampleRoute() { } func ExampleRecorder() { - p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel)) + saveFile := "recorder_example.json" + p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) if err != nil { panic(err) } - defer func() { - err = p.Shutdown(context.Background()) + defer func() { // Cleanup the parrot instance + err = p.Shutdown(context.Background()) // Gracefully shutdown the parrot instance if err != nil { panic(err) } + p.WaitShutdown() // Wait for the parrot instance to shutdown. Usually unnecessary, but we want to clean up the save file + os.Remove(saveFile) // Cleanup the save file for the example }() // Create a new recorder @@ -161,11 +167,10 @@ func ExampleRecorder() { // Call the route go func() { - resp, err := p.Call(http.MethodGet, "/test") + _, err := p.Call(http.MethodGet, "/test") if err != nil { panic(err) } - defer resp.Body.Close() }() // Record the route call diff --git a/parrot/parrot.go b/parrot/parrot.go index 92ed55b33..7fcf8dd05 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -56,7 +56,10 @@ type Server struct { host string address string + client *resty.Client shutDown bool + shutDownChan chan struct{} + shutDownOnce sync.Once saveFileName string useCustomLogger bool logFileName string @@ -69,10 +72,16 @@ type Server struct { routes map[string]*Route // Store routes based on "Method:Path" keys routesMu sync.RWMutex - recorderHooks []string + recorderHooks map[string]struct{} // Store recorders based on URL keys to avoid duplicates recordersMu sync.RWMutex } +// SaveFile is the structure of the file to save and load parrot data from +type SaveFile struct { + Routes []*Route `json:"routes"` + Recorders []string `json:"recorders"` +} + // ServerOption defines functional options for configuring the ParrotServer type ServerOption func(*Server) error @@ -154,8 +163,14 @@ func Wake(options ...ServerOption) (*Server, error) { logLevel: zerolog.InfoLevel, logFileName: "parrot.log", + client: resty.New(), + shutDownChan: make(chan struct{}), + routes: make(map[string]*Route), routesMu: sync.RWMutex{}, + + recorderHooks: make(map[string]struct{}), + recordersMu: sync.RWMutex{}, } for _, option := range options { @@ -204,7 +219,7 @@ func Wake(options ...ServerOption) (*Server, error) { } mux := http.NewServeMux() - // TODO: Add a route to + // TODO: Add a route to enable registering recorders mux.HandleFunc("/routes", p.routesHandler) mux.HandleFunc("/", p.dynamicHandler) @@ -215,7 +230,7 @@ func Wake(options ...ServerOption) (*Server, error) { } if err = p.load(); err != nil { - return nil, fmt.Errorf("failed to load saved routes: %w", err) + return nil, fmt.Errorf("failed to load data from '%s': %w", p.saveFileName, err) } go p.run(listener) @@ -225,12 +240,16 @@ func Wake(options ...ServerOption) (*Server, error) { func (p *Server) run(listener net.Listener) { defer func() { + p.shutDown = true if err := p.save(); err != nil { p.log.Error().Err(err).Msg("Failed to save routes") } if err := p.logFile.Close(); err != nil { p.log.Error().Err(err).Msg("Failed to close log file") } + p.shutDownOnce.Do(func() { + close(p.shutDownChan) + }) }() p.log.Info().Int("Port", p.Port()).Str("Address", p.address).Msg("Parrot awake and ready to squawk") @@ -242,14 +261,17 @@ func (p *Server) run(listener net.Listener) { // Shutdown gracefully shuts down the parrot server func (p *Server) Shutdown(ctx context.Context) error { - p.shutDown = true + if p.shutDown { + return ErrServerShutdown + } + p.log.Info().Msg("Putting cloth over the parrot's cage...") return p.server.Shutdown(ctx) } -// Host returns the host the parrot is running on -func (p *Server) Host() string { - return p.host +// WaitShutdown blocks until the parrot server has shut down +func (p *Server) WaitShutdown() { + <-p.shutDownChan } // Port returns the port the parrot is running on @@ -264,15 +286,15 @@ func (p *Server) Address() string { // Register adds a new route to the parrot func (p *Server) Register(route *Route) error { + if p.shutDown { + return ErrServerShutdown + } if route == nil { return ErrNilRoute } if !isValidPath(route.Path) { return newDynamicError(ErrInvalidPath, fmt.Sprintf("'%s'", route.Path)) } - if _, err := url.Parse(route.Path); err != nil { - return newDynamicError(ErrInvalidPath, fmt.Sprintf("%s: '%s'", err.Error(), route.Path)) - } if route.Method == "" { return ErrNoMethod } @@ -387,6 +409,10 @@ func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { // Record registers a new recorder with the parrot. All incoming requests to the parrot will be sent to the recorder. func (p *Server) Record(recorderURL string) error { + if p.shutDown { + return ErrServerShutdown + } + p.recordersMu.Lock() defer p.recordersMu.Unlock() if recorderURL == "" { @@ -396,12 +422,31 @@ func (p *Server) Record(recorderURL string) error { if err != nil { return ErrInvalidRecorderURL } - p.recorderHooks = append(p.recorderHooks, recorderURL) + p.recorderHooks[recorderURL] = struct{}{} return nil } +// Recorders returns the URLs of all registered recorders +func (p *Server) Recorders() []string { + if p.shutDown { + return nil + } + + p.recordersMu.RLock() + defer p.recordersMu.RUnlock() + recorders := make([]string, 0, len(p.recorderHooks)) + for recorder := range p.recorderHooks { + recorders = append(recorders, recorder) + } + return recorders +} + // Unregister removes a route from the parrot func (p *Server) Unregister(routeID string) error { + if p.shutDown { + return ErrServerShutdown + } + p.routesMu.RLock() _, exists := p.routes[routeID] p.routesMu.RUnlock() @@ -416,14 +461,11 @@ func (p *Server) Unregister(routeID string) error { } // Call makes a request to the parrot server -func (p *Server) Call(method, path string) (*http.Response, error) { - req, err := http.NewRequest(method, "http://"+filepath.Join(p.Address(), path), nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) +func (p *Server) Call(method, path string) (*resty.Response, error) { + if p.shutDown { + return nil, ErrServerShutdown } - - client := &http.Client{} - return client.Do(req) + return p.client.R().Execute(method, "http://"+filepath.Join(p.Address(), path)) } func (p *Server) Routes() []*Route { @@ -542,26 +584,37 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { // load loads all registered routes from a file. func (p *Server) load() error { if _, err := os.Stat(p.saveFileName); os.IsNotExist(err) { - p.log.Trace().Str("file", p.saveFileName).Msg("No routes to load") + p.log.Trace().Str("file", p.saveFileName).Msg("No data to load") return nil } - p.log.Debug().Str("file", p.saveFileName).Msg("Loading routes") + p.log.Debug().Str("File", p.saveFileName).Msg("Loading data") - data, err := os.ReadFile(p.saveFileName) + fileData, err := os.ReadFile(p.saveFileName) if err != nil { return fmt.Errorf("failed to read routes from file: %w", err) } - if len(data) == 0 { - p.log.Trace().Str("file", p.saveFileName).Msg("No routes to load") + if len(fileData) == 0 { + p.log.Trace().Str("File", p.saveFileName).Msg("No data to load") return nil } - p.routesMu.Lock() - defer p.routesMu.Unlock() + var saveData SaveFile + err = json.Unmarshal(fileData, &saveData) + if err != nil { + return fmt.Errorf("failed to unmarshal save file: %w", err) + } - if err = json.Unmarshal(data, &p.routes); err != nil { - return fmt.Errorf("failed to unmarshal routes: %w", err) + for _, route := range saveData.Routes { + if err = p.Register(route); err != nil { + return fmt.Errorf("failed to register route: %w", err) + } + } + + for _, recorder := range saveData.Recorders { + if err = p.Record(recorder); err != nil { + return fmt.Errorf("failed to register recorder: %w", err) + } } p.log.Info().Str("file", p.saveFileName).Int("number", len(p.routes)).Msg("Loaded routes") @@ -570,25 +623,25 @@ func (p *Server) load() error { // save saves all registered routes to a file. func (p *Server) save() error { - if len(p.routes) == 0 { - p.log.Debug().Msg("No routes to save") + saveFile := &SaveFile{ + Routes: p.Routes(), + Recorders: p.Recorders(), + } + if len(saveFile.Routes) == 0 && len(saveFile.Recorders) == 0 { + p.log.Trace().Str("File", p.saveFileName).Msg("No data to save") return nil } - p.log.Trace().Str("file", p.saveFileName).Msg("Saving routes") - - p.routesMu.RLock() - defer p.routesMu.RUnlock() - jsonData, err := json.Marshal(p.routes) + jsonData, err := json.Marshal(saveFile) if err != nil { - return fmt.Errorf("failed to marshal routes: %w", err) + return fmt.Errorf("failed to marshal save file: %w", err) } if err = os.WriteFile(p.saveFileName, jsonData, 0644); err != nil { //nolint:gosec - return fmt.Errorf("failed to write routes to file: %w", err) + return fmt.Errorf("failed to write to save file: %w", err) } - p.log.Trace().Str("file", p.saveFileName).Msg("Saved routes") + p.log.Debug().Str("File", p.saveFileName).Msg("Saved data") return nil } @@ -601,15 +654,16 @@ func (p *Server) sendToRecorders(routeCall *RouteCall) { } client := resty.New() - p.log.Trace().Strs("Recorders", p.recorderHooks).Str("Route ID", routeCall.RouteID).Msg("Sending route call to recorders") + p.log.Trace().Int("Recorder Count", len(p.recorderHooks)).Str("Route ID", routeCall.RouteID).Msg("Sending route call to recorders") - for _, hook := range p.recorderHooks { + for hook := range p.recorderHooks { go func(hook string) { resp, err := client.R().SetBody(routeCall).Post(hook) if err != nil { p.log.Error().Err(err).Str("Recorder Hook", hook).Msg("Failed to send route call to recorder") return } + defer resp.RawResponse.Body.Close() if resp.IsError() { p.log.Error(). Str("Recorder Hook", hook). diff --git a/parrot/parrot_benchmark_test.go b/parrot/parrot_benchmark_test.go new file mode 100644 index 000000000..68d941b13 --- /dev/null +++ b/parrot/parrot_benchmark_test.go @@ -0,0 +1,145 @@ +package parrot + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func BenchmarkRegisterRoute(b *testing.B) { + saveFile := b.Name() + ".json" + p, err := Wake(WithLogLevel(testLogLevel), WithSaveFile(saveFile)) + require.NoError(b, err) + + defer func() { // Cleanup + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + err := p.Shutdown(ctx) + cancel() + require.NoError(b, err, "error shutting down parrot") + p.WaitShutdown() + os.Remove(saveFile) + }() + + route := &Route{ + Method: "GET", + Path: "/bench", + RawResponseBody: "Benchmark Response", + ResponseStatusCode: http.StatusOK, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := p.Register(route) + require.NoError(b, err) + } + b.StopTimer() +} + +func BenchmarkRouteResponse(b *testing.B) { + saveFile := b.Name() + ".json" + p, err := Wake(WithLogLevel(testLogLevel), WithSaveFile(saveFile)) + require.NoError(b, err) + + defer func() { // Cleanup + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + err := p.Shutdown(ctx) + cancel() + require.NoError(b, err, "error shutting down parrot") + p.WaitShutdown() + os.Remove(saveFile) + }() + + route := &Route{ + Method: "GET", + Path: "/bench", + RawResponseBody: "Benchmark Response", + ResponseStatusCode: http.StatusOK, + } + err = p.Register(route) + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := p.Call(route.Method, route.Path) + require.NoError(b, err) + } + b.StopTimer() +} + +func BenchmarkSave(b *testing.B) { + var ( + routes = []*Route{} + saveFile = "bench_save_routes.json" + ) + + for i := 0; i < 1000; i++ { + routes = append(routes, &Route{ + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + RawResponseBody: fmt.Sprintf("Squawk %d", i), + ResponseStatusCode: http.StatusOK, + }) + } + p, err := Wake(WithRoutes(routes), WithLogLevel(testLogLevel), WithSaveFile(saveFile)) + require.NoError(b, err) + defer func() { // Cleanup + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + err = p.Shutdown(ctx) + cancel() + require.NoError(b, err, "error shutting down parrot") + p.WaitShutdown() + os.Remove(saveFile) + }() + + b.ResetTimer() // Start measuring time + for i := 0; i < b.N; i++ { + err := p.save() + require.NoError(b, err) + } + b.StopTimer() +} + +func BenchmarkLoad(b *testing.B) { + var ( + routes = []*Route{} + saveFile = "bench_load_routes.json" + ) + b.Cleanup(func() { + os.Remove(saveFile) + }) + + for i := 0; i < 1000; i++ { + routes = append(routes, &Route{ + Method: "GET", + Path: fmt.Sprintf("/bench%d", i), + RawResponseBody: fmt.Sprintf("Squawk %d", i), + ResponseStatusCode: http.StatusOK, + }) + } + p, err := Wake(WithRoutes(routes), WithLogLevel(zerolog.Disabled), WithSaveFile(saveFile)) + require.NoError(b, err, "error waking parrot") + defer func() { // Cleanup + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + err = p.Shutdown(ctx) + cancel() + require.NoError(b, err, "error shutting down parrot") + p.WaitShutdown() + os.Remove(saveFile) + }() + + err = p.save() + require.NoError(b, err, "error saving routes") + + b.ResetTimer() // Start measuring time + for i := 0; i < b.N; i++ { + err := p.load() + require.NoError(b, err) + } + b.StopTimer() +} diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index 29ca643a4..05f3069d8 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -5,10 +5,10 @@ import ( "encoding/json" "flag" "fmt" - "io" "net/http" "os" "testing" + "time" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" @@ -46,7 +46,7 @@ func TestRegisterRoutes(t *testing.T) { Method: http.MethodGet, Path: "/hello", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -55,7 +55,7 @@ func TestRegisterRoutes(t *testing.T) { Method: http.MethodGet, Path: "/json", ResponseBody: map[string]any{"message": "Squawk"}, - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -73,7 +73,7 @@ func TestRegisterRoutes(t *testing.T) { Method: http.MethodPut, Path: "/put", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -82,7 +82,7 @@ func TestRegisterRoutes(t *testing.T) { Method: http.MethodDelete, Path: "/delete", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -91,7 +91,7 @@ func TestRegisterRoutes(t *testing.T) { Method: http.MethodPatch, Path: "/patch", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -114,18 +114,15 @@ func TestRegisterRoutes(t *testing.T) { resp, err := p.Call(tc.route.Method, tc.route.Path) require.NoError(t, err, "error calling parrot") - defer resp.Body.Close() - assert.Equal(t, tc.route.ResponseStatusCode, resp.StatusCode) - body, _ := io.ReadAll(resp.Body) + assert.Equal(t, tc.route.ResponseStatusCode, resp.StatusCode()) if tc.route.ResponseBody != nil { jsonBody, err := json.Marshal(tc.route.ResponseBody) require.NoError(t, err) - assert.JSONEq(t, string(jsonBody), string(body)) + assert.JSONEq(t, string(jsonBody), string(resp.Body())) } else { - assert.Equal(t, tc.route.RawResponseBody, string(body)) + assert.Equal(t, tc.route.RawResponseBody, string(resp.Body())) } - resp.Body.Close() }) } } @@ -140,7 +137,7 @@ func TestGetRoutes(t *testing.T) { Method: http.MethodGet, Path: "/hello", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, { Method: http.MethodPost, @@ -195,6 +192,74 @@ func TestIsValidPath(t *testing.T) { } } +func TestPreRegisterRoutes(t *testing.T) { + t.Parallel() + + routes := []*Route{ + { + Method: http.MethodGet, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, + }, + { + Method: http.MethodPost, + Path: "/goodbye", + RawResponseBody: "Squeak", + ResponseStatusCode: 201, + }, + } + + saveFile := t.Name() + ".json" + p, err := Wake(WithSaveFile(saveFile), WithRoutes(routes), WithLogLevel(testLogLevel)) + require.NoError(t, err, "error waking parrot") + + t.Cleanup(func() { + err := p.Shutdown(context.Background()) + assert.NoError(t, err, "error shutting down parrot") + p.WaitShutdown() + os.Remove(saveFile) + }) + + registeredRoutes := p.Routes() + require.Len(t, registeredRoutes, len(routes)) +} + +func TestCustomLogFile(t *testing.T) { + t.Parallel() + + logFile := t.Name() + ".log" + saveFile := t.Name() + ".json" + p, err := Wake(WithLogFile(logFile), WithSaveFile(saveFile), WithLogLevel(zerolog.DebugLevel)) + require.NoError(t, err, "error waking parrot") + + t.Cleanup(func() { + err := p.Shutdown(context.Background()) + assert.NoError(t, err, "error shutting down parrot") + p.WaitShutdown() + os.Remove(logFile) + os.Remove(saveFile) + }) + + // Call a route to generate some logs + route := &Route{ + Method: http.MethodGet, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, + } + err = p.Register(route) + require.NoError(t, err, "error registering route") + + _, err = p.Call(route.Method, route.Path) + require.NoError(t, err, "error calling parrot") + + require.FileExists(t, logFile, "expected log file to exist") + logData, err := os.ReadFile(logFile) + require.NoError(t, err, "error reading log file") + require.Contains(t, string(logData), "GET:/hello", "expected log file to contain route call") +} + func TestBadRegisterRoute(t *testing.T) { t.Parallel() @@ -216,7 +281,7 @@ func TestBadRegisterRoute(t *testing.T) { route: &Route{ Path: "/hello", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -225,7 +290,7 @@ func TestBadRegisterRoute(t *testing.T) { route: &Route{ Method: http.MethodGet, RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -235,7 +300,7 @@ func TestBadRegisterRoute(t *testing.T) { Method: http.MethodGet, Path: "/", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, }, { @@ -245,7 +310,37 @@ func TestBadRegisterRoute(t *testing.T) { Method: http.MethodGet, Path: "invalid path", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, + }, + }, + { + name: "no response", + err: ErrNoResponse, + route: &Route{ + Method: http.MethodGet, + Path: "/hello", + ResponseStatusCode: http.StatusOK, + }, + }, + { + name: "invalid url", + err: ErrInvalidPath, + route: &Route{ + Method: http.MethodGet, + Path: "http://example.com", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, + }, + }, + { + name: "multiple responses", + err: ErrOnlyOneResponse, + route: &Route{ + Method: http.MethodGet, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseBody: map[string]any{"message": "Squawk"}, + ResponseStatusCode: http.StatusOK, }, }, } @@ -269,9 +364,8 @@ func TestUnregisteredRoute(t *testing.T) { resp, err := p.Call(http.MethodGet, "/unregistered") require.NoError(t, err, "error calling parrot") require.NotNil(t, resp, "response should not be nil") - defer resp.Body.Close() - assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, http.StatusNotFound, resp.StatusCode()) } func TestUnregister(t *testing.T) { @@ -283,7 +377,7 @@ func TestUnregister(t *testing.T) { Method: http.MethodPost, Path: "/hello", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, } err := p.Register(route) @@ -292,17 +386,15 @@ func TestUnregister(t *testing.T) { resp, err := p.Call(route.Method, route.Path) require.NoError(t, err, "error calling parrot") - assert.Equal(t, resp.StatusCode, route.ResponseStatusCode) - body, _ := io.ReadAll(resp.Body) - assert.Equal(t, route.RawResponseBody, string(body)) - resp.Body.Close() + assert.Equal(t, resp.StatusCode(), route.ResponseStatusCode) + assert.Equal(t, route.RawResponseBody, string(resp.Body())) err = p.Unregister(route.ID()) require.NoError(t, err, "error unregistering route") resp, err = p.Call(route.Method, route.Path) require.NoError(t, err, "error calling parrot") - assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, http.StatusNotFound, resp.StatusCode()) } func TestSaveLoad(t *testing.T) { @@ -315,7 +407,7 @@ func TestSaveLoad(t *testing.T) { Method: "GET", Path: "/hello", RawResponseBody: "Squawk", - ResponseStatusCode: 200, + ResponseStatusCode: http.StatusOK, }, { Method: "Post", @@ -325,11 +417,21 @@ func TestSaveLoad(t *testing.T) { }, } + recorders := []string{ // Dummy recorder URLs + "http://localhost:8080", + "http://localhost:8081", + } + for _, route := range routes { err := p.Register(route) require.NoError(t, err, "error registering route") } + for _, recorder := range recorders { + err := p.Record(recorder) + require.NoError(t, err, "error recording parrot") + } + err := p.save() require.NoError(t, err) @@ -341,106 +443,42 @@ func TestSaveLoad(t *testing.T) { resp, err := p.Call(route.Method, route.Path) require.NoError(t, err, "error calling parrot") - assert.Equal(t, route.ResponseStatusCode, resp.StatusCode, "unexpected status code for route %s", route.ID()) - body, _ := io.ReadAll(resp.Body) - assert.Equal(t, route.RawResponseBody, string(body)) - resp.Body.Close() - } -} - -func BenchmarkRegisterRoute(b *testing.B) { - p, err := Wake(WithLogLevel(zerolog.Disabled)) - require.NoError(b, err) - - route := &Route{ - Method: "GET", - Path: "/bench", - RawResponseBody: "Benchmark Response", - ResponseStatusCode: 200, + assert.Equal(t, route.ResponseStatusCode, resp.StatusCode(), "unexpected status code for route %s", route.ID()) + assert.Equal(t, route.RawResponseBody, string(resp.Body())) } - b.ResetTimer() - for i := 0; i < b.N; i++ { - err := p.Register(route) - require.NoError(b, err) - } + registeredRecorders := p.Recorders() + require.Len(t, registeredRecorders, len(recorders), "unexpected number of recorders") } -func BenchmarkRouteResponse(b *testing.B) { - p, err := Wake(WithLogLevel(zerolog.Disabled)) - require.NoError(b, err) - - route := &Route{ - Method: "GET", - Path: "/bench", - RawResponseBody: "Benchmark Response", - ResponseStatusCode: 200, - } - err = p.Register(route) - require.NoError(b, err) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := p.Call(route.Method, route.Path) - require.NoError(b, err) - } -} +func TestShutDown(t *testing.T) { + fileName := t.Name() + ".json" + p, err := Wake(WithSaveFile(fileName), WithLogLevel(testLogLevel)) + require.NoError(t, err, "error waking parrot") -func BenchmarkSaveRoutes(b *testing.B) { - var ( - routes = []*Route{} - saveFile = "bench_save_routes.json" - ) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + err = p.Shutdown(ctx) + require.NoError(t, err, "error shutting down parrot") - for i := 0; i < 1000; i++ { - routes = append(routes, &Route{ - Method: "GET", - Path: fmt.Sprintf("/bench%d", i), - RawResponseBody: fmt.Sprintf("Squawk %d", i), - ResponseStatusCode: 200, - }) - } - p, err := Wake(WithRoutes(routes), WithLogLevel(zerolog.Disabled), WithSaveFile(saveFile)) - require.NoError(b, err) + p.WaitShutdown() // Wait for shutdown to complete - b.Cleanup(func() { - os.Remove(saveFile) - }) + _, err = p.Call(http.MethodGet, "/hello") + require.ErrorIs(t, err, ErrServerShutdown, "expected error calling parrot after shutdown") - b.ResetTimer() // Start measuring time - for i := 0; i < b.N; i++ { - err := p.save() - require.NoError(b, err) - } -} + err = p.Record("http://localhost:8080") + require.ErrorIs(t, err, ErrServerShutdown, "expected error recording parrot after shutdown") -func BenchmarkLoadRoutes(b *testing.B) { - var ( - routes = []*Route{} - saveFile = "bench_load_routes.json" - ) - b.Cleanup(func() { - os.Remove(saveFile) + err = p.Register(&Route{ + Method: http.MethodGet, + Path: "/hello", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, }) + require.ErrorIs(t, err, ErrServerShutdown, "expected error registering route after shutdown") - for i := 0; i < 1000; i++ { - routes = append(routes, &Route{ - Method: "GET", - Path: fmt.Sprintf("/bench%d", i), - RawResponseBody: fmt.Sprintf("Squawk %d", i), - ResponseStatusCode: 200, - }) - } - p, err := Wake(WithRoutes(routes), WithLogLevel(zerolog.Disabled), WithSaveFile(saveFile)) - require.NoError(b, err, "error waking parrot") - err = p.save() - require.NoError(b, err, "error saving routes") - - b.ResetTimer() // Start measuring time - for i := 0; i < b.N; i++ { - err := p.load() - require.NoError(b, err) - } + err = p.Shutdown(context.Background()) + require.ErrorIs(t, err, ErrServerShutdown, "expected error shutting down parrot after shutdown") } func newParrot(t *testing.T) *Server { @@ -452,6 +490,7 @@ func newParrot(t *testing.T) *Server { t.Cleanup(func() { err := p.Shutdown(context.Background()) assert.NoError(t, err, "error shutting down parrot") + p.WaitShutdown() // Wait for shutdown to complete os.Remove(fileName) }) return p diff --git a/parrot/recorder.go b/parrot/recorder.go index aa0cb7b27..664f6148e 100644 --- a/parrot/recorder.go +++ b/parrot/recorder.go @@ -40,6 +40,7 @@ type RouteCallResponse struct { Body []byte `json:"body"` } +// NewRecorder creates a new recorder that listens for incoming requests to the parrot server func NewRecorder() (*Recorder, error) { r := &Recorder{ recordChan: make(chan *RouteCall), @@ -64,7 +65,7 @@ func NewRecorder() (*Recorder, error) { go func() { if err := r.server.Serve(listener); err != nil { if err != http.ErrServerClosed { - r.errChan <- fmt.Errorf("error serving recorder: %w", err) + fmt.Println("Error serving recorder:", err) } } }() @@ -76,12 +77,14 @@ func (r *Recorder) Record() chan *RouteCall { return r.recordChan } +// Close shuts down the recorder func (r *Recorder) Close() error { - close(r.recordChan) - close(r.errChan) + // close(r.recordChan) + // close(r.errChan) return r.server.Close() } +// Err receives errors from the recorder func (r *Recorder) Err() chan error { return r.errChan } diff --git a/parrot/recorder_test.go b/parrot/recorder_test.go index 0872e53b4..0b29194dd 100644 --- a/parrot/recorder_test.go +++ b/parrot/recorder_test.go @@ -4,7 +4,9 @@ import ( "io" "net/http" "net/http/httptest" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -79,11 +81,15 @@ func TestResponseWriterRecorder(t *testing.T) { } func TestRecorder(t *testing.T) { - p, err := Wake(WithLogLevel(testLogLevel)) - require.NoError(t, err, "error waking parrot") + t.Parallel() + + p := newParrot(t) recorder, err := NewRecorder() require.NoError(t, err, "error creating recorder") + t.Cleanup(func() { + require.NoError(t, recorder.Close()) + }) err = p.Record(recorder.URL) require.NoError(t, err, "error recording parrot") @@ -107,12 +113,8 @@ func TestRecorder(t *testing.T) { go func() { for i := 0; i < responseCount; i++ { - resp, err := p.Call(http.MethodGet, "/test") + _, err := p.Call(http.MethodGet, "/test") require.NoError(t, err, "error calling parrot") - - t.Cleanup(func() { - _ = resp.Body.Close() - }) } }() @@ -135,3 +137,67 @@ func TestRecorder(t *testing.T) { } } } + +func TestMultipleRecorders(t *testing.T) { + t.Parallel() + + p := newParrot(t) + + var ( + numRecorders = 10 + numCalls = 5 + ) + recorders := make([]*Recorder, numRecorders) + for i := 0; i < numRecorders; i++ { + recorder, err := NewRecorder() + require.NoError(t, err, "error creating recorder") + recorders[i] = recorder + } + t.Cleanup(func() { + for _, recorder := range recorders { + require.NoError(t, recorder.Close()) + } + }) + + for _, recorder := range recorders { + err := p.Record(recorder.URL) + require.NoError(t, err, "error recording parrot") + } + + route := &Route{ + Method: http.MethodGet, + Path: "/test", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, + } + err := p.Register(route) + require.NoError(t, err, "error registering route") + + var wg sync.WaitGroup + wg.Add(numCalls) + for i := 0; i < numCalls; i++ { + go func() { + defer wg.Done() + _, err := p.Call(http.MethodGet, "/test") + require.NoError(t, err, "error calling parrot") + }() + } + wg.Wait() + + for _, recorder := range recorders { + for i := 0; i < numCalls; i++ { + select { + case recordedRouteCall := <-recorder.Record(): + assert.Equal(t, route.ID(), recordedRouteCall.RouteID, "recorded response has unexpected route ID for recorder %d", i) + assert.Equal(t, http.StatusOK, recordedRouteCall.Response.StatusCode, "recorded response has unexpected status code for recorder %d", i) + assert.Equal(t, "Squawk", string(recordedRouteCall.Response.Body), "recorded response has unexpected body for recorder %d", i) + assert.Equal(t, "/test", recordedRouteCall.Request.URL.Path, "recorded request has unexpected path for recorder %d", i) + assert.Equal(t, http.MethodGet, recordedRouteCall.Request.Method, "recorded request has unexpected method for recorder %d", i) + case err := <-recorder.Err(): + require.NoError(t, err, "error recording route call") + case <-time.After(time.Second): + require.Fail(t, "timed out waiting for recorder %d", i) + } + } + } +} From d4ac2fa0c41c9667fe6fc771d99fb6f1cd44eac8 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 16:19:20 -0500 Subject: [PATCH 17/30] Fix goreleaser --- ...tserver-release.yml => parrot-release.yml} | 19 +++++++++++---- parrot/.goreleaser.yaml | 24 +++++++++---------- 2 files changed, 27 insertions(+), 16 deletions(-) rename .github/workflows/{parrotserver-release.yml => parrot-release.yml} (68%) diff --git a/.github/workflows/parrotserver-release.yml b/.github/workflows/parrot-release.yml similarity index 68% rename from .github/workflows/parrotserver-release.yml rename to .github/workflows/parrot-release.yml index 088cd0913..fda669fc2 100644 --- a/.github/workflows/parrotserver-release.yml +++ b/.github/workflows/parrot-release.yml @@ -3,7 +3,7 @@ name: Parrotserver Release on: push: tags: - - parrotserver/v* + - parrot/v* jobs: release: @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + with: + fetch-depth: 0 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 with: @@ -25,9 +27,18 @@ jobs: mask-password: 'true' env: AWS_REGION: ${{ secrets.QA_AWS_REGION }} - - name: Build and release + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + - name: Goreleaser Release + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser-pro + version: "~> v2" + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMAGE_PREFIX: ${{ secrets.QA_AWS_ACCOUNT_NUMBER }}.dkr.ecr.${{ secrets.QA_AWS_REGION }}.amazonaws.com/parrotserver + GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} + IMAGE_PREFIX: ${{ secrets.QA_AWS_ACCOUNT_NUMBER }}.dkr.ecr.${{ secrets.QA_AWS_REGION }}.amazonaws.com/parrot IMAGE_TAG: ${{ github.ref_name}} - run: goreleaser release --clean -f ./parrotserver/.goreleaser.yml diff --git a/parrot/.goreleaser.yaml b/parrot/.goreleaser.yaml index f663c839b..758fccc0a 100644 --- a/parrot/.goreleaser.yaml +++ b/parrot/.goreleaser.yaml @@ -1,10 +1,10 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json -version: 1 -project_name: parrotserver +version: 2 +project_name: parrot monorepo: - tag_prefix: parrotserver/ - dir: parrotserver + tag_prefix: parrot/ + dir: parrot env: - IMG_PRE={{ if index .Env "IMAGE_PREFIX" }}{{ .Env.IMAGE_PREFIX }}{{ else }}local{{ end }} @@ -12,7 +12,7 @@ env: # Build settings for binaries builds: - - id: parrotserver + - id: parrot goos: - linux - darwin @@ -26,12 +26,12 @@ archives: - format: binary dockers: - - id: linux-amd64-parrotserver + - id: linux-amd64-parrot goos: linux goarch: amd64 image_templates: - - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}' - - '{{ .Env.IMG_PRE }}/parrotserver:latest' + - '{{ .Env.IMG_PRE }}/parrot:{{ .Tag }}' + - '{{ .Env.IMG_PRE }}/parrot:latest' build_flag_templates: - --platform=linux/amd64 - --pull @@ -39,12 +39,12 @@ dockers: - --label=org.opencontainers.image.title={{.ProjectName}} - --label=org.opencontainers.image.revision={{.FullCommit}} - --label=org.opencontainers.image.version={{.Version}} - - id: linux-arm64-parrotserver + - id: linux-arm64-parrot goos: linux goarch: arm64 image_templates: - - '{{ .Env.IMG_PRE }}/parrotserver:{{ .Tag }}-arm64' - - '{{ .Env.IMG_PRE }}/parrotserver:latest-arm64' + - '{{ .Env.IMG_PRE }}/parrot:{{ .Tag }}-arm64' + - '{{ .Env.IMG_PRE }}/parrot:latest-arm64' build_flag_templates: - --platform=linux/arm64 - --pull @@ -55,4 +55,4 @@ dockers: before: hooks: - - cd parrotserver && go mod tidy \ No newline at end of file + - cd parrot && go mod tidy \ No newline at end of file From 26c8b03ecf88657b6ee48403535bf40105b80d7e Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 16:45:12 -0500 Subject: [PATCH 18/30] Goreleaser monorepo --- .github/workflows/parrot-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/parrot-release.yml b/.github/workflows/parrot-release.yml index fda669fc2..8a689e19d 100644 --- a/.github/workflows/parrot-release.yml +++ b/.github/workflows/parrot-release.yml @@ -36,7 +36,7 @@ jobs: with: distribution: goreleaser-pro version: "~> v2" - args: release --clean + args: release --clean -f ./parrot/.goreleaser.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} From 00d262d6c016a1d32290535927cc05c547a6a811 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 16:47:37 -0500 Subject: [PATCH 19/30] Update upload artifact --- .github/workflows/docker-test.yaml | 2 +- .github/workflows/framework-golden-tests.yml | 2 +- .github/workflows/generate-go-docs.yaml | 2 +- .github/workflows/k8s-e2e.yaml | 4 ++-- .github/workflows/lint.yaml | 2 +- .github/workflows/test.yaml | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-test.yaml b/.github/workflows/docker-test.yaml index 3a8b782cc..38a7ee0be 100644 --- a/.github/workflows/docker-test.yaml +++ b/.github/workflows/docker-test.yaml @@ -46,7 +46,7 @@ jobs: go test -timeout 20m -json -parallel 2 -cover -covermode=atomic -coverprofile=unit-test-coverage.out $(go list ./... | grep /docker/test_env) -run '${{ matrix.test.tests }}' 2>&1 | tee /tmp/gotest.log | ../gotestloghelper -ci - name: Publish Artifacts if: failure() - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@v4 with: name: test-logs path: ./lib/logs diff --git a/.github/workflows/framework-golden-tests.yml b/.github/workflows/framework-golden-tests.yml index 4c4ae4b2f..2bb91b0b7 100644 --- a/.github/workflows/framework-golden-tests.yml +++ b/.github/workflows/framework-golden-tests.yml @@ -104,7 +104,7 @@ jobs: go test -timeout ${{ matrix.test.timeout }} -v -count ${{ matrix.test.count }} -run ${{ matrix.test.name }} - name: Upload Logs if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: container-logs-${{ matrix.test.name }} path: framework/examples/myproject/logs diff --git a/.github/workflows/generate-go-docs.yaml b/.github/workflows/generate-go-docs.yaml index 59cca7239..a93a29e40 100644 --- a/.github/workflows/generate-go-docs.yaml +++ b/.github/workflows/generate-go-docs.yaml @@ -123,7 +123,7 @@ jobs: rm filtered_folders.json - name: Upload costs as artifact - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@v4 with: name: generation-costs path: ./costs diff --git a/.github/workflows/k8s-e2e.yaml b/.github/workflows/k8s-e2e.yaml index 480726d81..269541a28 100644 --- a/.github/workflows/k8s-e2e.yaml +++ b/.github/workflows/k8s-e2e.yaml @@ -98,7 +98,7 @@ jobs: QA_AWS_ROLE_TO_ASSUME: ${{ secrets.QA_AWS_ROLE_TO_ASSUME }} run_setup: false - name: Upload test log - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@v4 if: failure() with: name: test-log @@ -146,7 +146,7 @@ jobs: QA_KUBECONFIG: ${{ secrets.QA_KUBECONFIG }} run_setup: false - name: Upload test log - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@v4 if: failure() with: name: remote-runner-test-log diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2d0ab282d..beab894e8 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -100,7 +100,7 @@ jobs: run: test -f ${{ matrix.project.path }}golangci-lint-report.xml || true - name: Store lint report artifact if: always() - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@v4 with: name: golangci-lint-report-${{ matrix.project.name }} path: ${{ matrix.project.path }}golangci-lint-report.xml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e05ce1a9c..bc08cad0b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -54,7 +54,7 @@ jobs: make test_unit" - name: Publish Artifacts if: failure() - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@v4 with: name: test-logs path: /tmp/gotest.log From f75a8d9c732a0dd13a4c0c07db20644f6e4b3b97 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 17:17:38 -0500 Subject: [PATCH 20/30] Update docs --- parrot/.goreleaser.yaml | 3 ++- parrot/Makefile | 6 +++++- parrot/README.md | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/parrot/.goreleaser.yaml b/parrot/.goreleaser.yaml index 758fccc0a..14867fff3 100644 --- a/parrot/.goreleaser.yaml +++ b/parrot/.goreleaser.yaml @@ -13,6 +13,7 @@ env: # Build settings for binaries builds: - id: parrot + main: ./cmd/main.go goos: - linux - darwin @@ -23,7 +24,7 @@ builds: - '-s -w' archives: - - format: binary + - formats: ['binary'] dockers: - id: linux-amd64-parrot diff --git a/parrot/Makefile b/parrot/Makefile index f708951de..828753d60 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -30,4 +30,8 @@ bench: .PHONY: build build: - go build -o ./parrot ./cmd \ No newline at end of file + go build -o ./parrot ./cmd + +.PHONY: goreleaser +goreleaser: + cd .. && goreleaser build --snapshot --clean -f ./parrot/.goreleaser.yaml --output ./parrot/dist \ No newline at end of file diff --git a/parrot/README.md b/parrot/README.md index 0a4b13965..bd243ab42 100644 --- a/parrot/README.md +++ b/parrot/README.md @@ -16,4 +16,10 @@ make test make test PARROT_TEST_LOG_LEVEL=trace # Set log level for tests make test_race # Test with -race flag enabled make bench # Benchmark +``` + +## Build + +```sh +make goreleaser # Uses goreleaser to build binaries and docker containers ``` \ No newline at end of file From 6a3a5698a61adedadfab1583103c4bc9a1b6a6b9 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 17:28:18 -0500 Subject: [PATCH 21/30] Fix example name --- parrot/examples_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parrot/examples_test.go b/parrot/examples_test.go index 9c92f16a2..ad80e8c00 100644 --- a/parrot/examples_test.go +++ b/parrot/examples_test.go @@ -11,7 +11,7 @@ import ( "github.com/smartcontractkit/chainlink-testing-framework/parrot" ) -func ExampleRegister() { +func ExampleServer_Register() { // Create a new parrot instance with no logging and a custom save file saveFile := "register_example.json" p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) From e2058653a1d279a3d43a47e880b5cec961311552 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 17:29:03 -0500 Subject: [PATCH 22/30] Add linting --- parrot/Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/parrot/Makefile b/parrot/Makefile index 828753d60..daa5d408b 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -34,4 +34,8 @@ build: .PHONY: goreleaser goreleaser: - cd .. && goreleaser build --snapshot --clean -f ./parrot/.goreleaser.yaml --output ./parrot/dist \ No newline at end of file + cd .. && goreleaser build --snapshot --clean -f ./parrot/.goreleaser.yaml --output ./parrot/dist + +.PHONY: lint +lint: + golangci-lint run --fix ./... \ No newline at end of file From 8a73802c5101014a024084b4cfd2310914ef07c6 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 17:29:17 -0500 Subject: [PATCH 23/30] Remove typo --- parrot/Makefile | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/parrot/Makefile b/parrot/Makefile index daa5d408b..828753d60 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -34,8 +34,4 @@ build: .PHONY: goreleaser goreleaser: - cd .. && goreleaser build --snapshot --clean -f ./parrot/.goreleaser.yaml --output ./parrot/dist - -.PHONY: lint -lint: - golangci-lint run --fix ./... \ No newline at end of file + cd .. && goreleaser build --snapshot --clean -f ./parrot/.goreleaser.yaml --output ./parrot/dist \ No newline at end of file From d44b362ae8b4a52b68e331b43a47a24ab895b786 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 23 Jan 2025 20:11:19 -0500 Subject: [PATCH 24/30] Adds example for external recorder --- parrot/examples_test.go | 161 +++++++++++++++++++++++---- parrot/parrot.go | 239 +++++++++++++++++++++++----------------- parrot/parrot_test.go | 6 +- parrot/recorder.go | 38 +++++-- parrot/recorder_test.go | 4 +- 5 files changed, 314 insertions(+), 134 deletions(-) diff --git a/parrot/examples_test.go b/parrot/examples_test.go index ad80e8c00..ab89b5059 100644 --- a/parrot/examples_test.go +++ b/parrot/examples_test.go @@ -5,13 +5,14 @@ import ( "fmt" "net/http" "os" + "time" "github.com/go-resty/resty/v2" "github.com/rs/zerolog" "github.com/smartcontractkit/chainlink-testing-framework/parrot" ) -func ExampleServer_Register() { +func ExampleServer_Register_internal() { // Create a new parrot instance with no logging and a custom save file saveFile := "register_example.json" p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) @@ -48,30 +49,47 @@ func ExampleServer_Register() { } fmt.Println(resp.StatusCode()) fmt.Println(string(resp.Body())) + + // Get all routes from the parrot instance + routes := p.Routes() + fmt.Println(len(routes)) + + // Delete the route + err = p.Delete(route.ID()) + if err != nil { + panic(err) + } + + // Get all routes from the parrot instance + routes = p.Routes() + fmt.Println(len(routes)) // Output: // 200 // Squawk + // 1 + // 0 } -func ExampleRoute() { - // Run the parrot server as a separate instance, like in a Docker container - saveFile := "route_example.json" - p, err := parrot.Wake(parrot.WithPort(9090), parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) - if err != nil { - panic(err) - } - defer func() { // Cleanup the parrot instance - err = p.Shutdown(context.Background()) // Gracefully shutdown the parrot instance +func ExampleServer_Register_external() { + var ( + saveFile = "route_example.json" + port = 9090 + ) + defer os.Remove(saveFile) // Cleanup the save file for the example + + go func() { // Run the parrot server as a separate instance, like in a Docker container + _, err := parrot.Wake(parrot.WithPort(port), parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) if err != nil { panic(err) } - p.WaitShutdown() // Wait for the parrot instance to shutdown. Usually unnecessary, but we want to clean up the save file - os.Remove(saveFile) // Cleanup the save file for the example }() // Code that calls the parrot server from another service // Use resty to make HTTP calls to the parrot server client := resty.New() + client.SetBaseURL(fmt.Sprintf("http://localhost:%d", port)) // The URL of the parrot server + + waitForParrotServer(client, time.Second) // Wait for the parrot server to start // Register a new route /test that will return a 200 status code with a text/plain response body of "Squawk" route := &parrot.Route{ @@ -80,7 +98,7 @@ func ExampleRoute() { RawResponseBody: "Squawk", ResponseStatusCode: http.StatusOK, } - resp, err := client.R().SetBody(route).Post("http://localhost:9090/routes") + resp, err := client.R().SetBody(route).Post("/routes") if err != nil { panic(err) } @@ -89,7 +107,7 @@ func ExampleRoute() { // Get all routes from the parrot server routes := make([]*parrot.Route, 0) - resp, err = client.R().SetResult(&routes).Get("http://localhost:9090/routes") + resp, err = client.R().SetResult(&routes).Get("/routes") if err != nil { panic(err) } @@ -98,10 +116,7 @@ func ExampleRoute() { fmt.Println(len(routes)) // Delete the route - req := &parrot.RouteRequest{ - ID: route.ID(), - } - resp, err = client.R().SetBody(req).Delete("http://localhost:9090/routes") + resp, err = client.R().SetBody(route).Delete("/routes") if err != nil { panic(err) } @@ -110,7 +125,7 @@ func ExampleRoute() { // Get all routes from the parrot server routes = make([]*parrot.Route, 0) - resp, err = client.R().SetResult(&routes).Get("http://localhost:9090/routes") + resp, err = client.R().SetResult(&routes).Get("/routes") if err != nil { panic(err) } @@ -125,7 +140,7 @@ func ExampleRoute() { // 0 } -func ExampleRecorder() { +func ExampleRecorder_internal() { saveFile := "recorder_example.json" p, err := parrot.Wake(parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) if err != nil { @@ -147,7 +162,7 @@ func ExampleRecorder() { } // Register the recorder with the parrot instance - err = p.Record(recorder.URL) + err = p.Record(recorder.URL()) if err != nil { panic(err) } @@ -194,3 +209,107 @@ func ExampleRecorder() { // 200 // Squawk } + +// Example of how to use parrot recording when calling it from an external service +func ExampleRecorder_external() { + var ( + saveFile = "recorder_example.json" + port = 9091 + ) + defer os.Remove(saveFile) // Cleanup the save file for the example + + go func() { // Run the parrot server as a separate instance, like in a Docker container + _, err := parrot.Wake(parrot.WithPort(port), parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile)) + if err != nil { + panic(err) + } + }() + + client := resty.New() + client.SetBaseURL(fmt.Sprintf("http://localhost:%d", port)) // The URL of the parrot server + + waitForParrotServer(client, time.Second) // Wait for the parrot server to start + + // Register a new route /test that will return a 200 status code with a text/plain response body of "Squawk" + route := &parrot.Route{ + Method: http.MethodGet, + Path: "/test", + RawResponseBody: "Squawk", + ResponseStatusCode: http.StatusOK, + } + + // Register the route with the parrot instance + resp, err := client.R().SetBody(route).Post("/routes") + if err != nil { + panic(err) + } + + // Use the host of the machine your recorder is running on + // This should not be localhost if you are running the parrot server on a different machine + // It should be the public IP address of the machine running your code, so that the parrot can call back to it + host := "localhost" + + // Create a new recorder with our host + recorder, err := parrot.NewRecorder(parrot.WithHost(host)) + if err != nil { + panic(err) + } + + // Register the recorder with the parrot instance + resp, err = client.R().SetBody(recorder).Post("/record") + if err != nil { + panic(err) + } + if resp.StatusCode() != http.StatusCreated { + panic(fmt.Sprintf("failed to register recorder, got %d status code", resp.StatusCode())) + } + + go func() { // Some other service calls the /test route + _, err := client.R().Get("/test") + if err != nil { + panic(err) + } + }() + + // You can now listen to the recorder for all route calls + for { + select { + case recordedRouteCall := <-recorder.Record(): + if recordedRouteCall.RouteID == route.ID() { + fmt.Println(recordedRouteCall.RouteID) + fmt.Println(recordedRouteCall.Request.Method) + fmt.Println(recordedRouteCall.Response.StatusCode) + fmt.Println(string(recordedRouteCall.Response.Body)) + return + } + case err := <-recorder.Err(): + panic(err) + } + } + // Output: + // GET:/test + // GET + // 200 + // Squawk +} + +// waitForParrotServer checks the parrot server health endpoint until it returns a 200 status code or the timeout is reached +func waitForParrotServer(client *resty.Client, timeoutDur time.Duration) { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + timeout := time.NewTimer(timeoutDur) + for { // Wait for the parrot server to start + select { + case <-ticker.C: + resp, err := client.R().Get("/health") + if err != nil { + continue + } + if resp.StatusCode() == http.StatusOK { + return + } + case <-timeout.C: + panic("timeout waiting for parrot server to start") + } + } +} diff --git a/parrot/parrot.go b/parrot/parrot.go index 7fcf8dd05..c6b5960d2 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -45,11 +45,6 @@ func (r *Route) ID() string { return r.Method + ":" + r.Path } -// RouteRequest is the request body for querying the server on a specific route -type RouteRequest struct { - ID string `json:"id"` -} - // Server is a mock HTTP server that can register and respond to dynamic routes type Server struct { port int @@ -203,7 +198,7 @@ func Wake(options ...ServerOption) (*Server, error) { p.log = zerolog.New(multiWriter).Level(p.logLevel).With().Timestamp().Logger() } - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p.port)) + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", p.port)) if err != nil { return nil, fmt.Errorf("failed to start listener: %w", err) } @@ -220,8 +215,10 @@ func Wake(options ...ServerOption) (*Server, error) { mux := http.NewServeMux() // TODO: Add a route to enable registering recorders - mux.HandleFunc("/routes", p.routesHandler) - mux.HandleFunc("/", p.dynamicHandler) + mux.HandleFunc("/routes", p.registerHandler) + mux.HandleFunc("/record", p.recordHandler) + mux.HandleFunc("/health", p.healthHandler) + mux.HandleFunc("/", p.routesHandler) p.server = &http.Server{ ReadHeaderTimeout: 5 * time.Second, @@ -238,6 +235,7 @@ func Wake(options ...ServerOption) (*Server, error) { return p, nil } +// run starts the parrot server func (p *Server) run(listener net.Listener) { defer func() { p.shutDown = true @@ -252,7 +250,7 @@ func (p *Server) run(listener net.Listener) { }) }() - p.log.Info().Int("Port", p.Port()).Str("Address", p.address).Msg("Parrot awake and ready to squawk") + p.log.Info().Str("Address", p.address).Msg("Parrot awake and ready to squawk") p.log.Debug().Str("Save File", p.saveFileName).Str("Log File", p.logFileName).Msg("Configuration") if err := p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { p.log.Fatal().Err(err).Msg("Error while running server") @@ -274,11 +272,6 @@ func (p *Server) WaitShutdown() { <-p.shutDownChan } -// Port returns the port the parrot is running on -func (p *Server) Port() int { - return p.port -} - // Address returns the address the parrot is running on func (p *Server) Address() string { return p.address @@ -325,11 +318,83 @@ func (p *Server) Register(route *Route) error { return nil } -// routesHandler handles the dynamic route registration. -func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { +// Record registers a new recorder with the parrot. All incoming requests to the parrot will be sent to the recorder. +func (p *Server) Record(recorderURL string) error { + if p.shutDown { + return ErrServerShutdown + } + + p.recordersMu.Lock() + defer p.recordersMu.Unlock() + if recorderURL == "" { + return ErrNoRecorderURL + } + _, err := url.Parse(recorderURL) + if err != nil { + return ErrInvalidRecorderURL + } + p.recorderHooks[recorderURL] = struct{}{} + return nil +} + +// Recorders returns the URLs of all registered recorders +func (p *Server) Recorders() []string { + if p.shutDown { + return nil + } + + p.recordersMu.RLock() + defer p.recordersMu.RUnlock() + recorders := make([]string, 0, len(p.recorderHooks)) + for recorder := range p.recorderHooks { + recorders = append(recorders, recorder) + } + return recorders +} + +// Delete removes a route from the parrot +func (p *Server) Delete(routeID string) error { + if p.shutDown { + return ErrServerShutdown + } + + p.routesMu.RLock() + _, exists := p.routes[routeID] + p.routesMu.RUnlock() + + if !exists { + return newDynamicError(ErrRouteNotFound, routeID) + } + p.routesMu.Lock() + defer p.routesMu.Unlock() + delete(p.routes, routeID) + return nil +} + +// Call makes a request to the parrot server +func (p *Server) Call(method, path string) (*resty.Response, error) { + if p.shutDown { + return nil, ErrServerShutdown + } + return p.client.R().Execute(method, "http://"+filepath.Join(p.Address(), path)) +} + +func (p *Server) Routes() []*Route { + p.routesMu.RLock() + defer p.routesMu.RUnlock() + + routes := make([]*Route, 0, len(p.routes)) + for _, route := range p.routes { + routes = append(routes, route) + } + return routes +} + +// registerHandler handles registering, unregistering, and querying routes +func (p *Server) registerHandler(w http.ResponseWriter, r *http.Request) { routesLogger := zerolog.Ctx(r.Context()) if r.Method == http.MethodDelete { - var routeRequest *RouteRequest + var routeRequest *Route if err := json.NewDecoder(r.Body).Decode(&routeRequest); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) routesLogger.Debug().Err(err).Msg("Failed to decode request body") @@ -337,13 +402,13 @@ func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - if routeRequest.ID == "" { + if routeRequest.ID() == "" { http.Error(w, "Route ID required", http.StatusBadRequest) routesLogger.Debug().Msg("No Route ID provided") return } - err := p.Unregister(routeRequest.ID) + err := p.Delete(routeRequest.ID()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) routesLogger.Debug().Err(err).Msg("Failed to unregister route") @@ -352,7 +417,7 @@ func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) routesLogger.Info(). - Str("Route ID", routeRequest.ID). + Str("Route ID", routeRequest.ID()). Msg("Route unregistered") return } @@ -407,80 +472,8 @@ func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { routesLogger.Debug().Msg("Invalid method") } -// Record registers a new recorder with the parrot. All incoming requests to the parrot will be sent to the recorder. -func (p *Server) Record(recorderURL string) error { - if p.shutDown { - return ErrServerShutdown - } - - p.recordersMu.Lock() - defer p.recordersMu.Unlock() - if recorderURL == "" { - return ErrNoRecorderURL - } - _, err := url.Parse(recorderURL) - if err != nil { - return ErrInvalidRecorderURL - } - p.recorderHooks[recorderURL] = struct{}{} - return nil -} - -// Recorders returns the URLs of all registered recorders -func (p *Server) Recorders() []string { - if p.shutDown { - return nil - } - - p.recordersMu.RLock() - defer p.recordersMu.RUnlock() - recorders := make([]string, 0, len(p.recorderHooks)) - for recorder := range p.recorderHooks { - recorders = append(recorders, recorder) - } - return recorders -} - -// Unregister removes a route from the parrot -func (p *Server) Unregister(routeID string) error { - if p.shutDown { - return ErrServerShutdown - } - - p.routesMu.RLock() - _, exists := p.routes[routeID] - p.routesMu.RUnlock() - - if !exists { - return newDynamicError(ErrRouteNotFound, routeID) - } - p.routesMu.Lock() - defer p.routesMu.Unlock() - delete(p.routes, routeID) - return nil -} - -// Call makes a request to the parrot server -func (p *Server) Call(method, path string) (*resty.Response, error) { - if p.shutDown { - return nil, ErrServerShutdown - } - return p.client.R().Execute(method, "http://"+filepath.Join(p.Address(), path)) -} - -func (p *Server) Routes() []*Route { - p.routesMu.RLock() - defer p.routesMu.RUnlock() - - routes := make([]*Route, 0, len(p.routes)) - for _, route := range p.routes { - routes = append(routes, route) - } - return routes -} - -// dynamicHandler handles all incoming requests and responds based on the registered routes. -func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { +// routesHandler handles all incoming requests and responds based on the registered routes. +func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { p.routesMu.RLock() route, exists := p.routes[r.Method+":"+r.URL.Path] p.routesMu.RUnlock() @@ -581,6 +574,54 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { http.Error(recordingWriter, "Route has no response", http.StatusInternalServerError) } +// recordHandler handles registering recorders with the parrot +func (p *Server) recordHandler(w http.ResponseWriter, r *http.Request) { + recordingLogger := zerolog.Ctx(r.Context()) + if r.Method != http.MethodPost { + http.Error(w, "Invalid method", http.StatusMethodNotAllowed) + recordingLogger.Debug().Msg("Invalid method") + return + } + + var recorder *Recorder + if err := json.NewDecoder(r.Body).Decode(&recorder); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + recordingLogger.Debug().Err(err).Msg("Failed to decode request body") + return + } + defer r.Body.Close() + + if recorder == nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + recordingLogger.Debug().Msg("No recorder provided") + return + } + + if recorder.URL() == "" { + http.Error(w, "Recorder URL required", http.StatusBadRequest) + recordingLogger.Debug().Msg("No recorder URL provided") + return + } + + if err := p.Record(recorder.URL()); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + recordingLogger.Debug().Err(err).Msg("Failed to register recorder") + return + } + + w.WriteHeader(http.StatusCreated) + recordingLogger.Debug().Str("URL", recorder.URL()).Msg("Recorder added") +} + +func (p *Server) healthHandler(w http.ResponseWriter, _ *http.Request) { + if p.shutDown { + http.Error(w, "Server is shutting down", http.StatusServiceUnavailable) + return + } + + w.WriteHeader(http.StatusOK) +} + // load loads all registered routes from a file. func (p *Server) load() error { if _, err := os.Stat(p.saveFileName); os.IsNotExist(err) { @@ -700,7 +741,7 @@ var pathRegex = regexp.MustCompile(`^\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@\/]*$`) func isValidPath(path string) bool { switch path { - case "", "/", "//", "/register", "/.", "/..": + case "", "/", "//", "/register", "/health", "/.", "/..": return false } if !strings.HasPrefix(path, "/") { @@ -709,12 +750,8 @@ func isValidPath(path string) bool { if strings.HasPrefix(path, "/register") { return false } - if strings.HasPrefix(path, "/unregister") { - return false - } - u, err := url.Parse(path) - if err != nil || u.Path != path { + if strings.HasPrefix(path, "/health") { return false } - return pathRegex.MatchString(u.Path) + return pathRegex.MatchString(path) } diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index 05f3069d8..9df01397d 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -230,7 +230,7 @@ func TestCustomLogFile(t *testing.T) { logFile := t.Name() + ".log" saveFile := t.Name() + ".json" - p, err := Wake(WithLogFile(logFile), WithSaveFile(saveFile), WithLogLevel(zerolog.DebugLevel)) + p, err := Wake(WithLogFile(logFile), WithSaveFile(saveFile), WithLogLevel(zerolog.InfoLevel)) require.NoError(t, err, "error waking parrot") t.Cleanup(func() { @@ -368,7 +368,7 @@ func TestUnregisteredRoute(t *testing.T) { assert.Equal(t, http.StatusNotFound, resp.StatusCode()) } -func TestUnregister(t *testing.T) { +func TestDelete(t *testing.T) { t.Parallel() p := newParrot(t) @@ -389,7 +389,7 @@ func TestUnregister(t *testing.T) { assert.Equal(t, resp.StatusCode(), route.ResponseStatusCode) assert.Equal(t, route.RawResponseBody, string(resp.Body())) - err = p.Unregister(route.ID()) + err = p.Delete(route.ID()) require.NoError(t, err, "error unregistering route") resp, err = p.Call(route.Method, route.Path) diff --git a/parrot/recorder.go b/parrot/recorder.go index 664f6148e..ba5f16f1e 100644 --- a/parrot/recorder.go +++ b/parrot/recorder.go @@ -10,8 +10,10 @@ import ( "time" ) +// Recorder records route calls type Recorder struct { - URL string `json:"url"` + Host string `json:"host"` + Port string `json:"port"` server *http.Server recordChan chan *RouteCall @@ -40,19 +42,31 @@ type RouteCallResponse struct { Body []byte `json:"body"` } +// RecorderOption is a function that modifies a recorder +type RecorderOption func(*Recorder) + +// WithHost sets the host of the recorder +func WithHost(host string) RecorderOption { + return func(r *Recorder) { + r.Host = host + } +} + // NewRecorder creates a new recorder that listens for incoming requests to the parrot server -func NewRecorder() (*Recorder, error) { +func NewRecorder(opts ...RecorderOption) (*Recorder, error) { r := &Recorder{ recordChan: make(chan *RouteCall), errChan: make(chan error), } - // TODO: Will need a way to send out the URL to an external service (e.g. Parrotserver running in a docker container) - listener, err := net.Listen("tcp", "127.0.0.1:0") + listener, err := net.Listen("tcp", ":0") // nolint:gosec if err != nil { return nil, fmt.Errorf("failed to start listener: %w", err) } - r.URL = "http://" + listener.Addr().String() + r.Host, r.Port, err = net.SplitHostPort(listener.Addr().String()) + if err != nil { + return nil, fmt.Errorf("failed to split host and port: %w", err) + } mux := http.NewServeMux() mux.Handle("/", r.defaultRecordHandler()) @@ -62,6 +76,10 @@ func NewRecorder() (*Recorder, error) { Handler: mux, } + for _, opt := range opts { + opt(r) + } + go func() { if err := r.server.Serve(listener); err != nil { if err != http.ErrServerClosed { @@ -72,6 +90,14 @@ func NewRecorder() (*Recorder, error) { return r, nil } +// URL returns the URL of the recorder to send requests to +// WARNING: This URL automatically binds to the first available port on the host machine +// and the host will be 0.0.0.0 or localhost. If you're calling this from a different machine +// you will need to replace the host with the IP address of the machine running the recorder. +func (r *Recorder) URL() string { + return fmt.Sprintf("http://%s:%s", r.Host, r.Port) +} + // Record receives recorded calls func (r *Recorder) Record() chan *RouteCall { return r.recordChan @@ -79,8 +105,6 @@ func (r *Recorder) Record() chan *RouteCall { // Close shuts down the recorder func (r *Recorder) Close() error { - // close(r.recordChan) - // close(r.errChan) return r.server.Close() } diff --git a/parrot/recorder_test.go b/parrot/recorder_test.go index 0b29194dd..91684e5cd 100644 --- a/parrot/recorder_test.go +++ b/parrot/recorder_test.go @@ -91,7 +91,7 @@ func TestRecorder(t *testing.T) { require.NoError(t, recorder.Close()) }) - err = p.Record(recorder.URL) + err = p.Record(recorder.URL()) require.NoError(t, err, "error recording parrot") t.Cleanup(func() { require.NoError(t, recorder.Close()) @@ -160,7 +160,7 @@ func TestMultipleRecorders(t *testing.T) { }) for _, recorder := range recorders { - err := p.Record(recorder.URL) + err := p.Record(recorder.URL()) require.NoError(t, err, "error recording parrot") } From 9f0ef71835d6d70832a9944110f01b9609f25780 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 24 Jan 2025 08:49:32 -0500 Subject: [PATCH 25/30] Update README --- parrot/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/parrot/README.md b/parrot/README.md index bd243ab42..16936e860 100644 --- a/parrot/README.md +++ b/parrot/README.md @@ -9,6 +9,10 @@ go run ./cmd go run ./cmd -h # See all config options ``` +## Use + +See our runnable examples in [examples_test.go](./examples_test.go) to see how to use Parrot programmatically. + ## Test ```sh From a6bda1fb9036126df478f9f1ccc4ede76beaee3f Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 24 Jan 2025 08:50:44 -0500 Subject: [PATCH 26/30] Fix goreleaser --- parrot/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parrot/Makefile b/parrot/Makefile index 828753d60..478087dec 100644 --- a/parrot/Makefile +++ b/parrot/Makefile @@ -34,4 +34,4 @@ build: .PHONY: goreleaser goreleaser: - cd .. && goreleaser build --snapshot --clean -f ./parrot/.goreleaser.yaml --output ./parrot/dist \ No newline at end of file + cd .. && goreleaser build --snapshot --clean -f ./parrot/.goreleaser.yaml \ No newline at end of file From 207d9fe0959a46a15a3a69daa952eb5dfa52ec65 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 24 Jan 2025 08:56:17 -0500 Subject: [PATCH 27/30] More docs --- parrot/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/parrot/README.md b/parrot/README.md index 16936e860..f1500e4fe 100644 --- a/parrot/README.md +++ b/parrot/README.md @@ -2,6 +2,16 @@ A simple, high-performing mockserver that can dynamically build new routes with customized responses, parroting back whatever you tell it to. +## Features + +* Simplistic and fast design +* Run within your Go code, through a small binary, or in a minimal Docker container +* Easily record all incoming requests to the server to programmatically react to + +## Use + +See our runnable examples in [examples_test.go](./examples_test.go) to see how to use Parrot programmatically. + ## Run ```sh @@ -9,10 +19,6 @@ go run ./cmd go run ./cmd -h # See all config options ``` -## Use - -See our runnable examples in [examples_test.go](./examples_test.go) to see how to use Parrot programmatically. - ## Test ```sh From 378e65ab184068858ce8823c95aa914f5902302a Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 24 Jan 2025 09:15:28 -0500 Subject: [PATCH 28/30] Code cleanup and more tests --- parrot/README.md | 2 +- parrot/parrot.go | 61 ++++++++++++++++++++----------------------- parrot/parrot_test.go | 47 +++++++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/parrot/README.md b/parrot/README.md index f1500e4fe..cb9fc8011 100644 --- a/parrot/README.md +++ b/parrot/README.md @@ -32,4 +32,4 @@ make bench # Benchmark ```sh make goreleaser # Uses goreleaser to build binaries and docker containers -``` \ No newline at end of file +``` diff --git a/parrot/parrot.go b/parrot/parrot.go index c6b5960d2..595130bff 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -23,6 +23,12 @@ import ( "github.com/rs/zerolog/hlog" ) +const ( + healthRoute = "/health" + routesRoute = "/routes" + recordRoute = "/record" +) + // Route holds information about the mock route configuration type Route struct { // Method is the HTTP method to match @@ -215,10 +221,10 @@ func Wake(options ...ServerOption) (*Server, error) { mux := http.NewServeMux() // TODO: Add a route to enable registering recorders - mux.HandleFunc("/routes", p.registerHandler) - mux.HandleFunc("/record", p.recordHandler) - mux.HandleFunc("/health", p.healthHandler) - mux.HandleFunc("/", p.routesHandler) + mux.HandleFunc(routesRoute, p.routeHandler) + mux.HandleFunc(recordRoute, p.recordHandler) + mux.HandleFunc(healthRoute, p.healthHandler) + mux.HandleFunc("/", p.dynamicHandler) p.server = &http.Server{ ReadHeaderTimeout: 5 * time.Second, @@ -329,7 +335,7 @@ func (p *Server) Record(recorderURL string) error { if recorderURL == "" { return ErrNoRecorderURL } - _, err := url.Parse(recorderURL) + _, err := url.ParseRequestURI(recorderURL) if err != nil { return ErrInvalidRecorderURL } @@ -390,25 +396,19 @@ func (p *Server) Routes() []*Route { return routes } -// registerHandler handles registering, unregistering, and querying routes -func (p *Server) registerHandler(w http.ResponseWriter, r *http.Request) { +// routeHandler handles registering, unregistering, and querying routes +func (p *Server) routeHandler(w http.ResponseWriter, r *http.Request) { routesLogger := zerolog.Ctx(r.Context()) if r.Method == http.MethodDelete { - var routeRequest *Route - if err := json.NewDecoder(r.Body).Decode(&routeRequest); err != nil { + var route *Route + if err := json.NewDecoder(r.Body).Decode(&route); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) routesLogger.Debug().Err(err).Msg("Failed to decode request body") return } defer r.Body.Close() - if routeRequest.ID() == "" { - http.Error(w, "Route ID required", http.StatusBadRequest) - routesLogger.Debug().Msg("No Route ID provided") - return - } - - err := p.Delete(routeRequest.ID()) + err := p.Delete(route.ID()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) routesLogger.Debug().Err(err).Msg("Failed to unregister route") @@ -417,8 +417,8 @@ func (p *Server) registerHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) routesLogger.Info(). - Str("Route ID", routeRequest.ID()). - Msg("Route unregistered") + Str("Route ID", route.ID()). + Msg("Route deleted") return } @@ -431,13 +431,6 @@ func (p *Server) registerHandler(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - if route.Method == "" || route.Path == "" { - err := errors.New("Method and path are required") - http.Error(w, err.Error(), http.StatusBadRequest) - routesLogger.Debug().Err(err).Msg("Method and path are required") - return - } - err := p.Register(route) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -450,7 +443,8 @@ func (p *Server) registerHandler(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodGet { - jsonRoutes, err := json.Marshal(p.Routes()) + routes := p.Routes() + jsonRoutes, err := json.Marshal(routes) if err != nil { http.Error(w, "Failed to marshal routes", http.StatusInternalServerError) routesLogger.Debug().Err(err).Msg("Failed to marshal routes") @@ -464,7 +458,7 @@ func (p *Server) registerHandler(w http.ResponseWriter, r *http.Request) { return } - routesLogger.Debug().Msg("Returned routes") + routesLogger.Debug().Int("Count", len(routes)).Msg("Returned routes") return } @@ -472,8 +466,8 @@ func (p *Server) registerHandler(w http.ResponseWriter, r *http.Request) { routesLogger.Debug().Msg("Invalid method") } -// routesHandler handles all incoming requests and responds based on the registered routes. -func (p *Server) routesHandler(w http.ResponseWriter, r *http.Request) { +// dynamicHandler handles all incoming requests and responds based on the registered routes. +func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { p.routesMu.RLock() route, exists := p.routes[r.Method+":"+r.URL.Path] p.routesMu.RUnlock() @@ -741,16 +735,19 @@ var pathRegex = regexp.MustCompile(`^\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@\/]*$`) func isValidPath(path string) bool { switch path { - case "", "/", "//", "/register", "/health", "/.", "/..": + case "", "/", "//", healthRoute, recordRoute, routesRoute, "/.", "/..": return false } if !strings.HasPrefix(path, "/") { return false } - if strings.HasPrefix(path, "/register") { + if strings.HasPrefix(path, recordRoute) { + return false + } + if strings.HasPrefix(path, healthRoute) { return false } - if strings.HasPrefix(path, "/health") { + if strings.HasPrefix(path, routesRoute) { return false } return pathRegex.MatchString(path) diff --git a/parrot/parrot_test.go b/parrot/parrot_test.go index 9df01397d..8dd19bde9 100644 --- a/parrot/parrot_test.go +++ b/parrot/parrot_test.go @@ -170,8 +170,8 @@ func TestIsValidPath(t *testing.T) { valid: true, }, { - name: "no register", - paths: []string{"/register", "/register/", "/register//", "/register/other_stuff"}, + name: "no protected paths", + paths: []string{healthRoute, routesRoute, recordRoute, fmt.Sprintf("%s/%s", routesRoute, "route-id"), fmt.Sprintf("%s/%s", healthRoute, "recorder-id"), fmt.Sprintf("%s/%s", recordRoute, "recorder-id")}, valid: false, }, { @@ -343,6 +343,30 @@ func TestBadRegisterRoute(t *testing.T) { ResponseStatusCode: http.StatusOK, }, }, + { + name: "too many responses", + err: ErrOnlyOneResponse, + route: &Route{ + Method: http.MethodGet, + Path: "/hello", + ResponseBody: map[string]any{"message": "Squawk"}, + Handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Squawk")) + }, + ResponseStatusCode: http.StatusOK, + }, + }, + { + name: "bad JSON", + err: ErrResponseMarshal, + route: &Route{ + Method: http.MethodGet, + Path: "/json", + ResponseBody: map[string]any{"message": make(chan int)}, + ResponseStatusCode: http.StatusOK, + }, + }, } for _, tc := range testCases { @@ -356,6 +380,18 @@ func TestBadRegisterRoute(t *testing.T) { } } +func TestBadRecorder(t *testing.T) { + t.Parallel() + + p := newParrot(t) + + err := p.Record("") + require.ErrorIs(t, err, ErrNoRecorderURL, "expected error recording parrot") + + err = p.Record("invalid url") + require.ErrorIs(t, err, ErrInvalidRecorderURL, "expected error recording parrot") +} + func TestUnregisteredRoute(t *testing.T) { t.Parallel() @@ -395,6 +431,10 @@ func TestDelete(t *testing.T) { resp, err = p.Call(route.Method, route.Path) require.NoError(t, err, "error calling parrot") assert.Equal(t, http.StatusNotFound, resp.StatusCode()) + + // Try to delete the route again + err = p.Delete(route.ID()) + require.ErrorIs(t, err, ErrRouteNotFound, "expected error deleting route") } func TestSaveLoad(t *testing.T) { @@ -477,6 +517,9 @@ func TestShutDown(t *testing.T) { }) require.ErrorIs(t, err, ErrServerShutdown, "expected error registering route after shutdown") + err = p.Delete("route-id") + require.ErrorIs(t, err, ErrServerShutdown, "expected error deleting route after shutdown") + err = p.Shutdown(context.Background()) require.ErrorIs(t, err, ErrServerShutdown, "expected error shutting down parrot after shutdown") } From c22fa4292572e1b57c972487cd7fe681a49e084b Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 24 Jan 2025 13:13:42 -0500 Subject: [PATCH 29/30] Better debugging --- parrot/parrot.go | 16 +++++++++------- parrot/recorder.go | 20 +++++++++++++------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/parrot/parrot.go b/parrot/parrot.go index 595130bff..7ba4e30ac 100644 --- a/parrot/parrot.go +++ b/parrot/parrot.go @@ -479,9 +479,9 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { return } - requestID := uuid.New().String()[0:8] + routeCallID := uuid.New().String()[0:8] dynamicLogger.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str("Request ID", requestID).Str("Route ID", route.ID()) + return c.Str("Route Call ID", routeCallID).Str("Route ID", route.ID()) }) requestBody, err := io.ReadAll(r.Body) @@ -494,12 +494,14 @@ func (p *Server) dynamicHandler(w http.ResponseWriter, r *http.Request) { } routeCall := &RouteCall{ - RouteID: r.Method + ":" + r.URL.Path, + RouteCallID: routeCallID, + RouteID: r.Method + ":" + r.URL.Path, Request: &RouteCallRequest{ - Method: r.Method, - URL: r.URL, - Header: r.Header, - Body: requestBody, + Method: r.Method, + URL: r.URL, + Header: r.Header, + Body: requestBody, + RemoteAddr: r.RemoteAddr, }, } recordingWriter := newResponseWriterRecorder(w) diff --git a/parrot/recorder.go b/parrot/recorder.go index ba5f16f1e..8a2bb28b3 100644 --- a/parrot/recorder.go +++ b/parrot/recorder.go @@ -20,19 +20,25 @@ type Recorder struct { errChan chan error } -// RouteCall records that a route was called +// RouteCall records when a route is called, the request and response type RouteCall struct { - RouteID string `json:"route_id"` - Request *RouteCallRequest `json:"request"` + // RouteCallID is a unique identifier for the route call for help with debugging + RouteCallID string `json:"route_call_id"` + // RouteID is the identifier of the route that was called + RouteID string `json:"route_id"` + // Request is the request made to the route + Request *RouteCallRequest `json:"request"` + // Response is the response from the route Response *RouteCallResponse `json:"response"` } // RouteCallRequest records the request made to a route type RouteCallRequest struct { - Method string `json:"method"` - URL *url.URL `json:"url"` - Header http.Header `json:"header"` - Body []byte `json:"body"` + Method string `json:"method"` + URL *url.URL `json:"url"` + RemoteAddr string `json:"caller"` + Header http.Header `json:"header"` + Body []byte `json:"body"` } // RouteCallResponse records the response from a route From 336d1fc2c4e1901d91a72d7d51a6564c306cb87c Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Fri, 24 Jan 2025 13:20:52 -0500 Subject: [PATCH 30/30] Fix artifact name --- .github/workflows/framework-golden-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/framework-golden-tests.yml b/.github/workflows/framework-golden-tests.yml index 2bb91b0b7..61a759c8b 100644 --- a/.github/workflows/framework-golden-tests.yml +++ b/.github/workflows/framework-golden-tests.yml @@ -22,7 +22,7 @@ jobs: config: smoke.toml count: 1 timeout: 10m - - name: TestSmoke + - name: TestSmokeLimitedResources config: smoke_limited_resources.toml count: 1 timeout: 10m