Skip to content

Commit

Permalink
feat(error-handling): introduce unified and configurable error handli…
Browse files Browse the repository at this point in the history
…ng (#7761)

Refs #7778
  • Loading branch information
char0n committed Jan 24, 2022
1 parent 4f2287f commit 8b1c4a7
Show file tree
Hide file tree
Showing 19 changed files with 631 additions and 297 deletions.
1 change: 0 additions & 1 deletion config/jest/jest.unit.config.js
Expand Up @@ -7,7 +7,6 @@ module.exports = {
'**/test/unit/*.js?(x)',
'**/test/unit/**/*.js?(x)',
],
// testMatch: ['**/test/unit/core/plugins/auth/actions.js'],
setupFilesAfterEnv: ['<rootDir>/test/unit/setup.js'],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
Expand Down
165 changes: 165 additions & 0 deletions docs/customization/plug-points.md
Expand Up @@ -233,3 +233,168 @@ const ui = SwaggerUIBundle({
...snippetConfig,
})
```

### Error handling

SwaggerUI comes with a `safe-render` plugin that handles error handling allows plugging into error handling system and modify it.

The plugin accepts a list of component names that should be protected by error boundaries.

Its public API looks like this:

```js
{
fn: {
componentDidCatch,
withErrorBoundary: withErrorBoundary(getSystem),
},
components: {
ErrorBoundary,
Fallback,
},
}
```

safe-render plugin is automatically utilized by [base](https://github.com/swagger-api/swagger-ui/blob/78f62c300a6d137e65fd027d850981b010009970/src/core/presets/base.js) and [standalone](https://github.com/swagger-api/swagger-ui/tree/78f62c300a6d137e65fd027d850981b010009970/src/standalone) SwaggerUI presets and
should always be used as the last plugin, after all the components are already known to the SwaggerUI.
The plugin defines a default list of components that should be protected by error boundaries:

```js
[
"App",
"BaseLayout",
"VersionPragmaFilter",
"InfoContainer",
"ServersContainer",
"SchemesContainer",
"AuthorizeBtnContainer",
"FilterContainer",
"Operations",
"OperationContainer",
"parameters",
"responses",
"OperationServers",
"Models",
"ModelWrapper",
"Topbar",
"StandaloneLayout",
"onlineValidatorBadge"
]
```

As demonstrated below, additional components can be protected by utilizing the safe-render plugin
with configuration options. This gets really handy if you are a SwaggerUI integrator and you maintain a number of
plugins with additional custom components.

```js
const swaggerUI = SwaggerUI({
url: "https://petstore.swagger.io/v2/swagger.json",
dom_id: '#swagger-ui',
plugins: [
() => ({
components: {
MyCustomComponent1: () => 'my custom component',
},
}),
SwaggerUI.plugins.SafeRender({
fullOverride: true, // only the component list defined here will apply (not the default list)
componentList: [
"MyCustomComponent1",
],
}),
],
});
```

##### componentDidCatch

This static function is invoked after a component has thrown an error.
It receives two parameters:

1. `error` - The error that was thrown.
2. `info` - An object with a componentStack key containing [information about which component threw the error](https://reactjs.org/docs/error-boundaries.html#component-stack-traces).

It has precisely the same signature as error boundaries [componentDidCatch lifecycle method](https://reactjs.org/docs/react-component.html#componentdidcatch),
except it's a static function and not a class method.

Default implement of componentDidCatch uses `console.error` to display the received error:

```js
export const componentDidCatch = console.error;
```

To utilize your own error handling logic (e.g. [bugsnag](https://www.bugsnag.com/)), create new SwaggerUI plugin that overrides componentDidCatch:

{% highlight js linenos %}
const BugsnagErrorHandlerPlugin = () => {
// init bugsnag

return {
fn: {
componentDidCatch = (error, info) => {
Bugsnag.notify(error);
Bugsnag.notify(info);
},
},
};
};
{% endhighlight %}

##### withErrorBoundary

This function is HOC (Higher Order Component). It wraps a particular component into the `ErrorBoundary` component.
It can be overridden via a plugin system to control how components are wrapped by the ErrorBoundary component.
In 99.9% of situations, you won't need to override this function, but if you do, please read the source code of this function first.

##### Fallback

The component is displayed when the error boundary catches an error. It can be overridden via a plugin system.
Its default implementation is trivial:

```js
import React from "react"
import PropTypes from "prop-types"

const Fallback = ({ name }) => (
<div className="fallback">
😱 <i>Could not render { name === "t" ? "this component" : name }, see the console.</i>
</div>
)
Fallback.propTypes = {
name: PropTypes.string.isRequired,
}
export default Fallback
```

Feel free to override it to match your look & feel:

```js
const CustomFallbackPlugin = () => ({
components: {
Fallback: ({ name } ) => `This is my custom fallback. ${name} failed to render`,
},
});

const swaggerUI = SwaggerUI({
url: "https://petstore.swagger.io/v2/swagger.json",
dom_id: '#swagger-ui',
plugins: [
CustomFallbackPlugin,
]
});
```

##### ErrorBoundary

This is the component that implements React error boundaries. Uses `componentDidCatch` and `Fallback`
under the hood. In 99.9% of situations, you won't need to override this component, but if you do,
please read the source code of this component first.


##### Change in behavior

In prior releases of SwaggerUI (before v4.3.0), almost all components have been protected, and when thrown error,
`Fallback` component was displayed. This changes with SwaggerUI v4.3.0. Only components defined
by the `safe-render` plugin are now protected and display fallback. If a small component somewhere within
SwaggerUI React component tree fails to render and throws an error. The error bubbles up to the closest
error boundary, and that error boundary displays the `Fallback` component and invokes `componentDidCatch`.
2 changes: 1 addition & 1 deletion src/core/components/highlight-code.jsx
Expand Up @@ -31,7 +31,7 @@ const HighlightCode = ({value, fileName, className, downloadable, getConfigs, ca
}

const handlePreventYScrollingBeyondElement = (e) => {
const { target, deltaY } = e
const { target, deltaY } = e
const { scrollHeight: contentHeight, offsetHeight: visibleHeight, scrollTop } = target
const scrollOffset = visibleHeight + scrollTop
const isElementScrollable = contentHeight > visibleHeight
Expand Down
65 changes: 31 additions & 34 deletions src/core/components/layouts/base.jsx
Expand Up @@ -28,7 +28,6 @@ export default class BaseLayout extends React.Component {
const SchemesContainer = getComponent("SchemesContainer", true)
const AuthorizeBtnContainer = getComponent("AuthorizeBtnContainer", true)
const FilterContainer = getComponent("FilterContainer", true)
const ErrorBoundary = getComponent("ErrorBoundary", true)
let isSwagger2 = specSelectors.isSwagger2()
let isOAS3 = specSelectors.isOAS3()

Expand Down Expand Up @@ -87,40 +86,38 @@ export default class BaseLayout extends React.Component {

return (
<div className='swagger-ui'>
<ErrorBoundary targetName="BaseLayout">
<SvgAssets />
<VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}>
<Errors/>
<Row className="information-container">
<Col mobile={12}>
<InfoContainer/>
<SvgAssets />
<VersionPragmaFilter isSwagger2={isSwagger2} isOAS3={isOAS3} alsoShow={<Errors/>}>
<Errors/>
<Row className="information-container">
<Col mobile={12}>
<InfoContainer/>
</Col>
</Row>

{hasServers || hasSchemes || hasSecurityDefinitions ? (
<div className="scheme-container">
<Col className="schemes wrapper" mobile={12}>
{hasServers ? (<ServersContainer />) : null}
{hasSchemes ? (<SchemesContainer />) : null}
{hasSecurityDefinitions ? (<AuthorizeBtnContainer />) : null}
</Col>
</Row>

{hasServers || hasSchemes || hasSecurityDefinitions ? (
<div className="scheme-container">
<Col className="schemes wrapper" mobile={12}>
{hasServers ? (<ServersContainer />) : null}
{hasSchemes ? (<SchemesContainer />) : null}
{hasSecurityDefinitions ? (<AuthorizeBtnContainer />) : null}
</Col>
</div>
) : null}

<FilterContainer/>

<Row>
<Col mobile={12} desktop={12} >
<Operations/>
</Col>
</Row>
<Row>
<Col mobile={12} desktop={12} >
<Models/>
</Col>
</Row>
</VersionPragmaFilter>
</ErrorBoundary>
</div>
) : null}

<FilterContainer/>

<Row>
<Col mobile={12} desktop={12} >
<Operations/>
</Col>
</Row>
<Row>
<Col mobile={12} desktop={12} >
<Models/>
</Col>
</Row>
</VersionPragmaFilter>
</div>
)
}
Expand Down
3 changes: 3 additions & 0 deletions src/core/plugins/all.js
@@ -1,4 +1,5 @@
import { pascalCaseFilename } from "core/utils"
import SafeRender from "core/plugins/safe-render"

const request = require.context(".", true, /\.jsx?$/)

Expand All @@ -18,4 +19,6 @@ request.keys().forEach( function( key ){
allPlugins[pascalCaseFilename(key)] = mod.default ? mod.default : mod
})

allPlugins.SafeRender = SafeRender

export default allPlugins
2 changes: 1 addition & 1 deletion src/core/plugins/oas3/components/request-body.jsx
Expand Up @@ -88,7 +88,7 @@ const RequestBody = ({
const sampleForMediaType = rawExamplesOfMediaType?.map((container, key) => {
const val = container?.get("value", null)
if(val) {
container = container.set("value", getDefaultRequestBodyValue(
container = container.set("value", getDefaultRequestBodyValue(
requestBody,
contentType,
key,
Expand Down
@@ -1,27 +1,28 @@
import PropTypes from "prop-types"
import React, { Component } from "react"

import { componentDidCatch } from "../fn"
import Fallback from "./fallback"

export class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}

static getDerivedStateFromError(error) {
return { hasError: true, error }
}

constructor(...args) {
super(...args)
this.state = { hasError: false, error: null }
}

componentDidCatch(error, errorInfo) {
console.error(error, errorInfo) // eslint-disable-line no-console
this.props.fn.componentDidCatch(error, errorInfo)
}

render() {
const { getComponent, targetName, children } = this.props
const FallbackComponent = getComponent("Fallback")

if (this.state.hasError) {
const FallbackComponent = getComponent("Fallback")
return <FallbackComponent name={targetName} />
}

Expand All @@ -31,6 +32,7 @@ export class ErrorBoundary extends Component {
ErrorBoundary.propTypes = {
targetName: PropTypes.string,
getComponent: PropTypes.func,
fn: PropTypes.object,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
Expand All @@ -39,6 +41,9 @@ ErrorBoundary.propTypes = {
ErrorBoundary.defaultProps = {
targetName: "this component",
getComponent: () => Fallback,
fn: {
componentDidCatch,
},
children: null,
}

Expand Down
File renamed without changes.
32 changes: 32 additions & 0 deletions src/core/plugins/safe-render/fn.jsx
@@ -0,0 +1,32 @@
import React, { Component } from "react"

export const componentDidCatch = console.error

const isClassComponent = component => component.prototype && component.prototype.isReactComponent

export const withErrorBoundary = (getSystem) => (WrappedComponent) => {
const { getComponent, fn } = getSystem()
const ErrorBoundary = getComponent("ErrorBoundary")
const targetName = fn.getDisplayName(WrappedComponent)

class WithErrorBoundary extends Component {
render() {
return (
<ErrorBoundary targetName={targetName} getComponent={getComponent} fn={fn}>
<WrappedComponent {...this.props} {...this.context} />
</ErrorBoundary>
)
}
}
WithErrorBoundary.displayName = `WithErrorBoundary(${targetName})`
if (isClassComponent(WrappedComponent)) {
/**
* We need to handle case of class components defining a `mapStateToProps` public method.
* Components with `mapStateToProps` public method cannot be wrapped.
*/
WithErrorBoundary.prototype.mapStateToProps = WrappedComponent.prototype.mapStateToProps
}

return WithErrorBoundary
}

0 comments on commit 8b1c4a7

Please sign in to comment.