Files
pad/internal/urlimport/openapi_test.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

291 lines
7.4 KiB
Go

package urlimport
import (
"strings"
"testing"
)
func TestConvertOpenAPI_Petstore(t *testing.T) {
spec := loadFixture(t, "petstore-openapi.yaml")
res, err := ConvertOpenAPI(spec, "https://petstore.example.com/openapi.yaml")
if err != nil {
t.Fatalf("ConvertOpenAPI: %v", err)
}
if res.Title != "Swagger Petstore" {
t.Errorf("Title = %q, want 'Swagger Petstore'", res.Title)
}
md := res.Markdown
wantSubstrings := []string{
// Info block
"# Swagger Petstore",
"**Version:** `1.0.0`",
"**Contact:**",
"petstore@example.com",
"**License:**",
"MIT",
// Servers
"## Servers",
"https://petstore.example.com/v1",
"Production server",
// Endpoints
"## Endpoints",
"### pets",
"### store",
"`GET /pets`",
"`POST /pets`",
"`GET /pets/{petId}`",
"`POST /store/orders`",
// Operation details
"List pets",
"Create a pet",
"**Parameters**",
"| `limit` | query | no |",
"| `petId` | path | yes |",
// Request body
"**Request body** *(required)*",
"Content-Type: `application/json`",
// Example body
"name: Rex",
// Responses
"**Responses**",
"| `200` |",
"| `400` |",
"| `404` |",
"| `201` |",
// Deprecation marker
"⚠ **Deprecated.**",
// Operation IDs
"**Operation ID:** `listPets`",
// Schemas
"## Schemas",
"### `Order`",
"### `Pet`",
"A pet for sale.",
"| `id` |",
"| `name` |",
"`integer (int64)`",
}
for _, want := range wantSubstrings {
if !strings.Contains(md, want) {
t.Errorf("markdown missing %q\n--- markdown ---\n%s\n---", want, md)
}
}
}
func TestConvertOpenAPI_RejectsSwagger2(t *testing.T) {
// libopenapi accepts both v2 and v3 documents. ConvertOpenAPI is
// limited to 3.x; Swagger 2.0 should produce a clear error so the
// caller can fall back to generic conversion.
swagger2 := []byte(`swagger: "2.0"
info:
title: Old API
version: "1.0"
paths: {}
`)
_, err := ConvertOpenAPI(swagger2, "")
if err == nil {
t.Fatal("ConvertOpenAPI: expected error for Swagger 2.0, got nil")
}
if !strings.Contains(err.Error(), "only 3.x") {
t.Fatalf("error = %v, want to mention 'only 3.x'", err)
}
}
func TestConvertOpenAPI_RejectsGarbage(t *testing.T) {
_, err := ConvertOpenAPI([]byte("not an openapi spec"), "")
if err == nil {
t.Fatal("expected error for non-OpenAPI input, got nil")
}
}
func TestConvertOpenAPI_MinimalSpec(t *testing.T) {
// Smallest valid v3 spec — just info and an empty paths object.
minimal := []byte(`openapi: 3.0.0
info:
title: Tiny API
version: "0.1"
paths: {}
`)
res, err := ConvertOpenAPI(minimal, "")
if err != nil {
t.Fatalf("ConvertOpenAPI: %v", err)
}
if !strings.Contains(res.Markdown, "# Tiny API") {
t.Errorf("markdown missing title heading\n%s", res.Markdown)
}
if strings.Contains(res.Markdown, "## Endpoints") {
t.Errorf("markdown should not have Endpoints section for empty paths\n%s", res.Markdown)
}
}
func TestConvertOpenAPI_PathLevelParametersMerged(t *testing.T) {
// Path-level parameters apply to every operation on that path.
// Operation-level parameters override path-level ones on a
// (name, in) match. Both rules must hold.
spec := []byte(`openapi: 3.0.0
info:
title: Param Merge Test
version: "1.0"
paths:
/widgets/{widgetId}:
parameters:
- name: widgetId
in: path
required: true
description: Path-level widget id
schema:
type: string
- name: trace
in: header
required: false
description: Trace header from path-level
schema:
type: string
get:
summary: Get a widget
parameters:
- name: trace
in: header
required: true
description: Operation-level trace (overrides path-level)
schema:
type: string
- name: fields
in: query
required: false
description: Op-only filter
schema:
type: string
responses:
'200':
description: ok
`)
res, err := ConvertOpenAPI(spec, "")
if err != nil {
t.Fatalf("ConvertOpenAPI: %v", err)
}
md := res.Markdown
// Path-level parameter survives merge.
if !strings.Contains(md, "| `widgetId` | path | yes |") {
t.Errorf("missing path-level widgetId row in:\n%s", md)
}
// Operation-level override wins on (name, in) collision: trace
// goes from required=false (path) to required=yes (op).
if !strings.Contains(md, "| `trace` | header | yes |") {
t.Errorf("op-level trace override missing or didn't win in:\n%s", md)
}
if strings.Contains(md, "| `trace` | header | no |") {
t.Errorf("path-level trace row leaked in; op-level should override:\n%s", md)
}
// Op-only parameter present.
if !strings.Contains(md, "| `fields` | query |") {
t.Errorf("missing op-only fields row in:\n%s", md)
}
}
func TestMergeParameters_EmptyInputs(t *testing.T) {
if got := mergeParameters(nil, nil); got != nil {
t.Errorf("mergeParameters(nil, nil) = %v, want nil", got)
}
}
func TestConvertOpenAPI_ArrayOfRefTypeCell(t *testing.T) {
// Schema properties that are arrays of component refs must render
// as a single balanced backtick-wrapped type cell. The previous
// schemaTypeBrief→codeOrBlank pipeline could double-wrap and emit
// "`array of `Pet``" which breaks the table.
spec := []byte(`openapi: 3.0.0
info:
title: Array Of Ref Test
version: "1.0"
paths: {}
components:
schemas:
Pet:
type: object
properties:
name:
type: string
Litter:
type: object
properties:
pets:
type: array
items:
$ref: '#/components/schemas/Pet'
`)
res, err := ConvertOpenAPI(spec, "")
if err != nil {
t.Fatalf("ConvertOpenAPI: %v", err)
}
want := "| `pets` | `array of Pet` |"
if !strings.Contains(res.Markdown, want) {
t.Errorf("missing %q in:\n%s", want, res.Markdown)
}
// Negative: no double-backticked nor stray-backticked variants.
bad := []string{
"`array of `Pet``",
"``array",
"Pet``",
}
for _, b := range bad {
if strings.Contains(res.Markdown, b) {
t.Errorf("found malformed type cell substring %q in:\n%s", b, res.Markdown)
}
}
}
func TestCodeOrBlank(t *testing.T) {
tests := []struct{ in, want string }{
{"", ""},
{"string", "`string`"},
{" string ", "`string`"},
{"array of Pet", "`array of Pet`"},
{"`already`", "`already`"},
{"with`backtick`inside", "`withbacktickinside`"},
{"`", ""},
}
for _, tc := range tests {
if got := codeOrBlank(tc.in); got != tc.want {
t.Errorf("codeOrBlank(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSchemaTypeBrief_Nil(t *testing.T) {
if got := schemaTypeBrief(nil); got != "" {
t.Errorf("schemaTypeBrief(nil) = %q, want empty", got)
}
}
func TestEscapeTableCell(t *testing.T) {
tests := []struct{ in, want string }{
{`plain text`, `plain text`},
{`with | pipe`, `with \| pipe`},
{`back\slash`, `back\\slash`},
{`both | and \`, `both \| and \\`},
{``, ``},
}
for _, tc := range tests {
if got := escapeTableCell(tc.in); got != tc.want {
t.Errorf("escapeTableCell(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSingleLine(t *testing.T) {
tests := []struct{ in, want string }{
{"single line", "single line"},
{"multi\nline\ntext", "multi line text"},
{" whitespace collapsed ", "whitespace collapsed"},
{"with | pipe", "with \\| pipe"},
{"", ""},
}
for _, tc := range tests {
if got := singleLine(tc.in); got != tc.want {
t.Errorf("singleLine(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}