From 694ef316be21437711bebc8537e4ce5b503bccce Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Sun, 3 Nov 2024 08:20:50 +0200 Subject: [PATCH] feat: Enhance mock server 2 --- cmd/mockserver/run.go | 30 ++++-- internal/mockserver/mockserver.go | 159 ++++++++++++++++++++++++------ internal/mockserver/utils.go | 100 +++++++++++++++++++ 3 files changed, 252 insertions(+), 37 deletions(-) diff --git a/cmd/mockserver/run.go b/cmd/mockserver/run.go index 63aa277..f6b01c1 100644 --- a/cmd/mockserver/run.go +++ b/cmd/mockserver/run.go @@ -10,9 +10,11 @@ import ( ) type runConfig struct { - host string - port int - delay int + host string + port int + delay int + defaultResponseCode string + defaultResponseType string } // Command-specific flags for the run command @@ -34,9 +36,11 @@ func newRunCommand() *cobra.Command { return mockserver.Run( mockserver2.RunOptions{ - Host: runCfg.host, - Port: runCfg.port, - Delay: runCfg.delay, + Host: runCfg.host, + Port: runCfg.port, + Delay: runCfg.delay, + DefaultResponseCode: runCfg.defaultResponseCode, + DefaultResponseType: runCfg.defaultResponseType, }, ) }, @@ -45,6 +49,20 @@ func newRunCommand() *cobra.Command { cmd.Flags().StringVarP(&runCfg.host, "host", "", "127.0.0.1", "Host to run the mock server on") cmd.Flags().IntVarP(&runCfg.port, "port", "p", 8080, "Port to run the mock server on") cmd.Flags().IntVarP(&runCfg.delay, "delay", "d", 0, "Delay in milliseconds to simulate network latency") + cmd.Flags().StringVarP( + &runCfg.defaultResponseCode, + "default-response-code", + "", + "200", + "Default response code to use", + ) + cmd.Flags().StringVarP( + &runCfg.defaultResponseType, + "default-response-type", + "", + "json", + "Default response type to use", + ) return cmd } diff --git a/internal/mockserver/mockserver.go b/internal/mockserver/mockserver.go index c9c445e..482f527 100644 --- a/internal/mockserver/mockserver.go +++ b/internal/mockserver/mockserver.go @@ -11,10 +11,27 @@ import ( "github.com/gin-gonic/gin" ) +const ( + responseCodeHeaderName = "X-Mock-Response-Code" + responseCodeQueryParamName = "x-response-code" + responseTypeQueryParamName = "x-response-type" + availableResponsesHeaderName = "X-Available-Responses" +) + type RunOptions struct { - Host string - Port int - Delay int + Host string + Port int + Delay int + DefaultResponseCode string + DefaultResponseType string +} + +// XMLNode represents a generic XML node +type XMLNode struct { + XMLName xml.Name + Attrs []xml.Attr `xml:"attr,omitempty"` + Value string `xml:",chardata"` + Children []*XMLNode `xml:",any"` } type MockServer struct { @@ -28,8 +45,11 @@ func NewMockServer(doc *openapi3.T) *MockServer { } func (m *MockServer) Run(options RunOptions) error { + gin.SetMode(gin.ReleaseMode) app := gin.Default() + app.Use(m.availableResponsesMiddleware()) + for _, path := range m.doc.Paths.InMatchingOrder() { for method, operation := range m.doc.Paths.Find(path).Operations() { app.Handle(method, convertPathToGinFormat(path), m.registerHandler(operation, options)) @@ -41,20 +61,72 @@ func (m *MockServer) Run(options RunOptions) error { "/", func(c *gin.Context) { var routes []gin.H for _, route := range app.Routes() { + if route.Path == "/" { + continue + } + + path := m.doc.Paths.Find(convertGinPathToOpenAPI(route.Path)) + responseCodeToContentType := make(map[string]string) + if op := path.GetOperation(route.Method); op != nil { + for code, resp := range op.Responses.Map() { + responseCodeToContentType[code] = "application/json" + for contentType := range resp.Value.Content { + responseCodeToContentType[code] = contentType + } + } + } + routes = append( routes, gin.H{ - "method": route.Method, - "path": route.Path, + "method": route.Method, + "path": route.Path, + "availableResponses": responseCodeToContentType, }, ) } - c.JSON(http.StatusOK, routes) + + c.JSON( + http.StatusOK, gin.H{ + "routes": routes, + "usage": gin.H{ + "responseCode": gin.H{ + "queryParam": fmt.Sprintf("?%s=", responseCodeQueryParamName), + "header": fmt.Sprintf("%s: ", responseCodeHeaderName), + "availableCodes": fmt.Sprintf("%s header in response", availableResponsesHeaderName), + }, + "responseType": gin.H{ + "queryParam": fmt.Sprintf("?%s=", responseTypeQueryParamName), + }, + }, + }, + ) }, ) + fmt.Printf("Mock server listening on http://%s:%d\n", options.Host, options.Port) return app.Run(fmt.Sprintf("%s:%d", options.Host, options.Port)) } +func (m *MockServer) availableResponsesMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + // After handler execution, add header with available response codes + path := m.doc.Paths.Find(convertGinPathToOpenAPI(c.FullPath())) + if path != nil { + if op := path.GetOperation(c.Request.Method); op != nil { + var codes []string + for code := range op.Responses.Map() { + codes = append(codes, code) + } + if len(codes) > 0 { + c.Header(availableResponsesHeaderName, strings.Join(codes, ",")) + } + } + } + } +} + func (m *MockServer) registerHandler(op *openapi3.Operation, options RunOptions) gin.HandlerFunc { return func(c *gin.Context) { // If Delay is set in options, apply it to simulate latency @@ -62,33 +134,65 @@ func (m *MockServer) registerHandler(op *openapi3.Operation, options RunOptions) time.Sleep(time.Duration(options.Delay) * time.Millisecond) } - status, response := m.findResponse(op) + // Get desired response code from query param or header + desiredCode := c.Query(responseCodeQueryParamName) + if desiredCode == "" { + desiredCode = c.GetHeader(responseCodeHeaderName) + } + + // If no code specified, use default + if desiredCode == "" { + desiredCode = options.DefaultResponseCode + } + + // Get desired response type from query param + desiredType := c.Query(responseTypeQueryParamName) + if desiredType == "" { + desiredType = options.DefaultResponseType + } + + response := m.findSpecificResponse(op, desiredCode) if response == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "No response defined"}) + body := gin.H{ + "error": fmt.Sprintf("No response defined for status code %s", desiredCode), + "availableCodes": m.getAvailableResponseCodes(op), + } + if desiredType == "xml" { + c.XML(http.StatusBadRequest, body) + return + } else { + c.JSON(http.StatusBadRequest, body) + } return } - // Get accepted content types from Accept header + status := parseStatusCode(desiredCode) acceptHeader := c.GetHeader("Accept") acceptedTypes := parseAcceptHeader(acceptHeader) - // Find matching content type and schema var contentType string var schema *openapi3.Schema for mediaType, content := range response.Content { for _, acceptedType := range acceptedTypes { - if strings.HasPrefix(mediaType, acceptedType) { + if desiredType != "" { + if strings.HasSuffix(mediaType, desiredType) { + contentType = mediaType + schema = content.Schema.Value + break + } + + } else if strings.HasPrefix(mediaType, acceptedType) { contentType = mediaType schema = content.Schema.Value break } } + if schema != nil { break } } - // If no matching content type found, default to JSON if schema == nil { contentType = "application/json" if jsonContent, ok := response.Content["application/json"]; ok { @@ -96,35 +200,28 @@ func (m *MockServer) registerHandler(op *openapi3.Operation, options RunOptions) } } - // Generate mock data based on schema mockData := generateMockData(schema) - // Send response based on content type switch { case strings.Contains(contentType, "application/xml"): - c.Header("Content-Type", "application/xml") - xmlData, err := xml.Marshal(mockData) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate XML"}) - return - } - c.String(status, string(xmlData)) + c.XML(status, mapToXML(mockData, schema, "root")) default: c.JSON(status, mockData) } } } -func (m *MockServer) findResponse(op *openapi3.Operation) (int, *openapi3.Response) { - for statusCode, responseRef := range op.Responses.Map() { - status := parseStatusCode(statusCode) - if status >= 200 && status < 300 { - return status, responseRef.Value - } +func (m *MockServer) findSpecificResponse(op *openapi3.Operation, code string) *openapi3.Response { + if responseRef, ok := op.Responses.Map()[code]; ok { + return responseRef.Value } - // Default to 200 if no successful response found - if defaultResponse := op.Responses.Default(); defaultResponse != nil { - return 200, defaultResponse.Value + return nil +} + +func (m *MockServer) getAvailableResponseCodes(op *openapi3.Operation) []string { + var codes []string + for code := range op.Responses.Map() { + codes = append(codes, code) } - return 200, nil + return codes } diff --git a/internal/mockserver/utils.go b/internal/mockserver/utils.go index 2d8501c..885ece6 100644 --- a/internal/mockserver/utils.go +++ b/internal/mockserver/utils.go @@ -1,6 +1,8 @@ package mockserver import ( + "encoding/xml" + "fmt" "log" "strconv" "strings" @@ -57,6 +59,92 @@ func generateMockData(schema *openapi3.Schema) interface{} { } } +// mapToXML converts a map[string]interface{} to XML structure +func mapToXML(data interface{}, schema *openapi3.Schema, name string) *XMLNode { + if data == nil || schema == nil { + return nil + } + + // Get XML name from schema or use provided name + xmlName := name + if schema.XML != nil && schema.XML.Name != "" { + xmlName = schema.XML.Name + } + + node := &XMLNode{ + XMLName: xml.Name{Local: xmlName}, + } + + switch v := data.(type) { + case map[string]interface{}: + for key, value := range v { + propSchema := schema.Properties[key].Value + if propSchema == nil { + continue + } + + // Handle properties marked as XML attributes + if propSchema.XML != nil && propSchema.XML.Attribute { + node.Attrs = append( + node.Attrs, xml.Attr{ + Name: xml.Name{Local: key}, + Value: fmt.Sprintf("%v", value), + }, + ) + continue + } + + // Get property name from schema or use key + propName := key + if propSchema.XML != nil && propSchema.XML.Name != "" { + propName = propSchema.XML.Name + } + + childNode := mapToXML(value, propSchema, propName) + if childNode != nil { + node.Children = append(node.Children, childNode) + } + } + + case []interface{}: + // Handle array wrapping if specified in schema + if schema.XML != nil && schema.XML.Wrapped { + // For wrapped arrays, return the current node and append items as children + for _, item := range v { + childNode := mapToXML(item, schema.Items.Value, name) + if childNode != nil { + node.Children = append(node.Children, childNode) + } + } + return node + } else { + // For unwrapped arrays, return an array of nodes + nodes := make([]*XMLNode, 0) + for _, item := range v { + childNode := mapToXML(item, schema.Items.Value, name) + if childNode != nil { + nodes = append(nodes, childNode) + } + } + // If this is the root node, wrap it + if name != "" { + node.Children = nodes + return node + } + return &XMLNode{ + XMLName: xml.Name{Local: "array"}, + Children: nodes, + } + } + + default: + // Handle primitive values + node.Value = fmt.Sprintf("%v", v) + } + + return node +} + func parseAcceptHeader(header string) []string { if header == "" { return []string{"application/json"} @@ -73,6 +161,7 @@ func parseAcceptHeader(header string) []string { return types } +// convertPathToGinFormat Convert OpenAPI path params ({param}) to Gin format (:param) func convertPathToGinFormat(path string) string { path = strings.ReplaceAll(path, "{", ":") path = strings.ReplaceAll(path, "}", "") @@ -80,6 +169,17 @@ func convertPathToGinFormat(path string) string { return path } +// convertGinPathToOpenAPI Convert Gin path params (:param) back to OpenAPI format ({param}) +func convertGinPathToOpenAPI(path string) string { + parts := strings.Split(path, "/") + for i, part := range parts { + if strings.HasPrefix(part, ":") { + parts[i] = "{" + strings.TrimPrefix(part, ":") + "}" + } + } + return strings.Join(parts, "/") +} + // Helper function to parse the status code string to an integer func parseStatusCode(code string) int { status, err := strconv.Atoi(code)