diff --git a/CLAUDE.md b/CLAUDE.md index 9899998d..f6a58e95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,11 @@ factory por componente sob `factory/contrib/`. - API pública alterada sem teste e sem nota de compat - `git push` sem quality-gate verde (suspenso por enquanto, ver `docs/development/quality-gate.md`) - Issue ou PR ausentes (ver `docs/development/gitflow.md`) +- Mudança sem README/site atualizados ou sem issue no `xgodev/claude-plugin` - Tag, GitHub Release, PR `develop` → `main` ou bump de versão do site/README sem o mantenedor mandar lançar. Issue para no merge em `develop`; release - só por `release/vX.Y.Z` (ver `docs/development/gitflow.md`, seção Release) + só por `release/X.Y.Z` (minor/major) ou `bugfix/X.Y.Z` / `hotfix/X.Y.Z` + (patch), sem `v` no nome da branch (ver `docs/development/gitflow.md`, seção Versão) ## Regras gerais de código @@ -35,8 +37,16 @@ factory por componente sob `factory/contrib/`. - **Single source of truth** — config keys e defaults declarados uma vez no `config.go` do componente. - **Separação de concerns** — factory constrói, wrapper abstrai, app consome. -- Documentação é parte da tarefa: mudou componente/config key/comportamento → - atualizar o README do pacote no mesmo commit. +- **Premissa: toda mudança leva a doc junto.** Coisa nova, alterada ou + removida (componente, config key, anotação, comportamento) → no mesmo PR, + o README do pacote, o `README.md` da raiz e o site quando eles mostram + aquilo; e uma issue no `xgodev/claude-plugin` pedindo a atualização da + skill do boost (ver [Doc do plugin](#doc-do-plugin-fora-deste-repo)). + Sem as três, a issue não está pronta. +- **`examples/stock` é o app de exemplo completo.** Recurso novo no boost + (factory, plugin, adapter, anotação do builder) entra nele no mesmo PR, + com teste em `cmd//main_test.go` e linha no `examples/stock/README.md` + (chamada com a resposta real, chave de config, arquivo do recurso). - **`README.md` da raiz espelha o site** (`docs/site/index.html`): mesmas seções, texto, números, exemplo e versão. Mudou um, muda o outro no mesmo PR. - Teste valida comportamento, não só cobre linha. Bug fix → teste vermelho @@ -46,9 +56,11 @@ factory por componente sob `factory/contrib/`. A doc de consumo do boost e o Quality Gate são distribuídos pelo plugin **`claude-plugin@xgodev`** (repo `xgodev/claude-plugin`). Este repo (`boost`) -**não é marketplace, não contém skills do plugin e não sincroniza doc de -plugin** — isso é responsabilidade do `xgodev/claude-plugin`, fora do escopo -daqui. +**não é marketplace, não contém skills do plugin e não edita o plugin.** +Toda mudança no boost abre uma issue no `xgodev/claude-plugin` (label +`documentation`) dizendo o que mudou, com link para a issue/PR do boost e +para o README que tem os detalhes; quem atualiza a skill é o +`xgodev/claude-plugin`. Instalação do plugin: diff --git a/README.md b/README.md index e891a7fb..d84af827 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ github.com/acme/shop ├── doc.go go:generate └── internal/ ├── app/api/rest/ - │ ├── handler/stock/reserver/handler.go @REST - │ └── plugins.go @Provide + │ └── handler/stock/reserver/handler.go @REST └── pkg/ + ├── server/rest/doc.go @Server ├── domain/ │ ├── service/stock/reserver/ │ │ ├── local/service.go @Middleware @@ -56,7 +56,7 @@ github.com/acme/shop ├── memory/datastore.go @Default └── mongodb/ ├── config.go @Config - └── datastore.go @Provide + └── datastore.go @Inject ``` `internal/app/api/rest/handler/stock/reserver/handler.go` @@ -94,23 +94,14 @@ func (h *Handler) Handle(c echo.Context) error { ```
-internal/app/api/rest/plugins.go +internal/pkg/server/rest/doc.go ```go // Package rest holds what the rest server of app api needs besides its -// handlers. +// handlers: errorHandler turns boost errors into HTTP statuses, so a +// Conflict answers 409. +// @Server(type=rest, plugins=[recover, errorHandler]) package rest - -import ( - boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" - "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" -) - -// Plugins turns boost errors into HTTP statuses: a Conflict answers 409. -// @Provide(index=0) -func Plugins() []boostecho.Plugin { - return []boostecho.Plugin{error_handler.Register} -} ```
@@ -258,19 +249,17 @@ type datastore struct { coll *mongo.Collection } -// New connects through boost's mongo factory (boost.factory.mongo.*). +// New keeps the stock on the conn inject builds from boost.factory.mongo.*, +// which the app closes when it stops. // @Provide(index=0) -func New(ctx context.Context, o *Options) (stock.Repository, error) { - conn, err := mongofact.NewConn(ctx) - if err != nil { - return nil, err - } - return &datastore{coll: conn.Database.Collection(o.Collection)}, nil +// @Inject(index=0, plugins=[health, prometheus]) +func New(conn *mongofact.Conn, o *Options) stock.Repository { + return &datastore{coll: conn.Database.Collection(o.Collection)} } func (d *datastore) Take(ctx context.Context, sku string) error { res, err := d.coll.UpdateOne(ctx, - bson.M{"_id": sku, "units": bson.M{"$gt": 0}}, + bson.M{"_id": bson.M{"$eq": sku}, "units": bson.M{"$gt": 0}}, bson.M{"$inc": bson.M{"units": -1}}) if err != nil { return err @@ -307,12 +296,13 @@ gen/ │ │ ├── modules.go app api │ │ └── rest_module.go @REST │ └── middleware/…/local/new_middleware.go @Middleware +├── client/…/mongo-driver/v2/ +│ └── default_conn.go @Inject ├── implementation/…/stock/ │ └── repository_implementation.go @Default ├── internal/ │ ├── app/api/rest/ -│ │ ├── handler/stock/reserver/new_module.go -│ │ └── plugins_module.go +│ │ └── handler/stock/reserver/new_module.go │ └── pkg/ │ ├── domain/service/…/local/new_module.go │ └── datastore/stock/ @@ -387,10 +377,11 @@ import ( "go.uber.org/fx" - gen_rest_54b9e997 "github.com/acme/shop/gen/internal/app/api/rest" gen_reserver_5a8b66f7 "github.com/acme/shop/gen/internal/app/api/rest/handler/stock/reserver" src_reserver_5a8b66f7 "github.com/acme/shop/internal/app/api/rest/handler/stock/reserver" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -404,8 +395,13 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echoerrorhandler.Register, + } + }), gen_reserver_5a8b66f7.NewModule(), - gen_rest_54b9e997.PluginsModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_reserver_5a8b66f7.Handler) { srv.Add("POST", "/stock/:sku/reserve", h0.Handle) }), @@ -485,6 +481,75 @@ func (d *newDecorator) Reserve(ctx context.Context, a1 string) error { +
+gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go + +```go +// Code generated by inject; DO NOT EDIT. + +package mongo + +import ( + "context" + "sync" + + "go.uber.org/fx" + + factory "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + prometheusplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/contrib/prometheus/client_golang/v1" + healthplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/local/health" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultConnRoot is the config root of the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn); +// env BOOST_FACTORY_MONGO_*. +const defaultConnRoot = "boost.factory.mongo" + +func init() { + factory.ConfigAdd(defaultConnRoot) + healthplugin.ConfigAdd(defaultConnRoot + ".plugins.health") + prometheusplugin.ConfigAdd(defaultConnRoot + ".plugins.prometheus") +} + +var defaultConnOnce sync.Once + +// DefaultConnModule provides the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn), built from the keys under +// boost.factory.mongo with the plugins health, prometheus, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultConnModule() fx.Option { + options := fx.Options() + + defaultConnOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.DefaultConn", + contextfx.Module(), + fx.Provide(newDefaultConn), + ) + }) + + return options +} + +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*factory.Conn, error) { + health, err := healthplugin.NewHealthWithConfigPath(defaultConnRoot + ".plugins.health") + if err != nil { + return nil, err + } + prometheus, err := prometheusplugin.NewPrometheusWithConfigPath(defaultConnRoot + ".plugins.prometheus") + if err != nil { + return nil, err + } + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot, health.Register, prometheus.Register) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} +``` + +
+
gen/implementation/internal_/pkg/domain/repository/stock/repository_implementation.go @@ -600,7 +665,7 @@ The other generated files are in ```console $ go generate ./... 5:35PM INF scanning ~/shop (module github.com/acme/shop) -5:35PM INF generated 6 inject modules and 1 apps in ~/shop/gen, mains in ~/shop/cmd +5:35PM INF generated 5 inject modules and 1 apps in ~/shop/gen, mains in ~/shop/cmd $ go run ./cmd/api & 5:36PM INF starting Echo Server. https://echo.labstack.com/ # memory is the @Default datastore: 3 t-shirts in stock @@ -760,8 +825,10 @@ the app modules and main. | `@GRPC` | gRPC service | | `@Function` | azure functions · cloudevents · kafka · kafka-go · lambda · nats · pubsub | | `@MCP` | MCP tool | +| `@Job` | runs once and exits · CronJob | | `@Middleware` | generated decorator | | `@Default` | implementation picked by config | +| `@Server` | plugins of a server | ### Functions @@ -804,7 +871,7 @@ services is generated, checked and rewritten on every run. 1. **Annotate.** Mark constructors with `@Provide`, handlers with a door, services with `@Middleware`, the default implementation with `@Default`, - options with `@Config`. + options with `@Config`, the plugins of a server with `@Server`. 2. **Generate.** Inject and builder run in one pass. A broken graph stops here and leaves your output untouched. @@ -869,7 +936,7 @@ same or get better, never worse. require ( - github.com/xgodev/boost v1.1.1 -+ github.com/xgodev/boost v1.6.2 ++ github.com/xgodev/boost v1.7.0 ) ``` @@ -880,7 +947,7 @@ ok github.com/acme/shop/... 1 line changed in go.mod, **0 lines changed in your code**. -Releases: 2024-07 → `v1.1.0` → `v1.1.1` → **`v1.6.2`** +Releases: 2024-07 → `v1.1.0` → `v1.1.1` → **`v1.7.0`** ([all releases](https://github.com/xgodev/boost/releases)). ## There's a factory for that. diff --git a/annotation/README.md b/annotation/README.md index da1dbac7..d9bf6350 100644 --- a/annotation/README.md +++ b/annotation/README.md @@ -60,13 +60,14 @@ for _, entry := range collector.EntriesWith("RestRouter") { |---|---| | `WithPath(dir)` | Scans the packages under `dir` as `go list ./...` sees them from there: hidden (`.`), `_`, `testdata` and `vendor` directories are skipped. The packages they import are scanned too. | | `WithFilters(names...)` | Keeps only annotations whose name starts with one of the filters: `Rest` keeps `@Rest`, `@RestRouter` and `@RestResponse`. Without it, every annotation is kept. | +| `WithPackageDocs()` | Also collects the annotations of package doc comments: one entry per file whose package doc has one. Without it, only declarations are collected. | | `WithPackages(paths...)` | Scans only these import paths and the packages below them, both under the path and among their imports. The imports of a package that is not scanned are not followed. Without it, every non-standard-library package reached is scanned. | Standard library packages are never scanned. ### Entries -Each `Entry` is an annotated struct type, method or function: +Each `Entry` is an annotated struct type, method, function or package doc: - `IsStruct()`: a struct type. Interfaces and other non-struct types are not reported. `Fields` lists its fields in order, one per name (`A, B string` is two), with @@ -75,6 +76,9 @@ Each `Entry` is an annotated struct type, method or function: - `IsMethod()`: a method, with `Struct` set to its receiver type (`T`, `*T` or a generic `T[K]`), wherever in the package the type is declared. - `IsFunc()`: a package-level function. +- `IsPackage()`: the package doc comment of a file (`// Package x ...` above + `package x`), only with `WithPackageDocs()`. `Struct` and `Func` are empty and + `Header.Title` drops the leading `Package x`. `Path` is the import path, `Package` the package name, `Module` the module path and `File` the base name of the file that declares it. `Header.Title` is the first diff --git a/annotation/collect.go b/annotation/collect.go index 89990a93..57bb7fc8 100644 --- a/annotation/collect.go +++ b/annotation/collect.go @@ -123,6 +123,16 @@ func WithPackages(pkgs ...string) Option { } } +// WithPackageDocs also collects the annotations of package doc comments, +// each file's as an entry whose IsPackage is true. Without it, only +// declarations are collected. +func WithPackageDocs() Option { + return func(c *Collector) error { + c.packageDocs = true + return nil + } +} + // WithPath sets the directory to scan. It must be inside a Go module. func WithPath(path string) Option { return func(c *Collector) error { diff --git a/annotation/collect_test.go b/annotation/collect_test.go index aa87ec35..94ae05ff 100644 --- a/annotation/collect_test.go +++ b/annotation/collect_test.go @@ -68,6 +68,11 @@ import ( // @Rest(method=PUT) func (s *Service) Put() { _ = strings.ToUpper(d2.Hello()); Sprint() } +`, + "app/doc.go": `// Package app serves the fixture. +// @Boost(kind=pkg) +// @Other +package app `, "app/options.go": `package app @@ -217,6 +222,23 @@ func TestCollect_StructFields(t *testing.T) { assert.Empty(t, find(entries, "Service", "")[0].Fields, "an empty struct has no fields") } +func TestCollect_PackageDocsOnlyWhenAsked(t *testing.T) { + root := fixtureModule(t) + + assert.Empty(t, find(collectEntries(t, WithPath(root)), "", ""), "package docs are left out by default") + + pkg := find(collectEntries(t, WithPath(root), WithPackageDocs()), "", "") + require.Len(t, pkg, 1) + assert.True(t, pkg[0].IsPackage()) + assert.False(t, pkg[0].IsFunc() || pkg[0].IsStruct() || pkg[0].IsMethod()) + assert.Equal(t, "example.com/fixture/app", pkg[0].Path) + assert.Equal(t, "app", pkg[0].Package) + assert.Equal(t, "doc.go", pkg[0].File) + assert.Equal(t, "serves the fixture.", pkg[0].Header.Title, "the title drops the leading \"Package app\"") + require.Len(t, pkg[0].Annotations, 1, "the filters apply to package docs too") + assert.Equal(t, "pkg", pkg[0].Annotations[0].Map["kind"]) +} + func TestCollect_FiltersMatchTheAnnotationName(t *testing.T) { entries := collectEntries(t, WithPath(fixtureModule(t))) diff --git a/annotation/collector.go b/annotation/collector.go index 84757899..e261e568 100644 --- a/annotation/collector.go +++ b/annotation/collector.go @@ -23,7 +23,9 @@ type Collector struct { filters []string pkgs []string basePath string - entries []Entry + // packageDocs also collects the annotations of package doc comments. + packageDocs bool + entries []Entry // names maps the import path of every package seen while walking the // import graph to its package name. names map[string]string @@ -231,7 +233,12 @@ func (c *Collector) filterFiles(p *packages.Package) (entries []Entry) { fileName := filepath.Base(p.Fset.File(file.Pos()).Name()) imports := c.fileImports(file) - for _, ens := range declarations(file) { + decls := declarations(file) + if c.packageDocs && file.Doc != nil { + // the package doc goes first: it is above every declaration + decls = append([]Entry{{Comments: getComments(file.Doc)}}, decls...) + } + for _, ens := range decls { ans, ok := c.getAnnotations(ens.Comments) if !ok { continue @@ -242,6 +249,9 @@ func (c *Collector) filterFiles(p *packages.Package) (entries []Entry) { } ens.File = fileName ens.Header = parseHeader(name, ens.Comments) + if ens.IsPackage() { + ens.Header = parseHeader(p.Name, packageDocLines(ens.Comments)) + } ens.Path = p.PkgPath ens.Module = modName ens.Package = p.Name @@ -254,6 +264,22 @@ func (c *Collector) filterFiles(p *packages.Package) (entries []Entry) { return entries } +// packageDocLines drops the leading "Package" of a package doc, so its +// title loses the package name like the doc of a declaration loses its name. +func packageDocLines(cmts []string) []string { + lines := append([]string(nil), cmts...) + for i, cmt := range lines { + if strings.HasPrefix(cmt, annotationPrefix) { + continue + } + if rest, ok := strings.CutPrefix(strings.TrimSpace(strings.TrimPrefix(cmt, "//")), "Package "); ok { + lines[i] = "// " + rest + } + break + } + return lines +} + // fileImports maps the name each import has in the file to its path. func (c *Collector) fileImports(file *ast.File) map[string]string { imports := map[string]string{} diff --git a/annotation/entry.go b/annotation/entry.go index 7e5474bb..cd26043e 100644 --- a/annotation/entry.go +++ b/annotation/entry.go @@ -55,3 +55,9 @@ func (b *Entry) IsFunc() bool { func (b *Entry) IsMethod() bool { return b.Struct != "" && b.Func.Name != "" } + +// IsPackage reports whether the entry is the package doc comment of a file, +// collected only with WithPackageDocs. +func (b *Entry) IsPackage() bool { + return b.Struct == "" && b.Func.Name == "" +} diff --git a/bootstrap/README.md b/bootstrap/README.md index 61c56bee..d95f0637 100644 --- a/bootstrap/README.md +++ b/bootstrap/README.md @@ -20,6 +20,14 @@ The `function` subpackage provides structures and utilities for working with fun The cloudevents nats adapter has no responder: the event a function returns goes to its middlewares, like `publisher`, and never back on the subject. +### Job + +The `job` subpackage is a function an app runs once and then exits, the shape +of a Kubernetes CronJob: `job.Handler`, `func(context.Context) error`. +[`fx/modules/bootstrap/job`](../fx/modules/bootstrap/job) runs it and exits 0 +on nil, 1 on an error; SIGTERM cancels its context. builder generates the app +from `@Job(app=)`. + ### Examples `function/examples` holds one builder app per adapter and middleware diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/config.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/config.go index aeac06f7..2bfc0b90 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/config.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/config.go @@ -13,8 +13,8 @@ const ( ) func init() { - config.Add(port, 8080, "sets cloudvents port") - config.Add(path, "/", "sets cloudvents path") + config.Add(port, 8080, "sets cloudevents port") + config.Add(path, "/", "sets cloudevents path") } func Port() int { diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0/otel.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0/otel.go index 9793a872..5a0f34d2 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0/otel.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0/otel.go @@ -11,11 +11,13 @@ import ( "github.com/xgodev/boost/wrapper/log" ) -// Register registers a new opentelemetry plugin for http cloudevents. +// Register registers a new opentelemetry plugin for http cloudevents. It +// returns opts as they are when its options cannot be read. func Register(ctx context.Context, opts []cehttp.Option) []cehttp.Option { o, err := NewOptions() if err != nil { - return nil + log.FromContext(ctx).Errorf("opentelemetry plugin of http cloudevents not enabled: %s", err.Error()) + return opts } h := NewOtelWithOptions(o) return h.Register(ctx, opts) @@ -50,10 +52,12 @@ func NewOtel() *Otel { return NewOtelWithOptions(o) } -// Register registers this opentelemetry plugin for http cloudevents. +// Register registers this opentelemetry plugin for http cloudevents. When +// disabled it returns opts as they are, the port and path of the adapter +// included. func (i *Otel) Register(ctx context.Context, opts []cehttp.Option) []cehttp.Option { if !i.options.Enabled { - return nil + return opts } logger := log.FromContext(ctx) diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0/otel_test.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0/otel_test.go new file mode 100644 index 00000000..e376a84d --- /dev/null +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0/otel_test.go @@ -0,0 +1,26 @@ +package contrib + +import ( + "context" + "testing" + + ce "github.com/cloudevents/sdk-go/v2" + cehttp "github.com/cloudevents/sdk-go/v2/protocol/http" + "github.com/stretchr/testify/assert" +) + +func TestRegister_DisabledKeepsTheOptions(t *testing.T) { + opts := []cehttp.Option{ce.WithPort(8081), ce.WithPath("/events")} + + got := NewOtelWithOptions(&Options{Enabled: false}).Register(context.Background(), opts) + + assert.Len(t, got, 2, "a disabled plugin leaves the port and path of the adapter as they are") +} + +func TestRegister_EnabledAddsTheMiddleware(t *testing.T) { + opts := []cehttp.Option{ce.WithPort(8081), ce.WithPath("/events")} + + got := NewOtelWithOptions(&Options{Enabled: true}).Register(context.Background(), opts) + + assert.Len(t, got, 4, "the round tripper and the middleware after the adapter's options") +} diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go index 7c0c381d..a8975760 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/run.go @@ -40,7 +40,7 @@ func Run[T any](fn function.Handler[T], opts []client.Option, plugins ...Plugin) return err } - logger.Infof("listening on :%d%s\n", 8080, "/") + logger.Infof("listening on :%d%s", Port(), Path()) return c.StartReceiver(ctx, Wrapper(fn)) } diff --git a/bootstrap/function/examples/cloudevents/nats/gen/builder/worker/function_module.go b/bootstrap/function/examples/cloudevents/nats/gen/builder/worker/function_module.go index 9314cb05..96a7aa52 100644 --- a/bootstrap/function/examples/cloudevents/nats/gen/builder/worker/function_module.go +++ b/bootstrap/function/examples/cloudevents/nats/gen/builder/worker/function_module.go @@ -9,6 +9,7 @@ import ( src_cloudevents_c136a3fd "github.com/cloudevents/sdk-go/v2" bsfunction "github.com/xgodev/boost/bootstrap/function" + client_nats_49bf4525 "github.com/xgodev/boost/bootstrap/function/examples/cloudevents/nats/gen/client/github.com/nats-io/nats.go" gen_nats_51f399c9 "github.com/xgodev/boost/bootstrap/function/examples/cloudevents/nats/gen/internal/app/worker/nats" gen_replier_b724af88 "github.com/xgodev/boost/bootstrap/function/examples/cloudevents/nats/gen/internal/app/worker/nats/handler/message/replier" src_replier_b724af88 "github.com/xgodev/boost/bootstrap/function/examples/cloudevents/nats/internal/app/worker/nats/handler/message/replier" @@ -53,7 +54,8 @@ func FunctionModule() fx.Option { fx.ResultTags(`name:"bootstrap.function.middlewares"`), )), functionfx.Module[*src_cloudevents_c136a3fd.Event](nil), - cenatsfx.Module[*src_cloudevents_c136a3fd.Event](), + cenatsfx.ModuleWithoutClient[*src_cloudevents_c136a3fd.Event](), + client_nats_49bf4525.DefaultConnModule(), ) }) diff --git a/bootstrap/function/examples/cloudevents/nats/gen/client/github.com/nats-io/nats.go/default_conn.go b/bootstrap/function/examples/cloudevents/nats/gen/client/github.com/nats-io/nats.go/default_conn.go new file mode 100644 index 00000000..17bd8c80 --- /dev/null +++ b/bootstrap/function/examples/cloudevents/nats/gen/client/github.com/nats-io/nats.go/default_conn.go @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package nats + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "github.com/nats-io/nats.go" + factory "github.com/xgodev/boost/factory/contrib/nats-io/nats.go/v1" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultConnRoot is the config root of the default client (*github.com/nats-io/nats.go.Conn); +// env BOOST_FACTORY_NATS_*. +const defaultConnRoot = "boost.factory.nats" + +func init() { + factory.ConfigAdd(defaultConnRoot) +} + +var defaultConnOnce sync.Once + +// DefaultConnModule provides the default client (*github.com/nats-io/nats.go.Conn), built from the keys under +// boost.factory.nats without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultConnModule() fx.Option { + options := fx.Options() + + defaultConnOnce.Do(func() { + options = fx.Module("github.com/nats-io/nats.go.DefaultConn", + contextfx.Module(), + fx.Provide(newDefaultConn), + ) + }) + + return options +} + +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*client.Conn, error) { + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/bootstrap/function/examples/nats/gen/builder/worker/function_module.go b/bootstrap/function/examples/nats/gen/builder/worker/function_module.go index 113e00e5..a4a26a77 100644 --- a/bootstrap/function/examples/nats/gen/builder/worker/function_module.go +++ b/bootstrap/function/examples/nats/gen/builder/worker/function_module.go @@ -9,6 +9,7 @@ import ( src_cloudevents_c136a3fd "github.com/cloudevents/sdk-go/v2" bsfunction "github.com/xgodev/boost/bootstrap/function" + client_nats_c5703ab3 "github.com/xgodev/boost/bootstrap/function/examples/nats/gen/client/github.com/nats-io/nats.go" gen_nats_9025ab7a "github.com/xgodev/boost/bootstrap/function/examples/nats/gen/internal/app/worker/nats" gen_replier_23b9128f "github.com/xgodev/boost/bootstrap/function/examples/nats/gen/internal/app/worker/nats/handler/message/replier" src_replier_23b9128f "github.com/xgodev/boost/bootstrap/function/examples/nats/internal/app/worker/nats/handler/message/replier" @@ -53,7 +54,8 @@ func FunctionModule() fx.Option { fx.ResultTags(`name:"bootstrap.function.middlewares"`), )), functionfx.Module[*src_cloudevents_c136a3fd.Event](nil), - natsfx.Module[*src_cloudevents_c136a3fd.Event](), + natsfx.ModuleWithoutClient[*src_cloudevents_c136a3fd.Event](), + client_nats_c5703ab3.DefaultConnModule(), ) }) diff --git a/bootstrap/function/examples/nats/gen/client/github.com/nats-io/nats.go/default_conn.go b/bootstrap/function/examples/nats/gen/client/github.com/nats-io/nats.go/default_conn.go new file mode 100644 index 00000000..17bd8c80 --- /dev/null +++ b/bootstrap/function/examples/nats/gen/client/github.com/nats-io/nats.go/default_conn.go @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package nats + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "github.com/nats-io/nats.go" + factory "github.com/xgodev/boost/factory/contrib/nats-io/nats.go/v1" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultConnRoot is the config root of the default client (*github.com/nats-io/nats.go.Conn); +// env BOOST_FACTORY_NATS_*. +const defaultConnRoot = "boost.factory.nats" + +func init() { + factory.ConfigAdd(defaultConnRoot) +} + +var defaultConnOnce sync.Once + +// DefaultConnModule provides the default client (*github.com/nats-io/nats.go.Conn), built from the keys under +// boost.factory.nats without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultConnModule() fx.Option { + options := fx.Options() + + defaultConnOnce.Do(func() { + options = fx.Module("github.com/nats-io/nats.go.DefaultConn", + contextfx.Module(), + fx.Provide(newDefaultConn), + ) + }) + + return options +} + +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*client.Conn, error) { + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/bootstrap/function/examples/pubsub/gen/builder/worker/function_module.go b/bootstrap/function/examples/pubsub/gen/builder/worker/function_module.go index 271bba4d..e780a38a 100644 --- a/bootstrap/function/examples/pubsub/gen/builder/worker/function_module.go +++ b/bootstrap/function/examples/pubsub/gen/builder/worker/function_module.go @@ -9,6 +9,7 @@ import ( src_cloudevents_c136a3fd "github.com/cloudevents/sdk-go/v2" bsfunction "github.com/xgodev/boost/bootstrap/function" + client_pubsub_9386bbe4 "github.com/xgodev/boost/bootstrap/function/examples/pubsub/gen/client/cloud.google.com/go/pubsub/v2" gen_pubsub_d0698dae "github.com/xgodev/boost/bootstrap/function/examples/pubsub/gen/internal/app/worker/pubsub" gen_replier_3c662f05 "github.com/xgodev/boost/bootstrap/function/examples/pubsub/gen/internal/app/worker/pubsub/handler/message/replier" src_replier_3c662f05 "github.com/xgodev/boost/bootstrap/function/examples/pubsub/internal/app/worker/pubsub/handler/message/replier" @@ -55,7 +56,8 @@ func FunctionModule() fx.Option { )), functionfx.Module[*src_cloudevents_c136a3fd.Event](nil), pubsubv1fx.Module[*src_cloudevents_c136a3fd.Event](), - pubsubv2fx.Module[*src_cloudevents_c136a3fd.Event](), + pubsubv2fx.ModuleWithoutClient[*src_cloudevents_c136a3fd.Event](), + client_pubsub_9386bbe4.DefaultClientModule(), ) }) diff --git a/bootstrap/function/examples/pubsub/gen/client/cloud.google.com/go/pubsub/v2/default_client.go b/bootstrap/function/examples/pubsub/gen/client/cloud.google.com/go/pubsub/v2/default_client.go new file mode 100644 index 00000000..08d40476 --- /dev/null +++ b/bootstrap/function/examples/pubsub/gen/client/cloud.google.com/go/pubsub/v2/default_client.go @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package pubsub + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "cloud.google.com/go/pubsub/v2" + factory "github.com/xgodev/boost/factory/contrib/cloud.google.com/pubsub/v2" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultClientRoot is the config root of the default client (*cloud.google.com/go/pubsub/v2.Client); +// env BOOST_FACTORY_GCP_PUBSUB_*. +const defaultClientRoot = "boost.factory.gcp.pubsub" + +func init() { + factory.ConfigAdd(defaultClientRoot) +} + +var defaultClientOnce sync.Once + +// DefaultClientModule provides the default client (*cloud.google.com/go/pubsub/v2.Client), built from the keys under +// boost.factory.gcp.pubsub without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultClientModule() fx.Option { + options := fx.Options() + + defaultClientOnce.Do(func() { + options = fx.Module("cloud.google.com/go/pubsub/v2.DefaultClient", + contextfx.Module(), + fx.Provide(newDefaultClient), + ) + }) + + return options +} + +func newDefaultClient(ctx context.Context, lc fx.Lifecycle) (*client.Client, error) { + c, err := factory.NewClientWithConfigPath(ctx, defaultClientRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/bootstrap/job/README.md b/bootstrap/job/README.md new file mode 100644 index 00000000..62ebd314 --- /dev/null +++ b/bootstrap/job/README.md @@ -0,0 +1,21 @@ +# bootstrap/job + +A job is a function an app runs once and then exits, the shape of a +Kubernetes CronJob: + +```go +// Handler is the work of a job. +type Handler func(ctx context.Context) error +``` + +It returns when the work is done, with the error that makes the app exit +non-zero, or when `ctx` is cancelled because the app is stopping (SIGTERM). +[`fx/modules/bootstrap/job`](../../fx/modules/bootstrap/job) runs it; +[builder](../../builder#job-an-app-that-runs-once-and-exits) generates the app +from `@Job`: + +```go +// Run checks the stock of the configured items. +// @Job(app=stock-audit) +func (a *Auditor) Run(ctx context.Context) error { ... } +``` diff --git a/bootstrap/job/job.go b/bootstrap/job/job.go new file mode 100644 index 00000000..99331ee3 --- /dev/null +++ b/bootstrap/job/job.go @@ -0,0 +1,11 @@ +// Package job is a function an app runs once and then exits, the shape of a +// Kubernetes CronJob. fx/modules/bootstrap/job runs it; builder generates +// the app from @Job. +package job + +import "context" + +// Handler is the work of a job. It returns when the work is done, with the +// error that makes the app exit non-zero, or when ctx is cancelled because +// the app is stopping (SIGTERM). +type Handler func(ctx context.Context) error diff --git a/builder/README.md b/builder/README.md index 0f4eef51..c62e52c0 100644 --- a/builder/README.md +++ b/builder/README.md @@ -2,8 +2,8 @@ `builder` generates the doors of an application on top of [`inject`](../inject): a handler declares with an annotation which door serves -it (a REST route on echo, a gRPC service, an MCP tool or the event -function), and the +it (a REST route on echo, a gRPC service, an MCP tool, the event +function or a job that runs once), and the generator writes one fx module per door, a `Modules()` per app and the `main` of every app. `@Middleware` on a constructor wraps every method of the interface it provides in `extra/middleware` plugins. One command runs inject @@ -146,7 +146,8 @@ func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) (*cloudevent that reads with a client (`nats`, `cenats`, `pubsub`), else `NotValid`. The pubsub v1 adapter keeps its own client. - With a list, `adapter=[pubsub, nats]`, config chooses the adapter: - `.function.adapter` (env `_FUNCTION_ADAPTER`), default the first + `.function.adapter` (env `_FUNCTION_ADAPTER`; see + [App names](#app-names) for a kebab-case app), default the first name. The door includes only the chosen adapter module, so the others and their clients are never built; an unknown name fails the boot with `NotValid` naming the key, the value and the valid names. The adapters' @@ -166,6 +167,36 @@ func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) (*cloudevent `@Provide` constructor that returns it, and generation fails with NotFound when there is none (fx would skip the middleware silently). +### `@Job`: an app that runs once and exits + +```go +package stockaudit + +// Run checks the stock of the configured items. +// @Job(app=stock-audit) +func (a *Auditor) Run(ctx context.Context) error { ... } +``` + +| Key | Required | Meaning | +|---|---|---| +| `app` | yes | application; a job is a binary of its own | + +- The handler is a [`job.Handler`](../bootstrap/job): + `func(context.Context) error`, a function or a method. A method needs a + provided receiver, as in `@REST`; `@Middleware` on the services it + receives applies as in any door. +- The app is the shape of a Kubernetes CronJob: `boost.Start()`, the graph, + the handler once, then the app stops. The door includes + [`jobfx.Module()`](../fx/modules/bootstrap/job), which runs the handler on + start. +- Nil exits 0. An error is logged at ERROR (`job failed: `) and exits 1. +- SIGTERM cancels the handler's context and the app waits for it to return, + up to the fx stop timeout (15s by default). Its error (`ctx.Err()` when it + gave up) fails the stop and the app exits 1; nil exits 0; a handler still + running at the timeout exits 1. +- An app runs one `@Job` and nothing else: a server door or a `@Function` in + the same app fails the generation. + ### `@Middleware`: plugins around every method of a service ```go @@ -208,9 +239,94 @@ func New() Service { return &memory{} } every implementation may carry its own `@Middleware` of the same interface. -App and server names match `^[a-z][a-z0-9]*$` (they become a package, a file, -a function and a config path); an app is not a Go keyword, `main` or -`middleware`, and a server is not `function`. +### `@Server`: the plugins of a server + +```go +// Package rest holds what the REST server of the app is built with. +// @Server(type=rest, plugins=[recover, requestId, logger, errorHandler, health, prometheus]) +package rest +``` + +- It goes in a package doc, above the `package` clause (a `doc.go`): + everything of a server lives in its own package, + `internal/pkg/server//`. On a declaration it fails. +- `type` (required) is the server type, one of `rest`, `grpc`, `mcp` and + `cloudevents` (the http adapter of `@Function`). Each type has its own + plugins; a new server type is a new row of this table, not a new + annotation. +- `name` is the server (default: `rest`, `grpc` or `mcp`, the type's shipped + server; any other is a named server, see [Servers](#servers)) and `app` + its app (default `api`). `cloudevents` takes no `name`: its plugins go to + the `@Function` of `app` when it runs with that adapter. +- `plugins` lists the plugins in order, none twice; `plugins=[]` builds the + server without any. Without `plugins`, and for a server with no `@Server` + at all, it gets the default set (the plugins marked below). +- The door imports each plugin and provides the `[]Plugin` of the server + (the unnamed one of the shipped module, or the one named after the + server), so the app writes no plugin code. Config only turns one off: + `..enabled` (default `true`), as + `boost.factory.echo.plugins.prometheus.enabled`. + +| `rest` | Plugin (`boost.factory.echo.plugins.`) | Default | +|---|---|---| +| `recover` | [`native/recover`](../factory/contrib/labstack/echo/v4/plugins/native/recover) | yes | +| `requestId` | [`native/requestid`](../factory/contrib/labstack/echo/v4/plugins/native/requestid) | yes | +| `logger` | [`local/wrapper/log`](../factory/contrib/labstack/echo/v4/plugins/local/wrapper/log) | yes | +| `errorHandler` | [`extra/error_handler`](../factory/contrib/labstack/echo/v4/plugins/extra/error_handler) | yes | +| `health` | [`local/extra/health`](../factory/contrib/labstack/echo/v4/plugins/local/extra/health) | yes | +| `prometheus` | [`contrib/prometheus/client_golang/v1`](../factory/contrib/labstack/echo/v4/plugins/contrib/prometheus/client_golang/v1) | | +| `otel` | [`contrib/go.opentelemetry.io/contrib/v0`](../factory/contrib/labstack/echo/v4/plugins/contrib/go.opentelemetry.io/contrib/v0) | | +| `datadog` | [`contrib/datadog/dd-trace-go/v1`](../factory/contrib/labstack/echo/v4/plugins/contrib/datadog/dd-trace-go/v1) | | +| `pprof` | [`contrib/hiko1129/echo-pprof/v1`](../factory/contrib/labstack/echo/v4/plugins/contrib/hiko1129/echo-pprof/v1) | | +| `swagger` | [`contrib/swaggo/echo-swagger/v1`](../factory/contrib/labstack/echo/v4/plugins/contrib/swaggo/echo-swagger/v1) | | +| `cors` | [`native/cors`](../factory/contrib/labstack/echo/v4/plugins/native/cors) | | +| `gzip` | [`native/gzip`](../factory/contrib/labstack/echo/v4/plugins/native/gzip) | | +| `bodyLimit` | [`native/bodylimit`](../factory/contrib/labstack/echo/v4/plugins/native/bodylimit) | | +| `bodydump` | [`native/bodydump`](../factory/contrib/labstack/echo/v4/plugins/native/bodydump) | | +| `semaphore` | [`extra/semaphore`](../factory/contrib/labstack/echo/v4/plugins/extra/semaphore) | | +| `status` | [`local/model/restresponse`](../factory/contrib/labstack/echo/v4/plugins/local/model/restresponse) | | +| `multiServer` | [`local/extra/multi-server`](../factory/contrib/labstack/echo/v4/plugins/local/extra/multi-server) | | +| `bytedanceSonic` | [`contrib/bytedance/sonic/v1`](../factory/contrib/labstack/echo/v4/plugins/contrib/bytedance/sonic/v1) | | +| `goccyGoJson` | [`contrib/goccy/go-json/v0`](../factory/contrib/labstack/echo/v4/plugins/contrib/goccy/go-json/v0) | | + +| `grpc` | Plugin (`boost.factory.grpc.server.plugins.`) | Default | +|---|---|---| +| `log` | [`local/wrapper/log`](../factory/contrib/google.golang.org/grpc/v1/server/plugins/local/wrapper/log) | yes | +| `prometheus` | [`contrib/prometheus/client_golang/v1`](../factory/contrib/google.golang.org/grpc/v1/server/plugins/contrib/prometheus/client_golang/v1) | | +| `datadog` | [`contrib/datadog/dd-trace-go/v1`](../factory/contrib/google.golang.org/grpc/v1/server/plugins/contrib/datadog/dd-trace-go/v1) | | +| `compressor` | [`native/compressor`](../factory/contrib/google.golang.org/grpc/v1/server/plugins/native/compressor) | | + +| `mcp` | Plugin (`boost.factory.mcp.server.plugins.`) | Default | +|---|---|---| +| `log` | [`local/wrapper/log`](../factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log) | yes | +| `otel` | [`contrib/go.opentelemetry.io/otel/v1`](../factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/contrib/go.opentelemetry.io/otel/v1) | | + +| `cloudevents` | Plugin (`boost.bootstrap.function.adapter.cloudevents.http.plugins.`) | Default | +|---|---|---| +| `otel` | [`contrib/go.opentelemetry.io/contrib/v0`](../bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0) | | + +- A `@Provide` `[]Plugin` still builds a server (see [Servers](#servers)): + that server gets no default set, and a `@Server` for it too fails. + +### App names + +An app name is kebab case, as deploys are named: it matches +`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`, is not `middleware` and, without its +hyphens, is not a Go keyword or `main`. It maps to: + +| App | `cmd/` and `gen/builder/` directory, binary | Go package | Config path element | Env prefix | +|---|---|---|---|---| +| `api` | `api` | `api` | `api` | `API_` | +| `orders-updater` | `orders-updater` | `ordersupdater` | `ordersUpdater` | `ORDERS__UPDATER_` | + +The config element is camelCase because the env loader reads a `__` as the +hyphen of a camelCase word: `ORDERS__UPDATER_FUNCTION_ADAPTER=nats` sets +`ordersUpdater.function.adapter`, `ORDERS__UPDATER_SERVER_ADMIN_PORT` sets +`ordersUpdater.server.admin.port`. The generated main imports the package +under the alias `app`. + +A server name matches `^[a-z][a-z0-9]*$` (it becomes a file, a function and +a config path element) and is not `function`. ## Generating @@ -233,6 +349,7 @@ gen/ builder//modules.go # func Modules() []fx.Option builder//_module.go # one REST, gRPC or MCP server and its routes/services/tools builder//function_module.go # the @Function handler and its adapter + builder//job_module.go # the @Job handler, run once builder/middleware//_middleware.go # Decorate(): the @Middleware decorator ``` @@ -256,7 +373,8 @@ key per field and provides `*Struct` to the constructors that receive it (see [`inject`](../inject#config)). A door that reaches such a constructor includes the config module, so the package never imports `wrapper/config`. Factory clients too: `@Inject(index=0, name=quote, plugins=[otel])` on a -`*resty.Client` (redis, mongo, NATS, Pub/Sub, gocql, AWS) parameter makes +`*resty.Client` (redis, mongo, NATS, Pub/Sub, gocql, AWS, `*sql.DB` through +godror or pgx) parameter makes inject build that client with those plugins and its own config under `boost.factory.resty.clients.quote` (see [`inject`](../inject#factory-clients)), so the app writes no factory module. @@ -276,7 +394,9 @@ fails the generation instead of being overwritten. Other files under `cmd/` - Any other name is a separate server configured under `.server.` (`api.server.admin.port`): give it its own port. - A server name serves one kind of door: REST, gRPC or MCP. -- Plugins are optional and come from `@Provide`: a `[]boostecho.Plugin` +- Plugins come from the server's [`@Server`](#server-the-plugins-of-a-server) + or, without one, from its default set. A plugin of your own comes from + `@Provide` instead: a `[]boostecho.Plugin` (REST) or `[]server.Plugin` (gRPC, [`grpc/v1/server`](../factory/contrib/google.golang.org/grpc/v1/server); MCP, [`go-sdk/v1/server`](../factory/contrib/modelcontextprotocol/go-sdk/v1/server)) @@ -309,11 +429,18 @@ The command fails, without touching the output directory, when: to an exported function; - an app has two `@Function`, or `adapter` is missing or unknown, or its list is empty or repeats a name; +- `@Job` has no `app`, an app has two `@Job`, or a job app also has a + server door or a `@Function`; - `middlewares` is not written as `[a, b]`, or lists an empty, unknown or repeated name; - a server is used by two kinds of door; - `rest`, `grpc` or `mcp` gets both a `[]Plugin` with `name=` and one without `name`: provide one of them; +- `@Server` is not in a package doc, has no `type`, an unknown one, an + unknown or repeated plugin, `name` on `cloudevents`, names a server of + another type or one no app has (or an app whose `@Function` does not run + `cloudevents`), is repeated for a server, or meets a `@Provide []Plugin` + of the same server; - an app or server name is invalid, or an annotation has an unknown key; - the handler is unexported or in package `main`; - `@Middleware` lists no plugin, an unknown one or one twice, is repeated, @@ -364,7 +491,7 @@ mains, _ := doors.RenderCmd(ctx) // /main.go relative to CmdDir ## Example -[`examples/simple`](./examples/simple) has two apps: +[`examples/simple`](./examples/simple) has three apps: - `api`: REST `POST /orders` and `GET /orders/:id` on `rest`, `GET`/`HEAD /live` on `admin` (built with the `X-Served-By: admin` plugin of @@ -377,27 +504,37 @@ mains, _ := doors.RenderCmd(ctx) // /main.go relative to CmdDir sold-out items from its `@Config Options` (`builder.examples.simple.stock.memory.soldOut`, env `BUILDER_EXAMPLES_SIMPLE_STOCK_MEMORY_SOLD__OUT`, default `sold-out`); -- `worker`: the `ordercreated` function on the cloudevents, the nats or the - pubsub adapter, chosen by `worker.function.adapter` (`WORKER_FUNCTION_ADAPTER`, - default `cloudevents`), behind the `recovery` and `logger` middlewares; its - handler injects the publisher with `drivers=[nats, pubsub]`, so the worker - links no Kafka client. - -`cmd/api/main.go` and `cmd/worker/main.go` are generated. Its tests call the +- `order-worker`: the `ordercreated` function on the cloudevents, the nats or + the pubsub adapter, chosen by `orderWorker.function.adapter` + (`ORDER__WORKER_FUNCTION_ADAPTER`, default `cloudevents`), behind the + `recovery` and `logger` middlewares; its handler injects the publisher with + `drivers=[nats, pubsub]`, so the worker links no Kafka client. Its package + is `gen/builder/order-worker`, package `orderworker`; +- `stock-audit`: the `@Job` of `stockaudit`, which asks `stock.Service` (the + decorated memory one by default) for the items of its `@Config Options` + (`builder.examples.simple.stockaudit.items`, default `book, pen, + sold-out`), logs the sold-out ones and exits. + +`cmd/api/main.go`, `cmd/order-worker/main.go` and `cmd/stock-audit/main.go` +are generated. Its tests call the routes, the gRPC service and the MCP tool of the generated app, check that the handler receives the stock decorator and that prometheus counted its call, boot the api with `unlimited` (wired without the decorator), with an unknown name (the boot fails) and with `SOLD__OUT=book,pen` (the env replaces the default list), run the function handler, boot the worker with each adapter (nats on an embedded server) and with an unknown one (the boot fails), run -the generated main with only `WORKER_FUNCTION_ADAPTER=nats` until it records +the generated main with only `ORDER__WORKER_FUNCTION_ADAPTER=nats` until it records an order published on NATS and then send it SIGTERM (it exits 0 within a -second), run it with only `WORKER_FUNCTION_ADAPTER=pubsub`, v2 and v1, against +second), run it with only `ORDER__WORKER_FUNCTION_ADAPTER=pubsub`, v2 and v1, against a `pstest` fake until it records a CloudEvent and a raw message, stays up and exits 0 on SIGTERM, and with no subscription (the boot fails naming it), build the worker and check with `go version -m` that it has no `confluent-kafka-go`, boot it with the kafka driver (the boot fails listing -`nats, noop, pubsub`), and fail when `gen/` or a main is stale. +`nats, noop, pubsub`), run the generated main of `stock-audit` on the memory +stock (it exits 0 by itself), on the remote stock against a warehouse that +answers 500 (it logs `job failed` and exits 1) and against one that never +answers, sending SIGTERM while the job waits (the job context is cancelled +and it exits 1 within seconds), and fail when `gen/` or a main is stale. ## Non-goals diff --git a/builder/annotations.go b/builder/annotations.go index 7c9bcd88..7becbd69 100644 --- a/builder/annotations.go +++ b/builder/annotations.go @@ -8,6 +8,7 @@ import ( "unicode" "unicode/utf8" + "github.com/gobeam/stringy" "github.com/xgodev/boost/annotation" "github.com/xgodev/boost/inject" "github.com/xgodev/boost/model/errors" @@ -23,6 +24,7 @@ const ( defaultGRPCServer = "grpc" defaultMCPServer = "mcp" functionFile = "function" // the function door of an app is function_module.go + jobFile = "job" // the job door of an app is job_module.go middlewareDir = "middleware" // gen/builder/middleware holds the decorators, so no app takes the name ) @@ -58,6 +60,11 @@ type functionArgs struct { Client string `attr:"client"` } +// jobArgs are the arguments of @Job(app=sweeper). +type jobArgs struct { + App string `attr:"app"` +} + // functionMiddleware is a middleware of @Function: the fx module that // provides it and the type it provides, *Type[T] of package Pkg. type functionMiddleware struct { @@ -131,13 +138,19 @@ var ( grpcKeys = []string{"register", "server", "app"} mcpKeys = []string{"name", "server", "app"} functionKeys = []string{"adapter", "app", "middlewares", "client"} + jobKeys = []string{"app"} // clientName is the form of the client of @Function: inject's client // names, reached from an environment variable. clientName = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`) - // identName is the form of app and server names: they become a package, - // a file and a function name and a config path element. + // appName is the form of app names, kebab case as deploys are named: + // the directory of cmd/ and gen/builder/; without the hyphens + // it is the package name (appPackage), in camelCase the config path + // element (configName). + appName = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + // identName is the form of server names: they become a file and a + // function name and a config path element. identName = regexp.MustCompile(`^[a-z][a-z0-9]*$`) // toolName is the form of an MCP tool name. toolName = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) @@ -193,9 +206,10 @@ func parseList(raw, where, key string) ([]string, error) { } func validateNames(ann, where, app, server string) error { - // the app name is the package name of gen/builder/ - if !identName.MatchString(app) || token.IsKeyword(app) || app == "main" || app == middlewareDir { - return errors.NotValidf("@%s on %s: app name %q must match %s and not be a Go keyword, main or %s", ann, where, app, identName, middlewareDir) + // without its hyphens, the app name is the package name of gen/builder/ + if pkg := appPackage(app); !appName.MatchString(app) || token.IsKeyword(pkg) || pkg == "main" || app == middlewareDir { + return errors.NotValidf("@%s on %s: app name %q must match %s, not be %s and, without its hyphens, not be a Go keyword or main", + ann, where, app, appName, middlewareDir) } if server != "" && (!identName.MatchString(server) || server == functionFile) { return errors.NotValidf("@%s on %s: server name %q must match %s and not be %q", ann, where, server, identName, functionFile) @@ -203,6 +217,20 @@ func validateNames(ann, where, app, server string) error { return nil } +// appPackage is the Go package of gen/builder/: the app name without +// its hyphens, orders-updater is ordersupdater. +func appPackage(app string) string { + return strings.ReplaceAll(app, "-", "") +} + +// configName is the config path element of an app: orders-updater is +// ordersUpdater, the key boost's env loader reads ORDERS__UPDATER_X into +// (wrapper/config/contrib/knadh/koanf/v1 camel-cases a "__" word the same +// way). A hyphenated key could not be set by an environment variable. +func configName(app string) string { + return stringy.New(stringy.New(app).CamelCase().Get()).LcFirst() +} + // snakeCase turns a Go name into a tool name: CheckStock is check_stock and // ListHTTPOrders is list_http_orders. func snakeCase(name string) string { diff --git a/builder/collector.go b/builder/collector.go index 44a361dd..cd59660d 100644 --- a/builder/collector.go +++ b/builder/collector.go @@ -3,13 +3,15 @@ package builder import "github.com/xgodev/boost/annotation" // CollectEntries returns the declarations under path, and in the packages -// they import, annotated with a door (@REST, @GRPC, @MCP, @Function) or with the +// they import, annotated with a door (@REST, @GRPC, @MCP, @Function, @Job) or with the // inject annotations (@Provide, @Inject, @Invoke, @Default, @Config) that -// build their receivers, and the @Middleware of constructors. +// build their receivers, the @Middleware of constructors and the package docs +// with the @Server of each server. func CollectEntries(path string) ([]annotation.Entry, error) { collector, err := annotation.Collect( annotation.WithPath(path), - annotation.WithFilters("Inject", "Provide", "Invoke", "Default", "Config", "REST", "GRPC", "MCP", "Function", "Middleware"), + annotation.WithFilters("Inject", "Provide", "Invoke", "Default", "Config", "REST", "GRPC", "MCP", "Function", "Job", "Middleware", "Server"), + annotation.WithPackageDocs(), ) if err != nil { return []annotation.Entry{}, err diff --git a/builder/examples/simple/cmd/order-worker/main.go b/builder/examples/simple/cmd/order-worker/main.go new file mode 100644 index 00000000..dd5a10ff --- /dev/null +++ b/builder/examples/simple/cmd/order-worker/main.go @@ -0,0 +1,17 @@ +// Code generated by builder; DO NOT EDIT. + +// Command order-worker runs app order-worker: the doors builder generates in +// github.com/xgodev/boost/builder/examples/simple/gen/builder/order-worker. +package main + +import ( + "github.com/xgodev/boost" + app "github.com/xgodev/boost/builder/examples/simple/gen/builder/order-worker" + fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" +) + +func main() { + boost.Start() + + fxfact.NewApp(app.Modules()...).Run() +} diff --git a/builder/examples/simple/cmd/worker/main_test.go b/builder/examples/simple/cmd/order-worker/main_test.go similarity index 92% rename from builder/examples/simple/cmd/worker/main_test.go rename to builder/examples/simple/cmd/order-worker/main_test.go index 21752910..80c2f8bf 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/order-worker/main_test.go @@ -24,7 +24,7 @@ import ( natsadapter "github.com/xgodev/boost/bootstrap/function/adapter/contrib/nats-io/nats.go/v1" "github.com/xgodev/boost/bootstrap/function/middleware/logger" "github.com/xgodev/boost/bootstrap/function/middleware/recovery" - "github.com/xgodev/boost/builder/examples/simple/gen/builder/worker" + orderworker "github.com/xgodev/boost/builder/examples/simple/gen/builder/order-worker" "github.com/xgodev/boost/builder/examples/simple/order" "github.com/xgodev/boost/builder/examples/simple/ordercreated" "github.com/xgodev/boost/extra/middleware" @@ -52,7 +52,7 @@ func event(t *testing.T, data any) cloudevents.Event { return in } -// TestWorker builds app worker from the generated Modules() and calls the +// TestWorker builds app order-worker from the generated Modules() and calls the // function handler it provides, as the adapter does for each event. func TestWorker(t *testing.T) { var ( @@ -60,7 +60,7 @@ func TestWorker(t *testing.T) { svc *order.Service middlewares []middleware.AnyErrorMiddleware[*cloudevents.Event] ) - app := fx.New(append(worker.Modules(), fx.NopLogger, fx.Populate(&handle, &svc), + app := fx.New(append(orderworker.Modules(), fx.NopLogger, fx.Populate(&handle, &svc), fx.Invoke(fx.Annotate( func(m []middleware.AnyErrorMiddleware[*cloudevents.Event]) { middlewares = m }, fx.ParamTags(`name:"`+functionfx.MiddlewaresName+`"`), @@ -85,9 +85,10 @@ func TestWorker(t *testing.T) { } const ( - // adapterEnv chooses the adapter of the @Function of app worker - // (worker.function.adapter, generated for adapter=[cloudevents, nats, pubsub]). - adapterEnv = "WORKER_FUNCTION_ADAPTER" + // adapterEnv chooses the adapter of the @Function of app order-worker + // (orderWorker.function.adapter, generated for adapter=[cloudevents, nats, + // pubsub]): the "__" of the variable is the hyphen of the app name (#242). + adapterEnv = "ORDER__WORKER_FUNCTION_ADAPTER" // driverEnv chooses the driver of the publisher the handler announces // with (boost.wrapper.publisher.clients.orders.driver.name): the // publisher and the function share the orders clients. @@ -101,7 +102,7 @@ const ( childEnv = "BUILDER_EXAMPLE_CHILD" ) -// TestWorkerAdapter boots app worker in a new process per adapter and +// TestWorkerAdapter boots app order-worker in a new process per adapter and // publisher driver: config is read once per process. The function reads // from the broker the handler announces to, or from another one; the nats // and pubsub clients connect to a NATS server and a Pub/Sub fake this test @@ -140,7 +141,7 @@ func TestWorkerAdapter(t *testing.T) { // main instead of the tests. const mainEnv = "BUILDER_EXAMPLE_MAIN" -// TestMain_BadConfigExitsWithTheReason runs the generated main of app worker +// TestMain_BadConfigExitsWithTheReason runs the generated main of app order-worker // with an unknown adapter and every log level at its default: the process // exits non-zero and says why, so a pod that restarts shows its reason. func TestMain_BadConfigExitsWithTheReason(t *testing.T) { @@ -151,11 +152,11 @@ func TestMain_BadConfigExitsWithTheReason(t *testing.T) { var exit *exec.ExitError require.ErrorAs(t, err, &exit, "the main must fail: %s", out) assert.NotZero(t, exit.ExitCode()) - assert.Contains(t, string(out), "worker.function.adapter") + assert.Contains(t, string(out), "orderWorker.function.adapter") assert.Contains(t, string(out), `unknown adapter "nope"`) } -// TestMain_LinksOnlyTheDriversItUses builds the generated main of app worker, +// TestMain_LinksOnlyTheDriversItUses builds the generated main of app order-worker, // whose handler injects the publisher with drivers=[nats, pubsub] (#207): the // binary has no confluent-kafka-go, so it needs no librdkafka nor cgo for it. func TestMain_LinksOnlyTheDriversItUses(t *testing.T) { @@ -185,15 +186,15 @@ func TestMain_UnknownDriverListsTheGenerated(t *testing.T) { assert.Contains(t, string(out), `unknown driver "kafka"; valid: nats, noop, pubsub`) } -// TestMain_RunsTheChosenAdapter runs the generated main of app worker with -// only WORKER_FUNCTION_ADAPTER=nats set (#203): no CLI argument and no +// TestMain_RunsTheChosenAdapter runs the generated main of app order-worker with +// only ORDER__WORKER_FUNCTION_ADAPTER=nats set (#203): no CLI argument and no // boost.bootstrap.function.default. The worker subscribes and records the // order this test publishes. func TestMain_RunsTheChosenAdapter(t *testing.T) { recordOrder(t) } -// TestMain_StopsOnSIGTERM sends SIGTERM to the generated main of app worker +// TestMain_StopsOnSIGTERM sends SIGTERM to the generated main of app order-worker // after it recorded an order (#204): the nats adapter drains and returns, so // the worker exits 0 within a second instead of waiting the fx stop timeout. func TestMain_StopsOnSIGTERM(t *testing.T) { @@ -208,8 +209,8 @@ func TestMain_StopsOnSIGTERM(t *testing.T) { } } -// recordOrder runs the generated main of app worker with only -// WORKER_FUNCTION_ADAPTER=nats on an embedded server, publishes an order until +// recordOrder runs the generated main of app order-worker with only +// ORDER__WORKER_FUNCTION_ADAPTER=nats on an embedded server, publishes an order until // the worker records it, and returns the running worker, its output and the // channel its exit goes to. func recordOrder(t *testing.T) (*exec.Cmd, *syncBuffer, <-chan error) { @@ -284,8 +285,8 @@ func pubsubFake(t *testing.T, sub string) (*pstest.Server, *pubsub.Topic) { return fake, topic } -// startPubsubWorker runs the generated main of app worker with only -// WORKER_FUNCTION_ADAPTER=pubsub (plus env) against fake, and returns it, +// startPubsubWorker runs the generated main of app order-worker with only +// ORDER__WORKER_FUNCTION_ADAPTER=pubsub (plus env) against fake, and returns it, // its output and the channel its exit goes to. func startPubsubWorker(t *testing.T, fake *pstest.Server, env ...string) (*exec.Cmd, *syncBuffer, <-chan error) { t.Helper() @@ -301,7 +302,7 @@ func startPubsubWorker(t *testing.T, fake *pstest.Server, env ...string) (*exec. return cmd, out, exited } -// TestMain_HandlesPubSubMessages runs the generated main of app worker on +// TestMain_HandlesPubSubMessages runs the generated main of app order-worker on // the pubsub adapter, v2 and v1 (#205): it boots, stays up, records the // order of a CloudEvent and of a raw message (wrapped as the event data), // and exits 0 on SIGTERM. @@ -417,7 +418,7 @@ func TestWorkerAdapterChild(t *testing.T) { adapters []function.CmdFunc[*cloudevents.Event] handle function.Handler[*cloudevents.Event] ) - app := fx.New(append(worker.Modules(), fx.NopLogger, fx.Populate(&handle), + app := fx.New(append(orderworker.Modules(), fx.NopLogger, fx.Populate(&handle), fx.Invoke(fx.Annotate( func(a []function.CmdFunc[*cloudevents.Event]) { adapters = a }, fx.ParamTags(`group:"`+functionfx.BSFunctionAdaptersGroupKey+`"`), @@ -435,7 +436,7 @@ func TestWorkerAdapterChild(t *testing.T) { default: require.Error(t, app.Err(), "an unknown adapter fails the boot") assert.True(t, errors.IsNotValid(app.Err()), "got %v", app.Err()) - assert.Contains(t, app.Err().Error(), "worker.function.adapter") + assert.Contains(t, app.Err().Error(), "orderWorker.function.adapter") assert.Contains(t, app.Err().Error(), `"`+value+`"`) assert.Contains(t, app.Err().Error(), "cloudevents, nats, pubsub") } diff --git a/builder/examples/simple/cmd/stock-audit/main.go b/builder/examples/simple/cmd/stock-audit/main.go new file mode 100644 index 00000000..38a534a9 --- /dev/null +++ b/builder/examples/simple/cmd/stock-audit/main.go @@ -0,0 +1,17 @@ +// Code generated by builder; DO NOT EDIT. + +// Command stock-audit runs app stock-audit: the doors builder generates in +// github.com/xgodev/boost/builder/examples/simple/gen/builder/stock-audit. +package main + +import ( + "github.com/xgodev/boost" + app "github.com/xgodev/boost/builder/examples/simple/gen/builder/stock-audit" + fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" +) + +func main() { + boost.Start() + + fxfact.NewApp(app.Modules()...).Run() +} diff --git a/builder/examples/simple/cmd/stock-audit/main_test.go b/builder/examples/simple/cmd/stock-audit/main_test.go new file mode 100644 index 00000000..480eb10c --- /dev/null +++ b/builder/examples/simple/cmd/stock-audit/main_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "sync" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/bootstrap/job" + stockaudit "github.com/xgodev/boost/builder/examples/simple/gen/builder/stock-audit" + "github.com/xgodev/boost/builder/examples/simple/stock" + "go.uber.org/fx" +) + +// TestMain boots boost: the stock and the audit read their config. +func TestMain(m *testing.M) { + boost.Start() + os.Exit(m.Run()) +} + +// TestAudit builds app stock-audit from the generated Modules() and runs the +// job handler it provides, as the job module does once on start. +func TestAudit(t *testing.T) { + var ( + run job.Handler + stockSvc stock.Service + ) + app := fx.New(append(stockaudit.Modules(), fx.NopLogger, fx.Populate(&run, &stockSvc))...) + require.NoError(t, app.Err()) + + assert.Contains(t, reflect.TypeOf(stockSvc).Elem().PkgPath(), "/gen/builder/middleware/", + "the auditor gets the stock wrapped in the middlewares of @Middleware") + require.NoError(t, run(context.Background()), "the memory stock answers every item") +} + +const ( + // mainEnv makes the process the TestMain_* tests start run the generated + // main instead of the tests. + mainEnv = "BUILDER_EXAMPLE_MAIN" + // implementationEnv chooses the implementation of stock.Service + // (builder.examples.simple.stock.implementation). + implementationEnv = "BUILDER_EXAMPLES_SIMPLE_STOCK_IMPLEMENTATION" + // warehouseHostEnv points the warehouse client of stock/remote at a + // server (boost.factory.resty.clients.warehouse.host). + warehouseHostEnv = "BOOST_FACTORY_RESTY_CLIENTS_WAREHOUSE_HOST" + // requestTimeoutEnv sets how long the warehouse client waits for an + // answer (boost.factory.resty.clients.warehouse.requestTimeout). + requestTimeoutEnv = "BOOST_FACTORY_RESTY_CLIENTS_WAREHOUSE_REQUEST__TIMEOUT" +) + +// TestMain_ExitsZeroWhenTheJobSucceeds runs the generated main of app +// stock-audit on the memory stock: the audit returns nil and the process +// exits 0 by itself, nothing stops it. +func TestMain_ExitsZeroWhenTheJobSucceeds(t *testing.T) { + out, err := runMain(t, 10*time.Second) + + require.NoError(t, err, "the audit did not exit 0: %s", out) + assert.Contains(t, out, "sold-out is out of stock") + assert.Contains(t, out, "audited 3 items, 1 out of stock") +} + +// TestMain_ExitsOneWhenTheJobFails runs the generated main of app stock-audit +// on the remote stock with a warehouse that answers 500: the audit returns +// the error, which the app logs before exiting 1. +func TestMain_ExitsOneWhenTheJobFails(t *testing.T) { + warehouse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(warehouse.Close) + + out, err := runMain(t, 10*time.Second, implementationEnv+"=remote", warehouseHostEnv+"="+warehouse.URL) + + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, "the audit must fail: %s", out) + assert.Equal(t, 1, exit.ExitCode()) + assert.Contains(t, out, "job failed") + assert.Contains(t, out, "the warehouse answered 500") +} + +// TestMain_SIGTERMCancelsTheJob sends SIGTERM to the generated main of app +// stock-audit while the audit waits on a warehouse that never answers: the +// job context is cancelled, the request returns, and the process exits 1 +// right away instead of waiting the fx stop timeout, saying the job did not +// finish. +func TestMain_SIGTERMCancelsTheJob(t *testing.T) { + asked := make(chan struct{}, 1) + warehouse := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + asked <- struct{}{} + <-r.Context().Done() + })) + t.Cleanup(warehouse.Close) + + out := &syncBuffer{} + cmd := exec.Command(os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(os.Environ(), implementationEnv+"=remote", warehouseHostEnv+"="+warehouse.URL, + requestTimeoutEnv+"=1m", mainEnv+"=1") + cmd.Stdout, cmd.Stderr = out, out + require.NoError(t, cmd.Start()) + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + select { + case <-asked: + case err := <-exited: + t.Fatalf("the audit exited before asking the warehouse: %v: %s", err, out.String()) + case <-time.After(10 * time.Second): + t.Fatalf("the audit did not ask the warehouse: %s", out.String()) + } + + require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) + select { + case err := <-exited: + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, "an audit cut short must not exit 0: %s", out.String()) + assert.Equal(t, 1, exit.ExitCode()) + assert.Contains(t, out.String(), "job stopped before finishing") + assert.Contains(t, out.String(), "context canceled") + case <-time.After(5 * time.Second): + t.Fatalf("the audit did not stop within 5s of SIGTERM: %s", out.String()) + } +} + +// runMain runs the generated main of app stock-audit with env and returns +// its output and exit, failing the test if it runs past timeout. +func runMain(t *testing.T, timeout time.Duration, env ...string) (string, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(append(os.Environ(), env...), mainEnv+"=1") + out, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "the audit did not exit by itself: %s", out) + return string(out), err +} + +// syncBuffer is a bytes.Buffer the child process and the test share. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// TestMainChild runs the generated main, with no argument as a deployed +// binary gets, in the process the TestMain_* tests start. +func TestMainChild(t *testing.T) { + if os.Getenv(mainEnv) != "1" { + t.Skip("run by the TestMain_* tests") + } + os.Args = os.Args[:1] + main() +} diff --git a/builder/examples/simple/doc.go b/builder/examples/simple/doc.go index 190d53d1..d8ae719b 100644 --- a/builder/examples/simple/doc.go +++ b/builder/examples/simple/doc.go @@ -1,7 +1,8 @@ // Package simple is an application whose doors come from builder // annotations: app api serves REST routes on two echo servers (rest and -// admin), a gRPC health service and the check_stock MCP tool; app worker -// records the orders of order.created events. +// admin), a gRPC health service and the check_stock MCP tool; app order-worker +// records the orders of order.created events; app stock-audit is a job that +// checks the stock of a list of items once and exits. // // Regenerate gen/ with: // diff --git a/builder/examples/simple/gen/builder/api/mcp_module.go b/builder/examples/simple/gen/builder/api/mcp_module.go index 65f17889..dc6bb116 100644 --- a/builder/examples/simple/gen/builder/api/mcp_module.go +++ b/builder/examples/simple/gen/builder/api/mcp_module.go @@ -11,6 +11,7 @@ import ( gen_stocktool_d9696083 "github.com/xgodev/boost/builder/examples/simple/gen/stocktool" src_stocktool_d9696083 "github.com/xgodev/boost/builder/examples/simple/stocktool" mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" mcpfx "github.com/xgodev/boost/fx/modules/factory/contrib/modelcontextprotocol/go-sdk/v1/server" ) @@ -24,6 +25,11 @@ func McpModule() fx.Option { mcpOnce.Do(func() { options = fx.Module("api.mcp", mcpfx.Module(), + fx.Provide(func() []mcpserver.Plugin { + return []mcpserver.Plugin{ + mcplog.Register, + } + }), gen_stocktool_d9696083.NewModule(), fx.Invoke(func(srv *mcpserver.Server, h0 *src_stocktool_d9696083.Handler) { mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "check_stock", Description: "Tells whether an item can be ordered."}, h0.CheckStock) diff --git a/builder/examples/simple/gen/builder/worker/function_module.go b/builder/examples/simple/gen/builder/order-worker/function_module.go similarity index 91% rename from builder/examples/simple/gen/builder/worker/function_module.go rename to builder/examples/simple/gen/builder/order-worker/function_module.go index 9346fce6..99671e34 100644 --- a/builder/examples/simple/gen/builder/worker/function_module.go +++ b/builder/examples/simple/gen/builder/order-worker/function_module.go @@ -1,6 +1,6 @@ // Code generated by builder; DO NOT EDIT. -package worker +package orderworker import ( "sync" @@ -27,22 +27,22 @@ import ( config "github.com/xgodev/boost/wrapper/config" ) -// functionAdapterKey chooses the adapter of the @Function of app worker. -const functionAdapterKey = "worker.function.adapter" +// functionAdapterKey chooses the adapter of the @Function of app order-worker. +const functionAdapterKey = "orderWorker.function.adapter" func init() { - config.Add(functionAdapterKey, "cloudevents", "adapter of the @Function of app worker: cloudevents, nats, pubsub") + config.Add(functionAdapterKey, "cloudevents", "adapter of the @Function of app order-worker: cloudevents, nats, pubsub") } var functionOnce sync.Once -// FunctionModule runs the @Function handler with the adapter named by worker.function.adapter (cloudevents, nats, pubsub; default cloudevents). +// FunctionModule runs the @Function handler with the adapter named by orderWorker.function.adapter (cloudevents, nats, pubsub; default cloudevents). // It returns an empty option after the first call. func FunctionModule() fx.Option { options := fx.Options() functionOnce.Do(func() { - options = fx.Module("worker.function", + options = fx.Module("order-worker.function", gen_ordercreated_37848569.NewModule(), fx.Provide(func(h0 *src_ordercreated_37848569.Handler) bsfunction.Handler[*src_cloudevents_c136a3fd.Event] { return h0.Handle @@ -72,7 +72,7 @@ func FunctionModule() fx.Option { return options } -// functionAdapterModule wires the adapter named by worker.function.adapter +// functionAdapterModule wires the adapter named by orderWorker.function.adapter // and only that one: the others, and their clients, are never built. An // unknown name fails the boot. func functionAdapterModule() fx.Option { diff --git a/builder/examples/simple/gen/builder/order-worker/modules.go b/builder/examples/simple/gen/builder/order-worker/modules.go new file mode 100644 index 00000000..b954752a --- /dev/null +++ b/builder/examples/simple/gen/builder/order-worker/modules.go @@ -0,0 +1,16 @@ +// Code generated by builder; DO NOT EDIT. + +package orderworker + +import ( + "go.uber.org/fx" +) + +// Modules returns the doors of app order-worker: +// +// fxfact.NewApp(orderworker.Modules()...).Run() +func Modules() []fx.Option { + return []fx.Option{ + FunctionModule(), + } +} diff --git a/builder/examples/simple/gen/builder/stock-audit/job_module.go b/builder/examples/simple/gen/builder/stock-audit/job_module.go new file mode 100644 index 00000000..b24cd332 --- /dev/null +++ b/builder/examples/simple/gen/builder/stock-audit/job_module.go @@ -0,0 +1,34 @@ +// Code generated by builder; DO NOT EDIT. + +package stockaudit + +import ( + "sync" + + "go.uber.org/fx" + + bsjob "github.com/xgodev/boost/bootstrap/job" + gen_stockaudit_25c493b5 "github.com/xgodev/boost/builder/examples/simple/gen/stockaudit" + src_stockaudit_25c493b5 "github.com/xgodev/boost/builder/examples/simple/stockaudit" + jobfx "github.com/xgodev/boost/fx/modules/bootstrap/job" +) + +var jobOnce sync.Once + +// JobModule runs the @Job handler once; the app exits when it returns. +// It returns an empty option after the first call. +func JobModule() fx.Option { + options := fx.Options() + + jobOnce.Do(func() { + options = fx.Module("stock-audit.job", + gen_stockaudit_25c493b5.NewModule(), + fx.Provide(func(h0 *src_stockaudit_25c493b5.Auditor) bsjob.Handler { + return h0.Run + }), + jobfx.Module(), + ) + }) + + return options +} diff --git a/builder/examples/simple/gen/builder/stock-audit/modules.go b/builder/examples/simple/gen/builder/stock-audit/modules.go new file mode 100644 index 00000000..be1f016f --- /dev/null +++ b/builder/examples/simple/gen/builder/stock-audit/modules.go @@ -0,0 +1,19 @@ +// Code generated by builder; DO NOT EDIT. + +package stockaudit + +import ( + "go.uber.org/fx" + + mw_memory_e8b6d243 "github.com/xgodev/boost/builder/examples/simple/gen/builder/middleware/stock/memory" +) + +// Modules returns the doors of app stock-audit: +// +// fxfact.NewApp(stockaudit.Modules()...).Run() +func Modules() []fx.Option { + return []fx.Option{ + JobModule(), + mw_memory_e8b6d243.DecorateNew(), + } +} diff --git a/builder/examples/simple/gen/module.go b/builder/examples/simple/gen/module.go index b7e9e88c..d606d3ee 100644 --- a/builder/examples/simple/gen/module.go +++ b/builder/examples/simple/gen/module.go @@ -14,6 +14,7 @@ import ( gen_ordercreated_37848569 "github.com/xgodev/boost/builder/examples/simple/gen/ordercreated" gen_probe_a7e6caf2 "github.com/xgodev/boost/builder/examples/simple/gen/probe" gen_memory_109e67b9 "github.com/xgodev/boost/builder/examples/simple/gen/stock/memory" + gen_stockaudit_25c493b5 "github.com/xgodev/boost/builder/examples/simple/gen/stockaudit" gen_stocktool_d9696083 "github.com/xgodev/boost/builder/examples/simple/gen/stocktool" ) @@ -29,6 +30,8 @@ func Module() fx.Option { gen_probe_a7e6caf2.RestPluginsModule(), impl_stock_9c4bcef0.ServiceImplementationModule(), gen_memory_109e67b9.OptionsModule(), + gen_stockaudit_25c493b5.NewModule(), + gen_stockaudit_25c493b5.OptionsModule(), gen_stocktool_d9696083.NewModule(), client_resty_6382930f.WarehouseClientModule(), client_orders_285e8251.PublisherModule(), diff --git a/builder/examples/simple/gen/stockaudit/new_module.go b/builder/examples/simple/gen/stockaudit/new_module.go new file mode 100644 index 00000000..05fe5c10 --- /dev/null +++ b/builder/examples/simple/gen/stockaudit/new_module.go @@ -0,0 +1,31 @@ +// Code generated by inject; DO NOT EDIT. + +package stockaudit + +import ( + "sync" + + "go.uber.org/fx" + + impl_stock_9c4bcef0 "github.com/xgodev/boost/builder/examples/simple/gen/implementation/stock" + src_stockaudit_25c493b5 "github.com/xgodev/boost/builder/examples/simple/stockaudit" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/builder/examples/simple/stockaudit.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/builder/examples/simple/stockaudit.New", + impl_stock_9c4bcef0.ServiceImplementationModule(), + OptionsModule(), + fx.Provide(src_stockaudit_25c493b5.New), + ) + }) + + return options +} diff --git a/builder/examples/simple/gen/stockaudit/options_module.go b/builder/examples/simple/gen/stockaudit/options_module.go new file mode 100644 index 00000000..a5e45b57 --- /dev/null +++ b/builder/examples/simple/gen/stockaudit/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package stockaudit + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_stockaudit_25c493b5 "github.com/xgodev/boost/builder/examples/simple/stockaudit" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/builder/examples/simple/stockaudit.Options. +const optionsRoot = "builder.examples.simple.stockaudit" + +func init() { + // env BUILDER_EXAMPLES_SIMPLE_STOCKAUDIT_ITEMS + config.Add(optionsRoot+".items", []string{"book", "pen", "sold-out"}, "items the audit checks") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/builder/examples/simple/stockaudit, +// filled from the config keys under builder.examples.simple.stockaudit. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/builder/examples/simple/stockaudit.Options", + fx.Provide(func() (*src_stockaudit_25c493b5.Options, error) { + return config.NewOptionsWithPath[src_stockaudit_25c493b5.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/builder/examples/simple/ordercreated/handler.go b/builder/examples/simple/ordercreated/handler.go index 21cec0c6..b5335bff 100644 --- a/builder/examples/simple/ordercreated/handler.go +++ b/builder/examples/simple/ordercreated/handler.go @@ -1,5 +1,5 @@ // Package ordercreated records the orders of order.created events in app -// worker. +// order-worker. package ordercreated import ( @@ -36,7 +36,7 @@ type payload struct { } // Handle records the order of the event, announces it and replies with it. -// @Function(adapter=[cloudevents, nats, pubsub], app=worker, client=orders, middlewares=[recovery, logger]) +// @Function(adapter=[cloudevents, nats, pubsub], app=order-worker, client=orders, middlewares=[recovery, logger]) func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) (*cloudevents.Event, error) { var p payload if err := in.DataAs(&p); err != nil || p.Item == "" { diff --git a/builder/examples/simple/stockaudit/auditor.go b/builder/examples/simple/stockaudit/auditor.go new file mode 100644 index 00000000..6ae7500f --- /dev/null +++ b/builder/examples/simple/stockaudit/auditor.go @@ -0,0 +1,51 @@ +// Package stockaudit checks the stock of a list of items in app stock-audit, +// a job: the app runs the audit once and exits, the shape of a CronJob. +package stockaudit + +import ( + "context" + + "github.com/xgodev/boost/builder/examples/simple/stock" + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/log" +) + +// Options configures the audit. inject generates its config keys +// (builder.examples.simple.stockaudit.*) and provides *Options to New. +// @Config +type Options struct { + Items []string `default:"book,pen,sold-out" desc:"items the audit checks"` +} + +// Auditor checks the stock of the items Options lists. +type Auditor struct { + stock stock.Service + items []string +} + +// New provides the auditor with the stock service of the app, wrapped in +// the middlewares @Middleware lists on it. +// @Provide(index=0) +func New(svc stock.Service, o *Options) *Auditor { + return &Auditor{stock: svc, items: o.Items} +} + +// Run asks the stock for each item and logs the ones that are out. A stock +// that fails stops the audit with its error, and the app exits 1. +// @Job(app=stock-audit) +func (a *Auditor) Run(ctx context.Context) error { + logger := log.FromContext(ctx) + out := 0 + for _, item := range a.items { + available, err := a.stock.Available(ctx, item) + if err != nil { + return errors.Annotatef(err, "auditing %s", item) + } + if !available { + out++ + logger.Warnf("%s is out of stock", item) + } + } + logger.Infof("audited %d items, %d out of stock", len(a.items), out) + return nil +} diff --git a/builder/function_door.go b/builder/function_door.go index ba051f4e..959a7438 100644 --- a/builder/function_door.go +++ b/builder/function_door.go @@ -57,7 +57,7 @@ func functionDoor(base, app string, fn Function) (doorData, error) { // a list of adapters: config chooses one, and only its module is wired names := fn.Adapters() sw := &adapterSwitch{ - Key: app + "." + functionFile + ".adapter", + Key: configName(app) + "." + functionFile + ".adapter", Names: strings.Join(names, ", "), Config: imps.add("config", wrapperConfigPath), Errors: imps.add("errors", errorsPath), @@ -78,7 +78,8 @@ func functionDoor(base, app string, fn Function) (doorData, error) { return d, nil } -// adapterOptions returns the fx modules that run the named adapter of fn. +// adapterOptions returns the fx modules that run the named adapter of fn and +// the plugins it is built with. // An adapter reading with a client inject builds takes the app's client of // that type, the default one or the one client=x names: the module of its // inject constructor, or the one inject generates. @@ -108,6 +109,10 @@ func adapterOptions(base string, imps *imports, result string, fn Function, name } options = append(options, module, call) } + // the adapter is built with the plugins its @Server lists + if plugins := fn.Builtin[name]; len(plugins) > 0 { + options = append(options, builtinOption(imps, name, plugins, "")) + } return options, nil } diff --git a/builder/generator.go b/builder/generator.go index 2e7ecb16..eab4a04e 100644 --- a/builder/generator.go +++ b/builder/generator.go @@ -36,6 +36,8 @@ const ( contextFxPath = "github.com/xgodev/boost/fx/modules/core/context" functionPath = "github.com/xgodev/boost/bootstrap/function" functionFxPath = "github.com/xgodev/boost/fx/modules/bootstrap/function" + jobPath = "github.com/xgodev/boost/bootstrap/job" + jobFxPath = "github.com/xgodev/boost/fx/modules/bootstrap/job" publisherPath = "github.com/xgodev/boost/wrapper/publisher" wrapperConfigPath = "github.com/xgodev/boost/wrapper/config" errorsPath = "github.com/xgodev/boost/model/errors" @@ -52,7 +54,7 @@ type Options struct { } // Generator writes, under OutputDir/builder/, one fx module per door of -// the app (a REST or gRPC server, or the function) and a modules.go whose +// the app (a REST, gRPC or MCP server, the function or the job) and a modules.go whose // Modules() returns all of them and the decorators of the app's // @Middleware, written under OutputDir/builder/middleware/, and // under CmdDir/ the main that runs them. @@ -161,7 +163,7 @@ func (g *Generator) Render(context.Context) (map[string][]byte, error) { files := map[string][]byte{} for _, app := range g.spec.Apps { - mods := modulesData{Header: generatedHeader, Package: app.Name, Servers: len(app.Servers) > 0} + mods := modulesData{Header: generatedHeader, App: app.Name, Package: appPackage(app.Name), Servers: len(app.Servers) > 0} var doors []doorData for _, srv := range app.Servers { @@ -178,6 +180,13 @@ func (g *Generator) Render(context.Context) (map[string][]byte, error) { } doors = append(doors, door) } + if app.Job != nil { + door, err := jobDoor(base, app.Name, *app.Job) + if err != nil { + return nil, err + } + doors = append(doors, door) + } for _, door := range doors { src, err := inject.ExecuteTemplate(tmpl, "door", door) @@ -224,7 +233,8 @@ func (g *Generator) Render(context.Context) (map[string][]byte, error) { func door(app, name, doc string) doorData { return doorData{ Header: generatedHeader, - Package: app, + App: app, + Package: appPackage(app), Var: name, Func: strings.ToUpper(name[:1]) + name[1:], Doc: doc, @@ -251,7 +261,7 @@ func isDefault(srv Server) bool { // configPath is the config path of a named server. func configPath(srv Server) string { - return srv.App + ".server." + srv.Name + return configName(srv.App) + ".server." + srv.Name } func serverDoor(base string, srv Server) (doorData, error) { @@ -326,10 +336,19 @@ func serverDoor(base string, srv Server) (doorData, error) { } } - // the server is built with the plugins its constructors provide + // the server is built with the plugins its constructors provide, or + // with the ones its @Server lists for _, plugin := range srv.Plugins { include(plugin) } + var builtin string + if len(srv.Builtin) > 0 { + name := srv.Name + if isDefault(srv) { + name = "" + } + builtin = builtinOption(imps, string(srv.Kind), srv.Builtin, name) + } invoke := "func(srv *" + serverPkg + ".Server" + joinPrefixed(params) + ") {\n" + strings.Join(body, "\n") + "\n}" @@ -337,6 +356,9 @@ func serverDoor(base string, srv Server) (doorData, error) { def := defaultServers[srv.Kind] fxModule := imps.add(def.alias, def.fxPath) d.Options = append(d.Options, fxModule+".Module()") + if builtin != "" { + d.Options = append(d.Options, builtin) + } if srv.PluginsName != "" { // the shipped module takes the default []Plugin: give it the // value provided under the server's name @@ -376,6 +398,9 @@ func serverDoor(base string, srv Server) (doorData, error) { "fx.ParamTags("+named+"),\n"+ "fx.ResultTags("+goTag(`group:"`+multiserver.ServersGroupKey+`"`)+"),\n),\n)", ) + if builtin != "" { + d.Options = append(d.Options, builtin) + } d.Options = append(d.Options, modules...) d.Options = append(d.Options, "fx.Invoke(fx.Annotate(\n"+invoke+",\nfx.ParamTags("+named+"),\n))") d.Imports = imps.sorted() diff --git a/builder/generator_test.go b/builder/generator_test.go index fe0eafcd..aa062de0 100644 --- a/builder/generator_test.go +++ b/builder/generator_test.go @@ -114,6 +114,121 @@ func TestGenerator_RenderIsStable(t *testing.T) { assert.Equal(t, first, second, "the order of the annotations does not change the output") } +// TestGenerator_RenderKebabApp checks an app named in kebab case, as deploys +// are (#242): cmd/ and gen/builder/ keep the hyphens, the Go +// package drops them and the config path element is camelCase, the key an +// environment variable reaches (ORDERS__UPDATER_FUNCTION_ADAPTER is +// ordersUpdater.function.adapter). +func TestGenerator_RenderKebabApp(t *testing.T) { + entries := []annotation.Entry{ + decl("probe", "", "Live", echoHandler, errResult, ann("REST", "method=GET, path=/live, server=admin, app=orders-updater")), + decl("events", "", "New", nil, []string{"Handler"}, ann("Provide", "index=0")), + decl("events", "Handler", "Handle", eventParams, eventResults, ann("Function", "adapter=[pubsub, nats], app=orders-updater")), + } + spec, err := newTestSpec(t, entries) + require.NoError(t, err) + require.Len(t, spec.Apps, 1) + assert.Equal(t, "orders-updater", spec.Apps[0].Name) + + gen := NewGenerator(spec, testOptions("/src/app")) + files, err := gen.Render(context.Background()) + require.NoError(t, err) + mains, err := gen.RenderCmd(context.Background()) + require.NoError(t, err) + for name, src := range mains { + files["cmd/"+name] = src + } + + assert.Equal(t, []string{ + "builder/orders-updater/admin_module.go", + "builder/orders-updater/function_module.go", + "builder/orders-updater/modules.go", + "cmd/orders-updater/main.go", + }, fileNames(files)) + + for name, src := range files { + assertImportsUsed(t, name, src) + if strings.HasPrefix(name, "builder/") { + assert.Contains(t, string(src), "\npackage ordersupdater\n", name) + } + + golden := filepath.Join("testdata", "golden", "kebab", path.Base(name)+".golden") + if *update { + require.NoError(t, os.MkdirAll(filepath.Dir(golden), 0o755)) + require.NoError(t, os.WriteFile(golden, src, 0o644)) + continue + } + want, err := os.ReadFile(golden) + require.NoError(t, err, "run go test ./builder/ -run TestGenerator_RenderKebabApp -update") + assert.Equal(t, string(want), string(src), name) + } + assert.Contains(t, string(files["builder/orders-updater/admin_module.go"]), `"ordersUpdater.server.admin"`) + assert.Contains(t, string(files["builder/orders-updater/function_module.go"]), `"ordersUpdater.function.adapter"`) + assert.Contains(t, string(files["cmd/orders-updater/main.go"]), `app "example.com/app/gen/builder/orders-updater"`) +} + +// TestConfigName checks that the config path element of an app is the key +// boost's env loader reads from its environment variable: hyphens become +// camelCase, as ORDERS__UPDATER_X reaches ordersUpdater.x. +// TestGenerator_RenderJob renders app order-sweeper, whose @Job is a method, +// and app cleanup, whose @Job is a package function: each has a job door +// and the main every app has. +func TestGenerator_RenderJob(t *testing.T) { + entries := append(jobEntries("app=order-sweeper"), + decl("cleanup", "", "Run", []string{"context.Context"}, errResult, ann("Job", "app=cleanup"))) + spec, err := newTestSpec(t, entries) + require.NoError(t, err) + + gen := NewGenerator(spec, testOptions("/src/app")) + files, err := gen.Render(context.Background()) + require.NoError(t, err) + mains, err := gen.RenderCmd(context.Background()) + require.NoError(t, err) + for name, src := range mains { + files["cmd/"+name] = src + } + + assert.Equal(t, []string{ + "builder/cleanup/job_module.go", + "builder/cleanup/modules.go", + "builder/order-sweeper/job_module.go", + "builder/order-sweeper/modules.go", + "cmd/cleanup/main.go", + "cmd/order-sweeper/main.go", + }, fileNames(files)) + + for name, src := range files { + assertImportsUsed(t, name, src) + + golden := filepath.Join("testdata", "golden", "job", filepath.FromSlash(name)+".golden") + if *update { + require.NoError(t, os.MkdirAll(filepath.Dir(golden), 0o755)) + require.NoError(t, os.WriteFile(golden, src, 0o644)) + continue + } + want, err := os.ReadFile(golden) + require.NoError(t, err, "run go test ./builder/ -run TestGenerator_RenderJob -update") + assert.Equal(t, string(want), string(src), name) + } + + door := string(files["builder/order-sweeper/job_module.go"]) + assert.Contains(t, door, "jobfx.Module()") + assert.Contains(t, door, "return h0.Run") + assert.NotContains(t, string(files["builder/cleanup/job_module.go"]), "func(h0", "a package function needs no receiver") + assert.NotContains(t, string(files["builder/order-sweeper/modules.go"]), "multiserver", "a job app has no server") +} + +func TestConfigName(t *testing.T) { + for app, want := range map[string]string{ + "api": "api", + "orders-updater": "ordersUpdater", + "orders-v2-api": "ordersV2Api", + "orders-2": "orders2", + } { + assert.Equal(t, want, configName(app), app) + } +} + // TestGenerator_RenderFunctionAdapterList checks the function door of an // adapter list: the key that chooses the adapter and the switch that wires // only the chosen one. A single adapter renders as in TestGenerator_Render. diff --git a/builder/job_door.go b/builder/job_door.go new file mode 100644 index 00000000..18cdd9c8 --- /dev/null +++ b/builder/job_door.go @@ -0,0 +1,35 @@ +package builder + +import ( + "strings" + + "github.com/xgodev/boost/inject" +) + +// jobDoor provides the @Job handler as the job.Handler the job module runs +// once: the app exits when it returns. +func jobDoor(base, app string, job Job) (doorData, error) { + d := door(app, jobFile, "runs the @Job handler once; the app exits when it returns.") + imps := newImports() + + var params []string + var modules []string + include := func(provider inject.Component) { + imp, mod := inject.ModuleOf(base, provider.Entry) + imps.add(imp.Alias, imp.Path) + modules = append(modules, mod) + } + call, err := handlerFunc(imps, job.Handler, map[string]string{}, ¶ms, include) + if err != nil { + return doorData{}, err + } + + handlerType := imps.add("bsjob", jobPath) + ".Handler" + d.Options = append(d.Options, modules...) + d.Options = append(d.Options, + "fx.Provide(func("+strings.Join(params, ", ")+") "+handlerType+" {\nreturn "+call+"\n})", + imps.add("jobfx", jobFxPath)+".Module()", + ) + d.Imports = imps.sorted() + return d, nil +} diff --git a/builder/middleware.go b/builder/middleware.go index 37f82e0e..f1e129c3 100644 --- a/builder/middleware.go +++ b/builder/middleware.go @@ -366,6 +366,9 @@ func appRoots(app App) []inject.Component { if app.Function != nil { receiver(app.Function.Handler) } + if app.Job != nil { + receiver(app.Job.Handler) + } return roots } diff --git a/builder/middleware_test.go b/builder/middleware_test.go index 3bd4b008..53033b4d 100644 --- a/builder/middleware_test.go +++ b/builder/middleware_test.go @@ -158,6 +158,25 @@ func TestNewSpec_Middleware(t *testing.T) { assert.Empty(t, worker.Middlewares, "worker never builds resty.New") } +// TestNewSpec_MiddlewareOfAJob: the receiver of a @Job consumes +// quote.Service, so its app gets the decorator. +func TestNewSpec_MiddlewareOfAJob(t *testing.T) { + entries := []annotation.Entry{ + decl("resty", "", "New", nil, []string{"quote.Service"}, ann("Provide", "index=0"), ann("Middleware", "log")), + decl("sweep", "", "New", []string{"quote.Service"}, []string{"*Sweeper"}, ann("Provide", "index=0")), + decl("sweep", "Sweeper", "Run", []string{"context.Context"}, errResult, ann("Job", "app=quote-sweeper")), + } + for i := range entries { + entries[i].Imports = map[string]string{"quote": quotePath, "context": "context"} + } + + spec, err := newMiddlewareSpec(t, entries, middlewareSources) + require.NoError(t, err) + require.Len(t, spec.Apps, 1) + require.Len(t, spec.Apps[0].Middlewares, 1, "the job builds the receiver that consumes quote.Service") + assert.Equal(t, restyPath+".New", spec.Apps[0].Middlewares[0].Provider.Key()) +} + func TestNewSpec_MiddlewareWithoutLoader(t *testing.T) { spec, err := newTestSpec(t, validEntries()) require.NoError(t, err, "without @Middleware no loader is needed") diff --git a/builder/newspec.go b/builder/newspec.go index 6e1a69ca..b8d06fe4 100644 --- a/builder/newspec.go +++ b/builder/newspec.go @@ -13,35 +13,49 @@ import ( "github.com/xgodev/boost/model/errors" ) -// NewSpec builds the doors declared by the @REST, @GRPC, @MCP and @Function -// annotations of entries, and the @Middleware of the constructors each app -// builds. g is the inject graph of the same entries: it provides the +// NewSpec builds the doors declared by the @REST, @GRPC, @MCP, @Function and +// @Job annotations of entries, the @Middleware of the constructors each app +// builds and the plugins each server is built with: the ones the @Server of +// a package doc lists or, without one, the default set of its type. g is the +// inject graph of the same entries: it provides the // receivers of handler methods and the values of @GRPC constructors. load // reads the interfaces @Middleware decorates; it is only called, once, when // there is a @Middleware. // -// It returns NotValid for malformed annotations and for handlers that are -// unexported, in package main or whose signature does not match their door, +// It returns NotValid for malformed annotations, for handlers that are +// unexported, in package main or whose signature does not match their door +// and for an app that runs a @Job and anything else, // NotFound when the receiver of a handler method or a value a @Function // middleware needs has no provider, and -// AlreadyExists for a duplicate route, service or tool, for a second @Function in -// an app, for two @Middleware that decorate the same interface and for a rest, -// grpc or mcp server given both a named and an unnamed []Plugin. +// AlreadyExists for a duplicate route, service or tool, for a second @Function or +// @Job in an app, for two @Middleware that decorate the same interface, for a +// rest, grpc or mcp server given both a named and an unnamed []Plugin and for +// a server given two @Server or both a @Server and a @Provide []Plugin. A +// @Server of a server or adapter no app has is NotFound. func NewSpec(entries []annotation.Entry, g *graph.Graph[inject.Component], load PackageLoader) (*Spec, error) { - b := &specBuilder{graph: g, apps: map[string]*appState{}, middlewares: map[string]*Middleware{}} + b := &specBuilder{graph: g, apps: map[string]*appState{}, middlewares: map[string]*Middleware{}, servers: map[string]*serverDecl{}} seen := map[string]bool{} for _, entry := range entries { // the collector may load a package twice - if where := declName(entry); seen[where] { + key := declName(entry) + if entry.IsPackage() { + key += " " + entry.File + } + if seen[key] { continue - } else { - seen[where] = true } + seen[key] = true for _, ann := range entry.Annotations { + // a package doc only declares the plugins of a server + if entry.IsPackage() && ann.Name != "Server" { + continue + } var err error switch ann.Name { + case "Server": + err = b.addServer(entry, ann) case "REST": err = b.addRoute(entry, ann) case "GRPC": @@ -50,6 +64,8 @@ func NewSpec(entries []annotation.Entry, g *graph.Graph[inject.Component], load err = b.addTool(entry, ann) case "Function": err = b.addFunction(entry, ann) + case "Job": + err = b.addJob(entry, ann) case "Middleware": err = b.addMiddleware(entry, ann) } @@ -69,11 +85,13 @@ type specBuilder struct { graph *graph.Graph[inject.Component] apps map[string]*appState middlewares map[string]*Middleware // by constructor key + servers map[string]*serverDecl // the @Server, by serverKey } type appState struct { servers map[string]*Server function *Function + job *Job served map[string]bool // routes, services and tools already declared, per server } @@ -301,6 +319,38 @@ func (b *specBuilder) addFunction(entry annotation.Entry, ann annotation.Annotat return nil } +func (b *specBuilder) addJob(entry annotation.Entry, ann annotation.Annotation) error { + where := declName(entry) + var args jobArgs + if err := decode(ann, where, jobKeys, &args); err != nil { + return err + } + if args.App == "" { + return errors.NotValidf("@Job on %s: app is required: a job is a binary of its own", where) + } + if err := validateNames(ann.Name, where, args.App, ""); err != nil { + return err + } + + handler, err := b.handler(entry, ann.Name) + if err != nil { + return err + } + params, results := entry.Func.Parameters, entry.Func.Results + if len(params) != 1 || !isType(entry, params[0].Type, typeRef{Path: "context", Name: "Context"}) || + len(results) != 1 || results[0].Type != "error" { + return errors.NotValidf("@Job on %s: the handler must be func(context.Context) error", where) + } + + a := b.app(args.App) + if a.job != nil { + return errors.AlreadyExistsf("@Job on %s: app %s already runs %s and an app runs one @Job; set app= to another app", + where, args.App, declName(a.job.Handler.Entry)) + } + a.job = &Job{Handler: handler} + return nil +} + // validateFunctionClient checks client=x of @Function: an adapter reads with // a client inject builds, and the name reaches its keys from an environment // variable. @@ -423,7 +473,10 @@ func (b *specBuilder) spec() (*Spec, error) { spec := &Spec{} for _, name := range sortedNames(b.apps) { a := b.apps[name] - app := App{Name: name, Function: a.function} + if err := a.checkJob(name); err != nil { + return nil, err + } + app := App{Name: name, Function: a.function, Job: a.job} for _, srvName := range sortedNames(a.servers) { srv := *a.servers[srvName] sort.SliceStable(srv.Routes, func(i, j int) bool { @@ -441,14 +494,42 @@ func (b *specBuilder) spec() (*Spec, error) { return nil, err } srv.Plugins, srv.PluginsName = plugins, pluginsName + if srv.Builtin, err = b.serverBuiltin(srv, plugins); err != nil { + return nil, err + } app.Servers = append(app.Servers, srv) } + if app.Function != nil { + app.Function.Builtin = b.functionBuiltin(name, app.Function) + } app.Middlewares = appMiddlewares(b.middlewares, reached(appRoots(app), b.dependencies)) spec.Apps = append(spec.Apps, app) } + if err := b.unusedServer(); err != nil { + return nil, err + } return spec, nil } +// checkJob fails when the app runs a @Job and serves a server or runs a +// @Function: a job runs once and exits, which would stop them. +func (a *appState) checkJob(app string) error { + if a.job == nil { + return nil + } + var other string + switch { + case len(a.servers) > 0: + other = "serves server " + sortedNames(a.servers)[0] + case a.function != nil: + other = "runs the @Function " + declName(a.function.Handler.Entry) + default: + return nil + } + return errors.NotValidf("@Job on %s: app %s also %s, and a job app runs nothing else: it exits when the job returns; set app= to another app", + declName(a.job.Handler.Entry), app, other) +} + // dependencies returns the keys of the providers the component key depends on. func (b *specBuilder) dependencies(key string) []string { vertex := b.graph.Vertices[key] @@ -462,8 +543,12 @@ func (b *specBuilder) dependencies(key string) []string { return keys } -// declName names a declaration in messages: path.Func, path.Type.Method or path.Type. +// declName names a declaration in messages: path.Func, path.Type.Method, +// path.Type or, for a package doc, path. func declName(entry annotation.Entry) string { + if entry.IsPackage() { + return entry.Path + } name := entry.Func.Name switch { case entry.Struct != "" && name != "": diff --git a/builder/server.go b/builder/server.go new file mode 100644 index 00000000..691cc62e --- /dev/null +++ b/builder/server.go @@ -0,0 +1,277 @@ +package builder + +import ( + "slices" + "sort" + "strings" + + "github.com/xgodev/boost/annotation" + "github.com/xgodev/boost/inject" + "github.com/xgodev/boost/model/errors" +) + +// serverArgs are the arguments of +// @Server(type=rest, name=rest, app=api, plugins=[recover, logger]). +type serverArgs struct { + Type string `attr:"type"` + Name string `attr:"name"` + App string `attr:"app"` + Plugins string `attr:"plugins"` +} + +var serverKeys = []string{"type", "name", "app", "plugins"} + +// serverType is a type of @Server: a server the builder generates, the +// package of the Plugin type it is built with and the plugins @Server lists. +// A new server type is a new entry of serverTypes. +type serverType struct { + // Pkg is the package declaring the Plugin type. + Pkg string + // Alias prefixes the plugin packages imported by the generated door. + Alias string + // Plugins maps each plugin name to the package of its Register + // function. The name is the one the plugin's enabled key has in config, + // ..enabled, so config turns off what @Server lists. + Plugins map[string]string + // Defaults are the plugins of a server of the type without @Server, in + // order. + Defaults []string + // Adapter is the @Function adapter the type is, "" for a server of its + // own: its plugins go to the adapter of the app's function and name= + // does not apply. + Adapter string +} + +const ( + echoPlugins = boostEchoPath + "/plugins" + grpcPlugins = grpcServerPath + "/plugins" + mcpPlugins = mcpServerPath + "/plugins" + ceHTTPPath = functionPath + "/adapter/contrib/cloudevents/sdk-go/v2/core/http" +) + +// serverTypes are the types of @Server, by the name type= takes. +var serverTypes = map[string]serverType{ + string(KindREST): { + Pkg: boostEchoPath, + Alias: "echo", + Plugins: map[string]string{ + "bodydump": echoPlugins + "/native/bodydump", + "bodyLimit": echoPlugins + "/native/bodylimit", + "bytedanceSonic": echoPlugins + "/contrib/bytedance/sonic/v1", + "cors": echoPlugins + "/native/cors", + "datadog": echoPlugins + "/contrib/datadog/dd-trace-go/v1", + "errorHandler": echoPlugins + "/extra/error_handler", + "goccyGoJson": echoPlugins + "/contrib/goccy/go-json/v0", + "gzip": echoPlugins + "/native/gzip", + "health": echoPlugins + "/local/extra/health", + "logger": echoPlugins + "/local/wrapper/log", + "multiServer": echoPlugins + "/local/extra/multi-server", + "otel": echoPlugins + "/contrib/go.opentelemetry.io/contrib/v0", + "pprof": echoPlugins + "/contrib/hiko1129/echo-pprof/v1", + "prometheus": echoPlugins + "/contrib/prometheus/client_golang/v1", + "recover": echoPlugins + "/native/recover", + "requestId": echoPlugins + "/native/requestid", + "semaphore": echoPlugins + "/extra/semaphore", + "status": echoPlugins + "/local/model/restresponse", + "swagger": echoPlugins + "/contrib/swaggo/echo-swagger/v1", + }, + Defaults: []string{"recover", "requestId", "logger", "errorHandler", "health"}, + }, + string(KindGRPC): { + Pkg: grpcServerPath, + Alias: "grpc", + Plugins: map[string]string{ + "compressor": grpcPlugins + "/native/compressor", + "datadog": grpcPlugins + "/contrib/datadog/dd-trace-go/v1", + "log": grpcPlugins + "/local/wrapper/log", + "prometheus": grpcPlugins + "/contrib/prometheus/client_golang/v1", + }, + Defaults: []string{"log"}, + }, + string(KindMCP): { + Pkg: mcpServerPath, + Alias: "mcp", + Plugins: map[string]string{ + "log": mcpPlugins + "/local/wrapper/log", + "otel": mcpPlugins + "/contrib/go.opentelemetry.io/otel/v1", + }, + Defaults: []string{"log"}, + }, + "cloudevents": { + Pkg: ceHTTPPath, + Alias: "ce", + Plugins: map[string]string{ + "otel": ceHTTPPath + "/plugins/contrib/go.opentelemetry.io/contrib/v0", + }, + Adapter: "cloudevents", + }, +} + +// serverDecl is a @Server: the plugins of one server, or of one adapter of +// the function of an app. +type serverDecl struct { + Type string + Plugins []string + Where string + used bool +} + +// serverKey keys the @Server of server name of app, or of the adapter of +// its function. +func serverKey(app, name string, tp serverType) string { + if tp.Adapter != "" { + return app + " function " + tp.Adapter + } + return app + " server " + name +} + +func (b *specBuilder) addServer(entry annotation.Entry, ann annotation.Annotation) error { + where := declName(entry) + if !entry.IsPackage() { + return errors.NotValidf("@Server on %s: write it in the package doc of the server's package (its doc.go), above the package clause", where) + } + var args serverArgs + if err := decode(ann, where, serverKeys, &args); err != nil { + return err + } + + types := strings.Join(sortedNames(serverTypes), ", ") + if args.Type == "" { + return errors.NotValidf("@Server on %s: type is required (one of %s)", where, types) + } + tp, ok := serverTypes[args.Type] + if !ok { + return errors.NotValidf("@Server on %s: type %s is not one of %s", where, args.Type, types) + } + if tp.Adapter != "" && args.Name != "" { + return errors.NotValidf("@Server on %s: %s is the adapter of the app's @Function, so name= does not apply", where, args.Type) + } + + app, name := orDefault(args.App, defaultApp), args.Name + if tp.Adapter == "" { + name = orDefault(name, defaultServers[Kind(args.Type)].name) + } + if err := validateNames(ann.Name, where, app, args.Name); err != nil { + return err + } + + plugins := tp.Defaults + if _, listed := ann.Map["plugins"]; listed { + var err error + if plugins, err = parseList(args.Plugins, "@Server on "+where, "plugins"); err != nil { + return err + } + } + for _, plugin := range plugins { + if _, ok := tp.Plugins[plugin]; !ok { + return errors.NotValidf("@Server on %s: plugin %s of %s is not one of %s", where, plugin, args.Type, strings.Join(sortedNames(tp.Plugins), ", ")) + } + } + + key := serverKey(app, name, tp) + if other, dup := b.servers[key]; dup { + what := "server " + name + if tp.Adapter != "" { + what = "the " + tp.Adapter + " adapter" + } + return errors.AlreadyExistsf("@Server on %s and on %s both set the plugins of %s of app %s; keep one", other.Where, where, what, app) + } + b.servers[key] = &serverDecl{Type: args.Type, Plugins: plugins, Where: where} + return nil +} + +// serverBuiltin returns the plugins of srv from its @Server or, without +// one, the default set of its type, unless an @Provide []Plugin gives them. +func (b *specBuilder) serverBuiltin(srv Server, provided []inject.Component) ([]string, error) { + tp := serverTypes[string(srv.Kind)] + decl, ok := b.servers[serverKey(srv.App, srv.Name, tp)] + if !ok { + if len(provided) > 0 { + return nil, nil + } + return tp.Defaults, nil + } + + decl.used = true + switch { + case decl.Type != string(srv.Kind): + return nil, errors.NotValidf("@Server(type=%s) on %s: server %s of app %s serves %s", decl.Type, decl.Where, srv.Name, srv.App, srv.Kind) + case len(provided) > 0: + return nil, errors.AlreadyExistsf("@Server on %s lists the plugins of server %s of app %s and @Provide gives them too (%s); keep @Server", + decl.Where, srv.Name, srv.App, declNames(provided)) + } + return decl.Plugins, nil +} + +// functionBuiltin returns the plugins of each adapter of fn, from its +// @Server or the default set of the adapter's type, by adapter. +func (b *specBuilder) functionBuiltin(app string, fn *Function) map[string][]string { + var builtin map[string][]string + for _, name := range sortedNames(serverTypes) { + tp := serverTypes[name] + if tp.Adapter == "" || !slices.Contains(fn.Adapters(), tp.Adapter) { + continue + } + plugins := tp.Defaults + if decl, ok := b.servers[serverKey(app, "", tp)]; ok { + decl.used = true + plugins = decl.Plugins + } + if len(plugins) == 0 { + continue + } + if builtin == nil { + builtin = map[string][]string{} + } + builtin[tp.Adapter] = plugins + } + return builtin +} + +// unusedServer fails for the first @Server no server or adapter took: its +// app or name is wrong. +func (b *specBuilder) unusedServer() error { + keys := make([]string, 0, len(b.servers)) + for key := range b.servers { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + decl := b.servers[key] + if decl.used { + continue + } + app, rest, _ := strings.Cut(key, " ") + if tp := serverTypes[decl.Type]; tp.Adapter != "" { + return errors.NotFoundf("@Server(type=%s) on %s: app %s has no @Function with the %s adapter", decl.Type, decl.Where, app, tp.Adapter) + } + return errors.NotFoundf("@Server(type=%s) on %s: app %s has no server %s; set name= and app= to a server its doors declare", + decl.Type, decl.Where, app, strings.TrimPrefix(rest, "server ")) + } + return nil +} + +// builtinPlugins returns the type and the value of the []Plugin of the +// server type listing plugins, importing their packages: +// []pkg.Plugin and []pkg.Plugin{a.Register, ...}. +func builtinPlugins(imps *imports, typeName string, plugins []string) (string, string) { + tp := serverTypes[typeName] + // the door already imports the server's package under its own alias + list := "[]" + imps.add(tp.Alias+"plugin", tp.Pkg) + ".Plugin" + var registers []string + for _, plugin := range plugins { + registers = append(registers, imps.add(tp.Alias+strings.ToLower(plugin), tp.Plugins[plugin])+".Register") + } + return list, list + "{\n" + strings.Join(registers, ",\n") + ",\n}" +} + +// builtinOption returns the fx option providing the []Plugin of the server +// type listing plugins, under name or unnamed when name is "". +func builtinOption(imps *imports, typeName string, plugins []string, name string) string { + list, value := builtinPlugins(imps, typeName, plugins) + provider := "func() " + list + " {\nreturn " + value + "\n}" + if name == "" { + return "fx.Provide(" + provider + ")" + } + return "fx.Provide(\nfx.Annotate(\n" + provider + ",\nfx.ResultTags(" + goTag(`name:"`+name+`"`) + "),\n),\n)" +} diff --git a/builder/server_test.go b/builder/server_test.go new file mode 100644 index 00000000..2eee6d90 --- /dev/null +++ b/builder/server_test.go @@ -0,0 +1,159 @@ +package builder + +import ( + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost/annotation" + "github.com/xgodev/boost/model/errors" +) + +// pkgDoc builds the entry of the package doc of package pkg of testModule. +func pkgDoc(pkg string, anns ...annotation.Annotation) annotation.Entry { + return annotation.Entry{ + Path: testModule + "/" + pkg, + Package: path.Base(pkg), + File: "doc.go", + Annotations: anns, + Imports: fileImports, + } +} + +// ceFunction is a function app whose adapters are nats or cloudevents. +func ceFunction() []annotation.Entry { + return []annotation.Entry{ + decl("events", "", "Handle", eventParams, eventResults, ann("Function", "adapter=[nats, cloudevents], app=worker")), + } +} + +func TestNewSpec_ServerDefaults(t *testing.T) { + spec, err := newTestSpec(t, validEntries()) + require.NoError(t, err) + + api, worker := spec.Apps[0], spec.Apps[1] + admin, grpcSrv, mcpSrv, ops, rest := api.Servers[0], api.Servers[1], api.Servers[2], api.Servers[3], api.Servers[4] + restDefaults := []string{"recover", "requestId", "logger", "errorHandler", "health"} + assert.Equal(t, restDefaults, rest.Builtin, "a rest server without @Server gets the default set") + assert.Equal(t, restDefaults, admin.Builtin, "a named one too") + assert.Equal(t, []string{"log"}, grpcSrv.Builtin) + assert.Equal(t, []string{"log"}, mcpSrv.Builtin) + assert.Equal(t, []string{"log"}, ops.Builtin) + assert.Empty(t, worker.Function.Builtin, "the nats adapter has no plugins") + + spec, err = newTestSpec(t, ceFunction()) + require.NoError(t, err) + assert.Empty(t, spec.Apps[0].Function.Builtin, "cloudevents has no default plugin") +} + +func TestNewSpec_Server(t *testing.T) { + entries := append(validEntries(), + pkgDoc("server/rest", + ann("Server", "type=rest, plugins=[recover, prometheus]"), + ann("Server", "type=rest, name=admin, plugins=[]")), + pkgDoc("server/grpc", ann("Server", "type=grpc, plugins=[prometheus, log]")), + pkgDoc("server/mcp", ann("Server", "type=mcp, name=ops")), + ) + spec, err := newTestSpec(t, append(entries, entries...)) // duplicates are collapsed + require.NoError(t, err) + + api := spec.Apps[0] + admin, grpcSrv, mcpSrv, ops, rest := api.Servers[0], api.Servers[1], api.Servers[2], api.Servers[3], api.Servers[4] + assert.Equal(t, []string{"recover", "prometheus"}, rest.Builtin, "@Server lists the plugins in order") + assert.Empty(t, admin.Builtin, "plugins=[] turns them all off") + assert.Equal(t, []string{"prometheus", "log"}, grpcSrv.Builtin) + assert.Equal(t, []string{"log"}, ops.Builtin, "@Server without plugins= keeps the default set") + assert.Equal(t, []string{"log"}, mcpSrv.Builtin) + assert.Empty(t, rest.Plugins, "no @Provide []Plugin") +} + +func TestNewSpec_ServerOfAFunctionAdapter(t *testing.T) { + spec, err := newTestSpec(t, append(ceFunction(), pkgDoc("server/events", ann("Server", "type=cloudevents, app=worker, plugins=[otel]")))) + require.NoError(t, err) + assert.Equal(t, map[string][]string{"cloudevents": {"otel"}}, spec.Apps[0].Function.Builtin) +} + +func TestNewSpec_ServerProvidedPluginsStay(t *testing.T) { + spec, err := newTestSpec(t, append(validEntries(), pluginEntries()...)) + require.NoError(t, err) + for _, srv := range spec.Apps[0].Servers { + assert.NotEmpty(t, srv.Plugins, srv.Name) + assert.Empty(t, srv.Builtin, "server %s takes the []Plugin its @Provide gives, without the default set", srv.Name) + } +} + +func TestNewSpec_ServerErrors(t *testing.T) { + for _, tc := range []struct { + name string + entries []annotation.Entry + is func(error) bool + want string + }{ + {"type is required", []annotation.Entry{pkgDoc("s", ann("Server", "plugins=[log]"))}, errors.IsNotValid, "type is required"}, + {"unknown type", []annotation.Entry{pkgDoc("s", ann("Server", "type=soap"))}, errors.IsNotValid, "cloudevents, grpc, mcp, rest"}, + {"unknown key", []annotation.Entry{pkgDoc("s", ann("Server", "type=rest, port=8080"))}, errors.IsNotValid, `unknown key "port"`}, + {"unknown plugin", []annotation.Entry{pkgDoc("s", ann("Server", "type=grpc, plugins=[recover]"))}, errors.IsNotValid, "plugin recover of grpc is not one of compressor, datadog, log, prometheus"}, + {"plugin twice", []annotation.Entry{pkgDoc("s", ann("Server", "type=rest, plugins=[logger, logger]"))}, errors.IsNotValid, "lists logger twice"}, + {"bad server name", []annotation.Entry{pkgDoc("s", ann("Server", "type=rest, name=Admin"))}, errors.IsNotValid, `server name "Admin"`}, + {"name of an adapter", []annotation.Entry{pkgDoc("s", ann("Server", "type=cloudevents, name=ce"))}, errors.IsNotValid, "name= does not apply"}, + {"on a function", []annotation.Entry{decl("s", "", "Plugins", nil, nil, ann("Server", "type=rest"))}, errors.IsNotValid, "package doc"}, + {"twice", []annotation.Entry{pkgDoc("a", ann("Server", "type=rest")), pkgDoc("b", ann("Server", "type=rest"))}, errors.IsAlreadyExists, "server rest of app api"}, + {"no such server", []annotation.Entry{pkgDoc("s", ann("Server", "type=rest, name=missing"))}, errors.IsNotFound, "app api has no server missing"}, + {"another type", []annotation.Entry{pkgDoc("s", ann("Server", "type=grpc, name=rest"))}, errors.IsNotValid, "server rest of app api serves rest"}, + {"adapter not used", []annotation.Entry{pkgDoc("s", ann("Server", "type=cloudevents, app=worker"))}, errors.IsNotFound, "app worker has no @Function with the cloudevents adapter"}, + {"with @Provide []Plugin", append(pluginEntries(), pkgDoc("s", ann("Server", "type=rest, name=admin"))), errors.IsAlreadyExists, "plugins.Admin"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := newTestSpec(t, append(validEntries(), tc.entries...)) + require.Error(t, err) + assert.True(t, tc.is(err), "unexpected error kind: %v", err) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestNewSpec_OtherAnnotationsOnAPackageDocAreIgnored(t *testing.T) { + _, err := newTestSpec(t, append(validEntries(), pkgDoc("s", ann("REST", "method=GET, path=/x"), ann("Middleware", "")))) + require.NoError(t, err, "only @Server is read from a package doc") +} + +// TestGenerator_RenderServer checks that each door builds its server with +// the plugins its @Server lists: the default server with the unnamed +// []Plugin of the shipped module, a named one with the value named after it +// and an adapter with the []Plugin of its fx module. +func TestGenerator_RenderServer(t *testing.T) { + entries := append(validEntries(), + pkgDoc("server/rest", + ann("Server", "type=rest, plugins=[recover, prometheus]"), + ann("Server", "type=rest, name=admin, plugins=[]")), + pkgDoc("server/grpc", ann("Server", "type=grpc, plugins=[prometheus, log]")), + pkgDoc("server/mcp", ann("Server", "type=mcp, name=ops, app=api, plugins=[log, otel]")), + decl("events", "", "Handle", eventParams, eventResults, ann("Function", "adapter=cloudevents, app=events")), + pkgDoc("server/events", ann("Server", "type=cloudevents, app=events, plugins=[otel]")), + ) + files, err := render(t, entries) + require.NoError(t, err) + + assert.NotContains(t, string(files["builder/api/admin_module.go"]), "plugins/", "plugins=[] builds the server without plugins") + for _, name := range []string{"api/rest_module.go", "api/grpc_module.go", "api/mcp_module.go", "api/ops_module.go", "events/function_module.go"} { + src, ok := files["builder/"+name] + require.True(t, ok, "files: %v", fileNames(files)) + assertImportsUsed(t, name, src) + + golden := filepath.Join("testdata", "golden", "server", path.Base(name)+".golden") + if path.Dir(name) == "events" { + golden = filepath.Join("testdata", "golden", "server", "cloudevents_"+path.Base(name)+".golden") + } + if *update { + require.NoError(t, os.MkdirAll(filepath.Dir(golden), 0o755)) + require.NoError(t, os.WriteFile(golden, src, 0o644)) + continue + } + want, err := os.ReadFile(golden) + require.NoError(t, err, "run go test ./builder/ -run TestGenerator_RenderServer -update") + assert.Equal(t, string(want), string(src), name) + } +} diff --git a/builder/spec.go b/builder/spec.go index 1b99afaa..e8dadd1e 100644 --- a/builder/spec.go +++ b/builder/spec.go @@ -1,6 +1,7 @@ // Package builder generates the doors of an application (REST routes on -// echo, gRPC services, MCP tools and event functions) from @REST, @GRPC, @MCP -// and @Function annotations, on top of the fx modules generated by inject. +// echo, gRPC services, MCP tools, event functions and jobs) from @REST, +// @GRPC, @MCP, @Function and @Job annotations, on top of the fx modules +// generated by inject. package builder import ( @@ -28,11 +29,13 @@ type Spec struct { Apps []App // sorted by name } -// App is one binary: its servers and, optionally, its event function. +// App is one binary: its servers and, optionally, its event function, or +// its job alone. type App struct { Name string Servers []Server // sorted by name Function *Function // nil when the app has no @Function + Job *Job // nil when the app has no @Job; an app with one has nothing else // Middlewares are the @Middleware of the constructors the doors of the // app build, sorted by constructor. Middlewares []Middleware @@ -53,6 +56,10 @@ type Server struct { // PluginsName is the name Plugins are provided under, empty for the // default value and when there are no Plugins. PluginsName string + // Builtin are the plugins the generated door builds the server with, by + // their @Server name, in order: the ones its @Server lists or, without + // one, the default set of its kind. Empty when Plugins give them. + Builtin []string } // Route is a @REST annotation: an HTTP method and path served by a handler. @@ -98,6 +105,14 @@ type Function struct { // Client is the name of the client the adapters read with, from // client=x; "" for the default client. Client string + // Builtin are the plugins of each adapter that takes them, by adapter, + // from the @Server of the adapter's type or its default set. + Builtin map[string][]string +} + +// Job is a @Job annotation: the function a job app runs once before it exits. +type Job struct { + Handler Handler } // Adapters returns the adapter and its alternatives, in order. diff --git a/builder/spec_test.go b/builder/spec_test.go index eb6513b7..fff5a10e 100644 --- a/builder/spec_test.go +++ b/builder/spec_test.go @@ -110,7 +110,7 @@ func TestNewSpec(t *testing.T) { require.Len(t, api.Servers, 5) admin, grpcSrv, mcpSrv, ops, rest := api.Servers[0], api.Servers[1], api.Servers[2], api.Servers[3], api.Servers[4] - assert.Equal(t, Server{App: "api", Name: "admin", Kind: KindREST, Routes: admin.Routes}, admin) + assert.Equal(t, Server{App: "api", Name: "admin", Kind: KindREST, Routes: admin.Routes, Builtin: admin.Builtin}, admin) assert.Equal(t, []string{"GET /live probe.Live", "HEAD /live probe.Live"}, routeNames(admin.Routes)) assert.Nil(t, admin.Routes[0].Handler.Receiver, "a package function needs no provider") @@ -210,6 +210,32 @@ func TestNewSpec_ReceiverProvidedByAnotherPackage(t *testing.T) { assert.Equal(t, "*Handler", recv.Type) } +// jobEntries declare a job of the app the @Job value names, run by a +// method whose receiver New provides. +func jobEntries(value string) []annotation.Entry { + return []annotation.Entry{ + decl("sweep", "", "New", nil, []string{"*Sweeper"}, ann("Provide", "index=0")), + decl("sweep", "Sweeper", "Run", []string{"context.Context"}, errResult, ann("Job", value)), + } +} + +func TestNewSpec_Job(t *testing.T) { + spec, err := newTestSpec(t, append(validEntries(), jobEntries("app=order-sweeper")...)) + require.NoError(t, err) + + require.Len(t, spec.Apps, 3) + api, sweeper := spec.Apps[0], spec.Apps[1] + assert.Equal(t, "order-sweeper", sweeper.Name) + require.NotNil(t, sweeper.Job) + assert.Equal(t, "Run", sweeper.Job.Handler.Entry.Func.Name) + require.NotNil(t, sweeper.Job.Handler.Receiver) + assert.Equal(t, "*Sweeper", sweeper.Job.Handler.Receiver.Type) + assert.Equal(t, testModule+"/sweep.New", sweeper.Job.Handler.Receiver.Provider.Key()) + assert.Empty(t, sweeper.Servers) + assert.Nil(t, sweeper.Function) + assert.Nil(t, api.Job) +} + func TestNewSpec_Errors(t *testing.T) { live := func(anns ...annotation.Annotation) annotation.Entry { return decl("probe", "", "Live", echoHandler, errResult, anns...) @@ -225,6 +251,13 @@ func TestNewSpec_Errors(t *testing.T) { return decl("probe", "", "Status", params, results, a) } const toolSignature = "func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error)" + job := func(v string) annotation.Entry { + return decl("sweep", "", "Run", []string{"context.Context"}, errResult, ann("Job", v)) + } + jobFunc := func(params, results []string) annotation.Entry { + return decl("sweep", "", "Run", params, results, ann("Job", "app=sweeper")) + } + const jobSignature = "func(context.Context) error" testCases := []struct { name string @@ -262,7 +295,11 @@ func TestNewSpec_Errors(t *testing.T) { }()}, errors.IsNotValid, "package main"}, {"invalid server name", []annotation.Entry{live(rest("method=GET, path=/live, server=Admin"))}, errors.IsNotValid, "server name"}, {"reserved server name", []annotation.Entry{live(rest("method=GET, path=/live, server=function"))}, errors.IsNotValid, "server name"}, - {"invalid app name", []annotation.Entry{live(rest("method=GET, path=/live, app=my-app"))}, errors.IsNotValid, "app name"}, + {"invalid app name", []annotation.Entry{live(rest("method=GET, path=/live, app=my_app"))}, errors.IsNotValid, "app name"}, + {"app name ending in a hyphen", []annotation.Entry{live(rest("method=GET, path=/live, app=my-"))}, errors.IsNotValid, "app name"}, + {"app name with two hyphens in a row", []annotation.Entry{live(rest("method=GET, path=/live, app=my--app"))}, errors.IsNotValid, "app name"}, + {"app package is a Go keyword", []annotation.Entry{live(rest("method=GET, path=/live, app=g-o"))}, errors.IsNotValid, "app name"}, + {"app package main", []annotation.Entry{live(rest("method=GET, path=/live, app=ma-in"))}, errors.IsNotValid, "app name"}, {"app name is a Go keyword", []annotation.Entry{live(rest("method=GET, path=/live, app=go"))}, errors.IsNotValid, "app name"}, {"app name main", []annotation.Entry{live(rest("method=GET, path=/live, app=main"))}, errors.IsNotValid, "app name"}, {"server used by two kinds", []annotation.Entry{live(rest("method=GET, path=/live, server=grpc")), grpcSvc("register=healthpb.RegisterHealthServer")}, errors.IsNotValid, "grpc"}, @@ -309,6 +346,18 @@ func TestNewSpec_Errors(t *testing.T) { {"empty function middleware", []annotation.Entry{function("adapter=nats, middlewares=[logger, ]")}, errors.IsNotValid, "empty"}, {"publisher without driver", []annotation.Entry{function("adapter=nats, middlewares=[publisher]")}, errors.IsNotFound, "publisher.Driver"}, {"client of an adapter without client", []annotation.Entry{function("adapter=[lambda, kafka], client=prices")}, errors.IsNotValid, "client=prices"}, + {"job without app", []annotation.Entry{job("")}, errors.IsNotValid, "app is required"}, + {"job unknown key", []annotation.Entry{job("app=sweeper, adapter=nats")}, errors.IsNotValid, "adapter"}, + {"job invalid app name", []annotation.Entry{job("app=Sweeper")}, errors.IsNotValid, "app name"}, + {"job without params", []annotation.Entry{jobFunc(nil, errResult)}, errors.IsNotValid, jobSignature}, + {"job params", []annotation.Entry{jobFunc([]string{"context.Context", "string"}, errResult)}, errors.IsNotValid, jobSignature}, + {"job results", []annotation.Entry{jobFunc([]string{"context.Context"}, []string{"int", "error"})}, errors.IsNotValid, jobSignature}, + {"job result not an error", []annotation.Entry{jobFunc([]string{"context.Context"}, []string{"bool"})}, errors.IsNotValid, jobSignature}, + {"job receiver without provider", []annotation.Entry{decl("sweep", "Sweeper", "Run", []string{"context.Context"}, errResult, ann("Job", "app=sweeper"))}, errors.IsNotFound, "Sweeper"}, + {"two jobs in an app", []annotation.Entry{job("app=sweeper"), decl("sweep", "", "Other", []string{"context.Context"}, errResult, ann("Job", "app=sweeper"))}, errors.IsAlreadyExists, "@Job"}, + {"job and a server in an app", []annotation.Entry{job("app=api"), live(rest("method=GET, path=/live"))}, errors.IsNotValid, "runs nothing else"}, + {"server and a job in an app", []annotation.Entry{live(rest("method=GET, path=/live")), job("app=api")}, errors.IsNotValid, "runs nothing else"}, + {"job and a function in an app", []annotation.Entry{job("app=worker"), function("adapter=nats, app=worker")}, errors.IsNotValid, "runs nothing else"}, {"client name an environment variable does not reach", []annotation.Entry{function("adapter=nats, client=order_prices")}, errors.IsNotValid, "order_prices"}, } diff --git a/builder/template.go b/builder/template.go index 7cb2857c..57e2291d 100644 --- a/builder/template.go +++ b/builder/template.go @@ -15,7 +15,8 @@ var doorTemplate string // function_module.go). type doorData struct { Header string - Package string // app name + App string // app name + Package string // package name, the app name without hyphens Imports []inject.ImportData // imports besides sync and fx Init []string // statements of init() Var string // prefix of the once variable @@ -31,7 +32,7 @@ type doorData struct { // adapterSwitch is the config key that chooses the adapter of a @Function // among several, and the fx options of each one. type adapterSwitch struct { - Key string // config key, .function.adapter + Key string // config key, .function.adapter Names string // the adapters, comma separated, in annotation order Config string // import alias of wrapper/config Errors string // import alias of model/errors @@ -47,7 +48,8 @@ type adapterCase struct { // modulesData feeds the template of modules.go. type modulesData struct { Header string - Package string + App string // app name + Package string // package name, the app name without hyphens Servers bool Imports []inject.ImportData // the decorators, and multiserverfx when the app has servers Modules []string // module calls, in order diff --git a/builder/templates/door.tmpl b/builder/templates/door.tmpl index dc062e2e..4ecb1d03 100644 --- a/builder/templates/door.tmpl +++ b/builder/templates/door.tmpl @@ -12,7 +12,7 @@ import ( {{- end}} ) {{with .Adapter}} -// {{$.Var}}AdapterKey chooses the adapter of the @Function of app {{$.Package}}. +// {{$.Var}}AdapterKey chooses the adapter of the @Function of app {{$.App}}. const {{$.Var}}AdapterKey = {{printf "%q" .Key}} {{end}} {{if .Init}} @@ -76,7 +76,7 @@ import ( {{- end}} ) -// Modules returns the doors of app {{.Package}}{{if .Servers}} and the multiserver that runs its servers{{end}}: +// Modules returns the doors of app {{.App}}{{if .Servers}} and the multiserver that runs its servers{{end}}: // // fxfact.NewApp({{.Package}}.Modules()...).Run() func Modules() []fx.Option { diff --git a/builder/testdata/golden/api/admin_module.go.golden b/builder/testdata/golden/api/admin_module.go.golden index 4890f600..c42b1067 100644 --- a/builder/testdata/golden/api/admin_module.go.golden +++ b/builder/testdata/golden/api/admin_module.go.golden @@ -11,6 +11,11 @@ import ( src_probe_abc16892 "example.com/app/probe" multiserver "github.com/xgodev/boost/extra/multiserver" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" contextfx "github.com/xgodev/boost/fx/modules/core/context" ) @@ -44,6 +49,20 @@ func AdminModule() fx.Option { fx.ResultTags(`group:"extra.multiserver.servers"`), ), ), + fx.Provide( + fx.Annotate( + func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }, + fx.ResultTags(`name:"admin"`), + ), + ), fx.Invoke(fx.Annotate( func(srv *boostecho.Server) { srv.Add("GET", "/live", src_probe_abc16892.Live) diff --git a/builder/testdata/golden/api/grpc_module.go.golden b/builder/testdata/golden/api/grpc_module.go.golden index 20d0ba72..acde98f7 100644 --- a/builder/testdata/golden/api/grpc_module.go.golden +++ b/builder/testdata/golden/api/grpc_module.go.golden @@ -10,6 +10,7 @@ import ( gen_health_6c8ad5fb "example.com/app/gen/health" src_health_6c8ad5fb "example.com/app/health" grpcserver "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server" + grpclog "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server/plugins/local/wrapper/log" grpcfx "github.com/xgodev/boost/fx/modules/factory/contrib/google.golang.org/grpc/v1/server" src_healthpb_33efe7c5 "google.golang.org/grpc/health/grpc_health_v1" ) @@ -24,6 +25,11 @@ func GrpcModule() fx.Option { grpcOnce.Do(func() { options = fx.Module("api.grpc", grpcfx.Module(), + fx.Provide(func() []grpcserver.Plugin { + return []grpcserver.Plugin{ + grpclog.Register, + } + }), gen_health_6c8ad5fb.NewServerModule(), fx.Invoke(func(srv *grpcserver.Server, s0 *src_health_6c8ad5fb.Server) { src_healthpb_33efe7c5.RegisterHealthServer(srv.ServiceRegistrar(), s0) diff --git a/builder/testdata/golden/api/mcp_module.go.golden b/builder/testdata/golden/api/mcp_module.go.golden index 7df0d02f..3284bf4b 100644 --- a/builder/testdata/golden/api/mcp_module.go.golden +++ b/builder/testdata/golden/api/mcp_module.go.golden @@ -11,6 +11,7 @@ import ( src_orders_c30ecd34 "example.com/app/orders" mcp "github.com/modelcontextprotocol/go-sdk/mcp" mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" mcpfx "github.com/xgodev/boost/fx/modules/factory/contrib/modelcontextprotocol/go-sdk/v1/server" ) @@ -24,6 +25,11 @@ func McpModule() fx.Option { mcpOnce.Do(func() { options = fx.Module("api.mcp", mcpfx.Module(), + fx.Provide(func() []mcpserver.Plugin { + return []mcpserver.Plugin{ + mcplog.Register, + } + }), gen_orders_c30ecd34.NewModule(), fx.Invoke(func(srv *mcpserver.Server, h0 *src_orders_c30ecd34.Handler) { mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "check_stock", Description: "Tells how many units of a product are in stock.\nIt reads the stock service."}, h0.CheckStock) diff --git a/builder/testdata/golden/api/ops_module.go.golden b/builder/testdata/golden/api/ops_module.go.golden index da3facd3..af4f6eca 100644 --- a/builder/testdata/golden/api/ops_module.go.golden +++ b/builder/testdata/golden/api/ops_module.go.golden @@ -12,6 +12,7 @@ import ( mcp "github.com/modelcontextprotocol/go-sdk/mcp" multiserver "github.com/xgodev/boost/extra/multiserver" mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" contextfx "github.com/xgodev/boost/fx/modules/core/context" ) @@ -45,6 +46,16 @@ func OpsModule() fx.Option { fx.ResultTags(`group:"extra.multiserver.servers"`), ), ), + fx.Provide( + fx.Annotate( + func() []mcpserver.Plugin { + return []mcpserver.Plugin{ + mcplog.Register, + } + }, + fx.ResultTags(`name:"ops"`), + ), + ), fx.Invoke(fx.Annotate( func(srv *mcpserver.Server) { mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "probe.status"}, src_probe_abc16892.Status) diff --git a/builder/testdata/golden/api/rest_module.go.golden b/builder/testdata/golden/api/rest_module.go.golden index 59503655..04442ea1 100644 --- a/builder/testdata/golden/api/rest_module.go.golden +++ b/builder/testdata/golden/api/rest_module.go.golden @@ -10,6 +10,11 @@ import ( gen_orders_c30ecd34 "example.com/app/gen/orders" src_orders_c30ecd34 "example.com/app/orders" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_orders_c30ecd34.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_orders_c30ecd34.Handler) { srv.Add("POST", "/orders", h0.Create) diff --git a/builder/testdata/golden/job/builder/cleanup/job_module.go.golden b/builder/testdata/golden/job/builder/cleanup/job_module.go.golden new file mode 100644 index 00000000..ed2830e7 --- /dev/null +++ b/builder/testdata/golden/job/builder/cleanup/job_module.go.golden @@ -0,0 +1,32 @@ +// Code generated by builder; DO NOT EDIT. + +package cleanup + +import ( + "sync" + + "go.uber.org/fx" + + src_cleanup_0eea3478 "example.com/app/cleanup" + bsjob "github.com/xgodev/boost/bootstrap/job" + jobfx "github.com/xgodev/boost/fx/modules/bootstrap/job" +) + +var jobOnce sync.Once + +// JobModule runs the @Job handler once; the app exits when it returns. +// It returns an empty option after the first call. +func JobModule() fx.Option { + options := fx.Options() + + jobOnce.Do(func() { + options = fx.Module("cleanup.job", + fx.Provide(func() bsjob.Handler { + return src_cleanup_0eea3478.Run + }), + jobfx.Module(), + ) + }) + + return options +} diff --git a/builder/testdata/golden/job/builder/cleanup/modules.go.golden b/builder/testdata/golden/job/builder/cleanup/modules.go.golden new file mode 100644 index 00000000..72011909 --- /dev/null +++ b/builder/testdata/golden/job/builder/cleanup/modules.go.golden @@ -0,0 +1,16 @@ +// Code generated by builder; DO NOT EDIT. + +package cleanup + +import ( + "go.uber.org/fx" +) + +// Modules returns the doors of app cleanup: +// +// fxfact.NewApp(cleanup.Modules()...).Run() +func Modules() []fx.Option { + return []fx.Option{ + JobModule(), + } +} diff --git a/builder/testdata/golden/job/builder/order-sweeper/job_module.go.golden b/builder/testdata/golden/job/builder/order-sweeper/job_module.go.golden new file mode 100644 index 00000000..330f29df --- /dev/null +++ b/builder/testdata/golden/job/builder/order-sweeper/job_module.go.golden @@ -0,0 +1,34 @@ +// Code generated by builder; DO NOT EDIT. + +package ordersweeper + +import ( + "sync" + + "go.uber.org/fx" + + gen_sweep_f11bbadc "example.com/app/gen/sweep" + src_sweep_f11bbadc "example.com/app/sweep" + bsjob "github.com/xgodev/boost/bootstrap/job" + jobfx "github.com/xgodev/boost/fx/modules/bootstrap/job" +) + +var jobOnce sync.Once + +// JobModule runs the @Job handler once; the app exits when it returns. +// It returns an empty option after the first call. +func JobModule() fx.Option { + options := fx.Options() + + jobOnce.Do(func() { + options = fx.Module("order-sweeper.job", + gen_sweep_f11bbadc.NewModule(), + fx.Provide(func(h0 *src_sweep_f11bbadc.Sweeper) bsjob.Handler { + return h0.Run + }), + jobfx.Module(), + ) + }) + + return options +} diff --git a/builder/testdata/golden/job/builder/order-sweeper/modules.go.golden b/builder/testdata/golden/job/builder/order-sweeper/modules.go.golden new file mode 100644 index 00000000..e4c25e0e --- /dev/null +++ b/builder/testdata/golden/job/builder/order-sweeper/modules.go.golden @@ -0,0 +1,16 @@ +// Code generated by builder; DO NOT EDIT. + +package ordersweeper + +import ( + "go.uber.org/fx" +) + +// Modules returns the doors of app order-sweeper: +// +// fxfact.NewApp(ordersweeper.Modules()...).Run() +func Modules() []fx.Option { + return []fx.Option{ + JobModule(), + } +} diff --git a/builder/testdata/golden/job/cmd/cleanup/main.go.golden b/builder/testdata/golden/job/cmd/cleanup/main.go.golden new file mode 100644 index 00000000..1adfe094 --- /dev/null +++ b/builder/testdata/golden/job/cmd/cleanup/main.go.golden @@ -0,0 +1,17 @@ +// Code generated by builder; DO NOT EDIT. + +// Command cleanup runs app cleanup: the doors builder generates in +// example.com/app/gen/builder/cleanup. +package main + +import ( + app "example.com/app/gen/builder/cleanup" + "github.com/xgodev/boost" + fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" +) + +func main() { + boost.Start() + + fxfact.NewApp(app.Modules()...).Run() +} diff --git a/builder/testdata/golden/job/cmd/order-sweeper/main.go.golden b/builder/testdata/golden/job/cmd/order-sweeper/main.go.golden new file mode 100644 index 00000000..85c9f101 --- /dev/null +++ b/builder/testdata/golden/job/cmd/order-sweeper/main.go.golden @@ -0,0 +1,17 @@ +// Code generated by builder; DO NOT EDIT. + +// Command order-sweeper runs app order-sweeper: the doors builder generates in +// example.com/app/gen/builder/order-sweeper. +package main + +import ( + app "example.com/app/gen/builder/order-sweeper" + "github.com/xgodev/boost" + fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" +) + +func main() { + boost.Start() + + fxfact.NewApp(app.Modules()...).Run() +} diff --git a/builder/testdata/golden/kebab/admin_module.go.golden b/builder/testdata/golden/kebab/admin_module.go.golden new file mode 100644 index 00000000..ab4c976b --- /dev/null +++ b/builder/testdata/golden/kebab/admin_module.go.golden @@ -0,0 +1,76 @@ +// Code generated by builder; DO NOT EDIT. + +package ordersupdater + +import ( + "sync" + + "go.uber.org/fx" + + context "context" + src_probe_abc16892 "example.com/app/probe" + multiserver "github.com/xgodev/boost/extra/multiserver" + boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +func init() { + boostecho.ConfigAdd("ordersUpdater.server.admin") +} + +var adminOnce sync.Once + +// AdminModule serves the REST routes of server admin on echo. +// It returns an empty option after the first call. +func AdminModule() fx.Option { + options := fx.Options() + + adminOnce.Do(func() { + options = fx.Module("orders-updater.admin", + contextfx.Module(), + fx.Provide( + fx.Annotate( + func(ctx context.Context, plugins []boostecho.Plugin) (*boostecho.Server, error) { + return boostecho.NewServerWithConfigPath(ctx, "ordersUpdater.server.admin", plugins...) + }, + fx.ParamTags(``, `name:"admin" optional:"true"`), + fx.ResultTags(`name:"admin"`), + ), + fx.Annotate( + func(srv *boostecho.Server) multiserver.Server { + return srv + }, + fx.ParamTags(`name:"admin"`), + fx.ResultTags(`group:"extra.multiserver.servers"`), + ), + ), + fx.Provide( + fx.Annotate( + func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }, + fx.ResultTags(`name:"admin"`), + ), + ), + fx.Invoke(fx.Annotate( + func(srv *boostecho.Server) { + srv.Add("GET", "/live", src_probe_abc16892.Live) + }, + fx.ParamTags(`name:"admin"`), + )), + ) + }) + + return options +} diff --git a/builder/testdata/golden/kebab/function_module.go.golden b/builder/testdata/golden/kebab/function_module.go.golden new file mode 100644 index 00000000..297281a8 --- /dev/null +++ b/builder/testdata/golden/kebab/function_module.go.golden @@ -0,0 +1,71 @@ +// Code generated by builder; DO NOT EDIT. + +package ordersupdater + +import ( + "sync" + + "go.uber.org/fx" + + src_events_2fb115b0 "example.com/app/events" + client_pubsub_39dc3220 "example.com/app/gen/client/cloud.google.com/go/pubsub/v2" + client_nats_92a4ce51 "example.com/app/gen/client/github.com/nats-io/nats.go" + gen_events_2fb115b0 "example.com/app/gen/events" + src_cloudevents_c136a3fd "github.com/cloudevents/sdk-go/v2" + bsfunction "github.com/xgodev/boost/bootstrap/function" + functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" + pubsubv1fx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1" + pubsubv2fx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2" + natsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1" + errors "github.com/xgodev/boost/model/errors" + config "github.com/xgodev/boost/wrapper/config" +) + +// functionAdapterKey chooses the adapter of the @Function of app orders-updater. +const functionAdapterKey = "ordersUpdater.function.adapter" + +func init() { + config.Add(functionAdapterKey, "pubsub", "adapter of the @Function of app orders-updater: pubsub, nats") +} + +var functionOnce sync.Once + +// FunctionModule runs the @Function handler with the adapter named by ordersUpdater.function.adapter (pubsub, nats; default pubsub). +// It returns an empty option after the first call. +func FunctionModule() fx.Option { + options := fx.Options() + + functionOnce.Do(func() { + options = fx.Module("orders-updater.function", + gen_events_2fb115b0.NewModule(), + fx.Provide(func(h0 src_events_2fb115b0.Handler) bsfunction.Handler[*src_cloudevents_c136a3fd.Event] { + return h0.Handle + }), + functionfx.Module[*src_cloudevents_c136a3fd.Event](nil), + functionAdapterModule(), + ) + }) + + return options +} + +// functionAdapterModule wires the adapter named by ordersUpdater.function.adapter +// and only that one: the others, and their clients, are never built. An +// unknown name fails the boot. +func functionAdapterModule() fx.Option { + switch name := config.String(functionAdapterKey); name { + case "pubsub": + return fx.Options( + pubsubv1fx.Module[*src_cloudevents_c136a3fd.Event](), + pubsubv2fx.ModuleWithoutClient[*src_cloudevents_c136a3fd.Event](), + client_pubsub_39dc3220.DefaultClientModule(), + ) + case "nats": + return fx.Options( + natsfx.ModuleWithoutClient[*src_cloudevents_c136a3fd.Event](), + client_nats_92a4ce51.DefaultConnModule(), + ) + default: + return fx.Error(errors.NotValidf("%s: unknown adapter %q; valid: %s", functionAdapterKey, name, "pubsub, nats")) + } +} diff --git a/builder/testdata/golden/kebab/main.go.golden b/builder/testdata/golden/kebab/main.go.golden new file mode 100644 index 00000000..5161b9e4 --- /dev/null +++ b/builder/testdata/golden/kebab/main.go.golden @@ -0,0 +1,17 @@ +// Code generated by builder; DO NOT EDIT. + +// Command orders-updater runs app orders-updater: the doors builder generates in +// example.com/app/gen/builder/orders-updater. +package main + +import ( + app "example.com/app/gen/builder/orders-updater" + "github.com/xgodev/boost" + fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" +) + +func main() { + boost.Start() + + fxfact.NewApp(app.Modules()...).Run() +} diff --git a/builder/testdata/golden/kebab/modules.go.golden b/builder/testdata/golden/kebab/modules.go.golden new file mode 100644 index 00000000..33c1657b --- /dev/null +++ b/builder/testdata/golden/kebab/modules.go.golden @@ -0,0 +1,20 @@ +// Code generated by builder; DO NOT EDIT. + +package ordersupdater + +import ( + "go.uber.org/fx" + + multiserverfx "github.com/xgodev/boost/fx/modules/extra/multiserver" +) + +// Modules returns the doors of app orders-updater and the multiserver that runs its servers: +// +// fxfact.NewApp(ordersupdater.Modules()...).Run() +func Modules() []fx.Option { + return []fx.Option{ + AdminModule(), + FunctionModule(), + multiserverfx.Module(), + } +} diff --git a/builder/testdata/golden/server/cloudevents_function_module.go.golden b/builder/testdata/golden/server/cloudevents_function_module.go.golden new file mode 100644 index 00000000..6ad49ef9 --- /dev/null +++ b/builder/testdata/golden/server/cloudevents_function_module.go.golden @@ -0,0 +1,42 @@ +// Code generated by builder; DO NOT EDIT. + +package events + +import ( + "sync" + + "go.uber.org/fx" + + src_events_2fb115b0 "example.com/app/events" + src_cloudevents_c136a3fd "github.com/cloudevents/sdk-go/v2" + bsfunction "github.com/xgodev/boost/bootstrap/function" + ceplugin "github.com/xgodev/boost/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http" + ceotel "github.com/xgodev/boost/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0" + functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" + cloudeventsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http" +) + +var functionOnce sync.Once + +// FunctionModule runs the @Function handler with the cloudevents adapter. +// It returns an empty option after the first call. +func FunctionModule() fx.Option { + options := fx.Options() + + functionOnce.Do(func() { + options = fx.Module("events.function", + fx.Provide(func() bsfunction.Handler[*src_cloudevents_c136a3fd.Event] { + return src_events_2fb115b0.Handle + }), + functionfx.Module[*src_cloudevents_c136a3fd.Event](nil), + cloudeventsfx.Module[*src_cloudevents_c136a3fd.Event](), + fx.Provide(func() []ceplugin.Plugin { + return []ceplugin.Plugin{ + ceotel.Register, + } + }), + ) + }) + + return options +} diff --git a/builder/testdata/golden/server/grpc_module.go.golden b/builder/testdata/golden/server/grpc_module.go.golden new file mode 100644 index 00000000..142d9b38 --- /dev/null +++ b/builder/testdata/golden/server/grpc_module.go.golden @@ -0,0 +1,43 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "sync" + + "go.uber.org/fx" + + gen_health_6c8ad5fb "example.com/app/gen/health" + src_health_6c8ad5fb "example.com/app/health" + grpcserver "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server" + grpcprometheus "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server/plugins/contrib/prometheus/client_golang/v1" + grpclog "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server/plugins/local/wrapper/log" + grpcfx "github.com/xgodev/boost/fx/modules/factory/contrib/google.golang.org/grpc/v1/server" + src_healthpb_33efe7c5 "google.golang.org/grpc/health/grpc_health_v1" +) + +var grpcOnce sync.Once + +// GrpcModule serves the gRPC services of server grpc. +// It returns an empty option after the first call. +func GrpcModule() fx.Option { + options := fx.Options() + + grpcOnce.Do(func() { + options = fx.Module("api.grpc", + grpcfx.Module(), + fx.Provide(func() []grpcserver.Plugin { + return []grpcserver.Plugin{ + grpcprometheus.Register, + grpclog.Register, + } + }), + gen_health_6c8ad5fb.NewServerModule(), + fx.Invoke(func(srv *grpcserver.Server, s0 *src_health_6c8ad5fb.Server) { + src_healthpb_33efe7c5.RegisterHealthServer(srv.ServiceRegistrar(), s0) + }), + ) + }) + + return options +} diff --git a/builder/testdata/golden/server/mcp_module.go.golden b/builder/testdata/golden/server/mcp_module.go.golden new file mode 100644 index 00000000..3284bf4b --- /dev/null +++ b/builder/testdata/golden/server/mcp_module.go.golden @@ -0,0 +1,42 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "sync" + + "go.uber.org/fx" + + gen_orders_c30ecd34 "example.com/app/gen/orders" + src_orders_c30ecd34 "example.com/app/orders" + mcp "github.com/modelcontextprotocol/go-sdk/mcp" + mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" + mcpfx "github.com/xgodev/boost/fx/modules/factory/contrib/modelcontextprotocol/go-sdk/v1/server" +) + +var mcpOnce sync.Once + +// McpModule serves the MCP tools of server mcp. +// It returns an empty option after the first call. +func McpModule() fx.Option { + options := fx.Options() + + mcpOnce.Do(func() { + options = fx.Module("api.mcp", + mcpfx.Module(), + fx.Provide(func() []mcpserver.Plugin { + return []mcpserver.Plugin{ + mcplog.Register, + } + }), + gen_orders_c30ecd34.NewModule(), + fx.Invoke(func(srv *mcpserver.Server, h0 *src_orders_c30ecd34.Handler) { + mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "check_stock", Description: "Tells how many units of a product are in stock.\nIt reads the stock service."}, h0.CheckStock) + mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "list_http_orders"}, h0.ListHTTPOrders) + }), + ) + }) + + return options +} diff --git a/builder/testdata/golden/server/ops_module.go.golden b/builder/testdata/golden/server/ops_module.go.golden new file mode 100644 index 00000000..fa30b3fd --- /dev/null +++ b/builder/testdata/golden/server/ops_module.go.golden @@ -0,0 +1,71 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "sync" + + "go.uber.org/fx" + + context "context" + src_probe_abc16892 "example.com/app/probe" + mcp "github.com/modelcontextprotocol/go-sdk/mcp" + multiserver "github.com/xgodev/boost/extra/multiserver" + mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + mcpotel "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/contrib/go.opentelemetry.io/otel/v1" + mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +func init() { + mcpserver.ConfigAdd("api.server.ops") +} + +var opsOnce sync.Once + +// OpsModule serves the MCP tools of server ops. +// It returns an empty option after the first call. +func OpsModule() fx.Option { + options := fx.Options() + + opsOnce.Do(func() { + options = fx.Module("api.ops", + contextfx.Module(), + fx.Provide( + fx.Annotate( + func(ctx context.Context, plugins []mcpserver.Plugin) (*mcpserver.Server, error) { + return mcpserver.NewServerWithConfigPath(ctx, "api.server.ops", plugins...) + }, + fx.ParamTags(``, `name:"ops" optional:"true"`), + fx.ResultTags(`name:"ops"`), + ), + fx.Annotate( + func(srv *mcpserver.Server) multiserver.Server { + return srv + }, + fx.ParamTags(`name:"ops"`), + fx.ResultTags(`group:"extra.multiserver.servers"`), + ), + ), + fx.Provide( + fx.Annotate( + func() []mcpserver.Plugin { + return []mcpserver.Plugin{ + mcplog.Register, + mcpotel.Register, + } + }, + fx.ResultTags(`name:"ops"`), + ), + ), + fx.Invoke(fx.Annotate( + func(srv *mcpserver.Server) { + mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "probe.status"}, src_probe_abc16892.Status) + }, + fx.ParamTags(`name:"ops"`), + )), + ) + }) + + return options +} diff --git a/builder/testdata/golden/server/rest_module.go.golden b/builder/testdata/golden/server/rest_module.go.golden new file mode 100644 index 00000000..fa8a008f --- /dev/null +++ b/builder/testdata/golden/server/rest_module.go.golden @@ -0,0 +1,43 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "sync" + + "go.uber.org/fx" + + gen_orders_c30ecd34 "example.com/app/gen/orders" + src_orders_c30ecd34 "example.com/app/orders" + boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoprometheus "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/contrib/prometheus/client_golang/v1" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" +) + +var restOnce sync.Once + +// RestModule serves the REST routes of server rest on echo. +// It returns an empty option after the first call. +func RestModule() fx.Option { + options := fx.Options() + + restOnce.Do(func() { + options = fx.Module("api.rest", + echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echoprometheus.Register, + } + }), + gen_orders_c30ecd34.NewModule(), + fx.Invoke(func(srv *boostecho.Server, h0 *src_orders_c30ecd34.Handler) { + srv.Add("POST", "/orders", h0.Create) + srv.Add("GET", "/orders/:id", h0.Get) + }), + ) + }) + + return options +} diff --git a/docs/development/gitflow.md b/docs/development/gitflow.md index 53ac3b88..48cd0a83 100644 --- a/docs/development/gitflow.md +++ b/docs/development/gitflow.md @@ -1,17 +1,23 @@ # Gitflow — boost ``` -Issue → Branch (de develop) → Commits → PR → Review/Merge +Issue → Branch (de develop) → Commits → PR → merge em develop + ... develop acumula ... +Versão → release|bugfix/X.Y.Z → PR → main → tag vX.Y.Z → PR → develop ``` -| Branch | Propósito | Merge into | -|---|---|---| -| `main` | Releases | — | -| `develop` | Próxima release | `main` | -| `feature/*` | Funcionalidades | `develop` | -| `bugfix/*` | Correções | `develop` | -| `hotfix/*` | Urgências em produção | `main` + `develop` | -| `release/*` | Preparação de release | `main` + `develop` | +| Branch | Propósito | Sai de | Merge into | +|---|---|---|---| +| `main` | O que foi lançado; cada merge é uma tag | — | — | +| `develop` | Próxima versão | — | só pela branch de versão | +| `feature/{N}-slug` | Issue de funcionalidade | `develop` | `develop` | +| `bugfix/{N}-slug` | Issue de correção | `develop` | `develop` | +| `release/X.Y.Z` | Versão minor/major | `develop` | `main` + `develop` | +| `bugfix/X.Y.Z` | Versão patch | `develop` | `main` + `develop` | +| `hotfix/X.Y.Z` | Versão patch urgente, de produção | `main` | `main` + `develop` | + +**Só a tag leva `v`** (`v1.6.2`), porque o Go exige. Branch, milestone, +título de PR e de GitHub Release usam `X.Y.Z` (`1.6.2`). `{N}` é o número da issue; `X.Y.Z` é a versão. ## Regras @@ -19,55 +25,60 @@ Issue → Branch (de develop) → Commits → PR → Review/Merge GitHub antes de qualquer código. `gh issue list --search` antes de criar (evita duplicata). Label apropriada (`documentation`, `bug`, `enhancement`). Responsável `jpfaria` (`--assignee jpfaria`) e o milestone aberto da - próxima release (`--milestone vX.Y.Z`); sem milestone aberto, criar o do + próxima versão (`--milestone X.Y.Z`); sem milestone aberto, criar o do próximo patch. 2. **PR sempre.** Nada commitado direto em `develop` ou `main`. Toda mudança entra por Pull Request — sem exceção. -3. **Nome de branch: `feature/{N}-slug` / `bugfix/{N}-slug`** (`{N}` = número - da issue) — convenção já em uso no repo. Antes de criar: - `git fetch && git branch -a | grep {N}-`. -4. **A partir de `develop` atualizado**: `git checkout develop && git pull --ff-only`. -5. **Mergear `develop` antes de trabalho longo**: `git merge origin/develop`. -6. Commits em **inglês**, foco no **"why"**, **sem `Co-Authored-By`**. -7. **NUNCA `Closes #N` / `Fixes #N`** em commits — fechamento é manual e só a - pedido do usuário. -8. **NUNCA rebase.** Sempre `git merge`, nunca `git pull --rebase`. -9. **Quality gate verde é pré-requisito de qualquer `git push`.** *Suspenso por - enquanto:* ver o aviso no topo de [`quality-gate.md`](quality-gate.md). Ver - [`quality-gate.md`](quality-gate.md). Vermelho → corrige a causa raiz → - roda de novo → só então push. -10. **Push após cada commit lógico** (depois do gate verde). Bugfix/hotfix - mergeia rápido; feature aguarda review. **Nunca mergear feature→develop - sem o usuário pedir.** - -## Release +3. **Uma branch por issue**, `feature/{N}-slug` ou `bugfix/{N}-slug`, a partir + de `develop` atualizado (`git fetch && git branch -a | grep {N}-` antes de + criar). Nunca a partir de outra branch de issue: nada de PR empilhado. +4. **Mergear `develop` antes de trabalho longo**: `git merge origin/develop`. +5. Commits e PRs em **inglês**, foco no **"why"**, **sem `Co-Authored-By`** + nem outro trailer de atribuição. +6. **Nunca `Closes #N` / `Fixes #N`** em commit ou PR: a issue fecha à mão + (ver [Fechar issue](#fechar-issue)). +7. **Nunca rebase.** Sempre `git merge`, nunca `git pull --rebase`. +8. **Push após cada commit**, com `go build ./...` e `go test ./...` verdes. + O quality gate antes do push está suspenso (ver + [`quality-gate.md`](quality-gate.md)); religado, vermelho → corrige a causa + raiz → roda de novo → só então push. +9. **Issue pronta = PR mergeado em `develop`** (`gh pr merge N --merge`), sem + esperar review. Depois do merge, apagar a branch remota. O PR já leva o + README do pacote, o `README.md` da raiz e o site atualizados, e a issue + tem o link da issue aberta no `xgodev/claude-plugin` pedindo a + atualização da skill do boost (o boost não edita o plugin). + +## Versão (release) **Issue não gera versão.** A issue termina no merge do PR em `develop`: sem tag, sem GitHub Release, sem PR `develop` → `main`, sem mexer na versão do -site/README e sem branch `feature/N-release-*`. O `develop` acumula as issues -até a release ser lançada. +site/README. O `develop` acumula as issues até a versão ser lançada. + +Versão só quando o mantenedor mandar lançar, e com o número confirmado por +ele. O número segue o semver e escolhe a branch: -Release só quando o mantenedor mandar lançar, sempre por `release/vX.Y.Z`. -Release **não tem issue**: o PR `release/vX.Y.Z` → `main` é o registro dela. +- major (`X` anda, `/vX` no import) ou minor (`Y` anda): `release/X.Y.Z`; +- patch (só `Z` anda): `bugfix/X.Y.Z` saindo de `develop`, ou `hotfix/X.Y.Z` + saindo de `main` quando é urgência em produção. +Abaixo, `` é essa branch. A versão **não tem issue**: o PR `` +→ `main` é o registro dela. -0. Milestone `vX.Y.Z` (`gh api repos/xgodev/boost/milestones -f title=vX.Y.Z`) +0. Milestone `X.Y.Z` (`gh api repos/xgodev/boost/milestones -f title=X.Y.Z`) com todas as issues mergeadas em `develop` desde a tag anterior - (`gh issue edit N --milestone vX.Y.Z`); fechar o milestone no passo 4. -1. `git checkout -b release/vX.Y.Z origin/develop`. Versão: patch para - fix/deps, minor para feature. Mudança de API pequena vai como minor, com - nota no release; major (`/v2` no import) só por decisão do mantenedor. -2. Na `release/vX.Y.Z`, só preparação da release: a versão fixa em - `docs/site/index.html` (barra do topo, hero, diff do `go.mod`, timeline) e - no `README.md` da raiz (diff do `go.mod`, linha de releases). Nada de - feature nova nessa branch. -3. PR `release/vX.Y.Z` → `main` (`release: vX.Y.Z`), merge com - `gh pr merge N --merge`. + (`gh issue edit N --milestone X.Y.Z`). +1. `git checkout -b origin/develop` (`origin/main` no hotfix). +2. Na ``, só a preparação: a versão fixa em `docs/site/index.html` + (barra do topo, hero, diff do `go.mod`, timeline) e no `README.md` da raiz + (diff do `go.mod`, linha de releases). Nada de código novo nessa branch; + no hotfix, a correção entra por uma issue com PR para a ``. +3. PR `` → `main` (`release: X.Y.Z`), merge com `gh pr merge N --merge`. 4. Tag anotada `vX.Y.Z` no merge commit de `main` e `gh release create vX.Y.Z - --verify-tag --latest --generate-notes --notes-start-tag `, + --title X.Y.Z --verify-tag --latest --generate-notes --notes-start-tag `, com uma seção `Behavior / API changes` no topo (`--notes`) quando houver. -5. PR `release/vX.Y.Z` → `develop` (traz o bump de versão de volta), merge, e - apagar a `release/vX.Y.Z`. + Fechar o milestone. +5. PR `` → `develop` (`release: X.Y.Z back to develop`, traz o bump de versão de volta), merge, e + apagar a ``. O site (`docs/site/`) publica sozinho a cada push em `develop` que mexe em `docs/site/` (`.github/workflows/site.yml`): a versão nova aparece no site no @@ -82,10 +93,11 @@ análise técnica; merge; resumo final. Opções A/B/C ao usuário vão na issue ## Fechar issue -Só quando o usuário pedir. `gh issue close ` — sem auto-close via commit. +Depois do merge em `develop`: comentar o PR e o que entrou, e +`gh issue close `. Nunca por auto-close de commit ou PR. ## Labels que excluem das release notes - `duplicate` — escopo idêntico a outra issue (a duplicata é a mais nova). -- `documentation` quando puramente interno (CI/CD, scripts, planejamento) — - use também `wontfix`/critério do mantenedor para não vazar em notas. +- `documentation` quando puramente interno (CI/CD, scripts, planejamento); + `wontfix` para o que não deve aparecer nas notas. diff --git a/docs/site/example/build_hero.py b/docs/site/example/build_hero.py index c539beac..66753a60 100644 --- a/docs/site/example/build_hero.py +++ b/docs/site/example/build_hero.py @@ -88,8 +88,9 @@ def highlight(src): (2, "api/rest/", None, "", ""), (3, "handler/stock/reserver/", None, "", ""), (4, "handler.go", "internal/app/api/rest/handler/stock/reserver/handler.go", "@REST", "a"), - (3, "plugins.go", "internal/app/api/rest/plugins.go", "@Provide", "a"), (1, "pkg/", None, "", ""), + (2, "server/rest/", None, "", ""), + (3, "doc.go", "internal/pkg/server/rest/doc.go", "@Server", "a"), (2, "domain/", None, "", ""), (3, "service/stock/reserver/", None, "", ""), (4, "local/", None, "", ""), @@ -102,10 +103,11 @@ def highlight(src): (4, "datastore.go", "internal/pkg/datastore/stock/memory/datastore.go", "@Default", "a"), (3, "mongodb/", None, "", ""), (4, "config.go", "internal/pkg/datastore/stock/mongodb/config.go", "@Config", "a"), - (4, "datastore.go", "internal/pkg/datastore/stock/mongodb/datastore.go", "@Provide", "a"), + (4, "datastore.go", "internal/pkg/datastore/stock/mongodb/datastore.go", "@Inject", "a"), (0, "doc.go", "doc.go", "go:generate", ""), ] MID = "gen/builder/middleware/internal_/pkg/domain/service/stock/reserver/local/new_middleware.go" +CONN = "gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go" IMPL = "gen/implementation/internal_/pkg/domain/repository/stock/repository_implementation.go" G = "gen/internal/" GEN = [ @@ -118,6 +120,8 @@ def highlight(src): (3, "rest_module.go", "gen/builder/api/rest_module.go", "@REST", "a"), (2, "middleware/…/local/", None, "", ""), (3, "new_middleware.go", MID, "@Middleware", "a"), + (1, "client/…/mongo-driver/v2/", None, "", ""), + (2, "default_conn.go", CONN, "@Inject", "a"), (1, "implementation/…/stock/", None, "", ""), (2, "repository_implementation.go", IMPL, "@Default", "a"), (1, "internal/", None, "", ""), @@ -125,7 +129,6 @@ def highlight(src): (3, "api/rest/", None, "", ""), (4, "handler/stock/reserver/", None, "", ""), (5, "new_module.go", G + "app/api/rest/handler/stock/reserver/new_module.go", "", ""), - (4, "plugins_module.go", G + "app/api/rest/plugins_module.go", "", ""), (2, "pkg/", None, "", ""), (3, "domain/service/…/local/", None, "", ""), (4, "new_module.go", G + "pkg/domain/service/stock/reserver/local/new_module.go", "", ""), @@ -138,7 +141,7 @@ def highlight(src): RUN = """$ go generate ./... 5:35PM INF scanning ~/shop (module github.com/acme/shop) -5:35PM INF generated 6 inject modules and 1 apps in ~/shop/gen, mains in ~/shop/cmd +5:35PM INF generated 5 inject modules and 1 apps in ~/shop/gen, mains in ~/shop/cmd $ go run ./cmd/api & 5:36PM INF starting Echo Server. https://echo.labstack.com/ diff --git a/docs/site/example/shop/gen/builder/api/rest_module.go b/docs/site/example/shop/gen/builder/api/rest_module.go index 1c019096..94c70f1b 100644 --- a/docs/site/example/shop/gen/builder/api/rest_module.go +++ b/docs/site/example/shop/gen/builder/api/rest_module.go @@ -7,10 +7,11 @@ import ( "go.uber.org/fx" - gen_rest_54b9e997 "github.com/acme/shop/gen/internal/app/api/rest" gen_reserver_5a8b66f7 "github.com/acme/shop/gen/internal/app/api/rest/handler/stock/reserver" src_reserver_5a8b66f7 "github.com/acme/shop/internal/app/api/rest/handler/stock/reserver" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -24,8 +25,13 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echoerrorhandler.Register, + } + }), gen_reserver_5a8b66f7.NewModule(), - gen_rest_54b9e997.PluginsModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_reserver_5a8b66f7.Handler) { srv.Add("POST", "/stock/:sku/reserve", h0.Handle) }), diff --git a/docs/site/example/shop/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go b/docs/site/example/shop/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go new file mode 100644 index 00000000..1fa54098 --- /dev/null +++ b/docs/site/example/shop/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go @@ -0,0 +1,61 @@ +// Code generated by inject; DO NOT EDIT. + +package mongo + +import ( + "context" + "sync" + + "go.uber.org/fx" + + factory "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + prometheusplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/contrib/prometheus/client_golang/v1" + healthplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/local/health" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultConnRoot is the config root of the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn); +// env BOOST_FACTORY_MONGO_*. +const defaultConnRoot = "boost.factory.mongo" + +func init() { + factory.ConfigAdd(defaultConnRoot) + healthplugin.ConfigAdd(defaultConnRoot + ".plugins.health") + prometheusplugin.ConfigAdd(defaultConnRoot + ".plugins.prometheus") +} + +var defaultConnOnce sync.Once + +// DefaultConnModule provides the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn), built from the keys under +// boost.factory.mongo with the plugins health, prometheus, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultConnModule() fx.Option { + options := fx.Options() + + defaultConnOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.DefaultConn", + contextfx.Module(), + fx.Provide(newDefaultConn), + ) + }) + + return options +} + +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*factory.Conn, error) { + health, err := healthplugin.NewHealthWithConfigPath(defaultConnRoot + ".plugins.health") + if err != nil { + return nil, err + } + prometheus, err := prometheusplugin.NewPrometheusWithConfigPath(defaultConnRoot + ".plugins.prometheus") + if err != nil { + return nil, err + } + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot, health.Register, prometheus.Register) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/docs/site/example/shop/gen/internal/app/api/rest/plugins_module.go b/docs/site/example/shop/gen/internal/app/api/rest/plugins_module.go deleted file mode 100644 index 30de57b2..00000000 --- a/docs/site/example/shop/gen/internal/app/api/rest/plugins_module.go +++ /dev/null @@ -1,28 +0,0 @@ -// Code generated by inject; DO NOT EDIT. - -package rest - -import ( - "sync" - - "go.uber.org/fx" - - src_rest_54b9e997 "github.com/acme/shop/internal/app/api/rest" -) - -var PluginsOnce sync.Once - -// PluginsModule registers github.com/acme/shop/internal/app/api/rest.Plugins and the modules it depends on. -// It returns an empty option after the first call, so a constructor shared by -// several modules is registered only once. -func PluginsModule() fx.Option { - options := fx.Options() - - PluginsOnce.Do(func() { - options = fx.Module("github.com/acme/shop/internal/app/api/rest.Plugins", - fx.Provide(src_rest_54b9e997.Plugins), - ) - }) - - return options -} diff --git a/docs/site/example/shop/gen/internal/pkg/datastore/stock/mongodb/new_module.go b/docs/site/example/shop/gen/internal/pkg/datastore/stock/mongodb/new_module.go index 6d5d6917..910939a5 100644 --- a/docs/site/example/shop/gen/internal/pkg/datastore/stock/mongodb/new_module.go +++ b/docs/site/example/shop/gen/internal/pkg/datastore/stock/mongodb/new_module.go @@ -7,6 +7,7 @@ import ( "go.uber.org/fx" + client_mongo_d932eadb "github.com/acme/shop/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" src_mongodb_1bacb721 "github.com/acme/shop/internal/pkg/datastore/stock/mongodb" ) @@ -21,6 +22,7 @@ func NewModule() fx.Option { NewOnce.Do(func() { options = fx.Module("github.com/acme/shop/internal/pkg/datastore/stock/mongodb.New", OptionsModule(), + client_mongo_d932eadb.DefaultConnModule(), fx.Provide(src_mongodb_1bacb721.New), ) }) diff --git a/docs/site/example/shop/gen/module.go b/docs/site/example/shop/gen/module.go index 56e395eb..870b11fc 100644 --- a/docs/site/example/shop/gen/module.go +++ b/docs/site/example/shop/gen/module.go @@ -5,8 +5,8 @@ package gen import ( "go.uber.org/fx" + client_mongo_d932eadb "github.com/acme/shop/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" impl_stock_f8e11fb6 "github.com/acme/shop/gen/implementation/internal_/pkg/domain/repository/stock" - gen_rest_54b9e997 "github.com/acme/shop/gen/internal/app/api/rest" gen_reserver_5a8b66f7 "github.com/acme/shop/gen/internal/app/api/rest/handler/stock/reserver" gen_mongodb_1bacb721 "github.com/acme/shop/gen/internal/pkg/datastore/stock/mongodb" gen_local_9afcd4bd "github.com/acme/shop/gen/internal/pkg/domain/service/stock/reserver/local" @@ -15,10 +15,10 @@ import ( // Module registers every module generated by inject. func Module() fx.Option { return fx.Options( - gen_rest_54b9e997.PluginsModule(), gen_reserver_5a8b66f7.NewModule(), impl_stock_f8e11fb6.RepositoryImplementationModule(), gen_mongodb_1bacb721.OptionsModule(), gen_local_9afcd4bd.NewModule(), + client_mongo_d932eadb.DefaultConnModule(), ) } diff --git a/docs/site/example/shop/go.mod b/docs/site/example/shop/go.mod index fa6cc993..1b4d23d9 100644 --- a/docs/site/example/shop/go.mod +++ b/docs/site/example/shop/go.mod @@ -65,6 +65,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/docs/site/example/shop/internal/app/api/rest/plugins.go b/docs/site/example/shop/internal/app/api/rest/plugins.go deleted file mode 100644 index 28478624..00000000 --- a/docs/site/example/shop/internal/app/api/rest/plugins.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package rest holds what the rest server of app api needs besides its -// handlers. -package rest - -import ( - boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" - "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" -) - -// Plugins turns boost errors into HTTP statuses: a Conflict answers 409. -// @Provide(index=0) -func Plugins() []boostecho.Plugin { - return []boostecho.Plugin{error_handler.Register} -} diff --git a/docs/site/example/shop/internal/pkg/datastore/stock/mongodb/datastore.go b/docs/site/example/shop/internal/pkg/datastore/stock/mongodb/datastore.go index 85d89338..4c87437d 100644 --- a/docs/site/example/shop/internal/pkg/datastore/stock/mongodb/datastore.go +++ b/docs/site/example/shop/internal/pkg/datastore/stock/mongodb/datastore.go @@ -15,19 +15,17 @@ type datastore struct { coll *mongo.Collection } -// New connects through boost's mongo factory (boost.factory.mongo.*). +// New keeps the stock on the conn inject builds from boost.factory.mongo.*, +// which the app closes when it stops. // @Provide(index=0) -func New(ctx context.Context, o *Options) (stock.Repository, error) { - conn, err := mongofact.NewConn(ctx) - if err != nil { - return nil, err - } - return &datastore{coll: conn.Database.Collection(o.Collection)}, nil +// @Inject(index=0, plugins=[health, prometheus]) +func New(conn *mongofact.Conn, o *Options) stock.Repository { + return &datastore{coll: conn.Database.Collection(o.Collection)} } func (d *datastore) Take(ctx context.Context, sku string) error { res, err := d.coll.UpdateOne(ctx, - bson.M{"_id": sku, "units": bson.M{"$gt": 0}}, + bson.M{"_id": bson.M{"$eq": sku}, "units": bson.M{"$gt": 0}}, bson.M{"$inc": bson.M{"units": -1}}) if err != nil { return err diff --git a/docs/site/example/shop/internal/pkg/datastore/stock/mongodb/datastore_test.go b/docs/site/example/shop/internal/pkg/datastore/stock/mongodb/datastore_test.go new file mode 100644 index 00000000..5f8f4165 --- /dev/null +++ b/docs/site/example/shop/internal/pkg/datastore/stock/mongodb/datastore_test.go @@ -0,0 +1,24 @@ +package mongodb + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + mongofact "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// TestNew keeps the stock in the collection of Options, on the conn inject +// builds: the app closes it when it stops. +func TestNew(t *testing.T) { + // Connect does not reach the server: no mongo is needed + client, err := mongo.Connect(options.Client().ApplyURI("mongodb://127.0.0.1:1")) + require.NoError(t, err) + conn := &mongofact.Conn{Client: client, Database: client.Database("shop")} + + repo := New(conn, &Options{Collection: "units"}) + + assert.Equal(t, "units", repo.(*datastore).coll.Name()) +} diff --git a/docs/site/example/shop/internal/pkg/server/rest/doc.go b/docs/site/example/shop/internal/pkg/server/rest/doc.go new file mode 100644 index 00000000..5ff07597 --- /dev/null +++ b/docs/site/example/shop/internal/pkg/server/rest/doc.go @@ -0,0 +1,5 @@ +// Package rest holds what the rest server of app api needs besides its +// handlers: errorHandler turns boost errors into HTTP statuses, so a +// Conflict answers 409. +// @Server(type=rest, plugins=[recover, errorHandler]) +package rest diff --git a/docs/site/index.html b/docs/site/index.html index 2219cb21..d918d962 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -36,7 +36,7 @@ Integrations @@ -48,7 +48,7 @@
-
v1.6.2MITgo 1.18+github.com/xgodev/boost
+
v1.7.0MITgo 1.18+github.com/xgodev/boost

Enterprise Go services.
Built fast.

Boot, config, logs, 58 factories, middleware, health and code generation in one framework. You write the business logic.

@@ -79,8 +79,9 @@

Enterprise Go services.
Built fast.

api/rest/
handler/stock/reserver/
-
pkg/
+
server/rest/
+
domain/
service/stock/reserver/
local/
@@ -93,7 +94,7 @@

Enterprise Go services.
Built fast.

mongodb/
- +
@@ -126,20 +127,11 @@

Enterprise Go services.
Built fast.

} return c.NoContent(http.StatusNoContent) }
- + + -
@@ -897,7 +931,7 @@

From annotation to binary in two commands.

    -
  1. Annotate

    Mark constructors with @Provide, handlers with a door, services with @Middleware, the default implementation with @Default, options with @Config.

  2. +
  3. Annotate

    Mark constructors with @Provide, handlers with a door, services with @Middleware, the default implementation with @Default, options with @Config, the plugins of a server with @Server.

  4. Generate

    Inject and builder run in one pass. A broken graph stops here and leaves your output untouched.

    go generate ./...
  5. Run

    Each app gets its own main, with the servers and plugins it declared.

    go run ./cmd/api
@@ -954,14 +988,14 @@

A gate that only moves forward

 module github.com/acme/shop
 
  require (
--	github.com/xgodev/boost v1.1.1+	github.com/xgodev/boost v1.6.2 )
+-	github.com/xgodev/boost v1.1.1+	github.com/xgodev/boost v1.7.0 )
 
 $ go build ./... && go test ./...
 ok  github.com/acme/shop/...
1 line changed in go.mod0 lines changed in your code
- 2024-07v1.1.0v1.1.1v1.6.2 + 2024-07v1.1.0v1.1.1v1.7.0
diff --git a/examples/README.md b/examples/README.md index 885b0ca8..7a9589c9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,6 +10,15 @@ all. [`boot`](boot) is the smallest one: one handler, one service and the generated main, whose `boost.Start` loads the config and sets the logger. +[`stock`](stock) is the complete one: a stock with reservation that uses +every resource it can — REST, gRPC and MCP doors, a CloudEvents/NATS +function, a job, `@Server` plugins, service middlewares (log, prometheus), a +memory, MongoDB or Oracle repository, a resty client, the publisher and +`@Config` options, each switched by config. A resource that lands in boost +is added to it in the same PR. Its [`README.md`](stock/README.md) says how to +run each app, the calls with what they answer, the config switches and which +file shows each resource. + ## Where they are | Package | Examples | @@ -23,22 +32,27 @@ generated main, whose `boost.Start` loads the config and sets the logger. | [`wrapper/cache`](../wrapper/cache/examples) | freecache, freecache-binary, multi, plugin | | [`wrapper/config`](../wrapper/config/examples) | env, env_file | | [`builder`](../builder/examples) | simple: two apps with rest, grpc, mcp and function doors | +| [`examples/stock`](stock) | the complete app: every resource boost has | | [`docs/site/example/shop`](../docs/site/example/shop) | the app of the site | ## Shape of an example The layout is the one of an xgodev service: the handler calls a service, the service a repository or client, and the domain imports neither transport nor -persistence. +persistence. What a server needs besides its handlers (its plugins, the REST +model) is infrastructure and lives under `internal/pkg/server//`. ``` doc.go # the package doc, how to run it, //go:generate +README.md # when the example is one to hand over: run, call, config, files configs/config.yaml # when the example needs settings cmd//main.go # generated cmd//main_test.go # runs the app in process gen/ # generated, committed gen_test.go # fails when gen/ is stale internal/app///handler///handler.go +internal/pkg/server//doc.go # @Server: the plugins of the server +internal/pkg/server/rest/model/.go # the JSON model the REST handlers share internal/pkg/domain/model/entity/ internal/pkg/domain/service/// # interface internal/pkg/domain/service///local/ # implementation diff --git a/examples/boot/gen/builder/api/rest_module.go b/examples/boot/gen/builder/api/rest_module.go index af4623c0..8931be0e 100644 --- a/examples/boot/gen/builder/api/rest_module.go +++ b/examples/boot/gen/builder/api/rest_module.go @@ -10,6 +10,11 @@ import ( gen_greeter_7de5ec7e "github.com/xgodev/boost/examples/boot/gen/internal/app/api/rest/handler/greeting/greeter" src_greeter_7de5ec7e "github.com/xgodev/boost/examples/boot/internal/app/api/rest/handler/greeting/greeter" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_greeter_7de5ec7e.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_greeter_7de5ec7e.Handler) { srv.Add("GET", "/greetings/:name", h0.Handle) diff --git a/examples/boot/go.mod b/examples/boot/go.mod index d9cb2ad8..f44406d1 100644 --- a/examples/boot/go.mod +++ b/examples/boot/go.mod @@ -52,6 +52,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/examples/stock/.gitignore b/examples/stock/.gitignore new file mode 100644 index 00000000..63162673 --- /dev/null +++ b/examples/stock/.gitignore @@ -0,0 +1,2 @@ +# the generated code is part of the example +!gen/ diff --git a/examples/stock/README.md b/examples/stock/README.md new file mode 100644 index 00000000..00381eae --- /dev/null +++ b/examples/stock/README.md @@ -0,0 +1,157 @@ +# stock + +The complete example app of boost: a small stock with reservation, built from +every boost resource it can use. It is a living example: a resource that lands +in boost is added here in the same PR. + +| App | What it does | +|---|---| +| `api` | create, find and reserve stock on REST (`:8080`), gRPC (`:9090`) and MCP (`:8090/mcp`) | +| `worker` | reserves the item of each `order.created` CloudEvent it receives, over HTTP (`:8081`) or NATS | +| `stock-check` | a job: logs the units of the skus it is given and exits, 1 when a sku is not in stock | + +A reservation is announced as a `stock.reserved` event on boost's publisher. +The defaults need nothing running: the stock is in memory, seeded with +`tshirt=3` and `mug=1`, and the publisher is `noop`. + +## Run + +From this directory, each app in its own terminal: + +```shell +CONF=configs/api.yaml go run ./cmd/api +CONF=configs/worker.yaml go run ./cmd/worker +INTERNAL_APP_STOCKCHECK_JOB_STOCK_CHECKER_SKUS=tshirt,mug go run ./cmd/stock-check +``` + +`configs/worker.yaml` puts the worker's HTTP door on `8081` and makes it +reserve by calling app `api`, so run `api` first. + +In GoLand: open `examples/stock`, add a Go Build configuration per app (kind +Package, `github.com/xgodev/boost/examples/stock/cmd/api`), working directory +`examples/stock`, and the `CONF` variable above. + +## Call it + +In this order, on a fresh `api` and `worker`. + +REST, on app `api`: + +```console +$ curl localhost:8080/stocks/tshirt +{"sku":"tshirt","units":3} + +$ curl -XPOST localhost:8080/stocks/tshirt/reserve +{"sku":"tshirt","units":2} + +$ curl localhost:8080/stocks/nope +{"httpStatusCode":404,"errorCode":"404","message":"nope is not in stock"} + +$ curl 'localhost:8080/stocks/$gt' +{"httpStatusCode":400,"errorCode":"400","message":"$gt is not a sku: use 1 to 64 of a-z, 0-9 and -"} + +$ curl -XPOST localhost:8080/stocks -H 'Content-Type: application/json' -d '{"sku":"cap","units":1}' +{"sku":"cap","units":1} + +$ curl -XPOST localhost:8080/stocks/cap/reserve +{"sku":"cap","units":0} + +$ curl -XPOST localhost:8080/stocks/cap/reserve +{"httpStatusCode":409,"errorCode":"409","message":"cap is sold out"} + +$ curl localhost:8080/health +{"status":"OK"} +``` + +The create answers 201. `/metrics` serves Prometheus, and every response +carries an `X-Request-Id`. + +MCP: point any MCP client at `http://localhost:8090/mcp` (Claude Code: +`claude mcp add --transport http stock http://localhost:8090/mcp`). The tool +`find_stock` with `{"sku":"mug"}` answers `{"sku":"mug","units":1}`. + +gRPC (`api/proto/stock/v1/stock.proto`; the server has reflection on): + +```console +$ grpcurl -plaintext -d '{"sku":"mug"}' localhost:9090 stock.v1.StockReserver/Reserve +{ + "sku": "mug" +} +$ grpcurl -plaintext -d '{"sku":"mug"}' localhost:9090 stock.v1.StockReserver/Reserve +ERROR: + Code: Aborted + Message: mug is sold out +``` + +`units` is 0, so the JSON leaves it out. + +The worker, with one `order.created` CloudEvent: + +```console +$ curl localhost:8081 -H 'Ce-Id: 1' -H 'Ce-Source: shop' -H 'Ce-Type: order.created' \ + -H 'Ce-Specversion: 1.0' -H 'Content-Type: application/json' -d '{"sku":"tshirt"}' +``` + +Its log says `reserved one tshirt for event 1, 1 left`, and app `api` now +answers `{"sku":"tshirt","units":1}`. + +The job: + +```console +$ INTERNAL_APP_STOCKCHECK_JOB_STOCK_CHECKER_SKUS=tshirt,nope go run ./cmd/stock-check +INF tshirt has 3 units +ERR job failed: checking nope: nope is not in stock # exit 1 +``` + +## Config + +Every switch is a config key; the env var is the key in upper case with `_`. + +| Env var | Effect | +|---|---| +| `INTERNAL_PKG_DOMAIN_REPOSITORY_STOCK_IMPLEMENTATION=mongodb` | stock in MongoDB (`BOOST_FACTORY_MONGO_URI`) | +| `INTERNAL_PKG_DOMAIN_REPOSITORY_STOCK_IMPLEMENTATION=oracle` | stock in Oracle (`BOOST_FACTORY_GODROR_CONNECTSTRING`, `_USERNAME`, `_PASSWORD`); builds with cgo | +| `INTERNAL_PKG_DOMAIN_SERVICE_STOCK_RESERVER_IMPLEMENTATION=resty` | reserve by calling app `api` (`BOOST_FACTORY_RESTY_CLIENTS_STOCK_HOST`) | +| `BOOST_WRAPPER_PUBLISHER_DRIVER_NAME=nats` | announce `stock.reserved` on NATS (`BOOST_FACTORY_NATS_URL`) | +| `WORKER_FUNCTION_ADAPTER=nats` | the worker reads `order.created` from NATS (`BOOST_BOOTSTRAP_FUNCTION_ADAPTER_NATS_SUBJECTS`) | +| `BOOST_FACTORY_ECHO_PLUGINS_PROMETHEUS_ENABLED=false` | turns one plugin of a server off; the others stay | +| `INTERNAL_APP_STOCKCHECK_JOB_STOCK_CHECKER_SKUS=tshirt,mug` | the skus the job checks | + +## Where each resource is + +| Resource | File | Annotation | +|---|---|---| +| REST door | `internal/app/api/rest/handler/stock/{creator,finder,reserver}/handler.go` | `@REST` | +| gRPC door | `internal/app/api/grpc/handler/stock/{finder,reserver}/handler.go` | `@GRPC` | +| MCP tool | `internal/app/api/mcp/handler/stock/finder/handler.go` | `@MCP` | +| CloudEvents / NATS function | `internal/app/worker/cloudevents/handler/stock/reserver/handler.go` | `@Function(adapter=[cloudevents, nats])` | +| Job | `internal/app/stockcheck/job/stock/checker/handler.go` | `@Job` | +| Server plugins | `internal/pkg/server/{rest,grpc,mcp,cloudevents}/doc.go` | `@Server` | +| REST model | `internal/pkg/server/rest/model/stock.go` | -- | +| Service middlewares (log, prometheus) | `internal/pkg/domain/service/stock/*/local/service.go` | `@Middleware` | +| Implementation by config | `internal/pkg/datastore/stock/memory/datastore.go`, `.../reserver/local/service.go` | `@Default` | +| MongoDB datastore | `internal/pkg/datastore/stock/mongodb/datastore.go` | `@Inject(plugins=[...])` | +| Oracle datastore (`*sql.DB`) | `internal/pkg/datastore/stock/oracle/datastore.go` | `@Inject(drivers=[godror])` | +| resty client | `internal/pkg/client/stock/reserver/resty/client.go` | `@Inject(name=stock)` | +| Publisher | `internal/pkg/publisher/stock/reserved/boost/publisher.go` | `@Inject(drivers=[nats])` | +| Options | the `options.go` next to each of them | `@Config` | + +The domain (`internal/pkg/domain`) imports neither transport nor persistence: +the handler calls a service, the service a repository or client. Each +service checks the sku first (`entity.ValidSKU`: 1 to 64 of a-z, 0-9 and +`-`), so no door hands any other text to a datastore, and the MongoDB one +matches it with `$eq`. + +## Generate and test + +```shell +go generate ./... +go test ./... +``` + +`gen/` and `cmd/*/main.go` are generated and committed; `gen_test.go` fails +when they are stale. `api/proto/stock/v1` is regenerated with the `protoc` +line of its `doc.go`. The tests run each app in process on the defaults and +on each switch above; the MongoDB and Oracle ones skip unless +`BOOST_FACTORY_MONGO_URI` or `BOOST_FACTORY_GODROR_CONNECTSTRING` points at a +database. diff --git a/examples/stock/api/proto/stock/v1/doc.go b/examples/stock/api/proto/stock/v1/doc.go new file mode 100644 index 00000000..7fb0745c --- /dev/null +++ b/examples/stock/api/proto/stock/v1/doc.go @@ -0,0 +1,5 @@ +// Package stockv1 is the Go code protoc generates from stock.proto, the gRPC +// door of app api. Regenerate it from examples/stock with: +// +// protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative api/proto/stock/v1/stock.proto +package stockv1 diff --git a/examples/stock/api/proto/stock/v1/stock.pb.go b/examples/stock/api/proto/stock/v1/stock.pb.go new file mode 100644 index 00000000..dd8a9451 --- /dev/null +++ b/examples/stock/api/proto/stock/v1/stock.pb.go @@ -0,0 +1,251 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.35.2 +// protoc v7.35.0 +// source: api/proto/stock/v1/stock.proto + +// stock.v1 is the gRPC door of app api: the same use cases its REST routes +// and MCP tool serve. + +package stockv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type FindRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sku string `protobuf:"bytes,1,opt,name=sku,proto3" json:"sku,omitempty"` +} + +func (x *FindRequest) Reset() { + *x = FindRequest{} + mi := &file_api_proto_stock_v1_stock_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FindRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FindRequest) ProtoMessage() {} + +func (x *FindRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_stock_v1_stock_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FindRequest.ProtoReflect.Descriptor instead. +func (*FindRequest) Descriptor() ([]byte, []int) { + return file_api_proto_stock_v1_stock_proto_rawDescGZIP(), []int{0} +} + +func (x *FindRequest) GetSku() string { + if x != nil { + return x.Sku + } + return "" +} + +type ReserveRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sku string `protobuf:"bytes,1,opt,name=sku,proto3" json:"sku,omitempty"` +} + +func (x *ReserveRequest) Reset() { + *x = ReserveRequest{} + mi := &file_api_proto_stock_v1_stock_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReserveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReserveRequest) ProtoMessage() {} + +func (x *ReserveRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_stock_v1_stock_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReserveRequest.ProtoReflect.Descriptor instead. +func (*ReserveRequest) Descriptor() ([]byte, []int) { + return file_api_proto_stock_v1_stock_proto_rawDescGZIP(), []int{1} +} + +func (x *ReserveRequest) GetSku() string { + if x != nil { + return x.Sku + } + return "" +} + +type Stock struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sku string `protobuf:"bytes,1,opt,name=sku,proto3" json:"sku,omitempty"` + Units int64 `protobuf:"varint,2,opt,name=units,proto3" json:"units,omitempty"` +} + +func (x *Stock) Reset() { + *x = Stock{} + mi := &file_api_proto_stock_v1_stock_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Stock) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Stock) ProtoMessage() {} + +func (x *Stock) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_stock_v1_stock_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Stock.ProtoReflect.Descriptor instead. +func (*Stock) Descriptor() ([]byte, []int) { + return file_api_proto_stock_v1_stock_proto_rawDescGZIP(), []int{2} +} + +func (x *Stock) GetSku() string { + if x != nil { + return x.Sku + } + return "" +} + +func (x *Stock) GetUnits() int64 { + if x != nil { + return x.Units + } + return 0 +} + +var File_api_proto_stock_v1_stock_proto protoreflect.FileDescriptor + +var file_api_proto_stock_v1_stock_proto_rawDesc = []byte{ + 0x0a, 0x1e, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x74, 0x6f, 0x63, + 0x6b, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x08, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x2e, 0x76, 0x31, 0x22, 0x1f, 0x0a, 0x0b, 0x46, 0x69, + 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75, 0x22, 0x22, 0x0a, 0x0e, 0x52, + 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, + 0x03, 0x73, 0x6b, 0x75, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75, 0x22, + 0x2f, 0x0a, 0x05, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75, 0x12, 0x14, 0x0a, 0x05, 0x75, 0x6e, + 0x69, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x75, 0x6e, 0x69, 0x74, 0x73, + 0x32, 0x3f, 0x0a, 0x0b, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x46, 0x69, 0x6e, 0x64, 0x65, 0x72, 0x12, + 0x30, 0x0a, 0x04, 0x46, 0x69, 0x6e, 0x64, 0x12, 0x15, 0x2e, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x2e, + 0x76, 0x31, 0x2e, 0x46, 0x69, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0f, + 0x2e, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x22, + 0x00, 0x32, 0x47, 0x0a, 0x0d, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x12, 0x36, 0x0a, 0x07, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x12, 0x18, 0x2e, + 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0f, 0x2e, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x2e, + 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x22, 0x00, 0x42, 0x43, 0x5a, 0x41, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x67, 0x6f, 0x64, 0x65, 0x76, 0x2f, + 0x62, 0x6f, 0x6f, 0x73, 0x74, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x2f, 0x73, + 0x74, 0x6f, 0x63, 0x6b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x73, + 0x74, 0x6f, 0x63, 0x6b, 0x2f, 0x76, 0x31, 0x3b, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x76, 0x31, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_api_proto_stock_v1_stock_proto_rawDescOnce sync.Once + file_api_proto_stock_v1_stock_proto_rawDescData = file_api_proto_stock_v1_stock_proto_rawDesc +) + +func file_api_proto_stock_v1_stock_proto_rawDescGZIP() []byte { + file_api_proto_stock_v1_stock_proto_rawDescOnce.Do(func() { + file_api_proto_stock_v1_stock_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_proto_stock_v1_stock_proto_rawDescData) + }) + return file_api_proto_stock_v1_stock_proto_rawDescData +} + +var file_api_proto_stock_v1_stock_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_api_proto_stock_v1_stock_proto_goTypes = []any{ + (*FindRequest)(nil), // 0: stock.v1.FindRequest + (*ReserveRequest)(nil), // 1: stock.v1.ReserveRequest + (*Stock)(nil), // 2: stock.v1.Stock +} +var file_api_proto_stock_v1_stock_proto_depIdxs = []int32{ + 0, // 0: stock.v1.StockFinder.Find:input_type -> stock.v1.FindRequest + 1, // 1: stock.v1.StockReserver.Reserve:input_type -> stock.v1.ReserveRequest + 2, // 2: stock.v1.StockFinder.Find:output_type -> stock.v1.Stock + 2, // 3: stock.v1.StockReserver.Reserve:output_type -> stock.v1.Stock + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_api_proto_stock_v1_stock_proto_init() } +func file_api_proto_stock_v1_stock_proto_init() { + if File_api_proto_stock_v1_stock_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_api_proto_stock_v1_stock_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_api_proto_stock_v1_stock_proto_goTypes, + DependencyIndexes: file_api_proto_stock_v1_stock_proto_depIdxs, + MessageInfos: file_api_proto_stock_v1_stock_proto_msgTypes, + }.Build() + File_api_proto_stock_v1_stock_proto = out.File + file_api_proto_stock_v1_stock_proto_rawDesc = nil + file_api_proto_stock_v1_stock_proto_goTypes = nil + file_api_proto_stock_v1_stock_proto_depIdxs = nil +} diff --git a/examples/stock/api/proto/stock/v1/stock.proto b/examples/stock/api/proto/stock/v1/stock.proto new file mode 100644 index 00000000..22cbb0cf --- /dev/null +++ b/examples/stock/api/proto/stock/v1/stock.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +// stock.v1 is the gRPC door of app api: the same use cases its REST routes +// and MCP tool serve. +package stock.v1; + +option go_package = "github.com/xgodev/boost/examples/stock/api/proto/stock/v1;stockv1"; + +// StockFinder reads the units in stock of an item. +service StockFinder { + // Find returns the stock of the sku; an unknown sku is NOT_FOUND. + rpc Find(FindRequest) returns (Stock) {} +} + +// StockReserver takes units out of stock. +service StockReserver { + // Reserve takes one unit of the sku; an unknown sku is NOT_FOUND and a + // sold-out one the code boost maps a Conflict to. + rpc Reserve(ReserveRequest) returns (Stock) {} +} + +message FindRequest { + string sku = 1; +} + +message ReserveRequest { + string sku = 1; +} + +message Stock { + string sku = 1; + int64 units = 2; +} diff --git a/examples/stock/api/proto/stock/v1/stock_grpc.pb.go b/examples/stock/api/proto/stock/v1/stock_grpc.pb.go new file mode 100644 index 00000000..94790b31 --- /dev/null +++ b/examples/stock/api/proto/stock/v1/stock_grpc.pb.go @@ -0,0 +1,240 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v7.35.0 +// source: api/proto/stock/v1/stock.proto + +// stock.v1 is the gRPC door of app api: the same use cases its REST routes +// and MCP tool serve. + +package stockv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + StockFinder_Find_FullMethodName = "/stock.v1.StockFinder/Find" +) + +// StockFinderClient is the client API for StockFinder service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// StockFinder reads the units in stock of an item. +type StockFinderClient interface { + // Find returns the stock of the sku; an unknown sku is NOT_FOUND. + Find(ctx context.Context, in *FindRequest, opts ...grpc.CallOption) (*Stock, error) +} + +type stockFinderClient struct { + cc grpc.ClientConnInterface +} + +func NewStockFinderClient(cc grpc.ClientConnInterface) StockFinderClient { + return &stockFinderClient{cc} +} + +func (c *stockFinderClient) Find(ctx context.Context, in *FindRequest, opts ...grpc.CallOption) (*Stock, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Stock) + err := c.cc.Invoke(ctx, StockFinder_Find_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// StockFinderServer is the server API for StockFinder service. +// All implementations must embed UnimplementedStockFinderServer +// for forward compatibility. +// +// StockFinder reads the units in stock of an item. +type StockFinderServer interface { + // Find returns the stock of the sku; an unknown sku is NOT_FOUND. + Find(context.Context, *FindRequest) (*Stock, error) + mustEmbedUnimplementedStockFinderServer() +} + +// UnimplementedStockFinderServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedStockFinderServer struct{} + +func (UnimplementedStockFinderServer) Find(context.Context, *FindRequest) (*Stock, error) { + return nil, status.Errorf(codes.Unimplemented, "method Find not implemented") +} +func (UnimplementedStockFinderServer) mustEmbedUnimplementedStockFinderServer() {} +func (UnimplementedStockFinderServer) testEmbeddedByValue() {} + +// UnsafeStockFinderServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StockFinderServer will +// result in compilation errors. +type UnsafeStockFinderServer interface { + mustEmbedUnimplementedStockFinderServer() +} + +func RegisterStockFinderServer(s grpc.ServiceRegistrar, srv StockFinderServer) { + // If the following call pancis, it indicates UnimplementedStockFinderServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&StockFinder_ServiceDesc, srv) +} + +func _StockFinder_Find_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FindRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StockFinderServer).Find(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StockFinder_Find_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StockFinderServer).Find(ctx, req.(*FindRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// StockFinder_ServiceDesc is the grpc.ServiceDesc for StockFinder service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StockFinder_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "stock.v1.StockFinder", + HandlerType: (*StockFinderServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Find", + Handler: _StockFinder_Find_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "api/proto/stock/v1/stock.proto", +} + +const ( + StockReserver_Reserve_FullMethodName = "/stock.v1.StockReserver/Reserve" +) + +// StockReserverClient is the client API for StockReserver service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// StockReserver takes units out of stock. +type StockReserverClient interface { + // Reserve takes one unit of the sku; an unknown sku is NOT_FOUND and a + // sold-out one the code boost maps a Conflict to. + Reserve(ctx context.Context, in *ReserveRequest, opts ...grpc.CallOption) (*Stock, error) +} + +type stockReserverClient struct { + cc grpc.ClientConnInterface +} + +func NewStockReserverClient(cc grpc.ClientConnInterface) StockReserverClient { + return &stockReserverClient{cc} +} + +func (c *stockReserverClient) Reserve(ctx context.Context, in *ReserveRequest, opts ...grpc.CallOption) (*Stock, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Stock) + err := c.cc.Invoke(ctx, StockReserver_Reserve_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// StockReserverServer is the server API for StockReserver service. +// All implementations must embed UnimplementedStockReserverServer +// for forward compatibility. +// +// StockReserver takes units out of stock. +type StockReserverServer interface { + // Reserve takes one unit of the sku; an unknown sku is NOT_FOUND and a + // sold-out one the code boost maps a Conflict to. + Reserve(context.Context, *ReserveRequest) (*Stock, error) + mustEmbedUnimplementedStockReserverServer() +} + +// UnimplementedStockReserverServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedStockReserverServer struct{} + +func (UnimplementedStockReserverServer) Reserve(context.Context, *ReserveRequest) (*Stock, error) { + return nil, status.Errorf(codes.Unimplemented, "method Reserve not implemented") +} +func (UnimplementedStockReserverServer) mustEmbedUnimplementedStockReserverServer() {} +func (UnimplementedStockReserverServer) testEmbeddedByValue() {} + +// UnsafeStockReserverServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to StockReserverServer will +// result in compilation errors. +type UnsafeStockReserverServer interface { + mustEmbedUnimplementedStockReserverServer() +} + +func RegisterStockReserverServer(s grpc.ServiceRegistrar, srv StockReserverServer) { + // If the following call pancis, it indicates UnimplementedStockReserverServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&StockReserver_ServiceDesc, srv) +} + +func _StockReserver_Reserve_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReserveRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StockReserverServer).Reserve(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StockReserver_Reserve_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StockReserverServer).Reserve(ctx, req.(*ReserveRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// StockReserver_ServiceDesc is the grpc.ServiceDesc for StockReserver service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var StockReserver_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "stock.v1.StockReserver", + HandlerType: (*StockReserverServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Reserve", + Handler: _StockReserver_Reserve_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "api/proto/stock/v1/stock.proto", +} diff --git a/examples/stock/cmd/api/main.go b/examples/stock/cmd/api/main.go new file mode 100644 index 00000000..40334fe4 --- /dev/null +++ b/examples/stock/cmd/api/main.go @@ -0,0 +1,17 @@ +// Code generated by builder; DO NOT EDIT. + +// Command api runs app api: the doors builder generates in +// github.com/xgodev/boost/examples/stock/gen/builder/api. +package main + +import ( + "github.com/xgodev/boost" + app "github.com/xgodev/boost/examples/stock/gen/builder/api" + fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" +) + +func main() { + boost.Start() + + fxfact.NewApp(app.Modules()...).Run() +} diff --git a/examples/stock/cmd/api/main_test.go b/examples/stock/cmd/api/main_test.go new file mode 100644 index 00000000..27b1b683 --- /dev/null +++ b/examples/stock/cmd/api/main_test.go @@ -0,0 +1,449 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/modelcontextprotocol/go-sdk/mcp" + natsserver "github.com/nats-io/nats-server/v2/test" + natsgo "github.com/nats-io/nats.go" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + stockv1 "github.com/xgodev/boost/examples/stock/api/proto/stock/v1" + "github.com/xgodev/boost/examples/stock/gen/builder/api" + sqlclient "github.com/xgodev/boost/examples/stock/gen/client/database/sql" + mongoclient "github.com/xgodev/boost/examples/stock/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver" + mongofact "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + grpcserver "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server" + boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +// TestMain boots boost: the servers read their config. +func TestMain(m *testing.M) { + boost.Start() + os.Exit(m.Run()) +} + +func serve(srv *boostecho.Server, method, target, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + srv.Instance().ServeHTTP(rec, req) + return rec +} + +// TestAPI builds app api from the generated Modules() once (the modules +// register themselves once per process) and calls each door without +// listening on a port. Each door works on its own items of the one memory +// stock: rest on tshirt and cap, grpc on pen, mcp on mug. +func TestAPI(t *testing.T) { + var ( + rest *boostecho.Server + grpcSrv *grpcserver.Server + mcpSrv *mcpserver.Server + reserve reserver.Service + ) + app := fx.New(append(api.Modules(), fx.NopLogger, fx.Populate(&rest, &grpcSrv, &mcpSrv, &reserve))...) + require.NoError(t, app.Err()) + + t.Run("rest creates, finds and reserves", func(t *testing.T) { + got := serve(rest, http.MethodGet, "/stocks/tshirt", "") + assert.Equal(t, http.StatusOK, got.Code) + assert.JSONEq(t, `{"sku":"tshirt","units":3}`, got.Body.String(), "the memory stock starts with its seed") + + created := serve(rest, http.MethodPost, "/stocks", `{"sku":"cap","units":1}`) + assert.Equal(t, http.StatusCreated, created.Code) + assert.JSONEq(t, `{"sku":"cap","units":1}`, created.Body.String()) + + reserved := serve(rest, http.MethodPost, "/stocks/cap/reserve", "") + assert.Equal(t, http.StatusOK, reserved.Code) + assert.JSONEq(t, `{"sku":"cap","units":0}`, reserved.Body.String(), "the reservation answers what is left") + }) + + t.Run("rest answers each error kind with its status", func(t *testing.T) { + for _, c := range []struct { + name, method, target, body string + status int + }{ + {"unknown sku", http.MethodGet, "/stocks/nope", "", http.StatusNotFound}, + {"blank sku", http.MethodPost, "/stocks", `{"sku":"","units":1}`, http.StatusBadRequest}, + {"sku that is not a sku, created", http.MethodPost, "/stocks", `{"sku":"$gt","units":1}`, http.StatusBadRequest}, + {"sku that is not a sku, found", http.MethodGet, "/stocks/$gt", "", http.StatusBadRequest}, + {"sku that is not a sku, reserved", http.MethodPost, "/stocks/$ne/reserve", "", http.StatusBadRequest}, + {"sku in upper case", http.MethodGet, "/stocks/TSHIRT", "", http.StatusBadRequest}, + {"sku with a space", http.MethodGet, "/stocks/t%20shirt", "", http.StatusBadRequest}, + {"sku longer than 64", http.MethodGet, "/stocks/" + strings.Repeat("a", 65), "", http.StatusBadRequest}, + {"negative units", http.MethodPost, "/stocks", `{"sku":"hat","units":-1}`, http.StatusBadRequest}, + {"body that is not json", http.MethodPost, "/stocks", `{`, http.StatusBadRequest}, + {"sku already in stock", http.MethodPost, "/stocks", `{"sku":"tshirt","units":1}`, http.StatusConflict}, + {"sold out", http.MethodPost, "/stocks/cap/reserve", "", http.StatusConflict}, + } { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.status, serve(rest, c.method, c.target, c.body).Code) + }) + } + }) + + t.Run("rest says once that an unknown sku is not in stock", func(t *testing.T) { + var body struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(serve(rest, http.MethodGet, "/stocks/nope", "").Body.Bytes(), &body)) + assert.Equal(t, "nope is not in stock", body.Message) + }) + + t.Run("rest runs the plugins its @Server lists", func(t *testing.T) { + assert.Equal(t, http.StatusOK, serve(rest, http.MethodGet, "/health", "").Code) + assert.Equal(t, http.StatusOK, serve(rest, http.MethodGet, "/metrics", "").Code) + assert.NotEmpty(t, serve(rest, http.MethodGet, "/stocks/tshirt", "").Header().Get(echo.HeaderXRequestID), "requestId") + }) + + t.Run("the services run in their middlewares", func(t *testing.T) { + assert.Contains(t, reflect.TypeOf(reserve).Elem().PkgPath(), "gen/builder/middleware/", + "every consumer receives the decorator builder generates for @Middleware") + assert.GreaterOrEqual(t, opsTotal(t, "reserver.service.reserve"), 1.0, "the prometheus middleware counted the reservations") + assert.GreaterOrEqual(t, opsTotal(t, "finder.service.find"), 1.0) + assert.GreaterOrEqual(t, opsTotal(t, "creator.service.create"), 1.0) + }) + + t.Run("grpc finds and reserves", func(t *testing.T) { + require.Equal(t, http.StatusCreated, serve(rest, http.MethodPost, "/stocks", `{"sku":"pen","units":1}`).Code) + + lis := bufconn.Listen(1 << 20) + go func() { _ = grpcSrv.Server().Serve(lis) }() + t.Cleanup(grpcSrv.Server().Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + ctx := context.Background() + found, err := stockv1.NewStockFinderClient(conn).Find(ctx, &stockv1.FindRequest{Sku: "pen"}) + require.NoError(t, err) + assert.Equal(t, int64(1), found.GetUnits()) + + left, err := stockv1.NewStockReserverClient(conn).Reserve(ctx, &stockv1.ReserveRequest{Sku: "pen"}) + require.NoError(t, err) + assert.Equal(t, int64(0), left.GetUnits()) + + _, err = stockv1.NewStockReserverClient(conn).Reserve(ctx, &stockv1.ReserveRequest{Sku: "pen"}) + assert.Equal(t, codes.Aborted, status.Code(err), "a sold out item is a conflict: Aborted") + + _, err = stockv1.NewStockFinderClient(conn).Find(ctx, &stockv1.FindRequest{Sku: "nope"}) + assert.Equal(t, codes.NotFound, status.Code(err)) + assert.Equal(t, "nope is not in stock", status.Convert(err).Message()) + + _, err = stockv1.NewStockFinderClient(conn).Find(ctx, &stockv1.FindRequest{Sku: "$gt"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err), "a sku outside [a-z0-9-] never reaches the stock") + _, err = stockv1.NewStockReserverClient(conn).Reserve(ctx, &stockv1.ReserveRequest{Sku: "$ne"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + + t.Run("mcp serves the find_stock tool", func(t *testing.T) { + ctx := context.Background() + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := mcpSrv.Instance().Connect(ctx, serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + session, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1.0.0"}, nil).Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + tools, err := session.ListTools(ctx, nil) + require.NoError(t, err) + require.Len(t, tools.Tools, 1) + assert.Equal(t, "find_stock", tools.Tools[0].Name) + assert.Equal(t, "Tells how many units of an item are in stock.", tools.Tools[0].Description, "the description is the handler's doc comment") + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "find_stock", Arguments: map[string]any{"sku": "mug"}}) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Equal(t, map[string]any{"sku": "mug", "units": 1.0}, res.StructuredContent) + + res, err = session.CallTool(ctx, &mcp.CallToolParams{Name: "find_stock", Arguments: map[string]any{"sku": "nope"}}) + require.NoError(t, err) + assert.True(t, res.IsError, "an unknown sku is a tool error") + require.Len(t, res.Content, 1) + assert.Equal(t, "nope is not in stock", res.Content[0].(*mcp.TextContent).Text) + + res, err = session.CallTool(ctx, &mcp.CallToolParams{Name: "find_stock", Arguments: map[string]any{"sku": "$gt"}}) + require.NoError(t, err) + assert.True(t, res.IsError, "a sku outside [a-z0-9-] is a tool error") + require.Len(t, res.Content, 1) + assert.Equal(t, "$gt is not a sku: use 1 to 64 of a-z, 0-9 and -", res.Content[0].(*mcp.TextContent).Text) + }) +} + +const childEnv = "STOCK_EXAMPLE_CHILD" + +// child runs test in a new process with env set: config is read once per +// process. +func child(t *testing.T, test string, env ...string) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^"+test+"$", "-test.v") + cmd.Env = append(append(os.Environ(), childEnv+"=1"), env...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", out) + assert.Contains(t, string(out), "--- PASS: "+test) +} + +// seedEnv sets the items the memory stock starts with +// (internal.pkg.datastore.stock.memory.seed, from its @Config Options). +const seedEnv = "INTERNAL_PKG_DATASTORE_STOCK_MEMORY_SEED" + +// TestAPIConfig boots app api with the seed of the memory stock set from the +// environment. +func TestAPIConfig(t *testing.T) { + child(t, "TestAPIConfigChild", seedEnv+"=hat=5") +} + +// TestAPIConfigChild runs in the process TestAPIConfig starts. +func TestAPIConfigChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPIConfig") + } + + var rest *boostecho.Server + app := fx.New(append(api.Modules(), fx.NopLogger, fx.Populate(&rest))...) + require.NoError(t, app.Err()) + + assert.JSONEq(t, `{"sku":"hat","units":5}`, serve(rest, http.MethodGet, "/stocks/hat", "").Body.String()) + assert.Equal(t, http.StatusNotFound, serve(rest, http.MethodGet, "/stocks/tshirt", "").Code, "the env replaces the default seed") +} + +// metricsEnv turns off the prometheus plugin the @Server of the rest server +// lists (boost.factory.echo.plugins.prometheus.enabled). +const metricsEnv = "BOOST_FACTORY_ECHO_PLUGINS_PROMETHEUS_ENABLED" + +// TestAPIPluginOff boots app api with config turning off one plugin of its +// @Server: the others stay. +func TestAPIPluginOff(t *testing.T) { + child(t, "TestAPIPluginOffChild", metricsEnv+"=false") +} + +// TestAPIPluginOffChild runs in the process TestAPIPluginOff starts. +func TestAPIPluginOffChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPIPluginOff") + } + + var rest *boostecho.Server + app := fx.New(append(api.Modules(), fx.NopLogger, fx.Populate(&rest))...) + require.NoError(t, app.Err()) + + assert.Equal(t, http.StatusNotFound, serve(rest, http.MethodGet, "/metrics", "").Code, "prometheus is off") + assert.Equal(t, http.StatusOK, serve(rest, http.MethodGet, "/health", "").Code, "health stays") +} + +const ( + // driverEnv chooses the driver of the publisher that announces the + // reservations (boost.wrapper.publisher.driver.name, noop by default). + driverEnv = "BOOST_WRAPPER_PUBLISHER_DRIVER_NAME" + natsURLEnv = "BOOST_FACTORY_NATS_URL" +) + +// TestAPIPublisher boots app api with the nats driver on a NATS server this +// test runs: a reservation is announced as a stock.reserved CloudEvent. +func TestAPIPublisher(t *testing.T) { + opts := natsserver.DefaultTestOptions + opts.Port = -1 + srv := natsserver.RunServer(&opts) + t.Cleanup(srv.Shutdown) + + child(t, "TestAPIPublisherChild", driverEnv+"=nats", natsURLEnv+"="+srv.ClientURL()) +} + +// TestAPIPublisherChild runs in the process TestAPIPublisher starts. +func TestAPIPublisherChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPIPublisher") + } + + conn, err := natsgo.Connect(os.Getenv(natsURLEnv)) + require.NoError(t, err) + t.Cleanup(conn.Close) + sub, err := conn.SubscribeSync("stock.reserved") + require.NoError(t, err) + + var rest *boostecho.Server + app := fx.New(append(api.Modules(), fx.NopLogger, fx.Populate(&rest))...) + require.NoError(t, app.Err()) + require.Equal(t, http.StatusOK, serve(rest, http.MethodPost, "/stocks/tshirt/reserve", "").Code) + + msg, err := sub.NextMsg(5 * time.Second) + require.NoError(t, err, "the reservation was not announced on stock.reserved") + var event struct { + Type string `json:"type"` + Data struct { + SKU string `json:"sku"` + Units int64 `json:"units"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(msg.Data, &event), "%s", msg.Data) + assert.Equal(t, "stock.reserved", event.Type) + assert.Equal(t, "tshirt", event.Data.SKU) + assert.Equal(t, int64(2), event.Data.Units) +} + +const ( + // repositoryEnv chooses the implementation of the stock repository + // (internal.pkg.domain.repository.stock.implementation, memory by default). + repositoryEnv = "INTERNAL_PKG_DOMAIN_REPOSITORY_STOCK_IMPLEMENTATION" + mongoURIEnv = "BOOST_FACTORY_MONGO_URI" + oracleEnv = "BOOST_FACTORY_GODROR_CONNECTSTRING" + oracleTable = "INTERNAL_PKG_DATASTORE_STOCK_ORACLE_TABLE" +) + +// TestAPIMongo boots app api on the mongodb stock. It needs a MongoDB: +// BOOST_FACTORY_MONGO_URI=mongodb://localhost:27017/stock go test ./... +func TestAPIMongo(t *testing.T) { + if os.Getenv(mongoURIEnv) == "" { + t.Skip(mongoURIEnv + " is not set") + } + child(t, "TestAPIMongoChild", repositoryEnv+"=mongodb", + "INTERNAL_PKG_DATASTORE_STOCK_MONGODB_COLLECTION=stock_"+time.Now().Format("20060102150405.000000000")) + child(t, "TestAPIMongoStopChild") +} + +// TestAPIMongoStopChild runs in the process TestAPIMongo starts: the +// generated mongo client disconnects when the app stops. +func TestAPIMongoStopChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPIMongo") + } + + var conn *mongofact.Conn + app := fx.New(mongoclient.DefaultConnModule(), fx.NopLogger, fx.Populate(&conn)) + require.NoError(t, app.Err()) + ctx := context.Background() + require.NoError(t, app.Start(ctx)) + require.NoError(t, conn.Client.Ping(ctx, nil), "the client is connected while the app runs") + require.NoError(t, app.Stop(ctx)) + + assert.ErrorIs(t, conn.Client.Ping(ctx, nil), mongo.ErrClientDisconnected, "the app disconnects the client when it stops") +} + +// TestAPIMongoChild runs in the process TestAPIMongo starts, on a collection +// of its own. +func TestAPIMongoChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPIMongo") + } + + var rest *boostecho.Server + app := fx.New(append(api.Modules(), fx.NopLogger, fx.Populate(&rest))...) + require.NoError(t, app.Err()) + + assert.Equal(t, http.StatusNotFound, serve(rest, http.MethodGet, "/stocks/cap", "").Code) + assert.Equal(t, http.StatusCreated, serve(rest, http.MethodPost, "/stocks", `{"sku":"cap","units":1}`).Code) + assert.Equal(t, http.StatusConflict, serve(rest, http.MethodPost, "/stocks", `{"sku":"cap","units":1}`).Code) + assert.JSONEq(t, `{"sku":"cap","units":0}`, serve(rest, http.MethodPost, "/stocks/cap/reserve", "").Body.String()) + assert.Equal(t, http.StatusConflict, serve(rest, http.MethodPost, "/stocks/cap/reserve", "").Code) + assert.Equal(t, http.StatusNotFound, serve(rest, http.MethodPost, "/stocks/nope/reserve", "").Code) +} + +// TestAPIOracle boots app api on the oracle stock. It needs an Oracle +// database and its client libraries: +// BOOST_FACTORY_GODROR_CONNECTSTRING=localhost:1521/FREEPDB1 BOOST_FACTORY_GODROR_USERNAME=stock BOOST_FACTORY_GODROR_PASSWORD=stock go test ./... +func TestAPIOracle(t *testing.T) { + if os.Getenv(oracleEnv) == "" { + t.Skip(oracleEnv + " is not set") + } + child(t, "TestAPIOracleChild", repositoryEnv+"=oracle", oracleTable+"=stock_"+time.Now().Format("20060102150405")) + child(t, "TestAPIOracleStopChild") +} + +// TestAPIOracleStopChild runs in the process TestAPIOracle starts: the +// generated *sql.DB is closed when the app stops. +func TestAPIOracleStopChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPIOracle") + } + + var db *sql.DB + app := fx.New(sqlclient.DefaultDBModule(), fx.NopLogger, fx.Populate(&db)) + require.NoError(t, app.Err()) + ctx := context.Background() + require.NoError(t, app.Start(ctx)) + require.NoError(t, db.PingContext(ctx), "the db is open while the app runs") + require.NoError(t, app.Stop(ctx)) + + assert.ErrorContains(t, db.PingContext(ctx), "sql: database is closed", "the app closes the db when it stops") +} + +// TestAPIOracleChild runs in the process TestAPIOracle starts, on a table of +// its own that it creates and drops. +func TestAPIOracleChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestAPIOracle") + } + + var ( + rest *boostecho.Server + db *sql.DB + ) + app := fx.New(append(api.Modules(), fx.NopLogger, fx.Populate(&rest, &db))...) + require.NoError(t, app.Err()) + + ctx := context.Background() + table := os.Getenv(oracleTable) + _, err := db.ExecContext(ctx, "CREATE TABLE "+table+" (sku VARCHAR2(64) PRIMARY KEY, units NUMBER(19) NOT NULL)") + require.NoError(t, err) + t.Cleanup(func() { + _, err := db.ExecContext(ctx, "DROP TABLE "+table+" PURGE") + assert.NoError(t, err) + }) + + assert.Equal(t, http.StatusNotFound, serve(rest, http.MethodGet, "/stocks/cap", "").Code) + assert.Equal(t, http.StatusCreated, serve(rest, http.MethodPost, "/stocks", `{"sku":"cap","units":1}`).Code) + assert.Equal(t, http.StatusConflict, serve(rest, http.MethodPost, "/stocks", `{"sku":"cap","units":1}`).Code) + assert.JSONEq(t, `{"sku":"cap","units":0}`, serve(rest, http.MethodPost, "/stocks/cap/reserve", "").Body.String()) + assert.Equal(t, http.StatusConflict, serve(rest, http.MethodPost, "/stocks/cap/reserve", "").Code) + assert.Equal(t, http.StatusNotFound, serve(rest, http.MethodPost, "/stocks/nope/reserve", "").Code) +} + +// opsTotal returns the ops_total the prometheus middleware counted for the +// wrapper name. +func opsTotal(t *testing.T, name string) float64 { + t.Helper() + families, err := prometheus.DefaultGatherer.Gather() + require.NoError(t, err) + for _, family := range families { + if family.GetName() != "ops_total" { + continue + } + for _, metric := range family.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "name" && label.GetValue() == name { + return metric.GetCounter().GetValue() + } + } + } + } + return 0 +} diff --git a/examples/stock/cmd/stock-check/main.go b/examples/stock/cmd/stock-check/main.go new file mode 100644 index 00000000..95a4d70c --- /dev/null +++ b/examples/stock/cmd/stock-check/main.go @@ -0,0 +1,17 @@ +// Code generated by builder; DO NOT EDIT. + +// Command stock-check runs app stock-check: the doors builder generates in +// github.com/xgodev/boost/examples/stock/gen/builder/stock-check. +package main + +import ( + "github.com/xgodev/boost" + app "github.com/xgodev/boost/examples/stock/gen/builder/stock-check" + fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" +) + +func main() { + boost.Start() + + fxfact.NewApp(app.Modules()...).Run() +} diff --git a/examples/stock/cmd/stock-check/main_test.go b/examples/stock/cmd/stock-check/main_test.go new file mode 100644 index 00000000..27b50b10 --- /dev/null +++ b/examples/stock/cmd/stock-check/main_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "os" + "os/exec" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/bootstrap/job" + stockcheck "github.com/xgodev/boost/examples/stock/gen/builder/stock-check" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder" + "go.uber.org/fx" +) + +// TestMain boots boost: the stock and the check read their config. +func TestMain(m *testing.M) { + boost.Start() + os.Exit(m.Run()) +} + +// TestStockCheck builds app stock-check from the generated Modules() and +// runs the job handler it provides, as the job module does once on start. +func TestStockCheck(t *testing.T) { + var ( + check job.Handler + svc finder.Service + ) + app := fx.New(append(stockcheck.Modules(), fx.NopLogger, fx.Populate(&check, &svc))...) + require.NoError(t, app.Err()) + + assert.Contains(t, reflect.TypeOf(svc).Elem().PkgPath(), "/gen/builder/middleware/", + "the check gets the finder wrapped in the middlewares of @Middleware") + require.NoError(t, check(context.Background()), "the memory stock has tshirt and mug") +} + +const ( + // mainEnv makes the process the TestMain_* tests start run the generated + // main instead of the tests. + mainEnv = "STOCK_EXAMPLE_MAIN" + // skusEnv sets the skus the check looks at + // (internal.app.stockcheck.job.stock.checker.skus). + skusEnv = "INTERNAL_APP_STOCKCHECK_JOB_STOCK_CHECKER_SKUS" +) + +// TestMain_ExitsZeroWhenTheCheckPasses runs the generated main of app +// stock-check on the seeded memory stock: the check returns nil and the +// process exits 0 by itself. +func TestMain_ExitsZeroWhenTheCheckPasses(t *testing.T) { + out, err := runMain(t) + + require.NoError(t, err, "the check did not exit 0: %s", out) + assert.Contains(t, out, "tshirt has 3 units") + assert.Contains(t, out, "mug has 1 units") +} + +// TestMain_ExitsOneWhenTheCheckFails runs the generated main of app +// stock-check on a sku the stock does not have: the check returns the +// NotFound, which the app logs before exiting 1. +func TestMain_ExitsOneWhenTheCheckFails(t *testing.T) { + out, err := runMain(t, skusEnv+"=tshirt,cap") + + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, "the check must fail: %s", out) + assert.Equal(t, 1, exit.ExitCode()) + assert.Contains(t, out, "job failed: checking cap") +} + +// runMain runs the generated main of app stock-check with env and returns +// its output and exit, failing the test if it does not exit by itself. +func runMain(t *testing.T, env ...string) (string, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(append(os.Environ(), env...), mainEnv+"=1") + out, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "the check did not exit by itself: %s", out) + return string(out), err +} + +// TestMainChild runs the generated main, with no argument as a deployed +// binary gets, in the process the TestMain_* tests start. +func TestMainChild(t *testing.T) { + if os.Getenv(mainEnv) != "1" { + t.Skip("run by the TestMain_* tests") + } + os.Args = os.Args[:1] + main() +} diff --git a/builder/examples/simple/cmd/worker/main.go b/examples/stock/cmd/worker/main.go similarity index 67% rename from builder/examples/simple/cmd/worker/main.go rename to examples/stock/cmd/worker/main.go index 943e86f0..09d89e9c 100644 --- a/builder/examples/simple/cmd/worker/main.go +++ b/examples/stock/cmd/worker/main.go @@ -1,12 +1,12 @@ // Code generated by builder; DO NOT EDIT. // Command worker runs app worker: the doors builder generates in -// github.com/xgodev/boost/builder/examples/simple/gen/builder/worker. +// github.com/xgodev/boost/examples/stock/gen/builder/worker. package main import ( "github.com/xgodev/boost" - app "github.com/xgodev/boost/builder/examples/simple/gen/builder/worker" + app "github.com/xgodev/boost/examples/stock/gen/builder/worker" fxfact "github.com/xgodev/boost/factory/contrib/go.uber.org/fx/v1" ) diff --git a/examples/stock/cmd/worker/main_test.go b/examples/stock/cmd/worker/main_test.go new file mode 100644 index 00000000..bc2486af --- /dev/null +++ b/examples/stock/cmd/worker/main_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "bytes" + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/bootstrap/function" + cehttp "github.com/xgodev/boost/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http" + "github.com/xgodev/boost/bootstrap/function/middleware/logger" + "github.com/xgodev/boost/bootstrap/function/middleware/recovery" + "github.com/xgodev/boost/examples/stock/gen/builder/worker" + "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" + "github.com/xgodev/boost/extra/middleware" + functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" + "github.com/xgodev/boost/model/errors" + "go.uber.org/fx" +) + +// TestMain boots boost: the function and its adapter read their config. +func TestMain(m *testing.M) { + boost.Start() + os.Exit(m.Run()) +} + +func event(t *testing.T, data any) cloudevents.Event { + t.Helper() + in := cloudevents.NewEvent() + in.SetID("evt-1") + in.SetType("order.created") + in.SetSource("test") + require.NoError(t, in.SetData(cloudevents.ApplicationJSON, data)) + return in +} + +// TestWorker builds app worker from the generated Modules() and calls the +// function handler it provides, as the adapter does for each event. The +// reserver is local: it takes from the memory stock. +func TestWorker(t *testing.T) { + var ( + handle function.Handler[[]*cloudevents.Event] + repo stock.Repository + middlewares []middleware.AnyErrorMiddleware[[]*cloudevents.Event] + ) + app := fx.New(append(worker.Modules(), fx.NopLogger, fx.Populate(&handle, &repo), + fx.Invoke(fx.Annotate( + func(m []middleware.AnyErrorMiddleware[[]*cloudevents.Event]) { middlewares = m }, + fx.ParamTags(`name:"`+functionfx.MiddlewaresName+`"`), + )), + )...) + require.NoError(t, app.Err()) + + require.Len(t, middlewares, 2, "the function runs the middlewares of @Function") + assert.IsType(t, &recovery.Recovery[[]*cloudevents.Event]{}, middlewares[0], "the outermost first") + assert.IsType(t, &logger.Logger[[]*cloudevents.Event]{}, middlewares[1]) + + ctx := context.Background() + out, err := handle(ctx, event(t, map[string]string{"sku": "tshirt"})) + require.NoError(t, err) + assert.Empty(t, out, "the reservation is announced by the publisher, not replied") + left, err := repo.Get(ctx, "tshirt") + require.NoError(t, err) + assert.Equal(t, int64(2), left.Units, "the handler took one tshirt from the stock of the app") + + _, err = handle(ctx, event(t, map[string]string{})) + assert.True(t, errors.IsNotValid(err), "an order with no sku: got %v", err) + _, err = handle(ctx, event(t, map[string]string{"sku": "nope"})) + assert.True(t, errors.IsNotFound(err), "an unknown sku: got %v", err) + _, err = handle(ctx, event(t, map[string]string{"sku": "mug"})) + require.NoError(t, err) + _, err = handle(ctx, event(t, map[string]string{"sku": "mug"})) + assert.True(t, errors.IsConflict(err), "the last mug is gone: got %v", err) +} + +const ( + childEnv = "STOCK_EXAMPLE_CHILD" + // implementationEnv chooses the implementation of reserver.Service + // (internal.pkg.domain.service.stock.reserver.implementation, local by + // default). + implementationEnv = "INTERNAL_PKG_DOMAIN_SERVICE_STOCK_RESERVER_IMPLEMENTATION" + // hostEnv points the stock client builder generates for the resty + // reserver at app api (boost.factory.resty.clients.stock.host). + hostEnv = "BOOST_FACTORY_RESTY_CLIENTS_STOCK_HOST" +) + +// child runs test in a new process with env set: config is read once per +// process. +func child(t *testing.T, test string, env ...string) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^"+test+"$", "-test.v") + cmd.Env = append(append(os.Environ(), childEnv+"=1"), env...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", out) + assert.Contains(t, string(out), "--- PASS: "+test) +} + +// TestWorkerResty boots app worker with the resty reserver, which reserves +// by calling app api: here a server this test runs in its place. +func TestWorkerResty(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method != http.MethodPost: + w.WriteHeader(http.StatusMethodNotAllowed) + case r.URL.Path == "/stocks/tshirt/reserve": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sku":"tshirt","units":2}`)) + case r.URL.Path == "/stocks/mug/reserve": + w.WriteHeader(http.StatusConflict) + case r.URL.Path == "/stocks/down/reserve": + w.WriteHeader(http.StatusBadGateway) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(api.Close) + + child(t, "TestWorkerRestyChild", implementationEnv+"=resty", hostEnv+"="+api.URL) +} + +// TestWorkerRestyChild runs in the process TestWorkerResty starts. +func TestWorkerRestyChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestWorkerResty") + } + + var handle function.Handler[[]*cloudevents.Event] + app := fx.New(append(worker.Modules(), fx.NopLogger, fx.Populate(&handle))...) + require.NoError(t, app.Err()) + + ctx := context.Background() + _, err := handle(ctx, event(t, map[string]string{"sku": "tshirt"})) + assert.NoError(t, err) + _, err = handle(ctx, event(t, map[string]string{"sku": "mug"})) + assert.True(t, errors.IsConflict(err), "409 from app api: got %v", err) + _, err = handle(ctx, event(t, map[string]string{"sku": "nope"})) + assert.True(t, errors.IsNotFound(err), "404 from app api: got %v", err) + _, err = handle(ctx, event(t, map[string]string{"sku": "down"})) + assert.True(t, errors.IsInternal(err), "any other status: got %v", err) +} + +// TestWorkerConfig boots the worker with configs/worker.yaml, the file that +// runs it next to app api. +func TestWorkerConfig(t *testing.T) { + child(t, "TestWorkerConfigChild", "CONF=../../configs/worker.yaml") +} + +// TestWorkerConfigChild runs in the process TestWorkerConfig starts. +func TestWorkerConfigChild(t *testing.T) { + if os.Getenv(childEnv) != "1" { + t.Skip("run by TestWorkerConfig") + } + + assert.Equal(t, 8081, cehttp.Port(), "the worker listens next to app api on 8080") + assert.Equal(t, "resty", reserver.ServiceImplementation(), "the worker reserves through app api") + + app := fx.New(append(worker.Modules(), fx.NopLogger)...) + require.NoError(t, app.Err()) +} + +// mainEnv makes the process TestMain_ReservesOverHTTP starts run the +// generated main instead of the tests. +const mainEnv = "STOCK_EXAMPLE_MAIN" + +// otelEnv turns off the otel plugin the @Server of the cloudevents adapter +// lists (boost.bootstrap.function.adapter.cloudevents.http.plugins.otel.enabled). +const otelEnv = "BOOST_BOOTSTRAP_FUNCTION_ADAPTER_CLOUDEVENTS_HTTP_PLUGINS_OTEL_ENABLED" + +// TestMain_ReservesOverHTTP runs the generated main of app worker on a free +// port and posts it an order.created CloudEvent until it reserves the item, +// with the plugins of its @Server and with config turning one off. +func TestMain_ReservesOverHTTP(t *testing.T) { + t.Run("with the otel plugin", func(t *testing.T) { reservesOverHTTP(t) }) + t.Run("with the otel plugin turned off", func(t *testing.T) { reservesOverHTTP(t, otelEnv+"=false") }) +} + +func reservesOverHTTP(t *testing.T, env ...string) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := lis.Addr().(*net.TCPAddr).Port + require.NoError(t, lis.Close()) + + out := &syncBuffer{} + cmd := exec.Command(os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(append(os.Environ(), mainEnv+"=1", "BOOST_BOOTSTRAP_FUNCTION_ADAPTER_CLOUDEVENTS_HTTP_PORT="+strconv.Itoa(port)), env...) + cmd.Stdout, cmd.Stderr = out, out + require.NoError(t, cmd.Start()) + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + post := func() { + req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+strconv.Itoa(port)+"/", strings.NewReader(`{"sku":"tshirt"}`)) + require.NoError(t, err) + req.Header.Set("Ce-Id", "evt-1") + req.Header.Set("Ce-Source", "test") + req.Header.Set("Ce-Type", "order.created") + req.Header.Set("Ce-Specversion", "1.0") + req.Header.Set("Content-Type", "application/json") + if res, err := http.DefaultClient.Do(req); err == nil { + _ = res.Body.Close() + } + } + + // the worker listens once it boots: post until it reserves + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + deadline := time.After(10 * time.Second) + for !strings.Contains(out.String(), "reserved one tshirt for event evt-1") { + select { + case <-tick.C: + post() + case err := <-exited: + t.Fatalf("the worker exited (%v) without reserving: %s", err, out.String()) + case <-deadline: + t.Fatalf("the worker never reserved: %s", out.String()) + } + } + assert.Contains(t, out.String(), "listening on :"+strconv.Itoa(port)+"/", "the worker logs the port it listens on") +} + +// syncBuffer is a bytes.Buffer the child process and the test share. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// TestMainChild runs the generated main, with no argument as a deployed +// binary gets, in the process TestMain_ReservesOverHTTP starts. +func TestMainChild(t *testing.T) { + if os.Getenv(mainEnv) != "1" { + t.Skip("run by TestMain_ReservesOverHTTP") + } + os.Args = os.Args[:1] + main() +} diff --git a/examples/stock/configs/api.yaml b/examples/stock/configs/api.yaml new file mode 100644 index 00000000..6743e8f5 --- /dev/null +++ b/examples/stock/configs/api.yaml @@ -0,0 +1,4 @@ +boost: + factory: + zerolog: + level: DEBUG diff --git a/examples/stock/configs/worker.yaml b/examples/stock/configs/worker.yaml new file mode 100644 index 00000000..360d3028 --- /dev/null +++ b/examples/stock/configs/worker.yaml @@ -0,0 +1,23 @@ +# app worker next to app api on one machine: its HTTP door on 8081, and its +# reservations made by calling app api (the resty reserver). +boost: + factory: + zerolog: + level: DEBUG + resty: + clients: + stock: + host: http://localhost:8080 + bootstrap: + function: + adapter: + cloudevents: + http: + port: 8081 +internal: + pkg: + domain: + service: + stock: + reserver: + implementation: resty diff --git a/examples/stock/doc.go b/examples/stock/doc.go new file mode 100644 index 00000000..45588608 --- /dev/null +++ b/examples/stock/doc.go @@ -0,0 +1,41 @@ +// Package stock is the complete example app of boost: a small stock with +// reservation, built from every boost resource it can use. It is a living +// example: a resource that lands in boost is added here in the same PR. +// +// App api serves the same three use cases (create, find, reserve) on REST +// (echo, :8080), gRPC (:9090, api/proto/stock/v1) and MCP (:8090/mcp, the +// find_stock tool). App worker reserves the item of each order.created +// CloudEvent it receives (HTTP on :8081 or NATS). A reservation is announced +// as a stock.reserved event on boost's publisher (noop by default). App +// stock-check is a job: it logs the units of the skus it is given and exits, +// 1 when a sku is not in stock. +// +// Defaults need no infra: the stock is in memory (seeded with tshirt=3 and +// mug=1) and the publisher is noop. Config chooses the rest: +// +// INTERNAL_PKG_DOMAIN_REPOSITORY_STOCK_IMPLEMENTATION=mongodb stock in MongoDB (boost.factory.mongo.*) +// INTERNAL_PKG_DOMAIN_REPOSITORY_STOCK_IMPLEMENTATION=oracle stock in Oracle (boost.factory.godror.*, needs cgo) +// INTERNAL_PKG_DOMAIN_SERVICE_STOCK_RESERVER_IMPLEMENTATION=resty reserve by calling app api +// BOOST_WRAPPER_PUBLISHER_DRIVER_NAME=nats announce on NATS (boost.factory.nats.*) +// WORKER_FUNCTION_ADAPTER=nats worker reads order.created from NATS +// +// Run, from this directory (in GoLand: a Go Build configuration on the +// package, working directory here, and the CONF variable): +// +// CONF=configs/api.yaml go run ./cmd/api +// CONF=configs/worker.yaml go run ./cmd/worker +// INTERNAL_APP_STOCKCHECK_JOB_STOCK_CHECKER_SKUS=tshirt,mug go run ./cmd/stock-check +// +// and call it (README.md shows what each call answers): +// +// curl localhost:8080/stocks/tshirt +// curl -XPOST localhost:8080/stocks -d '{"sku":"cap","units":2}' -H 'Content-Type: application/json' +// curl -XPOST localhost:8080/stocks/tshirt/reserve +// grpcurl -plaintext -d '{"sku":"mug"}' localhost:9090 stock.v1.StockReserver/Reserve +// curl localhost:8081 -H 'Ce-Id: 1' -H 'Ce-Source: shop' -H 'Ce-Type: order.created' -H 'Ce-Specversion: 1.0' -H 'Content-Type: application/json' -d '{"sku":"tshirt"}' +// +// Regenerate gen/ and the mains with go generate ./... (and +// api/proto/stock/v1 with the protoc line of api/proto/stock/v1/doc.go). +package stock + +//go:generate go run github.com/xgodev/boost/builder/cmd/builder diff --git a/examples/stock/gen/builder/api/grpc_module.go b/examples/stock/gen/builder/api/grpc_module.go new file mode 100644 index 00000000..4603c455 --- /dev/null +++ b/examples/stock/gen/builder/api/grpc_module.go @@ -0,0 +1,47 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "sync" + + "go.uber.org/fx" + + src_stockv1_435579d5 "github.com/xgodev/boost/examples/stock/api/proto/stock/v1" + gen_finder_e709eb60 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/grpc/handler/stock/finder" + gen_reserver_7a871829 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/grpc/handler/stock/reserver" + src_finder_e709eb60 "github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/finder" + src_reserver_7a871829 "github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/reserver" + grpcserver "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server" + grpcprometheus "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server/plugins/contrib/prometheus/client_golang/v1" + grpclog "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server/plugins/local/wrapper/log" + grpcfx "github.com/xgodev/boost/fx/modules/factory/contrib/google.golang.org/grpc/v1/server" +) + +var grpcOnce sync.Once + +// GrpcModule serves the gRPC services of server grpc. +// It returns an empty option after the first call. +func GrpcModule() fx.Option { + options := fx.Options() + + grpcOnce.Do(func() { + options = fx.Module("api.grpc", + grpcfx.Module(), + fx.Provide(func() []grpcserver.Plugin { + return []grpcserver.Plugin{ + grpclog.Register, + grpcprometheus.Register, + } + }), + gen_finder_e709eb60.NewModule(), + gen_reserver_7a871829.NewModule(), + fx.Invoke(func(srv *grpcserver.Server, s0 *src_finder_e709eb60.Handler, s1 *src_reserver_7a871829.Handler) { + src_stockv1_435579d5.RegisterStockFinderServer(srv.ServiceRegistrar(), s0) + src_stockv1_435579d5.RegisterStockReserverServer(srv.ServiceRegistrar(), s1) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/builder/api/mcp_module.go b/examples/stock/gen/builder/api/mcp_module.go new file mode 100644 index 00000000..925fdd2c --- /dev/null +++ b/examples/stock/gen/builder/api/mcp_module.go @@ -0,0 +1,41 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "sync" + + "go.uber.org/fx" + + mcp "github.com/modelcontextprotocol/go-sdk/mcp" + gen_finder_0fe506c4 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/mcp/handler/stock/finder" + src_finder_0fe506c4 "github.com/xgodev/boost/examples/stock/internal/app/api/mcp/handler/stock/finder" + mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" + mcpfx "github.com/xgodev/boost/fx/modules/factory/contrib/modelcontextprotocol/go-sdk/v1/server" +) + +var mcpOnce sync.Once + +// McpModule serves the MCP tools of server mcp. +// It returns an empty option after the first call. +func McpModule() fx.Option { + options := fx.Options() + + mcpOnce.Do(func() { + options = fx.Module("api.mcp", + mcpfx.Module(), + fx.Provide(func() []mcpserver.Plugin { + return []mcpserver.Plugin{ + mcplog.Register, + } + }), + gen_finder_0fe506c4.NewModule(), + fx.Invoke(func(srv *mcpserver.Server, h0 *src_finder_0fe506c4.Handler) { + mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "find_stock", Description: "Tells how many units of an item are in stock."}, h0.FindStock) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/builder/api/modules.go b/examples/stock/gen/builder/api/modules.go new file mode 100644 index 00000000..87fa9533 --- /dev/null +++ b/examples/stock/gen/builder/api/modules.go @@ -0,0 +1,27 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "go.uber.org/fx" + + mw_local_4242a485 "github.com/xgodev/boost/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/creator/local" + mw_local_74558838 "github.com/xgodev/boost/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/finder/local" + mw_local_ec263859 "github.com/xgodev/boost/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/reserver/local" + multiserverfx "github.com/xgodev/boost/fx/modules/extra/multiserver" +) + +// Modules returns the doors of app api and the multiserver that runs its servers: +// +// fxfact.NewApp(api.Modules()...).Run() +func Modules() []fx.Option { + return []fx.Option{ + GrpcModule(), + McpModule(), + RestModule(), + mw_local_4242a485.DecorateNew(), + mw_local_74558838.DecorateNew(), + mw_local_ec263859.DecorateNew(), + multiserverfx.Module(), + } +} diff --git a/examples/stock/gen/builder/api/rest_module.go b/examples/stock/gen/builder/api/rest_module.go new file mode 100644 index 00000000..1eb848be --- /dev/null +++ b/examples/stock/gen/builder/api/rest_module.go @@ -0,0 +1,58 @@ +// Code generated by builder; DO NOT EDIT. + +package api + +import ( + "sync" + + "go.uber.org/fx" + + gen_creator_34324f41 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/rest/handler/stock/creator" + gen_finder_773dc854 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/rest/handler/stock/finder" + gen_reserver_e8b7ca47 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/rest/handler/stock/reserver" + src_creator_34324f41 "github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/creator" + src_finder_773dc854 "github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/finder" + src_reserver_e8b7ca47 "github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/reserver" + boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoprometheus "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/contrib/prometheus/client_golang/v1" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" + echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" +) + +var restOnce sync.Once + +// RestModule serves the REST routes of server rest on echo. +// It returns an empty option after the first call. +func RestModule() fx.Option { + options := fx.Options() + + restOnce.Do(func() { + options = fx.Module("api.rest", + echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + echoprometheus.Register, + } + }), + gen_creator_34324f41.NewModule(), + gen_finder_773dc854.NewModule(), + gen_reserver_e8b7ca47.NewModule(), + fx.Invoke(func(srv *boostecho.Server, h0 *src_creator_34324f41.Handler, h1 *src_finder_773dc854.Handler, h2 *src_reserver_e8b7ca47.Handler) { + srv.Add("POST", "/stocks", h0.Handle) + srv.Add("GET", "/stocks/:sku", h1.Handle) + srv.Add("POST", "/stocks/:sku/reserve", h2.Handle) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/creator/local/new_middleware.go b/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/creator/local/new_middleware.go new file mode 100644 index 00000000..a40ea84a --- /dev/null +++ b/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/creator/local/new_middleware.go @@ -0,0 +1,60 @@ +// Code generated by builder; DO NOT EDIT. + +package local + +import ( + "go.uber.org/fx" + + context "context" + src_entity_452f402c "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + src_creator_03017c96 "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/creator" + middleware "github.com/xgodev/boost/extra/middleware" + fmiddleware "github.com/xgodev/boost/factory/local/extra/middleware" + prometheusmw "github.com/xgodev/boost/factory/local/extra/middleware/plugins/contrib/prometheus/client_golang/v1" + logmw "github.com/xgodev/boost/factory/local/extra/middleware/plugins/local/wrapper/log" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// DecorateNew decorates the creator.Service that local.New provides: +// every consumer in the app receives a newDecorator, which runs the middlewares +// log, prometheus around each method; config can disable each one. +func DecorateNew() fx.Option { + return fx.Options( + contextfx.Module(), + fx.Decorate(decorateNew), + ) +} + +// decorateNew builds the wrappers of every method of creator.Service once. +func decorateNew(ctx context.Context, next src_creator_03017c96.Service) (src_creator_03017c96.Service, error) { + d := &newDecorator{next: next} + var err error + + d.createWrapper, err = fmiddleware.NewErrorWrapperWithOptions(ctx, &fmiddleware.Options{Name: "creator.service.create"}, + logmw.NewError, + prometheusmw.NewError, + ) + if err != nil { + return nil, err + } + + return d, nil +} + +// newDecorator implements creator.Service: each method runs next's in its wrapper. +type newDecorator struct { + next src_creator_03017c96.Service + createWrapper *middleware.ErrorWrapper +} + +// Create runs next.Create in the middlewares of creator.service.create. +func (d *newDecorator) Create(ctx context.Context, a1 *src_entity_452f402c.Stock) error { + return d.createWrapper.Exec(ctx, "creator.service.create", + func(ctx context.Context) error { + return d.next.Create(ctx, a1) + }, + func(_ context.Context, err error) error { + return err + }, + ) +} diff --git a/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/finder/local/new_middleware.go b/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/finder/local/new_middleware.go new file mode 100644 index 00000000..8ba4e805 --- /dev/null +++ b/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/finder/local/new_middleware.go @@ -0,0 +1,60 @@ +// Code generated by builder; DO NOT EDIT. + +package local + +import ( + "go.uber.org/fx" + + context "context" + src_entity_452f402c "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + src_finder_0d138921 "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder" + middleware "github.com/xgodev/boost/extra/middleware" + fmiddleware "github.com/xgodev/boost/factory/local/extra/middleware" + prometheusmw "github.com/xgodev/boost/factory/local/extra/middleware/plugins/contrib/prometheus/client_golang/v1" + logmw "github.com/xgodev/boost/factory/local/extra/middleware/plugins/local/wrapper/log" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// DecorateNew decorates the finder.Service that local.New provides: +// every consumer in the app receives a newDecorator, which runs the middlewares +// log, prometheus around each method; config can disable each one. +func DecorateNew() fx.Option { + return fx.Options( + contextfx.Module(), + fx.Decorate(decorateNew), + ) +} + +// decorateNew builds the wrappers of every method of finder.Service once. +func decorateNew(ctx context.Context, next src_finder_0d138921.Service) (src_finder_0d138921.Service, error) { + d := &newDecorator{next: next} + var err error + + d.findWrapper, err = fmiddleware.NewAnyErrorWrapperWithOptions(ctx, &fmiddleware.Options{Name: "finder.service.find"}, + logmw.NewAnyError[*src_entity_452f402c.Stock], + prometheusmw.NewAnyError[*src_entity_452f402c.Stock], + ) + if err != nil { + return nil, err + } + + return d, nil +} + +// newDecorator implements finder.Service: each method runs next's in its wrapper. +type newDecorator struct { + next src_finder_0d138921.Service + findWrapper *middleware.AnyErrorWrapper[*src_entity_452f402c.Stock] +} + +// Find runs next.Find in the middlewares of finder.service.find. +func (d *newDecorator) Find(ctx context.Context, a1 string) (*src_entity_452f402c.Stock, error) { + return d.findWrapper.Exec(ctx, "finder.service.find", + func(ctx context.Context) (*src_entity_452f402c.Stock, error) { + return d.next.Find(ctx, a1) + }, + func(_ context.Context, r *src_entity_452f402c.Stock, err error) (*src_entity_452f402c.Stock, error) { + return r, err + }, + ) +} diff --git a/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/reserver/local/new_middleware.go b/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/reserver/local/new_middleware.go new file mode 100644 index 00000000..fcd77adb --- /dev/null +++ b/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/reserver/local/new_middleware.go @@ -0,0 +1,66 @@ +// Code generated by builder; DO NOT EDIT. + +package local + +import ( + "go.uber.org/fx" + + context "context" + impl_reserver_e3c15ac4 "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver" + src_entity_452f402c "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + src_reserver_0e328155 "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver" + middleware "github.com/xgodev/boost/extra/middleware" + fmiddleware "github.com/xgodev/boost/factory/local/extra/middleware" + prometheusmw "github.com/xgodev/boost/factory/local/extra/middleware/plugins/contrib/prometheus/client_golang/v1" + logmw "github.com/xgodev/boost/factory/local/extra/middleware/plugins/local/wrapper/log" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// DecorateNew decorates the reserver.Service that local.New provides: +// every consumer in the app receives a newDecorator, which runs the middlewares +// log, prometheus around each method; config can disable each one. +// It applies only when local is the chosen implementation. +func DecorateNew() fx.Option { + if impl_reserver_e3c15ac4.ServiceImplementation() != "local" { + return fx.Options() + } + + return fx.Options( + contextfx.Module(), + fx.Decorate(decorateNew), + ) +} + +// decorateNew builds the wrappers of every method of reserver.Service once. +func decorateNew(ctx context.Context, next src_reserver_0e328155.Service) (src_reserver_0e328155.Service, error) { + d := &newDecorator{next: next} + var err error + + d.reserveWrapper, err = fmiddleware.NewAnyErrorWrapperWithOptions(ctx, &fmiddleware.Options{Name: "reserver.service.reserve"}, + logmw.NewAnyError[*src_entity_452f402c.Stock], + prometheusmw.NewAnyError[*src_entity_452f402c.Stock], + ) + if err != nil { + return nil, err + } + + return d, nil +} + +// newDecorator implements reserver.Service: each method runs next's in its wrapper. +type newDecorator struct { + next src_reserver_0e328155.Service + reserveWrapper *middleware.AnyErrorWrapper[*src_entity_452f402c.Stock] +} + +// Reserve runs next.Reserve in the middlewares of reserver.service.reserve. +func (d *newDecorator) Reserve(ctx context.Context, a1 string) (*src_entity_452f402c.Stock, error) { + return d.reserveWrapper.Exec(ctx, "reserver.service.reserve", + func(ctx context.Context) (*src_entity_452f402c.Stock, error) { + return d.next.Reserve(ctx, a1) + }, + func(_ context.Context, r *src_entity_452f402c.Stock, err error) (*src_entity_452f402c.Stock, error) { + return r, err + }, + ) +} diff --git a/examples/stock/gen/builder/stock-check/job_module.go b/examples/stock/gen/builder/stock-check/job_module.go new file mode 100644 index 00000000..13920ce1 --- /dev/null +++ b/examples/stock/gen/builder/stock-check/job_module.go @@ -0,0 +1,34 @@ +// Code generated by builder; DO NOT EDIT. + +package stockcheck + +import ( + "sync" + + "go.uber.org/fx" + + bsjob "github.com/xgodev/boost/bootstrap/job" + gen_checker_67912e54 "github.com/xgodev/boost/examples/stock/gen/internal/app/stockcheck/job/stock/checker" + src_checker_67912e54 "github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker" + jobfx "github.com/xgodev/boost/fx/modules/bootstrap/job" +) + +var jobOnce sync.Once + +// JobModule runs the @Job handler once; the app exits when it returns. +// It returns an empty option after the first call. +func JobModule() fx.Option { + options := fx.Options() + + jobOnce.Do(func() { + options = fx.Module("stock-check.job", + gen_checker_67912e54.NewModule(), + fx.Provide(func(h0 *src_checker_67912e54.Handler) bsjob.Handler { + return h0.Check + }), + jobfx.Module(), + ) + }) + + return options +} diff --git a/examples/stock/gen/builder/stock-check/modules.go b/examples/stock/gen/builder/stock-check/modules.go new file mode 100644 index 00000000..97a5332a --- /dev/null +++ b/examples/stock/gen/builder/stock-check/modules.go @@ -0,0 +1,19 @@ +// Code generated by builder; DO NOT EDIT. + +package stockcheck + +import ( + "go.uber.org/fx" + + mw_local_74558838 "github.com/xgodev/boost/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/finder/local" +) + +// Modules returns the doors of app stock-check: +// +// fxfact.NewApp(stockcheck.Modules()...).Run() +func Modules() []fx.Option { + return []fx.Option{ + JobModule(), + mw_local_74558838.DecorateNew(), + } +} diff --git a/examples/stock/gen/builder/worker/function_module.go b/examples/stock/gen/builder/worker/function_module.go new file mode 100644 index 00000000..db80cfbd --- /dev/null +++ b/examples/stock/gen/builder/worker/function_module.go @@ -0,0 +1,96 @@ +// Code generated by builder; DO NOT EDIT. + +package worker + +import ( + "sync" + + "go.uber.org/fx" + + src_cloudevents_c136a3fd "github.com/cloudevents/sdk-go/v2" + bsfunction "github.com/xgodev/boost/bootstrap/function" + ceplugin "github.com/xgodev/boost/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http" + ceotel "github.com/xgodev/boost/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http/plugins/contrib/go.opentelemetry.io/contrib/v0" + loggermw "github.com/xgodev/boost/bootstrap/function/middleware/logger" + recoverymw "github.com/xgodev/boost/bootstrap/function/middleware/recovery" + client_nats_37c13606 "github.com/xgodev/boost/examples/stock/gen/client/github.com/nats-io/nats.go" + gen_reserver_9ef9326a "github.com/xgodev/boost/examples/stock/gen/internal/app/worker/cloudevents/handler/stock/reserver" + src_reserver_9ef9326a "github.com/xgodev/boost/examples/stock/internal/app/worker/cloudevents/handler/stock/reserver" + bsmiddleware "github.com/xgodev/boost/extra/middleware" + functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" + cloudeventsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/core/http" + natsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1" + loggerfx "github.com/xgodev/boost/fx/modules/bootstrap/function/middleware/logger" + recoveryfx "github.com/xgodev/boost/fx/modules/bootstrap/function/middleware/recovery" + errors "github.com/xgodev/boost/model/errors" + config "github.com/xgodev/boost/wrapper/config" +) + +// functionAdapterKey chooses the adapter of the @Function of app worker. +const functionAdapterKey = "worker.function.adapter" + +func init() { + config.Add(functionAdapterKey, "cloudevents", "adapter of the @Function of app worker: cloudevents, nats") +} + +var functionOnce sync.Once + +// FunctionModule runs the @Function handler with the adapter named by worker.function.adapter (cloudevents, nats; default cloudevents). +// It returns an empty option after the first call. +func FunctionModule() fx.Option { + options := fx.Options() + + functionOnce.Do(func() { + options = fx.Module("worker.function", + gen_reserver_9ef9326a.NewModule(), + fx.Provide(func(h0 *src_reserver_9ef9326a.Handler) bsfunction.Handler[[]*src_cloudevents_c136a3fd.Event] { + return h0.Handle + }), + recoveryfx.Module[[]*src_cloudevents_c136a3fd.Event](), + loggerfx.Module[[]*src_cloudevents_c136a3fd.Event](), + fx.Provide(fx.Annotate( + func(m0 *recoverymw.Recovery[[]*src_cloudevents_c136a3fd.Event], m1 *loggermw.Logger[[]*src_cloudevents_c136a3fd.Event]) []bsmiddleware.AnyErrorMiddleware[[]*src_cloudevents_c136a3fd.Event] { + // a middleware disabled in config is not provided + var m []bsmiddleware.AnyErrorMiddleware[[]*src_cloudevents_c136a3fd.Event] + if m0 != nil { + m = append(m, m0) + } + if m1 != nil { + m = append(m, m1) + } + return m + }, + fx.ParamTags(`optional:"true"`, `optional:"true"`), + fx.ResultTags(`name:"bootstrap.function.middlewares"`), + )), + functionfx.Module[[]*src_cloudevents_c136a3fd.Event](nil), + functionAdapterModule(), + ) + }) + + return options +} + +// functionAdapterModule wires the adapter named by worker.function.adapter +// and only that one: the others, and their clients, are never built. An +// unknown name fails the boot. +func functionAdapterModule() fx.Option { + switch name := config.String(functionAdapterKey); name { + case "cloudevents": + return fx.Options( + cloudeventsfx.Module[[]*src_cloudevents_c136a3fd.Event](), + fx.Provide(func() []ceplugin.Plugin { + return []ceplugin.Plugin{ + ceotel.Register, + } + }), + ) + case "nats": + return fx.Options( + natsfx.ModuleWithoutClient[[]*src_cloudevents_c136a3fd.Event](), + client_nats_37c13606.DefaultConnModule(), + ) + default: + return fx.Error(errors.NotValidf("%s: unknown adapter %q; valid: %s", functionAdapterKey, name, "cloudevents, nats")) + } +} diff --git a/builder/examples/simple/gen/builder/worker/modules.go b/examples/stock/gen/builder/worker/modules.go similarity index 60% rename from builder/examples/simple/gen/builder/worker/modules.go rename to examples/stock/gen/builder/worker/modules.go index 1cb7b1b1..fd17f331 100644 --- a/builder/examples/simple/gen/builder/worker/modules.go +++ b/examples/stock/gen/builder/worker/modules.go @@ -4,6 +4,8 @@ package worker import ( "go.uber.org/fx" + + mw_local_ec263859 "github.com/xgodev/boost/examples/stock/gen/builder/middleware/internal_/pkg/domain/service/stock/reserver/local" ) // Modules returns the doors of app worker: @@ -12,5 +14,6 @@ import ( func Modules() []fx.Option { return []fx.Option{ FunctionModule(), + mw_local_ec263859.DecorateNew(), } } diff --git a/examples/stock/gen/client/database/sql/default_db.go b/examples/stock/gen/client/database/sql/default_db.go new file mode 100644 index 00000000..e5e74db1 --- /dev/null +++ b/examples/stock/gen/client/database/sql/default_db.go @@ -0,0 +1,56 @@ +// Code generated by inject; DO NOT EDIT. + +package sql + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "database/sql" + factory "github.com/xgodev/boost/factory/contrib/godror/godror/v0" + healthplugin "github.com/xgodev/boost/factory/core/database/sql/plugins/local/health" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultDBRoot is the config root of the default client (*database/sql.DB); +// env BOOST_FACTORY_GODROR_*. +const defaultDBRoot = "boost.factory.godror" + +func init() { + factory.ConfigAdd(defaultDBRoot) + healthplugin.ConfigAdd(defaultDBRoot + ".plugins.health") +} + +var defaultDBOnce sync.Once + +// DefaultDBModule provides the default client (*database/sql.DB), built from the keys under +// boost.factory.godror with the plugins health, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultDBModule() fx.Option { + options := fx.Options() + + defaultDBOnce.Do(func() { + options = fx.Module("database/sql.DefaultDB", + contextfx.Module(), + fx.Provide(newDefaultDB), + ) + }) + + return options +} + +func newDefaultDB(ctx context.Context, lc fx.Lifecycle) (*client.DB, error) { + health, err := healthplugin.NewHealthPluginWithConfigPath(defaultDBRoot + ".plugins.health") + if err != nil { + return nil, err + } + c, err := factory.NewDBWithConfigPath(ctx, defaultDBRoot, health) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/examples/stock/gen/client/github.com/go-resty/resty/v2/stock_client.go b/examples/stock/gen/client/github.com/go-resty/resty/v2/stock_client.go new file mode 100644 index 00000000..2f32a2ba --- /dev/null +++ b/examples/stock/gen/client/github.com/go-resty/resty/v2/stock_client.go @@ -0,0 +1,63 @@ +// Code generated by inject; DO NOT EDIT. + +package resty + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "github.com/go-resty/resty/v2" + factory "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2" + prometheusplugin "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/plugins/contrib/prometheus/client_golang/v1" + requestidplugin "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/plugins/extra/requestid" + logplugin "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/plugins/local/wrapper/log" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// stockClientRoot is the config root of the stock client (*github.com/go-resty/resty/v2.Client); +// env BOOST_FACTORY_RESTY_CLIENTS_STOCK_*. +const stockClientRoot = "boost.factory.resty.clients.stock" + +func init() { + factory.ConfigAdd(stockClientRoot) + logplugin.ConfigAdd(stockClientRoot + ".plugins.log") + requestidplugin.ConfigAdd(stockClientRoot + ".plugins.requestid") + prometheusplugin.ConfigAdd(stockClientRoot + ".plugins.prometheus") +} + +var stockClientOnce sync.Once + +// StockClientModule provides the stock client (*github.com/go-resty/resty/v2.Client), built from the keys under +// boost.factory.resty.clients.stock with the plugins log, requestid, prometheus. +// It returns an empty option after the first call, so every consumer can +// include it. +func StockClientModule() fx.Option { + options := fx.Options() + + stockClientOnce.Do(func() { + options = fx.Module("github.com/go-resty/resty/v2.StockClient", + contextfx.Module(), + fx.Provide(fx.Annotate(newStockClient, fx.ResultTags(`name:"stock"`))), + ) + }) + + return options +} + +func newStockClient(ctx context.Context) (*client.Client, error) { + log, err := logplugin.NewLogWithConfigPath(stockClientRoot + ".plugins.log") + if err != nil { + return nil, err + } + requestid, err := requestidplugin.NewRequestIDWithConfigPath(stockClientRoot + ".plugins.requestid") + if err != nil { + return nil, err + } + prometheus, err := prometheusplugin.NewPrometheusWithConfigPath(stockClientRoot + ".plugins.prometheus") + if err != nil { + return nil, err + } + return factory.NewClientWithConfigPath(ctx, stockClientRoot, log.Register, requestid.Register, prometheus.Register) +} diff --git a/examples/stock/gen/client/github.com/nats-io/nats.go/default_conn.go b/examples/stock/gen/client/github.com/nats-io/nats.go/default_conn.go new file mode 100644 index 00000000..17bd8c80 --- /dev/null +++ b/examples/stock/gen/client/github.com/nats-io/nats.go/default_conn.go @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package nats + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "github.com/nats-io/nats.go" + factory "github.com/xgodev/boost/factory/contrib/nats-io/nats.go/v1" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultConnRoot is the config root of the default client (*github.com/nats-io/nats.go.Conn); +// env BOOST_FACTORY_NATS_*. +const defaultConnRoot = "boost.factory.nats" + +func init() { + factory.ConfigAdd(defaultConnRoot) +} + +var defaultConnOnce sync.Once + +// DefaultConnModule provides the default client (*github.com/nats-io/nats.go.Conn), built from the keys under +// boost.factory.nats without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultConnModule() fx.Option { + options := fx.Options() + + defaultConnOnce.Do(func() { + options = fx.Module("github.com/nats-io/nats.go.DefaultConn", + contextfx.Module(), + fx.Provide(newDefaultConn), + ) + }) + + return options +} + +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*client.Conn, error) { + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/examples/stock/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go b/examples/stock/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go new file mode 100644 index 00000000..1fa54098 --- /dev/null +++ b/examples/stock/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go @@ -0,0 +1,61 @@ +// Code generated by inject; DO NOT EDIT. + +package mongo + +import ( + "context" + "sync" + + "go.uber.org/fx" + + factory "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + prometheusplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/contrib/prometheus/client_golang/v1" + healthplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/local/health" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultConnRoot is the config root of the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn); +// env BOOST_FACTORY_MONGO_*. +const defaultConnRoot = "boost.factory.mongo" + +func init() { + factory.ConfigAdd(defaultConnRoot) + healthplugin.ConfigAdd(defaultConnRoot + ".plugins.health") + prometheusplugin.ConfigAdd(defaultConnRoot + ".plugins.prometheus") +} + +var defaultConnOnce sync.Once + +// DefaultConnModule provides the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn), built from the keys under +// boost.factory.mongo with the plugins health, prometheus, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultConnModule() fx.Option { + options := fx.Options() + + defaultConnOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.DefaultConn", + contextfx.Module(), + fx.Provide(newDefaultConn), + ) + }) + + return options +} + +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*factory.Conn, error) { + health, err := healthplugin.NewHealthWithConfigPath(defaultConnRoot + ".plugins.health") + if err != nil { + return nil, err + } + prometheus, err := prometheusplugin.NewPrometheusWithConfigPath(defaultConnRoot + ".plugins.prometheus") + if err != nil { + return nil, err + } + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot, health.Register, prometheus.Register) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/examples/stock/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go b/examples/stock/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go new file mode 100644 index 00000000..3819aa83 --- /dev/null +++ b/examples/stock/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go @@ -0,0 +1,62 @@ +// Code generated by inject; DO NOT EDIT. + +package publisher + +import ( + "sync" + + contextfx "github.com/xgodev/boost/fx/modules/core/context" + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/config" + wrapper "github.com/xgodev/boost/wrapper/publisher" + "go.uber.org/fx" + + client_nats_37c13606 "github.com/xgodev/boost/examples/stock/gen/client/github.com/nats-io/nats.go" + driver_nats_3a6a55c7 "github.com/xgodev/boost/wrapper/publisher/driver/contrib/nats-io/nats.go/v1" + driver_noop_f9e2c811 "github.com/xgodev/boost/wrapper/publisher/driver/extra/noop" +) + +// publisherDriverKey chooses the driver of the publisher. +const publisherDriverKey = "boost.wrapper.publisher.driver.name" + +func init() { + config.Add(publisherDriverKey, "noop", "driver of the publisher inject builds: nats, noop") +} + +// PublisherDriver returns the name of the driver PublisherModule wires. +func PublisherDriver() string { + return config.String(publisherDriverKey) +} + +var publisherOnce sync.Once + +// PublisherModule provides the *publisher.Publisher of +// github.com/xgodev/boost/wrapper/publisher with the driver named by +// boost.wrapper.publisher.driver.name (nats, noop; default noop) +// and only that driver and its client. An unknown name fails the boot. It +// returns an empty option after the first call, so every consumer can include +// it. +func PublisherModule() fx.Option { + options := fx.Options() + + publisherOnce.Do(func() { + switch name := PublisherDriver(); name { + case "nats": + options = publisherModule(client_nats_37c13606.DefaultConnModule(), fx.Provide(fx.Private, driver_nats_3a6a55c7.New)) + case "noop": + options = publisherModule(fx.Provide(fx.Private, driver_noop_f9e2c811.New)) + default: + options = fx.Error(errors.NotValidf("%s: unknown driver %q; valid: %s", publisherDriverKey, name, "nats, noop")) + } + }) + + return options +} + +// publisherModule provides the publisher with the options wiring its driver; +// the driver is private to the module. +func publisherModule(driver ...fx.Option) fx.Option { + return fx.Module("github.com/xgodev/boost/wrapper/publisher.Publisher", + append([]fx.Option{contextfx.Module(), fx.Provide(wrapper.New)}, driver...)..., + ) +} diff --git a/examples/stock/gen/implementation/internal_/pkg/domain/repository/stock/repository_implementation.go b/examples/stock/gen/implementation/internal_/pkg/domain/repository/stock/repository_implementation.go new file mode 100644 index 00000000..8c028753 --- /dev/null +++ b/examples/stock/gen/implementation/internal_/pkg/domain/repository/stock/repository_implementation.go @@ -0,0 +1,53 @@ +// Code generated by inject; DO NOT EDIT. + +package stock + +import ( + "sync" + + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + gen_memory_e61b92bf "github.com/xgodev/boost/examples/stock/gen/internal/pkg/datastore/stock/memory" + gen_mongodb_06b6559e "github.com/xgodev/boost/examples/stock/gen/internal/pkg/datastore/stock/mongodb" + gen_oracle_34cf544c "github.com/xgodev/boost/examples/stock/gen/internal/pkg/datastore/stock/oracle" +) + +// repositoryImplementationKey chooses the implementation of github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock.Repository. +const repositoryImplementationKey = "internal.pkg.domain.repository.stock.implementation" + +func init() { + config.Add(repositoryImplementationKey, "memory", "implementation of github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock.Repository: memory, mongodb, oracle") +} + +// RepositoryImplementation returns the name of the implementation of +// github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock.Repository that RepositoryImplementationModule wires. +func RepositoryImplementation() string { + return config.String(repositoryImplementationKey) +} + +var repositoryImplementationOnce sync.Once + +// RepositoryImplementationModule wires the implementation of github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock.Repository +// named by internal.pkg.domain.repository.stock.implementation (memory, mongodb, oracle; default memory) and only that one. +// An unknown name fails the boot. It returns an empty option after the first +// call, so every consumer can include it. +func RepositoryImplementationModule() fx.Option { + options := fx.Options() + + repositoryImplementationOnce.Do(func() { + switch name := RepositoryImplementation(); name { + case "memory": + options = gen_memory_e61b92bf.NewModule() + case "mongodb": + options = gen_mongodb_06b6559e.NewModule() + case "oracle": + options = gen_oracle_34cf544c.NewModule() + default: + options = fx.Error(errors.NotValidf("%s: unknown implementation %q; valid: %s", repositoryImplementationKey, name, "memory, mongodb, oracle")) + } + }) + + return options +} diff --git a/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver/service_implementation.go b/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver/service_implementation.go new file mode 100644 index 00000000..258d40ab --- /dev/null +++ b/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver/service_implementation.go @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package reserver + +import ( + "sync" + + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + gen_resty_d6b44066 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/client/stock/reserver/resty" + gen_local_2037e52c "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/reserver/local" +) + +// serviceImplementationKey chooses the implementation of github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver.Service. +const serviceImplementationKey = "internal.pkg.domain.service.stock.reserver.implementation" + +func init() { + config.Add(serviceImplementationKey, "local", "implementation of github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver.Service: local, resty") +} + +// ServiceImplementation returns the name of the implementation of +// github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver.Service that ServiceImplementationModule wires. +func ServiceImplementation() string { + return config.String(serviceImplementationKey) +} + +var serviceImplementationOnce sync.Once + +// ServiceImplementationModule wires the implementation of github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver.Service +// named by internal.pkg.domain.service.stock.reserver.implementation (local, resty; default local) and only that one. +// An unknown name fails the boot. It returns an empty option after the first +// call, so every consumer can include it. +func ServiceImplementationModule() fx.Option { + options := fx.Options() + + serviceImplementationOnce.Do(func() { + switch name := ServiceImplementation(); name { + case "local": + options = gen_local_2037e52c.NewModule() + case "resty": + options = gen_resty_d6b44066.NewModule() + default: + options = fx.Error(errors.NotValidf("%s: unknown implementation %q; valid: %s", serviceImplementationKey, name, "local, resty")) + } + }) + + return options +} diff --git a/examples/stock/gen/internal/app/api/grpc/handler/stock/finder/new_module.go b/examples/stock/gen/internal/app/api/grpc/handler/stock/finder/new_module.go new file mode 100644 index 00000000..15d1fd36 --- /dev/null +++ b/examples/stock/gen/internal/app/api/grpc/handler/stock/finder/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package finder + +import ( + "sync" + + "go.uber.org/fx" + + gen_local_ecb3acf2 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/finder/local" + src_finder_e709eb60 "github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/finder" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/finder.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/finder.New", + gen_local_ecb3acf2.NewModule(), + fx.Provide(src_finder_e709eb60.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/api/grpc/handler/stock/reserver/new_module.go b/examples/stock/gen/internal/app/api/grpc/handler/stock/reserver/new_module.go new file mode 100644 index 00000000..4de5971d --- /dev/null +++ b/examples/stock/gen/internal/app/api/grpc/handler/stock/reserver/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package reserver + +import ( + "sync" + + "go.uber.org/fx" + + impl_reserver_e3c15ac4 "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver" + src_reserver_7a871829 "github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/reserver" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/reserver.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/api/grpc/handler/stock/reserver.New", + impl_reserver_e3c15ac4.ServiceImplementationModule(), + fx.Provide(src_reserver_7a871829.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/api/mcp/handler/stock/finder/new_module.go b/examples/stock/gen/internal/app/api/mcp/handler/stock/finder/new_module.go new file mode 100644 index 00000000..9f5efd43 --- /dev/null +++ b/examples/stock/gen/internal/app/api/mcp/handler/stock/finder/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package finder + +import ( + "sync" + + "go.uber.org/fx" + + gen_local_ecb3acf2 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/finder/local" + src_finder_0fe506c4 "github.com/xgodev/boost/examples/stock/internal/app/api/mcp/handler/stock/finder" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/api/mcp/handler/stock/finder.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/api/mcp/handler/stock/finder.New", + gen_local_ecb3acf2.NewModule(), + fx.Provide(src_finder_0fe506c4.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/api/rest/handler/stock/creator/new_module.go b/examples/stock/gen/internal/app/api/rest/handler/stock/creator/new_module.go new file mode 100644 index 00000000..e38f9602 --- /dev/null +++ b/examples/stock/gen/internal/app/api/rest/handler/stock/creator/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package creator + +import ( + "sync" + + "go.uber.org/fx" + + gen_local_671ce8b7 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/creator/local" + src_creator_34324f41 "github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/creator" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/creator.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/creator.New", + gen_local_671ce8b7.NewModule(), + fx.Provide(src_creator_34324f41.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/api/rest/handler/stock/finder/new_module.go b/examples/stock/gen/internal/app/api/rest/handler/stock/finder/new_module.go new file mode 100644 index 00000000..7ec6745c --- /dev/null +++ b/examples/stock/gen/internal/app/api/rest/handler/stock/finder/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package finder + +import ( + "sync" + + "go.uber.org/fx" + + gen_local_ecb3acf2 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/finder/local" + src_finder_773dc854 "github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/finder" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/finder.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/finder.New", + gen_local_ecb3acf2.NewModule(), + fx.Provide(src_finder_773dc854.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/api/rest/handler/stock/reserver/new_module.go b/examples/stock/gen/internal/app/api/rest/handler/stock/reserver/new_module.go new file mode 100644 index 00000000..a4b52959 --- /dev/null +++ b/examples/stock/gen/internal/app/api/rest/handler/stock/reserver/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package reserver + +import ( + "sync" + + "go.uber.org/fx" + + impl_reserver_e3c15ac4 "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver" + src_reserver_e8b7ca47 "github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/reserver" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/reserver.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/api/rest/handler/stock/reserver.New", + impl_reserver_e3c15ac4.ServiceImplementationModule(), + fx.Provide(src_reserver_e8b7ca47.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/stockcheck/job/stock/checker/new_module.go b/examples/stock/gen/internal/app/stockcheck/job/stock/checker/new_module.go new file mode 100644 index 00000000..7f7d9228 --- /dev/null +++ b/examples/stock/gen/internal/app/stockcheck/job/stock/checker/new_module.go @@ -0,0 +1,31 @@ +// Code generated by inject; DO NOT EDIT. + +package checker + +import ( + "sync" + + "go.uber.org/fx" + + gen_local_ecb3acf2 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/finder/local" + src_checker_67912e54 "github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker.New", + OptionsModule(), + gen_local_ecb3acf2.NewModule(), + fx.Provide(src_checker_67912e54.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/stockcheck/job/stock/checker/options_module.go b/examples/stock/gen/internal/app/stockcheck/job/stock/checker/options_module.go new file mode 100644 index 00000000..13ba00a4 --- /dev/null +++ b/examples/stock/gen/internal/app/stockcheck/job/stock/checker/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package checker + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_checker_67912e54 "github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker.Options. +const optionsRoot = "internal.app.stockcheck.job.stock.checker" + +func init() { + // env INTERNAL_APP_STOCKCHECK_JOB_STOCK_CHECKER_SKUS + config.Add(optionsRoot+".skus", []string{"tshirt", "mug"}, "skus the check looks at") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker, +// filled from the config keys under internal.app.stockcheck.job.stock.checker. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/stockcheck/job/stock/checker.Options", + fx.Provide(func() (*src_checker_67912e54.Options, error) { + return config.NewOptionsWithPath[src_checker_67912e54.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/app/worker/cloudevents/handler/stock/reserver/new_module.go b/examples/stock/gen/internal/app/worker/cloudevents/handler/stock/reserver/new_module.go new file mode 100644 index 00000000..157dd1e8 --- /dev/null +++ b/examples/stock/gen/internal/app/worker/cloudevents/handler/stock/reserver/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package reserver + +import ( + "sync" + + "go.uber.org/fx" + + impl_reserver_e3c15ac4 "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver" + src_reserver_9ef9326a "github.com/xgodev/boost/examples/stock/internal/app/worker/cloudevents/handler/stock/reserver" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/app/worker/cloudevents/handler/stock/reserver.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/app/worker/cloudevents/handler/stock/reserver.New", + impl_reserver_e3c15ac4.ServiceImplementationModule(), + fx.Provide(src_reserver_9ef9326a.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/client/stock/reserver/resty/new_module.go b/examples/stock/gen/internal/pkg/client/stock/reserver/resty/new_module.go new file mode 100644 index 00000000..b62f46ce --- /dev/null +++ b/examples/stock/gen/internal/pkg/client/stock/reserver/resty/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package resty + +import ( + "sync" + + "go.uber.org/fx" + + client_resty_4dadf93c "github.com/xgodev/boost/examples/stock/gen/client/github.com/go-resty/resty/v2" + src_resty_d6b44066 "github.com/xgodev/boost/examples/stock/internal/pkg/client/stock/reserver/resty" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/client/stock/reserver/resty.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/client/stock/reserver/resty.New", + client_resty_4dadf93c.StockClientModule(), + fx.Provide(fx.Annotate(src_resty_d6b44066.New, fx.ParamTags(`name:"stock"`))), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/datastore/stock/memory/new_module.go b/examples/stock/gen/internal/pkg/datastore/stock/memory/new_module.go new file mode 100644 index 00000000..ab076de3 --- /dev/null +++ b/examples/stock/gen/internal/pkg/datastore/stock/memory/new_module.go @@ -0,0 +1,29 @@ +// Code generated by inject; DO NOT EDIT. + +package memory + +import ( + "sync" + + "go.uber.org/fx" + + src_memory_e61b92bf "github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/memory" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/memory.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/memory.New", + OptionsModule(), + fx.Provide(src_memory_e61b92bf.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/datastore/stock/memory/options_module.go b/examples/stock/gen/internal/pkg/datastore/stock/memory/options_module.go new file mode 100644 index 00000000..f15a084d --- /dev/null +++ b/examples/stock/gen/internal/pkg/datastore/stock/memory/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package memory + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_memory_e61b92bf "github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/memory" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/memory.Options. +const optionsRoot = "internal.pkg.datastore.stock.memory" + +func init() { + // env INTERNAL_PKG_DATASTORE_STOCK_MEMORY_SEED + config.Add(optionsRoot+".seed", []string{"tshirt=3", "mug=1"}, "sku=units the memory stock starts with") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/memory, +// filled from the config keys under internal.pkg.datastore.stock.memory. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/memory.Options", + fx.Provide(func() (*src_memory_e61b92bf.Options, error) { + return config.NewOptionsWithPath[src_memory_e61b92bf.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/datastore/stock/mongodb/new_module.go b/examples/stock/gen/internal/pkg/datastore/stock/mongodb/new_module.go new file mode 100644 index 00000000..2bb37079 --- /dev/null +++ b/examples/stock/gen/internal/pkg/datastore/stock/mongodb/new_module.go @@ -0,0 +1,31 @@ +// Code generated by inject; DO NOT EDIT. + +package mongodb + +import ( + "sync" + + "go.uber.org/fx" + + client_mongo_c43e7bd8 "github.com/xgodev/boost/examples/stock/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + src_mongodb_06b6559e "github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/mongodb" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/mongodb.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/mongodb.New", + OptionsModule(), + client_mongo_c43e7bd8.DefaultConnModule(), + fx.Provide(src_mongodb_06b6559e.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/datastore/stock/mongodb/options_module.go b/examples/stock/gen/internal/pkg/datastore/stock/mongodb/options_module.go new file mode 100644 index 00000000..61f1b724 --- /dev/null +++ b/examples/stock/gen/internal/pkg/datastore/stock/mongodb/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package mongodb + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_mongodb_06b6559e "github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/mongodb" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/mongodb.Options. +const optionsRoot = "internal.pkg.datastore.stock.mongodb" + +func init() { + // env INTERNAL_PKG_DATASTORE_STOCK_MONGODB_COLLECTION + config.Add(optionsRoot+".collection", "stock", "collection of the units in stock") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/mongodb, +// filled from the config keys under internal.pkg.datastore.stock.mongodb. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/mongodb.Options", + fx.Provide(func() (*src_mongodb_06b6559e.Options, error) { + return config.NewOptionsWithPath[src_mongodb_06b6559e.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/datastore/stock/oracle/new_module.go b/examples/stock/gen/internal/pkg/datastore/stock/oracle/new_module.go new file mode 100644 index 00000000..899ddc4d --- /dev/null +++ b/examples/stock/gen/internal/pkg/datastore/stock/oracle/new_module.go @@ -0,0 +1,31 @@ +// Code generated by inject; DO NOT EDIT. + +package oracle + +import ( + "sync" + + "go.uber.org/fx" + + client_sql_45c63acd "github.com/xgodev/boost/examples/stock/gen/client/database/sql" + src_oracle_34cf544c "github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/oracle" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/oracle.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/oracle.New", + OptionsModule(), + client_sql_45c63acd.DefaultDBModule(), + fx.Provide(src_oracle_34cf544c.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/datastore/stock/oracle/options_module.go b/examples/stock/gen/internal/pkg/datastore/stock/oracle/options_module.go new file mode 100644 index 00000000..965462aa --- /dev/null +++ b/examples/stock/gen/internal/pkg/datastore/stock/oracle/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package oracle + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_oracle_34cf544c "github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/oracle" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/oracle.Options. +const optionsRoot = "internal.pkg.datastore.stock.oracle" + +func init() { + // env INTERNAL_PKG_DATASTORE_STOCK_ORACLE_TABLE + config.Add(optionsRoot+".table", "stock", "table of the units in stock: sku VARCHAR2 primary key, units NUMBER") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/oracle, +// filled from the config keys under internal.pkg.datastore.stock.oracle. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/datastore/stock/oracle.Options", + fx.Provide(func() (*src_oracle_34cf544c.Options, error) { + return config.NewOptionsWithPath[src_oracle_34cf544c.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/domain/service/stock/creator/local/new_module.go b/examples/stock/gen/internal/pkg/domain/service/stock/creator/local/new_module.go new file mode 100644 index 00000000..0e89ddd4 --- /dev/null +++ b/examples/stock/gen/internal/pkg/domain/service/stock/creator/local/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package local + +import ( + "sync" + + "go.uber.org/fx" + + impl_stock_3e1064df "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/repository/stock" + src_local_671ce8b7 "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/creator/local" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/creator/local.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/creator/local.New", + impl_stock_3e1064df.RepositoryImplementationModule(), + fx.Provide(src_local_671ce8b7.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/domain/service/stock/finder/local/new_module.go b/examples/stock/gen/internal/pkg/domain/service/stock/finder/local/new_module.go new file mode 100644 index 00000000..807b4944 --- /dev/null +++ b/examples/stock/gen/internal/pkg/domain/service/stock/finder/local/new_module.go @@ -0,0 +1,30 @@ +// Code generated by inject; DO NOT EDIT. + +package local + +import ( + "sync" + + "go.uber.org/fx" + + impl_stock_3e1064df "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/repository/stock" + src_local_ecb3acf2 "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder/local" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder/local.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder/local.New", + impl_stock_3e1064df.RepositoryImplementationModule(), + fx.Provide(src_local_ecb3acf2.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/domain/service/stock/reserver/local/new_module.go b/examples/stock/gen/internal/pkg/domain/service/stock/reserver/local/new_module.go new file mode 100644 index 00000000..e1c86b9a --- /dev/null +++ b/examples/stock/gen/internal/pkg/domain/service/stock/reserver/local/new_module.go @@ -0,0 +1,32 @@ +// Code generated by inject; DO NOT EDIT. + +package local + +import ( + "sync" + + "go.uber.org/fx" + + impl_stock_3e1064df "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/repository/stock" + gen_boost_d233c539 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost" + src_local_2037e52c "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver/local" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver/local.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver/local.New", + impl_stock_3e1064df.RepositoryImplementationModule(), + gen_boost_d233c539.NewModule(), + fx.Provide(src_local_2037e52c.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost/new_module.go b/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost/new_module.go new file mode 100644 index 00000000..4e126648 --- /dev/null +++ b/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost/new_module.go @@ -0,0 +1,31 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "go.uber.org/fx" + + client_publisher_bf2f93d5 "github.com/xgodev/boost/examples/stock/gen/client/github.com/xgodev/boost/wrapper/publisher" + src_boost_d233c539 "github.com/xgodev/boost/examples/stock/internal/pkg/publisher/stock/reserved/boost" +) + +var NewOnce sync.Once + +// NewModule registers github.com/xgodev/boost/examples/stock/internal/pkg/publisher/stock/reserved/boost.New and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewModule() fx.Option { + options := fx.Options() + + NewOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/publisher/stock/reserved/boost.New", + OptionsModule(), + client_publisher_bf2f93d5.PublisherModule(), + fx.Provide(src_boost_d233c539.New), + ) + }) + + return options +} diff --git a/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost/options_module.go b/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost/options_module.go new file mode 100644 index 00000000..f14772c1 --- /dev/null +++ b/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_boost_d233c539 "github.com/xgodev/boost/examples/stock/internal/pkg/publisher/stock/reserved/boost" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/examples/stock/internal/pkg/publisher/stock/reserved/boost.Options. +const optionsRoot = "internal.pkg.publisher.stock.reserved.boost" + +func init() { + // env INTERNAL_PKG_PUBLISHER_STOCK_RESERVED_BOOST_SUBJECT + config.Add(optionsRoot+".subject", "stock.reserved", "subject (topic) the reservation event is published to") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/examples/stock/internal/pkg/publisher/stock/reserved/boost, +// filled from the config keys under internal.pkg.publisher.stock.reserved.boost. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/examples/stock/internal/pkg/publisher/stock/reserved/boost.Options", + fx.Provide(func() (*src_boost_d233c539.Options, error) { + return config.NewOptionsWithPath[src_boost_d233c539.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/examples/stock/gen/module.go b/examples/stock/gen/module.go new file mode 100644 index 00000000..3df24194 --- /dev/null +++ b/examples/stock/gen/module.go @@ -0,0 +1,56 @@ +// Code generated by inject; DO NOT EDIT. + +package gen + +import ( + "go.uber.org/fx" + + client_sql_45c63acd "github.com/xgodev/boost/examples/stock/gen/client/database/sql" + client_resty_4dadf93c "github.com/xgodev/boost/examples/stock/gen/client/github.com/go-resty/resty/v2" + client_mongo_c43e7bd8 "github.com/xgodev/boost/examples/stock/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + client_publisher_bf2f93d5 "github.com/xgodev/boost/examples/stock/gen/client/github.com/xgodev/boost/wrapper/publisher" + impl_stock_3e1064df "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/repository/stock" + impl_reserver_e3c15ac4 "github.com/xgodev/boost/examples/stock/gen/implementation/internal_/pkg/domain/service/stock/reserver" + gen_finder_e709eb60 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/grpc/handler/stock/finder" + gen_reserver_7a871829 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/grpc/handler/stock/reserver" + gen_finder_0fe506c4 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/mcp/handler/stock/finder" + gen_creator_34324f41 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/rest/handler/stock/creator" + gen_finder_773dc854 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/rest/handler/stock/finder" + gen_reserver_e8b7ca47 "github.com/xgodev/boost/examples/stock/gen/internal/app/api/rest/handler/stock/reserver" + gen_checker_67912e54 "github.com/xgodev/boost/examples/stock/gen/internal/app/stockcheck/job/stock/checker" + gen_reserver_9ef9326a "github.com/xgodev/boost/examples/stock/gen/internal/app/worker/cloudevents/handler/stock/reserver" + gen_memory_e61b92bf "github.com/xgodev/boost/examples/stock/gen/internal/pkg/datastore/stock/memory" + gen_mongodb_06b6559e "github.com/xgodev/boost/examples/stock/gen/internal/pkg/datastore/stock/mongodb" + gen_oracle_34cf544c "github.com/xgodev/boost/examples/stock/gen/internal/pkg/datastore/stock/oracle" + gen_local_671ce8b7 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/creator/local" + gen_local_ecb3acf2 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/domain/service/stock/finder/local" + gen_boost_d233c539 "github.com/xgodev/boost/examples/stock/gen/internal/pkg/publisher/stock/reserved/boost" +) + +// Module registers every module generated by inject. +func Module() fx.Option { + return fx.Options( + gen_finder_e709eb60.NewModule(), + gen_reserver_7a871829.NewModule(), + gen_finder_0fe506c4.NewModule(), + gen_creator_34324f41.NewModule(), + gen_finder_773dc854.NewModule(), + gen_reserver_e8b7ca47.NewModule(), + gen_checker_67912e54.NewModule(), + gen_checker_67912e54.OptionsModule(), + gen_reserver_9ef9326a.NewModule(), + impl_reserver_e3c15ac4.ServiceImplementationModule(), + impl_stock_3e1064df.RepositoryImplementationModule(), + gen_memory_e61b92bf.OptionsModule(), + gen_mongodb_06b6559e.OptionsModule(), + gen_oracle_34cf544c.OptionsModule(), + gen_local_671ce8b7.NewModule(), + gen_local_ecb3acf2.NewModule(), + gen_boost_d233c539.NewModule(), + gen_boost_d233c539.OptionsModule(), + client_sql_45c63acd.DefaultDBModule(), + client_resty_4dadf93c.StockClientModule(), + client_mongo_c43e7bd8.DefaultConnModule(), + client_publisher_bf2f93d5.PublisherModule(), + ) +} diff --git a/examples/stock/gen_test.go b/examples/stock/gen_test.go new file mode 100644 index 00000000..dc0b132c --- /dev/null +++ b/examples/stock/gen_test.go @@ -0,0 +1,18 @@ +package stock + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost/builder" +) + +// TestGeneratedCodeIsUpToDate fails when gen/ or a main under cmd/ differs +// from what go generate writes for the annotations of this example. +func TestGeneratedCodeIsUpToDate(t *testing.T) { + stale, err := builder.Stale(context.Background(), ".", "gen", "cmd") + require.NoError(t, err) + assert.Empty(t, stale, "run go generate ./... in examples/stock") +} diff --git a/examples/stock/go.mod b/examples/stock/go.mod new file mode 100644 index 00000000..2bc73775 --- /dev/null +++ b/examples/stock/go.mod @@ -0,0 +1,124 @@ +module github.com/xgodev/boost/examples/stock + +go 1.26.5 + +require ( + github.com/cloudevents/sdk-go/v2 v2.16.2 + github.com/go-resty/resty/v2 v2.17.2 + github.com/godror/godror v0.51.5 + github.com/google/uuid v1.6.0 + github.com/labstack/echo/v4 v4.16.0 + github.com/modelcontextprotocol/go-sdk v1.8.0 + github.com/nats-io/nats-server/v2 v2.11.15 + github.com/nats-io/nats.go v1.54.0 + github.com/prometheus/client_golang v1.25.0 + github.com/stretchr/testify v1.12.1 + github.com/xgodev/boost v0.0.0 + go.mongodb.org/mongo-driver/v2 v2.9.2 + go.uber.org/fx v1.24.0 + google.golang.org/grpc v1.84.0 + google.golang.org/protobuf v1.36.12 +) + +require ( + github.com/VictoriaMetrics/easyproto v1.2.0 // indirect + github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.16 // indirect + github.com/globocom/echo-prometheus v0.1.2 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.2 // indirect + github.com/go-playground/universal-translator v0.18.2 // indirect + github.com/go-playground/validator/v10 v10.30.5 // indirect + github.com/gobeam/stringy v0.0.7 // indirect + github.com/godror/knownpb v0.3.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jedib0t/go-pretty/v6 v6.8.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.20.3 // indirect + github.com/knadh/koanf v1.5.0 // indirect + github.com/labstack/gommon v0.5.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/mattn/go-colorable v0.1.16 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.31 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.1 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect + go.opentelemetry.io/contrib/instrumentation/runtime v0.72.0 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.69.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/sdk v1.47.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.1 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + golang.org/x/tools v0.52.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/xgodev/boost => ../.. diff --git a/examples/stock/go.sum b/examples/stock/go.sum new file mode 100644 index 00000000..f214d894 --- /dev/null +++ b/examples/stock/go.sum @@ -0,0 +1,659 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/UNO-SOFT/zlog v0.8.1 h1:TEFkGJHtUfTRgMkLZiAjLSHALjwSBdw6/zByMC5GJt4= +github.com/UNO-SOFT/zlog v0.8.1/go.mod h1:yqFOjn3OhvJ4j7ArJqQNA+9V+u6t9zSAyIZdWdMweWc= +github.com/VictoriaMetrics/easyproto v1.2.0 h1:FJT9uNXA2isppFuJErbLqD306KoFlehl7Wn2dg/6oIE= +github.com/VictoriaMetrics/easyproto v1.2.0/go.mod h1:QlGlzaJnDfFd8Lk6Ci/fuLxfTo3/GThPs2KH23mv710= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= +github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw= +github.com/aws/aws-sdk-go-v2/credentials v1.4.3/go.mod h1:FNNC6nQZQUuyhq5aE5c7ata8o9e4ECGmS4lAXC7o1mQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0/go.mod h1:gqlclDEZp4aqJOancXK6TN24aKhT0W0Ae9MHk3wzTMM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4/go.mod h1:ZcBrrI3zBKlhGFNYWvju0I3TR93I7YIgAfy82Fh4lcQ= +github.com/aws/aws-sdk-go-v2/service/appconfig v1.4.2/go.mod h1:FZ3HkCe+b10uFZZkFdvf98LHW21k49W8o8J366lqVKY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72HRZDLMtmVQiLG2tLfQcaWLCssELvGl+Zf2WVxMmR8= +github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk= +github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g= +github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= +github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E= +github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/globocom/echo-prometheus v0.1.2 h1:tyusm7z6+873CHVhnl6QN8VOaKvNVgWfbNaiELkTRGc= +github.com/globocom/echo-prometheus v0.1.2/go.mod h1:3oQLuoG5ZI5nufWK0ILpMl4vmw1q9OIPe2iy+ToRE+A= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY= +github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU= +github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= +github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= +github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/gobeam/stringy v0.0.7 h1:TD8SfhedUoiANhW88JlJqfrMsihskIRpU/VTsHGnAps= +github.com/gobeam/stringy v0.0.7/go.mod h1:W3620X9dJHf2FSZF5fRnWekHcHQjwmCz8ZQ2d1qloqE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godror/godror v0.51.5 h1:NFvDtLILwg5mTU31DtL7Ae2AQvkDNL7nip+pSdMS4ow= +github.com/godror/godror v0.51.5/go.mod h1:ZxKkyFw54Ou5CGeXhP4EjK0s9PSB+2W87GyL765XbO8= +github.com/godror/knownpb v0.3.0 h1:+caUdy8hTtl7X05aPl3tdL540TvCcaQA6woZQroLZMw= +github.com/godror/knownpb v0.3.0/go.mod h1:PpTyfJwiOEAzQl7NtVCM8kdPCnp3uhxsZYIzZ5PV4zU= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0/go.mod h1:+ZQ6iUL8+ATyxGDQNuRKpNwJFdGDUi0e7E1QL9MvI7U= +github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ= +github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= +github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= +github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= +github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q= +github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs= +github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ= +github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= +github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.1.10/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= +github.com/labstack/echo/v4 v4.16.0 h1:cFqqpqVNmSVyn4nvsXHp5rU4aVLYG3hx4fGWc3FngBk= +github.com/labstack/echo/v4 v4.16.0/go.mod h1:VHAohjgM63iiTVI6EahEDjtRhQNXCMXFp0TMeIsFuW0= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A= +github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo= +github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.15 h1:StSf9TINInaZtr4oww2+kXmfwa9SkN//g/LwS19/UJ0= +github.com/nats-io/nats-server/v2 v2.11.15/go.mod h1:zwhv8Y0PE3KHyKgznJc/9Xoai638SaJd83zzJ5GJn74= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= +github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y= +github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.8.0 h1:rm9oMkeprG/wHifkZP4EuDww9S0mvEaQ/p9cXTNT4cw= +github.com/stretchr/objx v0.8.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= +go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= +go.mongodb.org/mongo-driver/v2 v2.9.2 h1:OCAdjI37pC/SVxucz/9CM8zD4jCdegtsY5QcKzYFCgE= +go.mongodb.org/mongo-driver/v2 v2.9.2/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= +go.opentelemetry.io/contrib/instrumentation/runtime v0.72.0 h1:rv8t3kKjyC2/9D99L1MZt4tJSZO4aYUmHus7L//iKKg= +go.opentelemetry.io/contrib/instrumentation/runtime v0.72.0/go.mod h1:ZzG+JmzLLvKzx6tMeLrw2l7F8ik2GeRZJALoPBqnM0c= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 h1:LaLVGJtIzHcvHjVs2OftmKcc+M9mdOqS8vZ1fBD1Yes= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0/go.mod h1:vF6H5sNDuQBJWgSX00iZgbIqk2MZZoVMH0ar5t5bOpw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0/go.mod h1:CLzakYy8jOdkBfcveCWYkeKJxujcF4xPhh2Z7IfE98U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= +go.opentelemetry.io/otel/exporters/prometheus v0.69.0 h1:LQVBTGnHvzrfGJpDoCSVTY7eaLZArvaTSq5l84Skxew= +go.opentelemetry.io/otel/exporters/prometheus v0.69.0/go.mod h1:moif0nlgj/M16NRT8lDZVt8MC52+EOAJfR8EKRJ8LyU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 h1:ww5DYBpMKcqLdxjWrhGUIh6O+ZfvZUaMk4niVyNQPEQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0/go.mod h1:Av5t+Ezx5S/Ks2dsEfWhny+GK+wrZ1K7NZ6aHSWnX3U= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw= +go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 h1:DS/w3N8rsfrwxwgGSj6WhMsPo7aNQGmDx74J81zd0NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941/go.mod h1:AuGAdi9KnXss7xbLxIKhCvk+Ro/SxpET/iS45DNYN+M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/examples/stock/internal/app/api/grpc/handler/stock/finder/handler.go b/examples/stock/internal/app/api/grpc/handler/stock/finder/handler.go new file mode 100644 index 00000000..f8d97a3d --- /dev/null +++ b/examples/stock/internal/app/api/grpc/handler/stock/finder/handler.go @@ -0,0 +1,33 @@ +// Package finder serves the StockFinder service of stock.v1. +package finder + +import ( + "context" + + stockv1 "github.com/xgodev/boost/examples/stock/api/proto/stock/v1" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder" + "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server" +) + +// Handler serves StockFinder. +type Handler struct { + stockv1.UnimplementedStockFinderServer + svc finder.Service +} + +// New provides the handler, which the generated grpc door registers with +// stockv1.RegisterStockFinderServer. +// @Provide(index=0) +// @GRPC(register=stockv1.RegisterStockFinderServer) +func New(svc finder.Service) *Handler { + return &Handler{svc: svc} +} + +// Find returns the stock of the sku of the request. +func (h *Handler) Find(ctx context.Context, req *stockv1.FindRequest) (*stockv1.Stock, error) { + s, err := h.svc.Find(ctx, req.GetSku()) + if err != nil { + return nil, server.Error(err) + } + return &stockv1.Stock{Sku: s.SKU, Units: s.Units}, nil +} diff --git a/examples/stock/internal/app/api/grpc/handler/stock/reserver/handler.go b/examples/stock/internal/app/api/grpc/handler/stock/reserver/handler.go new file mode 100644 index 00000000..533d866c --- /dev/null +++ b/examples/stock/internal/app/api/grpc/handler/stock/reserver/handler.go @@ -0,0 +1,33 @@ +// Package reserver serves the StockReserver service of stock.v1. +package reserver + +import ( + "context" + + stockv1 "github.com/xgodev/boost/examples/stock/api/proto/stock/v1" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver" + "github.com/xgodev/boost/factory/contrib/google.golang.org/grpc/v1/server" +) + +// Handler serves StockReserver. +type Handler struct { + stockv1.UnimplementedStockReserverServer + svc reserver.Service +} + +// New provides the handler, which the generated grpc door registers with +// stockv1.RegisterStockReserverServer. +// @Provide(index=0) +// @GRPC(register=stockv1.RegisterStockReserverServer) +func New(svc reserver.Service) *Handler { + return &Handler{svc: svc} +} + +// Reserve takes one unit of the sku of the request. +func (h *Handler) Reserve(ctx context.Context, req *stockv1.ReserveRequest) (*stockv1.Stock, error) { + left, err := h.svc.Reserve(ctx, req.GetSku()) + if err != nil { + return nil, server.Error(err) + } + return &stockv1.Stock{Sku: left.SKU, Units: left.Units}, nil +} diff --git a/examples/stock/internal/app/api/mcp/handler/stock/finder/handler.go b/examples/stock/internal/app/api/mcp/handler/stock/finder/handler.go new file mode 100644 index 00000000..9306db62 --- /dev/null +++ b/examples/stock/internal/app/api/mcp/handler/stock/finder/handler.go @@ -0,0 +1,41 @@ +// Package finder serves the find_stock tool of the mcp server of app api. +package finder + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder" +) + +// Handler serves the find_stock tool. +type Handler struct { + svc finder.Service +} + +// New provides the handler; the generated mcp door gets it from here. +// @Provide(index=0) +func New(svc finder.Service) *Handler { + return &Handler{svc: svc} +} + +// Input is the item the tool asks about. +type Input struct { + SKU string `json:"sku" jsonschema:"the sku of the item"` +} + +// Output is the stock of the item. +type Output struct { + SKU string `json:"sku"` + Units int64 `json:"units"` +} + +// FindStock tells how many units of an item are in stock. +// @MCP +func (h *Handler) FindStock(ctx context.Context, _ *mcp.CallToolRequest, in Input) (*mcp.CallToolResult, Output, error) { + s, err := h.svc.Find(ctx, in.SKU) + if err != nil { + return nil, Output{}, err + } + return nil, Output{SKU: s.SKU, Units: s.Units}, nil +} diff --git a/examples/stock/internal/app/api/rest/handler/stock/creator/handler.go b/examples/stock/internal/app/api/rest/handler/stock/creator/handler.go new file mode 100644 index 00000000..04a84c6b --- /dev/null +++ b/examples/stock/internal/app/api/rest/handler/stock/creator/handler.go @@ -0,0 +1,35 @@ +// Package creator serves POST /stocks. +package creator + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/creator" + "github.com/xgodev/boost/examples/stock/internal/pkg/server/rest/model" + "github.com/xgodev/boost/model/errors" +) + +// Handler puts a new item in stock. +type Handler struct { + svc creator.Service +} + +// New provides the handler to the generated rest door. +// @Provide(index=0) +func New(svc creator.Service) *Handler { + return &Handler{svc: svc} +} + +// Handle creates the stock in the body. +// @REST(method=POST, path=/stocks) +func (h *Handler) Handle(c echo.Context) error { + var body model.Stock + if err := c.Bind(&body); err != nil { + return errors.NewBadRequest(err, "body is not a stock") + } + if err := h.svc.Create(c.Request().Context(), body.Entity()); err != nil { + return err + } + return c.JSON(http.StatusCreated, body) +} diff --git a/examples/stock/internal/app/api/rest/handler/stock/finder/handler.go b/examples/stock/internal/app/api/rest/handler/stock/finder/handler.go new file mode 100644 index 00000000..52aaad25 --- /dev/null +++ b/examples/stock/internal/app/api/rest/handler/stock/finder/handler.go @@ -0,0 +1,31 @@ +// Package finder serves GET /stocks/:sku. +package finder + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder" + "github.com/xgodev/boost/examples/stock/internal/pkg/server/rest/model" +) + +// Handler reads the stock of an item. +type Handler struct { + svc finder.Service +} + +// New provides the handler to the generated rest door. +// @Provide(index=0) +func New(svc finder.Service) *Handler { + return &Handler{svc: svc} +} + +// Handle returns the stock of the sku in the path. +// @REST(method=GET, path=/stocks/:sku) +func (h *Handler) Handle(c echo.Context) error { + s, err := h.svc.Find(c.Request().Context(), c.Param("sku")) + if err != nil { + return err + } + return c.JSON(http.StatusOK, model.FromEntity(s)) +} diff --git a/examples/stock/internal/app/api/rest/handler/stock/reserver/handler.go b/examples/stock/internal/app/api/rest/handler/stock/reserver/handler.go new file mode 100644 index 00000000..1d7547b6 --- /dev/null +++ b/examples/stock/internal/app/api/rest/handler/stock/reserver/handler.go @@ -0,0 +1,31 @@ +// Package reserver serves POST /stocks/:sku/reserve. +package reserver + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver" + "github.com/xgodev/boost/examples/stock/internal/pkg/server/rest/model" +) + +// Handler reserves one unit of an item. +type Handler struct { + svc reserver.Service +} + +// New provides the handler to the generated rest door. +// @Provide(index=0) +func New(svc reserver.Service) *Handler { + return &Handler{svc: svc} +} + +// Handle reserves one unit of the sku in the path. +// @REST(method=POST, path=/stocks/:sku/reserve) +func (h *Handler) Handle(c echo.Context) error { + left, err := h.svc.Reserve(c.Request().Context(), c.Param("sku")) + if err != nil { + return err + } + return c.JSON(http.StatusOK, model.FromEntity(left)) +} diff --git a/examples/stock/internal/app/stockcheck/job/stock/checker/handler.go b/examples/stock/internal/app/stockcheck/job/stock/checker/handler.go new file mode 100644 index 00000000..b8b7272e --- /dev/null +++ b/examples/stock/internal/app/stockcheck/job/stock/checker/handler.go @@ -0,0 +1,43 @@ +// Package checker checks the stock of the skus Options lists in app +// stock-check, a job: the app runs the check once and exits, the shape of a +// Kubernetes CronJob. +package checker + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder" + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/log" +) + +// Handler checks the stock of skus. +type Handler struct { + svc finder.Service + skus []string +} + +// New provides the handler; the generated job door gets it from here. +// @Provide(index=0) +func New(svc finder.Service, o *Options) *Handler { + return &Handler{svc: svc, skus: o.SKUs} +} + +// Check logs the units of each sku and warns of the sold-out ones. A sku the +// stock does not have stops the check with its error, and the app exits 1. +// @Job(app=stock-check) +func (h *Handler) Check(ctx context.Context) error { + logger := log.FromContext(ctx) + for _, sku := range h.skus { + s, err := h.svc.Find(ctx, sku) + if err != nil { + return errors.Annotatef(err, "checking %s", sku) + } + if s.Units == 0 { + logger.Warnf("%s is sold out", s.SKU) + continue + } + logger.Infof("%s has %d units", s.SKU, s.Units) + } + return nil +} diff --git a/examples/stock/internal/app/stockcheck/job/stock/checker/options.go b/examples/stock/internal/app/stockcheck/job/stock/checker/options.go new file mode 100644 index 00000000..f90a5134 --- /dev/null +++ b/examples/stock/internal/app/stockcheck/job/stock/checker/options.go @@ -0,0 +1,8 @@ +package checker + +// Options configures the check; inject generates its config keys +// (internal.app.stockcheck.job.stock.checker.*) and provides *Options to New. +// @Config +type Options struct { + SKUs []string `config:"skus" default:"tshirt,mug" desc:"skus the check looks at"` +} diff --git a/examples/stock/internal/app/worker/cloudevents/handler/stock/reserver/handler.go b/examples/stock/internal/app/worker/cloudevents/handler/stock/reserver/handler.go new file mode 100644 index 00000000..24b14749 --- /dev/null +++ b/examples/stock/internal/app/worker/cloudevents/handler/stock/reserver/handler.go @@ -0,0 +1,46 @@ +// Package reserver reserves the item of each order.created event in app +// worker. +package reserver + +import ( + "context" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver" + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/log" +) + +// Handler reserves the stock of orders. +type Handler struct { + svc reserver.Service +} + +// New provides the handler; the generated function door gets it from here. +// @Provide(index=0) +func New(svc reserver.Service) *Handler { + return &Handler{svc: svc} +} + +// order is the data of an order.created event. +type order struct { + SKU string `json:"sku"` +} + +// Handle reserves one unit of the sku of the order. The reservation is +// announced by the reserver, so the function replies with no event. The +// adapter is worker.function.adapter: cloudevents (HTTP, the default) or +// nats. +// @Function(adapter=[cloudevents, nats], app=worker, middlewares=[recovery, logger]) +func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) ([]*cloudevents.Event, error) { + var o order + if err := in.DataAs(&o); err != nil || o.SKU == "" { + return nil, errors.NotValidf("event %s has no sku", in.ID()) + } + left, err := h.svc.Reserve(ctx, o.SKU) + if err != nil { + return nil, err + } + log.FromContext(ctx).Infof("reserved one %s for event %s, %d left", left.SKU, in.ID(), left.Units) + return nil, nil +} diff --git a/examples/stock/internal/pkg/client/stock/reserver/resty/client.go b/examples/stock/internal/pkg/client/stock/reserver/resty/client.go new file mode 100644 index 00000000..26767649 --- /dev/null +++ b/examples/stock/internal/pkg/client/stock/reserver/resty/client.go @@ -0,0 +1,56 @@ +// Package resty reserves stock by asking app api over HTTP +// (POST /stocks/:sku/reserve), chosen by +// INTERNAL_PKG_DOMAIN_SERVICE_STOCK_RESERVER_IMPLEMENTATION=resty. Its client +// is the resty client builder generates for the name stock, with its own +// config (boost.factory.resty.clients.stock.*). +package resty + +import ( + "context" + "net/http" + + "github.com/go-resty/resty/v2" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver" + "github.com/xgodev/boost/model/errors" +) + +type client struct { + http *resty.Client +} + +// New provides the remote stock reserver; builder builds its client with +// the log, requestid and prometheus plugins. +// @Provide(index=0) +// @Inject(index=0, name=stock, plugins=[log, requestid, prometheus]) +func New(c *resty.Client) reserver.Service { + return &client{http: c} +} + +// stock is the body app api answers with. +type stock struct { + SKU string `json:"sku"` + Units int64 `json:"units"` +} + +func (c *client) Reserve(ctx context.Context, sku string) (*entity.Stock, error) { + var left stock + resp, err := c.http.R(). + SetContext(ctx). + SetPathParam("sku", sku). + SetResult(&left). + Post("/stocks/{sku}/reserve") + if err != nil { + return nil, errors.NewInternal(err, "asking app api to reserve "+sku) + } + switch resp.StatusCode() { + case http.StatusOK: + return &entity.Stock{SKU: left.SKU, Units: left.Units}, nil + case http.StatusNotFound: + return nil, errors.NewNotFound(nil, sku+" is not in stock") + case http.StatusConflict: + return nil, errors.Conflictf("%s is sold out", sku) + default: + return nil, errors.Internalf("app api answered %s reserving %s", resp.Status(), sku) + } +} diff --git a/examples/stock/internal/pkg/datastore/stock/memory/datastore.go b/examples/stock/internal/pkg/datastore/stock/memory/datastore.go new file mode 100644 index 00000000..602a5c81 --- /dev/null +++ b/examples/stock/internal/pkg/datastore/stock/memory/datastore.go @@ -0,0 +1,70 @@ +// Package memory keeps the stock in memory: the default, no infra needed. +package memory + +import ( + "context" + "strconv" + "strings" + "sync" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" + "github.com/xgodev/boost/model/errors" +) + +type datastore struct { + mu sync.Mutex + units map[string]int64 +} + +// New provides the stock wired when no other is configured, holding the +// items of Options.Seed. +// @Provide(index=0) +// @Default +func New(o *Options) (stock.Repository, error) { + units := map[string]int64{} + for _, entry := range o.Seed { + sku, n, ok := strings.Cut(entry, "=") + count, err := strconv.ParseInt(n, 10, 64) + if !ok || sku == "" || err != nil || count < 0 { + return nil, errors.NotValidf("seed %q is not sku=units", entry) + } + units[sku] = count + } + return &datastore{units: units}, nil +} + +func (d *datastore) Create(_ context.Context, s *entity.Stock) error { + d.mu.Lock() + defer d.mu.Unlock() + if _, ok := d.units[s.SKU]; ok { + return errors.AlreadyExistsf("%s is already in stock", s.SKU) + } + d.units[s.SKU] = s.Units + return nil +} + +func (d *datastore) Get(_ context.Context, sku string) (*entity.Stock, error) { + d.mu.Lock() + defer d.mu.Unlock() + units, ok := d.units[sku] + if !ok { + // NewNotFound keeps the message as written; NotFoundf would add " not found". + return nil, errors.NewNotFound(nil, sku+" is not in stock") + } + return &entity.Stock{SKU: sku, Units: units}, nil +} + +func (d *datastore) Take(_ context.Context, sku string) (*entity.Stock, error) { + d.mu.Lock() + defer d.mu.Unlock() + units, ok := d.units[sku] + if !ok { + return nil, errors.NewNotFound(nil, sku+" is not in stock") + } + if units == 0 { + return nil, errors.Conflictf("%s is sold out", sku) + } + d.units[sku] = units - 1 + return &entity.Stock{SKU: sku, Units: units - 1}, nil +} diff --git a/examples/stock/internal/pkg/datastore/stock/memory/options.go b/examples/stock/internal/pkg/datastore/stock/memory/options.go new file mode 100644 index 00000000..4b03bf89 --- /dev/null +++ b/examples/stock/internal/pkg/datastore/stock/memory/options.go @@ -0,0 +1,8 @@ +package memory + +// Options configures the memory stock; inject generates its config keys +// (internal.pkg.datastore.stock.memory.*) and provides *Options to New. +// @Config +type Options struct { + Seed []string `default:"tshirt=3,mug=1" desc:"sku=units the memory stock starts with"` +} diff --git a/examples/stock/internal/pkg/datastore/stock/mongodb/datastore.go b/examples/stock/internal/pkg/datastore/stock/mongodb/datastore.go new file mode 100644 index 00000000..31b0731f --- /dev/null +++ b/examples/stock/internal/pkg/datastore/stock/mongodb/datastore.go @@ -0,0 +1,77 @@ +// Package mongodb keeps the stock in MongoDB, chosen by +// INTERNAL_PKG_DOMAIN_REPOSITORY_STOCK_IMPLEMENTATION=mongodb. Its connection +// is the one builder generates from boost's mongo factory +// (boost.factory.mongo.*), with the health and prometheus plugins. +package mongodb + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" + mongofact "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + "github.com/xgodev/boost/model/errors" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type datastore struct { + coll *mongo.Collection +} + +// document is a stock as MongoDB stores it: the sku is the _id. +type document struct { + SKU string `bson:"_id"` + Units int64 `bson:"units"` +} + +// New provides the mongo stock on the collection of Options. +// @Provide(index=0) +// @Inject(index=0, plugins=[health, prometheus]) +func New(conn *mongofact.Conn, o *Options) stock.Repository { + return &datastore{coll: conn.Database.Collection(o.Collection)} +} + +func (d *datastore) Create(ctx context.Context, s *entity.Stock) error { + _, err := d.coll.InsertOne(ctx, document{SKU: s.SKU, Units: s.Units}) + if mongo.IsDuplicateKeyError(err) { + return errors.AlreadyExistsf("%s is already in stock", s.SKU) + } + if err != nil { + return errors.Wrap(err, errors.Internalf("error creating the stock of %s", s.SKU)) + } + return nil +} + +func (d *datastore) Get(ctx context.Context, sku string) (*entity.Stock, error) { + var doc document + err := d.coll.FindOne(ctx, bson.M{"_id": bson.M{"$eq": sku}}).Decode(&doc) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, errors.NewNotFound(nil, sku+" is not in stock") + } + if err != nil { + return nil, errors.Wrap(err, errors.Internalf("error reading the stock of %s", sku)) + } + return &entity.Stock{SKU: doc.SKU, Units: doc.Units}, nil +} + +func (d *datastore) Take(ctx context.Context, sku string) (*entity.Stock, error) { + var doc document + err := d.coll.FindOneAndUpdate(ctx, + bson.M{"_id": bson.M{"$eq": sku}, "units": bson.M{"$gt": 0}}, + bson.M{"$inc": bson.M{"units": -1}}, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&doc) + if errors.Is(err, mongo.ErrNoDocuments) { + // nothing taken: either the sku is unknown or it is sold out + if _, err := d.Get(ctx, sku); err != nil { + return nil, err + } + return nil, errors.Conflictf("%s is sold out", sku) + } + if err != nil { + return nil, errors.Wrap(err, errors.Internalf("error taking a unit of %s", sku)) + } + return &entity.Stock{SKU: doc.SKU, Units: doc.Units}, nil +} diff --git a/examples/stock/internal/pkg/datastore/stock/mongodb/options.go b/examples/stock/internal/pkg/datastore/stock/mongodb/options.go new file mode 100644 index 00000000..91985158 --- /dev/null +++ b/examples/stock/internal/pkg/datastore/stock/mongodb/options.go @@ -0,0 +1,8 @@ +package mongodb + +// Options configures the mongo stock; inject generates its config keys +// (internal.pkg.datastore.stock.mongodb.*). +// @Config +type Options struct { + Collection string `default:"stock" desc:"collection of the units in stock"` +} diff --git a/examples/stock/internal/pkg/datastore/stock/oracle/datastore.go b/examples/stock/internal/pkg/datastore/stock/oracle/datastore.go new file mode 100644 index 00000000..77dac9ca --- /dev/null +++ b/examples/stock/internal/pkg/datastore/stock/oracle/datastore.go @@ -0,0 +1,84 @@ +// Package oracle keeps the stock in Oracle, chosen by +// INTERNAL_PKG_DOMAIN_REPOSITORY_STOCK_IMPLEMENTATION=oracle. Its *sql.DB is +// the one builder generates from boost's godror factory +// (boost.factory.godror.*), with the health plugin; godror builds with cgo +// and loads the Oracle client libraries at run time. +package oracle + +import ( + "context" + "database/sql" + + "github.com/godror/godror" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" + "github.com/xgodev/boost/model/errors" +) + +// uniqueViolation is ORA-00001, a duplicate primary key. +const uniqueViolation = 1 + +type datastore struct { + db *sql.DB + table string +} + +// New provides the oracle stock on the table of Options. +// @Provide(index=0) +// @Inject(index=0, drivers=[godror], plugins=[health]) +func New(db *sql.DB, o *Options) stock.Repository { + return &datastore{db: db, table: o.Table} +} + +func (d *datastore) Create(ctx context.Context, s *entity.Stock) error { + _, err := d.db.ExecContext(ctx, "INSERT INTO "+d.table+" (sku, units) VALUES (:1, :2)", s.SKU, s.Units) + if oraErr, ok := godror.AsOraErr(err); ok && oraErr.Code() == uniqueViolation { + return errors.AlreadyExistsf("%s is already in stock", s.SKU) + } + if err != nil { + return errors.Wrap(err, errors.Internalf("error creating the stock of %s", s.SKU)) + } + return nil +} + +func (d *datastore) Get(ctx context.Context, sku string) (*entity.Stock, error) { + return scanUnits(d.db.QueryRowContext(ctx, "SELECT units FROM "+d.table+" WHERE sku = :1", sku), sku) +} + +func (d *datastore) Take(ctx context.Context, sku string) (*entity.Stock, error) { + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return nil, errors.Wrap(err, errors.Internalf("error taking a unit of %s", sku)) + } + defer func() { _ = tx.Rollback() }() + + // the row stays locked until the commit, so two takes never sell one unit twice + s, err := scanUnits(tx.QueryRowContext(ctx, "SELECT units FROM "+d.table+" WHERE sku = :1 FOR UPDATE", sku), sku) + if err != nil { + return nil, err + } + if s.Units == 0 { + return nil, errors.Conflictf("%s is sold out", sku) + } + if _, err := tx.ExecContext(ctx, "UPDATE "+d.table+" SET units = units - 1 WHERE sku = :1", sku); err != nil { + return nil, errors.Wrap(err, errors.Internalf("error taking a unit of %s", sku)) + } + if err := tx.Commit(); err != nil { + return nil, errors.Wrap(err, errors.Internalf("error taking a unit of %s", sku)) + } + s.Units-- + return s, nil +} + +// scanUnits reads the units of sku from row; no row is a NotFound. +func scanUnits(row *sql.Row, sku string) (*entity.Stock, error) { + var units int64 + err := row.Scan(&units) + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(nil, sku+" is not in stock") + } + if err != nil { + return nil, errors.Wrap(err, errors.Internalf("error reading the stock of %s", sku)) + } + return &entity.Stock{SKU: sku, Units: units}, nil +} diff --git a/examples/stock/internal/pkg/datastore/stock/oracle/options.go b/examples/stock/internal/pkg/datastore/stock/oracle/options.go new file mode 100644 index 00000000..5c234b9b --- /dev/null +++ b/examples/stock/internal/pkg/datastore/stock/oracle/options.go @@ -0,0 +1,8 @@ +package oracle + +// Options configures the oracle stock; inject generates its config keys +// (internal.pkg.datastore.stock.oracle.*). +// @Config +type Options struct { + Table string `default:"stock" desc:"table of the units in stock: sku VARCHAR2 primary key, units NUMBER"` +} diff --git a/examples/stock/internal/pkg/domain/model/entity/stock.go b/examples/stock/internal/pkg/domain/model/entity/stock.go new file mode 100644 index 00000000..d0e82a3e --- /dev/null +++ b/examples/stock/internal/pkg/domain/model/entity/stock.go @@ -0,0 +1,26 @@ +// Package entity holds the entities of the domain: no transport, no driver +// tags. +package entity + +import ( + "regexp" + + "github.com/xgodev/boost/model/errors" +) + +// Stock is the units in stock of one item. +type Stock struct { + SKU string + Units int64 +} + +var sku = regexp.MustCompile(`^[a-z0-9-]{1,64}$`) + +// ValidSKU tells whether s is a sku: 1 to 64 of a-z, 0-9 and -. Every door +// reaches the stock through a service that checks it first. +func ValidSKU(s string) error { + if !sku.MatchString(s) { + return errors.NewNotValid(nil, s+" is not a sku: use 1 to 64 of a-z, 0-9 and -") + } + return nil +} diff --git a/examples/stock/internal/pkg/domain/publisher/stock/reserved/publisher.go b/examples/stock/internal/pkg/domain/publisher/stock/reserved/publisher.go new file mode 100644 index 00000000..89a4edc4 --- /dev/null +++ b/examples/stock/internal/pkg/domain/publisher/stock/reserved/publisher.go @@ -0,0 +1,14 @@ +// Package reserved announces that a unit of an item was reserved. +package reserved + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" +) + +// Publisher announces a reservation. +type Publisher interface { + // Publish announces the stock left after a reservation. + Publish(ctx context.Context, s *entity.Stock) error +} diff --git a/examples/stock/internal/pkg/domain/repository/stock/repository.go b/examples/stock/internal/pkg/domain/repository/stock/repository.go new file mode 100644 index 00000000..5843dcf6 --- /dev/null +++ b/examples/stock/internal/pkg/domain/repository/stock/repository.go @@ -0,0 +1,19 @@ +// Package stock is the persistence of the units in stock. +package stock + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" +) + +// Repository keeps the units in stock of each item. +type Repository interface { + // Create stores a new stock; a sku already stored is an AlreadyExists. + Create(ctx context.Context, s *entity.Stock) error + // Get returns the stock of sku; an unknown sku is a NotFound. + Get(ctx context.Context, sku string) (*entity.Stock, error) + // Take removes one unit of sku and returns what is left; an unknown sku + // is a NotFound and a sold-out one a Conflict. + Take(ctx context.Context, sku string) (*entity.Stock, error) +} diff --git a/examples/stock/internal/pkg/domain/service/stock/creator/local/service.go b/examples/stock/internal/pkg/domain/service/stock/creator/local/service.go new file mode 100644 index 00000000..ed1f0730 --- /dev/null +++ b/examples/stock/internal/pkg/domain/service/stock/creator/local/service.go @@ -0,0 +1,36 @@ +// Package local creates stock in process, on the stock repository. +package local + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/creator" + "github.com/xgodev/boost/model/errors" +) + +type service struct { + repo stock.Repository +} + +// New provides the stock creator, wrapped in the log and prometheus +// middlewares. +// @Provide(index=0) +// @Middleware(log, prometheus) +func New(repo stock.Repository) creator.Service { + return &service{repo: repo} +} + +func (s *service) Create(ctx context.Context, st *entity.Stock) error { + if st.SKU == "" { + return errors.NotValidf("sku is required") + } + if err := entity.ValidSKU(st.SKU); err != nil { + return err + } + if st.Units < 0 { + return errors.NotValidf("units of %s cannot be negative", st.SKU) + } + return s.repo.Create(ctx, st) +} diff --git a/examples/stock/internal/pkg/domain/service/stock/creator/service.go b/examples/stock/internal/pkg/domain/service/stock/creator/service.go new file mode 100644 index 00000000..bfa6570b --- /dev/null +++ b/examples/stock/internal/pkg/domain/service/stock/creator/service.go @@ -0,0 +1,15 @@ +// Package creator puts a new item in stock. +package creator + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" +) + +// Service creates stock. +type Service interface { + // Create stores the stock of a new sku; a blank sku or negative units is + // a NotValid and a sku already in stock an AlreadyExists. + Create(ctx context.Context, s *entity.Stock) error +} diff --git a/examples/stock/internal/pkg/domain/service/stock/finder/local/service.go b/examples/stock/internal/pkg/domain/service/stock/finder/local/service.go new file mode 100644 index 00000000..348ecd5c --- /dev/null +++ b/examples/stock/internal/pkg/domain/service/stock/finder/local/service.go @@ -0,0 +1,29 @@ +// Package local finds stock in process, on the stock repository. +package local + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/finder" +) + +type service struct { + repo stock.Repository +} + +// New provides the stock finder, wrapped in the log and prometheus +// middlewares. +// @Provide(index=0) +// @Middleware(log, prometheus) +func New(repo stock.Repository) finder.Service { + return &service{repo: repo} +} + +func (s *service) Find(ctx context.Context, sku string) (*entity.Stock, error) { + if err := entity.ValidSKU(sku); err != nil { + return nil, err + } + return s.repo.Get(ctx, sku) +} diff --git a/examples/stock/internal/pkg/domain/service/stock/finder/service.go b/examples/stock/internal/pkg/domain/service/stock/finder/service.go new file mode 100644 index 00000000..2c334377 --- /dev/null +++ b/examples/stock/internal/pkg/domain/service/stock/finder/service.go @@ -0,0 +1,14 @@ +// Package finder reads the units in stock of an item. +package finder + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" +) + +// Service finds stock. +type Service interface { + // Find returns the stock of sku; an unknown sku is a NotFound. + Find(ctx context.Context, sku string) (*entity.Stock, error) +} diff --git a/examples/stock/internal/pkg/domain/service/stock/reserver/local/service.go b/examples/stock/internal/pkg/domain/service/stock/reserver/local/service.go new file mode 100644 index 00000000..dee9cfe2 --- /dev/null +++ b/examples/stock/internal/pkg/domain/service/stock/reserver/local/service.go @@ -0,0 +1,41 @@ +// Package local reserves stock in process: it takes the unit from the +// repository and announces the reservation. +package local + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/publisher/stock/reserved" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/repository/stock" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/service/stock/reserver" + "github.com/xgodev/boost/model/errors" +) + +type service struct { + repo stock.Repository + pub reserved.Publisher +} + +// New provides the default stock reserver, wrapped in the log and +// prometheus middlewares. +// @Provide(index=0) +// @Default +// @Middleware(log, prometheus) +func New(repo stock.Repository, pub reserved.Publisher) reserver.Service { + return &service{repo: repo, pub: pub} +} + +func (s *service) Reserve(ctx context.Context, sku string) (*entity.Stock, error) { + if err := entity.ValidSKU(sku); err != nil { + return nil, err + } + left, err := s.repo.Take(ctx, sku) + if err != nil { + return nil, err + } + if err := s.pub.Publish(ctx, left); err != nil { + return nil, errors.Wrap(err, errors.Internalf("error announcing the reservation of %s", sku)) + } + return left, nil +} diff --git a/examples/stock/internal/pkg/domain/service/stock/reserver/service.go b/examples/stock/internal/pkg/domain/service/stock/reserver/service.go new file mode 100644 index 00000000..f6290173 --- /dev/null +++ b/examples/stock/internal/pkg/domain/service/stock/reserver/service.go @@ -0,0 +1,17 @@ +// Package reserver reserves one unit of an item. Its implementations, local +// (the default) and resty (asks app api over HTTP), are chosen by +// internal.pkg.domain.service.stock.reserver.implementation. +package reserver + +import ( + "context" + + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" +) + +// Service reserves stock. +type Service interface { + // Reserve takes one unit of sku and returns what is left; an unknown + // sku is a NotFound and a sold-out one a Conflict. + Reserve(ctx context.Context, sku string) (*entity.Stock, error) +} diff --git a/examples/stock/internal/pkg/publisher/stock/reserved/boost/options.go b/examples/stock/internal/pkg/publisher/stock/reserved/boost/options.go new file mode 100644 index 00000000..a4b43588 --- /dev/null +++ b/examples/stock/internal/pkg/publisher/stock/reserved/boost/options.go @@ -0,0 +1,8 @@ +package boost + +// Options configures the reservation event; inject generates its config keys +// (internal.pkg.publisher.stock.reserved.boost.*). +// @Config +type Options struct { + Subject string `default:"stock.reserved" desc:"subject (topic) the reservation event is published to"` +} diff --git a/examples/stock/internal/pkg/publisher/stock/reserved/boost/publisher.go b/examples/stock/internal/pkg/publisher/stock/reserved/boost/publisher.go new file mode 100644 index 00000000..ada9fe75 --- /dev/null +++ b/examples/stock/internal/pkg/publisher/stock/reserved/boost/publisher.go @@ -0,0 +1,49 @@ +// Package boost announces reservations on boost's wrapper/publisher as +// CloudEvents. The driver is boost.wrapper.publisher.driver.name: noop (the +// default) or nats, the only broker driver generated for this app. +package boost + +import ( + "context" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + "github.com/xgodev/boost/examples/stock/internal/pkg/domain/publisher/stock/reserved" + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/publisher" +) + +// Type is the CloudEvents type of the reservation event. +const Type = "stock.reserved" + +type boostPublisher struct { + pub *publisher.Publisher + subject string +} + +// New provides the reservation publisher on the publisher builder +// generates. +// @Provide(index=0) +// @Inject(index=0, drivers=[nats]) +func New(pub *publisher.Publisher, o *Options) reserved.Publisher { + return &boostPublisher{pub: pub, subject: o.Subject} +} + +// event is the data of the reservation event. +type event struct { + SKU string `json:"sku"` + Units int64 `json:"units"` +} + +func (p *boostPublisher) Publish(ctx context.Context, s *entity.Stock) error { + out := cloudevents.NewEvent() + out.SetID(uuid.NewString()) + out.SetType(Type) + out.SetSource("stock") + out.SetSubject(p.subject) + if err := out.SetData(cloudevents.ApplicationJSON, event{SKU: s.SKU, Units: s.Units}); err != nil { + return errors.Wrap(err, errors.Internalf("error encoding the reservation of %s", s.SKU)) + } + return p.pub.Publish(ctx, []*cloudevents.Event{&out}) +} diff --git a/examples/stock/internal/pkg/server/cloudevents/doc.go b/examples/stock/internal/pkg/server/cloudevents/doc.go new file mode 100644 index 00000000..d5993a15 --- /dev/null +++ b/examples/stock/internal/pkg/server/cloudevents/doc.go @@ -0,0 +1,5 @@ +// Package cloudevents holds what the CloudEvents HTTP adapter of app worker +// is built with: an OpenTelemetry span per event it receives. +// +// @Server(type=cloudevents, app=worker, plugins=[otel]) +package cloudevents diff --git a/examples/stock/internal/pkg/server/grpc/doc.go b/examples/stock/internal/pkg/server/grpc/doc.go new file mode 100644 index 00000000..c181ac04 --- /dev/null +++ b/examples/stock/internal/pkg/server/grpc/doc.go @@ -0,0 +1,5 @@ +// Package grpc holds what the grpc server of app api is built with besides +// its services: a log line and the prometheus metrics of every call. +// +// @Server(type=grpc, plugins=[log, prometheus]) +package grpc diff --git a/examples/stock/internal/pkg/server/mcp/doc.go b/examples/stock/internal/pkg/server/mcp/doc.go new file mode 100644 index 00000000..d5b4a158 --- /dev/null +++ b/examples/stock/internal/pkg/server/mcp/doc.go @@ -0,0 +1,5 @@ +// Package mcp holds what the mcp server of app api is built with besides +// its tools: a log line per tool call. +// +// @Server(type=mcp, plugins=[log]) +package mcp diff --git a/examples/stock/internal/pkg/server/rest/doc.go b/examples/stock/internal/pkg/server/rest/doc.go new file mode 100644 index 00000000..c40321bf --- /dev/null +++ b/examples/stock/internal/pkg/server/rest/doc.go @@ -0,0 +1,8 @@ +// Package rest holds what the rest server of app api is built with besides +// its handlers: recover from panics, a request id per call, a log line per +// call, boost errors as HTTP statuses (NotFound 404, Conflict 409), GET +// /health with the checkers the clients register and GET /metrics. Each +// plugin has its own enabled key under boost.factory.echo.plugins. +// +// @Server(type=rest, plugins=[recover, requestId, logger, errorHandler, health, prometheus]) +package rest diff --git a/examples/stock/internal/pkg/server/rest/model/stock.go b/examples/stock/internal/pkg/server/rest/model/stock.go new file mode 100644 index 00000000..a0b5ea3e --- /dev/null +++ b/examples/stock/internal/pkg/server/rest/model/stock.go @@ -0,0 +1,21 @@ +// Package model holds the JSON bodies of the rest server. +package model + +import "github.com/xgodev/boost/examples/stock/internal/pkg/domain/model/entity" + +// Stock is the body of a stock: the request of POST /stocks and the +// response of every route. +type Stock struct { + SKU string `json:"sku"` + Units int64 `json:"units"` +} + +// FromEntity returns the body of the stock s. +func FromEntity(s *entity.Stock) Stock { + return Stock{SKU: s.SKU, Units: s.Units} +} + +// Entity returns the stock of the body. +func (s Stock) Entity() *entity.Stock { + return &entity.Stock{SKU: s.SKU, Units: s.Units} +} diff --git a/extra/health/examples/simple/gen/builder/api/rest_module.go b/extra/health/examples/simple/gen/builder/api/rest_module.go index fb193071..92e4dca2 100644 --- a/extra/health/examples/simple/gen/builder/api/rest_module.go +++ b/extra/health/examples/simple/gen/builder/api/rest_module.go @@ -10,6 +10,11 @@ import ( gen_checker_96f8be6a "github.com/xgodev/boost/extra/health/examples/simple/gen/internal/app/api/rest/handler/health/checker" src_checker_96f8be6a "github.com/xgodev/boost/extra/health/examples/simple/internal/app/api/rest/handler/health/checker" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_checker_96f8be6a.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_checker_96f8be6a.Handler) { srv.Add("GET", "/health", h0.Handle) diff --git a/extra/health/examples/simple/go.mod b/extra/health/examples/simple/go.mod index 693cafaa..8e476d30 100644 --- a/extra/health/examples/simple/go.mod +++ b/extra/health/examples/simple/go.mod @@ -52,6 +52,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/extra/middleware/examples/custom/gen/builder/api/rest_module.go b/extra/middleware/examples/custom/gen/builder/api/rest_module.go index e8f52da4..4f43b74f 100644 --- a/extra/middleware/examples/custom/gen/builder/api/rest_module.go +++ b/extra/middleware/examples/custom/gen/builder/api/rest_module.go @@ -10,6 +10,11 @@ import ( gen_greeter_2f6c2ae5 "github.com/xgodev/boost/extra/middleware/examples/custom/gen/internal/app/api/rest/handler/greeting/greeter" src_greeter_2f6c2ae5 "github.com/xgodev/boost/extra/middleware/examples/custom/internal/app/api/rest/handler/greeting/greeter" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_greeter_2f6c2ae5.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_greeter_2f6c2ae5.Handler) { srv.Add("GET", "/greetings/:name", h0.Handle) diff --git a/extra/middleware/examples/custom/go.mod b/extra/middleware/examples/custom/go.mod index 74e40a37..8f238db0 100644 --- a/extra/middleware/examples/custom/go.mod +++ b/extra/middleware/examples/custom/go.mod @@ -52,6 +52,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/extra/middleware/examples/service/gen/builder/api/rest_module.go b/extra/middleware/examples/service/gen/builder/api/rest_module.go index 8e361bb6..af72f938 100644 --- a/extra/middleware/examples/service/gen/builder/api/rest_module.go +++ b/extra/middleware/examples/service/gen/builder/api/rest_module.go @@ -10,6 +10,11 @@ import ( gen_greeter_076ae19c "github.com/xgodev/boost/extra/middleware/examples/service/gen/internal/app/api/rest/handler/greeting/greeter" src_greeter_076ae19c "github.com/xgodev/boost/extra/middleware/examples/service/internal/app/api/rest/handler/greeting/greeter" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_greeter_076ae19c.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_greeter_076ae19c.Handler) { srv.Add("GET", "/greetings/:name", h0.Handle) diff --git a/extra/middleware/examples/service/go.mod b/extra/middleware/examples/service/go.mod index 76a38d73..170affab 100644 --- a/extra/middleware/examples/service/go.mod +++ b/extra/middleware/examples/service/go.mod @@ -56,6 +56,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/extra/middleware/examples/simple/gen/builder/api/rest_module.go b/extra/middleware/examples/simple/gen/builder/api/rest_module.go index c1de7e47..827c182d 100644 --- a/extra/middleware/examples/simple/gen/builder/api/rest_module.go +++ b/extra/middleware/examples/simple/gen/builder/api/rest_module.go @@ -10,6 +10,11 @@ import ( gen_greeter_abad0966 "github.com/xgodev/boost/extra/middleware/examples/simple/gen/internal/app/api/rest/handler/greeting/greeter" src_greeter_abad0966 "github.com/xgodev/boost/extra/middleware/examples/simple/internal/app/api/rest/handler/greeting/greeter" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_greeter_abad0966.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_greeter_abad0966.Handler) { srv.Add("GET", "/greetings/:name", h0.Handle) diff --git a/extra/middleware/examples/simple/go.mod b/extra/middleware/examples/simple/go.mod index 757118f1..d1b30c1f 100644 --- a/extra/middleware/examples/simple/go.mod +++ b/extra/middleware/examples/simple/go.mod @@ -56,6 +56,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/extra/middleware/plugins/local/wrapper/cache/examples/service/gen/builder/api/rest_module.go b/extra/middleware/plugins/local/wrapper/cache/examples/service/gen/builder/api/rest_module.go index ca391a41..059eb787 100644 --- a/extra/middleware/plugins/local/wrapper/cache/examples/service/gen/builder/api/rest_module.go +++ b/extra/middleware/plugins/local/wrapper/cache/examples/service/gen/builder/api/rest_module.go @@ -10,6 +10,11 @@ import ( gen_finder_b6539238 "github.com/xgodev/boost/extra/middleware/plugins/local/wrapper/cache/examples/service/gen/internal/app/api/rest/handler/report/finder" src_finder_b6539238 "github.com/xgodev/boost/extra/middleware/plugins/local/wrapper/cache/examples/service/internal/app/api/rest/handler/report/finder" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" ) @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_finder_b6539238.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_finder_b6539238.Handler) { srv.Add("GET", "/reports/:id", h0.Handle) diff --git a/extra/middleware/plugins/local/wrapper/cache/examples/service/go.mod b/extra/middleware/plugins/local/wrapper/cache/examples/service/go.mod index b1a92100..316bc055 100644 --- a/extra/middleware/plugins/local/wrapper/cache/examples/service/go.mod +++ b/extra/middleware/plugins/local/wrapper/cache/examples/service/go.mod @@ -54,6 +54,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/factory/contrib/go-resty/resty/v2/examples/cobra/gen/builder/api/mcp_module.go b/factory/contrib/go-resty/resty/v2/examples/cobra/gen/builder/api/mcp_module.go index 97dff60d..30bd0548 100644 --- a/factory/contrib/go-resty/resty/v2/examples/cobra/gen/builder/api/mcp_module.go +++ b/factory/contrib/go-resty/resty/v2/examples/cobra/gen/builder/api/mcp_module.go @@ -11,6 +11,7 @@ import ( gen_fetcher_2b952e75 "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/examples/cobra/gen/internal/app/api/mcp/handler/page/fetcher" src_fetcher_2b952e75 "github.com/xgodev/boost/factory/contrib/go-resty/resty/v2/examples/cobra/internal/app/api/mcp/handler/page/fetcher" mcpserver "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server" + mcplog "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/server/plugins/local/wrapper/log" mcpfx "github.com/xgodev/boost/fx/modules/factory/contrib/modelcontextprotocol/go-sdk/v1/server" ) @@ -24,6 +25,11 @@ func McpModule() fx.Option { mcpOnce.Do(func() { options = fx.Module("api.mcp", mcpfx.Module(), + fx.Provide(func() []mcpserver.Plugin { + return []mcpserver.Plugin{ + mcplog.Register, + } + }), gen_fetcher_2b952e75.NewModule(), fx.Invoke(func(srv *mcpserver.Server, h0 *src_fetcher_2b952e75.Handler) { mcp.AddTool(srv.Instance(), &mcp.Tool{Name: "bing", Description: "Calls Bing."}, h0.Bing) diff --git a/factory/contrib/go-resty/resty/v2/examples/cobra/go.mod b/factory/contrib/go-resty/resty/v2/examples/cobra/go.mod index d576999d..c3d10598 100644 --- a/factory/contrib/go-resty/resty/v2/examples/cobra/go.mod +++ b/factory/contrib/go-resty/resty/v2/examples/cobra/go.mod @@ -11,10 +11,13 @@ require ( ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gobeam/stringy v0.0.7 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect @@ -37,6 +40,11 @@ require ( github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect diff --git a/factory/contrib/go-resty/resty/v2/examples/cobra/go.sum b/factory/contrib/go-resty/resty/v2/examples/cobra/go.sum index 4198a4db..94afc05d 100644 --- a/factory/contrib/go-resty/resty/v2/examples/cobra/go.sum +++ b/factory/contrib/go-resty/resty/v2/examples/cobra/go.sum @@ -27,6 +27,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= @@ -61,6 +63,11 @@ github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -171,6 +178,8 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxv github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/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= @@ -220,8 +229,6 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -253,6 +260,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -296,6 +305,18 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= @@ -455,8 +476,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= diff --git a/factory/contrib/go.mongodb.org/mongo-driver/v1/conn.go b/factory/contrib/go.mongodb.org/mongo-driver/v1/conn.go index ee86d577..9ec24029 100644 --- a/factory/contrib/go.mongodb.org/mongo-driver/v1/conn.go +++ b/factory/contrib/go.mongodb.org/mongo-driver/v1/conn.go @@ -23,6 +23,11 @@ type Conn struct { Plugins []Plugin } +// Close disconnects the client of the connection. +func (c *Conn) Close(ctx context.Context) error { + return c.Client.Disconnect(ctx) +} + // ClientOptionsPlugin defines a mongo client options plugin signature. type ClientOptionsPlugin = factory.Plugin[*options.ClientOptions] diff --git a/factory/contrib/go.mongodb.org/mongo-driver/v1/conn_test.go b/factory/contrib/go.mongodb.org/mongo-driver/v1/conn_test.go new file mode 100644 index 00000000..d5ee42b3 --- /dev/null +++ b/factory/contrib/go.mongodb.org/mongo-driver/v1/conn_test.go @@ -0,0 +1,27 @@ +package mongo + +import ( + "context" + "testing" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// TestConn_Close disconnects the client of the connection: a module stops +// it with Close when the app stops. +func TestConn_Close(t *testing.T) { + // Connect does not reach the server: no mongo is needed + client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("mongodb://127.0.0.1:1")) + if err != nil { + t.Fatal(err) + } + conn := &Conn{Client: client} + + if err := conn.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + if err := client.Ping(context.Background(), nil); err != mongo.ErrClientDisconnected { + t.Fatalf("Ping after Close = %v, want %v", err, mongo.ErrClientDisconnected) + } +} diff --git a/factory/contrib/go.mongodb.org/mongo-driver/v1/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v1/default_conn.go b/factory/contrib/go.mongodb.org/mongo-driver/v1/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v1/default_conn.go index 5448910e..1e7b19d9 100644 --- a/factory/contrib/go.mongodb.org/mongo-driver/v1/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v1/default_conn.go +++ b/factory/contrib/go.mongodb.org/mongo-driver/v1/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v1/default_conn.go @@ -23,7 +23,7 @@ func init() { var defaultConnOnce sync.Once // DefaultConnModule provides the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v1.Conn), built from the keys under -// boost.factory.mongo without plugins. +// boost.factory.mongo without plugins, and closes it when the app stops. // It returns an empty option after the first call, so every consumer can // include it. func DefaultConnModule() fx.Option { @@ -39,6 +39,11 @@ func DefaultConnModule() fx.Option { return options } -func newDefaultConn(ctx context.Context) (*factory.Conn, error) { - return factory.NewConnWithConfigPath(ctx, defaultConnRoot) +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*factory.Conn, error) { + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil } diff --git a/factory/contrib/go.mongodb.org/mongo-driver/v2/conn.go b/factory/contrib/go.mongodb.org/mongo-driver/v2/conn.go index b7c35259..f309088b 100644 --- a/factory/contrib/go.mongodb.org/mongo-driver/v2/conn.go +++ b/factory/contrib/go.mongodb.org/mongo-driver/v2/conn.go @@ -23,6 +23,11 @@ type Conn struct { Plugins []Plugin } +// Close disconnects the client of the connection. +func (c *Conn) Close(ctx context.Context) error { + return c.Client.Disconnect(ctx) +} + // ClientOptionsPlugin defines a mongo client options plugin signature. type ClientOptionsPlugin = factory.Plugin[*options.ClientOptions] diff --git a/factory/contrib/go.mongodb.org/mongo-driver/v2/conn_test.go b/factory/contrib/go.mongodb.org/mongo-driver/v2/conn_test.go new file mode 100644 index 00000000..86c546e7 --- /dev/null +++ b/factory/contrib/go.mongodb.org/mongo-driver/v2/conn_test.go @@ -0,0 +1,27 @@ +package mongo + +import ( + "context" + "testing" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// TestConn_Close disconnects the client of the connection: a module stops +// it with Close when the app stops. +func TestConn_Close(t *testing.T) { + // Connect does not reach the server: no mongo is needed + client, err := mongo.Connect(options.Client().ApplyURI("mongodb://127.0.0.1:1")) + if err != nil { + t.Fatal(err) + } + conn := &Conn{Client: client} + + if err := conn.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + if err := client.Ping(context.Background(), nil); err != mongo.ErrClientDisconnected { + t.Fatalf("Ping after Close = %v, want %v", err, mongo.ErrClientDisconnected) + } +} diff --git a/factory/contrib/go.mongodb.org/mongo-driver/v2/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go b/factory/contrib/go.mongodb.org/mongo-driver/v2/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go index 6343e793..0387e22f 100644 --- a/factory/contrib/go.mongodb.org/mongo-driver/v2/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go +++ b/factory/contrib/go.mongodb.org/mongo-driver/v2/examples/ping/gen/client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/default_conn.go @@ -23,7 +23,7 @@ func init() { var defaultConnOnce sync.Once // DefaultConnModule provides the default client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn), built from the keys under -// boost.factory.mongo without plugins. +// boost.factory.mongo without plugins, and closes it when the app stops. // It returns an empty option after the first call, so every consumer can // include it. func DefaultConnModule() fx.Option { @@ -39,6 +39,11 @@ func DefaultConnModule() fx.Option { return options } -func newDefaultConn(ctx context.Context) (*factory.Conn, error) { - return factory.NewConnWithConfigPath(ctx, defaultConnRoot) +func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*factory.Conn, error) { + c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil } diff --git a/factory/contrib/google.golang.org/grpc/v1/server/error.go b/factory/contrib/google.golang.org/grpc/v1/server/error.go index a6bfb7c8..c32f2a55 100644 --- a/factory/contrib/google.golang.org/grpc/v1/server/error.go +++ b/factory/contrib/google.golang.org/grpc/v1/server/error.go @@ -31,7 +31,9 @@ func grpcCodeFor(kind errors.Kind) codes.Code { return codes.InvalidArgument case errors.KindServiceUnavailable: return codes.Unavailable - case errors.KindConflict, errors.KindAlreadyExists: + case errors.KindConflict: + return codes.Aborted + case errors.KindAlreadyExists: return codes.AlreadyExists case errors.KindNotImplemented, errors.KindNotProvisioned: return codes.Unimplemented diff --git a/factory/contrib/google.golang.org/grpc/v1/server/error_kind_test.go b/factory/contrib/google.golang.org/grpc/v1/server/error_kind_test.go new file mode 100644 index 00000000..58478a65 --- /dev/null +++ b/factory/contrib/google.golang.org/grpc/v1/server/error_kind_test.go @@ -0,0 +1,29 @@ +package server + +import ( + "testing" + + "github.com/xgodev/boost/model/errors" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestError_ConflictIsNotADuplicate: a conflict (the state does not allow +// the call, HTTP 409) answers Aborted; only a duplicate answers +// AlreadyExists. +func TestError_ConflictIsNotADuplicate(t *testing.T) { + for _, c := range []struct { + name string + err error + want codes.Code + }{ + {"conflict", errors.Conflictf("sold out"), codes.Aborted}, + {"already exists", errors.AlreadyExistsf("duplicate"), codes.AlreadyExists}, + } { + t.Run(c.name, func(t *testing.T) { + if got := status.Code(Error(c.err)); got != c.want { + t.Fatalf("code = %v, want %v", got, c.want) + } + }) + } +} diff --git a/factory/contrib/google.golang.org/grpc/v1/server/error_test.go b/factory/contrib/google.golang.org/grpc/v1/server/error_test.go index 5902d430..fdcb3deb 100644 --- a/factory/contrib/google.golang.org/grpc/v1/server/error_test.go +++ b/factory/contrib/google.golang.org/grpc/v1/server/error_test.go @@ -19,8 +19,8 @@ func TestError_RegisteredCustom(t *testing.T) { return ok }, errors.KindConflict) - if got := status.Code(Error(&customConflict{"dup"})); got != codes.AlreadyExists { - t.Fatalf("code = %v, want AlreadyExists", got) + if got := status.Code(Error(&customConflict{"dup"})); got != codes.Aborted { + t.Fatalf("code = %v, want Aborted", got) } } diff --git a/factory/contrib/modelcontextprotocol/go-sdk/v1/client/examples/greet/gen/builder/api/rest_module.go b/factory/contrib/modelcontextprotocol/go-sdk/v1/client/examples/greet/gen/builder/api/rest_module.go index f2de91f3..ff6ddee0 100644 --- a/factory/contrib/modelcontextprotocol/go-sdk/v1/client/examples/greet/gen/builder/api/rest_module.go +++ b/factory/contrib/modelcontextprotocol/go-sdk/v1/client/examples/greet/gen/builder/api/rest_module.go @@ -8,6 +8,11 @@ import ( "go.uber.org/fx" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" gen_greeter_ee651ff9 "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/client/examples/greet/gen/internal/app/api/rest/handler/greeting/greeter" src_greeter_ee651ff9 "github.com/xgodev/boost/factory/contrib/modelcontextprotocol/go-sdk/v1/client/examples/greet/internal/app/api/rest/handler/greeting/greeter" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_greeter_ee651ff9.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_greeter_ee651ff9.Handler) { srv.Add("GET", "/greetings/:name", h0.Handle) diff --git a/factory/contrib/redis/go-redis/v9/examples/client/gen/client/github.com/redis/go-redis/v9/default_client.go b/factory/contrib/redis/go-redis/v9/examples/client/gen/client/github.com/redis/go-redis/v9/default_client.go index 4fa6c273..b7acf8f4 100644 --- a/factory/contrib/redis/go-redis/v9/examples/client/gen/client/github.com/redis/go-redis/v9/default_client.go +++ b/factory/contrib/redis/go-redis/v9/examples/client/gen/client/github.com/redis/go-redis/v9/default_client.go @@ -26,7 +26,7 @@ func init() { var defaultClientOnce sync.Once // DefaultClientModule provides the default client (*github.com/redis/go-redis/v9.Client), built from the keys under -// boost.factory.redis with the plugins health. +// boost.factory.redis with the plugins health, and closes it when the app stops. // It returns an empty option after the first call, so every consumer can // include it. func DefaultClientModule() fx.Option { @@ -42,10 +42,15 @@ func DefaultClientModule() fx.Option { return options } -func newDefaultClient(ctx context.Context) (*client.Client, error) { +func newDefaultClient(ctx context.Context, lc fx.Lifecycle) (*client.Client, error) { health, err := healthplugin.NewClientHealthWithConfigPath(defaultClientRoot + ".plugins.health") if err != nil { return nil, err } - return factory.NewClientWithConfigPath(ctx, defaultClientRoot, health.Register) + c, err := factory.NewClientWithConfigPath(ctx, defaultClientRoot, health.Register) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil } diff --git a/factory/contrib/redis/go-redis/v9/examples/cluster/gen/client/github.com/redis/go-redis/v9/default_clusterclient.go b/factory/contrib/redis/go-redis/v9/examples/cluster/gen/client/github.com/redis/go-redis/v9/default_clusterclient.go index 05e7e48d..afbd717c 100644 --- a/factory/contrib/redis/go-redis/v9/examples/cluster/gen/client/github.com/redis/go-redis/v9/default_clusterclient.go +++ b/factory/contrib/redis/go-redis/v9/examples/cluster/gen/client/github.com/redis/go-redis/v9/default_clusterclient.go @@ -26,7 +26,7 @@ func init() { var defaultClusterClientOnce sync.Once // DefaultClusterClientModule provides the default client (*github.com/redis/go-redis/v9.ClusterClient), built from the keys under -// boost.factory.redis with the plugins health. +// boost.factory.redis with the plugins health, and closes it when the app stops. // It returns an empty option after the first call, so every consumer can // include it. func DefaultClusterClientModule() fx.Option { @@ -42,10 +42,15 @@ func DefaultClusterClientModule() fx.Option { return options } -func newDefaultClusterClient(ctx context.Context) (*client.ClusterClient, error) { +func newDefaultClusterClient(ctx context.Context, lc fx.Lifecycle) (*client.ClusterClient, error) { health, err := healthplugin.NewClusterHealthWithConfigPath(defaultClusterClientRoot + ".plugins.health") if err != nil { return nil, err } - return factory.NewClusterClientWithConfigPath(ctx, defaultClusterClientRoot, health.Register) + c, err := factory.NewClusterClientWithConfigPath(ctx, defaultClusterClientRoot, health.Register) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil } diff --git a/factory/local/extra/middleware/examples/error/gen/builder/api/rest_module.go b/factory/local/extra/middleware/examples/error/gen/builder/api/rest_module.go index bca055f6..62776f56 100644 --- a/factory/local/extra/middleware/examples/error/gen/builder/api/rest_module.go +++ b/factory/local/extra/middleware/examples/error/gen/builder/api/rest_module.go @@ -8,6 +8,11 @@ import ( "go.uber.org/fx" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" gen_greeter_ebf8938b "github.com/xgodev/boost/factory/local/extra/middleware/examples/error/gen/internal/app/api/rest/handler/greeting/greeter" src_greeter_ebf8938b "github.com/xgodev/boost/factory/local/extra/middleware/examples/error/internal/app/api/rest/handler/greeting/greeter" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_greeter_ebf8938b.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_greeter_ebf8938b.Handler) { srv.Add("GET", "/greetings/:name", h0.Handle) diff --git a/factory/local/extra/middleware/examples/error/go.mod b/factory/local/extra/middleware/examples/error/go.mod index 12a9034e..83a225a9 100644 --- a/factory/local/extra/middleware/examples/error/go.mod +++ b/factory/local/extra/middleware/examples/error/go.mod @@ -56,6 +56,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/factory/local/extra/middleware/examples/simple/gen/builder/api/rest_module.go b/factory/local/extra/middleware/examples/simple/gen/builder/api/rest_module.go index 024bf7dc..72b2b7ef 100644 --- a/factory/local/extra/middleware/examples/simple/gen/builder/api/rest_module.go +++ b/factory/local/extra/middleware/examples/simple/gen/builder/api/rest_module.go @@ -8,6 +8,11 @@ import ( "go.uber.org/fx" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" gen_greeter_297cd118 "github.com/xgodev/boost/factory/local/extra/middleware/examples/simple/gen/internal/app/api/rest/handler/greeting/greeter" src_greeter_297cd118 "github.com/xgodev/boost/factory/local/extra/middleware/examples/simple/internal/app/api/rest/handler/greeting/greeter" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_greeter_297cd118.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_greeter_297cd118.Handler) { srv.Add("GET", "/greetings/:name", h0.Handle) diff --git a/factory/local/extra/middleware/examples/simple/go.mod b/factory/local/extra/middleware/examples/simple/go.mod index b8431e9b..795a75da 100644 --- a/factory/local/extra/middleware/examples/simple/go.mod +++ b/factory/local/extra/middleware/examples/simple/go.mod @@ -56,6 +56,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/fx/modules/bootstrap/job/README.md b/fx/modules/bootstrap/job/README.md new file mode 100644 index 00000000..19787d55 --- /dev/null +++ b/fx/modules/bootstrap/job/README.md @@ -0,0 +1,29 @@ +# fx/modules/bootstrap/job + +fx module that runs the provided [`job.Handler`](../../../../bootstrap/job) +once and stops the app, so the process exits when the job is done: + +```go +boost.Start() +fxfact.NewApp( + fx.Provide(func(a *Auditor) job.Handler { return a.Run }), + jobfx.Module(), +).Run() +``` + +builder generates this for `@Job`. + +## Lifecycle + +- **OnStart** runs the handler in a goroutine, with a context the stop + cancels, so `fx.New` returns. +- When the handler returns, the module calls `Shutdowner.Shutdown` with exit + code 0 on nil; an error is logged at ERROR (`job failed: `) and + exits 1. +- **OnStop** (SIGTERM, or another hook stopping the app first) cancels the + handler's context and waits for it to return, up to the stop timeout (fx's + 15s by default). Its error fails the stop, so the app exits 1, and nil lets + it exit 0; a handler still running at the timeout fails the stop with the + deadline. A job cut short never reports success unless it says so. + +`Module()` returns an empty option after the first call. diff --git a/fx/modules/bootstrap/job/module.go b/fx/modules/bootstrap/job/module.go new file mode 100644 index 00000000..2e16ebb7 --- /dev/null +++ b/fx/modules/bootstrap/job/module.go @@ -0,0 +1,88 @@ +package job + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + + "github.com/xgodev/boost/bootstrap/job" + fxcontext "github.com/xgodev/boost/fx/modules/core/context" + "github.com/xgodev/boost/wrapper/log" + "go.uber.org/fx" +) + +type params struct { + fx.In + Lifecycle fx.Lifecycle + Shutdowner fx.Shutdowner + Handler job.Handler +} + +var once sync.Once + +// Module fx module that runs the job.Handler the app provides once. It runs +// from the app's OnStart and shuts the app down when it returns: exit code 0 +// on nil, 1 on an error, logged at ERROR. OnStop (SIGTERM) cancels its +// context and waits for it until the stop timeout; a handler that had not +// finished fails the stop, so the app exits 1. +func Module() fx.Option { + options := fx.Options() + + once.Do(func() { + options = fx.Options( + fxcontext.Module(), + fx.Invoke(register), + ) + }) + + return options +} + +func register(ctx context.Context, p params) { + runCtx, cancel := context.WithCancel(ctx) + ran := make(chan struct{}) + var err error + // reported is set by whoever reports how the job ended: the handler + // returning on its own, or OnStop + var reported atomic.Bool + + p.Lifecycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + go func() { + defer close(ran) + err = p.Handler(runCtx) + cancel() + if !reported.CompareAndSwap(false, true) { + return // the app is stopping: OnStop reports it + } + logger := log.FromContext(ctx) + code := 0 + if err != nil { + logger.Errorf("job failed: %v", err) + code = 1 + } + if err := p.Shutdowner.Shutdown(fx.ExitCode(code)); err != nil { + logger.Error(err) + } + }() + return nil + }, + OnStop: func(stopCtx context.Context) error { + if !reported.CompareAndSwap(false, true) { + return nil // the handler returned and shut the app down + } + cancel() + + select { + case <-ran: + if err != nil { + return fmt.Errorf("job stopped before finishing: %w", err) + } + return nil + case <-stopCtx.Done(): + return fmt.Errorf("job did not return before the stop timeout: %w", stopCtx.Err()) + } + }, + }) +} diff --git a/fx/modules/bootstrap/job/module_test.go b/fx/modules/bootstrap/job/module_test.go new file mode 100644 index 00000000..21d1a81a --- /dev/null +++ b/fx/modules/bootstrap/job/module_test.go @@ -0,0 +1,131 @@ +package job + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/bootstrap/job" + fxcontext "github.com/xgodev/boost/fx/modules/core/context" + "go.uber.org/fx" +) + +const wait = 5 * time.Second + +// TestMain boots boost (the module logs) and spends the core context +// module's once-per-process registration, so every app below supplies its +// own context. +func TestMain(m *testing.M) { + boost.Start() + fxcontext.Module() + os.Exit(m.Run()) +} + +// newApp builds the module with the handler; building must not run it. +func newApp(t *testing.T, h job.Handler) *fx.App { + t.Helper() + once = sync.Once{} // Module() registers once per process + + app := fx.New(fx.NopLogger, Module(), fx.Provide(context.Background, func() job.Handler { return h })) + require.NoError(t, app.Err()) + return app +} + +func start(t *testing.T, app *fx.App) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), wait) + defer cancel() + require.NoError(t, app.Start(ctx)) +} + +// shutdown waits for the signal the job sends when its handler returns. +func shutdown(t *testing.T, app *fx.App) fx.ShutdownSignal { + t.Helper() + select { + case sig := <-app.Wait(): + return sig + case <-time.After(wait): + t.Fatal("the job did not shut the app down") + return fx.ShutdownSignal{} + } +} + +func stop(app *fx.App, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return app.Stop(ctx) +} + +func TestModule_RunsOnceOnStartAndExitsZero(t *testing.T) { + runs := 0 + app := newApp(t, func(context.Context) error { + runs++ + return nil + }) + assert.Zero(t, runs, "the handler ran while building the app") + + start(t, app) + sig := shutdown(t, app) + assert.Equal(t, 0, sig.ExitCode) + require.NoError(t, stop(app, wait)) + assert.Equal(t, 1, runs) +} + +func TestModule_ErrorExitsOne(t *testing.T) { + app := newApp(t, func(context.Context) error { return errors.New("no rows") }) + + start(t, app) + sig := shutdown(t, app) + assert.Equal(t, 1, sig.ExitCode) + require.NoError(t, stop(app, wait), "the error is reported once, by the exit code and its log line") +} + +func TestModule_StopCancelsTheRunAndFails(t *testing.T) { + cancelled := make(chan struct{}) + app := newApp(t, func(ctx context.Context) error { + <-ctx.Done() + close(cancelled) + return ctx.Err() + }) + + start(t, app) + err := stop(app, wait) + require.Error(t, err, "a job stopped before it finished must not exit 0") + assert.ErrorIs(t, err, context.Canceled) + select { + case <-cancelled: + default: + t.Fatal("stop did not cancel the context of the handler") + } +} + +func TestModule_StopAfterACleanReturnSucceeds(t *testing.T) { + app := newApp(t, func(ctx context.Context) error { + <-ctx.Done() + return nil // finished its work before giving up + }) + + start(t, app) + require.NoError(t, stop(app, wait)) +} + +func TestModule_StopGivesUpAtTheStopTimeout(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + app := newApp(t, func(context.Context) error { + <-release // ignores its context + return nil + }) + + start(t, app) + began := time.Now() + err := stop(app, 100*time.Millisecond) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, time.Since(began), wait) +} diff --git a/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v1/module.go b/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v1/module.go index 4996e556..f7bb46f0 100644 --- a/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v1/module.go +++ b/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v1/module.go @@ -21,6 +21,7 @@ type params struct { var once sync.Once +// Module fx module for the mongo *Conn. The client disconnects on app stop. func Module() fx.Option { options := fx.Options() @@ -28,8 +29,13 @@ func Module() fx.Option { options = fx.Module("mongo", fxcontext.Module(), fx.Provide( - func(ctx context.Context, p params) (*mongo.Conn, error) { - return mongo.NewConn(ctx, p.Plugins...) //nolint:wrapcheck // This must be resolved in another task + func(ctx context.Context, lc fx.Lifecycle, p params) (*mongo.Conn, error) { + conn, err := mongo.NewConn(ctx, p.Plugins...) + if err != nil { + return nil, err //nolint:wrapcheck // This must be resolved in another task + } + lc.Append(fx.StopHook(conn.Close)) + return conn, nil }, func(conn *mongo.Conn) *m.Database { return conn.Database diff --git a/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v2/module.go b/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v2/module.go index b62fc220..20c6c323 100644 --- a/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v2/module.go +++ b/fx/modules/factory/contrib/go.mongodb.org/mongo-driver/v2/module.go @@ -21,6 +21,7 @@ type params struct { var once sync.Once +// Module fx module for the mongo *Conn. The client disconnects on app stop. func Module() fx.Option { options := fx.Options() @@ -28,8 +29,13 @@ func Module() fx.Option { options = fx.Module("mongo", fxcontext.Module(), fx.Provide( - func(ctx context.Context, p params) (*mongo.Conn, error) { - return mongo.NewConn(ctx, p.Plugins...) //nolint:wrapcheck // This must be resolved in another task + func(ctx context.Context, lc fx.Lifecycle, p params) (*mongo.Conn, error) { + conn, err := mongo.NewConn(ctx, p.Plugins...) + if err != nil { + return nil, err //nolint:wrapcheck // This must be resolved in another task + } + lc.Append(fx.StopHook(conn.Close)) + return conn, nil }, func(conn *mongo.Conn) *m.Database { return conn.Database diff --git a/fx/modules/factory/contrib/redis/go-redis/v9/module.go b/fx/modules/factory/contrib/redis/go-redis/v9/module.go index 3c19338f..abb87379 100644 --- a/fx/modules/factory/contrib/redis/go-redis/v9/module.go +++ b/fx/modules/factory/contrib/redis/go-redis/v9/module.go @@ -10,24 +10,29 @@ import ( "go.uber.org/fx" ) -var once sync.Once - type clusterParams struct { fx.In Plugins []redis.ClusterPlugin `optional:"true"` } -// ClusterModule fx module for redis cluster client. +var clusterOnce sync.Once + +// ClusterModule fx module for redis cluster client. The client closes on app stop. func ClusterModule() fx.Option { options := fx.Options() - once.Do(func() { + clusterOnce.Do(func() { options = fx.Options( contextfx.Module(), fx.Provide( - func(ctx context.Context, p clusterParams) (*r.ClusterClient, error) { - return redis.NewClusterClient(ctx, p.Plugins...) + func(ctx context.Context, lc fx.Lifecycle, p clusterParams) (*r.ClusterClient, error) { + client, err := redis.NewClusterClient(ctx, p.Plugins...) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(client.Close)) + return client, nil }, ), ) @@ -41,17 +46,24 @@ type clientParams struct { Plugins []redis.Plugin `optional:"true"` } -// ClientModule fx module for redis client. +var clientOnce sync.Once + +// ClientModule fx module for redis client. The client closes on app stop. func ClientModule() fx.Option { options := fx.Options() - once.Do(func() { + clientOnce.Do(func() { options = fx.Options( contextfx.Module(), fx.Provide( - func(ctx context.Context, p clientParams) (*r.Client, error) { - return redis.NewClient(ctx, p.Plugins...) + func(ctx context.Context, lc fx.Lifecycle, p clientParams) (*r.Client, error) { + client, err := redis.NewClient(ctx, p.Plugins...) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(client.Close)) + return client, nil }, ), ) diff --git a/fx/modules/factory/contrib/redis/go-redis/v9/module_test.go b/fx/modules/factory/contrib/redis/go-redis/v9/module_test.go new file mode 100644 index 00000000..f6d9b4f3 --- /dev/null +++ b/fx/modules/factory/contrib/redis/go-redis/v9/module_test.go @@ -0,0 +1,18 @@ +package redis + +import ( + "testing" + + r "github.com/redis/go-redis/v9" + "go.uber.org/fx" +) + +// TestModules_Together: an app takes both the client and the cluster +// client; ValidateApp checks the graph without reaching redis. +func TestModules_Together(t *testing.T) { + err := fx.ValidateApp(ClusterModule(), ClientModule(), fx.NopLogger, + fx.Invoke(func(*r.Client, *r.ClusterClient) {})) + if err != nil { + t.Fatalf("ClientModule after ClusterModule: %v", err) + } +} diff --git a/inject/README.md b/inject/README.md index 0f3ef9a6..e288a618 100644 --- a/inject/README.md +++ b/inject/README.md @@ -18,6 +18,7 @@ function plus a root `Module()` that wires the whole application. | `@Inject(group=X, index=N)` | `@Provide`/`@Invoke` function | parameter `N` (a slice `[]T`) receives every `T` of group `X` | | `@Inject(name=X, index=N, plugins=[a, b])` | `@Provide`/`@Invoke` function | parameter `N`, a factory client (`*resty.Client`, `*nats.Conn`, `*gocql.Session`, ...), is built by the generator with the plugins `a` and `b` (see below) | | `@Inject(index=N, drivers=[a, b])` | `@Provide`/`@Invoke` function | parameter `N`, a `*publisher.Publisher`, is built by the generator with only the drivers `a` and `b` (see [Publisher](#publisher)) | +| `@Inject(name=X, index=N, drivers=[d])` | `@Provide`/`@Invoke` function | parameter `N`, a `*sql.DB`, is built by the generator through the factory of driver `d` (`godror`, `pgx`; see [SQL databases](#sql-databases)) | | `@Invoke` | function | the function runs when the application starts (`fx.Invoke`) | | `@Default` | `@Provide` constructor | default implementation of a value with two or more providers; config picks one (see below) | | `@Config` | struct | the struct's fields are config keys; the generator registers them and provides `*Struct` (see below) | @@ -268,9 +269,11 @@ func newQuoteClient(ctx context.Context) (*resty.Client, error) { | `*gocql.Session` (`github.com/apache/cassandra-gocql-driver/v2`) | `factory/contrib/apache/cassandra-gocql-driver/v2` | `health` | | `*sns.Client`, `*sqs.Client`, `*kinesis.Client` (`github.com/aws/aws-sdk-go-v2/service/...`) | `factory/contrib/aws/aws-sdk-go-v2/v1/client/` (root `boost.factory.aws`) | `datadog` | | `*kafka.Producer` (`github.com/confluentinc/confluent-kafka-go/v2/kafka`, needs cgo) | `factory/contrib/confluentinc/confluent-kafka-go/v2` | none | +| `*sql.DB` (`database/sql`), with `drivers=[godror]` or `drivers=[pgx]` | `factory/contrib/godror/godror/v0` (root `boost.factory.godror`, needs cgo) or `factory/contrib/jackc/pgx/v5` (root `boost.factory.pgx`) | `health`, `otel`, `datadog` (`factory/core/database/sql/plugins`) | -NATS, Pub/Sub, Bigtable, gocql and kafka clients hold connections, so their constructor also -takes `fx.Lifecycle` and closes the client when the app stops: +Redis, MongoDB, NATS, Pub/Sub, Bigtable, gocql, kafka and SQL clients hold connections, so their +constructor also takes `fx.Lifecycle` and closes the client when the app stops (`*mongo.Conn` +through `Conn.Close`, which disconnects its `*mongo.Client`): ```go func newEventsConn(ctx context.Context, lc fx.Lifecycle) (*nats.Conn, error) { @@ -306,6 +309,49 @@ func newEventsConn(ctx context.Context, lc fx.Lifecycle) (*nats.Conn, error) { built, so a client of an implementation that config did not choose is never built. +### SQL databases + +A `*sql.DB` is built by the factory of the driver its `@Inject` names, so +only that driver is imported (godror needs cgo, and the Oracle client +libraries at run time): + +```go +package oracle // example.com/app/internal/pkg/datastore/gold/oracle + +// @Provide(index=0) +// @Inject(index=0, name=gold, drivers=[godror], plugins=[health, otel]) +func New(db *sql.DB) gold.Repository { ... } +``` + +The generator writes `gen/client/database/sql/gold_db.go`, which provides +the `*sql.DB` `name:"gold"` from the keys under +`boost.factory.godror.clients.gold` (`connectString`, `username`, +`password`, ...; env `BOOST_FACTORY_GODROR_CLIENTS_GOLD_CONNECTSTRING`) and +closes it when the app stops: + +```go +func newGoldDB(ctx context.Context, lc fx.Lifecycle) (*sql.DB, error) { + health, err := healthplugin.NewHealthPluginWithConfigPath(goldDBRoot + ".plugins.health") + ... + c, err := factory.NewDBWithConfigPath(ctx, goldDBRoot, health, otel) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} +``` + +- **One driver.** `drivers` names exactly one: `godror` (Oracle) or `pgx` + (PostgreSQL). Consumers of one `*sql.DB` name the same driver, or only one + names it and the others take it; a `*sql.DB` no consumer names a driver + for fails the generation. +- **The app wins.** A `*sql.DB` the app provides needs no driver, and + `drivers` on its consumers fails the generation. +- The plugins are passed to the factory as they are (`health`, `otel`), + since they are `sql.Plugin` values; the pool keys stay under + `boost.factory.sql`. + ## Publisher A `*publisher.Publisher` (`github.com/xgodev/boost/wrapper/publisher`) @@ -399,8 +445,11 @@ The generator fails, without touching the output directory, when: - `plugins` is on a `@Provide`, on a parameter that is not a factory client or that the app provides, or with `group`; lists an unknown or repeated plugin; or differs between two consumers of one client; -- `drivers` is on a `@Provide` or on a parameter that is not - `*publisher.Publisher`, is empty, or lists an unknown or repeated driver; +- `drivers` is on a `@Provide` or on a parameter that is neither + `*publisher.Publisher` nor `*sql.DB`, is empty, or lists an unknown or + repeated driver; +- a `*sql.DB` inject builds has no driver, `drivers` names more than one, + two consumers of it name different drivers, or the app provides it; - a client name is not lowerCamel, or two clients would be written to the same file (`name=default` and the unnamed client); - the dependencies have a cycle, or a function depends on what it provides; diff --git a/inject/client.go b/inject/client.go index e3d4c215..302cad76 100644 --- a/inject/client.go +++ b/inject/client.go @@ -25,6 +25,16 @@ type ClientFactory struct { Plugins map[string]ClientPlugin // by name: the last segment of the plugin's config root Constraint string // build constraint the client needs ("cgo"), "" when it builds anywhere Close bool // the generated module closes the client when the app stops + // Drivers are the factories that build the type, by name, when there is + // more than one (*sql.DB through godror or pgx): the @Inject of the + // client names one with drivers=, which sets Build and Root. + Drivers map[string]ClientDriver +} + +// ClientDriver is one of the factories of a client type built by several. +type ClientDriver struct { + Build string // factory import path + "." + the ...WithConfigPath constructor + Root string // config root of the factory } // ClientPlugin is a plugin of a client factory. The value of its constructor @@ -34,6 +44,7 @@ type ClientPlugin struct { Path string // import path of the plugin New string // ...WithConfigPath constructor of the plugin Register string // method passed to the factory, "" for Register + Value bool // the factory takes the value itself (a sql.Plugin), not a method } const boostFactories = "github.com/xgodev/boost/factory/contrib/" @@ -62,14 +73,14 @@ func init() { "prometheus": {Path: redis + "/plugins/contrib/prometheus/client_golang/v1", New: others}, } } - addClientFactory("github.com/redis/go-redis/v9", "Client", "redis", redis, "NewClientWithConfigPath", "boost.factory.redis", + addClosingClientFactory("github.com/redis/go-redis/v9", "Client", "redis", redis, "NewClientWithConfigPath", "boost.factory.redis", redisPlugins("NewClientHealthWithConfigPath", "NewClientWithConfigPath")) - addClientFactory("github.com/redis/go-redis/v9", "ClusterClient", "redis", redis, "NewClusterClientWithConfigPath", "boost.factory.redis", + addClosingClientFactory("github.com/redis/go-redis/v9", "ClusterClient", "redis", redis, "NewClusterClientWithConfigPath", "boost.factory.redis", redisPlugins("NewClusterHealthWithConfigPath", "NewClusterWithConfigPath")) for _, version := range []string{"v1", "v2"} { mongo := boostFactories + "go.mongodb.org/mongo-driver/" + version - addClientFactory(mongo, "Conn", "mongo", mongo, "NewConnWithConfigPath", "boost.factory.mongo", map[string]ClientPlugin{ + addClosingClientFactory(mongo, "Conn", "mongo", mongo, "NewConnWithConfigPath", "boost.factory.mongo", map[string]ClientPlugin{ "health": {Path: mongo + "/plugins/local/health", New: "NewHealthWithConfigPath"}, "otel": {Path: mongo + "/plugins/contrib/go.opentelemetry.io/contrib/v0", New: "NewOtelMongoWithConfigPath"}, "datadog": {Path: mongo + "/plugins/contrib/datadog/dd-trace-go/v1", New: "NewDatadogWithConfigPath"}, @@ -112,6 +123,21 @@ func init() { }) } + // *sql.DB is built by the factory of the driver its @Inject names; the + // sql plugins wrap the connector of any of them + sqlPlugins := "github.com/xgodev/boost/factory/core/database/sql/plugins" + db := ClientFactory{Path: "database/sql", Type: "DB", Package: "sql", Close: true, + Drivers: map[string]ClientDriver{ + "godror": {Build: boostFactories + "godror/godror/v0.NewDBWithConfigPath", Root: "boost.factory.godror"}, + "pgx": {Build: boostFactories + "jackc/pgx/v5.NewDBWithConfigPath", Root: "boost.factory.pgx"}, + }, + Plugins: map[string]ClientPlugin{ + "health": {Path: sqlPlugins + "/local/health", New: "NewHealthPluginWithConfigPath", Value: true}, + "otel": {Path: sqlPlugins + "/contrib/xsam/otelsql/v0", New: "NewOTelWithConfigPath", Value: true}, + "datadog": {Path: sqlPlugins + "/contrib/datadog/dd-trace-go/v1", New: "NewDatadogWithConfigPath", Value: true}, + }} + clientFactories[db.Path+"."+db.Type] = db + // the client of the kafka publisher driver (see publisherDrivers); // confluent-kafka-go binds librdkafka through cgo kafka := ClientFactory{Path: "github.com/confluentinc/confluent-kafka-go/v2/kafka", Type: "Producer", Package: "kafka", @@ -132,6 +158,19 @@ func addClosingClientFactory(path, tp, pkg, factory, build, root string, plugins clientFactories[path+"."+tp] = f } +// driverClients returns the client types built by one of several drivers, +// sorted. +func driverClients() []string { + var types []string + for key, f := range clientFactories { + if len(f.Drivers) > 0 { + types = append(types, "*"+key) + } + } + sort.Strings(types) + return types +} + // supportedClients returns the client types inject builds, sorted. func supportedClients() []string { types := make([]string, 0, len(clientFactories)) @@ -152,6 +191,27 @@ func (f ClientFactory) PluginNames() []string { return names } +// DriverNames returns the names of the factory's drivers, sorted; none when +// one factory builds the type. +func (f ClientFactory) DriverNames() []string { + names := make([]string, 0, len(f.Drivers)) + for name := range f.Drivers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Driver returns the factory built through its driver name. +func (f ClientFactory) Driver(name string) (ClientFactory, bool) { + d, ok := f.Drivers[name] + if !ok { + return ClientFactory{}, false + } + f.Build, f.Root = d.Build, d.Root + return f, true +} + // FactoryPath returns the import path of the factory. func (f ClientFactory) FactoryPath() string { return f.Build[:strings.LastIndex(f.Build, ".")] @@ -202,6 +262,22 @@ func parsePlugins(raw string) ([]string, error) { return parseList("plugins", raw) } +// parseClientDriver reads the drivers attribute of the @Inject of a client +// built by several factories: exactly one of them. +func parseClientDriver(f ClientFactory, raw string) (string, error) { + names, err := parseList("drivers", raw) + if err != nil { + return "", err + } + if len(names) != 1 { + return "", errors.NotValidf("drivers=%s: *%s.%s is built by one driver, one of %s", pluginList(names), f.Path, f.Type, strings.Join(f.DriverNames(), ", ")) + } + if _, ok := f.Drivers[names[0]]; !ok { + return "", errors.NotValidf("*%s.%s has no driver %s; its drivers are %s", f.Path, f.Type, names[0], strings.Join(f.DriverNames(), ", ")) + } + return names[0], nil +} + // parseList reads the list attribute attr of an annotation: "[a, b]" or a // single name. It returns nil when raw is empty and a non-nil empty list for // "[]". @@ -242,6 +318,7 @@ func parseList(attr, raw string) ([]string, error) { // and name asked by @Inject parameters that no annotated function provides. type Client struct { Factory ClientFactory + Driver string // driver of a factory with Drivers, "" for the others Name string // fx name of the client, "" for the default client Plugins []string // in the declared order, nil when no consumer lists them Consumers []string // keys of the components injecting the client, sorted @@ -251,13 +328,16 @@ type Client struct { // consumer without plugins takes the client as another consumer declares it. // It is NotValid when plugins are listed for a client the application // provides, a name is not reachable from an environment variable or two -// consumers of a client list different plugins, and AlreadyExists when two +// consumers of a client list different plugins. A client built by several +// factories takes the one its consumers name with drivers=; it is NotValid +// when none or two of them are named. It is AlreadyExists when two // clients share a file (a client named "default" and the unnamed one). func Clients(g *graph.Graph[Component]) ([]Client, error) { provided := providedValues(g) clients := map[string]*Client{} declaredBy := map[string]string{} // client id -> key of the consumer listing its plugins + driverBy := map[string]string{} // client id -> key of the consumer naming its driver for _, key := range g.SortedKeys() { comp := g.Vertices[key].Value for _, idx := range sortedIndexes(comp.Injects) { @@ -273,6 +353,10 @@ func Clients(g *graph.Graph[Component]) ([]Client, error) { return nil, errors.NotValidf("@Inject on %s parameter %d has plugins, but %s is provided by %s: plugins apply only to a client inject builds", key, idx, tp, provider) } + if ann.Drivers != "" { + return nil, errors.NotValidf("@Inject on %s parameter %d has drivers, but %s is provided by %s: drivers apply only to a client inject builds", + key, idx, tp, provider) + } continue } if ann.Name != "" && !envSegment.MatchString(ann.Name) { @@ -291,6 +375,18 @@ func Clients(g *graph.Graph[Component]) ([]Client, error) { c = &Client{Factory: f, Name: ann.Name} clients[id] = c } + if ann.Drivers != "" { + driver, err := parseClientDriver(f, ann.Drivers) + if err != nil { + return nil, errors.NewNotValid(err, fmt.Sprintf("@Inject on %s parameter %d", key, idx)) + } + if c.Driver != "" && c.Driver != driver { + return nil, errors.NotValidf("%s asks for the %s with driver %s, but %s asks for it with %s: the consumers of a client name the same driver, or only one names it", + driverBy[id], c, c.Driver, key, driver) + } + c.Driver = driver + driverBy[id] = key + } if plugins != nil { if c.Plugins != nil && !slices.Equal(c.Plugins, plugins) { return nil, errors.NotValidf("%s asks for the %s with plugins %s, but %s asks for it with %s: the consumers of a client list the same plugins, or only one lists them", @@ -320,6 +416,18 @@ func Clients(g *graph.Graph[Component]) ([]Client, error) { return list[i].Name < list[j].Name }) + for i, c := range list { + if len(c.Factory.Drivers) == 0 { + continue + } + f, ok := c.Factory.Driver(c.Driver) + if !ok { + return nil, errors.NotValidf("no consumer of the %s names its driver (%s): add drivers=[%s] to the @Inject of one of them; the drivers are %s", + c, strings.Join(c.Consumers, ", "), c.Factory.DriverNames()[0], strings.Join(c.Factory.DriverNames(), ", ")) + } + list[i].Factory = f + } + files := map[string]Client{} for _, c := range list { if other, dup := files[c.File()]; dup { @@ -481,7 +589,7 @@ func clientData(c Client) ClientData { plugin := c.Factory.Plugins[name] alias := name + "plugin" register := plugin.Register - if register == "" { + if register == "" && !plugin.Value { register = "Register" } data.Plugins = append(data.Plugins, ClientPluginData{Name: name, Alias: alias, New: plugin.New, Register: register}) diff --git a/inject/client_test.go b/inject/client_test.go index d74488a6..a1b30a83 100644 --- a/inject/client_test.go +++ b/inject/client_test.go @@ -23,6 +23,7 @@ const ( bigtablePath = "cloud.google.com/go/bigtable" gocqlPath = "github.com/gocql/gocql" gocql2Path = "github.com/apache/cassandra-gocql-driver/v2" + sqlDBKey = "database/sql.DB" ) func TestClientFactory(t *testing.T) { @@ -88,10 +89,40 @@ func TestClientFactories_PluginNames(t *testing.T) { assert.Equal(t, []string{"datadog", "log", "prometheus"}, clientFactories[bigtablePath+".Client"].PluginNames()) assert.Equal(t, "boost.factory.bigtable", clientFactories[bigtablePath+".Client"].Root) assert.True(t, clientFactories[bigtablePath+".Client"].Close, "the module closes the bigtable client when the app stops") + for _, key := range []string{ + mongoPath + ".Conn", + "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v1.Conn", + redisPath + ".Client", + redisPath + ".ClusterClient", + } { + assert.True(t, clientFactories[key].Close, "the module closes %s when the app stops: it holds a pool", key) + } assert.Equal(t, []string{"health"}, clientFactories[gocqlPath+".Session"].PluginNames()) assert.Equal(t, []string{"health"}, clientFactories[gocql2Path+".Session"].PluginNames()) assert.Equal(t, []string{"datadog"}, clientFactories["github.com/aws/aws-sdk-go-v2/service/sqs.Client"].PluginNames()) + assert.Equal(t, []string{"datadog", "health", "otel"}, clientFactories[sqlDBKey].PluginNames()) + assert.Equal(t, []string{"godror", "pgx"}, clientFactories[sqlDBKey].DriverNames()) + assert.True(t, clientFactories[sqlDBKey].Close, "the module closes the *sql.DB when the app stops: it holds a pool") +} + +func TestClientFactory_Driver(t *testing.T) { + db := clientFactories[sqlDBKey] + + godror, ok := db.Driver("godror") + require.True(t, ok) + assert.Equal(t, "github.com/xgodev/boost/factory/contrib/godror/godror/v0.NewDBWithConfigPath", godror.Build) + assert.Equal(t, "boost.factory.godror", godror.Root) + + pgx, ok := db.Driver("pgx") + require.True(t, ok) + assert.Equal(t, "github.com/xgodev/boost/factory/contrib/jackc/pgx/v5.NewDBWithConfigPath", pgx.Build) + assert.Equal(t, "boost.factory.pgx", pgx.Root) + + _, ok = db.Driver("mysql") + assert.False(t, ok) + _, ok = clientFactories[restyPath+".Client"].Driver("godror") + assert.False(t, ok, "a client of one factory has no drivers") } func TestParsePlugins(t *testing.T) { @@ -258,6 +289,83 @@ func TestClients_Rules(t *testing.T) { } } +// sqlConsumer builds New in package datastore/, a constructor taking a +// *sql.DB. +func sqlConsumer(pkg string, anns ...annotation.Annotation) annotation.Entry { + entry := at("datastore/"+pkg, "New", []string{"*sql.DB"}, []string{"*Repository"}, append(anns, provide("index=0"))...) + entry.Imports = map[string]string{"sql": "database/sql"} + return entry +} + +func TestClients_SQLDriver(t *testing.T) { + clients, err := Clients(newTestGraph(t, []annotation.Entry{ + sqlConsumer("gold", inject("index=0, name=gold, drivers=[godror], plugins=[health]")), + sqlConsumer("report", inject("index=0, name=gold")), + sqlConsumer("orders", inject("index=0, drivers=pgx")), + })) + require.NoError(t, err) + require.Len(t, clients, 2) + + def, gold := clients[0], clients[1] + assert.Equal(t, "pgx", def.Driver) + assert.Equal(t, "boost.factory.pgx", def.Root()) + assert.Equal(t, "github.com/xgodev/boost/factory/contrib/jackc/pgx/v5", def.Factory.FactoryPath()) + assert.Equal(t, "client/database/sql/default_db.go", def.File()) + + assert.Equal(t, "godror", gold.Driver, "a consumer without drivers takes the declared one") + assert.Equal(t, "boost.factory.godror.clients.gold", gold.Root()) + assert.Equal(t, "boost.factory.godror.clients.gold.plugins.health", gold.PluginRoot("health")) + assert.Equal(t, "NewDBWithConfigPath", gold.Factory.Constructor()) + assert.Equal(t, "GoldDB", gold.Ident()) + assert.Equal(t, "client/database/sql/gold_db.go", gold.File()) + assert.Equal(t, []string{testModule + "/datastore/gold.New", testModule + "/datastore/report.New"}, gold.Consumers) +} + +func TestClients_SQLDriverRules(t *testing.T) { + provider := at("datastore/oracle", "NewGold", nil, []string{"*sql.DB"}, provide("index=0, name=gold")) + provider.Imports = map[string]string{"sql": "database/sql"} + + testCases := []struct { + name string + entries []annotation.Entry + parts []string + }{ + {name: "no driver", entries: []annotation.Entry{sqlConsumer("gold", inject("index=0, name=gold"))}, + parts: []string{"gold client (*database/sql.DB)", testModule + "/datastore/gold.New", "drivers=[godror]", "godror, pgx"}}, + {name: "unknown driver", entries: []annotation.Entry{sqlConsumer("gold", inject("index=0, drivers=[mysql]"))}, + parts: []string{"mysql", "godror, pgx"}}, + {name: "two drivers", entries: []annotation.Entry{sqlConsumer("gold", inject("index=0, drivers=[godror, pgx]"))}, + parts: []string{"one driver", "[godror, pgx]"}}, + {name: "empty list", entries: []annotation.Entry{sqlConsumer("gold", inject("index=0, drivers=[]"))}, + parts: []string{"drivers=[]", "godror, pgx"}}, + {name: "different drivers", entries: []annotation.Entry{ + sqlConsumer("gold", inject("index=0, name=gold, drivers=[godror]")), + sqlConsumer("report", inject("index=0, name=gold, drivers=[pgx]")), + }, parts: []string{testModule + "/datastore/gold.New", testModule + "/datastore/report.New", "godror", "pgx"}}, + {name: "drivers on a db the app provides", entries: []annotation.Entry{provider, sqlConsumer("gold", inject("index=0, name=gold, drivers=[godror]"))}, + parts: []string{"drivers", "provided by", provider.Path + ".NewGold"}}, + {name: "drivers on a client of one factory", entries: []annotation.Entry{restyConsumer("quote", inject("index=0, drivers=[godror]"))}, + parts: []string{"drivers", "*resty.Client", "*publisher.Publisher", "*database/sql.DB"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewGraphFromEntries(context.Background(), tc.entries) + assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) + errorContains(t, err, tc.parts...) + }) + } +} + +func TestClients_SQLDriverProvidedByTheApp(t *testing.T) { + provider := at("datastore/oracle", "NewGold", nil, []string{"*sql.DB"}, provide("index=0, name=gold")) + provider.Imports = map[string]string{"sql": "database/sql"} + + clients, err := Clients(newTestGraph(t, []annotation.Entry{provider, sqlConsumer("gold", inject("index=0, name=gold"))})) + require.NoError(t, err) + assert.Empty(t, clients, "a *sql.DB the app provides needs no driver") +} + func TestClients_DefaultNameCollides(t *testing.T) { _, err := NewGraphFromEntries(context.Background(), []annotation.Entry{ restyConsumer("weather", inject("index=0")), @@ -317,6 +425,16 @@ func TestGenerator_RenderClosingClients(t *testing.T) { file: "client/github.com/gocql/gocql/store_session.go"}, {name: "gocql v2", pkg: "gocql", path: gocql2Path, tp: "*gocql.Session", inject: "index=0, name=ledger, plugins=[health]", file: "client/github.com/apache/cassandra-gocql-driver/v2/ledger_session.go"}, + {name: "mongo", pkg: "mongo", path: mongoPath, tp: "*mongo.Conn", inject: "index=0, name=stock, plugins=[health, prometheus]", + file: "client/github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/stock_conn.go"}, + {name: "redis", pkg: "redis", path: redisPath, tp: "*redis.Client", inject: "index=0, name=cache, plugins=[health]", + file: "client/github.com/redis/go-redis/v9/cache_client.go"}, + {name: "redis cluster", pkg: "redis", path: redisPath, tp: "*redis.ClusterClient", inject: "index=0, name=cache", + file: "client/github.com/redis/go-redis/v9/cache_clusterclient.go"}, + {name: "sql godror", pkg: "sql", path: "database/sql", tp: "*sql.DB", inject: "index=0, name=gold, drivers=[godror], plugins=[health, otel]", + file: "client/database/sql/gold_db.go"}, + {name: "sql pgx", pkg: "sql", path: "database/sql", tp: "*sql.DB", inject: "index=0, drivers=[pgx]", + file: "client/database/sql/default_db.go", golden: "pgx_default_db.go.golden"}, } for _, tc := range testCases { @@ -357,13 +475,22 @@ func TestClientRegistryCompiles(t *testing.T) { keys = append(keys, key) } for i, key := range keys { - f := clientFactories[key] - for _, c := range []Client{{Factory: f}, {Factory: f, Name: "check", Plugins: f.PluginNames()}} { - src, err := ExecuteTemplate(tmpl, "client", clientData(c)) - require.NoError(t, err, "%s", c) - file := filepath.Join(dir, strings.ReplaceAll(key, "/", "_"), filepath.Base(c.File())) - require.NoError(t, os.MkdirAll(filepath.Dir(file), os.ModePerm), "%d", i) - require.NoError(t, os.WriteFile(file, src, 0o644)) + // a factory with drivers is rendered once per driver + variants := map[string]ClientFactory{"": clientFactories[key]} + if names := clientFactories[key].DriverNames(); len(names) > 0 { + variants = map[string]ClientFactory{} + for _, name := range names { + variants[name], _ = clientFactories[key].Driver(name) + } + } + for driver, f := range variants { + for _, c := range []Client{{Factory: f, Driver: driver}, {Factory: f, Driver: driver, Name: "check", Plugins: f.PluginNames()}} { + src, err := ExecuteTemplate(tmpl, "client", clientData(c)) + require.NoError(t, err, "%s", c) + file := filepath.Join(dir, strings.ReplaceAll(key, "/", "_")+driver, filepath.Base(c.File())) + require.NoError(t, os.MkdirAll(filepath.Dir(file), os.ModePerm), "%d", i) + require.NoError(t, os.WriteFile(file, src, 0o644)) + } } } @@ -519,6 +646,38 @@ func TestClientProvider(t *testing.T) { assert.False(t, ok) } +// TestGenerator_DefaultClientWithoutDriver checks a *sql.DB another generator +// asks for: inject builds it only when a consumer names its driver. +func TestGenerator_DefaultClientWithoutDriver(t *testing.T) { + app := at("app", "New", nil, []string{"*App"}, provide("index=0")) + + for name, opts := range map[string]GeneratorOptions{ + "default": func() GeneratorOptions { + o := testOptions("/mod") + o.DefaultClients = []string{sqlDBKey} + return o + }(), + "named": func() GeneratorOptions { + o := testOptions("/mod") + o.NamedClients = []ClientRef{{ID: sqlDBKey, Name: "gold"}} + return o + }(), + } { + t.Run(name, func(t *testing.T) { + _, err := NewGenerator(newTestGraph(t, []annotation.Entry{app}), opts).Render(context.Background()) + assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) + errorContains(t, err, "*database/sql.DB", "drivers=", "godror, pgx") + }) + } + + consumer := sqlConsumer("gold", inject("index=0, name=gold, drivers=[godror]")) + opts := testOptions("/mod") + opts.NamedClients = []ClientRef{{ID: sqlDBKey, Name: "gold"}} + files, err := NewGenerator(newTestGraph(t, []annotation.Entry{consumer}), opts).Render(context.Background()) + require.NoError(t, err, "a consumer names the driver") + assert.NotNil(t, files["client/database/sql/gold_db.go"]) +} + func TestNamedClientModule(t *testing.T) { imp, call, err := NamedClientModule("example.com/app/gen", natsPath+".Conn", "prices") require.NoError(t, err) diff --git a/inject/generator.go b/inject/generator.go index 55603f9e..7707806f 100644 --- a/inject/generator.go +++ b/inject/generator.go @@ -262,6 +262,9 @@ func (p *Generator) defaultClients(clients []Client) ([]Client, error) { return nil, errors.NotValidf("inject builds no client of type *%s; it builds %s", id, strings.Join(supportedClients(), ", ")) } if _, ok := defaultClient(slices.Concat(clients, extra), id); !ok { + if err := needsNoDriver(Client{Factory: f}); err != nil { + return nil, err + } extra = append(extra, Client{Factory: f}) } } @@ -274,12 +277,25 @@ func (p *Generator) defaultClients(clients []Client) ([]Client, error) { return nil, errors.NotValidf("client name %s of *%s must match %s so an environment variable reaches its keys", ref.Name, ref.ID, envSegment) } if _, ok := clientOf(slices.Concat(clients, extra), ref.ID, ref.Name); !ok { + if err := needsNoDriver(Client{Factory: f, Name: ref.Name}); err != nil { + return nil, err + } extra = append(extra, Client{Factory: f, Name: ref.Name}) } } return extra, nil } +// needsNoDriver is NotValid for a client asked by another generator whose +// type is built by one of several drivers: only an @Inject names the driver. +func needsNoDriver(c Client) error { + if len(c.Factory.Drivers) == 0 { + return nil + } + return errors.NotValidf("the %s is built by the driver its @Inject names (drivers=[%s]), and no consumer injects it; its drivers are %s", + c, c.Factory.DriverNames()[0], strings.Join(c.Factory.DriverNames(), ", ")) +} + // renderClients adds the file of each client inject builds to files. It is // AlreadyExists when a client's file is already taken. func renderClients(tmpl *template.Template, clients []Client, files map[string][]byte) error { diff --git a/inject/graph.go b/inject/graph.go index cdfd10d6..2825a6bb 100644 --- a/inject/graph.go +++ b/inject/graph.go @@ -217,6 +217,10 @@ type parsedAnnotation struct { // newComponent parses the inject annotations of an entry. It returns false // when the entry does not take part in injection. func newComponent(entry annotation.Entry) (Component, bool, error) { + // a package doc (builder's @Server) holds no constructor nor config + if entry.IsPackage() { + return Component{}, false, nil + } parsed, err := parseAnnotations(entry) if err != nil || len(parsed) == 0 { return Component{}, false, err @@ -336,7 +340,7 @@ func validateProvides(entry annotation.Entry, provides map[int]Annotation) error return errors.NotValidf("@Provide on %s result %d cannot have plugins: plugins go on the @Inject of a client parameter", gid(entry), idx) } if ann.Drivers != "" { - return errors.NotValidf("@Provide on %s result %d cannot have drivers: drivers go on the @Inject of a *publisher.Publisher parameter", gid(entry), idx) + return errors.NotValidf("@Provide on %s result %d cannot have drivers: drivers go on the @Inject of a *publisher.Publisher or %s parameter", gid(entry), idx, strings.Join(driverClients(), ", ")) } } return nil @@ -377,15 +381,23 @@ func validateInjects(entry annotation.Entry, injects map[int]Annotation) error { } // validateDrivers checks the drivers of an @Inject: the parameter is a -// *publisher.Publisher and every driver is one inject generates. +// *publisher.Publisher and every driver is one inject generates, or a client +// built by several drivers (*sql.DB) and it names one of them. func validateDrivers(entry annotation.Entry, idx int, ann Annotation) error { if ann.Drivers == "" { return nil } tp := entry.Func.Parameters[idx].Type + if f, ok := clientFactory(entry, tp); ok && len(f.Drivers) > 0 { + if _, err := parseClientDriver(f, ann.Drivers); err != nil { + return errors.NewNotValid(err, fmt.Sprintf("@Inject on %s parameter %d", gid(entry), idx)) + } + return nil + } if !isPublisher(entry, tp) { - return errors.NotValidf("@Inject on %s parameter %d has drivers, but %s is not *publisher.Publisher", gid(entry), idx, tp) + return errors.NotValidf("@Inject on %s parameter %d has drivers, but %s is neither *publisher.Publisher nor a client built by one of several drivers (%s)", + gid(entry), idx, tp, strings.Join(driverClients(), ", ")) } drivers, err := parseDrivers(ann.Drivers) diff --git a/inject/graph_test.go b/inject/graph_test.go index 6b4c9867..632812fd 100644 --- a/inject/graph_test.go +++ b/inject/graph_test.go @@ -229,6 +229,10 @@ func (suite *NewGraphFromEntriesTestSuite) TestIgnoresEntriesWithoutInjectAnnota fn("NewA", nil, []string{"*A"}, provide("index=0")), fn("Other", nil, nil, annotation.NewAnnotation("MyAnnotation", "")), {Path: "x/p", Package: "p", Struct: "S", Annotations: []annotation.Annotation{provide("index=0")}}, + // a package doc, collected with annotation.WithPackageDocs, is not a component + {Path: "x/p", Package: "p", File: "doc.go", Annotations: []annotation.Annotation{ + provide("index=0"), annotation.NewAnnotation("Config", ""), + }}, } g, err := NewGraphFromEntries(context.Background(), entries) diff --git a/inject/template.go b/inject/template.go index adde3a5c..af518ae0 100644 --- a/inject/template.go +++ b/inject/template.go @@ -117,7 +117,7 @@ type ClientPluginData struct { Name string // plugin name, the last segment of its config root Alias string // import alias of the plugin New string // ...WithConfigPath constructor of the plugin - Register string // method of the plugin passed to the factory + Register string // method of the plugin passed to the factory, "" to pass the plugin itself } // ImplementationCase is one implementation of a switch and its module call. diff --git a/inject/templates/module.tmpl b/inject/templates/module.tmpl index 2a01d4ed..faddbe4b 100644 --- a/inject/templates/module.tmpl +++ b/inject/templates/module.tmpl @@ -211,14 +211,14 @@ func new{{.Ident}}(ctx context.Context{{if .Close}}, lc fx.Lifecycle{{end}}) ({{ } {{- end}} {{- if .Close}} - c, err := factory.{{.Build}}(ctx, {{.Var}}Root{{range .Plugins}}, {{.Name}}.{{.Register}}{{end}}) + c, err := factory.{{.Build}}(ctx, {{.Var}}Root{{range .Plugins}}, {{.Name}}{{with .Register}}.{{.}}{{end}}{{end}}) if err != nil { return nil, err } lc.Append(fx.StopHook(c.Close)) return c, nil {{- else}} - return factory.{{.Build}}(ctx, {{.Var}}Root{{range .Plugins}}, {{.Name}}.{{.Register}}{{end}}) + return factory.{{.Build}}(ctx, {{.Var}}Root{{range .Plugins}}, {{.Name}}{{with .Register}}.{{.}}{{end}}{{end}}) {{- end}} } {{end}} diff --git a/inject/testdata/client/cache_client.go.golden b/inject/testdata/client/cache_client.go.golden new file mode 100644 index 00000000..e1aa2cb3 --- /dev/null +++ b/inject/testdata/client/cache_client.go.golden @@ -0,0 +1,56 @@ +// Code generated by inject; DO NOT EDIT. + +package redis + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "github.com/redis/go-redis/v9" + factory "github.com/xgodev/boost/factory/contrib/redis/go-redis/v9" + healthplugin "github.com/xgodev/boost/factory/contrib/redis/go-redis/v9/plugins/local/extra/health" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// cacheClientRoot is the config root of the cache client (*github.com/redis/go-redis/v9.Client); +// env BOOST_FACTORY_REDIS_CLIENTS_CACHE_*. +const cacheClientRoot = "boost.factory.redis.clients.cache" + +func init() { + factory.ConfigAdd(cacheClientRoot) + healthplugin.ConfigAdd(cacheClientRoot + ".plugins.health") +} + +var cacheClientOnce sync.Once + +// CacheClientModule provides the cache client (*github.com/redis/go-redis/v9.Client), built from the keys under +// boost.factory.redis.clients.cache with the plugins health, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func CacheClientModule() fx.Option { + options := fx.Options() + + cacheClientOnce.Do(func() { + options = fx.Module("github.com/redis/go-redis/v9.CacheClient", + contextfx.Module(), + fx.Provide(fx.Annotate(newCacheClient, fx.ResultTags(`name:"cache"`))), + ) + }) + + return options +} + +func newCacheClient(ctx context.Context, lc fx.Lifecycle) (*client.Client, error) { + health, err := healthplugin.NewClientHealthWithConfigPath(cacheClientRoot + ".plugins.health") + if err != nil { + return nil, err + } + c, err := factory.NewClientWithConfigPath(ctx, cacheClientRoot, health.Register) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/inject/testdata/client/cache_clusterclient.go.golden b/inject/testdata/client/cache_clusterclient.go.golden new file mode 100644 index 00000000..3bd6208c --- /dev/null +++ b/inject/testdata/client/cache_clusterclient.go.golden @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package redis + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "github.com/redis/go-redis/v9" + factory "github.com/xgodev/boost/factory/contrib/redis/go-redis/v9" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// cacheClusterClientRoot is the config root of the cache client (*github.com/redis/go-redis/v9.ClusterClient); +// env BOOST_FACTORY_REDIS_CLIENTS_CACHE_*. +const cacheClusterClientRoot = "boost.factory.redis.clients.cache" + +func init() { + factory.ConfigAdd(cacheClusterClientRoot) +} + +var cacheClusterClientOnce sync.Once + +// CacheClusterClientModule provides the cache client (*github.com/redis/go-redis/v9.ClusterClient), built from the keys under +// boost.factory.redis.clients.cache without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func CacheClusterClientModule() fx.Option { + options := fx.Options() + + cacheClusterClientOnce.Do(func() { + options = fx.Module("github.com/redis/go-redis/v9.CacheClusterClient", + contextfx.Module(), + fx.Provide(fx.Annotate(newCacheClusterClient, fx.ResultTags(`name:"cache"`))), + ) + }) + + return options +} + +func newCacheClusterClient(ctx context.Context, lc fx.Lifecycle) (*client.ClusterClient, error) { + c, err := factory.NewClusterClientWithConfigPath(ctx, cacheClusterClientRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/inject/testdata/client/gold_db.go.golden b/inject/testdata/client/gold_db.go.golden new file mode 100644 index 00000000..36f996ae --- /dev/null +++ b/inject/testdata/client/gold_db.go.golden @@ -0,0 +1,62 @@ +// Code generated by inject; DO NOT EDIT. + +package sql + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "database/sql" + factory "github.com/xgodev/boost/factory/contrib/godror/godror/v0" + otelplugin "github.com/xgodev/boost/factory/core/database/sql/plugins/contrib/xsam/otelsql/v0" + healthplugin "github.com/xgodev/boost/factory/core/database/sql/plugins/local/health" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// goldDBRoot is the config root of the gold client (*database/sql.DB); +// env BOOST_FACTORY_GODROR_CLIENTS_GOLD_*. +const goldDBRoot = "boost.factory.godror.clients.gold" + +func init() { + factory.ConfigAdd(goldDBRoot) + healthplugin.ConfigAdd(goldDBRoot + ".plugins.health") + otelplugin.ConfigAdd(goldDBRoot + ".plugins.otel") +} + +var goldDBOnce sync.Once + +// GoldDBModule provides the gold client (*database/sql.DB), built from the keys under +// boost.factory.godror.clients.gold with the plugins health, otel, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func GoldDBModule() fx.Option { + options := fx.Options() + + goldDBOnce.Do(func() { + options = fx.Module("database/sql.GoldDB", + contextfx.Module(), + fx.Provide(fx.Annotate(newGoldDB, fx.ResultTags(`name:"gold"`))), + ) + }) + + return options +} + +func newGoldDB(ctx context.Context, lc fx.Lifecycle) (*client.DB, error) { + health, err := healthplugin.NewHealthPluginWithConfigPath(goldDBRoot + ".plugins.health") + if err != nil { + return nil, err + } + otel, err := otelplugin.NewOTelWithConfigPath(goldDBRoot + ".plugins.otel") + if err != nil { + return nil, err + } + c, err := factory.NewDBWithConfigPath(ctx, goldDBRoot, health, otel) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/inject/testdata/client/pgx_default_db.go.golden b/inject/testdata/client/pgx_default_db.go.golden new file mode 100644 index 00000000..637b9a17 --- /dev/null +++ b/inject/testdata/client/pgx_default_db.go.golden @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package sql + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "database/sql" + factory "github.com/xgodev/boost/factory/contrib/jackc/pgx/v5" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// defaultDBRoot is the config root of the default client (*database/sql.DB); +// env BOOST_FACTORY_PGX_*. +const defaultDBRoot = "boost.factory.pgx" + +func init() { + factory.ConfigAdd(defaultDBRoot) +} + +var defaultDBOnce sync.Once + +// DefaultDBModule provides the default client (*database/sql.DB), built from the keys under +// boost.factory.pgx without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func DefaultDBModule() fx.Option { + options := fx.Options() + + defaultDBOnce.Do(func() { + options = fx.Module("database/sql.DefaultDB", + contextfx.Module(), + fx.Provide(newDefaultDB), + ) + }) + + return options +} + +func newDefaultDB(ctx context.Context, lc fx.Lifecycle) (*client.DB, error) { + c, err := factory.NewDBWithConfigPath(ctx, defaultDBRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/inject/testdata/client/stock_conn.go.golden b/inject/testdata/client/stock_conn.go.golden new file mode 100644 index 00000000..a6eeeca5 --- /dev/null +++ b/inject/testdata/client/stock_conn.go.golden @@ -0,0 +1,61 @@ +// Code generated by inject; DO NOT EDIT. + +package mongo + +import ( + "context" + "sync" + + "go.uber.org/fx" + + factory "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2" + prometheusplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/contrib/prometheus/client_golang/v1" + healthplugin "github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2/plugins/local/health" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// stockConnRoot is the config root of the stock client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn); +// env BOOST_FACTORY_MONGO_CLIENTS_STOCK_*. +const stockConnRoot = "boost.factory.mongo.clients.stock" + +func init() { + factory.ConfigAdd(stockConnRoot) + healthplugin.ConfigAdd(stockConnRoot + ".plugins.health") + prometheusplugin.ConfigAdd(stockConnRoot + ".plugins.prometheus") +} + +var stockConnOnce sync.Once + +// StockConnModule provides the stock client (*github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.Conn), built from the keys under +// boost.factory.mongo.clients.stock with the plugins health, prometheus, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func StockConnModule() fx.Option { + options := fx.Options() + + stockConnOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/factory/contrib/go.mongodb.org/mongo-driver/v2.StockConn", + contextfx.Module(), + fx.Provide(fx.Annotate(newStockConn, fx.ResultTags(`name:"stock"`))), + ) + }) + + return options +} + +func newStockConn(ctx context.Context, lc fx.Lifecycle) (*factory.Conn, error) { + health, err := healthplugin.NewHealthWithConfigPath(stockConnRoot + ".plugins.health") + if err != nil { + return nil, err + } + prometheus, err := prometheusplugin.NewPrometheusWithConfigPath(stockConnRoot + ".plugins.prometheus") + if err != nil { + return nil, err + } + c, err := factory.NewConnWithConfigPath(ctx, stockConnRoot, health.Register, prometheus.Register) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/model/errors/alreadyexists.go b/model/errors/alreadyexists.go index be6d1ba9..3baf1a1d 100644 --- a/model/errors/alreadyexists.go +++ b/model/errors/alreadyexists.go @@ -6,6 +6,8 @@ type alreadyExists struct { } // AlreadyExistsf returns an error which satisfies IsAlreadyExists(). +// Its message is the formatted text followed by " already exists", so the format +// names the thing: AlreadyExistsf("sku %s", sku) says "sku cap already exists". func AlreadyExistsf(format string, args ...interface{}) error { return &alreadyExists{wrap(nil, format, " already exists", args...)} } diff --git a/model/errors/notassigned.go b/model/errors/notassigned.go index a508cfd9..efacf567 100644 --- a/model/errors/notassigned.go +++ b/model/errors/notassigned.go @@ -7,6 +7,8 @@ type notAssigned struct { } // NotAssignedf returns an error which satisfies IsNotAssigned(). +// Its message is the formatted text followed by " not assigned", so the format +// names the thing: NotAssignedf("sku %s", sku) says "sku cap not assigned". func NotAssignedf(format string, args ...interface{}) error { return ¬Assigned{wrap(nil, format, " not assigned", args...)} } diff --git a/model/errors/notfound.go b/model/errors/notfound.go index f2d658c6..9f25da5a 100644 --- a/model/errors/notfound.go +++ b/model/errors/notfound.go @@ -6,6 +6,8 @@ type notFound struct { } // NotFoundf returns an error which satisfies IsNotFound(). +// Its message is the formatted text followed by " not found", so the format +// names the thing: NotFoundf("sku %s", sku) says "sku cap not found". func NotFoundf(format string, args ...interface{}) error { return ¬Found{wrap(nil, format, " not found", args...)} } diff --git a/model/errors/notimplemented.go b/model/errors/notimplemented.go index 5bba25b5..10059bb3 100644 --- a/model/errors/notimplemented.go +++ b/model/errors/notimplemented.go @@ -7,6 +7,8 @@ type notImplemented struct { } // NotImplementedf returns an error which satisfies IsNotImplemented(). +// Its message is the formatted text followed by " not implemented", so the format +// names the thing: NotImplementedf("sku %s", sku) says "sku cap not implemented". func NotImplementedf(format string, args ...interface{}) error { return ¬Implemented{wrap(nil, format, " not implemented", args...)} } diff --git a/model/errors/notprovisioned.go b/model/errors/notprovisioned.go index 97346134..cb6459d7 100644 --- a/model/errors/notprovisioned.go +++ b/model/errors/notprovisioned.go @@ -6,6 +6,8 @@ type notProvisioned struct { } // NotProvisionedf returns an error which satisfies IsNotProvisioned(). +// Its message is the formatted text followed by " not provisioned", so the format +// names the thing: NotProvisionedf("sku %s", sku) says "sku cap not provisioned". func NotProvisionedf(format string, args ...interface{}) error { return ¬Provisioned{wrap(nil, format, " not provisioned", args...)} } diff --git a/model/errors/notsupported.go b/model/errors/notsupported.go index 29122544..197edb21 100644 --- a/model/errors/notsupported.go +++ b/model/errors/notsupported.go @@ -6,6 +6,8 @@ type notSupported struct { } // NotSupportedf returns an error which satisfies IsNotSupported(). +// Its message is the formatted text followed by " not supported", so the format +// names the thing: NotSupportedf("sku %s", sku) says "sku cap not supported". func NotSupportedf(format string, args ...interface{}) error { return ¬Supported{wrap(nil, format, " not supported", args...)} } diff --git a/model/errors/notvalid.go b/model/errors/notvalid.go index 62622ba5..6fd38147 100644 --- a/model/errors/notvalid.go +++ b/model/errors/notvalid.go @@ -6,6 +6,8 @@ type notValid struct { } // NotValidf returns an error which satisfies IsNotValid(). +// Its message is the formatted text followed by " not valid", so the format +// names the thing: NotValidf("sku %s", sku) says "sku cap not valid". func NotValidf(format string, args ...interface{}) error { return ¬Valid{wrap(nil, format, " not valid", args...)} } diff --git a/model/errors/timeout.go b/model/errors/timeout.go index d908ceaf..dd9afed5 100644 --- a/model/errors/timeout.go +++ b/model/errors/timeout.go @@ -6,6 +6,8 @@ type timeout struct { } // Timeoutf returns an error which satisfies IsTimeout(). +// Its message is the formatted text followed by " timeout", so the format +// names the thing: Timeoutf("sku %s", sku) says "sku cap timeout". func Timeoutf(format string, args ...interface{}) error { return &timeout{wrap(nil, format, " timeout", args...)} } diff --git a/wrapper/config/examples/env/gen/builder/api/rest_module.go b/wrapper/config/examples/env/gen/builder/api/rest_module.go index 595eaadd..a90898d9 100644 --- a/wrapper/config/examples/env/gen/builder/api/rest_module.go +++ b/wrapper/config/examples/env/gen/builder/api/rest_module.go @@ -8,6 +8,11 @@ import ( "go.uber.org/fx" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" gen_describer_a54e2aad "github.com/xgodev/boost/wrapper/config/examples/env/gen/internal/app/api/rest/handler/application/describer" src_describer_a54e2aad "github.com/xgodev/boost/wrapper/config/examples/env/internal/app/api/rest/handler/application/describer" @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_describer_a54e2aad.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_describer_a54e2aad.Handler) { srv.Add("GET", "/application", h0.Handle) diff --git a/wrapper/config/examples/env/go.mod b/wrapper/config/examples/env/go.mod index a1571156..5d423231 100644 --- a/wrapper/config/examples/env/go.mod +++ b/wrapper/config/examples/env/go.mod @@ -52,6 +52,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/wrapper/config/examples/env_file/gen/builder/api/rest_module.go b/wrapper/config/examples/env_file/gen/builder/api/rest_module.go index 3314e5e3..42a3132b 100644 --- a/wrapper/config/examples/env_file/gen/builder/api/rest_module.go +++ b/wrapper/config/examples/env_file/gen/builder/api/rest_module.go @@ -8,6 +8,11 @@ import ( "go.uber.org/fx" boostecho "github.com/xgodev/boost/factory/contrib/labstack/echo/v4" + echoerrorhandler "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/extra/error_handler" + echohealth "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/extra/health" + echologger "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/local/wrapper/log" + echorecover "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/recover" + echorequestid "github.com/xgodev/boost/factory/contrib/labstack/echo/v4/plugins/native/requestid" echofx "github.com/xgodev/boost/fx/modules/factory/contrib/labstack/echo/v4" gen_describer_5d448736 "github.com/xgodev/boost/wrapper/config/examples/env_file/gen/internal/app/api/rest/handler/application/describer" src_describer_5d448736 "github.com/xgodev/boost/wrapper/config/examples/env_file/internal/app/api/rest/handler/application/describer" @@ -23,6 +28,15 @@ func RestModule() fx.Option { restOnce.Do(func() { options = fx.Module("api.rest", echofx.Module(), + fx.Provide(func() []boostecho.Plugin { + return []boostecho.Plugin{ + echorecover.Register, + echorequestid.Register, + echologger.Register, + echoerrorhandler.Register, + echohealth.Register, + } + }), gen_describer_5d448736.NewModule(), fx.Invoke(func(srv *boostecho.Server, h0 *src_describer_5d448736.Handler) { srv.Add("GET", "/application", h0.Handle) diff --git a/wrapper/config/examples/env_file/go.mod b/wrapper/config/examples/env_file/go.mod index 3b8abc14..de25f338 100644 --- a/wrapper/config/examples/env_file/go.mod +++ b/wrapper/config/examples/env_file/go.mod @@ -52,6 +52,7 @@ require ( golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.52.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect