Skip to content

Commit 0a1f748

Browse files
authored
Merge pull request #3 from sv-tools/base
Add the first implementation
2 parents a30b08c + eff6d39 commit 0a1f748

File tree

8 files changed

+314
-1
lines changed

8 files changed

+314
-1
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
FROM scratch
22
ENV CONFIG=config.yaml
3+
ENV PORT=8080
34
ENTRYPOINT ["/mock-http-server"]
45
COPY mock-http-server /

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/sv-tools/mock-http-server?style=flat)](https://github.com/sv-tools/mock-http-server/releases)
77

88
A simple HTTP Server to be used for the unit or end-to-end to integrations tests.
9-
* yaml based configuration
9+
* yaml based configuration, see an `example_config.go`
1010
* docker image
1111

1212
## License

config.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"os"
7+
)
8+
9+
type Config struct {
10+
RequestIDHeader string `json:"request_id_header,omitempty" yaml:"request_id_header,omitempty"`
11+
Routes []Route `json:"routes" yaml:"routes"`
12+
Port int `json:"port,omitempty" yaml:"port,omitempty"`
13+
}
14+
15+
type Route struct {
16+
Method string `json:"method,omitempty" yaml:"method,omitempty"`
17+
Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"`
18+
Responses []Response `json:"responses" yaml:"responses"`
19+
}
20+
21+
type Response struct {
22+
Headers http.Header `json:"headers,omitempty" yaml:"headers,omitempty"`
23+
Repeat *int `json:"repeat,omitempty" yaml:"repeat,omitempty"`
24+
Body string `json:"body,omitempty" yaml:"body,omitempty"`
25+
File string `json:"file,omitempty" yaml:"file,omitempty"`
26+
Code int `json:"code,omitempty" yaml:"code,omitempty"`
27+
IsJSON bool `json:"is_json,omitempty" yaml:"is_json,omitempty"`
28+
}
29+
30+
func responsesWriter(responses []Response) http.HandlerFunc {
31+
var i int
32+
return func(writer http.ResponseWriter, request *http.Request) {
33+
for {
34+
if i > len(responses)-1 {
35+
http.NotFound(writer, request)
36+
return
37+
}
38+
response := responses[i]
39+
if response.Repeat != nil {
40+
if *response.Repeat <= 0 {
41+
i++
42+
continue
43+
}
44+
*response.Repeat--
45+
}
46+
47+
var data []byte
48+
if response.File != "" {
49+
var err error
50+
data, err = os.ReadFile(response.File)
51+
if err != nil {
52+
http.Error(writer, err.Error(), http.StatusInternalServerError)
53+
return
54+
}
55+
} else if len(response.Body) > 0 {
56+
data = []byte(response.Body)
57+
}
58+
59+
for name, header := range response.Headers {
60+
for _, value := range header {
61+
writer.Header().Add(name, value)
62+
}
63+
}
64+
if response.IsJSON {
65+
if writer.Header().Get("Content-Type") == "" {
66+
writer.Header().Set("Content-Type", "application/json")
67+
}
68+
}
69+
writer.WriteHeader(response.Code)
70+
71+
if len(data) > 0 {
72+
if _, err := writer.Write(data); err != nil {
73+
log.Printf("sending response failed: %+v", err)
74+
}
75+
}
76+
return
77+
}
78+
}
79+
}

config.schema.json

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://raw.githubusercontent.com/sv-tools/mock-http-server/main/config.schema.json",
4+
"title": "Mock HTTP Server Config Schema",
5+
"description": "Schema to validate the config of the Mock HTTP Server",
6+
"type": "object",
7+
"properties": {
8+
"port": {
9+
"description": "The http port for the server",
10+
"default": 8080,
11+
"type": "integer"
12+
},
13+
"request_id_header": {
14+
"description": "Name of an HTTP header for Request Id",
15+
"default": "X-Request-Id",
16+
"type": "string"
17+
},
18+
"routes": {
19+
"description": "The list of routes",
20+
"type": "array",
21+
"items": {
22+
"type": "object",
23+
"properties": {
24+
"method": {
25+
"description": "The HTTP method, any of the standard or custom.",
26+
"default": "GET",
27+
"type": "string"
28+
},
29+
"pattern": {
30+
"description": "An url pattern",
31+
"default": "/",
32+
"type": "string"
33+
},
34+
"responses": {
35+
"description": "List of the responses",
36+
"type": "array",
37+
"items": {
38+
"type": "object",
39+
"properties": {
40+
"code": {
41+
"description": "The response Status Code",
42+
"default": 200,
43+
"type": "integer"
44+
},
45+
"headers": {
46+
"description": "The response headers in form of name and list of values",
47+
"type": "object",
48+
"additionalProperties": {
49+
"type": "array",
50+
"items": {
51+
"type": "string"
52+
}
53+
}
54+
},
55+
"body": {
56+
"description": "The response body as text",
57+
"type": "string"
58+
},
59+
"file": {
60+
"description": "A path to a file to be used as response body",
61+
"type": "string"
62+
},
63+
"is_json": {
64+
"description": "A flag to automatically add the `Content-Type: application/json` response header",
65+
"default": false,
66+
"type": "boolean"
67+
},
68+
"repeat": {
69+
"description": "the number of repeats. Infinity if no set. Zero to skip. Or an exact number of repeats.",
70+
"type": "integer"
71+
}
72+
}
73+
},
74+
"minItems": 1
75+
}
76+
},
77+
"required": ["responses"]
78+
},
79+
"minItems": 1
80+
}
81+
},
82+
"required": ["routes"]
83+
}

example_config.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
routes:
2+
- method: get
3+
pattern: /users
4+
responses:
5+
- code: 200
6+
body: |
7+
[
8+
{
9+
"name": "foo"
10+
},
11+
{
12+
"name": "bar"
13+
}
14+
]
15+
is_json: true
16+
repeat: 3
17+
- code: 500
18+
repeat: 1
19+
body: "something is broken"
20+
- code: 200
21+
body: |
22+
[
23+
{
24+
"name": "foo"
25+
},
26+
{
27+
"name": "bar"
28+
}
29+
]
30+
is_json: true

go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
module github.com/sv-tools/mock-http-server
22

33
go 1.20
4+
5+
require (
6+
github.com/go-chi/chi/v5 v5.0.8
7+
github.com/spf13/pflag v1.0.5
8+
gopkg.in/yaml.v3 v3.0.1
9+
)

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
2+
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
3+
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
4+
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
5+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
6+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
8+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

main.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"os"
10+
"os/signal"
11+
"strconv"
12+
"syscall"
13+
"time"
14+
15+
"github.com/go-chi/chi/v5"
16+
"github.com/go-chi/chi/v5/middleware"
17+
flag "github.com/spf13/pflag"
18+
"gopkg.in/yaml.v3"
19+
)
20+
21+
func main() {
22+
conf := flag.StringP("config", "c", "config.yaml", "config file")
23+
port := flag.IntP("port", "p", 8080, "http port")
24+
flag.Parse()
25+
26+
if v := os.Getenv("CONFIG"); v != "" && !flag.Lookup("config").Changed {
27+
conf = &v
28+
}
29+
if v := os.Getenv("PORT"); v != "" && !flag.Lookup("port").Changed {
30+
p, err := strconv.Atoi(v)
31+
if err != nil {
32+
log.Fatal(err)
33+
}
34+
port = &p
35+
}
36+
37+
f, err := os.Open(*conf)
38+
if err != nil {
39+
log.Fatal(err)
40+
}
41+
42+
var config Config
43+
d := yaml.NewDecoder(f)
44+
d.KnownFields(true)
45+
if err := d.Decode(&config); err != nil {
46+
panic(err)
47+
}
48+
if config.Port != 0 {
49+
port = &config.Port
50+
}
51+
52+
if config.RequestIDHeader != "" {
53+
middleware.RequestIDHeader = config.RequestIDHeader
54+
}
55+
r := chi.NewRouter().With(middleware.Logger, middleware.RequestID)
56+
for _, route := range config.Routes {
57+
if route.Method == "" {
58+
route.Method = "GET"
59+
} else {
60+
chi.RegisterMethod(route.Method)
61+
}
62+
if route.Pattern == "" {
63+
route.Pattern = "/"
64+
}
65+
r.MethodFunc(route.Method, route.Pattern, responsesWriter(route.Responses))
66+
}
67+
68+
server := &http.Server{
69+
Addr: fmt.Sprintf(":%d", *port),
70+
Handler: r,
71+
ReadHeaderTimeout: 1 * time.Second,
72+
}
73+
serverCtx, serverStopCtx := context.WithCancel(context.Background())
74+
sig := make(chan os.Signal, 1)
75+
signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
76+
go func() {
77+
<-sig
78+
79+
// Shutdown signal with grace period of 30 seconds
80+
shutdownCtx, shutdownCancelCtx := context.WithTimeout(serverCtx, 30*time.Second)
81+
defer shutdownCancelCtx()
82+
83+
go func() {
84+
<-shutdownCtx.Done()
85+
if errors.Is(shutdownCtx.Err(), context.DeadlineExceeded) {
86+
log.Fatal("graceful shutdown timed out.. forcing exit.")
87+
}
88+
}()
89+
90+
// Trigger graceful shutdown
91+
err := server.Shutdown(shutdownCtx)
92+
if err != nil {
93+
log.Fatal(err)
94+
}
95+
serverStopCtx()
96+
}()
97+
98+
// Run the server
99+
log.Printf("Listen on http://localhost:%d\n", *port)
100+
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
101+
log.Fatal(err)
102+
}
103+
104+
// Wait for server context to be stopped
105+
<-serverCtx.Done()
106+
}

0 commit comments

Comments
 (0)