Browse Source

fix(OAS3): relative urls (#5341)

* Added tooling for appending OAS3 relative URLs to selected Server

Info
* Terms of service URL
* Contact URL
* License URL
* External Docs URL

Tag
* Tag External Docs URL

Operation
* Operation External Docs
** Operation Tag


Co-authored-by: Tim Lai <timothy.lai@gmail.com>
bubble
geraldglynn 4 years ago
committed by GitHub
parent
commit
d9f5691f65
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 174 additions and 31 deletions
  1. +27
    -19
      src/core/components/info.jsx
  2. +8
    -2
      src/core/components/operation-tag.jsx
  3. +4
    -2
      src/core/components/operation.jsx
  4. +5
    -1
      src/core/components/operations.jsx
  5. +4
    -2
      src/core/containers/info.jsx
  6. +2
    -3
      src/core/utils.js
  7. +23
    -0
      src/core/utils/url.js
  8. +4
    -1
      test/mocha/components/info-wrapper.jsx
  9. +2
    -0
      test/mocha/components/operations.jsx
  10. +93
    -0
      test/mocha/core/utils.js
  11. +2
    -1
      test/mocha/xss/info-sanitization.jsx

+ 27
- 19
src/core/components/info.jsx View File

@@ -1,8 +1,8 @@
import React from "react" import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import { fromJS } from "immutable"
import ImPropTypes from "react-immutable-proptypes" import ImPropTypes from "react-immutable-proptypes"
import { sanitizeUrl } from "core/utils" import { sanitizeUrl } from "core/utils"
import { buildUrl } from "core/utils/url"




export class InfoBasePath extends React.Component { export class InfoBasePath extends React.Component {
@@ -26,13 +26,16 @@ export class InfoBasePath extends React.Component {
class Contact extends React.Component { class Contact extends React.Component {
static propTypes = { static propTypes = {
data: PropTypes.object, data: PropTypes.object,
getComponent: PropTypes.func.isRequired
getComponent: PropTypes.func.isRequired,
specSelectors: PropTypes.object.isRequired,
selectedServer: PropTypes.string,
url: PropTypes.string.isRequired,
} }


render(){ render(){
let { data, getComponent } = this.props
let { data, getComponent, selectedServer, url: specUrl} = this.props
let name = data.get("name") || "the developer" let name = data.get("name") || "the developer"
let url = data.get("url")
let url = buildUrl(data.get("url"), specUrl, {selectedServer})
let email = data.get("email") let email = data.get("email")


const Link = getComponent("Link") const Link = getComponent("Link")
@@ -53,17 +56,18 @@ class Contact extends React.Component {
class License extends React.Component { class License extends React.Component {
static propTypes = { static propTypes = {
license: PropTypes.object, license: PropTypes.object,
getComponent: PropTypes.func.isRequired

getComponent: PropTypes.func.isRequired,
specSelectors: PropTypes.object.isRequired,
selectedServer: PropTypes.string,
url: PropTypes.string.isRequired,
} }


render(){ render(){
let { license, getComponent } = this.props
let { license, getComponent, selectedServer, url: specUrl } = this.props


const Link = getComponent("Link") const Link = getComponent("Link")

let name = license.get("name") || "License"
let url = license.get("url")
let name = license.get("name") || "License"
let url = buildUrl(license.get("url"), specUrl, {selectedServer})


return ( return (
<div className="info__license"> <div className="info__license">
@@ -88,7 +92,7 @@ export class InfoUrl extends React.PureComponent {


const Link = getComponent("Link") const Link = getComponent("Link")


return <Link target="_blank" href={ sanitizeUrl(url) }><span className="url"> { url } </span></Link>
return <Link target="_blank" href={ sanitizeUrl(url) }><span className="url"> { url }</span></Link>
} }
} }


@@ -100,17 +104,21 @@ export default class Info extends React.Component {
basePath: PropTypes.string, basePath: PropTypes.string,
externalDocs: ImPropTypes.map, externalDocs: ImPropTypes.map,
getComponent: PropTypes.func.isRequired, getComponent: PropTypes.func.isRequired,
oas3selectors: PropTypes.func,
selectedServer: PropTypes.string,
} }


render() { render() {
let { info, url, host, basePath, getComponent, externalDocs } = this.props
let { info, url, host, basePath, getComponent, externalDocs, selectedServer, url: specUrl } = this.props
let version = info.get("version") let version = info.get("version")
let description = info.get("description") let description = info.get("description")
let title = info.get("title") let title = info.get("title")
let termsOfService = info.get("termsOfService")
let termsOfServiceUrl = buildUrl(info.get("termsOfService"), specUrl, {selectedServer})
let contact = info.get("contact") let contact = info.get("contact")
let license = info.get("license") let license = info.get("license")
const { url:externalDocsUrl, description:externalDocsDescription } = (externalDocs || fromJS({})).toJS()
let rawExternalDocsUrl = externalDocs && externalDocs.get("url")
let externalDocsUrl = buildUrl(rawExternalDocsUrl, specUrl, {selectedServer})
let externalDocsDescription = externalDocs && externalDocs.get("description")


const Markdown = getComponent("Markdown", true) const Markdown = getComponent("Markdown", true)
const Link = getComponent("Link") const Link = getComponent("Link")
@@ -133,14 +141,14 @@ export default class Info extends React.Component {
</div> </div>


{ {
termsOfService && <div className="info__tos">
<Link target="_blank" href={ sanitizeUrl(termsOfService) }>Terms of service</Link>
termsOfServiceUrl && <div className="info__tos">
<Link target="_blank" href={ sanitizeUrl(termsOfServiceUrl) }>Terms of service</Link>
</div> </div>
} }


{contact && contact.size ? <Contact getComponent={getComponent} data={ contact } /> : null }
{license && license.size ? <License getComponent={getComponent} license={ license } /> : null }
{ externalDocsUrl ?
{contact && contact.size ? <Contact getComponent={getComponent} data={ contact } selectedServer={selectedServer} url={url} /> : null }
{license && license.size ? <License getComponent={getComponent} license={ license } selectedServer={selectedServer} url={url}/> : null }
{ externalDocs ?
<Link className="info__extdocs" target="_blank" href={sanitizeUrl(externalDocsUrl)}>{externalDocsDescription || externalDocsUrl}</Link> <Link className="info__extdocs" target="_blank" href={sanitizeUrl(externalDocsUrl)}>{externalDocsDescription || externalDocsUrl}</Link>
: null } : null }




+ 8
- 2
src/core/components/operation-tag.jsx View File

@@ -3,6 +3,7 @@ import PropTypes from "prop-types"
import ImPropTypes from "react-immutable-proptypes" import ImPropTypes from "react-immutable-proptypes"
import Im from "immutable" import Im from "immutable"
import { createDeepLinkPath, escapeDeepLinkPath, sanitizeUrl } from "core/utils" import { createDeepLinkPath, escapeDeepLinkPath, sanitizeUrl } from "core/utils"
import { buildUrl } from "core/utils/url"


export default class OperationTag extends React.Component { export default class OperationTag extends React.Component {


@@ -15,12 +16,15 @@ export default class OperationTag extends React.Component {
tagObj: ImPropTypes.map.isRequired, tagObj: ImPropTypes.map.isRequired,
tag: PropTypes.string.isRequired, tag: PropTypes.string.isRequired,


oas3Selectors: PropTypes.func.isRequired,
layoutSelectors: PropTypes.object.isRequired, layoutSelectors: PropTypes.object.isRequired,
layoutActions: PropTypes.object.isRequired, layoutActions: PropTypes.object.isRequired,


getConfigs: PropTypes.func.isRequired, getConfigs: PropTypes.func.isRequired,
getComponent: PropTypes.func.isRequired, getComponent: PropTypes.func.isRequired,


specUrl: PropTypes.string.isRequired,

children: PropTypes.element, children: PropTypes.element,
} }


@@ -29,11 +33,12 @@ export default class OperationTag extends React.Component {
tagObj, tagObj,
tag, tag,
children, children,
oas3Selectors,
layoutSelectors, layoutSelectors,
layoutActions, layoutActions,
getConfigs, getConfigs,
getComponent, getComponent,
specUrl,
} = this.props } = this.props


let { let {
@@ -50,7 +55,8 @@ export default class OperationTag extends React.Component {


let tagDescription = tagObj.getIn(["tagDetails", "description"], null) let tagDescription = tagObj.getIn(["tagDetails", "description"], null)
let tagExternalDocsDescription = tagObj.getIn(["tagDetails", "externalDocs", "description"]) let tagExternalDocsDescription = tagObj.getIn(["tagDetails", "externalDocs", "description"])
let tagExternalDocsUrl = tagObj.getIn(["tagDetails", "externalDocs", "url"])
let rawTagExternalDocsUrl = tagObj.getIn(["tagDetails", "externalDocs", "url"])
let tagExternalDocsUrl = buildUrl( rawTagExternalDocsUrl, specUrl, {selectedServer: oas3Selectors.selectedServer()} )


let isShownKey = ["operations-tag", tag] let isShownKey = ["operations-tag", tag]
let showTag = layoutSelectors.isShown(isShownKey, docExpansion === "full" || docExpansion === "list") let showTag = layoutSelectors.isShown(isShownKey, docExpansion === "full" || docExpansion === "list")


+ 4
- 2
src/core/components/operation.jsx View File

@@ -2,6 +2,7 @@ import React, { PureComponent } from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import { getList } from "core/utils" import { getList } from "core/utils"
import { getExtensions, sanitizeUrl, escapeDeepLinkPath } from "core/utils" import { getExtensions, sanitizeUrl, escapeDeepLinkPath } from "core/utils"
import { buildUrl } from "core/utils/url"
import { Iterable, List } from "immutable" import { Iterable, List } from "immutable"
import ImPropTypes from "react-immutable-proptypes" import ImPropTypes from "react-immutable-proptypes"


@@ -81,6 +82,7 @@ export default class Operation extends PureComponent {
schemes schemes
} = op } = op


const externalDocsUrl = externalDocs ? buildUrl(externalDocs.url, specSelectors.url(), { selectedServer: oas3Selectors.selectedServer() }) : ""
let operation = operationProps.getIn(["op"]) let operation = operationProps.getIn(["op"])
let responses = operation.get("responses") let responses = operation.get("responses")
let parameters = getList(operation, ["parameters"]) let parameters = getList(operation, ["parameters"])
@@ -127,14 +129,14 @@ export default class Operation extends PureComponent {
</div> </div>
} }
{ {
externalDocs && externalDocs.url ?
externalDocsUrl ?
<div className="opblock-external-docs-wrapper"> <div className="opblock-external-docs-wrapper">
<h4 className="opblock-title_normal">Find more details</h4> <h4 className="opblock-title_normal">Find more details</h4>
<div className="opblock-external-docs"> <div className="opblock-external-docs">
<span className="opblock-external-docs__description"> <span className="opblock-external-docs__description">
<Markdown source={ externalDocs.description } /> <Markdown source={ externalDocs.description } />
</span> </span>
<Link target="_blank" className="opblock-external-docs__link" href={sanitizeUrl(externalDocs.url)}>{externalDocs.url}</Link>
<Link target="_blank" className="opblock-external-docs__link" href={sanitizeUrl(externalDocsUrl)}>{externalDocsUrl}</Link>
</div> </div>
</div> : null </div> : null
} }


+ 5
- 1
src/core/components/operations.jsx View File

@@ -16,6 +16,7 @@ export default class Operations extends React.Component {
specActions: PropTypes.object.isRequired, specActions: PropTypes.object.isRequired,
oas3Actions: PropTypes.object.isRequired, oas3Actions: PropTypes.object.isRequired,
getComponent: PropTypes.func.isRequired, getComponent: PropTypes.func.isRequired,
oas3Selectors: PropTypes.func.isRequired,
layoutSelectors: PropTypes.object.isRequired, layoutSelectors: PropTypes.object.isRequired,
layoutActions: PropTypes.object.isRequired, layoutActions: PropTypes.object.isRequired,
authActions: PropTypes.object.isRequired, authActions: PropTypes.object.isRequired,
@@ -28,6 +29,7 @@ export default class Operations extends React.Component {
let { let {
specSelectors, specSelectors,
getComponent, getComponent,
oas3Selectors,
layoutSelectors, layoutSelectors,
layoutActions, layoutActions,
getConfigs, getConfigs,
@@ -65,10 +67,12 @@ export default class Operations extends React.Component {
key={"operation-" + tag} key={"operation-" + tag}
tagObj={tagObj} tagObj={tagObj}
tag={tag} tag={tag}
oas3Selectors={oas3Selectors}
layoutSelectors={layoutSelectors} layoutSelectors={layoutSelectors}
layoutActions={layoutActions} layoutActions={layoutActions}
getConfigs={getConfigs} getConfigs={getConfigs}
getComponent={getComponent}>
getComponent={getComponent}
specUrl={specSelectors.url()}>
{ {
operations.map( op => { operations.map( op => {
const path = op.get("path") const path = op.get("path")


+ 4
- 2
src/core/containers/info.jsx View File

@@ -7,16 +7,18 @@ export default class InfoContainer extends React.Component {
specActions: PropTypes.object.isRequired, specActions: PropTypes.object.isRequired,
specSelectors: PropTypes.object.isRequired, specSelectors: PropTypes.object.isRequired,
getComponent: PropTypes.func.isRequired, getComponent: PropTypes.func.isRequired,
oas3Selectors: PropTypes.func.isRequired,
} }


render () { render () {
const {specSelectors, getComponent} = this.props
const {specSelectors, getComponent, oas3Selectors} = this.props


const info = specSelectors.info() const info = specSelectors.info()
const url = specSelectors.url() const url = specSelectors.url()
const basePath = specSelectors.basePath() const basePath = specSelectors.basePath()
const host = specSelectors.host() const host = specSelectors.host()
const externalDocs = specSelectors.externalDocs() const externalDocs = specSelectors.externalDocs()
const selectedServer = oas3Selectors.selectedServer()


const Info = getComponent("info") const Info = getComponent("info")


@@ -24,7 +26,7 @@ export default class InfoContainer extends React.Component {
<div> <div>
{info && info.count() ? ( {info && info.count() ? (
<Info info={info} url={url} host={host} basePath={basePath} externalDocs={externalDocs} <Info info={info} url={url} host={host} basePath={basePath} externalDocs={externalDocs}
getComponent={getComponent}/>
getComponent={getComponent} selectedServer={selectedServer} />
) : null} ) : null}
</div> </div>
) )


+ 2
- 3
src/core/utils.js View File

@@ -399,7 +399,7 @@ export const validatePattern = (val, rxPattern) => {


// validation of parameters before execute // validation of parameters before execute
export const validateParam = (param, value, { isOAS3 = false, bypassRequiredCheck = false } = {}) => { export const validateParam = (param, value, { isOAS3 = false, bypassRequiredCheck = false } = {}) => {
let errors = [] let errors = []


let paramRequired = param.get("required") let paramRequired = param.get("required")
@@ -436,7 +436,7 @@ export const validateParam = (param, value, { isOAS3 = false, bypassRequiredChec
let objectStringCheck = type === "object" && typeof value === "string" && value let objectStringCheck = type === "object" && typeof value === "string" && value


const allChecks = [ const allChecks = [
stringCheck, arrayCheck, arrayListCheck, arrayStringCheck, fileCheck,
stringCheck, arrayCheck, arrayListCheck, arrayStringCheck, fileCheck,
booleanCheck, numberCheck, integerCheck, objectCheck, objectStringCheck, booleanCheck, numberCheck, integerCheck, objectCheck, objectStringCheck,
] ]


@@ -640,7 +640,6 @@ export function sanitizeUrl(url) {
return braintreeSanitizeUrl(url) return braintreeSanitizeUrl(url)
} }



export function requiresValidationURL(uri) { export function requiresValidationURL(uri) {
if (!uri || uri.indexOf("localhost") >= 0 || uri.indexOf("127.0.0.1") >= 0 || uri === "none") { if (!uri || uri.indexOf("localhost") >= 0 || uri.indexOf("127.0.0.1") >= 0 || uri === "none") {
return false return false


+ 23
- 0
src/core/utils/url.js View File

@@ -0,0 +1,23 @@
export function isAbsoluteUrl(url) {
return url.match(/^(?:[a-z]+:)?\/\//i) // Matches http://, HTTP://, https://, ftp://, //example.com,
}

export function addProtocol(url) {
if(!url.match(/^\/\//i)) return url // Checks if protocol is missing e.g. //example.com
return `${window.location.protocol}${url}`
}

export function buildBaseUrl(selectedServer, specUrl) {
if(!selectedServer) return specUrl
if(isAbsoluteUrl(selectedServer)) return addProtocol(selectedServer)
return new URL(selectedServer, specUrl).href
}

export function buildUrl(url, specUrl, { selectedServer="" } = {}) {
if(!url) return
if(isAbsoluteUrl(url)) return url

const baseUrl = buildBaseUrl(selectedServer, specUrl)
return new URL(url, baseUrl).href
}

+ 4
- 1
test/mocha/components/info-wrapper.jsx View File

@@ -17,7 +17,10 @@ describe("<InfoContainer/>", function () {
url () {}, url () {},
basePath () {}, basePath () {},
host () {}, host () {},
externalDocs () {}
externalDocs () {},
},
oas3Selectors: {
selectedServer () {},
}, },
getComponent: c => components[c] getComponent: c => components[c]
} }


+ 2
- 0
test/mocha/components/operations.jsx View File

@@ -29,6 +29,7 @@ describe("<Operations/>", function(){
}, },
specSelectors: { specSelectors: {
isOAS3() { return false }, isOAS3() { return false },
url() { return "https://petstore.swagger.io/v2/swagger.json" },
taggedOperations() { taggedOperations() {
return fromJS({ return fromJS({
"default": { "default": {
@@ -83,6 +84,7 @@ describe("<Operations/>", function(){
}, },
specSelectors: { specSelectors: {
isOAS3() { return true }, isOAS3() { return true },
url() { return "https://petstore.swagger.io/v2/swagger.json" },
taggedOperations() { taggedOperations() {
return fromJS({ return fromJS({
"default": { "default": {


+ 93
- 0
test/mocha/core/utils.js View File

@@ -32,6 +32,13 @@ import {
generateCodeVerifier, generateCodeVerifier,
createCodeChallenge, createCodeChallenge,
} from "core/utils" } from "core/utils"

import {
isAbsoluteUrl,
buildBaseUrl,
buildUrl,
} from "core/utils/url"

import win from "core/window" import win from "core/window"


describe("utils", function() { describe("utils", function() {
@@ -1334,6 +1341,92 @@ describe("utils", function() {
}) })
}) })


describe("isAbsoluteUrl", function() {

it("check if url is absolute", function() {
expect(!!isAbsoluteUrl("http://example.com")).toEqual(true)
expect(!!isAbsoluteUrl("https://secure-example.com")).toEqual(true)
expect(!!isAbsoluteUrl("HTTP://uppercase-example.com")).toEqual(true)
expect(!!isAbsoluteUrl("HTTP://uppercase-secure-example.com")).toEqual(true)
expect(!!isAbsoluteUrl("http://trailing-slash.com/")).toEqual(true)
expect(!!isAbsoluteUrl("ftp://file-transfer-protocol.com")).toEqual(true)
expect(!!isAbsoluteUrl("//no-protocol.com")).toEqual(true)
})

it("check if url is not absolute", function() {
expect(!!isAbsoluteUrl("/url-relative-to-host/base-path/path")).toEqual(false)
expect(!!isAbsoluteUrl("url-relative-to-base/base-path/path")).toEqual(false)
})
})

describe("buildBaseUrl", function() {
const specUrl = "https://petstore.swagger.io/v2/swagger.json"
const noServerSelected = ""
const absoluteServerUrl = "https://server-example.com/base-path/path"
const serverUrlRelativeToBase = "server-example/base-path/path"
const serverUrlRelativeToHost = "/server-example/base-path/path"

it("build base url with no server selected", function() {
expect(buildBaseUrl(noServerSelected, specUrl)).toBe("https://petstore.swagger.io/v2/swagger.json")
})

it("build base url from absolute server url", function() {
expect(buildBaseUrl(absoluteServerUrl, specUrl)).toBe("https://server-example.com/base-path/path")
})

it("build base url from relative server url", function() {
expect(buildBaseUrl(serverUrlRelativeToBase, specUrl)).toBe("https://petstore.swagger.io/v2/server-example/base-path/path")
expect(buildBaseUrl(serverUrlRelativeToHost, specUrl)).toBe("https://petstore.swagger.io/server-example/base-path/path")
})
})

describe("buildUrl", function() {
const specUrl = "https://petstore.swagger.io/v2/swagger.json"
const noUrl = ""
const absoluteUrl = "https://example.com/base-path/path"
const urlRelativeToBase = "relative-url/base-path/path"
const urlRelativeToHost = "/relative-url/base-path/path"

const noServerSelected = ""
const absoluteServerUrl = "https://server-example.com/base-path/path"
const serverUrlRelativeToBase = "server-example/base-path/path"
const serverUrlRelativeToHost = "/server-example/base-path/path"
it("build no url", function() {
expect(buildUrl(noUrl, specUrl, { selectedServer: absoluteServerUrl })).toBe(undefined)
expect(buildUrl(noUrl, specUrl, { selectedServer: serverUrlRelativeToBase })).toBe(undefined)
expect(buildUrl(noUrl, specUrl, { selectedServer: serverUrlRelativeToHost })).toBe(undefined)
})

it("build absolute url", function() {
expect(buildUrl(absoluteUrl, specUrl, { selectedServer: absoluteServerUrl })).toBe("https://example.com/base-path/path")
expect(buildUrl(absoluteUrl, specUrl, { selectedServer: serverUrlRelativeToBase })).toBe("https://example.com/base-path/path")
expect(buildUrl(absoluteUrl, specUrl, { selectedServer: serverUrlRelativeToHost })).toBe("https://example.com/base-path/path")
})

it("build relative url with no server selected", function() {
expect(buildUrl(urlRelativeToBase, specUrl, { selectedServer: noServerSelected })).toBe("https://petstore.swagger.io/v2/relative-url/base-path/path")
expect(buildUrl(urlRelativeToHost, specUrl, { selectedServer: noServerSelected })).toBe("https://petstore.swagger.io/relative-url/base-path/path")
})

it("build relative url with absolute server url", function() {
expect(buildUrl(urlRelativeToBase, specUrl, { selectedServer: absoluteServerUrl })).toBe("https://server-example.com/base-path/relative-url/base-path/path")
expect(buildUrl(urlRelativeToHost, specUrl, { selectedServer: absoluteServerUrl })).toBe("https://server-example.com/relative-url/base-path/path")
})

it("build relative url with server url relative to base", function() {
expect(buildUrl(urlRelativeToBase, specUrl, { selectedServer: serverUrlRelativeToBase })).toBe("https://petstore.swagger.io/v2/server-example/base-path/relative-url/base-path/path")
expect(buildUrl(urlRelativeToHost, specUrl, { selectedServer: serverUrlRelativeToBase })).toBe("https://petstore.swagger.io/relative-url/base-path/path")
})

it("build relative url with server url relative to host", function() {
expect(buildUrl(urlRelativeToBase, specUrl, { selectedServer: serverUrlRelativeToHost })).toBe("https://petstore.swagger.io/server-example/base-path/relative-url/base-path/path")
expect(buildUrl(urlRelativeToHost, specUrl, { selectedServer: serverUrlRelativeToHost })).toBe("https://petstore.swagger.io/relative-url/base-path/path")
})
})
describe("requiresValidationURL", function() { describe("requiresValidationURL", function() {
it("Should tell us if we require a ValidationURL", function() { it("Should tell us if we require a ValidationURL", function() {
const res = requiresValidationURL("https://example.com") const res = requiresValidationURL("https://example.com")


+ 2
- 1
test/mocha/xss/info-sanitization.jsx View File

@@ -18,7 +18,8 @@ describe("<Info/> Sanitization", function(){
description: "Description *with* <script>Markdown</script>" description: "Description *with* <script>Markdown</script>"
}), }),
host: "example.test", host: "example.test",
basePath: "/api"
basePath: "/api",
selectedServer: "https://example.test",
} }


it("renders sanitized .title content", function(){ it("renders sanitized .title content", function(){


Loading…
Cancel
Save