diff --git a/.github/workflows/codegen.yaml b/.github/workflows/codegen.yaml index 764da02..d13ad70 100644 --- a/.github/workflows/codegen.yaml +++ b/.github/workflows/codegen.yaml @@ -6,12 +6,18 @@ on: - main paths: - "codegen/**" + - "openapi.json" + - "composer.json" + - "justfile" - ".github/workflows/codegen.yaml" pull_request: branches: - main paths: - "codegen/**" + - "openapi.json" + - "composer.json" + - "justfile" - ".github/workflows/codegen.yaml" permissions: @@ -51,5 +57,11 @@ jobs: with: go-version-file: './codegen/go.mod' + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: '8.2' + coverage: none + - name: Run tests run: go test ./... diff --git a/.github/workflows/release-code-samples.yaml b/.github/workflows/release-code-samples.yaml new file mode 100644 index 0000000..f6c0ac3 --- /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-php-code-samples: + name: Sync PHP code samples + runs-on: ubuntu-latest + env: + TARGET_REPOSITORY: sumup/sumup-developer + TARGET_BRANCH: automation/php-code-samples + TARGET_FILE: src/codesamples/php.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 PHP code samples + working-directory: codegen + run: | + mkdir -p "../sumup-developer/$(dirname "${{ env.TARGET_FILE }}")" + go run . samples \ + --sdk-version-file ../composer.json \ + --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 PHP 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 PHP 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 PHP code samples" \ + --body "Updates \`${{ env.TARGET_FILE }}\` from \`${{ github.repository }}\` release \`${{ github.event.release.tag_name }}\`." diff --git a/.gitignore b/.gitignore index c2f1287..7d0772c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ composer.lock .phpunit.result.cache .envrc build/ +code-samples.json .phpdoc/ diff --git a/codegen/README.md b/codegen/README.md index 0d08582..cb0140d 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -16,6 +16,18 @@ go run . generate ../openapi.json ./build > Note: The PHP SDK now ships only with `openapi.json`; the YAML version is no longer maintained. +## PHP Code Samples + +The `samples` command generates a deterministic, versioned JSON catalog from the same OpenAPI model used to generate the SDK. Each entry contains a complete PHP program that calls the generated service method. 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 write it elsewhere. The generated file is ignored in this repository, and the codegen tests lint every program with PHP. Published releases regenerate the catalog from the release tag and open or update a pull request in `sumup/sumup-developer`. + ## Features ### Enum Support diff --git a/codegen/main.go b/codegen/main.go index 91c2762..5e92a45 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/pkg/generator/samples.go b/codegen/pkg/generator/samples.go new file mode 100644 index 0000000..107ad0b --- /dev/null +++ b/codegen/pkg/generator/samples.go @@ -0,0 +1,579 @@ +package generator + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "github.com/pb33f/libopenapi/datamodel/high/base" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "go.yaml.in/yaml/v4" +) + +const ( + sampleCatalogSchemaVersion = 1 + sdkPackage = "sumup/sumup-php" +) + +// 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 PHP 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 +} + +// Samples builds a deterministic catalog of syntax-valid PHP examples. +func (g *Generator) Samples(sdkVersion string) (*SampleCatalog, error) { + if g.spec == nil { + return nil, fmt.Errorf("missing specs: call Load to load the specs first") + } + if g.spec.Info == nil { + return nil, fmt.Errorf("missing specs info: call Load to load the specs first") + } + if g.spec.Paths == nil || g.spec.Paths.PathItems == nil { + return nil, fmt.Errorf("missing specs paths: call Load to load the specs first") + } + + paths := make([]string, 0, g.spec.Paths.PathItems.Len()) + for path := range g.spec.Paths.PathItems.FromOldest() { + paths = append(paths, path) + } + slices.Sort(paths) + + samples := make([]Sample, 0) + for _, path := range paths { + pathItem, ok := g.spec.Paths.PathItems.Get(path) + if !ok || pathItem == nil || pathItem.IsReference() { + continue + } + + operations := pathItem.GetOperations() + methods := slices.Collect(operations.KeysFromOldest()) + slices.Sort(methods) + for _, method := range methods { + specOperation, ok := operations.Get(method) + if !ok || specOperation == nil { + continue + } + if specOperation.OperationId == "" { + return nil, fmt.Errorf("missing operation id for %s %s", strings.ToUpper(method), path) + } + if len(specOperation.Tags) == 0 { + return nil, fmt.Errorf("missing tag for operation %q", specOperation.OperationId) + } + + params := make([]*v3.Parameter, 0, len(pathItem.Parameters)+len(specOperation.Parameters)) + params = append(params, pathItem.Parameters...) + params = append(params, specOperation.Parameters...) + built, err := g.buildOperation(strings.ToUpper(method), path, specOperation, params) + if err != nil { + return nil, fmt.Errorf("build operation %q: %w", specOperation.OperationId, err) + } + + operationSamples, err := g.samplesForOperation( + g.displayTagName(normalizeTagKey(specOperation.Tags[0])), + strings.ToUpper(method), + path, + specOperation, + params, + built, + ) + if err != nil { + return nil, fmt.Errorf("generate samples for %q: %w", specOperation.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: "php", + SDK: SDK{ + Module: sdkPackage, + Version: sdkVersion, + }, + OpenAPIVersion: strings.TrimSpace(g.spec.Info.Version), + Samples: samples, + }, nil +} + +func (g *Generator) samplesForOperation( + serviceClass string, + httpMethod string, + path string, + specOperation *v3.Operation, + params []*v3.Parameter, + built *operation, +) ([]Sample, error) { + examples := requestExamples(specOperation) + samples := make([]Sample, 0, len(examples)) + for _, example := range examples { + source, err := g.renderSample(serviceClass, specOperation, params, built, example) + if err != nil { + return nil, err + } + + id := specOperation.OperationId + if example.name != "" { + id += "." + example.name + } + summary := strings.TrimSpace(specOperation.Summary) + if example.summary != "" { + summary = strings.TrimSpace(example.summary) + } + description := strings.TrimSpace(specOperation.Description) + if example.description != "" { + description = strings.TrimSpace(example.description) + } + + samples = append(samples, Sample{ + ID: id, + OperationID: specOperation.OperationId, + Example: example.name, + Summary: summary, + Description: description, + HTTPMethod: httpMethod, + Path: path, + Source: source, + }) + } + + return samples, nil +} + +func requestExamples(operation *v3.Operation) []requestExample { + mediaType := requestJSONMediaType(operation) + if 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{{}} +} + +func requestJSONMediaType(operation *v3.Operation) *v3.MediaType { + if operation == nil || operation.RequestBody == nil || operation.RequestBody.Content == nil { + return nil + } + if mediaType, ok := operation.RequestBody.Content.Get("application/json"); ok { + return mediaType + } + for _, mediaType := range operation.RequestBody.Content.FromOldest() { + if mediaType != nil { + return mediaType + } + } + return nil +} + +func (g *Generator) renderSample( + serviceClass string, + specOperation *v3.Operation, + params []*v3.Parameter, + built *operation, + example requestExample, +) (string, error) { + var body strings.Builder + body.WriteString("%s = %s;\n", + queryParam.VarName, + renderPHPValue(value, 0), + )) + } + if len(assignments) == 0 && len(built.QueryParams) > 0 { + queryParam := built.QueryParams[0] + parameter := findParameter(params, queryParam.OriginalName, "query") + value := placeholderForParameter( + built.OriginalID+"_"+queryParam.OriginalName, + parameterSchema(parameter), + ) + assignments = append(assignments, fmt.Sprintf( + "$queryParams->%s = %s;\n", + queryParam.VarName, + renderPHPValue(value, 0), + )) + } + if len(assignments) > 0 { + usesQueryParams = true + paramsClass := queryParamsClassName(serviceClass, built) + fmt.Fprintf(&body, "\n$queryParams = new \\SumUp\\Services\\%s();\n", paramsClass) + for _, assignment := range assignments { + body.WriteString(assignment) + } + } + } + + if built.HasBody { + value := example.value + if !example.provided { + mediaType := requestJSONMediaType(specOperation) + if mediaType != nil { + value = exampleForSchema(mediaType.Schema, make(map[*base.SchemaProxy]struct{})) + } + } + body.WriteString("\n$body = ") + body.WriteString(renderPHPValue(value, 0)) + body.WriteString(";\n") + } + + args := make([]string, 0, len(built.PathParams)+2) + for _, pathParam := range built.PathParams { + parameter := findParameter(params, pathParam.OriginalName, "path") + value, provided := parameterExample(parameter) + if !provided { + value = placeholderForParameter(pathParam.OriginalName, parameterSchema(parameter)) + } + args = append(args, renderPHPValue(value, 1)) + } + if usesQueryParams { + args = append(args, "$queryParams") + } + if built.HasBody { + args = append(args, "$body") + } + + body.WriteString("\n$result = $sumup->") + body.WriteString(phpPropertyName(serviceClass)) + body.WriteString("()->") + body.WriteString(built.methodName()) + body.WriteString("(") + if len(args) > 0 { + body.WriteString("\n") + for _, argument := range args { + body.WriteString(" ") + body.WriteString(argument) + body.WriteString(",\n") + } + } + body.WriteString(");\n\nvar_dump($result);\n") + return body.String(), nil +} + +func findParameter(params []*v3.Parameter, name, location string) *v3.Parameter { + for _, parameter := range params { + 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 parameterExample(parameter *v3.Parameter) (any, bool) { + if parameter == nil { + return nil, false + } + if value, provided := decodeNode(parameter.Example); provided { + return value, true + } + if parameter.Examples != nil && parameter.Examples.Len() > 0 { + 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, provided := decodeNode(example.Value); provided { + 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, provided := decodeNode(spec.Example); provided { + return value, true + } + if value, provided := decodeNode(spec.Default); provided { + return value, true + } + if len(spec.Examples) > 0 { + if value, provided := decodeNode(spec.Examples[0]); provided { + 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 exampleForSchema(schema *base.SchemaProxy, visited map[*base.SchemaProxy]struct{}) any { + if value, provided := schemaExample(schema); provided { + return value + } + if schema == nil || schema.Schema() == nil { + return nil + } + if _, ok := visited[schema]; ok { + return nil + } + visited[schema] = struct{}{} + defer delete(visited, schema) + + spec := schema.Schema() + if len(spec.OneOf) > 0 { + return exampleForSchema(spec.OneOf[0], visited) + } + if len(spec.AnyOf) > 0 { + return exampleForSchema(spec.AnyOf[0], visited) + } + if len(spec.AllOf) > 0 || hasSchemaType(spec, "object") || spec.Properties != nil { + value := make(map[string]any) + for _, composite := range spec.AllOf { + if nested, ok := exampleForSchema(composite, visited).(map[string]any); ok { + for key, item := range nested { + value[key] = item + } + } + } + required := make(map[string]struct{}, len(spec.Required)) + for _, name := range spec.Required { + required[name] = struct{}{} + } + if spec.Properties != nil { + for name, property := range spec.Properties.FromOldest() { + _, isRequired := required[name] + propertyValue, provided := schemaExample(property) + if !isRequired && !provided { + continue + } + if !provided { + propertyValue = exampleForSchema(property, visited) + } + value[name] = propertyValue + } + } + return value + } + if hasSchemaType(spec, "array") { + if spec.Items != nil && spec.Items.A != nil { + return []any{exampleForSchema(spec.Items.A, visited)} + } + return []any{} + } + if hasSchemaType(spec, "boolean") { + return true + } + if hasSchemaType(spec, "integer") { + return 1 + } + if hasSchemaType(spec, "number") { + return 10.0 + } + if hasSchemaType(spec, "string") { + switch spec.Format { + case "date": + return "2026-01-01" + case "date-time": + return "2026-01-01T12:00:00Z" + case "email": + return "developer@example.com" + case "uuid": + return "00000000-0000-4000-8000-000000000000" + case "uri", "url": + return "https://example.com" + default: + return "example" + } + } + return nil +} + +func placeholderForParameter(name string, schema *base.SchemaProxy) any { + lowerName := strings.ToLower(name) + switch { + case strings.Contains(lowerName, "merchant"): + return "M123456789" + case strings.Contains(lowerName, "checkout"): + return "checkout-id" + case strings.Contains(lowerName, "transaction"): + return "transaction-id" + case strings.Contains(lowerName, "customer"): + return "customer-id" + case strings.Contains(lowerName, "reader"): + return "reader-id" + case strings.HasSuffix(lowerName, "_id"): + return strings.TrimSuffix(strings.ReplaceAll(lowerName, "_", "-"), "-id") + "-id" + default: + return exampleForSchema(schema, make(map[*base.SchemaProxy]struct{})) + } +} + +func renderPHPValue(value any, indent int) string { + switch typed := value.(type) { + case nil: + return "null" + case string: + return "'" + strings.ReplaceAll(strings.ReplaceAll(typed, "\\", "\\\\"), "'", "\\'") + "'" + case bool: + return strconv.FormatBool(typed) + 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) + case []any: + if len(typed) == 0 { + return "[]" + } + var result strings.Builder + result.WriteString("[\n") + for _, item := range typed { + result.WriteString(strings.Repeat(" ", indent+1)) + result.WriteString(renderPHPValue(item, indent+1)) + result.WriteString(",\n") + } + result.WriteString(strings.Repeat(" ", indent)) + result.WriteString("]") + return result.String() + 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 result strings.Builder + result.WriteString("[\n") + for _, key := range keys { + result.WriteString(strings.Repeat(" ", indent+1)) + result.WriteString(renderPHPValue(key, indent+1)) + result.WriteString(" => ") + result.WriteString(renderPHPValue(typed[key], indent+1)) + result.WriteString(",\n") + } + result.WriteString(strings.Repeat(" ", indent)) + result.WriteString("]") + return result.String() + case map[any]any: + normalized := make(map[string]any, len(typed)) + for key, item := range typed { + normalized[fmt.Sprint(key)] = item + } + return renderPHPValue(normalized, indent) + default: + return renderPHPValue(fmt.Sprint(value), indent) + } +} diff --git a/codegen/pkg/generator/samples_test.go b/codegen/pkg/generator/samples_test.go new file mode 100644 index 0000000..0c7905b --- /dev/null +++ b/codegen/pkg/generator/samples_test.go @@ -0,0 +1,154 @@ +package generator + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/pb33f/libopenapi" +) + +func TestGeneratorSamples(t *testing.T) { + t.Parallel() + + catalog, expectedSamples := testSampleCatalog(t) + if catalog.SchemaVersion != 1 { + t.Fatalf("SchemaVersion = %d, want 1", catalog.SchemaVersion) + } + if catalog.Language != "php" { + t.Fatalf("Language = %q, want php", catalog.Language) + } + if catalog.SDK.Module != "sumup/sumup-php" { + t.Fatalf("SDK.Module = %q", catalog.SDK.Module) + } + 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->checkouts()->create(") { + t.Fatalf("CreateCheckout sample does not call the generated SDK method:\n%s", createCheckout.Source) + } + if !strings.Contains(createCheckout.Source, "'checkout_reference' => '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) + } + + getTransaction := sampleByID(t, catalog.Samples, "GetTransactionV2.1") + if !strings.Contains(getTransaction.Source, "$queryParams->id = 'transaction-id';") { + t.Fatalf("GetTransaction sample does not select a transaction identifier:\n%s", getTransaction.Source) + } + if strings.Contains(getTransaction.Source, "$queryParams->transactionCode") { + t.Fatalf("GetTransaction sample sets mutually exclusive identifiers:\n%s", getTransaction.Source) + } + + php, err := exec.LookPath("php") + if err != nil { + t.Skip("php is not installed") + } + for _, sample := range catalog.Samples { + filename := filepath.Join(t.TempDir(), "sample.php") + if err := os.WriteFile(filename, []byte(sample.Source), 0o600); err != nil { + t.Fatalf("write sample %q: %v", sample.ID, err) + } + command := exec.CommandContext(t.Context(), php, "-l", filename) + if output, err := command.CombinedOutput(); err != nil { + t.Errorf("lint sample %q: %v\n%s", sample.ID, err, output) + } + } +} + +func TestGeneratorSamplesDeterministic(t *testing.T) { + t.Parallel() + + firstCatalog, _ := testSampleCatalog(t) + first, err := json.Marshal(firstCatalog) + if err != nil { + t.Fatalf("marshal first catalog: %v", err) + } + secondCatalog, _ := testSampleCatalog(t) + second, err := json.Marshal(secondCatalog) + if err != nil { + t.Fatalf("marshal second catalog: %v", err) + } + if string(first) != string(second) { + t.Fatal("sample generation is not deterministic") + } +} + +func testSampleCatalog(t *testing.T) (*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) + } + + g := New(Config{}) + if err := g.Load(&model.Model); err != nil { + t.Fatalf("load generator: %v", err) + } + catalog, err := g.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 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{} +} diff --git a/codegen/samples.go b/codegen/samples.go new file mode 100644 index 0000000..46bb554 --- /dev/null +++ b/codegen/samples.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pb33f/libopenapi" + "github.com/urfave/cli/v2" + + "github.com/sumup/sumup-php/codegen/pkg/generator" +) + +func Samples() *cli.Command { + var out string + var sdkVersion string + var sdkVersionFile string + return &cli.Command{ + Name: "samples", + Usage: "Generate PHP 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 := os.ReadFile(c.Args().First()) + if err != nil { + return fmt.Errorf("read specs: %w", err) + } + document, err := libopenapi.NewDocument(spec) + if err != nil { + return fmt.Errorf("load openapi document: %w", err) + } + model, err := document.BuildV3Model() + if err != nil { + return fmt.Errorf("build openapi v3 model: %w", err) + } + + g := generator.New(generator.Config{}) + if err := g.Load(&model.Model); err != nil { + return fmt.Errorf("load specs: %w", err) + } + catalog, err := g.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 PHP SDK version represented by the samples", + Destination: &sdkVersion, + }, + &cli.PathFlag{ + Name: "sdk-version-file", + Usage: "composer.json file containing the SDK version", + 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) { + contents, err := os.ReadFile(filename) + if err != nil { + return "", fmt.Errorf("read SDK version file: %w", err) + } + var composer struct { + Version string `json:"version"` + } + if err := json.Unmarshal(contents, &composer); err != nil { + return "", fmt.Errorf("decode SDK version file: %w", err) + } + if composer.Version == "" { + return "", fmt.Errorf("find SDK version in %q", filename) + } + return composer.Version, nil +} diff --git a/justfile b/justfile index 61658b9..79638c9 100644 --- a/justfile +++ b/justfile @@ -40,4 +40,13 @@ analyse: install # Generate SDK from the local OpenAPI specs. generate: - cd codegen && go run . generate ../openapi.json ../src + go -C codegen run . generate \ + ../openapi.json \ + ../src + +# Generate a versioned JSON catalog of PHP code samples. +generate-codesamples output="code-samples.json": + go -C codegen run . samples \ + --sdk-version-file ../composer.json \ + --out "{{ absolute_path(output) }}" \ + ../openapi.json