├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── client.go ├── client_integration_test.go ├── doc.go ├── dto.go ├── dto_test.go ├── go.mod ├── go.sum ├── imposter.go ├── internal ├── assert │ └── assert.go └── rest │ ├── rest.go │ ├── rest_integration_test.go │ └── rest_test.go └── scripts └── integration_test.sh /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: mbgo CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | test: 8 | strategy: 9 | matrix: 10 | go-version: [1.15.x, 1.16.x, 1.17.x, 1.18.x] 11 | os: [ubuntu-latest] 12 | 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | 22 | - name: Unit 23 | run: make unit 24 | 25 | - name: Integration 26 | run: make integration 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | coverage.txt 13 | 14 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 15 | .glide/ 16 | 17 | # Mountebank 18 | mb*.log 19 | mb*.pid 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Senseye 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGES := $(shell go list ./... | grep -v mock) 2 | 3 | .PHONY: default 4 | default: fmt lint 5 | 6 | .PHONY: fmt 7 | ## fmt: runs go fmt on source files 8 | fmt: 9 | @go fmt $(PACKAGES) 10 | 11 | .PHONY: help 12 | ## help: prints this help message 13 | help: 14 | @echo "Usage: \n" 15 | @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' 16 | 17 | .PHONY: integration 18 | ## integration: runs the integration tests 19 | integration: 20 | @go clean -testcache 21 | @sh ./scripts/integration_test.sh $(PACKAGES) 22 | 23 | .PHONY: lint 24 | ## lint: runs go lint on source files 25 | lint: 26 | @golint -set_exit_status -min_confidence=0.3 $(PACKAGES) 27 | 28 | .PHONY: unit 29 | ## unit: runs the unit tests 30 | unit: 31 | @go clean -testcache 32 | @go test -cover -covermode=atomic -race -timeout=1s $(PACKAGES) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mbgo 2 | 3 | [![GoDoc](https://godoc.org/github.com/senseyeio/mbgo?status.svg)](https://godoc.org/github.com/senseyeio/mbgo) [![Build Status](https://travis-ci.org/senseyeio/mbgo.svg?branch=master)](https://travis-ci.org/senseyeio/mbgo) [![Go Report Card](https://goreportcard.com/badge/github.com/senseyeio/mbgo)](https://goreportcard.com/report/github.com/senseyeio/mbgo) 4 | 5 | A mountebank API client for the Go programming language. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | go get -u github.com/senseyeio/mbgo@latest 11 | ``` 12 | 13 | ## Testing 14 | 15 | This package includes both unit and integration tests. Use the `unit` and `integration` targets in the Makefile to run them, respectively: 16 | 17 | ```sh 18 | make unit 19 | make integration 20 | ``` 21 | 22 | The integration tests expect Docker to be available on the host, using it to run a local mountebank container at 23 | `localhost:2525`, with the additional ports 8080-8081 exposed for test imposters. Currently tested against a mountebank 24 | v2.1.2 instance using the [andyrbell/mountebank](https://hub.docker.com/r/andyrbell/mountebank) image on DockerHub. 25 | 26 | ## Contributing 27 | 28 | * Fork the repository. 29 | * Code your changes. 30 | * If applicable, add tests and/or documentation. 31 | * Please ensure all unit and integration tests are passing, and that all code passes `make lint`. 32 | * Raise a new pull request with a short description of your changes. 33 | * Use the following convention for branch naming: `/`. For instance, `smotes/add-smtp-imposters`. 34 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | package mbgo 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "net" 13 | "net/http" 14 | "net/url" 15 | "strconv" 16 | "time" 17 | 18 | "github.com/senseyeio/mbgo/internal/rest" 19 | ) 20 | 21 | // Client represents a native client to the mountebank REST API. 22 | type Client struct { 23 | restCli *rest.Client 24 | } 25 | 26 | // NewClient returns a new instance of *Client given its underlying 27 | // *http.Client restCli and base *url.URL to the mountebank API root. 28 | // 29 | // If nil, defaults the root *url.URL value to point to http://localhost:2525. 30 | func NewClient(cli *http.Client, root *url.URL) *Client { 31 | if root == nil { 32 | root = &url.URL{ 33 | Scheme: "http", 34 | Host: net.JoinHostPort("localhost", "2525"), 35 | } 36 | } 37 | return &Client{ 38 | restCli: rest.NewClient(cli, root), 39 | } 40 | } 41 | 42 | // errorDTO represents the structure of an error received from the mountebank API. 43 | type errorDTO struct { 44 | Code string `json:"code"` 45 | Message string `json:"message"` 46 | } 47 | 48 | // decodeError is a helper method used to decode an errorDTO structure from the 49 | // given response body, usually when an unexpected response code is returned. 50 | func (cli *Client) decodeError(body io.ReadCloser) error { 51 | var wrap struct { 52 | Errors []errorDTO `json:"errors"` 53 | } 54 | if err := cli.restCli.DecodeResponseBody(body, &wrap); err != nil { 55 | return err 56 | } 57 | // Silently ignore all but the first error value if multiple are returned 58 | dto := wrap.Errors[0] 59 | return fmt.Errorf("%s: %s", dto.Code, dto.Message) 60 | } 61 | 62 | // Create creates a single new Imposter given its creation details imp. 63 | // 64 | // Note that the Imposter.RequestCount field is not used during creation. 65 | // 66 | // See more information on this resource at: 67 | // http://www.mbtest.org/docs/api/overview#post-imposters. 68 | func (cli *Client) Create(ctx context.Context, imp Imposter) (*Imposter, error) { 69 | p := "/imposters" 70 | b, err := json.Marshal(&imp) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | req, err := cli.restCli.NewRequest(ctx, http.MethodPost, p, bytes.NewReader(b), nil) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | resp, err := cli.restCli.Do(req) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if resp.StatusCode == http.StatusCreated { 86 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 87 | return nil, err 88 | } 89 | } else { 90 | return nil, cli.decodeError(resp.Body) 91 | } 92 | 93 | return &imp, nil 94 | } 95 | 96 | // Imposter retrieves the Imposter data at the given port. 97 | // 98 | // Note that the Imposter.RecordRequests and Imposter.AllowCORS fields 99 | // are ignored when un-marshalling an Imposter value and should only be 100 | // used when creating an Imposter. 101 | // 102 | // See more information about this resource at: 103 | // http://www.mbtest.org/docs/api/overview#get-imposter. 104 | func (cli *Client) Imposter(ctx context.Context, port int, replay bool) (*Imposter, error) { 105 | p := fmt.Sprintf("/imposters/%d", port) 106 | vs := url.Values{} 107 | vs.Add("replayable", strconv.FormatBool(replay)) 108 | 109 | req, err := cli.restCli.NewRequest(ctx, http.MethodGet, p, nil, vs) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | resp, err := cli.restCli.Do(req) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | var imp Imposter 120 | if resp.StatusCode == http.StatusOK { 121 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 122 | return nil, err 123 | } 124 | } else { 125 | return nil, cli.decodeError(resp.Body) 126 | } 127 | 128 | return &imp, nil 129 | } 130 | 131 | // AddStub adds a new Stub without restarting its Imposter given the imposter's 132 | // port and the new stub's index, or simply to the end of the array if index < 0. 133 | // 134 | // See more information about this resource at: 135 | // http://www.mbtest.org/docs/api/overview#add-stub 136 | func (cli *Client) AddStub(ctx context.Context, port, index int, stub Stub) (*Imposter, error) { 137 | p := fmt.Sprintf("/imposters/%d/stubs", port) 138 | 139 | dto := map[string]interface{}{"stub": stub} 140 | if index >= 0 { 141 | dto["index"] = index 142 | } 143 | b, err := json.Marshal(dto) 144 | if err != nil { 145 | return nil, err 146 | } 147 | 148 | req, err := cli.restCli.NewRequest(ctx, http.MethodPost, p, bytes.NewReader(b), nil) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | resp, err := cli.restCli.Do(req) 154 | if err != nil { 155 | return nil, err 156 | } 157 | 158 | var imp Imposter 159 | if resp.StatusCode == http.StatusOK { 160 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 161 | return nil, err 162 | } 163 | } else { 164 | return nil, cli.decodeError(resp.Body) 165 | } 166 | return &imp, nil 167 | } 168 | 169 | // OverwriteStub overwrites an existing Stub without restarting its Imposter, 170 | // where the stub index denotes the stub to be changed. 171 | // 172 | // See more information about this resouce at: 173 | // http://www.mbtest.org/docs/api/overview#change-stub 174 | func (cli *Client) OverwriteStub(ctx context.Context, port, index int, stub Stub) (*Imposter, error) { 175 | p := fmt.Sprintf("/imposters/%d/stubs/%d", port, index) 176 | 177 | b, err := json.Marshal(stub) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | req, err := cli.restCli.NewRequest(ctx, http.MethodPut, p, bytes.NewReader(b), nil) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | resp, err := cli.restCli.Do(req) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | var imp Imposter 193 | if resp.StatusCode == http.StatusOK { 194 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 195 | return nil, err 196 | } 197 | } else { 198 | return nil, cli.decodeError(resp.Body) 199 | } 200 | return &imp, nil 201 | } 202 | 203 | // OverwriteAllStubs overwrites all existing Stubs without restarting their Imposter. 204 | // 205 | // See more information about this resource at: 206 | // http://www.mbtest.org/docs/api/overview#change-stubs 207 | func (cli *Client) OverwriteAllStubs(ctx context.Context, port int, stubs []Stub) (*Imposter, error) { 208 | p := fmt.Sprintf("/imposters/%d/stubs", port) 209 | 210 | b, err := json.Marshal(map[string]interface{}{ 211 | "stubs": stubs, 212 | }) 213 | if err != nil { 214 | return nil, err 215 | } 216 | 217 | req, err := cli.restCli.NewRequest(ctx, http.MethodPut, p, bytes.NewReader(b), nil) 218 | if err != nil { 219 | return nil, err 220 | } 221 | 222 | resp, err := cli.restCli.Do(req) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | var imp Imposter 228 | if resp.StatusCode == http.StatusOK { 229 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 230 | return nil, err 231 | } 232 | } else { 233 | return nil, cli.decodeError(resp.Body) 234 | } 235 | return &imp, nil 236 | } 237 | 238 | // RemoveStub removes a Stub without restarting its Imposter. 239 | // 240 | // See more information about this resource at: 241 | // http://www.mbtest.org/docs/api/overview#delete-stub 242 | func (cli *Client) RemoveStub(ctx context.Context, port, index int) (*Imposter, error) { 243 | p := fmt.Sprintf("/imposters/%d/stubs/%d", port, index) 244 | 245 | req, err := cli.restCli.NewRequest(ctx, http.MethodDelete, p, http.NoBody, nil) 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | resp, err := cli.restCli.Do(req) 251 | if err != nil { 252 | return nil, err 253 | } 254 | 255 | var imp Imposter 256 | if resp.StatusCode == http.StatusOK { 257 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 258 | return nil, err 259 | } 260 | } else { 261 | return nil, cli.decodeError(resp.Body) 262 | } 263 | return &imp, nil 264 | } 265 | 266 | // Delete removes an Imposter configured on the given port and returns 267 | // the deleted Imposter data, or an empty Imposter struct if one does not 268 | // exist on the port. 269 | // 270 | // See more information about this resource at: 271 | // http://www.mbtest.org/docs/api/overview#delete-imposter. 272 | func (cli *Client) Delete(ctx context.Context, port int, replay bool) (*Imposter, error) { 273 | p := fmt.Sprintf("/imposters/%d", port) 274 | vs := url.Values{} 275 | vs.Add("replayable", strconv.FormatBool(replay)) 276 | 277 | req, err := cli.restCli.NewRequest(ctx, http.MethodDelete, p, nil, vs) 278 | if err != nil { 279 | return nil, err 280 | } 281 | 282 | resp, err := cli.restCli.Do(req) 283 | if err != nil { 284 | return nil, err 285 | } 286 | 287 | var imp Imposter 288 | if resp.StatusCode == http.StatusOK { 289 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 290 | return nil, err 291 | } 292 | } else { 293 | return nil, cli.decodeError(resp.Body) 294 | } 295 | return &imp, nil 296 | } 297 | 298 | // DeleteRequests removes any recorded requests associated with the 299 | // Imposter on the given port and returns the Imposter including the 300 | // deleted requests, or an empty Imposter struct if one does not exist 301 | // on the port. 302 | // 303 | // See more information about this resource at: 304 | // http://www.mbtest.org/docs/api/overview#delete-imposter-requests. 305 | func (cli *Client) DeleteRequests(ctx context.Context, port int) (*Imposter, error) { 306 | p := fmt.Sprintf("/imposters/%d/savedProxyResponses", port) 307 | 308 | req, err := cli.restCli.NewRequest(ctx, http.MethodDelete, p, nil, nil) 309 | if err != nil { 310 | return nil, err 311 | } 312 | 313 | resp, err := cli.restCli.Do(req) 314 | if err != nil { 315 | return nil, err 316 | } 317 | 318 | var imp Imposter 319 | if resp.StatusCode == http.StatusOK { 320 | if err := cli.restCli.DecodeResponseBody(resp.Body, &imp); err != nil { 321 | return nil, err 322 | } 323 | } else { 324 | return nil, cli.decodeError(resp.Body) 325 | } 326 | return &imp, nil 327 | } 328 | 329 | type imposterListWrapper struct { 330 | Imposters []Imposter `json:"imposters"` 331 | } 332 | 333 | // Overwrite is used to overwrite all registered Imposters with a new 334 | // set of Imposters. This call is destructive, removing all previous 335 | // Imposters even if the new set of Imposters do not conflict with 336 | // previously registered protocols/ports. 337 | // 338 | // See more information about this resource at: 339 | // http://www.mbtest.org/docs/api/overview#put-imposters. 340 | func (cli *Client) Overwrite(ctx context.Context, imps []Imposter) ([]Imposter, error) { 341 | p := "/imposters" 342 | 343 | b, err := json.Marshal(&struct { 344 | Imposters []Imposter `json:"imposters"` 345 | }{ 346 | Imposters: imps, 347 | }) 348 | if err != nil { 349 | return nil, err 350 | } 351 | 352 | req, err := cli.restCli.NewRequest(ctx, http.MethodPut, p, bytes.NewReader(b), nil) 353 | if err != nil { 354 | return nil, err 355 | } 356 | 357 | resp, err := cli.restCli.Do(req) 358 | if err != nil { 359 | return nil, err 360 | } 361 | 362 | var wrap imposterListWrapper 363 | if resp.StatusCode == http.StatusOK { 364 | if err := cli.restCli.DecodeResponseBody(resp.Body, &wrap); err != nil { 365 | return nil, err 366 | } 367 | } else { 368 | return nil, cli.decodeError(resp.Body) 369 | } 370 | return wrap.Imposters, nil 371 | } 372 | 373 | // Imposters retrieves a list of all Imposters registered in mountebank. 374 | // 375 | // See more information about this resource at: 376 | // http://www.mbtest.org/docs/api/overview#get-imposters. 377 | func (cli *Client) Imposters(ctx context.Context, replay bool) ([]Imposter, error) { 378 | p := "/imposters" 379 | vs := url.Values{} 380 | vs.Add("replayable", strconv.FormatBool(replay)) 381 | 382 | req, err := cli.restCli.NewRequest(ctx, http.MethodGet, p, nil, vs) 383 | if err != nil { 384 | return nil, err 385 | } 386 | 387 | resp, err := cli.restCli.Do(req) 388 | if err != nil { 389 | return nil, err 390 | } 391 | 392 | var wrap imposterListWrapper 393 | if resp.StatusCode == http.StatusOK { 394 | if err := cli.restCli.DecodeResponseBody(resp.Body, &wrap); err != nil { 395 | return nil, err 396 | } 397 | } else { 398 | return nil, cli.decodeError(resp.Body) 399 | } 400 | return wrap.Imposters, nil 401 | } 402 | 403 | // DeleteAll removes all registered Imposters from mountebank and closes 404 | // their listening socket. This is the surest way to reset mountebank 405 | // between test runs. 406 | // 407 | // See more information about this resource at: 408 | // http://www.mbtest.org/docs/api/overview#delete-imposters. 409 | func (cli *Client) DeleteAll(ctx context.Context, replay bool) ([]Imposter, error) { 410 | p := "/imposters" 411 | vs := url.Values{} 412 | vs.Add("replayable", strconv.FormatBool(replay)) 413 | 414 | req, err := cli.restCli.NewRequest(ctx, http.MethodDelete, p, nil, vs) 415 | if err != nil { 416 | return nil, err 417 | } 418 | 419 | resp, err := cli.restCli.Do(req) 420 | if err != nil { 421 | return nil, err 422 | } 423 | 424 | var wrap imposterListWrapper 425 | if resp.StatusCode == http.StatusOK { 426 | if err := cli.restCli.DecodeResponseBody(resp.Body, &wrap); err != nil { 427 | return nil, err 428 | } 429 | } else { 430 | return nil, cli.decodeError(resp.Body) 431 | } 432 | return wrap.Imposters, nil 433 | } 434 | 435 | // Config represents information about the configuration of the mountebank 436 | // server runtime, including its version, options and runtime information. 437 | // 438 | // See more information about its full structure at: 439 | // http://www.mbtest.org/docs/api/contracts?type=config. 440 | type Config struct { 441 | // Version represents the mountebank version in semantic M.m.p format. 442 | Version string `json:"version"` 443 | 444 | // Options represent runtime options of the mountebank server process. 445 | Options struct { 446 | Help bool `json:"help"` 447 | NoParse bool `json:"noParse"` 448 | NoLogFile bool `json:"nologfile"` 449 | AllowInjection bool `json:"allowInjection"` 450 | LocalOnly bool `json:"localOnly"` 451 | Mock bool `json:"mock"` 452 | Debug bool `json:"debug"` 453 | Port int `json:"port"` 454 | PIDFile string `json:"pidfile"` 455 | LogFile string `json:"logfile"` 456 | LogLevel string `json:"loglevel"` 457 | IPWhitelist []string `json:"ipWhitelist"` 458 | } `json:"options"` 459 | 460 | // Process represents information about the mountebank server NodeJS runtime. 461 | Process struct { 462 | NodeVersion string `json:"nodeVersion"` 463 | Architecture string `json:"architecture"` 464 | Platform string `json:"platform"` 465 | RSS int64 `json:"rss"` 466 | HeapTotal int64 `json:"heapTotal"` 467 | HeapUsed int64 `json:"heapUsed"` 468 | Uptime float64 `json:"uptime"` 469 | CWD string `json:"cwd"` 470 | } `json:"process"` 471 | } 472 | 473 | // Config retrieves the configuration information of the mountebank 474 | // server pointed to by the client. 475 | // 476 | // See more information on this resource at: 477 | // http://www.mbtest.org/docs/api/overview#get-config. 478 | func (cli *Client) Config(ctx context.Context) (*Config, error) { 479 | p := "/config" 480 | 481 | req, err := cli.restCli.NewRequest(ctx, http.MethodGet, p, nil, nil) 482 | if err != nil { 483 | return nil, err 484 | } 485 | 486 | resp, err := cli.restCli.Do(req) 487 | if err != nil { 488 | return nil, err 489 | } 490 | 491 | var cfg Config 492 | if resp.StatusCode == http.StatusOK { 493 | if err := cli.restCli.DecodeResponseBody(resp.Body, &cfg); err != nil { 494 | return nil, err 495 | } 496 | } else { 497 | return nil, cli.decodeError(resp.Body) 498 | } 499 | return &cfg, nil 500 | } 501 | 502 | // Log represents a log entry value in mountebank. 503 | // 504 | // See more information about its full structure at: 505 | // http://www.mbtest.org/docs/api/contracts?type=logs. 506 | type Log struct { 507 | Level string `json:"level"` 508 | Timestamp time.Time `json:"timestamp"` 509 | Message string `json:"message"` 510 | } 511 | 512 | // Logs retrieves the Log values across all registered Imposters 513 | // between the provided start and end indices, with either index 514 | // filter being excluded if less than zero. Set start < 0 and 515 | // end < 0 to include all Log values. 516 | // 517 | // See more information on this resource at: 518 | // http://www.mbtest.org/docs/api/overview#get-logs. 519 | func (cli *Client) Logs(ctx context.Context, start, end int) ([]Log, error) { 520 | p := "/logs" 521 | vs := url.Values{} 522 | if start >= 0 { 523 | vs.Add("startIndex", strconv.Itoa(start)) 524 | } 525 | if end >= 0 { 526 | vs.Add("endIndex", strconv.Itoa(end)) 527 | } 528 | 529 | req, err := cli.restCli.NewRequest(ctx, http.MethodGet, p, nil, vs) 530 | if err != nil { 531 | return nil, err 532 | } 533 | 534 | resp, err := cli.restCli.Do(req) 535 | if err != nil { 536 | return nil, err 537 | } 538 | 539 | var wrap struct { 540 | Logs []Log `json:"logs"` 541 | } 542 | if resp.StatusCode == http.StatusOK { 543 | if err := cli.restCli.DecodeResponseBody(resp.Body, &wrap); err != nil { 544 | return nil, err 545 | } 546 | } else { 547 | return nil, cli.decodeError(resp.Body) 548 | } 549 | return wrap.Logs, nil 550 | } 551 | -------------------------------------------------------------------------------- /client_integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | //go:build integration 5 | // +build integration 6 | 7 | package mbgo_test 8 | 9 | import ( 10 | "context" 11 | "errors" 12 | "net/http" 13 | "net/url" 14 | "testing" 15 | "time" 16 | 17 | "github.com/senseyeio/mbgo" 18 | "github.com/senseyeio/mbgo/internal/assert" 19 | ) 20 | 21 | // newMountebankClient creates a new mountebank client instance pointing to the host 22 | // denoted by the MB_HOST environment variable, or localhost:2525 if blank. 23 | func newMountebankClient() *mbgo.Client { 24 | return mbgo.NewClient(&http.Client{ 25 | Timeout: time.Second, 26 | }, &url.URL{ 27 | Scheme: "http", 28 | Host: "localhost:2525", 29 | }) 30 | } 31 | 32 | // newContext returns a new context instance with the given timeout. 33 | func newContext(timeout time.Duration) context.Context { 34 | ctx, _ := context.WithTimeout(context.Background(), timeout) 35 | return ctx 36 | } 37 | 38 | func TestClient_Logs_Integration(t *testing.T) { 39 | mb := newMountebankClient() 40 | 41 | vs, err := mb.Logs(newContext(time.Second), -1, -1) 42 | assert.MustOk(t, err) 43 | assert.Equals(t, true, len(vs) >= 2) 44 | assert.Equals(t, "[mb:2525] mountebank v2.1.2 now taking orders - point your browser to http://localhost:2525/ for help", vs[0].Message) 45 | assert.Equals(t, "[mb:2525] Running with --allowInjection set. See http://localhost:2525/docs/security for security info", vs[1].Message) 46 | assert.Equals(t, "[mb:2525] GET /logs", vs[2].Message) 47 | } 48 | 49 | func TestClient_Create_Integration(t *testing.T) { 50 | mb := newMountebankClient() 51 | 52 | cases := []struct { 53 | // general 54 | Description string 55 | Before func(*testing.T, *mbgo.Client) 56 | After func(*testing.T, *mbgo.Client) 57 | 58 | // input 59 | Input mbgo.Imposter 60 | 61 | // output expectations 62 | Expected *mbgo.Imposter 63 | Err error 64 | }{ 65 | { 66 | Description: "should error if an invalid port is provided", 67 | Input: mbgo.Imposter{ 68 | Proto: "http", 69 | Port: 328473289572983424, 70 | }, 71 | Err: errors.New("bad data: invalid value for 'port'"), 72 | }, 73 | { 74 | Description: "should error if an invalid protocol is provided", 75 | Input: mbgo.Imposter{ 76 | Proto: "udp", 77 | Port: 8080, 78 | }, 79 | Err: errors.New("bad data: the udp protocol is not yet supported"), 80 | }, 81 | { 82 | Description: "should create the expected HTTP Imposter on success", 83 | Input: mbgo.Imposter{ 84 | Proto: "http", 85 | Port: 8080, 86 | Name: "create_test", 87 | RecordRequests: true, 88 | AllowCORS: true, 89 | Stubs: []mbgo.Stub{ 90 | { 91 | Predicates: []mbgo.Predicate{ 92 | { 93 | Operator: "equals", 94 | Request: mbgo.HTTPRequest{ 95 | Method: http.MethodGet, 96 | Path: "/foo", 97 | Query: map[string][]string{ 98 | "page": {"3"}, 99 | }, 100 | Headers: map[string][]string{ 101 | "Accept": {"application/json"}, 102 | }, 103 | }, 104 | }, 105 | }, 106 | Responses: []mbgo.Response{ 107 | { 108 | Type: "is", 109 | Value: mbgo.HTTPResponse{ 110 | StatusCode: http.StatusOK, 111 | Headers: map[string][]string{ 112 | "Content-Type": {"application/json"}, 113 | }, 114 | Body: `{"test":true}`, 115 | }, 116 | }, 117 | }, 118 | }, 119 | }, 120 | }, 121 | Before: func(t *testing.T, mb *mbgo.Client) { 122 | _, err := mb.Delete(newContext(time.Second), 8080, false) 123 | assert.MustOk(t, err) 124 | }, 125 | After: func(t *testing.T, mb *mbgo.Client) { 126 | imp, err := mb.Delete(newContext(time.Second), 8080, false) 127 | assert.MustOk(t, err) 128 | assert.Equals(t, "create_test", imp.Name) 129 | }, 130 | Expected: &mbgo.Imposter{ 131 | Proto: "http", 132 | Port: 8080, 133 | Name: "create_test", 134 | RecordRequests: true, 135 | AllowCORS: true, 136 | RequestCount: 0, 137 | Stubs: []mbgo.Stub{ 138 | { 139 | Predicates: []mbgo.Predicate{ 140 | { 141 | Operator: "equals", 142 | Request: &mbgo.HTTPRequest{ 143 | Method: http.MethodGet, 144 | Path: "/foo", 145 | Query: map[string][]string{ 146 | "page": {"3"}, 147 | }, 148 | Headers: map[string][]string{ 149 | "Accept": {"application/json"}, 150 | }, 151 | }, 152 | }, 153 | }, 154 | Responses: []mbgo.Response{ 155 | { 156 | Type: "is", 157 | Value: &mbgo.HTTPResponse{ 158 | StatusCode: http.StatusOK, 159 | Headers: map[string][]string{ 160 | "Content-Type": {"application/json"}, 161 | }, 162 | Body: `{"test":true}`, 163 | }, 164 | }, 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | { 171 | Description: "should support creation of javascript injection on predicates", 172 | Input: mbgo.Imposter{ 173 | Proto: "tcp", 174 | Port: 8080, 175 | Name: "create_test_predicate_javascript_injection", 176 | Stubs: []mbgo.Stub{ 177 | { 178 | Predicates: []mbgo.Predicate{ 179 | { 180 | Operator: "inject", 181 | Request: "request => { return Buffer.from(request.data, 'base64')[2] <= 100; }", 182 | }, 183 | }, 184 | Responses: []mbgo.Response{ 185 | { 186 | Type: "is", 187 | Value: mbgo.TCPResponse{ 188 | Data: "c2Vjb25kIHJlc3BvbnNl", 189 | }, 190 | }, 191 | }, 192 | }, 193 | }, 194 | }, 195 | Before: func(t *testing.T, mb *mbgo.Client) { 196 | _, err := mb.Delete(newContext(time.Second), 8080, false) 197 | assert.MustOk(t, err) 198 | }, 199 | After: func(t *testing.T, mb *mbgo.Client) { 200 | _, err := mb.Delete(newContext(time.Second), 8080, false) 201 | assert.MustOk(t, err) 202 | }, 203 | Expected: &mbgo.Imposter{ 204 | Proto: "tcp", 205 | Port: 8080, 206 | Name: "create_test_predicate_javascript_injection", 207 | Stubs: []mbgo.Stub{ 208 | { 209 | Predicates: []mbgo.Predicate{ 210 | { 211 | Operator: "inject", 212 | Request: "request => { return Buffer.from(request.data, 'base64')[2] <= 100; }", 213 | }, 214 | }, 215 | Responses: []mbgo.Response{ 216 | { 217 | Type: "is", 218 | Value: &mbgo.TCPResponse{ 219 | Data: "c2Vjb25kIHJlc3BvbnNl", 220 | }, 221 | }, 222 | }, 223 | }, 224 | }, 225 | }, 226 | }, 227 | { 228 | Description: "should support nested logical predicates", 229 | Input: mbgo.Imposter{ 230 | Proto: "http", 231 | Port: 8080, 232 | Name: "create_test_predicate_nested_logical", 233 | Stubs: []mbgo.Stub{ 234 | { 235 | Predicates: []mbgo.Predicate{ 236 | { 237 | Operator: "or", 238 | Request: []mbgo.Predicate{ 239 | { 240 | Operator: "equals", 241 | Request: mbgo.HTTPRequest{ 242 | Method: http.MethodPost, 243 | Path: "/foo", 244 | }, 245 | }, 246 | { 247 | Operator: "equals", 248 | Request: mbgo.HTTPRequest{ 249 | Method: http.MethodPost, 250 | Path: "/bar", 251 | }, 252 | }, 253 | { 254 | Operator: "and", 255 | Request: []mbgo.Predicate{ 256 | { 257 | Operator: "equals", 258 | Request: mbgo.HTTPRequest{ 259 | Method: http.MethodPost, 260 | Path: "/baz", 261 | }, 262 | }, 263 | { 264 | Operator: "equals", 265 | Request: mbgo.HTTPRequest{ 266 | Body: "foo", 267 | }, 268 | }, 269 | }, 270 | }, 271 | }, 272 | }, 273 | }, 274 | Responses: []mbgo.Response{ 275 | { 276 | Type: "is", 277 | Value: mbgo.HTTPResponse{ 278 | StatusCode: http.StatusOK, 279 | }, 280 | }, 281 | }, 282 | }, 283 | }, 284 | }, 285 | Before: func(t *testing.T, mb *mbgo.Client) { 286 | _, err := mb.Delete(newContext(time.Second), 8080, false) 287 | assert.MustOk(t, err) 288 | }, 289 | After: func(t *testing.T, mb *mbgo.Client) { 290 | _, err := mb.Delete(newContext(time.Second), 8080, false) 291 | assert.MustOk(t, err) 292 | }, 293 | Expected: &mbgo.Imposter{ 294 | Proto: "http", 295 | Port: 8080, 296 | Name: "create_test_predicate_nested_logical", 297 | Stubs: []mbgo.Stub{ 298 | { 299 | Predicates: []mbgo.Predicate{ 300 | { 301 | Operator: "or", 302 | Request: []mbgo.Predicate{ 303 | { 304 | Operator: "equals", 305 | Request: &mbgo.HTTPRequest{ 306 | Method: http.MethodPost, 307 | Path: "/foo", 308 | }, 309 | }, 310 | { 311 | Operator: "equals", 312 | Request: &mbgo.HTTPRequest{ 313 | Method: http.MethodPost, 314 | Path: "/bar", 315 | }, 316 | }, 317 | { 318 | Operator: "and", 319 | Request: []mbgo.Predicate{ 320 | { 321 | Operator: "equals", 322 | Request: &mbgo.HTTPRequest{ 323 | Method: http.MethodPost, 324 | Path: "/baz", 325 | }, 326 | }, 327 | { 328 | Operator: "equals", 329 | Request: &mbgo.HTTPRequest{ 330 | Body: "foo", 331 | }, 332 | }, 333 | }, 334 | }, 335 | }, 336 | }, 337 | }, 338 | Responses: []mbgo.Response{ 339 | { 340 | Type: "is", 341 | Value: &mbgo.HTTPResponse{ 342 | StatusCode: http.StatusOK, 343 | }, 344 | }, 345 | }, 346 | }, 347 | }, 348 | }, 349 | }, 350 | } 351 | 352 | for _, c := range cases { 353 | t.Run(c.Description, func(t *testing.T) { 354 | if c.Before != nil { 355 | c.Before(t, mb) 356 | } 357 | 358 | actual, err := mb.Create(newContext(time.Second), c.Input) 359 | if c.Err != nil { 360 | assert.Equals(t, c.Err, err) 361 | } else { 362 | assert.Ok(t, err) 363 | } 364 | assert.Equals(t, c.Expected, actual) 365 | 366 | if c.After != nil { 367 | c.After(t, mb) 368 | } 369 | }) 370 | } 371 | } 372 | 373 | func TestClient_Imposter_Integration(t *testing.T) { 374 | mb := newMountebankClient() 375 | 376 | cases := []struct { 377 | // general 378 | Description string 379 | Before func(*testing.T, *mbgo.Client) 380 | After func(*testing.T, *mbgo.Client) 381 | 382 | // input 383 | Port int 384 | Replay bool 385 | 386 | // output expectations 387 | Expected *mbgo.Imposter 388 | Err error 389 | }{ 390 | { 391 | Description: "should error if an Imposter does not exist on the specified port", 392 | Port: 8080, 393 | Before: func(t *testing.T, mb *mbgo.Client) { 394 | _, err := mb.Delete(newContext(time.Second), 8080, false) 395 | assert.MustOk(t, err) 396 | }, 397 | Err: errors.New("no such resource: Try POSTing to /imposters first?"), 398 | }, 399 | { 400 | Description: "should return the expected TCP Imposter if it exists on the specified port", 401 | Before: func(t *testing.T, mb *mbgo.Client) { 402 | _, err := mb.Delete(newContext(time.Second), 8080, false) 403 | assert.MustOk(t, err) 404 | 405 | imp, err := mb.Create(newContext(time.Second), mbgo.Imposter{ 406 | Port: 8080, 407 | Proto: "tcp", 408 | Name: "imposter_test", 409 | RecordRequests: true, 410 | Stubs: []mbgo.Stub{ 411 | { 412 | Predicates: []mbgo.Predicate{ 413 | { 414 | Operator: "endsWith", 415 | Request: mbgo.TCPRequest{ 416 | Data: "SGVsbG8sIHdvcmxkIQ==", 417 | }, 418 | }, 419 | }, 420 | Responses: []mbgo.Response{ 421 | { 422 | Type: "is", 423 | Value: mbgo.TCPResponse{ 424 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 425 | }, 426 | }, 427 | }, 428 | }, 429 | }, 430 | }) 431 | assert.MustOk(t, err) 432 | assert.Equals(t, "imposter_test", imp.Name) 433 | }, 434 | After: func(t *testing.T, mb *mbgo.Client) { 435 | imp, err := mb.Delete(newContext(time.Second), 8080, false) 436 | assert.MustOk(t, err) 437 | assert.Equals(t, "imposter_test", imp.Name) 438 | }, 439 | Port: 8080, 440 | Replay: false, 441 | Expected: &mbgo.Imposter{ 442 | Port: 8080, 443 | Proto: "tcp", 444 | Name: "imposter_test", 445 | RecordRequests: false, // this field is only used for creation 446 | RequestCount: 0, 447 | Stubs: []mbgo.Stub{ 448 | { 449 | Predicates: []mbgo.Predicate{ 450 | { 451 | Operator: "endsWith", 452 | Request: &mbgo.TCPRequest{ 453 | Data: "SGVsbG8sIHdvcmxkIQ==", 454 | }, 455 | }, 456 | }, 457 | Responses: []mbgo.Response{ 458 | { 459 | Type: "is", 460 | Value: &mbgo.TCPResponse{ 461 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 462 | }, 463 | }, 464 | }, 465 | }, 466 | }, 467 | }, 468 | }, 469 | } 470 | 471 | for _, c := range cases { 472 | t.Run(c.Description, func(t *testing.T) { 473 | if c.Before != nil { 474 | c.Before(t, mb) 475 | } 476 | 477 | actual, err := mb.Imposter(newContext(time.Second), c.Port, c.Replay) 478 | if c.Err != nil { 479 | assert.Equals(t, c.Err, err) 480 | } else { 481 | assert.Ok(t, err) 482 | } 483 | assert.Equals(t, c.Expected, actual) 484 | 485 | if c.After != nil { 486 | c.After(t, mb) 487 | } 488 | }) 489 | } 490 | } 491 | 492 | func TestClient_AddStub_Integration(t *testing.T) { 493 | mb := newMountebankClient() 494 | 495 | cases := map[string]struct { 496 | Before func(*testing.T, *mbgo.Client) 497 | After func(*testing.T, *mbgo.Client) 498 | Port int 499 | Index int 500 | Stub mbgo.Stub 501 | 502 | // output expectations 503 | Expected *mbgo.Imposter 504 | Err error 505 | }{ 506 | "should error if an imposter does not exist on the specified port": { 507 | Port: 8080, 508 | Before: func(t *testing.T, mb *mbgo.Client) { 509 | _, err := mb.Delete(newContext(time.Second), 8080, false) 510 | assert.MustOk(t, err) 511 | }, 512 | Err: errors.New("no such resource: Try POSTing to /imposters first?"), 513 | }, 514 | "should update the stubs on the imposter if it exists on the specified port": { 515 | Port: 8080, 516 | Index: 0, 517 | Stub: mbgo.Stub{ 518 | Predicates: []mbgo.Predicate{ 519 | { 520 | Operator: "endsWith", 521 | Request: mbgo.TCPRequest{ 522 | Data: "foo", 523 | }, 524 | }, 525 | }, 526 | Responses: []mbgo.Response{ 527 | { 528 | Type: "is", 529 | Value: mbgo.TCPResponse{ 530 | Data: "bar", 531 | }, 532 | }, 533 | }, 534 | }, 535 | Before: func(t *testing.T, mb *mbgo.Client) { 536 | _, err := mb.Create(newContext(time.Second), mbgo.Imposter{ 537 | Port: 8080, 538 | Proto: "tcp", 539 | Name: "add_stub_test", 540 | Stubs: []mbgo.Stub{ 541 | { 542 | Predicates: []mbgo.Predicate{ 543 | { 544 | Operator: "endsWith", 545 | Request: mbgo.TCPRequest{ 546 | Data: "SGVsbG8sIHdvcmxkIQ==", 547 | }, 548 | }, 549 | }, 550 | Responses: []mbgo.Response{ 551 | { 552 | Type: "is", 553 | Value: mbgo.TCPResponse{ 554 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 555 | }, 556 | }, 557 | }, 558 | }, 559 | }, 560 | }) 561 | assert.MustOk(t, err) 562 | }, 563 | After: func(t *testing.T, client *mbgo.Client) { 564 | _, err := mb.Delete(newContext(time.Second), 8080, false) 565 | assert.MustOk(t, err) 566 | }, 567 | Expected: &mbgo.Imposter{ 568 | Port: 8080, 569 | Proto: "tcp", 570 | Name: "add_stub_test", 571 | Stubs: []mbgo.Stub{ 572 | { 573 | Predicates: []mbgo.Predicate{ 574 | { 575 | Operator: "endsWith", 576 | Request: &mbgo.TCPRequest{ 577 | Data: "foo", 578 | }, 579 | }, 580 | }, 581 | Responses: []mbgo.Response{ 582 | { 583 | Type: "is", 584 | Value: &mbgo.TCPResponse{ 585 | Data: "bar", 586 | }, 587 | }, 588 | }, 589 | }, 590 | { 591 | Predicates: []mbgo.Predicate{ 592 | { 593 | Operator: "endsWith", 594 | Request: &mbgo.TCPRequest{ 595 | Data: "SGVsbG8sIHdvcmxkIQ==", 596 | }, 597 | }, 598 | }, 599 | Responses: []mbgo.Response{ 600 | { 601 | Type: "is", 602 | Value: &mbgo.TCPResponse{ 603 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 604 | }, 605 | }, 606 | }, 607 | }, 608 | }, 609 | }, 610 | }, 611 | } 612 | 613 | for name, c := range cases { 614 | c := c 615 | 616 | t.Run(name, func(t *testing.T) { 617 | if c.Before != nil { 618 | c.Before(t, mb) 619 | } 620 | 621 | actual, err := mb.AddStub(newContext(time.Second), c.Port, c.Index, c.Stub) 622 | if c.Err != nil { 623 | assert.Equals(t, c.Err, err) 624 | } else { 625 | assert.Ok(t, err) 626 | } 627 | assert.Equals(t, c.Expected, actual) 628 | 629 | if c.After != nil { 630 | c.After(t, mb) 631 | } 632 | }) 633 | } 634 | } 635 | 636 | func TestClient_OverwriteStub_Integration(t *testing.T) { 637 | mb := newMountebankClient() 638 | 639 | cases := map[string]struct { 640 | Before func(*testing.T, *mbgo.Client) 641 | After func(*testing.T, *mbgo.Client) 642 | Port int 643 | Index int 644 | Stub mbgo.Stub 645 | 646 | // output expectations 647 | Expected *mbgo.Imposter 648 | Err error 649 | }{ 650 | "should error if an imposter does not exist on the specified port": { 651 | Port: 8080, 652 | Before: func(t *testing.T, mb *mbgo.Client) { 653 | _, err := mb.Delete(newContext(time.Second), 8080, false) 654 | assert.MustOk(t, err) 655 | }, 656 | Err: errors.New("no such resource: Try POSTing to /imposters first?"), 657 | }, 658 | "should overwrite the stub on the imposter if it exists on the specified port": { 659 | Port: 8080, 660 | Index: 0, 661 | Stub: mbgo.Stub{ 662 | Predicates: []mbgo.Predicate{ 663 | { 664 | Operator: "endsWith", 665 | Request: mbgo.TCPRequest{ 666 | Data: "foo", 667 | }, 668 | }, 669 | }, 670 | Responses: []mbgo.Response{ 671 | { 672 | Type: "is", 673 | Value: mbgo.TCPResponse{ 674 | Data: "bar", 675 | }, 676 | }, 677 | }, 678 | }, 679 | Before: func(t *testing.T, mb *mbgo.Client) { 680 | _, err := mb.Create(newContext(time.Second), mbgo.Imposter{ 681 | Port: 8080, 682 | Proto: "tcp", 683 | Name: "overwrite_stub_test", 684 | Stubs: []mbgo.Stub{ 685 | { 686 | Predicates: []mbgo.Predicate{ 687 | { 688 | Operator: "endsWith", 689 | Request: mbgo.TCPRequest{ 690 | Data: "SGVsbG8sIHdvcmxkIQ==", 691 | }, 692 | }, 693 | }, 694 | Responses: []mbgo.Response{ 695 | { 696 | Type: "is", 697 | Value: mbgo.TCPResponse{ 698 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 699 | }, 700 | }, 701 | }, 702 | }, 703 | }, 704 | }) 705 | assert.MustOk(t, err) 706 | }, 707 | After: func(t *testing.T, client *mbgo.Client) { 708 | _, err := mb.Delete(newContext(time.Second), 8080, false) 709 | assert.MustOk(t, err) 710 | }, 711 | Expected: &mbgo.Imposter{ 712 | Port: 8080, 713 | Proto: "tcp", 714 | Name: "overwrite_stub_test", 715 | Stubs: []mbgo.Stub{ 716 | { 717 | Predicates: []mbgo.Predicate{ 718 | { 719 | Operator: "endsWith", 720 | Request: &mbgo.TCPRequest{ 721 | Data: "foo", 722 | }, 723 | }, 724 | }, 725 | Responses: []mbgo.Response{ 726 | { 727 | Type: "is", 728 | Value: &mbgo.TCPResponse{ 729 | Data: "bar", 730 | }, 731 | }, 732 | }, 733 | }, 734 | }, 735 | }, 736 | }, 737 | } 738 | 739 | for name, c := range cases { 740 | c := c 741 | 742 | t.Run(name, func(t *testing.T) { 743 | if c.Before != nil { 744 | c.Before(t, mb) 745 | } 746 | 747 | actual, err := mb.OverwriteStub(newContext(time.Second), c.Port, c.Index, c.Stub) 748 | if c.Err != nil { 749 | assert.Equals(t, c.Err, err) 750 | } else { 751 | assert.Ok(t, err) 752 | } 753 | assert.Equals(t, c.Expected, actual) 754 | 755 | if c.After != nil { 756 | c.After(t, mb) 757 | } 758 | }) 759 | } 760 | } 761 | 762 | func TestClient_OverwriteAllStubs_Integration(t *testing.T) { 763 | mb := newMountebankClient() 764 | 765 | cases := map[string]struct { 766 | Before func(*testing.T, *mbgo.Client) 767 | After func(*testing.T, *mbgo.Client) 768 | Port int 769 | Stubs []mbgo.Stub 770 | 771 | // output expectations 772 | Expected *mbgo.Imposter 773 | Err error 774 | }{ 775 | "should error if an imposter does not exist on the specified port": { 776 | Port: 8080, 777 | Before: func(t *testing.T, mb *mbgo.Client) { 778 | _, err := mb.Delete(newContext(time.Second), 8080, false) 779 | assert.MustOk(t, err) 780 | }, 781 | Err: errors.New("no such resource: Try POSTing to /imposters first?"), 782 | }, 783 | "should overwrite all stubs if the imposter exists": { 784 | Port: 8080, 785 | Stubs: []mbgo.Stub{ 786 | { 787 | Predicates: []mbgo.Predicate{ 788 | { 789 | Operator: "endsWith", 790 | Request: mbgo.TCPRequest{ 791 | Data: "foo", 792 | }, 793 | }, 794 | }, 795 | Responses: []mbgo.Response{ 796 | { 797 | Type: "is", 798 | Value: mbgo.TCPResponse{ 799 | Data: "bar", 800 | }, 801 | }, 802 | }, 803 | }, 804 | { 805 | Predicates: []mbgo.Predicate{ 806 | { 807 | Operator: "endsWith", 808 | Request: mbgo.TCPRequest{ 809 | Data: "bar", 810 | }, 811 | }, 812 | }, 813 | Responses: []mbgo.Response{ 814 | { 815 | Type: "is", 816 | Value: mbgo.TCPResponse{ 817 | Data: "baz", 818 | }, 819 | }, 820 | }, 821 | }, 822 | }, 823 | Before: func(t *testing.T, mb *mbgo.Client) { 824 | _, err := mb.Create(newContext(time.Second), mbgo.Imposter{ 825 | Port: 8080, 826 | Proto: "tcp", 827 | Name: "overwrite_all_stubs_test", 828 | Stubs: []mbgo.Stub{ 829 | { 830 | Predicates: []mbgo.Predicate{ 831 | { 832 | Operator: "endsWith", 833 | Request: mbgo.TCPRequest{ 834 | Data: "SGVsbG8sIHdvcmxkIQ==", 835 | }, 836 | }, 837 | }, 838 | Responses: []mbgo.Response{ 839 | { 840 | Type: "is", 841 | Value: mbgo.TCPResponse{ 842 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 843 | }, 844 | }, 845 | }, 846 | }, 847 | }, 848 | }) 849 | assert.MustOk(t, err) 850 | }, 851 | After: func(t *testing.T, client *mbgo.Client) { 852 | _, err := mb.Delete(newContext(time.Second), 8080, false) 853 | assert.MustOk(t, err) 854 | }, 855 | Expected: &mbgo.Imposter{ 856 | Port: 8080, 857 | Proto: "tcp", 858 | Name: "overwrite_all_stubs_test", 859 | Stubs: []mbgo.Stub{ 860 | { 861 | Predicates: []mbgo.Predicate{ 862 | { 863 | Operator: "endsWith", 864 | Request: &mbgo.TCPRequest{ 865 | Data: "foo", 866 | }, 867 | }, 868 | }, 869 | Responses: []mbgo.Response{ 870 | { 871 | Type: "is", 872 | Value: &mbgo.TCPResponse{ 873 | Data: "bar", 874 | }, 875 | }, 876 | }, 877 | }, 878 | { 879 | Predicates: []mbgo.Predicate{ 880 | { 881 | Operator: "endsWith", 882 | Request: &mbgo.TCPRequest{ 883 | Data: "bar", 884 | }, 885 | }, 886 | }, 887 | Responses: []mbgo.Response{ 888 | { 889 | Type: "is", 890 | Value: &mbgo.TCPResponse{ 891 | Data: "baz", 892 | }, 893 | }, 894 | }, 895 | }, 896 | }, 897 | }, 898 | }, 899 | } 900 | 901 | for name, c := range cases { 902 | c := c 903 | 904 | t.Run(name, func(t *testing.T) { 905 | if c.Before != nil { 906 | c.Before(t, mb) 907 | } 908 | 909 | actual, err := mb.OverwriteAllStubs(newContext(time.Second), c.Port, c.Stubs) 910 | if c.Err != nil { 911 | assert.Equals(t, c.Err, err) 912 | } else { 913 | assert.Ok(t, err) 914 | } 915 | assert.Equals(t, c.Expected, actual) 916 | 917 | if c.After != nil { 918 | c.After(t, mb) 919 | } 920 | }) 921 | } 922 | } 923 | 924 | func TestClient_RemoveStub_Integration(t *testing.T) { 925 | mb := newMountebankClient() 926 | 927 | cases := map[string]struct { 928 | Before func(*testing.T, *mbgo.Client) 929 | After func(*testing.T, *mbgo.Client) 930 | Port int 931 | Index int 932 | 933 | // output expectations 934 | Expected *mbgo.Imposter 935 | Err error 936 | }{ 937 | "should error if an imposter does not exist on the specified port": { 938 | Port: 8080, 939 | Before: func(t *testing.T, mb *mbgo.Client) { 940 | _, err := mb.Delete(newContext(time.Second), 8080, false) 941 | assert.MustOk(t, err) 942 | }, 943 | Err: errors.New("no such resource: Try POSTing to /imposters first?"), 944 | }, 945 | "should error if the stub at the specified index does not exist": { 946 | Port: 8080, 947 | Index: 0, 948 | Before: func(t *testing.T, mb *mbgo.Client) { 949 | _, err := mb.Create(newContext(time.Second), mbgo.Imposter{ 950 | Port: 8080, 951 | Proto: "tcp", 952 | Name: "remove_stub_test", 953 | Stubs: []mbgo.Stub{}, 954 | }) 955 | assert.MustOk(t, err) 956 | }, 957 | After: func(t *testing.T, client *mbgo.Client) { 958 | _, err := mb.Delete(newContext(time.Second), 8080, false) 959 | assert.MustOk(t, err) 960 | }, 961 | Err: errors.New("bad data: 'stubIndex' must be a valid integer, representing the array index position of the stub to replace"), 962 | }, 963 | "should remove the stub on the imposter if it exists": { 964 | Port: 8080, 965 | Index: 0, 966 | Before: func(t *testing.T, mb *mbgo.Client) { 967 | _, err := mb.Create(newContext(time.Second), mbgo.Imposter{ 968 | Port: 8080, 969 | Proto: "tcp", 970 | Name: "remove_stub_test", 971 | Stubs: []mbgo.Stub{ 972 | { 973 | Predicates: []mbgo.Predicate{ 974 | { 975 | Operator: "endsWith", 976 | Request: mbgo.TCPRequest{ 977 | Data: "SGVsbG8sIHdvcmxkIQ==", 978 | }, 979 | }, 980 | }, 981 | Responses: []mbgo.Response{ 982 | { 983 | Type: "is", 984 | Value: mbgo.TCPResponse{ 985 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 986 | }, 987 | }, 988 | }, 989 | }, 990 | }, 991 | }) 992 | assert.MustOk(t, err) 993 | }, 994 | After: func(t *testing.T, client *mbgo.Client) { 995 | _, err := mb.Delete(newContext(time.Second), 8080, false) 996 | assert.MustOk(t, err) 997 | }, 998 | Expected: &mbgo.Imposter{ 999 | Port: 8080, 1000 | Proto: "tcp", 1001 | Name: "remove_stub_test", 1002 | Stubs: nil, 1003 | }, 1004 | }, 1005 | } 1006 | 1007 | for name, c := range cases { 1008 | c := c 1009 | 1010 | t.Run(name, func(t *testing.T) { 1011 | if c.Before != nil { 1012 | c.Before(t, mb) 1013 | } 1014 | 1015 | actual, err := mb.RemoveStub(newContext(time.Second), c.Port, c.Index) 1016 | if c.Err != nil { 1017 | assert.Equals(t, c.Err, err) 1018 | } else { 1019 | assert.Ok(t, err) 1020 | } 1021 | assert.Equals(t, c.Expected, actual) 1022 | 1023 | if c.After != nil { 1024 | c.After(t, mb) 1025 | } 1026 | }) 1027 | } 1028 | } 1029 | 1030 | func TestClient_Delete_Integration(t *testing.T) { 1031 | mb := newMountebankClient() 1032 | 1033 | cases := []struct { 1034 | // general 1035 | Description string 1036 | Before func(*mbgo.Client) 1037 | After func(*mbgo.Client) 1038 | 1039 | // input 1040 | Port int 1041 | Replay bool 1042 | 1043 | // output expectations 1044 | Expected *mbgo.Imposter 1045 | Err error 1046 | }{ 1047 | { 1048 | Description: "should return an empty Imposter struct if one is not configured on the specified port", 1049 | Port: 8080, 1050 | Before: func(mb *mbgo.Client) { 1051 | _, err := mb.Delete(newContext(time.Second), 8080, false) 1052 | assert.MustOk(t, err) 1053 | }, 1054 | Expected: &mbgo.Imposter{}, 1055 | }, 1056 | } 1057 | 1058 | for _, c := range cases { 1059 | t.Run(c.Description, func(t *testing.T) { 1060 | if c.Before != nil { 1061 | c.Before(mb) 1062 | } 1063 | 1064 | actual, err := mb.Delete(newContext(time.Second), c.Port, c.Replay) 1065 | if c.Err != nil { 1066 | assert.Equals(t, c.Err, err) 1067 | } else { 1068 | assert.Ok(t, err) 1069 | } 1070 | assert.Equals(t, c.Expected, actual) 1071 | 1072 | if c.After != nil { 1073 | c.After(mb) 1074 | } 1075 | }) 1076 | } 1077 | } 1078 | 1079 | func TestClient_DeleteRequests_Integration(t *testing.T) { 1080 | mb := newMountebankClient() 1081 | 1082 | cases := []struct { 1083 | // general 1084 | Description string 1085 | Before func(*testing.T, *mbgo.Client) 1086 | After func(*testing.T, *mbgo.Client) 1087 | 1088 | // input 1089 | Port int 1090 | 1091 | // output expectations 1092 | Expected *mbgo.Imposter 1093 | Err error 1094 | }{ 1095 | { 1096 | Description: "should error if one is not configured on the specified port", 1097 | Before: func(t *testing.T, mb *mbgo.Client) { 1098 | _, err := mb.Delete(newContext(time.Second), 8080, false) 1099 | assert.MustOk(t, err) 1100 | }, 1101 | Port: 8080, 1102 | Err: errors.New("no such resource: Try POSTing to /imposters first?"), 1103 | }, 1104 | { 1105 | Description: "should return the expected Imposter if it exists on successful deletion", 1106 | Before: func(t *testing.T, mb *mbgo.Client) { 1107 | _, err := mb.Delete(newContext(time.Second), 8080, false) 1108 | assert.MustOk(t, err) 1109 | 1110 | _, err = mb.Create(newContext(time.Second), mbgo.Imposter{ 1111 | Port: 8080, 1112 | Proto: "http", 1113 | Name: "delete_requests_test", 1114 | RecordRequests: true, 1115 | }) 1116 | assert.MustOk(t, err) 1117 | }, 1118 | After: func(t *testing.T, mb *mbgo.Client) { 1119 | imp, err := mb.Delete(newContext(time.Second), 8080, false) 1120 | assert.MustOk(t, err) 1121 | assert.Equals(t, "delete_requests_test", imp.Name) 1122 | }, 1123 | Port: 8080, 1124 | Expected: &mbgo.Imposter{ 1125 | Port: 8080, 1126 | Proto: "http", 1127 | Name: "delete_requests_test", 1128 | RequestCount: 0, 1129 | }, 1130 | }, 1131 | } 1132 | 1133 | for _, c := range cases { 1134 | t.Run(c.Description, func(t *testing.T) { 1135 | if c.Before != nil { 1136 | c.Before(t, mb) 1137 | } 1138 | 1139 | actual, err := mb.DeleteRequests(newContext(time.Second), c.Port) 1140 | if c.Err != nil { 1141 | assert.Equals(t, c.Err, err) 1142 | } else { 1143 | assert.Ok(t, err) 1144 | } 1145 | 1146 | if actual != nil { 1147 | for i := 0; i < len(actual.Requests); i++ { 1148 | req := actual.Requests[i].(mbgo.HTTPRequest) 1149 | ts := req.Timestamp 1150 | if len(ts) == 0 { 1151 | t.Errorf("expected non-empty timestamp in %v", req) 1152 | } 1153 | // clear out the timestamp before doing a deep equality check 1154 | // see https://github.com/senseyeio/mbgo/pull/5 for details 1155 | req.Timestamp = "" 1156 | actual.Requests[i] = req 1157 | } 1158 | 1159 | assert.Equals(t, c.Expected, actual) 1160 | } 1161 | 1162 | if c.After != nil { 1163 | c.After(t, mb) 1164 | } 1165 | }) 1166 | } 1167 | } 1168 | 1169 | func TestClient_Config_Integration(t *testing.T) { 1170 | mb := newMountebankClient() 1171 | 1172 | cfg, err := mb.Config(newContext(time.Second)) 1173 | assert.MustOk(t, err) 1174 | assert.Equals(t, "2.1.2", cfg.Version) 1175 | } 1176 | 1177 | func TestClient_Imposters_Integration(t *testing.T) { 1178 | cases := []struct { 1179 | // general 1180 | Description string 1181 | Before func(*testing.T, *mbgo.Client) 1182 | After func(*testing.T, *mbgo.Client) 1183 | 1184 | // input 1185 | Replay bool 1186 | 1187 | // output expectations 1188 | Expected []mbgo.Imposter 1189 | Err error 1190 | }{ 1191 | { 1192 | Description: "should return a minimal representation of all registered Imposters", 1193 | Before: func(t *testing.T, mb *mbgo.Client) { 1194 | _, err := mb.DeleteAll(newContext(time.Second), false) 1195 | assert.MustOk(t, err) 1196 | 1197 | // create a tcp imposter 1198 | imp, err := mb.Create(newContext(time.Second), mbgo.Imposter{ 1199 | Port: 8080, 1200 | Proto: "tcp", 1201 | Name: "imposters_tcp_test", 1202 | RecordRequests: true, 1203 | Stubs: []mbgo.Stub{ 1204 | { 1205 | Predicates: []mbgo.Predicate{ 1206 | { 1207 | Operator: "endsWith", 1208 | Request: mbgo.TCPRequest{ 1209 | Data: "SGVsbG8sIHdvcmxkIQ==", 1210 | }, 1211 | }, 1212 | }, 1213 | Responses: []mbgo.Response{ 1214 | { 1215 | Type: "is", 1216 | Value: mbgo.TCPResponse{ 1217 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 1218 | }, 1219 | }, 1220 | }, 1221 | }, 1222 | }, 1223 | }) 1224 | assert.MustOk(t, err) 1225 | assert.Equals(t, imp.Name, "imposters_tcp_test") 1226 | 1227 | // and an http imposter 1228 | imp, err = mb.Create(newContext(time.Second), mbgo.Imposter{ 1229 | Proto: "http", 1230 | Port: 8081, 1231 | Name: "imposters_http_test", 1232 | RecordRequests: true, 1233 | AllowCORS: true, 1234 | Stubs: []mbgo.Stub{ 1235 | { 1236 | Predicates: []mbgo.Predicate{ 1237 | { 1238 | Operator: "equals", 1239 | Request: mbgo.HTTPRequest{ 1240 | Method: http.MethodGet, 1241 | Path: "/foo", 1242 | Query: map[string][]string{ 1243 | "page": {"3"}, 1244 | }, 1245 | Headers: map[string][]string{ 1246 | "Accept": {"application/json"}, 1247 | }, 1248 | }, 1249 | }, 1250 | }, 1251 | Responses: []mbgo.Response{ 1252 | { 1253 | Type: "is", 1254 | Value: mbgo.HTTPResponse{ 1255 | StatusCode: http.StatusOK, 1256 | Headers: map[string][]string{ 1257 | "Content-Type": {"application/json"}, 1258 | }, 1259 | Body: `{"test":true}`, 1260 | }, 1261 | }, 1262 | }, 1263 | }, 1264 | }, 1265 | }) 1266 | assert.MustOk(t, err) 1267 | assert.Equals(t, imp.Name, "imposters_http_test") 1268 | }, 1269 | Expected: []mbgo.Imposter{ 1270 | { 1271 | Port: 8080, 1272 | Proto: "tcp", 1273 | RequestCount: 0, 1274 | }, 1275 | { 1276 | Port: 8081, 1277 | Proto: "http", 1278 | RequestCount: 0, 1279 | }, 1280 | }, 1281 | }, 1282 | } 1283 | 1284 | mb := newMountebankClient() 1285 | 1286 | for _, c := range cases { 1287 | t.Run(c.Description, func(t *testing.T) { 1288 | if c.Before != nil { 1289 | c.Before(t, mb) 1290 | } 1291 | 1292 | actual, err := mb.Imposters(newContext(time.Second), c.Replay) 1293 | if c.Err != nil { 1294 | assert.Equals(t, c.Err, err) 1295 | } else { 1296 | assert.Ok(t, err) 1297 | } 1298 | assert.Equals(t, c.Expected, actual) 1299 | 1300 | if c.After != nil { 1301 | c.After(t, mb) 1302 | } 1303 | }) 1304 | } 1305 | } 1306 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | // Package mbgo implements a mountebank API client with support for the HTTP and TCP protocols. 5 | package mbgo 6 | -------------------------------------------------------------------------------- /dto.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | package mbgo 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "net" 11 | "reflect" 12 | "strings" 13 | ) 14 | 15 | func parseClientSocket(s string) (ip net.IP, err error) { 16 | parts := strings.Split(s, ":") 17 | ipStr := strings.Join(parts[0:len(parts)-1], ":") 18 | 19 | ip = net.ParseIP(ipStr) 20 | if ip == nil { 21 | err = fmt.Errorf("invalid IP address: %s", ipStr) 22 | } 23 | return 24 | } 25 | 26 | func toMapValues(q map[string][]string) map[string]interface{} { 27 | if q == nil { 28 | return nil 29 | } 30 | 31 | out := make(map[string]interface{}, len(q)) 32 | 33 | for k, ss := range q { 34 | if len(ss) == 0 { 35 | continue 36 | } else if len(ss) == 1 { 37 | out[k] = ss[0] 38 | } else { 39 | out[k] = ss 40 | } 41 | } 42 | 43 | return out 44 | } 45 | 46 | func fromMapValues(q map[string]interface{}) (map[string][]string, error) { 47 | if q == nil { 48 | return nil, nil 49 | } 50 | 51 | out := make(map[string][]string, len(q)) 52 | 53 | for k, v := range q { 54 | switch typ := v.(type) { 55 | case string: 56 | out[k] = []string{typ} 57 | case []interface{}: 58 | ss := make([]string, len(typ)) 59 | for i, elem := range typ { 60 | s, ok := elem.(string) 61 | if !ok { 62 | return nil, errors.New("invalid query key array subtype") 63 | } 64 | ss[i] = s 65 | } 66 | out[k] = ss 67 | default: 68 | return nil, fmt.Errorf("invalid query key type: %#v", typ) 69 | } 70 | } 71 | 72 | return out, nil 73 | } 74 | 75 | type httpRequestDTO struct { 76 | RequestFrom string `json:"requestFrom,omitempty"` 77 | Method string `json:"method,omitempty"` 78 | Path string `json:"path,omitempty"` 79 | Query map[string]interface{} `json:"query,omitempty"` 80 | Headers map[string]interface{} `json:"headers,omitempty"` 81 | Body interface{} `json:"body,omitempty"` 82 | Timestamp string `json:"timestamp,omitempty"` 83 | } 84 | 85 | // MarshalJSON satisfies the json.Marshaler interface. 86 | func (r HTTPRequest) MarshalJSON() ([]byte, error) { 87 | dto := httpRequestDTO{ 88 | RequestFrom: "", 89 | Method: r.Method, 90 | Path: r.Path, 91 | Query: toMapValues(r.Query), 92 | Headers: toMapValues(r.Headers), 93 | Body: r.Body, 94 | Timestamp: r.Timestamp, 95 | } 96 | if r.RequestFrom != nil { 97 | dto.RequestFrom = r.RequestFrom.String() 98 | } 99 | return json.Marshal(dto) 100 | } 101 | 102 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 103 | func (r *HTTPRequest) UnmarshalJSON(b []byte) error { 104 | var v httpRequestDTO 105 | err := json.Unmarshal(b, &v) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | if v.RequestFrom != "" { 111 | r.RequestFrom, err = parseClientSocket(v.RequestFrom) 112 | if err != nil { 113 | return nil 114 | } 115 | } 116 | r.Method = v.Method 117 | r.Path = v.Path 118 | r.Query, err = fromMapValues(v.Query) 119 | if err != nil { 120 | return nil 121 | } 122 | r.Headers, err = fromMapValues(v.Headers) 123 | if err != nil { 124 | return nil 125 | } 126 | r.Body = v.Body 127 | r.Timestamp = v.Timestamp 128 | 129 | return nil 130 | } 131 | 132 | type httpResponseDTO struct { 133 | StatusCode int `json:"statusCode,omitempty"` 134 | Headers map[string]interface{} `json:"headers,omitempty"` 135 | Body interface{} `json:"body,omitempty"` 136 | Mode string `json:"_mode,omitempty"` 137 | } 138 | 139 | // MarshalJSON satisfies the json.Marshaler interface. 140 | func (r HTTPResponse) MarshalJSON() ([]byte, error) { 141 | return json.Marshal(httpResponseDTO{ 142 | StatusCode: r.StatusCode, 143 | Headers: toMapValues(r.Headers), 144 | Body: r.Body, 145 | Mode: r.Mode, 146 | }) 147 | } 148 | 149 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 150 | func (r *HTTPResponse) UnmarshalJSON(b []byte) error { 151 | var v httpResponseDTO 152 | err := json.Unmarshal(b, &v) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | r.StatusCode = v.StatusCode 158 | r.Headers, err = fromMapValues(v.Headers) 159 | if err != nil { 160 | return err 161 | } 162 | r.Body = v.Body 163 | r.Mode = v.Mode 164 | 165 | return nil 166 | } 167 | 168 | type tcpRequestDTO struct { 169 | RequestFrom string `json:"requestFrom,omitempty"` 170 | Data string `json:"data,omitempty"` 171 | } 172 | 173 | // MarshalJSON satisfies the json.Marshaler interface. 174 | func (r TCPRequest) MarshalJSON() ([]byte, error) { 175 | dto := tcpRequestDTO{ 176 | RequestFrom: "", 177 | Data: r.Data, 178 | } 179 | if r.RequestFrom != nil { 180 | dto.RequestFrom = r.RequestFrom.String() 181 | } 182 | return json.Marshal(dto) 183 | } 184 | 185 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 186 | func (r *TCPRequest) UnmarshalJSON(b []byte) error { 187 | var v tcpRequestDTO 188 | err := json.Unmarshal(b, &v) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | if v.RequestFrom != "" { 194 | r.RequestFrom, err = parseClientSocket(v.RequestFrom) 195 | if err != nil { 196 | return err 197 | } 198 | } 199 | r.Data = v.Data 200 | 201 | return err 202 | } 203 | 204 | type tcpResponseDTO struct { 205 | Data string `json:"data"` 206 | } 207 | 208 | // MarshalJSON satisfies the json.Marshaler interface. 209 | func (r TCPResponse) MarshalJSON() ([]byte, error) { 210 | return json.Marshal(tcpResponseDTO(r)) 211 | } 212 | 213 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 214 | func (r *TCPResponse) UnmarshalJSON(b []byte) error { 215 | var v tcpResponseDTO 216 | err := json.Unmarshal(b, &v) 217 | if err != nil { 218 | return err 219 | } 220 | 221 | r.Data = v.Data 222 | 223 | return nil 224 | } 225 | 226 | const ( 227 | // Predicate parameter keys for internal use. 228 | paramCaseSensitive = "caseSensitive" 229 | paramExcept = "except" 230 | paramJSONPath = "jsonpath" 231 | paramXPath = "xpath" 232 | ) 233 | 234 | type predicateDTO map[string]json.RawMessage 235 | 236 | // MarshalJSON satisfies the json.Marshaler interface. 237 | func (p Predicate) MarshalJSON() ([]byte, error) { 238 | dto := predicateDTO{} 239 | 240 | // marshal request based on type 241 | switch t := p.Request.(type) { 242 | case json.Marshaler: 243 | b, err := t.MarshalJSON() 244 | if err != nil { 245 | return nil, err 246 | } 247 | dto[p.Operator] = b 248 | 249 | case []Predicate: 250 | preds := make([]json.RawMessage, len(t)) 251 | for i, sub := range t { 252 | b, err := sub.MarshalJSON() 253 | if err != nil { 254 | return nil, err 255 | } 256 | preds[i] = b 257 | } 258 | b, err := json.Marshal(preds) 259 | if err != nil { 260 | return nil, err 261 | } 262 | dto[p.Operator] = b 263 | 264 | case string: 265 | b, err := json.Marshal(t) 266 | if err != nil { 267 | return nil, err 268 | } 269 | dto[p.Operator] = b 270 | 271 | default: 272 | return nil, fmt.Errorf("unsupported predicate request type: %v", 273 | reflect.TypeOf(t).String()) 274 | } 275 | 276 | if p.JSONPath != nil { 277 | b, err := json.Marshal(p.JSONPath) 278 | if err != nil { 279 | return nil, err 280 | } 281 | dto[paramJSONPath] = b 282 | } 283 | 284 | if p.CaseSensitive { 285 | b, err := json.Marshal(p.CaseSensitive) 286 | if err != nil { 287 | return nil, err 288 | } 289 | dto[paramCaseSensitive] = b 290 | } 291 | 292 | return json.Marshal(dto) 293 | } 294 | 295 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 296 | func (p *Predicate) UnmarshalJSON(b []byte) error { 297 | var dto predicateDTO 298 | err := json.Unmarshal(b, &dto) 299 | if err != nil { 300 | return err 301 | } 302 | 303 | // Handle and delete parameters from the DTO map before we check the 304 | // operator so that we can enforce only one operator exists in the map. 305 | if b, ok := dto[paramCaseSensitive]; ok { 306 | err = json.Unmarshal(b, &p.CaseSensitive) 307 | if err != nil { 308 | return err 309 | } 310 | delete(dto, paramCaseSensitive) 311 | } 312 | if b, ok := dto[paramJSONPath]; ok { 313 | err = json.Unmarshal(b, &p.JSONPath) 314 | if err != nil { 315 | return err 316 | } 317 | delete(dto, paramJSONPath) 318 | } 319 | // Ignore 'except' and 'xpath' parameters for now. 320 | delete(dto, paramExcept) 321 | delete(dto, paramXPath) 322 | 323 | if len(dto) < 1 { 324 | return errors.New("predicate should only have a single operator") 325 | } 326 | for key, b := range dto { 327 | p.Operator = key 328 | 329 | switch key { 330 | // Interpret the request as a string containing JavaScript if the 331 | // inject operator is used. 332 | case "inject": 333 | var js string 334 | err = json.Unmarshal(b, &js) 335 | if err != nil { 336 | return err 337 | } 338 | p.Request = js 339 | 340 | // Slice of predicates 341 | case "and", "or": 342 | var ps []Predicate 343 | err = json.Unmarshal(b, &ps) 344 | if err != nil { 345 | return err 346 | } 347 | p.Request = ps 348 | 349 | // Single predicate 350 | case "not": 351 | var v Predicate 352 | err = json.Unmarshal(b, &v) 353 | if err != nil { 354 | return err 355 | } 356 | p.Request = v 357 | 358 | // Otherwise we have a request object. 359 | default: 360 | p.Request = b // defer unmarshaling until protocol is known 361 | } 362 | } 363 | 364 | return nil 365 | } 366 | 367 | const ( 368 | keyBehaviors = "_behaviors" 369 | ) 370 | 371 | // MarshalJSON satisfies the json.Marshaler interface. 372 | func (r Response) MarshalJSON() ([]byte, error) { 373 | dto := make(map[string]json.RawMessage) 374 | 375 | m, ok := r.Value.(json.Marshaler) 376 | if !ok { 377 | return nil, errors.New("response value must implement json.Marshaler") 378 | } 379 | 380 | b, err := m.MarshalJSON() 381 | if err != nil { 382 | return nil, err 383 | } 384 | 385 | dto[r.Type] = b 386 | 387 | if r.Behaviors != nil { 388 | behaviors, err := json.Marshal(r.Behaviors) 389 | if err != nil { 390 | return nil, err 391 | } 392 | dto[keyBehaviors] = behaviors 393 | } 394 | 395 | return json.Marshal(dto) 396 | } 397 | 398 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 399 | func (r *Response) UnmarshalJSON(b []byte) error { 400 | var dto map[string]json.RawMessage 401 | err := json.Unmarshal(b, &dto) 402 | if err != nil { 403 | return err 404 | } 405 | 406 | // Handle and delete behaviors from the DTO map before we check the 407 | // type so that we can enforce only one type exists in the map. 408 | if b, ok := dto[keyBehaviors]; ok { 409 | behaviors := new(Behaviors) 410 | err = json.Unmarshal(b, behaviors) 411 | if err != nil { 412 | return err 413 | } 414 | delete(dto, keyBehaviors) 415 | r.Behaviors = behaviors 416 | } 417 | 418 | for key, b := range dto { 419 | r.Type = key 420 | r.Value = b // defer unmarshaling until protocol is known 421 | } 422 | 423 | return nil 424 | } 425 | 426 | type stubDTO struct { 427 | Predicates []Predicate `json:"predicates,omitempty"` 428 | Responses []Response `json:"responses"` 429 | } 430 | 431 | // MarshalJSON satisfies the json.Marshaler interface. 432 | func (s Stub) MarshalJSON() ([]byte, error) { 433 | return json.Marshal(stubDTO(s)) 434 | } 435 | 436 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 437 | func (s *Stub) UnmarshalJSON(b []byte) error { 438 | var dto stubDTO 439 | err := json.Unmarshal(b, &dto) 440 | if err != nil { 441 | return err 442 | } 443 | 444 | s.Predicates = dto.Predicates 445 | s.Responses = dto.Responses 446 | 447 | return nil 448 | } 449 | 450 | type imposterRequestDTO struct { 451 | Proto string `json:"protocol"` 452 | Port int `json:"port,omitempty"` 453 | Name string `json:"name,omitempty"` 454 | RecordRequests bool `json:"recordRequests,omitempty"` 455 | AllowCORS bool `json:"allowCORS,omitempty"` 456 | DefaultResponse json.RawMessage `json:"defaultResponse,omitempty"` 457 | Stubs []json.RawMessage `json:"stubs,omitempty"` 458 | } 459 | 460 | // MarshalJSON satisfies the json.Marshaler interface. 461 | func (imp Imposter) MarshalJSON() ([]byte, error) { 462 | dto := imposterRequestDTO{ 463 | Proto: imp.Proto, 464 | Port: imp.Port, 465 | Name: imp.Name, 466 | RecordRequests: imp.RecordRequests, 467 | AllowCORS: imp.AllowCORS, 468 | DefaultResponse: nil, 469 | Stubs: nil, 470 | } 471 | if imp.DefaultResponse != nil { 472 | jm, ok := imp.DefaultResponse.(json.Marshaler) 473 | if !ok { 474 | return nil, errors.New("default response must implemented json.Marshaler") 475 | } 476 | b, err := jm.MarshalJSON() 477 | if err != nil { 478 | return nil, err 479 | } 480 | dto.DefaultResponse = b 481 | } 482 | if n := len(imp.Stubs); n > 0 { 483 | dto.Stubs = make([]json.RawMessage, n) 484 | for i, stub := range imp.Stubs { 485 | b, err := stub.MarshalJSON() 486 | if err != nil { 487 | return nil, err 488 | } 489 | dto.Stubs[i] = b 490 | } 491 | } 492 | return json.Marshal(dto) 493 | } 494 | 495 | type imposterResponseDTO struct { 496 | Port int `json:"port"` 497 | Proto string `json:"protocol"` 498 | Name string `json:"name,omitempty"` 499 | RequestCount int `json:"numberOfRequests,omitempty"` 500 | Stubs []json.RawMessage `json:"stubs,omitempty"` 501 | Requests []json.RawMessage `json:"requests,omitempty"` 502 | } 503 | 504 | func getRequestUnmarshaler(proto string) (json.Unmarshaler, error) { 505 | var um json.Unmarshaler 506 | switch proto { 507 | case "http": 508 | um = &HTTPRequest{} 509 | case "tcp": 510 | um = &TCPRequest{} 511 | default: 512 | return nil, fmt.Errorf("unsupported protocol: %s", proto) 513 | } 514 | return um, nil 515 | } 516 | 517 | func unmarshalPredicateRecurse(proto string, p *Predicate) error { 518 | switch v := p.Request.(type) { 519 | case json.RawMessage: 520 | um, err := getRequestUnmarshaler(proto) 521 | if err != nil { 522 | return err 523 | } 524 | if err = um.UnmarshalJSON(v); err != nil { 525 | return err 526 | } 527 | p.Request = um 528 | 529 | case Predicate: 530 | if err := unmarshalPredicateRecurse(proto, &v); err != nil { 531 | return err 532 | } 533 | case []Predicate: 534 | for i := range v { 535 | if err := unmarshalPredicateRecurse(proto, &v[i]); err != nil { 536 | return err 537 | } 538 | } 539 | } 540 | return nil 541 | } 542 | 543 | func getResponseUnmarshaler(proto string) (json.Unmarshaler, error) { 544 | var um json.Unmarshaler 545 | switch proto { 546 | case "http": 547 | um = &HTTPResponse{} 548 | case "tcp": 549 | um = &TCPResponse{} 550 | default: 551 | return nil, fmt.Errorf("unsupported protocol: %s", proto) 552 | } 553 | return um, nil 554 | } 555 | 556 | // UnmarshalJSON satisfies the json.Unmarshaler interface. 557 | func (imp *Imposter) UnmarshalJSON(b []byte) error { 558 | var dto imposterResponseDTO 559 | err := json.Unmarshal(b, &dto) 560 | if err != nil { 561 | return err 562 | } 563 | 564 | imp.Port = dto.Port 565 | imp.Proto = dto.Proto 566 | imp.Name = dto.Name 567 | imp.RequestCount = dto.RequestCount 568 | 569 | if n := len(dto.Stubs); n > 0 { 570 | imp.Stubs = make([]Stub, n) 571 | for i, b := range dto.Stubs { 572 | var s Stub 573 | err = json.Unmarshal(b, &s) 574 | if err != nil { 575 | return err 576 | } 577 | 578 | for i := range s.Predicates { 579 | err = unmarshalPredicateRecurse(imp.Proto, &s.Predicates[i]) 580 | if err != nil { 581 | return err 582 | } 583 | } 584 | 585 | for i, r := range s.Responses { 586 | if raw, ok := r.Value.(json.RawMessage); ok { 587 | um, err := getResponseUnmarshaler(imp.Proto) 588 | if err != nil { 589 | return err 590 | } 591 | err = um.UnmarshalJSON(raw) 592 | if err != nil { 593 | return err 594 | } 595 | s.Responses[i].Value = um 596 | } 597 | } 598 | 599 | imp.Stubs[i] = s 600 | } 601 | } 602 | 603 | if n := len(dto.Requests); n > 0 { 604 | imp.Requests = make([]interface{}, n) 605 | for i, b := range dto.Requests { 606 | um, err := getRequestUnmarshaler(imp.Proto) 607 | if err != nil { 608 | return err 609 | } 610 | err = um.UnmarshalJSON(b) 611 | if err != nil { 612 | return err 613 | } 614 | imp.Requests[i] = um 615 | } 616 | } 617 | 618 | return nil 619 | } 620 | -------------------------------------------------------------------------------- /dto_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | package mbgo_test 5 | 6 | import ( 7 | "encoding/json" 8 | "net" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/senseyeio/mbgo" 13 | "github.com/senseyeio/mbgo/internal/assert" 14 | ) 15 | 16 | type duplex interface { 17 | json.Marshaler 18 | json.Unmarshaler 19 | } 20 | 21 | var ( 22 | _ duplex = &mbgo.HTTPRequest{} 23 | _ duplex = &mbgo.HTTPResponse{} 24 | _ duplex = &mbgo.TCPRequest{} 25 | _ duplex = &mbgo.TCPResponse{} 26 | _ duplex = &mbgo.Predicate{} 27 | _ duplex = &mbgo.Response{} 28 | _ duplex = &mbgo.Stub{} 29 | _ duplex = &mbgo.Imposter{} 30 | ) 31 | 32 | func TestPredicate_MarshalJSON(t *testing.T) { 33 | cases := map[string]struct { 34 | predicate mbgo.Predicate 35 | want map[string]interface{} 36 | }{ 37 | "contains request": { 38 | predicate: mbgo.Predicate{ 39 | Operator: "is", 40 | Request: mbgo.HTTPRequest{ 41 | Method: http.MethodGet, 42 | }, 43 | }, 44 | want: map[string]interface{}{ 45 | "is": map[string]interface{}{ 46 | "method": http.MethodGet, 47 | }, 48 | }, 49 | }, 50 | "contains nested predicate": { 51 | predicate: mbgo.Predicate{ 52 | Operator: "not", 53 | Request: mbgo.Predicate{ 54 | Operator: "is", 55 | Request: mbgo.HTTPRequest{ 56 | Method: http.MethodGet, 57 | }, 58 | }, 59 | }, 60 | want: map[string]interface{}{ 61 | "not": map[string]interface{}{ 62 | "is": map[string]interface{}{ 63 | "method": http.MethodGet, 64 | }, 65 | }, 66 | }, 67 | }, 68 | "contains nested predicate collection": { 69 | predicate: mbgo.Predicate{ 70 | Operator: "or", 71 | Request: []mbgo.Predicate{ 72 | { 73 | Operator: "is", 74 | Request: mbgo.HTTPRequest{ 75 | Method: http.MethodGet, 76 | }, 77 | }, 78 | { 79 | Operator: "is", 80 | Request: mbgo.HTTPRequest{ 81 | Body: "foo", 82 | }, 83 | }, 84 | }, 85 | }, 86 | want: map[string]interface{}{ 87 | "or": []map[string]interface{}{ 88 | { 89 | "is": map[string]interface{}{ 90 | "method": http.MethodGet, 91 | }, 92 | }, 93 | { 94 | "is": map[string]interface{}{ 95 | "body": "foo", 96 | }, 97 | }, 98 | }, 99 | }, 100 | }, 101 | } 102 | 103 | for name, c := range cases { 104 | c := c 105 | 106 | t.Run(name, func(t *testing.T) { 107 | t.Parallel() 108 | 109 | // verify JSON structure of expected value versus actual 110 | actualBytes, err := json.Marshal(c.predicate) 111 | assert.MustOk(t, err) 112 | 113 | expectedBytes, err := json.Marshal(c.want) 114 | assert.MustOk(t, err) 115 | 116 | var actual, expected map[string]interface{} 117 | err = json.Unmarshal(actualBytes, &actual) 118 | assert.MustOk(t, err) 119 | 120 | err = json.Unmarshal(expectedBytes, &expected) 121 | assert.MustOk(t, err) 122 | 123 | assert.Equals(t, expected, actual) 124 | }) 125 | } 126 | } 127 | 128 | func TestPredicate_UnmarshalJSON(t *testing.T) { 129 | cases := map[string]struct { 130 | want mbgo.Predicate 131 | json map[string]interface{} 132 | }{ 133 | "contains request": { 134 | json: map[string]interface{}{ 135 | "is": map[string]interface{}{ 136 | "method": http.MethodGet, 137 | }, 138 | }, 139 | want: mbgo.Predicate{ 140 | Operator: "is", 141 | Request: json.RawMessage(`{"method":"GET"}`), 142 | }, 143 | }, 144 | "contains nested predicate": { 145 | json: map[string]interface{}{ 146 | "not": map[string]interface{}{ 147 | "is": map[string]interface{}{ 148 | "method": http.MethodGet, 149 | }, 150 | }, 151 | }, 152 | want: mbgo.Predicate{ 153 | Operator: "not", 154 | Request: mbgo.Predicate{ 155 | Operator: "is", 156 | Request: json.RawMessage(`{"method":"GET"}`), 157 | }, 158 | }, 159 | }, 160 | "contains nested predicate collection": { 161 | json: map[string]interface{}{ 162 | "or": []map[string]interface{}{ 163 | { 164 | "is": map[string]interface{}{ 165 | "method": http.MethodGet, 166 | }, 167 | }, 168 | { 169 | "is": map[string]interface{}{ 170 | "body": "foo", 171 | }, 172 | }, 173 | }, 174 | }, 175 | want: mbgo.Predicate{ 176 | Operator: "or", 177 | Request: []mbgo.Predicate{ 178 | { 179 | Operator: "is", 180 | Request: json.RawMessage(`{"method":"GET"}`), 181 | }, 182 | { 183 | Operator: "is", 184 | Request: json.RawMessage(`{"body":"foo"}`), 185 | }, 186 | }, 187 | }, 188 | }, 189 | } 190 | 191 | for name, c := range cases { 192 | c := c 193 | 194 | t.Run(name, func(t *testing.T) { 195 | t.Parallel() 196 | 197 | b, err := json.Marshal(c.json) 198 | assert.MustOk(t, err) 199 | 200 | var got mbgo.Predicate 201 | err = json.Unmarshal(b, &got) 202 | assert.MustOk(t, err) 203 | 204 | // verify unmarshaled predicate versus expected 205 | assert.Equals(t, c.want, got) 206 | }) 207 | } 208 | } 209 | 210 | func TestImposter_MarshalJSON(t *testing.T) { 211 | cases := []struct { 212 | Description string 213 | Imposter mbgo.Imposter 214 | Expected map[string]interface{} 215 | }{ 216 | { 217 | Description: "should marshal the tcp Imposter into the expected JSON", 218 | Imposter: mbgo.Imposter{ 219 | Port: 8080, 220 | Proto: "tcp", 221 | Name: "tcp_test_imposter", 222 | RecordRequests: true, 223 | AllowCORS: true, 224 | Stubs: []mbgo.Stub{ 225 | { 226 | Predicates: []mbgo.Predicate{ 227 | { 228 | Operator: "equals", 229 | Request: &mbgo.TCPRequest{ 230 | RequestFrom: net.IPv4(172, 17, 0, 1), 231 | Data: "SGVsbG8sIHdvcmxkIQ==", 232 | }, 233 | }, 234 | }, 235 | Responses: []mbgo.Response{ 236 | { 237 | Type: "is", 238 | Value: mbgo.TCPResponse{ 239 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 240 | }, 241 | }, 242 | }, 243 | }, 244 | }, 245 | }, 246 | Expected: map[string]interface{}{ 247 | "port": 8080, 248 | "protocol": "tcp", 249 | "name": "tcp_test_imposter", 250 | "recordRequests": true, 251 | "allowCORS": true, 252 | "stubs": []interface{}{ 253 | map[string]interface{}{ 254 | "predicates": []interface{}{ 255 | map[string]interface{}{ 256 | "equals": map[string]interface{}{ 257 | "data": "SGVsbG8sIHdvcmxkIQ==", 258 | "requestFrom": "172.17.0.1", 259 | }, 260 | }, 261 | }, 262 | "responses": []interface{}{ 263 | map[string]interface{}{ 264 | "is": map[string]interface{}{ 265 | "data": "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 266 | }, 267 | }, 268 | }, 269 | }, 270 | }, 271 | }, 272 | }, 273 | { 274 | Description: "should marshal the http Imposter into the expected JSON", 275 | Imposter: mbgo.Imposter{ 276 | Port: 8080, 277 | Proto: "http", 278 | Name: "http_test_imposter", 279 | RecordRequests: true, 280 | AllowCORS: true, 281 | Stubs: []mbgo.Stub{ 282 | { 283 | Predicates: []mbgo.Predicate{ 284 | { 285 | Operator: "equals", 286 | Request: mbgo.HTTPRequest{ 287 | RequestFrom: net.IPv4(172, 17, 0, 1), 288 | Method: http.MethodGet, 289 | Path: "/foo", 290 | Query: map[string][]string{ 291 | "page": {"3"}, 292 | }, 293 | Headers: map[string][]string{ 294 | "Accept": {"application/json"}, 295 | }, 296 | Timestamp: "2018-10-10T09:12:08.075Z", 297 | }, 298 | }, 299 | }, 300 | Responses: []mbgo.Response{ 301 | { 302 | Type: "is", 303 | Value: mbgo.HTTPResponse{ 304 | StatusCode: http.StatusOK, 305 | Headers: map[string][]string{ 306 | "Content-Type": {"application/json"}, 307 | }, 308 | Body: `{"test":true}`, 309 | }, 310 | Behaviors: &mbgo.Behaviors{ 311 | Wait: 500, 312 | }, 313 | }, 314 | }, 315 | }, 316 | }, 317 | }, 318 | Expected: map[string]interface{}{ 319 | "port": 8080, 320 | "protocol": "http", 321 | "name": "http_test_imposter", 322 | "recordRequests": true, 323 | "allowCORS": true, 324 | "stubs": []interface{}{ 325 | map[string]interface{}{ 326 | "predicates": []interface{}{ 327 | map[string]interface{}{ 328 | "equals": map[string]interface{}{ 329 | "requestFrom": "172.17.0.1", 330 | "method": http.MethodGet, 331 | "path": "/foo", 332 | "query": map[string]string{ 333 | "page": "3", 334 | }, 335 | "headers": map[string]string{ 336 | "Accept": "application/json", 337 | }, 338 | "timestamp": "2018-10-10T09:12:08.075Z", 339 | }, 340 | }, 341 | }, 342 | "responses": []interface{}{ 343 | map[string]interface{}{ 344 | "is": map[string]interface{}{ 345 | "statusCode": 200, 346 | "headers": map[string]string{ 347 | "Content-Type": "application/json", 348 | }, 349 | "body": `{"test":true}`, 350 | }, 351 | "_behaviors": map[string]interface{}{ 352 | "wait": 500, 353 | }, 354 | }, 355 | }, 356 | }, 357 | }, 358 | }, 359 | }, 360 | { 361 | Description: "should marshal non-string bodies to JSON", 362 | Imposter: mbgo.Imposter{ 363 | Port: 8080, 364 | Proto: "http", 365 | Name: "http_test_imposter", 366 | RecordRequests: true, 367 | AllowCORS: true, 368 | Stubs: []mbgo.Stub{ 369 | { 370 | Predicates: []mbgo.Predicate{ 371 | { 372 | Operator: "equals", 373 | Request: mbgo.HTTPRequest{ 374 | RequestFrom: net.IPv4(172, 17, 0, 1), 375 | Method: http.MethodGet, 376 | Path: "/foo", 377 | Headers: map[string][]string{ 378 | "Accept": {"application/json"}, 379 | }, 380 | }, 381 | }, 382 | }, 383 | Responses: []mbgo.Response{ 384 | { 385 | Type: "is", 386 | Value: mbgo.HTTPResponse{ 387 | StatusCode: http.StatusOK, 388 | Headers: map[string][]string{ 389 | "Content-Type": {"application/json"}, 390 | }, 391 | Body: struct { 392 | Test bool `json:"test"` 393 | }{ 394 | Test: true, 395 | }, 396 | }, 397 | }, 398 | }, 399 | }, 400 | }, 401 | }, 402 | Expected: map[string]interface{}{ 403 | "port": 8080, 404 | "protocol": "http", 405 | "name": "http_test_imposter", 406 | "recordRequests": true, 407 | "allowCORS": true, 408 | "stubs": []interface{}{ 409 | map[string]interface{}{ 410 | "predicates": []interface{}{ 411 | map[string]interface{}{ 412 | "equals": map[string]interface{}{ 413 | "requestFrom": "172.17.0.1", 414 | "method": http.MethodGet, 415 | "path": "/foo", 416 | "headers": map[string]string{ 417 | "Accept": "application/json", 418 | }, 419 | }, 420 | }, 421 | }, 422 | "responses": []interface{}{ 423 | map[string]interface{}{ 424 | "is": map[string]interface{}{ 425 | "statusCode": 200, 426 | "headers": map[string]string{ 427 | "Content-Type": "application/json", 428 | }, 429 | "body": map[string]interface{}{"test": true}, 430 | }, 431 | }, 432 | }, 433 | }, 434 | }, 435 | }, 436 | }, 437 | { 438 | Description: "should include parameters on the predicate if specified", 439 | Imposter: mbgo.Imposter{ 440 | Port: 8080, 441 | Proto: "http", 442 | Name: "http_test_imposter", 443 | RecordRequests: true, 444 | AllowCORS: true, 445 | Stubs: []mbgo.Stub{ 446 | { 447 | Predicates: []mbgo.Predicate{ 448 | { 449 | Operator: "equals", 450 | Request: mbgo.HTTPRequest{ 451 | Method: http.MethodGet, 452 | Path: "/foo", 453 | }, 454 | // include JSONPath parameter 455 | JSONPath: &mbgo.JSONPath{ 456 | Selector: "$..test", 457 | }, 458 | // include case sensitive parameter 459 | CaseSensitive: true, 460 | }, 461 | }, 462 | Responses: []mbgo.Response{ 463 | { 464 | Type: "is", 465 | Value: mbgo.HTTPResponse{ 466 | StatusCode: http.StatusOK, 467 | Headers: map[string][]string{ 468 | "Content-Type": {"application/json"}, 469 | }, 470 | Body: `{"test":true}`, 471 | }, 472 | }, 473 | }, 474 | }, 475 | }, 476 | }, 477 | Expected: map[string]interface{}{ 478 | "port": 8080, 479 | "protocol": "http", 480 | "name": "http_test_imposter", 481 | "recordRequests": true, 482 | "allowCORS": true, 483 | "stubs": []interface{}{ 484 | map[string]interface{}{ 485 | "predicates": []interface{}{ 486 | map[string]interface{}{ 487 | "equals": map[string]interface{}{ 488 | "method": http.MethodGet, 489 | "path": "/foo", 490 | }, 491 | "jsonpath": map[string]interface{}{ 492 | "selector": "$..test", 493 | }, 494 | "caseSensitive": true, 495 | }, 496 | }, 497 | "responses": []interface{}{ 498 | map[string]interface{}{ 499 | "is": map[string]interface{}{ 500 | "statusCode": 200, 501 | "headers": map[string]string{ 502 | "Content-Type": "application/json", 503 | }, 504 | "body": `{"test":true}`, 505 | }, 506 | }, 507 | }, 508 | }, 509 | }, 510 | }, 511 | }, 512 | { 513 | Description: "should marshal the expected default response on an http imposter, if provided", 514 | Imposter: mbgo.Imposter{ 515 | Proto: "http", 516 | Port: 8080, 517 | DefaultResponse: mbgo.HTTPResponse{ 518 | StatusCode: http.StatusNotImplemented, 519 | Mode: "text", 520 | Body: "not implemented", 521 | }, 522 | }, 523 | Expected: map[string]interface{}{ 524 | "protocol": "http", 525 | "port": 8080, 526 | "defaultResponse": map[string]interface{}{ 527 | "statusCode": 501, 528 | "_mode": "text", 529 | "body": "not implemented", 530 | }, 531 | }, 532 | }, 533 | { 534 | Description: "should marshal the expected default response on a tcp imposter, if provided", 535 | Imposter: mbgo.Imposter{ 536 | Proto: "tcp", 537 | Port: 8080, 538 | DefaultResponse: mbgo.TCPResponse{ 539 | Data: "not implemented", 540 | }, 541 | }, 542 | Expected: map[string]interface{}{ 543 | "protocol": "tcp", 544 | "port": 8080, 545 | "defaultResponse": map[string]interface{}{ 546 | "data": "not implemented", 547 | }, 548 | }, 549 | }, 550 | { 551 | Description: "should marshal the expected javascript injection predicate", 552 | Imposter: mbgo.Imposter{ 553 | Proto: "tcp", 554 | Port: 8080, 555 | Stubs: []mbgo.Stub{ 556 | { 557 | Predicates: []mbgo.Predicate{ 558 | { 559 | Operator: "inject", 560 | Request: "request => { return Buffer.from(request.data, 'base64')[2] <= 100; }", 561 | }, 562 | }, 563 | Responses: []mbgo.Response{ 564 | { 565 | Type: "is", 566 | Value: mbgo.TCPResponse{ 567 | Data: "c2Vjb25kIHJlc3BvbnNl", 568 | }, 569 | }, 570 | }, 571 | }, 572 | }, 573 | }, 574 | Expected: map[string]interface{}{ 575 | "protocol": "tcp", 576 | "port": 8080, 577 | "stubs": []map[string]interface{}{ 578 | { 579 | "predicates": []map[string]interface{}{ 580 | { 581 | "inject": "request => { return Buffer.from(request.data, 'base64')[2] <= 100; }", 582 | }, 583 | }, 584 | "responses": []map[string]interface{}{ 585 | { 586 | "is": map[string]interface{}{ 587 | "data": "c2Vjb25kIHJlc3BvbnNl", 588 | }, 589 | }, 590 | }, 591 | }, 592 | }, 593 | }, 594 | }, 595 | } 596 | 597 | for _, c := range cases { 598 | c := c 599 | 600 | t.Run(c.Description, func(t *testing.T) { 601 | t.Parallel() 602 | 603 | // verify JSON structure of expected value versus actual 604 | actualBytes, err := json.Marshal(c.Imposter) 605 | assert.MustOk(t, err) 606 | 607 | expectedBytes, err := json.Marshal(c.Expected) 608 | assert.MustOk(t, err) 609 | 610 | var actual, expected map[string]interface{} 611 | err = json.Unmarshal(actualBytes, &actual) 612 | assert.MustOk(t, err) 613 | 614 | err = json.Unmarshal(expectedBytes, &expected) 615 | assert.MustOk(t, err) 616 | 617 | assert.Equals(t, expected, actual) 618 | }) 619 | } 620 | } 621 | 622 | func TestImposter_UnmarshalJSON(t *testing.T) { 623 | cases := []struct { 624 | Description string 625 | JSON map[string]interface{} 626 | Expected mbgo.Imposter 627 | }{ 628 | { 629 | Description: "should unmarshal the JSON into the expected http Imposter", 630 | JSON: map[string]interface{}{ 631 | "port": 8080, 632 | "protocol": "http", 633 | "name": "http_imposter", 634 | "numberOfRequests": 42, 635 | "stubs": []interface{}{ 636 | map[string]interface{}{ 637 | "predicates": []interface{}{ 638 | map[string]interface{}{ 639 | "equals": map[string]interface{}{ 640 | "requestFrom": "172.17.0.1:58112", 641 | "method": "POST", 642 | "path": "/foo", 643 | "query": map[string]string{ 644 | "bar": "baz", 645 | }, 646 | "headers": map[string]string{ 647 | "Content-Type": "application/json", 648 | }, 649 | "body": `{"predicate":true}`, 650 | }, 651 | }, 652 | }, 653 | "responses": []interface{}{ 654 | map[string]interface{}{ 655 | "is": map[string]interface{}{ 656 | "statusCode": 200, 657 | "_mode": "text", 658 | "headers": map[string]string{ 659 | "Accept": "application/json", 660 | "Content-Type": "application/json", 661 | }, 662 | "body": `{"response":true}`, 663 | }, 664 | "_behaviors": map[string]interface{}{ 665 | "wait": 500, 666 | }, 667 | }, 668 | }, 669 | }, 670 | }, 671 | }, 672 | Expected: mbgo.Imposter{ 673 | Port: 8080, 674 | Proto: "http", 675 | Name: "http_imposter", 676 | RequestCount: 42, 677 | Stubs: []mbgo.Stub{ 678 | { 679 | Predicates: []mbgo.Predicate{ 680 | { 681 | Operator: "equals", 682 | Request: &mbgo.HTTPRequest{ 683 | RequestFrom: net.IPv4(172, 17, 0, 1), 684 | Method: "POST", 685 | Path: "/foo", 686 | Query: map[string][]string{ 687 | "bar": {"baz"}, 688 | }, 689 | Headers: map[string][]string{ 690 | "Content-Type": {"application/json"}, 691 | }, 692 | Body: `{"predicate":true}`, 693 | }, 694 | }, 695 | }, 696 | Responses: []mbgo.Response{ 697 | { 698 | Type: "is", 699 | Value: &mbgo.HTTPResponse{ 700 | StatusCode: http.StatusOK, 701 | Mode: "text", 702 | Headers: map[string][]string{ 703 | "Accept": {"application/json"}, 704 | "Content-Type": {"application/json"}, 705 | }, 706 | Body: `{"response":true}`, 707 | }, 708 | Behaviors: &mbgo.Behaviors{ 709 | Wait: 500, 710 | }, 711 | }, 712 | }, 713 | }, 714 | }, 715 | }, 716 | }, 717 | { 718 | Description: "should unmarshal the JSON into the expected tcp Imposter", 719 | JSON: map[string]interface{}{ 720 | "port": 8080, 721 | "protocol": "tcp", 722 | "name": "tcp_imposter", 723 | "numberOfRequests": 4, 724 | "stubs": []interface{}{ 725 | map[string]interface{}{ 726 | "predicates": []interface{}{ 727 | map[string]interface{}{ 728 | "equals": map[string]interface{}{ 729 | "requestFrom": "172.17.0.1:58112", 730 | "data": "SGVsbG8sIHdvcmxkIQ==", 731 | }, 732 | }, 733 | }, 734 | "responses": []interface{}{ 735 | map[string]interface{}{ 736 | "is": map[string]interface{}{ 737 | "data": "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 738 | }, 739 | }, 740 | }, 741 | }, 742 | }, 743 | }, 744 | Expected: mbgo.Imposter{ 745 | Port: 8080, 746 | Proto: "tcp", 747 | Name: "tcp_imposter", 748 | RequestCount: 4, 749 | Stubs: []mbgo.Stub{ 750 | { 751 | Predicates: []mbgo.Predicate{ 752 | { 753 | Operator: "equals", 754 | Request: &mbgo.TCPRequest{ 755 | RequestFrom: net.IPv4(172, 17, 0, 1), 756 | Data: "SGVsbG8sIHdvcmxkIQ==", 757 | }, 758 | }, 759 | }, 760 | Responses: []mbgo.Response{ 761 | { 762 | Type: "is", 763 | Value: &mbgo.TCPResponse{ 764 | Data: "Z2l0aHViLmNvbS9zZW5zZXllaW8vbWJnbw==", 765 | }, 766 | }, 767 | }, 768 | }, 769 | }, 770 | }, 771 | }, 772 | } 773 | 774 | for _, c := range cases { 775 | c := c 776 | 777 | t.Run(c.Description, func(t *testing.T) { 778 | t.Parallel() 779 | 780 | actualBytes, err := json.Marshal(c.JSON) 781 | assert.MustOk(t, err) 782 | 783 | actual := mbgo.Imposter{} 784 | err = json.Unmarshal(actualBytes, &actual) 785 | assert.MustOk(t, err) 786 | assert.Equals(t, c.Expected, actual) 787 | }) 788 | } 789 | } 790 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/senseyeio/mbgo 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/senseyeio/mbgo/aa38c5ef3525c7174fa2ccd9332d2b1430f02e87/go.sum -------------------------------------------------------------------------------- /imposter.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | package mbgo 5 | 6 | import ( 7 | "net" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | // HTTPRequest describes an incoming HTTP request received by an 13 | // Imposter of the "http" protocol. 14 | // 15 | // See more information about HTTP requests in mountebank at: 16 | // http://www.mbtest.org/docs/protocols/http. 17 | type HTTPRequest struct { 18 | // RequestFrom is the originating address of the incoming request. 19 | RequestFrom net.IP 20 | 21 | // Method is the HTTP request method. 22 | Method string 23 | 24 | // Path is the path of the request, without the query parameters. 25 | Path string 26 | 27 | // Query contains the URL query parameters of the request. 28 | Query url.Values 29 | 30 | // Headers contains the HTTP headers of the request. 31 | Headers http.Header 32 | 33 | // Body is the body of the request. 34 | Body interface{} 35 | 36 | // Timestamp is the timestamp of the request. 37 | Timestamp string 38 | } 39 | 40 | // TCPRequest describes incoming TCP data received by an Imposter of 41 | // the "tcp" protocol. 42 | // 43 | // See more information about TCP requests in mountebank at: 44 | // http://www.mbtest.org/docs/protocols/tcp. 45 | type TCPRequest struct { 46 | // RequestFrom is the originating address of the incoming request. 47 | RequestFrom net.IP 48 | 49 | // Data is the data in the request as plaintext. 50 | Data string 51 | } 52 | 53 | // JSONPath is a predicate parameter used to narrow the scope of a tested value 54 | // to one found at the specified path in the response JSON. 55 | // 56 | // See more information about the JSONPath parameter at: 57 | // http://www.mbtest.org/docs/api/jsonpath. 58 | type JSONPath struct { 59 | // Selector is the JSON path of the value tested against the predicate. 60 | Selector string `json:"selector"` 61 | } 62 | 63 | // Predicate represents conditional behaviour attached to a Stub in order 64 | // for it to match or not match an incoming request. 65 | // 66 | // The supported operations for a Predicate are listed at: 67 | // http://www.mbtest.org/docs/api/predicates. 68 | type Predicate struct { 69 | // Operator is the conditional or logical operator of the Predicate. 70 | Operator string 71 | 72 | // Request is the request value challenged against the Operator; 73 | // either of type HTTPRequest or TCPRequest. 74 | Request interface{} 75 | 76 | // JSONPath is the predicate parameter for narrowing the scope of JSON 77 | // comparison; leave nil to disable functionality. 78 | JSONPath *JSONPath 79 | 80 | // CaseSensitive determines if the match is case sensitive or not. 81 | CaseSensitive bool 82 | } 83 | 84 | // HTTPResponse is a Response.Value used to respond to a matched HTTPRequest. 85 | // 86 | // See more information about HTTP responses in mountebank at: 87 | // http://www.mbtest.org/docs/protocols/http. 88 | type HTTPResponse struct { 89 | // StatusCode is the HTTP status code of the response. 90 | StatusCode int 91 | 92 | // Headers are the HTTP headers in the response. 93 | Headers http.Header 94 | 95 | // Body is the body of the response. It will be JSON encoded before sending to mountebank 96 | Body interface{} 97 | 98 | // Mode is the mode of the response; either "text" or "binary". 99 | // Defaults to "text" if excluded. 100 | Mode string 101 | } 102 | 103 | // TCPResponse is a Response.Value to a matched incoming TCPRequest. 104 | // 105 | // See more information about TCP responses in mountebank at: 106 | // http://www.mbtest.org/docs/protocols/tcp. 107 | type TCPResponse struct { 108 | // Data is the data in the data contained in the response. 109 | // An empty string does not respond with data, but does send 110 | // the FIN bit. 111 | Data string 112 | } 113 | 114 | // Behaviors defines the possible response behaviors for a stub. 115 | // 116 | // See more information on stub behaviours in mountebank at: 117 | // http://www.mbtest.org/docs/api/behaviors. 118 | type Behaviors struct { 119 | // Wait adds latency to a response by waiting a specified number of milliseconds before sending the response. 120 | Wait int `json:"wait,omitempty"` 121 | } 122 | 123 | // Response defines a networked response sent by a Stub whenever an 124 | // incoming Request matches one of its Predicates. Each Response is 125 | // has a Type field that defines its behaviour. Its currently supported 126 | // values are: 127 | // is - Merges the specified Response fields with the defaults. 128 | // proxy - Proxies the request to the specified destination and returns the response. 129 | // inject - Creates the Response object based on the injected Javascript. 130 | // 131 | // See more information on stub responses in mountebank at: 132 | // http://www.mbtest.org/docs/api/stubs. 133 | type Response struct { 134 | // Type is the type of the Response; one of "is", "proxy" or "inject". 135 | Type string 136 | 137 | // Value is the value of the Response; either of type HTTPResponse or TCPResponse. 138 | Value interface{} 139 | 140 | // Behaviors is an optional field allowing the user to define response behavior. 141 | Behaviors *Behaviors 142 | } 143 | 144 | // Stub adds behaviour to Imposters where one or more registered Responses 145 | // will be returned if an incoming request matches all of the registered 146 | // Predicates. Any Stub value without Predicates always matches and returns 147 | // its next Response. Note that the Responses slice acts as a circular-queue 148 | // type structure, where every time the Stub matches an incoming request, the 149 | // first Response is moved to the end of the slice. This allows for test cases 150 | // to define and handle a sequence of Responses. 151 | // 152 | // See more information about stubs in mountebank at: 153 | // http://www.mbtest.org/docs/api/stubs. 154 | type Stub struct { 155 | // Predicates are the list of Predicates associated with the Stub, 156 | // which are logically AND'd together if more than one exists. 157 | Predicates []Predicate 158 | 159 | // Responses are the circular queue of Responses used to respond to 160 | // incoming matched requests. 161 | Responses []Response 162 | } 163 | 164 | // Imposter is the primary mountebank resource, representing a server/service 165 | // that listens for networked traffic of a specified protocol and port, with the 166 | // ability to match incoming requests and respond to them based on the behaviour 167 | // of any attached Stub values. 168 | // 169 | // See one of the following links below for details on Imposter creation 170 | // parameters, which varies by protocol: 171 | // 172 | // http://www.mbtest.org/docs/protocols/http 173 | // 174 | // http://www.mbtest.org/docs/protocols/tcp 175 | type Imposter struct { 176 | // Port is the listening port of the Imposter; required. 177 | Port int 178 | 179 | // Proto is the listening protocol of the Imposter; required. 180 | Proto string 181 | 182 | // Name is the name of the Imposter. 183 | Name string 184 | 185 | // RecordRequests adds mock verification support to the Imposter 186 | // by having it remember any requests made to it, which can later 187 | // be retrieved and examined by the testing environment. 188 | RecordRequests bool 189 | 190 | // Requests are the list of recorded requests, or nil if RecordRequests == false. 191 | // Note that the underlying type will be HTTPRequest or TCPRequest depending on 192 | // the protocol of the Imposter. 193 | Requests []interface{} 194 | 195 | // RequestCount is the number of matched requests received by the Imposter. 196 | // Note that this value is only used/set when receiving Imposter data 197 | // from the mountebank server. 198 | RequestCount int 199 | 200 | // AllowCORS will allow all CORS pre-flight requests on the Imposter. 201 | AllowCORS bool 202 | 203 | // DefaultResponse is the default response to send if no predicate matches. 204 | // Only used by HTTP and TCP Imposters; should be one of HTTPResponse or TCPResponse. 205 | DefaultResponse interface{} 206 | 207 | // Stubs contains zero or more valid Stubs associated with the Imposter. 208 | Stubs []Stub 209 | } 210 | -------------------------------------------------------------------------------- /internal/assert/assert.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | // Package assert is used internally by tests to make basic assertions. 5 | package assert 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | // Equals is a helper function used throughout the unit and integration 13 | // tests to assert deep equality between an actual and expected value. 14 | func Equals(tb testing.TB, expected, actual interface{}) { 15 | tb.Helper() 16 | 17 | if !reflect.DeepEqual(expected, actual) { 18 | tb.Errorf("\n\n\texpected: %#v\n\n\tactual: %#v\n\n", expected, actual) 19 | } 20 | } 21 | 22 | // Ok fails the test if an err is not nil. 23 | func Ok(tb testing.TB, err error) { 24 | tb.Helper() 25 | 26 | if err != nil { 27 | tb.Errorf("unexpected error: %#v\n\n", err) 28 | } 29 | } 30 | 31 | // MustOk fails the test now if an err is not nil. 32 | func MustOk(tb testing.TB, err error) { 33 | tb.Helper() 34 | 35 | if err != nil { 36 | tb.Fatalf("fatal error: %#v\n\n", err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/rest/rest.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | // Package rest is used internally by the client to interact with the mountebank API. 5 | package rest 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | ) 14 | 15 | // Client represents a generic HTTP REST client that handles 16 | // a JSON structure in requests and responses. 17 | type Client struct { 18 | baseURL *url.URL 19 | httpClient *http.Client 20 | } 21 | 22 | // NewClient returns a new instance of *Client from the provided 23 | // inner *http.Client httpClient and API base *url.URL baseURL. 24 | func NewClient(cli *http.Client, root *url.URL) *Client { 25 | return &Client{ 26 | httpClient: cli, 27 | baseURL: root, 28 | } 29 | } 30 | 31 | // NewRequest builds the specified *http.Request value from the 32 | // provided request method, path, body and optional body/query 33 | // parameters, with the appropriate headers set depending on 34 | // the particular request method. 35 | func (cli *Client) NewRequest(ctx context.Context, method, path string, body io.Reader, q url.Values) (*http.Request, error) { 36 | u := cli.baseURL.ResolveReference(&url.URL{Path: path}) 37 | u.RawQuery = q.Encode() 38 | 39 | req, err := http.NewRequest(method, u.String(), body) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | req.Header.Set("Accept", "application/json") 45 | switch req.Method { 46 | case http.MethodPost, http.MethodPut: 47 | req.Header.Set("Content-Type", "application/json") 48 | } 49 | 50 | return req.WithContext(ctx), nil 51 | } 52 | 53 | // Do sends an HTTP request and returns an HTTP response. 54 | func (cli *Client) Do(req *http.Request) (*http.Response, error) { 55 | return cli.httpClient.Do(req) 56 | } 57 | 58 | // DecodeResponseBody reads a JSON-encoded value from the provided 59 | // HTTP response body and stores it into the value pointed to by v 60 | // and closes the body after reading. 61 | func (cli *Client) DecodeResponseBody(body io.ReadCloser, v interface{}) error { 62 | defer body.Close() 63 | 64 | return json.NewDecoder(body).Decode(v) 65 | } 66 | -------------------------------------------------------------------------------- /internal/rest/rest_integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | //go:build integration 5 | // +build integration 6 | 7 | package rest_test 8 | 9 | import ( 10 | "context" 11 | "net/http" 12 | "net/http/httptest" 13 | "net/url" 14 | "testing" 15 | "time" 16 | 17 | "github.com/senseyeio/mbgo/internal/assert" 18 | "github.com/senseyeio/mbgo/internal/rest" 19 | ) 20 | 21 | func TestClient_Do_Integration(t *testing.T) { 22 | t.Run("should error when the request context timeout deadline is exceeded", func(t *testing.T) { 23 | timeout := time.Millisecond * 10 24 | 25 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | // have a slow handler to make sure the request context times out 27 | time.Sleep(timeout * 2) 28 | w.WriteHeader(http.StatusNoContent) 29 | })) 30 | defer srv.Close() 31 | 32 | u, err := url.Parse(srv.URL) 33 | assert.MustOk(t, err) 34 | 35 | cli := rest.NewClient(&http.Client{ 36 | // increase the old-style client timeout above context deadline 37 | Timeout: timeout * 2, 38 | }, u) 39 | 40 | ctx, _ := context.WithTimeout(context.Background(), timeout) 41 | req, err := cli.NewRequest(ctx, http.MethodGet, "/foo", nil, nil) 42 | assert.MustOk(t, err) 43 | 44 | _, err = cli.Do(req) 45 | urlErr, ok := err.(*url.Error) 46 | assert.Equals(t, ok, true) 47 | assert.Equals(t, context.DeadlineExceeded, urlErr.Err) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /internal/rest/rest_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Senseye Ltd. All rights reserved. 2 | // Use of this source code is governed by the MIT License that can be found in the LICENSE file. 3 | 4 | package rest_test 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "io" 11 | "io/ioutil" 12 | "net" 13 | "net/http" 14 | "net/url" 15 | "reflect" 16 | "strings" 17 | "testing" 18 | 19 | "github.com/senseyeio/mbgo/internal/assert" 20 | "github.com/senseyeio/mbgo/internal/rest" 21 | ) 22 | 23 | func TestClient_NewRequest(t *testing.T) { 24 | cases := []struct { 25 | // general 26 | Description string 27 | 28 | // inputs 29 | Root *url.URL 30 | Method string 31 | Path string 32 | Body io.Reader 33 | Query url.Values 34 | 35 | // output expectations 36 | AssertFunc func(*testing.T, *http.Request, error) 37 | Request *http.Request 38 | Err error 39 | }{ 40 | { 41 | Description: "should return an error if the provided request method is invalid", 42 | Root: &url.URL{}, 43 | Method: "bad method", 44 | AssertFunc: func(t *testing.T, _ *http.Request, err error) { 45 | assert.Equals(t, errors.New(`net/http: invalid method "bad method"`), err) 46 | }, 47 | }, 48 | { 49 | Description: "should construct the URL based on provided root URL, path and query parameters", 50 | Root: &url.URL{ 51 | Scheme: "http", 52 | Host: net.JoinHostPort("localhost", "2525"), 53 | }, 54 | Method: http.MethodGet, 55 | Path: "foo", 56 | Query: url.Values{ 57 | "replayable": []string{"true"}, 58 | }, 59 | AssertFunc: func(t *testing.T, actual *http.Request, err error) { 60 | assert.Ok(t, err) 61 | expected := &http.Request{ 62 | Method: http.MethodGet, 63 | URL: &url.URL{ 64 | Scheme: "http", 65 | Host: net.JoinHostPort("localhost", "2525"), 66 | Path: "/foo", 67 | RawQuery: "replayable=true", 68 | }, 69 | Host: net.JoinHostPort("localhost", "2525"), 70 | Proto: "HTTP/1.1", 71 | ProtoMajor: 1, 72 | ProtoMinor: 1, 73 | Header: http.Header{"Accept": []string{"application/json"}}, 74 | } 75 | assert.Equals(t, expected.WithContext(context.Background()), actual) 76 | }, 77 | }, 78 | { 79 | Description: "should only set the 'Accept' header if method is GET", 80 | Root: &url.URL{}, 81 | Method: http.MethodGet, 82 | AssertFunc: func(t *testing.T, actual *http.Request, err error) { 83 | assert.Ok(t, err) 84 | expected := &http.Request{ 85 | Method: http.MethodGet, 86 | URL: &url.URL{}, 87 | Proto: "HTTP/1.1", 88 | ProtoMajor: 1, 89 | ProtoMinor: 1, 90 | Header: http.Header{"Accept": []string{"application/json"}}, 91 | } 92 | assert.Equals(t, expected.WithContext(context.Background()), actual) 93 | }, 94 | }, 95 | { 96 | Description: "should only set the 'Accept' header if method is DELETE", 97 | Root: &url.URL{}, 98 | Method: http.MethodDelete, 99 | AssertFunc: func(t *testing.T, actual *http.Request, err error) { 100 | assert.Ok(t, err) 101 | expected := &http.Request{ 102 | Method: http.MethodDelete, 103 | URL: &url.URL{}, 104 | Proto: "HTTP/1.1", 105 | ProtoMajor: 1, 106 | ProtoMinor: 1, 107 | Header: http.Header{"Accept": []string{"application/json"}}, 108 | } 109 | assert.Equals(t, expected.WithContext(context.Background()), actual) 110 | }, 111 | }, 112 | { 113 | Description: "should set both the 'Accept' and 'Content-Type' headers if method is POST", 114 | Root: &url.URL{}, 115 | Method: http.MethodPost, 116 | AssertFunc: func(t *testing.T, actual *http.Request, err error) { 117 | assert.Ok(t, err) 118 | expected := &http.Request{ 119 | Method: http.MethodPost, 120 | URL: &url.URL{}, 121 | Proto: "HTTP/1.1", 122 | ProtoMajor: 1, 123 | ProtoMinor: 1, 124 | Header: http.Header{ 125 | "Accept": []string{"application/json"}, 126 | "Content-Type": []string{"application/json"}, 127 | }, 128 | } 129 | assert.Equals(t, expected.WithContext(context.Background()), actual) 130 | }, 131 | }, 132 | { 133 | Description: "should set both the 'Accept' and 'Content-Type' headers if method is PUT", 134 | Root: &url.URL{}, 135 | Method: http.MethodPut, 136 | AssertFunc: func(t *testing.T, actual *http.Request, err error) { 137 | assert.Ok(t, err) 138 | expected := &http.Request{ 139 | Method: http.MethodPut, 140 | URL: &url.URL{}, 141 | Proto: "HTTP/1.1", 142 | ProtoMajor: 1, 143 | ProtoMinor: 1, 144 | Header: http.Header{ 145 | "Accept": []string{"application/json"}, 146 | "Content-Type": []string{"application/json"}, 147 | }, 148 | } 149 | assert.Equals(t, expected.WithContext(context.Background()), actual) 150 | }, 151 | }, 152 | } 153 | 154 | for _, c := range cases { 155 | c := c 156 | 157 | t.Run(c.Description, func(t *testing.T) { 158 | t.Parallel() 159 | 160 | cli := rest.NewClient(nil, c.Root) 161 | req, err := cli.NewRequest(context.Background(), c.Method, c.Path, c.Body, c.Query) 162 | c.AssertFunc(t, req, err) 163 | }) 164 | } 165 | } 166 | 167 | type testDTO struct { 168 | Test bool `json:"test"` 169 | Foo string `json:"foo"` 170 | } 171 | 172 | func TestClient_DecodeResponseBody(t *testing.T) { 173 | cases := []struct { 174 | // general 175 | Description string 176 | 177 | // inputs 178 | Body io.ReadCloser 179 | Value interface{} 180 | 181 | // output expectations 182 | Expected interface{} 183 | Err error 184 | }{ 185 | { 186 | Description: "should return an error if the JSON cannot be decoded into the value pointer", 187 | Body: ioutil.NopCloser(strings.NewReader(`"foo"`)), 188 | Value: &testDTO{}, 189 | Expected: &testDTO{}, 190 | Err: &json.UnmarshalTypeError{ 191 | Offset: 5, // 5 bytes read before first full JSON value 192 | Value: "string", 193 | Type: reflect.TypeOf(testDTO{}), 194 | }, 195 | }, 196 | { 197 | Description: "should unmarshal the expected JSON into value pointer when valid", 198 | Body: ioutil.NopCloser(strings.NewReader(`{"test":true,"foo":"bar"}`)), 199 | Value: &testDTO{}, 200 | Expected: &testDTO{ 201 | Test: true, 202 | Foo: "bar", 203 | }, 204 | }, 205 | } 206 | 207 | for _, c := range cases { 208 | c := c 209 | 210 | t.Run(c.Description, func(t *testing.T) { 211 | t.Parallel() 212 | 213 | cli := rest.NewClient(nil, nil) 214 | err := cli.DecodeResponseBody(c.Body, c.Value) 215 | if c.Err != nil { 216 | assert.Equals(t, c.Err, err) 217 | } else { 218 | assert.Ok(t, err) 219 | } 220 | assert.Equals(t, c.Expected, c.Value) 221 | }) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /scripts/integration_test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # get Go package list from caller 4 | PACKAGES=$@ 5 | 6 | # start the mountebank container at localhost:2525, with ports 8080 and 8081 for test imposters 7 | docker run -d --rm --name=mountebank_test -p 2525:2525 -p 8080:8080 -p 8081:8081 \ 8 | andyrbell/mountebank:2.1.2 mb --allowInjection 9 | 10 | # run integration tests and record exit code 11 | go test -cover -covermode=atomic -race -run=^*_Integration$ -tags=integration -timeout=5s ${PACKAGES} 12 | CODE=$? 13 | 14 | # always stop the mountebank container, even on failures 15 | docker stop mountebank_test 16 | 17 | exit ${CODE} --------------------------------------------------------------------------------