package handlers
import (
"io/fs"
"os"
)
type osFileReader struct {
fs.FS
}
func (fr osFileReader) ReadFile(name string) ([]byte, error) {
//nolint
return os.ReadFile(name)
}
package handlers
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
type Pretender struct {
sync.Mutex
index int
responses []response
fs fs.FS
logger *slog.Logger
healthCheckPath string
}
type response struct {
StatusCode uint `json:"status_code"`
Body json.RawMessage `json:"body"`
Headers map[string]string `json:"headers"`
DelayMs uint `json:"delay_ms"`
Repeat int `json:"repeat"`
count int
}
var healthResponse = &response{
StatusCode: 200,
Body: []byte("ok"),
Headers: map[string]string{},
DelayMs: 0,
Repeat: 1,
count: 1,
}
var errNoResponsesLeft = errors.New("no responses left")
func NewPretender(logger *slog.Logger, healthCheckPath ...string) *Pretender {
if len(healthCheckPath) == 0 || healthCheckPath[0] == "" {
healthCheckPath = []string{"/healthz"}
}
return &Pretender{
logger: logger,
fs: osFileReader{},
healthCheckPath: healthCheckPath[0],
}
}
func (hh *Pretender) HandleFunc(w http.ResponseWriter, rq *http.Request) {
hh.Lock()
defer hh.Unlock()
r, err := hh.getNextResponse(rq.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
hh.logger.Error("responding", "error", err)
return
}
delay := time.Duration(r.DelayMs) * time.Millisecond
if r.DelayMs > 0 {
time.Sleep(delay)
}
for k, v := range r.Headers {
w.Header().Set(k, v)
}
w.WriteHeader(int(r.StatusCode))
fmt.Fprintf(w, "%s\n", r.Body)
hh.logger.Info("responding",
"status_code", r.StatusCode,
"body", string(r.Body),
"headers", r.Headers,
"delay", delay,
"repeat", strings.Replace(fmt.Sprintf("%d/%d", r.count, r.Repeat), "-1", "∞", -1),
)
}
func (hh *Pretender) getNextResponse(path string) (*response, error) {
if path == hh.healthCheckPath {
return healthResponse, nil
}
if hh.index >= len(hh.responses) {
return &response{}, errNoResponsesLeft
}
response := &hh.responses[hh.index]
response.count++
if response.Repeat == response.count {
hh.index++
}
return response, nil
}
func (hh *Pretender) LoadResponsesFile(name string) (int, error) {
content, err := fs.ReadFile(hh.fs, name)
if err != nil {
return 0, fmt.Errorf("reading responses file [%s]: %w", name, err)
}
//nolint:nestif
if strings.HasSuffix(name, ".json") {
hh.responses = []response{}
err = json.Unmarshal(content, &hh.responses)
if err != nil {
return 0, fmt.Errorf("parsing responses: %w", err)
}
for i := range hh.responses {
if hh.responses[i].StatusCode == 0 {
hh.responses[i].StatusCode = 200
}
if hh.responses[i].Repeat == 0 {
hh.responses[i].Repeat = 1
}
// if the body is a string, remove the quotes
if len(hh.responses[i].Body) > 0 && string(hh.responses[i].Body[0]) == `"` {
hh.responses[i].Body = hh.responses[i].Body[1 : len(hh.responses[i].Body)-1]
}
}
} else {
lines := strings.Split(string(content), "\n")
hh.responses = make([]response, len(lines))
for i, line := range lines {
hh.responses[i] = response{StatusCode: 200, Body: []byte(line), Repeat: 1}
}
}
return len(hh.responses), nil
}
package pretender
import (
"fmt"
"log/slog"
"net/http"
"github.com/kilianc/pretender/internal/handlers"
)
// ErrorLoadingResponsesFile is the error returned when the responses file can't be loaded.
var ErrorLoadingResponsesFile = fmt.Errorf("loading responses file")
// NewHTTPHandler creates a new [http] handler function configured to serve the responses
// defined in the responseFileName. It also returns the number of responses loaded from the file.
// If the file can't be loaded, it returns an error.
// The healthCheckPath is an optional parameter to define a custom path for the health check endpoint.
// If not provided, the default path is "/healthz".
// The logger is used to log the requests and responses.
func NewHTTPHandler(
responseFileName string,
logger *slog.Logger,
healthCheckPath ...string,
) (func(http.ResponseWriter, *http.Request), int, error) {
hh := handlers.NewPretender(logger, healthCheckPath...)
rn, err := hh.LoadResponsesFile(responseFileName)
if err != nil {
return nil, 0, fmt.Errorf("%w: %w", ErrorLoadingResponsesFile, err)
}
return hh.HandleFunc, rn, nil
}
// NewServeMux creates a new [http.NewServeMux] configured to serve the responses defined in the
// responseFileName. It also returns the number of responses loaded from the file.
// If the file can't be loaded, it returns an error.
// The healthCheckPath is an optional parameter to define a custom path for the health check endpoint.
// If not provided, the default path is "/healthz".
// The logger is used to log the requests and responses.
func NewServeMux(
responseFileName string,
logger *slog.Logger,
healthCheckPath ...string,
) (*http.ServeMux, int, error) {
handler, rn, err := NewHTTPHandler(responseFileName, logger, healthCheckPath...)
if err != nil {
return nil, 0, err
}
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
return mux, rn, nil
}
// NewServer creates a new [http.Server] with the given port configured to serve the responses
// defined in the responseFileName. It also returns the number of responses loaded from the file.
// If the file can't be loaded, it returns an error.
// The healthCheckPath is an optional parameter to define a custom path for the health check endpoint.
// If not provided, the default path is "/healthz".
// The logger is used to log the requests and responses.
func NewServer(
host string,
port int,
responseFileName string,
logger *slog.Logger,
healthCheckPath ...string,
) (*http.Server, int, error) {
mux, rn, err := NewServeMux(responseFileName, logger, healthCheckPath...)
if err != nil {
return nil, 0, err
}
return &http.Server{Addr: fmt.Sprintf("%s:%d", host, port), Handler: mux}, rn, nil
}