diff --git a/internal/codesamples/codesamples.go b/internal/codesamples/codesamples.go index cc11a83..f6a1f06 100644 --- a/internal/codesamples/codesamples.go +++ b/internal/codesamples/codesamples.go @@ -14,6 +14,7 @@ import ( "github.com/sumup/sumup-cli/internal/apicommands" "github.com/sumup/sumup-cli/internal/commands" + "github.com/sumup/sumup-cli/internal/currency" ) const ( @@ -69,56 +70,6 @@ type sampleFlag struct { var argumentPattern = regexp.MustCompile(`[<\[]([a-z0-9-]+)[>\]]`) -// optionalSampleFlags adds a representative field where an otherwise valid -// command would not show what a create or update request changes. -var optionalSampleFlags = map[string]map[string]string{ - "checkouts update": { - "description": "Updated order", - }, - "customers create": { - "email": "customer@example.com", - }, - "customers update": { - "email": "updated-customer@example.com", - }, - "members update": { - "role": "role_employee", - }, - "roles update": { - "name": "Payment reviewer", - }, -} - -var flagSampleValues = map[string]string{ - "amount": "10.00", - "client-transaction-id": "19e12390-72cf-4f9f-80b5-b0c8a67fa43f", - "context": "example.com", - "currency": "EUR", - "email": "member@example.com", - "end-date": "2026-01-31", - "merchant-code": "$SUMUP_MERCHANT_CODE", - "name": "Example", - "pairing-code": "4WLFDSBF", - "password": "$MEMBER_PASSWORD", - "payment-type": "card", - "permission": "members_access", - "reference": "order-123", - "role": "role_employee", - "start-date": "2026-01-01", - "target": "https://apple-pay-gateway-cert.apple.com/paymentservices/startSession", -} - -var argumentSampleValues = map[string]string{ - "checkout-id": "$CHECKOUT_ID", - "customer-id": "$CUSTOMER_ID", - "member-id": "$MEMBER_ID", - "person-id": "$PERSON_ID", - "reader-id": "$READER_ID", - "role-id": "$ROLE_ID", - "token": "$PAYMENT_INSTRUMENT_TOKEN", - "transaction-id": "$TRANSACTION_ID", -} - // Generate builds a deterministic sample catalog for the current CLI command // tree and generated OpenAPI operation catalog. func Generate(cliVersion string) (*Catalog, error) { @@ -127,6 +78,11 @@ func Generate(cliVersion string) (*Catalog, error) { return nil, errors.New("cli version is required") } + spec, err := loadPinnedSpec() + if err != nil { + return nil, err + } + commandsByOperation := boundCommandsByOperation(commands.All()) samples := make([]Sample, 0, len(apicommands.Operations)) for _, operation := range apicommands.Operations { @@ -134,15 +90,25 @@ func Generate(cliVersion string) (*Catalog, error) { if err != nil { return nil, err } - source, err := renderCommand(command) + 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) } + summary := operation.Summary + if example.summary != "" { + summary = example.summary + } + description := operation.Description + if example.description != "" { + description = example.description + } samples = append(samples, Sample{ ID: operation.ID, OperationID: operation.ID, - Summary: operation.Summary, - Description: operation.Description, + Example: example.name, + Summary: summary, + Description: description, HTTPMethod: operation.HTTPMethod, Path: operation.Path, Source: source, @@ -205,51 +171,160 @@ func commandForOperation(operationID string, candidates []boundCommand) (boundCo return boundCommand{}, fmt.Errorf("OpenAPI operation %q has multiple CLI commands (%s)", operationID, strings.Join(paths, ", ")) } -func renderCommand(bound boundCommand) (string, error) { - invocation, err := buildInvocation(bound) +func renderCommand(document *openAPIDocument, bound boundCommand, example operationExample) (string, error) { + invocation, err := buildInvocation(document, bound, example) if err != nil { return "", err } return invocation.source(), nil } -func buildInvocation(bound boundCommand) (commandInvocation, error) { +func buildInvocation(document *openAPIDocument, bound boundCommand, example operationExample) (commandInvocation, error) { invocation := commandInvocation{path: strings.Fields(bound.path)} for _, match := range argumentPattern.FindAllStringSubmatch(bound.command.ArgsUsage, -1) { - value, ok := argumentSampleValues[match[1]] + value, ok := argumentValue(document, example, match[1]) if !ok { return commandInvocation{}, fmt.Errorf("no sample value for argument %q", match[1]) } invocation.arguments = append(invocation.arguments, value) } + flattened := flattenExample(example.body) for _, flag := range bound.command.Flags { name := flag.Names()[0] - value, optional := optionalSampleFlags[bound.path][name] - required := false - if requirement, ok := flag.(interface{ IsRequired() bool }); ok { - required = requirement.IsRequired() + required := flagRequired(flag) + _, inBody := lookupExample(name, flattened) + allowSchema := required || name == "merchant-code" || !example.bodyProvided + if !required && name != "merchant-code" && !inBody && example.bodyProvided { + continue } - if !required && name != "merchant-code" && !optional { + + values, ok := flagValues(document, example, flattened, name, required || name == "merchant-code", allowSchema) + if !ok { + if required { + return commandInvocation{}, fmt.Errorf("no sample value for flag --%s", name) + } continue } - if boolean, ok := flag.(interface{ IsBoolFlag() bool }); ok && boolean.IsBoolFlag() { + boolean := flagBoolean(flag) + if boolean { + if len(values) == 0 { + continue + } invocation.flags = append(invocation.flags, sampleFlag{name: name, boolean: true}) continue } - if !optional { - var ok bool - value, ok = flagSampleValues[name] - if !ok { - return commandInvocation{}, fmt.Errorf("no sample value for flag --%s", name) - } + for _, value := range values { + invocation.flags = append(invocation.flags, sampleFlag{name: name, value: value}) } - invocation.flags = append(invocation.flags, sampleFlag{name: name, value: value}) } return invocation, nil } +func argumentValue(document *openAPIDocument, example operationExample, name string) (string, bool) { + if value, ok := example.parameterExample(document, name); ok { + formatted := formatExampleValue(value) + if len(formatted) > 0 { + return formatted[0], true + } + } + parameter := example.parameter(name) + if parameter != nil { + if fallback := document.schemaFallback(parameter.Schema); fallback != nil { + formatted := formatExampleValue(fallback) + if len(formatted) > 0 { + return formatted[0], true + } + } + } + return "", false +} + +func flagValues(document *openAPIDocument, example operationExample, flattened map[string]any, name string, fromParameters, allowSchema bool) ([]string, bool) { + if value, ok := lookupExample(name, flattened); ok { + formatted := formatExampleValue(value) + if name == "currency" { + if supported, ok := supportedCurrencyValue(document, formatted); ok { + return []string{supported}, true + } + } else if len(formatted) > 0 { + return formatted, true + } + } + if fromParameters { + if value, ok := example.parameterExample(document, name); ok { + formatted := formatExampleValue(value) + if len(formatted) > 0 { + return formatted, true + } + } + } + if allowSchema { + if property := document.propertySchema(example.bodySchema, name); property != nil { + if value, ok := document.schemaExample(property, nil); ok { + formatted := formatExampleValue(value) + if len(formatted) > 0 { + return formatted, true + } + } + if fromParameters { + if fallback := document.schemaFallback(property); fallback != nil { + formatted := formatExampleValue(fallback) + if len(formatted) > 0 { + return formatted, true + } + } + } + } + } + if fromParameters { + parameter := example.parameter(name) + if parameter != nil { + if fallback := document.schemaFallback(parameter.Schema); fallback != nil { + formatted := formatExampleValue(fallback) + if len(formatted) > 0 { + return formatted, true + } + } + } + } + return nil, false +} + +func supportedCurrencyValue(document *openAPIDocument, formatted []string) (string, bool) { + if len(formatted) == 1 { + if _, err := currency.Parse(formatted[0]); err == nil { + return formatted[0], true + } + } + if document.Components.Schemas == nil { + return "", false + } + value, ok := document.schemaExample(document.Components.Schemas["Currency"], nil) + if !ok { + return "", false + } + text, ok := value.(string) + if !ok { + return "", false + } + if _, err := currency.Parse(text); err != nil { + return "", false + } + return text, true +} + +func flagRequired(flag cli.Flag) bool { + requirement, ok := flag.(interface{ IsRequired() bool }) + return ok && requirement.IsRequired() +} + +func flagBoolean(flag cli.Flag) bool { + boolean, ok := flag.(interface{ IsBoolFlag() bool }) + return ok && boolean.IsBoolFlag() +} + func (invocation commandInvocation) source() string { words := append([]string{"sumup"}, invocation.path...) for _, argument := range invocation.arguments { diff --git a/internal/codesamples/codesamples_test.go b/internal/codesamples/codesamples_test.go index 7a20852..c26fafc 100644 --- a/internal/codesamples/codesamples_test.go +++ b/internal/codesamples/codesamples_test.go @@ -55,16 +55,21 @@ func TestGenerate(t *testing.T) { assert.NoError(t, err, "sample %q is not valid shell syntax:\n%s\n%s", sample.ID, sample.Source, output) } + createCheckout := sampleByID(t, catalog.Samples, "CreateCheckout") + assert.Equal(t, "Checkout", createCheckout.Example) assert.Equal(t, `sumup checkouts create \ - --reference "order-123" \ - --amount "10.00" \ + --reference "f00a8f74-b05d-4605-bd73-2a901bae5802" \ + --amount "10.1" \ --currency "EUR" \ - --merchant-code "$SUMUP_MERCHANT_CODE" -`, sampleByID(t, catalog.Samples, "CreateCheckout").Source) - assert.Equal(t, `sumup merchants persons get "$PERSON_ID" \ - --merchant-code "$SUMUP_MERCHANT_CODE" + --merchant-code "MH4H92C7" \ + --description "Purchase" \ + --redirect-url "https://sumup.com" \ + --valid-until "2020-02-29T10:56:56+00:00" +`, createCheckout.Source) + assert.Equal(t, `sumup merchants persons get "pers_5AKFHN2KSK8D3TS79DJE3P3A2Z" \ + --merchant-code "MK10CL2A" `, sampleByID(t, catalog.Samples, "GetPerson").Source) - assert.Contains(t, sampleByID(t, catalog.Samples, "UpdateCheckout").Source, `--description "Updated order"`) + assert.Contains(t, sampleByID(t, catalog.Samples, "UpdateCheckout").Source, `--description "Updated purchase"`) 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") @@ -105,7 +110,9 @@ func TestGeneratedInvocationsReachAPITransport(t *testing.T) { commandsByOperation := boundCommandsByOperation(resourceCommands) bound, err := commandForOperation(operation.ID, commandsByOperation[operation.ID]) require.NoError(t, err) - invocation, err := buildInvocation(bound) + spec, err := loadPinnedSpec() + require.NoError(t, err) + invocation, err := buildInvocation(spec, bound, spec.exampleFor(operation.HTTPMethod, operation.Path)) require.NoError(t, err) transportError := errors.New("sample reached API transport") diff --git a/internal/codesamples/spec.go b/internal/codesamples/spec.go new file mode 100644 index 0000000..4bffddf --- /dev/null +++ b/internal/codesamples/spec.go @@ -0,0 +1,559 @@ +package codesamples + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +const sdkModule = "github.com/sumup/sumup-go" + +type openAPIDocument struct { + Paths map[string]*pathItem `json:"paths"` + Components struct { + Schemas map[string]*specSchema `json:"schemas"` + Parameters map[string]*parameter `json:"parameters"` + } `json:"components"` +} + +type pathItem struct { + Parameters []parameter `json:"parameters"` + Delete *operation `json:"delete"` + Get *operation `json:"get"` + Patch *operation `json:"patch"` + Post *operation `json:"post"` + Put *operation `json:"put"` +} + +type operation struct { + Parameters []parameter `json:"parameters"` + RequestBody *requestBody `json:"requestBody"` +} + +type requestBody struct { + Content map[string]*mediaType `json:"content"` +} + +type mediaType struct { + Schema *specSchema `json:"schema"` + Example json.RawMessage `json:"example"` + Examples map[string]*namedExample `json:"examples"` +} + +type namedExample struct { + Summary string `json:"summary"` + Description string `json:"description"` + Value json.RawMessage `json:"value"` +} + +type parameter struct { + Ref string `json:"$ref"` + Name string `json:"name"` + Location string `json:"in"` + Required bool `json:"required"` + Example json.RawMessage `json:"example"` + Examples map[string]*namedExample `json:"examples"` + Schema *specSchema `json:"schema"` +} + +type specSchema struct { + Ref string `json:"$ref"` + Type json.RawMessage `json:"type"` + Format string `json:"format"` + Example json.RawMessage `json:"example"` + Examples []json.RawMessage `json:"examples"` + Default json.RawMessage `json:"default"` + Enum []json.RawMessage `json:"enum"` + Properties map[string]*specSchema `json:"properties"` + Items *specSchema `json:"items"` + AllOf []*specSchema `json:"allOf"` + OneOf []*specSchema `json:"oneOf"` + AnyOf []*specSchema `json:"anyOf"` +} + +type moduleInfo struct { + Path string + Version string + Dir string + Replace *moduleInfo +} + +type operationExample struct { + name string + summary string + description string + body map[string]any + bodyProvided bool + bodySchema *specSchema + parameters []*parameter +} + +func loadPinnedSpec() (*openAPIDocument, error) { + command := exec.Command("go", "list", "-m", "-json", sdkModule) + output, err := command.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil, fmt.Errorf("resolve pinned SDK module: %s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("resolve pinned SDK module: %w", err) + } + + var module moduleInfo + if err := json.Unmarshal(output, &module); err != nil { + return nil, fmt.Errorf("decode pinned SDK module: %w", err) + } + moduleDir := module.Dir + if module.Replace != nil { + moduleDir = module.Replace.Dir + } + if moduleDir == "" { + return nil, errors.New("pinned SDK module has no resolved directory") + } + + return parseSpecFile(filepath.Join(moduleDir, "openapi.json")) +} + +func parseSpecFile(path string) (*openAPIDocument, error) { + spec, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read OpenAPI document: %w", err) + } + return parseSpec(spec) +} + +func parseSpec(spec []byte) (*openAPIDocument, error) { + var document openAPIDocument + if err := json.Unmarshal(spec, &document); err != nil { + return nil, fmt.Errorf("decode OpenAPI document: %w", err) + } + return &document, nil +} + +func (document *openAPIDocument) exampleFor(httpMethod, apiPath string) operationExample { + item := document.Paths[apiPath] + if item == nil { + return operationExample{} + } + + source := item.operation(httpMethod) + parameters := document.resolveParameters(append(slices.Clone(item.Parameters), operationParameters(source)...)) + example := operationExample{parameters: parameters} + if source == nil || source.RequestBody == nil { + return example + } + + mediaType, ok := jsonMediaType(source.RequestBody.Content) + if !ok || mediaType == nil { + return example + } + example.bodySchema = mediaType.Schema + + if len(mediaType.Examples) > 0 { + names := make([]string, 0, len(mediaType.Examples)) + for name := range mediaType.Examples { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + named := mediaType.Examples[name] + if named == nil { + continue + } + value, provided := decodeRaw(named.Value) + if !provided { + continue + } + example.name = name + example.summary = strings.TrimSpace(named.Summary) + example.description = strings.TrimSpace(named.Description) + example.body, example.bodyProvided = objectValue(value) + return example + } + } + + if value, provided := decodeRaw(mediaType.Example); provided { + example.body, example.bodyProvided = objectValue(value) + return example + } + if value, provided := document.schemaExample(mediaType.Schema, nil); provided { + example.body, example.bodyProvided = objectValue(value) + if example.bodyProvided { + return example + } + } + example.body = document.objectFromSchema(mediaType.Schema, nil) + return example +} + +func (item *pathItem) operation(httpMethod string) *operation { + if item == nil { + return nil + } + switch strings.ToUpper(httpMethod) { + case "DELETE": + return item.Delete + case "GET": + return item.Get + case "PATCH": + return item.Patch + case "POST": + return item.Post + case "PUT": + return item.Put + default: + return nil + } +} + +func operationParameters(source *operation) []parameter { + if source == nil { + return nil + } + return source.Parameters +} + +func jsonMediaType(content map[string]*mediaType) (*mediaType, bool) { + if content == nil { + return nil, false + } + if mediaType, ok := content["application/json"]; ok { + return mediaType, true + } + return nil, false +} + +func (document *openAPIDocument) resolveParameters(parameters []parameter) []*parameter { + resolved := make([]*parameter, 0, len(parameters)) + for i := range parameters { + parameter := document.resolveParameter(¶meters[i], nil) + if parameter != nil { + resolved = append(resolved, parameter) + } + } + return resolved +} + +func (document *openAPIDocument) resolveParameter(parameter *parameter, seen map[string]struct{}) *parameter { + if parameter == nil { + return nil + } + if parameter.Ref == "" { + return parameter + } + name := componentName(parameter.Ref) + if name == "" || document.Components.Parameters == nil { + return parameter + } + if seen == nil { + seen = make(map[string]struct{}) + } + if _, ok := seen[parameter.Ref]; ok { + return parameter + } + seen[parameter.Ref] = struct{}{} + target, ok := document.Components.Parameters[name] + if !ok { + return parameter + } + return document.resolveParameter(target, seen) +} + +func (document *openAPIDocument) resolveSchema(schema *specSchema, seen map[string]struct{}) *specSchema { + if schema == nil { + return nil + } + if schema.Ref == "" { + return schema + } + name := componentName(schema.Ref) + if name == "" || document.Components.Schemas == nil { + return schema + } + if seen == nil { + seen = make(map[string]struct{}) + } + if _, ok := seen[schema.Ref]; ok { + return schema + } + seen[schema.Ref] = struct{}{} + target, ok := document.Components.Schemas[name] + if !ok { + return schema + } + return document.resolveSchema(target, seen) +} + +func (document *openAPIDocument) schemaExample(schema *specSchema, seen map[string]struct{}) (any, bool) { + schema = document.resolveSchema(schema, seen) + if schema == nil { + return nil, false + } + if value, ok := decodeRaw(schema.Example); ok { + return value, true + } + for _, example := range schema.Examples { + if value, ok := decodeRaw(example); ok { + return value, true + } + } + if value, ok := decodeRaw(schema.Default); ok { + return value, true + } + if len(schema.Enum) > 0 { + return decodeRaw(schema.Enum[0]) + } + for _, candidate := range slices.Concat(schema.AllOf, schema.OneOf, schema.AnyOf) { + if value, ok := document.schemaExample(candidate, seen); ok { + return value, true + } + } + return nil, false +} + +func (document *openAPIDocument) schemaFallback(schema *specSchema) any { + schema = document.resolveSchema(schema, nil) + if schema == nil { + return nil + } + if value, ok := document.schemaExample(schema, nil); ok { + return value + } + types := schema.types() + switch { + case slices.Contains(types, "string"): + switch schema.Format { + case "date-time": + return "2025-01-01T00:00:00Z" + case "date": + return "2025-01-01" + case "time": + return "12:00:00" + case "email": + return "developer@example.com" + case "uri", "url": + return "https://example.com" + case "uuid": + return "00000000-0000-4000-8000-000000000000" + case "hostname": + return "example.com" + case "password": + return "secret" + default: + return "string" + } + case slices.Contains(types, "integer"): + return 1 + case slices.Contains(types, "number"): + return 1.0 + case slices.Contains(types, "boolean"): + return true + case slices.Contains(types, "array"): + if item, ok := document.schemaExample(schema.Items, nil); ok { + return []any{item} + } + if fallback := document.schemaFallback(schema.Items); fallback != nil { + return []any{fallback} + } + } + return nil +} + +func (schema *specSchema) types() []string { + if schema == nil || len(schema.Type) == 0 { + return nil + } + var value any + if err := json.Unmarshal(schema.Type, &value); err != nil { + return nil + } + switch typed := value.(type) { + case string: + return []string{typed} + case []any: + types := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if ok { + types = append(types, text) + } + } + return types + default: + return nil + } +} + +func (document *openAPIDocument) objectFromSchema(schema *specSchema, seen map[string]struct{}) map[string]any { + schema = document.resolveSchema(schema, seen) + if schema == nil { + return nil + } + if value, ok := document.schemaExample(schema, seen); ok { + if object, ok := objectValue(value); ok { + return object + } + } + + result := make(map[string]any) + for name, property := range document.directProperties(schema, seen) { + if value, ok := document.schemaExample(property, seen); ok { + result[name] = value + continue + } + if nested := document.objectFromSchema(property, seen); len(nested) > 0 { + result[name] = nested + } + } + if len(result) == 0 { + return nil + } + return result +} + +func (document *openAPIDocument) directProperties(schema *specSchema, seen map[string]struct{}) map[string]*specSchema { + schema = document.resolveSchema(schema, seen) + if schema == nil { + return nil + } + result := make(map[string]*specSchema) + for _, candidate := range slices.Concat([]*specSchema{schema}, schema.AllOf, schema.OneOf, schema.AnyOf) { + resolved := document.resolveSchema(candidate, seen) + if resolved == nil { + continue + } + for name, property := range resolved.Properties { + result[name] = property + } + } + return result +} + +func (document *openAPIDocument) propertySchema(schema *specSchema, flag string) *specSchema { + properties := document.properties(schema, nil) + if len(properties) == 0 { + return nil + } + paths := make([][]string, 0, len(properties)) + for path := range properties { + paths = append(paths, strings.Split(path, ".")) + } + matched, ok := bestPath(flag, paths) + if !ok { + return nil + } + return properties[strings.Join(matched, ".")] +} + +func (document *openAPIDocument) properties(schema *specSchema, seen map[string]struct{}) map[string]*specSchema { + schema = document.resolveSchema(schema, seen) + if schema == nil { + return nil + } + if seen == nil { + seen = make(map[string]struct{}) + } + result := make(map[string]*specSchema) + for _, candidate := range slices.Concat([]*specSchema{schema}, schema.AllOf, schema.OneOf, schema.AnyOf) { + resolved := document.resolveSchema(candidate, seen) + if resolved == nil { + continue + } + for name, property := range resolved.Properties { + result[name] = property + for nestedName, nested := range document.properties(property, seen) { + result[name+"."+nestedName] = nested + } + } + } + return result +} + +func (example operationExample) parameter(name string) *parameter { + want := normalizeName(name) + var best *parameter + bestScore := 0 + for _, parameter := range example.parameters { + got := normalizeName(parameter.Name) + score := 0 + switch { + case got == want: + score = 400 + case got == want+"s", want == got+"s": + score = 300 + case strings.HasSuffix(want, "_"+got): + score = 200 - len(got) + case strings.HasSuffix(got, "_"+want): + score = 100 - len(got) + } + if score > bestScore { + bestScore = score + best = parameter + } + } + return best +} + +func (example operationExample) parameterExample(document *openAPIDocument, name string) (any, bool) { + parameter := example.parameter(name) + if parameter == nil { + return nil, false + } + if value, ok := decodeRaw(parameter.Example); ok { + return value, true + } + if parameter.Examples != nil { + names := make([]string, 0, len(parameter.Examples)) + for exampleName := range parameter.Examples { + names = append(names, exampleName) + } + slices.Sort(names) + for _, exampleName := range names { + named := parameter.Examples[exampleName] + if named == nil { + continue + } + if value, ok := decodeRaw(named.Value); ok { + return value, true + } + } + } + return document.schemaExample(parameter.Schema, nil) +} + +func decodeRaw(raw json.RawMessage) (any, bool) { + if len(raw) == 0 { + return nil, false + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, false + } + return value, true +} + +func objectValue(value any) (map[string]any, bool) { + object, ok := value.(map[string]any) + if !ok { + return nil, false + } + return object, true +} + +func componentName(ref string) string { + parts := strings.Split(ref, "/") + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} + +func normalizeName(name string) string { + name = strings.TrimSuffix(name, "[]") + return strings.ReplaceAll(name, "-", "_") +} diff --git a/internal/codesamples/spec_test.go b/internal/codesamples/spec_test.go new file mode 100644 index 0000000..90ce579 --- /dev/null +++ b/internal/codesamples/spec_test.go @@ -0,0 +1,145 @@ +package codesamples + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSchemaExampleFollowsRefChain(t *testing.T) { + t.Parallel() + + document, err := parseSpec([]byte(`{ + "components": { + "schemas": { + "Currency": {"type": "string", "enum": ["BGN", "EUR"], "example": "EUR"}, + "Amount": { + "allOf": [ + {"$ref": "#/components/schemas/Currency"} + ] + } + } + } + }`)) + require.NoError(t, err) + + value, ok := document.schemaExample(&specSchema{Ref: "#/components/schemas/Amount"}, nil) + + require.True(t, ok) + assert.Equal(t, "EUR", value) +} + +func TestExampleForPrefersFirstNamedRequestExample(t *testing.T) { + t.Parallel() + + document, err := parseSpec([]byte(`{ + "paths": { + "/widgets": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Widget"}, + "examples": { + "Zulu": {"value": {"name": "zulu"}}, + "Alpha": {"value": {"name": "alpha", "color": "blue"}} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Widget": { + "type": "object", + "properties": { + "name": {"type": "string", "example": "property-name"}, + "color": {"type": "string", "example": "property-color"} + } + } + } + } + }`)) + require.NoError(t, err) + + example := document.exampleFor("POST", "/widgets") + + assert.Equal(t, "Alpha", example.name) + assert.Equal(t, map[string]any{"name": "alpha", "color": "blue"}, example.body) + assert.True(t, example.bodyProvided) +} + +func TestExampleForWalksPropertyExamplesWhenRequestExampleIsMissing(t *testing.T) { + t.Parallel() + + document, err := parseSpec([]byte(`{ + "paths": { + "/widgets": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Widget"} + } + } + } + } + } + }, + "components": { + "schemas": { + "Widget": { + "type": "object", + "properties": { + "name": {"type": "string", "example": "Widget"}, + "nested": {"$ref": "#/components/schemas/Details"} + } + }, + "Details": { + "type": "object", + "properties": { + "email": {"type": "string", "example": "user@example.com"} + } + } + } + } + }`)) + require.NoError(t, err) + + example := document.exampleFor("POST", "/widgets") + + assert.False(t, example.bodyProvided) + assert.Equal(t, map[string]any{ + "name": "Widget", + "nested": map[string]any{"email": "user@example.com"}, + }, example.body) +} + +func TestLookupExampleMapsCLIFlagsOntoNestedProperties(t *testing.T) { + t.Parallel() + + values := flattenExample(map[string]any{ + "checkout_reference": "ref-1", + "personal_details": map[string]any{ + "email": "user@example.com", + "address": map[string]any{ + "line_1": "Sample street", + }, + }, + }) + + reference, ok := lookupExample("reference", values) + require.True(t, ok) + assert.Equal(t, "ref-1", reference) + + email, ok := lookupExample("email", values) + require.True(t, ok) + assert.Equal(t, "user@example.com", email) + + line, ok := lookupExample("address-line-1", values) + require.True(t, ok) + assert.Equal(t, "Sample street", line) +} diff --git a/internal/codesamples/values.go b/internal/codesamples/values.go new file mode 100644 index 0000000..caeb715 --- /dev/null +++ b/internal/codesamples/values.go @@ -0,0 +1,234 @@ +package codesamples + +import ( + "math" + "strconv" + "strings" +) + +func flattenExample(value map[string]any) map[string]any { + result := make(map[string]any) + var walk func(prefix string, current any) + walk = func(prefix string, current any) { + if prefix != "" { + result[prefix] = current + } + object, ok := current.(map[string]any) + if !ok { + return + } + for key, nested := range object { + path := key + if prefix != "" { + path = prefix + "." + key + } + walk(path, nested) + } + } + walk("", value) + return result +} + +func lookupExample(flag string, values map[string]any) (any, bool) { + if flag == "amount" { + if value, ok := values["amount"]; ok { + return value, true + } + if amount, ok := majorAmount(values); ok { + return amount, true + } + } + if flag == "tip-amount" { + return convertedTipAmount(values) + } + if len(values) == 0 { + return nil, false + } + paths := make([][]string, 0, len(values)) + for path := range values { + paths = append(paths, strings.Split(path, ".")) + } + matched, ok := bestPath(flag, paths) + if !ok { + return nil, false + } + return values[strings.Join(matched, ".")], true +} + +func bestPath(flag string, paths [][]string) ([]string, bool) { + want := strings.ReplaceAll(flag, "-", "_") + wantPlural := want + if !strings.HasSuffix(want, "s") { + wantPlural = want + "s" + } + + var best []string + bestScore := 0 + for _, path := range paths { + for _, candidate := range pathCandidates(path) { + score := 0 + switch candidate { + case want: + score = 300 + 10 - len(path) + case wantPlural: + score = 200 + 10 - len(path) + default: + if strings.HasSuffix(candidate, "_"+want) || strings.HasSuffix(candidate, "_"+wantPlural) { + score = 100 + 10 - len(path) + } + } + if score > bestScore { + bestScore = score + best = path + } + } + } + if bestScore == 0 { + return nil, false + } + return best, true +} + +func pathCandidates(path []string) []string { + joined := strings.Join(path, "_") + candidates := []string{joined} + for i := 1; i < len(path); i++ { + candidates = append(candidates, strings.Join(path[i:], "_")) + } + if len(path) > 0 { + candidates = append(candidates, path[len(path)-1]) + } + return candidates +} + +func majorAmount(values map[string]any) (string, bool) { + total, ok := values["total_amount"].(map[string]any) + if !ok { + return "", false + } + major, ok := convertMinorUnits(total["value"], minorUnit(values, total)) + if !ok { + return "", false + } + return major, true +} + +func convertedTipAmount(values map[string]any) (any, bool) { + raw, ok := values["tip_amount"] + if !ok { + return nil, false + } + total, _ := values["total_amount"].(map[string]any) + major, ok := convertMinorUnits(raw, minorUnit(values, total)) + if !ok { + return raw, true + } + return major, true +} + +func minorUnit(values map[string]any, total map[string]any) int { + for _, raw := range []any{values["minor_unit"], values["total_amount.minor_unit"]} { + if unit, ok := intValue(raw); ok { + return unit + } + } + if total != nil { + if unit, ok := intValue(total["minor_unit"]); ok { + return unit + } + } + return 2 +} + +func convertMinorUnits(raw any, unit int) (string, bool) { + amount, ok := floatValue(raw) + if !ok { + return "", false + } + if unit < 0 { + unit = 0 + } + major := amount / math.Pow10(unit) + return strconv.FormatFloat(major, 'f', -1, 64), true +} + +func floatValue(raw any) (float64, bool) { + switch value := raw.(type) { + case float64: + return value, true + case int: + return float64(value), true + case int64: + return float64(value), true + default: + return 0, false + } +} + +func intValue(raw any) (int, bool) { + switch value := raw.(type) { + case float64: + return int(value), true + case int: + return value, true + case int64: + return int(value), true + default: + return 0, false + } +} + +func formatExampleValue(value any) []string { + switch typed := value.(type) { + case nil: + return nil + case bool: + if !typed { + return nil + } + return []string{""} + case map[string]any: + if enabled, ok := typed["enabled"].(bool); ok && enabled { + return []string{""} + } + return nil + case []any: + values := make([]string, 0, len(typed)) + for _, item := range typed { + values = append(values, formatExampleValue(item)...) + } + return values + case float64: + if typed == float64(int64(typed)) { + return []string{strconv.FormatInt(int64(typed), 10)} + } + return []string{strconv.FormatFloat(typed, 'f', -1, 64)} + case string: + if typed == "" { + return nil + } + return []string{typed} + default: + text := strings.TrimSpace(stringify(typed)) + if text == "" { + return nil + } + return []string{text} + } +} + +func stringify(value any) string { + switch typed := value.(type) { + case string: + return typed + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + case bool: + return strconv.FormatBool(typed) + default: + return "" + } +}