Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ A simple, configurable HTTP mock server written in Go. It enables you to define

## Features

- **Flexible Matching**: Match requests by HTTP Method, Path (Regex supported), and Body (Regex supported).
- **Flexible Matching**: Match requests by HTTP Method, Path (Regex supported), and Body (Regex supported). For GET requests, query parameters are automatically encoded and matched against the request pattern.
- **Custom Responses**: Define the Status Code, Headers, and Body for matched requests.
- **Request History**: View a log of received requests, including timestamps, remote addresses, and matching status.
- **Match Tracking**: Track how many times each expectation has been matched via the `matched_count` field.
- **Request History**: View a log of received requests, including timestamps, remote addresses, matching status, and the mock response that was returned.
- **Copy cURL**: Easily copy the cURL command for any received request from the history dashboard.
- **Web Interface**: Simple dashboard to view history.
- **Smart Content-Type**: Automatically uses the request's `Accept` header as `Content-Type` when no response headers are specified.
- **Configurable**: Load expectations via JSON/YAML files or Environment Variables.
- **REST API**: Manage and check expectations dynamically via a RESTful API.

Expand All @@ -26,11 +28,13 @@ The server is configured using Environment Variables:

Each expectation is an object with the following fields:

- `method`: HTTP Method (e.g., "POST", "GET"). Use `*` to match any method.
- `path`: URL path to match. Supports Regex (e.g., `^/api/v1/user/\d+$`) or `*` to match any path.
- `method`: HTTP Method (e.g., "POST", "GET"). Leave empty or omit to match any method.
- `path`: URL path to match. Supports Regex (e.g., `^/api/v1/user/\d+$`). Use `*` or leave empty to match any path.
- `request`: Regex to match against the request body. If empty or `*`, matches any body.
Comment thread
andboson marked this conversation as resolved.
- `status`: HTTP Status Code to return (e.g., 200, 404).
- For GET requests: Query parameters are automatically URL-encoded (e.g., `foo=bar&baz=qux`) and matched against this pattern. Parameter order is normalized for consistent matching.
- `status`: HTTP Status Code to return (e.g., 200, 404). Defaults to 200 if not specified.
- `headers`: Map of HTTP headers to include in the response.
- If the `headers` map is empty or omitted entirely, the server automatically uses the request's `Accept` header as the `Content-Type` in the response.
- `mock`: The response body string. Can start with `@` to load from a file (e.g. `@/path/to/response.json`).

#### Example `expectations.yaml`
Expand All @@ -50,6 +54,37 @@ Each expectation is an object with the following fields:
Content-Type: application/json
mock: "@/app/test_response.json"
```

#### GET Requests and Query Parameters

For GET requests, query parameters are automatically URL-encoded and matched against the `request` field. This allows you to match specific query parameter patterns:

```yaml
- method: GET
path: /api/search
request: "q=test&limit=10"
status: 200
headers:
Content-Type: application/json
mock: '{"results": []}'
```

The above expectation will match requests like:
- `GET /api/search?q=test&limit=10`
- `GET /api/search?limit=10&q=test` (parameter order is normalized)

You can also use regex patterns for flexible matching:

```yaml
- method: GET
path: /api/users
request: "id=\\d+"
status: 200
mock: '{"user": "data"}'
```

This matches any GET request to `/api/users` with an `id` parameter containing digits.



## Running the Server
Expand Down Expand Up @@ -84,9 +119,9 @@ The server provides a REST API to manage expectations dynamically.
### Endpoints

- `POST /api/expectation`: Add a new expectation.
- `GET /api/expectation/{id}`: Check if an expectation was matched.
- `GET /api/expectation/{id}`: Check if an expectation was matched. Returns `{"matched": boolean, "matched_count": integer}` indicating whether the expectation was ever matched and how many times.
- `DELETE /api/expectation/{id}`: Remove an expectation.
- `GET /api/expectations`: Get all registered expectations.
- `GET /api/expectations`: Get all registered expectations (includes `matched_count` for each).

### OpenAPI Specification

Expand Down Expand Up @@ -139,7 +174,7 @@ func main() {
if err != nil {
log.Fatalf("Failed to check status: %v", err)
}
fmt.Printf("Matched: %v\n", status.Matched)
fmt.Printf("Matched: %v, Match Count: %d\n", status.Matched, status.MatchedCount)
}
```

Expand Down
30 changes: 20 additions & 10 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ func TestParseExpectations(t *testing.T) {
err := c.ParseExpectations([]byte(jsonData))
require.NoError(t, err)
require.Len(t, c.Expectations(), 1)
require.Equal(t, "GET", c.Expectations()[0].Method)
require.Equal(t, "/test", c.Expectations()[0].Path)
require.NotNil(t, c.Expectations()[0].Method)
require.Equal(t, "GET", *c.Expectations()[0].Method)
require.NotNil(t, c.Expectations()[0].Path)
require.Equal(t, "/test", *c.Expectations()[0].Path)
}

func TestLoadExpectationsFromFile(t *testing.T) {
Expand All @@ -39,7 +41,8 @@ func TestLoadExpectationsFromFile(t *testing.T) {
err = c.LoadExpectationsFromFile(jsonFile)
require.NoError(t, err)
require.Len(t, c.Expectations(), 1)
require.Equal(t, "/json", c.Expectations()[0].Path)
require.NotNil(t, c.Expectations()[0].Path)
require.Equal(t, "/json", *c.Expectations()[0].Path)

// YAML File
yamlFile := filepath.Join(tempDir, "expectations.yaml")
Expand All @@ -53,7 +56,8 @@ func TestLoadExpectationsFromFile(t *testing.T) {
err = c.LoadExpectationsFromFile(yamlFile) // Append
require.NoError(t, err)
require.Len(t, c.Expectations(), 2)
require.Equal(t, "/yaml", c.Expectations()[1].Path)
require.NotNil(t, c.Expectations()[1].Path)
require.Equal(t, "/yaml", *c.Expectations()[1].Path)
}

func TestNewConfig_Env(t *testing.T) {
Expand All @@ -65,7 +69,8 @@ func TestNewConfig_Env(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, c)
require.Len(t, c.Expectations(), 1)
require.Equal(t, "/env", c.Expectations()[0].Path)
require.NotNil(t, c.Expectations()[0].Path)
require.Equal(t, "/env", *c.Expectations()[0].Path)
}

func TestNewConfig_EnvFile(t *testing.T) {
Expand All @@ -81,7 +86,8 @@ func TestNewConfig_EnvFile(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, c)
require.Len(t, c.Expectations(), 1)
require.Equal(t, "/envfile", c.Expectations()[0].Path)
require.NotNil(t, c.Expectations()[0].Path)
require.Equal(t, "/envfile", *c.Expectations()[0].Path)
}

func TestLoadExpectationsFromTestData(t *testing.T) {
Expand All @@ -92,8 +98,10 @@ func TestLoadExpectationsFromTestData(t *testing.T) {
require.Len(t, c.Expectations(), 6)

e := c.Expectations()[0]
require.Equal(t, "GET", e.Method)
require.Equal(t, "/api/hello", e.Path)
require.NotNil(t, e.Method)
require.Equal(t, "GET", *e.Method)
require.NotNil(t, e.Path)
require.Equal(t, "/api/hello", *e.Path)
require.Equal(t, 200, e.StatusCode)
})

Expand All @@ -104,8 +112,10 @@ func TestLoadExpectationsFromTestData(t *testing.T) {
require.Len(t, c.Expectations(), 6)

e := c.Expectations()[0]
require.Equal(t, "GET", e.Method)
require.Equal(t, "/api/hello", e.Path)
require.NotNil(t, e.Method)
require.Equal(t, "GET", *e.Method)
require.NotNil(t, e.Path)
require.Equal(t, "/api/hello", *e.Path)
require.Equal(t, 200, e.StatusCode)
})
}
99 changes: 74 additions & 25 deletions internal/models/expectation.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package models

import (
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
Expand All @@ -15,9 +17,9 @@ type Expectation struct {
MatchedCount int `json:"matched_count"`

// Request matching criteria
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
Request string `json:"request" yaml:"request"`
Method *string `json:"method,omitempty" yaml:"method,omitempty"`
Path *string `json:"path,omitempty" yaml:"path,omitempty"`
Request *string `json:"request,omitempty" yaml:"request,omitempty"`
Comment thread
andboson marked this conversation as resolved.

// Response details
StatusCode int `json:"status" yaml:"status"`
Expand All @@ -32,7 +34,22 @@ type Expectation struct {
}

func (e *Expectation) String() string {
return fmt.Sprintf("Expectation(Method=%s, Path=%s, Request=%s, StatusCode=%d)", e.Method, e.Path, e.Request, e.StatusCode)
method := "*"
if e.Method != nil {
method = *e.Method
}

path := "*"
if e.Path != nil {
path = *e.Path
}

request := "*"
if e.Request != nil {
request = *e.Request
}

return fmt.Sprintf("Expectation(Method=%s, Path=%s, Request=%s, StatusCode=%d)", method, path, request, e.StatusCode)
}

func (e *Expectation) IncrementMatchedCount() {
Expand Down Expand Up @@ -62,17 +79,17 @@ func (e *Expectation) CheckMockResponse() error {
// Compile prepares the regular expressions for the Path and Request fields.
// It should be called after loading the Expectation and before using Match.
func (e *Expectation) Compile() error {
if e.Path != "" && e.Path != "*" {
reg, err := regexp.Compile(e.Path)
if e.Path != nil && *e.Path != "" && *e.Path != "*" {
reg, err := regexp.Compile(*e.Path)
if err != nil {
return fmt.Errorf("compiling path regex: %w", err)
}

e.pathRegex = reg
}

if e.Request != "" && e.Request != "*" {
reg, err := regexp.Compile(e.Request)
if e.Request != nil && *e.Request != "" && *e.Request != "*" {
reg, err := regexp.Compile(*e.Request)
if err != nil {
return fmt.Errorf("compiling request regex: %w", err)
}
Expand All @@ -93,27 +110,19 @@ func (e *Expectation) Match(method, path, body string) bool {
return false
}

if e.Method != "POST" && e.Method != "PATCH" && e.Method != "PUT" {
return true
}

if e.Request != "" && !e.matchRequestBody(body) {
if e.Request != nil && !e.matchRequestBody(method, body) {
return false
}

return true
}

func (e *Expectation) matchPath(path string) bool {
if e.Path == "*" {
if e.Path == nil || *e.Path == "" || *e.Path == "*" {
return true
}

if path == "" && e.Path != "" {
return false
}

if e.Path == path {
if *e.Path == path {
return true
}

Expand All @@ -124,30 +133,70 @@ func (e *Expectation) matchPath(path string) bool {
return false
}

func (e *Expectation) matchRequestBody(body string) bool {
if e.Request == "*" || e.Method == "GET" {
func (e *Expectation) matchRequestBody(method, body string) bool {
if e.Request == nil || *e.Request == "" || *e.Request == "*" {
return true
}

if body == "" && e.Request != "" {
if body == "" && *e.Request != "" {
return false
}

if e.Request == body {
if *e.Request == body {
Comment thread
andboson marked this conversation as resolved.
return true
}

// For GET requests, we can treat the body as a query string and compare key-value pairs regardless of order
if method == http.MethodGet {
reqQuery, err := url.ParseQuery(body)
if err != nil {
return false
}

expectedQuery, err := url.ParseQuery(*e.Request)
if err != nil {
return false
}
Comment thread
andboson marked this conversation as resolved.

if compareQueries(reqQuery, expectedQuery) {
return true
}
}

if e.requestRegex != nil && e.requestRegex.MatchString(body) {
return true
}

return false
}

func compareQueries(query url.Values, query2 url.Values) bool {
if len(query) != len(query2) {
return false
}

for key, values := range query {
values2, ok := query2[key]
if !ok {
return false
}

if len(values) != len(values2) {
return false
}

if strings.Join(values, "") != strings.Join(values2, "") {
return false
}
Comment thread
andboson marked this conversation as resolved.
}

return true
}

func (e *Expectation) matchMethod(method string) bool {
if e.Method == "*" || method == "" {
if e.Method == nil || *e.Method == "" || *e.Method == "*" {
return true
}

return strings.EqualFold(e.Method, method)
return strings.EqualFold(*e.Method, method)
Comment thread
andboson marked this conversation as resolved.
}
Loading