From fb43aebafdf03fb4eb641aa92924dcbae5f4e8d4 Mon Sep 17 00:00:00 2001 From: Richard Carson Derr Date: Sat, 14 Dec 2024 22:04:55 -0500 Subject: [PATCH] story(bedrock): refactor to only contain base package and middleware package (#339) --- .github/workflows/build.yaml | 13 +- .github/workflows/release.yaml | 18 +- README.md | 2 +- {pkg/app => app}/app.go | 0 {pkg/app => app}/app_example_test.go | 0 {pkg/app => app}/app_test.go | 0 {pkg/app => app}/otel.go | 0 {pkg/app => app}/otel_example_test.go | 0 {pkg/app => app}/otel_test.go | 0 {pkg/appbuilder => appbuilder}/appbuilder.go | 0 .../appbuilder_example_test.go | 0 .../appbuilder_test.go | 0 {pkg/appbuilder => appbuilder}/doc.go | 0 {pkg/appbuilder => appbuilder}/otel.go | 0 {pkg/appbuilder => appbuilder}/otel_test.go | 0 bedrock.go | 2 +- bedrock_example_test.go | 2 +- bedrock_test.go | 2 +- {pkg/config => config}/config.go | 13 +- {pkg/config => config}/config_example_test.go | 0 {pkg/config => config}/config_test.go | 17 - {pkg/config => config}/env.go | 0 {pkg/config => config}/file.go | 0 {pkg/config => config}/file_test.go | 0 {pkg/config => config}/json.go | 4 +- {pkg/config => config}/json_test.go | 2 +- {pkg/config => config}/key/key.go | 0 {pkg/config => config}/map.go | 2 +- {pkg/config => config}/map_test.go | 2 +- {pkg/config => config}/testdata/config.yaml | 0 {pkg/config => config}/text_template.go | 4 +- {pkg/config => config}/text_template_test.go | 0 {pkg/config => config}/yaml.go | 4 +- {pkg/config => config}/yaml_test.go | 2 +- example/custom_framework/README.md | 11 - example/custom_framework/echo/Containerfile | 9 - example/custom_framework/echo/app/app.go | 31 - example/custom_framework/echo/config.yaml | 0 .../custom_framework/echo/endpoint/echo.go | 50 - .../echo/endpoint/echo_test.go | 57 - example/custom_framework/echo/main.go | 16 - .../framework/default_config.yaml | 9 - .../custom_framework/framework/framework.go | 76 -- .../framework/internal/config.go | 28 - .../framework/internal/global/config.go | 14 - .../framework/rest/default_config.yaml | 6 - .../framework/rest/operation.go | 45 - .../custom_framework/framework/rest/rest.go | 224 ---- example/simple_queue/Containerfile | 8 - example/simple_queue/config.yaml | 7 - example/simple_queue/main.go | 79 -- example/simple_rest/Containerfile | 8 - example/simple_rest/app/app.go | 68 -- example/simple_rest/config.yaml | 10 - example/simple_rest/echo/model.go | 14 - example/simple_rest/echo/service.go | 37 - example/simple_rest/main.go | 33 - go.mod | 12 +- go.sum | 67 +- internal/ioutil/ioutil.go | 56 + pkg/health/doc.go | 7 - pkg/health/health.go | 101 -- pkg/health/health_example_test.go | 52 - pkg/health/health_test.go | 147 --- pkg/internal/ioutil/ioutil.go | 59 -- pkg/noop/doc.go | 7 - pkg/noop/noop.go | 26 - pkg/ptr/ptr.go | 22 - queue/common_options.go | 37 - queue/doc.go | 7 - queue/queue.go | 303 ------ queue/queue_example_test.go | 160 --- queue/queue_test.go | 340 ------ rest/doc.go | 7 - rest/endpoint/doc.go | 7 - rest/endpoint/endpoint.go | 457 -------- rest/endpoint/endpoint_test.go | 989 ------------------ rest/endpoint/inject.go | 98 -- rest/endpoint/openapi_test.go | 494 --------- rest/endpoint/request.go | 217 ---- rest/endpoint/request_test.go | 274 ----- rest/endpoint/response.go | 185 ---- rest/endpoint/response_test.go | 187 ---- rest/endpoint/validate.go | 163 --- rest/mux/mux.go | 172 --- rest/mux/mux_test.go | 227 ---- rest/rest.go | 266 ----- rest/rest_example_test.go | 102 -- rest/rest_test.go | 683 ------------ 89 files changed, 77 insertions(+), 6783 deletions(-) rename {pkg/app => app}/app.go (100%) rename {pkg/app => app}/app_example_test.go (100%) rename {pkg/app => app}/app_test.go (100%) rename {pkg/app => app}/otel.go (100%) rename {pkg/app => app}/otel_example_test.go (100%) rename {pkg/app => app}/otel_test.go (100%) rename {pkg/appbuilder => appbuilder}/appbuilder.go (100%) rename {pkg/appbuilder => appbuilder}/appbuilder_example_test.go (100%) rename {pkg/appbuilder => appbuilder}/appbuilder_test.go (100%) rename {pkg/appbuilder => appbuilder}/doc.go (100%) rename {pkg/appbuilder => appbuilder}/otel.go (100%) rename {pkg/appbuilder => appbuilder}/otel_test.go (100%) rename {pkg/config => config}/config.go (92%) rename {pkg/config => config}/config_example_test.go (100%) rename {pkg/config => config}/config_test.go (91%) rename {pkg/config => config}/env.go (100%) rename {pkg/config => config}/file.go (100%) rename {pkg/config => config}/file_test.go (100%) rename {pkg/config => config}/json.go (93%) rename {pkg/config => config}/json_test.go (97%) rename {pkg/config => config}/key/key.go (100%) rename {pkg/config => config}/map.go (98%) rename {pkg/config => config}/map_test.go (98%) rename {pkg/config => config}/testdata/config.yaml (100%) rename {pkg/config => config}/text_template.go (97%) rename {pkg/config => config}/text_template_test.go (100%) rename {pkg/config => config}/yaml.go (93%) rename {pkg/config => config}/yaml_test.go (97%) delete mode 100644 example/custom_framework/README.md delete mode 100644 example/custom_framework/echo/Containerfile delete mode 100644 example/custom_framework/echo/app/app.go delete mode 100644 example/custom_framework/echo/config.yaml delete mode 100644 example/custom_framework/echo/endpoint/echo.go delete mode 100644 example/custom_framework/echo/endpoint/echo_test.go delete mode 100644 example/custom_framework/echo/main.go delete mode 100644 example/custom_framework/framework/default_config.yaml delete mode 100644 example/custom_framework/framework/framework.go delete mode 100644 example/custom_framework/framework/internal/config.go delete mode 100644 example/custom_framework/framework/internal/global/config.go delete mode 100644 example/custom_framework/framework/rest/default_config.yaml delete mode 100644 example/custom_framework/framework/rest/operation.go delete mode 100644 example/custom_framework/framework/rest/rest.go delete mode 100644 example/simple_queue/Containerfile delete mode 100644 example/simple_queue/config.yaml delete mode 100644 example/simple_queue/main.go delete mode 100644 example/simple_rest/Containerfile delete mode 100644 example/simple_rest/app/app.go delete mode 100644 example/simple_rest/config.yaml delete mode 100644 example/simple_rest/echo/model.go delete mode 100644 example/simple_rest/echo/service.go delete mode 100644 example/simple_rest/main.go create mode 100644 internal/ioutil/ioutil.go delete mode 100644 pkg/health/doc.go delete mode 100644 pkg/health/health.go delete mode 100644 pkg/health/health_example_test.go delete mode 100644 pkg/health/health_test.go delete mode 100644 pkg/internal/ioutil/ioutil.go delete mode 100644 pkg/noop/doc.go delete mode 100644 pkg/noop/noop.go delete mode 100644 pkg/ptr/ptr.go delete mode 100644 queue/common_options.go delete mode 100644 queue/doc.go delete mode 100644 queue/queue.go delete mode 100644 queue/queue_example_test.go delete mode 100644 queue/queue_test.go delete mode 100644 rest/doc.go delete mode 100644 rest/endpoint/doc.go delete mode 100644 rest/endpoint/endpoint.go delete mode 100644 rest/endpoint/endpoint_test.go delete mode 100644 rest/endpoint/inject.go delete mode 100644 rest/endpoint/openapi_test.go delete mode 100644 rest/endpoint/request.go delete mode 100644 rest/endpoint/request_test.go delete mode 100644 rest/endpoint/response.go delete mode 100644 rest/endpoint/response_test.go delete mode 100644 rest/endpoint/validate.go delete mode 100644 rest/mux/mux.go delete mode 100644 rest/mux/mux_test.go delete mode 100644 rest/rest.go delete mode 100644 rest/rest_example_test.go delete mode 100644 rest/rest_test.go diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0fab551..477e23b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5 with: - go-version: '1.22' + go-version: '1.23' - name: Lint Go Code uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6 @@ -43,13 +43,4 @@ jobs: run: go build ./... - name: Test - run: go test -race -cover ./... - - - name: Build example container images - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6 - with: - distribution: goreleaser - version: latest - args: release --clean --snapshot - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: go test -race -cover ./... \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4a14d89..7a23aeb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5 with: - go-version: '1.22' + go-version: '1.23' - name: Lint Go Code uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6 @@ -33,19 +33,3 @@ jobs: - name: Test run: go test -race -cover ./... - - - name: Login to GitHub Container Registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build example container images - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6 - with: - distribution: goreleaser - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 31670af..8df3f5d 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-94.2%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-98.6%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 diff --git a/pkg/app/app.go b/app/app.go similarity index 100% rename from pkg/app/app.go rename to app/app.go diff --git a/pkg/app/app_example_test.go b/app/app_example_test.go similarity index 100% rename from pkg/app/app_example_test.go rename to app/app_example_test.go diff --git a/pkg/app/app_test.go b/app/app_test.go similarity index 100% rename from pkg/app/app_test.go rename to app/app_test.go diff --git a/pkg/app/otel.go b/app/otel.go similarity index 100% rename from pkg/app/otel.go rename to app/otel.go diff --git a/pkg/app/otel_example_test.go b/app/otel_example_test.go similarity index 100% rename from pkg/app/otel_example_test.go rename to app/otel_example_test.go diff --git a/pkg/app/otel_test.go b/app/otel_test.go similarity index 100% rename from pkg/app/otel_test.go rename to app/otel_test.go diff --git a/pkg/appbuilder/appbuilder.go b/appbuilder/appbuilder.go similarity index 100% rename from pkg/appbuilder/appbuilder.go rename to appbuilder/appbuilder.go diff --git a/pkg/appbuilder/appbuilder_example_test.go b/appbuilder/appbuilder_example_test.go similarity index 100% rename from pkg/appbuilder/appbuilder_example_test.go rename to appbuilder/appbuilder_example_test.go diff --git a/pkg/appbuilder/appbuilder_test.go b/appbuilder/appbuilder_test.go similarity index 100% rename from pkg/appbuilder/appbuilder_test.go rename to appbuilder/appbuilder_test.go diff --git a/pkg/appbuilder/doc.go b/appbuilder/doc.go similarity index 100% rename from pkg/appbuilder/doc.go rename to appbuilder/doc.go diff --git a/pkg/appbuilder/otel.go b/appbuilder/otel.go similarity index 100% rename from pkg/appbuilder/otel.go rename to appbuilder/otel.go diff --git a/pkg/appbuilder/otel_test.go b/appbuilder/otel_test.go similarity index 100% rename from pkg/appbuilder/otel_test.go rename to appbuilder/otel_test.go diff --git a/bedrock.go b/bedrock.go index aac6697..03ee81e 100644 --- a/bedrock.go +++ b/bedrock.go @@ -10,7 +10,7 @@ import ( "errors" "fmt" - "github.com/z5labs/bedrock/pkg/config" + "github.com/z5labs/bedrock/config" ) // App represents the entry point for user specific code. diff --git a/bedrock_example_test.go b/bedrock_example_test.go index f32616d..07ae11d 100644 --- a/bedrock_example_test.go +++ b/bedrock_example_test.go @@ -10,7 +10,7 @@ import ( "fmt" "strings" - "github.com/z5labs/bedrock/pkg/config" + "github.com/z5labs/bedrock/config" ) type appFunc func(context.Context) error diff --git a/bedrock_test.go b/bedrock_test.go index f813bf8..5d7b8c6 100644 --- a/bedrock_test.go +++ b/bedrock_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "github.com/z5labs/bedrock/pkg/config" + "github.com/z5labs/bedrock/config" "github.com/stretchr/testify/assert" ) diff --git a/pkg/config/config.go b/config/config.go similarity index 92% rename from pkg/config/config.go rename to config/config.go index 34fe97d..653cd85 100644 --- a/pkg/config/config.go +++ b/config/config.go @@ -13,7 +13,7 @@ import ( "reflect" "time" - "github.com/z5labs/bedrock/pkg/config/key" + "github.com/z5labs/bedrock/config/key" "github.com/go-viper/mapstructure/v2" ) @@ -31,7 +31,7 @@ type Source interface { // Manager type Manager struct { - store Map + store Store } // Read @@ -41,10 +41,6 @@ func Read(srcs ...Source) (*Manager, error) { return &Manager{store: make(Map)}, nil } - if m, ok := srcs[0].(*Manager); len(srcs) == 1 && ok { - return m, nil - } - store := make(Map) for _, src := range srcs { err := src.Apply(store) @@ -58,11 +54,6 @@ func Read(srcs ...Source) (*Manager, error) { return m, nil } -// Apply implements the [Source] interface. -func (m *Manager) Apply(store Store) error { - return m.store.Apply(store) -} - // Unmarshal func (m *Manager) Unmarshal(v any) error { dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ diff --git a/pkg/config/config_example_test.go b/config/config_example_test.go similarity index 100% rename from pkg/config/config_example_test.go rename to config/config_example_test.go diff --git a/pkg/config/config_test.go b/config/config_test.go similarity index 91% rename from pkg/config/config_test.go rename to config/config_test.go index bb7beb2..fca8019 100644 --- a/pkg/config/config_test.go +++ b/config/config_test.go @@ -73,23 +73,6 @@ func TestRead(t *testing.T) { } }) }) - - t.Run("will be idempotent", func(t *testing.T) { - t.Run("if a single Manager is used as the source", func(t *testing.T) { - m, err := Read(FromYaml(strings.NewReader("hello: world"))) - if !assert.Nil(t, err) { - return - } - - m2, err := Read(m) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, m, m2) { - return - } - }) - }) } type Custom struct { diff --git a/pkg/config/env.go b/config/env.go similarity index 100% rename from pkg/config/env.go rename to config/env.go diff --git a/pkg/config/file.go b/config/file.go similarity index 100% rename from pkg/config/file.go rename to config/file.go diff --git a/pkg/config/file_test.go b/config/file_test.go similarity index 100% rename from pkg/config/file_test.go rename to config/file_test.go diff --git a/pkg/config/json.go b/config/json.go similarity index 93% rename from pkg/config/json.go rename to config/json.go index 92fc716..045f1c1 100644 --- a/pkg/config/json.go +++ b/config/json.go @@ -11,7 +11,7 @@ import ( "fmt" "io" - "github.com/z5labs/bedrock/pkg/internal/ioutil" + "github.com/z5labs/bedrock/internal/ioutil" ) // Json represents a Source where its underlying format is JSON. @@ -42,7 +42,7 @@ func (e InvalidJsonError) Unwrap() error { // Apply implements the Source interface. func (src Json) Apply(store Store) error { - b, err := ioutil.ReadAllAndClose(src.r) + b, err := ioutil.ReadAllAndTryClose(src.r) if err != nil && !errors.Is(err, ioutil.CloseError{}) { // We can ignore ioutil.CloseError because we've successfully // read the file contents and closing is just a nice clean up diff --git a/pkg/config/json_test.go b/config/json_test.go similarity index 97% rename from pkg/config/json_test.go rename to config/json_test.go index ae046d3..2d8703b 100644 --- a/pkg/config/json_test.go +++ b/config/json_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/z5labs/bedrock/pkg/config/key" + "github.com/z5labs/bedrock/config/key" "github.com/stretchr/testify/assert" ) diff --git a/pkg/config/key/key.go b/config/key/key.go similarity index 100% rename from pkg/config/key/key.go rename to config/key/key.go diff --git a/pkg/config/map.go b/config/map.go similarity index 98% rename from pkg/config/map.go rename to config/map.go index c112d87..db1cab9 100644 --- a/pkg/config/map.go +++ b/config/map.go @@ -8,7 +8,7 @@ package config import ( "fmt" - "github.com/z5labs/bedrock/pkg/config/key" + "github.com/z5labs/bedrock/config/key" ) // Map is an ordinary map[string]any but implements the [Store] and [Source] interfaces. diff --git a/pkg/config/map_test.go b/config/map_test.go similarity index 98% rename from pkg/config/map_test.go rename to config/map_test.go index 57c441b..701e42c 100644 --- a/pkg/config/map_test.go +++ b/config/map_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/z5labs/bedrock/pkg/config/key" + "github.com/z5labs/bedrock/config/key" "github.com/stretchr/testify/assert" ) diff --git a/pkg/config/testdata/config.yaml b/config/testdata/config.yaml similarity index 100% rename from pkg/config/testdata/config.yaml rename to config/testdata/config.yaml diff --git a/pkg/config/text_template.go b/config/text_template.go similarity index 97% rename from pkg/config/text_template.go rename to config/text_template.go index 4c0bc72..f95bcf0 100644 --- a/pkg/config/text_template.go +++ b/config/text_template.go @@ -14,7 +14,7 @@ import ( "sync" "text/template" - "github.com/z5labs/bedrock/pkg/internal/ioutil" + "github.com/z5labs/bedrock/internal/ioutil" ) // RenderTextTemplateOption represents options for configuring the TextTemplateRenderer. @@ -98,7 +98,7 @@ func (ttr *TextTemplateRenderer) Read(b []byte) (int, error) { var err error ttr.renderOnce.Do(func() { var sb strings.Builder - _, err = ioutil.CopyAndClose(&sb, ttr.r) + _, err = ioutil.CopyAndTryClose(&sb, ttr.r) if err != nil && !errors.Is(err, ioutil.CloseError{}) { // We can ignore ioutil.CloseError because we've successfully // read the file contents and closing is just a nice clean up diff --git a/pkg/config/text_template_test.go b/config/text_template_test.go similarity index 100% rename from pkg/config/text_template_test.go rename to config/text_template_test.go diff --git a/pkg/config/yaml.go b/config/yaml.go similarity index 93% rename from pkg/config/yaml.go rename to config/yaml.go index 182cf5c..9f4c158 100644 --- a/pkg/config/yaml.go +++ b/config/yaml.go @@ -10,7 +10,7 @@ import ( "fmt" "io" - "github.com/z5labs/bedrock/pkg/internal/ioutil" + "github.com/z5labs/bedrock/internal/ioutil" "gopkg.in/yaml.v3" ) @@ -43,7 +43,7 @@ func (e InvalidYamlError) Unwrap() error { // Apply implements the Source interface. func (src Yaml) Apply(store Store) error { - b, err := ioutil.ReadAllAndClose(src.r) + b, err := ioutil.ReadAllAndTryClose(src.r) if err != nil && !errors.Is(err, ioutil.CloseError{}) { // We can ignore ioutil.CloseError because we've successfully // read the file contents and closing is just a nice clean up diff --git a/pkg/config/yaml_test.go b/config/yaml_test.go similarity index 97% rename from pkg/config/yaml_test.go rename to config/yaml_test.go index 059503e..875c63b 100644 --- a/pkg/config/yaml_test.go +++ b/config/yaml_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/z5labs/bedrock/pkg/config/key" + "github.com/z5labs/bedrock/config/key" "github.com/stretchr/testify/assert" ) diff --git a/example/custom_framework/README.md b/example/custom_framework/README.md deleted file mode 100644 index 286a270..0000000 --- a/example/custom_framework/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Custom Framework - -In this example, bedrock is used to implement a custom -framework for quickly building a RESTful API service. - -In order to run this example, use the following command: -```bash -podman run ghcr.io/z5labs/bedrock/example/custom_framework/echo:latest -# or -docker run ghcr.io/z5labs/bedrock/example/custom_framework/echo:latest -``` \ No newline at end of file diff --git a/example/custom_framework/echo/Containerfile b/example/custom_framework/echo/Containerfile deleted file mode 100644 index f184dc2..0000000 --- a/example/custom_framework/echo/Containerfile +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2023 Z5Labs and Contributors -# -# This software is released under the MIT License. -# https://opensource.org/licenses/MIT - -FROM scratch -EXPOSE 8080 -COPY echo / -ENTRYPOINT ["/echo"] \ No newline at end of file diff --git a/example/custom_framework/echo/app/app.go b/example/custom_framework/echo/app/app.go deleted file mode 100644 index 1d62765..0000000 --- a/example/custom_framework/echo/app/app.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package app - -import ( - "context" - - "github.com/z5labs/bedrock/example/custom_framework/echo/endpoint" - - "github.com/z5labs/bedrock/example/custom_framework/framework" - "github.com/z5labs/bedrock/example/custom_framework/framework/rest" -) - -type Config struct { - rest.Config `config:",squash"` -} - -func Init(ctx context.Context, cfg Config) (framework.App, error) { - log := framework.Logger(cfg.Logging) - - app := rest.NewApp( - rest.OpenApi(cfg.OpenApi), - rest.OTel(cfg.OTel), - rest.HttpServer(cfg.Http), - rest.WithEndpoint(endpoint.Echo(log)), - ) - return app, nil -} diff --git a/example/custom_framework/echo/config.yaml b/example/custom_framework/echo/config.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/example/custom_framework/echo/endpoint/echo.go b/example/custom_framework/echo/endpoint/echo.go deleted file mode 100644 index 757d3ea..0000000 --- a/example/custom_framework/echo/endpoint/echo.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "log/slog" - "net/http" - - "github.com/z5labs/bedrock/example/custom_framework/framework/rest" - - "github.com/z5labs/bedrock/rest/endpoint" -) - -type echoHandler struct { - log *slog.Logger -} - -func Echo(log *slog.Logger) rest.Endpoint { - h := &echoHandler{ - log: log, - } - - return rest.Endpoint{ - Method: http.MethodPost, - Path: "/echo", - Operation: rest.NewOperation( - endpoint.ConsumesJson( - endpoint.ProducesJson(h), - ), - ), - } -} - -type EchoRequest struct { - Msg string `json:"msg"` -} - -type EchoResponse struct { - Msg string `json:"msg"` -} - -func (h *echoHandler) Handle(ctx context.Context, req *EchoRequest) (*EchoResponse, error) { - h.log.InfoContext(ctx, "echoing back received message to client", slog.String("echo_msg", req.Msg)) - resp := &EchoResponse{Msg: req.Msg} - return resp, nil -} diff --git a/example/custom_framework/echo/endpoint/echo_test.go b/example/custom_framework/echo/endpoint/echo_test.go deleted file mode 100644 index 3b23d2c..0000000 --- a/example/custom_framework/echo/endpoint/echo_test.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestEcho(t *testing.T) { - t.Run("will echo message back", func(t *testing.T) { - t.Run("always", func(t *testing.T) { - e := Echo(slog.Default()) - - req := `{"msg": "hello world"}` - - w := httptest.NewRecorder() - r := httptest.NewRequest( - http.MethodPost, - "/echo", - strings.NewReader(req), - ) - r.Header.Set("Content-Type", "application/json") - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, http.StatusOK, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var echoResp EchoResponse - err = json.Unmarshal(b, &echoResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello world", echoResp.Msg) { - return - } - }) - }) -} diff --git a/example/custom_framework/echo/main.go b/example/custom_framework/echo/main.go deleted file mode 100644 index 77e39b4..0000000 --- a/example/custom_framework/echo/main.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "bytes" - _ "embed" - - "github.com/z5labs/bedrock/example/custom_framework/echo/app" - "github.com/z5labs/bedrock/example/custom_framework/framework" -) - -//go:embed config.yaml -var cfgSrc []byte - -func main() { - framework.Run(bytes.NewReader(cfgSrc), app.Init) -} diff --git a/example/custom_framework/framework/default_config.yaml b/example/custom_framework/framework/default_config.yaml deleted file mode 100644 index a08a375..0000000 --- a/example/custom_framework/framework/default_config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -otel: - service_name: {{env "SERVICE_NAME"}} - service_version: {{env "SERVICE_VERSION"}} - otlp: - target: {{env "OTLP_ADDR"}} - -logging: - level: {{env "LOG_LEVEL" | default "INFO"}} - diff --git a/example/custom_framework/framework/framework.go b/example/custom_framework/framework/framework.go deleted file mode 100644 index 1a8b06a..0000000 --- a/example/custom_framework/framework/framework.go +++ /dev/null @@ -1,76 +0,0 @@ -package framework - -import ( - "bytes" - "context" - _ "embed" - "io" - "log/slog" - "os" - "sync" - - "github.com/z5labs/bedrock/example/custom_framework/framework/internal" - "github.com/z5labs/bedrock/example/custom_framework/framework/internal/global" - - "github.com/z5labs/bedrock" -) - -//go:embed default_config.yaml -var configBytes []byte - -func init() { - global.RegisterConfigSource(internal.ConfigSource(bytes.NewReader(configBytes))) -} - -type OTelConfig struct { - ServiceName string `config:"service_name"` - ServiceVersion string `config:"service_version"` - OTLP struct { - Target string `config:"target"` - } `config:"otlp"` -} - -type LoggingConfig struct { - Level slog.Level `config:"level"` -} - -type Config struct { - OTel OTelConfig `config:"otel"` - Logging LoggingConfig `config:"logging"` -} - -var logger *slog.Logger -var initLoggerOnce sync.Once - -func Logger(cfg LoggingConfig) *slog.Logger { - initLoggerOnce.Do(func() { - logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - AddSource: true, - Level: cfg.Level, - })) - }) - return logger -} - -type App bedrock.App - -func Run[T any](r io.Reader, build func(context.Context, T) (App, error)) { - err := bedrock.Run( - context.Background(), - bedrock.AppBuilderFunc[T](func(ctx context.Context, cfg T) (bedrock.App, error) { - return build(ctx, cfg) - }), - global.ConfigSources..., - ) - if err == nil { - return - } - - // there's a chance Run failed on config parsing/unmarshalling - // thus the logging config is most likely unusable and we should - // instead create our own logger here for logging this error - log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - AddSource: true, - })) - log.Error("failed while running application", slog.String("error", err.Error())) -} diff --git a/example/custom_framework/framework/internal/config.go b/example/custom_framework/framework/internal/config.go deleted file mode 100644 index ed84bf1..0000000 --- a/example/custom_framework/framework/internal/config.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package internal - -import ( - "io" - "os" - - "github.com/z5labs/bedrock/pkg/config" -) - -func ConfigSource(r io.Reader) config.Source { - return config.FromYaml( - config.RenderTextTemplate( - r, - config.TemplateFunc("env", os.Getenv), - config.TemplateFunc("default", func(s string, v any) any { - if len(s) == 0 { - return v - } - return s - }), - ), - ) -} diff --git a/example/custom_framework/framework/internal/global/config.go b/example/custom_framework/framework/internal/global/config.go deleted file mode 100644 index bffe9bc..0000000 --- a/example/custom_framework/framework/internal/global/config.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package global - -import "github.com/z5labs/bedrock/pkg/config" - -var ConfigSources []config.Source - -func RegisterConfigSource(src config.Source) { - ConfigSources = append(ConfigSources, src) -} diff --git a/example/custom_framework/framework/rest/default_config.yaml b/example/custom_framework/framework/rest/default_config.yaml deleted file mode 100644 index e0e46df..0000000 --- a/example/custom_framework/framework/rest/default_config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -openapi: - title: {{env "SERVICE_NAME"}} - version: {{env "SERVICE_VERSION"}} - -http: - port: {{env "HTTP_PORT" | default "8080"}} \ No newline at end of file diff --git a/example/custom_framework/framework/rest/operation.go b/example/custom_framework/framework/rest/operation.go deleted file mode 100644 index b296965..0000000 --- a/example/custom_framework/framework/rest/operation.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package rest - -import ( - "github.com/z5labs/bedrock/rest" - "github.com/z5labs/bedrock/rest/endpoint" -) - -type Operation rest.Operation - -type OperationHandler[Req, Resp any] endpoint.Handler[Req, Resp] - -type operationConfig struct { - headers []endpoint.Header -} - -type OperationOption func(*operationConfig) - -func Header(name string, required bool, pattern string) OperationOption { - return func(oc *operationConfig) { - oc.headers = append(oc.headers, endpoint.Header{ - Name: name, - Required: required, - Pattern: pattern, - }) - } -} - -func NewOperation[I, O any, Req endpoint.Request[I], Resp endpoint.Response[O]](h OperationHandler[I, O], opts ...OperationOption) rest.Operation { - opOpts := &operationConfig{} - for _, opt := range opts { - opt(opOpts) - } - - var endpointOpts []endpoint.Option - if len(opOpts.headers) > 0 { - endpointOpts = append(endpointOpts, endpoint.Headers(opOpts.headers...)) - } - - return endpoint.NewOperation[I, O, Req, Resp](h, endpointOpts...) -} diff --git a/example/custom_framework/framework/rest/rest.go b/example/custom_framework/framework/rest/rest.go deleted file mode 100644 index df53199..0000000 --- a/example/custom_framework/framework/rest/rest.go +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package rest - -import ( - "bytes" - "context" - _ "embed" - "fmt" - "net" - "net/http" - "os" - "strings" - - "github.com/z5labs/bedrock/example/custom_framework/framework" - "github.com/z5labs/bedrock/example/custom_framework/framework/internal" - "github.com/z5labs/bedrock/example/custom_framework/framework/internal/global" - - "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" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.26.0" - "go.opentelemetry.io/otel/trace" -) - -//go:embed default_config.yaml -var configBytes []byte - -func init() { - global.RegisterConfigSource(internal.ConfigSource(bytes.NewReader(configBytes))) -} - -type OpenApiConfig struct { - Title string `config:"title"` - Version string `config:"version"` -} - -type HttpServerConfig struct { - Port uint `config:"port"` -} - -type Config struct { - framework.Config `config:",squash"` - - OpenApi OpenApiConfig `config:"openapi"` - - Http HttpServerConfig `config:"http"` -} - -type Option func(*App) - -func OpenApi(cfg OpenApiConfig) Option { - return func(ra *App) { - ra.restOpts = append( - ra.restOpts, - rest.Title(cfg.Title), - rest.Version(cfg.Version), - ) - } -} - -func OTel(cfg framework.OTelConfig) Option { - return func(ra *App) { - ra.otelOpts = append( - ra.otelOpts, - app.OTelTextMapPropogator(func(ctx context.Context) (propagation.TextMapPropagator, error) { - tmp := propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{}) - return tmp, nil - }), - app.OTelTracerProvider(func(ctx context.Context) (trace.TracerProvider, error) { - // framework.Logger will return a logger that writes to STDOUT - // so we'll just send traces to STDERR for demo purposes. - exp, err := stdouttrace.New( - stdouttrace.WithWriter(os.Stderr), - ) - if err != nil { - return nil, err - } - - r, err := resource.Detect( - ctx, - resourceDetectFunc(func(ctx context.Context) (*resource.Resource, error) { - return resource.Default(), nil - }), - resource.StringDetector(semconv.SchemaURL, semconv.ServiceNameKey, func() (string, error) { - return cfg.ServiceName, nil - }), - resource.StringDetector(semconv.SchemaURL, semconv.ServiceVersionKey, func() (string, error) { - return cfg.ServiceVersion, nil - }), - ) - if err != nil { - return nil, err - } - - tp := sdktrace.NewTracerProvider( - sdktrace.WithBatcher(exp), - sdktrace.WithResource(r), - sdktrace.WithSampler(sdktrace.AlwaysSample()), - ) - ra.postRunHooks = append(ra.postRunHooks, shutdownHook(tp)) - - return tp, nil - }), - ) - } -} - -func HttpServer(cfg HttpServerConfig) Option { - return func(ra *App) { - ra.port = cfg.Port - } -} - -type Endpoint struct { - Method mux.Method - Path string - Operation Operation -} - -// ServeHTTP implements the http.Handler interface by simply just calling -// ServeHTTP on the Operation for the Endpoint. This method is only implemented -// as a convenience to simplify unit testing. -func (e Endpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { - e.Operation.ServeHTTP(w, r) -} - -func WithEndpoint(e Endpoint) Option { - return func(ra *App) { - ra.restOpts = append(ra.restOpts, rest.Register(rest.Endpoint{ - Method: e.Method, - Pattern: e.Path, - Operation: e.Operation, - })) - } -} - -type App struct { - port uint - restOpts []rest.Option - otelOpts []app.OTelOption - postRunHooks []app.LifecycleHook -} - -func NewApp(opts ...Option) *App { - ra := &App{} - for _, opt := range opts { - opt(ra) - } - return ra -} - -type resourceDetectFunc func(context.Context) (*resource.Resource, error) - -func (f resourceDetectFunc) Detect(ctx context.Context) (*resource.Resource, error) { - return f(ctx) -} - -func (ra *App) Run(ctx context.Context) error { - ls, err := net.Listen("tcp", fmt.Sprintf(":%d", ra.port)) - if err != nil { - return err - } - ra.restOpts = append(ra.restOpts, rest.Listener(ls)) - - var base bedrock.App = rest.NewApp(ra.restOpts...) - - base = app.WithLifecycleHooks(base, app.Lifecycle{ - PostRun: composePostRunHooks(ra.postRunHooks...), - }) - - base = app.WithOTel(base, ra.otelOpts...) - - base = app.WithSignalNotifications(base, os.Interrupt, os.Kill) - - return base.Run(ctx) -} - -type multiErr []error - -func (e multiErr) Error() string { - var sb strings.Builder - sb.WriteString("captured error(s):\n") - for _, err := range e { - sb.WriteString("\t- ") - sb.WriteString(err.Error()) - sb.WriteByte('\n') - } - return sb.String() -} - -func composePostRunHooks(hooks ...app.LifecycleHook) app.LifecycleHook { - return app.LifecycleHookFunc(func(ctx context.Context) error { - var me multiErr - for _, hook := range hooks { - err := hook.Run(ctx) - if err != nil { - me = append(me, err) - } - } - if len(me) == 0 { - return nil - } - return me - }) -} - -type shutdown interface { - Shutdown(context.Context) error -} - -func shutdownHook(s shutdown) app.LifecycleHook { - return app.LifecycleHookFunc(func(ctx context.Context) error { - return s.Shutdown(ctx) - }) -} diff --git a/example/simple_queue/Containerfile b/example/simple_queue/Containerfile deleted file mode 100644 index 750a6a0..0000000 --- a/example/simple_queue/Containerfile +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2023 Z5Labs and Contributors -# -# This software is released under the MIT License. -# https://opensource.org/licenses/MIT - -FROM scratch -COPY simple_queue / -ENTRYPOINT ["/simple_queue"] \ No newline at end of file diff --git a/example/simple_queue/config.yaml b/example/simple_queue/config.yaml deleted file mode 100644 index 0d0f51a..0000000 --- a/example/simple_queue/config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) 2024 Z5Labs and Contributors -# -# This software is released under the MIT License. -# https://opensource.org/licenses/MIT - -logging: - level: info \ No newline at end of file diff --git a/example/simple_queue/main.go b/example/simple_queue/main.go deleted file mode 100644 index 7ed41d2..0000000 --- a/example/simple_queue/main.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package main - -import ( - "context" - "embed" - "fmt" - "log/slog" - "os" - - "github.com/z5labs/bedrock" - "github.com/z5labs/bedrock/pkg/app" - "github.com/z5labs/bedrock/pkg/config" - "github.com/z5labs/bedrock/queue" -) - -type intGenerator struct { - n int -} - -func (g *intGenerator) Consume(ctx context.Context) (int, error) { - g.n += 1 - return g.n, nil -} - -type evenOrOdd struct{} - -func (p evenOrOdd) Process(ctx context.Context, n int) error { - if n%2 == 0 { - fmt.Println("even") - return nil - } - fmt.Println("odd") - return nil -} - -type Config struct { - Logging struct { - Level slog.Level `config:"level"` - } `config:"logging"` -} - -func initRuntime(ctx context.Context, cfg Config) (bedrock.App, error) { - logHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ - AddSource: true, - Level: cfg.Logging.Level, - }) - - consumer := &intGenerator{n: 0} - - processor := evenOrOdd{} - - rt := queue.Sequential[int]( - consumer, - processor, - queue.LogHandler(logHandler), - ) - return app.WithSignalNotifications(rt, os.Interrupt, os.Kill), nil -} - -//go:embed config.yaml -var configDir embed.FS - -func main() { - err := bedrock.Run( - context.Background(), - bedrock.AppBuilderFunc[Config](initRuntime), - config.FromYaml( - config.NewFileReader(configDir, "config.yaml"), - ), - ) - if err != nil { - slog.Default().Error("failed to run", slog.String("error", err.Error())) - } -} diff --git a/example/simple_rest/Containerfile b/example/simple_rest/Containerfile deleted file mode 100644 index 3a7442e..0000000 --- a/example/simple_rest/Containerfile +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2023 Z5Labs and Contributors -# -# This software is released under the MIT License. -# https://opensource.org/licenses/MIT - -FROM scratch -COPY simple_rest / -ENTRYPOINT ["/simple_rest"] \ No newline at end of file diff --git a/example/simple_rest/app/app.go b/example/simple_rest/app/app.go deleted file mode 100644 index e56da7b..0000000 --- a/example/simple_rest/app/app.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package app - -import ( - "context" - "fmt" - "log/slog" - "net" - "net/http" - "os" - - "github.com/z5labs/bedrock" - "github.com/z5labs/bedrock/example/simple_rest/echo" - "github.com/z5labs/bedrock/pkg/app" - "github.com/z5labs/bedrock/rest" - "github.com/z5labs/bedrock/rest/endpoint" -) - -type Config struct { - Logging struct { - Level slog.Level `config:"level"` - } `config:"logging"` - - Http struct { - Port uint `config:"port"` - } `config:"http"` -} - -func Init(ctx context.Context, cfg Config) (bedrock.App, error) { - logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: cfg.Logging.Level, - AddSource: true, - }) - - echoService := echo.NewService( - echo.LogHandler(logHandler), - ) - - ls, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Http.Port)) - if err != nil { - return nil, err - } - - restApp := rest.NewApp( - rest.Listener(ls), - rest.Register(rest.Endpoint{ - Method: http.MethodPost, - Pattern: "/echo", - Operation: endpoint.NewOperation( - endpoint.ConsumesJson( - endpoint.ProducesJson(echoService), - ), - endpoint.Headers( - endpoint.Header{ - Name: "Authorization", - }, - ), - ), - }), - ) - - app := app.WithSignalNotifications(restApp, os.Interrupt, os.Kill) - return app, nil -} diff --git a/example/simple_rest/config.yaml b/example/simple_rest/config.yaml deleted file mode 100644 index 2135b3f..0000000 --- a/example/simple_rest/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2024 Z5Labs and Contributors -# -# This software is released under the MIT License. -# https://opensource.org/licenses/MIT - -logging: - level: info - -http: - port: 8080 \ No newline at end of file diff --git a/example/simple_rest/echo/model.go b/example/simple_rest/echo/model.go deleted file mode 100644 index e87eecb..0000000 --- a/example/simple_rest/echo/model.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package echo - -type Request struct { - Msg string `json:"msg"` -} - -type Response struct { - Msg string `json:"msg"` -} diff --git a/example/simple_rest/echo/service.go b/example/simple_rest/echo/service.go deleted file mode 100644 index 30f2c47..0000000 --- a/example/simple_rest/echo/service.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package echo - -import ( - "context" - "log/slog" -) - -type Option func(*Service) - -func LogHandler(h slog.Handler) Option { - return func(s *Service) { - s.log = slog.New(h) - } -} - -type Service struct { - log *slog.Logger -} - -func NewService(opts ...Option) *Service { - s := &Service{} - for _, opt := range opts { - opt(s) - } - return s -} - -func (s *Service) Handle(ctx context.Context, req *Request) (*Response, error) { - s.log.InfoContext(ctx, "echoing back to client", slog.String("msg", req.Msg)) - - return &Response{Msg: req.Msg}, nil -} diff --git a/example/simple_rest/main.go b/example/simple_rest/main.go deleted file mode 100644 index e946aab..0000000 --- a/example/simple_rest/main.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package main - -import ( - "context" - "embed" - "log/slog" - - "github.com/z5labs/bedrock/example/simple_rest/app" - - "github.com/z5labs/bedrock" - "github.com/z5labs/bedrock/pkg/config" -) - -//go:embed config.yaml -var configDir embed.FS - -func main() { - err := bedrock.Run( - context.Background(), - bedrock.AppBuilderFunc[app.Config](app.Init), - config.FromYaml( - config.NewFileReader(configDir, "config.yaml"), - ), - ) - if err != nil { - slog.Default().Error("failed to run", slog.String("error", err.Error())) - } -} diff --git a/go.mod b/go.mod index 6e476ab..0c3a43d 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,11 @@ module github.com/z5labs/bedrock -go 1.22.0 +go 1.23.0 require ( github.com/go-viper/mapstructure/v2 v2.2.1 github.com/stretchr/testify v1.10.0 - github.com/swaggest/jsonschema-go v0.3.72 - github.com/swaggest/openapi-go v0.2.54 go.opentelemetry.io/contrib/bridges/otelslog v0.8.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/otel v1.33.0 go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.9.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 @@ -19,22 +16,15 @@ require ( go.opentelemetry.io/otel/sdk/log v0.9.0 go.opentelemetry.io/otel/sdk/metric v1.33.0 go.opentelemetry.io/otel/trace v1.33.0 - golang.org/x/sync v0.10.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect - github.com/swaggest/refl v1.3.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect golang.org/x/sys v0.28.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index a230ebe..f2c629c 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,5 @@ -github.com/bool64/dev v0.2.35 h1:M17TLsO/pV2J7PYI/gpe3Ua26ETkzZGb+dC06eoMqlk= -github.com/bool64/dev v0.2.35/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -18,102 +11,44 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.72 h1:IHaGlR1bdBUBPfhe4tfacN2TGAPKENEGiNyNzvnVHv4= -github.com/swaggest/jsonschema-go v0.3.72/go.mod h1:OrGyEoVqpfSFJ4Am4V/FQcQ3mlEC1vVeleA+5ggbVW4= -github.com/swaggest/openapi-go v0.2.54 h1:WnFKIHAgR2RIOiYys3qvSuYmsFd2a17MIoC9Tcvog5c= -github.com/swaggest/openapi-go v0.2.54/go.mod h1:2Q7NpuG9NgpGeTaNOo852GSR6cCzSP4IznA9DNdUTQw= -github.com/swaggest/refl v1.3.0 h1:PEUWIku+ZznYfsoyheF97ypSduvMApYyGkYF3nabS0I= -github.com/swaggest/refl v1.3.0/go.mod h1:3Ujvbmh1pfSbDYjC6JGG7nMgPvpG0ehQL4iNonnLNbg= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/bridges/otelslog v0.7.0 h1:uLoBPCQtxi5eFRryx5yd3DTxOKRQSils1VJUKjFnlSc= -go.opentelemetry.io/contrib/bridges/otelslog v0.7.0/go.mod h1:1nWHCQN5JjEeWriWKuEY9Zycy0P8OHaPV64KudYbaKw= go.opentelemetry.io/contrib/bridges/otelslog v0.8.0 h1:G3sKsNueSdxuACINFxKrQeimAIst0A5ytA2YJH+3e1c= go.opentelemetry.io/contrib/bridges/otelslog v0.8.0/go.mod h1:ptJm3wizguEPurZgarDAwOeX7O0iMR7l+QvIVenhYdE= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= -go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.9.0 h1:iI15wfQb5ZtAVTdS5WROxpYmw6Kjez3hT9SuzXhrgGQ= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.9.0/go.mod h1:yepwlNzVVxHWR5ugHIrll+euPQPq4pvysHTDr/daV9o= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 h1:FiOTYABOX4tdzi8A0+mtzcsTmi6WBOxk66u0f1Mj9Gs= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0/go.mod h1:xyo5rS8DgzV0Jtsht+LCEMwyiDbjpsxBpWETwFRF0/4= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 h1:W5AWUn/IVe8RFb5pZx1Uh9Laf/4+Qmm4kJL5zPuvR+0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0/go.mod h1:mzKxJywMNBdEX8TSJais3NnsVZUaJ+bAy6UxPTng2vk= -go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= -go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/log v0.9.0 h1:0OiWRefqJ2QszpCiqwGO0u9ajMPe17q6IscQvvp3czY= go.opentelemetry.io/otel/log v0.9.0/go.mod h1:WPP4OJ+RBkQ416jrFCQFuFKtXKD6mOoYCQm6ykK8VaU= -go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= -go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= -go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= -go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= go.opentelemetry.io/otel/sdk/log v0.9.0 h1:YPCi6W1Eg0vwT/XJWsv2/PaQ2nyAJYuF7UUjQSBe3bc= go.opentelemetry.io/otel/sdk/log v0.9.0/go.mod h1:y0HdrOz7OkXQBuc2yjiqnEHc+CRKeVhRE3hx4RwTmV4= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= go.opentelemetry.io/otel/sdk/metric v1.33.0 h1:Gs5VK9/WUJhNXZgn8MR6ITatvAmKeIuCtNbsP3JkNqU= go.opentelemetry.io/otel/sdk/metric v1.33.0/go.mod h1:dL5ykHZmm1B1nVRk9dDjChwDmt81MjVp3gLkQRwKf/Q= -go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= -go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ioutil/ioutil.go b/internal/ioutil/ioutil.go new file mode 100644 index 0000000..da07a76 --- /dev/null +++ b/internal/ioutil/ioutil.go @@ -0,0 +1,56 @@ +// Copyright (c) 2024 Z5Labs and Contributors +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +package ioutil + +import ( + "errors" + "fmt" + "io" +) + +type CloseError struct { + Cause error +} + +func (e CloseError) Error() string { + return fmt.Sprintf("failed to close reader: %s", e.Cause) +} + +func (e CloseError) Unwrap() error { + return e.Cause +} + +// ReadAllAndTryClose +func ReadAllAndTryClose(r io.Reader) (_ []byte, err error) { + defer tryClose(&err, r) + return io.ReadAll(r) +} + +// CopyAndTryClose +func CopyAndTryClose(dst io.Writer, src io.Reader) (_ int64, err error) { + defer tryClose(&err, src) + return io.Copy(dst, src) +} + +func tryClose(err *error, r io.Reader) { + rc, ok := r.(io.ReadCloser) + if !ok { + return + } + + closeErr := rc.Close() + if closeErr == nil { + return + } + + cerr := CloseError{ + Cause: closeErr, + } + if err == nil { + *err = cerr + } + *err = errors.Join(*err, cerr) +} diff --git a/pkg/health/doc.go b/pkg/health/doc.go deleted file mode 100644 index a85ce50..0000000 --- a/pkg/health/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -// Package health defines K8s inspired health metrics. -package health diff --git a/pkg/health/health.go b/pkg/health/health.go deleted file mode 100644 index 12efcad..0000000 --- a/pkg/health/health.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package health - -import ( - "context" - "sync" -) - -// Metric represents anything that can report its health status. -type Metric interface { - Healthy(context.Context) bool -} - -// Binary represents a health.Metric that is either healthy or not. -// The default value represents a healthy state. -type Binary struct { - mu sync.Mutex - unhealthy bool -} - -// Toggle toggles the state of Binary. -func (m *Binary) Toggle() { - m.mu.Lock() - defer m.mu.Unlock() - m.unhealthy = !m.unhealthy -} - -// Healthy implements the Metric interface. -func (m *Binary) Healthy(ctx context.Context) bool { - m.mu.Lock() - defer m.mu.Unlock() - return !m.unhealthy -} - -// AndMetric represents multiple Metrics all and'd together. -type AndMetric struct { - metrics []Metric -} - -// And returns a Metric where all the underlying Metrics healthy -// states are joined together via the logical and (&&) operator. -func And(metrics ...Metric) AndMetric { - return AndMetric{ - metrics: metrics, - } -} - -// Healthy implements the Metric interface. -func (m AndMetric) Healthy(ctx context.Context) bool { - for _, metric := range m.metrics { - if !metric.Healthy(ctx) { - return false - } - } - return true -} - -// OrMetric represents multiple Metrics all or'd together. -type OrMetric struct { - metrics []Metric -} - -// Or returns a Metric where all the underlying Metrics healthy -// states are joined together via the logical or (||) operator. -func Or(metrics ...Metric) OrMetric { - return OrMetric{ - metrics: metrics, - } -} - -// Healthy implements the Metric interface. -func (m OrMetric) Healthy(ctx context.Context) bool { - for _, metric := range m.metrics { - if metric.Healthy(ctx) { - return true - } - } - return false -} - -// NotMetric represents the not'd value of the unerlying Metric. -type NotMetric struct { - metric Metric -} - -// And returns a Metric where the underlying Metric healthy state -// is negated with the logical not (!) operator. -func Not(metric Metric) NotMetric { - return NotMetric{ - metric: metric, - } -} - -// Healthy implements the Metric interface. -func (m NotMetric) Healthy(ctx context.Context) bool { - return !m.metric.Healthy(ctx) -} diff --git a/pkg/health/health_example_test.go b/pkg/health/health_example_test.go deleted file mode 100644 index 89a385f..0000000 --- a/pkg/health/health_example_test.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package health - -import ( - "context" - "fmt" -) - -func ExampleBinary() { - var b Binary - fmt.Println(b.Healthy(context.Background())) - - b.Toggle() - fmt.Println(b.Healthy(context.Background())) - // Output: true - // false -} - -func ExampleAnd() { - var a Binary - var b Binary - b.Toggle() - - ab := And(&a, &b) - fmt.Println(ab.Healthy(context.Background())) - // Output: false -} - -func ExampleOr() { - var a Binary - var b Binary - b.Toggle() - - ob := Or(&a, &b) - fmt.Println(ob.Healthy(context.Background())) - // Output: true -} - -func ExampleNot() { - var b Binary - - nb := Not(&b) - - fmt.Println(b.Healthy(context.Background())) - fmt.Println(nb.Healthy(context.Background())) - // Output: true - // false -} diff --git a/pkg/health/health_test.go b/pkg/health/health_test.go deleted file mode 100644 index ad51ebe..0000000 --- a/pkg/health/health_test.go +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package health - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestBinary_Toggle(t *testing.T) { - t.Run("will make it unhealthy", func(t *testing.T) { - t.Run("if the current state is healthy", func(t *testing.T) { - var m Binary - m.Toggle() - assert.False(t, m.Healthy(context.Background())) - }) - }) - - t.Run("will make it healthy", func(t *testing.T) { - t.Run("if the current state is unhealthy", func(t *testing.T) { - m := Binary{ - unhealthy: true, - } - m.Toggle() - assert.True(t, m.Healthy(context.Background())) - }) - }) -} - -type healthyMetric bool - -func (m healthyMetric) Healthy(_ context.Context) bool { - return bool(m) -} - -func TestAndMetric_Healthy(t *testing.T) { - t.Run("will return true", func(t *testing.T) { - testCases := []struct { - Name string - Metrics []Metric - }{ - { - Name: "if there is a single healthy metric", - Metrics: []Metric{healthyMetric(true)}, - }, - { - Name: "if all metrics are healthy", - Metrics: []Metric{healthyMetric(true), healthyMetric(true)}, - }, - } - for _, testCase := range testCases { - t.Run(testCase.Name, func(t *testing.T) { - am := And(testCase.Metrics...) - assert.True(t, am.Healthy(context.Background())) - }) - } - }) - - t.Run("will return false", func(t *testing.T) { - testCases := []struct { - Name string - Metrics []Metric - }{ - { - Name: "if there is a single unhealthy metric", - Metrics: []Metric{healthyMetric(false)}, - }, - { - Name: "if all metrics are all unhealthy", - Metrics: []Metric{healthyMetric(false), healthyMetric(false)}, - }, - { - Name: "if all one of the metrics is unhealthy", - Metrics: []Metric{healthyMetric(true), healthyMetric(false)}, - }, - { - Name: "if all one of the metrics is unhealthy (symmetric)", - Metrics: []Metric{healthyMetric(false), healthyMetric(true)}, - }, - } - for _, testCase := range testCases { - t.Run(testCase.Name, func(t *testing.T) { - am := And(testCase.Metrics...) - assert.False(t, am.Healthy(context.Background())) - }) - } - }) -} - -func TestOrMetric_Healthy(t *testing.T) { - t.Run("will return true", func(t *testing.T) { - testCases := []struct { - Name string - Metrics []Metric - }{ - { - Name: "if there is a single healthy metric", - Metrics: []Metric{healthyMetric(true)}, - }, - { - Name: "if all metrics are healthy", - Metrics: []Metric{healthyMetric(true), healthyMetric(true)}, - }, - { - Name: "if all one of the metrics is unhealthy", - Metrics: []Metric{healthyMetric(true), healthyMetric(false)}, - }, - { - Name: "if all one of the metrics is unhealthy (symmetric)", - Metrics: []Metric{healthyMetric(false), healthyMetric(true)}, - }, - } - for _, testCase := range testCases { - t.Run(testCase.Name, func(t *testing.T) { - om := Or(testCase.Metrics...) - assert.True(t, om.Healthy(context.Background())) - }) - } - }) - - t.Run("will return false", func(t *testing.T) { - testCases := []struct { - Name string - Metrics []Metric - }{ - { - Name: "if there is a single unhealthy metric", - Metrics: []Metric{healthyMetric(false)}, - }, - { - Name: "if all metrics are all unhealthy", - Metrics: []Metric{healthyMetric(false), healthyMetric(false)}, - }, - } - for _, testCase := range testCases { - t.Run(testCase.Name, func(t *testing.T) { - om := Or(testCase.Metrics...) - assert.False(t, om.Healthy(context.Background())) - }) - } - }) -} diff --git a/pkg/internal/ioutil/ioutil.go b/pkg/internal/ioutil/ioutil.go deleted file mode 100644 index 269b79d..0000000 --- a/pkg/internal/ioutil/ioutil.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package ioutil - -import ( - "fmt" - "io" -) - -type CloseError struct { - Cause error -} - -func (e CloseError) Error() string { - return fmt.Sprintf("failed to close reader: %s", e.Cause) -} - -func (e CloseError) Unwrap() error { - return e.Cause -} - -func ReadAllAndClose(r io.Reader) ([]byte, error) { - b, err := io.ReadAll(r) - if err != nil { - return b, err - } - - rc, ok := r.(io.ReadCloser) - if !ok { - return b, nil - } - - err = rc.Close() - if err != nil { - return nil, CloseError{Cause: err} - } - return b, nil -} - -func CopyAndClose(w io.Writer, r io.Reader) (int64, error) { - n, err := io.Copy(w, r) - if err != nil { - return n, err - } - - rc, ok := r.(io.ReadCloser) - if !ok { - return n, nil - } - - err = rc.Close() - if err != nil { - return n, CloseError{Cause: err} - } - return n, nil -} diff --git a/pkg/noop/doc.go b/pkg/noop/doc.go deleted file mode 100644 index 3c30313..0000000 --- a/pkg/noop/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -// Package noop provides no-op implementations to be used in tests and as defaults. -package noop diff --git a/pkg/noop/noop.go b/pkg/noop/noop.go deleted file mode 100644 index 80b2a45..0000000 --- a/pkg/noop/noop.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package noop - -import ( - "context" - "log/slog" -) - -// LogHandler is a no-op implementation of the slog.Handler interface. -type LogHandler struct{} - -// Enabled implements the slog.Handler interface. -func (LogHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } - -// Handle implements the slog.Handler interface. -func (LogHandler) Handle(_ context.Context, _ slog.Record) error { return nil } - -// WithAttrs implements the slog.Handler interface. -func (h LogHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } - -// WithGroup implements the slog.Handler interface. -func (h LogHandler) WithGroup(name string) slog.Handler { return h } diff --git a/pkg/ptr/ptr.go b/pkg/ptr/ptr.go deleted file mode 100644 index d3ec19a..0000000 --- a/pkg/ptr/ptr.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -// Package ptr provides helpers for working with references of values. -package ptr - -// Deref returns either the zero value for type T or the -// dereferenced value of t. -func Deref[T any](t *T) T { - var zero T - if t == nil { - return zero - } - return *t -} - -// Ref returns a reference of the given value. -func Ref[T any](t T) *T { - return &t -} diff --git a/queue/common_options.go b/queue/common_options.go deleted file mode 100644 index 9babf70..0000000 --- a/queue/common_options.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package queue - -import ( - "log/slog" -) - -type commonOptions struct { - logHandler slog.Handler -} - -// CommonOption are options which are common to all queue based runtimes. -type CommonOption interface { - SequentialOption - ConcurrentOption -} - -type commonOptionFunc func(*commonOptions) - -func (f commonOptionFunc) applySequential(so *sequentialOptions) { - f(&so.commonOptions) -} - -func (f commonOptionFunc) applyPipe(po *concurrentOptions) { - f(&po.commonOptions) -} - -// LogHandler configures the underlying slog.Handler. -func LogHandler(h slog.Handler) CommonOption { - return commonOptionFunc(func(co *commonOptions) { - co.logHandler = h - }) -} diff --git a/queue/doc.go b/queue/doc.go deleted file mode 100644 index 84b8a1a..0000000 --- a/queue/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -// Package queue provides multiple patterns which implements the app.Runtime interface. -package queue diff --git a/queue/queue.go b/queue/queue.go deleted file mode 100644 index 086a7ed..0000000 --- a/queue/queue.go +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package queue - -import ( - "context" - "errors" - "log/slog" - - "github.com/z5labs/bedrock/pkg/noop" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/propagation" - "golang.org/x/sync/errgroup" -) - -// ErrNoItem should be returned by Consumers -// when no item has been consumed. -var ErrNoItem = errors.New("queue: no item") - -// Consumer consumes items from a queue. -// -// If no item is consumed, then the Consumer -// should return ErrNoItem. -type Consumer[T any] interface { - Consume(context.Context) (T, error) -} - -// Processor processes items that are retrieved from a queue. -type Processor[T any] interface { - Process(context.Context, T) error -} - -type sequentialOptions struct { - commonOptions -} - -// SequentialOption are options for configuring the SequentialRuntime. -type SequentialOption interface { - applySequential(*sequentialOptions) -} - -// SequentialApp is a bedrock.App for sequentially processing items from a queue. -type SequentialApp[T any] struct { - log *slog.Logger - c Consumer[T] - p Processor[T] -} - -// Sequential returns a fully initialized SequentialApp. -// -// Sequential will first consume an item from the Consumer, c. Then, -// process that item with the given Processor, p. After, processing -// the item, this sequence repeats. Thus, no new item will be consumed -// from the queue until the current item has been processed. -func Sequential[T any](c Consumer[T], p Processor[T], opts ...SequentialOption) *SequentialApp[T] { - so := &sequentialOptions{ - commonOptions: commonOptions{ - logHandler: noop.LogHandler{}, - }, - } - for _, opt := range opts { - opt.applySequential(so) - } - - return &SequentialApp[T]{ - log: slog.New(so.logHandler), - c: c, - p: p, - } -} - -// Run implements the app.Runtime interface. -func (rt *SequentialApp[T]) Run(ctx context.Context) error { - tracer := otel.Tracer("queue") - for { - select { - case <-ctx.Done(): - return nil - default: - } - - spanCtx, span := tracer.Start(ctx, "SequentialRuntime.Run") - item, err := consume(spanCtx, rt.c) - if errors.Is(err, ErrNoItem) { - span.End() - continue - } - if err != nil { - rt.log.ErrorContext(spanCtx, "failed to consume", slog.String("error", err.Error())) - span.End() - continue - } - - select { - case <-ctx.Done(): - span.End() - return nil - default: - } - - err = process(spanCtx, rt.p, item.value) - if err != nil { - rt.log.ErrorContext(spanCtx, "failed to process", slog.String("error", err.Error())) - } - span.End() - } -} - -type concurrentOptions struct { - commonOptions - - maxConcurrentProcessors int -} - -// ConcurrentOption are options for configuring the ConcurrentRuntime. -type ConcurrentOption interface { - applyPipe(*concurrentOptions) -} - -type concurrentOptionFunc func(*concurrentOptions) - -func (f concurrentOptionFunc) applyPipe(po *concurrentOptions) { - f(po) -} - -// MaxConcurrentProcessors configures a limit for the number -// of processor goroutines actively running. -func MaxConcurrentProcessors(n uint) ConcurrentOption { - return concurrentOptionFunc(func(po *concurrentOptions) { - if n == 0 { - return - } - po.maxConcurrentProcessors = int(n) - }) -} - -// ConcurrentApp is a bedrock.Runtime for concurrently processing items from a queue. -type ConcurrentApp[T any] struct { - log *slog.Logger - c Consumer[T] - p Processor[T] - - propagator propagation.TextMapPropagator - maxConcurrentProcessors int -} - -// Concurrent returns a fully initialized ConcurrentApp. -// -// Concurrent will consume and process items as concurrent processes. -// For every item returned by the Consumer, c, the Processor, p, is -// called in a separate goroutine to process the item. Due to the concurrent -// execution of the Consumer and Processor, new items will be consumed -// before the current item has been completely processed. -func Concurrent[T any](c Consumer[T], p Processor[T], opts ...ConcurrentOption) *ConcurrentApp[T] { - po := &concurrentOptions{ - commonOptions: commonOptions{ - logHandler: noop.LogHandler{}, - }, - maxConcurrentProcessors: -1, - } - for _, opt := range opts { - opt.applyPipe(po) - } - - return &ConcurrentApp[T]{ - log: slog.New(po.logHandler), - c: c, - p: p, - propagator: propagation.TraceContext{}, - maxConcurrentProcessors: po.maxConcurrentProcessors, - } -} - -// Run implements the app.Runtime interface -func (rt *ConcurrentApp[T]) Run(ctx context.Context) error { - itemCh := make(chan *item[T]) - - g, gctx := errgroup.WithContext(ctx) - g.Go(rt.consumeItems(gctx, itemCh)) - g.Go(rt.processItems(gctx, itemCh)) - return g.Wait() -} - -type item[T any] struct { - value T - - // for concurrent Consumer-Processor implemetations - // the otel context needs to be propagated between goroutines - carrier propagation.TextMapCarrier -} - -func (rt *ConcurrentApp[T]) consumeItems(ctx context.Context, itemCh chan<- *item[T]) func() error { - return func() error { - defer close(itemCh) - - tracer := otel.Tracer("queue") - for { - spanCtx, span := tracer.Start(ctx, "ConcurrentRuntime.consumeItems") - - select { - case <-spanCtx.Done(): - span.End() - return nil - default: - } - - item, err := consume(spanCtx, rt.c) - if errors.Is(err, ErrNoItem) { - span.End() - continue - } - if err != nil { - rt.log.ErrorContext(spanCtx, "failed to consume", slog.String("error", err.Error())) - span.End() - continue - } - - item.carrier = make(propagation.MapCarrier) - rt.propagator.Inject(spanCtx, item.carrier) - - select { - case <-spanCtx.Done(): - span.End() - return nil - case itemCh <- item: - span.End() - } - } - } -} - -func (rt *ConcurrentApp[T]) processItems(ctx context.Context, itemCh <-chan *item[T]) func() error { - return func() error { - g, gctx := errgroup.WithContext(ctx) - g.SetLimit(rt.maxConcurrentProcessors) - - for { - var i *item[T] - select { - case <-gctx.Done(): - return g.Wait() - case i = <-itemCh: - } - if i == nil { - rt.log.Debug("stopping item processing since item channel was closed") - return g.Wait() - } - - propCtx := rt.propagator.Extract(gctx, i.carrier) - g.Go(rt.processItem(propCtx, i)) - } - } -} - -func (rt *ConcurrentApp[T]) processItem(ctx context.Context, i *item[T]) func() error { - return func() error { - spanCtx, span := otel.Tracer("queue").Start(ctx, "processItem") - defer span.End() - - err := process(spanCtx, rt.p, i.value) - if err != nil { - rt.log.ErrorContext(spanCtx, "failed to process", slog.String("error", err.Error())) - } - return nil - } -} - -func consume[T any](ctx context.Context, c Consumer[T]) (i *item[T], err error) { - spanCtx, span := otel.Tracer("queue").Start(ctx, "consume") - defer span.End() - defer errRecover(&err) - - v, err := c.Consume(spanCtx) - if err != nil { - return nil, err - } - return &item[T]{value: v}, nil -} - -func process[T any](ctx context.Context, p Processor[T], value T) (err error) { - spanCtx, span := otel.Tracer("queue").Start(ctx, "process") - defer span.End() - defer errRecover(&err) - - return p.Process(spanCtx, value) -} - -func errRecover(err *error) { - r := recover() - if r == nil { - return - } - rerr, ok := r.(error) - if !ok { - *err = errors.New("recovered from consumer panic") - return - } - *err = rerr -} diff --git a/queue/queue_example_test.go b/queue/queue_example_test.go deleted file mode 100644 index 9b59ba1..0000000 --- a/queue/queue_example_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package queue - -import ( - "context" - "fmt" - "log/slog" - "slices" - "sync" - "sync/atomic" -) - -type consumerFunc[T any] func(context.Context) (T, error) - -func (f consumerFunc[T]) Consume(ctx context.Context) (T, error) { - return f(ctx) -} - -type processorFunc[T any] func(context.Context, T) error - -func (f processorFunc[T]) Process(ctx context.Context, t T) error { - return f(ctx, t) -} - -func ExampleSequential() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var n int - c := consumerFunc[int](func(_ context.Context) (int, error) { - n += 1 - return n, nil - }) - p := processorFunc[int](func(_ context.Context, n int) error { - if n > 5 { - cancel() - return nil - } - // items are processed sequentially in this case so we can - // compare based on the printed lines - fmt.Println(n) - return nil - }) - - rt := Sequential[int](c, p, LogHandler(slog.Default().Handler())) - - err := rt.Run(ctx) - if err != nil { - fmt.Println(err) - return - } - - //Output: 1 - // 2 - // 3 - // 4 - // 5 -} - -func ExampleConcurrent() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var n int - c := consumerFunc[int](func(_ context.Context) (int, error) { - n += 1 - return n, nil - }) - - var processed atomic.Int64 - var mu sync.Mutex - var nums []int - p := processorFunc[int](func(_ context.Context, n int) error { - processed.Add(1) - if processed.Load() > 5 { - cancel() - return nil - } - // items are processed concurrently so we can print them here - // since the order is not gauranteed - mu.Lock() - nums = append(nums, n) - mu.Unlock() - return nil - }) - - rt := Concurrent[int](c, p, LogHandler(slog.Default().Handler())) - - err := rt.Run(ctx) - if err != nil { - fmt.Println(err) - return - } - - // since the numbers are processed concurrently - // there's no gaurantee that the list only contains - // 1, 2, 3, 4, 5. - fmt.Println(sum(nums) >= 15) - //Output: true -} - -func ExampleConcurrent_maxConcurrentProcessors() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var n int - c := consumerFunc[int](func(_ context.Context) (int, error) { - n += 1 - return n, nil - }) - - var processed atomic.Int64 - var mu sync.Mutex - var nums []int - p := processorFunc[int](func(_ context.Context, n int) error { - processed.Add(1) - if processed.Load() > 5 { - cancel() - return nil - } - // items are processed concurrently so we can print them here - // since the order is not gauranteed - mu.Lock() - nums = append(nums, n) - mu.Unlock() - return nil - }) - - rt := Concurrent[int]( - c, - p, - LogHandler(slog.Default().Handler()), - MaxConcurrentProcessors(1), - ) - - err := rt.Run(ctx) - if err != nil { - fmt.Println(err) - return - } - - // Since there's only 1 processor goroutine and the - // nums are consumed sequentially the nums slice - // should be gauranteed to be 1 thru 5. - slices.Sort(nums) - fmt.Println(nums) - //Output: [1 2 3 4 5] -} - -func sum[T int | float64](xs []T) T { - var total T = 0 - for _, x := range xs { - total += x - } - return total -} diff --git a/queue/queue_test.go b/queue/queue_test.go deleted file mode 100644 index a9cf092..0000000 --- a/queue/queue_test.go +++ /dev/null @@ -1,340 +0,0 @@ -// Copyright (c) 2023 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package queue - -import ( - "context" - "errors" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestSequentialRuntime_Run(t *testing.T) { - t.Run("will stop", func(t *testing.T) { - t.Run("if the context is cancelled before consuming", func(t *testing.T) { - c := consumerFunc[int](func(ctx context.Context) (int, error) { - return 0, nil - }) - p := processorFunc[int](func(ctx context.Context, i int) error { - return nil - }) - - rt := Sequential[int](c, p) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - cancel() - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - }) - - t.Run("if the context is cancelled before processing", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - c := consumerFunc[int](func(ctx context.Context) (int, error) { - cancel() - return 0, nil - }) - p := processorFunc[int](func(ctx context.Context, i int) error { - return nil - }) - - rt := Sequential[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - }) - }) - - t.Run("will continue", func(t *testing.T) { - t.Run("if it fails to consume", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var count atomic.Uint64 - c := consumerFunc[int](func(ctx context.Context) (int, error) { - count.Add(1) - if count.Load() > 5 { - cancel() - } - return 0, errors.New("failed to consume") - }) - - called := false - p := processorFunc[int](func(ctx context.Context, i int) error { - called = true - return nil - }) - - rt := Sequential[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.False(t, called) { - return - } - }) - - t.Run("if it fails to process", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - c := consumerFunc[int](func(ctx context.Context) (int, error) { - return 0, nil - }) - - var count atomic.Uint64 - p := processorFunc[int](func(ctx context.Context, i int) error { - count.Add(1) - if count.Load() > 5 { - cancel() - } - return errors.New("failed to process") - }) - - rt := Sequential[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.Greater(t, count.Load(), uint64(1)) { - return - } - }) - }) -} - -func TestConcurrentRuntime_Run(t *testing.T) { - t.Run("will stop", func(t *testing.T) { - t.Run("if the context is cancelled before consuming", func(t *testing.T) { - c := consumerFunc[int](func(ctx context.Context) (int, error) { - return 0, nil - }) - p := processorFunc[int](func(ctx context.Context, i int) error { - return nil - }) - - rt := Concurrent[int](c, p) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - cancel() - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - }) - - t.Run("if the context is cancelled before processing", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - c := consumerFunc[int](func(ctx context.Context) (int, error) { - cancel() - return 0, nil - }) - p := processorFunc[int](func(ctx context.Context, i int) error { - return nil - }) - - rt := Concurrent[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - }) - }) - - t.Run("will continue", func(t *testing.T) { - t.Run("if it fails to consume", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var count atomic.Uint64 - c := consumerFunc[int](func(ctx context.Context) (int, error) { - count.Add(1) - if count.Load() > 5 { - cancel() - } - return 0, errors.New("failed to consume") - }) - - called := false - p := processorFunc[int](func(ctx context.Context, i int) error { - called = true - return nil - }) - - rt := Concurrent[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.False(t, called) { - return - } - }) - - t.Run("if it panics while consuming with a non-error", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var count atomic.Uint64 - c := consumerFunc[int](func(ctx context.Context) (int, error) { - count.Add(1) - if count.Load() > 5 { - cancel() - } - panic("panic while consuming") - return 0, nil - }) - - called := false - p := processorFunc[int](func(ctx context.Context, i int) error { - called = true - return nil - }) - - rt := Concurrent[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.False(t, called) { - return - } - }) - - t.Run("if it panics while consuming with a error", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var count atomic.Uint64 - c := consumerFunc[int](func(ctx context.Context) (int, error) { - count.Add(1) - if count.Load() > 5 { - cancel() - } - panic(errors.New("panic while consuming")) - return 0, nil - }) - - called := false - p := processorFunc[int](func(ctx context.Context, i int) error { - called = true - return nil - }) - - rt := Concurrent[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.False(t, called) { - return - } - }) - - t.Run("if it fails to process", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - c := consumerFunc[int](func(ctx context.Context) (int, error) { - return 0, nil - }) - - var count atomic.Uint64 - p := processorFunc[int](func(ctx context.Context, i int) error { - count.Add(1) - if count.Load() > 5 { - cancel() - } - return errors.New("failed to process") - }) - - rt := Concurrent[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.Greater(t, count.Load(), uint64(1)) { - return - } - }) - - t.Run("if it panics while processing with a non-error", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - c := consumerFunc[int](func(ctx context.Context) (int, error) { - return 0, nil - }) - - var count atomic.Uint64 - p := processorFunc[int](func(ctx context.Context, i int) error { - count.Add(1) - if count.Load() > 5 { - cancel() - } - panic("panic while processing") - return nil - }) - - rt := Concurrent[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.Greater(t, count.Load(), uint64(1)) { - return - } - }) - - t.Run("if it panics while processing with a error", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - c := consumerFunc[int](func(ctx context.Context) (int, error) { - return 0, nil - }) - - var count atomic.Uint64 - p := processorFunc[int](func(ctx context.Context, i int) error { - count.Add(1) - if count.Load() > 5 { - cancel() - } - panic(errors.New("panic while processing")) - return nil - }) - - rt := Concurrent[int](c, p) - - err := rt.Run(ctx) - if !assert.Nil(t, err) { - return - } - if !assert.Greater(t, count.Load(), uint64(1)) { - return - } - }) - }) -} diff --git a/rest/doc.go b/rest/doc.go deleted file mode 100644 index a1fad8a..0000000 --- a/rest/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -// Package rest provides a bedrock.App implementation for building RESTful applications. -package rest diff --git a/rest/endpoint/doc.go b/rest/endpoint/doc.go deleted file mode 100644 index 2920a29..0000000 --- a/rest/endpoint/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -// Package endpoint wraps generic RPC style code into a RESTful HTTP endpoint handler. -package endpoint diff --git a/rest/endpoint/endpoint.go b/rest/endpoint/endpoint.go deleted file mode 100644 index 56a0db9..0000000 --- a/rest/endpoint/endpoint.go +++ /dev/null @@ -1,457 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "errors" - "fmt" - "net/http" - "strconv" - - "github.com/z5labs/bedrock/pkg/ptr" - - "github.com/swaggest/openapi-go/openapi3" - "go.opentelemetry.io/otel" -) - -// Handler defines an RPC inspired way of handling HTTP requests. -// -// Req and Resp can implement various interfaces which [Operation] -// uses to automate many tasks before and after calling your Handler. -// For example, [Operation] handles unmarshaling and marshaling the request (Req) -// and response (Resp) types automatically if they implement [encoding.BinaryUnmarshaler] -// and [encoding.BinaryMarshaler], respectively. -type Handler[Req, Resp any] interface { - Handle(context.Context, *Req) (*Resp, error) -} - -// HandlerFunc is an adapter type to allow the use of ordinary functions as [Handler]s. -type HandlerFunc[Req, Resp any] func(context.Context, *Req) (*Resp, error) - -// Handle implements the [Handler] interface. -func (f HandlerFunc[Req, Resp]) Handle(ctx context.Context, req *Req) (*Resp, error) { - return f(ctx, req) -} - -// ErrorHandler defines the behaviour taken by [Operation] -// when a [Handler] returns an [error]. -type ErrorHandler interface { - HandleError(context.Context, http.ResponseWriter, error) -} - -type options struct { - pathParams map[PathParam]struct{} - headerParams map[Header]struct{} - queryParams map[QueryParam]struct{} - - defaultStatusCode int - validators []func(*http.Request) error - errHandler ErrorHandler - - openapi openapi3.Operation -} - -// Option configures a [Operation]. -type Option func(*options) - -// Operation is a RPC inspired [http.Handler] (aka endpoint) that also -// keeps track of the associated types and parameters -// in order to construct an OpenAPI operation definition. -type Operation[I, O any, Req Request[I], Resp Response[O]] struct { - validators []func(*http.Request) error - injectors []injector - - statusCode int - handler Handler[I, O] - - errHandler ErrorHandler - - openapi openapi3.Operation -} - -// DefaultStatusCode is the default HTTP status code returned -// by an [Operation] when the underlying [Handler] does not return an [error]. -const DefaultStatusCode = http.StatusOK - -// StatusCode will change the HTTP status code that is returned -// by an [Operation] when the underlying [Handler] does not return an [error]. -func StatusCode(statusCode int) Option { - return func(ho *options) { - ho.defaultStatusCode = statusCode - } -} - -// PathParam defines a URL path parameter e.g. /book/{id} where id is the path param. -type PathParam struct { - Name string - Pattern string - Required bool -} - -// PathParams registers the [PathParam]s with the OpenAPI operation definition. -func PathParams(ps ...PathParam) Option { - return func(o *options) { - for _, p := range ps { - o.pathParams[p] = struct{}{} - - o.openapi.Parameters = append(o.openapi.Parameters, openapi3.ParameterOrRef{ - Parameter: &openapi3.Parameter{ - In: openapi3.ParameterInPath, - Name: p.Name, - Required: ptr.Ref(p.Required), - Schema: &openapi3.SchemaOrRef{ - Schema: &openapi3.Schema{ - Type: ptr.Ref(openapi3.SchemaTypeString), - Pattern: ptr.Ref(p.Pattern), - }, - }, - }, - }) - - o.validators = append(o.validators, validatePathParam(p)) - } - } -} - -// Header defines a HTTP header. -type Header struct { - Name string - Pattern string - Required bool -} - -// Headers registers the [Header]s with the OpenAPI operation definition. -func Headers(hs ...Header) Option { - return func(o *options) { - for _, h := range hs { - o.headerParams[h] = struct{}{} - - o.openapi.Parameters = append(o.openapi.Parameters, openapi3.ParameterOrRef{ - Parameter: &openapi3.Parameter{ - In: openapi3.ParameterInHeader, - Name: h.Name, - Required: ptr.Ref(h.Required), - Schema: &openapi3.SchemaOrRef{ - Schema: &openapi3.Schema{ - Type: ptr.Ref(openapi3.SchemaTypeString), - Pattern: ptr.Ref(h.Pattern), - }, - }, - }, - }) - - o.validators = append(o.validators, validateHeader(h)) - } - } -} - -// QueryParam defines a URL query parameter e.g. /book?id=123 -type QueryParam struct { - Name string - Pattern string - Required bool -} - -// QueryParams registers the [QueryParam]s with the OpenAPI operation definition. -func QueryParams(qps ...QueryParam) Option { - return func(o *options) { - for _, qp := range qps { - o.queryParams[qp] = struct{}{} - - o.openapi.Parameters = append(o.openapi.Parameters, openapi3.ParameterOrRef{ - Parameter: &openapi3.Parameter{ - In: openapi3.ParameterInQuery, - Name: qp.Name, - Required: ptr.Ref(qp.Required), - Schema: &openapi3.SchemaOrRef{ - Schema: &openapi3.Schema{ - Type: ptr.Ref(openapi3.SchemaTypeString), - Pattern: ptr.Ref(qp.Pattern), - }, - }, - }, - }) - - o.validators = append(o.validators, validateQueryParam(qp)) - } - } -} - -// ContentTyper is the interface which request and response types -// should implement in order to allow the [Operation] to automatically -// validate and set the "Content-Type" HTTP Header along with -// properly documenting the types in the OpenAPI operation definition. -type ContentTyper interface { - ContentType() string -} - -// OpenApiV3Schemaer -type OpenApiV3Schemaer interface { - OpenApiV3Schema() (*openapi3.Schema, error) -} - -// Accepts registers the Req type in the OpenAPI operation definition -// as a possible request to the [Operation]. -func Accepts[I any, Req Request[I]]() Option { - return func(o *options) { - var i I - var req Req = &i - ct := req.ContentType() - if len(ct) > 0 { - o.validators = append(o.validators, validateHeader(Header{ - Name: "Content-Type", - Pattern: fmt.Sprintf("^%s$", ct), - Required: true, - })) - } - - schema, err := req.OpenApiV3Schema() - if err != nil { - panic(err) - } - - var schemaOrRef openapi3.SchemaOrRef - schemaOrRef.Schema = schema - - o.openapi.RequestBody = &openapi3.RequestBodyOrRef{ - RequestBody: &openapi3.RequestBody{ - Required: ptr.Ref(true), - Content: map[string]openapi3.MediaType{ - ct: { - Schema: &schemaOrRef, - }, - }, - }, - } - } -} - -// Returns registers the status code in the OpenAPI operation -// definition as a possible response from the [Operation]. -func Returns(status int) Option { - return func(o *options) { - o.openapi.Responses.MapOfResponseOrRefValues[strconv.Itoa(status)] = openapi3.ResponseOrRef{ - Response: &openapi3.Response{}, - } - } -} - -// ReturnsWith registers the Resp type and status code in the OpenAPI -// operation definition as a possible response from the [Operation]. -func ReturnsWith[O any, Resp Response[O]](status int) Option { - return func(opts *options) { - var o O - var resp Resp = &o - ct := resp.ContentType() - if len(ct) == 0 { - opts.openapi.Responses.MapOfResponseOrRefValues[strconv.Itoa(status)] = openapi3.ResponseOrRef{ - Response: &openapi3.Response{}, - } - return - } - - schema, err := resp.OpenApiV3Schema() - if err != nil { - panic(err) - } - - var schemaOrRef openapi3.SchemaOrRef - schemaOrRef.Schema = schema - - opts.openapi.Responses.MapOfResponseOrRefValues[strconv.Itoa(status)] = openapi3.ResponseOrRef{ - Response: &openapi3.Response{ - Content: map[string]openapi3.MediaType{ - ct: { - Schema: &schemaOrRef, - }, - }, - }, - } - } -} - -// OnError registers the [ErrorHandler] with the [Operation]. Any -// [error]s returned by the underlying [Handler] will be passed to -// this [ErrorHandler]. -func OnError(eh ErrorHandler) Option { - return func(o *options) { - o.errHandler = eh - } -} - -type errorHandlerFunc func(context.Context, http.ResponseWriter, error) - -func (f errorHandlerFunc) HandleError(ctx context.Context, w http.ResponseWriter, err error) { - f(ctx, w, err) -} - -// DefaultErrorHandler -var DefaultErrorHandler ErrorHandler = errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - w.WriteHeader(DefaultErrorStatusCode) -}) - -// DefaultErrorStatusCode is the default HTTP status code returned by -// an [Operation] if no [ErrorHandler] has been registered with the -// [OnError] option and the underlying [Handler] returns an [error]. -const DefaultErrorStatusCode = http.StatusInternalServerError - -// NewOperation initializes a Operation. -func NewOperation[I, O any, Req Request[I], Resp Response[O]](handler Handler[I, O], opts ...Option) *Operation[I, O, Req, Resp] { - o := &options{ - defaultStatusCode: DefaultStatusCode, - pathParams: make(map[PathParam]struct{}), - headerParams: make(map[Header]struct{}), - queryParams: make(map[QueryParam]struct{}), - errHandler: DefaultErrorHandler, - openapi: openapi3.Operation{ - Responses: openapi3.Responses{ - MapOfResponseOrRefValues: make(map[string]openapi3.ResponseOrRef), - }, - }, - } - - for _, opt := range withBuiltinOptions[I, O, Req, Resp](opts...) { - opt(o) - } - - return &Operation[I, O, Req, Resp]{ - injectors: initInjectors(o), - validators: o.validators, - statusCode: o.defaultStatusCode, - handler: handler, - errHandler: o.errHandler, - openapi: o.openapi, - } -} - -func withBuiltinOptions[I, O any, Req Request[I], Resp Response[O]](opts ...Option) []Option { - var i I - var req Req = &i - ct := req.ContentType() - if len(ct) > 0 { - opts = append(opts, Accepts[I, Req]()) - } - - var o O - var resp Resp = &o - ct = resp.ContentType() - if len(ct) > 0 { - opts = append(opts, func(o *options) { - ReturnsWith[O, Resp](o.defaultStatusCode)(o) - }) - } else { - opts = append(opts, func(o *options) { - Returns(o.defaultStatusCode)(o) - }) - } - - return opts -} - -func initInjectors(o *options) []injector { - injectors := []injector{injectResponseHeaders} - for p := range o.pathParams { - injectors = append(injectors, injectPathParam(p.Name)) - } - if len(o.headerParams) > 0 { - injectors = append(injectors, injectHeaders) - } - if len(o.queryParams) > 0 { - injectors = append(injectors, injectQueryParams) - } - return injectors -} - -// OpenApi returns the OpenAPI operation definition for this endpoint. -func (op *Operation[I, O, Req, Resp]) OpenApi() openapi3.Operation { - return op.openapi -} - -// ServeHTTP implements the [http.Handler] interface. -func (op *Operation[I, O, Req, Resp]) ServeHTTP(w http.ResponseWriter, r *http.Request) { - spanCtx, span := otel.Tracer("endpoint").Start(r.Context(), "Operation.ServeHTTP") - defer span.End() - - ctx := inject(spanCtx, w, r, op.injectors...) - - err := validateRequest(ctx, r, op.validators...) - if err != nil { - op.handleError(ctx, w, err) - return - } - - var i I - var req Req = &i - err = readRequest(ctx, r, req) - if err != nil { - op.handleError(ctx, w, err) - return - } - - err = validate(ctx, req) - if err != nil { - op.handleError(ctx, w, err) - return - } - - resp, err := op.handler.Handle(ctx, req) - if err != nil { - op.handleError(ctx, w, err) - return - } - if resp == nil { - op.handleError(ctx, w, ErrNilHandlerResponse) - return - } - - err = op.writeResponse(ctx, w, resp) - if err != nil { - op.handleError(ctx, w, err) - return - } -} - -func readRequest[Req RequestReader](ctx context.Context, r *http.Request, req Req) error { - _, span := otel.Tracer("endpoint").Start(ctx, "readRequest") - defer span.End() - - return req.ReadRequest(r) -} - -func validate[Req Validator](ctx context.Context, req Req) error { - _, span := otel.Tracer("endpoint").Start(ctx, "validate") - defer span.End() - - err := req.Validate() - span.RecordError(err) - return err -} - -// ErrNilHandlerResponse -var ErrNilHandlerResponse = errors.New("received nil for response that is expected to be in response body") - -func (op *Operation[I, O, Req, Resp]) writeResponse(ctx context.Context, w http.ResponseWriter, resp Resp) error { - _, span := otel.Tracer("endpoint").Start(ctx, "Operation.writeResponse") - defer span.End() - - ct := resp.ContentType() - if len(ct) > 0 { - w.Header().Set("Content-Type", ct) - } - w.WriteHeader(op.statusCode) - - _, err := resp.WriteTo(w) - span.RecordError(err) - return err -} - -func (op *Operation[I, O, Req, Resp]) handleError(ctx context.Context, w http.ResponseWriter, err error) { - spanCtx, span := otel.Tracer("endpoint").Start(ctx, "Operation.handleError") - defer span.End() - - op.errHandler.HandleError(spanCtx, w, err) -} diff --git a/rest/endpoint/endpoint_test.go b/rest/endpoint/endpoint_test.go deleted file mode 100644 index 82dde3d..0000000 --- a/rest/endpoint/endpoint_test.go +++ /dev/null @@ -1,989 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/swaggest/openapi-go/openapi3" -) - -type noopHandler[Req, Resp any] struct{} - -func (noopHandler[Req, Resp]) Handle(_ context.Context, _ *Req) (*Resp, error) { - var resp Resp - return &resp, nil -} - -type ReaderContent struct { - r io.Reader -} - -func (ReaderContent) ContentType() string { - return "application/octet" -} - -func (ReaderContent) Validate() error { - return nil -} - -func (ReaderContent) OpenApiV3Schema() (*openapi3.Schema, error) { - return nil, nil -} - -func (x *ReaderContent) ReadRequest(r *http.Request) (err error) { - defer close(&err, r.Body) - - var b []byte - b, err = io.ReadAll(r.Body) - if err != nil { - return - } - x.r = bytes.NewReader(b) - return -} - -func (x *ReaderContent) WriteTo(w io.Writer) (int64, error) { - return io.Copy(w, x.r) -} - -type FailReadFrom struct{} - -var errReadFrom = errors.New("failed to read from io.Reader") - -func (FailReadFrom) ContentType() string { - return "" -} - -func (FailReadFrom) Validate() error { - return nil -} - -func (FailReadFrom) OpenApiV3Schema() (*openapi3.Schema, error) { - return nil, nil -} - -func (*FailReadFrom) ReadRequest(r *http.Request) error { - return errReadFrom -} - -type InvalidRequest struct{} - -func (InvalidRequest) ContentType() string { - return "" -} - -var errInvalidRequest = errors.New("invalid request") - -func (InvalidRequest) Validate() error { - return errInvalidRequest -} - -func (InvalidRequest) OpenApiV3Schema() (*openapi3.Schema, error) { - return nil, nil -} - -func (*InvalidRequest) ReadRequest(r *http.Request) error { - return nil -} - -type FailWriteTo struct{} - -func (FailWriteTo) ContentType() string { - return "" -} - -func (FailWriteTo) OpenApiV3Schema() (*openapi3.Schema, error) { - return nil, nil -} - -var errWriteTo = errors.New("failed to write response") - -func (*FailWriteTo) WriteTo(w io.Writer) (int64, error) { - return 0, errWriteTo -} - -func TestEndpoint_ServeHTTP(t *testing.T) { - t.Run("will return the default success http status code", func(t *testing.T) { - t.Run("if the underlying Handler succeeds with an empty response", func(t *testing.T) { - e := NewOperation(noopHandler[EmptyRequest, EmptyResponse]{}) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - }) - - t.Run("if the underlying Handler succeeds with a io.WriterTo response", func(t *testing.T) { - e := NewOperation( - HandlerFunc[ReaderContent, ReaderContent](func(_ context.Context, req *ReaderContent) (*ReaderContent, error) { - return &ReaderContent{r: req.r}, nil - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("hello, world")) - r.Header.Set("Content-Type", ReaderContent{}.ContentType()) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello, world", string(b)) { - return - } - }) - - t.Run("if the response fails to write itself to the http.ResponseWriter", func(t *testing.T) { - var caughtError error - e := NewOperation( - HandlerFunc[EmptyRequest, FailWriteTo](func(_ context.Context, _ *EmptyRequest) (*FailWriteTo, error) { - t.Log("request received") - return &FailWriteTo{}, nil - }), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Equal(t, errWriteTo, caughtError) { - return - } - }) - }) - - t.Run("will inject path params", func(t *testing.T) { - t.Run("if a valid http.ServeMux path param pattern is used", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(ctx context.Context, _ *EmptyRequest) (*jsonContent, error) { - v := PathValue(ctx, "id") - return &jsonContent{Value: v}, nil - })), - PathParams(PathParam{ - Name: "id", - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/abc123", nil) - - // for path params a http.ServeMux must be used since - // Endpoint doesn't support it directly - mux := http.NewServeMux() - mux.Handle("GET /{id}", e) - mux.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var jsonResp jsonContent - err = json.Unmarshal(b, &jsonResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "abc123", jsonResp.Value) { - return - } - }) - - t.Run("if a valid http.ServeMux path param pattern is used and it's marked as required", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(ctx context.Context, _ *EmptyRequest) (*jsonContent, error) { - v := PathValue(ctx, "id") - return &jsonContent{Value: v}, nil - })), - PathParams(PathParam{ - Name: "id", - Required: true, - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/abc123", nil) - - // for path params a http.ServeMux must be used since - // Endpoint doesn't support it directly - mux := http.NewServeMux() - mux.Handle("GET /{id}", e) - mux.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var jsonResp jsonContent - err = json.Unmarshal(b, &jsonResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "abc123", jsonResp.Value) { - return - } - }) - }) - - t.Run("will inject headers", func(t *testing.T) { - t.Run("if a header is configured with the Headers option", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(ctx context.Context, _ *EmptyRequest) (*jsonContent, error) { - v := HeaderValue(ctx, "test-header") - return &jsonContent{Value: v}, nil - })), - Headers(Header{ - Name: "test-header", - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - r.Header.Set("test-header", "hello, world") - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var jsonResp jsonContent - err = json.Unmarshal(b, &jsonResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello, world", jsonResp.Value) { - return - } - }) - - t.Run("if a header is configured with the Headers option and marked as required", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(ctx context.Context, _ *EmptyRequest) (*jsonContent, error) { - v := HeaderValue(ctx, "test-header") - return &jsonContent{Value: v}, nil - })), - Headers(Header{ - Name: "test-header", - Required: true, - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - r.Header.Set("test-header", "hello, world") - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var jsonResp jsonContent - err = json.Unmarshal(b, &jsonResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello, world", jsonResp.Value) { - return - } - }) - }) - - t.Run("will inject query params", func(t *testing.T) { - t.Run("if a query param is configured with the QueryParams option", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(ctx context.Context, _ *EmptyRequest) (*jsonContent, error) { - v := QueryValue(ctx, "test-query") - return &jsonContent{Value: v}, nil - })), - QueryParams(QueryParam{ - Name: "test-query", - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/?test-query=abc123", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var jsonResp jsonContent - err = json.Unmarshal(b, &jsonResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "abc123", jsonResp.Value) { - return - } - }) - - t.Run("if a query param is configured with the QueryParams option and marked required", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(ctx context.Context, _ *EmptyRequest) (*jsonContent, error) { - v := QueryValue(ctx, "test-query") - return &jsonContent{Value: v}, nil - })), - QueryParams(QueryParam{ - Name: "test-query", - Required: true, - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/?test-query=abc123", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var jsonResp jsonContent - err = json.Unmarshal(b, &jsonResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "abc123", jsonResp.Value) { - return - } - }) - }) - - t.Run("will return custom success http status code", func(t *testing.T) { - t.Run("if the StatusCode option is used and the underlying Handler succeeds with an empty response", func(t *testing.T) { - statusCode := http.StatusCreated - if !assert.NotEqual(t, DefaultStatusCode, statusCode) { - return - } - - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - StatusCode(statusCode), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, statusCode, resp.StatusCode) { - return - } - }) - - t.Run("if the StatusCode option is used and the underlying Handler succeeds with a encoding.BinaryMarshaler response", func(t *testing.T) { - statusCode := http.StatusCreated - if !assert.NotEqual(t, DefaultStatusCode, statusCode) { - return - } - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(_ context.Context, _ *EmptyRequest) (*jsonContent, error) { - return &jsonContent{Value: "hello, world"}, nil - })), - StatusCode(statusCode), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, statusCode, resp.StatusCode) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var jsonResp jsonContent - err = json.Unmarshal(b, &jsonResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello, world", jsonResp.Value) { - return - } - }) - }) - - t.Run("will return non-success http status code", func(t *testing.T) { - t.Run("if the underlying Handler returns an error", func(t *testing.T) { - e := NewOperation( - HandlerFunc[EmptyRequest, EmptyResponse](func(_ context.Context, _ *EmptyRequest) (*EmptyResponse, error) { - return nil, errors.New("failed") - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - }) - - t.Run("if the underlying Handler returns a nil Response", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - var caughtError error - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(_ context.Context, _ *EmptyRequest) (*jsonContent, error) { - return nil, nil - })), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - if !assert.ErrorIs(t, ErrNilHandlerResponse, caughtError) { - return - } - }) - - t.Run("if the underlying Handler return a nil io.WriterTo", func(t *testing.T) { - var caughtError error - e := NewOperation( - HandlerFunc[EmptyRequest, ReaderContent](func(_ context.Context, _ *EmptyRequest) (*ReaderContent, error) { - return nil, nil - }), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - if !assert.ErrorIs(t, ErrNilHandlerResponse, caughtError) { - return - } - }) - - t.Run("if a custom error handler is set", func(t *testing.T) { - errStatusCode := http.StatusServiceUnavailable - if !assert.NotEqual(t, DefaultErrorStatusCode, errStatusCode) { - return - } - - e := NewOperation( - HandlerFunc[EmptyRequest, EmptyResponse](func(_ context.Context, _ *EmptyRequest) (*EmptyResponse, error) { - return nil, errors.New("failed") - }), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - w.WriteHeader(errStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, errStatusCode, resp.StatusCode) { - return - } - }) - - t.Run("if a required path param is missing", func(t *testing.T) { - var caughtError error - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - PathParams( - PathParam{ - Name: "id", - Required: true, - }, - ), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var merr MissingRequiredPathParamError - if !assert.ErrorAs(t, caughtError, &merr) { - return - } - if !assert.NotEmpty(t, merr.Error()) { - return - } - }) - - t.Run("if a path param does not match its expected pattern", func(t *testing.T) { - var caughtError error - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - PathParams( - PathParam{ - Name: "id", - Pattern: "^[a-zA-Z]*$", - }, - ), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - r.SetPathValue("id", "abc123") - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var ierr InvalidPathParamError - if !assert.ErrorAs(t, caughtError, &ierr) { - return - } - if !assert.NotEmpty(t, ierr.Error()) { - return - } - }) - - t.Run("if a required http header is missing", func(t *testing.T) { - var caughtError error - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - Headers( - Header{ - Name: "Authorization", - Required: true, - }, - ), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var herr MissingRequiredHeaderError - if !assert.ErrorAs(t, caughtError, &herr) { - return - } - if !assert.NotEmpty(t, herr.Error()) { - return - } - }) - - t.Run("if a http header does not match its expected pattern", func(t *testing.T) { - var caughtError error - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - Headers( - Header{ - Name: "Authorization", - Pattern: "^[a-zA-Z]*$", - }, - ), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - r.Header.Set("Authorization", "abc123") - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var herr InvalidHeaderError - if !assert.ErrorAs(t, caughtError, &herr) { - return - } - if !assert.NotEmpty(t, herr.Error()) { - return - } - }) - - t.Run("if a required query param is missing", func(t *testing.T) { - var caughtError error - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - QueryParams( - QueryParam{ - Name: "test", - Required: true, - }, - ), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var qerr MissingRequiredQueryParamError - if !assert.ErrorAs(t, caughtError, &qerr) { - return - } - if !assert.NotEmpty(t, qerr.Error()) { - return - } - }) - - t.Run("if a query param does not match its expected pattern", func(t *testing.T) { - var caughtError error - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - QueryParams( - QueryParam{ - Name: "test", - Pattern: "^[a-zA-Z]*$", - }, - ), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/?test=abc123", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var qerr InvalidQueryParamError - if !assert.ErrorAs(t, caughtError, &qerr) { - return - } - if !assert.NotEmpty(t, qerr.Error()) { - return - } - }) - - t.Run("if the request content type header does not match the content type from ContentTyper", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - var caughtError error - e := NewOperation( - ConsumesJson(HandlerFunc[jsonContent, EmptyResponse](func(_ context.Context, _ *jsonContent) (*EmptyResponse, error) { - return &EmptyResponse{}, nil - })), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - r.Header.Add("Content-Type", "application/xml") - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var herr InvalidHeaderError - if !assert.ErrorAs(t, caughtError, &herr) { - return - } - if !assert.NotEmpty(t, herr.Error()) { - return - } - }) - - t.Run("if the request body fails to unmarshal", func(t *testing.T) { - var caughtError error - e := NewOperation( - HandlerFunc[FailReadFrom, EmptyResponse](func(_ context.Context, _ *FailReadFrom) (*EmptyResponse, error) { - return &EmptyResponse{}, nil - }), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - if !assert.Equal(t, errReadFrom, caughtError) { - return - } - }) - - t.Run("if the unmarshaled request body is invalid", func(t *testing.T) { - var caughtError error - e := NewOperation( - HandlerFunc[InvalidRequest, EmptyResponse](func(_ context.Context, _ *InvalidRequest) (*EmptyResponse, error) { - return &EmptyResponse{}, nil - }), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - if !assert.Equal(t, errInvalidRequest, caughtError) { - return - } - }) - }) - - t.Run("will return response header", func(t *testing.T) { - t.Run("if the response body implements ContentTyper", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(_ context.Context, _ *EmptyRequest) (*jsonContent, error) { - return &jsonContent{Value: "hello, world"}, nil - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Equal(t, (&JsonResponse[jsonContent]{}).ContentType(), resp.Header.Get("Content-Type")) { - return - } - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var content jsonContent - err = json.Unmarshal(b, &content) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello, world", content.Value) { - return - } - }) - - t.Run("if the underlying Handler sets a custom response header using the context", func(t *testing.T) { - e := NewOperation( - HandlerFunc[EmptyRequest, EmptyResponse](func(ctx context.Context, _ *EmptyRequest) (*EmptyResponse, error) { - SetResponseHeader(ctx, "Content-Type", "test-content-type") - return &EmptyResponse{}, nil - }), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - e.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Equal(t, "test-content-type", resp.Header.Get("Content-Type")) { - return - } - }) - }) -} diff --git a/rest/endpoint/inject.go b/rest/endpoint/inject.go deleted file mode 100644 index c531e3c..0000000 --- a/rest/endpoint/inject.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "net/http" - "net/url" - - "go.opentelemetry.io/otel" -) - -type injector func(context.Context, http.ResponseWriter, *http.Request) context.Context - -func inject(ctx context.Context, w http.ResponseWriter, r *http.Request, injectors ...injector) context.Context { - _, span := otel.Tracer("endpoint").Start(ctx, "inject") - defer span.End() - - for _, injector := range injectors { - ctx = injector(ctx, w, r) - } - return ctx -} - -type injectKey string - -var ( - injectQueryParamsKey = injectKey("injectQueryParamsKey") - injectHeadersKey = injectKey("injectHeadersKey") - injectResponseHeadersKey = injectKey("injectResponseHeadersKey") -) - -type injectPathParamKey string - -// PathValue returns the value for a path parameter of the given name. -// The empty string will be returned either if the path param is not set -// or not found. -func PathValue(ctx context.Context, name string) string { - s, _ := ctx.Value(injectPathParamKey(name)).(string) - return s -} - -func injectPathParam(name string) injector { - return func(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context { - ctx = context.WithValue(ctx, injectPathParamKey(name), r.PathValue(name)) - return ctx - } -} - -// QueryValue returns the value for a query parameter of the given name. -// The empty string will be returned either if the query param is not set -// or not found. -func QueryValue(ctx context.Context, name string) string { - vals, ok := ctx.Value(injectQueryParamsKey).(url.Values) - if !ok { - return "" - } - return vals.Get(name) -} - -func injectQueryParams(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context { - ctx = context.WithValue(ctx, injectQueryParamsKey, r.URL.Query()) - return ctx -} - -// HeaderValue returns the value for a header of the given name. -// The empty string will be returned either if the header is not set -// or not found. -func HeaderValue(ctx context.Context, name string) string { - headers, ok := ctx.Value(injectHeadersKey).(http.Header) - if !ok { - return "" - } - return headers.Get(name) -} - -func injectHeaders(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context { - ctx = context.WithValue(ctx, injectHeadersKey, r.Header) - return ctx -} - -// SetResponseHeader allows you set a custom response header. -func SetResponseHeader(ctx context.Context, key, value string) { - headers, ok := ctx.Value(injectResponseHeadersKey).(http.Header) - if !ok || headers == nil { - return - } - - headers.Set(key, value) -} - -func injectResponseHeaders(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context { - ctx = context.WithValue(ctx, injectResponseHeadersKey, w.Header()) - return ctx -} diff --git a/rest/endpoint/openapi_test.go b/rest/endpoint/openapi_test.go deleted file mode 100644 index aed378a..0000000 --- a/rest/endpoint/openapi_test.go +++ /dev/null @@ -1,494 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "encoding/json" - "net/http" - "strconv" - "testing" - - "github.com/z5labs/bedrock/pkg/ptr" - - "github.com/stretchr/testify/assert" - "github.com/swaggest/openapi-go/openapi3" -) - -func TestEndpoint_OpenApi(t *testing.T) { - t.Run("will required path parameter", func(t *testing.T) { - t.Run("if a http.ServeMux path parameter pattern is used", func(t *testing.T) { - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - PathParams(PathParam{ - Name: "id", - Required: true, - }), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - params := op.Parameters - if !assert.Len(t, params, 1) { - return - } - - param := params[0].Parameter - if !assert.NotNil(t, param) { - return - } - if !assert.Equal(t, openapi3.ParameterInPath, param.In) { - return - } - if !assert.Equal(t, "id", param.Name) { - return - } - if !assert.True(t, ptr.Deref(param.Required)) { - return - } - }) - }) - - t.Run("will set non-required header parameter", func(t *testing.T) { - t.Run("if a header is provided with the Headers option", func(t *testing.T) { - header := Header{ - Name: "MyHeader", - Required: true, - } - - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - Headers(header), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - params := op.Parameters - if !assert.Len(t, params, 1) { - return - } - - param := params[0].Parameter - if !assert.NotNil(t, param) { - return - } - if !assert.Equal(t, openapi3.ParameterInHeader, param.In) { - return - } - if !assert.Equal(t, header.Name, param.Name) { - return - } - if !assert.Equal(t, header.Required, ptr.Deref(param.Required)) { - return - } - }) - }) - - t.Run("will set required header parameter", func(t *testing.T) { - t.Run("if a header is provided with the Headers option", func(t *testing.T) { - header := Header{ - Name: "MyHeader", - Required: true, - } - - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - Headers(header), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - params := op.Parameters - if !assert.Len(t, params, 1) { - return - } - - param := params[0].Parameter - if !assert.NotNil(t, param) { - return - } - if !assert.Equal(t, openapi3.ParameterInHeader, param.In) { - return - } - if !assert.Equal(t, header.Name, param.Name) { - return - } - if !assert.Equal(t, header.Required, ptr.Deref(param.Required)) { - return - } - }) - }) - - t.Run("will set non-required query param", func(t *testing.T) { - t.Run("if a query param is provided with the QueryParams option", func(t *testing.T) { - queryParam := QueryParam{ - Name: "myparam", - } - - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - QueryParams(queryParam), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - params := op.Parameters - if !assert.Len(t, params, 1) { - return - } - - param := params[0].Parameter - if !assert.NotNil(t, param) { - return - } - if !assert.Equal(t, openapi3.ParameterInQuery, param.In) { - return - } - if !assert.Equal(t, queryParam.Name, param.Name) { - return - } - if !assert.Equal(t, queryParam.Required, ptr.Deref(param.Required)) { - return - } - }) - }) - - t.Run("will set required query param", func(t *testing.T) { - t.Run("if a query param is provided with the QueryParams option", func(t *testing.T) { - queryParam := QueryParam{ - Name: "myparam", - Required: true, - } - - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - QueryParams(queryParam), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - params := op.Parameters - if !assert.Len(t, params, 1) { - return - } - - param := params[0].Parameter - if !assert.NotNil(t, param) { - return - } - if !assert.Equal(t, openapi3.ParameterInQuery, param.In) { - return - } - if !assert.Equal(t, queryParam.Name, param.Name) { - return - } - if !assert.Equal(t, queryParam.Required, ptr.Deref(param.Required)) { - return - } - }) - }) - - t.Run("will set request body type", func(t *testing.T) { - t.Run("if the request type implements ContentTyper interface", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ConsumesJson(HandlerFunc[jsonContent, EmptyResponse](func(_ context.Context, _ *jsonContent) (*EmptyResponse, error) { - return &EmptyResponse{}, nil - })), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - reqBodyOrRef := op.RequestBody - if !assert.NotNil(t, reqBodyOrRef) { - return - } - - reqBody := reqBodyOrRef.RequestBody - if !assert.NotNil(t, reqBody) { - return - } - - content := reqBody.Content - if !assert.Len(t, content, 1) { - return - } - - ct := (&JsonResponse[jsonContent]{}).ContentType() - if !assert.Contains(t, content, ct) { - return - } - - schemaOrRef := content[ct].Schema - if !assert.NotNil(t, schemaOrRef) { - return - } - - schema := schemaOrRef.Schema - if !assert.NotNil(t, schema) { - return - } - - props := schema.Properties - if !assert.Len(t, props, 1) { - return - } - if !assert.Contains(t, props, "value") { - return - } - }) - }) - - t.Run("will set response body type", func(t *testing.T) { - t.Run("if the response type implements ContentTyper interface", func(t *testing.T) { - type jsonContent struct { - Value string `json:"value"` - } - - e := NewOperation( - ProducesJson(HandlerFunc[EmptyRequest, jsonContent](func(_ context.Context, _ *EmptyRequest) (*jsonContent, error) { - return &jsonContent{}, nil - })), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - respOrRefValues := op.Responses.MapOfResponseOrRefValues - if !assert.Len(t, respOrRefValues, 1) { - return - } - if !assert.Contains(t, respOrRefValues, strconv.Itoa(DefaultStatusCode)) { - return - } - - resp := respOrRefValues[strconv.Itoa(DefaultStatusCode)].Response - if !assert.NotNil(t, resp) { - return - } - - content := resp.Content - if !assert.Len(t, content, 1) { - return - } - - ct := (&JsonResponse[jsonContent]{}).ContentType() - if !assert.Contains(t, content, ct) { - return - } - - schemaOrRef := content[ct].Schema - if !assert.NotNil(t, schemaOrRef) { - return - } - - schema := schemaOrRef.Schema - if !assert.NotNil(t, schema) { - return - } - - props := schema.Properties - if !assert.Len(t, props, 1) { - return - } - if !assert.Contains(t, props, "value") { - return - } - }) - }) - - t.Run("will set a empty response body", func(t *testing.T) { - t.Run("if the response type does not implement ContentTyper", func(t *testing.T) { - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - respOrRefValues := op.Responses.MapOfResponseOrRefValues - if !assert.Len(t, respOrRefValues, 1) { - return - } - if !assert.Contains(t, respOrRefValues, strconv.Itoa(DefaultStatusCode)) { - return - } - - resp := respOrRefValues[strconv.Itoa(DefaultStatusCode)].Response - if !assert.NotNil(t, resp) { - return - } - - content := resp.Content - if !assert.Len(t, content, 0) { - return - } - }) - - t.Run("if the Returns option is used with a http status code", func(t *testing.T) { - statusCode := http.StatusBadRequest - - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - Returns(statusCode), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - respOrRefValues := op.Responses.MapOfResponseOrRefValues - if !assert.Len(t, respOrRefValues, 2) { - return - } - if !assert.Contains(t, respOrRefValues, strconv.Itoa(DefaultStatusCode)) { - return - } - if !assert.Contains(t, respOrRefValues, strconv.Itoa(statusCode)) { - return - } - - defaultResp := respOrRefValues[strconv.Itoa(DefaultStatusCode)].Response - if !assert.NotNil(t, defaultResp) { - return - } - if !assert.Len(t, defaultResp.Content, 0) { - return - } - - resp := respOrRefValues[strconv.Itoa(statusCode)].Response - if !assert.NotNil(t, resp) { - return - } - if !assert.Len(t, resp.Content, 0) { - return - } - }) - }) - - t.Run("will override default response status code", func(t *testing.T) { - t.Run("if DefaultStatusCode option is used", func(t *testing.T) { - statusCode := http.StatusCreated - if !assert.NotEqual(t, statusCode, DefaultStatusCode) { - return - } - - e := NewOperation( - noopHandler[EmptyRequest, EmptyResponse]{}, - StatusCode(statusCode), - ) - - b, err := json.Marshal(e.OpenApi()) - if !assert.Nil(t, err) { - return - } - - var op openapi3.Operation - err = json.Unmarshal(b, &op) - if !assert.Nil(t, err) { - return - } - - respOrRefValues := op.Responses.MapOfResponseOrRefValues - if !assert.Len(t, respOrRefValues, 1) { - return - } - if !assert.Contains(t, respOrRefValues, strconv.Itoa(statusCode)) { - return - } - - resp := respOrRefValues[strconv.Itoa(statusCode)].Response - if !assert.NotNil(t, resp) { - return - } - - content := resp.Content - if !assert.Len(t, content, 0) { - return - } - }) - }) -} diff --git a/rest/endpoint/request.go b/rest/endpoint/request.go deleted file mode 100644 index 0b5c598..0000000 --- a/rest/endpoint/request.go +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - - "github.com/swaggest/jsonschema-go" - "github.com/swaggest/openapi-go/openapi3" - "gopkg.in/yaml.v3" -) - -// RequestReader -type RequestReader interface { - ReadRequest(r *http.Request) error -} - -// Request -type Request[T any] interface { - *T - - ContentTyper - Validator - OpenApiV3Schemaer - RequestReader -} - -// EmptyRequest -type EmptyRequest struct{} - -// ContentType implements [ContentTyper] interface. -func (EmptyRequest) ContentType() string { - return "" -} - -// Validate implements the [Validator] interface. -func (EmptyRequest) Validate() error { - return nil -} - -// OpenApiV3Schema implements the [OpenApiV3Schemaer] interface. -func (EmptyRequest) OpenApiV3Schema() (*openapi3.Schema, error) { - return nil, nil -} - -// ReadRequest implements the [RequestReader] interface. -func (*EmptyRequest) ReadRequest(r *http.Request) error { - return nil -} - -// ResponseOnlyHandler -type ResponseOnlyHandler[Resp any] interface { - Handle(context.Context) (*Resp, error) -} - -// EmptyRequestHandler wraps a given [ResponseOnlyHandler] into a complete [Handler] -// which expects an empty request body. -type EmptyRequestHandler[Resp any] struct { - inner ResponseOnlyHandler[Resp] -} - -// ConsumesNothing constructs a [EmptyRequestHandler] from the given [ResponseOnlyHandler]. -func ConsumesNothing[Resp any](h ResponseOnlyHandler[Resp]) *EmptyRequestHandler[Resp] { - return &EmptyRequestHandler[Resp]{ - inner: h, - } -} - -// Handle implements the [Handler] interface. -func (h *EmptyRequestHandler[Resp]) Handle(ctx context.Context, _ *EmptyRequest) (*Resp, error) { - return h.inner.Handle(ctx) -} - -// JsonRequestHandler wraps a given [Handler] and handles reading the underlying -// request type, Req, from JSON. -type JsonRequestHandler[Req, Resp any] struct { - inner Handler[Req, Resp] -} - -// ConsumesJson constructs a [JsonRequestHandler] from the given [Handler]. -func ConsumesJson[Req, Resp any](h Handler[Req, Resp]) *JsonRequestHandler[Req, Resp] { - return &JsonRequestHandler[Req, Resp]{ - inner: h, - } -} - -// Handle implements the [Handler] interface. -func (h *JsonRequestHandler[Req, Resp]) Handle(ctx context.Context, req *JsonRequest[Req]) (*Resp, error) { - return h.inner.Handle(ctx, &req.inner) -} - -// JsonRequest -type JsonRequest[T any] struct { - inner T -} - -// ContentType implements the [ContentTyper] interface. -func (JsonRequest[T]) ContentType() string { - return "application/json" -} - -// Validate implements the [Validator] interface. -func (req JsonRequest[T]) Validate() error { - iv, ok := any(req.inner).(Validator) - if !ok { - return nil - } - return iv.Validate() -} - -// OpenApiV3Schema implements the [OpenApiV3Schemaer] interface. -func (JsonRequest[T]) OpenApiV3Schema() (*openapi3.Schema, error) { - var reflector jsonschema.Reflector - var t T - jsonSchema, err := reflector.Reflect(t) - if err != nil { - return nil, err - } - var schemaOrRef openapi3.SchemaOrRef - schemaOrRef.FromJSONSchema(jsonSchema.ToSchemaOrBool()) - return schemaOrRef.Schema, nil -} - -// ReadRequest implements the [RequestReader] interface. -func (req *JsonRequest[T]) ReadRequest(r *http.Request) (err error) { - defer close(&err, r.Body) - - var b []byte - b, err = io.ReadAll(r.Body) - if err != nil { - return - } - err = json.Unmarshal(b, &req.inner) - return -} - -// YamlRequestHandler wraps a given [Handler] and handles reading the underlying -// request type, Req, from YAML. -type YamlRequestHandler[Req, Resp any] struct { - inner Handler[Req, Resp] -} - -// ConsumesYaml constructs a [YamlRequestHandler] from the given [Handler]. -func ConsumesYaml[Req, Resp any](h Handler[Req, Resp]) *YamlRequestHandler[Req, Resp] { - return &YamlRequestHandler[Req, Resp]{ - inner: h, - } -} - -// Handle implements the [Handler] interface. -func (h *YamlRequestHandler[Req, Resp]) Handle(ctx context.Context, req *YamlRequest[Req]) (*Resp, error) { - return h.inner.Handle(ctx, &req.inner) -} - -// YamlRequest -type YamlRequest[T any] struct { - inner T -} - -// ContentType implements the [ContentTyper] interface. -func (YamlRequest[T]) ContentType() string { - return "application/yaml" -} - -// Validate implements the [Validator] interface. -func (req YamlRequest[T]) Validate() error { - iv, ok := any(req.inner).(Validator) - if !ok { - return nil - } - return iv.Validate() -} - -// OpenApiV3Schema implements the [OpenApiV3Schemaer] interface. -func (YamlRequest[T]) OpenApiV3Schema() (*openapi3.Schema, error) { - var reflector jsonschema.Reflector - var t T - jsonSchema, err := reflector.Reflect(t) - if err != nil { - return nil, err - } - var schemaOrRef openapi3.SchemaOrRef - schemaOrRef.FromJSONSchema(jsonSchema.ToSchemaOrBool()) - return schemaOrRef.Schema, nil -} - -// ReadRequest implements the [RequestReader] interface. -func (req *YamlRequest[T]) ReadRequest(r *http.Request) (err error) { - defer close(&err, r.Body) - - var b []byte - b, err = io.ReadAll(r.Body) - if err != nil { - return - } - err = yaml.Unmarshal(b, &req.inner) - return -} - -func close(err *error, c io.Closer) { - closeErr := c.Close() - if closeErr == nil { - return - } - if *err == nil { - *err = closeErr - return - } - *err = errors.Join(*err, closeErr) -} diff --git a/rest/endpoint/request_test.go b/rest/endpoint/request_test.go deleted file mode 100644 index 46ab5eb..0000000 --- a/rest/endpoint/request_test.go +++ /dev/null @@ -1,274 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" -) - -type successValidator struct{} - -func (successValidator) Validate() error { - return nil -} - -func TestConsumesJson(t *testing.T) { - t.Run("will return an error while reading", func(t *testing.T) { - t.Run("if the request body is not valid json", func(t *testing.T) { - h := noopHandler[EmptyRequest, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesJson(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(``)) - r.Header.Set("Content-Type", JsonRequest[EmptyRequest]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var serr *json.SyntaxError - if !assert.ErrorAs(t, caughtError, &serr) { - return - } - }) - }) - - t.Run("will return a validation error", func(t *testing.T) { - t.Run("if the inner type fails to validate", func(t *testing.T) { - h := noopHandler[InvalidRequest, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesJson(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{}`)) - r.Header.Set("Content-Type", JsonRequest[InvalidRequest]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - if !assert.Equal(t, errInvalidRequest, caughtError) { - return - } - }) - }) - - t.Run("will not return a validation error", func(t *testing.T) { - t.Run("if the inner types successfully validates", func(t *testing.T) { - h := noopHandler[successValidator, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesJson(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{}`)) - r.Header.Set("Content-Type", JsonRequest[successValidator]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Nil(t, caughtError) { - return - } - }) - - t.Run("if the inner type does not implement the Validator interface", func(t *testing.T) { - type noop struct{} - - h := noopHandler[noop, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesJson(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{}`)) - r.Header.Set("Content-Type", JsonRequest[noop]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Nil(t, caughtError) { - return - } - }) - }) -} - -func TestConsumesYaml(t *testing.T) { - t.Run("will return an error while reading", func(t *testing.T) { - t.Run("if the request body is not valid yaml", func(t *testing.T) { - h := noopHandler[EmptyRequest, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesYaml(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`hello world`)) - r.Header.Set("Content-Type", YamlRequest[EmptyRequest]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - var terr *yaml.TypeError - if !assert.ErrorAs(t, caughtError, &terr) { - return - } - }) - }) - - t.Run("will return a validation error", func(t *testing.T) { - t.Run("if the inner type fails to validate", func(t *testing.T) { - h := noopHandler[InvalidRequest, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesYaml(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{}`)) - r.Header.Set("Content-Type", YamlRequest[InvalidRequest]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - - if !assert.Equal(t, errInvalidRequest, caughtError) { - return - } - }) - }) - - t.Run("will not return a validation error", func(t *testing.T) { - t.Run("if the inner types successfully validates", func(t *testing.T) { - h := noopHandler[successValidator, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesYaml(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{}`)) - r.Header.Set("Content-Type", YamlRequest[successValidator]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Nil(t, caughtError) { - return - } - }) - - t.Run("if the inner type does not implement the Validator interface", func(t *testing.T) { - type noop struct{} - - h := noopHandler[noop, EmptyResponse]{} - - var caughtError error - op := NewOperation( - ConsumesYaml(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{}`)) - r.Header.Set("Content-Type", YamlRequest[noop]{}.ContentType()) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Nil(t, caughtError) { - return - } - }) - }) -} diff --git a/rest/endpoint/response.go b/rest/endpoint/response.go deleted file mode 100644 index 45f8f9a..0000000 --- a/rest/endpoint/response.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "bytes" - "context" - "encoding/json" - "io" - - "github.com/swaggest/jsonschema-go" - "github.com/swaggest/openapi-go/openapi3" - "gopkg.in/yaml.v3" -) - -// Response -type Response[T any] interface { - *T - - ContentTyper - OpenApiV3Schemaer - io.WriterTo -} - -// EmptyResponse -type EmptyResponse struct{} - -// ContentType implements the [ContentTyper] interface. -func (EmptyResponse) ContentType() string { - return "" -} - -// OpenApiV3Schema implements the [OpenApiV3Schemaer] interface. -func (EmptyResponse) OpenApiV3Schema() (*openapi3.Schema, error) { - return nil, nil -} - -// WriteTo implements the [io.WriterTo] interface. -func (EmptyResponse) WriteTo(w io.Writer) (int64, error) { - return 0, nil -} - -// RequestOnlyHandler -type RequestOnlyHandler[Req any] interface { - Handle(context.Context, *Req) error -} - -// EmptyResponseHandler wraps a given [RequestOnlyHandler] into a complete [Handler] -// which does not return a response body. -type EmptyResponseHandler[Req any] struct { - inner RequestOnlyHandler[Req] -} - -// ProducesNothing constructs a [EmptyResponseHandler] from the given [RequestOnlyHandler]. -func ProducesNothing[Req any](h RequestOnlyHandler[Req]) *EmptyResponseHandler[Req] { - return &EmptyResponseHandler[Req]{ - inner: h, - } -} - -// Handle implements the [Handler] interface. -func (h *EmptyResponseHandler[Req]) Handle(ctx context.Context, req *Req) (*EmptyResponse, error) { - err := h.inner.Handle(ctx, req) - if err != nil { - return nil, err - } - return &EmptyResponse{}, nil -} - -// JsonResponseHandler wraps a given [Handler] and handles writing the underlying -// response type, Resp, to JSON. -type JsonResponseHandler[Req, Resp any] struct { - inner Handler[Req, Resp] -} - -// ProducesJson constructs a [JsonResponseHandler] from the given [Handler]. -func ProducesJson[Req, Resp any](h Handler[Req, Resp]) *JsonResponseHandler[Req, Resp] { - return &JsonResponseHandler[Req, Resp]{ - inner: h, - } -} - -// Handle implements the [Handler] interface. -func (h *JsonResponseHandler[Req, Resp]) Handle(ctx context.Context, req *Req) (*JsonResponse[Resp], error) { - resp, err := h.inner.Handle(ctx, req) - if err != nil { - return nil, err - } - if resp == nil { - return nil, ErrNilHandlerResponse - } - return &JsonResponse[Resp]{inner: resp}, nil -} - -// JsonResponse -type JsonResponse[T any] struct { - inner *T -} - -// ContentType implements the [ContentTyper] interface. -func (*JsonResponse[T]) ContentType() string { - return "application/json" -} - -// OpenApiV3Schema implements the [OpenApiV3Schemaer] interface. -func (JsonResponse[T]) OpenApiV3Schema() (*openapi3.Schema, error) { - var reflector jsonschema.Reflector - var t T - jsonSchema, err := reflector.Reflect(t) - if err != nil { - return nil, err - } - var schemaOrRef openapi3.SchemaOrRef - schemaOrRef.FromJSONSchema(jsonSchema.ToSchemaOrBool()) - return schemaOrRef.Schema, nil -} - -// WriteTo implements the [io.WriterTo] interface. -func (resp *JsonResponse[T]) WriteTo(w io.Writer) (int64, error) { - b, err := json.Marshal(resp.inner) - if err != nil { - return 0, err - } - return io.Copy(w, bytes.NewReader(b)) -} - -// YamlResponseHandler wraps a given [Handler] and handles writing the underlying -// response type, Resp, to YAML. -type YamlResponseHandler[Req, Resp any] struct { - inner Handler[Req, Resp] -} - -// ProducesYaml constructs a [YamlResponseHandler] from the given [Handler]. -func ProducesYaml[Req, Resp any](h Handler[Req, Resp]) *YamlResponseHandler[Req, Resp] { - return &YamlResponseHandler[Req, Resp]{ - inner: h, - } -} - -// Handle implements the [Handler] interface. -func (h *YamlResponseHandler[Req, Resp]) Handle(ctx context.Context, req *Req) (*YamlResponse[Resp], error) { - resp, err := h.inner.Handle(ctx, req) - if err != nil { - return nil, err - } - if resp == nil { - return nil, ErrNilHandlerResponse - } - return &YamlResponse[Resp]{inner: resp}, nil -} - -// YamlResponse -type YamlResponse[T any] struct { - inner *T -} - -// ContentType implements the [ContentTyper] interface. -func (*YamlResponse[T]) ContentType() string { - return "application/yaml" -} - -// OpenApiV3Schema implements the [OpenApiV3Schemaer] interface. -func (YamlResponse[T]) OpenApiV3Schema() (*openapi3.Schema, error) { - var reflector jsonschema.Reflector - var t T - jsonSchema, err := reflector.Reflect(t) - if err != nil { - return nil, err - } - var schemaOrRef openapi3.SchemaOrRef - schemaOrRef.FromJSONSchema(jsonSchema.ToSchemaOrBool()) - return schemaOrRef.Schema, nil -} - -// WriteTo implements the [io.WriterTo] interface. -func (resp *YamlResponse[T]) WriteTo(w io.Writer) (int64, error) { - b, err := yaml.Marshal(resp.inner) - if err != nil { - return 0, err - } - return io.Copy(w, bytes.NewReader(b)) -} diff --git a/rest/endpoint/response_test.go b/rest/endpoint/response_test.go deleted file mode 100644 index 4c6f2f9..0000000 --- a/rest/endpoint/response_test.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" -) - -func TestProducesJson(t *testing.T) { - t.Run("will return an error", func(t *testing.T) { - t.Run("if the inner handler returns an error", func(t *testing.T) { - handleErr := errors.New("failed to handle request") - h := HandlerFunc[EmptyRequest, EmptyResponse](func(ctx context.Context, req *EmptyRequest) (*EmptyResponse, error) { - return nil, handleErr - }) - - var caughtError error - op := NewOperation( - ProducesJson(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - if !assert.Equal(t, handleErr, caughtError) { - return - } - }) - }) - - t.Run("will return json", func(t *testing.T) { - t.Run("if the inner type successfully marshals to json", func(t *testing.T) { - type echo struct { - Msg string `json:"msg"` - } - - h := HandlerFunc[EmptyRequest, echo](func(ctx context.Context, req *EmptyRequest) (*echo, error) { - return &echo{Msg: "hello world"}, nil - }) - - var caughtError error - op := NewOperation( - ProducesJson(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Nil(t, caughtError) { - return - } - defer resp.Body.Close() - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var echoResp echo - err = json.Unmarshal(b, &echoResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello world", echoResp.Msg) { - return - } - }) - }) -} - -func TestProducesYaml(t *testing.T) { - t.Run("will return an error", func(t *testing.T) { - t.Run("if the inner handler returns an error", func(t *testing.T) { - handleErr := errors.New("failed to handle request") - h := HandlerFunc[EmptyRequest, EmptyResponse](func(ctx context.Context, req *EmptyRequest) (*EmptyResponse, error) { - return nil, handleErr - }) - - var caughtError error - op := NewOperation( - ProducesYaml(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultErrorStatusCode, resp.StatusCode) { - return - } - if !assert.Equal(t, handleErr, caughtError) { - return - } - }) - }) - - t.Run("will return yaml", func(t *testing.T) { - t.Run("if the inner type successfully marshals to yaml", func(t *testing.T) { - type echo struct { - Msg string `yaml:"msg"` - } - - h := HandlerFunc[EmptyRequest, echo](func(ctx context.Context, req *EmptyRequest) (*echo, error) { - return &echo{Msg: "hello world"}, nil - }) - - var caughtError error - op := NewOperation( - ProducesYaml(h), - OnError(errorHandlerFunc(func(ctx context.Context, w http.ResponseWriter, err error) { - caughtError = err - - w.WriteHeader(DefaultErrorStatusCode) - })), - ) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - op.ServeHTTP(w, r) - - resp := w.Result() - if !assert.Equal(t, DefaultStatusCode, resp.StatusCode) { - return - } - if !assert.Nil(t, caughtError) { - return - } - defer resp.Body.Close() - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var echoResp echo - err = yaml.Unmarshal(b, &echoResp) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "hello world", echoResp.Msg) { - return - } - }) - }) -} diff --git a/rest/endpoint/validate.go b/rest/endpoint/validate.go deleted file mode 100644 index 1af5023..0000000 --- a/rest/endpoint/validate.go +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package endpoint - -import ( - "context" - "fmt" - "net/http" - "regexp" - - "go.opentelemetry.io/otel" -) - -// Validator -type Validator interface { - Validate() error -} - -func validateRequest(ctx context.Context, r *http.Request, validators ...func(*http.Request) error) error { - _, span := otel.Tracer("endpoint").Start(ctx, "validateRequest") - defer span.End() - - for _, validator := range validators { - err := validator(r) - if err != nil { - span.RecordError(err) - return err - } - } - return nil -} - -// InvalidHeaderError occurs when a header value does not match -// it's expected pattern. -type InvalidHeaderError struct { - Header string -} - -// Error implements the [error] interface. -func (e InvalidHeaderError) Error() string { - return fmt.Sprintf("received invalid header for endpoint: %s", e.Header) -} - -// MissingRequiredHeaderError occurs when a header is marked as required -// but no value for the parameter is present in the request. -type MissingRequiredHeaderError struct { - Header string -} - -// Error implements the [error] interface. -func (e MissingRequiredHeaderError) Error() string { - return fmt.Sprintf("missing required header for endpoint: %s", e.Header) -} - -func validateHeader(h Header) func(*http.Request) error { - var pattern *regexp.Regexp - if h.Pattern != "" { - pattern = regexp.MustCompile(h.Pattern) - } - - return func(r *http.Request) error { - val := r.Header.Get(h.Name) - if pattern != nil && !pattern.MatchString(val) { - return InvalidHeaderError{Header: h.Name} - } - if !h.Required { - return nil - } - if val == "" { - return MissingRequiredHeaderError{Header: h.Name} - } - return nil - } -} - -// InvalidPathParamError occurs when a path parameter value does not match -// it's expected pattern. -type InvalidPathParamError struct { - Param string -} - -// Error implements the [error] interface. -func (e InvalidPathParamError) Error() string { - return fmt.Sprintf("received invalid path param for endpoint: %s", e.Param) -} - -// MissingRequiredPathParamError occurs when a path parameter is marked -// as required but no path value for the parameter is present in the request. -type MissingRequiredPathParamError struct { - Param string -} - -// Error implements the [error] interface. -func (e MissingRequiredPathParamError) Error() string { - return fmt.Sprintf("missing required path param for endpoint: %s", e.Param) -} - -func validatePathParam(p PathParam) func(*http.Request) error { - var pattern *regexp.Regexp - if p.Pattern != "" { - pattern = regexp.MustCompile(p.Pattern) - } - - return func(r *http.Request) error { - val := r.PathValue(p.Name) - if pattern != nil && !pattern.MatchString(val) { - return InvalidPathParamError{Param: p.Name} - } - if !p.Required { - return nil - } - if val == "" { - return MissingRequiredPathParamError{Param: p.Name} - } - return nil - } -} - -// InvalidQueryParamError occurs when a query parameter value does not -// match it's expected pattern. -type InvalidQueryParamError struct { - Param string -} - -// Error implements the [error] interface. -func (e InvalidQueryParamError) Error() string { - return fmt.Sprintf("received invalid query param for endpoint: %s", e.Param) -} - -// MissingRequiredQueryParamError occurs when a query parameter is marked -// as required but no value for the parameter is present in the request. -type MissingRequiredQueryParamError struct { - Param string -} - -// Error implements the [error] interface. -func (e MissingRequiredQueryParamError) Error() string { - return fmt.Sprintf("missing required query param for endpoint: %s", e.Param) -} - -func validateQueryParam(qp QueryParam) func(*http.Request) error { - var pattern *regexp.Regexp - if qp.Pattern != "" { - pattern = regexp.MustCompile(qp.Pattern) - } - - return func(r *http.Request) error { - val := r.URL.Query().Get(qp.Name) - if pattern != nil && !pattern.MatchString(val) { - return InvalidQueryParamError{Param: qp.Name} - } - if !qp.Required { - return nil - } - if val == "" { - return MissingRequiredQueryParamError{Param: qp.Name} - } - return nil - } -} diff --git a/rest/mux/mux.go b/rest/mux/mux.go deleted file mode 100644 index cba5039..0000000 --- a/rest/mux/mux.go +++ /dev/null @@ -1,172 +0,0 @@ -// 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" - "path" - "slices" - "strings" - "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) - - // {$} 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. -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 deleted file mode 100644 index a282b2d..0000000 --- a/rest/mux/mux_test.go +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package mux - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "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, - "http://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), - "http://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 - } - }) - } -} diff --git a/rest/rest.go b/rest/rest.go deleted file mode 100644 index 7dc65b0..0000000 --- a/rest/rest.go +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package rest - -import ( - "context" - "net" - "net/http" - "strings" - - "github.com/z5labs/bedrock/rest/endpoint" - "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" -) - -// Option represents configurable attributes of [App]. -type Option func(*App) - -// Listener allows you to configure the [net.Listener] for -// the underlying [http.Server] to use for serving requests. -// -// If this option is not supplied, then [net.Listen] will be -// used to create a [net.Listener] for "tcp" and address ":80". -func Listener(ls net.Listener) Option { - return func(a *App) { - a.ls = ls - } -} - -// OpenApiEndpoint registers a [http.Handler] with the underlying [http.ServeMux] -// meant for serving the OpenAPI schema. -func OpenApiEndpoint(method mux.Method, pattern string, f func(*openapi3.Spec) http.Handler) Option { - return func(a *App) { - a.openApiEndpoint = func(mux Mux) { - mux.Handle(method, pattern, f(a.spec)) - } - } -} - -type specHandler struct { - spec *openapi3.Spec -} - -func (h *specHandler) Handle(ctx context.Context) (*openapi3.Spec, error) { - return h.spec, nil -} - -// OpenApiJsonHandler returns an [http.Handler] which will respond with the OpenAPI schema as JSON. -func OpenApiJsonHandler(eh endpoint.ErrorHandler) func(*openapi3.Spec) http.Handler { - return func(spec *openapi3.Spec) http.Handler { - h := &specHandler{ - spec: spec, - } - - return endpoint.NewOperation( - endpoint.ProducesJson( - endpoint.ConsumesNothing( - h, - ), - ), - endpoint.OnError(eh), - ) - } -} - -// OpenApiYamlHandler returns an [http.Handler] which will respond with the OpenAPI schema as YAML. -func OpenApiYamlHandler(eh endpoint.ErrorHandler) func(*openapi3.Spec) http.Handler { - return func(spec *openapi3.Spec) http.Handler { - h := &specHandler{ - spec: spec, - } - - return endpoint.NewOperation( - endpoint.ProducesYaml( - endpoint.ConsumesNothing( - h, - ), - ), - endpoint.OnError(eh), - ) - } -} - -// Operation represents anything that can handle HTTP requests -// and provide OpenAPI documentation for itself. -type Operation interface { - http.Handler - - OpenApi() openapi3.Operation -} - -// Endpoint represents all information necessary for registering -// an [Operation] with a [App]. -type Endpoint struct { - Method mux.Method - Pattern string - Operation Operation -} - -// Register registers the [Endpoint] with both -// the App wide OpenAPI spec and the App wide HTTP server. -// -// "/" is always treated as "/{$}" because it would otherwise -// match too broadly and cause conflicts with other paths. -func Register(e Endpoint) Option { - return func(app *App) { - app.endpoints = append(app.endpoints, e) - } -} - -// Title sets the title of the API in its OpenAPI spec. -// -// In order for your OpenAPI spec to be fully compliant -// with other tooling, this option is required. -func Title(s string) Option { - return func(a *App) { - a.spec.Info.Title = s - } -} - -// Version sets the API version in its OpenAPI spec. -// -// In order for your OpenAPI spec to be fully compliant -// with other tooling, this option is required. -func Version(s string) Option { - return func(a *App) { - a.spec.Info.Version = s - } -} - -// 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(Mux) - - listen func(network, addr string) (net.Listener, error) -} - -// NewApp initializes a [App]. -func NewApp(opts ...Option) *App { - app := &App{ - spec: &openapi3.Spec{ - Openapi: "3.0.3", - }, - mux: mux.NewHttp(), - listen: net.Listen, - openApiEndpoint: func(_ Mux) {}, - } - for _, opt := range opts { - opt(app) - } - return app -} - -// Run implements the [bedrock.App] interface. -func (app *App) Run(ctx context.Context) error { - ls, err := app.listener() - if err != nil { - return err - } - - app.openApiEndpoint(app.mux) - - err = app.registerEndpoints() - if err != nil { - return err - } - - httpServer := &http.Server{ - Handler: otelhttp.NewHandler( - app.mux, - "server", - otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents), - ), - } - - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return httpServer.Serve(ls) - }) - eg.Go(func() error { - <-egctx.Done() - return httpServer.Shutdown(context.Background()) - }) - - err = eg.Wait() - if err != nil && err != http.ErrServerClosed { - return err - } - return nil -} - -func (app *App) listener() (net.Listener, error) { - if app.ls != nil { - return app.ls, nil - } - return app.listen("tcp", ":80") -} - -func (app *App) registerEndpoints() error { - for _, e := range app.endpoints { - // Per the net/http.ServeMux docs, https://pkg.go.dev/net/http#ServeMux: - // - // The special wildcard {$} matches only the end of the URL. - // For example, the pattern "/{$}" matches only the path "/", - // whereas the pattern "/" matches every path. - // - // This means that when registering the pattern with the OpenAPI spec - // the {$} needs to be stripped because OpenAPI will believe it's - // an actual path parameter. - trimmedPattern := strings.TrimSuffix(e.Pattern, "{$}") - - // Per the net/http.ServeMux docs, https://pkg.go.dev/net/http#ServeMux: - // - // A path can include wildcard segments of the form {NAME} or {NAME...}. - // - // The '...' wildcard has no equivalent in OpenAPI so we must remove it - // before registering the OpenAPI operation with the spec. - trimmedPattern = strings.ReplaceAll(trimmedPattern, "...", "") - - err := app.spec.AddOperation(string(e.Method), trimmedPattern, e.Operation.OpenApi()) - if err != nil { - return err - } - - // enforce strict matching for top-level path - // otherwise "/" would match too broadly and http.ServeMux - // will panic when other paths are registered e.g. /openapi.json - if e.Pattern == "/" { - e.Pattern = "/{$}" - } - - app.mux.Handle( - e.Method, - e.Pattern, - otelhttp.WithRouteTag(trimmedPattern, e.Operation), - ) - } - return nil -} diff --git a/rest/rest_example_test.go b/rest/rest_example_test.go deleted file mode 100644 index 3c1e023..0000000 --- a/rest/rest_example_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package rest - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "strings" - - re "github.com/z5labs/bedrock/rest/endpoint" - - "golang.org/x/sync/errgroup" -) - -type echoService struct{} - -type EchoRequest struct { - Msg string `json:"msg"` -} - -type EchoResponse struct { - Msg string `json:"msg"` -} - -func (echoService) Handle(ctx context.Context, req *EchoRequest) (*EchoResponse, error) { - return &EchoResponse{Msg: req.Msg}, nil -} - -func Example() { - ls, err := net.Listen("tcp", ":0") - if err != nil { - fmt.Println(err) - return - } - - app := NewApp( - Listener(ls), - Title("Example"), - Version("v0.0.0"), - Register(Endpoint{ - Method: http.MethodPost, - Pattern: "/", - Operation: re.NewOperation( - re.ConsumesJson( - re.ProducesJson(echoService{}), - ), - ), - }), - ) - - ctx, cancel := context.WithCancel(context.Background()) - - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return app.Run(egctx) - }) - eg.Go(func() error { - defer cancel() - - addr := ls.Addr() - - resp, err := http.Post( - fmt.Sprintf("http://%s", addr), - "application/json", - strings.NewReader(`{ - "msg": "hello, world" - }`), - ) - if err != nil { - return err - } - - b, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - var echoResp EchoResponse - err = json.Unmarshal(b, &echoResp) - if err != nil { - return err - } - - fmt.Println(echoResp.Msg) - return nil - }) - - err = eg.Wait() - if err != nil { - fmt.Println(err) - return - } - - // Output: hello, world -} diff --git a/rest/rest_test.go b/rest/rest_test.go deleted file mode 100644 index 39c064e..0000000 --- a/rest/rest_test.go +++ /dev/null @@ -1,683 +0,0 @@ -// Copyright (c) 2024 Z5Labs and Contributors -// -// This software is released under the MIT License. -// https://opensource.org/licenses/MIT - -package rest - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "path" - "testing" - - "github.com/z5labs/bedrock/pkg/ptr" - "github.com/z5labs/bedrock/rest/endpoint" - "github.com/z5labs/bedrock/rest/mux" - - "github.com/stretchr/testify/assert" - "github.com/swaggest/openapi-go/openapi3" - "golang.org/x/sync/errgroup" - "gopkg.in/yaml.v3" -) - -type statusCodeHandler int - -func (h statusCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(int(h)) -} - -func (h statusCodeHandler) OpenApi() openapi3.Operation { - return openapi3.Operation{} -} - -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 := mux.NewHttp( - mux.NotFoundHandler(notFoundHandler), - ) - - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp( - Listener(ls), - WithMux(mux), - func(a *App) { - if testCase.RegisterPattern == "" { - return - } - Register(Endpoint{ - Method: http.MethodGet, - Pattern: testCase.RegisterPattern, - Operation: statusCodeHandler(http.StatusOK), - })(a) - }, - ) - - respCh := make(chan *http.Response, 1) - ctx, cancel := context.WithCancel(context.Background()) - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return app.Run(egctx) - }) - eg.Go(func() error { - defer cancel() - defer close(respCh) - - addr := ls.Addr() - url := fmt.Sprintf("http://%s", path.Join(addr.String(), testCase.RequestPath)) - resp, err := http.Get(url) - if err != nil { - return err - } - - select { - case <-egctx.Done(): - return egctx.Err() - case respCh <- resp: - } - return nil - }) - - err = eg.Wait() - if !assert.Nil(t, err) { - return - } - - resp := <-respCh - 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[mux.Method]string - Method mux.Method - RequestPath string - MethodNotAllowed bool - }{ - { - Name: "should return success response when correct method is used", - RegisterPatterns: map[mux.Method]string{ - 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[mux.Method]string{ - http.MethodGet: "/", - http.MethodPost: "/", - }, - Method: mux.MethodGet, - RequestPath: "/", - MethodNotAllowed: false, - }, - { - Name: "should return method not allowed response when incorrect method is used", - RegisterPatterns: map[mux.Method]string{ - http.MethodGet: "/", - }, - Method: mux.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 := mux.NewHttp( - mux.MethodNotAllowedHandler(methodNotAllowedHandler), - ) - - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp( - Listener(ls), - WithMux(mux), - func(a *App) { - for method, pattern := range testCase.RegisterPatterns { - Register(Endpoint{ - Method: method, - Pattern: pattern, - Operation: statusCodeHandler(http.StatusOK), - })(a) - } - }, - ) - - respCh := make(chan *http.Response, 1) - ctx, cancel := context.WithCancel(context.Background()) - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return app.Run(egctx) - }) - eg.Go(func() error { - defer cancel() - defer close(respCh) - - addr := ls.Addr() - url := fmt.Sprintf("http://%s", path.Join(addr.String(), testCase.RequestPath)) - - req, err := http.NewRequestWithContext(egctx, string(testCase.Method), url, nil) - if err != nil { - return err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - - select { - case <-egctx.Done(): - return egctx.Err() - case respCh <- resp: - } - return nil - }) - - err = eg.Wait() - if !assert.Nil(t, err) { - return - } - - resp := <-respCh - 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 - } - }) - } -} - -func TestOpenApiJsonHandler(t *testing.T) { - t.Run("will return OpenAPI spec", func(t *testing.T) { - t.Run("if a GET request is sent to /openapi.json", func(t *testing.T) { - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp( - Listener(ls), - OpenApiEndpoint(http.MethodGet, "/openapi.json", OpenApiJsonHandler(endpoint.DefaultErrorHandler)), - ) - - respCh := make(chan *http.Response, 1) - ctx, cancel := context.WithCancel(context.Background()) - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return app.Run(egctx) - }) - eg.Go(func() error { - defer cancel() - defer close(respCh) - - addr := ls.Addr() - resp, err := http.Get(fmt.Sprintf("http://%s/openapi.json", addr)) - if err != nil { - return err - } - - select { - case <-egctx.Done(): - return egctx.Err() - case respCh <- resp: - } - return nil - }) - - err = eg.Wait() - if !assert.Nil(t, err) { - return - } - - resp := <-respCh - if !assert.NotNil(t, resp) { - return - } - defer resp.Body.Close() - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var spec openapi3.Spec - err = json.Unmarshal(b, &spec) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "3.0.3", spec.Openapi) { - return - } - }) - }) -} - -func TestOpenApiYamlHandler(t *testing.T) { - t.Run("will return OpenAPI spec", func(t *testing.T) { - t.Run("if a GET request is sent to /openapi.yaml", func(t *testing.T) { - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp( - Listener(ls), - OpenApiEndpoint(http.MethodGet, "/openapi.yaml", OpenApiYamlHandler(endpoint.DefaultErrorHandler)), - ) - - respCh := make(chan *http.Response, 1) - ctx, cancel := context.WithCancel(context.Background()) - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return app.Run(egctx) - }) - eg.Go(func() error { - defer cancel() - defer close(respCh) - - addr := ls.Addr() - resp, err := http.Get(fmt.Sprintf("http://%s/openapi.yaml", addr)) - if err != nil { - return err - } - - select { - case <-egctx.Done(): - return egctx.Err() - case respCh <- resp: - } - return nil - }) - - err = eg.Wait() - if !assert.Nil(t, err) { - return - } - - resp := <-respCh - if !assert.NotNil(t, resp) { - return - } - defer resp.Body.Close() - - b, err := io.ReadAll(resp.Body) - if !assert.Nil(t, err) { - return - } - - var spec openapi3.Spec - err = yaml.Unmarshal(b, &spec) - if !assert.Nil(t, err) { - return - } - if !assert.Equal(t, "3.0.3", spec.Openapi) { - return - } - }) - }) -} - -type operationHandler openapi3.Operation - -func (h operationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {} - -func (h operationHandler) OpenApi() openapi3.Operation { - return (openapi3.Operation)(h) -} - -func TestApp_Run(t *testing.T) { - t.Run("will return an error", func(t *testing.T) { - t.Run("if it fails to create the default net.Listener", func(t *testing.T) { - app := NewApp() - - listenErr := errors.New("failed to listen") - app.listen = func(network, addr string) (net.Listener, error) { - return nil, listenErr - } - - err := app.Run(context.Background()) - if !assert.Equal(t, listenErr, err) { - return - } - }) - - t.Run("if a path parameter defined in the openapi3.Operation is not in the path pattern", func(t *testing.T) { - h := operationHandler(openapi3.Operation{ - Parameters: []openapi3.ParameterOrRef{ - { - Parameter: &openapi3.Parameter{ - In: openapi3.ParameterInPath, - Name: "id", - Schema: &openapi3.SchemaOrRef{ - Schema: &openapi3.Schema{ - Type: ptr.Ref(openapi3.SchemaTypeString), - }, - }, - }, - }, - }, - }) - - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp( - Listener(ls), - Register(Endpoint{ - Method: http.MethodGet, - Pattern: "/", - Operation: h, - }), - ) - - err = app.Run(context.Background()) - if !assert.NotNil(t, err) { - return - } - }) - }) - - t.Run("will not return an error", func(t *testing.T) { - t.Run("if the context.Context is cancelled", func(t *testing.T) { - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp(Listener(ls)) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - err = app.Run(ctx) - if !assert.Nil(t, err) { - return - } - }) - - t.Run("if a path parameter defined in the openapi3.Operation is in the path pattern", func(t *testing.T) { - h := operationHandler(openapi3.Operation{ - Parameters: []openapi3.ParameterOrRef{ - { - Parameter: &openapi3.Parameter{ - In: openapi3.ParameterInPath, - Name: "id", - Schema: &openapi3.SchemaOrRef{ - Schema: &openapi3.Schema{ - Type: ptr.Ref(openapi3.SchemaTypeString), - }, - }, - }, - }, - }, - }) - - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp( - Listener(ls), - Register(Endpoint{ - Method: http.MethodGet, - Pattern: "/{id}", - Operation: h, - }), - ) - - ctx, cancel := context.WithCancel(context.Background()) - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return app.Run(egctx) - }) - - respCh := make(chan *http.Response, 1) - eg.Go(func() error { - defer cancel() - defer close(respCh) - - req, err := http.NewRequestWithContext( - egctx, - http.MethodGet, - fmt.Sprintf("http://%s/123", ls.Addr()), - nil, - ) - if err != nil { - return err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - - respCh <- resp - return nil - }) - - err = eg.Wait() - if !assert.Nil(t, err) { - return - } - - resp := <-respCh - if !assert.NotNil(t, resp) { - return - } - if !assert.Equal(t, http.StatusOK, resp.StatusCode) { - return - } - }) - - t.Run("if a wildcard path parameter is used", func(t *testing.T) { - h := operationHandler(openapi3.Operation{ - Parameters: []openapi3.ParameterOrRef{ - { - Parameter: &openapi3.Parameter{ - In: openapi3.ParameterInPath, - Name: "id", - Schema: &openapi3.SchemaOrRef{ - Schema: &openapi3.Schema{ - Type: ptr.Ref(openapi3.SchemaTypeString), - }, - }, - }, - }, - }, - }) - - ls, err := net.Listen("tcp", ":0") - if !assert.Nil(t, err) { - return - } - - app := NewApp( - Listener(ls), - Register(Endpoint{ - Method: http.MethodGet, - Pattern: "/{id...}", - Operation: h, - }), - ) - - ctx, cancel := context.WithCancel(context.Background()) - eg, egctx := errgroup.WithContext(ctx) - eg.Go(func() error { - return app.Run(egctx) - }) - - respCh := make(chan *http.Response, 1) - eg.Go(func() error { - defer cancel() - defer close(respCh) - - req, err := http.NewRequestWithContext( - egctx, - http.MethodGet, - fmt.Sprintf("http://%s/123", ls.Addr()), - nil, - ) - if err != nil { - return err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - - respCh <- resp - return nil - }) - - err = eg.Wait() - if !assert.Nil(t, err) { - return - } - - resp := <-respCh - if !assert.NotNil(t, resp) { - return - } - if !assert.Equal(t, http.StatusOK, resp.StatusCode) { - return - } - }) - }) -}