From fd4ff7ec4a7f637151f1104277c30edab49ebaa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sun, 2 Aug 2026 19:36:00 +0200 Subject: [PATCH] feat(codegen): generate validated Java code samples Generate deterministic, versioned Java 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/ci.yaml | 3 + .github/workflows/release-code-samples.yaml | 123 +++++ .gitignore | 3 + codegen/README.md | 18 +- codegen/app.go | 1 + codegen/go.mod | 2 +- codegen/internal/generator/model.go | 14 +- codegen/internal/generator/samples.go | 471 ++++++++++++++++++++ codegen/internal/generator/samples_test.go | 93 ++++ codegen/samples_command.go | 106 +++++ codegen/samples_command_test.go | 46 ++ justfile | 7 + src/build.gradle | 55 +++ 13 files changed, 938 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-code-samples.yaml create mode 100644 codegen/internal/generator/samples.go create mode 100644 codegen/internal/generator/samples_test.go create mode 100644 codegen/samples_command.go create mode 100644 codegen/samples_command_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 341e779..fe73bd4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,6 +46,9 @@ jobs: - name: Format SDK code run: ./gradlew spotlessApply + - name: Compile generated code samples + run: ./gradlew :sumup-sdk:compileCodeSamples + - name: Ensure clean working tree run: git diff --exit-code diff --git a/.github/workflows/release-code-samples.yaml b/.github/workflows/release-code-samples.yaml new file mode 100644 index 0000000..2846f13 --- /dev/null +++ b/.github/workflows/release-code-samples.yaml @@ -0,0 +1,123 @@ +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-java-code-samples: + name: Sync Java code samples + runs-on: ubuntu-latest + env: + TARGET_REPOSITORY: sumup/sumup-developer + TARGET_BRANCH: automation/java-code-samples + TARGET_FILE: src/codesamples/java.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: 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 Java code samples + run: | + mkdir -p "sumup-developer/$(dirname "${{ env.TARGET_FILE }}")" + go -C codegen run . samples \ + --spec ../openapi.json \ + --sdk-version-file ../VERSION \ + --out "../sumup-developer/${{ env.TARGET_FILE }}" + + - 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 Java 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 Java 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 Java code samples" \ + --body "Updates \`${{ env.TARGET_FILE }}\` from \`${{ github.repository }}\` release \`${{ github.event.release.tag_name }}\`." diff --git a/.gitignore b/.gitignore index b8b9391..56712f6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ build/ .classpath .project .settings + +# Generated developer portal artifact +/code-samples.json diff --git a/codegen/README.md b/codegen/README.md index 8704fc4..a706195 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -6,10 +6,12 @@ Custom Go-based generator that reads the repository’s `openapi.json` and emits the Java SDK in a tag-based structure. Each tag becomes its own client and the runtime is kept intentionally small so we can iterate quickly. -## Quickstart +## Java SDK + +The `generate` command reads `openapi.json` and generates the Java client, grouped API clients, models, and supporting source files. Generate the SDK from the repository root with: ```bash -go -C codegen run . generate +just generate ``` ### CLI flags @@ -19,3 +21,15 @@ go -C codegen run . generate - `--package` (default `com.sumup.sdk`) – base package for generated classes. The command is idempotent; rerunning it rewrites the generated clients in-place. Continuous Integration runs the same invocation and fails when the working tree is dirty afterward. + +## Java Code Samples + +The `samples` command generates a deterministic, versioned JSON catalog of Java examples from the same intermediate representation used to generate the SDK. Each catalog entry contains a complete Java program. Named OpenAPI request examples produce separate entries. + +Generate a catalog from the repository root with: + +```bash +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. Every generated program is compiled in Continuous Integration. 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/codegen/app.go b/codegen/app.go index 9ae146f..9514b26 100644 --- a/codegen/app.go +++ b/codegen/app.go @@ -13,6 +13,7 @@ func App() *cli.App { DefaultCommand: "generate", Commands: []*cli.Command{ GenerateCommand(), + SamplesCommand(), }, } } diff --git a/codegen/go.mod b/codegen/go.mod index 3ec056a..9a39ae9 100644 --- a/codegen/go.mod +++ b/codegen/go.mod @@ -6,6 +6,7 @@ require ( github.com/lmittmann/tint v1.2.0 github.com/pb33f/libopenapi v0.38.7 github.com/urfave/cli/v2 v2.27.7 + go.yaml.in/yaml/v4 v4.0.0-rc.6 ) require ( @@ -16,6 +17,5 @@ require ( github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/sync v0.22.0 // indirect ) diff --git a/codegen/internal/generator/model.go b/codegen/internal/generator/model.go index d3dd841..656cd3f 100644 --- a/codegen/internal/generator/model.go +++ b/codegen/internal/generator/model.go @@ -56,6 +56,8 @@ type operationModel struct { HasOptionalHeaders bool HasOptionalArgs bool TagName string + Operation *v3.Operation + RequestSchema *base.SchemaProxy } // parameterGroupModel holds information about optional parameter structs @@ -78,6 +80,8 @@ type parameterModel struct { Required bool Type javaType Location string + Schema *base.SchemaProxy + Parameter *v3.Parameter } // schemaModel represents the information required to render a POJO model. @@ -97,11 +101,13 @@ type schemaModel struct { // schemaField stores metadata for a field within a schemaModel. type schemaField struct { + WireName string Name string Type string DescriptionLines []string Required bool ReadOnly bool + Schema *base.SchemaProxy } // additionalPropertiesModel describes synthetic map storage used when object @@ -239,6 +245,7 @@ func convertOperation(method, path string, item *v3.PathItem, op *v3.Operation, HttpMethod: strings.ToUpper(method), Path: path, TagName: firstTag(op.Tags), + Operation: op, } params := collectParameters(item, op) @@ -277,6 +284,7 @@ func convertOperation(method, path string, item *v3.PathItem, op *v3.Operation, model.HasHeaderParams = len(model.RequiredHeaderParams) > 0 || len(model.OptionalHeaderParams) > 0 if op.RequestBody != nil { + model.RequestSchema = preferredSchema(op.RequestBody.Content) model.RequestBodyType = schemaTypeFromContent(op.RequestBody, resolver, sanitizedID, "Request") model.RequestRequired = op.RequestBody.Required != nil && *op.RequestBody.Required model.RequestDescription = normalizeText(op.RequestBody.Description) @@ -384,6 +392,8 @@ func filterParams(params []*v3.Parameter, location string, resolver *typeResolve Required: required, Type: javaType, Location: location, + Schema: schemaRef, + Parameter: param, }) } sort.Slice(filtered, func(i, j int) bool { @@ -568,7 +578,7 @@ func buildSchemas(doc *v3.Document, params Params, resolver *typeResolver) []sch imports := sortedImports(map[string]struct{}{ "com.fasterxml.jackson.annotation.JsonCreator": {}, "com.fasterxml.jackson.annotation.JsonValue": {}, - "java.util.Objects": {}, + "java.util.Objects": {}, }) result = append(result, schemaModel{ Name: name, @@ -655,11 +665,13 @@ func buildSchemaFields(name string, ref *base.SchemaProxy, resolver *typeResolve desc = schemaFromProxy(propRef).Description } fields = append(fields, schemaField{ + WireName: propName, Name: camelCase(propName, propName), Type: javaType.Name, DescriptionLines: splitComment(desc), Required: required[propName], ReadOnly: readOnly, + Schema: propRef, }) if required[propName] && !readOnly { hasRequired = true diff --git a/codegen/internal/generator/samples.go b/codegen/internal/generator/samples.go new file mode 100644 index 0000000..abd6582 --- /dev/null +++ b/codegen/internal/generator/samples.go @@ -0,0 +1,471 @@ +package generator + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" + + base "github.com/pb33f/libopenapi/datamodel/high/base" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "go.yaml.in/yaml/v4" +) + +const sampleCatalogSchemaVersion = 1 + +// SampleCatalog is the versioned JSON contract consumed by documentation sites. +type SampleCatalog struct { + SchemaVersion int `json:"schemaVersion"` + Language string `json:"language"` + SDK SampleSDK `json:"sdk"` + OpenAPIVersion string `json:"openAPIVersion"` + Samples []Sample `json:"samples"` +} + +// SampleSDK identifies the package and version used by generated samples. +type SampleSDK struct { + Module string `json:"module"` + Version string `json:"version"` +} + +// Sample is a complete Java 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"` +} + +type requestExample struct { + name string + summary string + description string + value any + provided bool +} + +// BuildSamples creates a deterministic catalog from the same model used by SDK generation. +func BuildSamples(params Params, sdkVersion string) (*SampleCatalog, error) { + if strings.TrimSpace(sdkVersion) == "" { + return nil, fmt.Errorf("missing SDK version") + } + if err := params.normalize(); err != nil { + return nil, err + } + doc, err := loadDocument(params.SpecPath) + if err != nil { + return nil, err + } + model, err := buildModel(doc, params) + if err != nil { + return nil, err + } + + registry := make(map[string]schemaModel, len(model.Schemas)*2) + for _, schema := range model.Schemas { + registry[schema.Package+"."+schema.ClassName] = schema + registry[schema.ClassName] = schema + } + + samples := make([]Sample, 0) + for _, client := range model.Clients { + for _, operation := range client.Methods { + for _, example := range operationRequestExamples(operation.Operation) { + renderer := javaSampleRenderer{registry: registry} + source, err := renderer.render(client, operation, example) + if err != nil { + return nil, fmt.Errorf("render sample %q: %w", operation.OperationID, err) + } + id := operation.OperationID + if example.name != "" { + id += "." + example.name + } + summary := strings.TrimSpace(operation.Operation.Summary) + if example.summary != "" { + summary = strings.TrimSpace(example.summary) + } + description := strings.TrimSpace(operation.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: operation.HttpMethod, + Path: operation.Path, + Source: source, + }) + } + } + } + sort.Slice(samples, func(i, j int) bool { return samples[i].ID < samples[j].ID }) + + openAPIVersion := "" + if doc.Info != nil { + openAPIVersion = strings.TrimSpace(doc.Info.Version) + } + return &SampleCatalog{ + SchemaVersion: sampleCatalogSchemaVersion, + Language: "java", + SDK: SampleSDK{ + Module: "com.sumup:sumup-sdk", + Version: strings.TrimSpace(sdkVersion), + }, + OpenAPIVersion: openAPIVersion, + Samples: samples, + }, nil +} + +type javaSampleRenderer struct { + registry map[string]schemaModel +} + +func (r javaSampleRenderer) render(client clientModel, operation operationModel, example requestExample) (string, error) { + args := make([]string, 0) + for _, parameter := range operation.PathParams { + value, provided := parameterSample(parameter.Parameter) + args = append(args, r.value(parameter.Type.Name, parameter.Schema, value, provided, 0)) + } + for _, parameter := range operation.RequiredQueryParams { + value, provided := parameterSample(parameter.Parameter) + args = append(args, r.value(parameter.Type.Name, parameter.Schema, value, provided, 0)) + } + if operation.HasRequestBody { + args = append(args, r.value(operation.RequestBodyType.Name, operation.RequestSchema, example.value, example.provided, 0)) + } + + className := pascalCase(operation.OperationID+" "+example.name, "Sample") + call := "client." + client.AccessorName + "()." + operation.MethodName + "(" + if len(args) > 0 { + call += "\n" + for i, arg := range args { + call += indentJava(arg, 8) + if i < len(args)-1 { + call += "," + } + call += "\n" + } + call += " " + } + call += ")" + + var source strings.Builder + source.WriteString("import com.sumup.sdk.SumUpClient;\n\n") + fmt.Fprintf(&source, "public final class %s {\n", className) + source.WriteString(" public static void main(String[] args) throws Exception {\n") + source.WriteString(" var client = new SumUpClient();\n\n") + if operation.ResponseType.IsVoid { + source.WriteString(" ") + source.WriteString(strings.ReplaceAll(call, "\n", "\n ")) + source.WriteString(";\n") + } else { + source.WriteString(" var result = ") + source.WriteString(strings.ReplaceAll(call, "\n", "\n ")) + source.WriteString(";\n") + source.WriteString(" System.out.println(result);\n") + } + source.WriteString(" }\n}\n") + return source.String(), nil +} + +func (r javaSampleRenderer) value(typeName string, schema *base.SchemaProxy, raw any, provided bool, depth int) string { + if depth > 8 { + return "null" + } + if !provided { + raw, provided = schemaSample(schema) + } + + if model, ok := r.registry[typeName]; ok { + if model.IsEnum { + value := stringValue(raw, "example") + if !provided && len(model.EnumValues) > 0 { + value = model.EnumValues[0].WireValue + } + return typeName + ".of(" + strconv.Quote(value) + ")" + } + values, _ := raw.(map[string]any) + if model.HasBuilder { + var expression strings.Builder + expression.WriteString(typeName + ".builder()") + for _, field := range model.Fields { + if field.ReadOnly || field.Name == "additionalProperties" { + continue + } + value, fieldProvided := values[field.WireName] + if !fieldProvided && field.Required { + value, fieldProvided = schemaSample(field.Schema) + } + if !field.Required && !fieldProvided { + continue + } + expression.WriteString("\n ." + field.Name + "(") + expression.WriteString(r.value(field.Type, field.Schema, value, fieldProvided, depth+1)) + expression.WriteString(")") + } + expression.WriteString("\n .build()") + return expression.String() + } + if len(model.Fields) == 1 { + field := model.Fields[0] + value, fieldProvided := values[field.WireName] + if !fieldProvided { + value, fieldProvided = schemaSample(field.Schema) + } + return "new " + typeName + "(" + r.value(field.Type, field.Schema, value, fieldProvided, depth+1) + ")" + } + } + + if strings.HasPrefix(typeName, "java.util.List<") { + inner := strings.TrimSuffix(strings.TrimPrefix(typeName, "java.util.List<"), ">") + items, _ := raw.([]any) + parts := make([]string, 0, len(items)) + var itemSchema *base.SchemaProxy + if schema != nil && schema.Schema() != nil && schema.Schema().Items != nil && schema.Schema().Items.IsA() { + itemSchema = schema.Schema().Items.A + } + for _, item := range items { + parts = append(parts, r.value(inner, itemSchema, item, true, depth+1)) + } + return "java.util.List.of(" + strings.Join(parts, ", ") + ")" + } + if strings.HasPrefix(typeName, "java.util.Map<") { + values, _ := raw.(map[string]any) + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, "java.util.Map.entry("+strconv.Quote(key)+", "+r.anyValue(values[key], depth+1)+")") + } + return "java.util.Map.ofEntries(" + strings.Join(entries, ", ") + ")" + } + + switch typeName { + case "String": + return strconv.Quote(stringValue(raw, fallbackString(schema))) + case "Boolean": + if value, ok := raw.(bool); ok { + return strconv.FormatBool(value) + } + return "true" + case "Integer": + return integerLiteral(raw, "") + case "Long": + return integerLiteral(raw, "L") + case "Float": + return numberLiteral(raw, "f") + case "Double": + return numberLiteral(raw, "d") + case "java.time.OffsetDateTime": + return "java.time.OffsetDateTime.parse(" + strconv.Quote(stringValue(raw, "2025-01-01T12:00:00Z")) + ")" + case "java.time.LocalDate": + return "java.time.LocalDate.parse(" + strconv.Quote(stringValue(raw, "2025-01-01")) + ")" + case "java.util.UUID": + return "java.util.UUID.fromString(" + strconv.Quote(stringValue(raw, "00000000-0000-0000-0000-000000000000")) + ")" + case "Object": + return r.anyValue(raw, depth+1) + default: + if strings.HasPrefix(typeName, "com.sumup.sdk.models.") { + return "null" + } + return r.anyValue(raw, depth+1) + } +} + +func (r javaSampleRenderer) anyValue(raw any, depth int) string { + switch value := raw.(type) { + case nil: + return "null" + 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) + "L" + case float64: + return strconv.FormatFloat(value, 'f', -1, 64) + "d" + case []any: + parts := make([]string, 0, len(value)) + for _, item := range value { + parts = append(parts, r.anyValue(item, depth+1)) + } + return "java.util.List.of(" + strings.Join(parts, ", ") + ")" + case map[string]any: + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + entries := make([]string, 0, len(keys)) + for _, key := range keys { + entries = append(entries, "java.util.Map.entry("+strconv.Quote(key)+", "+r.anyValue(value[key], depth+1)+")") + } + return "java.util.Map.ofEntries(" + strings.Join(entries, ", ") + ")" + default: + return strconv.Quote(fmt.Sprintf("%v", value)) + } +} + +func operationRequestExamples(operation *v3.Operation) []requestExample { + if operation == nil || operation.RequestBody == nil || operation.RequestBody.Content == nil { + return []requestExample{{}} + } + media := operation.RequestBody.Content.GetOrZero("application/json") + if media == nil { + return []requestExample{{}} + } + if media.Examples != nil && media.Examples.Len() > 0 { + names := make([]string, 0, media.Examples.Len()) + for name := range media.Examples.KeysFromOldest() { + names = append(names, name) + } + sort.Strings(names) + examples := make([]requestExample, 0, len(names)) + for _, name := range names { + example := media.Examples.GetOrZero(name) + if 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(media.Example); provided { + return []requestExample{{value: value, provided: true}} + } + if value, provided := schemaSample(media.Schema); provided { + return []requestExample{{value: value, provided: true}} + } + return []requestExample{{}} +} + +func parameterSample(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 { + for _, example := range parameter.Examples.FromOldest() { + if example != nil { + if value, ok := decodeNode(example.Value); ok { + return value, true + } + } + } + } + return schemaSample(parameterSchema(parameter)) +} + +func schemaSample(proxy *base.SchemaProxy) (any, bool) { + if proxy == nil || proxy.Schema() == nil { + return nil, false + } + schema := proxy.Schema() + if value, ok := decodeNode(schema.Example); ok { + return value, true + } + for _, example := range schema.Examples { + if value, ok := decodeNode(example); ok { + return value, true + } + } + if value, ok := decodeNode(schema.Default); ok { + return value, true + } + if len(schema.Enum) > 0 { + return decodeNode(schema.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 fallbackString(schema *base.SchemaProxy) string { + if schema == nil || schema.Schema() == nil { + return "example" + } + switch schema.Schema().Format { + case "uuid": + return "00000000-0000-0000-0000-000000000000" + case "uri", "url": + return "https://example.com" + case "email": + return "user@example.com" + case "date": + return "2025-01-01" + case "date-time": + return time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC).Format(time.RFC3339) + default: + return "example" + } +} + +func stringValue(value any, fallback string) string { + if text, ok := value.(string); ok && text != "" { + return text + } + return fallback +} + +func integerLiteral(value any, suffix string) string { + switch number := value.(type) { + case int: + return strconv.Itoa(number) + suffix + case int64: + return strconv.FormatInt(number, 10) + suffix + case float64: + return strconv.FormatInt(int64(number), 10) + suffix + default: + return "1" + suffix + } +} + +func numberLiteral(value any, suffix string) string { + var literal string + switch number := value.(type) { + case int: + literal = strconv.Itoa(number) + ".0" + case int64: + literal = strconv.FormatInt(number, 10) + ".0" + case float64: + literal = strconv.FormatFloat(number, 'f', -1, 64) + default: + literal = "10.1" + } + if !strings.Contains(literal, ".") { + literal += ".0" + } + return literal + suffix +} + +func indentJava(value string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix) +} diff --git a/codegen/internal/generator/samples_test.go b/codegen/internal/generator/samples_test.go new file mode 100644 index 0000000..b01f817 --- /dev/null +++ b/codegen/internal/generator/samples_test.go @@ -0,0 +1,93 @@ +package generator + +import ( + "encoding/json" + "slices" + "strings" + "testing" +) + +func TestBuildSamples(t *testing.T) { + t.Parallel() + catalog, err := BuildSamples(Params{SpecPath: "../../../openapi.json"}, "test") + if err != nil { + t.Fatalf("build samples: %v", err) + } + if catalog.SchemaVersion != 1 || catalog.Language != "java" { + t.Fatalf("catalog metadata = %#v", catalog) + } + if catalog.SDK.Module != "com.sumup:sumup-sdk" || catalog.SDK.Version != "test" { + t.Fatalf("SDK metadata = %#v", catalog.SDK) + } + if catalog.OpenAPIVersion != "1.0.0" { + t.Fatalf("OpenAPIVersion = %q", catalog.OpenAPIVersion) + } + if len(catalog.Samples) != 47 { + t.Fatalf("samples = %d, want 47", len(catalog.Samples)) + } + 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)) + operations := make(map[string]struct{}, len(catalog.Samples)) + namedExamples := 0 + for _, sample := range catalog.Samples { + if _, exists := seen[sample.ID]; exists { + t.Fatalf("duplicate sample ID %q", sample.ID) + } + seen[sample.ID] = struct{}{} + operations[sample.OperationID] = struct{}{} + if sample.Example != "" { + namedExamples++ + } + if !strings.Contains(sample.Source, "public static void main(String[] args) throws Exception") { + t.Fatalf("sample %q is not a complete Java program", sample.ID) + } + } + if len(operations) != 40 || namedExamples != 9 { + t.Fatalf("catalog coverage = %d operations and %d named examples, want 40 and 9", len(operations), namedExamples) + } + + hosted := sampleByID(t, catalog.Samples, "CreateCheckout.HostedCheckout") + if !strings.Contains(hosted.Source, "CheckoutCreateRequest.builder()") || + !strings.Contains(hosted.Source, `.checkoutReference("b50pr914-6k0e-3091-a592-890010285b3d")`) { + t.Fatalf("named request example was not rendered:\n%s", hosted.Source) + } + encoded, err := json.Marshal(hosted) + if err != nil { + t.Fatalf("marshal sample: %v", err) + } + if !strings.Contains(string(encoded), `"sample":`) || strings.Contains(string(encoded), `"source":`) { + t.Fatalf("portal JSON contract changed: %s", encoded) + } +} + +func TestBuildSamplesDeterministic(t *testing.T) { + t.Parallel() + first, err := BuildSamples(Params{SpecPath: "../../../openapi.json"}, "test") + if err != nil { + t.Fatalf("build first catalog: %v", err) + } + second, err := BuildSamples(Params{SpecPath: "../../../openapi.json"}, "test") + if err != nil { + t.Fatalf("build second catalog: %v", err) + } + firstJSON, _ := json.Marshal(first) + secondJSON, _ := json.Marshal(second) + if string(firstJSON) != string(secondJSON) { + t.Fatal("sample generation is not deterministic") + } +} + +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{} +} diff --git a/codegen/samples_command.go b/codegen/samples_command.go new file mode 100644 index 0000000..4ee6ac6 --- /dev/null +++ b/codegen/samples_command.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/sumup/sumup-java/codegen/internal/generator" + "github.com/urfave/cli/v2" +) + +// SamplesCommand generates the Java code-sample catalog consumed by documentation sites. +func SamplesCommand() *cli.Command { + return &cli.Command{ + Name: "samples", + Usage: "Generate Java code samples as a JSON catalog", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "spec", + Usage: "Path to the OpenAPI spec", + Value: "", + DefaultText: "openapi.json", + EnvVars: []string{"SUMUP_OPENAPI_SPEC"}, + }, + &cli.StringFlag{ + Name: "package", + Usage: "Base Java package for generated sources", + Value: "com.sumup.sdk", + }, + &cli.StringFlag{ + Name: "out", + Aliases: []string{"o"}, + Usage: "Path of the output JSON file (defaults to stdout)", + }, + &cli.StringFlag{ + Name: "sdk-version", + Usage: "SDK version represented by the samples", + }, + &cli.PathFlag{ + Name: "sdk-version-file", + Usage: "File containing the SDK version", + Value: filepath.Join("..", "VERSION"), + }, + }, + Action: func(ctx *cli.Context) error { + sdkVersion := strings.TrimSpace(ctx.String("sdk-version")) + if sdkVersion == "" { + version, err := readSDKVersion(ctx.Path("sdk-version-file")) + if err != nil { + return err + } + sdkVersion = version + } + + catalog, err := generator.BuildSamples(generator.Params{ + SpecPath: ctx.String("spec"), + BasePackage: ctx.String("package"), + }, 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 := ctx.App.Writer + if stdout == nil { + stdout = os.Stdout + } + return writeSamples(ctx.String("out"), encoded, stdout) + }, + } +} + +func readSDKVersion(filename string) (string, error) { + contents, err := os.ReadFile(filename) + if err != nil { + return "", fmt.Errorf("read SDK version: %w", err) + } + version := strings.TrimSpace(string(contents)) + if version == "" { + return "", fmt.Errorf("SDK version file %q is empty", filename) + } + return version, nil +} + +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 + } + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return fmt.Errorf("create samples directory: %w", err) + } + if err := os.WriteFile(out, encoded, 0o644); err != nil { + return fmt.Errorf("write samples: %w", err) + } + return nil +} diff --git a/codegen/samples_command_test.go b/codegen/samples_command_test.go new file mode 100644 index 0000000..686ad8b --- /dev/null +++ b/codegen/samples_command_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestReadSDKVersion(t *testing.T) { + t.Parallel() + filename := filepath.Join(t.TempDir(), "VERSION") + if err := os.WriteFile(filename, []byte("1.2.3\n"), 0o600); err != nil { + t.Fatalf("write version: %v", err) + } + version, err := readSDKVersion(filename) + if err != nil { + t.Fatalf("read version: %v", err) + } + if version != "1.2.3" { + t.Fatalf("version = %q, want 1.2.3", version) + } +} + +func TestWriteSamples(t *testing.T) { + t.Parallel() + var stdout bytes.Buffer + if err := writeSamples("", []byte("samples\n"), &stdout); err != nil { + t.Fatalf("write stdout: %v", err) + } + if stdout.String() != "samples\n" { + t.Fatalf("stdout = %q", stdout.String()) + } + + filename := filepath.Join(t.TempDir(), "nested", "samples.json") + if err := writeSamples(filename, []byte("samples\n"), &bytes.Buffer{}); err != nil { + t.Fatalf("write file: %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", contents) + } +} diff --git a/justfile b/justfile index af9e679..31c9182 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,13 @@ generate: go -C codegen run . generate --spec ../openapi.json just format +# Generate the developer portal code sample catalog. +generate-codesamples output="code-samples.json": + go -C codegen run . samples \ + --spec ../openapi.json \ + --sdk-version-file ../VERSION \ + --out "{{ absolute_path(output) }}" + # Run unit tests for the Go generator code. go-test: go -C codegen test ./... diff --git a/src/build.gradle b/src/build.gradle index 75489bf..0cecc24 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -1,3 +1,4 @@ +import groovy.json.JsonSlurper import org.gradle.language.jvm.tasks.ProcessResources plugins { @@ -47,6 +48,60 @@ tasks.named('processResources', ProcessResources) { } } +def codeSamplesJson = layout.buildDirectory.file('generated/code-samples/catalog.json') +def codeSamplesSourceDir = layout.buildDirectory.dir('generated/code-samples/src') + +def generateCodeSamples = tasks.register('generateCodeSamples', Exec) { + group = 'verification' + description = 'Generates the developer portal Java code sample catalog.' + workingDir rootProject.projectDir + commandLine( + 'go', '-C', 'codegen', 'run', '.', 'samples', + '--spec', '../openapi.json', + '--sdk-version-file', '../VERSION', + '--out', codeSamplesJson.get().asFile.absolutePath) + inputs.files( + rootProject.file('openapi.json'), + rootProject.file('VERSION'), + rootProject.fileTree('codegen') { + include '**/*.go' + include 'go.mod' + include 'go.sum' + }) + outputs.file(codeSamplesJson) +} + +def prepareCodeSamples = tasks.register('prepareCodeSamples') { + group = 'verification' + description = 'Writes generated Java code samples to individual source files.' + dependsOn(generateCodeSamples) + inputs.file(codeSamplesJson) + outputs.dir(codeSamplesSourceDir) + doLast { + def outputDir = codeSamplesSourceDir.get().asFile + delete(outputDir) + outputDir.mkdirs() + + def catalog = new JsonSlurper().parse(codeSamplesJson.get().asFile) + catalog.samples.each { sample -> + def classMatcher = sample.sample =~ /public final class ([A-Za-z0-9_]+)/ + if (!classMatcher.find()) { + throw new GradleException("Code sample ${sample.id} has no public class") + } + new File(outputDir, "${classMatcher.group(1)}.java").text = sample.sample + } + } +} + +tasks.register('compileCodeSamples', JavaCompile) { + group = 'verification' + description = 'Compiles every generated Java code sample against the SDK.' + dependsOn(prepareCodeSamples, tasks.named('classes')) + source(codeSamplesSourceDir) + classpath = sourceSets.main.runtimeClasspath + destinationDirectory = layout.buildDirectory.dir('classes/java/codeSamples') +} + tasks.withType(Javadoc).configureEach { options.addBooleanOption('Xdoclint:none', true) options.quiet()