From 53b1acbe55cab262b83a82abf256600e73cc1a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Fri, 11 Sep 2026 14:44:12 +0200 Subject: [PATCH] fix: include all operations in code samples --- internal/apicommands/apicommands.go | 9 ++- internal/apicommands/catalog.gen.go | 14 ++++ internal/cmd/generate-operations/main.go | 77 ++++++++++++++++++- internal/cmd/generate-operations/main_test.go | 33 ++++++++ internal/codesamples/codesamples.go | 18 +++-- internal/codesamples/codesamples_test.go | 4 + internal/commands/operations_test.go | 16 +++- 7 files changed, 158 insertions(+), 13 deletions(-) diff --git a/internal/apicommands/apicommands.go b/internal/apicommands/apicommands.go index b9d1e8a..a2b34b2 100644 --- a/internal/apicommands/apicommands.go +++ b/internal/apicommands/apicommands.go @@ -8,7 +8,7 @@ import ( "github.com/urfave/cli/v3" ) -//go:generate go run ../cmd/generate-operations -out catalog.gen.go +//go:generate go run ../cmd/generate-operations -out catalog.gen.go -unsupported-spec ../../openapi.json const operationIDMetadataKey = "sumup.openapi.operation-id" @@ -37,6 +37,7 @@ type Operation struct { Path string Summary string Description string + Unsupported bool Parameters []Parameter RequestBody *RequestBody } @@ -58,9 +59,13 @@ func Bind(operationID string, command *cli.Command) *cli.Command { if command == nil { panic("cannot bind an OpenAPI operation to a nil command") } - if _, ok := Lookup(operationID); !ok { + operation, ok := Lookup(operationID) + if !ok { panic(fmt.Sprintf("unknown OpenAPI operation %q", operationID)) } + if operation.Unsupported { + panic(fmt.Sprintf("unsupported OpenAPI operation %q", operationID)) + } if command.Metadata == nil { command.Metadata = make(map[string]any) } diff --git a/internal/apicommands/catalog.gen.go b/internal/apicommands/catalog.gen.go index 999503d..6df7867 100644 --- a/internal/apicommands/catalog.gen.go +++ b/internal/apicommands/catalog.gen.go @@ -576,4 +576,18 @@ var Operations = []Operation{ }, RequestBody: &RequestBody{Schema: "object", Required: false}, }, + { + ID: "ProcessCheckout", + Client: "Checkouts", + SDKMethod: "Process", + HTTPMethod: "PUT", + Path: "/v0.1/checkouts/{checkout_id}", + Summary: "Process a checkout", + Description: ":::caution[PCI DSS compliance required]\nWhen you submit raw card details directly to the Checkout API, your systems store, process, or transmit cardholder data and are therefore subject to applicable [PCI DSS requirements](https://www.pcisecuritystandards.org/document_library/). You should only use this integration if your environment is appropriately PCI DSS compliant.\n:::\n\nProcessing a checkout will attempt to charge the provided payment instrument for the amount of the specified checkout resource initiated in the `Create a checkout` endpoint.\n\nFollow this request with `Retrieve a checkout` to confirm its status.", + Unsupported: true, + Parameters: []Parameter{ + {Name: "checkout_id", Location: "path", Description: "Unique identifier of the checkout resource.", Type: "string", Format: "", Required: true}, + }, + RequestBody: &RequestBody{Schema: "ProcessCheckout", Required: true}, + }, } diff --git a/internal/cmd/generate-operations/main.go b/internal/cmd/generate-operations/main.go index 674185c..228e1d2 100644 --- a/internal/cmd/generate-operations/main.go +++ b/internal/cmd/generate-operations/main.go @@ -18,6 +18,11 @@ import ( const sdkModule = "github.com/sumup/sumup-go" +var unsupportedOperationIDs = map[string]struct{}{ + // Processing raw payment instruments is deliberately not exposed by the CLI. + "ProcessCheckout": {}, +} + type moduleInfo struct { Path string Version string @@ -82,6 +87,7 @@ type operation struct { Path string Summary string Description string + Unsupported bool Parameters []parameter RequestBody *requestBody } @@ -103,19 +109,21 @@ type requestBody struct { func main() { var outputPath string var specPath string + var unsupportedSpecPath string var sdkVersion string flag.StringVar(&outputPath, "out", "catalog.gen.go", "generated Go output path") flag.StringVar(&specPath, "spec", "", "OpenAPI document path; defaults to the pinned SDK module") + flag.StringVar(&unsupportedSpecPath, "unsupported-spec", "", "OpenAPI document containing explicitly unsupported operations omitted from the SDK") flag.StringVar(&sdkVersion, "sdk-version", "", "SDK version; defaults to the pinned module version") flag.Parse() - if err := run(outputPath, specPath, sdkVersion); err != nil { + if err := run(outputPath, specPath, sdkVersion, unsupportedSpecPath); err != nil { _, _ = fmt.Fprintf(os.Stderr, "generate operations: %v\n", err) os.Exit(1) } } -func run(outputPath, specPath, sdkVersion string) error { +func run(outputPath, specPath, sdkVersion, unsupportedSpecPath string) error { if specPath == "" || sdkVersion == "" { module, err := resolveModule() if err != nil { @@ -145,6 +153,23 @@ func run(outputPath, specPath, sdkVersion string) error { if err != nil { return err } + if unsupportedSpecPath != "" { + unsupportedSpec, err := os.ReadFile(unsupportedSpecPath) + if err != nil { + return fmt.Errorf("read unsupported operations document: %w", err) + } + candidates, err := parseUnsupportedOperations(unsupportedSpec) + if err != nil { + return err + } + for _, candidate := range candidates { + if !slices.ContainsFunc(operations, func(existing operation) bool { + return existing.ID == candidate.ID + }) { + operations = append(operations, candidate) + } + } + } generated, err := renderCatalog(document.Info.Version, sdkVersion, spec, operations) if err != nil { @@ -157,6 +182,50 @@ func run(outputPath, specPath, sdkVersion string) error { return nil } +func parseUnsupportedOperations(spec []byte) ([]operation, error) { + var document struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(spec, &document); err != nil { + return nil, fmt.Errorf("decode unsupported operations document: %w", err) + } + var operations []operation + for path, item := range document.Paths { + for _, method := range []string{"delete", "get", "patch", "post", "put"} { + raw, ok := item[method] + if !ok { + continue + } + var header struct { + ID string `json:"operationId"` + } + if err := json.Unmarshal(raw, &header); err != nil { + return nil, fmt.Errorf("decode operation ID: %w", err) + } + if _, unsupported := unsupportedOperationIDs[header.ID]; !unsupported { + continue + } + var source openAPIOperation + if err := json.Unmarshal(raw, &source); err != nil { + return nil, fmt.Errorf("decode unsupported operation %q: %w", header.ID, err) + } + var parameters []openAPIParameter + if rawParameters, ok := item["parameters"]; ok { + if err := json.Unmarshal(rawParameters, ¶meters); err != nil { + return nil, fmt.Errorf("decode path parameters for %q: %w", header.ID, err) + } + } + parsed, err := parseOperation(path, strings.ToUpper(method), parameters, &source) + if err != nil { + return nil, err + } + operations = append(operations, parsed) + } + } + slices.SortFunc(operations, func(a, b operation) int { return strings.Compare(a.ID, b.ID) }) + return operations, nil +} + func resolveModule() (*moduleInfo, error) { // go list only includes Dir when the module has already been downloaded. download := exec.Command("go", "mod", "download", sdkModule) @@ -261,6 +330,7 @@ func parseOperation(path, httpMethod string, pathParameters []openAPIParameter, Summary: strings.TrimSpace(source.Summary), Description: strings.TrimSpace(source.Description), } + _, result.Unsupported = unsupportedOperationIDs[source.OperationID] for _, sourceParameter := range append(slices.Clone(pathParameters), source.Parameters...) { result.Parameters = append(result.Parameters, parameter{ Name: sourceParameter.Name, @@ -327,6 +397,9 @@ func renderCatalog(openAPIVersion, sdkVersion string, spec []byte, operations [] fmt.Fprintf(&output, "\t\tPath: %q,\n", operation.Path) fmt.Fprintf(&output, "\t\tSummary: %q,\n", operation.Summary) fmt.Fprintf(&output, "\t\tDescription: %q,\n", operation.Description) + if operation.Unsupported { + output.WriteString("\t\tUnsupported: true,\n") + } if len(operation.Parameters) > 0 { output.WriteString("\t\tParameters: []Parameter{\n") for _, parameter := range operation.Parameters { diff --git a/internal/cmd/generate-operations/main_test.go b/internal/cmd/generate-operations/main_test.go index f936f63..3df2388 100644 --- a/internal/cmd/generate-operations/main_test.go +++ b/internal/cmd/generate-operations/main_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "strings" "testing" @@ -8,6 +10,37 @@ import ( "github.com/stretchr/testify/require" ) +func TestRunIncludesOnlyExplicitlyUnsupportedOperations(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + specPath := filepath.Join(directory, "sdk.json") + unsupportedSpecPath := filepath.Join(directory, "openapi.json") + outputPath := filepath.Join(directory, "catalog.go") + require.NoError(t, os.WriteFile(specPath, []byte(`{ + "info":{"version":"1.0.0"}, + "paths":{"/widgets":{"get":{"operationId":"ListWidgets","tags":["Widgets"],"x-codegen":{"method_name":"list"}}}} + }`), 0o600)) + require.NoError(t, os.WriteFile(unsupportedSpecPath, []byte(`{ + "info":{"version":"1.0.0"}, + "paths":{ + "/checkouts/{checkout_id}":{"put":{"operationId":"ProcessCheckout","tags":["Checkouts"],"x-codegen":{"method_name":"process"}}}, + "/other":{"get":{"operationId":"OtherOperation","tags":["Other"],"x-codegen":{"method_name":"get"}}}, + "/widgets":{"get":{"operationId":"ListWidgets","summary":"Must not override the SDK","tags":["Widgets"],"x-codegen":{"method_name":"list"}}} + } + }`), 0o600)) + + require.NoError(t, run(outputPath, specPath, "v1.2.3", unsupportedSpecPath)) + generated, err := os.ReadFile(outputPath) + require.NoError(t, err) + output := string(generated) + assert.Regexp(t, `ID:\s+"ProcessCheckout"`, output) + assert.Contains(t, output, "Unsupported: true") + assert.Equal(t, 1, strings.Count(output, `"ListWidgets"`)) + assert.NotContains(t, output, "OtherOperation") + assert.NotContains(t, output, "Must not override the SDK") +} + func TestParseOperations(t *testing.T) { t.Parallel() diff --git a/internal/codesamples/codesamples.go b/internal/codesamples/codesamples.go index f6a1f06..57db5cb 100644 --- a/internal/codesamples/codesamples.go +++ b/internal/codesamples/codesamples.go @@ -21,6 +21,7 @@ const ( catalogSchemaVersion = 1 cliModule = "github.com/sumup/sumup-cli" cliLanguage = "bash" + unsupportedSample = "# not supported" ) // Catalog is the versioned JSON contract consumed by documentation sites. @@ -86,14 +87,17 @@ func Generate(cliVersion string) (*Catalog, error) { commandsByOperation := boundCommandsByOperation(commands.All()) samples := make([]Sample, 0, len(apicommands.Operations)) for _, operation := range apicommands.Operations { - command, err := commandForOperation(operation.ID, commandsByOperation[operation.ID]) - if err != nil { - return nil, err - } example := spec.exampleFor(operation.HTTPMethod, operation.Path) - source, err := renderCommand(spec, command, example) - if err != nil { - return nil, fmt.Errorf("generate sample for %q: %w", operation.ID, err) + source := unsupportedSample + if !operation.Unsupported { + command, err := commandForOperation(operation.ID, commandsByOperation[operation.ID]) + if err != nil { + return nil, err + } + source, err = renderCommand(spec, command, example) + if err != nil { + return nil, fmt.Errorf("generate sample for %q: %w", operation.ID, err) + } } summary := operation.Summary if example.summary != "" { diff --git a/internal/codesamples/codesamples_test.go b/internal/codesamples/codesamples_test.go index c26fafc..b5f5cd8 100644 --- a/internal/codesamples/codesamples_test.go +++ b/internal/codesamples/codesamples_test.go @@ -73,6 +73,7 @@ func TestGenerate(t *testing.T) { assert.Contains(t, sampleByID(t, catalog.Samples, "CreateGoReaderCheckout").Source, "sumup readers go-checkout") assert.Contains(t, sampleByID(t, catalog.Samples, "CreateMerchantMember").Source, "sumup members create") assert.NotContains(t, sampleByID(t, catalog.Samples, "CreateMerchantMember").Source, "members invite") + assert.Equal(t, "# not supported", sampleByID(t, catalog.Samples, "ProcessCheckout").Source) encodedSample, err := json.Marshal(sampleByID(t, catalog.Samples, "CreateCheckout")) require.NoError(t, err) @@ -106,6 +107,9 @@ func TestGenerateRequiresVersion(t *testing.T) { func TestGeneratedInvocationsReachAPITransport(t *testing.T) { for _, operation := range apicommands.Operations { t.Run(operation.ID, func(t *testing.T) { + if operation.Unsupported { + return + } resourceCommands := commands.All() commandsByOperation := boundCommandsByOperation(resourceCommands) bound, err := commandForOperation(operation.ID, commandsByOperation[operation.ID]) diff --git a/internal/commands/operations_test.go b/internal/commands/operations_test.go index 283a2a9..9ac7c40 100644 --- a/internal/commands/operations_test.go +++ b/internal/commands/operations_test.go @@ -34,7 +34,11 @@ func TestOpenAPICatalogMatchesSDK(t *testing.T) { catalogMethods := make([]string, 0, len(apicommands.Operations)) for _, operation := range apicommands.Operations { - catalogMethods = append(catalogMethods, operation.Client+"."+operation.SDKMethod) + method := operation.Client + "." + operation.SDKMethod + if operation.Unsupported && !slices.Contains(sdkMethods, method) { + continue + } + catalogMethods = append(catalogMethods, method) } slices.Sort(catalogMethods) @@ -67,12 +71,19 @@ func TestCommandsCoverOpenAPICatalog(t *testing.T) { slices.Sort(unbound) missing := make([]string, 0) + exposedUnsupported := make([]string, 0) for _, operation := range apicommands.Operations { - if _, ok := commandsByOperation[operation.ID]; !ok { + paths, ok := commandsByOperation[operation.ID] + if operation.Unsupported { + exposedUnsupported = append(exposedUnsupported, paths...) + continue + } + if !ok { missing = append(missing, operation.Client+"."+operation.SDKMethod+" ("+operation.ID+")") } } slices.Sort(missing) + slices.Sort(exposedUnsupported) duplicates := make([]string, 0) for operationID, paths := range commandsByOperation { @@ -86,6 +97,7 @@ func TestCommandsCoverOpenAPICatalog(t *testing.T) { require.Empty(t, unbound, "API commands without an OpenAPI operation binding") require.Empty(t, duplicates, "OpenAPI operations exposed by more than one CLI command") + require.Empty(t, exposedUnsupported, "unsupported OpenAPI operations exposed by a CLI command") assert.Empty(t, missing, "SDK operations without a CLI command") }