diff --git a/cmd/sync/solution_sdk.go b/cmd/sync/solution_sdk.go index f623088e6..6362951f5 100644 --- a/cmd/sync/solution_sdk.go +++ b/cmd/sync/solution_sdk.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" @@ -19,6 +20,7 @@ import ( resources "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/shared" "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/core/standards" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -44,8 +46,15 @@ var SolutionSDKCmd = &cobra.Command{ Short: "Aggregate one client library per solution from api.consumes", Long: `Reads api.consumes in solution.codefly.yaml and produces one aggregated client library per solution, carrying only the declared services, generated -from the contracts of the module packages the workspace composes. Also keeps -the runtime service-dependencies in step with the declaration. +from the contracts of the module packages the workspace composes. A consume +of a module's rest or connect endpoint is generated from that service's +protobuf contract. Each consumed module's facade and bindings get a +directory of their own. + +A consume of another module is reached through the authority hop, which +fronts every module behind it on one Connect face, and needs no +service-dependencies entry; only a consume of the solution's own module is a +direct call, and that one is kept in step with the declaration. `, Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { @@ -60,7 +69,7 @@ the runtime service-dependencies in step with the declaration. func init() { SolutionSDKCmd.Flags().StringSliceVar(&solutionSDKLanguages, "language", nil, "languages to generate: go,typescript,python") SolutionSDKCmd.Flags().BoolVar(&solutionSDKCheck, "check", false, "do not write; exit 1 if the library or service-dependencies are out of date") - SolutionSDKCmd.Flags().BoolVar(&solutionSDKApply, "apply-dependencies", false, "add missing service-dependencies entries for every bound consume entry") + SolutionSDKCmd.Flags().BoolVar(&solutionSDKApply, "apply-dependencies", false, "add missing service-dependencies entries for consumes of the solution's own module (other modules are reached through the authority hop)") SolutionSDKCmd.Flags().BoolVar(&solutionSDKForce, "force", false, "overwrite a library generated from a different version of a consumed contract") } @@ -77,7 +86,11 @@ func runSyncSolutionSDK(ctx context.Context) error { if err != nil { return fmt.Errorf("cannot read %s: %w", manifest.FileName, err) } - solutionManifest, err := manifest.Load(data) + // This command reads api.consumes and nothing else, so it reads the + // manifest the way core says such a reader must: LoadConsumption holds the + // consumption strictly and leaves the executor sections (agent, lifecycle) + // unread, so a solution with no executor is not refused for lacking one. + solutionManifest, err := manifest.LoadConsumption(data) if err != nil { return fmt.Errorf("cannot load %s: %w", manifest.FileName, err) } @@ -96,7 +109,7 @@ func runSyncSolutionSDK(ctx context.Context) error { return err } - missing, err := missingServiceDependencies(entryService, entries) + missing, err := missingServiceDependencies(entryModule, entryService, solutionManifest.API.Consumes) if err != nil { return err } @@ -178,15 +191,9 @@ func resolveConsumedContracts(ctx context.Context, workspace *resources.Workspac } } - var endpointEntry *composition.APIContractEndpoint - for i := range catalog.Endpoints { - if catalog.Endpoints[i].Service == consume.Service && catalog.Endpoints[i].Endpoint == consume.Endpoint { - endpointEntry = &catalog.Endpoints[i] - break - } - } - if endpointEntry == nil { - return nil, fmt.Errorf("module %s catalog has no endpoint %s/%s", consume.Module, consume.Service, consume.Endpoint) + endpointEntry, err := consumedContract(ctx, dir, catalog, consume) + if err != nil { + return nil, err } if len(consume.Services) > 0 { @@ -224,6 +231,85 @@ func resolveConsumedContracts(ctx context.Context, workspace *resources.Workspac return entries, nil } +// consumedContract selects the catalog entry a consume is generated from: the +// protobuf contract the authority hop routes for the consumed service, since a +// consume of another module is called through the hop's Connect face. The +// endpoint the consume names wins when the catalog carries a protobuf contract +// for it. A rest or connect endpoint otherwise serves its service's protobuf +// contract over another transport — connect natively, rest transcoded — while +// the catalog carries that contract under the service's grpc endpoint, so the +// consume resolves to the service's protobuf contract. Several protobuf +// entries of one service are that one contract when they carry one digest, +// the rule the hop routes by (a service's gRPC and Connect listeners under one +// contract are one destination); under different digests which one the named +// endpoint serves has no single answer, so it is refused. A rest endpoint of a +// service with no protobuf contract keeps its own OpenAPI entry. +func consumedContract(ctx context.Context, moduleDir string, catalog *composition.APIContractCatalog, consume *manifest.APIDeclaration) (*composition.APIContractEndpoint, error) { + var named *composition.APIContractEndpoint + var protobuf []*composition.APIContractEndpoint + for i := range catalog.Endpoints { + entry := &catalog.Endpoints[i] + if entry.Service != consume.Service { + continue + } + if entry.Endpoint == consume.Endpoint { + named = entry + } + if entry.Kind == composition.APIContractKindProtobuf { + protobuf = append(protobuf, entry) + } + } + if named != nil && named.Kind == composition.APIContractKindProtobuf { + return named, nil + } + + api := "" + if named != nil { + api = named.API + } else { + declared, err := producerEndpointAPI(ctx, moduleDir, consume) + if err != nil { + return nil, err + } + api = declared + } + if (api == standards.REST || api == standards.CONNECT) && len(protobuf) > 0 { + if slices.ContainsFunc(protobuf, func(entry *composition.APIContractEndpoint) bool { return entry.Digest != protobuf[0].Digest }) { + endpoints := make([]string, 0, len(protobuf)) + for _, entry := range protobuf { + endpoints = append(endpoints, entry.Endpoint+" ("+entry.Digest+")") + } + return nil, fmt.Errorf("consume %s/%s/%s: service %s exports different protobuf contracts on several endpoints (%s), so which one its %s endpoint serves has no single answer; consume one of them by name", + consume.Module, consume.Service, consume.Endpoint, consume.Service, strings.Join(endpoints, ", "), api) + } + return protobuf[0], nil + } + if named != nil { + return named, nil + } + return nil, fmt.Errorf("module %s catalog has no contract for endpoint %s/%s (api %s)", consume.Module, consume.Service, consume.Endpoint, api) +} + +// producerEndpointAPI reads the API of the endpoint a consume names from the +// producing service's own declaration, for an endpoint the catalog carries no +// contract for. +func producerEndpointAPI(ctx context.Context, moduleDir string, consume *manifest.APIDeclaration) (string, error) { + module, err := resources.LoadModuleFromDir(ctx, moduleDir) + if err != nil { + return "", fmt.Errorf("cannot load module %s: %w", consume.Module, err) + } + service, err := module.LoadServiceFromName(ctx, consume.Service) + if err != nil { + return "", fmt.Errorf("consume %s/%s/%s: module %s has no service %s: %w", consume.Module, consume.Service, consume.Endpoint, consume.Module, consume.Service, err) + } + for _, endpoint := range service.Endpoints { + if endpoint != nil && endpoint.Name == consume.Endpoint { + return endpoint.API, nil + } + } + return "", fmt.Errorf("consume %s/%s/%s: service %s/%s declares no endpoint %s", consume.Module, consume.Service, consume.Endpoint, consume.Module, consume.Service, consume.Endpoint) +} + // resolveSolutionEntryService finds the solution root — the workspace's own // module (the `path: .` self module, or, failing an explicit path, the // module whose name matches the workspace) — and its service-entry. @@ -268,21 +354,30 @@ type missingDependency struct { Module, Service, Endpoint string } -// missingServiceDependencies reports which consumed entries have no matching -// service-dependencies item, matched by (name, module) — the same identity -// granularity core's own Application.HasServiceDependency uses. -func missingServiceDependencies(service *resources.Service, entries []generators.ContractEntry) ([]missingDependency, error) { +// missingServiceDependencies reports which consumed entries the entry service +// calls directly and declares no service-dependencies item for, matched by +// (name, module) — the same identity granularity core's own +// Application.HasServiceDependency uses. +// +// Only a consume of the solution's own module is a direct call. A consume of +// any other module is called through the authority hop's Connect face: the +// hop routes every module the composition puts behind it from that module's +// published API catalog (orchestration's hop-routes) and checks each call +// against the method's policy before forwarding it. A service-dependency on +// the module would be a mesh edge past the hop — a call no policy is checked +// on — so none is asked for, or added. +func missingServiceDependencies(solution *resources.Module, service *resources.Service, consumes []manifest.APIDeclaration) ([]missingDependency, error) { existing := make(map[[2]string]bool, len(service.ServiceDependencies)) for _, dep := range service.ServiceDependencies { existing[[2]string{dep.Name, dep.Module}] = true } var missing []missingDependency - for i := range entries { - entry := &entries[i] - if existing[[2]string{entry.Endpoint.Service, entry.ModuleName}] { + for i := range consumes { + consume := &consumes[i] + if consume.Module != solution.Name || existing[[2]string{consume.Service, consume.Module}] { continue } - missing = append(missing, missingDependency{Module: entry.ModuleName, Service: entry.Endpoint.Service, Endpoint: entry.Endpoint.Endpoint}) + missing = append(missing, missingDependency{Module: consume.Module, Service: consume.Service, Endpoint: consume.Endpoint}) } return missing, nil } @@ -406,6 +501,9 @@ func generateSolutionSDKInto(ctx context.Context, name, outputDir string, entrie Force: solutionSDKForce, GoModule: "github.com/codefly-dev/" + name + "-go", NpmPackage: "@codefly-dev/" + name, + // One library aggregates every consumed module, so each facade + // gets a package of its own. + FacadePackages: true, }) } diff --git a/cmd/sync/solution_sdk_fixture_test.go b/cmd/sync/solution_sdk_fixture_test.go new file mode 100644 index 000000000..9ddd47e0e --- /dev/null +++ b/cmd/sync/solution_sdk_fixture_test.go @@ -0,0 +1,265 @@ +package sync + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/conformance/conformancetest" + "github.com/codefly-dev/core/composition" + resources "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/core/standards" +) + +// fixtureProducers are testdata/solution-sdk's two composed packages: the +// consumed service, the protobuf package its proto declares, and its service. +// The solution consumes accounts over connect and records over rest; each +// catalog carries its protobuf contract under grpc, as `generate contracts` +// exports a service whose transport endpoints are not in its interface. +var fixtureProducers = []struct{ module, pkg, service string }{ + {"accounts", "fixture.accounts.v1", "AccountsService"}, + {"records", "fixture.records.v1", "RecordsService"}, +} + +// newTwoModuleSolution copies testdata/solution-sdk into a temp workspace and +// writes each producer's contract catalog, with contract(t, protoDir) as the +// grpc endpoint's descriptor set. +func newTwoModuleSolution(t *testing.T, contract func(t *testing.T, protoDir string) []byte) string { + t.Helper() + dir := t.TempDir() + if err := os.CopyFS(dir, os.DirFS(filepath.Join("testdata", "solution-sdk"))); err != nil { + t.Fatal(err) + } + for _, producer := range fixtureProducers { + moduleDir := filepath.Join(dir, "modules", producer.module) + data := contract(t, filepath.Join(moduleDir, "services", producer.module, "proto")) + path := "contracts/api/" + producer.module + "/grpc/contract.binpb" + if err := os.MkdirAll(filepath.Join(moduleDir, filepath.Dir(filepath.FromSlash(path))), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(moduleDir, filepath.FromSlash(path)), data, 0o644); err != nil { + t.Fatal(err) + } + catalog := &composition.APIContractCatalog{ + Schema: composition.APIContractCatalogSchema, + Package: "fixture/" + producer.module, + Version: "0.1.0", + Endpoints: []composition.APIContractEndpoint{{ + Service: producer.module, + Endpoint: "grpc", + API: standards.GRPC, + Kind: composition.APIContractKindProtobuf, + Package: producer.pkg, + Path: path, + Digest: composition.APIContractDigest(data), + Services: []composition.APIContractService{{ + Name: producer.service, + FullName: producer.pkg + "." + producer.service, + Procedures: []string{"/" + producer.pkg + "." + producer.service + "/Get"}, + }}, + }}, + } + encoded, err := catalog.CanonicalBytes() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(moduleDir, "contracts", "api", "catalog.codefly.json"), encoded, 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +// syntheticContract stands in for a descriptor set where nothing reaches +// codegen. +func syntheticContract(_ *testing.T, protoDir string) []byte { + return []byte("synthetic-descriptor-set " + protoDir) +} + +func backendServiceFile(wsDir string) string { + return filepath.Join(wsDir, "services", "backend", resources.ServiceConfigurationName) +} + +// Gap 1: the manifest names no executor. core's strict Load refuses it for +// that; this command reads only api.consumes and must not. +func TestSyncSolutionSDKReadsAManifestWithoutAnAgent(t *testing.T) { + wsDir := newTwoModuleSolution(t, syntheticContract) + data, err := os.ReadFile(filepath.Join(wsDir, manifest.FileName)) + if err != nil { + t.Fatal(err) + } + if _, err := manifest.Load(data); err == nil || !strings.Contains(err.Error(), "agent.kind") { + t.Fatalf("precondition: the fixture must be a manifest core's executor loader refuses for its agent block; got %v", err) + } + + t.Chdir(wsDir) + resetSolutionSDKFlags(t) + if err := runSyncSolutionSDK(context.Background()); err != nil { + t.Fatalf("a solution manifest without an agent block must sync: %v", err) + } +} + +// Gap 2: a consume of a module's connect or rest endpoint resolves to that +// service's protobuf contract, which the catalog carries under grpc. +func TestSyncSolutionSDKResolvesTransportEndpointsToTheProtobufContract(t *testing.T) { + wsDir := newTwoModuleSolution(t, syntheticContract) + ctx := context.Background() + ws := loadFixtureWorkspace(t, wsDir) + data, err := os.ReadFile(filepath.Join(wsDir, manifest.FileName)) + if err != nil { + t.Fatal(err) + } + declared, err := manifest.LoadConsumption(data) + if err != nil { + t.Fatal(err) + } + + entries, err := resolveConsumedContracts(ctx, ws, declared) + if err != nil { + t.Fatalf("resolveConsumedContracts: %v", err) + } + if len(entries) != len(fixtureProducers) { + t.Fatalf("resolved %d entries, want %d", len(entries), len(fixtureProducers)) + } + for i, producer := range fixtureProducers { + got := entries[i].Endpoint + if got.Endpoint != "grpc" || got.Kind != composition.APIContractKindProtobuf || got.Package != producer.pkg { + t.Errorf("consume of %s resolved to %s/%s %s %s, want the grpc protobuf contract %s", producer.module, got.Service, got.Endpoint, got.Kind, got.Package, producer.pkg) + } + } + + // A consume of the connect endpoint itself, once the catalog publishes it + // beside grpc under the same contract — one destination behind the hop — + // resolves to it; a rest consume resolves to that one contract too. + // Different contracts on the two give the rest consume no single answer. + catalog, err := composition.LoadAPIContractCatalog(filepath.Join(wsDir, "modules", "accounts")) + if err != nil { + t.Fatal(err) + } + connect := catalog.Endpoints[0] + connect.Endpoint, connect.API = "connect", standards.CONNECT + catalog.Endpoints = append(catalog.Endpoints, connect) + accounts := declared.API.Consumes[0] + if got, resolveErr := consumedContract(ctx, "", catalog, &accounts); resolveErr != nil || got.Endpoint != "connect" { + t.Fatalf("connect consume resolved to %+v (%v), want the published connect contract", got, resolveErr) + } + accounts.Endpoint = "rest" + catalog.Endpoints = append(catalog.Endpoints, composition.APIContractEndpoint{Service: "accounts", Endpoint: "rest", API: standards.REST, Kind: composition.APIContractKindOpenAPI}) + if got, resolveErr := consumedContract(ctx, "", catalog, &accounts); resolveErr != nil || got.Kind != composition.APIContractKindProtobuf { + t.Fatalf("rest consume over one contract on grpc and connect resolved to %+v (%v), want that protobuf contract", got, resolveErr) + } + catalog.Endpoints[1].Digest = "sha256:another" + if _, resolveErr := consumedContract(ctx, "", catalog, &accounts); resolveErr == nil || !strings.Contains(resolveErr.Error(), "different protobuf contracts") { + t.Fatalf("error = %v, want two contracts on one service refused", resolveErr) + } + + // An endpoint the producer does not declare is still refused, by name. + declared.API.Consumes[1].Endpoint = "graphql" + if _, err := resolveConsumedContracts(ctx, ws, declared); err == nil || !strings.Contains(err.Error(), "declares no endpoint graphql") { + t.Fatalf("error = %v, want the undeclared endpoint named", err) + } +} + +// Gap 3: both consumes are called through the authority hop's Connect face, +// so neither the check nor --apply-dependencies asks for a service-dependency: +// that edge is a mesh path past the hop, a call no method policy is checked on. +func TestSyncSolutionSDKAsksNoMeshEdgeForAHopRoutedConsume(t *testing.T) { + wsDir := newTwoModuleSolution(t, syntheticContract) + before, err := os.ReadFile(backendServiceFile(wsDir)) + if err != nil { + t.Fatal(err) + } + + t.Chdir(wsDir) + resetSolutionSDKFlags(t) + solutionSDKApply = true + if err := runSyncSolutionSDK(context.Background()); err != nil { + t.Fatalf("runSyncSolutionSDK --apply-dependencies: %v", err) + } + after, err := os.ReadFile(backendServiceFile(wsDir)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatalf("--apply-dependencies added a mesh edge past the hop:\n%s", after) + } + + // --check counts the absent library, and nothing else. + resetSolutionSDKFlags(t) + solutionSDKCheck = true + if err := runSyncSolutionSDK(context.Background()); err == nil || !strings.Contains(err.Error(), "(1 problem(s))") { + t.Fatalf("--check = %v, want only the absent library reported", err) + } +} + +// buildFixtureContract compiles a fixture proto directory with buf, as +// `generate contracts` would export it. +func buildFixtureContract(t *testing.T, protoDir string) []byte { + t.Helper() + out := filepath.Join(t.TempDir(), "contract.binpb") + cmd := exec.Command("buf", "build", protoDir, "-o", out) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("buf build %s: %v\n%s", protoDir, err, output) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + return data +} + +// The whole issue, through the real proto companion: the solution SDK of a +// solution with no agent block, consuming two modules over connect and rest +// with no service-dependencies, generates a Go half that builds (the command +// compiles it) and a TypeScript half, and --check then passes on it. +func TestSyncSolutionSDKTwoModulesEndToEnd(t *testing.T) { + conformancetest.Gate(t, "linux-amd64-docker-generate", "docker", "buf") + wsDir := newTwoModuleSolution(t, buildFixtureContract) + ctx := context.Background() + + t.Chdir(wsDir) + resetSolutionSDKFlags(t) + skipGenerateForTests = false + solutionSDKLanguages = []string{"go", "typescript"} + if err := runSyncSolutionSDK(ctx); err != nil { + t.Fatalf("runSyncSolutionSDK: %v", err) + } + + library := filepath.Join(wsDir, "libraries", "portal-sdk") + t.Cleanup(func() { + if t.Failed() { + _ = filepath.WalkDir(library, func(path string, entry os.DirEntry, err error) error { + if err == nil && !entry.IsDir() { + t.Log(strings.TrimPrefix(path, library+string(filepath.Separator))) + } + return nil + }) + } + }) + for _, path := range []string{ + "go/accounts/accounts_facade.pb.go", + "go/records/records_facade.pb.go", + "typescript/src/index.ts", + "typescript/src/accounts_facade.ts", + "typescript/src/records_facade.ts", + "typescript/src/gen/accounts", + "typescript/src/gen/records", + } { + if _, err := os.Stat(filepath.Join(library, filepath.FromSlash(path))); err != nil { + t.Errorf("generated library lacks %s: %v", path, err) + } + } + + resetSolutionSDKFlags(t) + skipGenerateForTests = false + solutionSDKLanguages = []string{"go", "typescript"} + solutionSDKCheck = true + if err := runSyncSolutionSDK(ctx); err != nil { + t.Fatalf("--check on the library just generated: %v", err) + } +} diff --git a/cmd/sync/solution_sdk_test.go b/cmd/sync/solution_sdk_test.go index 9ee14c121..b6dbfdb5b 100644 --- a/cmd/sync/solution_sdk_test.go +++ b/cmd/sync/solution_sdk_test.go @@ -319,6 +319,10 @@ func TestSyncSolutionSDKRejectsUnknownService(t *testing.T) { } } +// TestSyncSolutionSDKApplyDependencies: the only consume the entry service +// calls directly is one of the solution's own module, and --apply-dependencies +// adds exactly that edge, node-preserving. A consume of another module (here +// host's accounts) is called through the authority hop and gets none. func TestSyncSolutionSDKApplyDependencies(t *testing.T) { root := t.TempDir() hostDir := filepath.Join(root, "host") @@ -348,28 +352,48 @@ func TestSyncSolutionSDKApplyDependencies(t *testing.T) { t.Fatalf("runSyncSolutionSDK: %v", err) } - servicePath := filepath.Join(backendDir, "services", "api", resources.ServiceConfigurationName) - data, err := os.ReadFile(servicePath) + serviceDir := filepath.Join(backendDir, "services", "api") + service, err := resources.LoadServiceFromDir(ctx, serviceDir) if err != nil { t.Fatal(err) } - content := string(data) - if !strings.Contains(content, "# hand-written note: do not remove") { - t.Fatalf("hand-written comment was dropped:\n%s", content) + if len(service.ServiceDependencies) != 0 { + t.Fatalf("--apply-dependencies added a mesh edge past the hop to another module: %+v", service.ServiceDependencies) + } + + ws := loadFixtureWorkspace(t, wsDir) + solution, err := ws.LoadModuleFromName(ctx, "backend") + if err != nil { + t.Fatal(err) + } + consumes := []manifest.APIDeclaration{ + {ID: "accounts", Protocol: standards.CONNECT, Module: "host", Service: "accounts", Endpoint: "connect"}, + {ID: "worker", Protocol: standards.GRPC, Module: "backend", Service: "worker", Endpoint: "grpc"}, + } + missing, err := missingServiceDependencies(solution, service, consumes) + if err != nil { + t.Fatal(err) + } + if len(missing) != 1 || missing[0] != (missingDependency{Module: "backend", Service: "worker", Endpoint: "grpc"}) { + t.Fatalf("missing = %+v, want only the solution's own backend/worker", missing) + } + if err := applyServiceDependencies(service, missing); err != nil { + t.Fatal(err) } - service, err := resources.LoadServiceFromDir(ctx, filepath.Join(backendDir, "services", "api")) + data, err := os.ReadFile(filepath.Join(serviceDir, resources.ServiceConfigurationName)) if err != nil { t.Fatal(err) } - found := false - for _, dep := range service.ServiceDependencies { - if dep.Name == "accounts" && dep.Module == "host" { - found = true - } + if !strings.Contains(string(data), "# hand-written note: do not remove") { + t.Fatalf("hand-written comment was dropped:\n%s", data) + } + service, err = resources.LoadServiceFromDir(ctx, serviceDir) + if err != nil { + t.Fatal(err) } - if !found { - t.Fatalf("service-dependencies missing accounts/host: %+v", service.ServiceDependencies) + if len(service.ServiceDependencies) != 1 || service.ServiceDependencies[0].Name != "worker" || service.ServiceDependencies[0].Module != "backend" { + t.Fatalf("service-dependencies = %+v, want backend/worker alone", service.ServiceDependencies) } } diff --git a/cmd/sync/testdata/solution-sdk/module.codefly.yaml b/cmd/sync/testdata/solution-sdk/module.codefly.yaml new file mode 100644 index 000000000..8fb9a41e1 --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/module.codefly.yaml @@ -0,0 +1,5 @@ +kind: module +name: portal +service-entry: backend +services: + - name: backend diff --git a/cmd/sync/testdata/solution-sdk/modules/accounts/module.codefly.yaml b/cmd/sync/testdata/solution-sdk/modules/accounts/module.codefly.yaml new file mode 100644 index 000000000..f447d2b7b --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/accounts/module.codefly.yaml @@ -0,0 +1,4 @@ +kind: module +name: accounts +services: + - name: accounts diff --git a/cmd/sync/testdata/solution-sdk/modules/accounts/module.package.codefly.yaml b/cmd/sync/testdata/solution-sdk/modules/accounts/module.package.codefly.yaml new file mode 100644 index 000000000..e8fe994be --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/accounts/module.package.codefly.yaml @@ -0,0 +1,10 @@ +kind: module-package +schema: codefly/module-package/v2 +id: fixture/accounts +version: 0.1.0 +minimum-codefly-version: ">=0.0.0" +artifact-roots: + - contracts +contracts: + composition: ">=2.0" +services: [] diff --git a/cmd/sync/testdata/solution-sdk/modules/accounts/services/accounts/proto/accounts.proto b/cmd/sync/testdata/solution-sdk/modules/accounts/services/accounts/proto/accounts.proto new file mode 100644 index 000000000..677d7761f --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/accounts/services/accounts/proto/accounts.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; +package fixture.accounts.v1; + +// GetRequest and GetResponse share their names with the records contract's, +// so one TypeScript barrel re-exporting both contracts' bindings collides. +message GetRequest { string id = 1; } +message GetResponse { string name = 1; } + +service AccountsService { + rpc Get(GetRequest) returns (GetResponse); +} diff --git a/cmd/sync/testdata/solution-sdk/modules/accounts/services/accounts/service.codefly.yaml b/cmd/sync/testdata/solution-sdk/modules/accounts/services/accounts/service.codefly.yaml new file mode 100644 index 000000000..3a65d8ae6 --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/accounts/services/accounts/service.codefly.yaml @@ -0,0 +1,16 @@ +kind: service +name: accounts +version: 0.1.0 +module: accounts +agent: + kind: codefly:service + name: go-grpc + publisher: codefly.dev + version: 0.0.1 +endpoints: + - name: grpc + api: grpc + visibility: internal + - name: connect + api: connect + visibility: internal diff --git a/cmd/sync/testdata/solution-sdk/modules/records/module.codefly.yaml b/cmd/sync/testdata/solution-sdk/modules/records/module.codefly.yaml new file mode 100644 index 000000000..c364ab663 --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/records/module.codefly.yaml @@ -0,0 +1,4 @@ +kind: module +name: records +services: + - name: records diff --git a/cmd/sync/testdata/solution-sdk/modules/records/module.package.codefly.yaml b/cmd/sync/testdata/solution-sdk/modules/records/module.package.codefly.yaml new file mode 100644 index 000000000..d80215029 --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/records/module.package.codefly.yaml @@ -0,0 +1,10 @@ +kind: module-package +schema: codefly/module-package/v2 +id: fixture/records +version: 0.1.0 +minimum-codefly-version: ">=0.0.0" +artifact-roots: + - contracts +contracts: + composition: ">=2.0" +services: [] diff --git a/cmd/sync/testdata/solution-sdk/modules/records/services/records/proto/records.proto b/cmd/sync/testdata/solution-sdk/modules/records/services/records/proto/records.proto new file mode 100644 index 000000000..61a79f294 --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/records/services/records/proto/records.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; +package fixture.records.v1; + +message GetRequest { string key = 1; } +message GetResponse { bytes value = 1; } + +service RecordsService { + rpc Get(GetRequest) returns (GetResponse); +} diff --git a/cmd/sync/testdata/solution-sdk/modules/records/services/records/service.codefly.yaml b/cmd/sync/testdata/solution-sdk/modules/records/services/records/service.codefly.yaml new file mode 100644 index 000000000..3bf17db5b --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/modules/records/services/records/service.codefly.yaml @@ -0,0 +1,16 @@ +kind: service +name: records +version: 0.1.0 +module: records +agent: + kind: codefly:service + name: go-grpc + publisher: codefly.dev + version: 0.0.1 +endpoints: + - name: grpc + api: grpc + visibility: internal + - name: rest + api: rest + visibility: internal diff --git a/cmd/sync/testdata/solution-sdk/services/backend/service.codefly.yaml b/cmd/sync/testdata/solution-sdk/services/backend/service.codefly.yaml new file mode 100644 index 000000000..cb09c9f68 --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/services/backend/service.codefly.yaml @@ -0,0 +1,13 @@ +kind: service +name: backend +version: 0.0.1 +module: portal +agent: + kind: codefly:service + name: go + publisher: codefly.dev + version: 0.0.1 +endpoints: + - name: http + api: http + visibility: private diff --git a/cmd/sync/testdata/solution-sdk/solution.codefly.yaml b/cmd/sync/testdata/solution-sdk/solution.codefly.yaml new file mode 100644 index 000000000..365db63ce --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/solution.codefly.yaml @@ -0,0 +1,18 @@ +# A solution that names no executor: nothing it runs reads an agent block, +# so it declares none. It reaches both modules through the authority hop, +# so its backend declares no service-dependencies on them. +schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +api: + consumes: + - id: accounts + protocol: connect + module: accounts + service: accounts + endpoint: connect + services: [AccountsService] + - id: records + protocol: rest + module: records + service: records + endpoint: rest diff --git a/cmd/sync/testdata/solution-sdk/workspace.codefly.yaml b/cmd/sync/testdata/solution-sdk/workspace.codefly.yaml new file mode 100644 index 000000000..c2a4c2ce6 --- /dev/null +++ b/cmd/sync/testdata/solution-sdk/workspace.codefly.yaml @@ -0,0 +1,9 @@ +name: portal +layout: modules +modules: + - name: portal + path: . + - name: accounts + path: modules/accounts + - name: records + path: modules/records diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c707c8879..aebacfc21 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -4925,8 +4925,15 @@ Aggregate one client library per solution from api.consumes ``` Reads api.consumes in solution.codefly.yaml and produces one aggregated client library per solution, carrying only the declared services, generated -from the contracts of the module packages the workspace composes. Also keeps -the runtime service-dependencies in step with the declaration. +from the contracts of the module packages the workspace composes. A consume +of a module's rest or connect endpoint is generated from that service's +protobuf contract. Each consumed module's facade and bindings get a +directory of their own. + +A consume of another module is reached through the authority hop, which +fronts every module behind it on one Connect face, and needs no +service-dependencies entry; only a consume of the solution's own module is a +direct call, and that one is kept in step with the declaration. ``` ``` @@ -4936,7 +4943,7 @@ codefly sync solution-sdk [flags] Flags: ``` - --apply-dependencies add missing service-dependencies entries for every bound consume entry + --apply-dependencies add missing service-dependencies entries for consumes of the solution's own module (other modules are reached through the authority hop) --check do not write; exit 1 if the library or service-dependencies are out of date --force overwrite a library generated from a different version of a consumed contract --language strings languages to generate: go,typescript,python diff --git a/docs/commands.md b/docs/commands.md index 26cf4e870..16b023bc4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2417,37 +2417,75 @@ codefly sync solution-sdk --language python --apply-dependencies # Aggregate ap ```bash codefly sync solution-sdk --language python # Generate/refresh the solution's library -codefly sync solution-sdk --language go,python --apply-dependencies # Also wire up service-dependencies +codefly sync solution-sdk --language go,typescript # Go and TypeScript halves of one library codefly sync solution-sdk --language python --check # CI drift gate ``` Run from a solution workspace (`workspace.codefly.yaml` plus a module whose `service-entry` is the solution backend, as in `core-solutions/solutions/*`). Reads the single declaration `api.consumes` in `solution.codefly.yaml` (the -solution manifest core's `solution/manifest` package defines) and aggregates +solution manifest core's `solution/manifest` package defines) — through core's +`LoadConsumption`, which holds `api.consumes` strictly and leaves the executor +sections (`agent`, `lifecycle`) unread, so a solution that names no +`codefly:solution` executor needs no `agent` block — and aggregates every module-bound entry — `{module, service, endpoint, version, services, as}` — into **one** library, `libraries/-sdk/`, generated from the composed module packages' API contract catalogs (the same generator `generate client` uses, aggregating N contract entries into one library -instead of one). This is the **one declaration, three outputs** rule: the same -`api.consumes` entries drive the generated SDK, the runtime -`service-dependencies` (below), and the `SolutionBundle` `platform.consumes` -lodestar derives from the manifest. +instead of one). The generated client is the Codefly-generated Connect client +of each consumed contract, which the solution points at the hop. | Flag | Description | |------|-------------| | `--language` | Required, repeatable or comma-separated: `go`, `typescript`, `python` | | `--check` | Do not write; exit 1 if the library or `service-dependencies` are out of date | -| `--apply-dependencies` | Add any `service-dependencies` entry missing for a bound consume entry to the entry service's `service.codefly.yaml`; without it, missing entries are printed as a warning. Existing entries are never removed. | +| `--apply-dependencies` | Add any `service-dependencies` entry missing for a consume of the solution's **own** module to the entry service's `service.codefly.yaml`; without it, missing entries are printed as a warning. Existing entries are never removed. | | `--force` | Overwrite a library generated from a different version of a consumed contract (its recorded `sources[].contract-digest` differs) | +A consume of **another** module needs no `service-dependencies` entry, and +neither `--check` nor `--apply-dependencies` asks for one: the solution calls +that module through the authority hop's Connect face. The hop routes every +module the composition puts behind it (`hop/MODULES`) from that module's +published API catalog (see [Published hop routes](orchestration.md#published-hop-routes)) +and checks each call against the method's policy before forwarding it, so a +direct edge to the module's service would be a mesh path past the hop, a call +no policy is checked on. Only a consume of the solution's own module is a +direct call. + Each bound entry is resolved against the workspace's own composition: the composed module named by `module` (workspace `add module` first), its `module.package.codefly.yaml` and `contracts/api/catalog.codefly.json` (from `generate contracts`), `version` checked as a semver constraint against the composed package version, and `services` validated against the endpoint's -declared protobuf services. Two entries whose contracts share a proto package -at different digests (a diamond) are rejected. +declared protobuf services. A consume is generated from the protobuf contract +the hop routes for the consumed service. A consume of a `connect` or `rest` +endpoint is generated from that service's protobuf contract when the catalog +carries none for the endpoint itself: both transports serve the same proto +services (connect natively, rest transcoded), and a catalog exports the +contract under the service's `grpc` endpoint. Several protobuf entries of one +service under one digest are that one contract, as the hop routes a service's +gRPC and Connect listeners under one contract as one destination; under +different digests the consume has no single answer and is refused. A `rest` +endpoint of a service with no protobuf contract keeps its own OpenAPI entry. Two entries whose contracts +share a proto package at different digests (a diamond), or that would generate +the same facade name (set `as` on one), are rejected. + +The library gives each consumed module's facade a package of its own, so any +number of modules aggregate into one library: + +``` +libraries/-sdk/ + go/ go.mod, accounts/accounts_facade.pb.go, gen/accounts/…, records/…, gen/records/… + typescript/ package.json, src/index.ts, src/accounts_facade.ts, src/gen/accounts/…, src/records_facade.ts, src/gen/records/… + python/ pyproject.toml, /accounts.py, /records.py, /_gen/… +``` + +Go imports a facade as `/`; its bindings live under +`gen//`, since two contracts each rooted at an `api.proto` would +otherwise write one file. TypeScript's bindings live under `src/gen//` +for the same reason, and its entry point is the same barrel `generate client` +writes: every facade flat, every proto package as a namespace, so two +contracts' same-named messages stay reachable through their package. If a `solution-sdk.yaml` (the pre-`api.consumes` declaration this command replaces) sits next to the manifest, its dependencies must be migrated into diff --git a/docs/design/library-system.md b/docs/design/library-system.md index 2aa0b5390..bd156a12f 100644 --- a/docs/design/library-system.md +++ b/docs/design/library-system.md @@ -590,11 +590,13 @@ rejected as a diamond, the same rule `pkg/composition/coherence.go` enforces for a service's transitive library closure, applied here to the contracts one generation aggregates. -This single declaration drives three outputs, kept in step by the same -command: the generated SDK above; the entry service's runtime -`service-dependencies` (`--apply-dependencies` adds any entry missing there, -never removing an existing one); and the `SolutionBundle`'s -`platform.consumes`, which lodestar derives from the same manifest. Before +This declaration drives the generated SDK above. It does not drive the entry +service's `service-dependencies`: a solution calls another module through the +authority hop's Connect face, which routes each module behind it from that +module's published API catalog and checks every call against the method's +policy, so a direct edge would be a mesh path past the hop. Only a consume of +the solution's own module is a direct call, and `--apply-dependencies` adds +that edge when it is missing, never removing an existing one. Before `api.consumes`, a solution declared what it consumed in up to four disconnected places — `service-dependencies`, a vendored `solution-sdk.yaml`, a `go.mod` replace pin, and a hand-written `Manifest{}` struct for host diff --git a/pkg/generators/client.go b/pkg/generators/client.go index 9ab8424bb..ae49a73df 100644 --- a/pkg/generators/client.go +++ b/pkg/generators/client.go @@ -82,6 +82,19 @@ type ClientLibraryRequest struct { Force bool GoModule string NpmPackage string + + // FacadePackages gives every entry's facade and bindings a directory of + // their own, named after its facade entry-point: in Go the facade is the + // package go// (import /) over go/gen//, + // and in TypeScript the bindings sit under src/gen// beside the + // facade at src/. An aggregated library needs it: each Go facade carries + // its own package clause, so two cannot share go/, and buf writes a + // binding where its proto file sits in the contract, so two contracts each + // rooted at an api.proto would write one gen/api binding. The TypeScript + // entry point is the namespaced barrel either way (writeTypeScriptIndex). + // Without it the single facade sits at the root, the layout + // `generate client` has always produced. + FacadePackages bool } // generatedLanguageExport is one languages: entry of library.codefly.yaml. @@ -126,6 +139,9 @@ func GenerateClientLibrary(ctx context.Context, req *ClientLibraryRequest) error if err != nil { return err } + if err = checkFacadeNames(entries); err != nil { + return err + } openAPICount := 0 for i := range entries { if entries[i].Endpoint.Kind == composition.APIContractKindOpenAPI { @@ -203,7 +219,7 @@ func GenerateClientLibrary(ctx context.Context, req *ClientLibraryRequest) error if err := ctx.Err(); err != nil { return err } - s, genErr := stageLanguage(ctx, lang, filepath.Join(req.Output, string(lang)), entries, !req.NoFacade, req.GoModule) + s, genErr := stageLanguage(ctx, lang, filepath.Join(req.Output, string(lang)), entries, !req.NoFacade, req.FacadePackages, req.GoModule) if genErr != nil { return fmt.Errorf("cannot generate %s client: %w", lang, genErr) } @@ -277,6 +293,26 @@ func checkContractDiamond(entries []ContractEntry) error { return nil } +// checkFacadeNames refuses two entries generating the same facade entry-point: +// in every language the name is a file (and, with FacadePackages, a package +// directory), so the second would overwrite the first. +func checkFacadeNames(entries []ContractEntry) error { + seen := map[string]*ContractEntry{} + for i := range entries { + entry := &entries[i] + if entry.Endpoint.Kind != composition.APIContractKindProtobuf { + continue + } + name := entryModuleName(entry) + if previous, exists := seen[name]; exists { + return fmt.Errorf("%s/%s/%s and %s/%s/%s both generate the facade %q; give one of them its own name (`as` in api.consumes)", + previous.ModuleName, previous.Endpoint.Service, previous.Endpoint.Endpoint, entry.ModuleName, entry.Endpoint.Service, entry.Endpoint.Endpoint, name) + } + seen[name] = entry + } + return nil +} + // stagedLanguage is one language's generated output, held in scratch // directories until every language of the run has generated, then applied // to langDir. An OpenAPI contract has no facade to aggregate and is only ever @@ -287,6 +323,9 @@ type stagedLanguage struct { langDir string entries []stagedEntry openAPI string + // facadePackages is ClientLibraryRequest.FacadePackages: where apply + // merges each entry. + facadePackages bool } // stagedEntry is one protobuf contract entry generated into raw, with the @@ -309,8 +348,8 @@ func (s *stagedLanguage) remove() { // stageLanguage runs the companion for every entry of one language, writing // only into scratch directories. On error it leaves nothing behind. -func stageLanguage(ctx context.Context, lang languages.Language, langDir string, entries []ContractEntry, facade bool, goModule string) (_ *stagedLanguage, err error) { - staged := &stagedLanguage{lang: lang, langDir: langDir} +func stageLanguage(ctx context.Context, lang languages.Language, langDir string, entries []ContractEntry, facade, facadePackages bool, goModule string) (_ *stagedLanguage, err error) { + staged := &stagedLanguage{lang: lang, langDir: langDir, facadePackages: facadePackages} defer func() { if err != nil { staged.remove() @@ -367,9 +406,9 @@ func (s *stagedLanguage) apply(goModule, npmPackage, pyPackage string) (generate var mergeErr error switch s.lang { case languages.GO: - mergeErr = mergeGoEntry(s.langDir, entry.raw, entry.moduleName, goModule) + mergeErr = mergeGoEntry(s.langDir, entry.raw, entry.moduleName, goModule, s.facadePackages) case languages.TYPESCRIPT: - mergeErr = mergeTypeScriptEntry(s.langDir, entry.raw) + mergeErr = mergeTypeScriptEntry(s.langDir, entry.raw, entry.moduleName, s.facadePackages) case languages.PYTHON: mergeErr = mergePythonEntry(pkgDir, entry.raw, entry.moduleName, pyPackage) } diff --git a/pkg/generators/client_library.go b/pkg/generators/client_library.go index 75d00da31..a24947bfc 100644 --- a/pkg/generators/client_library.go +++ b/pkg/generators/client_library.go @@ -54,23 +54,45 @@ func wipeGoLibrary(langDir string) error { if err := os.RemoveAll(filepath.Join(langDir, "gen")); err != nil { return err } - return removeMatching(langDir, "*_facade.pb.go") + if err := removeMatching(langDir, "*_facade.pb.go"); err != nil { + return err + } + // A facade package (FacadePackages) is go//: its facade file is + // this command's, the directory only while nothing else lives in it. + packages, err := filepath.Glob(filepath.Join(langDir, "*", "*_facade.pb.go")) + if err != nil { + return err + } + for _, facade := range packages { + if err := os.Remove(facade); err != nil && !os.IsNotExist(err) { + return err + } + _ = os.Remove(filepath.Dir(facade)) // kept when a hand-added file remains + } + return nil } // mergeGoEntry rewrites raw's hardcoded import prefix to goModule/gen (keyed // to this entry's own facade module name) and moves it into langDir: the -// *_facade.pb.go file(s) to the go/ root, everything else to go/gen/. -func mergeGoEntry(langDir, raw, moduleName, goModule string) error { +// *_facade.pb.go file(s) to the go/ root, everything else to go/gen/. With +// facadePackages the entry's facade moves to its own package go// and +// its bindings to go/gen//: buf writes a binding where its proto file +// sits in the contract, and two contracts each rooted at an api.proto would +// otherwise write one go/gen/api.pb.go. +func mergeGoEntry(langDir, raw, moduleName, goModule string, facadePackages bool) error { service := moduleName if service == "" { service = defaultFacadeModule } + genDir, genImport, facadeDir := filepath.Join(langDir, "gen"), goModule+"/gen", langDir + if facadePackages { + genDir, genImport, facadeDir = filepath.Join(genDir, service), genImport+"/"+service, filepath.Join(langDir, service) + } oldPrefix := "github.com/codefly-dev/cli/pkg/builder/clients/" + service - if err := rewriteGoImportPrefix(raw, oldPrefix, goModule+"/gen"); err != nil { + if err := rewriteGoImportPrefix(raw, oldPrefix, genImport); err != nil { return fmt.Errorf("cannot rewrite generated import paths: %w", err) } - genDir := filepath.Join(langDir, "gen") - return moveTreeSplitBySuffix(raw, genDir, langDir, "_facade.pb.go") + return moveTreeSplitBySuffix(raw, genDir, facadeDir, "_facade.pb.go") } // finalizeGoLibrary writes go.mod (if absent), resolves it and proves the @@ -94,12 +116,19 @@ func wipeTypeScriptLibrary(langDir string) error { return os.RemoveAll(filepath.Join(langDir, "src")) } -// mergeTypeScriptEntry moves raw's tree under src/gen/, hoists the +// mergeTypeScriptEntry moves raw's tree under src/gen/ — src/gen// +// with facadePackages, for the reason mergeGoEntry gives — hoists the // *_facade.ts file(s) to src/, and rewrites the facade's same-directory // relative import ("./x") to reach into its new location under gen/ instead. -func mergeTypeScriptEntry(langDir, raw string) error { +// The entry point over all of them is writeTypeScriptIndex's namespaced +// barrel, which already keeps two contracts' same-named messages apart. +func mergeTypeScriptEntry(langDir, raw, moduleName string, facadePackages bool) error { srcDir := filepath.Join(langDir, "src") - genDir := filepath.Join(srcDir, "gen") + gen := "gen" + if facadePackages { + gen = path.Join(gen, moduleName) + } + genDir := filepath.Join(srcDir, filepath.FromSlash(gen)) facadeFiles, err := moveTreeSplitBySuffixWithRelDir(raw, genDir, srcDir, "_facade.ts") if err != nil { return err @@ -113,7 +142,7 @@ func mergeTypeScriptEntry(langDir, raw string) error { // path.Join cleans the facade's directory into the specifier: a facade // at raw's root (a proto with no package-derived subdirectory) has // relDir ".", which spliced in verbatim reads "./gen/./x". - prefix := "./" + path.Join("gen", relDir) + "/" + prefix := "./" + path.Join(gen, filepath.ToSlash(relDir)) + "/" rewritten := relImportRe.ReplaceAll(data, []byte("${1}"+prefix)) if err := os.WriteFile(facadePath, rewritten, 0o644); err != nil { //nolint:gosec return err diff --git a/pkg/generators/client_library_test.go b/pkg/generators/client_library_test.go index d59dd2ec2..4b8c9f113 100644 --- a/pkg/generators/client_library_test.go +++ b/pkg/generators/client_library_test.go @@ -168,7 +168,7 @@ func TestTypeScriptFacadeImportsReachIntoGen(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "api_pb.ts"), []byte("export const Pdf = {};\n"), 0o644); err != nil { t.Fatal(err) } - if err := mergeTypeScriptEntry(langDir, raw); err != nil { + if err := mergeTypeScriptEntry(langDir, raw, "", false); err != nil { t.Fatalf("mergeTypeScriptEntry: %v", err) } got, err := os.ReadFile(filepath.Join(langDir, "src", "pdf_facade.ts")) diff --git a/pkg/generators/facade_packages_test.go b/pkg/generators/facade_packages_test.go new file mode 100644 index 000000000..d2affd806 --- /dev/null +++ b/pkg/generators/facade_packages_test.go @@ -0,0 +1,206 @@ +package generators + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/composition" + "github.com/codefly-dev/core/languages" +) + +// writeRaw writes files (slash-separated path → content) under a fresh +// directory: the shape proto.GenerateClient leaves for one entry. +func writeRaw(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for path, content := range files { + target := filepath.Join(dir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +// rawGoEntry is what the Go facade plugin emits for one contract whose proto +// sits at the contract's root (a service's proto/api.proto): the binding at +// raw's root, and a facade, in a package named after the facade entry-point, +// importing it through the plugin's hardcoded prefix. Two such entries write +// the same binding path. +func rawGoEntry(t *testing.T, module string) string { + t.Helper() + return writeRaw(t, map[string]string{ + "api.pb.go": "package " + module + "v1\n\ntype GetRequest struct{}\n", + module + "_facade.pb.go": "package " + module + "\n\nimport (\n\t" + module + "v1 \"github.com/codefly-dev/cli/pkg/builder/clients/" + module + "\"\n)\n\ntype Request = " + module + "v1.GetRequest\n", + }) +} + +func mergeGoFixture(t *testing.T, facadePackages bool) string { + t.Helper() + langDir := t.TempDir() + const goModule = "example.test/portal-sdk-go" + if err := os.WriteFile(filepath.Join(langDir, "go.mod"), []byte("module "+goModule+"\n\ngo "+goLanguageVersion+"\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, module := range []string{"accounts", "records"} { + if err := mergeGoEntry(langDir, rawGoEntry(t, module), module, goModule, facadePackages); err != nil { + t.Fatalf("mergeGoEntry %s: %v", module, err) + } + } + return langDir +} + +// TestAggregatedGoLibraryGivesEachFacadeItsOwnPackage is the aggregated Go +// library with two consumed modules: each facade carries its own package +// clause, so at the go/ root they are two packages in one directory and the +// library does not build; each in its own directory, it does. +func TestAggregatedGoLibraryGivesEachFacadeItsOwnPackage(t *testing.T) { + t.Setenv("GOFLAGS", "") + t.Setenv("GOPROXY", "off") + + shared := mergeGoFixture(t, false) + err := resolveAndVerifyGoLibrary(shared) + if err == nil { + t.Fatal("two entries merged into one go/ root and one go/gen/ must not build") + } + + langDir := mergeGoFixture(t, true) + for _, module := range []string{"accounts", "records"} { + if _, err := os.Stat(filepath.Join(langDir, module, module+"_facade.pb.go")); err != nil { + t.Fatalf("facade of %s is not in its own package go/%s/: %v", module, module, err) + } + if _, err := os.Stat(filepath.Join(langDir, "gen", module, "api.pb.go")); err != nil { + t.Fatalf("bindings of %s are not under go/gen/%s/: %v", module, module, err) + } + } + if err := resolveAndVerifyGoLibrary(langDir); err != nil { + t.Fatalf("library with a package per facade does not build: %v", err) + } + + // A regenerate clears the facade packages it wrote, and only what it wrote. + note := filepath.Join(langDir, "records", "README.md") + if err := os.WriteFile(note, []byte("hand-written\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := wipeGoLibrary(langDir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(langDir, "accounts")); !os.IsNotExist(err) { + t.Fatalf("wipe left the generated facade package go/accounts/ behind: %v", err) + } + if _, err := os.Stat(filepath.Join(langDir, "records", "records_facade.pb.go")); !os.IsNotExist(err) { + t.Fatalf("wipe left a generated facade behind: %v", err) + } + if _, err := os.Stat(note); err != nil { + t.Fatalf("wipe removed a hand-added file: %v", err) + } +} + +// rawTypeScriptEntry is what protoc-gen-es and the TypeScript facade plugin +// emit for one contract rooted at api.proto, in the proto package +// .v1. Both fixture contracts write api_pb.ts and declare +// GetRequestSchema: one gen/ cannot hold both files. +func rawTypeScriptEntry(t *testing.T, module string) string { + t.Helper() + return writeRaw(t, map[string]string{ + "api_pb.ts": "// @generated from file api.proto (package " + module + ".v1, syntax proto3)\n" + + "export const GetRequestSchema = {};\n" + + "export const " + module + "Marker = {};\n", + module + "_facade.ts": "import { GetRequestSchema } from \"./api_pb.js\";\nexport const " + module + " = { GetRequestSchema };\n", + }) +} + +func mergeTypeScriptFixture(t *testing.T, facadePackages bool) string { + t.Helper() + langDir := t.TempDir() + for _, module := range []string{"accounts", "records"} { + if err := mergeTypeScriptEntry(langDir, rawTypeScriptEntry(t, module), module, facadePackages); err != nil { + t.Fatalf("mergeTypeScriptEntry %s: %v", module, err) + } + } + if _, err := finalizeTypeScriptLibrary(langDir, "@acme/portal-sdk"); err != nil { + t.Fatalf("finalizeTypeScriptLibrary: %v", err) + } + return langDir +} + +// TestAggregatedTypeScriptLibraryGivesEachEntryItsOwnBindings is the +// aggregated TypeScript library with two consumed modules: in one src/gen/ +// the second contract's api_pb.ts replaces the first's, and the first facade +// imports a binding that is not its own. Under src/gen// both stay, +// and the entry point is the namespaced barrel, which keeps the two +// contracts' same-named GetRequestSchema apart. +func TestAggregatedTypeScriptLibraryGivesEachEntryItsOwnBindings(t *testing.T) { + read := func(langDir, path string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(langDir, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + return string(data) + } + + shared := mergeTypeScriptFixture(t, false) + if binding := read(shared, "src/gen/api_pb.ts"); strings.Contains(binding, "accountsMarker") { + t.Fatalf("one src/gen/ kept both contracts' api_pb.ts; the fixture no longer shows the clash:\n%s", binding) + } + + langDir := mergeTypeScriptFixture(t, true) + for _, module := range []string{"accounts", "records"} { + if binding := read(langDir, "src/gen/"+module+"/api_pb.ts"); !strings.Contains(binding, module+"Marker") { + t.Errorf("bindings of %s are not under src/gen/%s/:\n%s", module, module, binding) + } + facade := read(langDir, "src/"+module+"_facade.ts") + if !strings.Contains(facade, `from "./gen/`+module+`/api_pb.js"`) { + t.Errorf("the %s facade does not import its own bindings:\n%s", module, facade) + } + } + + index := read(langDir, "src/index.ts") + for _, line := range []string{ + `export * from "./accounts_facade.js";`, + `export * from "./records_facade.js";`, + `export * as accounts_v1_pb from "./packages/accounts.v1.js";`, + `export * as records_v1_pb from "./packages/records.v1.js";`, + `// GetRequestSchema: accounts_v1_pb.GetRequestSchema, records_v1_pb.GetRequestSchema`, + } { + if !strings.Contains(index, line) { + t.Errorf("src/index.ts lacks %q:\n%s", line, index) + } + } + if packageBarrel := read(langDir, "src/packages/accounts.v1.ts"); !strings.Contains(packageBarrel, `export * from "../gen/accounts/api_pb.js";`) { + t.Errorf("the accounts.v1 barrel does not reach its own bindings:\n%s", packageBarrel) + } +} + +// TestAggregatedLibraryRefusesTwoEntriesWithOneFacadeName: the facade name is +// a file and a package directory in every language, so the second entry +// would overwrite the first. Refused before any codegen. +func TestAggregatedLibraryRefusesTwoEntriesWithOneFacadeName(t *testing.T) { + entry := func(module, pkg string) ContractEntry { + return ContractEntry{ + ModuleName: module, + FacadeModuleDefault: "accounts", + Endpoint: composition.APIContractEndpoint{ + Service: "accounts", Endpoint: "grpc", Kind: composition.APIContractKindProtobuf, + Package: pkg, Digest: "sha256:" + module, + }, + } + } + err := GenerateClientLibrary(context.Background(), &ClientLibraryRequest{ + Name: "portal-sdk", + Output: t.TempDir(), + Languages: []languages.Language{languages.GO}, + Entries: []ContractEntry{entry("host", "host.accounts.v1"), entry("other", "other.accounts.v1")}, + FacadePackages: true, + }) + if err == nil || !strings.Contains(err.Error(), `both generate the facade "accounts"`) { + t.Fatalf("error = %v, want the shared facade name refused", err) + } +}