From 83cfd8aeca75249f8d5c71ab227b3c9310fb0521 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Wed, 15 Oct 2025 22:54:35 -0700 Subject: [PATCH 1/4] Add gRPC support to mains plugin This change enhances the mains plugin to support gRPC servers in addition to HTTP servers, addressing issue #209. Key changes: - Only generate HTTP server code when the service design includes HTTP endpoints - Only generate gRPC server code when the service design includes gRPC endpoints - Always generate the metrics HTTP server (health/metrics/debug endpoints) - Fix duplicate package/import declarations in generated main.go - Fix WebSocket detection bug (Stream value of 0 means no streaming, not NoStreamKind) - Conditionally import transport-specific packages based on what's actually used - Add proper OTel instrumentation for gRPC servers - Implement graceful shutdown for both HTTP and gRPC servers The plugin now: 1. Scans the DSL for HTTP and gRPC endpoint definitions 2. Conditionally generates server initialization code based on transports used 3. Manages imports efficiently (only includes websocket, grpc packages when needed) 4. Provides a unified main.go that can run HTTP-only, gRPC-only, or both transports 5. Maintains the existing single-service (services//cmd/) and multi-service (cmd/) layouts Fixes #209 --- mains/generate.go | 110 +++++++++++++++++++++++++++++++++--- mains/templates/main.go.tpl | 88 +++++++++++++++++++---------- 2 files changed, 158 insertions(+), 40 deletions(-) diff --git a/mains/generate.go b/mains/generate.go index df5ced6f..cb09e463 100644 --- a/mains/generate.go +++ b/mains/generate.go @@ -31,6 +31,8 @@ type srvInfo struct { APIPkg string Services []*service.Data HasWS bool + HasHTTP bool + HasGRPC bool ServerName string } @@ -43,7 +45,11 @@ type svcT struct { SrvVar string GenPkg string GenHTTPPkg string + GenGRPCPkg string + GenGRPCPbPkg string HasWebSocket bool + HasHTTP bool + HasGRPC bool } // Register the plugin for the example phase. @@ -109,9 +115,20 @@ func generateExample(genpkg string, roots []eval.Root, files []*codegen.File) ([ apipkg := apiPkgAlias(genpkg, roots) if info, ok := srvMap[dir]; ok { info.HasWS = hasWS + info.HasHTTP = true if info.APIPkg == "" { info.APIPkg = apipkg } } else { - srvMap[dir] = &srvInfo{Dir: dir, APIPkg: apipkg, Services: svcs, HasWS: hasWS} + srvMap[dir] = &srvInfo{Dir: dir, APIPkg: apipkg, Services: svcs, HasWS: hasWS, HasHTTP: true} + } + } + // Detect gRPC servers from grpc.go files + for _, f := range files { + if filepath.Base(f.Path) != "grpc.go" { continue } + segs := strings.Split(filepath.ToSlash(f.Path), "/") + if len(segs) < 3 || segs[0] != "cmd" { continue } + dir := segs[1] + if info, ok := srvMap[dir]; ok { + info.HasGRPC = true } } @@ -119,11 +136,11 @@ func generateExample(genpkg string, roots []eval.Root, files []*codegen.File) ([ return files, nil } - // Filter out default example mains and http.go; we'll add our own mains. + // Filter out default example mains, http.go, and grpc.go; we'll add our own mains. var out []*codegen.File for _, f := range files { base := filepath.Base(f.Path) - if strings.HasPrefix(f.Path, "cmd/") && (base == "main.go" || base == "http.go") { + if strings.HasPrefix(f.Path, "cmd/") && (base == "main.go" || base == "http.go" || base == "grpc.go") { continue } out = append(out, f) @@ -156,6 +173,14 @@ func generateExample(genpkg string, roots []eval.Root, files []*codegen.File) ([ codegen.GoaNamedImport("http", "goahttp"), {Path: "google.golang.org/grpc/credentials/insecure"}, } + if info.HasGRPC { + specs = append(specs, + &codegen.ImportSpec{Path: "net"}, + &codegen.ImportSpec{Path: "google.golang.org/grpc"}, + &codegen.ImportSpec{Path: "google.golang.org/grpc/reflection"}, + &codegen.ImportSpec{Path: "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"}, + ) + } if info.HasWS { specs = append(specs, &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}) } @@ -164,19 +189,45 @@ func generateExample(genpkg string, roots []eval.Root, files []*codegen.File) ([ scope := codegen.NewNameScope() var svcsData []svcT + httpBySvc := httpServicesByName(roots) + grpcBySvc := grpcServicesByName(roots) wsBySvc := httpWebSocketByService(roots) hasAnyWS := false + hasAnyHTTP := false + hasAnyGRPC := false for _, sd := range info.Services { genAlias := scope.Unique(sd.PkgName, "svc") - httpAlias := scope.Unique(sd.PkgName+"svr", "svr") - specs = append(specs, - &codegen.ImportSpec{Path: path.Join(genpkg, sd.PathName), Name: genAlias}, - &codegen.ImportSpec{Path: path.Join(genpkg, "http", sd.PathName, "server"), Name: httpAlias}, - ) + hasHTTP := httpBySvc[sd.Name] + hasGRPC := grpcBySvc[sd.Name] hws := wsBySvc[sd.Name] + + var httpAlias, grpcAlias, grpcPbAlias string + + // Always add the base service package + specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, sd.PathName), Name: genAlias}) + + // Conditionally add HTTP server imports + if hasHTTP { + httpAlias = scope.Unique(sd.PkgName+"svr", "svr") + specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, "http", sd.PathName, "server"), Name: httpAlias}) + hasAnyHTTP = true + } + + // Conditionally add gRPC server imports + if hasGRPC { + grpcAlias = scope.Unique(sd.PkgName+"grpc", "grpcsvc") + grpcPbAlias = scope.Unique(sd.PkgName+"pb", "pb") + specs = append(specs, + &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", sd.PathName, "server"), Name: grpcAlias}, + &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", sd.PathName, "pb"), Name: grpcPbAlias}, + ) + hasAnyGRPC = true + } + if hws { hasAnyWS = true } + svcsData = append(svcsData, svcT{ Name: sd.Name, StructName: sd.StructName, @@ -185,7 +236,11 @@ func generateExample(genpkg string, roots []eval.Root, files []*codegen.File) ([ SrvVar: sd.VarName + "Server", GenPkg: genAlias, GenHTTPPkg: httpAlias, + GenGRPCPkg: grpcAlias, + GenGRPCPbPkg: grpcPbAlias, HasWebSocket: hws, + HasHTTP: hasHTTP, + HasGRPC: hasGRPC, }) } @@ -195,6 +250,8 @@ func generateExample(genpkg string, roots []eval.Root, files []*codegen.File) ([ "APIPkg": info.APIPkg, "Services": svcsData, "HasAnyWebSocket": hasAnyWS, + "HasHTTP": hasAnyHTTP, + "HasGRPC": hasAnyGRPC, "ServiceCount": len(svcsData), "ServerLabel": serverLabel(roots), }}, @@ -265,7 +322,8 @@ func httpWebSocketByService(roots []eval.Root) map[string]bool { if e.SSE != nil { continue } - if e.MethodExpr != nil && e.MethodExpr.Stream != expr.NoStreamKind { + // Stream is 0 when no streaming is defined, and >= NoStreamKind (1) when streaming is used + if e.MethodExpr != nil && e.MethodExpr.Stream != 0 { hasWS[svc.Name()] = true break } @@ -286,3 +344,37 @@ func rootServer(roots []eval.Root) *expr.ServerExpr { } return nil } + +// httpServicesByName returns map of service names that have HTTP endpoints. +func httpServicesByName(roots []eval.Root) map[string]bool { + hasHTTP := map[string]bool{} + for _, r := range roots { + root, ok := r.(*expr.RootExpr) + if !ok || root.API == nil || root.API.HTTP == nil { + continue + } + for _, svc := range root.API.HTTP.Services { + if len(svc.HTTPEndpoints) > 0 { + hasHTTP[svc.Name()] = true + } + } + } + return hasHTTP +} + +// grpcServicesByName returns map of service names that have gRPC endpoints. +func grpcServicesByName(roots []eval.Root) map[string]bool { + hasGRPC := map[string]bool{} + for _, r := range roots { + root, ok := r.(*expr.RootExpr) + if !ok || root.API == nil || root.API.GRPC == nil { + continue + } + for _, svc := range root.API.GRPC.Services { + if len(svc.GRPCEndpoints) > 0 { + hasGRPC[svc.Name()] = true + } + } + } + return hasGRPC +} diff --git a/mains/templates/main.go.tpl b/mains/templates/main.go.tpl index 45736101..7852aae4 100644 --- a/mains/templates/main.go.tpl +++ b/mains/templates/main.go.tpl @@ -1,35 +1,11 @@ -package main - -import ( - "context" - "flag" - "fmt" - "net/http" - "net/http/httptrace" - "os" - "os/signal" - "sync" - "syscall" - "time" - - "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "goa.design/clue/clue" - "goa.design/clue/debug" - "goa.design/clue/health" - "goa.design/clue/log" - goahttp "goa.design/goa/v3/http" - {{- if .HasAnyWebSocket }} - "github.com/gorilla/websocket" - {{- end }} - "google.golang.org/grpc/credentials/insecure" -) - func main() { var ( + {{- if .HasHTTP }} httpaddr = flag.String("http-addr", ":8080", "HTTP listen address") + {{- end }} + {{- if .HasGRPC }} + grpcaddr = flag.String("grpc-addr", ":9090", "gRPC listen address") + {{- end }} metricsAddr = flag.String("metrics-addr", ":8081", "metrics listen address") coladdr = flag.String("otel-addr", ":4317", "OpenTelemetry collector listen address") debugf = flag.Bool("debug", false, "Enable debug logs") @@ -126,6 +102,7 @@ func main() { {{ .EpVar }}.Use(log.Endpoint) {{- end }} + {{- if .HasHTTP }} // 6. Create HTTP transport mux := goahttp.NewMuxer() debug.MountDebugLogEnabler(debug.Adapt(mux)) @@ -139,6 +116,7 @@ func main() { {{- end }} {{- range .Services }} + {{- if .HasHTTP }} // {{ .Name }} HTTP server {{- if .HasWebSocket }} {{ .SrvVar }} := {{ .GenHTTPPkg }}.New({{ .EpVar }}, mux, goahttp.RequestDecoder, goahttp.ResponseEncoder, nil, nil, upgrader, nil) @@ -150,10 +128,37 @@ func main() { log.Print(ctx, log.KV{K: "method", V: m.Method}, log.KV{K: "endpoint", V: m.Verb + " " + m.Pattern}) } {{- end }} + {{- end }} httpServer := &http.Server{Addr: *httpaddr, Handler: handler} + {{- end }} + + {{- if .HasGRPC }} + // 6b. Create gRPC server with interceptors + var grpcServerOpts []grpc.ServerOption + grpcServerOpts = append(grpcServerOpts, grpc.StatsHandler(otelgrpc.NewServerHandler())) + grpcServerOpts = append(grpcServerOpts, grpc.ChainUnaryInterceptor( + log.UnaryServerInterceptor(ctx), + debug.UnaryServerInterceptor(), + )) + grpcServerOpts = append(grpcServerOpts, grpc.ChainStreamInterceptor( + log.StreamServerInterceptor(ctx), + debug.StreamServerInterceptor(), + )) + grpcServer := grpc.NewServer(grpcServerOpts...) + + {{- range .Services }} + {{- if .HasGRPC }} + // {{ .Name }} gRPC server + {{ .SvcVar }}GRPCServer := {{ .GenGRPCPkg }}.New({{ .EpVar }}, nil) + {{ .GenGRPCPbPkg }}.Register{{ .StructName }}Server(grpcServer, {{ .SvcVar }}GRPCServer) + {{- end }} + {{- end }} - // 7. Start HTTP servers (graceful shutdown) + reflection.Register(grpcServer) + {{- end }} + + // 7. Start servers (graceful shutdown) errc := make(chan error) go func() { c := make(chan os.Signal, 1) @@ -167,10 +172,24 @@ func main() { go func() { defer wg.Done() + {{- if .HasHTTP }} go func() { log.Printf(ctx, "HTTP server listening on %s", *httpaddr) errc <- httpServer.ListenAndServe() }() + {{- end }} + + {{- if .HasGRPC }} + go func() { + lis, err := net.Listen("tcp", *grpcaddr) + if err != nil { + errc <- err + return + } + log.Printf(ctx, "gRPC server listening on %s", *grpcaddr) + errc <- grpcServer.Serve(lis) + }() + {{- end }} go func() { log.Printf(ctx, "Metrics server listening on %s", *metricsAddr) @@ -178,7 +197,7 @@ func main() { }() <-ctx.Done() - log.Printf(ctx, "shutting down HTTP servers") + log.Printf(ctx, "shutting down servers") // Shutdown gracefully with a 30s timeout. sctx, scancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -192,9 +211,16 @@ func main() { } {{- end }} + {{- if .HasHTTP }} if err := httpServer.Shutdown(sctx); err != nil { log.Errorf(sctx, err, "failed to shutdown HTTP server") } + {{- end }} + + {{- if .HasGRPC }} + grpcServer.GracefulStop() + {{- end }} + if err := metricsServer.Shutdown(sctx); err != nil { log.Errorf(sctx, err, "failed to shutdown metrics server") } From b74a26e07518cbfadbb975189f83f46bc281cd50 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 26 Dec 2025 08:43:48 -0800 Subject: [PATCH 2/4] testing: fix type assertion for ArrayOf results in ScenarioRunner (#234) The testing plugin was generating invalid Go for array results in ScenarioRunner.callValidator, producing code like: typedResult := result.(*bff.[]*AccessControl) instead of the correct: typedResult := result.([]*bff.AccessControl) This was caused by hardcoding the assertion shape as `result.(*{{Pkg}}.{{Result}})`, which breaks for composite types. Fix: Use Goa's GoFullTypeRef to compute the properly qualified type reference (handling *, [], map[...], etc.) and store it in a new ResultTypeRef field on scenarioMethodData. Added regression test to ensure this pattern never recurs. --- testing/codegen/scenarios.go | 74 +++++++++++-------- testing/codegen/scenarios_test.go | 25 +++++++ .../codegen/templates/scenario_runner.go.tpl | 4 +- testing/codegen/testdata/dsls.go | 15 ++++ 4 files changed, 84 insertions(+), 34 deletions(-) diff --git a/testing/codegen/scenarios.go b/testing/codegen/scenarios.go index bdece2ba..ac3b1175 100644 --- a/testing/codegen/scenarios.go +++ b/testing/codegen/scenarios.go @@ -5,10 +5,10 @@ import ( "os" "path/filepath" - "gopkg.in/yaml.v3" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" + "gopkg.in/yaml.v3" ) // generateScenarios generates the scenario runner for a service. @@ -33,7 +33,7 @@ func generateScenarios(genpkg string, svcData *service.Data, root *expr.RootExpr {Path: "gopkg.in/yaml.v3", Name: "yaml"}, {Path: filepath.Join(genpkg, codegen.SnakeCase(svc.Name)), Name: svcData.PkgName}, } - + // Add validator package import if specified in YAML // Only add if it's different from the current package currentPkgPath := filepath.Join(genpkg, codegen.SnakeCase(svc.Name), codegen.SnakeCase(svc.Name)+"test") @@ -101,26 +101,30 @@ type ( *service.MethodData // Transports lists valid transport strings for YAML Transports []string + // ResultTypeRef is the fully qualified Go reference to the result type + // as seen from the generated test package (e.g. "*svc.Foo", "[]*svc.Bar"). + // This is used in generated type assertions for custom validators. + ResultTypeRef string } ) // generateExampleScenarios generates an example scenarios.yaml file for a service. func generateExampleScenarios(_ string, root *expr.RootExpr, svc *expr.ServiceExpr) *codegen.File { path := filepath.Join(codegen.Gendir, "..", "scenarios.yaml") - + svcData := service.NewServicesData(root).Get(svc.Name) if svcData == nil { return nil } - + data := buildScenariosData(svcData, root, svc) - + // For YAML files, we need to read the template directly since it's not a .go.tpl file tmplContent, err := templateFS.ReadFile("templates/example_scenarios.yaml.tpl") if err != nil { panic(fmt.Sprintf("failed to read example_scenarios.yaml.tpl: %v", err)) } - + sections := []*codegen.SectionTemplate{ { Name: "example-scenarios", @@ -128,7 +132,7 @@ func generateExampleScenarios(_ string, root *expr.RootExpr, svc *expr.ServiceEx Data: data, }, } - + return &codegen.File{ Path: path, SectionTemplates: sections, @@ -139,20 +143,20 @@ func generateExampleScenarios(_ string, root *expr.RootExpr, svc *expr.ServiceEx func buildScenariosData(svcData *service.Data, root *expr.RootExpr, svc *expr.ServiceExpr) *scenariosData { // Extract validator info from YAML validatorInfo := ExtractValidatorsFromYAML() - + data := &scenariosData{ - Data: svcData, - ServiceExpr: svc, - Methods: make([]*scenarioMethodData, 0), - HasHTTP: hasHTTPTransport(root, svc), - HasGRPC: hasGRPCTransport(root, svc), - HasJSONRPC: hasJSONRPCTransport(root, svc), + Data: svcData, + ServiceExpr: svc, + Methods: make([]*scenarioMethodData, 0), + HasHTTP: hasHTTPTransport(root, svc), + HasGRPC: hasGRPCTransport(root, svc), + HasJSONRPC: hasJSONRPCTransport(root, svc), ValidTransports: make([]string, 0), - Validators: validatorInfo.Validators, - ValidatorPkg: "", // Will be set below if it's a different package - ValidatorPath: validatorInfo.Path, + Validators: validatorInfo.Validators, + ValidatorPkg: "", // Will be set below if it's a different package + ValidatorPath: validatorInfo.Path, } - + // Build list of valid transports transportSet := make(map[string]bool) transportSet["auto"] = true @@ -176,15 +180,21 @@ func buildScenariosData(svcData *service.Data, root *expr.RootExpr, svc *expr.Se // Build method data with available transports for i, m := range svc.Methods { methodData := svcData.Methods[i] - + // Build targets for this method using shared function targets := buildMethodTargets(root, svc, m, methodData) - + md := &scenarioMethodData{ MethodData: methodData, Transports: make([]string, 0), } - + // Compute fully qualified result type reference for type assertions. + // This properly handles composite types like ArrayOf(...) without producing + // invalid Go like "svc.[]T" (see issue #234). + if m.Result != nil && m.Result.Type != expr.Empty { + md.ResultTypeRef = svcData.Scope.GoFullTypeRef(m.Result, svcData.PkgName) + } + // Build list of valid transport strings based on targets transportSet := make(map[string]bool) for _, target := range targets { @@ -205,15 +215,15 @@ func buildScenariosData(svcData *service.Data, root *expr.RootExpr, svc *expr.Se transportSet["jsonrpc-ws"] = true } } - + // Convert set to sorted list for transport := range transportSet { md.Transports = append(md.Transports, transport) } - + data.Methods = append(data.Methods, md) } - + return data } @@ -230,19 +240,19 @@ func ExtractValidatorsFromYAML() ValidatorInfo { Validators: make(map[string][]string), Package: "", // Empty means use current package } - + // Try to read scenarios.yaml from current directory data, err := os.ReadFile("scenarios.yaml") if err != nil { // File doesn't exist or can't be read, that's OK return info } - + if len(data) == 0 { // Empty file return info } - + var config struct { Validators struct { Package string `yaml:"package"` @@ -257,16 +267,16 @@ func ExtractValidatorsFromYAML() ValidatorInfo { } `yaml:"steps"` } `yaml:"scenarios"` } - + if err := yaml.Unmarshal(data, &config); err != nil { // Invalid YAML, skip return info } - + // Extract package info info.Package = config.Validators.Package info.Path = config.Validators.Path - + // Extract unique validators per method for _, scenario := range config.Scenarios { if scenario.Steps == nil { @@ -289,6 +299,6 @@ func ExtractValidatorsFromYAML() ValidatorInfo { } } } - + return info -} \ No newline at end of file +} diff --git a/testing/codegen/scenarios_test.go b/testing/codegen/scenarios_test.go index 2c8a54ae..d2cae3f4 100644 --- a/testing/codegen/scenarios_test.go +++ b/testing/codegen/scenarios_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" httpcodegen "goa.design/goa/v3/http/codegen" "goa.design/plugins/v3/testing/codegen/testdata" @@ -45,3 +46,27 @@ func TestGenerateScenarios(t *testing.T) { }) } } + +func TestGenerateScenarios_ArrayResultTypeAssertion(t *testing.T) { + root := httpcodegen.RunHTTPDSL(t, testdata.WithArrayResultDSL) + services := service.NewServicesData(root) + svc := root.Services[0] + svcData := services.Get(svc.Name) + fs := generateScenarios("", svcData, root, svc) + f := fs[0] + + sections := f.Section("scenario-runner") + if len(sections) != 1 { + t.Fatalf("expected 1 scenario-runner section, got %d", len(sections)) + } + code := codegen.SectionCode(t, sections[0]) + + // This is the canonical fully-qualified Go type reference that should be used + // in the generated type assertion. + wantRef := svcData.Scope.GoFullTypeRef(svc.Methods[0].Result, svcData.PkgName) + assert.Contains(t, code, "typedResult := result.("+wantRef+")") + + // Regression guard for invalid formatting like "pkg.[]T" (issue #234). + assert.NotContains(t, code, svcData.PkgName+".[]") + assert.NotContains(t, code, "*"+svcData.PkgName+".[]") +} diff --git a/testing/codegen/templates/scenario_runner.go.tpl b/testing/codegen/templates/scenario_runner.go.tpl index 4e07ffc2..825550de 100644 --- a/testing/codegen/templates/scenario_runner.go.tpl +++ b/testing/codegen/templates/scenario_runner.go.tpl @@ -222,8 +222,8 @@ func (r *ScenarioRunner) callValidator(t *testing.T, method string, result any, switch method { {{- range .Methods }} case "{{ .Name }}": - {{- if .ResultRef }} - typedResult := result.(*{{ $.PkgName }}.{{ .Result }}) + {{- if .ResultTypeRef }} + typedResult := result.({{ .ResultTypeRef }}) {{- $validators := index $.Validators .Name }} {{- if $validators }} diff --git a/testing/codegen/testdata/dsls.go b/testing/codegen/testdata/dsls.go index a2e58f45..e130842c 100644 --- a/testing/codegen/testdata/dsls.go +++ b/testing/codegen/testdata/dsls.go @@ -31,6 +31,21 @@ var WithoutResultDSL = func() { }) } +var WithArrayResultDSL = func() { + var AccessControl = ResultType("AccessControl", func() { + Attribute("id", String) + Required("id") + }) + Service("WithArrayResultService", func() { + Method("ListAccessControl", func() { + Result(ArrayOf(AccessControl)) + HTTP(func() { + GET("/") + }) + }) + }) +} + var WithStreamDSL = func() { Service("WithStreamService", func() { Method("WithStreamMethod", func() { From 8cf37bf436bff6e6db5255d015ad1a4355a6f7f6 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 26 Dec 2025 08:43:57 -0800 Subject: [PATCH 3/4] mains: fix websocket import detection for server mains The mains plugin was using httpcodegen.NeedDialer() to detect websocket usage, but that function is designed for client dialers, not servers. This caused the github.com/gorilla/websocket import to be missing when websocket.Upgrader was emitted in the generated main. Fix: Use httpWebSocketByService() to detect streaming endpoints that require websocket support, matching the same detection used for HasAnyWebSocket in the template. Also fixed the test to check the websocket import in the correct section (source-header) rather than the main body section. --- mains/generate.go | 660 ++++++++++++++++++++++------------------- mains/generate_test.go | 154 +++++----- 2 files changed, 431 insertions(+), 383 deletions(-) diff --git a/mains/generate.go b/mains/generate.go index cb09e463..d22a8650 100644 --- a/mains/generate.go +++ b/mains/generate.go @@ -6,375 +6,417 @@ package mains import ( - "path" - "path/filepath" - "strings" + "path" + "path/filepath" + "strings" - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" ) const ( - // pluginName is the registered plugin name. - pluginName = "mains" - // pluginCmd is the goa CLI command the plugin integrates with. - pluginCmd = "example" + // pluginName is the registered plugin name. + pluginName = "mains" + // pluginCmd is the goa CLI command the plugin integrates with. + pluginCmd = "example" ) // srvInfo stores server-level data derived from generated example files. type srvInfo struct { - Dir string - APIPkg string - Services []*service.Data - HasWS bool - HasHTTP bool - HasGRPC bool - ServerName string + Dir string + APIPkg string + Services []*service.Data + HasWS bool + HasHTTP bool + HasGRPC bool + ServerName string } // svcT provides template data for each service imported by a server. type svcT struct { - Name string - StructName string - SvcVar string - EpVar string - SrvVar string - GenPkg string - GenHTTPPkg string - GenGRPCPkg string - GenGRPCPbPkg string - HasWebSocket bool - HasHTTP bool - HasGRPC bool + Name string + StructName string + SvcVar string + EpVar string + SrvVar string + GenPkg string + GenHTTPPkg string + GenGRPCPkg string + GenGRPCPbPkg string + HasWebSocket bool + HasHTTP bool + HasGRPC bool } // Register the plugin for the example phase. func init() { - codegen.RegisterPluginLast(pluginName, pluginCmd, nil, Generate) + codegen.RegisterPluginLast(pluginName, pluginCmd, nil, Generate) } // Generate produces golden-path mains that follow the Goa conventions and // pulse weather layout: -// - For servers with a single service: services//cmd//main.go -// - For servers with multiple services: cmd//main.go +// - For servers with a single service: services//cmd//main.go +// - For servers with multiple services: cmd//main.go +// // It replaces the default example main and http.go files. func Generate(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { - return generateExample(genpkg, roots, files) + return generateExample(genpkg, roots, files) } func generateExample(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { - // Collect per-server services/APIPkg from example main files first - srvMap := map[string]*srvInfo{} - for _, f := range files { - if filepath.Base(f.Path) != "main.go" { continue } - segs := strings.Split(filepath.ToSlash(f.Path), "/") - if len(segs) < 3 || segs[0] != "cmd" { continue } - dir := segs[1] - var svcs []*service.Data - var apipkg string - for _, s := range f.SectionTemplates { - switch s.Name { - case "server-main-services": - if dm, ok := s.Data.(map[string]any); ok { - if v, ok := dm["Services"].([]*service.Data); ok { svcs = v } - } - case "server-main-logger": - if dm, ok := s.Data.(map[string]any); ok { - if v, ok := dm["APIPkg"].(string); ok { apipkg = v } - } - } - } - if len(svcs) == 0 { continue } - if apipkg == "" { apipkg = apiPkgAlias(genpkg, roots) } - if _, exists := srvMap[dir]; !exists { - srvMap[dir] = &srvInfo{Dir: dir, APIPkg: apipkg, Services: svcs} - } - } - // Complement with HTTP server data (WebSocket detection) and fallback if needed - for _, f := range files { - if filepath.Base(f.Path) != "http.go" { continue } - segs := strings.Split(filepath.ToSlash(f.Path), "/") - if len(segs) < 3 || segs[0] != "cmd" { continue } - dir := segs[1] - var httpSvcs []*httpcodegen.ServiceData - for _, s := range f.SectionTemplates { - if s.Name == "server-http-start" { - if dm, ok := s.Data.(map[string]any); ok { - if v, ok := dm["Services"].([]*httpcodegen.ServiceData); ok { httpSvcs = v; break } - } - } - } - if len(httpSvcs) == 0 { continue } - var svcs []*service.Data - for _, sd := range httpSvcs { if sd != nil && sd.Service != nil { svcs = append(svcs, sd.Service) } } - hasWS := httpcodegen.NeedDialer(httpSvcs) - apipkg := apiPkgAlias(genpkg, roots) - if info, ok := srvMap[dir]; ok { - info.HasWS = hasWS - info.HasHTTP = true - if info.APIPkg == "" { info.APIPkg = apipkg } - } else { - srvMap[dir] = &srvInfo{Dir: dir, APIPkg: apipkg, Services: svcs, HasWS: hasWS, HasHTTP: true} - } - } - // Detect gRPC servers from grpc.go files - for _, f := range files { - if filepath.Base(f.Path) != "grpc.go" { continue } - segs := strings.Split(filepath.ToSlash(f.Path), "/") - if len(segs) < 3 || segs[0] != "cmd" { continue } - dir := segs[1] - if info, ok := srvMap[dir]; ok { - info.HasGRPC = true - } - } + // Collect per-server services/APIPkg from example main files first + srvMap := map[string]*srvInfo{} + for _, f := range files { + if filepath.Base(f.Path) != "main.go" { + continue + } + segs := strings.Split(filepath.ToSlash(f.Path), "/") + if len(segs) < 3 || segs[0] != "cmd" { + continue + } + dir := segs[1] + var svcs []*service.Data + var apipkg string + for _, s := range f.SectionTemplates { + switch s.Name { + case "server-main-services": + if dm, ok := s.Data.(map[string]any); ok { + if v, ok := dm["Services"].([]*service.Data); ok { + svcs = v + } + } + case "server-main-logger": + if dm, ok := s.Data.(map[string]any); ok { + if v, ok := dm["APIPkg"].(string); ok { + apipkg = v + } + } + } + } + if len(svcs) == 0 { + continue + } + if apipkg == "" { + apipkg = apiPkgAlias(genpkg, roots) + } + if _, exists := srvMap[dir]; !exists { + srvMap[dir] = &srvInfo{Dir: dir, APIPkg: apipkg, Services: svcs} + } + } + // Complement with HTTP server data (WebSocket detection) and fallback if needed + for _, f := range files { + if filepath.Base(f.Path) != "http.go" { + continue + } + segs := strings.Split(filepath.ToSlash(f.Path), "/") + if len(segs) < 3 || segs[0] != "cmd" { + continue + } + dir := segs[1] + var httpSvcs []*httpcodegen.ServiceData + for _, s := range f.SectionTemplates { + if s.Name == "server-http-start" { + if dm, ok := s.Data.(map[string]any); ok { + if v, ok := dm["Services"].([]*httpcodegen.ServiceData); ok { + httpSvcs = v + break + } + } + } + } + if len(httpSvcs) == 0 { + continue + } + var svcs []*service.Data + for _, sd := range httpSvcs { + if sd != nil && sd.Service != nil { + svcs = append(svcs, sd.Service) + } + } + // Detect WebSocket usage from HTTP endpoints (streaming without SSE). + // NeedDialer() is for client dialers, but here we're generating server mains + // and need to know whether to import gorilla/websocket for the upgrader. + hasWS := false + wsBySvc := httpWebSocketByService(roots) + for _, sd := range httpSvcs { + if sd != nil && sd.Service != nil && wsBySvc[sd.Service.Name] { + hasWS = true + break + } + } + apipkg := apiPkgAlias(genpkg, roots) + if info, ok := srvMap[dir]; ok { + info.HasWS = hasWS + info.HasHTTP = true + if info.APIPkg == "" { + info.APIPkg = apipkg + } + } else { + srvMap[dir] = &srvInfo{Dir: dir, APIPkg: apipkg, Services: svcs, HasWS: hasWS, HasHTTP: true} + } + } + // Detect gRPC servers from grpc.go files + for _, f := range files { + if filepath.Base(f.Path) != "grpc.go" { + continue + } + segs := strings.Split(filepath.ToSlash(f.Path), "/") + if len(segs) < 3 || segs[0] != "cmd" { + continue + } + dir := segs[1] + if info, ok := srvMap[dir]; ok { + info.HasGRPC = true + } + } - if len(srvMap) == 0 { - return files, nil - } + if len(srvMap) == 0 { + return files, nil + } - // Filter out default example mains, http.go, and grpc.go; we'll add our own mains. - var out []*codegen.File - for _, f := range files { - base := filepath.Base(f.Path) - if strings.HasPrefix(f.Path, "cmd/") && (base == "main.go" || base == "http.go" || base == "grpc.go") { - continue - } - out = append(out, f) - } + // Filter out default example mains, http.go, and grpc.go; we'll add our own mains. + var out []*codegen.File + for _, f := range files { + base := filepath.Base(f.Path) + if strings.HasPrefix(f.Path, "cmd/") && (base == "main.go" || base == "http.go" || base == "grpc.go") { + continue + } + out = append(out, f) + } - // Create mains per server - for _, info := range srvMap { - if len(info.Services) == 0 { - continue - } - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net/http"}, - {Path: "net/http/httptrace"}, - {Path: "os"}, - {Path: "os/signal"}, - {Path: "sync"}, - {Path: "syscall"}, - {Path: "time"}, - {Path: "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace"}, - {Path: "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"}, - {Path: "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"}, - {Path: "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"}, - {Path: "goa.design/clue/clue"}, - {Path: "goa.design/clue/debug"}, - {Path: "goa.design/clue/health"}, - {Path: "goa.design/clue/log"}, - codegen.GoaNamedImport("http", "goahttp"), - {Path: "google.golang.org/grpc/credentials/insecure"}, - } - if info.HasGRPC { - specs = append(specs, - &codegen.ImportSpec{Path: "net"}, - &codegen.ImportSpec{Path: "google.golang.org/grpc"}, - &codegen.ImportSpec{Path: "google.golang.org/grpc/reflection"}, - &codegen.ImportSpec{Path: "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"}, - ) - } - if info.HasWS { - specs = append(specs, &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}) - } - rootPath := moduleRootFromGenpkg(genpkg) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: info.APIPkg}) + // Create mains per server + for _, info := range srvMap { + if len(info.Services) == 0 { + continue + } + specs := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "flag"}, + {Path: "fmt"}, + {Path: "net/http"}, + {Path: "net/http/httptrace"}, + {Path: "os"}, + {Path: "os/signal"}, + {Path: "sync"}, + {Path: "syscall"}, + {Path: "time"}, + {Path: "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace"}, + {Path: "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"}, + {Path: "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"}, + {Path: "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"}, + {Path: "goa.design/clue/clue"}, + {Path: "goa.design/clue/debug"}, + {Path: "goa.design/clue/health"}, + {Path: "goa.design/clue/log"}, + codegen.GoaNamedImport("http", "goahttp"), + {Path: "google.golang.org/grpc/credentials/insecure"}, + } + if info.HasGRPC { + specs = append(specs, + &codegen.ImportSpec{Path: "net"}, + &codegen.ImportSpec{Path: "google.golang.org/grpc"}, + &codegen.ImportSpec{Path: "google.golang.org/grpc/reflection"}, + &codegen.ImportSpec{Path: "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"}, + ) + } + if info.HasWS { + specs = append(specs, &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}) + } + rootPath := moduleRootFromGenpkg(genpkg) + specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: info.APIPkg}) - scope := codegen.NewNameScope() - var svcsData []svcT - httpBySvc := httpServicesByName(roots) - grpcBySvc := grpcServicesByName(roots) - wsBySvc := httpWebSocketByService(roots) - hasAnyWS := false - hasAnyHTTP := false - hasAnyGRPC := false - for _, sd := range info.Services { - genAlias := scope.Unique(sd.PkgName, "svc") - hasHTTP := httpBySvc[sd.Name] - hasGRPC := grpcBySvc[sd.Name] - hws := wsBySvc[sd.Name] + scope := codegen.NewNameScope() + var svcsData []svcT + httpBySvc := httpServicesByName(roots) + grpcBySvc := grpcServicesByName(roots) + wsBySvc := httpWebSocketByService(roots) + hasAnyWS := false + hasAnyHTTP := false + hasAnyGRPC := false + for _, sd := range info.Services { + genAlias := scope.Unique(sd.PkgName, "svc") + hasHTTP := httpBySvc[sd.Name] + hasGRPC := grpcBySvc[sd.Name] + hws := wsBySvc[sd.Name] - var httpAlias, grpcAlias, grpcPbAlias string + var httpAlias, grpcAlias, grpcPbAlias string - // Always add the base service package - specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, sd.PathName), Name: genAlias}) + // Always add the base service package + specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, sd.PathName), Name: genAlias}) - // Conditionally add HTTP server imports - if hasHTTP { - httpAlias = scope.Unique(sd.PkgName+"svr", "svr") - specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, "http", sd.PathName, "server"), Name: httpAlias}) - hasAnyHTTP = true - } + // Conditionally add HTTP server imports + if hasHTTP { + httpAlias = scope.Unique(sd.PkgName+"svr", "svr") + specs = append(specs, &codegen.ImportSpec{Path: path.Join(genpkg, "http", sd.PathName, "server"), Name: httpAlias}) + hasAnyHTTP = true + } - // Conditionally add gRPC server imports - if hasGRPC { - grpcAlias = scope.Unique(sd.PkgName+"grpc", "grpcsvc") - grpcPbAlias = scope.Unique(sd.PkgName+"pb", "pb") - specs = append(specs, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", sd.PathName, "server"), Name: grpcAlias}, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", sd.PathName, "pb"), Name: grpcPbAlias}, - ) - hasAnyGRPC = true - } + // Conditionally add gRPC server imports + if hasGRPC { + grpcAlias = scope.Unique(sd.PkgName+"grpc", "grpcsvc") + grpcPbAlias = scope.Unique(sd.PkgName+"pb", "pb") + specs = append(specs, + &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", sd.PathName, "server"), Name: grpcAlias}, + &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", sd.PathName, "pb"), Name: grpcPbAlias}, + ) + hasAnyGRPC = true + } - if hws { - hasAnyWS = true - } + if hws { + hasAnyWS = true + } - svcsData = append(svcsData, svcT{ - Name: sd.Name, - StructName: sd.StructName, - SvcVar: sd.VarName + "Svc", - EpVar: sd.VarName + "Endpoints", - SrvVar: sd.VarName + "Server", - GenPkg: genAlias, - GenHTTPPkg: httpAlias, - GenGRPCPkg: grpcAlias, - GenGRPCPbPkg: grpcPbAlias, - HasWebSocket: hws, - HasHTTP: hasHTTP, - HasGRPC: hasGRPC, - }) - } + svcsData = append(svcsData, svcT{ + Name: sd.Name, + StructName: sd.StructName, + SvcVar: sd.VarName + "Svc", + EpVar: sd.VarName + "Endpoints", + SrvVar: sd.VarName + "Server", + GenPkg: genAlias, + GenHTTPPkg: httpAlias, + GenGRPCPkg: grpcAlias, + GenGRPCPbPkg: grpcPbAlias, + HasWebSocket: hws, + HasHTTP: hasHTTP, + HasGRPC: hasGRPC, + }) + } - sections := []*codegen.SectionTemplate{ - codegen.Header("", "main", specs), - {Name: "mains-main", Source: tmpl.Read("main"), Data: map[string]any{ - "APIPkg": info.APIPkg, - "Services": svcsData, - "HasAnyWebSocket": hasAnyWS, - "HasHTTP": hasAnyHTTP, - "HasGRPC": hasAnyGRPC, - "ServiceCount": len(svcsData), - "ServerLabel": serverLabel(roots), - }}, - } + sections := []*codegen.SectionTemplate{ + codegen.Header("", "main", specs), + {Name: "mains-main", Source: tmpl.Read("main"), Data: map[string]any{ + "APIPkg": info.APIPkg, + "Services": svcsData, + "HasAnyWebSocket": hasAnyWS, + "HasHTTP": hasAnyHTTP, + "HasGRPC": hasAnyGRPC, + "ServiceCount": len(svcsData), + "ServerLabel": serverLabel(roots), + }}, + } - var fpath string - if len(info.Services) == 1 { - svc := info.Services[0] - fpath = filepath.ToSlash(filepath.Join("services", svc.PathName, "cmd", svc.PathName, "main.go")) - } else { - // Use the example server directory so people can still `go run ./cmd/` when multiple services - // are served from a single process. - // Derive server directory using same logic as example generator. - fpath = filepath.ToSlash(filepath.Join("cmd", example.Servers.Get(rootServer(roots), roots[0].(*expr.RootExpr)).Dir, "main.go")) - // But we already filtered out the default http.go/main.go, so only our main remains. - } + var fpath string + if len(info.Services) == 1 { + svc := info.Services[0] + fpath = filepath.ToSlash(filepath.Join("services", svc.PathName, "cmd", svc.PathName, "main.go")) + } else { + // Use the example server directory so people can still `go run ./cmd/` when multiple services + // are served from a single process. + // Derive server directory using same logic as example generator. + fpath = filepath.ToSlash(filepath.Join("cmd", example.Servers.Get(rootServer(roots), roots[0].(*expr.RootExpr)).Dir, "main.go")) + // But we already filtered out the default http.go/main.go, so only our main remains. + } - out = append(out, &codegen.File{Path: fpath, SectionTemplates: sections, SkipExist: true}) - } - return out, nil + out = append(out, &codegen.File{Path: fpath, SectionTemplates: sections, SkipExist: true}) + } + return out, nil } func apiPkgAlias(genpkg string, roots []eval.Root) string { - var apiName string - for _, r := range roots { - if root, ok := r.(*expr.RootExpr); ok { - if root != nil && root.API != nil { - apiName = root.API.Name - break - } - } - } - if apiName == "" { - apiName = "api" - } - scope := codegen.NewNameScope() - return scope.Unique(strings.ToLower(codegen.Goify(apiName, false)), "api") + var apiName string + for _, r := range roots { + if root, ok := r.(*expr.RootExpr); ok { + if root != nil && root.API != nil { + apiName = root.API.Name + break + } + } + } + if apiName == "" { + apiName = "api" + } + scope := codegen.NewNameScope() + return scope.Unique(strings.ToLower(codegen.Goify(apiName, false)), "api") } func serverLabel(roots []eval.Root) string { - for _, r := range roots { - if root, ok := r.(*expr.RootExpr); ok { - if root != nil && root.API != nil { - return strings.ToLower(codegen.Goify(root.API.Name, false)) - } - } - } - return "goa-service" + for _, r := range roots { + if root, ok := r.(*expr.RootExpr); ok { + if root != nil && root.API != nil { + return strings.ToLower(codegen.Goify(root.API.Name, false)) + } + } + } + return "goa-service" } func moduleRootFromGenpkg(genpkg string) string { - idx := strings.LastIndex(genpkg, "/") - if idx <= 0 { - return "." - } - return genpkg[:idx] + idx := strings.LastIndex(genpkg, "/") + if idx <= 0 { + return "." + } + return genpkg[:idx] } func httpWebSocketByService(roots []eval.Root) map[string]bool { - hasWS := map[string]bool{} - for _, r := range roots { - root, ok := r.(*expr.RootExpr) - if !ok || root.API == nil || root.API.HTTP == nil { - continue - } - for _, svc := range root.API.HTTP.Services { - for _, e := range svc.HTTPEndpoints { - if e.SSE != nil { - continue - } - // Stream is 0 when no streaming is defined, and >= NoStreamKind (1) when streaming is used - if e.MethodExpr != nil && e.MethodExpr.Stream != 0 { - hasWS[svc.Name()] = true - break - } - } - } - } - return hasWS + hasWS := map[string]bool{} + for _, r := range roots { + root, ok := r.(*expr.RootExpr) + if !ok || root.API == nil || root.API.HTTP == nil { + continue + } + for _, svc := range root.API.HTTP.Services { + for _, e := range svc.HTTPEndpoints { + if e.SSE != nil { + continue + } + // Stream is 0 when no streaming is defined, and >= NoStreamKind (1) when streaming is used + if e.MethodExpr != nil && e.MethodExpr.Stream != 0 { + hasWS[svc.Name()] = true + break + } + } + } + } + return hasWS } // rootServer returns the first server expression if any. func rootServer(roots []eval.Root) *expr.ServerExpr { - for _, r := range roots { - if root, ok := r.(*expr.RootExpr); ok { - if root.API != nil && len(root.API.Servers) > 0 { - return root.API.Servers[0] - } - } - } - return nil + for _, r := range roots { + if root, ok := r.(*expr.RootExpr); ok { + if root.API != nil && len(root.API.Servers) > 0 { + return root.API.Servers[0] + } + } + } + return nil } // httpServicesByName returns map of service names that have HTTP endpoints. func httpServicesByName(roots []eval.Root) map[string]bool { - hasHTTP := map[string]bool{} - for _, r := range roots { - root, ok := r.(*expr.RootExpr) - if !ok || root.API == nil || root.API.HTTP == nil { - continue - } - for _, svc := range root.API.HTTP.Services { - if len(svc.HTTPEndpoints) > 0 { - hasHTTP[svc.Name()] = true - } - } - } - return hasHTTP + hasHTTP := map[string]bool{} + for _, r := range roots { + root, ok := r.(*expr.RootExpr) + if !ok || root.API == nil || root.API.HTTP == nil { + continue + } + for _, svc := range root.API.HTTP.Services { + if len(svc.HTTPEndpoints) > 0 { + hasHTTP[svc.Name()] = true + } + } + } + return hasHTTP } // grpcServicesByName returns map of service names that have gRPC endpoints. func grpcServicesByName(roots []eval.Root) map[string]bool { - hasGRPC := map[string]bool{} - for _, r := range roots { - root, ok := r.(*expr.RootExpr) - if !ok || root.API == nil || root.API.GRPC == nil { - continue - } - for _, svc := range root.API.GRPC.Services { - if len(svc.GRPCEndpoints) > 0 { - hasGRPC[svc.Name()] = true - } - } - } - return hasGRPC + hasGRPC := map[string]bool{} + for _, r := range roots { + root, ok := r.(*expr.RootExpr) + if !ok || root.API == nil || root.API.GRPC == nil { + continue + } + for _, svc := range root.API.GRPC.Services { + if len(svc.GRPCEndpoints) > 0 { + hasGRPC[svc.Name()] = true + } + } + } + return hasGRPC } diff --git a/mains/generate_test.go b/mains/generate_test.go index a1352008..678307cb 100644 --- a/mains/generate_test.go +++ b/mains/generate_test.go @@ -1,96 +1,102 @@ package mains import ( - "testing" + "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "bytes" - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/eval" - httpcodegen "goa.design/goa/v3/http/codegen" - "goa.design/plugins/v3/mains/testdata" + "bytes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + httpcodegen "goa.design/goa/v3/http/codegen" + "goa.design/plugins/v3/mains/testdata" ) func TestSingleServiceMainRelocation(t *testing.T) { - root := codegen.RunDSL(t, testdata.SingleServiceDSL) - svcs := service.NewServicesData(root) - httpSvcs := httpcodegen.NewServicesData(svcs, root.API.HTTP) - files := append(example.ServerFiles("gen", root, svcs), httpcodegen.ExampleServerFiles("gen", httpSvcs)...) + root := codegen.RunDSL(t, testdata.SingleServiceDSL) + svcs := service.NewServicesData(root) + httpSvcs := httpcodegen.NewServicesData(svcs, root.API.HTTP) + files := append(example.ServerFiles("gen", root, svcs), httpcodegen.ExampleServerFiles("gen", httpSvcs)...) - // Sanity: default example mains produce cmd//main.go and cmd//http.go - require.GreaterOrEqual(t, len(files), 2) + // Sanity: default example mains produce cmd//main.go and cmd//http.go + require.GreaterOrEqual(t, len(files), 2) - out, err := Generate("gen", []eval.Root{root}, files) - require.NoError(t, err) + out, err := Generate("gen", []eval.Root{root}, files) + require.NoError(t, err) - // Expect relocated main under services/calc/cmd/calc/main.go - var hasRelocated bool - var hasOldMain, hasOldHTTP bool - for _, f := range out { - switch f.Path { - case "services/calc/cmd/calc/main.go": - hasRelocated = true - case "cmd/edge/main.go": - hasOldMain = true - case "cmd/edge/http.go": - hasOldHTTP = true - } - } - assert.True(t, hasRelocated, "expected relocated main not found") - assert.False(t, hasOldMain, "default example main should be removed") - assert.False(t, hasOldHTTP, "default http.go should be removed") + // Expect relocated main under services/calc/cmd/calc/main.go + var hasRelocated bool + var hasOldMain, hasOldHTTP bool + for _, f := range out { + switch f.Path { + case "services/calc/cmd/calc/main.go": + hasRelocated = true + case "cmd/edge/main.go": + hasOldMain = true + case "cmd/edge/http.go": + hasOldHTTP = true + } + } + assert.True(t, hasRelocated, "expected relocated main not found") + assert.False(t, hasOldMain, "default example main should be removed") + assert.False(t, hasOldHTTP, "default http.go should be removed") } func TestMultiServiceMainStaysUnderCmd(t *testing.T) { - root := codegen.RunDSL(t, testdata.MultiServiceDSL) - svcs := service.NewServicesData(root) - httpSvcs := httpcodegen.NewServicesData(svcs, root.API.HTTP) - files := append(example.ServerFiles("gen", root, svcs), httpcodegen.ExampleServerFiles("gen", httpSvcs)...) + root := codegen.RunDSL(t, testdata.MultiServiceDSL) + svcs := service.NewServicesData(root) + httpSvcs := httpcodegen.NewServicesData(svcs, root.API.HTTP) + files := append(example.ServerFiles("gen", root, svcs), httpcodegen.ExampleServerFiles("gen", httpSvcs)...) - out, err := Generate("gen", []eval.Root{root}, files) - require.NoError(t, err) + out, err := Generate("gen", []eval.Root{root}, files) + require.NoError(t, err) - var hasCmdMain, hasHTTP bool - for _, f := range out { - if f.Path == "cmd/api/main.go" { - hasCmdMain = true - } - if f.Path == "cmd/api/http.go" { - hasHTTP = true - } - } - assert.True(t, hasCmdMain, "expected cmd/api/main.go for multi-service server") - assert.False(t, hasHTTP, "default http.go should be removed") + var hasCmdMain, hasHTTP bool + for _, f := range out { + if f.Path == "cmd/api/main.go" { + hasCmdMain = true + } + if f.Path == "cmd/api/http.go" { + hasHTTP = true + } + } + assert.True(t, hasCmdMain, "expected cmd/api/main.go for multi-service server") + assert.False(t, hasHTTP, "default http.go should be removed") } func TestWebSocketMainIncludesUpgrader(t *testing.T) { - root := codegen.RunDSL(t, testdata.WebSocketServiceDSL) - svcs := service.NewServicesData(root) - httpSvcs := httpcodegen.NewServicesData(svcs, root.API.HTTP) - files := append(example.ServerFiles("gen", root, svcs), httpcodegen.ExampleServerFiles("gen", httpSvcs)...) + root := codegen.RunDSL(t, testdata.WebSocketServiceDSL) + svcs := service.NewServicesData(root) + httpSvcs := httpcodegen.NewServicesData(svcs, root.API.HTTP) + files := append(example.ServerFiles("gen", root, svcs), httpcodegen.ExampleServerFiles("gen", httpSvcs)...) + + out, err := Generate("gen", []eval.Root{root}, files) + require.NoError(t, err) - out, err := Generate("gen", []eval.Root{root}, files) - require.NoError(t, err) + // Find relocated main + var mainFile *codegen.File + for _, f := range out { + if f.Path == "services/chat/cmd/chat/main.go" { + mainFile = f + break + } + } + require.NotNil(t, mainFile) - // Find relocated main - var mainFile *codegen.File - for _, f := range out { - if f.Path == "services/chat/cmd/chat/main.go" { - mainFile = f - break - } - } - require.NotNil(t, mainFile) + // Assert WS import is present in the generated header. + header := mainFile.Section("source-header") + require.Greater(t, len(header), 0) + var hbuf bytes.Buffer + require.NoError(t, header[0].Write(&hbuf)) + assert.Contains(t, hbuf.String(), "github.com/gorilla/websocket") - // Extract code for our section and assert WS import and upgrader usage - sections := mainFile.Section("mains-main") - require.Greater(t, len(sections), 0) - var buf bytes.Buffer - require.NoError(t, sections[0].Write(&buf)) - code := buf.String() - assert.Contains(t, code, "github.com/gorilla/websocket") - assert.Contains(t, code, "websocket.Upgrader") + // Assert upgrader usage is present in the main body. + body := mainFile.Section("mains-main") + require.Greater(t, len(body), 0) + var bbuf bytes.Buffer + require.NoError(t, body[0].Write(&bbuf)) + assert.Contains(t, bbuf.String(), "websocket.Upgrader") } From a7f3b42f21851ed7de64b1220653b48783179af5 Mon Sep 17 00:00:00 2001 From: Raphael Simon Date: Fri, 26 Dec 2025 08:49:38 -0800 Subject: [PATCH 4/4] docs: update for goa v3.23.4 Schema.Defs rename Goa v3.23.4 renamed Schema.Definitions to Schema.Defs to align with JSON Schema draft 2020-12 which uses $defs instead of definitions. Changes: - Update dupSchema() to use Defs field instead of Definitions - Update inlineRefs() to handle both #/$defs/ and #/definitions/ prefixes - Update go.mod to require goa v3.23.4 - Regenerate golden files with new $ref format --- docs/generate.go | 6 +-- docs/inline_refs.go | 15 ++++--- docs/testdata/no-payload-user-return.json | 2 +- docs/testdata/user-payload-no-return.json | 2 +- go.mod | 24 +++++------ go.sum | 52 +++++++++++------------ 6 files changed, 53 insertions(+), 48 deletions(-) diff --git a/docs/generate.go b/docs/generate.go index c279c4d9..9b3efdcb 100644 --- a/docs/generate.go +++ b/docs/generate.go @@ -203,7 +203,7 @@ func dupSchema(s *openapi.Schema) *openapi.Schema { Required: s.Required, AdditionalProperties: s.AdditionalProperties, Properties: make(map[string]*openapi.Schema, len(s.Properties)), - Definitions: make(map[string]*openapi.Schema, len(s.Definitions)), + Defs: make(map[string]*openapi.Schema, len(s.Defs)), AnyOf: nil, Example: s.Example, Extensions: s.Extensions, @@ -214,8 +214,8 @@ func dupSchema(s *openapi.Schema) *openapi.Schema { if s.Items != nil { js.Items = dupSchema(s.Items) } - for n, d := range s.Definitions { - js.Definitions[n] = dupSchema(d) + for n, d := range s.Defs { + js.Defs[n] = dupSchema(d) } if len(s.AnyOf) > 0 { js.AnyOf = make([]*openapi.Schema, len(s.AnyOf)) diff --git a/docs/inline_refs.go b/docs/inline_refs.go index 81696625..fbb0a028 100644 --- a/docs/inline_refs.go +++ b/docs/inline_refs.go @@ -52,12 +52,17 @@ func inlineRefs(s *openapi.Schema, defs map[string]*openapi.Schema, stack map[st } if s.Ref != "" { - const prefix = "#/definitions/" - if !strings.HasPrefix(s.Ref, prefix) { + // Support both legacy "#/definitions/" and JSON Schema 2020-12 "#/$defs/" prefixes. + var name string + switch { + case strings.HasPrefix(s.Ref, "#/$defs/"): + name = strings.TrimPrefix(s.Ref, "#/$defs/") + case strings.HasPrefix(s.Ref, "#/definitions/"): + name = strings.TrimPrefix(s.Ref, "#/definitions/") + default: // Unexpected external ref; leave intact. return } - name := strings.TrimPrefix(s.Ref, prefix) if name == "" { return } @@ -96,8 +101,8 @@ func inlineRefs(s *openapi.Schema, defs map[string]*openapi.Schema, stack map[st inlineRefs(a, defs, stack) } } - if len(s.Definitions) > 0 { - for _, d := range s.Definitions { + if len(s.Defs) > 0 { + for _, d := range s.Defs { inlineRefs(d, defs, stack) } } diff --git a/docs/testdata/no-payload-user-return.json b/docs/testdata/no-payload-user-return.json index 82985aec..28ce1d8d 100644 --- a/docs/testdata/no-payload-user-return.json +++ b/docs/testdata/no-payload-user-return.json @@ -1 +1 @@ -{"api":{"name":"Test API","version":"0.0.1","servers":{"Test API":{"name":"Test API","description":"Default server for Test API","services":["Service"],"hosts":{"localhost":{"name":"localhost","server":"Test API","uris":["http://localhost:80","grpc://localhost:8080"]}}}}},"services":{"Service":{"name":"Service","methods":{"Method":{"name":"Method","result":{"type":{"$ref":"#/definitions/User"},"example":{"att1":"Soluta veritatis odit minus voluptatum sunt commodi.","att2":2887366790483849171}}}}}},"definitions":{"User":{"title":"User","type":"object","properties":{"att1":{"type":"string","example":"In voluptatem consectetur."},"att2":{"type":"integer","example":443436312039258672,"format":"int64"}},"example":{"att1":"Accusamus saepe et sit.","att2":8511135955551101225}}}} \ No newline at end of file +{"api":{"name":"Test API","version":"0.0.1","servers":{"Test API":{"name":"Test API","description":"Default server for Test API","services":["Service"],"hosts":{"localhost":{"name":"localhost","server":"Test API","uris":["http://localhost:80","grpc://localhost:8080"]}}}}},"services":{"Service":{"name":"Service","methods":{"Method":{"name":"Method","result":{"type":{"$ref":"#/$defs/User"},"example":{"att1":"Soluta veritatis odit minus voluptatum sunt commodi.","att2":2887366790483849171}}}}}},"definitions":{"User":{"title":"User","type":"object","properties":{"att1":{"type":"string","example":"In voluptatem consectetur."},"att2":{"type":"integer","example":443436312039258672,"format":"int64"}},"example":{"att1":"Accusamus saepe et sit.","att2":8511135955551101225}}}} \ No newline at end of file diff --git a/docs/testdata/user-payload-no-return.json b/docs/testdata/user-payload-no-return.json index 1845377f..5a6e3924 100644 --- a/docs/testdata/user-payload-no-return.json +++ b/docs/testdata/user-payload-no-return.json @@ -1 +1 @@ -{"api":{"name":"Test API","version":"0.0.1","servers":{"Test API":{"name":"Test API","description":"Default server for Test API","services":["Service"],"hosts":{"localhost":{"name":"localhost","server":"Test API","uris":["http://localhost:80","grpc://localhost:8080"]}}}}},"services":{"Service":{"name":"Service","methods":{"Method":{"name":"Method","payload":{"type":{"$ref":"#/definitions/User"},"example":{"att1":"Soluta veritatis odit minus voluptatum sunt commodi.","att2":2887366790483849171}}}}}},"definitions":{"User":{"title":"User","type":"object","properties":{"att1":{"type":"string","example":"In voluptatem consectetur."},"att2":{"type":"integer","example":443436312039258672,"format":"int64"}},"example":{"att1":"Accusamus saepe et sit.","att2":8511135955551101225}}}} \ No newline at end of file +{"api":{"name":"Test API","version":"0.0.1","servers":{"Test API":{"name":"Test API","description":"Default server for Test API","services":["Service"],"hosts":{"localhost":{"name":"localhost","server":"Test API","uris":["http://localhost:80","grpc://localhost:8080"]}}}}},"services":{"Service":{"name":"Service","methods":{"Method":{"name":"Method","payload":{"type":{"$ref":"#/$defs/User"},"example":{"att1":"Soluta veritatis odit minus voluptatum sunt commodi.","att2":2887366790483849171}}}}}},"definitions":{"User":{"title":"User","type":"object","properties":{"att1":{"type":"string","example":"In voluptatem consectetur."},"att2":{"type":"integer","example":443436312039258672,"format":"int64"}},"example":{"att1":"Accusamus saepe et sit.","att2":8511135955551101225}}}} \ No newline at end of file diff --git a/go.mod b/go.mod index 76898fc1..098bf4d0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.27.0 goa.design/clue v1.2.3 - goa.design/goa/v3 v3.22.5 + goa.design/goa/v3 v3.23.4 goa.design/model v1.13.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -21,7 +21,7 @@ require ( github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect - github.com/gohugoio/hashstructure v0.5.0 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -31,15 +31,15 @@ require ( go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/tools v0.37.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect - google.golang.org/grpc v1.75.1 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 1292e55f..794de6b8 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,8 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= -github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -70,8 +70,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= -go.opentelemetry.io/auto/sdk v1.2.0 h1:YpRtUFjvhSymycLS2T81lT6IGhcUP+LUPtv0iv1N8bM= -go.opentelemetry.io/auto/sdk v1.2.0/go.mod h1:1deq2zL7rwjwC8mR7XgY2N+tlIl6pjmEUoLDENMEzwk= +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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE= @@ -92,32 +92,32 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= goa.design/clue v1.2.3 h1:ho2TkqaLjdt0/fA2ouwQSwPbq75RLI/2o5/4xYxyCj4= goa.design/clue v1.2.3/go.mod h1:7/L931m3SrOfxebASs4/R3QP71K/4JUzUTol8mtk7wQ= -goa.design/goa/v3 v3.22.5 h1:8rSbco1Ind/jrSYsXN4fLzchxQrGgVESTQxSGYEGq8g= -goa.design/goa/v3 v3.22.5/go.mod h1:PgV47RNYgRg+buOAs4xYG0eG38a1yWf/kgiQasejF8s= +goa.design/goa/v3 v3.23.4 h1:7d9IAtyC8aP9bAvTdY+YPQaScpoZRd/paDH3PSXaxbM= +goa.design/goa/v3 v3.23.4/go.mod h1:da3W585WfJe9gT+hJCbP8YFB9yc4gmuCwB0MvkbwhXk= goa.design/model v1.13.0 h1:PeYp5lD7GhqGDRODNGXIdySl9em7VBhh91rCTwfhVmA= goa.design/model v1.13.0/go.mod h1:UypAKlz3BFCg4qa4DHNYpUq2ZLvaYjq6eonf5515uDU= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=