Files
pad/internal/urlimport/openapi.go
T
xarmian 8771f95ab2 feat(urlimport): OpenAPI 3.x → Markdown converter (TASK-1471) (#555)
* feat(urlimport): OpenAPI 3.x → Markdown converter (TASK-1471)

Adds ConvertOpenAPI to internal/urlimport — the "openapi" branch of
the v1 importer. Built on pb33f/libopenapi.

Layout:
  - H1 with the API title + version + description
  - Contact + License lines
  - Servers list
  - Endpoints section grouped by primary tag (or "Other" for the
    untagged). Per operation: `METHOD /path` heading, summary,
    description, deprecation marker, operation ID, parameter table,
    request-body summary (with media-type fences and YAML-rendered
    example), and response code table.
  - Schemas section with component schemas as Property/Type/Required/
    Description tables, schema names sorted for stable output.

Scope:
  - OpenAPI 3.x only. Swagger 2.0 detection returns an explicit
    "only 3.x" error so the import endpoint (TASK-1472) can fall
    through to the generic converter.
  - Recoverable libopenapi build errors (unresolved refs, etc.) are
    swallowed when the model is still produced — partial spec >
    no output.

Tests:
  - testdata/petstore-openapi.yaml — full v3 fixture: tags, params,
    requestBody example, deprecated op, ref-typed schema array, two
    component schemas with required-field markers.
  - TestConvertOpenAPI_Petstore — 30+ markdown-substring assertions
    on the rendered output.
  - TestConvertOpenAPI_RejectsSwagger2 — explicit v2 error.
  - TestConvertOpenAPI_RejectsGarbage — non-spec input.
  - TestConvertOpenAPI_MinimalSpec — empty paths short-circuits.
  - Helpers: schemaTypeBrief(nil), escapeTableCell, singleLine.

Dependency: github.com/pb33f/libopenapi v0.36.3 (MIT-licensed).

Parent: PLAN-1467.

* fix(urlimport): merge path-level + operation-level parameters per Codex review (round 1)

MEDIUM: OpenAPI path-item-level parameters apply to every operation
on the path. Previously only slot.op.Parameters was rendered, so
common specs that hoist a shared {id} parameter to the path-item
level emitted operations with the path parameter missing from the
docs.

Now opSlot carries item.Parameters as pathParams, and a new
mergeParameters helper produces the spec-conformant union:
- Path-level parameters first, in declared order.
- Operation-level parameters with matching (name, in) override the
  path-level entry in place.
- Operation-only parameters appended after.

Tests:
- TestConvertOpenAPI_PathLevelParametersMerged — inline fixture
  with a path-level widgetId + trace and an operation-level trace
  override + fields op-only param. Asserts widgetId survives, trace
  shows op-level (required=yes), no duplicate path-level trace row,
  fields appears.
- TestMergeParameters_EmptyInputs — nil/nil short-circuit.

* fix(urlimport): no double-backticks on array-of-ref schema types per Codex review (round 2)

MEDIUM: schemaTypeBrief() previously wrapped refs in inline backticks
("`Pet`"). For array-of-ref schemas the brief became "array of `Pet`",
and the table-cell call site (codeOrBlank) then wrapped the entire
value in another pair, producing broken markdown like
"`array of `Pet``". Schema properties whose type is an array of a
component schema are a normal OpenAPI shape — `Litter.pets: array of
Pet` — so this would have hit real specs immediately.

Fixes:
- schemaTypeBrief now returns plain text — ref names without
  surrounding backticks. Docstring updated to make the contract
  explicit ("never contains backticks; caller wraps").
- codeOrBlank strips any stray backticks from input before wrapping
  so the resulting cell always carries exactly one balanced pair.
  Defensive: the contract from schemaTypeBrief is plain text now,
  but stray backticks from any future caller can't corrupt the
  table.

Tests added:
- TestConvertOpenAPI_ArrayOfRefTypeCell — inline spec with a
  `Litter.pets: array of Pet` property. Asserts the type cell is
  exactly `` `array of Pet` `` and no malformed variants leak.
- TestCodeOrBlank — 7-case table covering empty, plain, whitespace,
  pre-backticked, embedded-backtick, and backtick-only inputs.
2026-05-15 00:24:14 -04:00

510 lines
14 KiB
Go

package urlimport
import (
"errors"
"fmt"
"sort"
"strings"
"github.com/pb33f/libopenapi"
"github.com/pb33f/libopenapi/datamodel/high/base"
v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
"go.yaml.in/yaml/v4"
)
// ConvertOpenAPI converts an OpenAPI 3.x specification into a
// human-readable markdown document. The output is organized as:
//
// - H1 with the API title
// - Info section (version, description, license, contact)
// - One section per tag (or "Other" for untagged operations), with
// a sub-section per Operation containing method+path, summary,
// parameter table, request-body summary, and response codes
// - Schemas section listing component schemas as markdown tables
//
// Swagger 2.0 specifications are detected by the Detect() function
// but are not converted by this function — they return an error so
// the caller can fall back to the generic converter. v3 covers the
// modern surface; v2 → v3 upgrade is a possible follow-up if real
// usage demands it.
func ConvertOpenAPI(spec []byte, pageURL string) (*ConvertResult, error) {
doc, err := libopenapi.NewDocument(spec)
if err != nil {
return nil, fmt.Errorf("urlimport: parse OpenAPI: %w", err)
}
version := doc.GetVersion()
if !strings.HasPrefix(version, "3.") {
return nil, fmt.Errorf("urlimport: OpenAPI %s not supported (only 3.x)", version)
}
model, buildErr := doc.BuildV3Model()
// libopenapi returns a non-nil error here for unresolved refs and
// other recoverable issues; when model is nil the error is fatal,
// otherwise we render what we got. The recoverable error is
// swallowed because the model is still usable.
if buildErr != nil && model == nil {
return nil, fmt.Errorf("urlimport: build OpenAPI model: %w", buildErr)
}
if model == nil || model.Model.Info == nil {
return nil, errors.New("urlimport: OpenAPI document missing required Info block")
}
var b strings.Builder
renderInfo(&b, &model.Model)
renderServers(&b, &model.Model)
renderOperations(&b, &model.Model)
renderSchemas(&b, &model.Model)
md := cleanupMarkdown(b.String())
if strings.TrimSpace(md) == "" {
return nil, errors.New("urlimport: empty markdown after OpenAPI conversion")
}
return &ConvertResult{
Markdown: md,
Title: strings.TrimSpace(model.Model.Info.Title),
Byline: "",
}, nil
}
func renderInfo(b *strings.Builder, doc *v3high.Document) {
info := doc.Info
title := strings.TrimSpace(info.Title)
if title == "" {
title = "API Reference"
}
fmt.Fprintf(b, "# %s\n\n", title)
if v := strings.TrimSpace(info.Version); v != "" {
fmt.Fprintf(b, "**Version:** `%s`\n\n", v)
}
if v := strings.TrimSpace(info.Description); v != "" {
b.WriteString(v)
b.WriteString("\n\n")
}
if info.Contact != nil {
var parts []string
if info.Contact.Name != "" {
parts = append(parts, info.Contact.Name)
}
if info.Contact.Email != "" {
parts = append(parts, fmt.Sprintf("<%s>", info.Contact.Email))
}
if info.Contact.URL != "" {
parts = append(parts, info.Contact.URL)
}
if len(parts) > 0 {
fmt.Fprintf(b, "**Contact:** %s\n\n", strings.Join(parts, " · "))
}
}
if info.License != nil && info.License.Name != "" {
license := info.License.Name
if info.License.URL != "" {
license = fmt.Sprintf("[%s](%s)", info.License.Name, info.License.URL)
}
fmt.Fprintf(b, "**License:** %s\n\n", license)
}
}
func renderServers(b *strings.Builder, doc *v3high.Document) {
if len(doc.Servers) == 0 {
return
}
b.WriteString("## Servers\n\n")
for _, s := range doc.Servers {
url := strings.TrimSpace(s.URL)
if url == "" {
continue
}
fmt.Fprintf(b, "- `%s`", url)
if d := strings.TrimSpace(s.Description); d != "" {
fmt.Fprintf(b, " — %s", d)
}
b.WriteString("\n")
}
b.WriteString("\n")
}
// opSlot is a unit of work for the tag-grouping pass: one HTTP method
// against one path, plus the Operation that defines it. pathParams
// carries the path-item's shared parameters so renderOperation can
// merge them with operation-scoped parameters per the OpenAPI spec
// (operation-level overrides path-level on a (name, in) match).
type opSlot struct {
method string
path string
op *v3high.Operation
pathParams []*v3high.Parameter
}
func renderOperations(b *strings.Builder, doc *v3high.Document) {
if doc.Paths == nil || doc.Paths.PathItems == nil || doc.Paths.PathItems.Len() == 0 {
return
}
// Collect (method, path, op, pathParams) tuples preserving spec
// order. Path-level parameters are captured here so each operation
// renders with the full merged parameter set.
var slots []opSlot
for pair := doc.Paths.PathItems.First(); pair != nil; pair = pair.Next() {
path := pair.Key()
item := pair.Value()
for opPair := item.GetOperations().First(); opPair != nil; opPair = opPair.Next() {
slots = append(slots, opSlot{
method: strings.ToUpper(opPair.Key()),
path: path,
op: opPair.Value(),
pathParams: item.Parameters,
})
}
}
// Group by primary tag (first declared) — "Other" for the untagged.
groups := map[string][]opSlot{}
var tagOrder []string
seen := map[string]bool{}
for _, s := range slots {
tag := "Other"
if len(s.op.Tags) > 0 && strings.TrimSpace(s.op.Tags[0]) != "" {
tag = s.op.Tags[0]
}
if !seen[tag] {
tagOrder = append(tagOrder, tag)
seen[tag] = true
}
groups[tag] = append(groups[tag], s)
}
b.WriteString("## Endpoints\n\n")
for _, tag := range tagOrder {
fmt.Fprintf(b, "### %s\n\n", tag)
for _, slot := range groups[tag] {
renderOperation(b, slot)
}
}
}
func renderOperation(b *strings.Builder, slot opSlot) {
fmt.Fprintf(b, "#### `%s %s`\n\n", slot.method, slot.path)
if s := strings.TrimSpace(slot.op.Summary); s != "" {
b.WriteString("**Summary:** ")
b.WriteString(s)
b.WriteString("\n\n")
}
if d := strings.TrimSpace(slot.op.Description); d != "" {
b.WriteString(d)
b.WriteString("\n\n")
}
if slot.op.Deprecated != nil && *slot.op.Deprecated {
b.WriteString("> ⚠ **Deprecated.**\n\n")
}
if slot.op.OperationId != "" {
fmt.Fprintf(b, "**Operation ID:** `%s`\n\n", slot.op.OperationId)
}
if params := mergeParameters(slot.pathParams, slot.op.Parameters); len(params) > 0 {
renderParameters(b, params)
}
if slot.op.RequestBody != nil {
renderRequestBody(b, slot.op.RequestBody)
}
if slot.op.Responses != nil {
renderResponses(b, slot.op.Responses)
}
}
// mergeParameters combines path-item-level parameters with
// operation-level ones, deduping on the (name, in) tuple. Operation-
// level parameters win on conflict — that's the OpenAPI spec's rule.
// Order is: path-level first (in declared order), then operation-
// level entries that don't collide, then operation-level overrides
// inserted in place of the path-level entry they override.
func mergeParameters(pathParams, opParams []*v3high.Parameter) []*v3high.Parameter {
if len(pathParams) == 0 {
return opParams
}
if len(opParams) == 0 {
return pathParams
}
type key struct{ name, in string }
override := map[key]*v3high.Parameter{}
for _, p := range opParams {
if p == nil {
continue
}
override[key{p.Name, p.In}] = p
}
out := make([]*v3high.Parameter, 0, len(pathParams)+len(opParams))
seen := map[key]bool{}
for _, p := range pathParams {
if p == nil {
continue
}
k := key{p.Name, p.In}
if rep, ok := override[k]; ok {
out = append(out, rep)
} else {
out = append(out, p)
}
seen[k] = true
}
for _, p := range opParams {
if p == nil {
continue
}
k := key{p.Name, p.In}
if seen[k] {
continue
}
out = append(out, p)
seen[k] = true
}
return out
}
func renderParameters(b *strings.Builder, params []*v3high.Parameter) {
if len(params) == 0 {
return
}
b.WriteString("**Parameters**\n\n")
b.WriteString("| Name | In | Required | Type | Description |\n")
b.WriteString("| --- | --- | --- | --- | --- |\n")
for _, p := range params {
required := "no"
if p.Required != nil && *p.Required {
required = "yes"
}
typ := schemaTypeBrief(p.Schema)
desc := singleLine(p.Description)
fmt.Fprintf(b, "| `%s` | %s | %s | %s | %s |\n",
escapeTableCell(p.Name),
escapeTableCell(p.In),
required,
codeOrBlank(typ),
desc,
)
}
b.WriteString("\n")
}
func renderRequestBody(b *strings.Builder, rb *v3high.RequestBody) {
b.WriteString("**Request body**")
if rb.Required != nil && *rb.Required {
b.WriteString(" *(required)*")
}
b.WriteString("\n\n")
if d := strings.TrimSpace(rb.Description); d != "" {
b.WriteString(d)
b.WriteString("\n\n")
}
if rb.Content != nil {
for pair := rb.Content.First(); pair != nil; pair = pair.Next() {
fmt.Fprintf(b, "- Content-Type: `%s`", pair.Key())
if t := schemaTypeBrief(pair.Value().Schema); t != "" {
fmt.Fprintf(b, " — %s", t)
}
b.WriteString("\n")
renderMediaExample(b, pair.Value())
}
b.WriteString("\n")
}
}
func renderResponses(b *strings.Builder, resps *v3high.Responses) {
if resps == nil {
return
}
hasAny := (resps.Codes != nil && resps.Codes.Len() > 0) || resps.Default != nil
if !hasAny {
return
}
b.WriteString("**Responses**\n\n")
b.WriteString("| Code | Description |\n")
b.WriteString("| --- | --- |\n")
if resps.Codes != nil {
for pair := resps.Codes.First(); pair != nil; pair = pair.Next() {
desc := ""
if r := pair.Value(); r != nil {
desc = singleLine(r.Description)
}
fmt.Fprintf(b, "| `%s` | %s |\n", escapeTableCell(pair.Key()), desc)
}
}
if resps.Default != nil {
fmt.Fprintf(b, "| `default` | %s |\n", singleLine(resps.Default.Description))
}
b.WriteString("\n")
}
func renderMediaExample(b *strings.Builder, mt *v3high.MediaType) {
if mt == nil || mt.Example == nil {
return
}
// Scalar nodes carry their literal text in .Value; mapping and
// sequence nodes don't, so we serialize the whole node tree to
// YAML to get a usable representation. Tag/anchor metadata is
// untouched — we're rendering whatever the spec author wrote.
example := strings.TrimSpace(mt.Example.Value)
if example == "" {
out, err := yaml.Marshal(mt.Example)
if err != nil {
return
}
example = strings.TrimSpace(string(out))
}
if example == "" {
return
}
b.WriteString("\n Example:\n\n ```\n ")
b.WriteString(strings.ReplaceAll(example, "\n", "\n "))
b.WriteString("\n ```\n")
}
func renderSchemas(b *strings.Builder, doc *v3high.Document) {
if doc.Components == nil || doc.Components.Schemas == nil || doc.Components.Schemas.Len() == 0 {
return
}
b.WriteString("## Schemas\n\n")
// Sort schema names for stable output.
var names []string
for pair := doc.Components.Schemas.First(); pair != nil; pair = pair.Next() {
names = append(names, pair.Key())
}
sort.Strings(names)
for _, name := range names {
proxy, _ := doc.Components.Schemas.Get(name)
if proxy == nil {
continue
}
fmt.Fprintf(b, "### `%s`\n\n", name)
schema := proxy.Schema()
if schema == nil {
b.WriteString("*(unresolved schema)*\n\n")
continue
}
if d := strings.TrimSpace(schema.Description); d != "" {
b.WriteString(d)
b.WriteString("\n\n")
}
renderSchemaProperties(b, schema)
}
}
func renderSchemaProperties(b *strings.Builder, s *base.Schema) {
if s == nil || s.Properties == nil || s.Properties.Len() == 0 {
return
}
required := map[string]bool{}
for _, r := range s.Required {
required[r] = true
}
b.WriteString("| Property | Type | Required | Description |\n")
b.WriteString("| --- | --- | --- | --- |\n")
for pair := s.Properties.First(); pair != nil; pair = pair.Next() {
name := pair.Key()
typ := schemaTypeBrief(pair.Value())
desc := ""
if sub := pair.Value().Schema(); sub != nil {
desc = singleLine(sub.Description)
}
req := "no"
if required[name] {
req = "yes"
}
fmt.Fprintf(b, "| `%s` | %s | %s | %s |\n",
escapeTableCell(name),
codeOrBlank(typ),
req,
desc,
)
}
b.WriteString("\n")
}
// codeOrBlank wraps t in backticks unless t is empty. Any stray
// backtick characters inside t are stripped first so the resulting
// cell always carries exactly one balanced pair — schemaTypeBrief is
// documented to return plain text, but a hand-typed call site that
// accidentally embeds backticks would otherwise break the markdown.
func codeOrBlank(t string) string {
if t == "" {
return ""
}
t = strings.ReplaceAll(strings.TrimSpace(t), "`", "")
if t == "" {
return ""
}
return "`" + escapeTableCell(t) + "`"
}
// schemaTypeBrief returns a one-line type description for a schema
// proxy: the declared type, format if present, item type for arrays,
// or the ref name for unresolved references.
//
// The return value never contains backticks. The caller is responsible
// for wrapping the result in a single backtick pair (see codeOrBlank).
// Returning a partially-backticked string from here would compose
// incorrectly with codeOrBlank — "array of `Pet`" gets wrapped into
// "`array of `Pet“" which is broken markdown.
func schemaTypeBrief(p *base.SchemaProxy) string {
if p == nil {
return ""
}
if p.IsReference() {
ref := p.GetReference()
// Trim the canonical `#/components/schemas/` prefix to keep
// the type cell readable.
if i := strings.LastIndex(ref, "/"); i >= 0 {
return ref[i+1:]
}
return ref
}
s := p.Schema()
if s == nil {
return ""
}
var parts []string
if len(s.Type) > 0 {
parts = append(parts, s.Type[0])
}
if s.Format != "" {
parts = append(parts, fmt.Sprintf("(%s)", s.Format))
}
if len(s.Type) > 0 && s.Type[0] == "array" && s.Items != nil && s.Items.A != nil {
sub := schemaTypeBrief(s.Items.A)
if sub != "" {
parts = append(parts, "of "+sub)
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " ")
}
// singleLine collapses internal whitespace and newlines for safe
// inclusion in a markdown table cell.
func singleLine(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.ReplaceAll(s, "\r", "")
// Replace newlines with spaces, collapse runs of whitespace.
s = strings.ReplaceAll(s, "\n", " ")
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
return escapeTableCell(s)
}
// escapeTableCell escapes characters that would break a markdown table
// row: `|` becomes `\|`, and existing backslashes are doubled so the
// escape stays valid.
func escapeTableCell(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "|", `\|`)
return s
}