From 6f7ce1c059e9d9cccef95d2af33f1f8281c7cfad Mon Sep 17 00:00:00 2001 From: Roshan Patil Date: Tue, 30 Jul 2024 11:42:20 +0200 Subject: [PATCH] Added support for Server Side events Signed-off-by: Roshan Patil --- README.md | 461 ++++++++++++++++----------------- config/sse.yaml | 42 +++ internal/server/dispatcher.go | 2 +- pkg/mock/http.go | 49 +++- pkg/mock/message_translator.go | 2 +- 5 files changed, 312 insertions(+), 244 deletions(-) create mode 100644 config/sse.yaml diff --git a/README.md b/README.md index 8cf0d89..e3e6981 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ ![Mmock](/docs/logo.png "Mmock logo") ========= +[![Build Status](https://app.travis-ci.com/jmartin82/mmock.svg?branch=master)](https://app.travis-ci.com/jmartin82/mmock) + Mmock is a testing and fast prototyping tool for developers: Easy and fast HTTP mock server. @@ -19,7 +21,7 @@ Built with Go - Mmock runs without installation on multiple platforms. * Easy mock definition via JSON or YAML * Variables in response (fake or request data) * Route patterns may include named parameters (/hello/:name) -* Glob matching ( * hello * ) +* Glob matching ( *hello* ) * Match request by method, URL params, query string, headers, cookies and bodies. * Mock definitions hot replace (edit your mocks without restart) * Web interface to view requests data (method,path,headers,cookies,body,etc..) @@ -33,6 +35,7 @@ Built with Go - Mmock runs without installation on multiple platforms. * Public interface auto discover * Lightweight and portable * No installation required +* Server Side Events (SSE) ### Example @@ -42,22 +45,22 @@ Mock definition file example: ```json { - "request": { - "method": "GET", - "path": "/hello/*" - }, - "response": { - "statusCode": 200, - "headers": { - "Content-Type":["application/json"] - }, - "body": "{\"hello\": \"{{request.query.name}}, my name is {{fake.FirstName}}\"}" - } + "request": { + "method": "GET", + "path": "/hello/*" + }, + "response": { + "statusCode": 200, + "headers": { + "Content-Type":["application/json"] + }, + "body": "{\"hello\": \"{{request.query.name}}, my name is {{fake.FirstName}}\"}" + } } ``` -Or +Or ```yaml --- @@ -96,100 +99,89 @@ mmock -h To configure Mmock, use command line flags described in help. - ``` Usage of ./mmock: - -config-path string - Mocks definition folder (default "execution_pathconfig") - -console - Console enabled (true/false) (default true) - -console-ip string - Console server IP (default "public_ip") - -console-port int - Console server Port (default 8082) - -request-storage-capacity int - Request storage capacity (0 = infinite) (default 100) - -results-per-page uint - Number of results per page (default 25) - -server-ip string - Mock server IP (default "public_ip") - -server-port int - Mock server Port (default 8083) - -server-statistics - Mock server sends anonymous statistics (default true) - -server-tls-port int - Mock server TLS Port (default 8084) - -tls-path string - TLS config folder (server.crt and server.key should be inside) (default "execution_path/tls") + -config-path string + Mocks definition folder (default "execution_pathconfig") + -console + Console enabled (true/false) (default true) + -console-ip string + Console server IP (default "public_ip") + -console-port int + Console server Port (default 8082) + -request-storage-capacity int + Request storage capacity (0 = infinite) (default 100) + -results-per-page uint + Number of results per page (default 25) + -server-ip string + Mock server IP (default "public_ip") + -server-port int + Mock server Port (default 8083) + -server-statistics + Mock server sends anonymous statistics (default true) + -server-tls-port int + Mock server TLS Port (default 8084) + -tls-path string + TLS config folder (server.crt and server.key should be inside) (default "execution_path/tls") ``` -The default logging level is INFO, but you can change it by setting the -environment variable LOG_LEVEL to one of the following: - - * CRITICAL - * ERROR - * WARNING - * NOTICE - * INFO - * DEBUG - ### Mock Mock definition: ```json { - "description": "Some text that describes the intended usage of the current configuration", - "request": { - "host": "example.com", - "method": "GET|POST|PUT|PATCH|...", - "path": "/your/path/:variable", - "queryStringParameters": { - "name": ["value"], - "name": ["value", "value"] - }, - "headers": { - "name": ["value"] - }, - "cookies": { - "name": "value" - }, - "body": "Expected Body" - }, - "response": { - "statusCode": "int (2xx,4xx,5xx,xxx)", - "headers": { - "name": ["value"] - }, - "cookies": { - "name": "value" - }, - "body": "Response body" - }, - "callback": { - "method": "GET|POST|PUT|PATCH|...", - "url": "http://your-callback/", - "delay": "string (response delay in s,ms)", - "headers": { - "name": ["value"] - }, - "body": "Response body" - }, - "control": { - "scenario": { - "name": "string (scenario name)", - "requiredState": [ - "not_started (default state)", - "another_state_name" - ], - "newState": "new_stat_neme" - }, - "proxyBaseURL": "string (original URL endpoint)", - "delay": "string (response delay in s,ms)", - "crazy": "bool (return random 5xx)", - "priority": "int (matching priority)", - "webHookURL" : "string (URL endpoint)" - } + "description": "Some text that describes the intended usage of the current configuration", + "request": { + "host": "example.com", + "method": "GET|POST|PUT|PATCH|...", + "path": "/your/path/:variable", + "queryStringParameters": { + "name": ["value"], + "name": ["value", "value"] + }, + "headers": { + "name": ["value"] + }, + "cookies": { + "name": "value" + }, + "body": "Expected Body" + }, + "response": { + "statusCode": "int (2xx,4xx,5xx,xxx)", + "headers": { + "name": ["value"] + }, + "cookies": { + "name": "value" + }, + "body": "Response body" + }, + "callback": { + "method": "GET|POST|PUT|PATCH|...", + "url": "http://your-callback/", + "delay": "string (response delay in s,ms)", + "headers": { + "name": ["value"] + }, + "body": "Response body" + }, + "control": { + "scenario": { + "name": "string (scenario name)", + "requiredState": [ + "not_started (default state)", + "another_state_name" + ], + "newState": "new_stat_neme" + }, + "proxyBaseURL": "string (original URL endpoint)", + "delay": "string (response delay in s,ms)", + "crazy": "bool (return random 5xx)", + "priority": "int (matching priority)", + "webHookURL" : "string (URL endpoint)" + } } ``` @@ -209,6 +201,7 @@ A core feature of Mmock is the ability to return canned HTTP responses for reque In case of queryStringParameters, headers and cookies, the request can be matched only if all defined keys in mock will be present with the exact or glob value. Glob matching is available for: + * host * path * headers @@ -217,26 +210,21 @@ Glob matching is available for: * body Query strings and headers support also global matches (*) in the header/parameter name. For example: + ```json - "headers": { - "Custom-Header-*": [ - "partial val*" - ] - } + "headers": { + "Custom-Header-*": [ + "partial val*" + ] + } ``` -Regexp matching is available for: -- body -- query strings - -See https://pkg.go.dev/regexp/syntax for regexp syntax - #### Response (Optional on proxy call) * *statusCode*: Response status code * *headers*: Array of headers. It allows more than one value for the same key and vars. * *cookies*: Array of cookies. It allows vars. -* *body*: Body string. It allows vars. +* *body*: Body string. It allows vars. In case of SSE, body should be array of JSON Object to stream. #### Callback (Optional) @@ -262,37 +250,35 @@ This is used to have mmock make an API request after receiving the mocked reques You can use variable data in response. The variables will be defined as tags like this {{nameVar}} - - URI - - description - **Request data:** Use them if you want to add request data in your response. - - request.scheme - - request.hostname - - request.port - - request.path (full path) - - request.path."*key*" - - request.query."*key*" - - request.cookie."*key*" - - request.fragment - - request.url (full url with scheme, hostname, port, path and query parameters) - - request.authority (return scheme, hostname and port (optional)) - - request.body +* request.scheme +* request.hostname +* request.port +* request.path (full path) +* request.path."*key*" +* request.query."*key*" +* request.cookie."*key*" +* request.fragment +* request.url (full url with scheme, hostname, port, path and query parameters) +* request.autority (return scheme, hostname and port (optional)) +* request.body You can extract information from the request body too, using a dot notation path: - - - request.body."*key*" (support for `application/json`, `application/xml` and `application/x-www-form-urlencoded` requests) - - request.body."*deep*"."*key*" (support for `application/json`, `application/xml` requests) + +* request.body."*key*" (support for `application/json`, `application/xml` and `application/x-www-form-urlencoded` requests) +* request.body."*deep*"."*key*" (support for `application/json`, `application/xml` requests) Quick overview of the path syntax available to extract values form the request: [https://github.com/tidwall/gjson#path-syntax](https://github.com/tidwall/gjson#path-syntax) You can also use "regex" and "concat" commands to complement GJson query: -- request.body."*deep*"."*key*".regex() (support for `application/json`, `application/xml` requests) -- request.body."*deep*"."*key*".concat() (support for `application/json`, `application/xml` requests) -- request.body."*deep*"."*key*".regex().concat() (support for `application/json`, `application/xml` requests) +* request.body."*deep*"."*key*".regex() (support for `application/json`, `application/xml` requests) +* request.body."*deep*"."*key*".concat() (support for `application/json`, `application/xml` requests) +* request.body."*deep*"."*key*".regex().concat() (support for `application/json`, `application/xml` requests) **Example request data:** + ```json { "email": "hilari@sapo.pt", @@ -301,7 +287,9 @@ You can also use "regex" and "concat" commands to complement GJson query: "discarded": "do not return" } ``` + **Example config mock data:** + ```json { "email": "{{request.body.email.regex((\@gmail.com))}}", @@ -310,7 +298,9 @@ You can also use "regex" and "concat" commands to complement GJson query: "discarded": "{{request.body.discarded.concat(, Please!)}}" } ``` + **Example response data:** + ```json { "email": "", @@ -322,64 +312,64 @@ You can also use "regex" and "concat" commands to complement GJson query: **External streams:** Perfect for embedding big payloads or getting data from another service. - - file.contents(FILE_PATH) - - http.contents(URL) +* file.contents(FILE_PATH) +* http.contents(URL) **[Fake](https://godoc.org/github.com/icrowley/fake) data:** Useful to provide a more rich and random response. - - fake.Brand - - fake.Character - - fake.Characters - - fake.CharactersN(n) - - fake.City - - fake.Color - - fake.Company - - fake.Continent - - fake.Country - - fake.CreditCardVisa - - fake.CreditCardMasterCard - - fake.CreditCardAmericanExpress - - fake.Currency - - fake.CurrencyCode - - fake.Day - - fake.Digits - - fake.DigitsN(n) - - fake.EmailAddress - - fake.FirstName - - fake.FullName - - fake.LastName - - fake.Gender - - fake.Hex(n) - random hexidecimal string n characters in length - - fake.IPv4 - - fake.Language - - fake.Model - - fake.Month - - fake.MonthShort - - fake.MonthNum - - fake.Year - - fake.Paragraph - - fake.Paragraphs - - fake.ParagraphsN(n) - - fake.Phone - - fake.Product - - fake.Sentence - - fake.Sentences - - fake.SentencesN(n) - - fake.SimplePassword - - fake.State - - fake.StateAbbrev - - fake.Street - - fake.StreetAddress - - fake.UserName - - fake.WeekDay - - fake.Word - - fake.Words - - fake.WordsN(n) - - fake.Zip - - fake.Int(n) - random positive integer less than or equal to n - - fake.IntMinMax(min, max) - random positive number greater or equal to min and less than max - - fake.Float(n) - random positive floating point number less than n - - fake.UUID - generates a unique id +* fake.Brand +* fake.Character +* fake.Characters +* fake.CharactersN(n) +* fake.City +* fake.Color +* fake.Company +* fake.Continent +* fake.Country +* fake.CreditCardVisa +* fake.CreditCardMasterCard +* fake.CreditCardAmericanExpress +* fake.Currency +* fake.CurrencyCode +* fake.Day +* fake.Digits +* fake.DigitsN(n) +* fake.EmailAddress +* fake.FirstName +* fake.FullName +* fake.LastName +* fake.Gender +* fake.Hex(n) - random hexidecimal string n characters in length +* fake.IPv4 +* fake.Language +* fake.Model +* fake.Month +* fake.MonthShort +* fake.MonthNum +* fake.Year +* fake.Paragraph +* fake.Paragraphs +* fake.ParagraphsN(n) +* fake.Phone +* fake.Product +* fake.Sentence +* fake.Sentences +* fake.SentencesN(n) +* fake.SimplePassword +* fake.State +* fake.StateAbbrev +* fake.Street +* fake.StreetAddress +* fake.UserName +* fake.WeekDay +* fake.Word +* fake.Words +* fake.WordsN(n) +* fake.Zip +* fake.Int(n) - random positive integer less than or equal to n +* fake.IntMinMax(min, max) - random positive number greater or equal to min and less than max +* fake.Float(n) - random positive floating point number less than n +* fake.UUID - generates a unique id ### Scenarios @@ -417,13 +407,13 @@ MMock uses the Open API Specification (OAI, formerly known as Swagger) to descri The OAI specification makes writing client applications easier by: auto-generating boilerplate code (like data object classes) and dealing with authentication and error handling. -You can find a comprehensive set of open tools for the OAI specification at: https://github.com/swagger-api. +You can find a comprehensive set of open tools for the OAI specification at: . #### REST Endpoints ### Verify -The Mmock records the incoming requests in memory (last 100 by default). +The Mmock records the incoming requests in memory (last 100 by default). This makes it possible to verify that a request matching a specific pattern was received, and also to fetch the requests details. **Title** : Get all requests.
@@ -450,22 +440,23 @@ Like stubbing this call also uses the same DSL to filter and query requests. ```json { - "host": "example.com", - "method": "GET|POST|PUT|PATCH|... (Mandatory)", - "path": "/your/path/:variable (Mandatory)", - "queryStringParameters": { - "name": ["value"], - "name": ["value", "value"] - }, - "headers": { - "name": ["value"] - }, - "cookies": { - "name": "value" - }, - "body": "Expected Body" + "host": "example.com", + "method": "GET|POST|PUT|PATCH|... (Mandatory)", + "path": "/your/path/:variable (Mandatory)", + "queryStringParameters": { + "name": ["value"], + "name": ["value", "value"] + }, + "headers": { + "name": ["value"] + }, + "cookies": { + "name": "value" + }, + "body": "Expected Body" } ``` + **Response Codes**: Success (200 OK)
**Title** : Get all non matched requests.
@@ -482,22 +473,23 @@ Like stubbing this call also uses the same DSL to filter and query requests. ```json { - "host": "example.com", - "method": "GET|POST|PUT|PATCH|... (Mandatory)", - "path": "/your/path/:variable (Mandatory)", - "queryStringParameters": { - "name": ["value"], - "name": ["value", "value"] - }, - "headers": { - "name": ["value"] - }, - "cookies": { - "name": "value" - }, - "body": "Expected Body" + "host": "example.com", + "method": "GET|POST|PUT|PATCH|... (Mandatory)", + "path": "/your/path/:variable (Mandatory)", + "queryStringParameters": { + "name": ["value"], + "name": ["value", "value"] + }, + "headers": { + "name": ["value"] + }, + "cookies": { + "name": "value" + }, + "body": "Expected Body" } ``` + **Response Codes**: Success (200 OK)
### Scenario @@ -563,7 +555,6 @@ Also there is a real time endpoint available through WebSockets that broadcast a **Title** : Get all requests.
**URL** : /echo
- #### Output All endpoints have the same output format: @@ -607,30 +598,28 @@ Mmock is collecting anonymous statistics about the usage of the following action Source code: [/statistics/statistics.go](https://github.com/jmartin82/mmock/blob/master/internal/statistics/statistics.go#L30) -- `requests.mock`: Mocks served (number) -- `requests.console`: Web console usage (number) -- `requests.verify`: Verify requests (number) -- `feature.scenario`: Mocks with scenario feature served (number) -- `feature.proxy`: Mocks with proxy feature served (number) +* `requests.mock`: Mocks served (number) +* `requests.console`: Web console usage (number) +* `requests.verify`: Verify requests (number) +* `feature.scenario`: Mocks with scenario feature served (number) +* `feature.proxy`: Mocks with proxy feature served (number) You can always disable this behavior adding the following flag `-server-statistics=false` - ### Contributors -- Amazing request body parsing form [@hmoragrega](https://github.com/hmoragrega) -- Awesome use statistics from [@alfonsfoubert](https://github.com/alfonsfoubert) -- More request variables available thanks to [@Bartek-CMP](https://github.com/Bartek-CMP) -- Scenario pause feature thanks to [@Nekroze](https://github.com/Nekroze) -- Storage reset feature thanks to [@rubencougil](https://github.com/rubencougil) -- Improved docker image thanks to [@daroot](https://github.com/daroot) -- Added the possibility of access to an array index in dynamic responses [@jaimelopez](https://github.com/jaimelopez) -- Create mapping via console thanks to [@inabajunmr](https://github.com/inabajunmr) -- Thanks to [@joel-44](https://github.com/joel-44) for bug fixing -- Enviroment variables as mock variables thanks to [@marcoreni](https://github.com/marcoreni) -- Support Regular Expressions for Field Values in JSON Request Body thanks to [@rosspatil](https://github.com/rosspatil) -- Improved logging with levels thanks to [@jcdietrich](https://github.com/jcdietrich) [@jdietrich-tc](https://github.com/jdietrich-tc) -- Support for Regular Expressions for QueryStringParameters [@jcdietrich](https://github.com/jcdietrich) [@jdietrich-tc](https://github.com/jdietrich-tc) -- Support for URI and Description tags [@jcdietrich](https://github.com/jcdietrich) [@jdietrich-tc](https://github.com/jdietrich-tc) + +* Amazing request body parsing form [@hmoragrega](https://github.com/hmoragrega) + +* Awesome use statistics from [@alfonsfoubert](https://github.com/alfonsfoubert) +* More request variables available thanks to [@Bartek-CMP](https://github.com/Bartek-CMP) +* Scenario pause feature thanks to [@Nekroze](https://github.com/Nekroze) +* Storage reset feature thanks to [@rubencougil](https://github.com/rubencougil) +* Improved docker image thanks to [@daroot](https://github.com/daroot) +* Added the possibility of access to an array index in dynamic responses [@jaimelopez](https://github.com/jaimelopez) +* Create mapping via console thanks to [@inabajunmr](https://github.com/inabajunmr) +* Thanks to [@joel-44](https://github.com/joel-44) for bug fixing +* Enviroment variables as mock variables thanks to [@marcoreni](https://github.com/marcoreni) +* Support Regular Expressions for Field Values in JSON Request Body thanks to [@rosspatil](https://github.com/rosspatil) ### Contributing @@ -644,6 +633,6 @@ If you make any changes, please run ```go fmt ./...``` before submitting a pull ### Licence -Copyright © 2016 - 2020, Jordi Martín (http://jordi.io) +Copyright © 2016 - 2020, Jordi Martín () Released under MIT license, see [LICENSE](LICENSE.md) for details. diff --git a/config/sse.yaml b/config/sse.yaml new file mode 100644 index 0000000..7e94a17 --- /dev/null +++ b/config/sse.yaml @@ -0,0 +1,42 @@ +request: + method: POST + path: /events +response: + statusCode: 200 + headers: + Content-Type: + - "text/event-stream" + Connection: + - "keep-alive" + Cache-Control: + - "no-cache" + body: > + [ + { + "test":"1" + }, + { + "test":"2" + }, + { + "test":"3" + }, + { + "test":"4" + }, + { + "test":"5" + }, + { + "test":"6" + }, + { + "test":"7" + }, + { + "test":"8" + }, + { + "test":"9" + } + ] diff --git a/internal/server/dispatcher.go b/internal/server/dispatcher.go index fc51691..81c7454 100644 --- a/internal/server/dispatcher.go +++ b/internal/server/dispatcher.go @@ -95,7 +95,7 @@ func (di *Dispatcher) ServeHTTP(w http.ResponseWriter, req *http.Request) { } //translate request - di.Translator.WriteHTTPResponseFromDefinition(transaction.Response, w) + di.Translator.WriteHTTPResponseFromDefinition(transaction.Response, w, req) if mock.Callback.Url != "" { go func() { diff --git a/pkg/mock/http.go b/pkg/mock/http.go index 810a7f7..30d8bf3 100644 --- a/pkg/mock/http.go +++ b/pkg/mock/http.go @@ -1,11 +1,14 @@ package mock import ( + "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "strings" + "time" + + "github.com/tidwall/gjson" ) // HTTP is and adaptor beteewn the http and mock config. @@ -39,7 +42,7 @@ func (t HTTP) BuildRequestDefinitionFromHTTP(req *http.Request) Request { res.QueryStringParameters[name] = values } - body, _ := ioutil.ReadAll(req.Body) + body, _ := io.ReadAll(req.Body) res.Body = string(body) return res @@ -68,14 +71,34 @@ func getHostAndPort(req *http.Request) (string, string) { } // WriteHTTPResponseFromDefinition read a mock response and write a http response. -func (t HTTP) WriteHTTPResponseFromDefinition(fr *Response, w http.ResponseWriter) { +func (t HTTP) WriteHTTPResponseFromDefinition(fr *Response, w http.ResponseWriter, req *http.Request) { + if isSSE(fr) { + streamResponse(fr, w, req) + return + } + addHeadersAndCookies(fr, w) + w.WriteHeader(fr.StatusCode) + io.WriteString(w, fr.Body) +} +// Check if the response is of type SSE +func isSSE(fr *Response) bool { + values, ok := fr.Headers["content-type"] + if ok { + for _, value := range values { + return strings.ToLower(value) == "text/event-stream" + } + } + return false +} + +func addHeadersAndCookies(fr *Response, w http.ResponseWriter) { for header, values := range fr.Headers { for _, value := range values { w.Header().Add(header, value) } - } + if len(fr.Cookies) > 0 { cookies := []string{} for cookie, value := range fr.Cookies { @@ -83,7 +106,21 @@ func (t HTTP) WriteHTTPResponseFromDefinition(fr *Response, w http.ResponseWrite } w.Header().Add("Set-Cookie", strings.Join(cookies, ";")) } +} - w.WriteHeader(fr.StatusCode) - io.WriteString(w, fr.Body) +// streamResponse - stream response +func streamResponse(fr *Response, w http.ResponseWriter, req *http.Request) { + addHeadersAndCookies(fr, w) + + for _, response := range gjson.Parse(fr.Body).Array() { + time.Sleep(time.Second * 2) + select { + case <-req.Context().Done(): + return + default: + ba, _ := json.Marshal((response.Value())) + fmt.Fprintf(w, "data: %s\n\n", string(ba)) + w.(http.Flusher).Flush() + } + } } diff --git a/pkg/mock/message_translator.go b/pkg/mock/message_translator.go index 9ef4d26..b3b0df4 100644 --- a/pkg/mock/message_translator.go +++ b/pkg/mock/message_translator.go @@ -11,7 +11,7 @@ type MockRequestBuilder interface { // MockResponseWriter defines the translator from config.Response to http.ResponseWriter type MockResponseWriter interface { - WriteHTTPResponseFromDefinition(fr *Response, w http.ResponseWriter) + WriteHTTPResponseFromDefinition(fr *Response, w http.ResponseWriter, req *http.Request) } // MessageTranslator defines the translator contract between http and mock and viceversa.