package SwagMock import ( "encoding/json" "fmt" "github.com/go-openapi/loads" "github.com/go-openapi/spec" "github.com/imdario/mergo" "github.com/pkg/errors" "gopkg.in/yaml.v2" "io/ioutil" "path" "reflect" "regexp" "strings" ) // StubGeneratorOptions that can configure the stub generator type StubGeneratorOptions struct { Overlay string BasePath string } // StubGenerator is the main type used to interact with this library's feature set type StubGenerator struct { spec spec.Swagger overlay Overlay } // NewStubGenerator loads an OpenAPI spec from the given url/path and returns a StubGenerator func NewStubGenerator(urlOrPath string, options StubGeneratorOptions) (*StubGenerator, error) { document, err := loads.Spec(urlOrPath) if err != nil { return nil, errors.Wrap(err, "unable to load input file") } document, err = document.Expanded() if err != nil { return nil, errors.Wrap(err, "expanding spec refs") } // the openapi libraries suggest that the base path is // prefixed to paths: https://godoc.org/github.com/go-openapi/spec#Paths // but it doesn't seem to be happening in practice. // we'll expand them here ExpandPaths(document, options.BasePath) ExpandOperationIDs(document) var overlay *Overlay if options.Overlay != "" { overlay, err = LoadOverlayFile(options.Overlay) if err != nil { return nil, errors.Wrap(err, "loading overlay") } } else { tmp := EmptyOverlay() overlay = &tmp } stub := &StubGenerator{ spec: *document.Spec(), overlay: *overlay, } return stub, nil } // StubResponse returns data that matches the schema for a given Operation in the OpenAPI spec. The Operation is determined by a path and method func (stub *StubGenerator) StubResponse(path string, method string) (interface{}, error) { operation, err := stub.FindOperation(path, method) if err != nil { return nil, errors.Wrap(err, "finding operation from path and method") } response, statusCode, err := stub.FindResponse(operation) if err != nil { return nil, errors.Wrap(err, "finding response for operation") } stubbedData := StubSchema(*response.Schema) responseOverlay, err := stub.overlay.FindResponse(path, method, *statusCode) if err == nil { ApplyResponseOverlay(*responseOverlay, &stubbedData) } return stubbedData, nil } // FindOperation returns the best matching OpenAPI operation from the Spec given an HTTP Request func (stub *StubGenerator) FindOperation(httpPath string, httpMethod string) (*spec.Operation, error) { // for every path, calculate a match score // more matching path params means a higher score, 1 point per matching path param scores := make(map[string]int) for path := range stub.spec.Paths.Paths { matcher := pathToRegexp(httpPath) matches := matcher.FindAllString(path, -1) scores[path] = len(matches) } // pick the best matching path // a lower score means less matching segments and a more specific path. // we'll choose the most specific path var bestPath *string for path, score := range scores { if score != 0 && (bestPath == nil || score < scores[*bestPath]) { copy := string(path) bestPath = © } } if bestPath == nil { return nil, fmt.Errorf("unknown path %s", httpPath) } // find the operation from the pathItem using http method var operation *spec.Operation switch strings.ToUpper(httpMethod) { case "GET": operation = stub.spec.Paths.Paths[*bestPath].Get case "POST": operation = stub.spec.Paths.Paths[*bestPath].Post case "PUT": operation = stub.spec.Paths.Paths[*bestPath].Put case "PATCH": operation = stub.spec.Paths.Paths[*bestPath].Patch case "HEAD": operation = stub.spec.Paths.Paths[*bestPath].Head case "OPTIONS": operation = stub.spec.Paths.Paths[*bestPath].Options default: operation = nil } if operation == nil { return nil, fmt.Errorf("no operation for HTTP %s %s", httpMethod, httpPath) } return operation, nil } // pathToRegexp will convert an openapi path i.e. /api/{param}/thing/ into a regexp like /api/(.*)/thing/ func pathToRegexp(path string) *regexp.Regexp { quotedPath := regexp.QuoteMeta(path) result := regexp.MustCompile(`(\\{\w+\\})`).ReplaceAllString(quotedPath, "(.*)") return regexp.MustCompile("^" + result + "$") } // FindResponse returns either the default response from an operation or the response with the lowest HTTP status code (i.e. success codes over error codes) func (stub *StubGenerator) FindResponse(operation *spec.Operation) (*spec.Response, *int, error) { var response *spec.Response lowestCode := 999 for code, res := range operation.Responses.StatusCodeResponses { if code < lowestCode { tmp := res response = &tmp lowestCode = code } } if response == nil { return nil, nil, fmt.Errorf("no response definition found for operation %s", operation.ID) } return response, &lowestCode, nil } // ExpandPaths modifies all the paths in the openapi document by prefixing them with the basePath func ExpandPaths(document *loads.Document, basePath string) { paths := map[string]spec.PathItem{} if basePath == "" { basePath = document.BasePath() } for apiPath, pathItem := range document.Spec().Paths.Paths { expandedPath := applyBasePath(basePath, apiPath) paths[expandedPath] = pathItem } document.Spec().Paths.Paths = paths } func applyBasePath(prefix string, suffix string) string { joint := path.Join(prefix, suffix) if strings.HasSuffix(suffix, "/") { // path.Join will clean a trailing slash. // some webservers actually care about the trailing slash // and so we want to preserve the "trailing slash-ness" // of the spec joint += "/" } return joint } // ExpandOperationIDs the operationId field can be omitted in the spec. Codegen tools will automatically generate a default value for this field but go-openapi does not. // ExpandOperationIDs will expand empty operationId fields into a useful name for usage in error/logging. func ExpandOperationIDs(document *loads.Document) { for path, pathItem := range document.Spec().Paths.Paths { if op := pathItem.Head; op != nil && op.ID == "" { op.ID = "Head: " + path } if op := pathItem.Options; op != nil && op.ID == "" { op.ID = "Options: " + path } if op := pathItem.Put; op != nil && op.ID == "" { op.ID = "Put: " + path } if op := pathItem.Get; op != nil && op.ID == "" { op.ID = "Get: " + path } if op := pathItem.Post; op != nil && op.ID == "" { op.ID = "Post: " + path } if op := pathItem.Patch; op != nil && op.ID == "" { op.ID = "Patch: " + path } if op := pathItem.Delete; op != nil && op.ID == "" { op.ID = "Delete: " + path } } } type Overlay struct { Paths map[string]PathItem `yaml:"paths"` } type PathItem struct { Get *Operation `yaml:"get,omitempty"` Put *Operation `yaml:"put,omitempty"` Post *Operation `yaml:"post,omitempty"` Patch *Operation `yaml:"patch,omitempty"` Options *Operation `yaml:"options,omitempty"` Head *Operation `yaml:"head,omitempty"` } type Operation struct { Responses map[int]Response `yaml:"responses"` } type Response struct { Content string `yaml:"content"` } // LoadOverlayFile reads an overlay.yaml file into an Overlay struct func LoadOverlayFile(path string) (*Overlay, error) { content, err := ioutil.ReadFile(path) if err != nil { return nil, errors.Wrap(err, "reading overlay file") } overlay := &Overlay{} err = yaml.Unmarshal(content, overlay) if err != nil { return nil, errors.Wrap(err, "unmarshalling overlay file") } return overlay, nil } // EmptyOverlay is used when the user doesn't provide and overlay file. it's just used inplace of a nil value. func EmptyOverlay() Overlay { return Overlay{} } func (overlay *Overlay) FindResponse(path string, method string, statusCode int) (*Response, error) { for pathOverlay, pathItem := range overlay.Paths { if pathOverlay == path { var operationOverlay *Operation switch method { case "GET": operationOverlay = pathItem.Get case "POST": operationOverlay = pathItem.Post case "PUT": operationOverlay = pathItem.Put case "PATCH": operationOverlay = pathItem.Patch case "OPTIONS": operationOverlay = pathItem.Options case "HEAD": operationOverlay = pathItem.Head } if operationOverlay != nil { if response, ok := operationOverlay.Responses[statusCode]; ok { return &response, nil } } } } return nil, fmt.Errorf("response overlay for %v %v %v not found", statusCode, method, path) } // ApplyResponseOverlay expects data to be passed by reference. The response overlay will be applied by merging/overriding data. func ApplyResponseOverlay(response Response, data interface{}) error { switch reflect.Indirect(reflect.ValueOf(data)).Kind() { case reflect.String: *data.(*string) = string(response.Content) return nil case reflect.Map: var override map[string]interface{} err := json.Unmarshal([]byte(response.Content), &override) if err != nil { return errors.Wrap(err, "unmarshalling object response overlay") } err = mergo.Merge(data, override, mergo.WithOverride) if err != nil { return errors.Wrap(err, "merging response overlay with generated response stub") } return nil default: // I don't know why, but I can't match a reflect.Slice // so instead i'm handling slices in the default case // TODO: actually solve my problems... var override interface{} err := json.Unmarshal([]byte(response.Content), &override) if err != nil { return errors.Wrap(err, "unmarshalling array response overlay") } *data.(*interface{}) = override return nil } }