Skip to content

feat(oas3): make OpenAPI Links interactive via a "Follow Link" action - #11026

Open
manwar wants to merge 1 commit into
swagger-api:mainfrom
manwar:feature/interactive-openapi-links
Open

feat(oas3): make OpenAPI Links interactive via a "Follow Link" action#11026
manwar wants to merge 1 commit into
swagger-api:mainfrom
manwar:feature/interactive-openapi-links

Conversation

@manwar

@manwar manwar commented Aug 27, 2026

Copy link
Copy Markdown

Make OpenAPI Links interactive via a "Follow Link" action

Closes #7533

Summary

links on an OpenAPI response are currently rendered as inert text, a name, a description, the target operationId, and a raw JSON dump of parameters -- with no way to actually act on them. This PR adds a "Follow Link" button that expands the linked operation, scrolls to it, and resolves + pre-fills its parameters using the response that was just received, matching what the OpenAPI Link Object was designed to enable.

What changed

File Change
src/core/plugins/spec/selectors.js New operationById(state, operationId) selector
src/core/plugins/oas3/actions.js New executeLink(payload) thunk action
src/core/plugins/oas3/components/operation-link.jsx responseContext prop typed as ImPropTypes.iterable
src/core/components/response.jsx Forwards oas3Actions / responseContext to OperationLink
src/core/components/responses.jsx Computes responseContext, scoped to the matching response row
test/unit/core/plugins/spec/selectors.js New operationById suite
test/unit/core/plugins/oas3/actions.js New file, full executeLink suite

Design notes

A few of these decisions aren't obvious from the diff alone, and each one corresponds to a real bug that only surfaced under live testing in a browser, documenting them here so a review doesn't have to rediscover the same ground.

operationById is a plain function, not a curried selector. The natural-looking implementation is createSelector(operations, (operations) => (operationId) => {...}). That's wrong in this codebase specifically: the plugin system's selector binding (getBoundSelectors in core/system.js) calls a bound selector as fn(state, ...args), and if the result of that call is itself a function, the system assumes it's a getSystem-consuming selector and invokes that returned function with the whole system object, not the caller's original arguments. A curried selector's inner function is exactly such a "result that is a function," so specSelectors.operationById(operationId) would silently receive the system object in place of operationId, with no error. operationById is written as a plain (state, operationId) function instead, matching the existing responseFor/requestFor/ findDefinition pattern already in this file.

The expand/scroll target key is ["operations", tag, operationId], not [path, method]. The tag-based key is what
OperationContainer's own isShownKey/toggleShown and operation.jsx's rendered element id both actually use. A
[path, method]-based key looks equally plausible but writes to a piece of layout state nothing else reads, so the operation panel never opens and nothing indicates why.

Scrolling uses the real rendered DOM id, not the deep-linking plugin's scrollTo/readyToScroll. That mechanism is a one-shot check: each operation's wrapping ref callback fires once, at mount, and checks whether the current scroll target already matches its own key -- designed for the "page loads with a URL hash already set" case. It does not re-fire for an operation that's already mounted on an already-loaded page, which is always the case for a click-driven navigation like this one. Instead, executeLink looks up the operation's real DOM node directly via escapeDeepLinkPath(["operations", tag, operationId].join("-")), the same id operation.jsx sets on its own root element -- and calls scrollIntoView on it after a setTimeout(0) to let the just-dispatched show action's re-render complete first.

changeParam is called positionally, not with an options object. Its real signature is changeParam(pathMethod, paramName, paramIn, value, isXml), and there is no isOas3 flag on it at all, plain parameter editing is shared between OAS2 and OAS3 specs in this codebase. Calling it with an options object instead threw deep inside the parameter-update reducer (TypeError: i is not iterable), since the reducer destructures its first argument as the [path, method] tuple it expects.

Resolved parameter values are coerced to strings. Parameter state is always driven from text inputs, so it's string-typed regardless of what a resolved runtime expression naturally is (a $response.body#/id pointer into {"id": 2} resolves to the JS number 2). Numbers, booleans, and objects are coerced (String(...) / JSON.stringify(...) respectively) before being handed to changeParam.

What's intentionally out of scope

  • operationRef targets. Only operationId-based links render a "Follow Link" button; operationRef links still render as text-only, same as before this change.
  • Runtime expressions other than $response.body#/.... $request.*, $url, $method, $statusCode, and $response.header.* are valid per spec but not resolved here; they pass through as their literal, unresolved expression string.
  • Path-item-level shared parameters. A parameter declared once at the path-item level and shared across multiple HTTP methods, rather than redeclared per-operation, isn't currently merged in by the operations selector this is built on. The path-template check ({paramName} presence) still correctly identifies it as a path parameter in that case; only the fallback lookup into an operation's own declared parameter list would miss it.

Any of these would be reasonable, separable follow-ups if there's interest.

Testing

  • test/unit/core/plugins/spec/selectors.js: new operationById suite runs against the real operations -> paths ->
    specJsonWithResolvedSubtrees selector chain (no mocking of the selector's own dependencies), plus an explicit assertion on the exported function's arity (operationById.length === 2) as a standing regression guard against the curried-selector bug described above. Includes a case against the repo's own Petstore fixture.
  • test/unit/core/plugins/oas3/actions.js: new file. Covers the tag-based expand key and its "default" fallback, the positional changeParam call, value coercion, JSON Pointer resolution (including a multi-segment case), path- vs. query-parameter inference, the real scroll-target DOM id (via a live jsdom element), and both console.warn failure branches.
  • Manually verified end-to-end against a locally running API with a real OpenAPI document containing operationId-based links with both empty and non-empty parameters maps, including a link targeting a path parameter with a $response.body#/id runtime expression: clicking "Follow Link" correctly expands and scrolls to
    the target operation and pre-fills its parameter field with the real value from the response.

Checklist

  • New behavior covered by unit tests
  • Existing unit tests unaffected (npm run test:unit)

Closes swagger-api#7533

OpenAPI `links` on a response were rendered as inert text only (link
name, description, target operationId, raw parameters JSON) with no
way to actually act on them. This adds a "Follow Link" button that
expands the linked operation, scrolls to it, and resolves + pre-fills
its parameters using the response that was just received.

New

* `specSelectors.operationById(operationId)`
  Finds an operation's flattened entry (path, method, operation, id,
  specPath) by its declared operationId. Deliberately a plain
  `(state, operationId)` function, not a curried `createSelector`.
  The plugin system's selector binding (`getBoundSelectors` in
  core/system.js) invokes a bound selector as `fn(state, ...args)`;
  if the result of that call is itself a function, the system treats
  it as a getSystem-consuming selector and invokes it with the whole
  system object instead of the caller's arguments. A curried
  `createSelector(operations, (operations) => (operationId) => {...})`
  here returns exactly such a function as its result, so
  `specSelectors.operationById(operationId)` would silently receive
  the system object in place of `operationId`. Matching the plain
  function pattern already used by `responseFor`/`requestFor`/
  `findDefinition` elsewhere in this file avoids the class of bug
  entirely.

* `oas3Actions.executeLink({ operationId, parameters, responseContext })`
  The thunk `OperationLink`'s "Follow Link" button dispatches.
  - Resolves the target operation via `operationById`.
  - Expands it under `["operations", tag, operationId]`, reading the
    tag directly off the operation object and falling back to
    Swagger UI's own "default" tag grouping when none is declared.
    (Not `["operations", path, method]`, that key is never read by
    anything and silently no-ops.)
  - Resolves each parameter's runtime expression against the
    response body. Supports `$response.body#/{json-pointer}`,
    including multi-segment pointers with RFC 6901 `~0`/`~1`
    unescaping. Other runtime expression types (`$request.*`, `$url`,
    `$method`, `$statusCode`, `$response.header.*`) and constant
    values pass through unresolved.
  - Infers `path` vs `query` parameter location from the target's
    path template (`{paramName}` presence), since parameters
    declared once at the shared path-item level rather than
    per-operation aren't currently merged into `operations`.
  - Coerces resolved values to strings before dispatch; parameter
    state is always string-driven from text inputs, regardless of
    the source value's JSON type.
  - Calls `specActions.changeParam` using its real positional
    signature, `(pathMethod, paramName, paramIn, value, isXml)`,
    not an options object, and with no OAS3-specific flag (plain
    parameter editing is shared between OAS2 and OAS3 specs here).
  - Scrolls to the target using the same DOM id `operation.jsx`
    itself renders on its root element
    (`escapeDeepLinkPath(["operations", tag, operationId].join("-"))`),
    not the deep-linking plugin's `readyToScroll`/`scrollTo`
    mechanism. That mechanism is a one-shot check that only fires
    once, at mount time, against whatever scroll target the URL hash
    already named when the operation first mounted, it does not
    fire again for an operation that's already mounted on an
    already-loaded page, which is always our case here.
  - Logs a `console.warn` (never throws or fails silently) when the
    link has no `operationId`, or when `operationId` doesn't resolve
    to a real operation.

Changed

* `OperationLink`: `responseContext` prop typed as
  `ImPropTypes.iterable`, matching its real (Immutable) runtime
  shape, consistent with how `link` is already typed in this file.
* `Response`: forwards `oas3Actions` and `responseContext` down to
  `OperationLink` (previously neither was passed through at all, so
  the "Follow Link" button's own click guard silently no-op'd).
* `Responses`: computes `responseContext`, scoped to only the
  response row whose status code matches what "Try it out" actually
  returned (reusing the `tryItOutResponse.get("status") == code`
  check this file already computes for its own `response_current`
  styling), so a link's `$response.body#/...` parameters resolve
  against the real result rather than an unrelated documented
  response row.

Known limitations (intentionally out of scope for this change)

* `operationRef` targets are not supported; only `operationId`-based
  links render a "Follow Link" button.
* Path parameters declared once at the path-item level rather than
  per-operation aren't merged in by `operations`; the path-template
  check covers the common case, but a declared `in` for such a
  parameter won't be found via `declaredParams`.

Tests

* test/unit/core/plugins/spec/selectors.js: new `operationById`
  suite, including an explicit arity assertion
  (`operationById.length === 2`) that guards against a regression
  back to the curried shape described above.
* test/unit/core/plugins/oas3/actions.js: new file. Covers the
  tag-based expand key and "default" fallback, the positional
  `changeParam` call, JSON value-to-string coercion, JSON Pointer
  resolution (including a multi-segment case), path- vs
  query-parameter inference, the real scroll-target DOM id via a
  live jsdom element, and both `console.warn` branches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How to use OpenAPI feature LINKS in Swagger-UI? It does not seem to work properly

1 participant