diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index c5fba70..58bb126 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,8 +14,8 @@ jobs: strategy: matrix: go-version: - - 1.18 - - 1.19 + - 1.23 + - 1.24 steps: - uses: actions/checkout@v3 diff --git a/.scripts/test.sh b/.scripts/test.sh index 6aadd47..9d2bfdc 100644 --- a/.scripts/test.sh +++ b/.scripts/test.sh @@ -7,7 +7,7 @@ if [[ -f coverage.txt ]]; then fi for d in $(go list ./... | grep -v ditest); do - go test -coverprofile=profile.out -coverpkg=./... -covermode=atomic "$d" + go test -race -coverprofile=profile.out -coverpkg=./... -covermode=atomic "$d" if [[ -f profile.out ]]; then cat profile.out >>coverage.txt rm profile.out diff --git a/README.md b/README.md index 7133a4b..f782f7a 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,28 @@ go get github.com/gozix/echo/v4 } ``` +Without server names the `http-server` command runs all servers from the `echo` config section: + +```golang +gzEcho.NewBundle() +``` + +With server names the command runs only these servers and fails if any of them is not configured: + ```golang gzEcho.NewBundle("admin", "public", "private") ``` +To get a server echo instance outside the `http-server` command, use the factory: + +```golang +func NewRoutesCommand(f *gzEcho.Factory) *cobra.Command { + // ... + var e, err = f.Echo("admin") + // ... +} +``` + ## Documentation You can find documentation on [pkg.go.dev][documentation-url] and read source code if needed. diff --git a/di.go b/di.go index 74b07c4..dfcb119 100644 --- a/di.go +++ b/di.go @@ -4,107 +4,47 @@ import ( "strconv" "github.com/gozix/di" -) - -const ( - // tagServerName is tag to mark a server name value. - tagServerName = "echo.server_name" - - // tagEcho is tag to mark a echo instance. - tagEcho = "echo.echo" - - // tagConfigurator is tag to mark configurators. - tagConfigurator = "echo.configurator" - - // tagController is tag to mark controllers. - tagController = "echo.controller" - // tagMiddleware is tag to mark middlewares. - tagMiddleware = "echo.middleware" - - // argMiddlewarePriority is name of priority argument. - argMiddlewarePriority = "priority" + "github.com/gozix/echo/v4/internal/tag" ) // AsConfigurator is syntax sugar for the di container. func AsConfigurator(srvName string) di.ProvideOption { return di.Tags{{ - Name: tagConfigurator + "." + srvName, + Name: tag.Configurator + "." + srvName, }} } // AsController is syntax sugar for the di container. func AsController(srvName string) di.ProvideOption { return di.ProvideOptions(di.Tags{{ - Name: tagController + "." + srvName, + Name: tag.Controller + "." + srvName, }}, di.As(new(Controller))) } // AsMiddleware is syntax sugar for the di container. func AsMiddleware(srvName string, priority int64) di.ProvideOption { return di.Tags{{ - Name: tagMiddleware + "." + srvName, + Name: tag.Middleware + "." + srvName, Args: di.Args{{ - Key: argMiddlewarePriority, + Key: tag.ArgMiddlewarePriority, Value: strconv.FormatInt(priority, 10), }}, }} } -func asServerName(srvName string) di.AddOption { +func asServerName() di.AddOption { return di.Tags{{ - Name: tagServerName, - Args: di.Args{{ - Key: "name", - Value: srvName, - }}, - }, { - Name: tagServerName + "." + srvName, + Name: tag.ServerName, }} } func asEcho(srvName string) di.ProvideOption { return di.Tags{{ - Name: tagEcho + "." + srvName, + Name: tag.Echo + "." + srvName, }} } -func sortByPriority() di.Modifier { - return di.Sort(func(x, y di.Definition) bool { - var xp int64 - for _, tag := range x.Tags() { - for _, arg := range tag.Args { - if arg.Key == argMiddlewarePriority { - xp, _ = strconv.ParseInt(arg.Value, 10, 64) - } - } - } - - var yp int64 - for _, tag := range y.Tags() { - for _, arg := range tag.Args { - if arg.Key == argMiddlewarePriority { - yp, _ = strconv.ParseInt(arg.Value, 10, 64) - } - } - } - - return xp > yp - }) -} - -func withServerName(srvName string) di.Modifier { - return di.WithTags(tagServerName + "." + srvName) -} - -func withConfigurator(srvName string) di.Modifier { - return di.WithTags(tagConfigurator + "." + srvName) -} - -func withController(srvName string) di.Modifier { - return di.WithTags(tagController + "." + srvName) -} - -func withMiddleware(srvName string) di.Modifier { - return di.WithTags(tagMiddleware + "." + srvName) +func withServerName() di.Modifier { + return di.WithTags(tag.ServerName) } diff --git a/echo.go b/echo.go index ae73abf..ac82f20 100644 --- a/echo.go +++ b/echo.go @@ -10,10 +10,11 @@ import ( gzValidator "github.com/gozix/validator/v3" gzViper "github.com/gozix/viper/v3" gzZap "github.com/gozix/zap/v3" + "github.com/labstack/echo/v4" "github.com/gozix/echo/v4/internal/command" "github.com/gozix/echo/v4/internal/configurator" - "github.com/gozix/echo/v4/internal/echo" + "github.com/gozix/echo/v4/internal/factory" ) type ( @@ -27,6 +28,9 @@ type ( // Controller is type alias of controller.Controller. Controller = configurator.Controller + + // Factory is type alias of factory.Factory. + Factory = factory.Factory ) // BundleName is default definition name. @@ -50,34 +54,21 @@ func (b *Bundle) Name() string { func (b *Bundle) Build(builder di.Builder) error { var opt = []di.BuilderOption{ di.Provide(command.NewHTTPServer, glue.AsCliCommand()), + di.Provide(factory.New, di.Constraint(4, withServerName(), di.Optional(true))), } for _, srvName := range b.svrNames { opt = append(opt, di.BuilderOptions( // server name - di.Add(srvName, asServerName(srvName)), + di.Add(srvName, asServerName()), // echo di.Provide( - echo.New, asEcho(srvName), - di.Constraint(0, withConfigurator(srvName)), - ), - - // configurators - di.Provide( - configurator.NewController, AsConfigurator(srvName), - di.Constraint(0, withController(srvName), di.Optional(true)), - ), - di.Provide( - configurator.NewMiddleware, AsConfigurator(srvName), - di.Constraint(0, withMiddleware(srvName), di.Optional(true), sortByPriority()), - ), - di.Provide( - configurator.NewEcho, AsConfigurator(srvName), - di.Constraint(0, withServerName(srvName)), + func(f *Factory) (*echo.Echo, error) { + return f.Echo(srvName) + }, + asEcho(srvName), ), - di.Provide(configurator.NewErrHandler, AsConfigurator(srvName)), - di.Provide(configurator.NewValidator, AsConfigurator(srvName)), )) } diff --git a/go.mod b/go.mod index c26aebc..230dd0b 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.26.0 github.com/gobwas/glob v0.2.3 - github.com/gozix/di v1.0.3 + github.com/gozix/di v1.0.4 github.com/gozix/glue/v3 v3.0.0 github.com/gozix/validator/v3 v3.0.0 github.com/gozix/viper/v3 v3.0.3 diff --git a/go.sum b/go.sum index ad1e4e0..8db404a 100644 --- a/go.sum +++ b/go.sum @@ -21,8 +21,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 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/gozix/di v1.0.3 h1:HPAcwupunuzABgfWuX4NcspTMEG9yUq1dTgiRSUmb/k= -github.com/gozix/di v1.0.3/go.mod h1:VpR4iuzehn5oXLUaBcn6Mw8VgZlIpqTO/OssNIZaHHc= +github.com/gozix/di v1.0.4 h1:Y0QPlZ+sP4Jsy2h7wEVTl3qePqReK9WdBY14NXGGiHc= +github.com/gozix/di v1.0.4/go.mod h1:VpR4iuzehn5oXLUaBcn6Mw8VgZlIpqTO/OssNIZaHHc= github.com/gozix/glue/v3 v3.0.0 h1:nnISjcf1n7DDuI3bdabPl6oNk446JoZI3icr72LtmTQ= github.com/gozix/glue/v3 v3.0.0/go.mod h1:+AdMEhqEnm1q13ouVItuEieGWlcdK4LNDj6IJrEK/J8= github.com/gozix/universal-translator/v3 v3.0.0 h1:7C3pbv4KzIw1Rfc3EhleVZjLzJJd0HdXQLo4/9tklUE= diff --git a/internal/command/http_server.go b/internal/command/http_server.go index c7541c6..644d858 100644 --- a/internal/command/http_server.go +++ b/internal/command/http_server.go @@ -6,8 +6,10 @@ package command import ( "context" + "errors" "fmt" "net" + "net/http" "time" "github.com/gozix/di" @@ -17,12 +19,8 @@ import ( "go.uber.org/zap" "golang.org/x/sync/errgroup" - "github.com/gozix/echo/v4/internal/modifier" -) - -const ( - tagEcho = "echo.echo" - tagServerName = "echo.server_name" + "github.com/gozix/echo/v4/internal/factory" + "github.com/gozix/echo/v4/internal/filter" ) // NewHTTPServer is command constructor. @@ -30,23 +28,25 @@ func NewHTTPServer(ctn di.Container) *cobra.Command { return &cobra.Command{ Use: "http-server [name...]", Short: "Run http server", - RunE: func(cmd *cobra.Command, args []string) (err error) { - var modServers *modifier.Modifier - if modServers, err = modifier.NewModifier(tagServerName, args); err != nil { - return fmt.Errorf("unable create echo glob modifier : %w", err) - } + RunE: func(cmd *cobra.Command, args []string) error { + return ctn.Call(func(f *factory.Factory, cfg *viper.Viper, logger *zap.Logger) (err error) { + var names []string + if names, err = filter.Names(f.Names(), args); err != nil { + return fmt.Errorf("unable filter echo servers : %w", err) + } + + // build all servers before start + var servers = make(map[string]*echo.Echo, len(names)) + for _, srvName := range names { + if servers[srvName], err = f.Echo(srvName); err != nil { + return err + } + } - return ctn.Call(func(serverNames []string, cfg *viper.Viper, logger *zap.Logger) error { var wg, ctx = errgroup.WithContext(cmd.Context()) - for _, srvName := range serverNames { - var srvName = srvName + for srvName, e := range servers { wg.Go(func() (err error) { - var e *echo.Echo - if err = ctn.Resolve(&e, di.WithTags(tagEcho+"."+srvName)); err != nil { - return err - } - var ( subCfg = cfg.Sub("echo." + srvName) addr = net.JoinHostPort( @@ -61,30 +61,41 @@ func NewHTTPServer(ctn di.Container) *cobra.Command { log.Info("Starting HTTP server") + var stopped = make(chan error, 1) go func() { - if err = e.Start(addr); err != nil { - log.Info("Gracefully shutting down the HTTP server") - } + stopped <- e.Start(addr) }() - log.Info("HTTP server started") + select { + case <-ctx.Done(): + // graceful shutdown + var timeout = 10 * time.Second + log.Info("Stopping HTTP server", zap.Duration("timeout", timeout)) + + var timeoutContext, cancel = context.WithTimeout(context.Background(), timeout) + defer cancel() - // wait, context cancellation - <-ctx.Done() + if err = e.Shutdown(timeoutContext); err != nil { + return fmt.Errorf("unable to stop http server %s : %w", srvName, err) + } - // graceful shutdown - var timeout = 10 * time.Second - log.Info("Stopping HTTP server", zap.Duration("timeout", timeout)) + err = <-stopped + case err = <-stopped: + // stopped without shutdown, e.g. the address is already in use + } - var timeoutContext, cancel = context.WithTimeout(context.Background(), timeout) - defer cancel() + // http.ErrServerClosed is returned by the start after the shutdown + if errors.Is(err, http.ErrServerClosed) { + log.Info("HTTP server stopped") + return nil + } - return e.Shutdown(timeoutContext) + return fmt.Errorf("http server %s : %w", srvName, err) }) } return wg.Wait() - }, di.Constraint(0, modServers.Modifier())) + }) }, } } diff --git a/internal/command/http_server_test.go b/internal/command/http_server_test.go new file mode 100644 index 0000000..348f8f7 --- /dev/null +++ b/internal/command/http_server_test.go @@ -0,0 +1,119 @@ +// Copyright 2018 Sergey Novichkov. All rights reserved. +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +package command_test + +import ( + "context" + "net" + "net/http" + "syscall" + "testing" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gozix/di" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/gozix/echo/v4/internal/command" + "github.com/gozix/echo/v4/internal/factory" + "github.com/gozix/echo/v4/internal/tag" +) + +func TestHTTPServerGracefulShutdown(t *testing.T) { + var ctn = newContainer(t, "0") + + var f *factory.Factory + require.NoError(t, ctn.Resolve(&f)) + + var e, err = f.Echo("admin") + require.NoError(t, err) + + var ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + + var done = make(chan error, 1) + go func() { + done <- execute(ctx, ctn) + }() + + require.Eventually(t, func() bool { + return e.ListenerAddr() != nil + }, 5*time.Second, 10*time.Millisecond, "http server is not started") + + resp, err := http.Get("http://" + e.ListenerAddr().String()) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + cancel() + + select { + case err = <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("http server is not stopped") + } +} + +func TestHTTPServerStartError(t *testing.T) { + var busy, err = net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + defer func() { + _ = busy.Close() + }() + + _, port, err := net.SplitHostPort(busy.Addr().String()) + require.NoError(t, err) + + var done = make(chan error, 1) + go func() { + done <- execute(context.Background(), newContainer(t, port)) + }() + + select { + case err = <-done: + require.ErrorIs(t, err, syscall.EADDRINUSE) + case <-time.After(5 * time.Second): + t.Fatal("http server start error is not returned") + } +} + +func newContainer(t *testing.T, port string) di.Container { + t.Helper() + + var cfg = viper.New() + cfg.Set("echo.admin.host", "127.0.0.1") + cfg.Set("echo.admin.port", port) + cfg.Set("echo.admin.hide_banner", true) + cfg.Set("echo.admin.hide_port", true) + + var builder, err = di.NewBuilder( + di.Add(cfg), + di.Add(zap.NewNop()), + di.Add(validator.New()), + di.Provide( + factory.New, + di.Constraint(4, di.WithTags(tag.ServerName), di.Optional(true)), + ), + ) + require.NoError(t, err) + + ctn, err := builder.Build() + require.NoError(t, err) + + return ctn +} + +func execute(ctx context.Context, ctn di.Container) error { + var cmd = command.NewHTTPServer(ctn) + cmd.SetArgs([]string{}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + return cmd.ExecuteContext(ctx) +} diff --git a/internal/factory/factory.go b/internal/factory/factory.go new file mode 100644 index 0000000..7ab4edc --- /dev/null +++ b/internal/factory/factory.go @@ -0,0 +1,131 @@ +// Copyright 2018 Sergey Novichkov. All rights reserved. +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +package factory + +import ( + "fmt" + "maps" + "slices" + "strconv" + "sync" + + "github.com/go-playground/validator/v10" + "github.com/gozix/di" + "github.com/labstack/echo/v4" + "github.com/spf13/viper" + "go.uber.org/zap" + + "github.com/gozix/echo/v4/internal/configurator" + internalEcho "github.com/gozix/echo/v4/internal/echo" + "github.com/gozix/echo/v4/internal/tag" +) + +// configKey is config section with servers configuration. +const configKey = "echo" + +// Factory builds echo servers on demand, after config is read. +type Factory struct { + ctn di.Container + cfg *viper.Viper + logger *zap.Logger + validate *validator.Validate + names []string + + mux sync.Mutex + servers map[string]*echo.Echo +} + +// New is factory constructor. The names are the server names passed to the bundle, may be empty. +func New(ctn di.Container, cfg *viper.Viper, logger *zap.Logger, validate *validator.Validate, names []string) *Factory { + return &Factory{ + ctn: ctn, + cfg: cfg, + logger: logger, + validate: validate, + names: names, + servers: make(map[string]*echo.Echo, len(names)), + } +} + +// Names returns the server names passed to the bundle or, if none were passed, the keys of the echo config section. +func (f *Factory) Names() []string { + if len(f.names) > 0 { + return slices.Clone(f.names) + } + + return slices.Sorted(maps.Keys(f.cfg.GetStringMap(configKey))) +} + +// Echo returns the server echo instance. The instance is built on the first call. +func (f *Factory) Echo(name string) (_ *echo.Echo, err error) { + f.mux.Lock() + defer f.mux.Unlock() + + if e, ok := f.servers[name]; ok { + return e, nil + } + + if !f.cfg.IsSet(configKey + "." + name) { + return nil, fmt.Errorf("configuration %s.%s is not found", configKey, name) + } + + var configurators []configurator.Configurator + if configurators, err = f.getConfigurators(name); err != nil { + return nil, fmt.Errorf("unable to resolve echo %s configurators : %w", name, err) + } + + var e *echo.Echo + if e, err = internalEcho.New(configurators); err != nil { + return nil, err + } + + f.servers[name] = e + + return e, nil +} + +func (f *Factory) getConfigurators(name string) (_ []configurator.Configurator, err error) { + var configurators []configurator.Configurator + err = f.ctn.Call( + func(controllers []configurator.Controller, mws []echo.MiddlewareFunc, custom []configurator.Configurator) { + configurators = append([]configurator.Configurator{ + configurator.NewController(controllers), + configurator.NewMiddleware(mws), + configurator.NewEcho(name, f.cfg), + configurator.NewErrHandler(f.logger), + configurator.NewValidator(f.validate), + }, custom...) + }, + di.Constraint(0, di.Optional(true), di.WithTags(tag.Controller+"."+name)), + di.Constraint(1, di.Optional(true), di.WithTags(tag.Middleware+"."+name), sortByPriority()), + di.Constraint(2, di.Optional(true), di.WithTags(tag.Configurator+"."+name)), + ) + + return configurators, err +} + +func sortByPriority() di.Modifier { + return di.Sort(func(x, y di.Definition) bool { + var xp int64 + for _, t := range x.Tags() { + for _, arg := range t.Args { + if arg.Key == tag.ArgMiddlewarePriority { + xp, _ = strconv.ParseInt(arg.Value, 10, 64) + } + } + } + + var yp int64 + for _, t := range y.Tags() { + for _, arg := range t.Args { + if arg.Key == tag.ArgMiddlewarePriority { + yp, _ = strconv.ParseInt(arg.Value, 10, 64) + } + } + } + + return xp > yp + }) +} diff --git a/internal/filter/filter.go b/internal/filter/filter.go new file mode 100644 index 0000000..7a7b56e --- /dev/null +++ b/internal/filter/filter.go @@ -0,0 +1,70 @@ +// Copyright 2018 Sergey Novichkov. All rights reserved. +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +package filter + +import ( + "strings" + + "github.com/gobwas/glob" +) + +// Names returns the names matching the glob patterns, with negative patterns support. +// Without positive patterns all names match. +func Names(names, patterns []string) (_ []string, err error) { + var ( + exclude = make([]glob.Glob, 0, len(patterns)) + include = make([]glob.Glob, 0, len(patterns)) + ) + + // compile patterns, with negative support + for _, pattern := range patterns { + var index = 0 + if strings.HasPrefix(pattern, "!") { + index = 1 + } + + var g glob.Glob + if g, err = glob.Compile(pattern[index:]); err != nil { + return nil, err + } + + if index == 0 { + include = append(include, g) + continue + } + + exclude = append(exclude, g) + } + + // all included by default + if len(include) == 0 { + include = append(include, glob.MustCompile("*")) + } + + var filtered = make([]string, 0, len(names)) + for _, name := range names { + if match(name, include, exclude) { + filtered = append(filtered, name) + } + } + + return filtered, nil +} + +func match(name string, include, exclude []glob.Glob) bool { + for _, pattern := range exclude { + if pattern.Match(name) { + return false + } + } + + for _, pattern := range include { + if pattern.Match(name) { + return true + } + } + + return false +} diff --git a/internal/modifier/modifier.go b/internal/modifier/modifier.go deleted file mode 100644 index 807bda5..0000000 --- a/internal/modifier/modifier.go +++ /dev/null @@ -1,102 +0,0 @@ -package modifier - -import ( - "strings" - - "github.com/gobwas/glob" - "github.com/gozix/di" -) - -// Modifier is custom di Modifier -type Modifier struct { - exclude []glob.Glob - include []glob.Glob - names []string - tag string -} - -const ArgNameKey = "name" - -func NewModifier(tag string, patterns []string) (_ *Modifier, err error) { - var m = &Modifier{ - exclude: make([]glob.Glob, 0, len(patterns)), - include: make([]glob.Glob, 0, len(patterns)), - names: make([]string, 0, 8), - tag: tag, - } - - // compile patterns, with negative support - for _, pattern := range patterns { - var index = 0 - if strings.HasPrefix(pattern, "!") { - index = 1 - } - - var g glob.Glob - if g, err = glob.Compile(pattern[index:]); err != nil { - return nil, err - } - - if index == 0 { - m.include = append(m.include, g) - continue - } - - m.exclude = append(m.exclude, g) - } - - // all included by default - if len(m.include) == 0 { - m.include = append(m.include, glob.MustCompile("*")) - } - - return m, nil -} - -func (m *Modifier) Modifier() di.Modifier { - return di.Filter(func(def di.Definition) bool { - // extract name - var name string - for _, tag := range def.Tags() { - if tag.Name == m.tag { - for _, arg := range tag.Args { - if arg.Key == ArgNameKey { - name = arg.Value - } - - break - } - - break - } - } - - if len(name) == 0 { - return false - } - - // filter - for _, pattern := range m.exclude { - if pattern.Match(name) { - return false - } - } - - for _, pattern := range m.include { - if pattern.Match(name) { - m.names = append(m.names, name) - return true - } - } - - return false - }) -} - -func (m *Modifier) Name(index int) string { - if 0 > index || index >= len(m.names) { - return "unknown" - } - - return m.names[index] -} diff --git a/internal/tag/tag.go b/internal/tag/tag.go new file mode 100644 index 0000000..f6bf15c --- /dev/null +++ b/internal/tag/tag.go @@ -0,0 +1,25 @@ +// Copyright 2018 Sergey Novichkov. All rights reserved. +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +package tag + +const ( + // ServerName is tag to mark a server name value. + ServerName = "echo.server_name" + + // Echo is tag to mark a echo instance. + Echo = "echo.echo" + + // Configurator is tag to mark configurators. + Configurator = "echo.configurator" + + // Controller is tag to mark controllers. + Controller = "echo.controller" + + // Middleware is tag to mark middlewares. + Middleware = "echo.middleware" + + // ArgMiddlewarePriority is name of priority argument. + ArgMiddlewarePriority = "priority" +)