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
2 changes: 0 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@ linters:
- asasalint
- asciicheck
- bodyclose
- contextcheck
- copyloopvar
- dupl
- durationcheck
- err113
- errcheck
- errorlint
- exhaustive
Expand Down
25 changes: 16 additions & 9 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import (
"net/http"
"os"
"text/template"
"time"
)

const JsonContentType = "application/json"

type Config struct {
RequestIDHeader string `json:"request_id_header,omitempty" yaml:"request_id_header,omitempty"`
Routes []Route `json:"routes" yaml:"routes"`
Expand All @@ -21,15 +24,16 @@ type Route struct {
}

type Response struct {
Headers http.Header `json:"headers,omitempty" yaml:"headers,omitempty"`
Repeat *int `json:"repeat,omitempty" yaml:"repeat,omitempty"`
Body string `json:"body,omitempty" yaml:"body,omitempty"`
File string `json:"file,omitempty" yaml:"file,omitempty"`
Code int `json:"code,omitempty" yaml:"code,omitempty"`
IsJSON bool `json:"is_json,omitempty" yaml:"is_json,omitempty"`
Headers http.Header `json:"headers,omitempty" yaml:"headers,omitempty"`
Repeat *int `json:"repeat,omitempty" yaml:"repeat,omitempty"`
Body string `json:"body,omitempty" yaml:"body,omitempty"`
File string `json:"file,omitempty" yaml:"file,omitempty"`
Code int `json:"code,omitempty" yaml:"code,omitempty"`
IsJSON bool `json:"is_json,omitempty" yaml:"is_json,omitempty"`
Delay time.Duration `json:"delay,omitempty" yaml:"delay,omitempty"`
}

func responsesWriter(responses []Response, log *slog.Logger) http.HandlerFunc {
func responsesWriter(responses []Response) http.HandlerFunc {
var i int
return func(writer http.ResponseWriter, request *http.Request) {
for {
Expand Down Expand Up @@ -69,14 +73,17 @@ func responsesWriter(responses []Response, log *slog.Logger) http.HandlerFunc {
}
if response.IsJSON {
if writer.Header().Get("Content-Type") == "" {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Content-Type", JsonContentType)
}
}
if response.Delay > 0 {
time.Sleep(response.Delay)
}
writer.WriteHeader(response.Code)

if len(data) > 0 {
if _, err := writer.Write(data); err != nil {
log.ErrorContext(request.Context(), "sending response failed", slog.String("error", err.Error()))
slog.Error("Sending response failed", slog.String("error", err.Error()))
}
}
return
Expand Down
5 changes: 5 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@
"repeat": {
"description": "the number of repeats. Infinity if no set. Zero to skip. Or an exact number of repeats.",
"type": "integer"
},
"delay": {
"description": "The delay before sending the response",
"default": "0ms",
"type": "string"
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions example_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ routes:
repeat: 1
- code: 404
body: user "{{.PathValue "id"}}" not found
- pattern: GET /delay/1min
responses:
- code: 200
delay: 1m
121 changes: 121 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"context"
"fmt"
"net"
"net/http"
"strconv"
"testing"
"time"
)

// TestRun_ServerIntegration starts the real HTTP server, exercises endpoints, then shuts it down.
func TestRun_ServerIntegration(t *testing.T) {
t.Parallel()

// Pick a free port
lc := net.ListenConfig{}
ln, err := lc.Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("listen :0: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
t.Fatalf("close listener: %v", err)
}

configContent := `routes:
- pattern: /hello
responses:
- code: 200
body: Hello
- pattern: /json
responses:
- code: 201
body: '{"ok":true}'
is_json: true
`
cfgPath := writeConfig(t, configContent)
ctx, cancel := context.WithCancel(t.Context())

done := make(chan struct{})
go func() {
defer close(done)
err := run(ctx, []string{"-c", cfgPath, "-p", strconv.Itoa(port)})
if err != nil {
t.Errorf("run: %v", err)
}
}()

client := &http.Client{Timeout: 2 * time.Second}
base := fmt.Sprintf("http://localhost:%d", port)

deadline := time.Now().Add(5 * time.Second)
for {
if time.Now().After(deadline) {
t.Fatalf("server did not start in time on %s", base)
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, base+"/hello", http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
time.Sleep(50 * time.Millisecond)
continue
}
_ = resp.Body.Close()
break
}

req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, base+"/hello", http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET /hello: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("/hello expected 200 got %d", resp.StatusCode)
}
_ = resp.Body.Close()

req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, base+"/json", http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err = client.Do(req)
if err != nil {
t.Fatalf("GET /json: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("/json expected 201 got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != JsonContentType {
t.Fatalf("expected application/json got %q", ct)
}
_ = resp.Body.Close()

req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, base+"/", http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err = client.Do(req)
if err != nil {
t.Fatalf("GET /: %v", err)
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("/ expected 404 got %d", resp.StatusCode)
}
_ = resp.Body.Close()

cancel()

select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatalf("server did not exit in time")
}
}
13 changes: 11 additions & 2 deletions log.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
)

type wrapper struct {
Expand All @@ -29,7 +30,7 @@ func (w *wrapper) Write(b []byte) (int, error) {

var _ http.ResponseWriter = &wrapper{}

func StructuredLogger(log *slog.Logger, reqIDHeader string, next http.HandlerFunc) http.HandlerFunc {
func StructuredLogger(reqIDHeader string, next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
wr := &wrapper{writer: writer}
next.ServeHTTP(wr, request)
Expand All @@ -39,7 +40,7 @@ func StructuredLogger(log *slog.Logger, reqIDHeader string, next http.HandlerFun
scheme = "https"
}

log.LogAttrs(request.Context(), slog.LevelInfo, "request completed",
slog.LogAttrs(request.Context(), slog.LevelInfo, "request completed",
slog.String("http_scheme", scheme),
slog.String("http_proto", request.Proto),
slog.String("http_method", request.Method),
Expand All @@ -52,3 +53,11 @@ func StructuredLogger(log *slog.Logger, reqIDHeader string, next http.HandlerFun
)
}
}

func InitLogger() {
slog.SetLogLoggerLevel(slog.LevelDebug)
logHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
slog.SetDefault(slog.New(logHandler))
}
Loading
Loading