├── .nvmrc ├── .dockerignore ├── .eslintignore ├── client ├── assets │ ├── logo.png │ ├── favicon.ico │ └── logo180.png ├── globals.d.ts ├── components │ ├── Mermaid.scss │ ├── App.scss │ ├── MockEditor │ │ ├── MockEditor.scss │ │ ├── utils.tsx │ │ ├── MockDynamicResponseEditor.tsx │ │ ├── MockContextEditor.tsx │ │ ├── MockProxyResponseEditor.tsx │ │ ├── MockStaticResponseEditor.tsx │ │ ├── BodyMatcherEditor.tsx │ │ ├── KeyValueEditor.tsx │ │ └── MockRequestEditor.tsx │ ├── Navbar.scss │ ├── Code.scss │ ├── Visualize.scss │ ├── Sidebar.scss │ ├── Mermaid.tsx │ ├── Navbar.tsx │ ├── App.tsx │ ├── History.scss │ ├── Code.tsx │ ├── Mocks.scss │ ├── Sidebar.tsx │ └── Visualize.tsx ├── index.tsx ├── index.html ├── variables.scss └── modules │ ├── actions.ts │ ├── utils.test.ts │ ├── types.ts │ └── epics.ts ├── docs ├── logo-horizontal.png └── screenshots │ ├── screenshot-history.png │ └── screenshot-mocks.png ├── Caddyfile ├── main_test.go ├── tests ├── sessions │ ├── sessions.yml │ └── 3giPMr5IR │ │ ├── history.yml │ │ └── mocks.yml ├── data │ ├── basic_mock.yml │ ├── restricted_mock_list.yml │ ├── proxy_mock_list.yml │ ├── basic_mock_list.yml │ ├── matcher_mock_list.yml │ └── dynamic_mock_list.yml └── features │ ├── 0_persistence.yml │ ├── use_history.yml │ ├── import_sessions.yml │ ├── locks_mocks.yml │ ├── verify_session.yml │ ├── use_sessions.yml │ └── set_mocks.yml ├── sonar-project.properties ├── .gitignore ├── .golangci.yml ├── .editorconfig ├── server ├── templates │ ├── utils.go │ ├── interface.go │ ├── go_template.go │ └── lua.go ├── types │ ├── errors.go │ ├── templates.go │ ├── graph.go │ ├── encoding.go │ ├── sessions.go │ ├── history.go │ ├── matchers_test.go │ └── mock.go ├── config │ └── config.go ├── mock_server.go ├── handlers │ ├── utils.go │ ├── mocks.go │ └── admin.go ├── services │ ├── graphs.go │ └── mocks.go ├── admin_server.go └── middlewares.go ├── .eslintrc.yml ├── Dockerfile ├── tsconfig.json ├── LICENSE ├── go.mod ├── main.go ├── package.json ├── .github └── workflows │ └── main.yml ├── Makefile └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .git/ 3 | .vscode/ 4 | node_modules/ 5 | tests/ 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .parcel-cache/ 2 | .vscode/ 3 | build/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /client/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smocker-dev/smocker/HEAD/client/assets/logo.png -------------------------------------------------------------------------------- /docs/logo-horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smocker-dev/smocker/HEAD/docs/logo-horizontal.png -------------------------------------------------------------------------------- /client/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smocker-dev/smocker/HEAD/client/assets/favicon.ico -------------------------------------------------------------------------------- /client/assets/logo180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smocker-dev/smocker/HEAD/client/assets/logo180.png -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | http://localhost:8082 { 2 | uri strip_prefix /smocker 3 | reverse_proxy localhost:8081 4 | } 5 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestIntegration(_ *testing.T) { 6 | main() 7 | } 8 | -------------------------------------------------------------------------------- /tests/sessions/sessions.yml: -------------------------------------------------------------------------------- 1 | - id: 3giPMr5IR 2 | name: 'Session #1' 3 | date: 2024-01-22T21:05:11.838034004+01:00 4 | -------------------------------------------------------------------------------- /docs/screenshots/screenshot-history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smocker-dev/smocker/HEAD/docs/screenshots/screenshot-history.png -------------------------------------------------------------------------------- /docs/screenshots/screenshot-mocks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smocker-dev/smocker/HEAD/docs/screenshots/screenshot-mocks.png -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=smocker 2 | sonar.projectKey=Thiht_smocker 3 | sonar.projectName=Smocker 4 | 5 | sonar.go.coverage.reportPaths=coverage/cover.out 6 | -------------------------------------------------------------------------------- /tests/data/basic_mock.yml: -------------------------------------------------------------------------------- 1 | - request: 2 | path: /test 3 | response: 4 | headers: 5 | Content-Type: application/json 6 | body: > 7 | {"message": "test"} 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .parcel-cache/ 3 | .vscode/ 4 | build/ 5 | coverage/ 6 | node_modules/ 7 | sessions 8 | !tests/sessions 9 | smocker 10 | smocker.test 11 | yarn-error.log 12 | -------------------------------------------------------------------------------- /client/globals.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | declare var basePath: string; 3 | declare var version: string; 4 | declare module "codemirror/mode/*"; 5 | declare module "codemirror/addon/*"; 6 | declare module "*.png"; 7 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | lll: 3 | line-length: 150 4 | 5 | issues: 6 | exclude-rules: 7 | - text: "exported type .* should have comment or be unexported" 8 | linters: 9 | - go-lint 10 | -------------------------------------------------------------------------------- /client/components/Mermaid.scss: -------------------------------------------------------------------------------- 1 | .mermaid { 2 | & > div:nth-child(1) { 3 | display: flex; 4 | & > svg { 5 | margin: auto; 6 | } 7 | } 8 | .ant-spin { 9 | margin-left: calc(50% - 1em); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "react-dom"; 3 | import App from "./components/App"; 4 | 5 | render(, document.getElementById("root")); 6 | 7 | if (module.hot) { 8 | module.hot.accept(); 9 | } 10 | -------------------------------------------------------------------------------- /client/components/App.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root, 4 | #root .layout { 5 | height: 100%; 6 | position: relative; 7 | } 8 | 9 | #root .layout.scrollable { 10 | overflow-y: auto; 11 | position: relative; 12 | .not-scrollable { 13 | min-height: auto; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/components/MockEditor/MockEditor.scss: -------------------------------------------------------------------------------- 1 | .inline-form-items { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .hidden { 7 | visibility: hidden; 8 | } 9 | 10 | .display-none { 11 | display: none; 12 | } 13 | 14 | .ant-form-item { 15 | margin-bottom: 12px; 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.go] 12 | indent_size = unset 13 | indent_style = tab 14 | 15 | [Makefile] 16 | indent_size = unset 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /server/templates/utils.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import "encoding/json" 4 | 5 | func StructToMSI(s interface{}) (map[string]interface{}, error) { 6 | bytes, err := json.Marshal(s) 7 | if err != nil { 8 | return nil, err 9 | } 10 | msi := map[string]interface{}{} 11 | err = json.Unmarshal(bytes, &msi) 12 | if err != nil { 13 | return nil, err 14 | } 15 | return msi, nil 16 | } 17 | -------------------------------------------------------------------------------- /tests/sessions/3giPMr5IR/history.yml: -------------------------------------------------------------------------------- 1 | - context: 2 | mockid: YnJEM95SR 3 | mocktype: static 4 | delay: "" 5 | request: 6 | path: /test 7 | method: GET 8 | date: 2024-01-22T21:06:00.547178247+01:00 9 | response: 10 | status: 200 11 | body: 12 | message: test 13 | headers: 14 | Content-Type: 15 | - application/json 16 | date: 2024-01-22T21:06:00.547400418+01:00 17 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | env: 3 | browser: true 4 | node: true 5 | parser: "@typescript-eslint/parser" 6 | plugins: 7 | - "@typescript-eslint" 8 | - jest 9 | - react 10 | settings: 11 | react: 12 | version: detect 13 | extends: 14 | - eslint:recommended 15 | - plugin:@typescript-eslint/recommended 16 | - plugin:jest/recommended 17 | - plugin:react/recommended 18 | - prettier/@typescript-eslint 19 | rules: 20 | no-empty: 21 | - error 22 | - allowEmptyCatch: true 23 | -------------------------------------------------------------------------------- /client/components/Navbar.scss: -------------------------------------------------------------------------------- 1 | #root .navbar { 2 | box-shadow: 0 0 5px 3px rgba(0, 0, 0, 0.5); 3 | line-height: 46px; 4 | height: 46px; 5 | .logo { 6 | font-variant: small-caps; 7 | font-weight: bolder; 8 | color: white; 9 | height: 42px; 10 | line-height: 42px; 11 | margin-right: 1em; 12 | img { 13 | height: 32px; 14 | vertical-align: middle; 15 | margin-right: 0.5em; 16 | display: inline-block; 17 | padding-bottom: 5px; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/components/Code.scss: -------------------------------------------------------------------------------- 1 | .code-editor { 2 | .CodeMirror { 3 | position: relative; 4 | height: auto; 5 | overflow-y: hidden; 6 | padding: 0.5em 0 0.5em 0.5em; 7 | margin: 0; 8 | border-radius: 3px; 9 | 10 | .CodeMirror-scroll { 11 | height: auto; 12 | } 13 | 14 | .CodeMirror-line { 15 | line-height: 12px; 16 | font-size: 12px; 17 | } 18 | 19 | .CodeMirror-gutter-elt { 20 | line-height: 12px; 21 | font-size: 14px; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/sessions/3giPMr5IR/mocks.yml: -------------------------------------------------------------------------------- 1 | - request: 2 | path: 3 | matcher: ShouldEqual 4 | value: /test 5 | method: 6 | matcher: ShouldEqual 7 | value: GET 8 | response: 9 | body: | 10 | {"message": "test"} 11 | status: 200 12 | headers: 13 | Content-Type: 14 | - application/json 15 | context: 16 | times: 1 17 | state: 18 | id: YnJEM95SR 19 | times_count: 0 20 | locked: false 21 | creation_date: 2024-01-22T21:05:27.349554475+01:00 22 | -------------------------------------------------------------------------------- /client/components/MockEditor/utils.tsx: -------------------------------------------------------------------------------- 1 | export const defaultResponseStatus = 200; 2 | 3 | export const unaryMatchers = ["ShouldBeEmpty", "ShouldNotBeEmpty"]; 4 | 5 | export const positiveMatchers = [ 6 | "ShouldEqual", 7 | "ShouldMatch", 8 | "ShouldContainSubstring", 9 | "ShouldBeEmpty", 10 | "ShouldStartWith", 11 | "ShouldEndWith", 12 | ]; 13 | 14 | export const negativeMatchers = [ 15 | "ShouldNotEqual", 16 | "ShouldNotMatch", 17 | "ShouldNotBeEmpty", 18 | "ShouldNotContainSubstring", 19 | "ShouldNotStartWith", 20 | "ShouldNotEndWith", 21 | ]; 22 | -------------------------------------------------------------------------------- /server/types/errors.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | StatusSmockerInternalError = 600 5 | StatusSmockerEngineExecutionError = 601 6 | StatusSmockerProxyRedirectionError = 602 7 | StatusSmockerMockNotFound = 666 8 | 9 | SmockerInternalError = "Smocker internal error" 10 | SmockerEngineExecutionError = "Error during template engine execution" 11 | SmockerProxyRedirectionError = "Error during request redirection" 12 | SmockerMockNotFound = "No mock found matching the request" 13 | SmockerMockExceeded = "Matching mock found but was exceeded" 14 | ) 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1.22 2 | FROM golang:${GO_VERSION}-alpine AS build-backend 3 | RUN apk add --no-cache make 4 | ARG VERSION=snapshot 5 | ARG COMMIT 6 | WORKDIR /go/src/smocker 7 | COPY go.mod go.sum ./ 8 | RUN go mod download 9 | COPY Makefile main.go ./ 10 | COPY server/ ./server/ 11 | RUN make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build 12 | 13 | FROM alpine 14 | LABEL org.opencontainers.image.source="https://github.com/smocker-dev/smocker" 15 | WORKDIR /opt 16 | EXPOSE 8080 8081 17 | COPY build/client client/ 18 | COPY --from=build-backend /go/src/smocker/build/* /opt/ 19 | CMD ["/opt/smocker"] 20 | -------------------------------------------------------------------------------- /server/types/templates.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Engine string 4 | 5 | const ( 6 | GoTemplateEngineID Engine = "go_template" 7 | GoTemplateYamlEngineID Engine = "go_template_yaml" 8 | GoTemplateJsonEngineID Engine = "go_template_json" 9 | LuaEngineID Engine = "lua" 10 | ) 11 | 12 | var TemplateEngines = [...]Engine{GoTemplateEngineID, GoTemplateYamlEngineID, GoTemplateJsonEngineID, LuaEngineID} 13 | 14 | func (e Engine) IsValid() bool { 15 | for _, existingEngine := range TemplateEngines { 16 | if e == existingEngine { 17 | return true 18 | } 19 | } 20 | return false 21 | } 22 | -------------------------------------------------------------------------------- /server/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | LogLevel string 5 | ConfigListenPort int 6 | ConfigBasePath string 7 | MockServerListenPort int 8 | StaticFiles string 9 | HistoryMaxRetention int 10 | PersistenceDirectory string 11 | TLSEnable bool 12 | TLSCertFile string 13 | TLSKeyFile string 14 | Build Build 15 | } 16 | 17 | type Build struct { 18 | AppName string `json:"app_name"` 19 | BuildVersion string `json:"build_version"` 20 | BuildCommit string `json:"build_commit"` 21 | BuildDate string `json:"build_date"` 22 | } 23 | -------------------------------------------------------------------------------- /server/types/graph.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | type GraphConfig struct { 6 | SrcHeader string `query:"src"` 7 | DestHeader string `query:"dest"` 8 | } 9 | 10 | type GraphEntry struct { 11 | Type string `json:"type"` 12 | Message string `json:"message"` 13 | From string `json:"from"` 14 | To string `json:"to"` 15 | Date time.Time `json:"date"` 16 | } 17 | 18 | type GraphHistory []GraphEntry 19 | 20 | func (p GraphHistory) Len() int { 21 | return len(p) 22 | } 23 | 24 | func (p GraphHistory) Less(i, j int) bool { 25 | return p[i].Date.Before(p[j].Date) 26 | } 27 | 28 | func (p GraphHistory) Swap(i, j int) { 29 | p[i], p[j] = p[j], p[i] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./client", 4 | "allowSyntheticDefaultImports": true, 5 | "module": "ESNext", 6 | "target": "ES5", 7 | "lib": ["ES6", "DOM", "ES2019.Array"], 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "removeComments": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "experimentalDecorators": true, 20 | "esModuleInterop": true 21 | }, 22 | "include": ["client/**/*"], 23 | "exclude": ["node_modules", "build", "client/coverage"] 24 | } 25 | -------------------------------------------------------------------------------- /server/templates/interface.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/smocker-dev/smocker/server/types" 7 | ) 8 | 9 | type TemplateEngine interface { 10 | Execute(request types.Request, script string) (*types.MockResponse, error) 11 | } 12 | 13 | func GenerateMockResponse(d *types.DynamicMockResponse, request types.Request) (*types.MockResponse, error) { 14 | switch d.Engine { 15 | case types.GoTemplateEngineID, types.GoTemplateYamlEngineID: 16 | return NewGoTemplateYamlEngine().Execute(request, d.Script) 17 | case types.GoTemplateJsonEngineID: 18 | return NewGoTemplateJsonEngine().Execute(request, d.Script) 19 | case types.LuaEngineID: 20 | return NewLuaEngine().Execute(request, d.Script) 21 | default: 22 | return nil, fmt.Errorf("invalid engine: %q", d.Engine) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/data/restricted_mock_list.yml: -------------------------------------------------------------------------------- 1 | - request: 2 | path: /test 3 | method: GET 4 | context: 5 | times: 1 6 | response: 7 | headers: 8 | Content-Type: application/json 9 | body: > 10 | {"message": "test"} 11 | - request: 12 | path: /test 13 | method: GET 14 | context: 15 | times: 1 16 | response: 17 | headers: 18 | Content-Type: application/json 19 | body: > 20 | {"message": "test2"} 21 | - request: 22 | path: /test 23 | method: POST 24 | context: 25 | times: 2 26 | response: 27 | headers: 28 | Content-Type: application/json 29 | body: > 30 | {"message": "test3"} 31 | - request: 32 | path: /test 33 | method: PUT 34 | response: 35 | headers: 36 | Content-Type: application/json 37 | body: > 38 | {"message": "test4"} 39 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | Smocker 14 | 15 | 16 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /client/components/MockEditor/MockDynamicResponseEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Form, Select } from "antd"; 3 | import Code from "../Code"; 4 | 5 | export const MockDynamicResponseEditor = (): JSX.Element => ( 6 | <> 7 | 8 | 17 | 18 | 19 | prevValues?.dynamic_response?.engine !== 22 | currentValues?.dynamic_response?.engine} 23 | > 24 | {({ getFieldValue }) => ( 25 | 26 | 27 | 28 | )} 29 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Thibaut Rousseau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/mock_server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/labstack/echo/v4" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/smocker-dev/smocker/server/config" 10 | "github.com/smocker-dev/smocker/server/handlers" 11 | "github.com/smocker-dev/smocker/server/services" 12 | ) 13 | 14 | func NewMockServer(cfg config.Config) (*http.Server, services.Mocks) { 15 | mockServerEngine := echo.New() 16 | persistence := services.NewPersistence(cfg.PersistenceDirectory) 17 | sessions, err := persistence.LoadSessions() 18 | if err != nil { 19 | log.Error("Unable to load sessions: ", err) 20 | } 21 | mockServices := services.NewMocks(sessions, cfg.HistoryMaxRetention, persistence) 22 | 23 | mockServerEngine.HideBanner = true 24 | mockServerEngine.HidePort = true 25 | mockServerEngine.Use(recoverMiddleware(), loggerMiddleware(), HistoryMiddleware(mockServices)) 26 | 27 | handler := handlers.NewMocks(mockServices) 28 | mockServerEngine.Any("/*", handler.GenericHandler) 29 | 30 | mockServerEngine.Server.Addr = ":" + strconv.Itoa(cfg.MockServerListenPort) 31 | return mockServerEngine.Server, mockServices 32 | } 33 | -------------------------------------------------------------------------------- /server/types/encoding.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // StringSlice is a type that can unmarshal a string as a slice string 8 | // This allows to write a single item slice as a string 9 | type StringSlice []string 10 | 11 | func (ss *StringSlice) UnmarshalJSON(data []byte) error { 12 | var str string 13 | if err := json.Unmarshal(data, &str); err == nil { 14 | *ss = append(*ss, str) 15 | return nil 16 | } 17 | 18 | var strSlice []string 19 | if err := json.Unmarshal(data, &strSlice); err != nil { 20 | return err 21 | } 22 | 23 | *ss = make(StringSlice, 0, len(strSlice)) 24 | *ss = append(*ss, strSlice...) 25 | 26 | return nil 27 | } 28 | 29 | func (ss *StringSlice) UnmarshalYAML(unmarshal func(interface{}) error) error { 30 | var str string 31 | if err := unmarshal(&str); err == nil { 32 | *ss = append(*ss, str) 33 | return nil 34 | } 35 | 36 | var strSlice []string 37 | if err := unmarshal(&strSlice); err != nil { 38 | return err 39 | } 40 | 41 | *ss = make(StringSlice, 0, len(strSlice)) 42 | *ss = append(*ss, strSlice...) 43 | 44 | return nil 45 | } 46 | 47 | type MapStringSlice map[string]StringSlice 48 | -------------------------------------------------------------------------------- /client/variables.scss: -------------------------------------------------------------------------------- 1 | $color-red-light: #fc5c65; 2 | $color-red-dark: #eb3b5a; 3 | 4 | $color-orange-light: #fd9644; 5 | $color-orange-dark: #fa8231; 6 | 7 | $color-yellow-light: #fed330; 8 | $color-yellow-dark: #f7b731; 9 | 10 | $color-green-light: #26de81; 11 | $color-green-dark: #20bf6b; 12 | 13 | $color-teal-light: #2bcbba; 14 | $color-teal-dark: #0fb9b1; 15 | 16 | $color-blue-light: #45aaf2; 17 | $color-blue-dark: #2d98da; 18 | 19 | $color-azure-light: #4b7bec; 20 | $color-azure-dark: #3867d6; 21 | 22 | $color-purple-light: #a55eea; 23 | $color-purple-dark: #8854d0; 24 | 25 | $color-grey-light: #d1d8e0; 26 | $color-grey-dark: #a5b1c2; 27 | 28 | $color-darkgrey-light: #778ca3; 29 | $color-darkgrey-dark: #4b6584; 30 | 31 | $color-black-light: #353b48; 32 | $color-black-dark: #2f3640; 33 | 34 | $color-white-light: #f5f6fa; 35 | $color-white-dark: #dcdde1; 36 | 37 | $base-font-size: 13px; 38 | $button-font-size: 12px; 39 | $base-font-family: Lato, BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, 40 | Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", 41 | Helvetica, Arial, sans-serif; 42 | $base-box-shadow: 0 0 5px 5px rgba(0, 0, 0, 0.5); 43 | -------------------------------------------------------------------------------- /client/components/MockEditor/MockContextEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Form, InputNumber, Switch } from "antd"; 3 | 4 | export const MockContextEditor = (): JSX.Element => ( 5 |
6 | 7 | 8 | 9 | 10 | prevValues?.context?.times_enabled !== 13 | currentValues?.context?.times_enabled || 14 | prevValues?.context?.times !== currentValues?.context?.times} 15 | style={{ marginBottom: 0, paddingLeft: "5px" }} 16 | > 17 | {({ getFieldValue }) => ( 18 | <> 19 | 20 | 23 | 24 | {getFieldValue(["context", "times"]) <= 1 ? ( 25 | time 26 | ) : ( 27 | times 28 | )} 29 | 30 | )} 31 | 32 |
33 | ); 34 | -------------------------------------------------------------------------------- /tests/features/0_persistence.yml: -------------------------------------------------------------------------------- 1 | name: Persistance 2 | version: "2" 3 | testcases: 4 | - name: Check Mocks 5 | steps: 6 | - type: http 7 | method: GET 8 | url: http://localhost:8081/mocks 9 | assertions: 10 | - result.statuscode ShouldEqual 200 11 | - result.bodyjson.__len__ ShouldEqual 1 12 | - result.bodyjson.bodyjson0.state.id ShouldEqual YnJEM95SR 13 | - name: Check History 14 | steps: 15 | - type: http 16 | method: GET 17 | url: http://localhost:8081/history 18 | assertions: 19 | - result.statuscode ShouldEqual 200 20 | - result.bodyjson.__len__ ShouldEqual 1 21 | - result.bodyjson.bodyjson0.context.mock_id ShouldEqual YnJEM95SR 22 | - name: Check Sessions 23 | steps: 24 | - type: http 25 | method: GET 26 | url: http://localhost:8081/sessions 27 | assertions: 28 | - result.statuscode ShouldEqual 200 29 | - result.bodyjson.__len__ ShouldEqual 1 30 | - result.bodyjson.bodyjson0.id ShouldEqual 3giPMr5IR 31 | - result.bodyjson.bodyjson0.history.__len__ ShouldEqual 1 32 | - result.bodyjson.bodyjson0.mocks.__len__ ShouldEqual 1 -------------------------------------------------------------------------------- /client/components/MockEditor/MockProxyResponseEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Col, Form, 4 | Input, Row, Switch 5 | } from "antd"; 6 | import { KeyValueEditor } from "./KeyValueEditor"; 7 | 8 | export const MockProxyResponseEditor = (): JSX.Element => ( 9 | 10 | 11 | 12 | 13 | 14 | Additional Headers: 15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 32 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | ); 44 | -------------------------------------------------------------------------------- /tests/data/proxy_mock_list.yml: -------------------------------------------------------------------------------- 1 | - request: 2 | path: /posts/1 3 | proxy: 4 | host: https://jsonplaceholder.typicode.com 5 | - request: 6 | path: /todos 7 | query_params: 8 | userId: "1" 9 | proxy: 10 | host: https://jsonplaceholder.typicode.com 11 | - request: 12 | path: /redirect-to 13 | proxy: 14 | host: https://httpbin.org 15 | - request: 16 | path: /redirect-to 17 | headers: 18 | X-Follow-Redirect: "true" 19 | proxy: 20 | host: https://httpbin.org 21 | follow_redirect: true 22 | - request: 23 | path: /get 24 | proxy: 25 | host: https://httpbin.org 26 | - request: 27 | path: /get 28 | headers: 29 | X-Keep-Host: "true" 30 | proxy: 31 | host: https://httpbin.org 32 | keep_host: true 33 | - request: 34 | path: /headers 35 | proxy: 36 | host: https://httpbin.org 37 | headers: 38 | custom: "foobar" 39 | multi: 40 | - "foo" 41 | - "baz" 42 | - request: 43 | method: GET 44 | headers: 45 | X-Filter: badssl 46 | X-Value: insecure 47 | proxy: 48 | host: https://self-signed.badssl.com 49 | skip_verify_tls: true 50 | - request: 51 | method: GET 52 | headers: 53 | X-Filter: badssl 54 | X-Value: secure 55 | proxy: 56 | host: https://self-signed.badssl.com 57 | skip_verify_tls: false 58 | -------------------------------------------------------------------------------- /client/components/Visualize.scss: -------------------------------------------------------------------------------- 1 | @import "../variables.scss"; 2 | 3 | #root .visualize { 4 | padding: 1em 8em 0; 5 | 6 | @media screen and (max-width: 1200px) { 7 | padding: 1em 2em 0; 8 | } 9 | 10 | .no-margin { 11 | margin: 0; 12 | } 13 | 14 | .collapse { 15 | margin-bottom: 1em; 16 | font-size: 12px; 17 | &, 18 | .collapse-panel { 19 | border: none; 20 | background-color: transparent; 21 | .ant-row.ant-form-item { 22 | margin-right: 0.5em; 23 | margin-bottom: 0; 24 | } 25 | } 26 | } 27 | 28 | .container { 29 | position: relative; 30 | display: block; 31 | 32 | .absolute { 33 | position: absolute; 34 | bottom: 0; 35 | right: 1em; 36 | } 37 | } 38 | 39 | .action.buttons { 40 | button { 41 | margin-left: 0.5em; 42 | } 43 | } 44 | 45 | .drawer { 46 | .ant-drawer-body { 47 | overflow-y: auto; 48 | .form { 49 | background-color: #263238; 50 | border-radius: 3px; 51 | height: 100%; 52 | .CodeMirror { 53 | position: relative; 54 | height: auto; 55 | overflow-y: hidden; 56 | padding: 0.5em 0 0.5em 0.5em; 57 | margin: 0; 58 | border-radius: 3px; 59 | 60 | .CodeMirror-scroll { 61 | height: auto; 62 | } 63 | } 64 | } 65 | } 66 | .action.buttons { 67 | text-align: right; 68 | button { 69 | margin-left: 0.5em; 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/handlers/utils.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/labstack/echo/v4" 8 | log "github.com/sirupsen/logrus" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | const MIMEApplicationXYaml = "application/x-yaml" 13 | 14 | func bindAccordingAccept(c echo.Context, res interface{}) error { 15 | if err := c.Bind(res); err != nil { 16 | if err != echo.ErrUnsupportedMediaType { 17 | log.WithError(err).Error("Failed to parse payload") 18 | return err 19 | } 20 | 21 | // echo doesn't support YAML yet 22 | req := c.Request() 23 | contentType := req.Header.Get(echo.HeaderContentType) 24 | if contentType == "" || strings.Contains(strings.ToLower(contentType), MIMEApplicationXYaml) { 25 | if err := yaml.NewDecoder(req.Body).Decode(res); err != nil { 26 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 27 | } 28 | } else { 29 | return echo.NewHTTPError(http.StatusUnsupportedMediaType, err.Error()) 30 | } 31 | } 32 | return nil 33 | } 34 | 35 | func respondAccordingAccept(c echo.Context, body interface{}) error { 36 | accept := c.Request().Header.Get(echo.HeaderAccept) 37 | if strings.Contains(strings.ToLower(accept), MIMEApplicationXYaml) { 38 | c.Response().Header().Set(echo.HeaderContentType, MIMEApplicationXYaml) 39 | c.Response().WriteHeader(http.StatusOK) 40 | 41 | out, err := yaml.Marshal(body) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | _, err = c.Response().Write(out) 47 | return err 48 | } 49 | return c.JSONPretty(http.StatusOK, body, " ") 50 | } 51 | -------------------------------------------------------------------------------- /client/components/Sidebar.scss: -------------------------------------------------------------------------------- 1 | #root .sidebar { 2 | height: 100%; 3 | border-right: 1px solid rgba(0, 0, 0, 0.125); 4 | 5 | @media screen and (max-width: 1200px) { 6 | position: absolute; 7 | z-index: 5; 8 | } 9 | 10 | .ant-layout-sider-zero-width-trigger { 11 | top: 0; 12 | border-right: 1px solid rgba(0, 0, 0, 0.125); 13 | border-bottom: 1px solid rgba(0, 0, 0, 0.125); 14 | border-top-right-radius: 0; 15 | border-top-left-radius: 0; 16 | } 17 | 18 | .menu { 19 | height: 100%; 20 | border-right: 0; 21 | 22 | label { 23 | input[type="file"] { 24 | display: none; 25 | } 26 | a { 27 | margin-right: 0.5em; 28 | } 29 | } 30 | 31 | .group { 32 | height: calc(100% - 3.5em); 33 | .ant-menu-item-group-title { 34 | padding-top: 1em; 35 | .ant-spin .ant-spin-dot { 36 | top: 50%; 37 | left: 100%; 38 | margin: -15px; 39 | } 40 | } 41 | .ant-menu-item-group-list { 42 | height: calc(100% - 3.5em); 43 | overflow-x: hidden; 44 | overflow-y: auto; 45 | padding-right: 1px; 46 | .session-name { 47 | max-width: 80%; 48 | } 49 | .session-button { 50 | width: 100%; 51 | border-style: dashed; 52 | } 53 | } 54 | } 55 | .menu-button { 56 | padding: 0 24px; 57 | .ant-btn > .anticon + span { 58 | margin-left: 0; 59 | } 60 | } 61 | .reset-button { 62 | width: 100%; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/components/Mermaid.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Spin } from "antd"; 2 | import mermaidAPI from "mermaid"; 3 | import * as React from "react"; 4 | import "./Mermaid.scss"; 5 | 6 | export const Mermaid = ({ 7 | name, 8 | chart, 9 | loading, 10 | onChange, 11 | }: { 12 | name: string; 13 | chart: string; 14 | loading?: boolean; 15 | onChange?: (svg: string) => unknown; 16 | }): JSX.Element => { 17 | const [diagram, setDiagram] = React.useState(""); 18 | const [error, setError] = React.useState(""); 19 | const [spinner, setSpinner] = React.useState(Boolean(loading)); 20 | 21 | React.useEffect(() => { 22 | setSpinner(true); 23 | const cb = (svg = "") => { 24 | setSpinner(false); 25 | setDiagram(svg); 26 | setError(""); 27 | onChange && onChange(svg); 28 | }; 29 | setTimeout(() => { 30 | try { 31 | mermaidAPI.parse(chart); 32 | mermaidAPI.initialize({ startOnLoad: false }); 33 | mermaidAPI.render(name, chart, cb); 34 | } catch (e) { 35 | setDiagram(""); 36 | console.error(e); 37 | setError(e.str || `${e}`); 38 | } 39 | }, 1); 40 | }, [name, chart]); 41 | 42 | return ( 43 | 44 |
45 |
46 | {error && ( 47 | 53 | )} 54 |
55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /tests/data/basic_mock_list.yml: -------------------------------------------------------------------------------- 1 | - request: 2 | path: /test 3 | response: 4 | headers: 5 | Content-Type: application/json 6 | body: > 7 | {"message": "test"} 8 | 9 | - request: 10 | path: /encoded%2Fpath 11 | response: 12 | headers: 13 | Content-Type: application/json 14 | body: > 15 | {"message": "encoded path"} 16 | 17 | - request: 18 | path: /test 19 | method: POST 20 | response: 21 | headers: 22 | Content-Type: application/json 23 | body: > 24 | {"message": "test2"} 25 | delay: 10ms 26 | 27 | - request: 28 | path: /test 29 | method: DELETE 30 | response: 31 | headers: 32 | Content-Type: application/json 33 | body: > 34 | {"message": "test3"} 35 | delay: 36 | min: 0ms 37 | max: 10ms 38 | 39 | - request: 40 | path: /test 41 | query_params: 42 | foo: [bar, baz] 43 | response: 44 | headers: 45 | Content-Type: application/json 46 | body: > 47 | {"message": "test4"} 48 | 49 | - request: 50 | path: /test 51 | headers: 52 | X-Custom-Header: bar 53 | response: 54 | headers: 55 | Content-Type: application/json 56 | body: > 57 | {"message": "test5"} 58 | 59 | - request: 60 | path: /test 61 | headers: 62 | X-Custom-Header-1: bar 63 | X-Custom-Header-2: [foo, bar] 64 | X-Custom-Header-3: 65 | - foo 66 | - matcher: ShouldEqual 67 | value: bar 68 | - matcher: ShouldMatch 69 | value: baz.* 70 | response: 71 | headers: 72 | Content-Type: application/json 73 | body: > 74 | {"message": "test6"} 75 | -------------------------------------------------------------------------------- /client/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Layout, Menu, Row } from "antd"; 2 | import * as React from "react"; 3 | import { Link, useLocation } from "react-router-dom"; 4 | import Logo from "../assets/logo180.png"; 5 | import { cleanQueryParams } from "../modules/utils"; 6 | import "./Navbar.scss"; 7 | 8 | const Navbar = (): JSX.Element => { 9 | const location = useLocation(); 10 | return ( 11 | 12 | 13 | 14 | 15 | Smocker 16 | 17 | 24 | 25 | ({ 27 | ...cleanQueryParams(loc), 28 | pathname: "/pages/history", 29 | })} 30 | > 31 | History 32 | 33 | 34 | 35 | ({ 37 | ...cleanQueryParams(loc), 38 | pathname: "/pages/mocks", 39 | })} 40 | > 41 | Mocks 42 | 43 | 44 | 45 | 46 | Documentation 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default Navbar; 56 | -------------------------------------------------------------------------------- /server/types/sessions.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | var SessionNotFound = fmt.Errorf("session not found") 9 | 10 | type Sessions []*Session 11 | 12 | func (s Sessions) Clone() Sessions { 13 | sessions := make(Sessions, 0, len(s)) 14 | for _, session := range s { 15 | sessions = append(sessions, session.Clone()) 16 | } 17 | return sessions 18 | } 19 | 20 | func (s Sessions) Summarize() []SessionSummary { 21 | sessions := make([]SessionSummary, 0, len(s)) 22 | for _, session := range s { 23 | sessions = append(sessions, session.Summarize()) 24 | } 25 | return sessions 26 | } 27 | 28 | type Session struct { 29 | ID string `json:"id"` 30 | Name string `json:"name"` 31 | Date time.Time `json:"date"` 32 | History History `json:"history"` 33 | Mocks Mocks `json:"mocks"` 34 | } 35 | 36 | func (s *Session) Clone() *Session { 37 | return &Session{ 38 | ID: s.ID, 39 | Name: s.Name, 40 | Date: s.Date, 41 | History: s.History.Clone(), 42 | Mocks: s.Mocks.Clone(), 43 | } 44 | } 45 | 46 | func (s Session) Summarize() SessionSummary { 47 | return SessionSummary(s) 48 | } 49 | 50 | type SessionSummary struct { 51 | ID string `json:"id"` 52 | Name string `json:"name"` 53 | Date time.Time `json:"date"` 54 | History History `json:"-" yaml:"-"` 55 | Mocks Mocks `json:"-" yaml:"-"` 56 | } 57 | 58 | type VerifyResult struct { 59 | Mocks struct { 60 | Verified bool `json:"verified"` 61 | AllUsed bool `json:"all_used"` 62 | Message string `json:"message"` 63 | Failures Mocks `json:"failures,omitempty"` 64 | Unused Mocks `json:"unused,omitempty"` 65 | } `json:"mocks"` 66 | History struct { 67 | Verified bool `json:"verified"` 68 | Message string `json:"message"` 69 | Failures History `json:"failures,omitempty"` 70 | } `json:"history"` 71 | } 72 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/smocker-dev/smocker 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.2.2 7 | github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect 8 | github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect 9 | github.com/facebookgo/freeport v0.0.0-20150612182905-d4adf43b75b9 // indirect 10 | github.com/facebookgo/grace v0.0.0-20180706040059-75cf19382434 11 | github.com/facebookgo/httpdown v0.0.0-20180706035922-5979d39b15c2 // indirect 12 | github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect 13 | github.com/facebookgo/stats v0.0.0-20151006221625-1b76add642e4 // indirect 14 | github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect 15 | github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect 16 | github.com/kr/pretty v0.1.0 // indirect 17 | github.com/labstack/echo/v4 v4.7.0 18 | github.com/layeh/gopher-json v0.0.0-20190114024228-97fed8db8427 19 | github.com/mattn/go-colorable v0.1.12 // indirect 20 | github.com/mitchellh/mapstructure v1.1.2 // indirect 21 | github.com/mitchellh/reflectwalk v1.0.1 // indirect 22 | github.com/namsral/flag v1.7.4-pre 23 | github.com/sirupsen/logrus v1.4.2 24 | github.com/smartystreets/assertions v1.0.1 25 | github.com/stretchr/objx v0.2.0 26 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf 27 | github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 28 | github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb 29 | golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 // indirect 30 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect 31 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a 32 | golang.org/x/sys v0.0.0-20220307203707-22a9840ba4d7 // indirect 33 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 34 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b 35 | layeh.com/gopher-luar v1.0.7 36 | ) 37 | -------------------------------------------------------------------------------- /client/components/MockEditor/MockStaticResponseEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Col, Form, InputNumber, 4 | Radio, 5 | RadioChangeEvent, 6 | Row 7 | } from "antd"; 8 | import { getReasonPhrase } from "http-status-codes"; 9 | import Code, { Language } from "../Code"; 10 | import { defaultResponseStatus } from "./utils"; 11 | import { KeyValueEditor } from "./KeyValueEditor"; 12 | 13 | export const MockStaticResponseEditor = (): JSX.Element => { 14 | const [bodyLanguage, setBodyLanguage] = React.useState("json"); 15 | const [responseStatus, setResponseStatus] = React.useState( 16 | defaultResponseStatus 17 | ); 18 | 19 | const responseStatusText = () => { 20 | try { 21 | return getReasonPhrase(responseStatus); 22 | } catch { 23 | return "Unknown"; 24 | } 25 | }; 26 | 27 | const languages = [ 28 | { label: "JSON", value: "json" }, 29 | { label: "YAML", value: "yaml" }, 30 | { label: "XML", value: "xml" }, 31 | { label: "Plain Text", value: "txt" }, 32 | ]; 33 | 34 | return ( 35 | <> 36 | 37 | 38 | { 42 | setResponseStatus(typeof value === "number" ? value : NaN); 43 | }} /> 44 | 45 | {responseStatusText()} 46 | 47 | 48 | 49 | 50 | Headers: 51 | 52 | 53 | 54 | 55 | 56 | setBodyLanguage(e.target.value)} 60 | optionType="button" 61 | buttonStyle="solid" 62 | size="small" 63 | style={{ marginBottom: 5 }} /> 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /client/components/MockEditor/BodyMatcherEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button, Col, Form, Row } from "antd"; 3 | import Code from "../Code"; 4 | import { bodyMatcherToPaths } from "../../modules/utils"; 5 | import { KeyValueEditorEngine } from "./KeyValueEditor"; 6 | 7 | interface BodyMatcherEditorProps { 8 | name: string[]; 9 | } 10 | 11 | export const BodyMatcherEditor = ({ 12 | name, 13 | }: BodyMatcherEditorProps): JSX.Element => { 14 | const [initialized, setInitialized] = React.useState(false); 15 | const [rawJSON, setRawJSON] = React.useState(""); 16 | 17 | return ( 18 | 19 | {(fields, actions) => 20 | !initialized ? ( 21 | <> 22 |

23 | Please paste a JSON payload below in order to generate the 24 | corresponding body matcher. For better results, only keep the JSON 25 | fields you want to match upon. 26 |

27 | setRawJSON(value)} 31 | /> 32 | 33 | 34 | 52 | 53 | 54 | 55 | ) : ( 56 | 62 | ) 63 | } 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /tests/data/matcher_mock_list.yml: -------------------------------------------------------------------------------- 1 | - request: 2 | method: GET 3 | path: 4 | matcher: ShouldMatch 5 | value: /.* 6 | response: 7 | headers: 8 | Content-Type: application/json 9 | body: > 10 | {"message": "test"} 11 | - request: 12 | path: /test 13 | method: 14 | matcher: ShouldContainSubstring 15 | value: PO 16 | response: 17 | headers: 18 | Content-Type: application/json 19 | body: > 20 | {"message": "test2"} 21 | - request: 22 | path: /test 23 | method: DELETE 24 | body: 25 | matcher: ShouldEqualJSON 26 | value: > 27 | {"id": 1} 28 | response: 29 | headers: 30 | Content-Type: application/json 31 | body: > 32 | {"message": "test3"} 33 | - request: 34 | path: /test 35 | method: PUT 36 | headers: 37 | Content-Type: 38 | - matcher: ShouldMatch 39 | value: "application/.*" 40 | response: 41 | headers: 42 | Content-Type: application/json 43 | body: > 44 | {"message": "test4"} 45 | - request: 46 | path: /test 47 | method: PATCH 48 | query_params: 49 | # It will be factorized in smocker as 50 | # query_params: 51 | # test: ["true"] 52 | # because 'ShouldEqual' is the default matcher 53 | test: 54 | - matcher: ShouldEqual 55 | value: "true" 56 | test2: 57 | - matcher: ShouldContainSubstring 58 | value: "test3" 59 | response: 60 | headers: 61 | Content-Type: application/json 62 | body: > 63 | {"message": "test5"} 64 | - request: 65 | path: /test6 66 | body: 67 | matcher: ShouldNotBeEmpty 68 | response: 69 | body: > 70 | {"message": "test7"} 71 | - request: 72 | path: /test8 73 | body: 74 | matcher: ShouldNotBeEmpty 75 | query_params: 76 | test: 77 | matcher: ShouldContainSubstring 78 | value: "true" 79 | response: 80 | body: > 81 | {"message": "test9"} 82 | - request: 83 | path: /test10 84 | body: 85 | key1: test 86 | response: 87 | body: > 88 | {"message": "test11"} 89 | - request: 90 | path: /test12 91 | headers: 92 | Content-Type: application/x-www-form-urlencoded 93 | body: 94 | key1[0]: test 95 | response: 96 | body: > 97 | {"message": "test13"} 98 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/namsral/flag" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/smocker-dev/smocker/server" 9 | "github.com/smocker-dev/smocker/server/config" 10 | ) 11 | 12 | var appName, buildVersion, buildCommit, buildDate string // nolint 13 | 14 | func parseConfig() (c config.Config) { 15 | c.Build = config.Build{ 16 | AppName: appName, 17 | BuildVersion: buildVersion, 18 | BuildCommit: buildCommit, 19 | BuildDate: buildDate, 20 | } 21 | 22 | // Use a prefix for environment variables 23 | flag.CommandLine = flag.NewFlagSetWithEnvPrefix(os.Args[0], "SMOCKER", flag.ExitOnError) 24 | 25 | flag.StringVar(&c.LogLevel, "log-level", "info", "Available levels: panic, fatal, error, warning, info, debug, trace") 26 | flag.StringVar(&c.ConfigBasePath, "config-base-path", "/", "Base path applied to Smocker UI") 27 | flag.IntVar(&c.ConfigListenPort, "config-listen-port", 8081, "Listening port of Smocker administration server") 28 | flag.IntVar(&c.MockServerListenPort, "mock-server-listen-port", 8080, "Listening port of Smocker mock server") 29 | flag.StringVar(&c.StaticFiles, "static-files", "client", "Location of the static files to serve (index.html, etc.)") 30 | flag.IntVar(&c.HistoryMaxRetention, "history-retention", 0, "Maximum number of calls to keep in the history per session (0 = no limit)") 31 | flag.StringVar(&c.PersistenceDirectory, "persistence-directory", "", "If defined, the directory where the sessions will be synchronized") 32 | flag.BoolVar(&c.TLSEnable, "tls-enable", false, "Enable TLS using the provided certificate") 33 | flag.StringVar(&c.TLSCertFile, "tls-cert-file", "/etc/smocker/tls/certs/cert.pem", "Path to TLS certificate file ") 34 | flag.StringVar(&c.TLSKeyFile, "tls-private-key-file", "/etc/smocker/tls/private/key.pem", "Path to TLS key file") 35 | flag.Parse() 36 | return 37 | } 38 | 39 | func setupLogger(logLevel string) { 40 | log.SetFormatter(&log.TextFormatter{ 41 | FullTimestamp: true, 42 | QuoteEmptyFields: true, 43 | }) 44 | 45 | level, err := log.ParseLevel(logLevel) 46 | if err != nil { 47 | log.WithError(err).WithField("log-level", level).Warn("Invalid log level, fallback to info") 48 | level = log.InfoLevel 49 | } 50 | log.WithField("log-level", level).Info("Setting log level") 51 | log.SetLevel(level) 52 | } 53 | 54 | func main() { 55 | c := parseConfig() 56 | setupLogger(c.LogLevel) 57 | server.Serve(c) 58 | } 59 | -------------------------------------------------------------------------------- /tests/data/dynamic_mock_list.yml: -------------------------------------------------------------------------------- 1 | - # Lua mock with body as string 2 | request: 3 | path: /test 4 | dynamic_response: 5 | engine: lua 6 | script: > 7 | return { 8 | body = '{"message":"request path '..request.path..'"}', 9 | headers = { 10 | ["Content-Type"] = "application/json" 11 | }, 12 | delay = { 13 | min = "0", 14 | max = "10ms" 15 | } 16 | } 17 | 18 | - # Lua mock with body as Table 19 | request: 20 | path: /test2 21 | dynamic_response: 22 | engine: lua 23 | script: > 24 | local name; 25 | if request.query_params ~= nil then 26 | name = request.query_params.name[1] 27 | end 28 | return { 29 | body = { 30 | message = "request path "..request.path, 31 | name = name, 32 | }, 33 | headers = { 34 | ["Content-Type"] = "application/json" 35 | }, 36 | delay = "10ms" 37 | } 38 | 39 | - # Go Template Yaml mock by default on go_template engine 40 | request: 41 | path: /test3 42 | dynamic_response: 43 | engine: go_template 44 | script: > 45 | headers: 46 | Content-Type: application/json 47 | body: > 48 | { 49 | "message": "request path {{.Request.Path}}" 50 | } 51 | 52 | - # Go Template Yaml mock 53 | request: 54 | path: /test4 55 | dynamic_response: 56 | engine: go_template_yaml 57 | script: > 58 | headers: 59 | Content-Type: [application/json] 60 | body: > 61 | { 62 | "message": "request path {{.Request.Path}}" 63 | } 64 | 65 | - # Go Template Json mock with body as string 66 | request: 67 | path: /test5 68 | dynamic_response: 69 | engine: go_template_json 70 | script: | 71 | { 72 | "body": "{\"message\": \"request path {{.Request.Path}}\"}", 73 | "headers": { 74 | "Content-Type": ["application/json"] 75 | }, 76 | "delay": "1ms" 77 | } 78 | 79 | - # Go Template Json mock with body as json 80 | request: 81 | path: /test6 82 | dynamic_response: 83 | engine: go_template_json 84 | script: > 85 | { 86 | "body": { 87 | "message": "request path {{.Request.Path}}" 88 | }, 89 | "headers": { 90 | "Content-Type": ["application/json"] 91 | }, 92 | "delay": { 93 | "min": "0", 94 | "max": "10ms" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { GithubFilled, ReadOutlined } from "@ant-design/icons"; 2 | import { Layout } from "antd"; 3 | import * as React from "react"; 4 | import { hot } from "react-hot-loader"; 5 | import { Provider } from "react-redux"; 6 | import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; 7 | import { applyMiddleware, createStore } from "redux"; 8 | import { composeWithDevTools } from "redux-devtools-extension"; 9 | import { createEpicMiddleware } from "redux-observable"; 10 | import { Actions } from "../modules/actions"; 11 | import rootEpic from "../modules/epics"; 12 | import rootReducer from "../modules/reducers"; 13 | import "./App.scss"; 14 | import History from "./History"; 15 | import Mocks from "./Mocks"; 16 | import Navbar from "./Navbar"; 17 | import Sidebar from "./Sidebar"; 18 | import Visualize from "./Visualize"; 19 | 20 | const epicMiddleware = createEpicMiddleware(); 21 | 22 | const store = createStore( 23 | rootReducer, 24 | composeWithDevTools(applyMiddleware(epicMiddleware)) 25 | ); 26 | 27 | epicMiddleware.run(rootEpic); 28 | 29 | const App = () => ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | Smocker version {window.version} – MIT Licensed 48 |
49 | 55 | 56 | 57 |   58 | 64 | 65 | 66 |
67 |
68 |
69 |
70 |
71 |
72 | ); 73 | export default hot(module)(App); 74 | -------------------------------------------------------------------------------- /tests/features/use_history.yml: -------------------------------------------------------------------------------- 1 | name: Retrieve calls history 2 | version: "2" 3 | testcases: 4 | - name: Load dynamic mocks, call them and check that history is right 5 | steps: 6 | - type: http 7 | method: POST 8 | url: http://localhost:8081/reset 9 | - type: http 10 | method: POST 11 | url: http://localhost:8081/mocks 12 | bodyFile: ../data/dynamic_mock_list.yml 13 | assertions: 14 | - result.statuscode ShouldEqual 200 15 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 16 | - type: http 17 | method: GET 18 | url: http://localhost:8080/test 19 | assertions: 20 | - result.statuscode ShouldEqual 200 21 | - type: http 22 | method: GET 23 | url: http://localhost:8080/test2 24 | assertions: 25 | - result.statuscode ShouldEqual 200 26 | - type: http 27 | method: GET 28 | url: http://localhost:8080/test3 29 | assertions: 30 | - result.statuscode ShouldEqual 200 31 | - type: http 32 | method: GET 33 | url: http://localhost:8081/history 34 | assertions: 35 | - result.statuscode ShouldEqual 200 36 | - result.bodyjson.__len__ ShouldEqual 3 37 | - result.bodyjson.bodyjson0.request.path ShouldEqual /test 38 | - result.bodyjson.bodyjson0.response.body.message ShouldEqual "request path /test" 39 | - result.bodyjson.bodyjson1.request.path ShouldEqual /test2 40 | - result.bodyjson.bodyjson1.response.body.message ShouldEqual "request path /test2" 41 | - result.bodyjson.bodyjson2.request.path ShouldEqual /test3 42 | - result.bodyjson.bodyjson2.response.body.message ShouldEqual "request path /test3" 43 | - type: http 44 | method: GET 45 | url: http://localhost:8081/history?filter=/test2 46 | assertions: 47 | - result.statuscode ShouldEqual 200 48 | - result.bodyjson.__len__ ShouldEqual 1 49 | - result.bodyjson.bodyjson0.request.path ShouldEqual /test2 50 | - result.bodyjson.bodyjson0.response.body.message ShouldEqual "request path /test2" 51 | 52 | - type: http 53 | method: GET 54 | url: http://localhost:8081/history/summary 55 | assertions: 56 | - result.statuscode ShouldEqual 200 57 | - result.bodyjson.__len__ ShouldEqual 9 58 | - result.bodyjson.bodyjson0.type ShouldEqual request 59 | - result.bodyjson.bodyjson0.from ShouldEqual Client 60 | - result.bodyjson.bodyjson0.to ShouldEqual Smocker 61 | - result.bodyjson.bodyjson1.type ShouldEqual processing 62 | - result.bodyjson.bodyjson1.from ShouldEqual Smocker 63 | - result.bodyjson.bodyjson1.to ShouldEqual Smocker 64 | - result.bodyjson.bodyjson2.type ShouldEqual response 65 | - result.bodyjson.bodyjson2.from ShouldEqual Smocker 66 | - result.bodyjson.bodyjson2.to ShouldEqual Client 67 | -------------------------------------------------------------------------------- /client/modules/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, createAction, createAsyncAction } from "typesafe-actions"; 2 | import { 3 | GraphHistory, 4 | History, 5 | Mocks, 6 | Session, 7 | Sessions, 8 | SmockerError, 9 | } from "./types"; 10 | 11 | const fetchSessions = createAsyncAction( 12 | "@APP/SESSIONS/FETCH", 13 | "@APP/SESSIONS/FETCH/SUCCESS", 14 | "@APP/SESSIONS/FETCH/FAILURE" 15 | )(); 16 | 17 | const newSession = createAsyncAction( 18 | "@APP/SESSIONS/NEW", 19 | "@APP/SESSIONS/NEW/SUCCESS", 20 | "@APP/SESSIONS/NEW/FAILURE" 21 | )(); 22 | 23 | const updateSession = createAsyncAction( 24 | "@APP/SESSIONS/UPDATE", 25 | "@APP/SESSIONS/UPDATE/SUCCESS", 26 | "@APP/SESSIONS/UPDATE/FAILURE" 27 | )(); 28 | 29 | const uploadSessions = createAsyncAction( 30 | "@APP/SESSIONS/UPLOAD", 31 | "@APP/SESSIONS/UPLOAD/SUCCESS", 32 | "@APP/SESSIONS/UPLOAD/FAILURE" 33 | )(); 34 | 35 | const selectSession = createAction("@APP/SESSIONS/SELECT")(); 36 | 37 | const openMockEditor = createAction("@APP/MOCKEDITOR/OPEN")< 38 | [boolean, string] 39 | >(); 40 | 41 | const fetchHistory = createAsyncAction( 42 | "@APP/HISTORY/FETCH", 43 | "@APP/HISTORY/FETCH/SUCCESS", 44 | "@APP/HISTORY/FETCH/FAILURE" 45 | )(); 46 | 47 | const summarizeHistory = createAsyncAction( 48 | "@APP/HISTORY/SUMMARIZE", 49 | "@APP/HISTORY/SUMMARIZE/SUCCESS", 50 | "@APP/HISTORY/SUMMARIZE/FAILURE" 51 | )< 52 | { sessionID: string; src: string; dest: string }, 53 | GraphHistory, 54 | SmockerError 55 | >(); 56 | 57 | const fetchMocks = createAsyncAction( 58 | "@APP/MOCKS/FETCH", 59 | "@APP/MOCKS/FETCH/SUCCESS", 60 | "@APP/MOCKS/FETCH/FAILURE" 61 | )(); 62 | 63 | export interface NewMocks { 64 | mocks: string; 65 | } 66 | 67 | const addMocks = createAsyncAction( 68 | "@APP/MOCKS/ADD", 69 | "@APP/MOCKS/ADD/SUCCESS", 70 | "@APP/MOCKS/ADD/FAILURE" 71 | )(); 72 | 73 | const lockMocks = createAsyncAction( 74 | "@APP/MOCKS/LOCK", 75 | "@APP/MOCKS/LOCK/SUCCESS", 76 | "@APP/MOCKS/LOCK/FAILURE" 77 | )(); 78 | 79 | const unlockMocks = createAsyncAction( 80 | "@APP/MOCKS/UNLOCK", 81 | "@APP/MOCKS/UNLOCK/SUCCESS", 82 | "@APP/MOCKS/UNLOCK/FAILURE" 83 | )(); 84 | 85 | const reset = createAsyncAction( 86 | "@APP/RESET", 87 | "@APP/RESET/SUCCESS", 88 | "@APP/RESET/FAILURE" 89 | )(); 90 | 91 | export const actions = { 92 | fetchSessions, 93 | newSession, 94 | updateSession, 95 | selectSession, 96 | openMockEditor, 97 | uploadSessions, 98 | fetchHistory, 99 | summarizeHistory, 100 | fetchMocks, 101 | addMocks, 102 | lockMocks, 103 | unlockMocks, 104 | reset, 105 | }; 106 | 107 | export type Actions = ActionType; 108 | -------------------------------------------------------------------------------- /server/templates/go_template.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "text/template" 8 | 9 | "github.com/Masterminds/sprig/v3" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/smocker-dev/smocker/server/types" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | type goTemplateYamlEngine struct{} 16 | 17 | func NewGoTemplateYamlEngine() TemplateEngine { 18 | return &goTemplateYamlEngine{} 19 | } 20 | 21 | func (*goTemplateYamlEngine) Execute(request types.Request, script string) (*types.MockResponse, error) { 22 | tmpl, err := template.New("engine").Funcs(sprig.TxtFuncMap()).Parse(script) 23 | if err != nil { 24 | log.WithError(err).Error("Failed to parse dynamic template") 25 | return nil, fmt.Errorf("failed to parse dynamic template: %w", err) 26 | } 27 | 28 | buffer := new(bytes.Buffer) 29 | if err = tmpl.Execute(buffer, map[string]interface{}{"Request": request}); err != nil { 30 | log.WithError(err).Error("Failed to execute dynamic template") 31 | return nil, fmt.Errorf("failed to execute dynamic template: %w", err) 32 | } 33 | 34 | var result types.MockResponse 35 | if err = yaml.Unmarshal(buffer.Bytes(), &result); err != nil { 36 | log.WithError(err).Error("Failed to unmarshal response as mock response") 37 | } 38 | 39 | return &result, nil 40 | } 41 | 42 | type goTemplateJsonEngine struct{} 43 | 44 | func NewGoTemplateJsonEngine() TemplateEngine { 45 | return &goTemplateJsonEngine{} 46 | } 47 | 48 | func (*goTemplateJsonEngine) Execute(request types.Request, script string) (*types.MockResponse, error) { 49 | tmpl, err := template.New("engine").Funcs(sprig.TxtFuncMap()).Parse(script) 50 | if err != nil { 51 | log.WithError(err).Error("Failed to parse dynamic template") 52 | return nil, fmt.Errorf("failed to parse dynamic template: %w", err) 53 | } 54 | 55 | buffer := new(bytes.Buffer) 56 | if err = tmpl.Execute(buffer, map[string]interface{}{"Request": request}); err != nil { 57 | log.WithError(err).Error("Failed to execute dynamic template") 58 | return nil, fmt.Errorf("failed to execute dynamic template: %w", err) 59 | } 60 | 61 | var tmplResult map[string]interface{} 62 | if err = json.Unmarshal(buffer.Bytes(), &tmplResult); err != nil { 63 | log.WithError(err).Error("Failed to unmarshal response from dynamic template") 64 | return nil, fmt.Errorf("failed to unmarshal response from dynamic template: %w", err) 65 | } 66 | 67 | body := tmplResult["body"] 68 | if _, ok := body.(string); !ok { 69 | b, err := json.Marshal(body) 70 | if err != nil { 71 | log.WithError(err).Error("Failed to marshal response body as JSON") 72 | return nil, fmt.Errorf("failed to marshal response body as JSON: %w", err) 73 | } 74 | tmplResult["body"] = string(b) 75 | } 76 | 77 | b, err := json.Marshal(tmplResult) 78 | if err != nil { 79 | log.WithError(err).Error("Failed to marshal template result as JSON") 80 | } 81 | 82 | var result types.MockResponse 83 | if err = json.Unmarshal(b, &result); err != nil { 84 | log.WithError(err).Error("Failed to unmarshal response as mock response") 85 | } 86 | 87 | return &result, nil 88 | } 89 | -------------------------------------------------------------------------------- /client/components/History.scss: -------------------------------------------------------------------------------- 1 | @import "../variables.scss"; 2 | 3 | #root .history { 4 | padding: 1em 5% 0; 5 | 6 | @media screen and (max-width: 1200px) { 7 | padding: 1em 2em 0; 8 | } 9 | 10 | .container { 11 | position: relative; 12 | 13 | .absolute { 14 | position: absolute; 15 | bottom: 0; 16 | right: 1em; 17 | } 18 | } 19 | 20 | .action.buttons { 21 | button { 22 | margin-left: 0.5em; 23 | } 24 | } 25 | 26 | // These rules are used for the inline filters (sort by, filter, etc.) 27 | .ant-btn-link { 28 | padding: 6px; 29 | .ant-select-selector { 30 | padding: 0; 31 | } 32 | } 33 | 34 | .entry { 35 | border-radius: 3px; 36 | background-color: white; 37 | position: relative; 38 | display: flex; 39 | padding: 1em; 40 | margin: 0.75em 0; 41 | border: 1px solid rgba(0, 0, 0, 0.125); 42 | 43 | .request { 44 | padding-right: 1em; 45 | } 46 | .response { 47 | border-left: 1px dashed $color-grey-dark; 48 | padding-left: 1em; 49 | } 50 | 51 | .request, 52 | .response { 53 | width: 50%; 54 | display: flex; 55 | flex-direction: column; 56 | 57 | .details { 58 | display: flex; 59 | align-items: center; 60 | 61 | .ant-tag { 62 | margin-right: 0; 63 | } 64 | 65 | .error { 66 | color: #eb2f96; 67 | } 68 | 69 | & > span { 70 | margin-bottom: 1em; 71 | border-radius: 3px; 72 | font-size: 0.75rem; 73 | white-space: nowrap; 74 | 75 | &.date { 76 | flex: 1 1 auto; 77 | text-align: right; 78 | font-weight: bolder; 79 | } 80 | 81 | & + span { 82 | margin-left: 0.75em; 83 | } 84 | } 85 | } 86 | 87 | .actions { 88 | font-size: 0.75rem; 89 | text-align: right; 90 | } 91 | 92 | table { 93 | border-collapse: collapse; 94 | border-radius: 3px; 95 | border-style: hidden; 96 | box-shadow: 0 0 0 1px $color-grey-light; 97 | width: 100%; 98 | background-color: rgba($color-white-dark, 0.125); 99 | font-size: $base-font-size; 100 | margin-bottom: 1em; 101 | } 102 | 103 | tr + tr { 104 | border-top: 1px solid $color-grey-light; 105 | } 106 | 107 | td { 108 | width: 50%; 109 | padding: 0.5em 0.7em; 110 | word-break: break-all; 111 | 112 | &:nth-child(1) { 113 | font-weight: bolder; 114 | } 115 | } 116 | 117 | .delay { 118 | flex: 1 1 auto; 119 | text-align: right; 120 | margin: 1em 0 0; 121 | font-size: 0.75rem; 122 | span { 123 | font-weight: bolder; 124 | color: #1890ff; 125 | } 126 | } 127 | } 128 | 129 | .request .details > span { 130 | &.method { 131 | border-radius: 3px; 132 | color: $color-white-light; 133 | padding: 0.5em; 134 | background-color: $color-blue-dark; 135 | } 136 | 137 | &.path { 138 | font-family: monospace; 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "browserslist": "> 0.5%, last 2 versions, not dead", 4 | "scripts": { 5 | "start": "concurrently --kill-others 'yarn:start:tsc' 'yarn:start:parcel'", 6 | "start:parcel": "parcel watch ./client/index.html", 7 | "start:tsc": "tsc -p . --noEmit --watch", 8 | "build": "NODE_ENV=production parcel build ./client/index.html", 9 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 10 | "format": "prettier --write ./client/**/*.{ts,tsx,html,scss,json}", 11 | "test": "jest", 12 | "test:watch": "jest --watch" 13 | }, 14 | "targets": { 15 | "default": { 16 | "distDir": "./build/client", 17 | "publicUrl": "./assets" 18 | } 19 | }, 20 | "dependencies": { 21 | "@ant-design/icons": "^4.x", 22 | "antd": "^4.16.9", 23 | "classnames": "^2.2.6", 24 | "codemirror": "^5.55.0", 25 | "dayjs": "^1.8.28", 26 | "fp-ts": "^2.6.7", 27 | "http-status-codes": "^2.1.4", 28 | "io-ts": "^2.2.7", 29 | "js-yaml": "^3.13.1", 30 | "lodash": "^4.17.19", 31 | "lodash.debounce": "^4.0.8", 32 | "mermaid": "^8.5.2", 33 | "react": "^16.10.2", 34 | "react-codemirror2": "^7.2.1", 35 | "react-dom": "^16.10.2", 36 | "react-redux": "^7.1.1", 37 | "react-router-dom": "^5.1.2", 38 | "react-use-localstorage": "^3.4.3", 39 | "redux": "^4.0.4", 40 | "redux-observable": "^1.2.0", 41 | "rxjs": "^6.5.3", 42 | "typesafe-actions": "^5.1.0", 43 | "use-lodash-debounce": "^1.3.0" 44 | }, 45 | "devDependencies": { 46 | "@hot-loader/react-dom": "^16.10.2", 47 | "@parcel/transformer-sass": "^2.0.0", 48 | "@types/classnames": "^2.2.9", 49 | "@types/codemirror": "0.0.96", 50 | "@types/jest": "^26.0.3", 51 | "@types/js-yaml": "^3.12.5", 52 | "@types/lodash": "^4.14.157", 53 | "@types/mermaid": "^8.2.1", 54 | "@types/node": "^14.0.14", 55 | "@types/react": "^16.9.41", 56 | "@types/react-dom": "^16.9.2", 57 | "@types/react-redux": "^7.1.5", 58 | "@types/react-router-dom": "^5.1.0", 59 | "@types/redux": "^3.6.0", 60 | "@types/webpack-env": "^1.15.2", 61 | "@typescript-eslint/eslint-plugin": "^3.5.0", 62 | "@typescript-eslint/parser": "^3.5.0", 63 | "axios": "^0.24.0", 64 | "concurrently": "^5.2.0", 65 | "eslint": "^7.3.1", 66 | "eslint-config-prettier": "^6.11.0", 67 | "eslint-plugin-jest": "^23.17.1", 68 | "eslint-plugin-react": "^7.20.2", 69 | "husky": ">=1", 70 | "jest": "^26.1.0", 71 | "lint-staged": ">=10.2.11", 72 | "parcel": "^2.0.0", 73 | "prettier": "2.0.5", 74 | "react-hot-loader": "^4.12.15", 75 | "redux-devtools-extension": "^2.13.8", 76 | "sass": "^1.26.9", 77 | "ts-jest": "^26.1.1", 78 | "tslib": "^2.3.0", 79 | "typescript": "^3.8.3" 80 | }, 81 | "resolutions": { 82 | "@types/react": "16.9.41" 83 | }, 84 | "alias": { 85 | "react-dom": "@hot-loader/react-dom" 86 | }, 87 | "husky": { 88 | "hooks": { 89 | "pre-commit": "lint-staged" 90 | } 91 | }, 92 | "lint-staged": { 93 | "*.{ts,tsx,html,scss,json}": "prettier --write" 94 | }, 95 | "prettier": { 96 | "semi": true, 97 | "singleQuote": false 98 | }, 99 | "jest": { 100 | "preset": "ts-jest" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /server/services/graphs.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "sort" 7 | "time" 8 | 9 | "github.com/smocker-dev/smocker/server/types" 10 | ) 11 | 12 | const ( 13 | ClientHost = "Client" 14 | SmockerHost = "Smocker" 15 | 16 | requestType = "request" 17 | responseType = "response" 18 | processingType = "processing" 19 | ) 20 | 21 | type Graph interface { 22 | Generate(cfg types.GraphConfig, session *types.Session) types.GraphHistory 23 | } 24 | 25 | type graph struct { 26 | } 27 | 28 | func NewGraph() Graph { 29 | return &graph{} 30 | } 31 | 32 | func (g *graph) Generate(cfg types.GraphConfig, session *types.Session) types.GraphHistory { 33 | 34 | endpointCpt := 0 35 | endpoints := map[string]string{} 36 | 37 | mocksByID := map[string]*types.Mock{} 38 | for _, mock := range session.Mocks { 39 | mocksByID[mock.State.ID] = mock 40 | } 41 | 42 | history := types.GraphHistory{} 43 | for _, entry := range session.History { 44 | from := ClientHost 45 | if src := entry.Request.Headers.Get(cfg.SrcHeader); src != "" { 46 | from = src 47 | } 48 | to := SmockerHost 49 | if dest := entry.Request.Headers.Get(cfg.DestHeader); dest != "" { 50 | to = dest 51 | } 52 | 53 | params := entry.Request.QueryParams.Encode() 54 | if decoded, err := url.QueryUnescape(params); err == nil { 55 | params = decoded 56 | } 57 | if params != "" { 58 | params = "?" + params 59 | } 60 | 61 | requestMessage := entry.Request.Method + " " + entry.Request.Path + params 62 | history = append(history, types.GraphEntry{ 63 | Type: requestType, 64 | Message: requestMessage, 65 | From: from, 66 | To: SmockerHost, 67 | Date: entry.Request.Date, 68 | }) 69 | 70 | history = append(history, types.GraphEntry{ 71 | Type: responseType, 72 | Message: fmt.Sprintf("%d", entry.Response.Status), 73 | From: SmockerHost, 74 | To: from, 75 | Date: entry.Response.Date, 76 | }) 77 | 78 | if entry.Context.MockID != "" { 79 | if mocksByID[entry.Context.MockID].Proxy != nil { 80 | host := mocksByID[entry.Context.MockID].Proxy.Host 81 | u, err := url.Parse(host) 82 | if err == nil { 83 | host = u.Host 84 | } 85 | if to == SmockerHost { 86 | if endpoint := endpoints[host]; endpoint == "" { 87 | endpointCpt++ 88 | endpoints[host] = fmt.Sprintf("Endpoint%d", endpointCpt) 89 | } 90 | to = endpoints[host] 91 | } 92 | 93 | history = append(history, types.GraphEntry{ 94 | Type: requestType, 95 | Message: requestMessage, 96 | From: SmockerHost, 97 | To: to, 98 | Date: entry.Request.Date.Add(1 * time.Nanosecond), 99 | }) 100 | 101 | history = append(history, types.GraphEntry{ 102 | Type: responseType, 103 | Message: fmt.Sprintf("%d", entry.Response.Status), 104 | From: to, 105 | To: SmockerHost, 106 | Date: entry.Response.Date.Add(-1 * time.Nanosecond), 107 | }) 108 | } else { 109 | history = append(history, types.GraphEntry{ 110 | Type: processingType, 111 | Message: "use response mock", 112 | From: SmockerHost, 113 | To: SmockerHost, 114 | Date: entry.Response.Date.Add(-1 * time.Nanosecond), 115 | }) 116 | } 117 | } 118 | 119 | } 120 | sort.Sort(history) 121 | return history 122 | } 123 | -------------------------------------------------------------------------------- /server/types/history.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | const ContextKey = "Context" 17 | 18 | type History []*Entry 19 | 20 | func (h History) Clone() History { 21 | return append(make(History, 0, len(h)), h...) 22 | } 23 | 24 | type Entry struct { 25 | Context Context `json:"context"` 26 | Request Request `json:"request"` 27 | Response Response `json:"response"` 28 | } 29 | 30 | type Context struct { 31 | MockID string `json:"mock_id,omitempty"` 32 | MockType string `json:"mock_type,omitempty"` 33 | Delay string `json:"delay,omitempty"` 34 | } 35 | 36 | type Request struct { 37 | Path string `json:"path"` 38 | Method string `json:"method"` 39 | Origin string `json:"origin"` 40 | BodyString string `json:"body_string" yaml:"body_string"` 41 | Body interface{} `json:"body,omitempty" yaml:"body,omitempty"` 42 | QueryParams url.Values `json:"query_params,omitempty" yaml:"query_params,omitempty"` 43 | Headers http.Header `json:"headers,omitempty" yaml:"headers,omitempty"` 44 | Date time.Time `json:"date" yaml:"date"` 45 | } 46 | 47 | type Response struct { 48 | Status int `json:"status"` 49 | Body interface{} `json:"body,omitempty" yaml:"body,omitempty"` 50 | Headers http.Header `json:"headers,omitempty" yaml:"headers,omitempty"` 51 | Date time.Time `json:"date" yaml:"date"` 52 | } 53 | 54 | func HTTPRequestToRequest(req *http.Request) Request { 55 | bodyBytes := []byte{} 56 | if req.Body != nil { 57 | var err error 58 | bodyBytes, err = io.ReadAll(req.Body) 59 | if err != nil { 60 | log.WithError(err).Error("Failed to read request body") 61 | } 62 | } 63 | req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 64 | var body interface{} 65 | var tmp map[string]interface{} 66 | if err := json.Unmarshal(bodyBytes, &tmp); err != nil { 67 | body = string(bodyBytes) 68 | } else { 69 | body = tmp 70 | } 71 | 72 | headers := http.Header{} 73 | for key, values := range req.Header { 74 | headers[key] = make([]string, 0, len(values)) 75 | for _, value := range values { 76 | headers.Add(key, value) 77 | } 78 | } 79 | headers.Add("Host", req.Host) 80 | return Request{ 81 | Path: req.URL.EscapedPath(), 82 | Method: req.Method, 83 | Origin: getOrigin(req), 84 | Body: body, 85 | BodyString: string(bodyBytes), 86 | QueryParams: req.URL.Query(), 87 | Headers: headers, 88 | Date: time.Now(), 89 | } 90 | } 91 | 92 | func getOrigin(r *http.Request) string { 93 | for _, h := range []string{"X-Forwarded-For", "X-Real-Ip"} { 94 | addresses := strings.Split(r.Header.Get(h), ",") 95 | // march from right to left until we get a public address 96 | // that will be the address right before our proxy. 97 | for i := len(addresses) - 1; i >= 0; i-- { 98 | // header can contain spaces too, strip those out. 99 | ip := strings.TrimSpace(addresses[i]) 100 | if ip != "" { 101 | return ip 102 | } 103 | } 104 | } 105 | 106 | // port declared in RemoteAddr is inconsistent so we can't use it 107 | host, _, err := net.SplitHostPort(r.RemoteAddr) 108 | if err != nil { 109 | return r.RemoteAddr 110 | } 111 | return host 112 | } 113 | -------------------------------------------------------------------------------- /client/components/MockEditor/KeyValueEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; 3 | import { Button, Form, Input, Select, Space } from "antd"; 4 | import { defaultMatcher } from "../../modules/types"; 5 | import { positiveMatchers, negativeMatchers, unaryMatchers } from "./utils"; 6 | import { FormListFieldData, FormListOperation } from "antd/lib/form/FormList"; 7 | 8 | export interface KeyValueEditorProps { 9 | name: string[]; 10 | withMatchers?: boolean; 11 | } 12 | 13 | export const KeyValueEditor = ({ 14 | name, 15 | withMatchers, 16 | }: KeyValueEditorProps): JSX.Element => ( 17 | 18 | {(fields, actions) => ( 19 | 25 | )} 26 | 27 | ); 28 | 29 | export interface KeyValueEditorEngineProps extends KeyValueEditorProps { 30 | fields: FormListFieldData[]; 31 | actions: FormListOperation; 32 | } 33 | 34 | export const KeyValueEditorEngine = ({ 35 | name, 36 | withMatchers, 37 | fields, 38 | actions, 39 | }: KeyValueEditorEngineProps): JSX.Element => ( 40 |
41 | {fields.map(({ key, name: fieldName, fieldKey, ...restField }) => ( 42 | 47 | actions.remove(fieldName)} /> 48 | 49 | 54 | 55 | 56 | 57 | {withMatchers && ( 58 | 63 | 79 | 80 | )} 81 | 82 | 83 | {({ getFieldValue }) => ( 84 | 94 | )} 95 | 96 | 97 | ))} 98 | 99 | 100 | 108 | 109 |
110 | ); 111 | -------------------------------------------------------------------------------- /tests/features/import_sessions.yml: -------------------------------------------------------------------------------- 1 | name: Import sessions 2 | version: "2" 3 | testcases: 4 | - name: Init Smocker 5 | steps: 6 | - type: http 7 | method: POST 8 | url: http://localhost:8081/reset 9 | 10 | - name: Import sessions then check history and mocks 11 | steps: 12 | - type: http 13 | method: POST 14 | url: http://localhost:8081/sessions/import 15 | headers: 16 | Content-Type: application/json 17 | body: > 18 | [ 19 | { 20 | "id": "1d6d264b-4d13-4e0b-a51e-e44fc80eca9f", 21 | "name": "test", 22 | "date": "2020-02-12T00:04:29.5940297+01:00", 23 | "history": [ 24 | { 25 | "mock_id": "05519745-7648-46ed-a5b0-757534e077d0", 26 | "request": { 27 | "path": "/hello/world", 28 | "method": "GET", 29 | "body": "", 30 | "headers": { 31 | "Accept": ["application/json, text/plain, */*"], 32 | "Connection": ["close"], 33 | "Host": ["localhost:8080"], 34 | "User-Agent": ["axios/0.19.2"] 35 | }, 36 | "date": "2020-02-12T00:04:46.1526269+01:00" 37 | }, 38 | "response": { 39 | "status": 200, 40 | "body": { 41 | "message": "Hello, World!" 42 | }, 43 | "headers": { 44 | "Content-Type": ["application/json"] 45 | }, 46 | "date": "2020-02-12T00:04:46.1532019+01:00" 47 | } 48 | } 49 | ], 50 | "mocks": [ 51 | { 52 | "request": { 53 | "path": "/hello/world", 54 | "method": "GET" 55 | }, 56 | "response": { 57 | "body": "{\"message\": \"Hello, World!\"}\n", 58 | "status": 200, 59 | "headers": { 60 | "Content-Type": ["application/json"] 61 | } 62 | }, 63 | "context": {}, 64 | "state": { 65 | "id": "05519745-7648-46ed-a5b0-757534e077d0", 66 | "times_count": 1, 67 | "creation_date": "2020-02-12T00:04:43.3337425+01:00" 68 | } 69 | } 70 | ] 71 | } 72 | ] 73 | assertions: 74 | - result.statuscode ShouldEqual 200 75 | - result.bodyjson.__len__ ShouldEqual 1 76 | - result.bodyjson.bodyjson0.id ShouldEqual 1d6d264b-4d13-4e0b-a51e-e44fc80eca9f 77 | - result.bodyjson.bodyjson0.name ShouldEqual test 78 | - type: http 79 | method: GET 80 | url: http://localhost:8081/history 81 | assertions: 82 | - result.statuscode ShouldEqual 200 83 | - result.bodyjson.__len__ ShouldEqual 1 84 | - result.bodyjson.bodyjson0.request.path ShouldEqual /hello/world 85 | - result.bodyjson.bodyjson0.response.status ShouldEqual 200 86 | - result.bodyjson.bodyjson0.response.body.message ShouldEqual "Hello, World!" 87 | - type: http 88 | method: GET 89 | url: http://localhost:8081/mocks 90 | assertions: 91 | - result.statuscode ShouldEqual 200 92 | - result.bodyjson.__len__ ShouldEqual 1 93 | - result.bodyjson.bodyjson0.request.path.value ShouldEqual /hello/world 94 | - result.bodyjson.bodyjson0.response.status ShouldEqual 200 95 | -------------------------------------------------------------------------------- /server/templates/lua.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | goJson "github.com/layeh/gopher-json" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/smocker-dev/smocker/server/types" 10 | "github.com/yuin/gluamapper" 11 | lua "github.com/yuin/gopher-lua" 12 | luar "layeh.com/gopher-luar" 13 | ) 14 | 15 | type luaEngine struct{} 16 | 17 | func NewLuaEngine() TemplateEngine { 18 | return &luaEngine{} 19 | } 20 | 21 | func (*luaEngine) Execute(request types.Request, script string) (*types.MockResponse, error) { 22 | luaState := lua.NewState(lua.Options{ 23 | SkipOpenLibs: true, 24 | }) 25 | defer luaState.Close() 26 | 27 | for _, pair := range []struct { 28 | n string 29 | f lua.LGFunction 30 | }{ 31 | {lua.LoadLibName, lua.OpenPackage}, 32 | {lua.BaseLibName, lua.OpenBase}, 33 | {lua.MathLibName, lua.OpenMath}, 34 | {lua.StringLibName, lua.OpenString}, 35 | {lua.TabLibName, lua.OpenTable}, 36 | } { 37 | if err := luaState.CallByParam( 38 | lua.P{ 39 | Fn: luaState.NewFunction(pair.f), 40 | NRet: 0, 41 | Protect: true, 42 | }, 43 | lua.LString(pair.n), 44 | ); err != nil { 45 | log.WithError(err).Error("Failed to load Lua libraries") 46 | return nil, fmt.Errorf("failed to load Lua libraries: %w", err) 47 | } 48 | } 49 | if err := luaState.DoString("coroutine=nil;debug=nil;io=nil;open=nil;os=nil"); err != nil { 50 | log.WithError(err).Error("Failed to sandbox Lua environment") 51 | return nil, fmt.Errorf("failed to sandbox Lua environment: %w", err) 52 | } 53 | 54 | m, err := StructToMSI(request) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to convert request as map[string]any: %w", err) 57 | } 58 | 59 | luaState.SetGlobal("request", luar.New(luaState, m)) 60 | if err := luaState.DoString(script); err != nil { 61 | log.WithError(err).Error("Failed to execute Lua script") 62 | return nil, fmt.Errorf("failed to execute Lua script: %w", err) 63 | } 64 | 65 | luaResult := luaState.Get(-1).(*lua.LTable) 66 | body := luaResult.RawGetString("body") 67 | if body.Type() == lua.LTTable { 68 | // FIXME: this should depend on the Content-Type of the luaResult 69 | b, _ := goJson.Encode(body) 70 | luaResult.RawSetString("body", lua.LString(string(b))) 71 | } 72 | 73 | delay := &lua.LTable{} 74 | if err := parseLuaDelay(luaResult, "delay", delay, "value"); err != nil { 75 | log.WithError(err).Error("Invalid delay from lua script") 76 | return nil, fmt.Errorf("invalid delay from Lua script: %w", err) 77 | } 78 | luaResult.RawSetString("delay", delay) 79 | 80 | var result types.MockResponse 81 | if err := gluamapper.Map(luaResult, &result); err != nil { 82 | log.WithError(err).Error("Invalid result from Lua script") 83 | return nil, fmt.Errorf("invalid result from Lua script: %w", err) 84 | } 85 | 86 | return &result, nil 87 | } 88 | 89 | func parseLuaDelay(value *lua.LTable, valueKey string, res *lua.LTable, resKey string) error { 90 | d := value.RawGetString(valueKey) 91 | switch d.Type() { 92 | case lua.LTNil: 93 | return nil 94 | case lua.LTNumber: 95 | res.RawSetString(resKey, d) 96 | case lua.LTString: 97 | delay, err := time.ParseDuration(d.String()) 98 | if err != nil { 99 | return err 100 | } 101 | res.RawSetString(resKey, lua.LNumber(float64(delay))) 102 | case lua.LTTable: 103 | table := d.(*lua.LTable) 104 | if err := parseLuaDelay(table, "value", res, "value"); err != nil { 105 | return err 106 | } 107 | if err := parseLuaDelay(table, "min", res, "min"); err != nil { 108 | return err 109 | } 110 | if err := parseLuaDelay(table, "max", res, "max"); err != nil { 111 | return err 112 | } 113 | default: 114 | return fmt.Errorf("invalid lua type for key %q: %s", valueKey, d.Type().String()) 115 | } 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /client/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import { Collapse } from "antd"; 2 | import { EditorConfiguration } from "codemirror"; 3 | import "codemirror/addon/fold/brace-fold"; 4 | import "codemirror/addon/fold/comment-fold"; 5 | import "codemirror/addon/fold/foldcode"; 6 | import "codemirror/addon/fold/foldgutter"; 7 | import "codemirror/addon/fold/foldgutter.css"; 8 | import "codemirror/addon/fold/indent-fold"; 9 | import "codemirror/addon/lint/lint"; 10 | import "codemirror/addon/lint/lint.css"; 11 | import "codemirror/addon/lint/yaml-lint"; 12 | import "codemirror/lib/codemirror.css"; 13 | import "codemirror/mode/javascript/javascript"; 14 | import "codemirror/mode/ruby/ruby"; 15 | import "codemirror/mode/xml/xml"; 16 | import "codemirror/mode/yaml/yaml"; 17 | import "codemirror/theme/material.css"; 18 | import jsyaml from "js-yaml"; 19 | import * as React from "react"; 20 | import { Controlled, UnControlled } from "react-codemirror2"; 21 | import "./Code.scss"; 22 | 23 | window.jsyaml = jsyaml; 24 | 25 | const largeBodyLength = 5000; 26 | 27 | export type Language = 28 | | "go_template" 29 | | "go_template_yaml" 30 | | "yaml" 31 | | "go_template_json" 32 | | "json" 33 | | "lua" 34 | | "xml" 35 | | "txt"; 36 | 37 | interface Props { 38 | value?: string; 39 | language: Language; 40 | collapsible?: boolean; 41 | onChange?: (value: string) => unknown; 42 | } 43 | 44 | const codeMirrorOptions: EditorConfiguration = { 45 | theme: "material", 46 | lineWrapping: true, 47 | readOnly: true, 48 | viewportMargin: Infinity, 49 | foldGutter: true, 50 | gutters: ["CodeMirror-foldgutter"], 51 | indentWithTabs: false, 52 | indentUnit: 2, 53 | smartIndent: false, 54 | extraKeys: { 55 | // Insert spaces when using the Tab key 56 | Tab: (cm) => cm.replaceSelection(" ", "end"), 57 | }, 58 | }; 59 | 60 | const Code = ({ 61 | value, 62 | language, 63 | onChange: onBeforeChange, 64 | collapsible = true, 65 | }: Props): JSX.Element => { 66 | let mode: string = language; 67 | switch (mode) { 68 | case "lua": 69 | mode = "ruby"; // because lua mode doesn't handle fold 70 | break; 71 | 72 | case "json": 73 | case "go_template_json": 74 | mode = "application/json"; 75 | break; 76 | 77 | case "go_template": 78 | case "go_template_yaml": 79 | mode = "yaml"; 80 | break; 81 | 82 | case "xml": 83 | mode = "application/xml"; 84 | break; 85 | } 86 | 87 | let body = null; 88 | if (!onBeforeChange) { 89 | body = ( 90 | 98 | ); 99 | } 100 | 101 | const onBeforeChangeWrapper = (_: unknown, __: unknown, newValue: string) => { 102 | if (onBeforeChange) { 103 | onBeforeChange(newValue); 104 | } 105 | }; 106 | body = ( 107 | 124 | ); 125 | 126 | if (collapsible && value && value.length > largeBodyLength) { 127 | return ( 128 | 129 | 133 | {body} 134 | 135 | 136 | ); 137 | } 138 | return body; 139 | }; 140 | 141 | export default Code; 142 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - '*.*.*' # semver, will override latest 8 | - '*-preview' # preview, won't override latest 9 | pull_request: 10 | branches: 11 | - main 12 | workflow_dispatch: # Allow manual trigger 13 | 14 | permissions: 15 | contents: write 16 | packages: write 17 | 18 | jobs: 19 | lint: 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 5 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version-file: .nvmrc 28 | cache: yarn 29 | 30 | - run: yarn install --frozen-lockfile 31 | 32 | - name: Lint sources 33 | run: | 34 | make lint 35 | yarn lint 36 | 37 | test: 38 | runs-on: ubuntu-latest 39 | timeout-minutes: 5 40 | steps: 41 | - uses: actions/checkout@v4 42 | with: 43 | fetch-depth: 0 44 | 45 | - uses: actions/setup-node@v4 46 | with: 47 | node-version-file: .nvmrc 48 | cache: yarn 49 | 50 | 51 | - name: Setup Go environment 52 | uses: actions/setup-go@v5 53 | with: 54 | go-version: stable 55 | 56 | - run: yarn install --frozen-lockfile 57 | 58 | - name: Execute tests 59 | run: | 60 | make test 61 | make test-integration 62 | yarn test 63 | make coverage 64 | 65 | - name: SonarCloud Scan 66 | uses: sonarsource/sonarcloud-github-action@master 67 | continue-on-error: true 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 71 | 72 | build: 73 | runs-on: ubuntu-latest 74 | timeout-minutes: 5 75 | steps: 76 | - uses: actions/checkout@v4 77 | 78 | - id: extract_ref 79 | run: echo ::set-output name=GIT_REF::$(echo ${GITHUB_REF##*/}) 80 | 81 | - uses: actions/setup-node@v4 82 | with: 83 | node-version-file: .nvmrc 84 | cache: yarn 85 | 86 | - name: Setup Go environment 87 | uses: actions/setup-go@v5 88 | with: 89 | go-version: stable 90 | 91 | - run: yarn install --frozen-lockfile 92 | 93 | - name: Build 94 | run: | 95 | make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} RELEASE=1 release 96 | make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} build-docker 97 | make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} start-docker 98 | 99 | - if: startsWith(github.ref, 'refs/tags/') 100 | uses: actions/upload-artifact@v4 101 | with: 102 | name: smocker-bin 103 | path: ./build/smocker.tar.gz 104 | 105 | deploy: 106 | needs: [lint, test, build] 107 | if: startsWith(github.ref, 'refs/tags/') 108 | runs-on: ubuntu-latest 109 | timeout-minutes: 10 110 | steps: 111 | - uses: actions/checkout@v4 112 | 113 | - id: extract_ref 114 | run: echo ::set-output name=GIT_REF::$(echo ${GITHUB_REF##*/}) 115 | 116 | - uses: actions/download-artifact@v4 117 | with: 118 | name: smocker-bin 119 | path: ./build 120 | 121 | - run: cd build && tar -xvf smocker.tar.gz 122 | 123 | - name: Docker login 124 | uses: docker/login-action@v3 125 | with: 126 | registry: ghcr.io 127 | username: ${{ github.actor }} 128 | password: ${{ secrets.GITHUB_TOKEN }} 129 | 130 | - name: Set up QEMU 131 | uses: docker/setup-qemu-action@v3 132 | 133 | - name: Set up Docker Buildx 134 | uses: docker/setup-buildx-action@v3 135 | 136 | - name: Deploy on Docker registry 137 | run: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} deploy-docker 138 | 139 | - name: Deploy on GitHub releases 140 | uses: softprops/action-gh-release@v2 141 | with: 142 | files: build/smocker.tar.gz 143 | token: ${{ secrets.GITHUB_TOKEN }} 144 | -------------------------------------------------------------------------------- /server/admin_server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/tls" 5 | "html/template" 6 | "io" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/facebookgo/grace/gracehttp" 11 | "github.com/labstack/echo/v4" 12 | "github.com/labstack/echo/v4/middleware" 13 | log "github.com/sirupsen/logrus" 14 | "github.com/smocker-dev/smocker/server/config" 15 | "github.com/smocker-dev/smocker/server/handlers" 16 | "github.com/smocker-dev/smocker/server/services" 17 | ) 18 | 19 | // TemplateRenderer is a custom html/template renderer for Echo framework 20 | type TemplateRenderer struct { 21 | *template.Template 22 | } 23 | 24 | // Render renders a template document 25 | func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 26 | return t.ExecuteTemplate(w, name, data) 27 | } 28 | 29 | var templateRenderer *TemplateRenderer 30 | 31 | func Serve(config config.Config) { 32 | adminServerEngine := echo.New() 33 | adminServerEngine.HideBanner = true 34 | adminServerEngine.HidePort = true 35 | 36 | adminServerEngine.Use(recoverMiddleware(), loggerMiddleware(), middleware.Gzip()) 37 | 38 | mockServerEngine, mockServices := NewMockServer(config) 39 | graphServices := services.NewGraph() 40 | handler := handlers.NewAdmin(mockServices, graphServices) 41 | 42 | // Admin Routes 43 | mocksGroup := adminServerEngine.Group("/mocks") 44 | mocksGroup.GET("", handler.GetMocks) 45 | mocksGroup.POST("", handler.AddMocks) 46 | mocksGroup.POST("/lock", handler.LockMocks) 47 | mocksGroup.POST("/unlock", handler.UnlockMocks) 48 | 49 | historyGroup := adminServerEngine.Group("/history") 50 | historyGroup.GET("", handler.GetHistory) 51 | historyGroup.GET("/summary", handler.SummarizeHistory) 52 | 53 | sessionsGroup := adminServerEngine.Group("/sessions") 54 | sessionsGroup.GET("", handler.GetSessions) 55 | sessionsGroup.POST("", handler.NewSession) 56 | sessionsGroup.PUT("", handler.UpdateSession) 57 | sessionsGroup.POST("/verify", handler.VerifySession) 58 | sessionsGroup.GET("/summary", handler.SummarizeSessions) 59 | sessionsGroup.POST("/import", handler.ImportSession) 60 | 61 | adminServerEngine.POST("/reset", handler.Reset) 62 | 63 | // Health Check Route 64 | adminServerEngine.GET("/version", func(c echo.Context) error { 65 | return c.JSON(http.StatusOK, config.Build) 66 | }) 67 | 68 | // UI Routes 69 | adminServerEngine.Static("/assets", config.StaticFiles) 70 | adminServerEngine.GET("/*", renderIndex(adminServerEngine, config)) 71 | 72 | log.WithField("port", config.ConfigListenPort).Info("Starting admin server") 73 | log.WithField("port", config.MockServerListenPort).Info("Starting mock server") 74 | adminServerEngine.Server.Addr = ":" + strconv.Itoa(config.ConfigListenPort) 75 | 76 | if config.TLSEnable { 77 | certificate, err := tls.LoadX509KeyPair(config.TLSCertFile, config.TLSKeyFile) 78 | if err != nil { 79 | log.WithFields(log.Fields{ 80 | "tls-cert-file": config.TLSCertFile, 81 | "tls-key-file": config.TLSKeyFile, 82 | }).Fatalf("Invalid certificate: %v", err) 83 | } 84 | 85 | adminServerEngine.Server.TLSConfig = &tls.Config{ 86 | NextProtos: []string{"http/1.1"}, 87 | Certificates: []tls.Certificate{certificate}, 88 | } 89 | mockServerEngine.TLSConfig = &tls.Config{ 90 | NextProtos: []string{"http/1.1"}, 91 | Certificates: []tls.Certificate{certificate}, 92 | } 93 | } 94 | 95 | if err := gracehttp.Serve(adminServerEngine.Server, mockServerEngine); err != nil { 96 | log.Fatal(err) 97 | } 98 | log.Info("Shutting down gracefully") 99 | } 100 | 101 | func renderIndex(e *echo.Echo, cfg config.Config) echo.HandlerFunc { 102 | return func(c echo.Context) error { 103 | // In development mode, index.html might not be available yet 104 | if templateRenderer == nil { 105 | template, err := template.ParseFiles(cfg.StaticFiles + "/index.html") 106 | if err != nil { 107 | return c.String(http.StatusNotFound, "index is building...") 108 | } 109 | templateRenderer := &TemplateRenderer{template} 110 | e.Renderer = templateRenderer 111 | } 112 | 113 | return c.Render(http.StatusOK, "index.html", echo.Map{ 114 | "basePath": cfg.ConfigBasePath, 115 | "version": cfg.Build.BuildVersion, 116 | }) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /tests/features/locks_mocks.yml: -------------------------------------------------------------------------------- 1 | name: Lock/Unlock mocks into smocker 2 | version: "2" 3 | testcases: 4 | - name: Init 5 | steps: 6 | - type: http 7 | method: POST 8 | url: http://localhost:8081/reset 9 | - type: http 10 | method: POST 11 | url: http://localhost:8081/mocks 12 | headers: 13 | Content-Type: "application/x-yaml" 14 | bodyFile: ../data/basic_mock.yml 15 | assertions: 16 | - result.statuscode ShouldEqual 200 17 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 18 | - type: http 19 | method: GET 20 | url: http://localhost:8081/mocks 21 | assertions: 22 | - result.statuscode ShouldEqual 200 23 | - result.bodyjson.__len__ ShouldEqual 1 24 | - result.bodyjson.bodyjson0.request.method.matcher ShouldEqual ShouldMatch 25 | - result.bodyjson.bodyjson0.request.method.value ShouldEqual .* 26 | vars: 27 | mock_id: 28 | from: result.bodyjson.bodyjson0.state.id 29 | 30 | - name: Lock Mock 31 | steps: 32 | - type: http 33 | method: POST 34 | url: http://localhost:8081/mocks/lock 35 | headers: 36 | Content-Type: "application/json" 37 | body: > 38 | ["{{.Init.mock_id}}"] 39 | assertions: 40 | - result.statuscode ShouldEqual 200 41 | - result.bodyjson.bodyjson0.state.locked ShouldBeTrue 42 | - type: http 43 | method: POST 44 | url: http://localhost:8081/reset 45 | - type: http 46 | method: GET 47 | url: http://localhost:8081/mocks 48 | assertions: 49 | - result.statuscode ShouldEqual 200 50 | - result.bodyjson.__len__ ShouldEqual 1 51 | - result.bodyjson.bodyjson0.state.id ShouldEqual {{.Init.mock_id}} 52 | - result.bodyjson.bodyjson0.state.locked ShouldBeTrue 53 | 54 | - name: Unlock Mock 55 | steps: 56 | - type: http 57 | method: POST 58 | url: http://localhost:8081/mocks/unlock 59 | headers: 60 | Content-Type: "application/json" 61 | body: > 62 | ["{{.Init.mock_id}}"] 63 | assertions: 64 | - result.statuscode ShouldEqual 200 65 | - result.bodyjson.bodyjson0.state.locked ShouldBeFalse 66 | - type: http 67 | method: POST 68 | url: http://localhost:8081/reset 69 | - type: http 70 | method: GET 71 | url: http://localhost:8081/mocks 72 | assertions: 73 | - result.statuscode ShouldEqual 200 74 | - result.bodyjson.__len__ ShouldEqual 0 75 | 76 | - name: ResetForce 77 | steps: 78 | - type: http 79 | method: POST 80 | url: http://localhost:8081/reset 81 | - type: http 82 | method: POST 83 | url: http://localhost:8081/mocks 84 | headers: 85 | Content-Type: "application/x-yaml" 86 | bodyFile: ../data/basic_mock.yml 87 | assertions: 88 | - result.statuscode ShouldEqual 200 89 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 90 | - type: http 91 | method: GET 92 | url: http://localhost:8081/mocks 93 | assertions: 94 | - result.statuscode ShouldEqual 200 95 | - result.bodyjson.__len__ ShouldEqual 1 96 | - result.bodyjson.bodyjson0.request.method.matcher ShouldEqual ShouldMatch 97 | - result.bodyjson.bodyjson0.request.method.value ShouldEqual .* 98 | vars: 99 | mock_id: 100 | from: result.bodyjson.bodyjson0.state.id 101 | - type: http 102 | method: POST 103 | url: http://localhost:8081/mocks/lock 104 | headers: 105 | Content-Type: "application/json" 106 | body: > 107 | ["{{.ResetForce.mock_id}}"] 108 | assertions: 109 | - result.statuscode ShouldEqual 200 110 | - result.bodyjson.bodyjson0.state.locked ShouldBeTrue 111 | - type: http 112 | method: POST 113 | url: http://localhost:8081/reset?force=true 114 | - type: http 115 | method: GET 116 | url: http://localhost:8081/mocks 117 | assertions: 118 | - result.statuscode ShouldEqual 200 119 | - result.bodyjson.__len__ ShouldEqual 0 120 | -------------------------------------------------------------------------------- /server/types/matchers_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func TestStringMatcherJSON(t *testing.T) { 12 | test := `"test"` 13 | serialized := `{"matcher":"ShouldEqual","value":"test"}` 14 | 15 | var res StringMatcher 16 | if err := json.Unmarshal([]byte(test), &res); err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | if res.Matcher != DefaultMatcher { 21 | t.Fatalf("matcher %s should be equal to %s", res.Matcher, DefaultMatcher) 22 | } 23 | if res.Value != "test" { 24 | t.Fatalf("value %s should be equal to %s", res.Value, "test") 25 | } 26 | 27 | b, err := json.Marshal(&res) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | if string(b) != serialized { 32 | t.Fatalf("serialized value %s should be equal to %s", string(b), test) 33 | } 34 | 35 | test = `{"matcher":"ShouldEqual","value":"test2"}` 36 | serialized = test 37 | res = StringMatcher{} 38 | if err = json.Unmarshal([]byte(test), &res); err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | if res.Matcher != "ShouldEqual" { 43 | t.Fatalf("matcher %s should be equal to %s", res.Matcher, "ShouldEqual") 44 | } 45 | if res.Value != "test2" { 46 | t.Fatalf("value %s should be equal to %s", res.Value, "test2") 47 | } 48 | 49 | b, err = json.Marshal(&res) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | if string(b) != serialized { 55 | t.Fatalf("serialized value %s should be equal to %s", string(b), test) 56 | } 57 | } 58 | 59 | func TestStringMatcherYAML(t *testing.T) { 60 | test := `test` 61 | var res StringMatcher 62 | if err := yaml.Unmarshal([]byte(test), &res); err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | if res.Matcher != DefaultMatcher { 67 | t.Fatalf("matcher %s should be equal to %s", res.Matcher, DefaultMatcher) 68 | } 69 | if res.Value != "test" { 70 | t.Fatalf("value %s should be equal to %s", res.Value, "test") 71 | } 72 | 73 | if _, err := yaml.Marshal(&res); err != nil { 74 | t.Fatal(err) 75 | } 76 | 77 | test = `{"matcher":"ShouldEqual","value":"test2"}` 78 | res = StringMatcher{} 79 | if err := yaml.Unmarshal([]byte(test), &res); err != nil { 80 | t.Fatal(err) 81 | } 82 | 83 | if res.Matcher != "ShouldEqual" { 84 | t.Fatalf("matcher %s should be equal to %s", res.Matcher, "ShouldEqual") 85 | } 86 | if res.Value != "test2" { 87 | t.Fatalf("value %s should be equal to %s", res.Value, "test2") 88 | } 89 | 90 | if _, err := yaml.Marshal(&res); err != nil { 91 | t.Fatal(err) 92 | } 93 | } 94 | 95 | func TestMultiMapMatcherJSON(t *testing.T) { 96 | test := `{"test":"test"}` 97 | serialized := `{"test":[{"matcher":"ShouldEqual","value":"test"}]}` 98 | var res MultiMapMatcher 99 | if err := json.Unmarshal([]byte(test), &res); err != nil { 100 | t.Fatal(err) 101 | } 102 | 103 | if res == nil { 104 | t.Fatal("multimap matcher should not be nil") 105 | } 106 | 107 | for _, value := range res { 108 | if value[0].Matcher != DefaultMatcher { 109 | t.Fatalf("matcher %s should be equal to %s", value[0].Matcher, DefaultMatcher) 110 | } 111 | } 112 | 113 | expected := MultiMapMatcher{ 114 | "test": { 115 | {Matcher: "ShouldEqual", Value: "test"}, 116 | }, 117 | } 118 | if !reflect.DeepEqual(res, expected) { 119 | t.Fatalf("values %v should be equal to %v", res, expected) 120 | } 121 | 122 | b, err := json.Marshal(&res) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | 127 | if string(b) != serialized { 128 | t.Fatalf("serialized value %s should be equal to %s", string(b), test) 129 | } 130 | 131 | test = `{"test":{"matcher":"ShouldEqual","value":"test3"}}` 132 | serialized = `{"test":[{"matcher":"ShouldEqual","value":"test3"}]}` 133 | res = MultiMapMatcher{} 134 | if err = json.Unmarshal([]byte(test), &res); err != nil { 135 | t.Fatal(err) 136 | } 137 | 138 | if res["test"][0].Matcher != "ShouldEqual" { 139 | t.Fatalf("matcher %s should be equal to %s", res["test"][0].Matcher, "ShouldEqual") 140 | } 141 | 142 | expected = MultiMapMatcher{ 143 | "test": { 144 | {Matcher: "ShouldEqual", Value: "test3"}, 145 | }, 146 | } 147 | 148 | if !reflect.DeepEqual(res, expected) { 149 | t.Fatalf("values %v should be equal to %v", res, expected) 150 | } 151 | 152 | b, err = json.Marshal(&res) 153 | if err != nil { 154 | t.Fatal(err) 155 | } 156 | 157 | if string(b) != serialized { 158 | t.Fatalf("serialized value %s should be equal to %s", string(b), test) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /client/modules/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { Entry } from "./types"; 2 | import { entryToCurl, bodyMatcherToPaths } from "./utils"; 3 | 4 | const baseEntry: Entry = { 5 | context: { 6 | mock_id: "", 7 | mock_type: "", 8 | delay: "", 9 | }, 10 | request: { 11 | method: "", 12 | path: "", 13 | query_params: undefined, 14 | headers: undefined, 15 | body: undefined, 16 | date: "", 17 | }, 18 | response: { 19 | status: 0, 20 | headers: undefined, 21 | body: undefined, 22 | date: "", 23 | }, 24 | }; 25 | 26 | describe("Generate curl command from:", () => { 27 | test("GET history entry", () => { 28 | const entry: Entry = { 29 | ...baseEntry, 30 | request: { 31 | ...baseEntry.request, 32 | method: "GET", 33 | path: "/test", 34 | }, 35 | }; 36 | expect(entryToCurl(entry)).toBe("curl -XGET '/test'"); 37 | }); 38 | 39 | test("GET history entry with Host header", () => { 40 | const entry: Entry = { 41 | ...baseEntry, 42 | request: { 43 | ...baseEntry.request, 44 | method: "GET", 45 | path: "/test", 46 | headers: { 47 | Host: ["localhost:8080"], 48 | }, 49 | }, 50 | }; 51 | expect(entryToCurl(entry)).toBe("curl -XGET 'localhost:8080/test'"); 52 | }); 53 | 54 | test("GET history entry with headers", () => { 55 | const entry: Entry = { 56 | ...baseEntry, 57 | request: { 58 | ...baseEntry.request, 59 | method: "GET", 60 | path: "/test", 61 | headers: { 62 | "X-Foo-Header": ["foo"], 63 | "X-Bar-Header": ["bar", "baz"], 64 | }, 65 | }, 66 | }; 67 | expect(entryToCurl(entry)).toBe( 68 | "curl -XGET --header 'X-Foo-Header: foo' --header 'X-Bar-Header: bar' --header 'X-Bar-Header: baz' '/test'" 69 | ); 70 | }); 71 | 72 | test("GET history entry with query parameters", () => { 73 | const entry: Entry = { 74 | ...baseEntry, 75 | request: { 76 | ...baseEntry.request, 77 | method: "GET", 78 | path: "/test", 79 | query_params: { 80 | foo: ["foo"], 81 | bar: ["bar", "baz"], 82 | }, 83 | }, 84 | }; 85 | expect(entryToCurl(entry)).toBe( 86 | "curl -XGET '/test?foo=foo&bar=bar&bar=baz'" 87 | ); 88 | }); 89 | 90 | test("GET history entry with body JSON", () => { 91 | const entry: Entry = { 92 | ...baseEntry, 93 | request: { 94 | ...baseEntry.request, 95 | method: "POST", 96 | path: "/test", 97 | body: { 98 | key: "value containing 'single quotes'", 99 | }, 100 | }, 101 | }; 102 | expect(entryToCurl(entry)).toBe( 103 | `curl -XPOST '/test' --data '{"key":"value containing \\'single quotes\\'"}'` 104 | ); 105 | }); 106 | 107 | test("GET history entry with body string", () => { 108 | const entry: Entry = { 109 | ...baseEntry, 110 | request: { 111 | ...baseEntry.request, 112 | method: "POST", 113 | path: "/test", 114 | body: "value containing 'single quotes'", 115 | }, 116 | }; 117 | expect(entryToCurl(entry)).toBe( 118 | // FIXME: we shouldn't have the " if the client sent raw text 119 | `curl -XPOST '/test' --data '"value containing \\'single quotes\\'"'` 120 | ); 121 | }); 122 | }); 123 | 124 | describe("Generate paths from body matcher:", () => { 125 | test("Nested array", () => { 126 | const body = [{ foo: 0 }, { foo: 1 }]; 127 | const actual = { 128 | body: bodyMatcherToPaths(body), 129 | }; 130 | expect(actual).toMatchObject({ 131 | body: { 132 | "[0].foo": 0, 133 | "[1].foo": 1, 134 | }, 135 | }); 136 | }); 137 | 138 | test("Nested object body", () => { 139 | const body = { 140 | foo: 3, 141 | bar: ["a", "b"], 142 | baz: { 143 | level1: { 144 | level2: { 145 | foo: 3, 146 | bar: ["a", "b"], 147 | }, 148 | }, 149 | }, 150 | }; 151 | const actual = { 152 | body: bodyMatcherToPaths(body), 153 | }; 154 | expect(actual).toMatchObject({ 155 | body: { 156 | foo: 3, 157 | "bar[0]": "a", 158 | "bar[1]": "b", 159 | "baz.level1.level2.foo": 3, 160 | "baz.level1.level2.bar[0]": "a", 161 | "baz.level1.level2.bar[1]": "b", 162 | }, 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /client/components/Mocks.scss: -------------------------------------------------------------------------------- 1 | @import "../variables.scss"; 2 | 3 | #root .mocks { 4 | padding: 1em 5% 0; 5 | 6 | @media screen and (max-width: 1200px) { 7 | padding: 1em 2em 0; 8 | } 9 | 10 | .container { 11 | position: relative; 12 | 13 | .absolute { 14 | position: absolute; 15 | bottom: 0; 16 | right: 1em; 17 | } 18 | } 19 | 20 | .action.buttons { 21 | button { 22 | margin-left: 0.5em; 23 | } 24 | } 25 | 26 | .drawer { 27 | .ant-drawer-body { 28 | overflow-y: auto; 29 | .form { 30 | background-color: #263238; 31 | border-radius: 3px; 32 | height: 100%; 33 | .CodeMirror { 34 | position: relative; 35 | height: auto; 36 | overflow-y: hidden; 37 | padding: 0.5em 0 0.5em 0.5em; 38 | margin: 0; 39 | border-radius: 3px; 40 | 41 | .CodeMirror-scroll { 42 | height: auto; 43 | } 44 | } 45 | } 46 | } 47 | .action.buttons { 48 | text-align: right; 49 | button { 50 | margin-left: 0.5em; 51 | } 52 | } 53 | } 54 | 55 | .mock { 56 | border-radius: 3px; 57 | background-color: white; 58 | position: relative; 59 | display: flex; 60 | padding: 0.5em; 61 | margin: 0.75em 0; 62 | border: 1px solid rgba(0, 0, 0, 0.125); 63 | flex-direction: column; 64 | 65 | strong { 66 | font-weight: bolder; 67 | } 68 | 69 | .meta { 70 | display: flex; 71 | justify-content: space-between; 72 | padding: 1em; 73 | margin-bottom: 0.5em; 74 | border-bottom: 1px dashed $color-grey-dark; 75 | 76 | .label { 77 | font-weight: bolder; 78 | margin-right: 0.5em; 79 | } 80 | 81 | .date { 82 | flex: 1 1 auto; 83 | text-align: right; 84 | font-weight: bolder; 85 | } 86 | } 87 | 88 | .content { 89 | display: flex; 90 | flex-direction: row; 91 | .request, 92 | .response { 93 | width: 50%; 94 | padding: 1em; 95 | display: flex; 96 | flex-direction: column; 97 | 98 | .body-matcher { 99 | margin-bottom: 0.5em; 100 | } 101 | 102 | .details { 103 | display: flex; 104 | align-items: center; 105 | justify-content: space-between; 106 | margin-bottom: 1em; 107 | &:only-child { 108 | margin-bottom: 0em; 109 | } 110 | 111 | .group { 112 | display: flex; 113 | align-items: center; 114 | } 115 | 116 | span, 117 | strong { 118 | border-radius: 3px; 119 | font-size: 0.75rem; 120 | white-space: nowrap; 121 | 122 | &.fluid { 123 | flex: 1 1 auto; 124 | text-align: right; 125 | font-weight: bolder; 126 | } 127 | 128 | &.wrong { 129 | color: $color-red-dark; 130 | } 131 | 132 | & + span, 133 | & + strong { 134 | margin-left: 0.5em; 135 | } 136 | } 137 | } 138 | 139 | table { 140 | border-collapse: collapse; 141 | border-radius: 3px; 142 | border-style: hidden; 143 | box-shadow: 0 0 0 1px $color-grey-light; 144 | width: 100%; 145 | background-color: rgba($color-white-dark, 0.125); 146 | font-size: $base-font-size; 147 | margin-bottom: 1em; 148 | } 149 | 150 | tr + tr { 151 | border-top: 1px solid $color-grey-light; 152 | } 153 | 154 | td { 155 | width: 50%; 156 | padding: 0.5em 0.7em; 157 | word-break: break-all; 158 | 159 | &:nth-child(1) { 160 | font-weight: bolder; 161 | } 162 | } 163 | } 164 | 165 | .request .details .group { 166 | overflow: hidden; 167 | text-overflow: ellipsis; 168 | .method { 169 | border-radius: 3px; 170 | color: $color-white-light; 171 | padding: 0.5em; 172 | background-color: $color-blue-dark; 173 | } 174 | 175 | .path { 176 | font-family: monospace; 177 | font-weight: bold; 178 | } 179 | } 180 | 181 | .response { 182 | border-left: 1px dashed $color-grey-dark; 183 | 184 | .status, 185 | .engine { 186 | border-radius: 3px; 187 | padding: 0.5em; 188 | 189 | &.info { 190 | color: $color-white-light; 191 | background-color: $color-blue-dark; 192 | } 193 | 194 | &.failure { 195 | color: $color-white-light; 196 | background-color: $color-red-dark; 197 | } 198 | } 199 | } 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /server/handlers/mocks.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "github.com/labstack/echo/v4" 11 | log "github.com/sirupsen/logrus" 12 | "github.com/smocker-dev/smocker/server/services" 13 | "github.com/smocker-dev/smocker/server/templates" 14 | "github.com/smocker-dev/smocker/server/types" 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | type Mocks struct { 19 | mocksServices services.Mocks 20 | mu sync.Mutex 21 | } 22 | 23 | func NewMocks(ms services.Mocks) *Mocks { 24 | return &Mocks{ 25 | mocksServices: ms, 26 | mu: sync.Mutex{}, 27 | } 28 | } 29 | 30 | func (m *Mocks) GenericHandler(c echo.Context) error { 31 | actualRequest := types.HTTPRequestToRequest(c.Request()) 32 | b, _ := yaml.Marshal(actualRequest) 33 | log.Debugf("Received request:\n---\n%s\n", string(b)) 34 | 35 | /* Request matching */ 36 | 37 | var ( 38 | matchingMock *types.Mock 39 | response *types.MockResponse 40 | err error 41 | ) 42 | exceededMocks := types.Mocks{} 43 | context := &types.Context{} 44 | session := m.mocksServices.GetLastSession() 45 | mocks, err := m.mocksServices.GetMocks(session.ID) 46 | if err != nil { 47 | return c.JSON(types.StatusSmockerInternalError, echo.Map{ 48 | "message": fmt.Sprintf("%s: %v", types.SmockerInternalError, err), 49 | "request": actualRequest, 50 | }) 51 | } 52 | 53 | for _, mock := range mocks { 54 | if mock.Request.Match(actualRequest) { 55 | matchingMock = mock 56 | if matchingMock.Context.Times > 0 && matchingMock.State.TimesCount >= matchingMock.Context.Times { 57 | b, _ = yaml.Marshal(mock) 58 | log.Tracef("Times exceeded, skipping mock:\n---\n%s\n", string(b)) 59 | exceededMocks = append(exceededMocks, mock) 60 | continue 61 | } 62 | 63 | b, _ = yaml.Marshal(matchingMock) 64 | log.Debugf("Matching mock:\n---\n%s\n", string(b)) 65 | context.MockID = mock.State.ID 66 | if mock.DynamicResponse != nil { 67 | response, err = templates.GenerateMockResponse(mock.DynamicResponse, actualRequest) 68 | context.MockType = "dynamic" 69 | if err != nil { 70 | c.Set(types.ContextKey, context) 71 | return c.JSON(types.StatusSmockerEngineExecutionError, echo.Map{ 72 | "message": fmt.Sprintf("%s: %v", types.SmockerEngineExecutionError, err), 73 | "request": actualRequest, 74 | }) 75 | } 76 | } else if mock.Proxy != nil { 77 | response, err = mock.Proxy.Redirect(actualRequest) 78 | context.MockType = "proxy" 79 | if err != nil { 80 | c.Set(types.ContextKey, context) 81 | return c.JSON(types.StatusSmockerProxyRedirectionError, echo.Map{ 82 | "message": fmt.Sprintf("%s: %v", types.SmockerProxyRedirectionError, err), 83 | "request": actualRequest, 84 | }) 85 | } 86 | } else if mock.Response != nil { 87 | context.MockType = "static" 88 | response = mock.Response 89 | } 90 | 91 | m.mu.Lock() 92 | matchingMock.State.TimesCount++ 93 | m.mu.Unlock() 94 | break 95 | } else { 96 | b, _ = yaml.Marshal(mock) 97 | log.Tracef("Skipping mock:\n---\n%s\n", string(b)) 98 | } 99 | } 100 | 101 | if response == nil { 102 | resp := echo.Map{ 103 | "message": types.SmockerMockNotFound, 104 | "request": actualRequest, 105 | } 106 | 107 | if len(exceededMocks) > 0 { 108 | for _, mock := range exceededMocks { 109 | m.mu.Lock() 110 | mock.State.TimesCount++ 111 | m.mu.Unlock() 112 | } 113 | resp["message"] = types.SmockerMockExceeded 114 | resp["nearest"] = exceededMocks 115 | } 116 | 117 | b, _ = yaml.Marshal(resp) 118 | log.Debugf("No mock found, returning:\n---\n%s\n", string(b)) 119 | return c.JSON(types.StatusSmockerMockNotFound, resp) 120 | } 121 | 122 | /* Response writing */ 123 | 124 | // Headers 125 | for key, values := range response.Headers { 126 | for _, value := range values { 127 | c.Response().Header().Add(key, value) 128 | } 129 | } 130 | 131 | // Delay 132 | var delay time.Duration 133 | if response.Delay.Min != response.Delay.Max { 134 | rand.Seed(time.Now().Unix()) 135 | var n int64 = int64(response.Delay.Max - response.Delay.Min) 136 | delay = time.Duration(rand.Int63n(n) + int64(response.Delay.Min)) 137 | } else { 138 | delay = response.Delay.Min 139 | } 140 | if delay > 0 { 141 | context.Delay = delay.String() 142 | } 143 | time.Sleep(delay) 144 | c.Set(types.ContextKey, context) 145 | 146 | // Status 147 | if response.Status == 0 { 148 | // Fallback to 200 OK 149 | response.Status = http.StatusOK 150 | } 151 | c.Response().WriteHeader(response.Status) 152 | 153 | // Body 154 | if _, err = c.Response().Write([]byte(response.Body)); err != nil { 155 | log.WithError(err).Error("Failed to write response body") 156 | return echo.NewHTTPError(types.StatusSmockerInternalError, fmt.Sprintf("%s: %v", types.SmockerInternalError, err)) 157 | } 158 | 159 | b, _ = yaml.Marshal(response) 160 | log.Debugf("Returned response:\n---\n%s\n", string(b)) 161 | return nil 162 | } 163 | -------------------------------------------------------------------------------- /client/components/MockEditor/MockRequestEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Col, Form, Input, Radio, Row, Select, Switch } from "antd"; 3 | import Code from "../Code"; 4 | import { BodyMatcherEditor } from "./BodyMatcherEditor"; 5 | import { KeyValueEditor } from "./KeyValueEditor"; 6 | import classNames from "classnames"; 7 | 8 | export const MockRequestEditor = (): JSX.Element => { 9 | const methodSelector = ( 10 | 11 | 18 | 19 | ); 20 | 21 | const regexToggle = ( 22 | 23 | 24 | 25 | ); 26 | 27 | // TODO: handle YAML and XML 28 | const languages = [ 29 | { label: "JSON", value: "json" }, 30 | { label: "Default", value: "txt" }, 31 | ]; 32 | 33 | const fallbackMatchers = [ 34 | { label: "ShouldEqual", value: "ShouldEqual" }, 35 | { label: "ShouldMatch", value: "ShouldMatch" }, 36 | { label: "ShouldEqualJSON", value: "ShouldEqualJSON" }, 37 | { label: "ShouldContainSubstring", value: "ShouldContainSubstring" }, 38 | ]; 39 | 40 | return ( 41 | <> 42 | 43 | 48 | 49 | 50 | 51 | 52 | Query parameters: 53 | 54 | 55 | 56 | 57 | Headers: 58 | 59 | 60 | 61 | 62 | 65 | prevValues?.request?.method !== currentValues?.request?.method 66 | } 67 | > 68 | {({ getFieldValue }) => 69 | getFieldValue(["request", "method"]) !== "GET" && ( 70 |
71 | Body: 72 | 73 | 84 | 85 | 88 | prevValues?.request?.body_type !== 89 | currentValues?.request?.body_type 90 | } 91 | > 92 | {({ getFieldValue }) => ( 93 | <> 94 | {/* Use divs with display to make sure the form components render */} 95 |
101 | 102 |
103 |
109 | 110 | 120 | 121 | 122 | 123 | 124 |
125 | 126 | )} 127 |
128 |
129 | ) 130 | } 131 |
132 | 133 | ); 134 | }; 135 | -------------------------------------------------------------------------------- /tests/features/verify_session.yml: -------------------------------------------------------------------------------- 1 | name: Use and verify resticted mocks 2 | version: "2" 3 | testcases: 4 | - name: Use restricted mocks 5 | steps: 6 | - type: http 7 | method: POST 8 | url: http://localhost:8081/reset 9 | - type: http 10 | method: POST 11 | url: http://localhost:8081/mocks 12 | headers: 13 | Content-Type: "application/x-yaml" 14 | bodyFile: ../data/restricted_mock_list.yml 15 | assertions: 16 | - result.statuscode ShouldEqual 200 17 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 18 | 19 | # 'test' and 'test2' should match 1 times each 20 | # 'test2' is loaded after 'test' so it will come out first 21 | - type: http 22 | method: GET 23 | url: http://localhost:8080/test 24 | assertions: 25 | - result.statuscode ShouldEqual 200 26 | - result.bodyjson.message ShouldEqual test2 27 | - type: http 28 | method: GET 29 | url: http://localhost:8080/test 30 | assertions: 31 | - result.statuscode ShouldEqual 200 32 | - result.bodyjson.message ShouldEqual test 33 | 34 | # 'test3' should match 2 times 35 | - type: http 36 | method: POST 37 | url: http://localhost:8080/test 38 | assertions: 39 | - result.statuscode ShouldEqual 200 40 | - result.bodyjson.message ShouldEqual test3 41 | - type: http 42 | method: POST 43 | url: http://localhost:8080/test 44 | assertions: 45 | - result.statuscode ShouldEqual 200 46 | - result.bodyjson.message ShouldEqual test3 47 | 48 | # 'test4' should match any times 49 | - type: http 50 | method: PUT 51 | url: http://localhost:8080/test 52 | assertions: 53 | - result.statuscode ShouldEqual 200 54 | - result.bodyjson.message ShouldEqual test4 55 | - type: http 56 | method: PUT 57 | url: http://localhost:8080/test 58 | assertions: 59 | - result.statuscode ShouldEqual 200 60 | - result.bodyjson.message ShouldEqual test4 61 | - type: http 62 | method: PUT 63 | url: http://localhost:8080/test 64 | assertions: 65 | - result.statuscode ShouldEqual 200 66 | - result.bodyjson.message ShouldEqual test4 67 | 68 | # The mocks should be verified 69 | - type: http 70 | method: POST 71 | url: http://localhost:8081/sessions/verify 72 | assertions: 73 | - result.statuscode ShouldEqual 200 74 | - result.bodyjson.mocks.verified ShouldBeTrue 75 | - result.bodyjson.mocks.all_used ShouldBeTrue 76 | - result.bodyjson.mocks.message ShouldEqual "All mocks match expectations" 77 | - result.bodyjson.history.verified ShouldBeTrue 78 | - result.bodyjson.history.message ShouldEqual "History is clean" 79 | 80 | # We add an extra call to 'test'/'test2' wich should failed because call times was exceeded 81 | - type: http 82 | method: GET 83 | url: http://localhost:8080/test 84 | assertions: 85 | - result.statuscode ShouldEqual 666 86 | - result.bodyjson.message ShouldEqual "Matching mock found but was exceeded" 87 | # both 'test' and 'test2' would have both matched if they had not exceeded their times count. 88 | - result.bodyjson.nearest.__len__ ShouldEqual 2 89 | 90 | # After the extra call the expectations should failed 91 | - type: http 92 | method: POST 93 | url: http://localhost:8081/sessions/verify 94 | assertions: 95 | - result.statuscode ShouldEqual 200 96 | - result.bodyjson.mocks.verified ShouldBeFalse 97 | - result.bodyjson.mocks.all_used ShouldBeTrue 98 | - result.bodyjson.mocks.message ShouldEqual "Some mocks don't match expectations" 99 | - result.bodyjson.mocks.failures.__len__ ShouldEqual 2 100 | - result.bodyjson.history.verified ShouldBeFalse 101 | - result.bodyjson.history.failures.__len__ ShouldEqual 1 102 | 103 | # We add an extra unused mocks 104 | - type: http 105 | method: POST 106 | url: http://localhost:8081/mocks 107 | headers: 108 | Content-Type: "application/x-yaml" 109 | body: > 110 | - request: 111 | method: GET 112 | path: /test 113 | response: 114 | status: 200 115 | assertions: 116 | - result.statuscode ShouldEqual 200 117 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 118 | 119 | # After that there should be an unused mocks 120 | - type: http 121 | method: POST 122 | url: http://localhost:8081/sessions/verify 123 | assertions: 124 | - result.statuscode ShouldEqual 200 125 | - result.bodyjson.mocks.message ShouldEqual "Some mocks don't match expectations" 126 | - result.bodyjson.mocks.all_used ShouldBeFalse 127 | - result.bodyjson.mocks.unused.__len__ ShouldEqual 1 128 | -------------------------------------------------------------------------------- /server/middlewares.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net" 11 | "net/http" 12 | "runtime" 13 | "time" 14 | 15 | "github.com/labstack/echo/v4" 16 | log "github.com/sirupsen/logrus" 17 | "github.com/smocker-dev/smocker/server/services" 18 | "github.com/smocker-dev/smocker/server/types" 19 | ) 20 | 21 | type bodyDumpResponseWriter struct { 22 | io.Writer 23 | http.ResponseWriter 24 | } 25 | 26 | func (w *bodyDumpResponseWriter) WriteHeader(code int) { 27 | w.ResponseWriter.WriteHeader(code) 28 | } 29 | 30 | func (w *bodyDumpResponseWriter) Write(b []byte) (int, error) { 31 | return w.Writer.Write(b) 32 | } 33 | 34 | func (w *bodyDumpResponseWriter) Flush() { 35 | w.ResponseWriter.(http.Flusher).Flush() 36 | } 37 | 38 | func (w *bodyDumpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 39 | return w.ResponseWriter.(http.Hijacker).Hijack() 40 | } 41 | 42 | func HistoryMiddleware(s services.Mocks) echo.MiddlewareFunc { 43 | return func(next echo.HandlerFunc) echo.HandlerFunc { 44 | return func(c echo.Context) error { 45 | session := s.GetLastSession() 46 | if c.Request() == nil { 47 | return echo.NewHTTPError(types.StatusSmockerInternalError, fmt.Sprintf("%s: Empty request", types.SmockerInternalError)) 48 | } 49 | 50 | request := types.HTTPRequestToRequest(c.Request()) 51 | request.Date = time.Now() 52 | 53 | responseBody := new(bytes.Buffer) 54 | mw := io.MultiWriter(c.Response().Writer, responseBody) 55 | writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: c.Response().Writer} 56 | c.Response().Writer = writer 57 | 58 | if err := next(c); err != nil { 59 | return echo.NewHTTPError(types.StatusSmockerInternalError, fmt.Sprintf("%s: %v", types.SmockerInternalError, err)) 60 | } 61 | 62 | responseBytes := responseBody.Bytes() 63 | if c.Response().Header().Get("Content-Encoding") == "gzip" { 64 | r, err := gzip.NewReader(responseBody) 65 | if err != nil { 66 | log.WithError(err).Error("Unable to uncompress response body") 67 | } else { 68 | responseBytes, err = io.ReadAll(r) 69 | if err != nil { 70 | log.WithError(err).Error("Unable to read uncompressed response body") 71 | responseBytes = responseBody.Bytes() 72 | } 73 | } 74 | } 75 | 76 | var body interface{} 77 | if err := json.Unmarshal(responseBytes, &body); err != nil { 78 | body = string(responseBytes) 79 | } 80 | 81 | context, _ := c.Get(types.ContextKey).(*types.Context) 82 | if context == nil { 83 | context = &types.Context{} 84 | } 85 | _, err := s.AddHistoryEntry(session.ID, &types.Entry{ 86 | Context: *context, 87 | Request: request, 88 | Response: types.Response{ 89 | Status: c.Response().Status, 90 | Body: body, 91 | Headers: c.Response().Header(), 92 | Date: time.Now(), 93 | }, 94 | }) 95 | if err != nil { 96 | return echo.NewHTTPError(types.StatusSmockerInternalError, fmt.Sprintf("%s: %v", types.SmockerInternalError, err)) 97 | } 98 | return nil 99 | } 100 | } 101 | } 102 | 103 | func loggerMiddleware() echo.MiddlewareFunc { 104 | return func(next echo.HandlerFunc) echo.HandlerFunc { 105 | return func(c echo.Context) error { 106 | req := c.Request() 107 | 108 | start := time.Now() 109 | p := req.URL.Path 110 | if p == "" { 111 | p = "/" 112 | } 113 | 114 | bytesIn := req.Header.Get(echo.HeaderContentLength) 115 | if bytesIn == "" { 116 | bytesIn = "0" 117 | } 118 | 119 | headers := fmt.Sprintf("%+v", req.Header) 120 | 121 | entry := log.WithFields(log.Fields{ 122 | "start": start.Format(time.RFC3339), 123 | "remote-ip": c.RealIP(), 124 | "host": req.Host, 125 | "uri": req.RequestURI, 126 | "method": req.Method, 127 | "path": p, 128 | "headers": headers, 129 | "bytes-in": bytesIn, 130 | }) 131 | entry.Debug("Handling request...") 132 | 133 | if err := next(c); err != nil { 134 | c.Error(err) 135 | } 136 | 137 | res := c.Response() 138 | end := time.Now() 139 | entry = entry.WithFields(log.Fields{ 140 | "end": end.Format(time.RFC3339), 141 | "status": res.Status, 142 | "latency": end.Sub(start).String(), 143 | "bytes-out": res.Size, 144 | }) 145 | 146 | switch { 147 | case res.Status < 400: 148 | entry.Info("Handled request") 149 | case res.Status < 500: 150 | entry.Warn("Handled request") 151 | default: 152 | entry.Error("Handled request") 153 | } 154 | 155 | return nil 156 | } 157 | } 158 | } 159 | 160 | // Same as echo's RecoverWithConfig middleware, with DefaultRecoverConfig 161 | func recoverMiddleware() echo.MiddlewareFunc { 162 | return func(next echo.HandlerFunc) echo.HandlerFunc { 163 | return func(c echo.Context) error { 164 | defer func() { 165 | if r := recover(); r != nil { 166 | err, ok := r.(error) 167 | if !ok { 168 | err = fmt.Errorf("%v", r) 169 | } 170 | stack := make([]byte, 4<<10) // 4 KB 171 | length := runtime.Stack(stack, true) 172 | log.WithError(err).Errorf("[PANIC RECOVER] %s", stack[:length]) 173 | c.Error(err) 174 | } 175 | }() 176 | return next(c) 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /tests/features/use_sessions.yml: -------------------------------------------------------------------------------- 1 | name: Retrieve sessions 2 | version: "2" 3 | testcases: 4 | - name: Init Smocker 5 | steps: 6 | - type: http 7 | method: POST 8 | url: http://localhost:8081/reset 9 | 10 | - name: Create 'test' session, add mocks, use them and check history 11 | steps: 12 | - type: http 13 | method: POST 14 | url: http://localhost:8081/sessions?name=test 15 | - type: http 16 | method: POST 17 | url: http://localhost:8081/mocks 18 | bodyFile: ../data/dynamic_mock_list.yml 19 | assertions: 20 | - result.statuscode ShouldEqual 200 21 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 22 | - type: http 23 | method: GET 24 | url: http://localhost:8080/test 25 | assertions: 26 | - result.statuscode ShouldEqual 200 27 | - type: http 28 | method: GET 29 | url: http://localhost:8081/history 30 | assertions: 31 | - result.statuscode ShouldEqual 200 32 | - result.bodyjson.__len__ ShouldEqual 1 33 | - result.bodyjson.bodyjson0.request.path ShouldEqual /test 34 | - result.bodyjson.bodyjson0.response.body.message ShouldEqual "request path /test" 35 | 36 | - name: Create 'test2' session, add mocks, use them and check history 37 | steps: 38 | - type: http 39 | method: POST 40 | url: http://localhost:8081/sessions?name=test2 41 | - type: http 42 | method: POST 43 | url: http://localhost:8081/mocks 44 | bodyFile: ../data/dynamic_mock_list.yml 45 | assertions: 46 | - result.statuscode ShouldEqual 200 47 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 48 | - type: http 49 | method: GET 50 | url: http://localhost:8080/test2 51 | assertions: 52 | - result.statuscode ShouldEqual 200 53 | - type: http 54 | method: GET 55 | url: http://localhost:8081/history 56 | assertions: 57 | - result.statuscode ShouldEqual 200 58 | - result.bodyjson.__len__ ShouldEqual 1 59 | - result.bodyjson.bodyjson0.request.path ShouldEqual /test2 60 | - result.bodyjson.bodyjson0.response.body.message ShouldEqual "request path /test2" 61 | 62 | - name: Create 'test3' session, add mocks, use them and check history 63 | steps: 64 | - type: http 65 | method: POST 66 | url: http://localhost:8081/sessions?name=test3 67 | - type: http 68 | method: POST 69 | url: http://localhost:8081/mocks 70 | bodyFile: ../data/dynamic_mock_list.yml 71 | assertions: 72 | - result.statuscode ShouldEqual 200 73 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 74 | - type: http 75 | method: GET 76 | url: http://localhost:8080/test3 77 | assertions: 78 | - result.statuscode ShouldEqual 200 79 | - type: http 80 | method: GET 81 | url: http://localhost:8081/history 82 | assertions: 83 | - result.statuscode ShouldEqual 200 84 | - result.bodyjson.__len__ ShouldEqual 1 85 | - result.bodyjson.bodyjson0.request.path ShouldEqual /test3 86 | - result.bodyjson.bodyjson0.response.body.message ShouldEqual "request path /test3" 87 | 88 | - name: Retrieve sessions and check histories 89 | steps: 90 | - type: http 91 | method: GET 92 | url: http://localhost:8081/sessions 93 | assertions: 94 | - result.statuscode ShouldEqual 200 95 | - result.bodyjson.__len__ ShouldEqual 3 96 | - result.bodyjson.bodyjson0.name ShouldEqual test 97 | - result.bodyjson.bodyjson0.history.history0.response.body.message ShouldEqual "request path /test" 98 | - result.bodyjson.bodyjson1.name ShouldEqual test2 99 | - result.bodyjson.bodyjson1.history.history0.response.body.message ShouldEqual "request path /test2" 100 | - result.bodyjson.bodyjson2.name ShouldEqual test3 101 | - result.bodyjson.bodyjson2.history.history0.response.body.message ShouldEqual "request path /test3" 102 | 103 | - name: RetrieveSessionsSummary 104 | steps: 105 | - type: http 106 | method: GET 107 | url: http://localhost:8081/sessions/summary 108 | assertions: 109 | - result.statuscode ShouldEqual 200 110 | - result.bodyjson.__len__ ShouldEqual 3 111 | - result.bodyjson.bodyjson0.name ShouldEqual test 112 | - result.bodyjson.bodyjson1.name ShouldEqual test2 113 | - result.bodyjson.bodyjson2.name ShouldEqual test3 114 | vars: 115 | session_id: 116 | from: result.bodyjson.bodyjson0.id 117 | - type: http 118 | method: PUT 119 | url: http://localhost:8081/sessions 120 | headers: 121 | Content-Type: "application/json" 122 | body: > 123 | { "id": "{{.RetrieveSessionsSummary.session_id}}", "name": "test4"} 124 | assertions: 125 | - result.statuscode ShouldEqual 200 126 | - type: http 127 | method: GET 128 | url: http://localhost:8081/sessions/summary 129 | assertions: 130 | - result.statuscode ShouldEqual 200 131 | - result.bodyjson.__len__ ShouldEqual 3 132 | - result.bodyjson.bodyjson0.name ShouldEqual test4 133 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APPNAME=$(shell basename $(shell go list)) 2 | VERSION?=snapshot 3 | COMMIT=$(shell git rev-parse --verify HEAD) 4 | DATE?=$(shell date +%FT%T%z) 5 | RELEASE?=0 6 | 7 | GOPATH?=$(shell go env GOPATH) 8 | GO_LDFLAGS+=-X main.appName=$(APPNAME) 9 | GO_LDFLAGS+=-X main.buildVersion=$(VERSION) 10 | GO_LDFLAGS+=-X main.buildCommit=$(COMMIT) 11 | GO_LDFLAGS+=-X main.buildDate=$(DATE) 12 | ifeq ($(RELEASE), 1) 13 | # Strip debug information from the binary 14 | GO_LDFLAGS+=-s -w 15 | endif 16 | GO_LDFLAGS:=-ldflags="$(GO_LDFLAGS)" 17 | 18 | DOCKER_IMAGE=ghcr.io/smocker-dev/smocker 19 | 20 | # See: https://docs.docker.com/engine/reference/commandline/tag/#extended-description 21 | # A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes. 22 | # A tag name may not start with a period or a dash and may contain a maximum of 128 characters. 23 | DOCKER_TAG:=$(shell echo $(VERSION) | tr -cd '[:alnum:]_.-') 24 | IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$") 25 | 26 | LEVEL=debug 27 | 28 | SUITE=*.yml 29 | 30 | .PHONY: default 31 | default: start 32 | 33 | REFLEX=$(GOPATH)/bin/reflex 34 | $(REFLEX): 35 | go install github.com/cespare/reflex@latest 36 | 37 | GOLANGCILINTVERSION:=1.64.8 38 | GOLANGCILINT=$(GOPATH)/bin/golangci-lint 39 | $(GOLANGCILINT): 40 | curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOPATH)/bin v$(GOLANGCILINTVERSION) 41 | 42 | VENOMVERSION:=v1.0.0-rc.6 43 | VENOM=$(GOPATH)/bin/venom 44 | $(VENOM): 45 | go install github.com/ovh/venom/cmd/venom@$(VENOMVERSION) 46 | 47 | GOCOVMERGE=$(GOPATH)/bin/gocovmerge 48 | $(GOCOVMERGE): 49 | go install github.com/wadey/gocovmerge@latest 50 | 51 | CADDY=$(GOPATH)/bin/caddy 52 | $(CADDY): 53 | cd /tmp; go get github.com/caddyserver/caddy/v2/... 54 | 55 | .PHONY: persistence 56 | persistence: 57 | rm -rf ./sessions || true 58 | cp -r tests/sessions sessions 59 | 60 | .PHONY: start 61 | start: $(REFLEX) persistence 62 | $(REFLEX) --start-service \ 63 | --decoration='none' \ 64 | --regex='\.go$$' \ 65 | --inverse-regex='^vendor|node_modules|.cache/' \ 66 | -- go run $(GO_LDFLAGS) main.go --log-level=$(LEVEL) --static-files ./build/client --persistence-directory ./sessions 67 | 68 | .PHONY: build 69 | build: 70 | go build -trimpath $(GO_LDFLAGS) -o ./build/$(APPNAME) 71 | 72 | .PHONY: lint 73 | lint: $(GOLANGCILINT) 74 | $(GOLANGCILINT) run 75 | 76 | .PHONY: format 77 | format: 78 | gofmt -s -w . 79 | 80 | .PHONY: test 81 | test: 82 | mkdir -p coverage 83 | go test -v -race -coverprofile=coverage/test-cover.out ./server/... 84 | 85 | PID_FILE=/tmp/$(APPNAME).test.pid 86 | .PHONY: test-integration 87 | test-integration: $(VENOM) check-default-ports persistence 88 | mkdir -p coverage 89 | go test -race -coverpkg="./..." -c . -o $(APPNAME).test 90 | SMOCKER_PERSISTENCE_DIRECTORY=./sessions ./$(APPNAME).test -test.coverprofile=coverage/test-integration-cover.out >/dev/null 2>&1 & echo $$! > $(PID_FILE) 91 | sleep 5 92 | $(VENOM) run tests/features/$(SUITE) 93 | kill `cat $(PID_FILE)` 2> /dev/null || true 94 | 95 | .PHONY: start-integration 96 | start-integration: $(VENOM) 97 | $(VENOM) run tests/features/$(SUITE) 98 | 99 | coverage/test-cover.out: 100 | $(MAKE) test 101 | 102 | coverage/test-integration-cover.out: 103 | $(MAKE) test-integration 104 | 105 | .PHONY: coverage 106 | coverage: $(GOCOVMERGE) coverage/test-cover.out coverage/test-integration-cover.out 107 | $(GOCOVMERGE) coverage/test-cover.out coverage/test-integration-cover.out > coverage/cover.out 108 | 109 | .PHONY: clean 110 | clean: 111 | rm -rf ./build ./coverage 112 | 113 | .PHONY: build-docker 114 | build-docker: 115 | docker build --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --tag $(DOCKER_IMAGE):latest . 116 | docker tag $(DOCKER_IMAGE) $(DOCKER_IMAGE):$(DOCKER_TAG) 117 | 118 | .PHONY: start-docker 119 | start-docker: check-default-ports 120 | docker run -d -p 8080:8080 -p 8081:8081 --name $(APPNAME) $(DOCKER_IMAGE):$(DOCKER_TAG) 121 | 122 | .PHONY: check-default-ports 123 | check-default-ports: 124 | @lsof -i:8080 > /dev/null && (echo "Port 8080 already in use"; exit 1) || true 125 | @lsof -i:8081 > /dev/null && (echo "Port 8081 already in use"; exit 1) || true 126 | 127 | .PHONY: optimize 128 | optimize: 129 | find client/assets/ -iname '*.png' -print0 | xargs -0 -n1 optipng -strip all 130 | find docs/ -iname '*.png' -print0 | xargs -0 -n1 optipng -strip all 131 | 132 | # The following targets are only available for CI usage 133 | 134 | build/smocker.tar.gz: 135 | $(MAKE) build 136 | yarn install --frozen-lockfile --ignore-scripts 137 | yarn build 138 | cd build/; tar -czvf smocker.tar.gz * 139 | 140 | .PHONY: release 141 | release: build/smocker.tar.gz 142 | 143 | .PHONY: start-release 144 | start-release: clean build/smocker.tar.gz 145 | cd build/; ./smocker --config-base-path=/smocker/ 146 | 147 | .PHONY: start-caddy 148 | start-caddy: $(CADDY) 149 | $(CADDY) run 150 | 151 | .PHONY: deploy-docker 152 | deploy-docker: 153 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 154 | docker buildx create --use 155 | ifdef IS_SEMVER 156 | docker buildx build --push --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --tag $(DOCKER_IMAGE):latest . 157 | endif 158 | docker buildx build --push --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --tag $(DOCKER_IMAGE):$(DOCKER_TAG) . 159 | -------------------------------------------------------------------------------- /client/modules/types.ts: -------------------------------------------------------------------------------- 1 | import { fold, left } from "fp-ts/lib/Either"; 2 | import { pipe } from "fp-ts/lib/pipeable"; 3 | import * as t from "io-ts"; 4 | import { PathReporter } from "io-ts/lib/PathReporter"; 5 | import { Observable, of, throwError } from "rxjs"; 6 | 7 | export const dateFormat = "ddd, D MMM YYYY HH:mm:ss.SSS"; 8 | export const defaultMatcher = "ShouldEqual"; 9 | 10 | export const ErrorCodec = t.type({ 11 | message: t.union([t.string, t.undefined]), 12 | }); 13 | export type SmockerError = t.TypeOf; 14 | 15 | export const SessionCodec = t.type({ 16 | id: t.string, 17 | name: t.string, 18 | date: t.string, 19 | }); 20 | export type Session = t.TypeOf; 21 | 22 | export const SessionsCodec = t.array(SessionCodec); 23 | export type Sessions = t.TypeOf; 24 | 25 | const MultimapCodec = t.record(t.string, t.array(t.string)); 26 | export type Multimap = t.TypeOf; 27 | 28 | const StringMatcherCodec = t.type({ 29 | matcher: t.string, 30 | value: t.string, 31 | }); 32 | export type StringMatcher = t.TypeOf; 33 | 34 | const StringMatcherSliceCodec = t.array(StringMatcherCodec); 35 | export type StringMatcherSlice = t.TypeOf; 36 | 37 | const StringMatcherMapCodec = t.record(t.string, StringMatcherCodec); 38 | export type StringMatcherMap = t.TypeOf; 39 | 40 | const MultimapMatcherCodec = t.record(t.string, StringMatcherSliceCodec); 41 | export type MultimapMatcher = t.TypeOf; 42 | 43 | const BodyMatcherCodec = t.union([StringMatcherCodec, StringMatcherMapCodec]); 44 | export type BodyMatcher = t.TypeOf; 45 | 46 | const EntryContextCodec = t.type({ 47 | mock_id: t.union([t.string, t.undefined]), 48 | mock_type: t.union([t.string, t.undefined]), 49 | delay: t.union([t.string, t.undefined]), 50 | }); 51 | export type EntryContext = t.TypeOf; 52 | 53 | const EntryRequestCodec = t.type({ 54 | path: t.string, 55 | method: t.string, 56 | body: t.union([t.unknown, t.undefined]), 57 | query_params: t.union([MultimapCodec, t.undefined]), 58 | headers: t.union([MultimapCodec, t.undefined]), 59 | date: t.string, 60 | }); 61 | export type EntryRequest = t.TypeOf; 62 | 63 | const EntryResponseCodec = t.type({ 64 | status: t.number, 65 | body: t.union([t.unknown, t.undefined]), 66 | headers: t.union([MultimapCodec, t.undefined]), 67 | date: t.string, 68 | }); 69 | export type EntryResponse = t.TypeOf; 70 | 71 | const EntryCodec = t.type({ 72 | context: EntryContextCodec, 73 | request: EntryRequestCodec, 74 | response: EntryResponseCodec, 75 | }); 76 | export type Entry = t.TypeOf; 77 | 78 | export const HistoryCodec = t.array(EntryCodec); 79 | export type History = t.TypeOf; 80 | 81 | const MockRequestCodec = t.type({ 82 | path: StringMatcherCodec, 83 | method: StringMatcherCodec, 84 | body: t.union([BodyMatcherCodec, t.undefined]), 85 | query_params: t.union([MultimapMatcherCodec, t.undefined]), 86 | headers: t.union([MultimapMatcherCodec, t.undefined]), 87 | }); 88 | export type MockRequest = t.TypeOf; 89 | 90 | const MockResponseCodec = t.type({ 91 | status: t.number, 92 | body: t.union([t.undefined, t.unknown]), 93 | headers: t.union([MultimapCodec, t.undefined]), 94 | }); 95 | export type MockResponse = t.TypeOf; 96 | 97 | const MockDynamicResponseCodec = t.type({ 98 | engine: t.union([ 99 | t.literal("go_template"), 100 | t.literal("go_template_yaml"), 101 | t.literal("go_template_json"), 102 | t.literal("lua"), 103 | ]), 104 | script: t.string, 105 | }); 106 | export type MockDynamicResponse = t.TypeOf; 107 | 108 | const MockProxyCodec = t.type({ 109 | host: t.string, 110 | }); 111 | export type MockProxy = t.TypeOf; 112 | 113 | const MockContextCodec = t.type({ 114 | times: t.union([t.number, t.undefined]), 115 | }); 116 | export type MockContext = t.TypeOf; 117 | 118 | const MockStateCodec = t.type({ 119 | times_count: t.number, 120 | creation_date: t.string, 121 | id: t.string, 122 | locked: t.boolean, 123 | }); 124 | export type MockState = t.TypeOf; 125 | 126 | const MockCodec = t.type({ 127 | request: MockRequestCodec, 128 | response: t.union([MockResponseCodec, t.undefined]), 129 | dynamic_response: t.union([MockDynamicResponseCodec, t.undefined]), 130 | proxy: t.union([MockProxyCodec, t.undefined]), 131 | context: MockContextCodec, 132 | state: MockStateCodec, 133 | }); 134 | export type Mock = t.TypeOf; 135 | 136 | export const MocksCodec = t.array(MockCodec); 137 | export type Mocks = t.TypeOf; 138 | 139 | const GraphEntryCodec = t.type({ 140 | type: t.string, 141 | message: t.string, 142 | from: t.string, 143 | to: t.string, 144 | date: t.string, 145 | }); 146 | export type GraphEntry = t.TypeOf; 147 | 148 | export const GraphHistoryCodec = t.array(GraphEntryCodec); 149 | export type GraphHistory = t.TypeOf; 150 | 151 | export function decode( 152 | codec: C 153 | ): (json: unknown) => Observable> { 154 | return (json) => { 155 | return pipe( 156 | codec.decode(json), 157 | fold( 158 | (error) => 159 | throwError(new Error(PathReporter.report(left(error)).join("\n"))), 160 | (data) => of(data) 161 | ) 162 | ); 163 | }; 164 | } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Smocker 3 |

4 | 5 | [![CI](https://github.com/smocker-dev/smocker/actions/workflows/main.yml/badge.svg)](https://github.com/smocker-dev/smocker/actions/workflows/main.yml) 6 | [![Docker Repository](https://img.shields.io/badge/ghcr.io%2Fsmocker--dev%2Fsmocker-blue?logo=docker&label=docker)](https://github.com/smocker-dev/smocker/pkgs/container/smocker) 7 | [![Github Release](https://img.shields.io/github/v/release/smocker-dev/smocker.svg?logo=github)](https://github.com/smocker-dev/smocker/releases/latest) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/smocker-dev/smocker)](https://goreportcard.com/report/github.com/smocker-dev/smocker) 9 | [![License](https://img.shields.io/github/license/smocker-dev/smocker?logo=open-source-initiative)](https://github.com/smocker-dev/smocker/blob/main/LICENSE) 10 | 11 | **Smocker** (server mock) is a simple and efficient HTTP mock server. 12 | 13 | The documentation is available on [smocker.dev](https://smocker.dev). 14 | 15 | ## Table of contents 16 | 17 | - [Installation](#installation) 18 | - [With Docker](#with-docker) 19 | - [Manual Deployment](#manual-deployment) 20 | - [Healthcheck](#healthcheck) 21 | - [User Interface](#user-interface) 22 | - [Usage](#usage) 23 | - [Hello, World!](#hello-world) 24 | - [Development](#development) 25 | - [Backend](#backend) 26 | - [Frontend](#frontend) 27 | - [Docker](#docker) 28 | - [Caddy](#caddy) 29 | - [HTTPS](#https) 30 | - [Authors](#authors) 31 | - [Contributors](#contributors) 32 | 33 | ## Installation 34 | 35 | ### With Docker 36 | 37 | ```sh 38 | docker run -d \ 39 | --restart=always \ 40 | -p 8080:8080 \ 41 | -p 8081:8081 \ 42 | --name smocker \ 43 | ghcr.io/smocker-dev/smocker 44 | ``` 45 | 46 | ### Manual Deployment 47 | 48 | ```sh 49 | # This will be the deployment folder for the Smocker instance 50 | mkdir -p /opt/smocker && cd /opt/smocker 51 | wget -P /tmp https://github.com/smocker-dev/smocker/releases/latest/download/smocker.tar.gz 52 | tar xf /tmp/smocker.tar.gz 53 | nohup ./smocker -mock-server-listen-port=8080 -config-listen-port=8081 & 54 | ``` 55 | 56 | ### Healthcheck 57 | 58 | ```sh 59 | curl localhost:8081/version 60 | ``` 61 | 62 | ### User Interface 63 | 64 | Smocker exposes a configuration user interface. You can access it in your web browser on http://localhost:8081/. 65 | 66 | ![History](docs/screenshots/screenshot-history.png) 67 | 68 | ![Mocks](docs/screenshots/screenshot-mocks.png) 69 | 70 | ## Usage 71 | 72 | Smocker exposes two ports: 73 | 74 | - `8080` is the mock server port. It will expose the routes you register through the configuration port 75 | - `8081` is the configuration port. It's the port you will use to register new mocks. This port also exposes a user interface. 76 | 77 | ### Hello, World! 78 | 79 | To register a mock, you can use the YAML and the JSON formats. A basic mock might look like this: 80 | 81 | ```yaml 82 | # helloworld.yml 83 | # This mock register two routes: GET /hello/world and GET /foo/bar 84 | - request: 85 | # Note: the method could be omitted because GET is the default 86 | method: GET 87 | path: /hello/world 88 | response: 89 | # Note: the status could be omitted because 200 is the default 90 | status: 200 91 | headers: 92 | Content-Type: application/json 93 | body: > 94 | { 95 | "hello": "Hello, World!" 96 | } 97 | 98 | - request: 99 | method: GET 100 | path: /foo/bar 101 | response: 102 | status: 204 103 | ``` 104 | 105 | You can then register it to the configuration server with the following command: 106 | 107 | ```sh 108 | curl -XPOST \ 109 | --header "Content-Type: application/x-yaml" \ 110 | --data-binary "@helloworld.yml" \ 111 | localhost:8081/mocks 112 | ``` 113 | 114 | After your mock is registered, you can query the mock server on the specified route, so that it returns the expected response to you: 115 | 116 | ```sh 117 | $ curl -i localhost:8080/hello/world 118 | HTTP/1.1 200 OK 119 | Content-Type: application/json 120 | Date: Thu, 05 Sep 2019 15:49:32 GMT 121 | Content-Length: 31 122 | 123 | { 124 | "hello": "Hello, World!" 125 | } 126 | ``` 127 | 128 | To cleanup the mock server without restarting it, you can execute the following command: 129 | 130 | ```sh 131 | curl -XPOST localhost:8081/reset 132 | ``` 133 | 134 | For more advanced usage, please read the [project's documentation](https://smocker.dev). 135 | 136 | ## Development 137 | 138 | ### Backend 139 | 140 | The backend is written in Go. You can use the following commands to manage the development lifecycle: 141 | 142 | - `make start`: start the backend in development mode, with live reload 143 | - `make build`, `make VERSION=xxx build`: compile the code and generate a binary 144 | - `make lint`: run static analysis on the code 145 | - `make format`: automatically format the backend code 146 | - `make test`: execute unit tests 147 | - `make test-integration`: execute integration tests 148 | 149 | ### Frontend 150 | 151 | The frontend is written with TypeScript and React. You can use the following commands to manage the development lifecycle: 152 | 153 | - `yarn install`: install the dependencies 154 | - `yarn start`: start the frontend in development mode, with live reload 155 | - `yarn build`: generate the transpiled and minified files and assets 156 | - `yarn lint`: run static analysis on the code 157 | - `yarn format`: automatically format the frontend code 158 | - `yarn test`: execute unit tests 159 | - `yarn test:watch`: execute unit tests, with live reload 160 | 161 | ### Docker 162 | 163 | The application can be packaged as a standalone Docker image. You can use the following commands to manage the development lifecycle: 164 | 165 | - `make build-docker`, `make VERSION=xxx build-docker`: build the application as a Docker image 166 | - `make start-docker`, `make VERSION=xxx start-docker`: run a Smocker Docker image 167 | 168 | ### Caddy 169 | 170 | If you need to test Smocker with a base path, you can use the Caddyfile provided in the repository ([Caddy v2](https://caddyserver.com/v2)): 171 | 172 | - `make start-release`, `make VERSION=xxx start-release`: create a released version of Smocker and launch it with `/smocker/` as base path 173 | - `make start-caddy`: start Caddy to make Smocker accessible at http://localhost:8082/smocker/ 174 | 175 | ### HTTPS 176 | 177 | If you need to test Smocker with HTTPS enabled, the easiest way is to generate a locally signed certificate with [mkcert](https://github.com/FiloSottile/mkcert): 178 | 179 | ```sh 180 | # Install the local certificate authority 181 | mkcert -install 182 | 183 | # Create a certificate for localhost 184 | mkcert -cert-file /tmp/cert.pem -key-file /tmp/key.pem localhost 185 | ``` 186 | 187 | Then, start Smocker with TLS enabled, using your generated certificate: 188 | 189 | ```sh 190 | ./smocker -mock-server-listen-port=44300 -config-listen-port=44301 -tls-enable -tls-cert-file=/tmp/cert.pem -tls-private-key-file=/tmp/key.pem 191 | ``` 192 | 193 | ## Authors 194 | 195 | - [Thibaut Rousseau](https://github.com/Thiht) 196 | - [Gwendal Leclerc](https://github.com/gwleclerc) 197 | 198 | ## Contributors 199 | 200 | - [Amanda Yoshiizumi (mandyellow)](https://github.com/mandyellow): thank you for your awesome logo! 201 | -------------------------------------------------------------------------------- /tests/features/set_mocks.yml: -------------------------------------------------------------------------------- 1 | name: Set mocks into smocker 2 | version: "2" 3 | testcases: 4 | - name: AddBasicMock 5 | steps: 6 | - type: http 7 | method: POST 8 | url: http://localhost:8081/mocks?reset=true 9 | headers: 10 | Content-Type: "application/x-yaml" 11 | bodyFile: ../data/basic_mock.yml 12 | assertions: 13 | - result.statuscode ShouldEqual 200 14 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 15 | 16 | - type: http 17 | method: GET 18 | url: http://localhost:8081/mocks 19 | assertions: 20 | - result.statuscode ShouldEqual 200 21 | - result.bodyjson.__len__ ShouldEqual 1 22 | - result.bodyjson.bodyjson0.request.method.matcher ShouldEqual ShouldMatch 23 | - result.bodyjson.bodyjson0.request.method.value ShouldEqual .* 24 | vars: 25 | mock_id: 26 | from: result.bodyjson.bodyjson0.state.id 27 | 28 | - type: http 29 | method: GET 30 | url: http://localhost:8081/mocks?id={{.AddBasicMock.mock_id}} 31 | assertions: 32 | - result.statuscode ShouldEqual 200 33 | - result.bodyjson.__len__ ShouldEqual 1 34 | - result.bodyjson.bodyjson0.state.id ShouldEqual {{.AddBasicMock.mock_id}} 35 | 36 | - name: Add basic mock list 37 | steps: 38 | - type: http 39 | method: POST 40 | url: http://localhost:8081/mocks?reset=true 41 | headers: 42 | Content-Type: "application/x-yaml" 43 | bodyFile: ../data/basic_mock_list.yml 44 | assertions: 45 | - result.statuscode ShouldEqual 200 46 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 47 | 48 | - type: http 49 | method: GET 50 | url: http://localhost:8081/mocks 51 | assertions: 52 | - result.statuscode ShouldEqual 200 53 | - result.bodyjson.__len__ ShouldEqual 7 54 | 55 | # Mocks are stored as a stack 56 | - result.bodyjson.bodyjson5.request.method.value ShouldEqual .* 57 | - result.bodyjson.bodyjson4.request.method.value ShouldEqual POST 58 | - result.bodyjson.bodyjson3.request.method.value ShouldEqual DELETE 59 | - result.bodyjson.bodyjson2.request.method.value ShouldEqual .* 60 | - result.bodyjson.bodyjson1.request.method.value ShouldEqual .* 61 | - result.bodyjson.bodyjson0.request.headers.x-custom-header-1.x-custom-header-10.value ShouldEqual bar 62 | 63 | - name: Add basic mock with reset after a basic mock list 64 | steps: 65 | - type: http 66 | method: POST 67 | url: http://localhost:8081/mocks?reset=true 68 | bodyFile: ../data/basic_mock_list.yml 69 | assertions: 70 | - result.statuscode ShouldEqual 200 71 | 72 | - type: http 73 | method: GET 74 | url: http://localhost:8081/mocks 75 | assertions: 76 | - result.bodyjson.__len__ ShouldEqual 7 77 | 78 | - type: http 79 | method: POST 80 | url: http://localhost:8081/mocks?reset=true 81 | bodyFile: ../data/basic_mock.yml 82 | assertions: 83 | - result.statuscode ShouldEqual 200 84 | 85 | - type: http 86 | method: GET 87 | url: http://localhost:8081/mocks 88 | assertions: 89 | - result.bodyjson.__len__ ShouldEqual 1 90 | 91 | - name: Add mocks with matchers 92 | steps: 93 | - type: http 94 | method: POST 95 | url: http://localhost:8081/mocks?reset=true 96 | headers: 97 | Content-Type: "application/x-yaml" 98 | bodyFile: ../data/matcher_mock_list.yml 99 | assertions: 100 | - result.statuscode ShouldEqual 200 101 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 102 | 103 | - type: http 104 | method: GET 105 | url: http://localhost:8081/mocks 106 | assertions: 107 | - result.statuscode ShouldEqual 200 108 | - result.bodyjson.__len__ ShouldEqual 9 109 | - result.bodyjson.bodyjson8.request.path.matcher ShouldEqual "ShouldMatch" 110 | - result.bodyjson.bodyjson8.request.path.value ShouldEqual "/.*" 111 | - result.bodyjson.bodyjson7.request.method.matcher ShouldEqual "ShouldContainSubstring" 112 | - result.bodyjson.bodyjson7.request.method.value ShouldEqual "PO" 113 | - result.bodyjson.bodyjson6.request.body.matcher ShouldEqual "ShouldEqualJSON" 114 | - result.bodyjson.bodyjson6.request.body.value ShouldContainSubstring id 115 | - result.bodyjson.bodyjson5.request.headers.content-type.content-type0.matcher ShouldEqual "ShouldMatch" 116 | - result.bodyjson.bodyjson5.request.headers.content-type.content-type0.value ShouldEqual application/.* 117 | - result.bodyjson.bodyjson4.request.query_params.test.test0.value ShouldEqual true 118 | - result.bodyjson.bodyjson3.request.body.matcher ShouldEqual "ShouldNotBeEmpty" 119 | - result.bodyjson.bodyjson2.request.query_params.test.test0.value ShouldEqual true 120 | - result.bodyjson.bodyjson1.request.body.key1.value ShouldEqual test 121 | - result.bodyjson.bodyjson0.request.body.key1[0].value ShouldEqual test 122 | 123 | - name: Add dynamic mocks 124 | steps: 125 | - type: http 126 | method: POST 127 | url: http://localhost:8081/mocks?reset=true 128 | headers: 129 | Content-Type: "application/x-yaml" 130 | bodyFile: ../data/dynamic_mock_list.yml 131 | assertions: 132 | - result.statuscode ShouldEqual 200 133 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 134 | 135 | - type: http 136 | method: GET 137 | url: http://localhost:8081/mocks 138 | assertions: 139 | - result.statuscode ShouldEqual 200 140 | - result.bodyjson.__len__ ShouldEqual 6 141 | - result.bodyjson.bodyjson5.dynamic_response.engine ShouldEqual lua 142 | - result.bodyjson.bodyjson4.dynamic_response.engine ShouldEqual lua 143 | - result.bodyjson.bodyjson3.dynamic_response.engine ShouldEqual go_template 144 | - result.bodyjson.bodyjson2.dynamic_response.engine ShouldEqual go_template_yaml 145 | - result.bodyjson.bodyjson1.dynamic_response.engine ShouldEqual go_template_json 146 | - result.bodyjson.bodyjson0.dynamic_response.engine ShouldEqual go_template_json 147 | 148 | - name: Add proxy mocks 149 | steps: 150 | - type: http 151 | method: POST 152 | url: http://localhost:8081/mocks?reset=true 153 | headers: 154 | Content-Type: "application/x-yaml" 155 | bodyFile: ../data/proxy_mock_list.yml 156 | assertions: 157 | - result.statuscode ShouldEqual 200 158 | - result.bodyjson.message ShouldEqual "Mocks registered successfully" 159 | 160 | - type: http 161 | method: GET 162 | url: http://localhost:8081/mocks 163 | assertions: 164 | - result.statuscode ShouldEqual 200 165 | - result.bodyjson.__len__ ShouldEqual 9 166 | - result.bodyjson.bodyjson8.proxy.host ShouldEqual https://jsonplaceholder.typicode.com 167 | - result.bodyjson.bodyjson7.proxy.host ShouldEqual https://jsonplaceholder.typicode.com 168 | - result.bodyjson.bodyjson6.proxy.host ShouldEqual https://httpbin.org 169 | - result.bodyjson.bodyjson5.proxy.host ShouldEqual https://httpbin.org 170 | - result.bodyjson.bodyjson4.proxy.host ShouldEqual https://httpbin.org 171 | - result.bodyjson.bodyjson3.proxy.host ShouldEqual https://httpbin.org 172 | - result.bodyjson.bodyjson2.proxy.host ShouldEqual https://httpbin.org 173 | - result.bodyjson.bodyjson1.proxy.host ShouldEqual https://self-signed.badssl.com 174 | - result.bodyjson.bodyjson0.proxy.host ShouldEqual https://self-signed.badssl.com 175 | -------------------------------------------------------------------------------- /client/modules/epics.ts: -------------------------------------------------------------------------------- 1 | import { combineEpics, Epic } from "redux-observable"; 2 | import { of } from "rxjs"; 3 | import { ajax, AjaxError, AjaxResponse } from "rxjs/ajax"; 4 | import { catchError, exhaustMap, filter, map, mergeMap } from "rxjs/operators"; 5 | import { isActionOf } from "typesafe-actions"; 6 | import { trimedPath } from "../modules/utils"; 7 | import { Actions, actions } from "./actions"; 8 | import { 9 | decode, 10 | GraphHistoryCodec, 11 | HistoryCodec, 12 | MocksCodec, 13 | SessionCodec, 14 | SessionsCodec, 15 | SmockerError, 16 | } from "./types"; 17 | 18 | const { 19 | fetchSessions, 20 | newSession, 21 | updateSession, 22 | uploadSessions, 23 | fetchHistory, 24 | summarizeHistory, 25 | fetchMocks, 26 | addMocks, 27 | lockMocks, 28 | unlockMocks, 29 | reset, 30 | } = actions; 31 | 32 | const ContentTypeJSON = "application/json"; 33 | const ContentTypeYAML = "application/x-yaml"; 34 | 35 | const extractError = (error: AjaxResponse | AjaxError | SmockerError) => { 36 | const ajaxError = error as AjaxResponse | AjaxError; 37 | let message = ajaxError?.xhr?.response?.message || error["message"]; 38 | if (message === "ajax error") { 39 | message = 40 | "Failed to connect to the server, please make sure it's still running"; 41 | } 42 | return { message }; 43 | }; 44 | 45 | const fetchSessionsEpic: Epic = (action$) => 46 | action$.pipe( 47 | filter(isActionOf([fetchSessions.request, reset.success])), 48 | exhaustMap(() => 49 | ajax.get(`${trimedPath}/sessions/summary`).pipe( 50 | mergeMap(({ response }) => 51 | decode(SessionsCodec)(response).pipe( 52 | map((resp) => fetchSessions.success(resp)) 53 | ) 54 | ), 55 | catchError((error) => of(fetchSessions.failure(extractError(error)))) 56 | ) 57 | ) 58 | ); 59 | 60 | const newSessionEpic: Epic = (action$) => 61 | action$.pipe( 62 | filter(isActionOf(newSession.request)), 63 | exhaustMap(() => 64 | ajax.post(`${trimedPath}/sessions`).pipe( 65 | mergeMap(({ response }) => 66 | decode(SessionCodec)(response).pipe( 67 | map((resp) => newSession.success(resp)) 68 | ) 69 | ), 70 | catchError((error) => of(newSession.failure(extractError(error)))) 71 | ) 72 | ) 73 | ); 74 | 75 | const updateSessionEpic: Epic = (action$) => 76 | action$.pipe( 77 | filter(isActionOf(updateSession.request)), 78 | exhaustMap((action) => 79 | ajax 80 | .put(`${trimedPath}/sessions`, action.payload, { 81 | "Content-Type": ContentTypeJSON, 82 | }) 83 | .pipe( 84 | mergeMap(({ response }) => 85 | decode(SessionCodec)(response).pipe( 86 | map((resp) => updateSession.success(resp)) 87 | ) 88 | ), 89 | catchError((error) => of(updateSession.failure(extractError(error)))) 90 | ) 91 | ) 92 | ); 93 | 94 | const uploadSessionsEpic: Epic = (action$) => 95 | action$.pipe( 96 | filter(isActionOf(uploadSessions.request)), 97 | exhaustMap((action) => 98 | ajax 99 | .post(`${trimedPath}/sessions/import`, action.payload, { 100 | "Content-Type": ContentTypeJSON, 101 | }) 102 | .pipe( 103 | mergeMap(({ response }) => 104 | decode(SessionsCodec)(response).pipe( 105 | map((resp) => uploadSessions.success(resp)) 106 | ) 107 | ), 108 | catchError((error) => of(uploadSessions.failure(extractError(error)))) 109 | ) 110 | ) 111 | ); 112 | 113 | const fetchHistoryEpic: Epic = (action$) => 114 | action$.pipe( 115 | filter(isActionOf(fetchHistory.request)), 116 | exhaustMap((action) => { 117 | const query = action.payload ? `?session=${action.payload}` : ""; 118 | return ajax.get(`${trimedPath}/history${query}`).pipe( 119 | mergeMap(({ response }) => 120 | decode(HistoryCodec)(response).pipe( 121 | map((resp) => fetchHistory.success(resp)) 122 | ) 123 | ), 124 | catchError((error) => of(fetchHistory.failure(extractError(error)))) 125 | ); 126 | }) 127 | ); 128 | 129 | const summarizeHistoryEpic: Epic = (action$) => 130 | action$.pipe( 131 | filter(isActionOf(summarizeHistory.request)), 132 | exhaustMap((action) => { 133 | const query = `?session=${action.payload.sessionID}&src=${action.payload.src}&dest=${action.payload.dest}`; 134 | return ajax.get(`${trimedPath}/history/summary${query}`).pipe( 135 | mergeMap(({ response }) => 136 | decode(GraphHistoryCodec)(response).pipe( 137 | map((resp) => summarizeHistory.success(resp)) 138 | ) 139 | ), 140 | catchError((error) => of(summarizeHistory.failure(extractError(error)))) 141 | ); 142 | }) 143 | ); 144 | 145 | const fetchMocksEpic: Epic = (action$) => 146 | action$.pipe( 147 | filter(isActionOf([fetchMocks.request, addMocks.success])), 148 | exhaustMap((action) => { 149 | const query = action.payload ? `?session=${action.payload}` : ""; 150 | return ajax.get(`${trimedPath}/mocks${query}`).pipe( 151 | mergeMap(({ response }) => 152 | decode(MocksCodec)(response).pipe( 153 | map((resp) => fetchMocks.success(resp)) 154 | ) 155 | ), 156 | catchError((error) => of(fetchMocks.failure(extractError(error)))) 157 | ); 158 | }) 159 | ); 160 | 161 | const addMocksEpic: Epic = (action$) => 162 | action$.pipe( 163 | filter(isActionOf(addMocks.request)), 164 | exhaustMap((action) => 165 | ajax 166 | .post(`${trimedPath}/mocks`, action.payload.mocks, { 167 | "Content-Type": ContentTypeYAML, 168 | }) 169 | .pipe( 170 | map(() => addMocks.success()), 171 | catchError((error) => of(addMocks.failure(extractError(error)))) 172 | ) 173 | ) 174 | ); 175 | 176 | const lockMocksEpic: Epic = (action$) => 177 | action$.pipe( 178 | filter(isActionOf(lockMocks.request)), 179 | exhaustMap((action) => { 180 | return ajax 181 | .post(`${trimedPath}/mocks/lock`, action.payload, { 182 | "Content-Type": ContentTypeJSON, 183 | }) 184 | .pipe( 185 | mergeMap(({ response }) => { 186 | return decode(MocksCodec)(response).pipe( 187 | map((resp) => lockMocks.success(resp)) 188 | ); 189 | }), 190 | catchError((error) => { 191 | return of(lockMocks.failure(extractError(error))); 192 | }) 193 | ); 194 | }) 195 | ); 196 | 197 | const unlockMocksEpic: Epic = (action$) => 198 | action$.pipe( 199 | filter(isActionOf(unlockMocks.request)), 200 | exhaustMap((action) => { 201 | return ajax 202 | .post(`${trimedPath}/mocks/unlock`, action.payload, { 203 | "Content-Type": ContentTypeJSON, 204 | }) 205 | .pipe( 206 | mergeMap(({ response }) => { 207 | return decode(MocksCodec)(response).pipe( 208 | map((resp) => unlockMocks.success(resp)) 209 | ); 210 | }), 211 | catchError((error) => { 212 | return of(unlockMocks.failure(extractError(error))); 213 | }) 214 | ); 215 | }) 216 | ); 217 | 218 | const resetEpic: Epic = (action$) => 219 | action$.pipe( 220 | filter(isActionOf(reset.request)), 221 | exhaustMap(() => 222 | ajax.post(`${trimedPath}/reset`).pipe( 223 | map(() => reset.success()), 224 | catchError((error) => of(reset.failure(extractError(error)))) 225 | ) 226 | ) 227 | ); 228 | 229 | export default combineEpics( 230 | fetchSessionsEpic, 231 | newSessionEpic, 232 | updateSessionEpic, 233 | uploadSessionsEpic, 234 | fetchHistoryEpic, 235 | summarizeHistoryEpic, 236 | fetchMocksEpic, 237 | addMocksEpic, 238 | lockMocksEpic, 239 | unlockMocksEpic, 240 | resetEpic 241 | ); 242 | -------------------------------------------------------------------------------- /client/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteOutlined, 3 | EditOutlined, 4 | LoadingOutlined, 5 | PlusOutlined, 6 | UploadOutlined, 7 | } from "@ant-design/icons"; 8 | import { 9 | Button, 10 | Form, 11 | Input, 12 | Layout, 13 | Menu, 14 | Popover, 15 | Row, 16 | Tooltip, 17 | Typography, 18 | } from "antd"; 19 | import * as React from "react"; 20 | import { connect } from "react-redux"; 21 | import { Dispatch } from "redux"; 22 | import { Actions, actions } from "../modules/actions"; 23 | import { AppState } from "../modules/reducers"; 24 | import { Session, Sessions } from "../modules/types"; 25 | import { usePoll, useQueryParams } from "../modules/utils"; 26 | import "./Sidebar.scss"; 27 | 28 | const EditableItem = ({ 29 | value, 30 | onValidate, 31 | }: { 32 | value?: string; 33 | onValidate: (name: string) => unknown; 34 | }) => { 35 | const [visible, setVisible] = React.useState(false); 36 | const [name, setName] = React.useState(value || ""); 37 | const onSubmit = (event: React.MouseEvent) => { 38 | event.preventDefault(); 39 | onValidate(name.trim()); 40 | setVisible(false); 41 | }; 42 | const onChange = (event: React.ChangeEvent) => { 43 | setName(event.target.value); 44 | }; 45 | return ( 46 | 52 | 53 | 54 | 55 | 56 | 59 | 60 | 61 | } 62 | title="Rename session" 63 | trigger="click" 64 | > 65 | 66 | 67 | ); 68 | }; 69 | 70 | interface Props { 71 | sessions: Sessions; 72 | loading: boolean; 73 | uploading: boolean; 74 | selected: string; 75 | fetch: () => unknown; 76 | selectSession: (sessionID: string) => unknown; 77 | newSession: () => unknown; 78 | updateSession: (session: Session) => unknown; 79 | uploadSessions: (sessions: Session[]) => unknown; 80 | resetSessions: () => unknown; 81 | } 82 | 83 | const SideBar = ({ 84 | fetch, 85 | selected, 86 | sessions, 87 | loading, 88 | uploading, 89 | selectSession, 90 | updateSession, 91 | newSession, 92 | uploadSessions, 93 | resetSessions, 94 | }: Props) => { 95 | const [queryParams, setQueryParams] = useQueryParams(); 96 | const [, , setPolling] = usePoll(10000, fetch, undefined); 97 | const [fileUploading, setFileUploading] = React.useState(false); 98 | 99 | const querySessionID = queryParams.get("session"); 100 | 101 | const handleSelectSession = (sessionID: string) => { 102 | setQueryParams({ session: sessionID }); 103 | selectSession(sessionID); 104 | }; 105 | 106 | React.useEffect(() => { 107 | if (!loading && !selected && sessions.length > 0) { 108 | if ( 109 | !querySessionID || 110 | sessions.filter((session) => session.id === querySessionID).length === 0 111 | ) { 112 | handleSelectSession(sessions[sessions.length - 1].id); 113 | } else { 114 | querySessionID && handleSelectSession(querySessionID); 115 | } 116 | } 117 | if (!loading && selected && !querySessionID) { 118 | setQueryParams({ session: selected }); 119 | } 120 | }, [loading, selected, sessions, querySessionID]); 121 | 122 | const selectedItem = selected ? [selected] : undefined; 123 | const onCollapse = (col: boolean) => setPolling(!col); 124 | const onSelect = ({ key }: { key: string }) => { 125 | if (key !== "new" && key !== "reset") { 126 | handleSelectSession(key); 127 | } else { 128 | setQueryParams({ session: "" }); 129 | } 130 | }; 131 | const onChangeSessionName = (index: number) => (name: string) => { 132 | updateSession({ ...sessions[index], name }); 133 | }; 134 | const items = sessions.map((session: Session, index: number) => ( 135 | 136 | 141 | 142 | {session.name || session.id} 143 | 144 | 148 | 149 | 150 | )); 151 | 152 | const onFileUpload = (event: React.ChangeEvent) => { 153 | setFileUploading(true); 154 | const files = event.target.files; 155 | if (!files) { 156 | return; 157 | } 158 | const file = files[0]; 159 | const reader = new FileReader(); 160 | reader.onload = (ev: ProgressEvent) => { 161 | try { 162 | const sessionToUpload = JSON.parse(ev.target?.result as string); 163 | uploadSessions(sessionToUpload); 164 | setFileUploading(false); 165 | } catch (e) { 166 | console.error(e); 167 | } 168 | }; 169 | reader.readAsText(file); 170 | }; 171 | 172 | const title: JSX.Element = 173 | fileUploading || uploading ? ( 174 | <> 175 | 180 | Sessions 181 | 182 | ) : ( 183 | <> 184 | 189 | 195 | 196 | Sessions 197 | 198 | ); 199 | return ( 200 | 209 | 215 | 216 | {items} 217 | 218 | 227 | 228 | 229 | 230 | 238 | 239 | 240 | 241 | ); 242 | }; 243 | 244 | export default connect( 245 | (state: AppState) => ({ 246 | sessions: state.sessions.list, 247 | loading: state.sessions.loading, 248 | uploading: state.sessions.uploading, 249 | selected: state.sessions.selected, 250 | }), 251 | (dispatch: Dispatch) => ({ 252 | fetch: () => dispatch(actions.fetchSessions.request()), 253 | selectSession: (sessionID: string) => 254 | dispatch(actions.selectSession(sessionID)), 255 | newSession: () => dispatch(actions.newSession.request()), 256 | updateSession: (session: Session) => 257 | dispatch(actions.updateSession.request(session)), 258 | uploadSessions: (sessions: Sessions) => 259 | dispatch(actions.uploadSessions.request(sessions)), 260 | resetSessions: () => dispatch(actions.reset.request()), 261 | }) 262 | )(SideBar); 263 | -------------------------------------------------------------------------------- /client/components/Visualize.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ArrowLeftOutlined, 3 | EditOutlined, 4 | SaveOutlined, 5 | } from "@ant-design/icons"; 6 | import { 7 | Button, 8 | Card, 9 | Collapse, 10 | Drawer, 11 | Empty, 12 | Form, 13 | Input, 14 | PageHeader, 15 | Row, 16 | Spin, 17 | } from "antd"; 18 | import * as React from "react"; 19 | import { connect } from "react-redux"; 20 | import { Link } from "react-router-dom"; 21 | import { Dispatch } from "redux"; 22 | import { useDebounce } from "use-lodash-debounce"; 23 | import { Actions, actions } from "../modules/actions"; 24 | import { AppState } from "../modules/reducers"; 25 | import { GraphHistory } from "../modules/types"; 26 | import { cleanQueryParams, useQueryParams } from "../modules/utils"; 27 | import Code from "./Code"; 28 | import { Mermaid } from "./Mermaid"; 29 | import "./Visualize.scss"; 30 | 31 | const EditGraph = ({ 32 | display, 33 | value, 34 | onChange, 35 | onClose, 36 | }: { 37 | display: boolean; 38 | value: string; 39 | onChange: (value: string) => unknown; 40 | onClose: () => unknown; 41 | }) => { 42 | return ( 43 | 53 |
54 | 60 | 61 |
62 | ); 63 | }; 64 | 65 | interface Props { 66 | sessionID: string; 67 | graph: GraphHistory; 68 | loading: boolean; 69 | visualize: (sessionID: string, src: string, dest: string) => unknown; 70 | } 71 | 72 | const Visualize = ({ sessionID, graph, loading, visualize }: Props) => { 73 | React.useEffect(() => { 74 | document.title = "Visualize | Smocker"; 75 | }); 76 | const [queryParams, setQueryParams] = useQueryParams(); 77 | 78 | const [diagram, setDiagram] = React.useState(""); 79 | const [src, setSrc] = React.useState(queryParams.get("source-header") || ""); 80 | const [dest, setDest] = React.useState( 81 | queryParams.get("destination-header") || "" 82 | ); 83 | const [editGraph, setEditGraph] = React.useState(false); 84 | const [svg, setSVG] = React.useState(""); 85 | const debouncedDiagram = useDebounce(diagram, 1000); 86 | 87 | React.useEffect(() => { 88 | setDiagram(computeGraph(graph)); 89 | }, [graph]); 90 | 91 | React.useLayoutEffect(() => { 92 | visualize(sessionID, src, dest); 93 | }, [sessionID]); 94 | 95 | const handleChangeSrc = (event: React.ChangeEvent) => { 96 | setQueryParams({ "source-header": event.target.value }, true); 97 | setSrc(event.target.value); 98 | }; 99 | const handleChangeDest = (event: React.ChangeEvent) => { 100 | setQueryParams({ "destination-header": event.target.value }, true); 101 | setDest(event.target.value); 102 | }; 103 | const handleGenerate = () => visualize(sessionID, src, dest); 104 | const handleEditGraph = () => setEditGraph(true); 105 | const handleChangeGraph = (diag: string) => setDiagram(diag); 106 | const handleCloseEditGraph = () => setEditGraph(false); 107 | const handleChangeSVG = (content: string) => { 108 | setSVG(content); 109 | }; 110 | 111 | const onSaveSVG = () => { 112 | const image = "data:image/svg+xml," + escape(svg); 113 | const link = document.createElement("a"); 114 | link.download = "sequence.svg"; 115 | link.href = image; 116 | return link.click(); 117 | }; 118 | 119 | const emptyDiagram = !debouncedDiagram.replace("sequenceDiagram", "").trim(); 120 | return ( 121 |
122 | 126 | ({ 128 | ...cleanQueryParams(location), 129 | pathname: "/pages/history", 130 | })} 131 | > 132 | 133 | 134 | 137 | 144 |
145 | } 146 | > 147 |

148 | This is a graphical representation of call history. 149 |

150 | 151 | 156 |
157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 167 | 168 |
169 |
170 |
171 | 172 | 173 | {!emptyDiagram && ( 174 | 175 | 180 | 181 | )} 182 | {emptyDiagram && ( 183 | 184 | )} 185 | 186 | 187 | 188 | {editGraph && ( 189 | 195 | )} 196 |
197 | ); 198 | }; 199 | 200 | export default connect( 201 | (state: AppState) => { 202 | const { sessions, history } = state; 203 | return { 204 | sessionID: sessions.selected, 205 | graph: history.graph, 206 | loading: history.loading, 207 | }; 208 | }, 209 | (dispatch: Dispatch) => ({ 210 | visualize: (sessionID: string, src: string, dest: string) => 211 | dispatch(actions.summarizeHistory.request({ sessionID, src, dest })), 212 | }) 213 | )(Visualize); 214 | 215 | const computeGraph = (graph: GraphHistory): string => { 216 | const endpoints: Record = {}; 217 | graph.forEach((entry) => { 218 | if (!endpoints[entry.from]) { 219 | endpoints[entry.from] = `P${Object.keys(endpoints).length}`; 220 | } 221 | if (!endpoints[entry.to]) { 222 | endpoints[entry.to] = `P${Object.keys(endpoints).length}`; 223 | } 224 | }); 225 | 226 | const indent = " "; 227 | let res = "sequenceDiagram\n\n"; 228 | Object.entries(endpoints).forEach(([endpoint, alias]) => { 229 | res += indent + `participant ${alias} as ${endpoint}\n`; 230 | }); 231 | res += "\n"; 232 | graph.forEach((entry) => { 233 | let arrow = "-->>"; 234 | if (entry.type === "request") { 235 | arrow = "->>+"; 236 | } else if (entry.type === "response") { 237 | arrow = "-->>-"; 238 | } 239 | if (entry.from === "Client") { 240 | res += "\n"; 241 | } 242 | res += 243 | indent + 244 | `${endpoints[entry.from]}${arrow}${endpoints[entry.to]}: ${ 245 | entry.message 246 | }\n`; 247 | }); 248 | return res; 249 | }; 250 | -------------------------------------------------------------------------------- /server/handlers/admin.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/labstack/echo/v4" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/smocker-dev/smocker/server/services" 10 | "github.com/smocker-dev/smocker/server/types" 11 | ) 12 | 13 | type Admin struct { 14 | mocksServices services.Mocks 15 | graphsServices services.Graph 16 | } 17 | 18 | func NewAdmin(ms services.Mocks, graph services.Graph) *Admin { 19 | return &Admin{ 20 | mocksServices: ms, 21 | graphsServices: graph, 22 | } 23 | } 24 | 25 | func (a *Admin) GetMocks(c echo.Context) error { 26 | sessionID := c.QueryParam("session") 27 | if sessionID == "" { 28 | sessionID = a.mocksServices.GetLastSession().ID 29 | } 30 | 31 | if id := c.QueryParam("id"); id != "" { 32 | mock, err := a.mocksServices.GetMockByID(sessionID, id) 33 | if err != nil { 34 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 35 | } 36 | 37 | return respondAccordingAccept(c, types.Mocks{mock}) 38 | } 39 | 40 | mocks, err := a.mocksServices.GetMocks(sessionID) 41 | if err != nil { 42 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 43 | } 44 | 45 | return respondAccordingAccept(c, mocks) 46 | } 47 | 48 | func (a *Admin) AddMocks(c echo.Context) error { 49 | if reset, _ := strconv.ParseBool(c.QueryParam("reset")); reset { 50 | a.mocksServices.Reset(false) 51 | } 52 | 53 | sessionName := c.QueryParam("session") 54 | if sessionName == "" { 55 | // Deprecated, keep it for retrocompatibility 56 | sessionName = c.QueryParam("newSession") 57 | } 58 | if sessionName != "" { 59 | a.mocksServices.NewSession(sessionName) 60 | } 61 | 62 | sessionID := a.mocksServices.GetLastSession().ID 63 | var mocks types.Mocks 64 | if err := bindAccordingAccept(c, &mocks); err != nil { 65 | return err 66 | } 67 | 68 | for _, mock := range mocks { 69 | if err := mock.Validate(); err != nil { 70 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 71 | } 72 | } 73 | 74 | for _, mock := range mocks { 75 | if _, err := a.mocksServices.AddMock(sessionID, mock); err != nil { 76 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 77 | } 78 | } 79 | 80 | return c.JSON(http.StatusOK, echo.Map{ 81 | "message": "Mocks registered successfully", 82 | }) 83 | } 84 | 85 | func (a *Admin) LockMocks(c echo.Context) error { 86 | var ids []string 87 | if err := bindAccordingAccept(c, &ids); err != nil { 88 | return err 89 | } 90 | 91 | mocks := a.mocksServices.LockMocks(ids) 92 | return c.JSON(http.StatusOK, mocks) 93 | } 94 | 95 | func (a *Admin) UnlockMocks(c echo.Context) error { 96 | var ids []string 97 | if err := bindAccordingAccept(c, &ids); err != nil { 98 | return err 99 | } 100 | 101 | mocks := a.mocksServices.UnlockMocks(ids) 102 | return c.JSON(http.StatusOK, mocks) 103 | } 104 | 105 | func (a *Admin) VerifySession(c echo.Context) error { 106 | sessionID := c.QueryParam("session") 107 | var session *types.Session 108 | if sessionID != "" { 109 | var err error 110 | session, err = a.mocksServices.GetSessionByID(sessionID) 111 | if err != nil { 112 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 113 | } 114 | } else { 115 | session = a.mocksServices.GetLastSession() 116 | } 117 | failedMocks := types.Mocks{} 118 | unusedMocks := types.Mocks{} 119 | for _, mock := range session.Mocks { 120 | if !mock.Verify() { 121 | failedMocks = append(failedMocks, mock) 122 | } 123 | if mock.State.TimesCount == 0 { 124 | unusedMocks = append(unusedMocks, mock) 125 | } 126 | } 127 | 128 | failedHistory := types.History{} 129 | for _, entry := range session.History { 130 | if entry.Response.Status > 600 { 131 | failedHistory = append(failedHistory, entry) 132 | } 133 | } 134 | 135 | mocksVerified := len(failedMocks) == 0 136 | mocksAllUsed := len(unusedMocks) == 0 137 | historyIsClean := len(failedHistory) == 0 138 | 139 | response := types.VerifyResult{} 140 | response.Mocks.Verified = mocksVerified 141 | response.Mocks.AllUsed = mocksAllUsed 142 | response.History.Verified = historyIsClean 143 | 144 | if mocksVerified && mocksAllUsed { 145 | response.Mocks.Message = "All mocks match expectations" 146 | } else { 147 | response.Mocks.Message = "Some mocks don't match expectations" 148 | if !mocksVerified { 149 | response.Mocks.Failures = failedMocks 150 | } 151 | if !mocksAllUsed { 152 | response.Mocks.Unused = unusedMocks 153 | } 154 | } 155 | 156 | if historyIsClean { 157 | response.History.Message = "History is clean" 158 | } else { 159 | response.History.Message = "There are errors in the history" 160 | response.History.Failures = failedHistory 161 | } 162 | 163 | return respondAccordingAccept(c, response) 164 | } 165 | 166 | func (a *Admin) GetHistory(c echo.Context) error { 167 | sessionID := c.QueryParam("session") 168 | if sessionID == "" { 169 | sessionID = a.mocksServices.GetLastSession().ID 170 | } 171 | 172 | filter := c.QueryParam("filter") 173 | history, err := a.mocksServices.GetHistoryByPath(sessionID, filter) 174 | if err != nil { 175 | if err == types.SessionNotFound { 176 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 177 | } 178 | 179 | log.WithError(err).Error("Failed to retrieve history") 180 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 181 | } 182 | return respondAccordingAccept(c, history) 183 | } 184 | 185 | func (a *Admin) GetSessions(c echo.Context) error { 186 | sessions := a.mocksServices.GetSessions() 187 | return respondAccordingAccept(c, sessions) 188 | } 189 | 190 | func (a *Admin) SummarizeSessions(c echo.Context) error { 191 | sessions := a.mocksServices.GetSessions() 192 | return respondAccordingAccept(c, sessions.Summarize()) 193 | } 194 | 195 | func (a *Admin) NewSession(c echo.Context) error { 196 | name := c.QueryParam("name") 197 | session := a.mocksServices.NewSession(name) 198 | return respondAccordingAccept(c, types.SessionSummary(*session)) 199 | } 200 | 201 | type updateSessionBody struct { 202 | ID string `json:"id"` 203 | Name string `json:"name"` 204 | } 205 | 206 | func (a *Admin) UpdateSession(c echo.Context) error { 207 | var body updateSessionBody 208 | if err := c.Bind(&body); err != nil { 209 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 210 | } 211 | 212 | session, err := a.mocksServices.UpdateSession(body.ID, body.Name) 213 | if err != nil { 214 | if err == types.SessionNotFound { 215 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 216 | } 217 | 218 | return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) 219 | } 220 | 221 | return respondAccordingAccept(c, types.SessionSummary{ 222 | ID: session.ID, 223 | Name: session.Name, 224 | Date: session.Date, 225 | }) 226 | } 227 | 228 | func (a *Admin) ImportSession(c echo.Context) error { 229 | var sessions types.Sessions 230 | if err := c.Bind(&sessions); err != nil { 231 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 232 | } 233 | a.mocksServices.SetSessions(sessions) 234 | sessionSummaries := []types.SessionSummary{} 235 | for _, session := range sessions { 236 | sessionSummaries = append(sessionSummaries, types.SessionSummary{ 237 | ID: session.ID, 238 | Name: session.Name, 239 | Date: session.Date, 240 | }) 241 | } 242 | return respondAccordingAccept(c, sessionSummaries) 243 | } 244 | 245 | func (a *Admin) Reset(c echo.Context) error { 246 | force, _ := strconv.ParseBool(c.QueryParam("force")) 247 | a.mocksServices.Reset(force) 248 | return c.JSON(http.StatusOK, echo.Map{ 249 | "message": "Reset successful", 250 | }) 251 | } 252 | 253 | func (a *Admin) SummarizeHistory(c echo.Context) error { 254 | sessionID := "" 255 | if sessionID = c.QueryParam("session"); sessionID == "" { 256 | sessionID = a.mocksServices.GetLastSession().ID 257 | } 258 | session, err := a.mocksServices.GetSessionByID(sessionID) 259 | if err == types.SessionNotFound { 260 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 261 | } else if err != nil { 262 | log.WithError(err).Error("Failed to retrieve session") 263 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 264 | } 265 | 266 | var cfg types.GraphConfig 267 | if err := c.Bind(&cfg); err != nil { 268 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 269 | } 270 | return respondAccordingAccept(c, a.graphsServices.Generate(cfg, session)) 271 | } 272 | -------------------------------------------------------------------------------- /server/services/mocks.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/smocker-dev/smocker/server/types" 11 | "github.com/teris-io/shortid" 12 | ) 13 | 14 | var ( 15 | SessionNotFound = fmt.Errorf("session not found") 16 | MockNotFound = fmt.Errorf("mock not found") 17 | ) 18 | 19 | type Mocks interface { 20 | AddMock(sessionID string, mock *types.Mock) (*types.Mock, error) 21 | GetMocks(sessionID string) (types.Mocks, error) 22 | GetMockByID(sessionID, id string) (*types.Mock, error) 23 | LockMocks(ids []string) types.Mocks 24 | UnlockMocks(ids []string) types.Mocks 25 | AddHistoryEntry(sessionID string, entry *types.Entry) (*types.Entry, error) 26 | GetHistory(sessionID string) (types.History, error) 27 | GetHistoryByPath(sessionID, filterPath string) (types.History, error) 28 | NewSession(name string) *types.Session 29 | UpdateSession(id, name string) (*types.Session, error) 30 | GetLastSession() *types.Session 31 | GetSessionByID(id string) (*types.Session, error) 32 | GetSessions() types.Sessions 33 | SetSessions(sessions types.Sessions) 34 | Reset(force bool) 35 | } 36 | 37 | type mocks struct { 38 | sessions types.Sessions 39 | mu sync.Mutex 40 | historyRetention int 41 | persistence Persistence 42 | } 43 | 44 | func NewMocks(sessions types.Sessions, historyRetention int, persistence Persistence) Mocks { 45 | s := &mocks{ 46 | sessions: types.Sessions{}, 47 | historyRetention: historyRetention, 48 | persistence: persistence, 49 | } 50 | if sessions != nil { 51 | s.sessions = sessions 52 | } 53 | return s 54 | } 55 | 56 | func (s *mocks) AddMock(sessionID string, newMock *types.Mock) (*types.Mock, error) { 57 | session, err := s.GetSessionByID(sessionID) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | s.mu.Lock() 63 | defer s.mu.Unlock() 64 | 65 | newMock.Init() 66 | session.Mocks = append(types.Mocks{newMock}, session.Mocks...) 67 | go s.persistence.StoreMocks(session.ID, session.Mocks.Clone()) 68 | return newMock, nil 69 | } 70 | 71 | func (s *mocks) GetMocks(sessionID string) (types.Mocks, error) { 72 | session, err := s.GetSessionByID(sessionID) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | s.mu.Lock() 78 | defer s.mu.Unlock() 79 | return session.Mocks.Clone(), nil 80 | } 81 | 82 | func (s *mocks) LockMocks(ids []string) types.Mocks { 83 | session := s.GetLastSession() 84 | s.mu.Lock() 85 | defer s.mu.Unlock() 86 | modifiedMocks := make(types.Mocks, 0, len(session.Mocks)) 87 | for _, id := range ids { 88 | for _, mock := range session.Mocks { 89 | if mock.State.ID == id { 90 | mock.State.Locked = true 91 | modifiedMocks = append(modifiedMocks, mock) 92 | } 93 | } 94 | } 95 | go s.persistence.StoreMocks(session.ID, session.Mocks.Clone()) 96 | return modifiedMocks 97 | } 98 | 99 | func (s *mocks) UnlockMocks(ids []string) types.Mocks { 100 | session := s.GetLastSession() 101 | s.mu.Lock() 102 | defer s.mu.Unlock() 103 | modifiedMocks := make(types.Mocks, 0, len(session.Mocks)) 104 | for _, id := range ids { 105 | for _, mock := range session.Mocks { 106 | if mock.State.ID == id { 107 | mock.State.Locked = false 108 | modifiedMocks = append(modifiedMocks, mock) 109 | } 110 | } 111 | } 112 | go s.persistence.StoreMocks(session.ID, session.Mocks.Clone()) 113 | return modifiedMocks 114 | } 115 | 116 | func (s *mocks) GetMockByID(sessionID, id string) (*types.Mock, error) { 117 | session, err := s.GetSessionByID(sessionID) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | s.mu.Lock() 123 | defer s.mu.Unlock() 124 | 125 | for _, mock := range session.Mocks { 126 | if mock.State.ID == id { 127 | return mock, nil 128 | } 129 | } 130 | return nil, types.MockNotFound 131 | } 132 | 133 | func (s *mocks) AddHistoryEntry(sessionID string, entry *types.Entry) (*types.Entry, error) { 134 | session, err := s.GetSessionByID(sessionID) 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | s.mu.Lock() 140 | defer s.mu.Unlock() 141 | 142 | if s.historyRetention > 0 && len(session.History)+1 > s.historyRetention { 143 | session.History = session.History[1:] 144 | } 145 | 146 | session.History = append(session.History, entry) 147 | go s.persistence.StoreHistory(session.ID, session.History.Clone()) 148 | return entry, nil 149 | } 150 | 151 | func (s *mocks) GetHistory(sessionID string) (types.History, error) { 152 | session, err := s.GetSessionByID(sessionID) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | s.mu.Lock() 158 | defer s.mu.Unlock() 159 | return session.History.Clone(), nil 160 | } 161 | 162 | func (s *mocks) GetHistoryByPath(sessionID, filterPath string) (types.History, error) { 163 | history, err := s.GetHistory(sessionID) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | s.mu.Lock() 169 | defer s.mu.Unlock() 170 | 171 | regex, err := regexp.Compile(filterPath) 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | res := types.History{} 177 | for _, entry := range history { 178 | if regex.MatchString(entry.Request.Path) { 179 | res = append(res, entry) 180 | } 181 | } 182 | return res, nil 183 | } 184 | 185 | func (s *mocks) NewSession(name string) *types.Session { 186 | if strings.TrimSpace(name) == "" { 187 | name = fmt.Sprintf("Session #%d", len(s.sessions)+1) 188 | } 189 | 190 | var history types.History 191 | if s.historyRetention > 0 { 192 | history = make(types.History, 0, s.historyRetention) 193 | } else { 194 | history = types.History{} 195 | } 196 | 197 | mocks := types.Mocks{} 198 | if len(s.sessions) > 0 { 199 | session := s.GetLastSession() 200 | s.mu.Lock() 201 | for _, mock := range session.Mocks { 202 | if mock.State.Locked { 203 | mocks = append(mocks, mock.CloneAndReset()) 204 | } 205 | } 206 | s.mu.Unlock() 207 | } 208 | 209 | s.mu.Lock() 210 | defer s.mu.Unlock() 211 | 212 | session := &types.Session{ 213 | ID: shortid.MustGenerate(), 214 | Name: name, 215 | Date: time.Now(), 216 | History: history, 217 | Mocks: mocks, 218 | } 219 | s.sessions = append(s.sessions, session) 220 | 221 | go s.persistence.StoreSession(s.sessions.Summarize(), session) 222 | return session 223 | } 224 | 225 | func (s *mocks) UpdateSession(sessionID, name string) (*types.Session, error) { 226 | session, err := s.GetSessionByID(sessionID) 227 | if err != nil { 228 | return nil, err 229 | } 230 | 231 | s.mu.Lock() 232 | defer s.mu.Unlock() 233 | 234 | session.Name = name 235 | go s.persistence.StoreSession(s.sessions.Summarize(), session) 236 | return session, nil 237 | } 238 | 239 | func (s *mocks) GetLastSession() *types.Session { 240 | s.mu.Lock() 241 | if len(s.sessions) == 0 { 242 | s.mu.Unlock() 243 | s.NewSession("") 244 | s.mu.Lock() 245 | } 246 | defer s.mu.Unlock() 247 | return s.sessions[len(s.sessions)-1].Clone() 248 | } 249 | 250 | func (s *mocks) GetSessionByID(id string) (*types.Session, error) { 251 | if id == "" { 252 | return nil, types.SessionNotFound 253 | } 254 | 255 | s.mu.Lock() 256 | defer s.mu.Unlock() 257 | 258 | for _, session := range s.sessions { 259 | if session.ID == id { 260 | return session, nil 261 | } 262 | } 263 | return nil, types.SessionNotFound 264 | } 265 | 266 | func (s *mocks) GetSessionByName(name string) (*types.Session, error) { 267 | s.mu.Lock() 268 | defer s.mu.Unlock() 269 | 270 | if name == "" { 271 | return nil, types.SessionNotFound 272 | } 273 | 274 | for _, session := range s.sessions { 275 | if session.Name == name { 276 | return session, nil 277 | } 278 | } 279 | return nil, types.SessionNotFound 280 | } 281 | 282 | func (s *mocks) GetSessions() types.Sessions { 283 | s.mu.Lock() 284 | defer s.mu.Unlock() 285 | return s.sessions.Clone() 286 | } 287 | 288 | func (s *mocks) SetSessions(sessions types.Sessions) { 289 | s.mu.Lock() 290 | defer s.mu.Unlock() 291 | s.sessions = sessions 292 | go s.persistence.StoreSessions(s.sessions.Clone()) 293 | } 294 | 295 | func (s *mocks) Reset(force bool) { 296 | session := s.GetLastSession() 297 | 298 | s.mu.Lock() 299 | mocks := types.Mocks{} 300 | for _, mock := range session.Mocks { 301 | if mock.State.Locked { 302 | mocks = append(mocks, mock.CloneAndReset()) 303 | } 304 | } 305 | s.sessions = types.Sessions{} 306 | s.mu.Unlock() 307 | 308 | if len(mocks) > 0 && !force { 309 | _ = s.GetLastSession() 310 | s.mu.Lock() 311 | s.sessions[len(s.sessions)-1].Mocks = mocks 312 | s.mu.Unlock() 313 | } 314 | 315 | go s.persistence.StoreSessions(s.sessions.Clone()) 316 | } 317 | -------------------------------------------------------------------------------- /server/types/mock.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | log "github.com/sirupsen/logrus" 15 | "github.com/teris-io/shortid" 16 | ) 17 | 18 | var MockNotFound = fmt.Errorf("mock not found") 19 | 20 | type Mocks []*Mock 21 | 22 | func (m Mocks) Clone() Mocks { 23 | return append(make(Mocks, 0, len(m)), m...) 24 | } 25 | 26 | type Mock struct { 27 | Request MockRequest `json:"request,omitempty" yaml:"request"` 28 | Response *MockResponse `json:"response,omitempty" yaml:"response,omitempty"` 29 | Context *MockContext `json:"context,omitempty" yaml:"context,omitempty"` 30 | State *MockState `json:"state,omitempty" yaml:"state,omitempty"` 31 | DynamicResponse *DynamicMockResponse `json:"dynamic_response,omitempty" yaml:"dynamic_response,omitempty"` 32 | Proxy *MockProxy `json:"proxy,omitempty" yaml:"proxy,omitempty"` 33 | } 34 | 35 | func (m *Mock) Validate() error { 36 | if m.Response == nil && m.DynamicResponse == nil && m.Proxy == nil { 37 | return errors.New("The route must define at least a response, a dynamic response or a proxy") 38 | } 39 | 40 | if m.Response != nil && m.DynamicResponse != nil && m.Proxy != nil { 41 | return errors.New("The route must define either a response, a dynamic response or a proxy, not multiple of them") 42 | } 43 | 44 | m.Request.Path.Value = strings.TrimSpace(m.Request.Path.Value) 45 | if m.Request.Path.Value == "" { 46 | m.Request.Path.Matcher = "ShouldMatch" 47 | m.Request.Path.Value = ".*" 48 | } 49 | 50 | m.Request.Method.Value = strings.TrimSpace(m.Request.Method.Value) 51 | if m.Request.Method.Value == "" { 52 | m.Request.Method.Matcher = "ShouldMatch" 53 | m.Request.Method.Value = ".*" 54 | } 55 | 56 | if m.DynamicResponse != nil && !m.DynamicResponse.Engine.IsValid() { 57 | return fmt.Errorf("The dynamic response engine must be one of the following: %v", TemplateEngines) 58 | } 59 | 60 | if m.Context != nil && m.Context.Times < 0 { 61 | return fmt.Errorf("The times field in mock context must be greater than or equal to 0") 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (m *Mock) Init() { 68 | m.State = &MockState{ 69 | CreationDate: time.Now(), 70 | ID: shortid.MustGenerate(), 71 | } 72 | 73 | if m.Context == nil { 74 | m.Context = &MockContext{} 75 | } 76 | } 77 | 78 | func (m *Mock) Verify() bool { 79 | isTimesDefined := m.Context.Times > 0 80 | hasBeenCalledRightNumberOfTimes := m.State.TimesCount == m.Context.Times 81 | return !isTimesDefined || hasBeenCalledRightNumberOfTimes 82 | } 83 | 84 | func (m *Mock) CloneAndReset() *Mock { 85 | return &Mock{ 86 | Request: m.Request, 87 | Context: m.Context, 88 | State: &MockState{ 89 | ID: m.State.ID, 90 | CreationDate: time.Now(), 91 | Locked: m.State.Locked, 92 | TimesCount: 0, 93 | }, 94 | DynamicResponse: m.DynamicResponse, 95 | Proxy: m.Proxy, 96 | Response: m.Response, 97 | } 98 | } 99 | 100 | type MockRequest struct { 101 | Path StringMatcher `json:"path" yaml:"path"` 102 | Method StringMatcher `json:"method" yaml:"method"` 103 | Body *BodyMatcher `json:"body,omitempty" yaml:"body,omitempty"` 104 | QueryParams MultiMapMatcher `json:"query_params,omitempty" yaml:"query_params,omitempty"` 105 | Headers MultiMapMatcher `json:"headers,omitempty" yaml:"headers,omitempty"` 106 | } 107 | 108 | func (mr MockRequest) Match(req Request) bool { 109 | matchMethod := mr.Method.Match(req.Method) 110 | if !matchMethod { 111 | log.Trace("Method did not match") 112 | return false 113 | } 114 | matchPath := mr.Path.Match(req.Path) 115 | if !matchPath { 116 | log.Trace("Path did not match") 117 | return false 118 | } 119 | matchHeaders := mr.Headers == nil || mr.Headers.Match(req.Headers) 120 | if !matchHeaders { 121 | log.Trace("Headers did not match") 122 | return false 123 | } 124 | matchQueryParams := mr.QueryParams == nil || mr.QueryParams.Match(req.QueryParams) 125 | if !matchQueryParams { 126 | log.Trace("Query params did not match") 127 | return false 128 | } 129 | matchBody := mr.Body == nil || mr.Body.Match(req.Headers, req.BodyString) 130 | if !matchBody { 131 | log.Trace("Body did not match") 132 | return false 133 | } 134 | return true 135 | } 136 | 137 | type MockResponse struct { 138 | Body string `json:"body,omitempty" yaml:"body,omitempty"` 139 | Status int `json:"status" yaml:"status"` 140 | Delay Delay `json:"delay,omitempty" yaml:"delay,omitempty"` 141 | Headers MapStringSlice `json:"headers,omitempty" yaml:"headers,omitempty"` 142 | } 143 | 144 | type DynamicMockResponse struct { 145 | Engine Engine `json:"engine" yaml:"engine"` 146 | Script string `json:"script" yaml:"script"` 147 | } 148 | 149 | type MockProxy struct { 150 | Host string `json:"host" yaml:"host"` 151 | Delay Delay `json:"delay,omitempty" yaml:"delay,omitempty"` 152 | FollowRedirect bool `json:"follow_redirect,omitempty" yaml:"follow_redirect,omitempty"` 153 | SkipVerifyTLS bool `json:"skip_verify_tls,omitempty" yaml:"skip_verify_tls,omitempty"` 154 | KeepHost bool `json:"keep_host,omitempty" yaml:"keep_host,omitempty"` 155 | Headers MapStringSlice `json:"headers,omitempty" yaml:"headers,omitempty"` 156 | } 157 | 158 | type Delay struct { 159 | Min time.Duration `json:"min,omitempty" yaml:"min,omitempty"` 160 | Max time.Duration `json:"max,omitempty" yaml:"max,omitempty"` 161 | } 162 | 163 | func (d *Delay) UnmarshalJSON(data []byte) error { 164 | var s time.Duration 165 | if err := json.Unmarshal(data, &s); err == nil { 166 | d.Min = s 167 | d.Max = s 168 | return d.validate() 169 | } 170 | 171 | var res struct { 172 | Min time.Duration `json:"min"` 173 | Max time.Duration `json:"max"` 174 | } 175 | 176 | if err := json.Unmarshal(data, &res); err != nil { 177 | return err 178 | } 179 | d.Min = res.Min 180 | d.Max = res.Max 181 | return d.validate() 182 | } 183 | 184 | func (d *Delay) UnmarshalYAML(unmarshal func(interface{}) error) error { 185 | var s time.Duration 186 | if err := unmarshal(&s); err == nil { 187 | d.Min = s 188 | d.Max = s 189 | return d.validate() 190 | } 191 | 192 | var res struct { 193 | Min time.Duration `yaml:"min,flow"` 194 | Max time.Duration `yaml:"max,flow"` 195 | } 196 | 197 | if err := unmarshal(&res); err != nil { 198 | return err 199 | } 200 | 201 | d.Min = res.Min 202 | d.Max = res.Max 203 | return d.validate() 204 | } 205 | 206 | func (d *Delay) validate() error { 207 | if d.Min < 0 || d.Max < d.Min { 208 | return fmt.Errorf("invalid delay range: min => %v, max => %v", d.Min, d.Max) 209 | } 210 | return nil 211 | } 212 | 213 | func noFollow(req *http.Request, via []*http.Request) error { 214 | return http.ErrUseLastResponse 215 | } 216 | 217 | func (mp MockProxy) Redirect(req Request) (*MockResponse, error) { 218 | proxyReq, err := http.NewRequest(req.Method, mp.Host+req.Path, strings.NewReader(req.BodyString)) 219 | if err != nil { 220 | return nil, err 221 | } 222 | proxyReq.Header = req.Headers.Clone() 223 | if mp.KeepHost { 224 | proxyReq.Host = req.Headers.Get("Host") 225 | } 226 | for key, values := range mp.Headers { 227 | proxyReq.Header.Del(key) 228 | for _, value := range values { 229 | proxyReq.Header.Add(key, value) 230 | } 231 | } 232 | query := url.Values{} 233 | for key, values := range req.QueryParams { 234 | query[key] = values 235 | } 236 | proxyReq.URL.RawQuery = query.Encode() 237 | log.Debugf("Redirecting to %s", proxyReq.URL.String()) 238 | client := &http.Client{} 239 | if !mp.FollowRedirect { 240 | client.CheckRedirect = noFollow 241 | } 242 | if mp.SkipVerifyTLS { 243 | // we clone to avoid overwriting the default transport configuration 244 | customTransport := http.DefaultTransport.(*http.Transport).Clone() 245 | customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 246 | client.Transport = customTransport 247 | } 248 | resp, err := client.Do(proxyReq) 249 | if err != nil { 250 | return nil, err 251 | } 252 | defer resp.Body.Close() 253 | body, err := ioutil.ReadAll(resp.Body) 254 | if err != nil { 255 | return nil, err 256 | } 257 | respHeader := MapStringSlice{} 258 | for key, values := range resp.Header { 259 | respHeader[key] = values 260 | } 261 | return &MockResponse{ 262 | Status: resp.StatusCode, 263 | Body: string(body), 264 | Headers: respHeader, 265 | Delay: mp.Delay, 266 | }, nil 267 | } 268 | 269 | type MockContext struct { 270 | Times int `json:"times,omitempty" yaml:"times,omitempty"` 271 | } 272 | 273 | type MockState struct { 274 | ID string `json:"id" yaml:"id"` 275 | TimesCount int `json:"times_count" yaml:"times_count"` 276 | Locked bool `json:"locked" yaml:"locked"` 277 | CreationDate time.Time `json:"creation_date" yaml:"creation_date"` 278 | } 279 | --------------------------------------------------------------------------------