Rename package mux to httpx and refactor http helpers

Signed-off-by: Philip Laine <philip.laine@gmail.com>
This commit is contained in:
Philip Laine 2025-05-23 13:40:51 +02:00
parent 56c8b000ba
commit fdf96eee4b
No known key found for this signature in database
GPG Key ID: F6D0B743CA3EFF33
15 changed files with 212 additions and 68 deletions

View File

@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#880](https://github.com/spegel-org/spegel/pull/880) Refactor store advertisement to list content. - [#880](https://github.com/spegel-org/spegel/pull/880) Refactor store advertisement to list content.
- [#888](https://github.com/spegel-org/spegel/pull/888) Refactor OCI events to support content events. - [#888](https://github.com/spegel-org/spegel/pull/888) Refactor OCI events to support content events.
- [#890](https://github.com/spegel-org/spegel/pull/890) Refactor Containerd options to use config struct. - [#890](https://github.com/spegel-org/spegel/pull/890) Refactor Containerd options to use config struct.
- [#896](https://github.com/spegel-org/spegel/pull/896) Rename package mux to httpx and refactor http helpers.
### Deprecated ### Deprecated

View File

@ -3,7 +3,6 @@ package cleanup
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
@ -14,6 +13,7 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/spegel-org/spegel/internal/channel" "github.com/spegel-org/spegel/internal/channel"
"github.com/spegel-org/spegel/pkg/httpx"
"github.com/spegel-org/spegel/pkg/oci" "github.com/spegel-org/spegel/pkg/oci"
) )
@ -135,8 +135,9 @@ func probeIPs(ctx context.Context, client *http.Client, ips []net.IPAddr, port s
if err != nil { if err != nil {
return err return err
} }
if resp.StatusCode != http.StatusOK { err = httpx.CheckResponseStatus(resp, http.StatusOK)
return fmt.Errorf("unexpected status code %s", resp.Status) if err != nil {
return err
} }
return nil return nil
}) })

View File

@ -13,7 +13,7 @@ import (
"github.com/go-logr/logr" "github.com/go-logr/logr"
"github.com/prometheus/common/expfmt" "github.com/prometheus/common/expfmt"
"github.com/spegel-org/spegel/pkg/mux" "github.com/spegel-org/spegel/pkg/httpx"
"github.com/spegel-org/spegel/pkg/oci" "github.com/spegel-org/spegel/pkg/oci"
"github.com/spegel-org/spegel/pkg/routing" "github.com/spegel-org/spegel/pkg/routing"
) )
@ -44,14 +44,14 @@ func NewWeb(router routing.Router) (*Web, error) {
} }
func (w *Web) Handler(log logr.Logger) http.Handler { func (w *Web) Handler(log logr.Logger) http.Handler {
m := mux.NewServeMux(log) m := httpx.NewServeMux(log)
m.Handle("GET /debug/web/", w.indexHandler) m.Handle("GET /debug/web/", w.indexHandler)
m.Handle("GET /debug/web/stats", w.statsHandler) m.Handle("GET /debug/web/stats", w.statsHandler)
m.Handle("GET /debug/web/measure", w.measureHandler) m.Handle("GET /debug/web/measure", w.measureHandler)
return m return m
} }
func (w *Web) indexHandler(rw mux.ResponseWriter, req *http.Request) { func (w *Web) indexHandler(rw httpx.ResponseWriter, req *http.Request) {
err := w.tmpls.ExecuteTemplate(rw, "index.html", nil) err := w.tmpls.ExecuteTemplate(rw, "index.html", nil)
if err != nil { if err != nil {
rw.WriteError(http.StatusInternalServerError, err) rw.WriteError(http.StatusInternalServerError, err)
@ -59,7 +59,7 @@ func (w *Web) indexHandler(rw mux.ResponseWriter, req *http.Request) {
} }
} }
func (w *Web) statsHandler(rw mux.ResponseWriter, req *http.Request) { func (w *Web) statsHandler(rw httpx.ResponseWriter, req *http.Request) {
//nolint: errcheck // Ignore error. //nolint: errcheck // Ignore error.
srvAddr := req.Context().Value(http.LocalAddrContextKey).(net.Addr) srvAddr := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
resp, err := http.Get(fmt.Sprintf("http://%s/metrics", srvAddr.String())) resp, err := http.Get(fmt.Sprintf("http://%s/metrics", srvAddr.String()))
@ -112,7 +112,7 @@ type pullResult struct {
Duration time.Duration Duration time.Duration
} }
func (w *Web) measureHandler(rw mux.ResponseWriter, req *http.Request) { func (w *Web) measureHandler(rw httpx.ResponseWriter, req *http.Request) {
// Parse image name. // Parse image name.
imgName := req.URL.Query().Get("image") imgName := req.URL.Query().Get("image")
if imgName == "" { if imgName == "" {

7
pkg/httpx/httpx.go Normal file
View File

@ -0,0 +1,7 @@
package httpx
const (
HeaderContentType = "Content-Type"
HeaderContentLength = "Content-Length"
HeaderAcceptRanges = "Accept-Ranges"
)

View File

@ -1,4 +1,4 @@
package mux package httpx
import "github.com/prometheus/client_golang/prometheus" import "github.com/prometheus/client_golang/prometheus"

View File

@ -1,4 +1,4 @@
package mux package httpx
import ( import (
"errors" "errors"

View File

@ -1,4 +1,4 @@
package mux package httpx
import ( import (
"net/http" "net/http"

View File

@ -1,4 +1,4 @@
package mux package httpx
import ( import (
"bufio" "bufio"

View File

@ -1,4 +1,4 @@
package mux package httpx
import ( import (
"errors" "errors"

63
pkg/httpx/status.go Normal file
View File

@ -0,0 +1,63 @@
package httpx
import (
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
)
type StatusError struct {
Message string
ExpectedCodes []int
StatusCode int
}
func (e *StatusError) Error() string {
expectedCodeStrs := []string{}
for _, expected := range e.ExpectedCodes {
expectedCodeStrs = append(expectedCodeStrs, fmt.Sprintf("%d %s", expected, http.StatusText(expected)))
}
msg := fmt.Sprintf("expected one of the following statuses [%s], but received %d %s", strings.Join(expectedCodeStrs, ", "), e.StatusCode, http.StatusText(e.StatusCode))
if e.Message != "" {
msg += ": " + e.Message
}
return msg
}
func CheckResponseStatus(resp *http.Response, expectedCodes ...int) error {
if len(expectedCodes) == 0 {
return errors.New("expected codes cannot be empty")
}
if slices.Contains(expectedCodes, resp.StatusCode) {
return nil
}
message, messageErr := getErrorMessage(resp)
statusErr := &StatusError{
Message: message,
ExpectedCodes: expectedCodes,
StatusCode: resp.StatusCode,
}
return errors.Join(statusErr, messageErr)
}
func getErrorMessage(resp *http.Response) (string, error) {
defer resp.Body.Close()
if resp.Request.Method == http.MethodHead {
return "", nil
}
contentTypes := []string{
"text/plain",
"text/html",
"application/json",
"application/xml",
}
if !slices.Contains(contentTypes, resp.Header.Get(HeaderContentType)) {
_, err := io.Copy(io.Discard, resp.Body)
return "", err
}
b, err := io.ReadAll(resp.Body)
return string(b), err
}

97
pkg/httpx/status_test.go Normal file
View File

@ -0,0 +1,97 @@
package httpx
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestStatusError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
contentType string
body string
expectedError string
requestMethod string
expectedCodes []int
statusCode int
}{
{
name: "status code matches one of expected",
contentType: "text/plain",
body: "Hello World",
statusCode: http.StatusOK,
expectedCodes: []int{http.StatusNotFound, http.StatusOK},
requestMethod: http.MethodGet,
expectedError: "",
},
{
name: "no expected status codes",
contentType: "text/plain",
statusCode: http.StatusOK,
expectedCodes: []int{},
expectedError: "expected codes cannot be empty",
},
{
name: "wrong code with text content and GET request",
contentType: "text/plain",
body: "Hello World",
statusCode: http.StatusNotFound,
expectedCodes: []int{http.StatusOK},
requestMethod: http.MethodGet,
expectedError: "expected one of the following statuses [200 OK], but received 404 Not Found: Hello World",
},
{
name: "wrong code with text content and HEAD request",
contentType: "text/plain",
body: "Hello World",
statusCode: http.StatusNotFound,
expectedCodes: []int{http.StatusOK, http.StatusPartialContent},
requestMethod: http.MethodHead,
expectedError: "expected one of the following statuses [200 OK, 206 Partial Content], but received 404 Not Found",
},
{
name: "wrong code with text content and GET request but octet stream",
contentType: "application/octet-stream",
body: "Hello World",
statusCode: http.StatusNotFound,
expectedCodes: []int{http.StatusOK},
requestMethod: http.MethodGet,
expectedError: "expected one of the following statuses [200 OK], but received 404 Not Found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
rec.WriteHeader(tt.statusCode)
rec.Header().Set("Content-Type", tt.contentType)
rec.Body = bytes.NewBufferString(tt.body)
resp := &http.Response{
StatusCode: tt.statusCode,
Status: http.StatusText(tt.statusCode),
Header: rec.Header(),
Body: io.NopCloser(rec.Body),
Request: &http.Request{
Method: tt.requestMethod,
},
}
err := CheckResponseStatus(resp, tt.expectedCodes...)
if tt.expectedError == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tt.expectedError)
}
})
}
}

View File

@ -3,7 +3,7 @@ package metrics
import ( import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/spegel-org/spegel/pkg/mux" "github.com/spegel-org/spegel/pkg/httpx"
) )
var ( var (
@ -50,5 +50,5 @@ func Register() {
DefaultRegisterer.MustRegister(AdvertisedImageTags) DefaultRegisterer.MustRegister(AdvertisedImageTags)
DefaultRegisterer.MustRegister(AdvertisedImageDigests) DefaultRegisterer.MustRegister(AdvertisedImageDigests)
DefaultRegisterer.MustRegister(AdvertisedKeys) DefaultRegisterer.MustRegister(AdvertisedKeys)
mux.RegisterMetrics(DefaultRegisterer) httpx.RegisterMetrics(DefaultRegisterer)
} }

View File

@ -4,12 +4,10 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -18,38 +16,14 @@ import (
"github.com/containerd/containerd/v2/core/images" "github.com/containerd/containerd/v2/core/images"
"github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spegel-org/spegel/pkg/httpx"
) )
const ( const (
DigestHeader = "Docker-Content-Digest" HeaderDockerDigest = "Docker-Content-Digest"
ContentTypeHeader = "Content-Type"
ContentLengthHeader = "Content-Length"
) )
type StatusError struct {
Content string
StatusCode int
}
func (e *StatusError) Error() string {
return fmt.Sprintf("unexpected status code %d with body %s", e.StatusCode, e.Content)
}
func CheckResponseStatus(resp *http.Response, expected ...int) error {
if slices.Contains(expected, resp.StatusCode) {
return nil
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return &StatusError{
StatusCode: resp.StatusCode,
Content: string(b),
}
}
type Client struct { type Client struct {
hc *http.Client hc *http.Client
tc sync.Map tc sync.Map
@ -226,13 +200,13 @@ func (c *Client) fetch(ctx context.Context, method string, dist DistributionPath
c.tc.Store(tcKey, token) c.tc.Store(tcKey, token)
continue continue
} }
err = CheckResponseStatus(resp, http.StatusOK) err = httpx.CheckResponseStatus(resp, http.StatusOK, http.StatusPartialContent)
if err != nil { if err != nil {
return nil, ocispec.Descriptor{}, err return nil, ocispec.Descriptor{}, err
} }
dgst := dist.Digest dgst := dist.Digest
dgstStr := resp.Header.Get(DigestHeader) dgstStr := resp.Header.Get(HeaderDockerDigest)
if dgstStr != "" { if dgstStr != "" {
dgst, err = digest.Parse(dgstStr) dgst, err = digest.Parse(dgstStr)
if err != nil { if err != nil {
@ -242,11 +216,11 @@ func (c *Client) fetch(ctx context.Context, method string, dist DistributionPath
if dgst == "" { if dgst == "" {
return nil, ocispec.Descriptor{}, errors.New("digest cannot be empty") return nil, ocispec.Descriptor{}, errors.New("digest cannot be empty")
} }
mt := resp.Header.Get(ContentTypeHeader) mt := resp.Header.Get(httpx.HeaderContentType)
if mt == "" { if mt == "" {
return nil, ocispec.Descriptor{}, errors.New("content type header cannot be empty") return nil, ocispec.Descriptor{}, errors.New("content type header cannot be empty")
} }
cl := resp.Header.Get(ContentLengthHeader) cl := resp.Header.Get(httpx.HeaderContentLength)
if cl == "" { if cl == "" {
return nil, ocispec.Descriptor{}, errors.New("content length header cannot be empty") return nil, ocispec.Descriptor{}, errors.New("content length header cannot be empty")
} }
@ -297,7 +271,7 @@ func getBearerToken(ctx context.Context, wwwAuth string, client *http.Client) (s
if err != nil { if err != nil {
return "", err return "", err
} }
err = CheckResponseStatus(resp, http.StatusOK) err = httpx.CheckResponseStatus(resp, http.StatusOK)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spegel-org/spegel/pkg/httpx"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -72,9 +73,9 @@ func TestPull(t *testing.T) {
return return
} }
rw.Header().Set(ContentTypeHeader, mt) rw.Header().Set(httpx.HeaderContentType, mt)
dgst := digest.SHA256.FromBytes(b) dgst := digest.SHA256.FromBytes(b)
rw.Header().Set(DigestHeader, dgst.String()) rw.Header().Set(HeaderDockerDigest, dgst.String())
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
//nolint: errcheck // Ignore error. //nolint: errcheck // Ignore error.

View File

@ -15,14 +15,14 @@ import (
"github.com/go-logr/logr" "github.com/go-logr/logr"
"github.com/spegel-org/spegel/pkg/httpx"
"github.com/spegel-org/spegel/pkg/metrics" "github.com/spegel-org/spegel/pkg/metrics"
"github.com/spegel-org/spegel/pkg/mux"
"github.com/spegel-org/spegel/pkg/oci" "github.com/spegel-org/spegel/pkg/oci"
"github.com/spegel-org/spegel/pkg/routing" "github.com/spegel-org/spegel/pkg/routing"
) )
const ( const (
MirroredHeaderKey = "X-Spegel-Mirrored" HeaderSpegelMirrored = "X-Spegel-Mirrored"
) )
type RegistryConfig struct { type RegistryConfig struct {
@ -149,7 +149,7 @@ func NewRegistry(ociStore oci.Store, router routing.Router, opts ...RegistryOpti
} }
func (r *Registry) Server(addr string) (*http.Server, error) { func (r *Registry) Server(addr string) (*http.Server, error) {
m := mux.NewServeMux(r.log) m := httpx.NewServeMux(r.log)
m.Handle("GET /healthz", r.readyHandler) m.Handle("GET /healthz", r.readyHandler)
m.Handle("GET /v2/", r.registryHandler) m.Handle("GET /v2/", r.registryHandler)
m.Handle("HEAD /v2/", r.registryHandler) m.Handle("HEAD /v2/", r.registryHandler)
@ -160,7 +160,7 @@ func (r *Registry) Server(addr string) (*http.Server, error) {
return srv, nil return srv, nil
} }
func (r *Registry) readyHandler(rw mux.ResponseWriter, req *http.Request) { func (r *Registry) readyHandler(rw httpx.ResponseWriter, req *http.Request) {
rw.SetHandler("ready") rw.SetHandler("ready")
ok, err := r.router.Ready(req.Context()) ok, err := r.router.Ready(req.Context())
if err != nil { if err != nil {
@ -173,7 +173,7 @@ func (r *Registry) readyHandler(rw mux.ResponseWriter, req *http.Request) {
} }
} }
func (r *Registry) registryHandler(rw mux.ResponseWriter, req *http.Request) { func (r *Registry) registryHandler(rw httpx.ResponseWriter, req *http.Request) {
rw.SetHandler("registry") rw.SetHandler("registry")
// Check basic authentication // Check basic authentication
@ -200,9 +200,9 @@ func (r *Registry) registryHandler(rw mux.ResponseWriter, req *http.Request) {
} }
// Request with mirror header are proxied. // Request with mirror header are proxied.
if req.Header.Get(MirroredHeaderKey) != "true" { if req.Header.Get(HeaderSpegelMirrored) != "true" {
// Set mirrored header in request to stop infinite loops // Set mirrored header in request to stop infinite loops
req.Header.Set(MirroredHeaderKey, "true") req.Header.Set(HeaderSpegelMirrored, "true")
// If content is present locally we should skip the mirroring and just serve it. // If content is present locally we should skip the mirroring and just serve it.
var ociErr error var ociErr error
@ -234,7 +234,7 @@ func (r *Registry) registryHandler(rw mux.ResponseWriter, req *http.Request) {
} }
} }
func (r *Registry) handleMirror(rw mux.ResponseWriter, req *http.Request, dist oci.DistributionPath) { func (r *Registry) handleMirror(rw httpx.ResponseWriter, req *http.Request, dist oci.DistributionPath) {
log := r.log.WithValues("ref", dist.Reference(), "path", req.URL.Path) log := r.log.WithValues("ref", dist.Reference(), "path", req.URL.Path)
defer func() { defer func() {
@ -292,7 +292,7 @@ func (r *Registry) handleMirror(rw mux.ResponseWriter, req *http.Request, dist o
} }
} }
func (r *Registry) handleManifest(rw mux.ResponseWriter, req *http.Request, dist oci.DistributionPath) { func (r *Registry) handleManifest(rw httpx.ResponseWriter, req *http.Request, dist oci.DistributionPath) {
if dist.Digest == "" { if dist.Digest == "" {
dgst, err := r.ociStore.Resolve(req.Context(), dist.Reference()) dgst, err := r.ociStore.Resolve(req.Context(), dist.Reference())
if err != nil { if err != nil {
@ -306,9 +306,9 @@ func (r *Registry) handleManifest(rw mux.ResponseWriter, req *http.Request, dist
rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get manifest content for digest %s: %w", dist.Digest.String(), err)) rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get manifest content for digest %s: %w", dist.Digest.String(), err))
return return
} }
rw.Header().Set("Content-Type", mediaType) rw.Header().Set(httpx.HeaderContentType, mediaType)
rw.Header().Set("Content-Length", strconv.FormatInt(int64(len(b)), 10)) rw.Header().Set(httpx.HeaderContentLength, strconv.FormatInt(int64(len(b)), 10))
rw.Header().Set("Docker-Content-Digest", dist.Digest.String()) rw.Header().Set(oci.HeaderDockerDigest, dist.Digest.String())
if req.Method == http.MethodHead { if req.Method == http.MethodHead {
return return
} }
@ -319,16 +319,16 @@ func (r *Registry) handleManifest(rw mux.ResponseWriter, req *http.Request, dist
} }
} }
func (r *Registry) handleBlob(rw mux.ResponseWriter, req *http.Request, dist oci.DistributionPath) { func (r *Registry) handleBlob(rw httpx.ResponseWriter, req *http.Request, dist oci.DistributionPath) {
size, err := r.ociStore.Size(req.Context(), dist.Digest) size, err := r.ociStore.Size(req.Context(), dist.Digest)
if err != nil { if err != nil {
rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not determine size of blob with digest %s: %w", dist.Digest.String(), err)) rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not determine size of blob with digest %s: %w", dist.Digest.String(), err))
return return
} }
rw.Header().Set("Accept-Ranges", "bytes") rw.Header().Set(httpx.HeaderAcceptRanges, "bytes")
rw.Header().Set("Content-Type", "application/octet-stream") rw.Header().Set(httpx.HeaderContentType, "application/octet-stream")
rw.Header().Set("Content-Length", strconv.FormatInt(size, 10)) rw.Header().Set(httpx.HeaderContentLength, strconv.FormatInt(size, 10))
rw.Header().Set("Docker-Content-Digest", dist.Digest.String()) rw.Header().Set(oci.HeaderDockerDigest, dist.Digest.String())
if req.Method == http.MethodHead { if req.Method == http.MethodHead {
return return
} }