├── .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 |
9 |
10 | Go Template (YAML)
11 |
12 |
13 | Go Template (JSON)
14 |
15 | Lua
16 |
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 | {
36 | try {
37 | const json = JSON.parse(rawJSON);
38 | Object.entries(bodyMatcherToPaths(json)).forEach(
39 | ([key, value]) => {
40 | // TODO: handle more matchers
41 | actions.add({ key, matcher: "ShouldEqual", value });
42 | }
43 | );
44 | setInitialized(true);
45 | } catch (e) {
46 | console.error("Invalid JSON body", e);
47 | }
48 | }}
49 | >
50 | Generate Body Matcher
51 |
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 |
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 |
12 | GET
13 | POST
14 | PUT
15 | PATCH
16 | DELETE
17 |
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 |
3 |
4 |
5 | [](https://github.com/smocker-dev/smocker/actions/workflows/main.yml)
6 | [](https://github.com/smocker-dev/smocker/pkgs/container/smocker)
7 | [](https://github.com/smocker-dev/smocker/releases/latest)
8 | [](https://goreportcard.com/report/github.com/smocker-dev/smocker)
9 | [](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 | 
67 |
68 | 
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 |
57 | Save
58 |
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 |
176 |
177 |
178 |
179 |
180 | Sessions
181 | >
182 | ) : (
183 | <>
184 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 | Sessions
197 | >
198 | );
199 | return (
200 |
209 |
215 |
216 | {items}
217 |
218 | }
222 | className="session-button"
223 | onClick={newSession}
224 | >
225 | New Session
226 |
227 |
228 |
229 |
230 | }
233 | className="reset-button"
234 | onClick={resetSessions}
235 | >
236 | Reset Sessions
237 |
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 |
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 | }>Back to History
133 |
134 | } onClick={onSaveSVG}>
135 | Save SVG
136 |
137 | }
140 | onClick={handleEditGraph}
141 | >
142 | Edit
143 |
144 |
145 | }
146 | >
147 |
148 | This is a graphical representation of call history.
149 |
150 |
151 |
156 |
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 |
--------------------------------------------------------------------------------