feat: add metrics support to mock server (#606)
* feat: add metrics support to mock server * add unit tests * fix the unit testing --------- Co-authored-by: rick <LinuxSuRen@users.noreply.github.com>
This commit is contained in:
parent
664451e6ee
commit
accdb0e4ef
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2023 API Testing Authors.
|
||||
Copyright 2023-2025 API Testing Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -21,6 +21,7 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -36,7 +37,8 @@ func TestConvert(t *testing.T) {
|
|||
c.SetOut(io.Discard)
|
||||
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
tmpFile := path.Join(os.TempDir(), time.Now().String())
|
||||
now := strconv.Itoa(int(time.Now().Unix()))
|
||||
tmpFile := path.Join(os.TempDir(), now)
|
||||
defer os.RemoveAll(tmpFile)
|
||||
|
||||
c.SetArgs([]string{"convert", "-p=testdata/simple-suite.yaml", "--converter=jmeter", "--target", tmpFile})
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
@ -37,7 +38,7 @@ func TestExtensionCmd(t *testing.T) {
|
|||
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
d := downloader.NewStoreDownloader()
|
||||
server := mock.NewInMemoryServer(0)
|
||||
server := mock.NewInMemoryServer(context.Background(), 0)
|
||||
|
||||
err := server.Start(mock.NewLocalFileReader("../pkg/downloader/testdata/registry.yaml"), "/v2")
|
||||
assert.NoError(t, err)
|
||||
|
|
16
cmd/mock.go
16
cmd/mock.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2024 API Testing Authors.
|
||||
Copyright 2024-2025 API Testing Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -28,8 +28,9 @@ import (
|
|||
)
|
||||
|
||||
type mockOption struct {
|
||||
port int
|
||||
prefix string
|
||||
port int
|
||||
prefix string
|
||||
metrics bool
|
||||
}
|
||||
|
||||
func createMockCmd() (c *cobra.Command) {
|
||||
|
@ -45,12 +46,16 @@ func createMockCmd() (c *cobra.Command) {
|
|||
flags := c.Flags()
|
||||
flags.IntVarP(&opt.port, "port", "", 6060, "The mock server port")
|
||||
flags.StringVarP(&opt.prefix, "prefix", "", "/mock", "The mock server API prefix")
|
||||
flags.BoolVarP(&opt.metrics, "metrics", "m", true, "Enable request metrics collection")
|
||||
return
|
||||
}
|
||||
|
||||
func (o *mockOption) runE(c *cobra.Command, args []string) (err error) {
|
||||
reader := mock.NewLocalFileReader(args[0])
|
||||
server := mock.NewInMemoryServer(o.port)
|
||||
server := mock.NewInMemoryServer(c.Context(), o.port)
|
||||
if o.metrics {
|
||||
server.EnableMetrics()
|
||||
}
|
||||
if err = server.Start(reader, o.prefix); err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -58,6 +63,9 @@ func (o *mockOption) runE(c *cobra.Command, args []string) (err error) {
|
|||
clean := make(chan os.Signal, 1)
|
||||
signal.Notify(clean, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
|
||||
printLocalIPs(c, o.port)
|
||||
if o.metrics {
|
||||
c.Printf("Metrics available at http://localhost:%d%s/metrics\n", o.port, o.prefix)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-c.Context().Done():
|
||||
|
|
|
@ -275,7 +275,7 @@ func (o *serverOption) runE(cmd *cobra.Command, args []string) (err error) {
|
|||
mockWriter = mock.NewInMemoryReader("")
|
||||
}
|
||||
|
||||
dynamicMockServer := mock.NewInMemoryServer(0)
|
||||
dynamicMockServer := mock.NewInMemoryServer(cmd.Context(), 0)
|
||||
mockServerController := server.NewMockServerController(mockWriter, dynamicMockServer, o.httpPort)
|
||||
|
||||
clean := make(chan os.Signal, 1)
|
||||
|
|
|
@ -210,7 +210,7 @@ func TestFrontEndHandlerWithLocation(t *testing.T) {
|
|||
resp := newFakeResponseWriter()
|
||||
|
||||
opt.getAtestBinary(resp, req, map[string]string{})
|
||||
assert.Equal(t, `failed to read "atest": open : no such file or directory`, resp.GetBody().String())
|
||||
assert.Contains(t, resp.GetBody().String(), `failed to read "atest"`)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
Binary file not shown.
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2024 API Testing Authors.
|
||||
Copyright 2024-2025 API Testing Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
@ -58,7 +59,7 @@ func TestDetectAuthURL(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDownload(t *testing.T) {
|
||||
server := mock.NewInMemoryServer(0)
|
||||
server := mock.NewInMemoryServer(context.Background(), 0)
|
||||
err := server.Start(mock.NewLocalFileReader("testdata/registry.yaml"), "/v2")
|
||||
assert.NoError(t, err)
|
||||
defer func() {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2024 API Testing Authors.
|
||||
Copyright 2024-2025 API Testing Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -53,15 +53,17 @@ type inMemoryServer struct {
|
|||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
reader Reader
|
||||
metrics RequestMetrics
|
||||
}
|
||||
|
||||
func NewInMemoryServer(port int) DynamicServer {
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
func NewInMemoryServer(ctx context.Context, port int) DynamicServer {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &inMemoryServer{
|
||||
port: port,
|
||||
wg: sync.WaitGroup{},
|
||||
ctx: ctx,
|
||||
cancelFunc: cancel,
|
||||
metrics: NewNoopMetrics(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,6 +74,7 @@ func (s *inMemoryServer) SetupHandler(reader Reader, prefix string) (handler htt
|
|||
s.mux = mux.NewRouter().PathPrefix(prefix).Subrouter()
|
||||
s.prefix = prefix
|
||||
handler = s.mux
|
||||
s.metrics.AddMetricsHandler(s.mux)
|
||||
err = s.Load()
|
||||
return
|
||||
}
|
||||
|
@ -107,22 +110,31 @@ func (s *inMemoryServer) Load() (err error) {
|
|||
memLogger.Info("start to proxy", "target", proxy.Target)
|
||||
s.mux.HandleFunc(proxy.Path, func(w http.ResponseWriter, req *http.Request) {
|
||||
api := fmt.Sprintf("%s/%s", proxy.Target, strings.TrimPrefix(req.URL.Path, s.prefix))
|
||||
api, err = render.Render("proxy api", api, s)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
memLogger.Error(err, "failed to render proxy api")
|
||||
return
|
||||
}
|
||||
memLogger.Info("redirect to", "target", api)
|
||||
|
||||
targetReq, err := http.NewRequestWithContext(req.Context(), req.Method, api, req.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
memLogger.Error(err, "failed to create proxy request")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(targetReq)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
memLogger.Error(err, "failed to do proxy request")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
memLogger.Error(err, "failed to read response body")
|
||||
return
|
||||
}
|
||||
|
@ -148,10 +160,15 @@ func (s *inMemoryServer) Start(reader Reader, prefix string) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func (s *inMemoryServer) EnableMetrics() {
|
||||
s.metrics = NewInMemoryMetrics()
|
||||
}
|
||||
|
||||
func (s *inMemoryServer) startObject(obj Object) {
|
||||
// create a simple CRUD server
|
||||
s.mux.HandleFunc("/"+obj.Name, func(w http.ResponseWriter, req *http.Request) {
|
||||
fmt.Println("mock server received request", req.URL.Path)
|
||||
s.metrics.RecordRequest(req.URL.Path)
|
||||
method := req.Method
|
||||
w.Header().Set(util.ContentType, util.JSON)
|
||||
|
||||
|
@ -210,6 +227,7 @@ func (s *inMemoryServer) startObject(obj Object) {
|
|||
|
||||
// handle a single object
|
||||
s.mux.HandleFunc(fmt.Sprintf("/%s/{name}", obj.Name), func(w http.ResponseWriter, req *http.Request) {
|
||||
s.metrics.RecordRequest(req.URL.Path)
|
||||
w.Header().Set(util.ContentType, util.JSON)
|
||||
objects := s.data[obj.Name]
|
||||
if objects != nil {
|
||||
|
@ -278,15 +296,17 @@ func (s *inMemoryServer) startItem(item Item) {
|
|||
headerSlices = append(headerSlices, k, v)
|
||||
}
|
||||
|
||||
adHandler := &advanceHandler{item: &item}
|
||||
adHandler := &advanceHandler{item: &item, metrics: s.metrics}
|
||||
s.mux.HandleFunc(item.Request.Path, adHandler.handle).Methods(strings.Split(method, ",")...).Headers(headerSlices...)
|
||||
}
|
||||
|
||||
type advanceHandler struct {
|
||||
item *Item
|
||||
item *Item
|
||||
metrics RequestMetrics
|
||||
}
|
||||
|
||||
func (h *advanceHandler) handle(w http.ResponseWriter, req *http.Request) {
|
||||
h.metrics.RecordRequest(req.URL.Path)
|
||||
memLogger.Info("receiving mock request", "name", h.item.Name, "method", req.Method, "path", req.URL.Path,
|
||||
"encoder", h.item.Response.Encoder)
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2024 API Testing Authors.
|
||||
Copyright 2024-2025 API Testing Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,6 +17,7 @@ package mock
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
@ -27,7 +28,8 @@ import (
|
|||
)
|
||||
|
||||
func TestInMemoryServer(t *testing.T) {
|
||||
server := NewInMemoryServer(0)
|
||||
server := NewInMemoryServer(context.Background(), 0)
|
||||
server.EnableMetrics()
|
||||
|
||||
err := server.Start(NewLocalFileReader("testdata/api.yaml"), "/mock")
|
||||
assert.NoError(t, err)
|
||||
|
@ -165,13 +167,13 @@ func TestInMemoryServer(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("not found config file", func(t *testing.T) {
|
||||
server := NewInMemoryServer(0)
|
||||
server := NewInMemoryServer(context.Background(), 0)
|
||||
err := server.Start(NewLocalFileReader("fake"), "/")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid webhook", func(t *testing.T) {
|
||||
server := NewInMemoryServer(0)
|
||||
server := NewInMemoryServer(context.Background(), 0)
|
||||
err := server.Start(NewInMemoryReader(`webhooks:
|
||||
- timer: aa
|
||||
name: fake`), "/")
|
||||
|
@ -179,14 +181,14 @@ func TestInMemoryServer(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("missing name or timer in webhook", func(t *testing.T) {
|
||||
server := NewInMemoryServer(0)
|
||||
server := NewInMemoryServer(context.Background(), 0)
|
||||
err := server.Start(NewInMemoryReader(`webhooks:
|
||||
- timer: 1s`), "/")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid webhook payload", func(t *testing.T) {
|
||||
server := NewInMemoryServer(0)
|
||||
server := NewInMemoryServer(context.Background(), 0)
|
||||
err := server.Start(NewInMemoryReader(`webhooks:
|
||||
- name: invalid
|
||||
timer: 1ms
|
||||
|
@ -196,7 +198,7 @@ func TestInMemoryServer(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("invalid webhook api template", func(t *testing.T) {
|
||||
server := NewInMemoryServer(0)
|
||||
server := NewInMemoryServer(context.Background(), 0)
|
||||
err := server.Start(NewInMemoryReader(`webhooks:
|
||||
- name: invalid
|
||||
timer: 1ms
|
||||
|
@ -205,4 +207,20 @@ func TestInMemoryServer(t *testing.T) {
|
|||
path: "{{.fake"`), "/")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("proxy", func(t *testing.T) {
|
||||
resp, err = http.Get(api + "/v1/myProjects")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = http.Get(api + "/v1/invalid-template")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("metrics", func(t *testing.T) {
|
||||
resp, err = http.Get(api + "/metrics")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
Copyright 2025 API Testing Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
package mock
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
var _ RequestMetrics = &NoopMetrics{}
|
||||
|
||||
// NoopMetrics implements RequestMetrics but does nothing
|
||||
type NoopMetrics struct{}
|
||||
|
||||
// NewNoopMetrics creates a new NoopMetrics instance
|
||||
func NewNoopMetrics() *NoopMetrics {
|
||||
return &NoopMetrics{}
|
||||
}
|
||||
|
||||
// RecordRequest implements RequestMetrics but does nothing
|
||||
func (m *NoopMetrics) RecordRequest(path string) {}
|
||||
|
||||
// GetMetrics implements RequestMetrics but returns empty map
|
||||
func (m *NoopMetrics) GetMetrics() MetricData {
|
||||
return MetricData{}
|
||||
}
|
||||
|
||||
// AddMetricsHandler implements RequestMetrics but does nothing
|
||||
func (m *NoopMetrics) AddMetricsHandler(mux MetricsHandler) {}
|
||||
|
||||
type MetricsHandler interface {
|
||||
HandleFunc(path string, f func(http.ResponseWriter, *http.Request)) *mux.Route
|
||||
}
|
||||
|
||||
type MetricData struct {
|
||||
FirstRequestTime time.Time
|
||||
LastRequestTime time.Time
|
||||
Requests map[string]int
|
||||
}
|
||||
|
||||
// RequestMetrics represents an interface for collecting request metrics
|
||||
type RequestMetrics interface {
|
||||
RecordRequest(path string)
|
||||
GetMetrics() MetricData
|
||||
AddMetricsHandler(MetricsHandler)
|
||||
}
|
||||
|
||||
var _ RequestMetrics = &InMemoryMetrics{}
|
||||
|
||||
// InMemoryMetrics implements RequestMetrics with in-memory storage
|
||||
type InMemoryMetrics struct {
|
||||
MetricData
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewInMemoryMetrics creates a new InMemoryMetrics instance
|
||||
func NewInMemoryMetrics() *InMemoryMetrics {
|
||||
return &InMemoryMetrics{
|
||||
MetricData: MetricData{
|
||||
Requests: make(map[string]int),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RecordRequest records a request for the given path
|
||||
func (m *InMemoryMetrics) RecordRequest(path string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Requests[path]++
|
||||
if m.FirstRequestTime.IsZero() {
|
||||
m.FirstRequestTime = time.Now()
|
||||
}
|
||||
m.LastRequestTime = time.Now()
|
||||
}
|
||||
|
||||
// GetMetrics returns a copy of the current metrics
|
||||
func (m *InMemoryMetrics) GetMetrics() MetricData {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.MetricData
|
||||
}
|
||||
|
||||
func (m *InMemoryMetrics) AddMetricsHandler(mux MetricsHandler) {
|
||||
// Add metrics endpoint
|
||||
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||
metrics := m.GetMetrics()
|
||||
_ = json.NewEncoder(w).Encode(metrics)
|
||||
})
|
||||
}
|
|
@ -15,7 +15,9 @@ limitations under the License.
|
|||
*/
|
||||
package mock
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Loadable interface {
|
||||
Load() error
|
||||
|
@ -26,6 +28,7 @@ type DynamicServer interface {
|
|||
SetupHandler(reader Reader, prefix string) (http.Handler, error)
|
||||
Stop() error
|
||||
GetPort() string
|
||||
EnableMetrics()
|
||||
Loadable
|
||||
}
|
||||
|
||||
|
|
|
@ -49,6 +49,11 @@ items:
|
|||
"status": "success"
|
||||
}]
|
||||
}
|
||||
proxies:
|
||||
- path: /v1/myProjects
|
||||
target: http://localhost:{{.GetPort}}
|
||||
- path: /v1/invalid-template
|
||||
target: http://localhost:{{.GetPort}
|
||||
webhooks:
|
||||
- timer: 1ms
|
||||
name: baidu
|
||||
|
|
|
@ -1274,7 +1274,7 @@ func (s *mockServerController) Reload(ctx context.Context, in *MockConfig) (repl
|
|||
}
|
||||
}
|
||||
|
||||
server := mock.NewInMemoryServer(int(in.GetPort()))
|
||||
server := mock.NewInMemoryServer(ctx, int(in.GetPort()))
|
||||
server.Start(s.mockWriter, in.Prefix)
|
||||
s.loader = server
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue