diff --git a/cmd/convert_test.go b/cmd/convert_test.go index bae7c49..0f047bf 100644 --- a/cmd/convert_test.go +++ b/cmd/convert_test.go @@ -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}) diff --git a/cmd/extension_test.go b/cmd/extension_test.go index 6207481..6ffa7b9 100644 --- a/cmd/extension_test.go +++ b/cmd/extension_test.go @@ -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) diff --git a/cmd/mock.go b/cmd/mock.go index aca2b8c..e25798c 100644 --- a/cmd/mock.go +++ b/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(): diff --git a/cmd/server.go b/cmd/server.go index 0f45255..f7afddb 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -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) diff --git a/cmd/server_test.go b/cmd/server_test.go index 7c3484e..b64ba73 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -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"`) }) } diff --git a/console/atest-desktop/api-testing.icns b/console/atest-desktop/api-testing.icns index fe7ffe6..0be1944 100644 Binary files a/console/atest-desktop/api-testing.icns and b/console/atest-desktop/api-testing.icns differ diff --git a/pkg/downloader/oci_test.go b/pkg/downloader/oci_test.go index b1e055e..3749a2e 100644 --- a/pkg/downloader/oci_test.go +++ b/pkg/downloader/oci_test.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. @@ -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() { diff --git a/pkg/mock/in_memory.go b/pkg/mock/in_memory.go index 7f24c23..c5ef070 100644 --- a/pkg/mock/in_memory.go +++ b/pkg/mock/in_memory.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. @@ -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) diff --git a/pkg/mock/in_memory_test.go b/pkg/mock/in_memory_test.go index 2ae2ca7..caafcc2 100644 --- a/pkg/mock/in_memory_test.go +++ b/pkg/mock/in_memory_test.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. @@ -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) + }) } diff --git a/pkg/mock/metrics.go b/pkg/mock/metrics.go new file mode 100644 index 0000000..73a0732 --- /dev/null +++ b/pkg/mock/metrics.go @@ -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) + }) +} diff --git a/pkg/mock/server.go b/pkg/mock/server.go index 0a21025..7f098af 100644 --- a/pkg/mock/server.go +++ b/pkg/mock/server.go @@ -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 } diff --git a/pkg/mock/testdata/api.yaml b/pkg/mock/testdata/api.yaml index 3241a69..b5c5b83 100644 --- a/pkg/mock/testdata/api.yaml +++ b/pkg/mock/testdata/api.yaml @@ -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 diff --git a/pkg/server/remote_server.go b/pkg/server/remote_server.go index 6817283..5fd2ad0 100644 --- a/pkg/server/remote_server.go +++ b/pkg/server/remote_server.go @@ -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 }