Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions internal/apicommands/apicommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -37,6 +37,7 @@ type Operation struct {
Path string
Summary string
Description string
Unsupported bool
Parameters []Parameter
RequestBody *RequestBody
}
Expand All @@ -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)
}
Expand Down
14 changes: 14 additions & 0 deletions internal/apicommands/catalog.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 75 additions & 2 deletions internal/cmd/generate-operations/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,6 +87,7 @@ type operation struct {
Path string
Summary string
Description string
Unsupported bool
Parameters []parameter
RequestBody *requestBody
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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, &parameters); 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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions internal/cmd/generate-operations/main_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"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()

Expand Down
18 changes: 11 additions & 7 deletions internal/codesamples/codesamples.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 != "" {
Expand Down
4 changes: 4 additions & 0 deletions internal/codesamples/codesamples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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])
Expand Down
16 changes: 14 additions & 2 deletions internal/commands/operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand All @@ -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")
}

Expand Down