diff --git a/.gitignore b/.gitignore index 228264c..bea1f19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .DS_Store vendor logo.png -apisprout +/apisprout apisprout.exe apisprout*.zip apisprout*.xz diff --git a/Dockerfile b/Dockerfile index e8f8096..12d441f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /apisprout COPY . . RUN apk add --no-cache git && \ go get github.com/ahmetb/govvv && \ - govvv install + govvv install ./cmd/apisprout FROM alpine:3.8 COPY --from=build /go/bin/apisprout /usr/local/bin/ diff --git a/README.md b/README.md index 66a669d..9fc131c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Download the appropriate binary from the [releases](https://github.com/danielgta Alternatively, you can use `go get`: ```sh -go get github.com/danielgtaylor/apisprout +go get github.com/danielgtaylor/apisprout/cmd/apisprout ``` ## Extra Features diff --git a/apisprout.go b/apisprout.go index 44fc633..c0f9906 100644 --- a/apisprout.go +++ b/apisprout.go @@ -1,8 +1,9 @@ -package main +package apisprout import ( "context" "encoding/json" + "errors" "fmt" "io/ioutil" "log" @@ -11,19 +12,19 @@ import ( "net/http" "net/url" "os" - "path/filepath" "regexp" "strconv" "strings" - "time" + "syscall" "github.com/fsnotify/fsnotify" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" "github.com/gobwas/glob" - "github.com/pkg/errors" + "github.com/oklog/run" "github.com/spf13/cobra" - "github.com/spf13/pflag" "github.com/spf13/viper" yaml "gopkg.in/yaml.v2" ) @@ -55,22 +56,6 @@ var ( marshalYAMLMatcher = regexp.MustCompile(`^(application|text)/(x-|vnd\..+\+)?yaml$`) ) -type RefreshableRouter struct { - router *openapi3filter.Router -} - -func (rr *RefreshableRouter) Set(router *openapi3filter.Router) { - rr.router = router -} - -func (rr *RefreshableRouter) Get() *openapi3filter.Router { - return rr.router -} - -func NewRefreshableRouter() *RefreshableRouter { - return &RefreshableRouter{} -} - // ContentNegotiator is used to match a media type during content negotiation // of HTTP requests. type ContentNegotiator struct { @@ -109,64 +94,6 @@ func (cn *ContentNegotiator) Match(mediatype string) bool { return false } -func main() { - rand.Seed(time.Now().UnixNano()) - - // Load configuration from file(s) if provided. - viper.SetConfigName("config") - viper.AddConfigPath("/etc/apisprout/") - viper.AddConfigPath("$HOME/.apisprout/") - viper.ReadInConfig() - - // Load configuration from the environment if provided. Flags below get - // transformed automatically, e.g. `foo-bar` -> `SPROUT_FOO_BAR`. - viper.SetEnvPrefix("SPROUT") - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - viper.AutomaticEnv() - - // Build the root command. This is the application's entry point. - cmd := filepath.Base(os.Args[0]) - root := &cobra.Command{ - Use: fmt.Sprintf("%s [flags] FILE", cmd), - Version: GitSummary, - Args: cobra.MinimumNArgs(1), - Run: server, - Example: fmt.Sprintf(" # Basic usage\n %s openapi.yaml\n\n # Validate server name and use base path\n %s --validate-server openapi.yaml\n\n # Fetch API via HTTP with custom auth header\n %s -H 'Authorization: abc123' http://example.com/openapi.yaml", cmd, cmd, cmd), - } - - // Set up global options. - flags := root.PersistentFlags() - - addParameter(flags, "port", "p", 8000, "HTTP port") - addParameter(flags, "validate-server", "s", false, "Check scheme/hostname/basepath against configured servers") - addParameter(flags, "validate-request", "", false, "Check request data structure") - addParameter(flags, "watch", "w", false, "Reload when input file changes") - addParameter(flags, "disable-cors", "", false, "Disable CORS headers") - addParameter(flags, "header", "H", "", "Add a custom header when fetching API") - addParameter(flags, "add-server", "", "", "Add a new valid server URL, use with --validate-server") - addParameter(flags, "https", "", false, "Use HTTPS instead of HTTP") - addParameter(flags, "public-key", "", "", "Public key for HTTPS, use with --https") - addParameter(flags, "private-key", "", "", "Private key for HTTPS, use with --https") - - // 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 unless an "example" item exists in the Prefer header @@ -209,7 +136,7 @@ func getTypedExample(mt *openapi3.MediaType, prefer map[string]string) (interfac // Using the Prefer http header, the consumer can specify the type of response they want. func getExample(negotiator *ContentNegotiator, prefer map[string]string, op *openapi3.Operation) (int, string, map[string]*openapi3.HeaderRef, interface{}, error) { var responses []string - var blankHeaders = make(map[string]*openapi3.HeaderRef) + blankHeaders := make(map[string]*openapi3.HeaderRef) if !mapContainsKey(prefer, "status") { // First, make a list of responses ordered by successful (200-299 status code) @@ -271,7 +198,7 @@ func getExample(negotiator *ContentNegotiator, prefer map[string]string, op *ope // addLocalServers will ensure that requests to localhost are always allowed // even if not specified in the OpenAPI document. -func addLocalServers(swagger *openapi3.Swagger) error { +func (cr *ConfigReloader) addLocalServers(swagger *openapi3.T) error { seen := make(map[string]bool) for _, s := range swagger.Servers { seen[s.URL] = true @@ -286,7 +213,7 @@ func addLocalServers(swagger *openapi3.Swagger) error { if u.Hostname() != "localhost" { u.Scheme = "http" - u.Host = fmt.Sprintf("localhost:%d", viper.GetInt("port")) + u.Host = fmt.Sprintf("localhost:%d", cr.Port) ls := &openapi3.Server{ URL: u.String(), @@ -308,59 +235,6 @@ func addLocalServers(swagger *openapi3.Swagger) error { return nil } -// Load the OpenAPI document and create the router. -func load(uri string, data []byte) (swagger *openapi3.Swagger, router *openapi3filter.Router, err error) { - defer func() { - if r := recover(); r != nil { - swagger = nil - router = nil - if e, ok := r.(error); ok { - err = errors.Wrap(e, "Caught panic while trying to load") - } else { - err = fmt.Errorf("Caught panic while trying to load") - } - } - }() - - loader := openapi3.NewSwaggerLoader() - loader.IsExternalRefsAllowed = true - - var u *url.URL - u, err = url.Parse(uri) - if err != nil { - return - } - - swagger, err = loader.LoadSwaggerFromDataWithPath(data, u) - if err != nil { - return - } - - if !viper.GetBool("validate-server") { - // Clear the server list so no validation happens. Note: this has a side - // effect of no longer parsing any server-declared parameters. - swagger.Servers = make([]*openapi3.Server, 0) - } else { - // Special-case localhost to always be allowed for local testing. - if err = addLocalServers(swagger); err != nil { - return - } - - if cs := viper.GetString("add-server"); cs != "" { - swagger.Servers = append(swagger.Servers, &openapi3.Server{ - URL: cs, - Description: "Custom server from command line param", - Variables: make(map[string]*openapi3.ServerVariable), - }) - } - } - - // Create a new router using the OpenAPI document's declared paths. - router = openapi3filter.NewRouter().WithSwagger(swagger) - - return -} - // parsePreferHeader takes the value of a prefer header and splits it out into key value pairs // // HTTP Prefer header specification examples: @@ -421,9 +295,9 @@ func mapContainsKey(dict map[string]string, key string) bool { return false } -var handler = func(rr *RefreshableRouter) http.Handler { +func (s *OpenAPIServer) handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if !viper.GetBool("disable-cors") { + if !s.DisableCORS { corsOrigin := req.Header.Get("Origin") if corsOrigin == "" { corsOrigin = "*" @@ -471,20 +345,15 @@ var handler = func(rr *RefreshableRouter) http.Handler { req.URL.Scheme = "https" } - if viper.GetBool("validate-server") { - // Use the scheme/host in the log message since we are validating it. - info = fmt.Sprintf("%s %v", req.Method, req.URL) - } - - route, pathParams, err := rr.Get().FindRoute(req.Method, req.URL) + route, pathParams, err := s.r.FindRoute(req) if err != nil { log.Printf("ERROR: %s => %v", info, err) w.WriteHeader(http.StatusNotFound) return } - if viper.GetBool("validate-request") { - err = openapi3filter.ValidateRequest(nil, &openapi3filter.RequestValidationInput{ + if s.ValidateRequest { + err = openapi3filter.ValidateRequest(req.Context(), &openapi3filter.RequestValidationInput{ Request: req, Route: route, PathParams: pathParams, @@ -517,7 +386,7 @@ var handler = func(rr *RefreshableRouter) http.Handler { if err != nil { log.Printf("ERROR: %s => %v", info, err) w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(fmt.Sprintf("%v", err))) + fmt.Fprintf(w, "%v", err) return } } @@ -536,7 +405,7 @@ var handler = func(rr *RefreshableRouter) http.Handler { if err != nil { log.Printf("%s => Missing example", info) w.WriteHeader(http.StatusTeapot) - w.Write([]byte("No example available.")) + fmt.Fprint(w, "No example available.") return } @@ -559,13 +428,13 @@ var handler = func(rr *RefreshableRouter) http.Handler { } else if marshalYAMLMatcher.MatchString(mediatype) { encoded, err = yaml.Marshal(example) } else { - log.Printf("Cannot marshal as '%s'!", mediatype) err = ErrCannotMarshal } if err != nil { + log.Printf("Cannot marshal as '%s'!: %s", mediatype, err.Error()) w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Unable to marshal response")) + fmt.Fprint(w, "Unable to marshal response") return } } @@ -597,184 +466,353 @@ var handler = func(rr *RefreshableRouter) http.Handler { }) } +type ConfigReloader struct { + OpenAPIServer *OpenAPIServer + Mux *http.ServeMux + + URI string + WithServer string + Watch bool + HTTPS bool + DisableCORS bool + ValidateServer bool + ValidateRequest bool + Header string + PublicKey string + PrivateKey string + Port int +} + +type Option func(c *config) + +func WithValidateRequest(s bool) Option { + return func(c *config) { + c.ValidateRequest = s + } +} + +func WithDisableCORS(b bool) Option { + return func(c *config) { + c.DisableCORS = b + } +} + +func NewOpenAPIServer(swagger *openapi3.T, options ...Option) (*OpenAPIServer, error) { + c := &config{} + for _, o := range options { + o(c) + } + r, err := gorillamux.NewRouter(swagger) + if err != nil { + return nil, err + } + + s := &OpenAPIServer{ + Swagger: swagger, + r: r, + config: *c, + } + + return s, nil +} + +type config struct { + DisableCORS bool + ValidateRequest bool +} + +type OpenAPIServer struct { + Swagger *openapi3.T + r routers.Router + config +} + +func (s *OpenAPIServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.handler().ServeHTTP(w, r) +} + +func ServeCMD(cmd *cobra.Command, args []string) error { + r := &ConfigReloader{ + Mux: http.NewServeMux(), + URI: args[0], + Header: viper.GetString("header"), + WithServer: viper.GetString("add-server"), + Watch: viper.GetBool("watch"), + Port: viper.GetInt("port"), + HTTPS: viper.GetBool("https"), + DisableCORS: viper.GetBool("disable-cors"), + ValidateServer: viper.GetBool("validate-server"), + ValidateRequest: viper.GetBool("validate-request"), + PublicKey: viper.GetString("public-key"), + PrivateKey: viper.GetString("private-key"), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + g := run.Group{} + g.Add(run.SignalHandler(ctx, os.Interrupt, syscall.SIGTERM)) + g.Add(func() error { + return r.Serve(ctx) + }, func(err error) { + cancel() + }) + + sErr := new(run.SignalError) + if err := g.Run(); errors.As(err, sErr) { + log.Println(sErr.Signal) + } else { + return err + } + return nil +} + +func (s *ConfigReloader) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.Mux.ServeHTTP(w, r) +} + // 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) { - var swagger *openapi3.Swagger - rr := NewRefreshableRouter() +func (s *ConfigReloader) Serve(ctx context.Context) error { + if err := s.Run(ctx); err != nil { + return err + } - uri := args[0] + format := "🌱 Sprouting %s on port %d" + if s.HTTPS { + format = "🌱 Securely sprouting %s on port %d" + } + fmt.Printf(format, s.OpenAPIServer.Swagger.Info.Title, s.Port) + if s.ValidateServer && len(s.OpenAPIServer.Swagger.Servers) != 0 { + fmt.Printf(" with valid servers:\n") + for _, s := range s.OpenAPIServer.Swagger.Servers { + fmt.Println("• " + s.URL) + } + } else { + fmt.Printf("\n") + } + port := fmt.Sprintf(":%d", s.Port) var err error - var data []byte - dataType := strings.Trim(strings.ToLower(filepath.Ext(uri)), ".") + server := http.Server{Addr: port, Handler: s} + go func() { + <-ctx.Done() + server.Close() + }() - // Load either from an HTTP URL or from a local file depending on the passed - // in value. - if strings.HasPrefix(uri, "http") { - req, err := http.NewRequest("GET", uri, nil) + if s.HTTPS { + err = server.ListenAndServeTLS(s.PublicKey, + s.PrivateKey) + } else { + err = server.ListenAndServe() + } + return err +} + +func (r *ConfigReloader) Reload(ctx context.Context) error { + swagger, err := r.Load(ctx) + if err != nil { + return err + } + if r.ValidateServer { + // Special-case localhost to always be allowed for local testing. + if err = r.addLocalServers(swagger); err != nil { + return err + } + + if cs := r.WithServer; cs != "" { + swagger.Servers = append(swagger.Servers, &openapi3.Server{ + URL: cs, + Description: "Custom server from command line param", + Variables: make(map[string]*openapi3.ServerVariable), + }) + } + } else { + swagger.AddServer(&openapi3.Server{URL: "/", Description: "server to listen on all addresses"}) + } + + o, err := NewOpenAPIServer( + swagger, + WithDisableCORS(r.DisableCORS), + WithValidateRequest(r.ValidateRequest), + ) + if err != nil { + return err + } + + if r.OpenAPIServer == nil { + r.OpenAPIServer = o + + return nil + } + + *r.OpenAPIServer = *o + + return nil +} + +func (s *ConfigReloader) Load(ctx context.Context) (swagger *openapi3.T, err error) { + var data []byte + if strings.HasPrefix(s.URI, "http") { + if s.Watch { + return nil, errors.New("Watching a URL is not supported.") + } + req, err := http.NewRequest("GET", s.URI, nil) if err != nil { - log.Fatal(err) + return nil, err } - if customHeader := viper.GetString("header"); customHeader != "" { + if customHeader := s.Header; customHeader != "" { header := strings.Split(customHeader, ":") if len(header) != 2 { - log.Fatal("Header format is invalid.") + return nil, err } req.Header.Add(strings.TrimSpace(header[0]), strings.TrimSpace(header[1])) } client := &http.Client{} resp, err := client.Do(req) if err != nil { - log.Fatal(err) + return nil, err } data, err = ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { - log.Fatal(err) - } - - if viper.GetBool("watch") { - log.Fatal("Watching a URL is not supported.") + return nil, err } } else { - data, err = ioutil.ReadFile(uri) + + data, err = ioutil.ReadFile(s.URI) if err != nil { - log.Fatal(err) + return nil, err } + } - if viper.GetBool("watch") { - // Set up a new filesystem watcher and reload the router every time - // the file has changed on disk. - watcher, err := fsnotify.NewWatcher() - if err != nil { - log.Fatal(err) - } - defer watcher.Close() - - go func() { - // Since waiting for events or errors is blocking, we do this in a - // goroutine. It loops forever here but will exit when the process - // is finished, e.g. when you `ctrl+c` to exit. - for { - select { - case event, ok := <-watcher.Events: - if !ok { - return - } - if event.Op&fsnotify.Write == fsnotify.Write { - fmt.Printf("🌙 Reloading %s\n", uri) - data, err = ioutil.ReadFile(uri) - if err != nil { - log.Fatal(err) - } + loader := &openapi3.Loader{Context: ctx, IsExternalRefsAllowed: true} - if s, r, err := load(uri, data); err == nil { - swagger = s - rr.Set(r) - } else { - log.Printf("ERROR: Unable to load OpenAPI document: %s", err) - } + var u *url.URL + u, err = url.Parse(s.URI) + if err != nil { + return nil, fmt.Errorf("failed parse URI: %w", err) + } + + swagger, err = loader.LoadFromDataWithPath(data, u) + if err != nil { + return nil, fmt.Errorf("failed to load openapi spec: %w", err) + } + + return swagger, nil +} + +// Run loads the swagger spec from the URI and adds some utility routes to the server mux +func (s *ConfigReloader) Run(ctx context.Context) error { + // Load either from an HTTP URL or from a local file depending on the passed + // in value. + if err := s.Reload(ctx); err != nil { + return err + } + + if s.Watch { + // Set up a new filesystem watcher and reload the router every time + // the file has changed on disk. + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + go func() { + // Since waiting for events or errors is blocking, we do this in a + // goroutine. It loops forever here but will exit when the process + // is finished, e.g. when you `ctrl+c` to exit. + for { + select { + case event, ok := <-watcher.Events: + fmt.Println(event) + if !ok { + log.Fatal("watcher closed") + } + // Many IDEs (eg.: vim, neovim) rename and delete the file on changes. + // We will stop watching the events for the file name that we care for, + // but only watch the renamed path. In case the renamed file was deleted, + // it will also be deteled from the watched paths, causing us to watch nothing. + // In this implementation we would continue running the server for the renamed file + // until it is deleted. + // The hidden "feature" would be that renamed files would still be watched. + // We can't remove it from the watch list because we can't get the new name. + // At least not with this version of fsnotify. + if event.Op&fsnotify.Rename == fsnotify.Rename { + if err := watcher.Add(s.URI); err != nil { + log.Fatal(err) } - case err, ok := <-watcher.Errors: - if !ok { - return + } + if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Remove == fsnotify.Remove { + fmt.Printf("🌙 Reloading %s\n", s.URI) + if err := s.Reload(ctx); err != nil { + log.Printf("ERROR: Unable to load OpenAPI document: %s", err) } - fmt.Println("error:", err) } + case err, ok := <-watcher.Errors: + if !ok { + watcher.Close() + return + } + fmt.Println("error:", err) + case <-ctx.Done(): + watcher.Close() + return } - }() + } + }() - watcher.Add(uri) + if err := watcher.Add(s.URI); err != nil { + return err } } - swagger, router, err := load(uri, data) - if err != nil { - log.Fatal(err) - } - - rr.Set(router) - - if strings.HasPrefix(uri, "http") { - http.HandleFunc("/__reload", func(w http.ResponseWriter, r *http.Request) { - resp, err := http.Get(uri) - if err != nil { + if strings.HasPrefix(s.URI, "http") { + s.Mux.HandleFunc("/__reload", func(w http.ResponseWriter, r *http.Request) { + if err := s.Reload(ctx); err != nil { log.Printf("ERROR: %v", err) w.WriteHeader(http.StatusBadRequest) w.Write([]byte("error while reloading")) return - } - - data, err = ioutil.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - log.Printf("ERROR: %v", err) - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("error while parsing")) - return - } - if s, r, err := load(uri, data); err == nil { - swagger = s - rr.Set(r) } - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) w.Write([]byte("reloaded")) - log.Printf("Reloaded from %s", uri) + log.Printf("Reloaded from %s", s.URI) }) } // Add a health check route which returns 200 - http.HandleFunc("/__health", func(w http.ResponseWriter, r *http.Request) { + s.Mux.HandleFunc("/__health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) log.Printf("Health check") }) // Another custom handler to return the exact swagger document given to us - http.HandleFunc("/__schema", func(w http.ResponseWriter, req *http.Request) { - if !viper.GetBool("disable-cors") { - corsOrigin := req.Header.Get("Origin") - if corsOrigin == "" { - corsOrigin = "*" - } - w.Header().Set("Access-Control-Allow-Origin", corsOrigin) - } - - w.Header().Set("Content-Type", fmt.Sprintf("application/%v; charset=utf-8", dataType)) + s.Mux.HandleFunc("/__schema", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) - fmt.Fprint(w, string(data)) + json.NewEncoder(w).Encode(s.OpenAPIServer.Swagger) }) - // Register our custom HTTP handler that will use the router to find // the appropriate OpenAPI operation and try to return an example. - http.Handle("/", handler(rr)) - - format := "🌱 Sprouting %s on port %d" - if viper.GetBool("https") { - format = "🌱 Securely sprouting %s on port %d" - } - fmt.Printf(format, swagger.Info.Title, viper.GetInt("port")) - - if viper.GetBool("validate-server") && len(swagger.Servers) != 0 { - fmt.Printf(" with valid servers:\n") - for _, s := range swagger.Servers { - fmt.Println("• " + s.URL) - } + if s.DisableCORS { + s.Mux.Handle("/", disableCORS(s.OpenAPIServer)) } else { - fmt.Printf("\n") + s.Mux.Handle("/", s.OpenAPIServer) } + return nil +} - port := fmt.Sprintf(":%d", viper.GetInt("port")) - if viper.GetBool("https") { - err = http.ListenAndServeTLS(port, viper.GetString("public-key"), - viper.GetString("private-key"), nil) - } else { - err = http.ListenAndServe(port, nil) - } - if err != nil { - log.Fatal(err) - } +func disableCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + corsOrigin := r.Header.Get("Origin") + if corsOrigin == "" { + corsOrigin = "*" + } + w.Header().Set("Access-Control-Allow-Origin", corsOrigin) + next.ServeHTTP(w, r) + }) } diff --git a/apisprout_test.go b/apisprout_test.go index cbc6d87..4a5692b 100644 --- a/apisprout_test.go +++ b/apisprout_test.go @@ -1,14 +1,16 @@ -package main +package apisprout import ( + "context" "fmt" "net/http" "net/http/httptest" + "os" "reflect" + "strings" "testing" "github.com/getkin/kin-openapi/openapi3" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -56,7 +58,7 @@ var localServerTests = []struct { } func TestAddLocalServers(t *testing.T) { - viper.SetDefault("port", 8000) + cr := &ConfigReloader{Port: 8000} for _, tt := range localServerTests { t.Run(tt.name, func(t *testing.T) { servers := make([]*openapi3.Server, len(tt.in)) @@ -66,11 +68,11 @@ func TestAddLocalServers(t *testing.T) { } } - s := &openapi3.Swagger{ + s := &openapi3.T{ Servers: servers, } - err := addLocalServers(s) + err := cr.addLocalServers(s) if len(tt.in) > 0 && len(tt.out) == 0 { assert.Error(t, err) return @@ -235,20 +237,156 @@ func TestMediaTypes(t *testing.T) { } for _, test := range tests { t.Run(test.MediaType, func(t *testing.T) { - _, router, err := load("file:///swagger.json", []byte(fmt.Sprintf(schema, test.MediaType))) - require.NoError(t, err) - require.NotNil(t, router) - - rr := NewRefreshableRouter() - rr.Set(router) + swagger, err := openapi3.NewLoader().LoadFromData([]byte(fmt.Sprintf(schema, test.MediaType))) + require.Nil(t, err) req, err := http.NewRequest("GET", "/test", nil) require.NoError(t, err) resp := httptest.NewRecorder() - handler(rr).ServeHTTP(resp, req) + + s, err := NewOpenAPIServer(swagger) + require.Nil(t, err) + + s.ServeHTTP(resp, req) assert.Equal(t, test.StatusCode, resp.Code) }) } } + +func TestOpenAPIServer(t *testing.T) { + for _, test := range []struct { + name string + method string + path string + code int + }{ + { + name: "root", + path: "/", + method: http.MethodGet, + code: http.StatusNotFound, + }, + { + name: "find by tag", + path: "/v3/pet/findByStatus", + method: http.MethodGet, + code: http.StatusOK, + }, + } { + t.Run(test.name, func(t *testing.T) { + s, err := NewOpenAPIServer(loadPetStoreScheme(t)) + require.Nil(t, err) + + req := httptest.NewRequest(test.method, test.path, nil) + // apisprout does not support xml, so we need to set the Accept header. + req.Header.Add("Accept", "application/json") + + w := httptest.NewRecorder() + + s.ServeHTTP(w, req) + + assert.Equal(t, test.code, w.Code) + }) + } +} + +func loadPetStoreScheme(t *testing.T) *openapi3.T { + buf, err := os.ReadFile("testdata/petstore.yaml") + require.Nil(t, err) + s, err := openapi3.NewLoader().LoadFromData(buf) + require.Nil(t, err) + + return s +} + +func TestConfigReloaderHandler(t *testing.T) { + for _, test := range []struct { + name string + method string + path string + code int + validateServer bool + validateRequest bool + body string + }{ + { + name: "root", + path: "/", + method: http.MethodGet, + code: http.StatusNotFound, + }, + { + name: "find by tag with server validation", + path: "/v3/pet/findByStatus", + method: http.MethodGet, + code: http.StatusOK, + validateServer: true, + }, + { + name: "find by tag without server validation", + path: "/pet/findByStatus", + method: http.MethodGet, + code: http.StatusOK, + }, + { + name: "find by tag with server validation and wrong path", + path: "/pet/findByStatus", + method: http.MethodGet, + code: http.StatusNotFound, + validateServer: true, + }, + { + name: "new pet", + path: "/pet", + method: http.MethodPost, + code: http.StatusOK, + }, + { + name: "new pet with request validation", + path: "/pet", + method: http.MethodPost, + code: http.StatusOK, + validateRequest: true, + body: `{ + "name": "foo", + "photoUrls": [] + }`, + }, + { + name: "new pet with request validation and missing parameter", + path: "/pet", + method: http.MethodPost, + code: http.StatusBadRequest, + validateRequest: true, + body: `{ + "name": "foo" + }`, + }, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cr := &ConfigReloader{ + Mux: http.NewServeMux(), + URI: "testdata/petstore.yaml", + ValidateServer: test.validateServer, + ValidateRequest: test.validateRequest, + } + require.Nil(t, cr.Run(ctx)) + + req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body)) + // apisprout does not support xml, so we need to set the Accept header. + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + + w := httptest.NewRecorder() + + cr.ServeHTTP(w, req) + + assert.Equal(t, test.code, w.Code) + }) + } +} diff --git a/cmd/apisprout/main.go b/cmd/apisprout/main.go new file mode 100644 index 0000000..c5dfb24 --- /dev/null +++ b/cmd/apisprout/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/danielgtaylor/apisprout" +) + +func main() { + rand.Seed(time.Now().UnixNano()) + + // Load configuration from file(s) if provided. + viper.SetConfigName("config") + viper.AddConfigPath("/etc/apisprout/") + viper.AddConfigPath("$HOME/.apisprout/") + viper.ReadInConfig() + + // Load configuration from the environment if provided. Flags below get + // transformed automatically, e.g. `foo-bar` -> `SPROUT_FOO_BAR`. + viper.SetEnvPrefix("SPROUT") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + // Build the root command. This is the application's entry point. + cmd := filepath.Base(os.Args[0]) + root := &cobra.Command{ + Use: fmt.Sprintf("%s [flags] FILE", cmd), + Version: apisprout.GitSummary, + Args: cobra.MinimumNArgs(1), + RunE: apisprout.ServeCMD, + Example: fmt.Sprintf(" # Basic usage\n %s openapi.yaml\n\n # Validate server name and use base path\n %s --validate-server openapi.yaml\n\n # Fetch API via HTTP with custom auth header\n %s -H 'Authorization: abc123' http://example.com/openapi.yaml", cmd, cmd, cmd), + } + + // Set up global options. + flags := root.PersistentFlags() + + addParameter(flags, "port", "p", 8000, "HTTP port") + addParameter(flags, "validate-server", "s", false, "Check scheme/hostname/basepath against configured servers") + addParameter(flags, "validate-request", "", false, "Check request data structure") + addParameter(flags, "watch", "w", false, "Reload when input file changes") + addParameter(flags, "disable-cors", "", false, "Disable CORS headers") + addParameter(flags, "header", "H", "", "Add a custom header when fetching API") + addParameter(flags, "add-server", "", "", "Add a new valid server URL, use with --validate-server") + addParameter(flags, "https", "", false, "Use HTTPS instead of HTTP") + addParameter(flags, "public-key", "", "", "Public key for HTTPS, use with --https") + addParameter(flags, "private-key", "", "", "Private key for HTTPS, use with --https") + + // Run the app! + if err := root.Execute(); err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(1) + } +} + +// 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)) +} diff --git a/example.go b/example.go index 475d553..490f25b 100644 --- a/example.go +++ b/example.go @@ -1,4 +1,4 @@ -package main +package apisprout import ( "fmt" diff --git a/example_test.go b/example_test.go index ad799bb..d4c8663 100644 --- a/example_test.go +++ b/example_test.go @@ -1,4 +1,4 @@ -package main +package apisprout import ( "encoding/json" @@ -522,7 +522,7 @@ func TestGenExample(t *testing.T) { } func TestRecursiveSchema(t *testing.T) { - loader := openapi3.NewSwaggerLoader() + loader := openapi3.NewLoader() tests := []struct { name string @@ -557,7 +557,7 @@ func TestRecursiveSchema(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - swagger, err := loader.LoadSwaggerFromData([]byte(test.in)) + swagger, err := loader.LoadFromData([]byte(test.in)) require.NoError(t, err) ex, err := OpenAPIExample(ModeResponse, swagger.Components.Schemas[test.schema].Value) diff --git a/go.mod b/go.mod index d8c2877..9b7ad6a 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.12 require ( github.com/fsnotify/fsnotify v1.4.7 - github.com/getkin/kin-openapi v0.2.0 + github.com/getkin/kin-openapi v0.109.0 github.com/gobwas/glob v0.2.3 github.com/magiconair/properties v1.8.1 // indirect + github.com/oklog/run v1.1.0 github.com/pelletier/go-toml v1.4.0 // indirect github.com/pkg/errors v0.8.1 github.com/spf13/afero v1.2.2 // indirect @@ -14,8 +15,8 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.3 github.com/spf13/viper v1.4.0 - github.com/stretchr/testify v1.3.0 + github.com/stretchr/testify v1.8.1 golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1 // indirect golang.org/x/text v0.3.2 // indirect - gopkg.in/yaml.v2 v2.2.2 + gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 08a6a60..f033cc0 100644 --- a/go.sum +++ b/go.sum @@ -23,13 +23,16 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/getkin/kin-openapi v0.2.0 h1:PbHHtYZpjKwZtGlIyELgA2DploRrsaXztoNNx9HjwNY= -github.com/getkin/kin-openapi v0.2.0/go.mod h1:V1z9xl9oF5Wt7v32ne4FmiF1alpS4dM6mNzoywPOXlk= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/getkin/kin-openapi v0.109.0 h1:Cpb0PmIPFEV0LVvikEvfo3gw3rBMVSjJ57w15j+/A/U= +github.com/getkin/kin-openapi v0.109.0/go.mod h1:QtwUNt0PAAgIIBEvFWYfB7dfngxtAaqCX1zYHMZDeK8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= @@ -42,6 +45,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= @@ -50,6 +55,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -61,17 +68,22 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e h1:hB2xlXdHp/pmPZq0y3QnmWAArdw9PqbmotexnWx/FU8= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg= github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= @@ -94,7 +106,6 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= @@ -102,7 +113,6 @@ github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.4 h1:S0tLZ3VOKl2Te0hpq8+ke0eSJPfCnNTPiDlsfwi1/NE= github.com/spf13/cobra v0.0.4/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= @@ -113,9 +123,14 @@ github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= @@ -147,7 +162,6 @@ golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1 h1:R4dVlxdmKenVdMRS/tTspEpSTRWINYrHD8ySIU9yCIU= golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -167,6 +181,11 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/testdata/petstore.yaml b/testdata/petstore.yaml new file mode 100644 index 0000000..58d869a --- /dev/null +++ b/testdata/petstore.yaml @@ -0,0 +1,819 @@ +openapi: 3.0.2 +servers: + - url: /v3 +info: + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + version: 1.0.17 + title: Swagger Petstore - OpenAPI 3.0 + termsOfService: 'http://swagger.io/terms/' + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: 'http://swagger.io' + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: 'http://swagger.io' + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + description: Create a new pet in the store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + description: Update an existent pet in the store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: false + explode: true + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - 'write:pets' + - 'read:pets' + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + type: string + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + description: '' + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + x-swagger-router-controller: OrderController + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: Place a new order in the store + operationId: placeOrder + x-swagger-router-controller: OrderController + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '405': + description: Invalid input + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Order' + '/store/order/{orderId}': + get: + tags: + - store + summary: Find purchase order by ID + x-swagger-router-controller: OrderController + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + x-swagger-router-controller: OrderController + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + description: Created user object + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: 'Creates list of users with given input array' + x-swagger-router-controller: UserController + operationId: createUsersWithListInput + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + default: + description: successful operation + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false + schema: + type: string + responses: + '200': + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + parameters: [] + responses: + default: + description: successful operation + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Update user + x-swagger-router-controller: UserController + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + responses: + default: + description: successful operation + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +components: + schemas: + Order: + x-swagger-router-model: io.swagger.petstore.model.Order + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + example: approved + complete: + type: boolean + xml: + name: order + type: object + Customer: + properties: + id: + type: integer + format: int64 + example: 100000 + username: + type: string + example: fehguy + address: + type: array + items: + $ref: '#/components/schemas/Address' + xml: + wrapped: true + name: addresses + xml: + name: customer + type: object + Address: + properties: + street: + type: string + example: 437 Lytton + city: + type: string + example: Palo Alto + state: + type: string + example: CA + zip: + type: string + example: 94301 + xml: + name: address + type: object + Category: + x-swagger-router-model: io.swagger.petstore.model.Category + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + type: object + User: + x-swagger-router-model: io.swagger.petstore.model.User + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: 12345 + phone: + type: string + example: 12345 + userStatus: + type: integer + format: int32 + example: 1 + description: User Status + xml: + name: user + type: object + Tag: + x-swagger-router-model: io.swagger.petstore.model.Tag + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + type: object + Pet: + x-swagger-router-model: io.swagger.petstore.model.Pet + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + $ref: '#/components/schemas/Category' + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: '#/components/schemas/Tag' + xml: + name: tag + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + type: object + ApiResponse: + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: '##default' + type: object + requestBodies: + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'https://petstore.swagger.io/oauth/authorize' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header