diff --git a/cmd/generate/client_test.go b/cmd/generate/client_test.go index d45bf046..71eef605 100644 --- a/cmd/generate/client_test.go +++ b/cmd/generate/client_test.go @@ -623,6 +623,22 @@ func TestGenerateClientPreservesHandEditedFiles(t *testing.T) { if afterFields["private"] != true { t.Fatalf("hand-edited package.json was overwritten: %s", afterPackageJSON) } + // The client is published under the library's version, the producer's + // own, never the scaffold's 0.0.1. + libraryManifest, err := os.ReadFile(filepath.Join(output, resources.LibraryConfigurationName)) + if err != nil { + t.Fatal(err) + } + var library struct { + Version string `yaml:"version"` + } + if err := yaml.Unmarshal(libraryManifest, &library); err != nil { + t.Fatal(err) + } + if library.Version == "" || library.Version == "0.0.1" || afterFields["version"] != library.Version { + t.Fatalf("package.json version = %v, library.codefly.yaml version = %q: the client must carry the library's version", + afterFields["version"], library.Version) + } } // TestGenerateClientPartialLanguageRerunKeepsManifest guards against diff --git a/pkg/generators/client.go b/pkg/generators/client.go index 49c246af..5a1998de 100644 --- a/pkg/generators/client.go +++ b/pkg/generators/client.go @@ -161,6 +161,11 @@ func GenerateClientLibrary(ctx context.Context, req *ClientLibraryRequest) error } pyPackage := strings.ReplaceAll(req.Name, "-", "_") + version := libraryVersion(req.Output, req.Entries) + if strings.TrimSpace(version) == "" { + // Refused before anything is generated, so the library is untouched. + return fmt.Errorf("library %s declares no version: its producer's version is empty", req.Name) + } // Every language is generated before anything under req.Output is // touched. The companion runs are what fail — a contract that does not @@ -196,13 +201,16 @@ func GenerateClientLibrary(ctx context.Context, req *ClientLibraryRequest) error if applyErr != nil { return fmt.Errorf("cannot generate %s client: %w", s.lang, applyErr) } + if err := stampPackageVersion(s.lang, s.langDir, version); err != nil { + return fmt.Errorf("cannot set the %s client's version: %w", s.lang, err) + } langExports = append(langExports, export) cli.Info("generated %s client at %s", s.lang, s.langDir) } langExports = mergeLanguageExports(existingLanguages, langExports, req.Languages) - if err := writeLibraryManifest(ctx, req.Output, req.Name, req.Entries, langExports, !req.NoFacade); err != nil { + if err := writeLibraryManifest(ctx, req.Output, req.Name, version, req.Entries, langExports, !req.NoFacade); err != nil { return fmt.Errorf("cannot write %s: %w", resources.LibraryConfigurationName, err) } @@ -711,6 +719,18 @@ func readExistingLanguageExports(outputDir string) ([]generatedLanguageExport, e return doc.Languages, nil } +// libraryVersion is the version a generated library carries, in its +// library.codefly.yaml and in every language's package manifest: the +// producer's own declared version for a single contract (the composed module +// package's, or the local service's), and an aggregated library's existing +// top-level version, since no one producer's version is its own. +func libraryVersion(outputDir string, entries []ContractEntry) string { + if len(entries) > 1 { + return existingTopVersion(outputDir) + } + return entries[0].PackageVersion +} + // existingTopVersion reads an existing manifest's top-level version:, or // "0.1.0" when there is none to read — the seed for a brand new aggregated // library, which (unlike a single-entry generate client library) has no one @@ -824,7 +844,7 @@ func writeOneContractDir(ctx context.Context, contractDir string, entry *Contrac // so the sources: and generated-by: provenance survives; resources.Library // still loads the file fine, since a plain YAML decode ignores fields it does // not declare. -func writeLibraryManifest(ctx context.Context, outputDir, name string, entries []ContractEntry, langExports []generatedLanguageExport, facade bool) error { +func writeLibraryManifest(ctx context.Context, outputDir, name, version string, entries []ContractEntry, langExports []generatedLanguageExport, facade bool) error { type languageEntry struct { Name string `yaml:"name"` Path string `yaml:"path"` @@ -868,11 +888,6 @@ func writeLibraryManifest(ctx context.Context, outputDir, name string, entries [ }) } - version := entries[0].PackageVersion - if len(entries) > 1 { - version = existingTopVersion(outputDir) - } - codeflyVersion, _ := cli.GetCurrentVersion() companion := "" if image, err := coreproto.CompanionImage(ctx); err == nil { diff --git a/pkg/generators/client_library.go b/pkg/generators/client_library.go index 17814d12..ac2557e9 100644 --- a/pkg/generators/client_library.go +++ b/pkg/generators/client_library.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "encoding/json" "fmt" "io" "os" @@ -11,6 +12,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "github.com/codefly-dev/cli/pkg/cli" @@ -789,6 +791,90 @@ func writeTypeScriptScaffold(langDir, npmPackage string) error { return writeFileIfMissing(filepath.Join(langDir, "tsconfig.json"), []byte(tsconfig)) } +// pyprojectVersionRe matches pyproject.toml's project version line, the shape +// writePythonScaffold writes. +var pyprojectVersionRe = regexp.MustCompile(`(?m)^version = "[^"]*"$`) + +// stampPackageVersion sets the version a language's package manifest declares +// to the library's own. The scaffold is written once and kept, so without this +// every generated TypeScript and Python client stays at the scaffold's 0.0.1 +// while library.codefly.yaml says the producer's version, and is published +// under the wrong one. Only the version member is edited; every hand-edit +// around it stays. A Go module's version is its tag, which nothing here sets. +func stampPackageVersion(lang languages.Language, langDir, version string) error { + if strings.TrimSpace(version) == "" { + return fmt.Errorf("the library declares no version") + } + switch lang { + case languages.TYPESCRIPT: + return stampPackageJSONVersion(filepath.Join(langDir, "package.json"), version) + case languages.PYTHON: + return replaceVersion(filepath.Join(langDir, "pyproject.toml"), pyprojectVersionRe, "version = "+strconv.Quote(version)) + default: + return nil + } +} + +// stampPackageJSONVersion replaces the value of package.json's top-level +// "version" member in place, whatever the file's formatting: the member is +// found by walking the document's tokens, so a nested "version" key or a +// reformatted file never takes the edit. +func stampPackageJSONVersion(path, version string) error { + data, err := os.ReadFile(path) //nolint:gosec // path is a file this command just wrote + if err != nil { + return err + } + quoted, err := json.Marshal(version) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return fmt.Errorf("%s is not a JSON object", path) + } + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return fmt.Errorf("cannot parse %s: %w", path, err) + } + afterKey := decoder.InputOffset() + if key != "version" { + var skip json.RawMessage + if err := decoder.Decode(&skip); err != nil { + return fmt.Errorf("cannot parse %s: %w", path, err) + } + continue + } + var current string + if err := decoder.Decode(¤t); err != nil { + return fmt.Errorf("%s: \"version\" is not a string: %w", path, err) + } + afterValue := decoder.InputOffset() + valueStart := afterKey + int64(bytes.IndexByte(data[afterKey:afterValue], '"')) + updated := append([]byte{}, data[:valueStart]...) + updated = append(updated, quoted...) + updated = append(updated, data[afterValue:]...) + return os.WriteFile(path, updated, 0o644) //nolint:gosec + } + return fmt.Errorf("cannot find a version in %s", path) +} + +// replaceVersion replaces the first match of re in path with replacement, as is. +func replaceVersion(path string, re *regexp.Regexp, replacement string) error { + data, err := os.ReadFile(path) //nolint:gosec // path is a file this command just wrote + if err != nil { + return err + } + match := re.FindIndex(data) + if match == nil { + return fmt.Errorf("cannot find a version in %s", path) + } + updated := append([]byte{}, data[:match[0]]...) + updated = append(updated, replacement...) + updated = append(updated, data[match[1]:]...) + return os.WriteFile(path, updated, 0o644) //nolint:gosec +} + func writePythonScaffold(langDir, pyPackage string) error { pyproject := fmt.Sprintf(`[project] name = %q diff --git a/pkg/generators/client_version_test.go b/pkg/generators/client_version_test.go new file mode 100644 index 00000000..e358989d --- /dev/null +++ b/pkg/generators/client_version_test.go @@ -0,0 +1,121 @@ +package generators + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/languages" +) + +// A generated client carries the library's version, the producer's own, not +// the scaffold's 0.0.1: the document store's library said 0.1.14 while its +// TypeScript client's package.json said 0.0.1 and was published as that. +func TestTypeScriptClientCarriesTheLibraryVersion(t *testing.T) { + langDir := t.TempDir() + if err := writeTypeScriptScaffold(langDir, "@acme/billing-client"); err != nil { + t.Fatal(err) + } + if err := stampPackageVersion(languages.TYPESCRIPT, langDir, "0.1.14"); err != nil { + t.Fatal(err) + } + if got := packageJSON(t, langDir)["version"]; got != "0.1.14" { + t.Fatalf("package.json version = %v, want 0.1.14", got) + } + // A later generation at a newer producer version moves it again. + if err := stampPackageVersion(languages.TYPESCRIPT, langDir, "0.1.15"); err != nil { + t.Fatal(err) + } + if got := packageJSON(t, langDir)["version"]; got != "0.1.15" { + t.Fatalf("package.json version = %v, want 0.1.15", got) + } +} + +// Only the top-level member moves: a hand-edited, reformatted package.json +// keeps every other byte, and a nested "version" key is not the package's. +func TestTypeScriptVersionStampKeepsHandEdits(t *testing.T) { + langDir := t.TempDir() + original := `{"engines":{"version":"keep"},"name":"@acme/billing-client","private":true,"version":"0.0.1"}` + if err := os.WriteFile(filepath.Join(langDir, "package.json"), []byte(original), 0o644); err != nil { + t.Fatal(err) + } + if err := stampPackageVersion(languages.TYPESCRIPT, langDir, "2.0.0"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(langDir, "package.json")) + if err != nil { + t.Fatal(err) + } + want := strings.Replace(original, `"version":"0.0.1"`, `"version":"2.0.0"`, 1) + if string(got) != want { + t.Fatalf("package.json:\ngot %s\nwant %s", got, want) + } +} + +func TestPythonClientCarriesTheLibraryVersion(t *testing.T) { + langDir := t.TempDir() + if err := writePythonScaffold(langDir, "billing_client"); err != nil { + t.Fatal(err) + } + path := filepath.Join(langDir, "pyproject.toml") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + handEdited := string(data) + "\n[tool.pytest.ini_options]\nminversion = \"8.0\"\n" + if err := os.WriteFile(path, []byte(handEdited), 0o644); err != nil { + t.Fatal(err) + } + if err := stampPackageVersion(languages.PYTHON, langDir, "0.1.14"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := strings.Replace(handEdited, `version = "0.0.1"`, `version = "0.1.14"`, 1) + if string(got) != want { + t.Fatalf("pyproject.toml:\ngot:\n%s\nwant:\n%s", got, want) + } +} + +// A library with no version is refused by name rather than stamped empty. +func TestAClientWithoutAVersionIsRefused(t *testing.T) { + langDir := t.TempDir() + if err := writeTypeScriptScaffold(langDir, "@acme/billing-client"); err != nil { + t.Fatal(err) + } + err := stampPackageVersion(languages.TYPESCRIPT, langDir, " ") + if err == nil || !strings.Contains(err.Error(), "declares no version") { + t.Fatalf("err = %v, want a refusal naming the missing version", err) + } +} + +// A Go module's version is its tag: nothing is written. +func TestGoClientVersionIsItsTag(t *testing.T) { + if err := stampPackageVersion(languages.GO, t.TempDir(), "0.1.14"); err != nil { + t.Fatal(err) + } +} + +func TestLibraryVersionIsTheProducersForOneContract(t *testing.T) { + entries := []ContractEntry{{PackageVersion: "0.1.14"}} + if got := libraryVersion(t.TempDir(), entries); got != "0.1.14" { + t.Fatalf("libraryVersion = %q, want 0.1.14", got) + } +} + +func packageJSON(t *testing.T, langDir string) map[string]any { + t.Helper() + data, err := os.ReadFile(filepath.Join(langDir, "package.json")) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("package.json is not valid JSON: %v\n%s", err, data) + } + return doc +}