├── img ├── dump.png ├── platypus.jpg └── screenshot.png ├── .github ├── renovate.json └── workflows │ ├── goreleaser.yml │ └── ci.yml ├── .gitignore ├── cmd ├── logo.go ├── version.go └── root.go ├── Dockerfile ├── Dockerfile.pack ├── .goreleaser.yml ├── infra └── cors.go ├── LICENSE ├── mocker ├── mocker.go ├── dump.go ├── notfound.go ├── generator.go ├── response.go ├── validator.go ├── method.go └── endpoints.go ├── main.go ├── Makefile ├── go.mod ├── README.md └── go.sum /img/dump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/depado/platypus/HEAD/img/dump.png -------------------------------------------------------------------------------- /img/platypus.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/depado/platypus/HEAD/img/platypus.jpg -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/depado/platypus/HEAD/img/screenshot.png -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:best-practices", ":automergeMinor", ":automergeDigest"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: GoReleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | actions: read 11 | 12 | jobs: 13 | goreleaser: 14 | uses: depado/github-actions/.github/workflows/goreleaser.yml@main 15 | 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | mocks/ 14 | mock.yml 15 | platypus 16 | dist/ 17 | coverage.txt 18 | .vscode -------------------------------------------------------------------------------- /cmd/logo.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | // Logo is the string that should be displayed when the program starts 4 | var Logo = ` _ _ 5 | _ __ | | __ _| |_ _ _ _ __ _ _ ___ 6 | | '_ \| |/ _` + "`" + ` | __| | | | '_ \| | | / __| 7 | | |_) | | (_| | |_| |_| | |_) | |_| \__ \ 8 | | .__/|_|\__,_|\__|\__, | .__/ \__,_|___/ 9 | |_| |___/|_| 10 | ` 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Step 2 | FROM golang:1.25.5-alpine@sha256:72567335df90b4ed71c01bf91fb5f8cc09fc4d5f6f21e183a085bafc7ae1bec8 as builder 3 | 4 | # Dependencies 5 | RUN apk update && apk add --no-cache make git 6 | 7 | # Source 8 | WORKDIR $GOPATH/src/github.com/depado/platypus 9 | COPY go.mod go.sum ./ 10 | RUN go mod download 11 | RUN go mod verify 12 | COPY . . 13 | 14 | # Build 15 | RUN make tmp 16 | 17 | 18 | # Final Step 19 | FROM gcr.io/distroless/static@sha256:4b2a093ef4649bccd586625090a3c668b254cfe180dee54f4c94f3e9bd7e381e 20 | COPY --from=builder /tmp/platypus /go/bin/platypus 21 | ENTRYPOINT ["/go/bin/platypus"] 22 | -------------------------------------------------------------------------------- /Dockerfile.pack: -------------------------------------------------------------------------------- 1 | # Build Step 2 | FROM golang:1.25.5-alpine@sha256:72567335df90b4ed71c01bf91fb5f8cc09fc4d5f6f21e183a085bafc7ae1bec8 as builder 3 | 4 | # Dependencies 5 | RUN apk update && apk add --no-cache upx make git 6 | 7 | # Source 8 | WORKDIR $GOPATH/src/github.com/depado/platypus 9 | COPY go.mod go.sum ./ 10 | RUN go mod download 11 | RUN go mod verify 12 | COPY . . 13 | 14 | # Build 15 | RUN make tmp 16 | RUN upx --best --lzma /tmp/platypus 17 | 18 | 19 | # Final Step 20 | FROM gcr.io/distroless/static@sha256:4b2a093ef4649bccd586625090a3c668b254cfe180dee54f4c94f3e9bd7e381e 21 | COPY --from=builder /tmp/platypus /go/bin/platypus 22 | ENTRYPOINT ["/go/bin/platypus"] 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["v*"] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | golang: 12 | name: Golang 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | checks: write 17 | uses: depado/github-actions/.github/workflows/golang.yml@main 18 | 19 | docker: 20 | name: Docker 21 | needs: [golang] 22 | permissions: 23 | contents: read 24 | packages: write 25 | actions: read 26 | uses: depado/github-actions/.github/workflows/docker.yml@main 27 | secrets: 28 | cleanup-token: ${{ secrets.PAT_DELETE_PACKAGES }} 29 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - goarch: [amd64, arm, arm64, 386] 9 | goarm: [6, 7] 10 | goos: [linux, darwin, windows] 11 | ignore: 12 | - goos: darwin 13 | goarch: 386 14 | env: 15 | - CGO_ENABLED=0 16 | ldflags: 17 | - -s -w 18 | - -X 'main.Version={{ .Version }}' 19 | - -X 'main.Build={{ .Commit }}' 20 | 21 | archives: 22 | - format: tar.gz 23 | name_template: >- 24 | {{ .ProjectName }}_ 25 | {{- .Os }}_ 26 | {{- if eq .Arch "amd64" }}x86_64 27 | {{- else if eq .Arch "386" }}i386 28 | {{- else }}{{ .Arch }}{{ end }} 29 | {{- if .Arm }}v{{ .Arm }}{{ end }} 30 | format_overrides: 31 | - goos: windows 32 | format: zip 33 | 34 | changelog: 35 | sort: asc 36 | filters: 37 | exclude: 38 | - "^docs:" 39 | - "^test:" 40 | -------------------------------------------------------------------------------- /infra/cors.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gin-contrib/cors" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // NewCorsConfig generates a new cors config with the given parameters 11 | func NewCorsConfig(enable, all bool, origins, methods, headers, expose []string) *cors.Config { 12 | if !enable { 13 | return nil 14 | } 15 | c := &cors.Config{ 16 | AllowCredentials: true, 17 | MaxAge: 50 * time.Second, 18 | AllowMethods: methods, 19 | AllowHeaders: headers, 20 | ExposeHeaders: expose, 21 | } 22 | 23 | switch { 24 | case len(origins) > 0: 25 | c.AllowOrigins = origins 26 | case all: 27 | c.AllowAllOrigins = true 28 | default: 29 | logrus.WithField("error", "allow all origin disabled but no allowed origin provided").Fatal("Couldn't configure CORS") 30 | } 31 | logrus.WithFields(logrus.Fields{ 32 | "AllowMethods": methods, 33 | "AllowHeaders": headers, 34 | "ExposeHeaders": expose, 35 | "AllowOrigins": origins, 36 | "AllowAllOrigins": all, 37 | }).Debug("CORS configuration") 38 | return c 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mocker/mocker.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/pkg/errors" 8 | "github.com/sirupsen/logrus" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // MockConf contains all the endpoints and is used for parsing. 13 | type MockConf struct { 14 | Endpoints []Endpoint `yaml:"endpoints"` 15 | NoRoute *NoRouteConf `yaml:"noroute"` 16 | } 17 | 18 | // GenerateRoutes generates the routes and applies them to the r gin.Engine. 19 | func GenerateRoutes(path string, r *gin.Engine) error { 20 | var err error 21 | var out []byte 22 | 23 | mc := &MockConf{} 24 | if out, err = os.ReadFile(path); err != nil { 25 | return errors.Wrap(err, "open file") 26 | } 27 | logrus.Infof("Found mock file %s", path) 28 | 29 | if err = yaml.Unmarshal(out, mc); err != nil { 30 | return errors.Wrap(err, "unmarshal") 31 | } 32 | logrus.Info("Mock file is valid") 33 | 34 | for _, e := range mc.Endpoints { 35 | e.Compute() 36 | for _, g := range e.All { 37 | g.Generate(e.Path, r) 38 | } 39 | } 40 | 41 | if mc.NoRoute != nil { 42 | mc.NoRoute.Handle(r) 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // Build number and versions injected at compile time 10 | var ( 11 | Version = "unknown" 12 | Build = "unknown" 13 | Time = "unknown" 14 | Packer = "" 15 | ) 16 | 17 | // Descriptive help text for version command 18 | var versionHelp = ` 19 | This command will output the build number, version number and build date of platypus. 20 | The build number corresponds to the sha1 commit the binary was built against, 21 | while the version number corresponds to the latest tag the binary was built on. 22 | Finally the build date corresponds to the date the binary was built. 23 | 24 | If both values are "unknown" make sure to build platypus with "make". 25 | ` 26 | 27 | // VersionCmd is a command that will display the build number and version (if any) 28 | var VersionCmd = &cobra.Command{ 29 | Use: "version", 30 | Short: "Show build, version and build date", 31 | Long: versionHelp, 32 | Run: func(cmd *cobra.Command, args []string) { 33 | packed := "" 34 | if Packer != "" { 35 | packed = fmt.Sprintf("Packer: %s\n", Packer) 36 | } 37 | 38 | fmt.Printf("Build: %s\nVersion: %s\nBuild Date: %s\n%s", Build, Version, Time, packed) 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /mocker/dump.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/logrusorgru/aurora" 9 | ) 10 | 11 | // Dump is a type alias 12 | type Dump []string 13 | 14 | // Contains will return whether or not a specific string is present in the 15 | // slice or not. 16 | func (d Dump) Contains(s string) bool { 17 | for _, v := range d { 18 | if v == s { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | // Handle will handle request dumping if any. 26 | func (d Dump) Handle(r *http.Request, body []byte) { 27 | fmt.Println("--------------------------------------------------") 28 | if d.Contains("host") || d.Contains("all") { 29 | fmt.Printf("%s %s\n", aurora.Blue("Host:"), r.Host) 30 | } 31 | 32 | if d.Contains("proto") || d.Contains("all") { 33 | fmt.Printf("%s %s\n", aurora.Blue("Proto:"), r.Proto) 34 | } 35 | 36 | if d.Contains("host") || d.Contains("proto") || d.Contains("all") { 37 | fmt.Println() 38 | } 39 | 40 | if d.Contains("headers") || d.Contains("all") { 41 | for k, v := range r.Header { 42 | fmt.Printf("%s %s\n", aurora.BrightBlue(k+":"), strings.Join(v, ",")) 43 | } 44 | fmt.Println() 45 | } 46 | 47 | if (d.Contains("body") || d.Contains("all")) && len(body) > 0 { 48 | fmt.Printf("%s\n\n", string(body)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /mocker/notfound.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/logrusorgru/aurora" 10 | ) 11 | 12 | // NoRouteConf defines the behavior when no route is found in the mock. Defaults 13 | // to a standard 404 response. 14 | type NoRouteConf struct { 15 | Dump Dump `yaml:"dump"` 16 | Echo bool `yaml:"echo"` 17 | Code *int `yaml:"code"` 18 | Body *string `yaml:"body"` 19 | } 20 | 21 | // Handle will add a special route to customize the 404 behavior. 22 | func (nc NoRouteConf) Handle(r *gin.Engine) { 23 | 24 | code := http.StatusNotFound 25 | if nc.Code != nil { 26 | code = *nc.Code 27 | } 28 | 29 | fmt.Printf("\n%s %s", aurora.Underline("No route"), codeToColor(code).String()) 30 | 31 | if nc.Echo { 32 | fmt.Print(" 🔊") 33 | } 34 | fmt.Print("\n") 35 | 36 | r.NoRoute(func(c *gin.Context) { 37 | body, err := io.ReadAll(c.Request.Body) 38 | if err != nil { 39 | c.Status(http.StatusInternalServerError) 40 | return 41 | } 42 | 43 | nc.Dump.Handle(c.Request, body) 44 | 45 | if nc.Echo { 46 | for header, values := range c.Request.Header { 47 | for _, v := range values { 48 | c.Header(header, v) 49 | } 50 | } 51 | status := http.StatusOK 52 | if nc.Code != nil { 53 | status = *nc.Code 54 | } 55 | if nc.Body != nil { 56 | c.String(status, string(body)) 57 | } else { 58 | c.Status(status) 59 | } 60 | return 61 | } 62 | 63 | if nc.Body == nil { 64 | c.Status(code) 65 | return 66 | } 67 | 68 | c.String(code, *nc.Body) 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/depado/platypus/cmd" 7 | "github.com/depado/platypus/infra" 8 | "github.com/depado/platypus/mocker" 9 | "github.com/gin-contrib/cors" 10 | "github.com/gin-gonic/gin" 11 | "github.com/sirupsen/logrus" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | // Main command that will be run when no other command is provided on the 17 | // command-line 18 | var rootCmd = &cobra.Command{ 19 | Use: "platypus", 20 | Short: "Platypus is a very simple mock server", 21 | Run: func(cmd *cobra.Command, args []string) { run() }, // nolint: unparam 22 | } 23 | 24 | func run() { 25 | var err error 26 | 27 | fmt.Println(cmd.Logo) 28 | gin.SetMode(viper.GetString("server.mode")) 29 | r := gin.Default() 30 | corsc := infra.NewCorsConfig( 31 | viper.GetBool("server.cors.enable"), 32 | viper.GetBool("server.cors.all"), 33 | viper.GetStringSlice("server.cors.origins"), 34 | viper.GetStringSlice("server.cors.methods"), 35 | viper.GetStringSlice("server.cors.headers"), 36 | viper.GetStringSlice("server.cors.expose"), 37 | ) 38 | if corsc != nil { 39 | logrus.Info("CORS Enabled") 40 | r.Use(cors.New(*corsc)) 41 | } 42 | 43 | logrus.Info("Generating Routes") 44 | if err = mocker.GenerateRoutes(viper.GetString("mock"), r); err != nil { 45 | logrus.WithError(err).Fatal("Couldn't generate routes") 46 | } 47 | fmt.Println() 48 | logrus.Infof("Running Mock Server on %s:%d", viper.GetString("server.host"), viper.GetInt("server.port")) 49 | if err = r.Run(fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port"))); err != nil { 50 | logrus.WithError(err).Fatal("Couldn't start router") 51 | } 52 | } 53 | 54 | func main() { 55 | // Initialize Cobra and Viper 56 | cobra.OnInitialize(cmd.Initialize) 57 | cmd.AddAllFlags(rootCmd) 58 | rootCmd.AddCommand(cmd.VersionCmd) 59 | 60 | // Run the command 61 | if err := rootCmd.Execute(); err != nil { 62 | logrus.WithError(err).Fatal("Couldn't start") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | export GO111MODULE=on 4 | export CGO_ENABLED=0 5 | export VERSION=$(shell git describe --abbrev=0 --tags 2> /dev/null || echo "0.1.0") 6 | export BUILD=$(shell git rev-parse HEAD 2> /dev/null || echo "undefined") 7 | export BUILDDATE=$(shell LANG=en_us_88591 date) 8 | BINARY=platypus 9 | LDFLAGS=-ldflags "-X 'github.com/depado/platypus/cmd.Version=$(VERSION)' \ 10 | -X 'github.com/depado/platypus/cmd.Build=$(BUILD)' \ 11 | -X 'github.com/depado/platypus/cmd.Time=$(BUILDDATE)' -s -w" 12 | PACKEDFLAGS=-ldflags "-X 'github.com/depado/platypus/cmd.Version=$(VERSION)' \ 13 | -X 'github.com/depado/platypus/cmd.Build=$(BUILD)' \ 14 | -X 'github.com/depado/platypus/cmd.Time=$(BUILDDATE)' \ 15 | -X 'github.com/depado/platypus/cmd.Packer=upx --best --lzma' -s -w" 16 | 17 | .PHONY: help 18 | help: ## Display help text for makefile 19 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 20 | 21 | .PHONY: build 22 | build: ## Build 23 | go build $(LDFLAGS) -o $(BINARY) 24 | 25 | .PHONY: tmp 26 | tmp: ## Build and output the binary in /tmp 27 | go build $(LDFLAGS) -o /tmp/$(BINARY) 28 | 29 | .PHONY: packed 30 | packed: ## Build a packed version of the binary 31 | go build $(PACKEDFLAGS) -o $(BINARY) 32 | upx --best --lzma $(BINARY) 33 | 34 | .PHONY: docker 35 | docker: ## Build the docker image 36 | docker build -t $(BINARY):latest -t $(BINARY):$(BUILD) -f Dockerfile . 37 | 38 | .PHONY: release 39 | release: ## Create a new release on Github 40 | goreleaser 41 | 42 | .PHONY: snapshot 43 | snapshot: ## Create a new snapshot release 44 | goreleaser --snapshot --clean 45 | 46 | .PHONY: lint 47 | lint: ## Runs the linter 48 | $(GOPATH)/bin/golangci-lint run --exclude-use-default=false 49 | 50 | .PHONY: test 51 | test: ## Run the test suite 52 | CGO_ENABLED=1 go test -race -coverprofile="coverage.txt" ./... 53 | 54 | .PHONY: clean 55 | clean: ## Remove the binary 56 | if [ -f $(BINARY) ] ; then rm $(BINARY) ; fi 57 | if [ -f coverage.txt ] ; then rm coverage.txt ; fi 58 | -------------------------------------------------------------------------------- /mocker/generator.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | // EndpointGenerator is a simple interface. 6 | type EndpointGenerator interface { 7 | Generate(string, *gin.Engine) 8 | } 9 | 10 | // GetEndpoint implements the EndpointGenerator interface. 11 | type GetEndpoint struct { 12 | EndpointMethod `yaml:",inline"` 13 | } 14 | 15 | // Generate generates the endpoint 16 | func (e GetEndpoint) Generate(path string, r *gin.Engine) { r.GET(path, e.ToHandler()) } 17 | 18 | // PostEndpoint implements the EndpointGenerator interface. 19 | type PostEndpoint struct { 20 | EndpointMethod `yaml:",inline"` 21 | } 22 | 23 | // Generate generates the endpoint 24 | func (e PostEndpoint) Generate(path string, r *gin.Engine) { r.POST(path, e.ToHandler()) } 25 | 26 | // PutEndpoint implements the EndpointGenerator interface. 27 | type PutEndpoint struct { 28 | EndpointMethod `yaml:",inline"` 29 | } 30 | 31 | // Generate generates the endpoint 32 | func (e PutEndpoint) Generate(path string, r *gin.Engine) { r.PUT(path, e.ToHandler()) } 33 | 34 | // PatchEndpoint implements the EndpointGenerator interface. 35 | type PatchEndpoint struct { 36 | EndpointMethod `yaml:",inline"` 37 | } 38 | 39 | // Generate generates the endpoint 40 | func (e PatchEndpoint) Generate(path string, r *gin.Engine) { r.PATCH(path, e.ToHandler()) } 41 | 42 | // DeleteEndpoint implements the EndpointGenerator interface. 43 | type DeleteEndpoint struct { 44 | EndpointMethod `yaml:",inline"` 45 | } 46 | 47 | // Generate generates the endpoint 48 | func (e DeleteEndpoint) Generate(path string, r *gin.Engine) { r.DELETE(path, e.ToHandler()) } 49 | 50 | // HeadEndpoint implements the EndpointGenerator interface. 51 | type HeadEndpoint struct { 52 | EndpointMethod `yaml:",inline"` 53 | } 54 | 55 | // Generate generates the endpoint 56 | func (e HeadEndpoint) Generate(path string, r *gin.Engine) { r.HEAD(path, e.ToHandler()) } 57 | 58 | // OptionsEndpoint implements the EndpointGenerator interface. 59 | type OptionsEndpoint struct { 60 | EndpointMethod `yaml:",inline"` 61 | } 62 | 63 | // Generate generates the endpoint. 64 | func (e OptionsEndpoint) Generate(path string, r *gin.Engine) { r.OPTIONS(path, e.ToHandler()) } 65 | -------------------------------------------------------------------------------- /mocker/response.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/logrusorgru/aurora" 9 | ) 10 | 11 | // Responses represents multiple responses. 12 | type Responses []Response 13 | 14 | // Pick will pick the appropriate response. 15 | func (rr Responses) Pick(platy string) Response { 16 | // No desired code/name, return the first response 17 | if platy == "" { 18 | return rr[0] 19 | } 20 | 21 | // Find by name first 22 | for _, r := range rr { 23 | if r.Name == platy { 24 | return r 25 | } 26 | } 27 | 28 | // Find by status code next 29 | o, err := strconv.Atoi(platy) 30 | if err == nil { 31 | for _, r := range rr { 32 | if r.Code == o { 33 | return r 34 | } 35 | } 36 | } 37 | 38 | return rr[0] 39 | } 40 | 41 | // Response represents the response structure for a single endpoint method. 42 | type Response struct { 43 | Name string `yaml:"name"` 44 | Code int `yaml:"code"` 45 | Echo bool `yaml:"echo"` 46 | Headers map[string]string `yaml:"headers"` 47 | Preset string `yaml:"preset"` 48 | Body string `yaml:"body"` 49 | } 50 | 51 | // Info returns a string to print out the information of a response. 52 | func (r Response) Info(prefix string, last bool) string { 53 | var sb strings.Builder 54 | s := "├─" 55 | if last { 56 | s = "└─" 57 | } 58 | 59 | sb.WriteString(fmt.Sprintf("%s %s %s", prefix, s, codeToColor(r.Code).String())) 60 | if r.Name != "" { 61 | sb.WriteString(aurora.Cyan(" " + r.Name).String()) 62 | } 63 | if r.Preset != "" { 64 | switch r.Preset { 65 | case "json": 66 | sb.WriteString(" JSON") 67 | case "text": 68 | sb.WriteString(" Text") 69 | default: 70 | sb.WriteString(" " + r.Preset) 71 | } 72 | } 73 | if r.Echo { 74 | sb.WriteString(" 🔊") 75 | } 76 | 77 | return sb.String() 78 | } 79 | 80 | func codeToColor(code int) aurora.Value { 81 | switch { 82 | case code >= 100 && code < 200: 83 | return aurora.Cyan(code) 84 | case code >= 200 && code < 300: 85 | return aurora.Green(code) 86 | case code >= 300 && code < 400: 87 | return aurora.Yellow(code) 88 | case code >= 400 && code < 500: 89 | return aurora.Red(code) 90 | case code >= 500 && code < 600: 91 | return aurora.BrightRed(code) 92 | } 93 | return aurora.White(code) 94 | } 95 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/depado/platypus 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.25.5 6 | 7 | require ( 8 | github.com/gin-contrib/cors v1.7.6 9 | github.com/gin-gonic/gin v1.11.0 10 | github.com/logrusorgru/aurora v2.0.3+incompatible 11 | github.com/onrik/logrus v0.11.0 12 | github.com/pkg/errors v0.9.1 13 | github.com/sirupsen/logrus v1.9.3 14 | github.com/spf13/cobra v1.10.2 15 | github.com/spf13/viper v1.21.0 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/bytedance/sonic v1.14.0 // indirect 21 | github.com/bytedance/sonic/loader v0.3.0 // indirect 22 | github.com/cloudwego/base64x v0.1.6 // indirect 23 | github.com/fsnotify/fsnotify v1.9.0 // indirect 24 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 25 | github.com/gin-contrib/sse v1.1.0 // indirect 26 | github.com/go-playground/locales v0.14.1 // indirect 27 | github.com/go-playground/universal-translator v0.18.1 // indirect 28 | github.com/go-playground/validator/v10 v10.27.0 // indirect 29 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 30 | github.com/goccy/go-json v0.10.5 // indirect 31 | github.com/goccy/go-yaml v1.18.0 // indirect 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 33 | github.com/json-iterator/go v1.1.12 // indirect 34 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 35 | github.com/leodido/go-urn v1.4.0 // indirect 36 | github.com/mattn/go-isatty v0.0.20 // indirect 37 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 38 | github.com/modern-go/reflect2 v1.0.2 // indirect 39 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 40 | github.com/quic-go/qpack v0.6.0 // indirect 41 | github.com/quic-go/quic-go v0.57.0 // indirect 42 | github.com/sagikazarmark/locafero v0.11.0 // indirect 43 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 44 | github.com/spf13/afero v1.15.0 // indirect 45 | github.com/spf13/cast v1.10.0 // indirect 46 | github.com/spf13/pflag v1.0.10 // indirect 47 | github.com/subosito/gotenv v1.6.0 // indirect 48 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 49 | github.com/ugorji/go/codec v1.3.0 // indirect 50 | go.yaml.in/yaml/v3 v3.0.4 // indirect 51 | golang.org/x/arch v0.20.0 // indirect 52 | golang.org/x/crypto v0.45.0 // indirect 53 | golang.org/x/net v0.47.0 // indirect 54 | golang.org/x/sys v0.38.0 // indirect 55 | golang.org/x/text v0.31.0 // indirect 56 | google.golang.org/protobuf v1.36.9 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /mocker/validator.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // RequestValidator wraps a header validator and a body validator. 10 | type RequestValidator struct { 11 | Headers HeadersValidator `yaml:"headers"` 12 | Body BodyValidator `yaml:"body"` 13 | } 14 | 15 | // Handle will handle the validation of an incoming request, given the request 16 | // and the body in byte form. 17 | func (rv RequestValidator) Handle(r *http.Request, body []byte) []error { 18 | errors := rv.Headers.Validate(r) 19 | if err := rv.Body.Validate(body); err != nil { 20 | errors = append(errors, err) 21 | } 22 | 23 | return errors 24 | } 25 | 26 | // BodyValidator holds the values to validate an incoming request's body. 27 | type BodyValidator struct { 28 | Contains string `yaml:"contains"` 29 | } 30 | 31 | // Validate will validate that the incoming body matches the expectations of 32 | // the body validator. 33 | func (bv BodyValidator) Validate(body []byte) error { 34 | if bv.Contains != "" { 35 | if !strings.Contains(string(body), bv.Contains) { 36 | return fmt.Errorf("body doesn't contain '%s'", bv.Contains) 37 | } 38 | } 39 | return nil 40 | } 41 | 42 | // HeadersValidator holds the values to validate the headers of an incoming 43 | // request. 44 | type HeadersValidator struct { 45 | Present []string `yaml:"present"` 46 | Absent []string `yaml:"absent"` 47 | Match map[string]string `yaml:"match"` 48 | } 49 | 50 | // Validate will validate that the incoming HTTP request validates the various 51 | // rules. 52 | func (hv HeadersValidator) Validate(r *http.Request) []error { 53 | errors := []error{} 54 | 55 | for _, pres := range hv.Present { 56 | found := false 57 | for k := range r.Header { 58 | if k == pres { 59 | found = true 60 | } 61 | } 62 | if !found { 63 | errors = append(errors, fmt.Errorf("header not present: %s", pres)) 64 | } 65 | } 66 | 67 | for _, abs := range hv.Absent { 68 | for k := range r.Header { 69 | if k == abs { 70 | errors = append(errors, fmt.Errorf("header present: %s", abs)) 71 | } 72 | } 73 | } 74 | 75 | for reqkey, reqval := range hv.Match { 76 | found := false 77 | for hk, hv := range r.Header { 78 | if reqkey == hk { 79 | for _, v := range hv { 80 | if reqval == v { 81 | found = true 82 | break 83 | } 84 | } 85 | } 86 | } 87 | if !found { 88 | errors = append(errors, fmt.Errorf("incorrect header value or missing header: %s:%s", reqkey, reqval)) 89 | } 90 | } 91 | 92 | return errors 93 | } 94 | -------------------------------------------------------------------------------- /mocker/method.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | // EndpointMethod represents a single method associated to a parent endpoint. 12 | type EndpointMethod struct { 13 | Echo bool `yaml:"echo"` 14 | Dump Dump `yaml:"dump"` 15 | Validate RequestValidator `yaml:"validate"` 16 | Responses Responses `yaml:"responses"` 17 | } 18 | 19 | // Info returns the string representing the information. 20 | func (e EndpointMethod) Info(last bool) string { 21 | var sb strings.Builder 22 | pref := "\n│ " 23 | if last { 24 | pref = "\n " 25 | } 26 | 27 | for i, r := range e.Responses { 28 | sb.WriteString(r.Info(pref, i == len(e.Responses)-1)) 29 | } 30 | 31 | return sb.String() 32 | } 33 | 34 | // ToHandler generates a handler to apply on the router. 35 | func (e EndpointMethod) ToHandler() func(c *gin.Context) { 36 | return func(c *gin.Context) { 37 | // Extract the body 38 | body, err := io.ReadAll(c.Request.Body) 39 | if err != nil { 40 | c.Status(http.StatusInternalServerError) 41 | return 42 | } 43 | 44 | // Dump if required 45 | e.Dump.Handle(c.Request, body) 46 | 47 | // Validate headers and body if any validation is required 48 | if err := e.Validate.Handle(c.Request, body); len(err) != 0 { 49 | var errs []string 50 | for _, ie := range err { 51 | errs = append(errs, ie.Error()) 52 | } 53 | c.JSON(http.StatusBadRequest, gin.H{"error": "request validation failed", "reasons": errs}) 54 | return 55 | } 56 | 57 | // If no response is defined, return a 200 without a body 58 | if e.Responses == nil { 59 | c.Status(http.StatusOK) 60 | return 61 | } 62 | 63 | // Pick the desired response 64 | desired := c.Query("platy") 65 | r := e.Responses.Pick(desired) 66 | 67 | // Echo mode 68 | if r.Echo { 69 | for header, values := range c.Request.Header { 70 | for _, v := range values { 71 | c.Header(header, v) 72 | } 73 | } 74 | status := http.StatusOK 75 | if r.Code != 0 { 76 | status = r.Code 77 | } 78 | if body != nil { 79 | c.String(status, string(body)) 80 | } else { 81 | c.Status(status) 82 | } 83 | return 84 | } 85 | 86 | switch r.Preset { 87 | case "json": 88 | c.Header("Content-Type", "application/json; charset=utf-8") 89 | } 90 | 91 | for k, v := range r.Headers { 92 | c.Header(k, v) 93 | } 94 | 95 | if r.Body != "" { 96 | c.String(r.Code, r.Body) 97 | } else { 98 | c.Status(r.Code) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /mocker/endpoints.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/logrusorgru/aurora" 7 | ) 8 | 9 | // Endpoint represents a single endpoint. 10 | type Endpoint struct { 11 | Path string `yaml:"path"` 12 | 13 | Get *GetEndpoint `yaml:"get"` 14 | Post *PostEndpoint `yaml:"post"` 15 | Put *PutEndpoint `yaml:"put"` 16 | Patch *PatchEndpoint `yaml:"patch"` 17 | Delete *DeleteEndpoint `yaml:"delete"` 18 | Head *HeadEndpoint `yaml:"head"` 19 | Options *OptionsEndpoint `yaml:"options"` 20 | 21 | All []EndpointGenerator `yaml:"-"` 22 | } 23 | 24 | // Compute checks every possible endpoint to generate a slice of 25 | // EndpointGenerator. 26 | func (e *Endpoint) Compute() { 27 | e.All = []EndpointGenerator{} 28 | var hasNext bool 29 | withNext := "├─ %s%s\n│\n" 30 | withoutNext := "└─ %s%s\n" 31 | 32 | fmt.Printf("\n%s\n", aurora.Underline(e.Path)) 33 | if e.Get != nil { 34 | e.All = append(e.All, e.Get) 35 | hasNext = e.Post != nil || e.Put != nil || e.Patch != nil || e.Delete != nil || e.Head != nil || e.Options != nil 36 | if hasNext { 37 | fmt.Printf(withNext, aurora.Blue("GET"), e.Get.Info(!hasNext)) 38 | } else { 39 | fmt.Printf(withoutNext, aurora.Blue("GET"), e.Get.Info(!hasNext)) 40 | } 41 | } 42 | if e.Post != nil { 43 | e.All = append(e.All, e.Post) 44 | hasNext = e.Put != nil || e.Patch != nil || e.Delete != nil || e.Head != nil || e.Options != nil 45 | if hasNext { 46 | fmt.Printf(withNext, aurora.Green("POST"), e.Post.Info(!hasNext)) 47 | } else { 48 | fmt.Printf(withoutNext, aurora.Green("POST"), e.Post.Info(!hasNext)) 49 | } 50 | } 51 | if e.Put != nil { 52 | e.All = append(e.All, e.Put) 53 | hasNext = e.Patch != nil || e.Delete != nil || e.Head != nil || e.Options != nil 54 | if hasNext { 55 | fmt.Printf(withNext, aurora.Yellow("PUT"), e.Put.Info(!hasNext)) 56 | } else { 57 | fmt.Printf(withoutNext, aurora.Yellow("PUT"), e.Put.Info(!hasNext)) 58 | } 59 | } 60 | if e.Patch != nil { 61 | e.All = append(e.All, e.Patch) 62 | hasNext = e.Delete != nil || e.Head != nil || e.Options != nil 63 | if hasNext { 64 | fmt.Printf(withNext, aurora.BrightYellow("PATCH"), e.Patch.Info(!hasNext)) 65 | } else { 66 | fmt.Printf(withoutNext, aurora.BrightYellow("PATCH"), e.Patch.Info(!hasNext)) 67 | } 68 | } 69 | if e.Delete != nil { 70 | e.All = append(e.All, e.Delete) 71 | hasNext = e.Head != nil || e.Options != nil 72 | if hasNext { 73 | fmt.Printf(withNext, aurora.Red("DELETE"), e.Delete.Info(!hasNext)) 74 | } else { 75 | fmt.Printf(withoutNext, aurora.Red("DELETE"), e.Delete.Info(!hasNext)) 76 | } 77 | } 78 | if e.Head != nil { 79 | e.All = append(e.All, e.Head) 80 | hasNext = e.Options != nil 81 | if hasNext { 82 | fmt.Printf(withNext, aurora.Cyan("HEAD"), e.Head.Info(!hasNext)) 83 | } else { 84 | fmt.Printf(withoutNext, aurora.Cyan("HEAD"), e.Head.Info(!hasNext)) 85 | } 86 | } 87 | if e.Options != nil { 88 | e.All = append(e.All, e.Options) 89 | fmt.Printf(withoutNext, aurora.BrightCyan("OPTIONS"), e.Options.Info(true)) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/onrik/logrus/filename" 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // AddLoggerFlags adds support to configure the level of the logger 13 | func AddLoggerFlags(c *cobra.Command) { 14 | c.PersistentFlags().String("log.level", "info", "one of debug, info, warn, error or fatal") 15 | c.PersistentFlags().String("log.format", "text", "one of text or json") 16 | c.PersistentFlags().Bool("log.line", false, "enable filename and line in logs") 17 | } 18 | 19 | // AddServerFlags adds support to configure the server 20 | func AddServerFlags(c *cobra.Command) { 21 | // Server related flags 22 | c.PersistentFlags().String("server.host", "127.0.0.1", "host on which the server should listen") 23 | c.PersistentFlags().Int("server.port", 8080, "port on which the server should listen") 24 | c.PersistentFlags().String("server.mode", "release", "server mode can be either 'debug', 'test' or 'release'") 25 | 26 | // CORS related flags 27 | c.PersistentFlags().Bool("server.cors.enable", false, "enable CORS") 28 | c.PersistentFlags().StringSlice("server.cors.methods", []string{"GET", "PUT", "POST", "DELETE", "OPTION", "PATCH"}, "array of allowed method when cors is enabled") 29 | c.PersistentFlags().StringSlice("server.cors.headers", []string{"Origin", "Authorization", "Content-Type"}, "array of allowed headers") 30 | c.PersistentFlags().StringSlice("server.cors.expose", []string{}, "array of exposed headers") 31 | c.PersistentFlags().StringSlice("server.cors.origins", []string{}, "array of allowed origins (overwritten if all is active)") 32 | c.PersistentFlags().Bool("server.cors.all", false, "defines that all origins are allowed") 33 | } 34 | 35 | // AddConfigurationFlag adds support to provide a configuration file on the 36 | // command line 37 | func AddConfigurationFlag(c *cobra.Command) { 38 | c.PersistentFlags().String("conf", "", "configuration file to use") 39 | c.PersistentFlags().String("mock", "mock.yml", "file to mock from") 40 | } 41 | 42 | // AddAllFlags will add all the flags provided in this package to the provided 43 | // command and will bind those flags with viper 44 | func AddAllFlags(c *cobra.Command) { 45 | AddConfigurationFlag(c) 46 | AddLoggerFlags(c) 47 | AddServerFlags(c) 48 | if err := viper.BindPFlags(c.PersistentFlags()); err != nil { 49 | logrus.WithError(err).WithField("step", "AddAllFlags").Fatal("Couldn't bind flags") 50 | } 51 | } 52 | 53 | // Initialize will be run when cobra finishes its initialization 54 | func Initialize() { 55 | // Environment variables 56 | viper.AutomaticEnv() 57 | viper.SetEnvPrefix("platypus") 58 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 59 | 60 | // Configuration file 61 | if viper.GetString("conf") != "" { 62 | viper.SetConfigFile(viper.GetString("conf")) 63 | } else { 64 | viper.SetConfigName("conf") 65 | viper.AddConfigPath(".") 66 | viper.AddConfigPath("/config/") 67 | } 68 | hasconf := viper.ReadInConfig() == nil 69 | 70 | // Set log level 71 | lvl := viper.GetString("log.level") 72 | if l, err := logrus.ParseLevel(lvl); err != nil { 73 | logrus.WithFields(logrus.Fields{"level": lvl, "fallback": "info"}).Warn("Invalid log level") 74 | } else { 75 | logrus.SetLevel(l) 76 | } 77 | 78 | // Set log format 79 | switch viper.GetString("log.format") { 80 | case "json": 81 | logrus.SetFormatter(&logrus.JSONFormatter{}) 82 | default: 83 | logrus.SetFormatter(&logrus.TextFormatter{ 84 | DisableTimestamp: true, 85 | ForceColors: true, 86 | }) 87 | } 88 | 89 | // Defines if logrus should display filenames and line where the log ocured 90 | if viper.GetBool("log.line") { 91 | logrus.AddHook(filename.NewHook()) 92 | } 93 | 94 | // Delays the log for once the logger has been setup 95 | if hasconf { 96 | logrus.WithField("file", viper.ConfigFileUsed()).Debug("Found configuration file") 97 | } else { 98 | logrus.Debug("No configuration file found") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
4 |
5 | [](https://forthebadge.com)[](https://forthebadge.com)[](https://forthebadge.com)
6 |
7 | 
8 | [](https://goreportcard.com/report/github.com/depado/platypus)
9 | [](https://github.com/depado/platypus/blob/master/LICENSE)
10 | [](https://saythanks.io/to/Depado)
11 |
12 | Very simple mock server that doesn't do much
13 |
61 |
165 |