Skip to content

[EPIC] OpenAPI bridge — ingest OpenAPI operations into the Endpoint/RequestExample/FieldTable kit #40

Description

@mhenrixon

Problem / Goal

Epic #8 explicitly deferred an OpenAPI → docs bridge as "a natural follow-up epic once #14/#15 exist as render targets." Those render targets now all ship on main:

So the precondition the epic named is met. Today an API site still authors an endpoint's method/path/params/responses by hand in a Phlex page, even when it already maintains an openapi.yaml (app-4 generates one from its request specs, but it's unconnected to the docs). This is the last big restatement source the DX overhaul didn't close.

Goal

Ingest OpenAPI 3.x operations and render them through the existing kit with zero hand-restatement: one openapi.yaml operation → a DocsUI::Endpoint (method/path/summary), its parameters/request body → FieldTable, its responses → ErrorTable + JsonResponse (from the schema/examples), and its x-code-samples (or a generated ApiRequest) → RequestExample tabs.

Likely shape (to be designed)

  • A registry-level source: DocsKit::OpenApi.load("openapi.yaml") yielding operation objects.
  • A page helper: endpoint operation("createInvoice") that expands to the full Endpoint + tables + examples.
  • Schema → FieldTable rows (name/type/required/description) and $ref resolution.
  • Response examples → JsonResponse; the ApiRequest value object as the bridge for generating client tabs where x-code-samples are absent.

Invariants (inherited from #8)

Chrome is DocsUI:: components only; site values come from DocsKit.configuration with defaults; pages work with JS off; the ONE docs-nav controller; TDD (specs before code); the .md twin + llms.txt/search/MCP surfaces must derive from the rendered output for free.

Out of scope (initial)

  • Authoring OpenAPI itself (sites bring their own spec).
  • OpenAPI 2.0 / Swagger, AsyncAPI, GraphQL SDL.
  • Round-tripping docs → spec.

Context

https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX


Plan (designed 2026-07-03)

Context (read these first)

  • lib/docs_kit/configuration.rb — the config surface. The new c.openapi knob lands beside the existing API knobs (api_base_url / api_auth_header / api_clients, ~lines 167–254). Note the house pattern: attr_writer + a resolving reader (#api_clients, #search_shortcuts) and degrade-gracefully readers.
  • lib/docs_kit.rb — zeitwerk setup. lib/docs_kit/open_api.rb autoloads as DocsKit::OpenApi with no inflection changes (default camelize: open_apiOpenApi); a lib/docs_kit/open_api/ directory maps to DocsKit::OpenApi::*.
  • app/components/docs_ui/endpoint.rbEndpoint.new(method, path), renders inline; unknown verb degrades to a neutral badge.
  • app/components/docs_ui/field_table.rbFieldTable.new([{name:, type:, required: false, description:}]); a description cell accepts [:md, "…"] for inline Markdown (OpenAPI descriptions are CommonMark — use this form).
  • app/components/docs_ui/error_table.rbErrorTable.new([{scenario:, status:, type:, param: nil}]). type is currently required via fetch — this plan makes it optional (see Decision).
  • app/components/docs_ui/request_example.rbRequestExample.new(method:, path:, body: nil, query: nil, headers: {}, clients: nil). It builds the DocsKit::ApiRequest internally from config (api_base_url + api_auth_header); the bridge only feeds it kwargs and must NOT construct ApiRequest itself.
  • app/components/docs_ui/json_response.rbJsonResponse.new(body, filename: "response.json"); a Hash is deep-stringified.
  • app/components/docs_ui/example.rbExample needs ≥ 2 tabs to render tabs (a lone tab renders nothing) — drives the x-codeSamples handling below.
  • app/components/docs_ui/page_helpers.rb — the lowercase authoring helpers (md/prose/example), extracted from Page so they unit-test against a bare Phlex host (DocsUI::Page itself cannot load in the standalone suite — it includes live Rails helper modules). The new operation helper lives here.
  • app/components/docs_ui/section.rbSection.new(title, id: nil, description: nil); description: accepts a Phlex component (the dogfood API page passes a DocsUI::Endpoint — same shape here).
  • lib/docs_kit/markdown_export.rb — the .md twin (and llms.txt / search / MCP, which all converge on it) walks the rendered HTML with a Nokogiri visitor. A component composed from existing kit pieces exports for free; no markdown work is needed in this epic.
  • docs/app/views/docs/pages/api.rb — the dogfood page that hand-authors exactly what this bridge generates ("A full endpoint, live" section). The new dogfood page sits beside it.
  • lib/generators/docs_kit/install/templates/docs_kit.rb.erb, skill.md.erb, agents_md.erb; lib/docs_kit/templates/new_site.rb — the install path (Critical Rule 8: new setup wires into the generator AND the new-site template, never README-only).
  • Spec conventions to mirror: spec/docs_ui/request_example_spec.rb (component render, semantics-not-snapshots, config reset), spec/docs_kit/configuration_spec.rb (100%-coverage aspiration), spec/docs_ui/page_helpers_spec.rb (bare Phlex host).

Decision

Parse the spec with stdlib (Psych/JSON) into a narrow, gem-owned object model — DocsKit::OpenApi::Document + DocsKit::OpenApi::Operation — then render through ONE new composing component DocsUI::OpenApiOperation and one lowercase page helper operation. No new runtime dependency; the model exposes only what the existing render targets consume (field rows, error rows, example bodies, code samples).

Alternatives rejected:

  1. Add openapi3_parser (or similar) as a dependency — every consuming site pays a runtime dep for an opt-in feature (the gemspec is deliberately minimal — nokogiri/commonmarker earn their place by powering core features); its validation strictness can reject real-world specs that would render fine; and the kit needs only a narrow slice (operation lookup, schema properties, examples), not a full validating object graph.
  2. Build-time codegen — a generator that reads openapi.yaml and writes Phlex pages — restatement returns the moment the spec changes: regeneration fights hand edits, docs drift from the spec again, and merge conflicts replace the typing it saved. Runtime derivation is what makes it zero-restatement. (A future generator could scaffold page stubs that call the helper — out of scope.)

Key sub-decisions (an executor should not re-litigate these):

  • Loud on lookup, graceful on rendering. An unknown operationId raises DocsKit::OpenApi::OperationNotFound naming the available ids — a missing operation has nothing useful to degrade to (unlike Endpoint's neutral badge). Missing examples/blocks degrade gracefully: no request body → no body table; no 2xx example and nothing synthesizable → no JsonResponse block.
  • Local $ref only (#/components/...), resolved cycle-safely (track visited refs; on a cycle stop descending and label the type object). An external/remote ref raises DocsKit::OpenApi::UnsupportedRef naming the ref. allOf is shallow-merged; oneOf/anyOf render as the type label one of: A | B without deep expansion.
  • ErrorTable#type becomes optional (em-dash , not code-styled, when absent — mirroring its existing param handling). OpenAPI has no canonical error-type field; when a 4xx/5xx response's example body carries a top-level "type" string the bridge passes it through, otherwise the row has no type. Backwards compatible: rows that pass type: render exactly as today.
  • Example precedence: explicit example → first entry of examples → synthesized from the schema (per-property exampledefault → first enum value → type placeholder: "string" / 0 / true / [] / {}), depth-capped and cycle-safe.
  • x-codeSamples / x-code-samples (both spellings) win over the generated RequestExample: ≥ 2 samples → DocsUI::Example tabs; exactly 1 → a plain DocsUI::Code (Example needs two tabs).
  • Paths stay spec-verbatim (/v1/invoices/{id}) in the Endpoint badge; in the RequestExample URL a path parameter's example value is substituted when present (copy-pasteable snippet), else the {id} literal stays. Query params appear in the snippet only when they carry an explicit example (required-but-example-less params stay documentation-only, never invent values).
  • The config knob is c.openapi — a String/Pathname path or an already-parsed Hash, default nil (fully backwards compatible). Configuration#openapi_document lazily loads + memoizes, and re-loads when the file's mtime changes (so editing openapi.yaml in dev is picked up without a restart); reading it while c.openapi is unset raises a DocsKit::Error that names the knob.
  • One helper, operation (not the sketched endpoint operation(...) pair): operation "createInvoice" looks up + renders in one call — the primary arg positional per the kit's authoring convention. Bespoke composition stays possible via DocsKit.configuration.openapi_document.operation(...) and the component directly. (endpoint as a helper name would collide semantically with DocsUI::Endpoint, which is just the badge line.)
  • What the component renders (DocsUI::OpenApiOperation.new(operation, clients: nil) { ... }): a DocsUI::Section titled with the operation summary (id: the operationId, so deep links + the auto-TOC work), description: a DocsUI::Endpoint badge; then the operation description as Markdown (if any); a parameters FieldTable and a request-body FieldTable (each preceded by a one-line label only when both are present); an ErrorTable from the 4xx/5xx responses (if any); the request block (x-codeSamples or RequestExample, with clients: passed through); a JsonResponse from the first 2xx response (if derivable); finally the passed block, so a page can append hand-authored prose/callouts inside the same section.

Implementation steps (TDD — each spec lands RED before its code)

Milestone 1 — the object model (Layer 2, no Rails)

  1. Create the fixture spec/fixtures/openapi.yaml: 2–3 operations covering query + path parameters (with and without example), a requestBody with a $ref into components/schemas, a nested object + an array-of property, 200/401/422 responses (one with an explicit example, one examples, one schema-only), one operation with x-codeSamples, a YAML anchor/alias, an allOf, and a deliberate schema cycle.
  2. spec/docs_kit/open_api/document_spec.rb (RED): DocsKit::OpenApi.load from a .yaml path, a .json path, and a Hash; #operation("opId"); #operation(:post, "/v1/invoices") (method + path lookup for specs without operationIds); #operations enumeration; local $ref resolution incl. the cycle; OperationNotFound message lists available ids; UnsupportedRef on an external ref.
  3. Implement lib/docs_kit/open_api.rb (module, .load, the error classes) and lib/docs_kit/open_api/document.rb. YAML via YAML.safe_load(..., aliases: true, permitted_classes: [Date, Time]); keys stay strings internally.
  4. spec/docs_kit/open_api/operation_spec.rb (RED): #method/#path/#summary/#description/#operation_id/#deprecated?; #parameter_rows + #body_rows as FieldTable-ready hashes (name, type labels incl. array of string, required flags, [:md, description] cells, dotted names for nested objects with a depth cap); #error_rows; #example_body precedence chain; #example_path substitution; #example_query; #success_example (status + body); #code_samples under both key spellings.
  5. Implement lib/docs_kit/open_api/operation.rb and lib/docs_kit/open_api/schema.rb (type labels, row flattening, example synthesis). Respect the file-size rule — split an example_builder.rb out of schema.rb if it grows past ~300 lines.

Milestone 2 — the config knob (Layer 1)

  1. Extend spec/docs_kit/configuration_spec.rb (RED): c.openapi defaults to nil; accepts String/Pathname/Hash; #openapi_document returns a Document, memoizes, re-loads on file mtime change; raises DocsKit::Error naming c.openapi when read unset. (Configuration aspires to 100% coverage — cover every branch.)
  2. Implement in lib/docs_kit/configuration.rb.

Milestone 3 — components + helper (Layer 3)

  1. Extend spec/docs_ui/error_table_spec.rb (RED): a row without type: renders the em-dash (not code-styled); rows with type: unchanged. Implement in error_table.rb (fetch(:type) → optional, mirroring the param pattern).
  2. spec/docs_ui/open_api_operation_spec.rb (RED), rendering against the fixture: Section heading = summary with id: = operationId; the Endpoint badge; parameters + request-body FieldTables with their labels (and a single unlabelled table when only one source exists); ErrorTable rows from 4xx/5xx; RequestExample tabs incl. clients: passthrough; JsonResponse presence; x-codeSamples override (1 sample → Code, ≥ 2 → Example tabs); block passthrough renders after the generated content; operation description renders as Markdown. Assert semantics, never full-HTML snapshots.
  3. Implement app/components/docs_ui/open_api_operation.rb. Compose ONLY existing DocsUI:: components — no raw daisyUI markup, no new literal Tailwind classes expected.
  4. Extend spec/docs_ui/page_helpers_spec.rb (RED): on a bare Phlex host with DocsKit.configure { |c| c.openapi = fixture_path }, operation "opId" renders the operation; clients: and a block pass through; reset configuration around examples. Implement operation(operation_id, clients: nil, &block) in page_helpers.rb.

Milestone 4 — install path, docs, dogfood

  1. Generator wiring (Critical Rule 8): add a commented # c.openapi = Rails.root.join("openapi.yaml") (with a one-line explainer) beside the api_* knobs in lib/generators/docs_kit/install/templates/docs_kit.rb.erb; add the operation helper to the authoring contracts in skill.md.erb and agents_md.erb. Extend spec/generators/install_generator_spec.rb (RED first) to assert the knob comment lands in the generated initializer. Verify lib/docs_kit/templates/new_site.rb reaches the initializer via docs_kit:install (it should — if it embeds its own initializer copy, mirror the change there too).
  2. README: an "OpenAPI bridge" subsection inside the existing "API docs — one request, every client tab" section — load the spec, the operation helper, the operation → component mapping table, x-codeSamples, and the out-of-scope list.
  3. Dogfood (pattern from docs(dogfood): rewrite the docs-kit docs site to cover the whole gem #44): add docs/openapi.yaml (a small realistic sample), set c.openapi in docs/config/initializers/docs_kit.rb, and author docs/app/views/docs/pages/open_api.rb (page "OpenAPI", group "Authoring") that documents the bridge AND renders operation live — beside the hand-authored api.rb it supersedes for spec-backed sites.

Verification gates

  • bundle exec rspec — all green; SimpleCov ≥ 80% overall (Configuration at 100%).
  • bundle exec rubocop — no offenses (the shipped docs-kit cops included).
  • CSS contract: NO new emitted classes are expected (the component only composes existing kit pieces). If implementation does add a literal class, follow Critical Rule 6 (@source scan / bun run build:css in a consuming site) before merging.
  • Backwards compat: a site that never sets c.openapi renders byte-identical; ErrorTable rows passing type: render exactly as before.
  • Derived surfaces for free: on the dogfood site, GET /docs/open-api.md shows the operation's heading, field tables, and code fences (manual check — no export code should be needed).

Out of scope

  • Authoring or validating OpenAPI (sites bring their own spec; invalid specs may raise at load — that's acceptable).
  • OpenAPI 2.0 / Swagger, AsyncAPI, GraphQL SDL; round-tripping docs → spec.
  • External-file / remote $refs (raise UnsupportedRef).
  • Auto-generating registry pages per tag/operation (a follow-up generator could scaffold page stubs that call operation).
  • Rendering webhooks/callbacks, security schemes, or server variables.
  • New Stimulus behavior of any kind (the ONE docs-nav controller already covers the tabs via DocsUI::Example).

Execution

Execute with /lfg 40. Natural PR split if the diff grows: Milestones 1–2 (the lib layer, no Rails) first, then 3–4 (components + install path + dogfood).

https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions