mirror of
https://github.com/tarampampam/error-pages.git
synced 2024-08-30 18:22:40 +00:00
wip: 🔕 temporary commit
This commit is contained in:
parent
669aaf6a1e
commit
1b94bc367c
@ -5,5 +5,4 @@
|
||||
!/cmd
|
||||
!/internal
|
||||
!/templates
|
||||
!/error-pages.yml
|
||||
!/go.*
|
||||
|
19
Dockerfile
19
Dockerfile
@ -48,22 +48,19 @@ WORKDIR /tmp/rootfs
|
||||
|
||||
# prepare rootfs for runtime
|
||||
RUN --mount=type=bind,source=.,target=/src set -x \
|
||||
&& mkdir -p ./etc ./bin ./opt/html \
|
||||
&& mkdir -p ./etc ./bin \
|
||||
&& echo 'appuser:x:10001:10001::/nonexistent:/sbin/nologin' > ./etc/passwd \
|
||||
&& echo 'appuser:x:10001:' > ./etc/group \
|
||||
&& cp -rv /src/templates ./opt/templates \
|
||||
&& rm -v ./opt/templates/*.md \
|
||||
&& cp -rv /src/error-pages.yml ./opt/error-pages.yml
|
||||
&& echo 'appuser:x:10001:' > ./etc/group
|
||||
|
||||
# take the binary from the compile stage
|
||||
COPY --from=compile /tmp/error-pages ./bin/error-pages
|
||||
|
||||
WORKDIR /tmp/rootfs/opt
|
||||
|
||||
# generate static error pages (for usage inside another docker images, for example)
|
||||
RUN set -x \
|
||||
&& ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \
|
||||
&& ls -l ./html
|
||||
## generate static error pages (for usage inside another docker images, for example)
|
||||
#RUN set -x \
|
||||
# && ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \
|
||||
# && ls -l ./html
|
||||
|
||||
# -✂- and this is the final stage (an empty filesystem is used) -------------------------------------------------------
|
||||
FROM scratch AS runtime
|
||||
@ -98,9 +95,9 @@ WORKDIR /opt
|
||||
|
||||
# docs: https://docs.docker.com/reference/dockerfile/#healthcheck
|
||||
HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=2s CMD [\
|
||||
"/bin/error-pages", "--log-json", "healthcheck" \
|
||||
"/bin/error-pages", "--log-format", "json", "healthcheck" \
|
||||
]
|
||||
|
||||
ENTRYPOINT ["/bin/error-pages"]
|
||||
|
||||
CMD ["--log-json", "serve"]
|
||||
CMD ["--log-format", "json", "serve"]
|
||||
|
@ -10,7 +10,7 @@ services:
|
||||
web:
|
||||
build: {target: runtime}
|
||||
ports: ['8080:8080/tcp'] # open http://127.0.0.1:8080
|
||||
command: serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah --catch-all
|
||||
command: --log-level debug serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah
|
||||
develop: # available since docker compose v2.22, https://docs.docker.com/compose/file-watch/
|
||||
watch: [{action: rebuild, path: .}]
|
||||
security_opt: [no-new-privileges:true]
|
||||
|
@ -9,7 +9,6 @@ import (
|
||||
_ "github.com/urfave/cli-docs/v3" // required for `go generate` to work
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal-old/env"
|
||||
"gh.tarampamp.am/error-pages/internal/appmeta"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
|
||||
"gh.tarampamp.am/error-pages/internal/cli/serve"
|
||||
@ -25,7 +24,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
Name: "log-level",
|
||||
Value: logger.InfoLevel.String(),
|
||||
Usage: "logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
|
||||
Sources: cli.EnvVars(env.LogLevel.String()),
|
||||
Sources: cli.EnvVars("LOG_LEVEL"),
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(s string) error {
|
||||
@ -41,7 +40,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
Name: "log-format",
|
||||
Value: logger.ConsoleFormat.String(),
|
||||
Usage: "logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
|
||||
Sources: cli.EnvVars(env.LogFormat.String()),
|
||||
Sources: cli.EnvVars("LOG_FORMAT"),
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(s string) error {
|
||||
|
@ -4,13 +4,22 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// New creates a new handler that always returns "OK" with status code 200.
|
||||
// New creates a new handler that returns "OK" for GET and HEAD requests.
|
||||
func New() http.Handler {
|
||||
var body = []byte("OK")
|
||||
var body = []byte("OK\n")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
|
||||
case http.MethodHead:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -13,14 +13,51 @@ import (
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var handler = live.New()
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
live.New().ServeHTTP(rr, req)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Equal(t, rr.Body.String(), "OK")
|
||||
assert.Equal(t, "OK\n", rr.Body.String())
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Empty(t, rr.Header().Get("Content-Type"))
|
||||
assert.Empty(t, rr.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
for _, method := range []string{
|
||||
http.MethodDelete,
|
||||
http.MethodPatch,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
var (
|
||||
req = httptest.NewRequest(method, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusMethodNotAllowed)
|
||||
assert.Equal(t, "Method Not Allowed\n", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -15,8 +15,17 @@ func New(ver string) http.Handler {
|
||||
})
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
|
||||
case http.MethodHead:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -13,13 +13,51 @@ import (
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var handler = version.New("\t\n foo@bar ")
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
version.New("\t\n foo@bar ").ServeHTTP(rr, req)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "application/json; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`)
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Empty(t, rr.Header().Get("Content-Type"))
|
||||
assert.Empty(t, rr.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
for _, method := range []string{
|
||||
http.MethodDelete,
|
||||
http.MethodPatch,
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
var (
|
||||
req = httptest.NewRequest(method, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusMethodNotAllowed)
|
||||
assert.Equal(t, "Method Not Allowed\n", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -22,7 +23,6 @@ import (
|
||||
type Server struct {
|
||||
log *zap.Logger
|
||||
server *http.Server
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer creates a new HTTP server.
|
||||
@ -33,45 +33,65 @@ func NewServer(baseCtx context.Context, log *zap.Logger) Server {
|
||||
maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB
|
||||
)
|
||||
|
||||
var (
|
||||
mux = http.NewServeMux()
|
||||
srv = &http.Server{
|
||||
Handler: mux,
|
||||
return Server{
|
||||
log: log,
|
||||
server: &http.Server{
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
ReadHeaderTimeout: readTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
ErrorLog: zap.NewStdLog(log),
|
||||
BaseContext: func(net.Listener) context.Context { return baseCtx },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return Server{log: log, server: srv, mux: mux}
|
||||
}
|
||||
|
||||
// Register server handlers, middlewares, etc.
|
||||
func (s *Server) Register(cfg *config.Config) error {
|
||||
// register middleware
|
||||
var (
|
||||
liveHandler = live.New()
|
||||
versionHandler = version.New(appmeta.Version())
|
||||
errorPagesHandler = error_page.New()
|
||||
|
||||
errorPageRegex = regexp.MustCompile(`^/(\d{3})(?:\.html|\.htm)?$`) // TODO: rewrite to function
|
||||
)
|
||||
|
||||
s.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var url, method = r.URL.Path, r.Method
|
||||
|
||||
switch {
|
||||
// live endpoints
|
||||
case url == "/health/live" || url == "/health" || url == "/healthz" || url == "/live":
|
||||
liveHandler.ServeHTTP(w, r)
|
||||
// version endpoint
|
||||
case url == "/version":
|
||||
versionHandler.ServeHTTP(w, r)
|
||||
// error pages endpoints:
|
||||
// - /
|
||||
// - /{code}.html
|
||||
// - /{code}.htm
|
||||
// - /{code}
|
||||
case method == http.MethodGet && (url == "/" || errorPageRegex.MatchString(url)):
|
||||
errorPagesHandler.ServeHTTP(w, r)
|
||||
// wrong requests handling
|
||||
default:
|
||||
switch {
|
||||
case method == http.MethodHead:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case method == http.MethodGet:
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// apply middleware
|
||||
s.server.Handler = logreq.New(s.log, func(r *http.Request) bool {
|
||||
// skip logging healthcheck requests
|
||||
return strings.Contains(strings.ToLower(r.UserAgent()), "healthcheck")
|
||||
})(s.server.Handler)
|
||||
|
||||
{ // register handlers (https://go.dev/blog/routing-enhancements)
|
||||
var errorPageHandler = error_page.New()
|
||||
|
||||
s.mux.Handle("/", errorPageHandler)
|
||||
s.mux.Handle("/{any}", errorPageHandler)
|
||||
|
||||
var liveHandler = live.New()
|
||||
|
||||
s.mux.Handle("GET /health/live", liveHandler)
|
||||
s.mux.Handle("GET /healthz", liveHandler)
|
||||
s.mux.Handle("GET /live", liveHandler)
|
||||
|
||||
s.mux.Handle("GET /version", version.New(appmeta.Version()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
221
internal/http/server_test.go
Normal file
221
internal/http/server_test.go
Normal file
@ -0,0 +1,221 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
appHttp "gh.tarampamp.am/error-pages/internal/http"
|
||||
)
|
||||
|
||||
func TestRouting(t *testing.T) {
|
||||
var (
|
||||
srv = appHttp.NewServer(context.Background(), zap.NewNop())
|
||||
cfg = config.New()
|
||||
)
|
||||
|
||||
require.NoError(t, srv.Register(&cfg))
|
||||
|
||||
var baseUrl, stopServer = startServer(t, &srv)
|
||||
|
||||
defer stopServer()
|
||||
|
||||
t.Run("health", func(t *testing.T) {
|
||||
var routes = []string{"/health/live", "/health", "/healthz", "/live"}
|
||||
|
||||
t.Run("success (get)", func(t *testing.T) {
|
||||
for _, route := range routes {
|
||||
status, body, headers := sendRequest(t, http.MethodGet, baseUrl+route)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success (head)", func(t *testing.T) {
|
||||
for _, route := range routes {
|
||||
status, body, headers := sendRequest(t, http.MethodHead, baseUrl+route)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, body)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
for _, route := range routes {
|
||||
var url = baseUrl + route
|
||||
|
||||
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
|
||||
status, body, headers := sendRequest(t, method, url)
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("version", func(t *testing.T) {
|
||||
var url = baseUrl + "/version"
|
||||
|
||||
t.Run("success (get)", func(t *testing.T) {
|
||||
status, body, headers := sendRequest(t, http.MethodGet, url)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.Contains(t, headers.Get("Content-Type"), "application/json")
|
||||
})
|
||||
|
||||
t.Run("success (head)", func(t *testing.T) {
|
||||
status, body, headers := sendRequest(t, http.MethodHead, url)
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, body)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
|
||||
status, body, headers := sendRequest(t, method, url)
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("error page", func(t *testing.T) {
|
||||
t.Skip("not implemented")
|
||||
})
|
||||
|
||||
t.Run("errors handling", func(t *testing.T) {
|
||||
var missingRoutes = []string{"/not-found", "/not-found/", "/not-found.html"}
|
||||
|
||||
t.Run("not found (get)", func(t *testing.T) {
|
||||
for _, path := range missingRoutes {
|
||||
status, body, headers := sendRequest(t, http.MethodGet, baseUrl+path)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found (head)", func(t *testing.T) {
|
||||
for _, path := range missingRoutes {
|
||||
status, body, headers := sendRequest(t, http.MethodHead, baseUrl+path)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.Empty(t, body)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("methods not allowed", func(t *testing.T) {
|
||||
for _, path := range missingRoutes {
|
||||
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
|
||||
status, body, headers := sendRequest(t, method, baseUrl+path)
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// sendRequest is a helper function to send an HTTP request and return its status code, body, and headers.
|
||||
func sendRequest(t *testing.T, method, url string, headers ...map[string]string) (
|
||||
status int,
|
||||
body []byte,
|
||||
_ http.Header,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
req, reqErr := http.NewRequest(method, url, nil)
|
||||
|
||||
require.NoError(t, reqErr)
|
||||
|
||||
if len(headers) > 0 {
|
||||
for key, value := range headers[0] {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
return resp.StatusCode, body, resp.Header
|
||||
}
|
||||
|
||||
// startServer is a helper function to start an HTTP server and return its base URL and a stop function.
|
||||
func startServer(t *testing.T, srv *appHttp.Server) (_ string, stop func()) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
port = getFreeTcpPort(t)
|
||||
hostPort = fmt.Sprintf("%s:%d", "127.0.0.1", port)
|
||||
)
|
||||
|
||||
go func() {
|
||||
if err := srv.Start("127.0.0.1", port); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// wait until the server starts
|
||||
for {
|
||||
if conn, err := net.DialTimeout("tcp", hostPort, time.Second); err == nil {
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
<-time.After(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s", hostPort), func() { assert.NoError(t, srv.Stop(10*time.Millisecond)) }
|
||||
}
|
||||
|
||||
// getFreeTcpPort is a helper function to get a free TCP port number.
|
||||
func getFreeTcpPort(t *testing.T) uint16 {
|
||||
t.Helper()
|
||||
|
||||
l, lErr := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, lErr)
|
||||
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, l.Close())
|
||||
|
||||
// make sure port is closed
|
||||
for {
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
require.NoError(t, conn.Close())
|
||||
<-time.After(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
return uint16(port)
|
||||
}
|
Loading…
Reference in New Issue
Block a user