Skip to content

Commit 0783eb0

Browse files
fix(generate): scope a contract endpoint's package to its declared surface (#991)
`codefly generate contracts` refuses a service whose owned proto tree carries both its served API and a client facade for another service's API: proto declares services in multiple packages [saas.accounts.v1 saas.policylog.v1]; a contract endpoint must be single-package The contract is in fact single-package. The facade is there because the service CALLS that API and needs generated stubs, so the proto has to sit in the owned tree for codegen to reach it — `--path saas`, with vendored imports excluded as import-only. But the service serves none of it, and serviceContractPackage derived the package purely by walking the proto tree, so a package the endpoint does not serve made a well-formed endpoint look multi-package and the export failed. The answer was already on disk and unread. cli#947 gave a module the CLI-owned contracts.codefly.yaml, whose `surfaces` name a per-endpoint inventory of the services each endpoint actually serves, and writeBuiltProtobufContract already receives that endpoint's surface in order to fill the catalog's `services[]`. It just never reached the package derivation. It does now: when a surface is declared, candidate packages are scoped to the ones its services' fully-qualified names carry. Deliberately unchanged in two directions, because a fix to a refusal must not become a way past it: - A module that declares NO surface narrows nothing. The single-package rule stands exactly as before, which is what keeps the default honest for a service that has no reason to carry a foreign package. - A surface that names no package the descriptor set declares a service in narrows nothing either. That is a configuration error, and the existing unscoped diagnostics describe it far better than a silently empty contract would. The copied proto sources are untouched: the descriptor set spans the whole owned tree and the sources copy with it, as it already does for message-only packages. Narrowing what gets copied is a separate question from which package the endpoint's contract reports. Found from module-saas-starter#1067, where accounts consumes a policy-log witness (`policylogv1.WitnessServiceClient`) and `generate contracts --check` refused the tree. Verified against that exact tree with a locally built binary: before, the multi-package refusal; after, it proceeds and reports ordinary drift — `run codefly generate contracts to update` — which is what --check should say for a tree whose contracts have not been regenerated yet. Tests: three cases in TestServiceContractPackage — a facade in a foreign package scoped away by its surface (and still refused without one, so the rule is scoped by a declaration rather than removed), a surface naming an absent package falling back to the unscoped diagnostics, and a surface whose service name carries no package qualifier narrowing nothing. Mutation-verified: emptying the scoping set reds the first. `go test ./cmd/generate/` passes, `go build ./...` and `go vet` clean. docs/commands.md records the behaviour in the contracts.codefly.yaml section. Co-authored-by: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a9e467b commit 0783eb0

3 files changed

Lines changed: 126 additions & 7 deletions

File tree

‎cmd/generate/contracts.go‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path"
1111
"path/filepath"
1212
"sort"
13+
"strings"
1314
"time"
1415

1516
"github.com/codefly-dev/cli/cmd/common"
@@ -382,7 +383,7 @@ func writeBuiltProtobufContract(ctx context.Context, module *resources.Module, s
382383
return nil, fmt.Errorf("cannot parse generated descriptor set: %w", err)
383384
}
384385

385-
pkg, err := serviceContractPackage(&set, protoDir)
386+
pkg, err := serviceContractPackage(&set, protoDir, surface)
386387
if err != nil {
387388
return nil, fmt.Errorf("service %s endpoint %s: %w", service.Name, endpoint.Name, err)
388389
}
@@ -422,7 +423,18 @@ func writeBuiltProtobufContract(ctx context.Context, module *resources.Module, s
422423
// all of its RPC services in one package; message-only packages contributed
423424
// alongside it (composed settings, shared types) carry no service to expose in
424425
// a client contract and are ignored.
425-
func serviceContractPackage(set *descriptorpb.FileDescriptorSet, protoDir string) (string, error) {
426+
//
427+
// `surface` is the endpoint's declared served surface, when the module declares
428+
// one through contracts.codefly.yaml. It names which services this endpoint
429+
// serves, so it also settles which proto package the contract covers, and the
430+
// candidates are scoped to it. That matters for a service that must generate
431+
// client stubs from ANOTHER service's API: those protos declare services in a
432+
// foreign package and have to live in the owned tree for codegen to reach them,
433+
// yet the endpoint does not serve them, so without this they make a
434+
// single-package endpoint look multi-package and the export fails on a
435+
// contract that is in fact well formed. A module that declares no surface is
436+
// unchanged: nothing narrows the candidates and the single-package rule stands.
437+
func serviceContractPackage(set *descriptorpb.FileDescriptorSet, protoDir string, surface []composition.APIContractService) (string, error) {
426438
own := map[string]struct{}{}
427439
walkErr := filepath.WalkDir(protoDir, func(p string, entry os.DirEntry, err error) error {
428440
if err != nil {
@@ -462,6 +474,23 @@ func serviceContractPackage(set *descriptorpb.FileDescriptorSet, protoDir string
462474
}
463475
sort.Strings(packages)
464476

477+
// Scope to the declared surface. Dropping to zero means the surface names
478+
// no package the descriptor set declares a service in, which is a
479+
// configuration error the unscoped diagnostics below describe better than a
480+
// silent empty contract, so the narrowing is only applied when it keeps at
481+
// least one candidate.
482+
if served := surfacePackages(surface); len(served) > 0 {
483+
scoped := make([]string, 0, len(packages))
484+
for _, pkg := range packages {
485+
if _, ok := served[pkg]; ok {
486+
scoped = append(scoped, pkg)
487+
}
488+
}
489+
if len(scoped) > 0 {
490+
packages = scoped
491+
}
492+
}
493+
465494
switch len(packages) {
466495
case 0:
467496
// buf always includes the service's own files in the descriptor set,
@@ -481,6 +510,21 @@ func serviceContractPackage(set *descriptorpb.FileDescriptorSet, protoDir string
481510
}
482511
}
483512

513+
// surfacePackages is the set of proto packages a declared surface names, taken
514+
// from each service's fully-qualified name. A name carrying no package
515+
// qualifier contributes nothing rather than an empty-string package.
516+
func surfacePackages(surface []composition.APIContractService) map[string]struct{} {
517+
packages := make(map[string]struct{}, len(surface))
518+
for _, service := range surface {
519+
dot := strings.LastIndex(service.FullName, ".")
520+
if dot <= 0 {
521+
continue
522+
}
523+
packages[service.FullName[:dot]] = struct{}{}
524+
}
525+
return packages
526+
}
527+
484528
func writeRestContract(ctx context.Context, module *resources.Module, endpoint *basev0.Endpoint, rest *basev0.RestAPI, physicalDir, canonicalDir string) (*composition.APIContractEndpoint, error) {
485529
if len(rest.Openapi) == 0 {
486530
return nil, fmt.Errorf("endpoint %s has no OpenAPI document", endpoint.Name)

‎cmd/generate/contracts_test.go‎

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,14 @@ func TestServiceContractPackage(t *testing.T) {
543543
// protoDir holds the service's own files; imports buf pulls in live
544544
// elsewhere and must be ignored even when they declare services.
545545
protoDir := t.TempDir()
546-
for _, rel := range []string{"saas/acct/v1/api.proto", "saas/shared/v1/types.proto"} {
546+
// witness.proto stands for a client facade the service generates stubs from
547+
// but does not serve. A subtest only sees it when its own descriptor set
548+
// names it, so its presence here changes nothing for the others.
549+
for _, rel := range []string{
550+
"saas/acct/v1/api.proto",
551+
"saas/shared/v1/types.proto",
552+
"saas/policylog/v1/witness.proto",
553+
} {
547554
full := filepath.Join(protoDir, filepath.FromSlash(rel))
548555
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
549556
t.Fatal(err)
@@ -568,7 +575,7 @@ func TestServiceContractPackage(t *testing.T) {
568575
// an import: has a service but is not one of the service's own files.
569576
file("google/protobuf/descriptor.proto", "google.protobuf", true),
570577
}}
571-
pkg, err := serviceContractPackage(set, protoDir)
578+
pkg, err := serviceContractPackage(set, protoDir, nil)
572579
if err != nil {
573580
t.Fatalf("unexpected error: %v", err)
574581
}
@@ -582,7 +589,7 @@ func TestServiceContractPackage(t *testing.T) {
582589
file("saas/acct/v1/api.proto", "saas.acct.v1", false),
583590
file("saas/shared/v1/types.proto", "saas.shared.v1", false),
584591
}}
585-
if _, err := serviceContractPackage(set, protoDir); err == nil {
592+
if _, err := serviceContractPackage(set, protoDir, nil); err == nil {
586593
t.Fatal("expected an error when no own file declares a service")
587594
}
588595
})
@@ -592,11 +599,67 @@ func TestServiceContractPackage(t *testing.T) {
592599
file("saas/acct/v1/api.proto", "saas.acct.v1", true),
593600
file("saas/shared/v1/types.proto", "saas.shared.v1", true),
594601
}}
595-
if _, err := serviceContractPackage(set, protoDir); err == nil {
602+
if _, err := serviceContractPackage(set, protoDir, nil); err == nil {
596603
t.Fatal("expected an error when services span multiple packages")
597604
}
598605
})
599606

607+
t.Run("a declared surface scopes the package, so a client facade in a foreign package is not a second contract package", func(t *testing.T) {
608+
// The shape that sent this to a CLI issue: a service whose own tree
609+
// carries BOTH its served API and a client facade for another
610+
// service's API. The facade has to sit in the owned tree for codegen to
611+
// reach it, and it declares a service in its own package, so the
612+
// unscoped rule reports a multi-package endpoint for a contract that is
613+
// in fact single-package. The declared surface already says which
614+
// services the endpoint serves.
615+
set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{
616+
file("saas/acct/v1/api.proto", "saas.acct.v1", true),
617+
file("saas/policylog/v1/witness.proto", "saas.policylog.v1", true),
618+
}}
619+
surface := []composition.APIContractService{
620+
{Name: "AcctService", FullName: "saas.acct.v1.AcctService"},
621+
}
622+
pkg, err := serviceContractPackage(set, protoDir, surface)
623+
if err != nil {
624+
t.Fatalf("unexpected error: %v", err)
625+
}
626+
if pkg != "saas.acct.v1" {
627+
t.Fatalf("pkg = %q, want saas.acct.v1", pkg)
628+
}
629+
// Without the surface the same set is still refused, so the rule is
630+
// scoped by a declaration rather than removed.
631+
if _, err = serviceContractPackage(set, protoDir, nil); err == nil {
632+
t.Fatal("expected the unscoped call to still refuse two service packages")
633+
}
634+
})
635+
636+
t.Run("a surface naming no declared package falls back to the unscoped diagnostics", func(t *testing.T) {
637+
set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{
638+
file("saas/acct/v1/api.proto", "saas.acct.v1", true),
639+
file("saas/shared/v1/types.proto", "saas.shared.v1", true),
640+
}}
641+
surface := []composition.APIContractService{
642+
{Name: "Absent", FullName: "saas.absent.v1.Absent"},
643+
}
644+
if _, err := serviceContractPackage(set, protoDir, surface); err == nil {
645+
t.Fatal("expected the multi-package refusal rather than a silent empty contract")
646+
}
647+
})
648+
649+
t.Run("a surface whose service name carries no package qualifier narrows nothing", func(t *testing.T) {
650+
set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{
651+
file("saas/acct/v1/api.proto", "saas.acct.v1", true),
652+
}}
653+
surface := []composition.APIContractService{{Name: "Acct", FullName: "Acct"}}
654+
pkg, err := serviceContractPackage(set, protoDir, surface)
655+
if err != nil {
656+
t.Fatalf("unexpected error: %v", err)
657+
}
658+
if pkg != "saas.acct.v1" {
659+
t.Fatalf("pkg = %q, want saas.acct.v1", pkg)
660+
}
661+
})
662+
600663
t.Run("descriptor names not matching own paths reports a layout remap, not 'no services'", func(t *testing.T) {
601664
// buf stripped a "saas/" prefix (a module path or roots entry), so no
602665
// descriptor file name equals its path under protoDir even though the
@@ -605,7 +668,7 @@ func TestServiceContractPackage(t *testing.T) {
605668
file("acct/v1/api.proto", "saas.acct.v1", true),
606669
file("shared/v1/types.proto", "saas.shared.v1", false),
607670
}}
608-
_, err := serviceContractPackage(set, protoDir)
671+
_, err := serviceContractPackage(set, protoDir, nil)
609672
if err == nil {
610673
t.Fatal("expected an error when no descriptor file matches an own path")
611674
}

‎docs/commands.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2772,6 +2772,18 @@ supply the inventory, since copying a full descriptor to each cannot prove which
27722772
listener serves a method. A single endpoint keeps the full-descriptor default;
27732773
restricted single listeners must also supply their inventory.
27742774

2775+
A declared inventory also settles which proto **package** each endpoint's
2776+
contract covers, which matters for a service that generates client stubs from
2777+
another service's API. Those protos declare services in a foreign package and
2778+
must sit in the owned tree for codegen to reach them, yet the endpoint does not
2779+
serve them. Without an inventory such a tree is refused — `proto declares
2780+
services in multiple packages …; a contract endpoint must be single-package` —
2781+
for a contract that is in fact single-package. With one, candidate packages are
2782+
scoped to those the inventory names. A service that declares no inventory is
2783+
unaffected, and so is one whose inventory names no package the descriptor set
2784+
declares a service in: the single-package rule and its diagnostics stand
2785+
unchanged, rather than yielding an empty contract.
2786+
27752787
This input is a stopgap for the released Core/agent endpoint report. In Core
27762788
v0.18.0, `GrpcAPI.rpcs` is loaded from the service's shared proto, without
27772789
listener filtering, while Connect is reported as `HttpAPI` with no RPC list.

0 commit comments

Comments
 (0)