You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

328 lines
9.4 KiB

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 = &copy
}
}
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
}
}