diff --git a/v2/app/errors.go b/v2/app/errors.go index 8b1fe46e..ae4abfa7 100644 --- a/v2/app/errors.go +++ b/v2/app/errors.go @@ -46,4 +46,10 @@ var ( "Conflicting service definitions", "A service cannot use both an //encore:service directive and a service.New() call.", ) + + errExposeUnknownGateway = errRange.Newf( + "Endpoint exposed on undeclared gateway", + "The endpoint is exposed on gateway %q via //encore:expose, but no gateway with that name has been declared.", + errors.WithDetails("Add gateway.New(\"\", gateway.Config{}) in an encore.gateway.go file. The implicit \"api-gateway\" name is only available when no gateways are declared at all."), + ) ) diff --git a/v2/app/testdata/expose_unknown_gateway.txt b/v2/app/testdata/expose_unknown_gateway.txt new file mode 100644 index 00000000..70ac081a --- /dev/null +++ b/v2/app/testdata/expose_unknown_gateway.txt @@ -0,0 +1,50 @@ +# Verify that //encore:expose with a gateway name that hasn't been declared +# (and isn't the synthesized "api-gateway" default) is rejected. +! parse +err 'exposed on undeclared gateway' + +-- encore.app -- +{"edition": "2026"} + +-- gw/encore.gateway.go -- +package gw + +import "encore.dev/gateway" + +var _ = gateway.New("api", gateway.Config{}) + +-- svc/encore.service.go -- +package svc + +import "encore.dev/service" + +type MyService struct{} + +var _ = service.New("svc", service.Config[MyService]{}) + +-- svc/svc.go -- +package svc + +import "context" + +//encore:api path=/hello +//encore:expose gateway=does-not-exist +func Hello(ctx context.Context) error { return nil } +-- want: errors -- + +── Endpoint exposed on undeclared gateway ─────────────────────────────────────────────────[E9999]── + +The endpoint is exposed on gateway "does-not-exist" via //encore:expose, but no gateway with that +name has been declared. + + ╭─[ svc/svc.go:6:17 ] + │ + 4 │ + 5 │ //encore:api path=/hello + 6 │ //encore:expose gateway=does-not-exist + ⋮ ────────────────────── + 7 │ func Hello(ctx context.Context) error { return nil } +───╯ + +Add gateway.New("", gateway.Config{}) in an encore.gateway.go file. The implicit +"api-gateway" name is only available when no gateways are declared at all. diff --git a/v2/app/testdata/expose_user_named_gateway.txt b/v2/app/testdata/expose_user_named_gateway.txt new file mode 100644 index 00000000..ec9c106c --- /dev/null +++ b/v2/app/testdata/expose_user_named_gateway.txt @@ -0,0 +1,32 @@ +# Verify that a user-declared gateway with a non-default name works end-to-end: +# the endpoint's expose list should reference the user's gateway, and the +# implicit "api-gateway" should NOT be synthesized when any gateway is declared. +parse + +-- encore.app -- +{"edition": "2026"} + +-- gw/encore.gateway.go -- +package gw + +import "encore.dev/gateway" + +var _ = gateway.New("api", gateway.Config{}) + +-- svc/encore.service.go -- +package svc + +import "encore.dev/service" + +type MyService struct{} + +var _ = service.New("svc", service.Config[MyService]{}) + +-- svc/svc.go -- +package svc + +import "context" + +//encore:api path=/hello +//encore:expose gateway=api +func Hello(ctx context.Context) error { return nil } diff --git a/v2/app/v2meta/v2meta.go b/v2/app/v2meta/v2meta.go index ea4a792f..a76332a7 100644 --- a/v2/app/v2meta/v2meta.go +++ b/v2/app/v2meta/v2meta.go @@ -10,8 +10,8 @@ import ( metav2 "encore.dev/appruntime/exported/proto/encore/parser/meta/v2" schemapb "encore.dev/appruntime/exported/proto/encore/parser/schema/v2" - "encr.dev/pkg/fns" sourcev1 "encore.dev/appruntime/exported/proto/encore/parser/source/v1" + "encr.dev/pkg/fns" "encr.dev/pkg/idents" "encr.dev/pkg/option" "encr.dev/v2/app" @@ -543,13 +543,6 @@ func (b *builder) build() { } } - // Ensure a default gateway exists. - if len(b.res.Gateways) == 0 { - b.res.Gateways["api-gateway"] = &metav2.Gateway{ - RepoId: "default", - Name: "api-gateway", - } - } // For legacy edition (single auth handler), attach it to the first gateway. // For edition 2026 with multiple handlers, gateways get their handler from // the gateway.New() config (already parsed). For now, attach the first handler @@ -1321,7 +1314,6 @@ func (b *builder) secrets(r *secrets.Secrets) { } } - func (b *builder) apiPath(path *resourcepaths.Path) *metav2.Path { res := &metav2.Path{} for _, p := range path.Segments { diff --git a/v2/app/validate_apis.go b/v2/app/validate_apis.go index 4e5a332f..21b91094 100644 --- a/v2/app/validate_apis.go +++ b/v2/app/validate_apis.go @@ -23,6 +23,13 @@ func (d *Desc) validateAPIs(pc *parsectx.Context, fw *apiframework.AppDesc, resu apiPaths := resourcepaths.NewSet() + // Build a set of declared gateway names. discoverGateways guarantees + // "api-gateway" is included when no gateway has been explicitly declared. + declaredGateways := make(map[string]struct{}, len(d.Gateways)) + for _, gw := range d.Gateways { + declaredGateways[gw.EncoreName] = struct{}{} + } + for _, svc := range d.Services { fwSvc, ok := svc.Framework.Get() if !ok { @@ -43,6 +50,17 @@ func (d *Desc) validateAPIs(pc *parsectx.Context, fw *apiframework.AppDesc, resu ) } + // Verify each gateway named in //encore:expose has been declared + // (or matches the synthesized default). Only edition 2026 endpoints + // can populate ExposedGateways; legacy endpoints leave it empty. + for _, name := range ep.ExposedGateways { + if _, ok := declaredGateways[name]; ok { + continue + } + err := errExposeUnknownGateway(name) + pc.Errs.Add(errors.AtOptionalNode(err, ep.ExposeField)) + } + // Check for duplicate paths by adding them to the set // Note, errors will be reported automatically to pc.Errs for _, method := range ep.HTTPMethods { diff --git a/v2/codegenv2/servergen/servergen.go b/v2/codegenv2/servergen/servergen.go index cbad7a02..b93a4e59 100644 --- a/v2/codegenv2/servergen/servergen.go +++ b/v2/codegenv2/servergen/servergen.go @@ -11,6 +11,8 @@ import ( "github.com/dave/jennifer/jen" + metav2 "encore.dev/appruntime/exported/proto/encore/parser/meta/v2" + schemapb "encore.dev/appruntime/exported/proto/encore/parser/schema/v2" "encr.dev/pkg/paths" "encr.dev/v2/app" "encr.dev/v2/app/apiframework" @@ -20,8 +22,6 @@ import ( "encr.dev/v2/parser/apis/api" "encr.dev/v2/parser/apis/selector" "encr.dev/v2/parser/infra/pubsub" - metav2 "encore.dev/appruntime/exported/proto/encore/parser/meta/v2" - schemapb "encore.dev/appruntime/exported/proto/encore/parser/schema/v2" ) const ( @@ -220,12 +220,23 @@ func genEndpoint(grpcService string, ep *api.Endpoint, handlerDecl *codegenv2.Va // Set access-related fields. switch ep.Access { - case api.Public: - d[jen.Id("NoAuthRequired")] = jen.True() - d[jen.Id("ExposedGateways")] = jen.Index().String().Values(jen.Lit("api-gateway")) - case api.Auth: - d[jen.Id("NoAuthRequired")] = jen.False() - d[jen.Id("ExposedGateways")] = jen.Index().String().Values(jen.Lit("api-gateway")) + case api.Public, api.Auth: + d[jen.Id("NoAuthRequired")] = jen.Lit(ep.Access == api.Public) + + // Use the parser's expose list if populated (edition 2026 with + // //encore:expose gateway=...). Fall back to the synthesized + // "api-gateway" — discoverGateways guarantees a gateway with that + // name exists when no other gateway has been declared. + names := ep.ExposedGateways + if len(names) == 0 { + names = []string{"api-gateway"} + } + lits := make([]jen.Code, len(names)) + for i, n := range names { + lits[i] = jen.Lit(n) + } + d[jen.Id("ExposedGateways")] = jen.Index().String().Values(lits...) + case api.Private: d[jen.Id("NoAuthRequired")] = jen.True() }