From dfedf9251923d6a35fb7f1b19fdde3209e24de84 Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Tue, 1 Oct 2024 23:51:37 -0400 Subject: [PATCH 1/4] feat(issue-295): create package for abstracting and simplifying htp.ServeMux --- rest/mux/mux.go | 142 +++++++++++++++++++++++++++ rest/mux/mux_test.go | 229 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 rest/mux/mux.go create mode 100644 rest/mux/mux_test.go diff --git a/rest/mux/mux.go b/rest/mux/mux.go new file mode 100644 index 0000000..37ebe98 --- /dev/null +++ b/rest/mux/mux.go @@ -0,0 +1,142 @@ +// Copyright (c) 2024 Z5Labs and Contributors +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +// Package mux defines a simple API for all http multiplexers to implement. +package mux + +import ( + "fmt" + "net/http" + "slices" + "sync" +) + +// Method defines an HTTP method expected to be used in a RESTful API. +type Method string + +const ( + MethodGet Method = http.MethodGet + MethodPut Method = http.MethodPut + MethodPost Method = http.MethodPost + MethodDelete Method = http.MethodDelete +) + +// HttpOption defines a configuration option for [Http]. +type HttpOption func(*Http) + +// NotFoundHandler will register the given [http.Handler] to handle +// any HTTP requests that do not match any other method-pattern combinations. +func NotFoundHandler(h http.Handler) HttpOption { + return func(mux *Http) { + mux.notFound = h + } +} + +// MethodNotAllowedHandler will register the given [http.Handler] to handle +// any HTTP requests whose method does not match the method registered to a pattern. +func MethodNotAllowedHandler(h http.Handler) HttpOption { + return func(mux *Http) { + mux.methodNotAllowed = h + } +} + +// Http wraps a [http.ServeMux] and provides some helpers around overriding +// the default "HTTP 404 Not Found" and "HTTP 405 Method Not Allowed" behaviour. +type Http struct { + mux *http.ServeMux + + initFallbacksOnce sync.Once + notFound http.Handler + methodNotAllowed http.Handler + + pathMethods map[string][]Method +} + +// NewHttp initializes a request multiplexer using the standard [http.ServeMux.] +func NewHttp(opts ...HttpOption) *Http { + mux := &Http{ + mux: http.NewServeMux(), + pathMethods: make(map[string][]Method), + } + for _, opt := range opts { + opt(mux) + } + return mux +} + +// Handle will register the [http.Handler] for the given method and pattern +// with the underlying [http.ServeMux]. The method and pattern will be formatted +// together as "method pattern" when calling [http.ServeMux.Handle]. +func (m *Http) Handle(method Method, pattern string, h http.Handler) { + m.pathMethods[pattern] = append(m.pathMethods[pattern], method) + m.mux.Handle(fmt.Sprintf("%s %s", method, pattern), h) +} + +// ServeHTTP implements the [http.Handler] interface. +func (m *Http) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.initFallbacksOnce.Do(m.registerFallbackHandlers) + + m.mux.ServeHTTP(w, r) +} + +func (m *Http) registerFallbackHandlers() { + fs := []func(*http.ServeMux){ + registerNotFoundHandler(m.notFound), + registerMethodNotAllowedHandler(m.methodNotAllowed, m.pathMethods), + } + for _, f := range fs { + f(m.mux) + } +} + +func registerNotFoundHandler(h http.Handler) func(*http.ServeMux) { + return func(mux *http.ServeMux) { + if h == nil { + return + } + mux.Handle("/{path...}", h) + } +} + +func registerMethodNotAllowedHandler(h http.Handler, pathMethods map[string][]Method) func(*http.ServeMux) { + return func(mux *http.ServeMux) { + if h == nil { + return + } + if len(pathMethods) == 0 { + return + } + + // this list is pulled from the OpenAPI v3 Path Item Object documentation. + supportedMethods := []Method{ + http.MethodGet, + http.MethodPut, + http.MethodPost, + http.MethodDelete, + http.MethodOptions, + http.MethodHead, + http.MethodPatch, + http.MethodTrace, + } + + for path, methods := range pathMethods { + unsupportedMethods := diffSets(supportedMethods, methods) + for _, method := range unsupportedMethods { + mux.Handle(fmt.Sprintf("%s %s", method, path), h) + } + } + } +} + +func diffSets[T comparable](xs, ys []T) []T { + zs := make([]T, 0, len(xs)) + for _, x := range xs { + if slices.Contains(ys, x) { + continue + } + zs = append(zs, x) + } + return zs +} diff --git a/rest/mux/mux_test.go b/rest/mux/mux_test.go new file mode 100644 index 0000000..7acb43c --- /dev/null +++ b/rest/mux/mux_test.go @@ -0,0 +1,229 @@ +// Copyright (c) 2024 Z5Labs and Contributors +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +package mux + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +type statusCodeHandler int + +func (h statusCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(int(h)) +} + +func TestNotFoundHandler(t *testing.T) { + testCases := []struct { + Name string + RegisterPattern string + RequestPath string + NotFound bool + }{ + { + Name: "should match not found if no other endpoints are registered and '/' is requested", + RequestPath: "/", + NotFound: true, + }, + { + Name: "should match not found if no other endpoints are registered and a sub path is requested", + RequestPath: "/hello", + NotFound: true, + }, + { + Name: "should match not found if other endpoints are registered and '/' is requested", + RegisterPattern: "/hello", + RequestPath: "/", + NotFound: true, + }, + { + Name: "should match not found if other endpoints are registered and unknown sub-path is requested", + RegisterPattern: "/hello", + RequestPath: "/bye", + NotFound: true, + }, + { + Name: "should match not found if '/{$}' is registered and a sub-path is requested", + RegisterPattern: "/{$}", + RequestPath: "/bye", + NotFound: true, + }, + { + Name: "should not match not found if endpoint pattern is requested", + RegisterPattern: "/hello", + RequestPath: "/hello", + NotFound: false, + }, + { + Name: "should not match not found if '/{$}' is registered and '/' requested", + RegisterPattern: "/{$}", + RequestPath: "/", + NotFound: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + notFoundHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + + enc := json.NewEncoder(w) + enc.Encode(map[string]any{"hello": "world"}) + }) + + mux := NewHttp( + NotFoundHandler(notFoundHandler), + ) + + if testCase.RegisterPattern != "" { + mux.Handle(MethodGet, testCase.RegisterPattern, statusCodeHandler(http.StatusOK)) + } + + w := httptest.NewRecorder() + r := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf("http://%s", path.Join("example.com", testCase.RequestPath)), + nil, + ) + + mux.ServeHTTP(w, r) + + resp := w.Result() + if !assert.NotNil(t, resp) { + return + } + if !testCase.NotFound { + assert.Equal(t, http.StatusOK, resp.StatusCode) + return + } + if !assert.Equal(t, http.StatusNotFound, resp.StatusCode) { + return + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if !assert.Nil(t, err) { + return + } + + var m map[string]any + err = json.Unmarshal(b, &m) + if !assert.Nil(t, err) { + return + } + if !assert.Contains(t, m, "hello") { + return + } + if !assert.Equal(t, "world", m["hello"]) { + return + } + }) + } +} + +func TestMethodNotAllowedHandler(t *testing.T) { + testCases := []struct { + Name string + RegisterPatterns map[Method]string + Method Method + RequestPath string + MethodNotAllowed bool + }{ + { + Name: "should return success response when correct method is used", + RegisterPatterns: map[Method]string{ + http.MethodGet: "/", + }, + Method: MethodGet, + RequestPath: "/", + MethodNotAllowed: false, + }, + { + Name: "should return success response when more than one method is registered for same path", + RegisterPatterns: map[Method]string{ + http.MethodGet: "/", + http.MethodPost: "/", + }, + Method: MethodGet, + RequestPath: "/", + MethodNotAllowed: false, + }, + { + Name: "should return method not allowed response when incorrect method is used", + RegisterPatterns: map[Method]string{ + http.MethodGet: "/", + }, + Method: MethodPost, + RequestPath: "/", + MethodNotAllowed: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { + methodNotAllowedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + + enc := json.NewEncoder(w) + enc.Encode(map[string]any{"hello": "world"}) + }) + + mux := NewHttp( + MethodNotAllowedHandler(methodNotAllowedHandler), + ) + + for method, pattern := range testCase.RegisterPatterns { + mux.Handle(method, pattern, statusCodeHandler(http.StatusOK)) + } + + w := httptest.NewRecorder() + r := httptest.NewRequest( + string(testCase.Method), + fmt.Sprintf("http://%s", path.Join("example.com", testCase.RequestPath)), + nil, + ) + + mux.ServeHTTP(w, r) + + resp := w.Result() + if !assert.NotNil(t, resp) { + return + } + if !testCase.MethodNotAllowed { + assert.Equal(t, http.StatusOK, resp.StatusCode) + return + } + if !assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) { + return + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if !assert.Nil(t, err) { + return + } + + var m map[string]any + err = json.Unmarshal(b, &m) + if !assert.Nil(t, err) { + return + } + if !assert.Contains(t, m, "hello") { + return + } + if !assert.Equal(t, "world", m["hello"]) { + return + } + }) + } +} From 72fcd8619323ad406456e93cd2e703774b0b4615 Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Tue, 1 Oct 2024 23:52:13 -0400 Subject: [PATCH 2/4] refactor(issue-295): define interface that matches mux package --- .../custom_framework/framework/rest/rest.go | 3 +- rest/rest.go | 115 +++++------------- rest/rest_test.go | 31 +++-- 3 files changed, 55 insertions(+), 94 deletions(-) diff --git a/example/custom_framework/framework/rest/rest.go b/example/custom_framework/framework/rest/rest.go index b32adbf..b9036c5 100644 --- a/example/custom_framework/framework/rest/rest.go +++ b/example/custom_framework/framework/rest/rest.go @@ -22,6 +22,7 @@ import ( "github.com/z5labs/bedrock" "github.com/z5labs/bedrock/pkg/app" "github.com/z5labs/bedrock/rest" + "github.com/z5labs/bedrock/rest/mux" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" @@ -120,7 +121,7 @@ func HttpServer(cfg HttpServerConfig) Option { } type Endpoint struct { - Method string + Method mux.Method Path string Operation Operation } diff --git a/rest/rest.go b/rest/rest.go index a818c08..ef806ec 100644 --- a/rest/rest.go +++ b/rest/rest.go @@ -9,13 +9,13 @@ import ( "bytes" "context" "encoding/json" - "fmt" "io" "net" "net/http" - "slices" "strings" + "github.com/z5labs/bedrock/rest/mux" + "github.com/swaggest/openapi-go/openapi3" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "golang.org/x/sync/errgroup" @@ -38,10 +38,10 @@ func Listener(ls net.Listener) Option { // OpenApiEndpoint registers a [http.Handler] with the underlying [http.ServeMux] // meant for serving the OpenAPI schema. -func OpenApiEndpoint(method, pattern string, f func(*openapi3.Spec) http.Handler) Option { +func OpenApiEndpoint(method mux.Method, pattern string, f func(*openapi3.Spec) http.Handler) Option { return func(a *App) { - a.openApiEndpoint = func(mux *http.ServeMux) { - mux.Handle(fmt.Sprintf("%s %s", method, pattern), f(a.spec)) + a.openApiEndpoint = func(mux Mux) { + mux.Handle(method, pattern, f(a.spec)) } } } @@ -86,7 +86,7 @@ type Operation interface { } type endpoint struct { - method string + method mux.Method pattern string op Operation } @@ -96,7 +96,7 @@ type endpoint struct { // // "/" is always treated as "/{$}" because it would otherwise // match too broadly and cause conflicts with other paths. -func Endpoint(method, pattern string, op Operation) Option { +func Endpoint(method mux.Method, pattern string, op Operation) Option { return func(app *App) { app.endpoints = append(app.endpoints, endpoint{ method: method, @@ -106,22 +106,6 @@ func Endpoint(method, pattern string, op Operation) Option { } } -// NotFoundHandler will register the given [http.Handler] to handle -// any HTTP requests that do not match any other method-pattern combinations. -func NotFoundHandler(h http.Handler) Option { - return func(app *App) { - app.notFoundHandler = h - } -} - -// MethodNotAllowedHandler will register the given [http.Handler] to handle -// any HTTP requests whose method does not match the method registered to a pattern. -func MethodNotAllowedHandler(h http.Handler) Option { - return func(app *App) { - app.methodNotAllowedHandler = h - } -} - // Title sets the title of the API in its OpenAPI spec. // // In order for your OpenAPI spec to be fully compliant @@ -142,20 +126,30 @@ func Version(s string) Option { } } +// Mux +type Mux interface { + http.Handler + + Handle(method mux.Method, pattern string, h http.Handler) +} + +// WithMux +func WithMux(m Mux) Option { + return func(a *App) { + a.mux = m + } +} + // App is a [bedrock.App] implementation to help simplify // building RESTful applications. type App struct { ls net.Listener spec *openapi3.Spec + mux Mux endpoints []endpoint - openApiEndpoint func(*http.ServeMux) - - notFoundHandler http.Handler - - pathMethods map[string][]string - methodNotAllowedHandler http.Handler + openApiEndpoint func(Mux) listen func(network, addr string) (net.Listener, error) } @@ -166,9 +160,9 @@ func NewApp(opts ...Option) *App { spec: &openapi3.Spec{ Openapi: "3.0.3", }, - pathMethods: make(map[string][]string), + mux: mux.NewHttp(), listen: net.Listen, - openApiEndpoint: func(sm *http.ServeMux) {}, + openApiEndpoint: func(_ Mux) {}, } for _, opt := range opts { opt(app) @@ -183,23 +177,16 @@ func (app *App) Run(ctx context.Context) error { return err } - mux := http.NewServeMux() - app.openApiEndpoint(mux) + app.openApiEndpoint(app.mux) - err = app.registerEndpoints(mux) + err = app.registerEndpoints() if err != nil { return err } - if app.notFoundHandler != nil { - mux.Handle("/{path...}", app.notFoundHandler) - } - - app.registerMethodNotAllowedHandler(mux) - httpServer := &http.Server{ Handler: otelhttp.NewHandler( - mux, + app.mux, "server", otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents), ), @@ -228,7 +215,7 @@ func (app *App) listener() (net.Listener, error) { return app.listen("tcp", ":80") } -func (app *App) registerEndpoints(mux *http.ServeMux) error { +func (app *App) registerEndpoints() error { for _, e := range app.endpoints { // Per the net/http.ServeMux docs, https://pkg.go.dev/net/http#ServeMux: // @@ -249,7 +236,7 @@ func (app *App) registerEndpoints(mux *http.ServeMux) error { // before registering the OpenAPI operation with the spec. trimmedPattern = strings.ReplaceAll(trimmedPattern, "...", "") - err := app.spec.AddOperation(e.method, trimmedPattern, e.op.OpenApi()) + err := app.spec.AddOperation(string(e.method), trimmedPattern, e.op.OpenApi()) if err != nil { return err } @@ -260,48 +247,12 @@ func (app *App) registerEndpoints(mux *http.ServeMux) error { if e.pattern == "/" { e.pattern = "/{$}" } - app.pathMethods[e.pattern] = append(app.pathMethods[e.pattern], e.method) - mux.Handle( - fmt.Sprintf("%s %s", e.method, e.pattern), + app.mux.Handle( + e.method, + e.pattern, otelhttp.WithRouteTag(trimmedPattern, e.op), ) } return nil } - -func (app *App) registerMethodNotAllowedHandler(mux *http.ServeMux) { - if app.methodNotAllowedHandler == nil { - return - } - - // this list is pulled from the OpenAPI v3 Path Item Object documentation. - supportedMethods := []string{ - http.MethodGet, - http.MethodPut, - http.MethodPost, - http.MethodDelete, - http.MethodOptions, - http.MethodHead, - http.MethodPatch, - http.MethodTrace, - } - - for path, methods := range app.pathMethods { - unsupportedMethods := diffSets(supportedMethods, methods) - for _, method := range unsupportedMethods { - mux.Handle(fmt.Sprintf("%s %s", method, path), app.methodNotAllowedHandler) - } - } -} - -func diffSets(xs, ys []string) []string { - zs := make([]string, 0, len(xs)) - for _, x := range xs { - if slices.Contains(ys, x) { - continue - } - zs = append(zs, x) - } - return zs -} diff --git a/rest/rest_test.go b/rest/rest_test.go index 57458f9..d2d1fa4 100644 --- a/rest/rest_test.go +++ b/rest/rest_test.go @@ -17,6 +17,7 @@ import ( "testing" "github.com/z5labs/bedrock/pkg/ptr" + "github.com/z5labs/bedrock/rest/mux" "github.com/stretchr/testify/assert" "github.com/swaggest/openapi-go/openapi3" @@ -92,6 +93,10 @@ func TestNotFoundHandler(t *testing.T) { enc.Encode(map[string]any{"hello": "world"}) }) + mux := mux.NewHttp( + mux.NotFoundHandler(notFoundHandler), + ) + ls, err := net.Listen("tcp", ":0") if !assert.Nil(t, err) { return @@ -99,13 +104,13 @@ func TestNotFoundHandler(t *testing.T) { app := NewApp( Listener(ls), + WithMux(mux), func(a *App) { if testCase.RegisterPattern == "" { return } Endpoint(http.MethodGet, testCase.RegisterPattern, statusCodeHandler(http.StatusOK))(a) }, - NotFoundHandler(notFoundHandler), ) respCh := make(chan *http.Response, 1) @@ -174,36 +179,36 @@ func TestNotFoundHandler(t *testing.T) { func TestMethodNotAllowedHandler(t *testing.T) { testCases := []struct { Name string - RegisterPatterns map[string]string - Method string + RegisterPatterns map[mux.Method]string + Method mux.Method RequestPath string MethodNotAllowed bool }{ { Name: "should return success response when correct method is used", - RegisterPatterns: map[string]string{ + RegisterPatterns: map[mux.Method]string{ http.MethodGet: "/", }, - Method: http.MethodGet, + Method: mux.MethodGet, RequestPath: "/", MethodNotAllowed: false, }, { Name: "should return success response when more than one method is registered for same path", - RegisterPatterns: map[string]string{ + RegisterPatterns: map[mux.Method]string{ http.MethodGet: "/", http.MethodPost: "/", }, - Method: http.MethodGet, + Method: mux.MethodGet, RequestPath: "/", MethodNotAllowed: false, }, { Name: "should return method not allowed response when incorrect method is used", - RegisterPatterns: map[string]string{ + RegisterPatterns: map[mux.Method]string{ http.MethodGet: "/", }, - Method: http.MethodPost, + Method: mux.MethodPost, RequestPath: "/", MethodNotAllowed: true, }, @@ -218,6 +223,10 @@ func TestMethodNotAllowedHandler(t *testing.T) { enc.Encode(map[string]any{"hello": "world"}) }) + mux := mux.NewHttp( + mux.MethodNotAllowedHandler(methodNotAllowedHandler), + ) + ls, err := net.Listen("tcp", ":0") if !assert.Nil(t, err) { return @@ -225,12 +234,12 @@ func TestMethodNotAllowedHandler(t *testing.T) { app := NewApp( Listener(ls), + WithMux(mux), func(a *App) { for method, pattern := range testCase.RegisterPatterns { Endpoint(method, pattern, statusCodeHandler(http.StatusOK))(a) } }, - MethodNotAllowedHandler(methodNotAllowedHandler), ) respCh := make(chan *http.Response, 1) @@ -246,7 +255,7 @@ func TestMethodNotAllowedHandler(t *testing.T) { addr := ls.Addr() url := fmt.Sprintf("http://%s", path.Join(addr.String(), testCase.RequestPath)) - req, err := http.NewRequestWithContext(egctx, testCase.Method, url, nil) + req, err := http.NewRequestWithContext(egctx, string(testCase.Method), url, nil) if err != nil { return err } From f1532f9ea0bee961394ab0d609f0e87860d42f04 Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Wed, 2 Oct 2024 22:48:08 -0400 Subject: [PATCH 3/4] feat(issue-295): register trailing slash and non-trailing slash endpoints to avoid redirects --- rest/mux/mux.go | 30 ++++++++++++++++++++++++++++++ rest/mux/mux_test.go | 6 ++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/rest/mux/mux.go b/rest/mux/mux.go index 37ebe98..cba5039 100644 --- a/rest/mux/mux.go +++ b/rest/mux/mux.go @@ -9,7 +9,9 @@ package mux import ( "fmt" "net/http" + "path" "slices" + "strings" "sync" ) @@ -72,6 +74,34 @@ func NewHttp(opts ...HttpOption) *Http { func (m *Http) Handle(method Method, pattern string, h http.Handler) { m.pathMethods[pattern] = append(m.pathMethods[pattern], method) m.mux.Handle(fmt.Sprintf("%s %s", method, pattern), h) + + // {$} is a special case where we only want to exact match the path pattern. + if strings.HasSuffix(pattern, "{$}") { + return + } + + if strings.HasSuffix(pattern, "/") { + withoutTrailingSlash := pattern[:len(pattern)-1] + if len(withoutTrailingSlash) == 0 { + return + } + + m.pathMethods[withoutTrailingSlash] = append(m.pathMethods[withoutTrailingSlash], method) + m.mux.Handle(fmt.Sprintf("%s %s", method, withoutTrailingSlash), h) + return + } + + // if the end of the path contains the "..." wildcard segment + // then we can't add a "/" to it since "..." should not be followed + // by a "/", per the http.ServeMux docs. + base := path.Base(pattern) + if strings.Contains(base, "...") { + return + } + + withTrailingSlash := pattern + "/" + m.pathMethods[withTrailingSlash] = append(m.pathMethods[withTrailingSlash], method) + m.mux.Handle(fmt.Sprintf("%s %s", method, withTrailingSlash), h) } // ServeHTTP implements the [http.Handler] interface. diff --git a/rest/mux/mux_test.go b/rest/mux/mux_test.go index 7acb43c..a282b2d 100644 --- a/rest/mux/mux_test.go +++ b/rest/mux/mux_test.go @@ -7,11 +7,9 @@ package mux import ( "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" - "path" "testing" "github.com/stretchr/testify/assert" @@ -92,7 +90,7 @@ func TestNotFoundHandler(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest( http.MethodGet, - fmt.Sprintf("http://%s", path.Join("example.com", testCase.RequestPath)), + "http://example.com"+testCase.RequestPath, nil, ) @@ -189,7 +187,7 @@ func TestMethodNotAllowedHandler(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest( string(testCase.Method), - fmt.Sprintf("http://%s", path.Join("example.com", testCase.RequestPath)), + "http://example.com"+testCase.RequestPath, nil, ) From 85203d9ad2f081e48b4b60e14f626ddf6b7ea5d7 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 3 Oct 2024 02:49:22 +0000 Subject: [PATCH 4/4] chore(docs): updated coverage badge. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8760181..e492dfb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) [![Go Reference](https://pkg.go.dev/badge/github.com/z5labs/bedrock.svg)](https://pkg.go.dev/github.com/z5labs/bedrock) [![Go Report Card](https://goreportcard.com/badge/github.com/z5labs/bedrock)](https://goreportcard.com/report/github.com/z5labs/bedrock) -![Coverage](https://img.shields.io/badge/Coverage-97.0%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-96.5%25-brightgreen) [![build](https://github.com/z5labs/bedrock/actions/workflows/build.yaml/badge.svg)](https://github.com/z5labs/bedrock/actions/workflows/build.yaml) **bedrock provides a minimal, modular and composable foundation for