diff --git a/README.md b/README.md
index 3ba6b44e..560dfed3 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,6 @@ To help with the migration, here are the currently known issues with 3.X. This l
- Only part of the [parameters](#parameters) previously supported are available.
- The JSON Form Editor is not implemented.
-- Shebang URL support for operations is missing.
- Support for `collectionFormat` is partial.
- l10n (translations) is not implemented.
- Relative path support for external files is not implemented.
diff --git a/docs/deep-linking.md b/docs/deep-linking.md
new file mode 100644
index 00000000..8fd45497
--- /dev/null
+++ b/docs/deep-linking.md
@@ -0,0 +1,36 @@
+# Deep linking
+
+Swagger-UI allows you to deeply link into tags and operations within a spec. When Swagger-UI is provided a URL fragment at runtime, it will automatically expand and scroll to a specified tag or operation.
+
+## Usage
+
+👉🏼 Add `deepLinking: true` to your Swagger-UI configuration to enable this functionality.
+
+When you expand a tag or operation, Swagger-UI will automatically update its URL fragment with a deep link to the item.
+Conversely, when you collapse a tag or operation, Swagger-UI will clear the URL fragment.
+
+You can also right-click a tag name or operation path in order to copy a link to that tag or operation.
+
+#### Fragment format
+
+The fragment is formatted in one of two ways:
+
+- `#/{tagName}`, to trigger the focus of a specific tag
+- `#/{tagName}/{operationId}`, to trigger the focus of a specific operation within a tag
+
+`operationId` is the explicit operationId provided in the spec, if one exists.
+Otherwise, Swagger-UI generates an implicit operationId by combining the operation's path and method, and escaping non-alphanumeric characters.
+
+## FAQ
+
+> I'm using Swagger-UI in an application that needs control of the URL fragment. How do I disable deep-linking?
+
+This functionality is disabled by default, but you can pass `deepLinking: false` into Swagger-UI as a configuration item to be sure.
+
+> Can I link to multiple tags or operations?
+
+No, this is not supported.
+
+> Can I collapse everything except the operation or tag I'm linking to?
+
+Sure - use `docExpansion: none` to collapse all tags and operations. Your deep link will take precedence over the setting, so only the tag or operation you've specified will be expanded.
diff --git a/package.json b/package.json
index 895e22dd..1893dc7f 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"redux-logger": "*",
"reselect": "2.5.3",
"sanitize-html": "^1.14.1",
+ "scroll-to-element": "^2.0.0",
"serialize-error": "2.0.0",
"shallowequal": "0.2.2",
"swagger-client": "3.0.17",
diff --git a/src/core/components/operation.jsx b/src/core/components/operation.jsx
index 52807722..a8b975f8 100644
--- a/src/core/components/operation.jsx
+++ b/src/core/components/operation.jsx
@@ -116,7 +116,8 @@ export default class Operation extends PureComponent {
specActions,
specSelectors,
authActions,
- authSelectors
+ authSelectors,
+ getConfigs
} = this.props
let summary = operation.get("summary")
@@ -141,6 +142,10 @@ export default class Operation extends PureComponent {
const Markdown = getComponent( "Markdown" )
const Schemes = getComponent( "schemes" )
+ const { deepLinking } = getConfigs()
+
+ const isDeepLinkingEnabled = deepLinking && deepLinking !== "false"
+
// Merge in Live Response
if(response && response.size > 0) {
let notDocumented = !responses.get(String(response.get("status")))
@@ -152,13 +157,18 @@ export default class Operation extends PureComponent {
let onChangeKey = [ path, method ] // Used to add values to _this_ operation ( indexed by path and method )
return (
-
+
-
{method.toUpperCase()}
-
- {path}
-
-
+
{method.toUpperCase()}
+
+ e.preventDefault()}
+ href={ isDeepLinkingEnabled ? `#/${isShownKey[1]}/${isShownKey[2]}` : ""} >
+ {path}
+
+
+
{ !showSummary ? null :
diff --git a/src/core/components/operations.jsx b/src/core/components/operations.jsx
index 679af129..c9031651 100644
--- a/src/core/components/operations.jsx
+++ b/src/core/components/operations.jsx
@@ -1,5 +1,8 @@
import React from "react"
import PropTypes from "prop-types"
+import { helpers } from "swagger-client"
+
+const { opId } = helpers
export default class Operations extends React.Component {
@@ -33,7 +36,15 @@ export default class Operations extends React.Component {
const Collapse = getComponent("Collapse")
let showSummary = layoutSelectors.showSummary()
- let { docExpansion, displayOperationId, displayRequestDuration, maxDisplayedTags } = getConfigs()
+ let {
+ docExpansion,
+ displayOperationId,
+ displayRequestDuration,
+ maxDisplayedTags,
+ deepLinking
+ } = getConfigs()
+
+ const isDeepLinkingEnabled = deepLinking && deepLinking !== "false"
let filter = layoutSelectors.currentFilter()
@@ -62,8 +73,16 @@ export default class Operations extends React.Component {
return (
-
layoutActions.show(isShownKey, !showTag)} className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag" }>
- {tag}
+ layoutActions.show(isShownKey, !showTag)}
+ className={!tagDescription ? "opblock-tag no-desc" : "opblock-tag" }
+ id={isShownKey.join("-")}>
+ e.preventDefault()}
+ href={ isDeepLinkingEnabled ? `#/${tag}` : ""}>
+ {tag}
+
{ !tagDescription ? null :
{ tagDescription }
@@ -81,11 +100,14 @@ export default class Operations extends React.Component {
{
operations.map( op => {
- const isShownKey = ["operations", op.get("id"), tag]
const path = op.get("path", "")
const method = op.get("method", "")
const jumpToKey = `paths.${path}.${method}`
+ const operationId =
+ op.getIn(["operation", "operationId"]) || op.getIn(["operation", "__originalOperationId"]) || opId(op.get("operation"), path, method) || op.get("id")
+ const isShownKey = ["operations", tag, operationId]
+
const allowTryItOut = specSelectors.allowTryItOutFor(op.get("path"), op.get("method"))
const response = specSelectors.responseFor(op.get("path"), op.get("method"))
const request = specSelectors.requestFor(op.get("path"), op.get("method"))
diff --git a/src/core/index.js b/src/core/index.js
index fa5db888..c0ba97c8 100644
--- a/src/core/index.js
+++ b/src/core/index.js
@@ -30,6 +30,7 @@ const CONFIGS = [
"parameterMacro",
"displayOperationId",
"displayRequestDuration",
+ "deepLinking",
]
// eslint-disable-next-line no-undef
@@ -61,6 +62,7 @@ module.exports = function SwaggerUI(opts) {
custom: {},
displayOperationId: false,
displayRequestDuration: false,
+ deepLinking: false,
// Initial set of plugins ( TODO rename this, or refactor - we don't need presets _and_ plugins. Its just there for performance.
// Instead, we can compile the first plugin ( it can be a collection of plugins ), then batch the rest.
diff --git a/src/core/plugins/deep-linking/README.md b/src/core/plugins/deep-linking/README.md
new file mode 100644
index 00000000..5f417982
--- /dev/null
+++ b/src/core/plugins/deep-linking/README.md
@@ -0,0 +1 @@
+See `docs/deep-linking.md`.
diff --git a/src/core/plugins/deep-linking/helpers.js b/src/core/plugins/deep-linking/helpers.js
new file mode 100644
index 00000000..d06bbe27
--- /dev/null
+++ b/src/core/plugins/deep-linking/helpers.js
@@ -0,0 +1,7 @@
+export const setHash = (value) => {
+ if(value) {
+ return history.pushState(null, null, `#${value}`)
+ } else {
+ return window.location.hash = ""
+ }
+}
diff --git a/src/core/plugins/deep-linking/index.js b/src/core/plugins/deep-linking/index.js
new file mode 100644
index 00000000..8cec4dd5
--- /dev/null
+++ b/src/core/plugins/deep-linking/index.js
@@ -0,0 +1,18 @@
+// import reducers from "./reducers"
+// import * as actions from "./actions"
+// import * as selectors from "./selectors"
+import * as specWrapActions from "./spec-wrap-actions"
+import * as layoutWrapActions from "./layout-wrap-actions"
+
+export default function() {
+ return {
+ statePlugins: {
+ spec: {
+ wrapActions: specWrapActions
+ },
+ layout: {
+ wrapActions: layoutWrapActions
+ }
+ }
+ }
+}
diff --git a/src/core/plugins/deep-linking/layout-wrap-actions.js b/src/core/plugins/deep-linking/layout-wrap-actions.js
new file mode 100644
index 00000000..72d98948
--- /dev/null
+++ b/src/core/plugins/deep-linking/layout-wrap-actions.js
@@ -0,0 +1,36 @@
+import { setHash } from "./helpers"
+
+export const show = (ori, { getConfigs }) => (...args) => {
+ ori(...args)
+
+ const isDeepLinkingEnabled = getConfigs().deepLinking
+ if(!isDeepLinkingEnabled || isDeepLinkingEnabled === "false") {
+ return
+ }
+
+ try {
+ let [thing, shown] = args
+ let [type] = thing
+
+ if(type === "operations-tag" || type === "operations") {
+ if(!shown) {
+ return setHash("/")
+ }
+
+ if(type === "operations") {
+ let [, tag, operationId] = thing
+ setHash(`/${tag}/${operationId}`)
+ }
+
+ if(type === "operations-tag") {
+ let [, tag] = thing
+ setHash(`/${tag}`)
+ }
+ }
+
+ } catch(e) {
+ // This functionality is not mission critical, so if something goes wrong
+ // we'll just move on
+ console.error(e)
+ }
+}
diff --git a/src/core/plugins/deep-linking/spec-wrap-actions.js b/src/core/plugins/deep-linking/spec-wrap-actions.js
new file mode 100644
index 00000000..bb13a6ff
--- /dev/null
+++ b/src/core/plugins/deep-linking/spec-wrap-actions.js
@@ -0,0 +1,51 @@
+import scrollTo from "scroll-to-element"
+
+const SCROLL_OFFSET = -5
+let hasHashBeenParsed = false
+
+
+export const updateResolved = (ori, { layoutActions, getConfigs }) => (...args) => {
+ ori(...args)
+
+ const isDeepLinkingEnabled = getConfigs().deepLinking
+ if(!isDeepLinkingEnabled || isDeepLinkingEnabled === "false") {
+ return
+ }
+
+ if(window.location.hash && !hasHashBeenParsed ) {
+ let hash = window.location.hash.slice(1) // # is first character
+
+ if(hash[0] === "!") {
+ // Parse UI 2.x shebangs
+ hash = hash.slice(1)
+ }
+
+ if(hash[0] === "/") {
+ // "/pet/addPet" => "pet/addPet"
+ // makes the split result cleaner
+ // also handles forgotten leading slash
+ hash = hash.slice(1)
+ }
+
+ let [tag, operationId] = hash.split("/")
+
+ if(tag && operationId) {
+ // Pre-expand and scroll to the operation
+ layoutActions.show(["operations-tag", tag], true)
+ layoutActions.show(["operations", tag, operationId], true)
+
+ scrollTo(`#operations-${tag}-${operationId}`, {
+ offset: SCROLL_OFFSET
+ })
+ } else if(tag) {
+ // Pre-expand and scroll to the tag
+ layoutActions.show(["operations-tag", tag], true)
+
+ scrollTo(`#operations-tag-${tag}`, {
+ offset: SCROLL_OFFSET
+ })
+ }
+ }
+
+ hasHashBeenParsed = true
+}
diff --git a/src/core/presets/base.js b/src/core/presets/base.js
index d5471791..0043b543 100644
--- a/src/core/presets/base.js
+++ b/src/core/presets/base.js
@@ -10,6 +10,7 @@ import auth from "core/plugins/auth"
import util from "core/plugins/util"
import SplitPaneModePlugin from "core/plugins/split-pane-mode"
import downloadUrlPlugin from "core/plugins/download-url"
+import deepLinkingPlugin from "core/plugins/deep-linking"
import App from "core/components/app"
import AuthorizationPopup from "core/components/auth/authorization-popup"
@@ -131,6 +132,7 @@ export default function() {
auth,
ast,
SplitPaneModePlugin,
- downloadUrlPlugin
+ downloadUrlPlugin,
+ deepLinkingPlugin
]
}
diff --git a/src/style/_layout.scss b/src/style/_layout.scss
index 61efe855..3b797e1b 100644
--- a/src/style/_layout.scss
+++ b/src/style/_layout.scss
@@ -644,3 +644,16 @@ section
@include text_headline();
}
}
+
+a.nostyle {
+ text-decoration: inherit;
+ color: inherit;
+ cursor: auto;
+ display: inline;
+
+ &:visited {
+ text-decoration: inherit;
+ color: inherit;
+ cursor: auto;
+ }
+}