From f71feed02dd88a09e95e2caf862abfa7497137a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Wed, 22 Apr 2026 22:50:10 +0200 Subject: [PATCH] feat(codegen): generate validated Go code samples Generate a versioned JSON catalog of complete, formatted Go programs from the same intermediate representation used by SDK codegen. Preserve named OpenAPI examples and SDK metadata, and compile every generated sample in tests. Sync the catalog into sumup-developer through a release-triggered pull request instead of committing generated JSON or publishing a JavaScript package. --- .github/workflows/codegen-ci.yml | 2 + .github/workflows/release-code-samples.yaml | 124 +++ .gitignore | 1 + Makefile | 6 + internal/cmd/codegen/README.md | 20 + internal/cmd/codegen/generate.go | 17 +- internal/cmd/codegen/main.go | 1 + internal/cmd/codegen/openapi.go | 28 + .../builder/intermediate_representation.go | 1 + internal/cmd/codegen/pkg/builder/samples.go | 812 ++++++++++++++++++ .../cmd/codegen/pkg/builder/samples_test.go | 175 ++++ internal/cmd/codegen/pkg/builder/transform.go | 2 + internal/cmd/codegen/pkg/builder/types.go | 35 +- internal/cmd/codegen/samples.go | 120 +++ internal/cmd/codegen/samples_test.go | 54 ++ 15 files changed, 1367 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/release-code-samples.yaml create mode 100644 internal/cmd/codegen/openapi.go create mode 100644 internal/cmd/codegen/pkg/builder/samples.go create mode 100644 internal/cmd/codegen/pkg/builder/samples_test.go create mode 100644 internal/cmd/codegen/samples.go create mode 100644 internal/cmd/codegen/samples_test.go diff --git a/.github/workflows/codegen-ci.yml b/.github/workflows/codegen-ci.yml index df7eade3..5ffcf964 100644 --- a/.github/workflows/codegen-ci.yml +++ b/.github/workflows/codegen-ci.yml @@ -7,12 +7,14 @@ on: - main paths: - 'internal/cmd/codegen/**' + - 'openapi.json' - '.github/workflows/codegen-ci.yml' pull_request: branches: - main paths: - 'internal/cmd/codegen/**' + - 'openapi.json' - '.github/workflows/codegen-ci.yml' env: diff --git a/.github/workflows/release-code-samples.yaml b/.github/workflows/release-code-samples.yaml new file mode 100644 index 00000000..a21d27c8 --- /dev/null +++ b/.github/workflows/release-code-samples.yaml @@ -0,0 +1,124 @@ +name: Release Code Samples + +on: + release: + types: + - published + +concurrency: + group: release-code-samples-${{ github.event.release.tag_name }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sync-go-code-samples: + name: Sync Go code samples + runs-on: ubuntu-latest + env: + TARGET_REPOSITORY: sumup/sumup-developer + TARGET_BRANCH: automation/go-code-samples + TARGET_FILE: src/codesamples/go.json + steps: + - name: Checkout source code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: refs/tags/${{ github.event.release.tag_name }} + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: internal/cmd/codegen/go.mod + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.SUMUP_BOT_APP_ID }} + private-key: ${{ secrets.SUMUP_BOT_PRIVATE_KEY }} + owner: sumup + repositories: sumup-developer + + - name: Checkout target repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ env.TARGET_REPOSITORY }} + ref: main + token: ${{ steps.app-token.outputs.token }} + path: sumup-developer + persist-credentials: true + + - name: Get GitHub App User ID + id: get-user-id + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" + + - name: Configure git + run: | + git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]' + git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com' + + - name: Prepare target branch + working-directory: sumup-developer + run: | + git fetch origin "${{ env.TARGET_BRANCH }}:refs/remotes/origin/${{ env.TARGET_BRANCH }}" || true + git checkout -B "${{ env.TARGET_BRANCH }}" origin/main + + - name: Generate Go code samples + working-directory: internal/cmd/codegen + run: | + mkdir -p "../../../sumup-developer/$(dirname "${{ env.TARGET_FILE }}")" + go run . samples \ + --sdk-version-file ../../../internal/version.go \ + --out "../../../sumup-developer/${{ env.TARGET_FILE }}" \ + ../../../openapi.json + + - name: Commit generated samples + id: commit + working-directory: sumup-developer + run: | + git add "${{ env.TARGET_FILE }}" + if git diff --cached --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git commit -m "chore: update Go code samples for ${{ github.event.release.tag_name }}" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Push branch + if: steps.commit.outputs.changed == 'true' + working-directory: sumup-developer + run: git push --force-with-lease origin "${{ env.TARGET_BRANCH }}" + + - name: Create or update pull request + if: steps.commit.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + head_ref="sumup:${{ env.TARGET_BRANCH }}" + pr_url="$(gh pr list \ + --repo "${{ env.TARGET_REPOSITORY }}" \ + --head "$head_ref" \ + --base main \ + --state open \ + --json url \ + --jq '.[0].url')" + + if [ -n "$pr_url" ]; then + gh pr edit "$pr_url" \ + --repo "${{ env.TARGET_REPOSITORY }}" \ + --title "chore: update Go code samples" \ + --body "Updates \`${{ env.TARGET_FILE }}\` from \`${{ github.repository }}\` release \`${{ github.event.release.tag_name }}\`." + exit 0 + fi + + gh pr create \ + --repo "${{ env.TARGET_REPOSITORY }}" \ + --base main \ + --head "${{ env.TARGET_BRANCH }}" \ + --title "chore: update Go code samples" \ + --body "Updates \`${{ env.TARGET_FILE }}\` from \`${{ github.repository }}\` release \`${{ github.event.release.tag_name }}\`." diff --git a/.gitignore b/.gitignore index 7a6353d6..71610181 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .envrc +/code-samples.json diff --git a/Makefile b/Makefile index b4cc9bd7..ac0a830d 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,12 @@ generate: ## Generate latest SDK cd internal/cmd/codegen && go run ./... generate --out ../../.. ../../../openapi.json gomarkdoc --repository.url https://github.com/sumup/sumup-go --repository.default-branch main --exclude-dirs ./internal/cmd/codegen/... --output DOCUMENTATION.md ./... +CODESAMPLES_OUT ?= code-samples.json + +.PHONY: generate-codesamples +generate-codesamples: ## Generate Go code samples + cd internal/cmd/codegen && go run ./... samples --sdk-version-file ../../../internal/version.go --out "$(abspath $(CODESAMPLES_OUT))" ../../../openapi.json + .PHONY: install-tools install-tools: # Install development dependencies cd internal/cmd/codegen && go install ./cmd/go-sdk-gen diff --git a/internal/cmd/codegen/README.md b/internal/cmd/codegen/README.md index b207167c..8ef0ff41 100644 --- a/internal/cmd/codegen/README.md +++ b/internal/cmd/codegen/README.md @@ -5,3 +5,23 @@ Code generator for [sumup-go](https://github.com/sumup/sumup-go). + +## Go SDK + +The `generate` command reads `openapi.json` and generates the Go client, services, request types, and response types in the repository root. Generate the SDK and refresh its API documentation from the repository root with: + +```shell +make generate +``` + +## Go Code Samples + +The `samples` command generates a deterministic, versioned JSON catalog of Go examples from the same intermediate representation used to generate the SDK. Each catalog entry contains a complete, formatted Go program. Named OpenAPI request examples produce separate entries. + +Generate a catalog from the repository root with: + +```shell +make generate-codesamples +``` + +The target writes `code-samples.json` in the repository root by default. Set `CODESAMPLES_OUT` to use another path. Every generated program is compiled by the codegen test suite. When an SDK release is published, the release workflow regenerates the catalog from that tag and opens or updates a pull request in `sumup/sumup-developer`; the generated JSON is not committed to this repository. diff --git a/internal/cmd/codegen/generate.go b/internal/cmd/codegen/generate.go index 1f9b2e94..eea0efff 100644 --- a/internal/cmd/codegen/generate.go +++ b/internal/cmd/codegen/generate.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" - "github.com/pb33f/libopenapi" "github.com/urfave/cli/v2" "github.com/sumup/sumup-go/internal/cmd/codegen/pkg/builder" @@ -29,26 +28,16 @@ func Generate() *cli.Command { return fmt.Errorf("create output directory %q: %w", out, err) } - spec, err := os.ReadFile(specs) + spec, err := loadOpenAPIDocument(specs) if err != nil { - return fmt.Errorf("read specs: %w", err) - } - - doc, err := libopenapi.NewDocument(spec) - if err != nil { - return fmt.Errorf("load openapi document: %w", err) - } - - model, err := doc.BuildV3Model() - if err != nil { - return fmt.Errorf("build openapi v3 model: %w", err) + return err } builder := builder.New(builder.Config{ Out: out, }) - if err := builder.Load(&model.Model); err != nil { + if err := builder.Load(spec); err != nil { return fmt.Errorf("load spec: %w", err) } diff --git a/internal/cmd/codegen/main.go b/internal/cmd/codegen/main.go index 6717e2e9..46d93ce0 100644 --- a/internal/cmd/codegen/main.go +++ b/internal/cmd/codegen/main.go @@ -28,6 +28,7 @@ func App() *cli.App { }, Commands: []*cli.Command{ Generate(), + Samples(), }, } } diff --git a/internal/cmd/codegen/openapi.go b/internal/cmd/codegen/openapi.go new file mode 100644 index 00000000..c8a899e0 --- /dev/null +++ b/internal/cmd/codegen/openapi.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "os" + + "github.com/pb33f/libopenapi" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" +) + +func loadOpenAPIDocument(filename string) (*v3.Document, error) { + spec, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read specs: %w", err) + } + + document, err := libopenapi.NewDocument(spec) + if err != nil { + return nil, fmt.Errorf("load openapi document: %w", err) + } + + model, err := document.BuildV3Model() + if err != nil { + return nil, fmt.Errorf("build openapi v3 model: %w", err) + } + + return &model.Model, nil +} diff --git a/internal/cmd/codegen/pkg/builder/intermediate_representation.go b/internal/cmd/codegen/pkg/builder/intermediate_representation.go index f4635eac..7dc824cf 100644 --- a/internal/cmd/codegen/pkg/builder/intermediate_representation.go +++ b/internal/cmd/codegen/pkg/builder/intermediate_representation.go @@ -50,6 +50,7 @@ type StructField struct { Comment string Parameter *v3.Parameter + Schema *base.SchemaProxy } type EnumOption[E cmp.Ordered] struct { diff --git a/internal/cmd/codegen/pkg/builder/samples.go b/internal/cmd/codegen/pkg/builder/samples.go new file mode 100644 index 00000000..d25db852 --- /dev/null +++ b/internal/cmd/codegen/pkg/builder/samples.go @@ -0,0 +1,812 @@ +package builder + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "slices" + "strconv" + "strings" + "time" + + "github.com/pb33f/libopenapi/datamodel/high/base" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "go.yaml.in/yaml/v4" + + "github.com/sumup/sumup-go/internal/cmd/codegen/internal/strcase" +) + +const ( + sampleCatalogSchemaVersion = 1 + sdkModule = "github.com/sumup/sumup-go" +) + +// SampleCatalog is the versioned JSON contract consumed by documentation sites. +type SampleCatalog struct { + SchemaVersion int `json:"schemaVersion"` + Language string `json:"language"` + SDK SDK `json:"sdk"` + OpenAPIVersion string `json:"openAPIVersion"` + Samples []Sample `json:"samples"` +} + +// SDK identifies the package used by every generated sample. +type SDK struct { + Module string `json:"module"` + Version string `json:"version"` +} + +// Sample is a complete, formatted Go program for one OpenAPI operation example. +type Sample struct { + ID string `json:"id"` + OperationID string `json:"operationId"` + Example string `json:"example,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + HTTPMethod string `json:"httpMethod"` + Path string `json:"path"` + Source string `json:"sample"` +} + +// Samples builds a deterministic catalog of compilable Go examples. +func (b *Builder) Samples(sdkVersion string) (*SampleCatalog, error) { + if b.spec == nil { + return nil, fmt.Errorf("missing specs: call Load to load the specs first") + } + if b.spec.Info == nil { + return nil, fmt.Errorf("missing specs info: call Load to load the specs first") + } + + registry := b.sampleTypeRegistry() + samples := make([]Sample, 0) + for _, apiPath := range pathsInMatchingOrder(b.spec.Paths) { + pathItem, ok := b.spec.Paths.PathItems.Get(apiPath) + if !ok || pathItem == nil || pathItem.IsReference() { + continue + } + + operations := pathItem.GetOperations() + methods := slices.Collect(operations.KeysFromOldest()) + slices.Sort(methods) + for _, httpMethod := range methods { + operation, ok := operations.Get(httpMethod) + if !ok || operation == nil { + continue + } + if operation.OperationId == "" { + return nil, fmt.Errorf("missing operation id for %s %s", strings.ToUpper(httpMethod), apiPath) + } + + operationCopy := *operation + operationCopy.Parameters = append(slices.Clone(operation.Parameters), pathItem.Parameters...) + tagName := "shared" + if len(operation.Tags) > 0 { + tagName = strings.ToLower(operation.Tags[0]) + } + + method, err := b.operationToMethod(tagName, strings.ToUpper(httpMethod), apiPath, &operationCopy) + if err != nil { + return nil, fmt.Errorf("build operation %q: %w", operation.OperationId, err) + } + + operationSamples, err := b.samplesForOperation( + registry, + tagName, + strings.ToUpper(httpMethod), + apiPath, + &operationCopy, + method, + ) + if err != nil { + return nil, fmt.Errorf("generate samples for %q: %w", operation.OperationId, err) + } + samples = append(samples, operationSamples...) + } + } + + slices.SortFunc(samples, func(a, b Sample) int { + return strings.Compare(a.ID, b.ID) + }) + + return &SampleCatalog{ + SchemaVersion: sampleCatalogSchemaVersion, + Language: "go", + SDK: SDK{ + Module: sdkModule, + Version: sdkVersion, + }, + OpenAPIVersion: strings.TrimSpace(b.spec.Info.Version), + Samples: samples, + }, nil +} + +type requestExample struct { + name string + summary string + description string + value any + provided bool +} + +func (b *Builder) samplesForOperation( + registry sampleTypeRegistry, + tagName string, + httpMethod string, + apiPath string, + operation *v3.Operation, + method *Method, +) ([]Sample, error) { + examples := requestExamples(operation) + samples := make([]Sample, 0, len(examples)) + for _, example := range examples { + renderer := sampleRenderer{ + builder: b, + registry: registry, + imports: make(map[string]struct{}), + } + source, err := renderer.render(tagName, operation, method, example) + if err != nil { + return nil, err + } + + id := operation.OperationId + if example.name != "" { + id += "." + example.name + } + summary := strings.TrimSpace(operation.Summary) + if example.summary != "" { + summary = strings.TrimSpace(example.summary) + } + description := strings.TrimSpace(operation.Description) + if example.description != "" { + description = strings.TrimSpace(example.description) + } + + samples = append(samples, Sample{ + ID: id, + OperationID: operation.OperationId, + Example: example.name, + Summary: summary, + Description: description, + HTTPMethod: httpMethod, + Path: apiPath, + Source: source, + }) + } + + return samples, nil +} + +func requestExamples(operation *v3.Operation) []requestExample { + if operation.RequestBody == nil || operation.RequestBody.Content == nil { + return []requestExample{{}} + } + mediaType, ok := getJSONMediaType(operation.RequestBody.Content) + if !ok || mediaType == nil { + return []requestExample{{}} + } + + if mediaType.Examples != nil && mediaType.Examples.Len() > 0 { + names := slices.Collect(mediaType.Examples.KeysFromOldest()) + slices.Sort(names) + examples := make([]requestExample, 0, len(names)) + for _, name := range names { + example, ok := mediaType.Examples.Get(name) + if !ok || example == nil { + continue + } + value, provided := decodeNode(example.Value) + examples = append(examples, requestExample{ + name: name, + summary: example.Summary, + description: example.Description, + value: value, + provided: provided, + }) + } + if len(examples) > 0 { + return examples + } + } + + if value, provided := decodeNode(mediaType.Example); provided { + return []requestExample{{value: value, provided: true}} + } + if value, provided := schemaExample(mediaType.Schema); provided { + return []requestExample{{value: value, provided: true}} + } + + return []requestExample{{}} +} + +type sampleTypeRegistry map[string]*TypeDeclaration + +func (b *Builder) sampleTypeRegistry() sampleTypeRegistry { + registry := make(sampleTypeRegistry) + add := func(writables []Writable) { + for _, writable := range writables { + switch declaration := writable.(type) { + case *TypeDeclaration: + registry[declaration.Name] = declaration + case *EnumDeclaration[string]: + registry[declaration.Type.Name] = &declaration.Type + case *EnumDeclaration[int]: + registry[declaration.Type.Name] = &declaration.Type + case *EnumDeclaration[int32]: + registry[declaration.Type.Name] = &declaration.Type + case *EnumDeclaration[int64]: + registry[declaration.Type.Name] = &declaration.Type + case *EnumDeclaration[float32]: + registry[declaration.Type.Name] = &declaration.Type + case *EnumDeclaration[float64]: + registry[declaration.Type.Name] = &declaration.Type + } + } + } + + schemaTags := make([]string, 0, len(b.resolvedSchemas)) + for tagName := range b.resolvedSchemas { + schemaTags = append(schemaTags, tagName) + } + slices.Sort(schemaTags) + for _, tagName := range schemaTags { + add(b.schemasToTypes(b.resolvedSchemas[tagName], b.errorSchemas)) + } + + pathTags := make([]string, 0, len(b.pathsByTag)) + for tagName := range b.pathsByTag { + pathTags = append(pathTags, tagName) + } + slices.Sort(pathTags) + for _, tagName := range pathTags { + add(b.pathsToBodyTypes(tagName, b.pathsByTag[tagName])) + add(b.pathsToParamTypes(tagName, b.pathsByTag[tagName])) + } + + return registry +} + +type sampleRenderer struct { + builder *Builder + registry sampleTypeRegistry + imports map[string]struct{} + usesPointer bool + usesTimeParse bool +} + +func (r *sampleRenderer) render( + tagName string, + operation *v3.Operation, + method *Method, + example requestExample, +) (string, error) { + r.imports["context"] = struct{}{} + r.imports["log"] = struct{}{} + r.imports[sdkModule] = struct{}{} + + args := []string{"ctx"} + pathArgumentCount := len(method.PathParams) + if method.HasBody { + pathArgumentCount-- + } + for i := range pathArgumentCount { + argument := method.PathParams[i] + parameter := r.parameter(operation.Parameters, argument.Name, "path") + value, provided := parameterExample(parameter) + expression, err := r.value(argument.Type, parameterSchema(parameter), value, provided) + if err != nil { + return "", fmt.Errorf("render path parameter %q: %w", argument.Name, err) + } + args = append(args, expression) + } + + if method.HasBody { + argument := method.PathParams[len(method.PathParams)-1] + mediaType, _ := getJSONMediaType(operation.RequestBody.Content) + expression, err := r.value(argument.Type, mediaType.Schema, example.value, example.provided) + if err != nil { + return "", fmt.Errorf("render request body: %w", err) + } + args = append(args, expression) + } + + if method.QueryParams != nil { + values := make(map[string]any) + if declaration := r.registry.declaration(method.QueryParams.Type); declaration != nil { + for _, field := range declaration.Fields { + if value, provided := parameterExample(field.Parameter); provided { + values[field.Name] = value + } + } + } + expression, err := r.value(method.QueryParams.Type, nil, values, false) + if err != nil { + return "", fmt.Errorf("render query parameters: %w", err) + } + args = append(args, expression) + } + + call := fmt.Sprintf("client.%s.%s(%s)", strcase.ToCamel(tagName), method.FunctionName, strings.Join(args, ", ")) + var body strings.Builder + body.WriteString("func main() {\n") + body.WriteString("\tctx := context.Background()\n") + body.WriteString("\tclient := sumup.NewClient()\n\n") + if method.ResponseType == nil { + fmt.Fprintf(&body, "\tif err := %s; err != nil {\n", call) + body.WriteString("\t\tlog.Fatal(err)\n") + body.WriteString("\t}\n") + } else { + fmt.Fprintf(&body, "\tresult, err := %s\n", call) + body.WriteString("\tif err != nil {\n") + body.WriteString("\t\tlog.Fatal(err)\n") + body.WriteString("\t}\n\n") + body.WriteString("\tlog.Printf(\"%+v\", result)\n") + } + body.WriteString("}\n") + + if r.usesPointer { + body.WriteString("\nfunc ptr[T any](value T) *T {\n\treturn &value\n}\n") + } + if r.usesTimeParse { + body.WriteString("\nfunc mustParseTime(value string) time.Time {\n") + body.WriteString("\tparsed, err := time.Parse(time.RFC3339, value)\n") + body.WriteString("\tif err != nil {\n\t\tpanic(err)\n\t}\n") + body.WriteString("\treturn parsed\n}\n") + } + + var source bytes.Buffer + source.WriteString("package main\n\n") + source.WriteString(r.importBlock()) + source.WriteString("\n") + source.WriteString(body.String()) + formatted, err := format.Source(source.Bytes()) + if err != nil { + return "", fmt.Errorf("format source: %w\n%s", err, source.String()) + } + return string(formatted), nil +} + +func (r *sampleRenderer) parameter(parameters []*v3.Parameter, name, location string) *v3.Parameter { + for _, parameter := range parameters { + resolved := r.builder.resolveParameter(parameter) + if resolved != nil && resolved.Name == name && resolved.In == location { + return resolved + } + } + return nil +} + +func (r *sampleRenderer) value( + typeName string, + schema *base.SchemaProxy, + raw any, + provided bool, +) (string, error) { + declaration := r.registry.declaration(typeName) + if declaration != nil { + switch { + case declaration.Type == "struct": + return r.structValue(typeName, declaration, raw, provided) + case strings.HasPrefix(declaration.Type, "[]"): + return r.sliceValue(typeName, strings.TrimPrefix(declaration.Type, "[]"), schema, raw, provided) + case strings.HasPrefix(declaration.Type, "map["): + return r.mapValue(typeName, raw) + case declaration.Type == "json.RawMessage": + return r.rawMessage(raw) + case strings.HasPrefix(declaration.Type, "= "): + aliased := strings.TrimSpace(strings.TrimPrefix(declaration.Type, "= ")) + target := r.registry.declaration(aliased) + if target != nil && target.Type == "struct" { + return r.structValue(typeName, target, raw, provided) + } + return r.primitiveValue(typeName, schema, raw, provided), nil + default: + return r.primitiveValue(typeName, schema, raw, provided), nil + } + } + + switch { + case strings.HasPrefix(typeName, "[]"): + return r.sliceValue(typeName, strings.TrimPrefix(typeName, "[]"), schema, raw, provided) + case strings.HasPrefix(typeName, "map["): + return r.mapValue(typeName, raw) + case strings.HasPrefix(typeName, "nullable.Field["): + inner := strings.TrimSuffix(strings.TrimPrefix(typeName, "nullable.Field["), "]") + r.imports[sdkModule+"/nullable"] = struct{}{} + if provided && raw == nil { + return fmt.Sprintf("nullable.Null[%s]()", r.qualifyType(inner)), nil + } + expression, err := r.value(inner, schema, raw, provided) + if err != nil { + return "", err + } + return fmt.Sprintf("nullable.Value(%s)", expression), nil + case typeName == "time.Time": + r.imports["time"] = struct{}{} + if text, ok := raw.(string); ok && text != "" { + r.usesTimeParse = true + return fmt.Sprintf("mustParseTime(%q)", text), nil + } + return "time.Time{}", nil + case typeName == "datetime.Date": + r.imports["time"] = struct{}{} + r.imports[sdkModule+"/datetime"] = struct{}{} + date := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) + if text, ok := raw.(string); ok { + if parsed, err := time.Parse(time.DateOnly, text); err == nil { + date = parsed + } + } + return fmt.Sprintf("datetime.NewDate(%d, time.%s, %d)", date.Year(), date.Month(), date.Day()), nil + case typeName == "datetime.Time": + r.imports[sdkModule+"/datetime"] = struct{}{} + clock := time.Date(0, time.January, 1, 12, 0, 0, 0, time.UTC) + if text, ok := raw.(string); ok { + if parsed, err := time.Parse(time.TimeOnly, text); err == nil { + clock = parsed + } + } + return fmt.Sprintf("datetime.NewTime(%d, %d, %d)", clock.Hour(), clock.Minute(), clock.Second()), nil + case typeName == "secret.Secret": + r.imports[sdkModule+"/secret"] = struct{}{} + return fmt.Sprintf("secret.New(%q)", stringValue(raw, "secret")), nil + case typeName == "json.Number": + r.imports["encoding/json"] = struct{}{} + return fmt.Sprintf("json.Number(%q)", stringValue(raw, "1")), nil + case typeName == "any": + return r.anyValue(raw), nil + default: + return r.primitiveValue(typeName, schema, raw, provided), nil + } +} + +func (r *sampleRenderer) structValue( + typeName string, + declaration *TypeDeclaration, + raw any, + provided bool, +) (string, error) { + values, _ := raw.(map[string]any) + var body strings.Builder + fmt.Fprintf(&body, "%s{\n", r.qualifyType(typeName)) + for _, field := range declaration.Fields { + if isReadOnly(field.Schema) { + continue + } + key := field.Name + value, fieldProvided := values[key] + if !fieldProvided && (!provided || !field.Optional) { + value, fieldProvided = schemaExample(field.Schema) + } + if field.Optional && !fieldProvided { + continue + } + + expression, err := r.value(field.Type, field.Schema, value, fieldProvided) + if err != nil { + return "", fmt.Errorf("render field %q: %w", field.Name, err) + } + if field.Pointer && !strings.HasPrefix(field.Type, "nullable.Field[") { + expression = r.pointerValue(field.Type, expression) + } + fmt.Fprintf(&body, "%s: %s,\n", structFieldName(field.Name), expression) + } + body.WriteString("}") + return body.String(), nil +} + +func (r *sampleRenderer) pointerValue(typeName, expression string) string { + if declaration := r.registry.declaration(typeName); declaration != nil && isPrimitiveDeclaration(declaration) { + expression = fmt.Sprintf("%s(%s)", r.qualifyType(typeName), expression) + } else { + switch typeName { + case "int32", "int64", "float32", "float64": + expression = fmt.Sprintf("%s(%s)", typeName, expression) + } + } + r.usesPointer = true + return fmt.Sprintf("ptr(%s)", expression) +} + +func (r *sampleRenderer) sliceValue( + typeName string, + elementType string, + schema *base.SchemaProxy, + raw any, + provided bool, +) (string, error) { + items, _ := raw.([]any) + var itemSchema *base.SchemaProxy + if schema != nil && schema.Schema() != nil && schema.Schema().Items != nil && schema.Schema().Items.IsA() { + itemSchema = schema.Schema().Items.A + } + var body strings.Builder + fmt.Fprintf(&body, "%s{", r.qualifyType(typeName)) + for _, item := range items { + expression, err := r.value(elementType, itemSchema, item, true) + if err != nil { + return "", err + } + fmt.Fprintf(&body, "%s,", expression) + } + body.WriteString("}") + return body.String(), nil +} + +func (r *sampleRenderer) mapValue(typeName string, raw any) (string, error) { + values, _ := raw.(map[string]any) + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + var body strings.Builder + fmt.Fprintf(&body, "%s{", r.qualifyType(typeName)) + for _, key := range keys { + fmt.Fprintf(&body, "%q: %s,", key, r.anyValue(values[key])) + } + body.WriteString("}") + return body.String(), nil +} + +func (r *sampleRenderer) rawMessage(raw any) (string, error) { + r.imports["encoding/json"] = struct{}{} + encoded, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("encode raw message: %w", err) + } + return fmt.Sprintf("json.RawMessage(%q)", string(encoded)), nil +} + +func (r *sampleRenderer) primitiveValue( + typeName string, + schema *base.SchemaProxy, + raw any, + provided bool, +) string { + if !provided { + raw, provided = schemaExample(schema) + } + if !provided { + raw = fallbackValue(typeName, schema) + } + + switch value := raw.(type) { + case string: + return strconv.Quote(value) + case bool: + return strconv.FormatBool(value) + case int: + return strconv.Itoa(value) + case int64: + return strconv.FormatInt(value, 10) + case float64: + return strconv.FormatFloat(value, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(value), 'f', -1, 32) + case json.Number: + return value.String() + case nil: + return "nil" + default: + return fmt.Sprintf("%v", value) + } +} + +func (r *sampleRenderer) anyValue(raw any) string { + switch value := raw.(type) { + case map[string]any: + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + slices.Sort(keys) + var body strings.Builder + body.WriteString("map[string]any{") + for _, key := range keys { + fmt.Fprintf(&body, "%q: %s,", key, r.anyValue(value[key])) + } + body.WriteString("}") + return body.String() + case []any: + var body strings.Builder + body.WriteString("[]any{") + for _, item := range value { + fmt.Fprintf(&body, "%s,", r.anyValue(item)) + } + body.WriteString("}") + return body.String() + case string: + return strconv.Quote(value) + case bool: + return strconv.FormatBool(value) + case float64: + return strconv.FormatFloat(value, 'f', -1, 64) + case nil: + return "nil" + default: + return fmt.Sprintf("%v", value) + } +} + +func (r *sampleRenderer) qualifyType(typeName string) string { + if strings.HasPrefix(typeName, "[]") { + return "[]" + r.qualifyType(strings.TrimPrefix(typeName, "[]")) + } + if _, ok := r.registry[typeName]; ok { + return "sumup." + typeName + } + return typeName +} + +func (r *sampleRenderer) importBlock() string { + standard := make([]string, 0) + thirdParty := make([]string, 0) + for path := range r.imports { + if strings.Contains(path, ".") { + thirdParty = append(thirdParty, path) + } else { + standard = append(standard, path) + } + } + slices.Sort(standard) + slices.Sort(thirdParty) + + var body strings.Builder + body.WriteString("import (\n") + for _, path := range standard { + fmt.Fprintf(&body, "\t%q\n", path) + } + if len(standard) > 0 && len(thirdParty) > 0 { + body.WriteString("\n") + } + for _, path := range thirdParty { + fmt.Fprintf(&body, "\t%q\n", path) + } + body.WriteString(")\n") + return body.String() +} + +func (registry sampleTypeRegistry) declaration(typeName string) *TypeDeclaration { + declaration := registry[typeName] + seen := make(map[string]struct{}) + for declaration != nil && strings.HasPrefix(declaration.Type, "= ") { + if _, ok := seen[declaration.Name]; ok { + return declaration + } + seen[declaration.Name] = struct{}{} + aliased := strings.TrimSpace(strings.TrimPrefix(declaration.Type, "= ")) + target := registry[aliased] + if target == nil { + return declaration + } + declaration = target + } + return declaration +} + +func parameterSchema(parameter *v3.Parameter) *base.SchemaProxy { + if parameter == nil { + return nil + } + return parameter.Schema +} + +func parameterExample(parameter *v3.Parameter) (any, bool) { + if parameter == nil { + return nil, false + } + if value, ok := decodeNode(parameter.Example); ok { + return value, true + } + if parameter.Examples != nil { + names := slices.Collect(parameter.Examples.KeysFromOldest()) + slices.Sort(names) + for _, name := range names { + example, ok := parameter.Examples.Get(name) + if ok && example != nil { + if value, ok := decodeNode(example.Value); ok { + return value, true + } + } + } + } + return schemaExample(parameter.Schema) +} + +func schemaExample(schema *base.SchemaProxy) (any, bool) { + if schema == nil || schema.Schema() == nil { + return nil, false + } + spec := schema.Schema() + if value, ok := decodeNode(spec.Example); ok { + return value, true + } + for _, example := range spec.Examples { + if value, ok := decodeNode(example); ok { + return value, true + } + } + if value, ok := decodeNode(spec.Default); ok { + return value, true + } + if len(spec.Enum) > 0 { + return decodeNode(spec.Enum[0]) + } + return nil, false +} + +func decodeNode(node *yaml.Node) (any, bool) { + if node == nil { + return nil, false + } + var value any + if err := node.Decode(&value); err != nil { + return nil, false + } + return value, true +} + +func fallbackValue(typeName string, schema *base.SchemaProxy) any { + if schema != nil && schema.Schema() != nil { + spec := schema.Schema() + switch { + case slices.Contains(spec.Type, "string"): + switch spec.Format { + case "date-time": + return "2025-01-01T00:00:00Z" + case "date": + return "2025-01-01" + case "time": + return "12:00:00" + case "email": + return "developer@example.com" + case "uri", "url": + return "https://example.com" + case "uuid": + return "00000000-0000-4000-8000-000000000000" + default: + return "string" + } + case slices.Contains(spec.Type, "integer"): + return float64(1) + case slices.Contains(spec.Type, "number"): + return 1.0 + case slices.Contains(spec.Type, "boolean"): + return true + } + } + + switch typeName { + case "string", "secret.Secret", "json.Number": + return "string" + case "bool": + return true + case "int", "int32", "int64", "float32", "float64": + return float64(1) + default: + return "string" + } +} + +func stringValue(value any, fallback string) string { + if value == nil { + return fallback + } + return fmt.Sprint(value) +} + +func isReadOnly(schema *base.SchemaProxy) bool { + return schema != nil && schema.Schema() != nil && schema.Schema().ReadOnly != nil && *schema.Schema().ReadOnly +} + +func isPrimitiveDeclaration(declaration *TypeDeclaration) bool { + if declaration == nil { + return false + } + return declaration.Type != "struct" && + !strings.HasPrefix(declaration.Type, "[]") && + !strings.HasPrefix(declaration.Type, "map[") && + declaration.Type != "json.RawMessage" +} diff --git a/internal/cmd/codegen/pkg/builder/samples_test.go b/internal/cmd/codegen/pkg/builder/samples_test.go new file mode 100644 index 00000000..0d738fed --- /dev/null +++ b/internal/cmd/codegen/pkg/builder/samples_test.go @@ -0,0 +1,175 @@ +package builder + +import ( + "encoding/json" + "fmt" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/pb33f/libopenapi" +) + +func TestBuilderSamples(t *testing.T) { + t.Parallel() + + repositoryRoot, catalog, expectedSamples := testSampleCatalog(t) + if catalog.SchemaVersion != 1 { + t.Fatalf("SchemaVersion = %d, want 1", catalog.SchemaVersion) + } + if catalog.SDK.Module != "github.com/sumup/sumup-go" { + t.Fatalf("SDK.Module = %q", catalog.SDK.Module) + } + if catalog.Language != "go" { + t.Fatalf("Language = %q, want go", catalog.Language) + } + if catalog.OpenAPIVersion != "1.0.0" { + t.Fatalf("OpenAPIVersion = %q, want 1.0.0", catalog.OpenAPIVersion) + } + if len(catalog.Samples) != expectedSamples { + t.Fatalf("len(Samples) = %d, want %d", len(catalog.Samples), expectedSamples) + } + if !slices.IsSortedFunc(catalog.Samples, func(a, b Sample) int { + return strings.Compare(a.ID, b.ID) + }) { + t.Fatal("samples are not sorted by ID") + } + seen := make(map[string]struct{}, len(catalog.Samples)) + for _, sample := range catalog.Samples { + if _, ok := seen[sample.ID]; ok { + t.Fatalf("duplicate sample ID %q", sample.ID) + } + seen[sample.ID] = struct{}{} + } + + createCheckout := sampleByID(t, catalog.Samples, "CreateCheckout.HostedCheckout") + if !strings.Contains(createCheckout.Source, "sumup.CheckoutsCreateParams{") { + t.Fatalf("CreateCheckout sample does not use the generated params type:\n%s", createCheckout.Source) + } + if !strings.Contains(createCheckout.Source, `CheckoutReference: "b50pr914-6k0e-3091-a592-890010285b3d"`) { + t.Fatalf("CreateCheckout sample does not use the OpenAPI example:\n%s", createCheckout.Source) + } + encodedSample, err := json.Marshal(createCheckout) + if err != nil { + t.Fatalf("marshal CreateCheckout sample: %v", err) + } + if !strings.Contains(string(encodedSample), `"sample":`) { + t.Fatalf("sample JSON does not preserve the portal contract: %s", encodedSample) + } + if strings.Contains(string(encodedSample), `"source":`) { + t.Fatalf("sample JSON contains internal source field name: %s", encodedSample) + } + + for _, sample := range catalog.Samples { + if _, err := parser.ParseFile(token.NewFileSet(), sample.ID+".go", sample.Source, parser.AllErrors); err != nil { + t.Errorf("parse sample %q: %v", sample.ID, err) + } + } + + compileSamples(t, repositoryRoot, catalog.Samples) +} + +func TestBuilderSamplesDeterministic(t *testing.T) { + t.Parallel() + + _, first, _ := testSampleCatalog(t) + _, second, _ := testSampleCatalog(t) + firstJSON, err := json.Marshal(first) + if err != nil { + t.Fatalf("marshal first catalog: %v", err) + } + secondJSON, err := json.Marshal(second) + if err != nil { + t.Fatalf("marshal second catalog: %v", err) + } + if string(firstJSON) != string(secondJSON) { + t.Fatal("sample generation is not deterministic") + } +} + +func testSampleCatalog(t *testing.T) (string, *SampleCatalog, int) { + t.Helper() + + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..", "..", "..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + spec, err := os.ReadFile(filepath.Join(repositoryRoot, "openapi.json")) + if err != nil { + t.Fatalf("read OpenAPI document: %v", err) + } + document, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatalf("load OpenAPI document: %v", err) + } + model, err := document.BuildV3Model() + if err != nil { + t.Fatalf("build OpenAPI model: %v", err) + } + + builder := New(Config{}) + if err := builder.Load(&model.Model); err != nil { + t.Fatalf("load builder: %v", err) + } + catalog, err := builder.Samples("test") + if err != nil { + t.Fatalf("generate samples: %v", err) + } + expectedSamples := 0 + for _, pathItem := range model.Model.Paths.PathItems.FromOldest() { + for _, operation := range pathItem.GetOperations().FromOldest() { + expectedSamples += len(requestExamples(operation)) + } + } + return repositoryRoot, catalog, expectedSamples +} + +func sampleByID(t *testing.T, samples []Sample, id string) Sample { + t.Helper() + for _, sample := range samples { + if sample.ID == id { + return sample + } + } + t.Fatalf("sample %q not found", id) + return Sample{} +} + +func compileSamples(t *testing.T, repositoryRoot string, samples []Sample) { + t.Helper() + + dir := t.TempDir() + module := fmt.Sprintf(`module generated-samples + +go 1.24.0 + +require github.com/sumup/sumup-go v0.0.0 + +replace github.com/sumup/sumup-go => %s +`, repositoryRoot) + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil { + t.Fatalf("write sample module: %v", err) + } + for i, sample := range samples { + packageDir := filepath.Join(dir, fmt.Sprintf("sample%03d", i)) + if err := os.Mkdir(packageDir, 0o700); err != nil { + t.Fatalf("create package for %q: %v", sample.ID, err) + } + if err := os.WriteFile(filepath.Join(packageDir, "main.go"), []byte(sample.Source), 0o600); err != nil { + t.Fatalf("write sample %q: %v", sample.ID, err) + } + } + + command := exec.CommandContext(t.Context(), "go", "test", "-mod=mod", "./...") + command.Dir = dir + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("compile generated samples: %v\n%s", err, output) + } +} diff --git a/internal/cmd/codegen/pkg/builder/transform.go b/internal/cmd/codegen/pkg/builder/transform.go index 652e38b5..518b1895 100644 --- a/internal/cmd/codegen/pkg/builder/transform.go +++ b/internal/cmd/codegen/pkg/builder/transform.go @@ -178,6 +178,7 @@ func (b *Builder) pathsToParamTypes(tagName string, paths *v3.Paths) []Writable Name: name, Type: typ, Parameter: param, + Schema: param.Schema, Optional: optional, Pointer: pointer, Comment: parameterPropertyGodoc(param), @@ -680,6 +681,7 @@ func (b *Builder) createFields(properties *orderedmap.Map[string, *base.SchemaPr fields = append(fields, StructField{ Name: property, Type: typeName, + Schema: schema, Comment: schemaPropertyGodoc(schema.Schema()), Tags: map[string][]string{ "json": tags, diff --git a/internal/cmd/codegen/pkg/builder/types.go b/internal/cmd/codegen/pkg/builder/types.go index 011e5268..be6e87c7 100644 --- a/internal/cmd/codegen/pkg/builder/types.go +++ b/internal/cmd/codegen/pkg/builder/types.go @@ -60,23 +60,7 @@ func (f *StructField) String() string { if f.Comment != "" { fmt.Fprintf(buf, "// %s\n", f.Comment) } - name := f.Name - - // TODO: extract into helper - if strings.HasPrefix(name, "+") { - name = strings.Replace(name, "+", "Plus", 1) - } - if strings.HasPrefix(name, "-") { - name = strings.Replace(name, "-", "Minus", 1) - } - if strings.HasPrefix(name, "@") { - name = strings.Replace(name, "@", "At", 1) - } - if strings.HasPrefix(name, "$") { - name = strings.Replace(name, "$", "", 1) - } - - name = strcase.ToCamel(name) + name := structFieldName(f.Name) if f.Pointer { fmt.Fprintf(buf, "\t%s *%s", name, f.Type) } else { @@ -93,6 +77,23 @@ func (f *StructField) String() string { return buf.String() } +func structFieldName(name string) string { + if strings.HasPrefix(name, "+") { + name = strings.Replace(name, "+", "Plus", 1) + } + if strings.HasPrefix(name, "-") { + name = strings.Replace(name, "-", "Minus", 1) + } + if strings.HasPrefix(name, "@") { + name = strings.Replace(name, "@", "At", 1) + } + if strings.HasPrefix(name, "$") { + name = strings.Replace(name, "$", "", 1) + } + + return strcase.ToCamel(name) +} + func (et *EnumDeclaration[E]) String() string { buf := new(strings.Builder) fmt.Fprint(buf, et.Type.String()) diff --git a/internal/cmd/codegen/samples.go b/internal/cmd/codegen/samples.go new file mode 100644 index 00000000..48f7ea10 --- /dev/null +++ b/internal/cmd/codegen/samples.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + + "github.com/urfave/cli/v2" + + "github.com/sumup/sumup-go/internal/cmd/codegen/pkg/builder" +) + +var versionPattern = regexp.MustCompile(`(?m)^const Version = "([^"]+)"`) + +func Samples() *cli.Command { + var out string + var sdkVersion string + var sdkVersionFile string + return &cli.Command{ + Name: "samples", + Usage: "Generate Go code samples as a JSON catalog", + Args: true, + Action: func(c *cli.Context) error { + if !c.Args().Present() { + return fmt.Errorf("empty argument, path to openapi specs expected") + } + if sdkVersion == "" && sdkVersionFile != "" { + version, err := readSDKVersion(sdkVersionFile) + if err != nil { + return err + } + sdkVersion = version + } + if sdkVersion == "" { + return fmt.Errorf("missing SDK version: set --sdk-version or --sdk-version-file") + } + + spec, err := loadOpenAPIDocument(c.Args().First()) + if err != nil { + return err + } + + generator := builder.New(builder.Config{}) + if err := generator.Load(spec); err != nil { + return fmt.Errorf("load spec: %w", err) + } + catalog, err := generator.Samples(sdkVersion) + if err != nil { + return fmt.Errorf("generate samples: %w", err) + } + + encoded, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return fmt.Errorf("encode samples: %w", err) + } + encoded = append(encoded, '\n') + + stdout := c.App.Writer + if stdout == nil { + stdout = os.Stdout + } + if err := writeSamples(out, encoded, stdout); err != nil { + return err + } + + return nil + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "out", + Aliases: []string{"o"}, + Usage: "path of the output JSON file (defaults to stdout)", + Destination: &out, + }, + &cli.StringFlag{ + Name: "sdk-version", + Usage: "SumUp Go SDK version represented by the samples", + Destination: &sdkVersion, + }, + &cli.PathFlag{ + Name: "sdk-version-file", + Usage: "Go source file containing the SDK Version constant", + Destination: &sdkVersionFile, + }, + }, + } +} + +func writeSamples(out string, encoded []byte, stdout io.Writer) error { + if out == "" { + if _, err := stdout.Write(encoded); err != nil { + return fmt.Errorf("write samples: %w", err) + } + return nil + } + + dir := filepath.Dir(out) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output directory %q: %w", dir, err) + } + if err := os.WriteFile(out, encoded, 0o644); err != nil { + return fmt.Errorf("write samples %q: %w", out, err) + } + return nil +} + +func readSDKVersion(filename string) (string, error) { + source, err := os.ReadFile(filename) + if err != nil { + return "", fmt.Errorf("read SDK version file: %w", err) + } + match := versionPattern.FindSubmatch(source) + if len(match) != 2 { + return "", fmt.Errorf("find SDK version in %q", filename) + } + return string(match[1]), nil +} diff --git a/internal/cmd/codegen/samples_test.go b/internal/cmd/codegen/samples_test.go new file mode 100644 index 00000000..f92dfbab --- /dev/null +++ b/internal/cmd/codegen/samples_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestWriteSamplesToStdout(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + if err := writeSamples("", []byte("samples\n"), &stdout); err != nil { + t.Fatalf("write samples: %v", err) + } + if stdout.String() != "samples\n" { + t.Fatalf("stdout = %q, want %q", stdout.String(), "samples\\n") + } +} + +func TestWriteSamplesToFile(t *testing.T) { + t.Parallel() + + filename := filepath.Join(t.TempDir(), "nested", "samples.json") + if err := writeSamples(filename, []byte("samples\n"), &bytes.Buffer{}); err != nil { + t.Fatalf("write samples: %v", err) + } + contents, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("read samples: %v", err) + } + if string(contents) != "samples\n" { + t.Fatalf("contents = %q, want %q", contents, "samples\\n") + } +} + +func TestReadSDKVersion(t *testing.T) { + t.Parallel() + + filename := filepath.Join(t.TempDir(), "version.go") + source := []byte("package internal\n\nconst Version = \"1.2.3\" // x-release-please-version\n") + if err := os.WriteFile(filename, source, 0o600); err != nil { + t.Fatalf("write version file: %v", err) + } + + version, err := readSDKVersion(filename) + if err != nil { + t.Fatalf("read SDK version: %v", err) + } + if version != "1.2.3" { + t.Fatalf("version = %q, want 1.2.3", version) + } +}