parrot

package module
v0.6.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 11, 2025 License: MIT Imports: 24 Imported by: 4

README

Parrot Server

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 to see how to use Parrot programmatically.

Run

go run ./cmd
go run ./cmd -h # See all config options 

Test

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

make goreleaser # Uses goreleaser to build binaries and docker containers

Documentation

Index

Examples

Constants

View Source
const (
	HealthRoute   = "/health"
	RoutesRoute   = "/routes"
	RecorderRoute = "/recorder"

	// MethodAny is a wildcard for any HTTP method
	MethodAny = "ANY"
)

Variables

View Source
var (
	ErrNilRoute        = errors.New("route is nil")
	ErrInvalidPath     = errors.New("invalid path")
	ErrInvalidMethod   = errors.New("invalid method")
	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")
	ErrWildcardPath    = fmt.Errorf("path can only contain one wildcard '*' and it must be the final value")

	ErrNoRecorderURL      = errors.New("no recorder URL specified")
	ErrInvalidRecorderURL = errors.New("invalid recorder URL")
	ErrRecorderNotFound   = errors.New("recorder not found")

	ErrServerShutdown  = errors.New("parrot is already asleep")
	ErrServerUnhealthy = errors.New("parrot is unhealthy")
)

Functions

This section is empty.

Types

type Client added in v0.4.0

type Client struct {
	// contains filtered or unexported fields
}

Client interacts with a parrot server

func NewClient added in v0.4.0

func NewClient(url string) *Client

NewClient creates a new client for a parrot server running at the given url.

func (*Client) CallRoute added in v0.4.0

func (c *Client) CallRoute(method, path string) (*resty.Response, error)

CallRoute calls a route on the server

func (*Client) DeleteRoute added in v0.4.0

func (c *Client) DeleteRoute(route *Route) error

DeleteRoute deletes a route on the server

func (*Client) Healthy added in v0.4.0

func (c *Client) Healthy() (bool, error)

Health returns the health of the server

func (*Client) Recorders added in v0.4.0

func (c *Client) Recorders() ([]string, error)

Recorders returns all the recorders registered on the server

func (*Client) RegisterRecorder added in v0.4.0

func (c *Client) RegisterRecorder(recorder *Recorder) error

RegisterRecorder registers a recorder on the server

func (*Client) RegisterRoute added in v0.4.0

func (c *Client) RegisterRoute(route *Route) error

RegisterRoute registers a route on the server

func (*Client) Routes added in v0.4.0

func (c *Client) Routes() ([]*Route, error)

Routes returns all the routes registered on the server

type Recorder

type Recorder struct {
	Host string `json:"host"`
	Port string `json:"port"`
	// contains filtered or unexported fields
}

Recorder records route calls

Example (External)

Example of how to use parrot recording when calling it from an external service

package main

import (
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/rs/zerolog"

	"github.com/smartcontractkit/chainlink-testing-framework/parrot"
)

func main() {
	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.NewServer(parrot.WithPort(port), parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile))
		if err != nil {
			panic(err)
		}
	}()

	client := parrot.NewClient(fmt.Sprintf("http://localhost:%d", port))
	waitForParrotServerExternal(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
	err := client.RegisterRoute(route)
	if err != nil {
		panic(err)
	}

	// Use the recorderHost 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
	recorderHost := "localhost"

	// Create a new recorder with our host
	recorder, err := parrot.NewRecorder(parrot.WithRecorderHost(recorderHost))
	if err != nil {
		panic(err)
	}

	// Register the recorder with the parrot instance
	err = client.RegisterRecorder(recorder)
	if err != nil {
		panic(err)
	}

	recorders, err := client.Recorders()
	if err != nil {
		panic(err)
	}
	fmt.Printf("Found %d recorders\n", len(recorders))

	go func() { // Some other service calls the /test route
		_, err := client.CallRoute(http.MethodGet, "/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)
		}
	}
}

// waitForParrotServerExternal checks the parrot server health endpoint until it returns a 200 status code or the timeout is reached
func waitForParrotServerExternal(client *parrot.Client, timeoutDur time.Duration) {
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()
	timeout := time.NewTimer(timeoutDur)
	for {
		select {
		case <-ticker.C:
			healthy, err := client.Healthy()
			if err != nil {
				continue
			}
			if healthy {
				return
			}
		case <-timeout.C:
			panic("timeout waiting for parrot server to start")
		}
	}
}
Output:

Found 1 recorders
GET:/test
GET
200
Squawk
Example (Internal)
package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/rs/zerolog"

	"github.com/smartcontractkit/chainlink-testing-framework/parrot"
)

func main() {
	saveFile := "recorder_example.json"
	p, err := parrot.NewServer(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
		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
	recorder, err := parrot.NewRecorder()
	if err != nil {
		panic(err)
	}

	waitForParrotServerInternal(p, time.Second) // Wait for the parrot server to start

	// 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() {
		_, err := p.Call(http.MethodGet, "/test")
		if err != nil {
			panic(err)
		}
	}()

	// 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)
		}
	}
}

func waitForParrotServerInternal(p *parrot.Server, timeoutDur time.Duration) {
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()
	timeout := time.NewTimer(timeoutDur)
	for {
		select {
		case <-ticker.C:
			if err := p.Healthy(); err == nil {
				return
			}
		case <-timeout.C:
			panic("timeout waiting for parrot server to start")
		}
	}
}
Output:

GET:/test
GET
200
Squawk

func NewRecorder

func NewRecorder(opts ...RecorderOption) (*Recorder, error)

NewRecorder creates a new recorder that listens for incoming requests to the parrot server

func (*Recorder) Close

func (r *Recorder) Close() error

Close shuts down the recorder

func (*Recorder) Err

func (r *Recorder) Err() chan error

Err receives errors from the recorder

func (*Recorder) Record

func (r *Recorder) Record() chan *RouteCall

Record receives recorded calls

func (*Recorder) URL

func (r *Recorder) URL() string

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.

type RecorderOption

type RecorderOption func(*Recorder)

RecorderOption is a function that modifies a recorder

func WithRecorderHost added in v0.6.1

func WithRecorderHost(host string) RecorderOption

WithRecorderHost sets the host of the recorder

type Route

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"`
	// 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"`
}

Route holds information about the mock route configuration

func (*Route) ID

func (r *Route) ID() string

ID returns the unique identifier for the route

type RouteCall

type RouteCall struct {
	// ID is a unique identifier for the route call for help with debugging
	ID string `json:"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"`
}

RouteCall records when a route is called, the request and response

type RouteCallRequest

type RouteCallRequest struct {
	Method     string      `json:"method"`
	URL        *url.URL    `json:"url"`
	RemoteAddr string      `json:"caller"`
	Header     http.Header `json:"header"`
	Body       []byte      `json:"body"`
}

RouteCallRequest records the request made to a route

type RouteCallResponse

type RouteCallResponse struct {
	StatusCode int         `json:"status_code"`
	Header     http.Header `json:"header"`
	Body       []byte      `json:"body"`
}

RouteCallResponse records the response from a route

type SaveFile

type SaveFile struct {
	Routes    []*Route `json:"routes"`
	Recorders []string `json:"recorders"`
}

SaveFile is the structure of the file to save and load parrot data from

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a mock HTTP server that can register and respond to dynamic routes

Example (External)
package main

import (
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/rs/zerolog"

	"github.com/smartcontractkit/chainlink-testing-framework/parrot"
)

func main() {
	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.NewServer(parrot.WithPort(port), parrot.WithLogLevel(zerolog.NoLevel), parrot.WithSaveFile(saveFile))
		if err != nil {
			panic(err)
		}
	}()

	// Get a client to interact with the parrot server
	client := parrot.NewClient(fmt.Sprintf("http://localhost:%d", port))
	waitForParrotServerExternal(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,
	}
	err := client.RegisterRoute(route)
	if err != nil {
		panic(err)
	}
	fmt.Println("Registered route")

	// Get all routes from the parrot server
	routes, err := client.Routes()
	if err != nil {
		panic(err)
	}
	fmt.Printf("Found %d routes\n", len(routes))

	// Delete the route
	err = client.DeleteRoute(route)
	if err != nil {
		panic(err)
	}
	fmt.Println("Deleted route")

	// Get all routes from the parrot server
	routes, err = client.Routes()
	if err != nil {
		panic(err)
	}
	fmt.Printf("Found %d routes\n", len(routes))

}

// waitForParrotServerExternal checks the parrot server health endpoint until it returns a 200 status code or the timeout is reached
func waitForParrotServerExternal(client *parrot.Client, timeoutDur time.Duration) {
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()
	timeout := time.NewTimer(timeoutDur)
	for {
		select {
		case <-ticker.C:
			healthy, err := client.Healthy()
			if err != nil {
				continue
			}
			if healthy {
				return
			}
		case <-timeout.C:
			panic("timeout waiting for parrot server to start")
		}
	}
}
Output:

Registered route
Found 1 routes
Deleted route
Found 0 routes
Example (Internal)
package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/rs/zerolog"

	"github.com/smartcontractkit/chainlink-testing-framework/parrot"
)

func main() {
	// Create a new parrot instance with no logging and a custom save file
	saveFile := "register_example.json"
	p, err := parrot.NewServer(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
		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"
	route := &parrot.Route{
		Method:             http.MethodGet,
		Path:               "/test",
		RawResponseBody:    "Squawk",
		ResponseStatusCode: http.StatusOK,
	}

	waitForParrotServerInternal(p, time.Second) // Wait for the parrot server to start

	// 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)
	}
	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
	p.Delete(route)

	// Get all routes from the parrot instance
	routes = p.Routes()
	fmt.Println(len(routes))
}

func waitForParrotServerInternal(p *parrot.Server, timeoutDur time.Duration) {
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()
	timeout := time.NewTimer(timeoutDur)
	for {
		select {
		case <-ticker.C:
			if err := p.Healthy(); err == nil {
				return
			}
		case <-timeout.C:
			panic("timeout waiting for parrot server to start")
		}
	}
}
Output:

200
Squawk
1
0

func NewServer added in v0.4.0

func NewServer(options ...ServerOption) (*Server, error)

NewServer creates a new Parrot server with dynamic route handling

func (*Server) Address

func (p *Server) Address() string

Address returns the address the parrot is running on

func (*Server) Call

func (p *Server) Call(method, path string) (*resty.Response, error)

Call makes a request to the parrot server

func (*Server) Delete

func (p *Server) Delete(route *Route)

Delete removes a route from the parrot

func (*Server) Healthy added in v0.3.0

func (p *Server) Healthy() error

Healthy checks if the parrot server is healthy

func (*Server) Host added in v0.3.1

func (p *Server) Host() string

Host returns the host the parrot is running on

func (*Server) Port added in v0.3.1

func (p *Server) Port() int

Port returns the port the parrot is running on

func (*Server) Record

func (p *Server) Record(recorderURL string) error

Record registers a new recorder with the parrot. All incoming requests to the parrot will be sent to the recorder.

func (*Server) Recorders

func (p *Server) Recorders() []string

Recorders returns the URLs of all registered recorders

func (*Server) Register

func (p *Server) Register(route *Route) error

Register adds a new route to the parrot

func (*Server) Routes

func (p *Server) Routes() []*Route

func (*Server) Shutdown

func (p *Server) Shutdown(ctx context.Context) error

Shutdown gracefully shuts down the parrot server

func (*Server) WaitShutdown

func (p *Server) WaitShutdown()

WaitShutdown blocks until the parrot server has shut down

type ServerOption

type ServerOption func(*Server) error

ServerOption defines functional options for configuring the ParrotServer

func DisableConsoleLogs added in v0.1.7

func DisableConsoleLogs() ServerOption

DisableConsoleLogs disables logging to the console

func WithHost

func WithHost(host string) ServerOption

WithHost sets the address for the ParrotServer to run on

func WithJSONLogs

func WithJSONLogs() ServerOption

WithJSONLogs sets the logger to output JSON logs

func WithLogFile

func WithLogFile(logFile string) ServerOption

WithLogFile sets the file to save the logs to

func WithLogLevel

func WithLogLevel(level zerolog.Level) ServerOption

WithLogLevel sets the visible log level of the default logger

func WithPort

func WithPort(port int) ServerOption

WithPort sets the port for the ParrotServer to run on

func WithRecorders added in v0.2.0

func WithRecorders(recorders ...string) ServerOption

WithRecorders sets the initial recorders for the Parrot

func WithRoutes

func WithRoutes(routes []*Route) ServerOption

WithRoutes sets the initial routes for the Parrot

func WithSaveFile

func WithSaveFile(saveFile string) ServerOption

WithSaveFile sets the file to save the routes to

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL