Merge PR #2: Phase 1 Foundation

Reviewed and merged Phase 1 Foundation PR. All tests pass, CI changes look good, linting config is comprehensive, and shared test utilities improve code quality. The merge preserves the existing CLI on main.
This commit is contained in:
Naranmandakh Enkhbat 2026-06-24 11:39:33 +08:00 committed by GitHub
commit 65b2435275
8 changed files with 261 additions and 121 deletions

View File

@ -17,6 +17,14 @@ jobs:
go-version: '1.25'
cache: true
- name: Check formatting
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "Files not formatted:"
gofmt -l .
exit 1
fi
- name: Vet
run: go vet ./...
@ -41,6 +49,21 @@ jobs:
curl -fsS http://localhost:8080/healthz | grep -q 'ok'
docker stop hatch-ci
lint-go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
lint-docs:
runs-on: ubuntu-latest
steps:

5
.gitignore vendored
View File

@ -12,6 +12,11 @@ go.work
/bin
/dist
# Runtime data
data/
*.db
coverage.out
# IDE
.idea/
.vscode/

57
.golangci.yml Normal file
View File

@ -0,0 +1,57 @@
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofmt
- misspell
- unconvert
- unparam
- prealloc
- gocritic
- revive
linters-settings:
errcheck:
check-type-assertions: true
check-blank: true
govet:
check-shadowing: true
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: range
- name: receiver-naming
- name: indent-error-flow
- name: superfluous-else
- name: unreachable-code
- name: redefines-builtin-id
issues:
exclude-rules:
# Exclude some linters from running on test files
- path: _test\.go
linters:
- errcheck
- gocritic
- unparam
# Exclude known issues in test files
- path: _test\.go
text: "func name"
run:
timeout: 5m
modules-download-mode: readonly

34
Makefile Normal file
View File

@ -0,0 +1,34 @@
.PHONY: all build test lint fmt vet clean help
all: lint test build
## build: Build the hatch binary
build:
CGO_ENABLED=0 go build -o hatch ./cmd/hatch
## test: Run all tests with race detection and coverage
test:
go test ./... -v -race -coverprofile=coverage.out
## lint: Run golangci-lint
lint:
golangci-lint run ./...
## fmt: Format code with gofmt
fmt:
gofmt -s -w .
## vet: Run go vet
vet:
go vet ./...
## clean: Remove build artifacts
clean:
rm -f hatch coverage.out
## help: Show this help message
help:
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'

View File

@ -8,10 +8,11 @@ import (
"testing"
"github.com/elfoundation/hatch/internal/store"
"github.com/elfoundation/hatch/internal/testutil"
)
func TestV1CaptureRequest(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
r := testRouter(repo)
body := `{"method":"POST","path":"/test","headers":{"X-Custom":"value"},"query":"foo=bar","body":"hello"}`
@ -35,16 +36,16 @@ func TestV1CaptureRequest(t *testing.T) {
t.Errorf("expected path /test, got %v", resp["path"])
}
// Verify request stored in repo.
if len(repo.requests) != 1 {
t.Fatalf("expected 1 request stored, got %d", len(repo.requests))
if len(repo.Requests) != 1 {
t.Fatalf("expected 1 request stored, got %d", len(repo.Requests))
}
if repo.requests[0].Headers != `{"X-Custom":"value"}` {
t.Errorf("expected headers JSON, got %q", repo.requests[0].Headers)
if repo.Requests[0].Headers != `{"X-Custom":"value"}` {
t.Errorf("expected headers JSON, got %q", repo.Requests[0].Headers)
}
}
func TestV1ListRequests(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "list-ep")
// Add a few requests.
repo.AppendRequest(nil, "list-ep", &store.Request{Method: "GET", Path: "/a"})
@ -77,7 +78,7 @@ func TestV1ListRequests(t *testing.T) {
}
func TestV1SearchRequests(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "search-ep")
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "GET", Path: "/users", Headers: `{"Accept":"json"}`})
repo.AppendRequest(nil, "search-ep", &store.Request{Method: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)})
@ -111,7 +112,7 @@ func TestV1SearchRequests(t *testing.T) {
}
func TestV1MockSet(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "mock-ep")
r := testRouter(repo)
@ -138,7 +139,7 @@ func TestV1MockSet(t *testing.T) {
}
func TestV1OpenAPI(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
r := testRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/test/openapi.json", nil)
w := httptest.NewRecorder()

View File

@ -11,89 +11,10 @@ import (
"time"
"github.com/elfoundation/hatch/internal/store"
"github.com/elfoundation/hatch/internal/testutil"
"github.com/go-chi/chi/v5"
)
// fakeRepo implements store.Repository for tests.
type fakeRepo struct {
endpoints map[string]*store.Endpoint
requests []*store.Request
mocks map[string]*store.MockConfig
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
endpoints: map[string]*store.Endpoint{},
mocks: map[string]*store.MockConfig{},
}
}
func (f *fakeRepo) CreateEndpoint(_ context.Context, u string) (*store.Endpoint, error) {
e := &store.Endpoint{ID: u, URL: u, CreatedAt: "t", UpdatedAt: "t"}
f.endpoints[u] = e
return e, nil
}
func (f *fakeRepo) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) {
e, ok := f.endpoints[id]
if !ok {
return nil, errNotFound
}
return e, nil
}
func (f *fakeRepo) AppendRequest(_ context.Context, eid string, r *store.Request) error {
r.ID = "req-" + string(rune(len(f.requests)+'0'))
r.EndpointID = eid
f.requests = append(f.requests, r)
return nil
}
func (f *fakeRepo) GetRequest(_ context.Context, id string) (*store.Request, error) {
for _, r := range f.requests {
if r.ID == id {
return r, nil
}
}
return nil, errNotFound
}
func (f *fakeRepo) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) {
return f.requests, nil
}
func (f *fakeRepo) SearchRequests(_ context.Context, _ string, query string, limit int) ([]*store.Request, error) {
if query == "" {
return f.requests, nil
}
var matched []*store.Request
for _, r := range f.requests {
if strings.Contains(r.Method, query) ||
strings.Contains(r.Path, query) ||
strings.Contains(r.Headers, query) ||
strings.Contains(r.Query, query) ||
strings.Contains(string(r.Body), query) {
matched = append(matched, r)
if limit > 0 && len(matched) >= limit {
break
}
}
}
return matched, nil
}
func (f *fakeRepo) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) {
m, ok := f.mocks[endpointID]
if !ok {
return nil, errNotFound
}
return m, nil
}
func (f *fakeRepo) SetMock(_ context.Context, mock *store.MockConfig) error {
f.mocks[mock.EndpointID] = mock
return nil
}
func (f *fakeRepo) Close() error { return nil }
var errNotFound = &se{"nf"}
type se struct{ m string }
func (e *se) Error() string { return e.m }
// testRouter creates a chi router with all routes registered using a fake repo.
func testRouter(repo store.Repository) chi.Router {
@ -104,7 +25,7 @@ func testRouter(repo store.Repository) chi.Router {
}
func TestHealthz(t *testing.T) {
r := testRouter(newFakeRepo())
r := testRouter(testutil.NewFakeRepository())
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@ -121,7 +42,7 @@ func TestHealthz(t *testing.T) {
func TestCaptureRecordsAllVerbs(t *testing.T) {
methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
for _, m := range methods {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
r := testRouter(repo)
body := ""
@ -138,11 +59,11 @@ func TestCaptureRecordsAllVerbs(t *testing.T) {
if w.Code != 200 {
t.Errorf("%s: expected 200, got %d", m, w.Code)
}
if len(repo.requests) != 1 {
t.Errorf("%s: expected 1 request, got %d", m, len(repo.requests))
if len(repo.Requests) != 1 {
t.Errorf("%s: expected 1 request, got %d", m, len(repo.Requests))
continue
}
reqCaptured := repo.requests[0]
reqCaptured := repo.Requests[0]
if reqCaptured.Method != m {
t.Errorf("%s: wrong method %s", m, reqCaptured.Method)
}
@ -150,7 +71,7 @@ func TestCaptureRecordsAllVerbs(t *testing.T) {
}
func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
srv := httptest.NewServer(testRouter(repo))
defer srv.Close()
@ -224,7 +145,7 @@ func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
}
func TestInspectReturnsHTML(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{
Method: "POST",
@ -290,7 +211,7 @@ func TestInspectReturnsHTML(t *testing.T) {
}
func TestInspectEmptyState(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "new-ep")
r := testRouter(repo)
@ -320,7 +241,7 @@ func TestInspectEmptyState(t *testing.T) {
}
func TestInspectAutoCreatesEndpoint(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
// The endpoint doesn't exist yet.
r := testRouter(repo)
@ -350,7 +271,7 @@ func TestInspectAutoCreatesEndpoint(t *testing.T) {
}
func TestCaptureReturnsJSON200(t *testing.T) {
r := testRouter(newFakeRepo())
r := testRouter(testutil.NewFakeRepository())
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil))
@ -369,7 +290,7 @@ func TestCaptureReturnsJSON200(t *testing.T) {
}
func TestMockSetsAndConfiguresResponse(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "mock-ep")
r := testRouter(repo)
@ -399,7 +320,7 @@ func TestMockSetsAndConfiguresResponse(t *testing.T) {
}
func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "mock-cap")
// Pre-set a mock.
repo.SetMock(nil, &store.MockConfig{
@ -427,16 +348,16 @@ func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) {
}
// Request should still be captured even when mock responds.
if len(repo.requests) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
if len(repo.Requests) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(repo.Requests))
}
if repo.requests[0].Method != "POST" {
t.Errorf("captured method: %s", repo.requests[0].Method)
if repo.Requests[0].Method != "POST" {
t.Errorf("captured method: %s", repo.Requests[0].Method)
}
}
func TestMockAutoCreatesEndpointOnSet(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
r := testRouter(repo)
// Set a mock on an endpoint that doesn't exist yet.

View File

@ -8,13 +8,14 @@ import (
"testing"
"github.com/elfoundation/hatch/internal/store"
"github.com/elfoundation/hatch/internal/testutil"
)
func TestReplayMissingTargetURL(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"})
reqID := repo.requests[0].ID
reqID := repo.Requests[0].ID
r := testRouter(repo)
body := `{}`
@ -34,10 +35,10 @@ func TestReplayMissingTargetURL(t *testing.T) {
}
func TestReplayInvalidScheme(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.requests[0].ID
reqID := repo.Requests[0].ID
r := testRouter(repo)
body := `{"target_url": "ftp://bad.scheme/"}`
@ -52,10 +53,10 @@ func TestReplayInvalidScheme(t *testing.T) {
}
func TestReplaySSRFBlocksLocalhost(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.requests[0].ID
reqID := repo.Requests[0].ID
r := testRouter(repo)
body := `{"target_url": "http://localhost:8080/"}`
@ -70,10 +71,10 @@ func TestReplaySSRFBlocksLocalhost(t *testing.T) {
}
func TestReplaySSRFBlocksPrivate(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
reqID := repo.requests[0].ID
reqID := repo.Requests[0].ID
r := testRouter(repo)
for _, addr := range []string{
@ -96,7 +97,7 @@ func TestReplaySSRFBlocksPrivate(t *testing.T) {
}
func TestReplayNotFound(t *testing.T) {
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
r := testRouter(repo)
body := `{"target_url": "https://example.com/"}`
req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/nonexistent/replay", strings.NewReader(body))
@ -118,7 +119,7 @@ func TestReplaySuccess(t *testing.T) {
}))
defer sink.Close()
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{
Method: "POST",
@ -127,7 +128,7 @@ func TestReplaySuccess(t *testing.T) {
Query: "foo=bar",
Body: []byte(`{"msg":"hello"}`),
})
reqID := repo.requests[0].ID
reqID := repo.Requests[0].ID
// Set env to allow private replay since httptest server is on loopback.
t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
@ -175,7 +176,7 @@ func TestReplayE2ECaptureThenReplay(t *testing.T) {
defer sink.Close()
// Step 2: Capture a request.
repo := newFakeRepo()
repo := testutil.NewFakeRepository()
rt := testRouter(repo)
captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`))
@ -183,10 +184,10 @@ func TestReplayE2ECaptureThenReplay(t *testing.T) {
captureW := httptest.NewRecorder()
rt.ServeHTTP(captureW, captureReq)
if len(repo.requests) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
if len(repo.Requests) != 1 {
t.Fatalf("expected 1 captured request, got %d", len(repo.Requests))
}
captured := repo.requests[0]
captured := repo.Requests[0]
if captured.Method != "POST" {
t.Errorf("captured method: %s", captured.Method)
}

View File

@ -0,0 +1,98 @@
package testutil
import (
"context"
"strings"
"github.com/elfoundation/hatch/internal/store"
)
// FakeRepository implements store.Repository for tests.
type FakeRepository struct {
Endpoints map[string]*store.Endpoint
Requests []*store.Request
Mocks map[string]*store.MockConfig
}
// NewFakeRepository creates a new FakeRepository.
func NewFakeRepository() *FakeRepository {
return &FakeRepository{
Endpoints: map[string]*store.Endpoint{},
Mocks: map[string]*store.MockConfig{},
}
}
func (f *FakeRepository) CreateEndpoint(_ context.Context, url string) (*store.Endpoint, error) {
e := &store.Endpoint{ID: url, URL: url, CreatedAt: "t", UpdatedAt: "t"}
f.Endpoints[url] = e
return e, nil
}
func (f *FakeRepository) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) {
e, ok := f.Endpoints[id]
if !ok {
return nil, errNotFound
}
return e, nil
}
func (f *FakeRepository) AppendRequest(_ context.Context, eid string, r *store.Request) error {
r.ID = "req-" + string(rune(len(f.Requests)+'0'))
r.EndpointID = eid
f.Requests = append(f.Requests, r)
return nil
}
func (f *FakeRepository) GetRequest(_ context.Context, id string) (*store.Request, error) {
for _, r := range f.Requests {
if r.ID == id {
return r, nil
}
}
return nil, errNotFound
}
func (f *FakeRepository) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) {
return f.Requests, nil
}
func (f *FakeRepository) SearchRequests(_ context.Context, _ string, query string, limit int) ([]*store.Request, error) {
if query == "" {
return f.Requests, nil
}
var matched []*store.Request
for _, r := range f.Requests {
if strings.Contains(r.Method, query) ||
strings.Contains(r.Path, query) ||
strings.Contains(r.Headers, query) ||
strings.Contains(r.Query, query) ||
strings.Contains(string(r.Body), query) {
matched = append(matched, r)
if limit > 0 && len(matched) >= limit {
break
}
}
}
return matched, nil
}
func (f *FakeRepository) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) {
m, ok := f.Mocks[endpointID]
if !ok {
return nil, errNotFound
}
return m, nil
}
func (f *FakeRepository) SetMock(_ context.Context, mock *store.MockConfig) error {
f.Mocks[mock.EndpointID] = mock
return nil
}
func (f *FakeRepository) Close() error { return nil }
type notFoundError struct{}
func (e *notFoundError) Error() string { return "not found" }
var errNotFound = &notFoundError{}