diff --git a/.gitignore b/.gitignore index 1c2d6c4..e6aa940 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store vendor logo.png +apisprout*.zip +apisprout*.xz diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..51ef034 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## [Unreleased] +- Add unreleased items here. + +## [1.0.0] - 2018-07-24 +- Initial release. diff --git a/Gopkg.lock b/Gopkg.lock index edd764b..1056fa0 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -16,7 +16,7 @@ "openapi3filter", "pathpattern" ] - revision = "60e95f1b88c48a8c98824f41ea5cc3c8f2d54e82" + revision = "4d32f1d41bec8d922505f20e07bc624dbd59db6a" [[projects]] name = "github.com/ghodss/yaml" @@ -24,6 +24,21 @@ revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" version = "v1.0.0" +[[projects]] + name = "github.com/gobwas/glob" + packages = [ + ".", + "compiler", + "match", + "syntax", + "syntax/ast", + "syntax/lexer", + "util/runes", + "util/strings" + ] + revision = "5ccd90ef52e1e632236f7326478d4faa74f99438" + version = "v0.2.3" + [[projects]] branch = "master" name = "github.com/hashicorp/hcl" @@ -108,7 +123,7 @@ branch = "master" name = "golang.org/x/sys" packages = ["unix"] - revision = "ac767d655b305d4e9612f5f6e33120b9176c4ad4" + revision = "e072cadbbdc8dd3d3ffa82b8b4b9304c261d9311" [[projects]] name = "golang.org/x/text" @@ -132,6 +147,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "1c6c6bd1f05cfc68d7c065c8e8228fa034a2a2ed300fbff79e2f8c204e3526a9" + inputs-digest = "5f938e4d95214f993067d19f8035458a27bff8dcb49a96279db1c38c016517e8" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 2a2b148..fe142a0 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -44,3 +44,7 @@ [[constraint]] name = "gopkg.in/yaml.v2" version = "2.2.1" + +[[constraint]] + name = "github.com/gobwas/glob" + version = "0.2.3" diff --git a/README.md b/README.md index 1fc6b8f..2ecff32 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,51 @@ API Sprout -A simple, quick, cross-platform API mock server that returns examples specified in an OpenAPI 3.x document. Usage is simple: +A simple, quick, cross-platform API mock server that returns examples specified in an API description document. Features include: + +- OpenAPI 3.x support +- Load from a URL or local file +- Accept header content negotiation +- Prefer header to select response to test specific cases +- Server name validation (enabled with `--validate-server`) +- Request parameter & body validation (enabled with `--validate-request`) +- Configuration via files, environment, or commandline flags + +Usage is simple: ```sh +# Load from a local file apisprout my-api.yaml + +# Load from a URL +apisprout https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/api-with-examples.yaml +``` + +## Installation + +Download the appropriate binary from the [releases](https://github.com/danielgtaylor/apisprout/releases) page. + +Alternatively, you can use `go get`: + +```sh +go get github.com/danielgtaylor/apisprout ``` -## ToDo +## Contributing + +Contributions are very welcome. Please open a tracking issue or pull request and we can work to get things merged in. + +## Release Process + +The following describes the steps to make a new release of API Sprout. -- [x] OpenAPI 3.x support -- [x] Return defined examples -- [ ] Validate request payload -- [ ] Take `Accept` header into account to return the right media type -- [ ] Generate fake data from schema if no example is available -- [ ] Release binaries for Windows / Mac / Linux -- [ ] Public Docker image +1. Merge open PRs you want to release. +1. Select a new semver version number (major/minor/patch depending on changes). +1. Update `CHANGELOG.md` to describe changes. +1. Create a commit for the release. +1. Tag the commit with `git tag -a -m 'Tagging x.y.z release' vx.y.z`. +1. Build release binaries with `./release.sh`. +1. Push the commit and tags. +1. Upload the release binaries. ## License diff --git a/apisprout.go b/apisprout.go index 4580fcd..b5d3a9e 100644 --- a/apisprout.go +++ b/apisprout.go @@ -1,12 +1,14 @@ package main import ( + "context" "encoding/json" "errors" "fmt" "io/ioutil" "log" "math/rand" + "mime" "net/http" "os" "path/filepath" @@ -16,19 +18,66 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" + "github.com/gobwas/glob" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/spf13/viper" yaml "gopkg.in/yaml.v2" ) +// GitSummary is filled in by `govvv` for version info. +var GitSummary string + var ( // ErrNoExample is sent when no example was found for an operation. ErrNoExample = errors.New("No example found") // ErrCannotMarshal is set when an example cannot be marshalled. ErrCannotMarshal = errors.New("Cannot marshal example") + + // ErrMissingAuth is set when no authorization header or key is present but + // one is required by the API description. + ErrMissingAuth = errors.New("Missing auth") ) +// ContentNegotiator is used to match a media type during content negotiation +// of HTTP requests. +type ContentNegotiator struct { + globs []glob.Glob +} + +// NewContentNegotiator creates a new negotiator from an HTTP Accept header. +func NewContentNegotiator(accept string) *ContentNegotiator { + // The HTTP Accept header is parsed and converted to simple globs, which + // can be used to match an incoming mimetype. Example: + // Accept: text/html, text/*;q=0.9, */*;q=0.8 + // Will be turned into the following globs: + // - text/html + // - text/* + // - */* + globs := make([]glob.Glob, 0) + for _, mt := range strings.Split(accept, ",") { + parsed, _, _ := mime.ParseMediaType(mt) + globs = append(globs, glob.MustCompile(parsed)) + } + + return &ContentNegotiator{ + globs: globs, + } +} + +// Match returns true if the given mediatype string matches any of the allowed +// types in the accept header. +func (cn *ContentNegotiator) Match(mediatype string) bool { + for _, glob := range cn.globs { + if glob.Match(mediatype) { + return true + } + } + + return false +} + func main() { rand.Seed(time.Now().UnixNano()) @@ -48,7 +97,7 @@ func main() { cmd := filepath.Base(os.Args[0]) root := &cobra.Command{ Use: fmt.Sprintf("%s [flags] FILE", cmd), - Version: "1.0", + Version: GitSummary, Args: cobra.MinimumNArgs(1), Run: server, Example: fmt.Sprintf(" %s openapi.yaml", cmd), @@ -57,18 +106,29 @@ func main() { // Set up global options. flags := root.PersistentFlags() - viper.SetDefault("port", 8000) - flags.IntP("port", "p", 8000, "HTTP port") - viper.BindPFlag("port", flags.Lookup("port")) - - viper.SetDefault("validate-server", false) - flags.BoolP("validate-server", "", false, "Check hostname against configured servers") - viper.BindPFlag("validate-server", flags.Lookup("validate-server")) + addParameter(flags, "port", "p", 8000, "HTTP port") + addParameter(flags, "validate-server", "", false, "Check hostname against configured servers") + addParameter(flags, "validate-request", "", false, "Check request data structure") // Run the app! root.Execute() } +// addParameter adds a new global parameter with a default value that can be +// configured using configuration files, the environment, or commandline flags. +func addParameter(flags *pflag.FlagSet, name, short string, def interface{}, desc string) { + viper.SetDefault(name, def) + switch v := def.(type) { + case bool: + flags.BoolP(name, short, v, desc) + case int: + flags.IntP(name, short, v, desc) + case string: + flags.StringP(name, short, v, desc) + } + viper.BindPFlag(name, flags.Lookup(name)) +} + // getTypedExample will return an example from a given media type, if such an // example exists. If multiple examples are given, then one is selected at // random. @@ -85,7 +145,7 @@ func getTypedExample(mt *openapi3.MediaType) (interface{}, error) { } selected := keys[rand.Intn(len(keys))] - return mt.Examples[selected].Value, nil + return mt.Examples[selected].Value.Value, nil } // TODO: generate data from JSON schema, if available? @@ -94,33 +154,87 @@ func getTypedExample(mt *openapi3.MediaType) (interface{}, error) { } // getExample tries to return an example for a given operation. -func getExample(op *openapi3.Operation) (int, string, interface{}, error) { - for s, response := range op.Responses { - - status, _ := strconv.Atoi(s) - - // Prefer successful status codes, if available. - if status >= 200 && status < 300 { - for mime, content := range response.Value.Content { - example, err := getTypedExample(content) - if err == nil { - return status, mime, example, nil - } +func getExample(negotiator *ContentNegotiator, prefer string, op *openapi3.Operation) (int, string, interface{}, error) { + var responses []string + if prefer == "" { + // First, make a list of responses ordered by successful (200-299 status code) + // before other types. + success := make([]string, 0) + other := make([]string, 0) + for s := range op.Responses { + if status, err := strconv.Atoi(s); err == nil && status >= 200 && status < 300 { + success = append(success, s) + continue } + other = append(other, s) } + responses = append(success, other...) + } else { + if op.Responses[prefer] == nil { + return 0, "", nil, ErrNoExample + } + responses = []string{prefer} + } + + // Now try to find the first example we can and return it! + for _, s := range responses { + response := op.Responses[s] + status, err := strconv.Atoi(s) + if err != nil { + // Treat default and other named statuses as 200. + status = http.StatusOK + } + + if response.Value.Content == nil { + // This is a valid response but has no body defined. + return status, "", "", nil + } + + for mt, content := range response.Value.Content { + if negotiator != nil && !negotiator.Match(mt) { + // This is not what the client asked for. + continue + } - // TODO: support other status codes. + example, err := getTypedExample(content) + if err == nil { + return status, mt, example, nil + } + } } return 0, "", nil, ErrNoExample } +// server loads an OpenAPI file and runs a mock server using the paths and +// examples defined in the file. func server(cmd *cobra.Command, args []string) { - data, err := ioutil.ReadFile(args[0]) - if err != nil { - log.Fatal(err) + uri := args[0] + + var err error + var data []byte + + // Load either from an HTTP URL or from a local file depending on the passed + // in value. + if strings.HasPrefix(uri, "http") { + resp, err := http.Get(uri) + if err != nil { + log.Fatal(err) + } + + data, err = ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + log.Fatal(err) + } + } else { + data, err = ioutil.ReadFile(uri) + if err != nil { + log.Fatal(err) + } } + // Load the OpenAPI document. loader := openapi3.NewSwaggerLoader() var swagger *openapi3.Swagger if strings.HasSuffix(args[0], ".yaml") || strings.HasSuffix(args[0], ".yml") { @@ -138,18 +252,61 @@ func server(cmd *cobra.Command, args []string) { swagger.Servers = make([]*openapi3.Server, 0) } + // Create a new router using the OpenAPI document's declared paths. var router = openapi3filter.NewRouter().WithSwagger(swagger) + // Register our custom HTTP handler that will use the router to find + // the appropriate OpenAPI operation and try to return an example. http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { info := fmt.Sprintf("%s %v", req.Method, req.URL) route, _, err := router.FindRoute(req.Method, req.URL) if err != nil { log.Printf("ERROR: %s => %v", info, err) - w.WriteHeader(404) + w.WriteHeader(http.StatusNotFound) return } - status, mime, example, err := getExample(route.Operation) + if viper.GetBool("validate-request") { + err = openapi3filter.ValidateRequest(nil, &openapi3filter.RequestValidationInput{ + Request: req, + Route: route, + Options: &openapi3filter.Options{ + AuthenticationFunc: func(c context.Context, input *openapi3filter.AuthenticationInput) error { + // TODO: support more schemes + sec := input.SecurityScheme + if sec.Type == "http" && sec.Scheme == "bearer" { + if req.Header.Get("Authorization") == "" { + return ErrMissingAuth + } + } + return nil + }, + }, + }) + if err != nil { + log.Printf("ERROR: %s => %v", info, err) + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(fmt.Sprintf("%v", err))) + return + } + } + + var negotiator *ContentNegotiator + if accept := req.Header.Get("Accept"); accept != "" { + negotiator = NewContentNegotiator(accept) + if accept != "*/*" { + info = fmt.Sprintf("%s (Accept %s)", info, accept) + } + } + + prefer := req.Header.Get("Prefer") + if strings.HasPrefix(prefer, "status=") { + prefer = prefer[7:10] + } else { + prefer = "" + } + + status, mediatype, example, err := getExample(negotiator, prefer, route.Operation) if err != nil { log.Printf("%s => Missing example", info) w.WriteHeader(http.StatusTeapot) @@ -157,7 +314,7 @@ func server(cmd *cobra.Command, args []string) { return } - log.Printf("%s => %d (%s)", info, status, mime) + log.Printf("%s => %d (%s)", info, status, mediatype) var encoded []byte @@ -166,28 +323,30 @@ func server(cmd *cobra.Command, args []string) { } else if _, ok := example.([]byte); ok { encoded = example.([]byte) } else { - switch mime { + switch mediatype { case "application/json": encoded, err = json.MarshalIndent(example, "", " ") case "application/x-yaml", "application/yaml", "text/x-yaml", "text/yaml", "text/vnd.yaml": encoded, err = yaml.Marshal(example) default: - log.Printf("Cannot marshal as %s!", mime) + log.Printf("Cannot marshal as '%s'!", mediatype) err = ErrCannotMarshal } if err != nil { - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Unable to marshal response")) return } } - w.Header().Add("Content-Type", mime) + if mediatype != "" { + w.Header().Add("Content-Type", mediatype) + } w.WriteHeader(status) w.Write(encoded) }) - fmt.Printf("Starting server on port %d\n", viper.GetInt("port")) + fmt.Printf("🌱 Sprouting %s on port %d\n", swagger.Info.Title, viper.GetInt("port")) http.ListenAndServe(fmt.Sprintf(":%d", viper.GetInt("port")), nil) } diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..8541689 --- /dev/null +++ b/release.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -e + +govvv install +VERSION=$(apisprout --version | cut -d ' ' -f3) + +GOOS=darwin GOARCH=amd64 govvv build +tar -cJf apisprout-$VERSION-mac.tar.xz apisprout + +GOOS=linux GOARCH=amd64 govvv build +tar -cJf apisprout-$VERSION-linux.tar.xz apisprout + +GOOS=windows GOARCH=amd64 govvv build +zip -r apisprout-$VERSION-win-$GOARCH.zip apisprout.exe + +rm -f apisprout apisprout.exe