Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sequences to the http server #64

Merged
merged 2 commits into from
Nov 8, 2023
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added

- Added `exit-on-unmatched-rule` flag: [#63](https://github.com/elastic/stream/pull/63)
- Added sequences to the http server: [#64](https://github.com/elastic/stream/pull/64)

### Changed

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ The rules will be defined in order, and will only match if all criteria is true

### Options

- `as_sequence`: if this is set to `true`, the server will exit with an error if the requests are not performed in order.
- `rules`: a list of rules. More restrictive rules need to go on top.
- `path`: the path to match. It can use [gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux#pkg-overview) parameters patterns.
- `methods`: a list of methods to match with the rule.
- `user` and `password`: username and password for basic auth matching.
- `query_params`: Key-Value definitions of the query parameters to match. It can use [gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux#Route.Queries) parameters patterns for the values. Web form params will also be added and compared against this for simplicity. If a key is given an empty value, requests with this parameter will not satisfy the rule.
- `request_headers`: Key-Value definitions of the headers to match. Any headers outside of this list will be ignored. The matches can be defined [as regular expressions](https://pkg.go.dev/github.com/gorilla/mux#Route.HeadersRegexp).
- `request_body`: a string defining the expected body to match for the request. If the string is quoted with slashes, the leading and trailing slash are stripped and the resulting string is interpreted as a regular expression.
- `responses`: a list of zero or more responses to return on matches. If more than one are set, they will be returned in rolling sequence.
- `responses`: a list of zero or more responses to return on matches. If more than one are set, they will be returned in rolling sequence. If `as_sequence` is set to `true`, they will only be able to be hit once instead of in rolling sequence.
- `status_code`: the status code to return.
- `headers`: Key-Value list of the headers to return with the response. The values will be evaluated as [Go templates](https://golang.org/pkg/text/template/).
- `body`: a string defining the body that will be returned as a response. It will be evaluated as a [Go template](https://golang.org/pkg/text/template/).
Expand Down
3 changes: 2 additions & 1 deletion pkg/httpserver/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import (
)

type config struct {
Rules []rule `config:"rules"`
AsSequence bool `config:"as_sequence"`
Rules []rule `config:"rules"`
}

type rule struct {
Expand Down
12 changes: 12 additions & 0 deletions pkg/httpserver/httpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,23 @@ func newHandlerFromConfig(config *config, notFoundHandler http.HandlerFunc, logg

var buf bytes.Buffer

var currInSeq int
var posInSeq int
for i, rule := range config.Rules {
rule := rule
var count int
i := i
if i > 0 {
posInSeq += len(config.Rules[i-1].Responses)
}
posInSeq := posInSeq
logger.Debugf("Setting up rule #%d for path %q", i, rule.Path)
route := router.HandleFunc(rule.Path, func(w http.ResponseWriter, r *http.Request) {
isNext := currInSeq == posInSeq+count
if config.AsSequence && !isNext {
logger.Fatalf("expecting to match request #%d in sequence, matched rule #%d instead, exiting", currInSeq, posInSeq+count)
}

response := func() *response {
switch len(rule.Responses) {
case 0:
Expand All @@ -135,6 +146,7 @@ func newHandlerFromConfig(config *config, notFoundHandler http.HandlerFunc, logg
}()

count++
currInSeq++

logger.Debug(fmt.Sprintf("Rule #%d matched: request #%d => %s", i, count, strRequest(r)))

Expand Down
231 changes: 220 additions & 11 deletions pkg/httpserver/httpserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
package httpserver

import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
Expand All @@ -15,6 +17,8 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"

"github.com/elastic/stream/pkg/log"
"github.com/elastic/stream/pkg/output"
Expand Down Expand Up @@ -68,17 +72,7 @@ func TestHTTPServer(t *testing.T) {
logger, err := log.NewLogger()
require.NoError(t, err)

server, err := New(&opts, logger.Sugar())
require.NoError(t, err)

t.Cleanup(func() { server.Close() })

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

require.NoError(t, server.Start(ctx))

addr := server.listener.Addr().(*net.TCPAddr).String()
_, addr := startTestServer(t, &opts, logger.Sugar())

t.Run("request does not match path unless all requirements are met", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://"+addr+"/path1/test?p1=v1", nil)
Expand Down Expand Up @@ -143,3 +137,218 @@ func TestHTTPServer(t *testing.T) {
assert.Equal(t, []byte{}, body)
})
}

func TestRunAsSequence(t *testing.T) {
cfg := `---
as_sequence: true
rules:
- path: "/path/1"
methods: ["GET"]
request_headers:
accept: ["application/json"]
responses:
- status_code: 200
body: |-
{"req1": "{{ .req_num }}"}
- status_code: 200
body: |-
{"req2": "{{ .req_num }}"}
- path: "/path/2"
methods: ["GET"]
request_headers:
accept: ["application/json"]
responses:
- status_code: 200
body: |-
{"req3": "{{ .req_num }}"}
- path: "/path/3"
methods: ["GET"]
request_headers:
accept: ["application/json"]
responses:
- status_code: 200
body: |-
{"req4": "{{ .req_num }}"}
- status_code: 200
body: |-
{"req5": "{{ .req_num }}"}
- path: "/path/4"
methods: ["GET"]
request_headers:
accept: ["application/json"]
responses:
- status_code: 200
body: |-
{"req6": "{{ .req_num }}"}
- path: "/path/5"
methods: ["GET"]
request_headers:
accept: ["application/json"]
responses:
- status_code: 200
body: |-
{"req7": "{{ .req_num }}"}
`

f, err := ioutil.TempFile("", "test")
require.NoError(t, err)

t.Cleanup(func() { os.Remove(f.Name()) })

_, err = f.WriteString(cfg)
require.NoError(t, err)

opts := Options{
Options: &output.Options{
Addr: "localhost:0",
},
ConfigPath: f.Name(),
}

logger, err := log.NewLogger()
logger = logger.WithOptions(zap.OnFatal(zapcore.WriteThenPanic))
require.NoError(t, err)

t.Run("requests succeed if made in the expected order", func(t *testing.T) {
server, addr := startTestServer(t, &opts, logger.Sugar())

reqTests := []struct {
path string
expectedBody string
}{
{"http://" + addr + "/path/1", `{"req1": "1"}`},
{"http://" + addr + "/path/1", `{"req2": "2"}`},
{"http://" + addr + "/path/2", `{"req3": "1"}`},
{"http://" + addr + "/path/3", `{"req4": "1"}`},
{"http://" + addr + "/path/3", `{"req5": "2"}`},
{"http://" + addr + "/path/4", `{"req6": "1"}`},
{"http://" + addr + "/path/5", `{"req7": "1"}`},
}

buf := new(bytes.Buffer)
server.server.Handler = http.HandlerFunc(inspectPanic(server.server.Handler, buf))

for _, reqTest := range reqTests {
req, err := http.NewRequest("GET", reqTest.path, nil)
require.NoError(t, err)
req.Header.Add("accept", "application/json")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)

body, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
resp.Body.Close()

assert.JSONEq(t, reqTest.expectedBody, string(body))
assert.Equal(t, "", buf.String())
}
})

t.Run("requests fail if made in the wrong order", func(t *testing.T) {
server, addr := startTestServer(t, &opts, logger.Sugar())
buf := new(bytes.Buffer)
server.server.Handler = http.HandlerFunc(inspectPanic(server.server.Handler, buf))

req, err := http.NewRequest("GET", "http://"+addr+"/path/1", nil)
require.NoError(t, err)
req.Header.Add("accept", "application/json")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)

body, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
resp.Body.Close()

assert.JSONEq(t, `{"req1": "1"}`, string(body))

req, err = http.NewRequest("GET", "http://"+addr+"/path/2", nil)
require.NoError(t, err)
req.Header.Add("accept", "application/json")

resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 500, resp.StatusCode)

assert.Equal(t, "expecting to match request #1 in sequence, matched rule #2 instead, exiting", buf.String())
})
}

func TestExitOnUnmatchedRule(t *testing.T) {
cfg := `---
rules:
- path: "/path/1"
methods: ["GET"]
request_headers:
accept: ["application/json"]
responses:
- status_code: 200
body: |-
{"req1": "{{ .req_num }}"}
`

f, err := ioutil.TempFile("", "test")
require.NoError(t, err)

t.Cleanup(func() { os.Remove(f.Name()) })

_, err = f.WriteString(cfg)
require.NoError(t, err)

opts := Options{
Options: &output.Options{
Addr: "localhost:0",
},
ConfigPath: f.Name(),
ExitOnUnmatchedRule: true,
}

logger, err := log.NewLogger()
logger = logger.WithOptions(zap.OnFatal(zapcore.WriteThenPanic))
require.NoError(t, err)

server, addr := startTestServer(t, &opts, logger.Sugar())
buf := new(bytes.Buffer)
server.server.Handler = http.HandlerFunc(inspectPanic(server.server.Handler, buf))

req, err := http.NewRequest("GET", "http://"+addr+"/path/2", nil)
require.NoError(t, err)
req.Header.Add("accept", "application/json")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 404, resp.StatusCode)

assert.Equal(t, "--exit-on-unmatched-rule is set, exiting", buf.String())
}

func inspectPanic(h http.Handler, writer io.Writer) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
fmt.Fprintf(writer, "%v", err)
w.WriteHeader(http.StatusInternalServerError)
}
}()
h.ServeHTTP(w, r)
}
}

func startTestServer(t *testing.T, opts *Options, logger *zap.SugaredLogger) (*Server, string) {
server, err := New(opts, logger)
require.NoError(t, err)

t.Cleanup(func() { server.Close() })

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

require.NoError(t, server.Start(ctx))

addr := server.listener.Addr().(*net.TCPAddr).String()

return server, addr
}
Loading