From 8b4642fd78ee525f65e663c58edfc4954327c427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sun, 2 Aug 2026 19:39:41 +0200 Subject: [PATCH] feat(codegen): generate validated Python code samples Generate deterministic, versioned Python sample catalogs from the SDK codegen model. Validate every generated program, expose a just recipe, and sync release-tag output to the developer portal. --- .github/workflows/generate.yaml | 11 + .github/workflows/release-code-samples.yaml | 124 ++++ .gitignore | 1 + codegen/README.md | 18 +- codegen/generate.go | 17 +- codegen/main.go | 1 + codegen/openapi.go | 27 + .../builder/intermediate_representation.go | 4 + codegen/pkg/builder/methods.go | 7 +- codegen/pkg/builder/samples.go | 559 ++++++++++++++++++ codegen/pkg/builder/samples_test.go | 158 +++++ codegen/pkg/builder/transform.go | 1 + codegen/samples.go | 116 ++++ codegen/samples_test.go | 38 ++ justfile | 12 +- 15 files changed, 1073 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/release-code-samples.yaml create mode 100644 codegen/openapi.go create mode 100644 codegen/pkg/builder/samples.go create mode 100644 codegen/pkg/builder/samples_test.go create mode 100644 codegen/samples.go create mode 100644 codegen/samples_test.go diff --git a/.github/workflows/generate.yaml b/.github/workflows/generate.yaml index d205a04c..56baef60 100644 --- a/.github/workflows/generate.yaml +++ b/.github/workflows/generate.yaml @@ -5,6 +5,9 @@ on: pull_request: paths: - openapi.json + - codegen/** + - justfile + - sumup/_version.py branches: - main @@ -42,6 +45,14 @@ jobs: run: go run ./... generate --out ../sumup/ ../openapi.json working-directory: codegen + - name: Test code generator and code samples + run: go test -race ./... + working-directory: codegen + + - name: Generate code sample catalog + run: go run . samples --sdk-version-file ../sumup/_version.py --out /tmp/sumup-py-code-samples.json ../openapi.json + working-directory: codegen + - name: Format run: uv run ruff format diff --git a/.github/workflows/release-code-samples.yaml b/.github/workflows/release-code-samples.yaml new file mode 100644 index 00000000..ec933bbe --- /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-python-code-samples: + name: Sync Python code samples + runs-on: ubuntu-latest + env: + TARGET_REPOSITORY: sumup/sumup-developer + TARGET_BRANCH: automation/python-code-samples + TARGET_FILE: src/codesamples/python.json + steps: + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ github.event.release.tag_name }} + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: codegen/go.mod + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + 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 Python code samples + working-directory: codegen + run: | + mkdir -p "../sumup-developer/$(dirname "${{ env.TARGET_FILE }}")" + go run . samples \ + --sdk-version-file ../sumup/_version.py \ + --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 Python 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 Python 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 Python code samples" \ + --body "Updates \`${{ env.TARGET_FILE }}\` from \`${{ github.repository }}\` release \`${{ github.event.release.tag_name }}\`." diff --git a/.gitignore b/.gitignore index 4f326a46..d2f40624 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ sumup.egg-info/ +code-samples.json diff --git a/codegen/README.md b/codegen/README.md index f7350390..dbcb4cd9 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -6,10 +6,22 @@ A highly opinionated OpenAPI specs to SDK generator for [sumup-py](https://githu -## Quickstart +## Python SDK -Generate the SDK using: +The `generate` command reads `openapi.json` and generates the Python client, resources, request types, and response types. Generate the SDK from the repository root with: ```sh -go run ./... --name 'My API' ./openapi.yaml +just generate ``` + +## Python Code Samples + +The `samples` command generates a deterministic, versioned JSON catalog from the same intermediate representation used to generate the SDK. Each entry contains a complete Python program, and named OpenAPI request examples produce separate entries. + +Generate the catalog from the repository root with: + +```sh +just generate-codesamples +``` + +The recipe writes `code-samples.json` in the repository root by default. Pass another path as its argument to use a different destination. The codegen test suite compiles every generated program, and the release workflow sends the release-tag catalog to `src/codesamples/python.json` in `sumup/sumup-developer`; generated JSON is not committed to this repository. diff --git a/codegen/generate.go b/codegen/generate.go index 4eaf44a6..c375da34 100644 --- a/codegen/generate.go +++ b/codegen/generate.go @@ -4,7 +4,6 @@ import ( "fmt" "os" - "github.com/pb33f/libopenapi" "github.com/urfave/cli/v2" "github.com/sumup/sumup-py/codegen/pkg/builder" @@ -30,26 +29,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/codegen/main.go b/codegen/main.go index a9fefd14..5faff694 100644 --- a/codegen/main.go +++ b/codegen/main.go @@ -28,6 +28,7 @@ func App() *cli.App { }, Commands: []*cli.Command{ Generate(), + Samples(), }, } } diff --git a/codegen/openapi.go b/codegen/openapi.go new file mode 100644 index 00000000..827ec0dc --- /dev/null +++ b/codegen/openapi.go @@ -0,0 +1,27 @@ +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/codegen/pkg/builder/intermediate_representation.go b/codegen/pkg/builder/intermediate_representation.go index ac659929..cd236822 100644 --- a/codegen/pkg/builder/intermediate_representation.go +++ b/codegen/pkg/builder/intermediate_representation.go @@ -4,6 +4,8 @@ import ( "cmp" "fmt" "strings" + + "github.com/pb33f/libopenapi/datamodel/high/base" ) // ClassDeclaration holds the information for generating a type. @@ -43,6 +45,8 @@ type Property struct { Type string // Optional field. Optional bool + // Schema is the OpenAPI schema used to generate this property. + Schema *base.SchemaProxy Comment string } diff --git a/codegen/pkg/builder/methods.go b/codegen/pkg/builder/methods.go index c6f3aae2..677f81a8 100644 --- a/codegen/pkg/builder/methods.go +++ b/codegen/pkg/builder/methods.go @@ -48,7 +48,7 @@ func (mt Method) ParamsString() string { res.WriteString("self") for _, p := range mt.PathParams { res.WriteString(", ") - res.WriteString(fmt.Sprintf("%s: %s", strcase.ToSnake(p.Name), p.Type)) + fmt.Fprintf(&res, "%s: %s", strcase.ToSnake(p.Name), p.Type) } needsKeywordOnly := mt.HasFlattenedBody() || len(mt.QueryFields) > 0 if mt.HasFlattenedBody() { @@ -61,7 +61,7 @@ func (mt Method) ParamsString() string { } } else if mt.HasBody { res.WriteString(", ") - res.WriteString(fmt.Sprintf("body: %sInput", mt.BodyType)) + fmt.Fprintf(&res, "body: %sInput", mt.BodyType) } if len(mt.QueryFields) > 0 { if !mt.HasFlattenedBody() { @@ -160,7 +160,7 @@ func pathBuilder(path string) string { if match == nil { res.WriteString(part) } else { - res.WriteString(fmt.Sprintf("{%s}", strcase.ToSnake(match[1]))) + fmt.Fprintf(&res, "{%s}", strcase.ToSnake(match[1])) } } res.WriteString(`"`) @@ -277,6 +277,7 @@ func (b *Builder) buildQueryFields(o *v3.Operation) ([]Property, error) { SerializedName: alias, Type: typeName, Optional: p.Required == nil || !*p.Required, + Schema: p.Schema, Comment: parameterPropertyDoc(p.Schema.Schema()), }) } diff --git a/codegen/pkg/builder/samples.go b/codegen/pkg/builder/samples.go new file mode 100644 index 00000000..92ef34a7 --- /dev/null +++ b/codegen/pkg/builder/samples.go @@ -0,0 +1,559 @@ +package builder + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "github.com/iancoleman/strcase" + "github.com/pb33f/libopenapi/datamodel/high/base" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +const ( + sampleCatalogSchemaVersion = 1 + sdkPackage = "sumup" +) + +// 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 Python 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 Python examples using the generated SDK method IR. +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") + } + + paths := slices.Collect(b.spec.Paths.PathItems.KeysFromOldest()) + slices.Sort(paths) + samples := make([]Sample, 0) + for _, apiPath := range 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...) + method, err := b.operationToMethod(httpMethod, apiPath, &operationCopy) + if err != nil { + return nil, fmt.Errorf("build operation %q: %w", operation.OperationId, err) + } + + tagName := "shared" + if len(operation.Tags) > 0 { + tagName = strings.ToLower(operation.Tags[0]) + } + if pathsForTag := b.pathsByTag[tagName]; pathsForTag != nil { + flattenMethodBodies([]*Method{method}, b.pathsToBodyTypes(pathsForTag)) + } + + operationSamples, err := b.samplesForOperation( + 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: "python", + SDK: SDK{ + Module: sdkPackage, + 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( + 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 { + source, err := b.renderSample(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 { + mediaType, ok := requestJSONMediaType(operation) + if !ok { + 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 := decodeSampleNode(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 := decodeSampleNode(mediaType.Example); provided { + return []requestExample{{value: value, provided: true}} + } + if value, provided := sampleSchemaExample(mediaType.Schema); provided { + return []requestExample{{value: value, provided: true}} + } + + return []requestExample{{}} +} + +type sampleArgument struct { + name string + value any +} + +func (b *Builder) renderSample( + tagName string, + operation *v3.Operation, + method *Method, + example requestExample, +) (string, error) { + arguments := make([]sampleArgument, 0) + for _, pathParameter := range method.PathParams { + parameter := operationParameter(operation.Parameters, pathParameter.Name, "path") + value, provided := sampleParameterValue(parameter) + arguments = append(arguments, sampleArgument{ + value: sampleValue(parameterSchema(parameter), value, provided), + }) + } + + if method.HasFlattenedBody() { + values, _ := example.value.(map[string]any) + for _, field := range method.BodyFields { + if sampleSchemaReadOnly(field.Schema) { + continue + } + key := field.WireName() + value, provided := values[key] + if !provided && (!example.provided || !field.Optional) { + value, provided = sampleSchemaExample(field.Schema) + } + if field.Optional && !provided { + continue + } + arguments = append(arguments, sampleArgument{ + name: field.FieldName(), + value: sampleValue(field.Schema, value, provided), + }) + } + } else if method.HasBody { + mediaType, _ := requestJSONMediaType(operation) + arguments = append(arguments, sampleArgument{ + value: sampleValue(mediaType.Schema, example.value, example.provided), + }) + } + + for _, field := range method.QueryFields { + parameter := operationParameter(operation.Parameters, field.WireName(), "query") + value, provided := sampleParameterValue(parameter) + if field.Optional && !provided { + continue + } + arguments = append(arguments, sampleArgument{ + name: field.FieldName(), + value: sampleValue(field.Schema, value, provided), + }) + } + + resourceName := strcase.ToSnake(tagName) + var source strings.Builder + source.WriteString("import os\n\n") + source.WriteString("import sumup\n\n\n") + source.WriteString("def main() -> None:\n") + source.WriteString(" client = sumup.Sumup(api_key=os.environ[\"SUMUP_API_KEY\"])\n\n") + if method.ResponseType != nil { + source.WriteString(" result = ") + } else { + source.WriteString(" ") + } + fmt.Fprintf(&source, "client.%s.%s(", resourceName, method.FunctionName) + if len(arguments) == 0 { + source.WriteString(")\n") + } else { + source.WriteString("\n") + } + for _, argument := range arguments { + source.WriteString(" ") + if argument.name != "" { + fmt.Fprintf(&source, "%s=", argument.name) + } + source.WriteString(renderPythonValue(argument.value, " ")) + source.WriteString(",\n") + } + if len(arguments) > 0 { + source.WriteString(" )\n") + } + if method.ResponseType != nil { + source.WriteString(" print(result)\n") + } + source.WriteString("\n\nif __name__ == \"__main__\":\n") + source.WriteString(" main()\n") + + return source.String(), nil +} + +func requestJSONMediaType(operation *v3.Operation) (*v3.MediaType, bool) { + if operation == nil || operation.RequestBody == nil { + return nil, false + } + return jsonMediaType(operation.RequestBody.Content) +} + +func jsonMediaType(content *orderedmap.Map[string, *v3.MediaType]) (*v3.MediaType, bool) { + if content == nil { + return nil, false + } + if mediaType, ok := content.Get("application/json"); ok && mediaType != nil { + return mediaType, true + } + for contentType, mediaType := range content.FromOldest() { + if strings.HasSuffix(contentType, "+json") && mediaType != nil { + return mediaType, true + } + } + return nil, false +} + +func operationParameter(parameters []*v3.Parameter, name, location string) *v3.Parameter { + for _, parameter := range parameters { + if parameter != nil && parameter.Name == name && parameter.In == location { + return parameter + } + } + return nil +} + +func parameterSchema(parameter *v3.Parameter) *base.SchemaProxy { + if parameter == nil { + return nil + } + return parameter.Schema +} + +func sampleParameterValue(parameter *v3.Parameter) (any, bool) { + if parameter == nil { + return nil, false + } + if value, ok := decodeSampleNode(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 := decodeSampleNode(example.Value); ok { + return value, true + } + } + } + } + return sampleSchemaExample(parameter.Schema) +} + +func sampleSchemaExample(schema *base.SchemaProxy) (any, bool) { + if schema == nil || schema.Schema() == nil { + return nil, false + } + spec := schema.Schema() + if value, ok := decodeSampleNode(spec.Example); ok { + return value, true + } + for _, example := range spec.Examples { + if value, ok := decodeSampleNode(example); ok { + return value, true + } + } + if value, ok := decodeSampleNode(spec.Default); ok { + return value, true + } + if len(spec.Enum) > 0 { + return decodeSampleNode(spec.Enum[0]) + } + return nil, false +} + +func decodeSampleNode(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 sampleValue(schema *base.SchemaProxy, raw any, provided bool) any { + return sampleValueAtDepth(schema, raw, provided, 0) +} + +func sampleValueAtDepth(schema *base.SchemaProxy, raw any, provided bool, depth int) any { + if provided { + return raw + } + if value, ok := sampleSchemaExample(schema); ok { + return value + } + if schema == nil || schema.Schema() == nil { + return "value" + } + return fallbackSampleValue(schema.Schema(), depth) +} + +func fallbackSampleValue(schema *base.Schema, depth int) any { + if schema == nil || depth > 8 { + return map[string]any{} + } + + if len(schema.AllOf) > 0 { + result := make(map[string]any) + for _, part := range schema.AllOf { + value := sampleValueAtDepth(part, nil, false, depth+1) + if fields, ok := value.(map[string]any); ok { + for key, field := range fields { + result[key] = field + } + } + } + return result + } + if len(schema.OneOf) > 0 { + return sampleValueAtDepth(schema.OneOf[0], nil, false, depth+1) + } + if len(schema.AnyOf) > 0 { + return sampleValueAtDepth(schema.AnyOf[0], nil, false, depth+1) + } + + switch { + case slices.Contains(schema.Type, "object") || schema.Properties != nil: + value := make(map[string]any) + if schema.Properties != nil { + for _, name := range schema.Required { + property, ok := schema.Properties.Get(name) + if ok && !sampleSchemaReadOnly(property) { + if example, provided := sampleSchemaExample(property); provided { + value[name] = example + } else if property != nil && property.Schema() != nil { + value[name] = fallbackSampleValue(property.Schema(), depth+1) + } + } + } + } + return value + case slices.Contains(schema.Type, "array"): + return []any{} + case slices.Contains(schema.Type, "boolean"): + return true + case slices.Contains(schema.Type, "integer"): + return 1 + case slices.Contains(schema.Type, "number"): + return 1.0 + case slices.Contains(schema.Type, "string"): + switch schema.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-0000-0000-000000000000" + default: + return "string" + } + default: + return map[string]any{} + } +} + +func sampleSchemaReadOnly(schema *base.SchemaProxy) bool { + return schema != nil && schema.Schema() != nil && schema.Schema().ReadOnly != nil && *schema.Schema().ReadOnly +} + +func renderPythonValue(value any, indent string) string { + switch typed := value.(type) { + case map[string]any: + if len(typed) == 0 { + return "{}" + } + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + slices.Sort(keys) + var out strings.Builder + out.WriteString("{\n") + for _, key := range keys { + fmt.Fprintf(&out, "%s %s: %s,\n", indent, strconv.Quote(key), renderPythonValue(typed[key], indent+" ")) + } + out.WriteString(indent) + out.WriteString("}") + return out.String() + case []any: + if len(typed) == 0 { + return "[]" + } + var out strings.Builder + out.WriteString("[\n") + for _, item := range typed { + fmt.Fprintf(&out, "%s %s,\n", indent, renderPythonValue(item, indent+" ")) + } + out.WriteString(indent) + out.WriteString("]") + return out.String() + case string: + return strconv.Quote(typed) + case bool: + if typed { + return "True" + } + return "False" + case nil: + return "None" + case int: + return strconv.Itoa(typed) + case int8: + return strconv.FormatInt(int64(typed), 10) + case int16: + return strconv.FormatInt(int64(typed), 10) + case int32: + return strconv.FormatInt(int64(typed), 10) + case int64: + return strconv.FormatInt(typed, 10) + case uint: + return strconv.FormatUint(uint64(typed), 10) + case uint8: + return strconv.FormatUint(uint64(typed), 10) + case uint16: + return strconv.FormatUint(uint64(typed), 10) + case uint32: + return strconv.FormatUint(uint64(typed), 10) + case uint64: + return strconv.FormatUint(typed, 10) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 32) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + default: + return strconv.Quote(fmt.Sprint(typed)) + } +} diff --git a/codegen/pkg/builder/samples_test.go b/codegen/pkg/builder/samples_test.go new file mode 100644 index 00000000..d3ffda33 --- /dev/null +++ b/codegen/pkg/builder/samples_test.go @@ -0,0 +1,158 @@ +package builder + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/pb33f/libopenapi" +) + +func TestBuilderSamples(t *testing.T) { + t.Parallel() + + _, catalog, expectedSamples := testSampleCatalog(t) + if catalog.SchemaVersion != 1 { + t.Fatalf("SchemaVersion = %d, want 1", catalog.SchemaVersion) + } + if catalog.SDK.Module != "sumup" { + t.Fatalf("SDK.Module = %q, want sumup", catalog.SDK.Module) + } + if catalog.Language != "python" { + t.Fatalf("Language = %q, want python", 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{}{} + } + + hostedCheckout := sampleByID(t, catalog.Samples, "CreateCheckout.HostedCheckout") + if !strings.Contains(hostedCheckout.Source, "client.checkouts.create(") { + t.Fatalf("CreateCheckout sample does not call the generated SDK method:\n%s", hostedCheckout.Source) + } + if !strings.Contains(hostedCheckout.Source, "hosted_checkout={") || + !strings.Contains(hostedCheckout.Source, `"enabled": True`) { + t.Fatalf("CreateCheckout sample does not use the OpenAPI example:\n%s", hostedCheckout.Source) + } + encodedSample, err := json.Marshal(hostedCheckout) + 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) + } + + compilePythonSamples(t, 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) + } + + generator := New(Config{}) + if err := generator.Load(&model.Model); err != nil { + t.Fatalf("load builder: %v", err) + } + catalog, err := generator.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 compilePythonSamples(t *testing.T, samples []Sample) { + t.Helper() + + python, err := exec.LookPath("python3") + if err != nil { + t.Fatalf("find python3 for generated sample validation: %v", err) + } + dir := t.TempDir() + args := []string{"-m", "py_compile"} + for i, sample := range samples { + filename := filepath.Join(dir, fmt.Sprintf("sample%03d.py", i)) + if err := os.WriteFile(filename, []byte(sample.Source), 0o600); err != nil { + t.Fatalf("write sample %q: %v", sample.ID, err) + } + args = append(args, filename) + } + + command := exec.CommandContext(t.Context(), python, args...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("compile generated Python samples: %v\n%s", err, output) + } +} diff --git a/codegen/pkg/builder/transform.go b/codegen/pkg/builder/transform.go index 0b9d0c97..18fce8f3 100644 --- a/codegen/pkg/builder/transform.go +++ b/codegen/pkg/builder/transform.go @@ -389,6 +389,7 @@ func (b *Builder) createFields(properties *orderedmap.Map[string, *base.SchemaPr Type: typeName, Comment: schemaPropertyGodoc(schema.Schema()), Optional: optional, + Schema: schema, }) types = append(types, moreTypes...) } diff --git a/codegen/samples.go b/codegen/samples.go new file mode 100644 index 00000000..d67ed609 --- /dev/null +++ b/codegen/samples.go @@ -0,0 +1,116 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + + "github.com/urfave/cli/v2" + + "github.com/sumup/sumup-py/codegen/pkg/builder" +) + +var versionPattern = regexp.MustCompile(`(?m)^__version__\s*=\s*["']([^"']+)["']`) + +func Samples() *cli.Command { + var out string + var sdkVersion string + var sdkVersionFile string + return &cli.Command{ + Name: "samples", + Usage: "Generate Python 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 + } + return writeSamples(out, encoded, stdout) + }, + 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 Python SDK version represented by the samples", + Destination: &sdkVersion, + }, + &cli.PathFlag{ + Name: "sdk-version-file", + Usage: "Python source file containing the SDK __version__ assignment", + 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/codegen/samples_test.go b/codegen/samples_test.go new file mode 100644 index 00000000..f5497a9d --- /dev/null +++ b/codegen/samples_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestReadSDKVersion(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filename := filepath.Join(dir, "_version.py") + if err := os.WriteFile(filename, []byte(`__version__ = "1.2.3" # release`), 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) + } +} + +func TestWriteSamplesToStdout(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + if err := writeSamples("", []byte("catalog\n"), &output); err != nil { + t.Fatalf("write samples: %v", err) + } + if output.String() != "catalog\n" { + t.Fatalf("output = %q, want catalog newline", output.String()) + } +} diff --git a/justfile b/justfile index fc454a1b..ec8d06a3 100644 --- a/justfile +++ b/justfile @@ -27,7 +27,17 @@ check-fix: # Generate code from OpenAPI specs [group('misc')] generate: && fmt check-fix - cd codegen && go run ./... generate --out ../sumup/ ../openapi.json + go -C codegen run . generate \ + --out ../sumup/ \ + ../openapi.json + +# Generate the versioned Python code sample catalog +[group('misc')] +generate-codesamples output="code-samples.json": + go -C codegen run . samples \ + --sdk-version-file ../sumup/_version.py \ + --out "{{ absolute_path(output) }}" \ + ../openapi.json [group('test')] test: