← back to Cli Printing Press
feat(openapi): add OpenAPI 3.0+ parser with kin-openapi
e81fc8723ae8d1f490412dc49777f8dfb4e600ec · 2026-03-23 10:28:32 -0700 · Matt Van Horn
Maps OpenAPI info/servers/security/paths/schemas to internal APISpec
format. Handles $ref resolution, allOf merging, path-level params,
resource grouping by first path segment, and operation name derivation.
Limits to 50 resources and 20 endpoints per resource.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Files touched
M go.modM go.sumA internal/openapi/detect.goA internal/openapi/parser.goA internal/openapi/parser_test.go
Diff
commit e81fc8723ae8d1f490412dc49777f8dfb4e600ec
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Mon Mar 23 10:28:32 2026 -0700
feat(openapi): add OpenAPI 3.0+ parser with kin-openapi
Maps OpenAPI info/servers/security/paths/schemas to internal APISpec
format. Handles $ref resolution, allOf merging, path-level params,
resource grouping by first path segment, and operation name derivation.
Limits to 50 resources and 20 endpoints per resource.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
go.mod | 10 +
go.sum | 20 +
internal/openapi/detect.go | 15 +
internal/openapi/parser.go | 1057 +++++++++++++++++++++++++++++++++++++++
internal/openapi/parser_test.go | 63 +++
5 files changed, 1165 insertions(+)
diff --git a/go.mod b/go.mod
index 1fd7224f..cb8d3133 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,17 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/getkin/kin-openapi v0.133.0 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
+ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
+ github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
+ github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ github.com/woodsbury/decimal128 v1.3.0 // indirect
)
diff --git a/go.sum b/go.sum
index 5352f4f3..8e2e2b25 100644
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,26 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
+github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
+github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -12,6 +30,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
+github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/openapi/detect.go b/internal/openapi/detect.go
new file mode 100644
index 00000000..4045233d
--- /dev/null
+++ b/internal/openapi/detect.go
@@ -0,0 +1,15 @@
+package openapi
+
+import "strings"
+
+func IsOpenAPI(data []byte) bool {
+ if len(data) == 0 {
+ return false
+ }
+ if len(data) > 500 {
+ data = data[:500]
+ }
+
+ content := strings.ToLower(string(data))
+ return strings.Contains(content, "openapi:") || strings.Contains(content, "\"openapi\"")
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
new file mode 100644
index 00000000..f56d3c46
--- /dev/null
+++ b/internal/openapi/parser.go
@@ -0,0 +1,1057 @@
+package openapi
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "github.com/getkin/kin-openapi/openapi3"
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+const (
+ maxResources = 50
+ maxEndpointsPerResource = 20
+)
+
+func Parse(data []byte) (*spec.APISpec, error) {
+ loader := openapi3.NewLoader()
+ doc, err := loader.LoadFromData(data)
+ if err != nil {
+ return nil, fmt.Errorf("loading OpenAPI spec: %w", err)
+ }
+
+ doc.InternalizeRefs(context.Background(), nil)
+
+ name := "api"
+ description := ""
+ version := ""
+ if doc.Info != nil {
+ if v := toKebabCase(doc.Info.Title); v != "" {
+ name = v
+ }
+ description = strings.TrimSpace(doc.Info.Description)
+ version = strings.TrimSpace(doc.Info.Version)
+ }
+
+ baseURL := ""
+ if len(doc.Servers) > 0 && doc.Servers[0] != nil {
+ baseURL = strings.TrimRight(strings.TrimSpace(doc.Servers[0].URL), "/")
+ }
+
+ result := &spec.APISpec{
+ Name: name,
+ Description: description,
+ Version: version,
+ BaseURL: baseURL,
+ Auth: mapAuth(doc, name),
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: fmt.Sprintf("~/.config/%s-cli/config.toml", name),
+ },
+ Resources: map[string]spec.Resource{},
+ Types: map[string]spec.TypeDef{},
+ }
+
+ mapResources(doc, result, baseURLPath(baseURL))
+ mapTypes(doc, result)
+
+ if err := result.Validate(); err != nil {
+ return nil, fmt.Errorf("validating parsed spec: %w", err)
+ }
+
+ return result, nil
+}
+
+func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
+ auth := spec.AuthConfig{Type: "none"}
+ schemeName, scheme := selectSecurityScheme(doc)
+ if scheme == nil {
+ return auth
+ }
+
+ auth.Scheme = schemeName
+
+ switch strings.ToLower(scheme.Type) {
+ case "http":
+ switch strings.ToLower(scheme.Scheme) {
+ case "bearer":
+ auth.Type = "bearer_token"
+ auth.Header = "Authorization"
+ case "basic":
+ auth.Type = "api_key"
+ auth.Header = "Authorization"
+ auth.Format = "Basic {username}:{password}"
+ }
+ case "apikey":
+ auth.Type = "api_key"
+ auth.Header = strings.TrimSpace(scheme.Name)
+ if auth.Header == "" {
+ auth.Header = "Authorization"
+ }
+ auth.In = strings.TrimSpace(scheme.In)
+ case "oauth2":
+ auth.Type = "bearer_token"
+ auth.Header = "Authorization"
+ }
+
+ envPrefix := strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+ switch auth.Type {
+ case "api_key":
+ auth.EnvVars = []string{envPrefix + "_API_KEY"}
+ case "bearer_token":
+ auth.EnvVars = []string{envPrefix + "_TOKEN"}
+ }
+
+ return auth
+}
+
+func selectSecurityScheme(doc *openapi3.T) (string, *openapi3.SecurityScheme) {
+ if doc == nil || doc.Components == nil || len(doc.Components.SecuritySchemes) == 0 {
+ return "", nil
+ }
+
+ orderedNames := orderedSecuritySchemeNames(doc)
+ for _, name := range orderedNames {
+ scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
+ if scheme == nil {
+ continue
+ }
+ switch strings.ToLower(scheme.Type) {
+ case "apikey", "oauth2":
+ return name, scheme
+ case "http":
+ switch strings.ToLower(scheme.Scheme) {
+ case "bearer", "basic":
+ return name, scheme
+ }
+ }
+ }
+
+ for _, name := range orderedNames {
+ scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
+ if scheme != nil {
+ return name, scheme
+ }
+ }
+
+ return "", nil
+}
+
+func orderedSecuritySchemeNames(doc *openapi3.T) []string {
+ seen := map[string]struct{}{}
+ var names []string
+
+ for _, requirement := range doc.Security {
+ var requirementNames []string
+ for name := range requirement {
+ requirementNames = append(requirementNames, name)
+ }
+ sort.Strings(requirementNames)
+ for _, name := range requirementNames {
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ names = append(names, name)
+ }
+ }
+
+ var all []string
+ for name := range doc.Components.SecuritySchemes {
+ all = append(all, name)
+ }
+ sort.Strings(all)
+ for _, name := range all {
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ names = append(names, name)
+ }
+
+ return names
+}
+
+func securitySchemeValue(ref *openapi3.SecuritySchemeRef) *openapi3.SecurityScheme {
+ if ref == nil {
+ return nil
+ }
+ return ref.Value
+}
+
+func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
+ if doc == nil || out == nil || doc.Paths == nil {
+ return
+ }
+
+ tagDescriptions := mapTagDescriptions(doc.Tags)
+
+ pathMap := doc.Paths.Map()
+ pathKeys := make([]string, 0, len(pathMap))
+ for path := range pathMap {
+ pathKeys = append(pathKeys, path)
+ }
+ sort.Strings(pathKeys)
+
+ for _, path := range pathKeys {
+ pathItem := doc.Paths.Value(path)
+ if pathItem == nil {
+ warnf("skipping path %q: path item is nil", path)
+ continue
+ }
+
+ operations := pathItem.Operations()
+ if len(operations) == 0 {
+ warnf("skipping path %q: no valid HTTP methods", path)
+ continue
+ }
+
+ resourceName := resourceNameFromPath(path, basePath)
+ if resourceName == "" {
+ warnf("skipping path %q: could not derive resource name", path)
+ continue
+ }
+
+ resource, ok := out.Resources[resourceName]
+ if !ok {
+ if len(out.Resources) >= maxResources {
+ warnf("skipping path %q: resource limit (%d) reached", path, maxResources)
+ continue
+ }
+ resource = spec.Resource{
+ Description: tagDescriptions[resourceName],
+ Endpoints: map[string]spec.Endpoint{},
+ }
+ }
+
+ methods := make([]string, 0, len(operations))
+ for method := range operations {
+ methods = append(methods, method)
+ }
+ sort.Strings(methods)
+
+ for _, method := range methods {
+ op := operations[method]
+ if op == nil {
+ warnf("skipping %s %q: operation is nil", method, path)
+ continue
+ }
+
+ if len(resource.Endpoints) >= maxEndpointsPerResource {
+ warnf("skipping %s %q: endpoint limit (%d) reached for resource %q", method, path, maxEndpointsPerResource, resourceName)
+ continue
+ }
+
+ endpointName := resolveEndpointName(method, path, op, resource.Endpoints, resourceName, basePath)
+ endpoint := spec.Endpoint{
+ Method: strings.ToUpper(method),
+ Path: path,
+ Description: firstNonEmpty(strings.TrimSpace(op.Summary), strings.TrimSpace(op.Description)),
+ Params: mapParameters(pathItem, op),
+ Body: mapRequestBody(op.RequestBody, method, path),
+ }
+
+ endpoint.Response, endpoint.ResponsePath = mapResponse(op, endpointName)
+ if resource.Description == "" {
+ resource.Description = resourceDescription(op, tagDescriptions)
+ }
+ resource.Endpoints[endpointName] = endpoint
+ }
+
+ out.Resources[resourceName] = resource
+ }
+}
+
+func mapTagDescriptions(tags openapi3.Tags) map[string]string {
+ out := make(map[string]string, len(tags))
+ for _, tag := range tags {
+ if tag == nil {
+ continue
+ }
+ name := toSnakeCase(tag.Name)
+ if name == "" {
+ continue
+ }
+ if desc := strings.TrimSpace(tag.Description); desc != "" {
+ out[name] = desc
+ }
+ }
+ return out
+}
+
+func resourceDescription(op *openapi3.Operation, tagDescriptions map[string]string) string {
+ if op == nil {
+ return ""
+ }
+ for _, tag := range op.Tags {
+ if desc := tagDescriptions[toSnakeCase(tag)]; desc != "" {
+ return desc
+ }
+ }
+ return ""
+}
+
+func resolveEndpointName(method, path string, op *openapi3.Operation, existing map[string]spec.Endpoint, resourceName, basePath string) string {
+ name := operationIDToName(operationID(op))
+ if name == "" {
+ name = defaultEndpointName(method, path)
+ }
+ if name == "" {
+ name = strings.ToLower(method)
+ }
+
+ if _, ok := existing[name]; !ok {
+ return name
+ }
+
+ suffix := endpointCollisionSuffix(path, resourceName, basePath)
+ if suffix == "" {
+ suffix = "endpoint"
+ }
+
+ candidate := name + "-" + suffix
+ if _, ok := existing[candidate]; !ok {
+ return candidate
+ }
+
+ for i := 2; ; i++ {
+ alt := fmt.Sprintf("%s-%s-%d", name, suffix, i)
+ if _, ok := existing[alt]; !ok {
+ return alt
+ }
+ }
+}
+
+func operationID(op *openapi3.Operation) string {
+ if op == nil {
+ return ""
+ }
+ return strings.TrimSpace(op.OperationID)
+}
+
+func defaultEndpointName(method, path string) string {
+ switch strings.ToUpper(method) {
+ case "GET":
+ if hasPathParams(path) {
+ return "get"
+ }
+ return "list"
+ case "POST":
+ return "create"
+ case "PUT", "PATCH":
+ return "update"
+ case "DELETE":
+ return "delete"
+ default:
+ return strings.ToLower(method)
+ }
+}
+
+func hasPathParams(path string) bool {
+ return strings.Contains(path, "{") && strings.Contains(path, "}")
+}
+
+func mapParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []spec.Param {
+ merged := mergeParameters(pathItem, op)
+ params := make([]spec.Param, 0, len(merged))
+ for _, parameter := range merged {
+ if parameter == nil {
+ continue
+ }
+ if parameter.In != openapi3.ParameterInPath && parameter.In != openapi3.ParameterInQuery {
+ continue
+ }
+
+ schema := schemaRefValue(parameter.Schema)
+ param := spec.Param{
+ Name: parameter.Name,
+ Type: mapSchemaType(schema),
+ Required: parameter.Required,
+ Positional: parameter.In == openapi3.ParameterInPath,
+ Description: strings.TrimSpace(parameter.Description),
+ Enum: schemaEnum(schema),
+ Format: schemaFormat(schema),
+ }
+ if schema != nil && schema.Default != nil {
+ param.Default = schema.Default
+ }
+ if param.Positional {
+ param.Required = true
+ }
+ params = append(params, param)
+ }
+ return params
+}
+
+func mergeParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []*openapi3.Parameter {
+ var merged []*openapi3.Parameter
+ index := map[string]int{}
+
+ add := func(parameters openapi3.Parameters, override bool) {
+ for _, parameterRef := range parameters {
+ if parameterRef == nil || parameterRef.Value == nil {
+ continue
+ }
+ parameter := parameterRef.Value
+ key := strings.ToLower(parameter.In) + ":" + parameter.Name
+ if i, ok := index[key]; ok {
+ if override {
+ merged[i] = parameter
+ }
+ continue
+ }
+ index[key] = len(merged)
+ merged = append(merged, parameter)
+ }
+ }
+
+ if pathItem != nil {
+ add(pathItem.Parameters, false)
+ }
+ if op != nil {
+ add(op.Parameters, true)
+ }
+
+ return merged
+}
+
+func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string) []spec.Param {
+ requestBody := requestBodyValue(requestBodyRef)
+ if requestBody == nil || requestBody.Content == nil {
+ return nil
+ }
+
+ media := requestBody.Content.Get("application/json")
+ if media == nil {
+ media = firstJSONMediaType(requestBody.Content)
+ }
+ if media == nil || media.Schema == nil || media.Schema.Value == nil {
+ return nil
+ }
+
+ properties := map[string]*openapi3.SchemaRef{}
+ required := map[string]struct{}{}
+ if collectAllOfProperties(media.Schema, properties, required, map[*openapi3.Schema]struct{}{}) {
+ warnf("skipping request body for %s %q: contains oneOf/anyOf", strings.ToUpper(method), path)
+ return nil
+ }
+
+ if len(properties) == 0 {
+ return nil
+ }
+
+ names := make([]string, 0, len(properties))
+ for name := range properties {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ body := make([]spec.Param, 0, len(names))
+ for _, name := range names {
+ schema := schemaRefValue(properties[name])
+ param := spec.Param{
+ Name: name,
+ Type: mapSchemaType(schema),
+ Required: isRequired(required, name),
+ Description: schemaDescription(schema),
+ Enum: schemaEnum(schema),
+ Format: schemaFormat(schema),
+ }
+ if schema != nil && schema.Default != nil {
+ param.Default = schema.Default
+ }
+ body = append(body, param)
+ }
+
+ return body
+}
+
+func collectAllOfProperties(
+ schemaRef *openapi3.SchemaRef,
+ properties map[string]*openapi3.SchemaRef,
+ required map[string]struct{},
+ visited map[*openapi3.Schema]struct{},
+) bool {
+ if schemaRef == nil || schemaRef.Value == nil {
+ return false
+ }
+
+ schema := schemaRef.Value
+ if _, ok := visited[schema]; ok {
+ return false
+ }
+ visited[schema] = struct{}{}
+
+ if len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 {
+ return true
+ }
+
+ for _, field := range schema.Required {
+ required[field] = struct{}{}
+ }
+ for name, prop := range schema.Properties {
+ if prop == nil {
+ continue
+ }
+ properties[name] = prop
+ }
+ for _, sub := range schema.AllOf {
+ if collectAllOfProperties(sub, properties, required, visited) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func mapResponse(op *openapi3.Operation, fallbackName string) (spec.ResponseDef, string) {
+ if op == nil || op.Responses == nil {
+ return spec.ResponseDef{}, ""
+ }
+
+ success := selectSuccessResponse(op.Responses)
+ if success == nil || success.Value == nil {
+ return spec.ResponseDef{}, ""
+ }
+
+ schemaRef := selectResponseSchema(success.Value)
+ if schemaRef == nil || schemaRef.Value == nil {
+ return spec.ResponseDef{}, ""
+ }
+
+ schema := schemaRef.Value
+ if isObjectSchema(schema) {
+ if dataRef := schema.Properties["data"]; dataRef != nil && isArraySchema(schemaRefValue(dataRef)) {
+ return spec.ResponseDef{
+ Type: "array",
+ Item: schemaTypeName(schemaRefValue(dataRef).Items, fallbackName+"Item"),
+ }, "data"
+ }
+ }
+
+ if isArraySchema(schema) {
+ return spec.ResponseDef{
+ Type: "array",
+ Item: schemaTypeName(schema.Items, fallbackName+"Item"),
+ }, ""
+ }
+
+ if isObjectSchema(schema) {
+ return spec.ResponseDef{
+ Type: "object",
+ Item: schemaTypeName(schemaRef, fallbackName+"Response"),
+ }, ""
+ }
+
+ return spec.ResponseDef{}, ""
+}
+
+func selectSuccessResponse(responses *openapi3.Responses) *openapi3.ResponseRef {
+ if responses == nil {
+ return nil
+ }
+ if v := responses.Value("200"); v != nil {
+ return v
+ }
+ if v := responses.Value("201"); v != nil {
+ return v
+ }
+
+ responseMap := responses.Map()
+ keys := make([]string, 0, len(responseMap))
+ for key := range responseMap {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+
+ for _, key := range keys {
+ if len(key) == 3 && key[0] == '2' {
+ return responses.Value(key)
+ }
+ if strings.EqualFold(key, "2XX") {
+ return responses.Value(key)
+ }
+ }
+
+ return nil
+}
+
+func selectResponseSchema(response *openapi3.Response) *openapi3.SchemaRef {
+ if response == nil || response.Content == nil {
+ return nil
+ }
+ if media := response.Content.Get("application/json"); media != nil && media.Schema != nil {
+ return media.Schema
+ }
+
+ contentTypes := make([]string, 0, len(response.Content))
+ for contentType := range response.Content {
+ contentTypes = append(contentTypes, contentType)
+ }
+ sort.Strings(contentTypes)
+ for _, contentType := range contentTypes {
+ media := response.Content[contentType]
+ if media != nil && media.Schema != nil {
+ return media.Schema
+ }
+ }
+
+ return nil
+}
+
+func mapTypes(doc *openapi3.T, out *spec.APISpec) {
+ if doc == nil || doc.Components == nil {
+ return
+ }
+
+ schemaMap := doc.Components.Schemas
+ if len(schemaMap) == 0 {
+ return
+ }
+
+ names := make([]string, 0, len(schemaMap))
+ for name := range schemaMap {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ for _, name := range names {
+ schemaRef := schemaMap[name]
+ schema := schemaRefValue(schemaRef)
+ if schema == nil {
+ warnf("skipping schema %q: schema is nil", name)
+ continue
+ }
+ if !isObjectSchema(schema) {
+ continue
+ }
+
+ properties := map[string]*openapi3.SchemaRef{}
+ collectTypeProperties(schemaRef, properties, map[*openapi3.Schema]struct{}{})
+
+ fieldNames := make([]string, 0, len(properties))
+ for fieldName := range properties {
+ fieldNames = append(fieldNames, fieldName)
+ }
+ sort.Strings(fieldNames)
+
+ fields := make([]spec.TypeField, 0, len(fieldNames))
+ for _, fieldName := range fieldNames {
+ fields = append(fields, spec.TypeField{
+ Name: fieldName,
+ Type: mapSchemaType(schemaRefValue(properties[fieldName])),
+ })
+ }
+
+ out.Types[name] = spec.TypeDef{Fields: fields}
+ }
+}
+
+func collectTypeProperties(schemaRef *openapi3.SchemaRef, properties map[string]*openapi3.SchemaRef, visited map[*openapi3.Schema]struct{}) {
+ if schemaRef == nil || schemaRef.Value == nil {
+ return
+ }
+
+ schema := schemaRef.Value
+ if _, ok := visited[schema]; ok {
+ return
+ }
+ visited[schema] = struct{}{}
+
+ for name, prop := range schema.Properties {
+ if prop == nil {
+ continue
+ }
+ properties[name] = prop
+ }
+ for _, sub := range schema.AllOf {
+ collectTypeProperties(sub, properties, visited)
+ }
+}
+
+func requestBodyValue(ref *openapi3.RequestBodyRef) *openapi3.RequestBody {
+ if ref == nil {
+ return nil
+ }
+ return ref.Value
+}
+
+func schemaRefValue(ref *openapi3.SchemaRef) *openapi3.Schema {
+ if ref == nil {
+ return nil
+ }
+ return ref.Value
+}
+
+func mapSchemaType(schema *openapi3.Schema) string {
+ if schema == nil || schema.Type == nil {
+ return "string"
+ }
+ switch {
+ case schema.Type.Is(openapi3.TypeString):
+ return "string"
+ case schema.Type.Is(openapi3.TypeInteger):
+ return "int"
+ case schema.Type.Is(openapi3.TypeBoolean):
+ return "bool"
+ case schema.Type.Is(openapi3.TypeNumber):
+ return "float"
+ default:
+ return "string"
+ }
+}
+
+func schemaEnum(schema *openapi3.Schema) []string {
+ if schema == nil || len(schema.Enum) == 0 {
+ return nil
+ }
+ enum := make([]string, 0, len(schema.Enum))
+ for _, value := range schema.Enum {
+ switch v := value.(type) {
+ case string:
+ enum = append(enum, v)
+ default:
+ enum = append(enum, fmt.Sprint(v))
+ }
+ }
+ return enum
+}
+
+func schemaFormat(schema *openapi3.Schema) string {
+ if schema == nil {
+ return ""
+ }
+ return strings.TrimSpace(schema.Format)
+}
+
+func schemaDescription(schema *openapi3.Schema) string {
+ if schema == nil {
+ return ""
+ }
+ return strings.TrimSpace(schema.Description)
+}
+
+func isArraySchema(schema *openapi3.Schema) bool {
+ if schema == nil {
+ return false
+ }
+ if schema.Type != nil && schema.Type.Is(openapi3.TypeArray) {
+ return true
+ }
+ return schema.Items != nil
+}
+
+func isObjectSchema(schema *openapi3.Schema) bool {
+ if schema == nil {
+ return false
+ }
+ if schema.Type != nil && schema.Type.Is(openapi3.TypeObject) {
+ return true
+ }
+ return len(schema.Properties) > 0 || len(schema.AllOf) > 0
+}
+
+func schemaTypeName(schemaRef *openapi3.SchemaRef, fallback string) string {
+ if schemaRef == nil {
+ return toTypeName(fallback)
+ }
+
+ if refName := refComponentName(schemaRef.Ref); refName != "" {
+ return toTypeName(refName)
+ }
+
+ schema := schemaRef.Value
+ if schema == nil {
+ return toTypeName(fallback)
+ }
+ if schema.Title != "" {
+ return toTypeName(schema.Title)
+ }
+
+ if schema.Type != nil {
+ switch {
+ case schema.Type.Is(openapi3.TypeString):
+ return "string"
+ case schema.Type.Is(openapi3.TypeInteger):
+ return "int"
+ case schema.Type.Is(openapi3.TypeBoolean):
+ return "bool"
+ case schema.Type.Is(openapi3.TypeNumber):
+ return "float"
+ }
+ }
+
+ return toTypeName(fallback)
+}
+
+func refComponentName(ref string) string {
+ if ref == "" {
+ return ""
+ }
+ i := strings.LastIndex(ref, "/")
+ if i == -1 || i+1 >= len(ref) {
+ return ""
+ }
+ return ref[i+1:]
+}
+
+func firstJSONMediaType(content openapi3.Content) *openapi3.MediaType {
+ if content == nil {
+ return nil
+ }
+
+ contentTypes := make([]string, 0, len(content))
+ for contentType := range content {
+ contentTypes = append(contentTypes, contentType)
+ }
+ sort.Strings(contentTypes)
+
+ for _, contentType := range contentTypes {
+ if strings.Contains(strings.ToLower(contentType), "json") {
+ return content[contentType]
+ }
+ }
+
+ for _, contentType := range contentTypes {
+ media := content[contentType]
+ if media != nil && media.Schema != nil {
+ return media
+ }
+ }
+
+ return nil
+}
+
+func resourceNameFromPath(path, basePath string) string {
+ segments := pathSegmentsAfterBase(path, basePath)
+ if len(segments) == 0 {
+ return ""
+ }
+ if isPathParamSegment(segments[0]) {
+ return ""
+ }
+ return toSnakeCase(segments[0])
+}
+
+func endpointCollisionSuffix(path, resourceName, basePath string) string {
+ segments := pathSegmentsAfterBase(path, basePath)
+ if len(segments) == 0 {
+ return ""
+ }
+ if toSnakeCase(segments[0]) == resourceName {
+ segments = segments[1:]
+ }
+
+ for _, segment := range segments {
+ if isPathParamSegment(segment) {
+ continue
+ }
+ if suffix := toKebabCase(segment); suffix != "" {
+ return suffix
+ }
+ }
+ for _, segment := range segments {
+ segment = strings.Trim(segment, "{}")
+ if suffix := toKebabCase(segment); suffix != "" {
+ return suffix
+ }
+ }
+
+ return ""
+}
+
+func pathSegmentsAfterBase(path, basePath string) []string {
+ segments := splitPath(path)
+ if len(segments) == 0 {
+ return nil
+ }
+
+ baseSegments := splitPath(basePath)
+ if len(baseSegments) > 0 && len(segments) >= len(baseSegments) {
+ match := true
+ for i := range baseSegments {
+ if segments[i] != baseSegments[i] {
+ match = false
+ break
+ }
+ }
+ if match {
+ segments = segments[len(baseSegments):]
+ }
+ }
+
+ if len(segments) > 0 && isVersionSegment(segments[0]) {
+ segments = segments[1:]
+ }
+
+ return segments
+}
+
+func splitPath(path string) []string {
+ path = strings.Trim(path, "/")
+ if path == "" {
+ return nil
+ }
+ raw := strings.Split(path, "/")
+ segments := make([]string, 0, len(raw))
+ for _, segment := range raw {
+ segment = strings.TrimSpace(segment)
+ if segment != "" {
+ segments = append(segments, segment)
+ }
+ }
+ return segments
+}
+
+func isVersionSegment(segment string) bool {
+ if len(segment) < 2 || segment[0] != 'v' {
+ return false
+ }
+ _, err := strconv.Atoi(segment[1:])
+ return err == nil
+}
+
+func isPathParamSegment(segment string) bool {
+ return strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}")
+}
+
+func baseURLPath(baseURL string) string {
+ if baseURL == "" {
+ return ""
+ }
+ parsed, err := url.Parse(baseURL)
+ if err != nil {
+ return ""
+ }
+ return parsed.Path
+}
+
+func operationIDToName(operationID string) string {
+ if operationID == "" {
+ return ""
+ }
+ return toSnakeCase(operationID)
+}
+
+func toSnakeCase(input string) string {
+ var b strings.Builder
+ var prev rune
+ lastUnderscore := true
+
+ for i, r := range input {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ if unicode.IsUpper(r) && i > 0 && (unicode.IsLower(prev) || unicode.IsDigit(prev)) && !lastUnderscore {
+ b.WriteByte('_')
+ lastUnderscore = true
+ }
+ b.WriteRune(unicode.ToLower(r))
+ lastUnderscore = false
+ } else if !lastUnderscore && b.Len() > 0 {
+ b.WriteByte('_')
+ lastUnderscore = true
+ }
+ prev = r
+ }
+
+ return strings.Trim(b.String(), "_")
+}
+
+func toKebabCase(input string) string {
+ var b strings.Builder
+ lastHyphen := true
+
+ for _, r := range input {
+ switch {
+ case unicode.IsLetter(r), unicode.IsDigit(r):
+ b.WriteRune(unicode.ToLower(r))
+ lastHyphen = false
+ case unicode.IsSpace(r):
+ if !lastHyphen && b.Len() > 0 {
+ b.WriteByte('-')
+ lastHyphen = true
+ }
+ }
+ }
+
+ return strings.Trim(b.String(), "-")
+}
+
+func toTypeName(input string) string {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return "Item"
+ }
+
+ var parts []string
+ var current strings.Builder
+
+ flush := func() {
+ if current.Len() == 0 {
+ return
+ }
+ parts = append(parts, current.String())
+ current.Reset()
+ }
+
+ for i, r := range input {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ if i > 0 && unicode.IsUpper(r) {
+ prev := rune(input[i-1])
+ if unicode.IsLower(prev) || unicode.IsDigit(prev) {
+ flush()
+ }
+ }
+ current.WriteRune(r)
+ } else {
+ flush()
+ }
+ }
+ flush()
+
+ if len(parts) == 0 {
+ return "Item"
+ }
+
+ var b strings.Builder
+ for _, part := range parts {
+ part = strings.ToLower(part)
+ if part == "" {
+ continue
+ }
+ b.WriteString(strings.ToUpper(part[:1]))
+ b.WriteString(part[1:])
+ }
+
+ result := b.String()
+ if result == "" {
+ return "Item"
+ }
+ if unicode.IsDigit(rune(result[0])) {
+ return "Type" + result
+ }
+ return result
+}
+
+func isRequired(required map[string]struct{}, name string) bool {
+ _, ok := required[name]
+ return ok
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func warnf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "warning: "+format+"\n", args...)
+}
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
new file mode 100644
index 00000000..93e03728
--- /dev/null
+++ b/internal/openapi/parser_test.go
@@ -0,0 +1,63 @@
+package openapi
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePetstore(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+ require.NoError(t, err)
+
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+
+ assert.Equal(t, "swagger-petstore-openapi-30", parsed.Name)
+ assert.Equal(t, "/api/v3", parsed.BaseURL)
+ assert.NotEmpty(t, parsed.Resources)
+
+ hasEndpoint := false
+ for _, resource := range parsed.Resources {
+ if len(resource.Endpoints) > 0 {
+ hasEndpoint = true
+ break
+ }
+ }
+ assert.True(t, hasEndpoint)
+
+ assert.NotEmpty(t, parsed.Types)
+ assert.Contains(t, parsed.Types, "Pet")
+}
+
+func TestIsOpenAPI(t *testing.T) {
+ t.Parallel()
+
+ openAPIYAML := []byte(`
+openapi: 3.0.0
+info:
+ title: Demo
+ version: 1.0.0
+paths: {}
+`)
+ openAPIJSON := []byte(`{"openapi":"3.0.1","info":{"title":"Demo","version":"1.0.0"},"paths":{}}`)
+ internalYAML := []byte(`
+name: demo
+base_url: https://api.example.com
+resources:
+ users:
+ endpoints:
+ list:
+ method: GET
+ path: /users
+`)
+
+ assert.True(t, IsOpenAPI(openAPIYAML))
+ assert.True(t, IsOpenAPI(openAPIJSON))
+ assert.False(t, IsOpenAPI(internalYAML))
+}
← 4e3888ab feat(spec): extend internal format for OpenAPI + add real Op
·
back to Cli Printing Press
·
feat(cli): auto-detect OpenAPI vs internal spec format 60009103 →