Added Swagger

This commit is contained in:
2020-06-10 08:25:21 +02:00
parent 5c6f37eaf7
commit af76cbca87
257 changed files with 48861 additions and 12 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"rules": {
"import/no-extraneous-dependencies": [
2,
{
"devDependencies": false
}
]
}
}
+67
View File
@@ -0,0 +1,67 @@
import deepMerge from "deepmerge"
import SwaggerUI from "swagger-ui"
import EditorLayout from "./layout"
import EditorPlugin from "./plugins/editor"
import LocalStoragePlugin from "./plugins/local-storage"
import ValidateBasePlugin from "./plugins/validate-base"
import ValidateSemanticPlugin from "./plugins/validate-semantic"
import ValidateJsonSchemaPlugin from "./plugins/json-schema-validator"
import EditorAutosuggestPlugin from "./plugins/editor-autosuggest"
import EditorAutosuggestSnippetsPlugin from "./plugins/editor-autosuggest-snippets"
import EditorAutosuggestKeywordsPlugin from "./plugins/editor-autosuggest-keywords"
import EditorAutosuggestOAS3KeywordsPlugin from "./plugins/editor-autosuggest-oas3-keywords"
import EditorAutosuggestRefsPlugin from "./plugins/editor-autosuggest-refs"
import PerformancePlugin from "./plugins/performance"
import JumpToPathPlugin from "./plugins/jump-to-path"
import SplitPaneModePlugin from "./plugins/split-pane-mode"
import ASTPlugin from "./plugins/ast"
// eslint-disable-next-line no-undef
const { GIT_DIRTY, GIT_COMMIT, PACKAGE_VERSION } = buildInfo
window.versions = window.versions || {}
window.versions.swaggerEditor = `${PACKAGE_VERSION}/${GIT_COMMIT || "unknown"}${GIT_DIRTY ? "-dirty" : ""}`
const plugins = {
EditorPlugin,
ValidateBasePlugin,
ValidateSemanticPlugin,
ValidateJsonSchemaPlugin,
LocalStoragePlugin,
EditorAutosuggestPlugin,
EditorAutosuggestSnippetsPlugin,
EditorAutosuggestKeywordsPlugin,
EditorAutosuggestRefsPlugin,
EditorAutosuggestOAS3KeywordsPlugin,
PerformancePlugin,
JumpToPathPlugin,
SplitPaneModePlugin,
ASTPlugin,
}
const defaults = {
// we have the `dom_id` prop for legacy reasons
dom_id: "#swagger-editor", // eslint-disable-line camelcase
layout: "EditorLayout",
presets: [
SwaggerUI.presets.apis
],
plugins: Object.values(plugins),
components: {
EditorLayout
},
showExtensions: true,
swagger2GeneratorUrl: "https://generator.swagger.io/api/swagger.json",
oas3GeneratorUrl: "https://generator3.swagger.io/openapi.json",
swagger2ConverterUrl: "https://converter.swagger.io/api/convert",
}
export default function SwaggerEditor(options) {
let mergedOptions = deepMerge(defaults, options)
mergedOptions.presets = defaults.presets.concat(options.presets || [])
mergedOptions.plugins = defaults.plugins.concat(options.plugins || [])
return SwaggerUI(mergedOptions)
}
SwaggerEditor.plugins = plugins
+80
View File
@@ -0,0 +1,80 @@
import React from "react"
import PropTypes from "prop-types"
import Dropzone from "react-dropzone"
Dropzone.displayName = "Dropzone" // For testing
export default class EditorLayout extends React.Component {
static propTypes = {
errSelectors: PropTypes.object.isRequired,
errActions: PropTypes.object.isRequired,
specActions: PropTypes.object.isRequired,
getComponent: PropTypes.func.isRequired,
layoutSelectors: PropTypes.object.isRequired,
layoutActions: PropTypes.object.isRequired
}
onChange = (newYaml, origin="editor") => {
this.props.specActions.updateSpec(newYaml, origin)
}
onDrop = (acceptedFiles, rejectedFiles) => {
const someFilesWereRejected = rejectedFiles && rejectedFiles.length > 0
const thereIsExactlyOneAcceptedFile = acceptedFiles && acceptedFiles.length === 1
if ( someFilesWereRejected || !thereIsExactlyOneAcceptedFile) {
alert("Sorry, there was an error processing your file.\nPlease drag and drop exactly one .yaml or .json OpenAPI definition file.")
} else {
const file = acceptedFiles[0]
const reader = new FileReader()
reader.onloadend = () => {
const spec = reader.result
this.onChange(spec, "fileDrop")
}
reader.readAsText(file, "utf-8")
}
}
render() {
const { getComponent } = this.props
const UIBaseLayout = getComponent("BaseLayout", true)
const EditorContainer = getComponent("EditorContainer", true)
const SplitPaneMode = getComponent("SplitPaneMode", true)
const Container = getComponent("Container")
return (
<div className="swagger-editor">
<Container className="container">
<Dropzone
className="dropzone"
accept=".yaml,application/json"
multiple={false}
onDrop={this.onDrop}
disablePreview
disableClick
>
{({ isDragActive }) => {
if (isDragActive) {
return (
<div className="dropzone__overlay">
Please drop a .yaml or .json OpenAPI spec.
</div>
)
} else {
return (
<SplitPaneMode>
<EditorContainer onChange={this.onChange} />
<UIBaseLayout/>
</SplitPaneMode>
)
}
}}
</Dropzone>
</Container>
</div>
)
}
}
+308
View File
@@ -0,0 +1,308 @@
import YAML from "yaml-js"
import isArray from "lodash/isArray"
import lodashFind from "lodash/find"
import memoize from "lodash/memoize"
let cachedCompose = memoize(YAML.compose) // TODO: build a custom cache based on content
var MAP_TAG = "tag:yaml.org,2002:map"
var SEQ_TAG = "tag:yaml.org,2002:seq"
export function getLineNumberForPath(yaml, path) {
// Type check
if (typeof yaml !== "string") {
throw new TypeError("yaml should be a string")
}
if (!isArray(path)) {
throw new TypeError("path should be an array of strings")
}
var i = 0
let ast = cachedCompose(yaml)
// simply walks the tree using current path recursively to the point that
// path is empty
return find(ast, path)
function find(current, path, last) {
if(!current) {
// something has gone quite wrong
// return the last start_mark as a best-effort
if(last && last.start_mark)
return last.start_mark.line
return 0
}
if (path.length && current.tag === MAP_TAG) {
for (i = 0; i < current.value.length; i++) {
var pair = current.value[i]
var key = pair[0]
var value = pair[1]
if (key.value === path[0]) {
return find(value, path.slice(1), current)
}
if (key.value === path[0].replace(/\[.*/, "")) {
// access the array at the index in the path (example: grab the 2 in "tags[2]")
var index = parseInt(path[0].match(/\[(.*)\]/)[1])
if(value.value.length === 1 && index !== 0 && !!index) {
var nextVal = lodashFind(value.value[0], { value: index.toString() })
} else { // eslint-disable-next-line no-redeclare
var nextVal = value.value[index]
}
return find(nextVal, path.slice(1), value.value)
}
}
}
if (path.length && current.tag === SEQ_TAG) {
var item = current.value[path[0]]
if (item && item.tag) {
return find(item, path.slice(1), current.value)
}
}
if (current.tag === MAP_TAG && !Array.isArray(last)) {
return current.start_mark.line
} else {
return current.start_mark.line + 1
}
}
}
/**
* Get a position object with given
* @param {string} yaml
* YAML or JSON string
* @param {array} path
* an array of stings that constructs a
* JSON Path similar to JSON Pointers(RFC 6901). The difference is, each
* component of path is an item of the array instead of being separated with
* slash(/) in a string
*/
export function positionRangeForPath(yaml, path) {
// Type check
if (typeof yaml !== "string") {
throw new TypeError("yaml should be a string")
}
if (!isArray(path)) {
throw new TypeError("path should be an array of strings")
}
var invalidRange = {
start: {line: -1, column: -1},
end: {line: -1, column: -1}
}
var i = 0
let ast = cachedCompose(yaml)
// simply walks the tree using astValue path recursively to the point that
// path is empty.
return find(ast)
function find(astValue, astKeyValue) {
if (astValue.tag === MAP_TAG) {
for (i = 0; i < astValue.value.length; i++) {
var pair = astValue.value[i]
var key = pair[0]
var value = pair[1]
if (key.value === path[0]) {
path.shift()
return find(value, key)
}
}
}
if (astValue.tag === SEQ_TAG) {
var item = astValue.value[path[0]]
if (item && item.tag) {
path.shift()
return find(item, astKeyValue)
}
}
// if path is still not empty we were not able to find the node
if (path.length) {
return invalidRange
}
const range = {
start: {
line: astValue.start_mark.line,
column: astValue.start_mark.column,
pointer: astValue.start_mark.pointer,
},
end: {
line: astValue.end_mark.line,
column: astValue.end_mark.column,
pointer: astValue.end_mark.pointer,
}
}
if(astKeyValue) {
// eslint-disable-next-line camelcase
range.key_start = {
line: astKeyValue.start_mark.line,
column: astKeyValue.start_mark.column,
pointer: astKeyValue.start_mark.pointer,
}
// eslint-disable-next-line camelcase
range.key_end = {
line: astKeyValue.end_mark.line,
column: astKeyValue.end_mark.column,
pointer: astKeyValue.end_mark.pointer,
}
}
return range
}
}
/**
* Get a JSON Path for position object in the spec
* @param {string} yaml
* YAML or JSON string
* @param {object} position
* position in the YAML or JSON string with `line` and `column` properties.
* `line` and `column` number are zero indexed
*/
export function pathForPosition(yaml, position) {
// Type check
if (typeof yaml !== "string") {
throw new TypeError("yaml should be a string")
}
if (typeof position !== "object" || typeof position.line !== "number" ||
typeof position.column !== "number") {
throw new TypeError("position should be an object with line and column" +
" properties")
}
try {
var ast = cachedCompose(yaml)
} catch (e) {
console.error("Error composing AST", e)
const problemMark = e.problem_mark || {}
const errorTraceMessage = [
yaml.split("\n").slice(problemMark.line - 5, problemMark.line + 1).join("\n"),
Array(problemMark.column).fill(" ").join("") + `^----- ${e.name}: ${e.toString().split("\n")[0]}`,
yaml.split("\n").slice(problemMark.line + 1, problemMark.line + 5).join("\n")
].join("\n")
console.error(errorTraceMessage)
return null
}
var path = []
return find(ast)
/**
* recursive find function that finds the node matching the position
* @param {object} current - AST object to serach into
*/
function find(current) {
// algorythm:
// is current a promitive?
// // finish recursion without modifying the path
// is current a hash?
// // find a key or value that position is in their range
// // if key is in range, terminate recursion with exisiting path
// // if a value is in range push the corresponding key to the path
// // andcontinue recursion
// is current an array
// // find the item that position is in it"s range and push the index
// // of the item to the path and continue recursion with that item.
var i = 0
if (!current || [MAP_TAG, SEQ_TAG].indexOf(current.tag) === -1) {
return path
}
if (current.tag === MAP_TAG) {
for (i = 0; i < current.value.length; i++) {
var pair = current.value[i]
var key = pair[0]
var value = pair[1]
if (isInRange(key)) {
return path
} else if (isInRange(value)) {
path.push(key.value)
return find(value)
}
}
}
if (current.tag === SEQ_TAG) {
for (i = 0; i < current.value.length; i++) {
var item = current.value[i]
if (isInRange(item)) {
path.push(i.toString())
return find(item)
}
}
}
return path
/**
* Determines if position is in node"s range
* @param {object} node - AST node
* @return {Boolean} true if position is in node"s range
*/
function isInRange(node) {
/* jshint camelcase: false */
// if node is in a single line
if (node.start_mark.line === node.end_mark.line) {
return (position.line === node.start_mark.line) &&
(node.start_mark.column <= position.column) &&
(node.end_mark.column >= position.column)
}
// if position is in the same line as start_mark
if (position.line === node.start_mark.line) {
return position.column >= node.start_mark.column
}
// if position is in the same line as end_mark
if (position.line === node.end_mark.line) {
return position.column <= node.end_mark.column
}
// if position is between start and end lines return true, otherwise
// return false.
return (node.start_mark.line < position.line) &&
(node.end_mark.line > position.line)
}
}
}
// utility fns
export let pathForPositionAsync = promisifySyncFn(pathForPosition)
export let positionRangeForPathAsync = promisifySyncFn(positionRangeForPath)
export let getLineNumberForPathAsync = promisifySyncFn(getLineNumberForPath)
function promisifySyncFn(fn) {
return function(...args) {
return new Promise((resolve) => resolve(fn(...args)))
}
}
+7
View File
@@ -0,0 +1,7 @@
import * as AST from "./ast"
export default function() {
return {
fn: { AST }
}
}
@@ -0,0 +1,21 @@
import keywordMap from "./keyword-map"
import getKeywordsForPath from "./get-keywords-for-path"
export default function getCompletions(editor, session, pos, prefix, cb, ctx, system) {
const { fn: { getPathForPosition }, specSelectors } = system
const { isOAS3 } = specSelectors
if(isOAS3 && isOAS3()) {
// isOAS3 selector exists, and returns true
return cb(null, null)
}
const { AST } = ctx
var editorValue = editor.getValue()
const path = getPathForPosition({ pos, prefix, editorValue, AST})
const suggestions = getKeywordsForPath({ system, path, keywordMap })
cb(null, suggestions)
}
@@ -0,0 +1,176 @@
import isArray from "lodash/isArray"
import isObject from "lodash/isObject"
import mapValues from "lodash/mapValues"
import isPlainObject from "lodash/isPlainObject"
import toArray from "lodash/toArray"
import isString from "lodash/isString"
import get from "lodash/get"
export default function getKeywordsForPath({ system, path, keywordMap }) {
keywordMap = Object.assign({}, keywordMap)
// is getting path was not successful stop here and return no candidates
if (!isArray(path)) {
return [
{
name: "array",
value: " ",
score: 300,
meta: "Couldn't load suggestions"
}
]
}
if(path[path.length - 2] === "tags" && path.length > 2) {
// 'path.length > 2' excludes top-level 'tags'
return system.specSelectors.tags().map(tag => ({
score: 0,
meta: "local",
value: tag.get("name"),
})).toJS()
}
let reversePath = path.slice(0).reverse()
if(reversePath[1] === "security" && isNumeric(reversePath[0])) {
// **.security[x]
return system.specSelectors.securityDefinitions().keySeq().map(sec => ({
score: 0,
meta: "local",
caption: sec,
snippet: `${sec}: []`
})).toJS()
}
if(reversePath[0] === "security") {
// **.security:
return system.specSelectors.securityDefinitions().keySeq().map(sec => ({
score: 0,
meta: "local",
caption: sec,
snippet: `\n- ${sec}: []`
})).toJS()
}
// traverse down the keywordMap for each key in the path until there is
// no key in the path
var key = path.shift()
while (key && isObject(keywordMap)) {
keywordMap = getChild(keywordMap, key)
key = path.shift()
}
// if no keywordMap was found after the traversal return no candidates
if (!isObject(keywordMap)) {
return []
}
// if keywordMap is an array of strings, return the array as list of
// suggestions
if (isArray(keywordMap) && keywordMap.every(isString)) {
return keywordMap.map(constructAceCompletion.bind(null, "value"))
}
// If keywordMap is describing an array unwrap the inner map so we can
// suggest for array items
if (isArray(keywordMap)) {
if(isArray(keywordMap[0])) {
return keywordMap[0].map(item => {
return {
name: "array",
value: "- " + item,
score: 300,
meta: "array item"
}
})
} else {
return [{
name: "array",
value: "- ",
score: 300,
meta: "array item"
}]
}
}
// if keywordMap is not an object at this point return no candidates
if (!isObject(keywordMap)) {
return []
}
// for each key in keywordMap map construct a completion candidate and
// return the array
return suggestionFromSchema(keywordMap)
}
function getChild(object, key) {
var keys = Object.keys(object)
var regex
var isArrayAccess = /^\d+$/.test(key)
if(isArrayAccess && isArray(object)) {
return object[0]
}
for (var i = 0; i < keys.length; i++) {
let childVal = object[keys[i]]
if(!childVal) {
return null
}
regex = new RegExp(childVal.__regex || keys[i])
if (regex.test(key) && childVal) {
if(typeof childVal === "object" && !isArray(childVal)) {
return Object.assign({}, childVal)
} else {
return childVal
}
}
}
}
function suggestionFromSchema(map) {
const res = toArray(mapValues(map, (val, key) => {
const keyword = get(val, "__value", key)
const meta = isPlainObject(val) ? "object" : "keyword"
return constructAceCompletion(meta, keyword)
}))
return res
}
function constructAceCompletion(meta, keyword) {
if(keyword.slice(0, 2) === "__") {
return {}
}
// Give keywords, that extra colon
let snippet
switch(meta) {
case "keyword":
snippet = `${keyword}: `
break
case "object":
snippet = `${keyword}:\n `
break
default:
snippet = keyword
}
// snippet's treat `$` as special characters
snippet = snippet.replace("$", "\\$")
return {
snippet,
caption: keyword,
score: 300,
meta,
}
}
function isNumeric(obj) {
return !isNaN(obj)
}
@@ -0,0 +1,11 @@
import * as wrapActions from "./wrap-actions"
export default function EditorAutosuggestKeywordsPlugin() {
return {
statePlugins: {
editor: {
wrapActions,
}
}
}
}
@@ -0,0 +1,265 @@
var Bool = ["true", "false"]
var Anything = String
var combine = (...objs) => objs ? Object.assign({}, ...objs) : {}
var makeValue = (val = "") => {
return {
__value: val
}
}
var emptyValue = makeValue("")
var externalDocs = {
description: String,
url: String
}
var xml = {
name: String,
namespace: String,
prefix: String,
attribute: Bool,
wrapped: Bool
}
var schema = {
$ref: String,
format: String,
title: String,
description: String,
default: String,
maximum: Number,
minimum: Number,
exclusiveMaximum: Bool,
exclusiveMinimum: Bool,
maxLength: Number,
minLength: Number,
pattern: String,
maxItems: Number,
minItems: Number,
uniqueItems: Bool,
enum: [String],
multipleOf: Number,
maxProperties: Number,
minProperties: Number,
required: [String],
type: ["string", "number", "integer", "boolean", "array", "object"],
get items () { return this },
get allOf () { return [this] },
get properties () {
return {
".": this
}
},
get additionalProperties () { return this },
discriminator: String,
readOnly: Bool,
xml: xml,
externalDocs: externalDocs,
example: String
}
var schemes = [
"http",
"https",
"ws",
"wss"
]
var items = {
type: ["string", "number", "integer", "boolean", "array"],
format: String,
get items () { return this },
collectionFormat: ["csv"],
default: Anything,
minimum: String,
maximum: String,
exclusiveMinimum: Bool,
exclusiveMaximum: Bool,
minLength: String,
maxLength: String,
pattern: String,
minItems: String,
maxItems: String,
uniqueItems: Bool,
enum: [Anything],
multipleOf: String
}
var header = {
description: String,
type: String,
format: String,
items: items,
collectionFormat: ["csv"],
default: Anything,
enum: [String],
minimum: String,
maximum: String,
exclusiveMinimum: Bool,
exclusiveMaximum: Bool,
multipleOf: String,
maxLength: String,
minLength: String,
pattern: String,
minItems: String,
maxItems: String,
uniqueItems: Bool
}
var parameter = {
name: String,
description: String,
required: ["true", "false"],
type: [
"string",
"number",
"boolean",
"integer",
"array",
"file"
],
format: String,
schema: schema,
enum: [String],
minimum: String,
maximum: String,
exclusiveMinimum: Bool,
exclusiveMaximum: Bool,
multipleOf: String,
maxLength: String,
minLength: String,
pattern: String,
minItems: String,
maxItems: String,
uniqueItems: Bool,
allowEmptyValue: Bool,
collectionFormat: ["csv", "multi"],
default: String,
items: items,
in: [
"body",
"formData",
"header",
"path",
"query"
]
}
var reference = {
"$ref": String
}
var response = {
description: String,
schema: schema,
headers: {
".": combine(header, {
__value: ""
})
},
examples: String
}
var operation = {
summary: String,
description: String,
schemes: [schemes],
externalDocs: externalDocs,
operationId: String,
produces: [String],
consumes: [String],
deprecated: Bool,
security: [String],
parameters: [combine(reference, parameter)],
responses: {
"[2-6][0-9][0-9]": combine(reference, response, emptyValue),
"default": combine(reference, response)
},
tags: [String]
}
var securityScheme = {
type: ["oauth2", "apiKey", "basic"],
description: String,
name: String,
in: ["query", "header"],
flow: ["implicit", "password", "application", "accessCode"],
authorizationUrl: String,
tokenUrl: String,
scopes: String // actually an object, but this is equivalent
}
var info = {
version: String,
title: String,
description: String,
termsOfService: String,
contact: {
name: String,
url: String,
email: String
},
license: {
name: String,
url: String
}
}
var map = {
swagger: ["\'2.0\'"],
info: info,
host: String,
basePath: String,
schemes: [schemes],
produces: [String],
consumes: [String],
paths: {
//path
".": {
__value: "",
parameters: [combine(reference, parameter)],
"get": operation,
"put": operation,
"post": operation,
"delete": operation,
"options": operation,
"head": operation,
"patch": operation,
"$ref": String
}
},
definitions: {
// Definition name
".": combine(schema, emptyValue)
},
parameters: {
".": combine(reference, parameter, emptyValue)
},
responses: {
"[2-6][0-9][0-9]": combine(response, emptyValue)
},
securityDefinitions: {
".": combine(securityScheme, emptyValue)
},
security: [String],
tags: [{
name: String,
description: String,
externalDocs: externalDocs
}],
externalDocs: externalDocs
}
export default map
@@ -0,0 +1,11 @@
import getCompletions from "./get-completions"
// Add an autosuggest completer
export const addAutosuggestionCompleters = (ori, system) => (context) => {
return ori(context).concat([{
getCompletions(...args) {
// Add `context`, then `system` as the last args
return getCompletions(...args, context, system)
}
}])
}
@@ -0,0 +1,21 @@
import keywordMap from "./keyword-map"
import getKeywordsForPath from "./get-keywords-for-path"
export default function getCompletions(editor, session, pos, prefix, cb, ctx, system) {
const { fn: { getPathForPosition }, specSelectors } = system
const { isOAS3 } = specSelectors
if(isOAS3 && !isOAS3()) {
// isOAS3 selector exists, and returns false
return cb(null, null)
}
const { AST } = ctx
var editorValue = editor.getValue()
const path = getPathForPosition({ pos, prefix, editorValue, AST})
const suggestions = getKeywordsForPath({ system, path, keywordMap })
cb(null, suggestions)
}
@@ -0,0 +1,176 @@
import isArray from "lodash/isArray"
import isObject from "lodash/isObject"
import mapValues from "lodash/mapValues"
import isPlainObject from "lodash/isPlainObject"
import toArray from "lodash/toArray"
import isString from "lodash/isString"
import get from "lodash/get"
export default function getKeywordsForPath({ system, path, keywordMap}) {
keywordMap = Object.assign({}, keywordMap)
// is getting path was not successful stop here and return no candidates
if (!isArray(path)) {
return [
{
name: "array",
value: " ",
score: 300,
meta: "Couldn't load suggestions"
}
]
}
if(path[path.length - 2] === "tags" && path.length > 2) {
// 'path.length > 2' excludes top-level 'tags'
return system.specSelectors.tags().map(tag => ({
score: 0,
meta: "local",
value: tag.get("name"),
})).toJS()
}
let reversePath = path.slice(0).reverse()
if(reversePath[1] === "security" && isNumeric(reversePath[0])) {
// **.security[x]
return system.specSelectors.securityDefinitions().keySeq().map(sec => ({
score: 0,
meta: "local",
caption: sec,
snippet: `${sec}: []`
})).toJS()
}
if(reversePath[0] === "security") {
// **.security:
return system.specSelectors.securityDefinitions().keySeq().map(sec => ({
score: 0,
meta: "local",
caption: sec,
snippet: `\n- ${sec}: []`
})).toJS()
}
// traverse down the keywordMap for each key in the path until there is
// no key in the path
var key = path.shift()
while (key && isObject(keywordMap)) {
keywordMap = getChild(keywordMap, key)
key = path.shift()
}
// if no keywordMap was found after the traversal return no candidates
if (!isObject(keywordMap)) {
return []
}
// if keywordMap is an array of strings, return the array as list of
// suggestions
if (isArray(keywordMap) && keywordMap.every(isString)) {
return keywordMap.map(constructAceCompletion.bind(null, "value"))
}
// If keywordMap is describing an array unwrap the inner map so we can
// suggest for array items
if (isArray(keywordMap)) {
if(isArray(keywordMap[0])) {
return keywordMap[0].map(item => {
return {
name: "array",
value: "- " + item,
score: 300,
meta: "array item"
}
})
} else {
return [{
name: "array",
value: "- ",
score: 300,
meta: "array item"
}]
}
}
// if keywordMap is not an object at this point return no candidates
if (!isObject(keywordMap)) {
return []
}
// for each key in keywordMap map construct a completion candidate and
// return the array
return suggestionFromSchema(keywordMap)
}
function getChild(object, key) {
var keys = Object.keys(object)
var regex
var isArrayAccess = /^\d+$/.test(key)
if(isArrayAccess && isArray(object)) {
return object[0]
}
for (var i = 0; i < keys.length; i++) {
let childVal = object[keys[i]]
if (!childVal) {
return null
}
regex = new RegExp(childVal.__regex || keys[i])
if (regex.test(key) && childVal) {
if(typeof childVal === "object" && !isArray(childVal)) {
return Object.assign({}, childVal)
} else {
return childVal
}
}
}
}
function suggestionFromSchema(map) {
const res = toArray(mapValues(map, (val, key) => {
const keyword = get(val, "__value", key)
const meta = isPlainObject(val) ? "object" : "keyword"
return constructAceCompletion(meta, keyword)
}))
return res
}
function constructAceCompletion(meta, keyword) {
if(keyword.slice(0, 2) === "__") {
return {}
}
// Give keywords, that extra colon
let snippet
switch(meta) {
case "keyword":
snippet = `${keyword}: `
break
case "object":
snippet = `${keyword}:\n `
break
default:
snippet = keyword
}
// snippet's treat `$` as special characters
snippet = snippet.replace("$", "\\$")
return {
snippet,
caption: keyword,
score: 300,
meta,
}
}
function isNumeric(obj) {
return !isNaN(obj)
}
@@ -0,0 +1,11 @@
import * as wrapActions from "./wrap-actions"
export default function EditorAutosuggestOAS3KeywordsPlugin() {
return {
statePlugins: {
editor: {
wrapActions,
}
}
}
}
@@ -0,0 +1,20 @@
import {
ExternalDocumentation,
Info,
SecurityRequirement,
Server,
Tag,
Components,
Paths
} from "./oas3-objects.js"
export default {
openapi: String,
info: Info,
servers: [Server],
paths: Paths,
components: Components,
security: [SecurityRequirement],
tags: [Tag],
externalDocs: ExternalDocumentation
}
@@ -0,0 +1,331 @@
// Adapted from OAS 3.0.0-rc2
// comma dangles in this file = cleaner diffs
/*eslint comma-dangle: ["error", "always-multiline"]*/
// anyOf and combine are the same for now.
// they are seperated for semantics, and for possible future improvement
const anyOf = (...objs) => objs ? Object.assign({}, ...objs) : {}
const stringEnum = (arr) => arr
const Any = null
export const ExternalDocumentation = {
description: String,
url: String,
}
export const Contact = {
name: String,
url: String,
email: String,
}
export const License = {
name: String,
url: String,
}
export const Info = {
title: String,
description: String,
termsOfService: String,
contact: Contact,
license: License,
version: String,
}
export const ServerVariable = {
enum: [String],
default: String,
description: String,
}
export const XML = {
name: String,
namespace: String,
prefix: String,
attribute: Boolean,
wrapped: Boolean,
}
export const OAuthFlow = {
authorizationUrl: String,
tokenUrl: String,
refreshUrl: String,
scopes: {
".": String,
},
}
export const Reference = {
"$ref": String,
}
export const Example = {
summary: String,
description: String,
value: Any,
externalValue: String,
}
export const SecurityRequirement = {
".": [String],
}
export const Server = {
url: String,
description: String,
variables: {
".": ServerVariable,
},
}
export const Link = {
operationRef: String,
operationId: String,
parameters: {
".": Any,
},
requestBody: Any,
description: String,
server: Server,
}
export const Schema = {
// Lifted from JSONSchema
title: String,
multipleOf: String,
maximum: String,
exclusiveMaximum: String,
minimum: String,
exclusiveMinimum: String,
maxLength: String,
minLength: String,
pattern: RegExp,
maxItems: String,
minItems: String,
uniqueItems: Boolean,
maxProperties: String,
minProperties: String,
required: Boolean,
enum: String,
// Adapted from JSONSchema
type: String,
get allOf () { return this },
get oneOf () { return this },
get anyOf () { return this },
get not () { return this },
get items () { return this },
get properties () {
return {
".": this,
}
},
get additionalProperties () { return this },
description: String,
format: String,
default: Any,
nullable: Boolean,
readOnly: Boolean,
writeOnly: Boolean,
xml: XML,
externalDocs: ExternalDocumentation,
example: Any,
deprecated: Boolean,
}
export const Encoding = {
contentType: String,
headers: {
".": undefined,
},
style: stringEnum(["matrix", "label", "form", "simple", "spaceDelimited", "pipeDelimited", "deepObject"]),
explode: Boolean,
allowReserved: Boolean,
}
export const MediaType = {
schema: anyOf(Schema, Reference),
example: Any,
examples: {
".": anyOf(Example, Reference),
},
encoding: {
".": Encoding,
},
}
export const Parameter = {
name: String,
in: stringEnum(["query", "header", "path", "cookie"]),
description: String,
required: Boolean,
deprecated: Boolean,
allowEmptyValue: Boolean,
style: stringEnum(["matrix", "label", "form", "simple", "spaceDelimited", "pipeDelimited", "deepObject"]),
explode: String,
allowReserved: Boolean,
schema: anyOf(Schema, Reference),
example: Any,
examples: {
".": anyOf(Example, Reference),
},
content: {
".": MediaType,
},
}
export const Header = {
description: String,
required: Boolean,
deprecated: Boolean,
allowEmptyValue: Boolean,
style: stringEnum(["matrix", "label", "form", "simple", "spaceDelimited", "pipeDelimited", "deepObject"]),
explode: String,
allowReserved: Boolean,
schema: anyOf(Schema, Reference),
example: Any,
examples: {
".": anyOf(Example, Reference),
},
content: {
".": MediaType,
},
}
export const RequestBody = {
description: String,
content: {
".": MediaType,
},
}
export const Response = {
description: String,
headers: {
".": anyOf(Header, Reference),
},
content: {
".": MediaType,
},
links: {
".": anyOf(Link, Reference),
},
}
export const Responses = {
default: anyOf(Response, Reference),
"\\d\\d\\d|\\d\\dX|\\dXX": anyOf(Response, Reference),
}
export const Callback = {
// ".": PathItem,
}
export const Tag = {
name: String,
description: String,
externalDocs: ExternalDocumentation,
}
export const OAuthFlows = {
implicit: OAuthFlow,
password: OAuthFlow,
clientCredentials: OAuthFlow,
authorizationCode: OAuthFlow,
}
export const SecurityScheme = {
type: String,
description: String,
name: String,
in: String,
scheme: String,
bearerFormat: String,
flows: OAuthFlows,
openIdConnectUrl: String,
}
const ComponentFixedFieldRegex = "^[a-zA-Z0-9\.\-_]+$"
export const Components = {
schemas: {
[ComponentFixedFieldRegex]: anyOf(Schema, Reference),
},
responses: {
[ComponentFixedFieldRegex]: anyOf(Response, Reference),
},
parameters: {
[ComponentFixedFieldRegex]: anyOf(Parameter, Reference),
},
examples: {
[ComponentFixedFieldRegex]: anyOf(Example, Reference),
},
requestBodies: {
[ComponentFixedFieldRegex]: anyOf(RequestBody, Reference),
},
headers: {
[ComponentFixedFieldRegex]: anyOf(Header, Reference),
},
securitySchemes: {
[ComponentFixedFieldRegex]: anyOf(SecurityScheme, Reference),
},
links: {
[ComponentFixedFieldRegex]: anyOf(Link, Reference),
},
callbacks: {
get [ComponentFixedFieldRegex]() { return anyOf(Callback, Reference) },
},
}
export const Operation = {
tags: [String],
summary: String,
description: String,
externalDocs: ExternalDocumentation,
operationId: String,
parameters: [anyOf(Parameter, Reference)],
requestBody: anyOf(RequestBody, Reference),
responses: Responses,
get callbacks() {
return {
".": anyOf(Callback, Reference),
}
},
deprecated: Boolean,
security: [SecurityRequirement],
servers: [Server],
}
export const Discriminator = {
propertyName: String,
mapping: {
".": String,
},
}
export const PathItem = anyOf(Reference, {
summary: String,
description: String,
get: Operation,
put: Operation,
post: Operation,
delete: Operation,
options: Operation,
head: Operation,
patch: Operation,
trace: Operation,
servers: Server,
parameters: anyOf(Parameter, Reference),
})
export const Paths = {
"/.": PathItem,
}
// solves `PathItem -> Operation -> Callback -> PathItem` circular reference
Callback["."] = PathItem
// solves `Encoding -> Header -> MediaType -> Encoding` circular reference
Encoding.headers["."] = Header
@@ -0,0 +1,11 @@
import getCompletions from "./get-completions"
// Add an autosuggest completer
export const addAutosuggestionCompleters = (ori, system) => (context) => {
return ori(context).concat([{
getCompletions(...args) {
// Add `context`, then `system` as the last args
return getCompletions(...args, context, system)
}
}])
}
@@ -0,0 +1,12 @@
import getRefsForPath from "./get-refs-for-path"
export default function getCompletions(editor, session, pos, prefix, cb, ctx, system) {
const { fn: { getPathForPosition } } = system
const { AST } = ctx
var editorValue = editor.getValue()
const path = getPathForPosition({ pos, prefix, editorValue, AST})
const suggestions = getRefsForPath({ system, path})
cb(null, suggestions)
}
@@ -0,0 +1,24 @@
import isArray from "lodash/isArray"
import last from "lodash/last"
export default function getRefsForPath({ system, path }) {
// Note fellow ace hackers:
// we have to be weary of _what_ ace will filter on, see the order ( probably should be fixed, but... ): https://github.com/ajaxorg/ace/blob/b219b5584456534fbccb5fb20470c61011fa0b0a/lib/ace/autocomplete.js#L469
// Because of that, I'm matching on `caption` and using `snippet` instead of `value` for injecting
if(isArray(path) && last(path) === "$ref") {
const localRefs = system.specSelectors.localRefs()
const refType = system.specSelectors.getRefType(path)
return localRefs
.filter(r => r.get("type") == refType)
.toJS()
.map(r => ({
score: 100,
meta: "local",
snippet: `'${r.$ref}'`, // wrap in quotes
caption: r.name,
}))
}
return []
}
@@ -0,0 +1,11 @@
import * as wrapActions from "./wrap-actions"
export default function EditorAutosuggestRefsPlugin() {
return {
statePlugins: {
editor: {
wrapActions,
}
}
}
}
@@ -0,0 +1,11 @@
import getCompletions from "./get-completions"
// Add an autosuggest completer
export const addAutosuggestionCompleters = (ori, system) => (context) => {
return ori(context).concat([{
getCompletions(...args) {
// Add `context`, then `system` as the last args
return getCompletions(...args, context, system)
}
}])
}
@@ -0,0 +1,21 @@
import snippets from "./snippets"
import getSnippetsForPath from "./get-snippets-for-path"
export default function getCompletions(editor, session, pos, prefix, cb, ctx, system) {
const { fn: { getPathForPosition }, specSelectors } = system
const { isOAS3 } = specSelectors
if(isOAS3 && isOAS3()) {
// isOAS3 selector exists, and returns true
return cb(null, null)
}
const { AST } = ctx
const editorValue = editor.getValue()
const path = getPathForPosition({ pos, prefix, editorValue, AST})
const suggestions = getSnippetsForPath({ path, snippets})
return cb(null, suggestions)
}
@@ -0,0 +1,49 @@
import isArray from "lodash/isArray"
export default function getSnippetsForPath({ path, snippets }) {
// find all possible snippets, modify them to be compatible with Ace and
// sort them based on their position. Sorting is done by assigning a score
// to each snippet, not by sorting the array
if (!isArray(path)) {
return []
}
return snippets
.filter(snippet => {
return snippet.path.length === path.length
})
.filter(snippet => {
return snippet.path.every((k, i) => {
return !!(new RegExp(k)).test(path[i])
})
})
.map(snippet => {
// change shape of snippets for ACE
return {
caption: snippet.name,
snippet: snippet.content,
meta: "snippet"
}
})
.map(snippetSorterForPos(path))
}
export function snippetSorterForPos(path) {
return function(snippet) {
// by default score is high
let score = 1000
// if snippets content has the keyword it will get a lower score because
// it's more likely less relevant
// (FIX) is this logic work for all cases?
path.forEach(function(keyword) {
if (snippet.snippet.indexOf(keyword)) {
score = 500
}
})
snippet.score = score
return snippet
}
}
@@ -0,0 +1,11 @@
import * as wrapActions from "./wrap-actions"
export default function EditorAutosuggestSnippetsPlugin() {
return {
statePlugins: {
editor: {
wrapActions,
}
}
}
}
@@ -0,0 +1,192 @@
const operationRegex = "get|put|post|delete|options|head|patch"
/**
* Makes an HTTP operation snippet's content based on operation name
*
* @param {string} operationName - the HTTP verb
*
* @return {string} - the snippet content for that operation
*/
function makeOperationSnippet(operationName) {
return [
"${1:" + operationName + "}:",
" summary: ${2}",
" description: ${2}",
" responses:",
" ${3:200:}",
" description: ${4:OK}",
"${6}"
].join("\n")
}
/**
* Makes an HTTP response code snippet's content based on code
*
* @param {string} code - HTTP Response Code
*
* @return {string} - Snippet content
*/
function makeResponseCodeSnippet(code) {
return [
"${1:" + code + "}:",
" description: ${2}",
"${3}"
].join("\n")
}
export default [
{
name: "swagger",
trigger: "sw",
path: [],
content: [
"swagger: \'2.0\'",
"${1}"
].join("\n")
},
{
name: "info",
trigger: "info",
path: [],
content: [
"info:",
" version: ${1:0.0.0}",
" title: ${2:title}",
" description: ${3:description}",
" termsOfService: ${4:terms}",
" contact:",
" name: ${5}",
" url: ${6}",
" email: ${7}",
" license:",
" name: ${8:MIT}",
" url: ${9:http://opensource.org/licenses/MIT}",
"${10}"
].join("\n")
},
{
name: "get",
trigger: "get",
path: ["paths", "."],
content: makeOperationSnippet("get")
},
{
name: "post",
trigger: "post",
path: ["paths", "."],
content: makeOperationSnippet("post")
},
{
name: "put",
trigger: "put",
path: ["paths", "."],
content: makeOperationSnippet("put")
},
{
name: "delete",
trigger: "delete",
path: ["paths", "."],
content: makeOperationSnippet("delete")
},
{
name: "patch",
trigger: "patch",
path: ["paths", "."],
content: makeOperationSnippet("patch")
},
{
name: "options",
trigger: "options",
path: ["paths", "."],
content: makeOperationSnippet("options")
},
// operation level parameter
{
name: "parameter",
trigger: "param",
path: ["paths", ".", ".", "parameters"],
content: [
"- name: ${1:parameter_name}",
" in: ${2:query}",
" description: ${3:description}",
" type: ${4:string}",
"${5}"
].join("\n")
},
// path level parameter
{
name: "parameter",
trigger: "param",
path: ["paths", ".", "parameters"],
content: [
"- name: ${1:parameter_name}",
" in: ${2:path}",
" required: true",
" description: ${3:description}",
" type: ${4:string}",
"${5}"
].join("\n")
},
{
name: "response",
trigger: "resp",
path: ["paths", ".", ".", "responses"],
content: [
"${1:code}:",
" description: ${2}",
" schema: ${3}",
"${4}"
].join("\n")
},
{
name: "200",
trigger: "200",
path: ["paths", ".", operationRegex, "responses"],
content: makeResponseCodeSnippet("200")
},
{
name: "300",
trigger: "300",
path: ["paths", ".", operationRegex, "responses"],
content: makeResponseCodeSnippet("300")
},
{
name: "400",
trigger: "400",
path: ["paths", ".", operationRegex, "responses"],
content: makeResponseCodeSnippet("400")
},
{
name: "500",
trigger: "500",
path: ["paths", ".", operationRegex, "responses"],
content: makeResponseCodeSnippet("500")
},
{
name: "model",
trigger: "mod|def",
regex: "mod|def",
path: ["definitions"],
content: [
"${1:ModelName}:",
" type: object",
" properties:",
" ${2}"
]
}
]
@@ -0,0 +1,11 @@
import getCompletions from "./get-completions"
// Add an autosuggest completer
export const addAutosuggestionCompleters = (ori, system) => (context) => {
return ori(context).concat([{
getCompletions(...args) {
// Add `context`, then `system` as the last args
return getCompletions(...args, context, system)
}
}])
}
@@ -0,0 +1,15 @@
// Enable Ace editor autocompletions
export const enableAutocompletions = ({editor}) => () => {
editor.setOptions({
enableBasicAutocompletion: true,
enableSnippets: true,
enableLiveAutocompletion: true
})
}
// Add completers. Just override this method. And concat on your completer(s)
// see: https://github.com/ajaxorg/ace/blob/master/lib/ace/autocomplete.js
// eg: return ori(...args).concat({ getCompletions() {...}})
export const addAutosuggestionCompleters = () => () => {
return []
}
@@ -0,0 +1,73 @@
export function getPathForPosition({ pos: originalPos, prefix, editorValue, AST }) {
var pos = Object.assign({}, originalPos)
var lines = editorValue.split(/\r\n|\r|\n/)
var previousLine = lines[pos.row - 1] || ""
var currentLine = lines[pos.row]
var nextLine = lines[pos.row + 1] || ""
var prepared = false
// we're always at the document root when there's no indentation,
// so let's save some effort
if (pos.column === 1) {
return []
}
let prevLineIndent = getIndent(previousLine).length
let currLineIndent = getIndent(currentLine).length
const isCurrentLineEmpty = currentLine.replace(prefix, "").trim() === ""
if(
(previousLine.trim()[0] === "-" || nextLine.trim()[0] === "-")
&& currLineIndent >= prevLineIndent
&& isCurrentLineEmpty
) {
// for arrays with existing items under it, on blank lines
// example:
// myArray:
// - a: 1
// | <-- user cursor
currentLine += "- a: b" // fake array item
// pos.column += 1
prepared = true
}
// if current position is in at a free line with whitespace insert a fake
// key value pair so the generated AST in ASTManager has current position in
// editing node
if ( !prepared && isCurrentLineEmpty) {
currentLine += "a: b" // fake key value pair
pos.column += 1
prepared = true
}
if(currentLine[currentLine.length - 1] === ":") {
// Add a space if a user doesn't put one after a colon
// NOTE: this doesn't respect the "prepared" flag.
currentLine += " "
pos.column += 1
}
//if prefix is empty then add fake, empty value
if( !prepared && !prefix){
// for scalar values with no values
// i.e. "asdf: "
currentLine += "~"
}
// append inserted character in currentLine for better AST results
lines[originalPos.row] = currentLine
editorValue = lines.join("\n")
let path = AST.pathForPosition(editorValue, {
line: pos.row,
column: pos.column
})
return path
}
function getIndent(str) {
let match = str.match(/^ +/)
return match ? match[0] : ""
}
@@ -0,0 +1,46 @@
export function wrapCompleters(completers, cutoff = 100) {
let isLiveCompletionDisabled = false
let lastSpeeds = []
let isPerformant = () => lastSpeeds.every(speed => speed < cutoff)
if(cutoff === 0 || cutoff === "0") {
// never disable live autocomplete
return completers
}
return completers.map((completer, i) => {
let ori = completer.getCompletions
completer.getCompletions = function(editor, session, pos, prefix, callback) {
let startTime = Date.now()
try {
ori(editor, session, pos, prefix, (...args) => {
let msElapsed = Date.now() - startTime
lastSpeeds[i] = msElapsed
if(isLiveCompletionDisabled && isPerformant()) {
console.warn("Manual autocomplete was performant - re-enabling live autocomplete")
editor.setOptions({
enableLiveAutocompletion: true
})
isLiveCompletionDisabled = false
}
if(msElapsed > cutoff && editor.getOption("enableLiveAutocompletion")) {
console.warn("Live autocomplete is slow - disabling it")
editor.setOptions({
enableLiveAutocompletion: false
})
isLiveCompletionDisabled = true
}
callback(...args)
})
} catch(e) {
console.error("Autocompleter encountered an error")
console.error(e)
callback(null, [])
}
}
return completer
})
}
@@ -0,0 +1,36 @@
import * as actions from "./actions"
import * as fn from "./fn"
import * as specSelectors from "./spec-selectors"
import { wrapCompleters } from "./helpers"
export default function EditorAutosuggestPlugin() {
return {
fn,
statePlugins: {
spec: {
selectors: specSelectors,
},
editor: {
actions,
wrapActions: {
onLoad: (ori, sys) => (context) => {
const { editor } = context
// Any other calls for editor#onLoad
ori(context)
// Enable autosuggestions ( aka: autocompletions )
sys.editorActions.enableAutocompletions(context)
// Add completers ( for autosuggestions )
const completers = sys.editorActions.addAutosuggestionCompleters(context)
const cutoff = sys.getConfigs().liveAutocompleteCutoff
const wrappedCompleters = wrapCompleters(completers || [], cutoff)
editor.completers = wrappedCompleters
return
}
}
}
}
}
}
@@ -0,0 +1,61 @@
import { createSelector } from "reselect"
import { Set, Map } from "immutable"
import { escapeJsonPointerToken } from "../refs-util"
const SWAGGER2_REF_MAP = {
"paths": "pathitems",
"definitions": "definitions",
"schema": "definitions",
"parameters": "parameters",
"responses": "responses"
}
const OAS3_REF_MAP = {
schemas: "components/schemas", // for Schemas within Components
schema: "components/schemas", // for Schemas throughout document
parameters: "components/parameters",
requestBody: "components/requestBodies",
callbacks: "components/callbacks",
examples: "components/examples",
responses: "components/responses",
headers: "components/headers",
links: "components/links"
}
const SWAGGER2_TYPES = Set(Object.values(SWAGGER2_REF_MAP))
const OAS3_TYPES = Set(Object.values(OAS3_REF_MAP))
// Return a normalized "type" for a given path [a,b,c]
// eg: /definitions/bob => definition
// /paths/~1pets/responses/200/schema => definition ( because of schema )
export const getRefType = (state, path) => (sys) => createSelector(
() => {
for( var i=path.length-1; i>-1; i-- ) {
let tag = path[i]
if(sys.specSelectors.isOAS3 && sys.specSelectors.isOAS3()) {
if(OAS3_REF_MAP[tag]) {
return OAS3_REF_MAP[tag]
}
} else if( SWAGGER2_REF_MAP[tag] ) {
return SWAGGER2_REF_MAP[tag]
}
}
return null
})(state)
export const localRefs = (state) => (sys) => createSelector(
sys.specSelectors.spec,
sys.specSelectors.isOAS3 || (() => false),
(spec, isOAS3) => {
return (isOAS3 ? OAS3_TYPES : SWAGGER2_TYPES).toList().flatMap( type => {
return spec
.getIn(type.split("/"), Map({}))
.keySeq()
.map( name => Map({
name,
type,
$ref: `#/${type}/${escapeJsonPointerToken(name)}`,
}))
})
}
)(state)
@@ -0,0 +1,15 @@
export default function(system) {
return {
rootInjects: {
getEditorMetadata() {
const allErrors = system.errSelectors.allErrors()
return {
contentString: system.specSelectors.specStr(),
contentObject: system.specSelectors.specJson().toJS(),
isValid: allErrors.size === 0,
errors: allErrors.toJS()
}
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
export const JUMP_TO_LINE = "jump_to_line"
export function jumpToLine(line) {
return {
type: JUMP_TO_LINE,
payload: line
}
}
// This is a hook. Will have editor instance
// It needs to be an async-function, to avoid dispatching an object to the reducer
export const onLoad = () => () => {}
@@ -0,0 +1,6 @@
/* global ace */
ace.define("ace/snippets/yaml",
["require","exports","module"], function(e,t,n){ // eslint-disable-line no-unused-vars
t.snippetText=undefined
t.scope="yaml"
})
@@ -0,0 +1,65 @@
import React from "react"
import PropTypes from "prop-types"
export default class EditorContainer extends React.Component {
// This is already debounced by editor.jsx
onChange = (value) => {
this.props.onChange(value)
}
render() {
let { specSelectors, getComponent, errSelectors, fn, editorSelectors, configsSelectors } = this.props
let Editor = getComponent("Editor")
let wrapperClasses = ["editor-wrapper"]
const readOnly = !!configsSelectors.get("readOnly")
if(readOnly) {
wrapperClasses.push("read-only")
}
let propsForEditor = this.props
const editorOptions = {
enableLiveAutocompletion: configsSelectors.get("editorLiveAutocomplete"),
readOnly: readOnly,
highlightActiveLine: !readOnly,
highlightGutterLine: !readOnly,
}
return (
<div id='editor-wrapper' className={wrapperClasses.join(" ")}>
{ readOnly ? <h2 className="editor-readonly-watermark">Read Only</h2> : null }
<Editor
{...propsForEditor}
value={specSelectors.specStr()}
origin={specSelectors.specOrigin()}
editorOptions={editorOptions}
specObject={specSelectors.specJson().toJS()}
errors={errSelectors.allErrors()}
onChange={this.onChange}
goToLine={editorSelectors.gotoLine()}
AST={fn.AST}
/>
</div>
)
}
}
EditorContainer.defaultProps = {
onChange: Function.prototype
}
EditorContainer.propTypes = {
specActions: PropTypes.object.isRequired,
configsSelectors: PropTypes.object.isRequired,
onChange: PropTypes.func,
fn: PropTypes.object,
specSelectors: PropTypes.object.isRequired,
errSelectors: PropTypes.object.isRequired,
editorSelectors: PropTypes.object.isRequired,
getComponent: PropTypes.func.isRequired,
}
@@ -0,0 +1,326 @@
import React from "react"
import PropTypes from "prop-types"
import AceEditor from "react-ace"
import editorPluginsHook from "../editor-plugins/hook"
import { placeMarkerDecorations } from "../editor-helpers/marker-placer"
import Im, { fromJS } from "immutable"
import ImPropTypes from "react-immutable-proptypes"
import win from "src/window"
import isUndefined from "lodash/isUndefined"
import omit from "lodash/omit"
import isEqual from "lodash/isEqual"
import debounce from "lodash/debounce"
import ace from "brace"
import "brace/mode/yaml"
import "brace/theme/tomorrow_night_eighties"
import "brace/ext/language_tools"
import "brace/ext/searchbox"
import "./brace-snippets-yaml"
const NOOP = Function.prototype // Apparently the best way to no-op
export default function makeEditor({ editorPluginsToRun }) {
class Editor extends React.Component {
constructor(props, context) {
super(props, context)
this.editor = null
this.debouncedOnChange = props.debounce > 0
? debounce(props.onChange, props.debounce)
: props.onChange
}
static propTypes = {
specId: PropTypes.string,
value: PropTypes.string,
editorOptions: PropTypes.object,
origin: PropTypes.string,
debounce: PropTypes.number,
onChange: PropTypes.func,
onMarkerLineUpdate: PropTypes.func,
markers: PropTypes.object,
goToLine: PropTypes.object,
specObject: PropTypes.object.isRequired,
editorActions: PropTypes.object,
AST: PropTypes.object.isRequired,
errors: ImPropTypes.list,
}
static defaultProps = {
value: "",
specId: "--unknown--",
origin: "not-editor",
onChange: NOOP,
onMarkerLineUpdate: NOOP,
markers: {},
goToLine: {},
errors: fromJS([]),
editorActions: {onLoad(){}},
editorOptions: {},
debounce: 800 // 0.5 imperial seconds™
}
checkForSilentOnChange = (value) => {
if(!this.silent) {
this.debouncedOnChange(value)
}
}
onLoad = (editor) => {
const { props } = this
const { AST, specObject } = props
const langTools = ace.acequire("ace/ext/language_tools")
const session = editor.getSession()
this.editor = editor
// fixes a warning, see https://github.com/ajaxorg/ace/issues/2499
editor.$blockScrolling = Infinity
session.setUseWrapMode(true)
session.on("changeScrollLeft", xPos => { // eslint-disable-line no-unused-vars
session.setScrollLeft(0)
})
// TODO Remove this in favour of editorActions.onLoad
editorPluginsHook(editor, props, editorPluginsToRun || [], {
langTools, AST, specObject
})
editor.setHighlightActiveLine(false)
editor.setHighlightActiveLine(true)
this.syncOptionsFromState(props.editorOptions)
if(props.editorActions && props.editorActions.onLoad)
props.editorActions.onLoad({...props, langTools, editor})
this.updateMarkerAnnotations(this.props)
}
onResize = () => {
const { editor } = this
if(editor) {
let session = editor.getSession()
editor.resize()
let wrapLimit = session.getWrapLimit()
editor.setPrintMarginColumn(wrapLimit)
}
}
onClick = () => {
// onClick is deferred by 40ms, to give element resizes time to settle.
setTimeout(() => {
if(this.getWidth() !== this.width) {
this.onResize()
this.width = this.getWidth()
}
}, 40)
}
getWidth = () => {
let el = win.document.getElementById("editor-wrapper")
return el ? el.getBoundingClientRect().width : null
}
updateErrorAnnotations = (nextProps) => {
if(this.editor && nextProps.errors) {
let editorAnnotations = nextProps.errors.toJS().map(err => {
// Create annotation objects that ACE can use
return {
row: err.line - 1,
column: 0,
type: err.level,
text: err.message
}
})
this.editor.getSession().setAnnotations(editorAnnotations)
}
}
updateMarkerAnnotations = (props) => {
const { editor } = this
const markers = Im.Map.isMap(props.markers) ? props.markers.toJS() : {}
this._removeMarkers = placeMarkerDecorations({
editor,
markers,
onMarkerLineUpdate: props.onMarkerLineUpdate,
})
}
removeMarkers = () => {
if(this._removeMarkers) {
this._removeMarkers()
this._removeMarkers = null
}
}
shouldUpdateYaml = (props) => {
// No editor instance
if(!this.editor)
return false
// Origin is editor
if(props.origin === "editor")
return false
// Redundant
if(this.editor.getValue() === props.value)
return false
// Value and origin are same, no update.
if(this.props.value === props.value
&& this.props.origin === props.origin)
return false
return true
}
shouldUpdateMarkers = (props) => {
const { markers } = props
if(Im.Map.isMap(markers)) {
return !Im.is(markers, this.props.markers) // Different from previous?
}
return true // Not going to do a deep compare of object-like markers
}
updateYamlAndMarkers = (props) => {
// If we update the yaml, we need to "lift" the yaml first
if(this.shouldUpdateYaml(props)) {
this.removeMarkers()
this.updateYaml(props)
this.updateMarkerAnnotations(props)
} else if (this.shouldUpdateMarkers(props)) {
this.removeMarkers()
this.updateMarkerAnnotations(props)
}
}
updateYaml = (props) => {
if (props.origin === "insert") {
// Don't clobber the undo stack in this case.
this.editor.session.doc.setValue(props.value)
this.editor.selection.clearSelection()
} else {
// session.setValue does not trigger onChange, nor add to undo stack.
// Neither of which we want here.
this.editor.session.setValue(props.value)
}
}
syncOptionsFromState = (editorOptions={}) => {
const { editor } = this
if(!editor) {
return
}
const setOptions = omit(editorOptions, ["readOnly"])
editor.setOptions(setOptions)
const readOnly = isUndefined(editorOptions.readOnly)
? false
: editorOptions.readOnly // If its undefined, default to false.
editor.setReadOnly(readOnly)
}
componentWillMount() {
// add user agent info to document
// allows our custom Editor styling for IE10 to take effect
var doc = win.document.documentElement
doc.setAttribute("data-useragent", win.navigator.userAgent)
this.syncOptionsFromState(this.props.editorOptions)
}
componentDidMount() {
// eslint-disable-next-line react/no-did-mount-set-state
this.width = this.getWidth()
win.document.addEventListener("click", this.onClick)
}
componentWillUnmount() {
win.document.removeEventListener("click", this.onClick)
}
componentWillReceiveProps(nextProps) {
let hasChanged = (k) => !isEqual(nextProps[k], this.props[k])
const editor = this.editor
// Change the debounce value/func
if(this.props.debounce !== nextProps.debounce) {
if(this.debouncedOnChange.flush)
this.debouncedOnChange.flush()
this.debouncedOnChange = nextProps.debounce > 0
? debounce(nextProps.onChange, nextProps.debounce)
: nextProps.onChange
}
this.updateYamlAndMarkers(nextProps)
this.updateErrorAnnotations(nextProps)
if(hasChanged("editorOptions")) {
this.syncOptionsFromState(nextProps.editorOptions)
}
if(editor && nextProps.goToLine && nextProps.goToLine.line && hasChanged("goToLine")) {
editor.gotoLine(nextProps.goToLine.line)
nextProps.editorActions.jumpToLine(null)
}
}
shouldComponentUpdate() {
return false // Never update, see: componentWillRecieveProps and this.updateYaml for where we update things.
}
render() {
// NOTE: we're manually managing the value lifecycle, outside of react render
// This will only render once.
return (
<AceEditor
mode="yaml"
theme="tomorrow_night_eighties"
value={this.props.value /* This will only load once, thereafter it'll be via updateYaml */}
onLoad={this.onLoad}
onChange={this.checkForSilentOnChange}
name="ace-editor"
width="100%"
height="100%"
tabSize={2}
fontSize={14}
useSoftTabs="true"
wrapEnabled={true}
editorProps={{
"display_indent_guides": true,
folding: "markbeginandend"
}}
setOptions={{
cursorStyle: "smooth",
wrapBehavioursEnabled: true
}}
/>
)
}
}
return Editor
}
@@ -0,0 +1,51 @@
// This code is registered as a helper, not a plugin, because its lifecycle is
// unique to the needs of the marker placement logic.
import countBy from "lodash/countBy"
import map from "lodash/map"
let removers = []
function setRemovers(arr) {
removers.forEach(fn => fn()) // remove existing anchors & gutters
removers = arr // use parent scope to persist reference
}
export function placeMarkerDecorations({editor, markers, onMarkerLineUpdate}) {
if(typeof editor !== "object") {
return
}
let markerLines = countBy(Object.values(markers), "position")
let removeFns = map(markerLines, (count, line) => {
let className = `editor-marker-${count > 8 ? "9-plus" : count}`
let s = editor.getSession()
let anchor = s.getDocument().createAnchor(+line, 0)
anchor.setPosition(+line, 0) // noClip = true
s.addGutterDecoration(+line, className)
anchor.on("change", function (e) {
var oldLine = e.old.row
var newLine = e.value.row
s.removeGutterDecoration(oldLine, className)
s.addGutterDecoration(newLine, className)
onMarkerLineUpdate([oldLine, newLine, line])
})
return function () {
// // Remove the anchor & decoration
let currentLine = +anchor.getPosition().row
editor.getSession().removeGutterDecoration(currentLine, className)
anchor.detach()
}
})
setRemovers(removeFns)
// To manually remove them
return () => setRemovers([])
}
@@ -0,0 +1,16 @@
import isFunction from "lodash/isFunction"
export default function(editor, { onGutterClick }) {
editor.on("guttermousedown", (e) => {
let editor = e.editor
let line = e.getDocumentPosition().row
let region = editor.renderer.$gutterLayer.getRegion(e)
e.stop()
if(isFunction(onGutterClick)) {
onGutterClick({ region, line })
}
})
}
@@ -0,0 +1,22 @@
// TODO: Turn these into actions, that we can override
import GutterClick from "./gutter-click"
import JsonToYaml from "./json-to-yaml"
import TabHandler from "./tab-handler"
const plugins = [
{fn: GutterClick, name: "gutterClick"},
{fn: JsonToYaml, name: "jsonToYaml"},
{fn: TabHandler, name: "tabHandler"},
]
export default function (editor, props = {}, editorPluginsToRun = [], helpers = {}) {
plugins
.filter(plugin => ~editorPluginsToRun.indexOf(plugin.name))
.forEach( plugin => {
try {
plugin.fn(editor, props, helpers)
} catch(e) {
console.error(`${plugin.name || ""} plugin error:`, e)
}
})
}
@@ -0,0 +1,53 @@
import YAML from "js-yaml"
export default function(editor) {
editor.on("paste", e => {
const originalStr = e.text
if (!isJSON(originalStr)) {
return
}
let yamlString
try {
yamlString = YAML.safeDump(YAML.safeLoad(originalStr), {
lineWidth: -1 // don't generate line folds
})
} catch (e) {
return
}
if (!confirm("Would you like to convert your JSON into YAML?")) {
return
}
// using SelectionRange instead of CursorPosition, because:
// SR.start|end === CP when there's no selection
// and it catches indentation edge cases when there is one
const padding = makePadding(editor.getSelectionRange().start.column)
// update the pasted content
e.text = yamlString
.split("\n")
.map((line, i) => i == 0 ? line : padding + line) // don't pad first line, it's already indented
.join("\n")
.replace(/\t/g, " ") // tabs -> spaces, just to be sure
})
}
function isJSON (str){
// basic test: "does this look like JSON?"
let regex = /^[ \r\n\t]*[{\[]/
return regex.test(str)
}
function makePadding(len) {
let str = ""
while(str.length < len) {
str += " "
}
return str
}
@@ -0,0 +1,7 @@
export default function(editor) {
// NOTE: react-ace has an onPaste prop.. we could refactor to that.
editor.on("paste", e => {
// replace all U+0009 tabs in pasted string with two spaces
e.text = e.text.replace(/\t/g, " ")
})
}
+23
View File
@@ -0,0 +1,23 @@
import makeEditor from "./components/editor"
import EditorContainer from "./components/editor-container"
import * as actions from "./actions"
import reducers from "./reducers"
import * as selectors from "./selectors"
import EditorSpecPlugin from "./spec"
let Editor = makeEditor({
editorPluginsToRun: ["gutterClick", "jsonToYaml", "pasteHandler"]
})
export default function () {
return [EditorSpecPlugin, {
components: { Editor, EditorContainer },
statePlugins: {
editor: {
reducers,
actions,
selectors
}
}
}]
}
+9
View File
@@ -0,0 +1,9 @@
import {
JUMP_TO_LINE
} from "./actions"
export default {
[JUMP_TO_LINE]: (state, { payload } ) =>{
return state.set("gotoLine", { line: payload })
}
}
+13
View File
@@ -0,0 +1,13 @@
import { createSelector } from "reselect"
import Im from "immutable"
const state = state => {
return state || Im.Map()
}
export const gotoLine = createSelector(
state,
state => {
return state.get("gotoLine") || null
}
)
+34
View File
@@ -0,0 +1,34 @@
const SPEC_UPDATE_ORIGIN = "spec_update_spec_origin"
// wraps updateSpec to include the "origin" parameter, defaulting to "not-editor"
// Includes a selector to get the origin, specSelectors.specOrigin
export default function EditorSpecPlugin() {
return {
statePlugins: {
spec: {
wrapActions: {
updateSpec: (ori, system) => (specStr, origin) => {
system.specActions.updateSpecOrigin(origin)
ori(specStr)
}
},
reducers: {
[SPEC_UPDATE_ORIGIN]: (state, action) => {
return state.set("specOrigin", action.payload)
}
},
selectors: {
specOrigin: (state) => state.get("specOrigin") || "not-editor"
},
actions: {
updateSpecOrigin(origin="not-editor") {
return {
payload: origin+"",
type: SPEC_UPDATE_ORIGIN,
}
}
}
}
}
}
}
@@ -0,0 +1,44 @@
A guide to custom patterns used in the JSON Schema validation documents in this folder...
### Pivot key existential switch
Applies a schema to an object based on whether a key exists in an object.
> "If key A exists on the object, apply schema X. Else, apply schema Y."
```yaml
switch:
- if:
required: [a]
then:
description: schema X; within `then` can be any JSON Schema content
- then:
description: schema Y; within `then` can be any JSON Schema content
```
### Pivot key value switch
Applies a schema to an object based on the value of a specific, always-required key (the "pivot key").
> "If key A is foo, apply schema X. Else, if key A is bar, apply schema Y. Else, tell the user that key A must be foo or bar."
- The pivot key must be `required` in each `if` block, otherwise the switch may generate a false positive for the entire object when the key isn't provided at all.
- The default case (the last one, with `then` but no `if`) must always require the pivot key's presence and report all possible values back as an enum, otherwise a misleading error message may be shown to the user.
```yaml
switch:
- if:
required: [a]
properties: { a: { enum: [foo] } }
then:
description: schema X; within `then` can be any JSON Schema content
- if:
required: [a]
properties: { a: { enum: [bar] } }
then:
description: schema Y; within `then` can be any JSON Schema content
- then:
description: fallback schema; ensures the user is told the pivot key is needed and should have one of the enumerated values
required: [a]
properties: { a: { enum: [foo, bar] } }
```
@@ -0,0 +1,176 @@
// JSON-Schema ( draf04 ) validator
import JsonSchemaWebWorker from "./validator.worker.js"
import YAML from "js-yaml"
import PromiseWorker from "promise-worker"
import debounce from "lodash/debounce"
import swagger2SchemaYaml from "./swagger2-schema.yaml"
import oas3SchemaYaml from "./oas3-schema.yaml"
const swagger2Schema = YAML.safeLoad(swagger2SchemaYaml)
const oas3Schema = YAML.safeLoad(oas3SchemaYaml)
// Lazily created promise worker
let _promiseWorker
const promiseWorker = () => {
if (!_promiseWorker)
_promiseWorker = new PromiseWorker(new JsonSchemaWebWorker())
return _promiseWorker
}
export const addSchema = (schema, schemaPath = []) => () => {
promiseWorker().postMessage({
type: "add-schema",
payload: {
schemaPath,
schema
}
})
}
// Figure out what schema we need to use ( we're making provision to be able to do sub-schema validation later on)
// ...for now we just pick which base schema to use (eg: openapi-2-0, openapi-3.0, etc)
export const getSchemaBasePath = () => ({ specSelectors }) => {
// Eg: [openapi-3.0] or [openapi-2-0]
// later on... ["openapi-2.0", "paths", "get"]
const isOAS3 = specSelectors.isOAS3 ? specSelectors.isOAS3() : false
const isSwagger2 = specSelectors.isSwagger2
? specSelectors.isSwagger2()
: false
const isAmbiguousVersion = isOAS3 && isSwagger2
// Refuse to handle ambiguity
if (isAmbiguousVersion) return []
if (isSwagger2) return ["openapi-2.0"]
if (isOAS3) return ["openapi-3.0"]
}
export const setup = () => ({ jsonSchemaValidatorActions }) => {
// Add schemas , once off
jsonSchemaValidatorActions.addSchema(swagger2Schema, ["openapi-2.0"])
jsonSchemaValidatorActions.addSchema(oas3Schema, ["openapi-3.0"])
}
export const validate = ({ spec, path = [], ...rest }) => system => {
// stagger clearing errors, in case there is another debounced validation
// run happening, which can occur when the user's typing cadence matches
// the latency of validation
// TODO: instead of using a timeout, be aware of any pending validation
// promises, and use them to schedule error clearing.
setTimeout(() => {
system.errActions.clear({
source: system.jsonSchemaValidatorSelectors.errSource()
})
}, 50)
system.jsonSchemaValidatorActions.validateDebounced({ spec, path, ...rest })
}
// Create a debounced validate, that is lazy
let _debValidate
export const validateDebounced = (...args) => system => {
// Lazily create one...
if (!_debValidate) {
_debValidate = debounce((...args) => {
system.jsonSchemaValidatorActions.validateImmediate(...args)
}, 200)
}
return _debValidate(...args)
}
export const validateImmediate = ({ spec, path = [] }) => system => {
// schemaPath refers to type of schema, and later might refer to sub-schema
const baseSchemaPath = system.jsonSchemaValidatorSelectors.getSchemaBasePath()
// No base path? Then we're unable to do anything...
if (!baseSchemaPath.length)
throw new Error("Ambiguous schema path, unable to run validation")
return system.jsonSchemaValidatorActions.validateWithBaseSchema({
spec,
path: [...baseSchemaPath, ...path]
})
}
export const validateWithBaseSchema = ({ spec, path = [] }) => system => {
const errSource = system.jsonSchemaValidatorSelectors.errSource()
return promiseWorker()
.postMessage({
type: "validate",
payload: {
jsSpec: spec,
specStr: system.specSelectors.specStr(),
schemaPath: path,
source: errSource
}
})
.then(
({ results, path }) => {
system.jsonSchemaValidatorActions.handleResults(null, {
results,
path
})
},
err => {
system.jsonSchemaValidatorActions.handleResults(err, {})
}
)
}
export const handleResults = (err, { results }) => system => {
if (err) {
// Something bad happened with validation.
throw err
}
system.errActions.clear({
source: system.jsonSchemaValidatorSelectors.errSource()
})
if (!Array.isArray(results)) {
results = [results]
}
// Filter out anything funky
results = results.filter(val => typeof val === "object" && val !== null)
if (results.length) {
system.errActions.newSpecErrBatch(results)
}
}
export default function() {
return {
afterLoad: system => system.jsonSchemaValidatorActions.setup(),
statePlugins: {
jsonSchemaValidator: {
actions: {
addSchema,
validate,
handleResults,
validateDebounced,
validateImmediate,
validateWithBaseSchema,
setup
},
selectors: {
getSchemaBasePath,
errSource() {
// Used to identify the errors generated by this plugin
return "structural"
}
}
},
spec: {
wrapActions: {
validateSpec: (ori, system) => (...args) => {
ori(...args)
const [spec, path] = args
system.jsonSchemaValidatorActions.validate({ spec, path })
}
}
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import "src/polyfills"
import registerPromiseWorker from "promise-worker/register"
import Validator from "./validator"
const validator = new Validator()
registerPromiseWorker(({ type, payload }) => {
if (type == "add-schema") {
const { schema, schemaPath } = payload
validator.addSchema(schema, schemaPath)
return
}
if (type == "validate") {
const { jsSpec, specStr, schemaPath, source } = payload
let validationResults = validator.validate({
jsSpec,
specStr,
schemaPath,
source
})
return { results: validationResults }
}
})
@@ -0,0 +1,118 @@
// Error condenser!
//
// 1. group all errors by path
// 2. score them by message frequency
// 3. select the most frequent messages (ties retain all equally-frequent messages)
// 4. concatenate the params of each occurrence of the most frequent message
// 5. create one condensed error for the path
// 6. return all condensed errors as an array
export function condenseErrors(errors) {
if (!Array.isArray(errors)) {
return []
}
const tree = {}
function countFor(dataPath, message) {
return tree[dataPath][message].length
}
errors.forEach(err => {
const { dataPath, message } = err
if (tree[dataPath] && tree[dataPath][message]) {
tree[dataPath][message].push(err)
} else if (tree[dataPath]) {
tree[dataPath][message] = [err]
} else {
tree[dataPath] = {
[message]: [err]
}
}
})
const dataPaths = Object.keys(tree)
return dataPaths.reduce((res, path) => {
const messages = Object.keys(tree[path])
const mostFrequentMessageNames = messages.reduce(
(obj, msg) => {
const count = countFor(path, msg)
if (count > obj.max) {
return {
messages: [msg],
max: count
}
} else if (count === obj.max) {
obj.messages.push(msg)
return obj
} else {
return obj
}
},
{ max: 0, messages: [] }
).messages
const mostFrequentMessages = mostFrequentMessageNames.map(
name => tree[path][name]
)
const condensedErrors = mostFrequentMessages.map(messages => {
return messages.reduce((prev, err) => {
const obj = Object.assign({}, prev, {
params: mergeParams(prev.params, err.params)
})
if (!prev.params && !err.params) {
delete obj.params
}
return obj
})
})
return res.concat(condensedErrors)
}, [])
}
// Helpers
function mergeParams(objA = {}, objB = {}) {
if (!objA && !objB) {
return undefined
}
const res = {}
for (let k in objA) {
if (Object.prototype.hasOwnProperty.call(objA, k)) {
res[k] = arrayify(objA[k])
}
}
for (let k in objB) {
if (Object.prototype.hasOwnProperty.call(objB, k)) {
if (res[k]) {
const curr = res[k]
res[k] = curr.concat(arrayify(objB[k]))
} else {
res[k] = arrayify(objB[k])
}
}
}
return res
}
function arrayify(thing) {
if (thing === undefined || thing === null) {
return thing
}
if (Array.isArray(thing)) {
return thing
} else {
return [thing]
}
}
@@ -0,0 +1,94 @@
import Ajv from "ajv"
import AjvErrors from "ajv-errors"
import AjvKeywords from "ajv-keywords"
import { getLineNumberForPath } from "./shared.js"
import { condenseErrors } from "./condense-errors.js"
import jsonSchema from "./jsonSchema"
const IGNORED_AJV_PARAMS = ["type", "errors"]
export default class JSONSchemaValidator {
constructor() {
this.ajv = new Ajv({
allErrors: true,
jsonPointers: true,
})
AjvKeywords(this.ajv, "switch")
AjvErrors(this.ajv)
this.addSchema(jsonSchema)
}
addSchema(schema, key) {
this.ajv.addSchema(schema, normalizeKey(key))
}
validate({ jsSpec, specStr, schemaPath, source }) {
this.ajv.validate(normalizeKey(schemaPath), jsSpec)
if (!this.ajv.errors || !this.ajv.errors.length) {
return null
}
const condensedErrors = condenseErrors(this.ajv.errors)
try {
const boundGetLineNumber = getLineNumberForPath.bind(null, specStr)
return condensedErrors.map(err => {
let preparedMessage = err.message
if (err.params) {
preparedMessage += "\n"
for (var k in err.params) {
if (IGNORED_AJV_PARAMS.indexOf(k) === -1) {
const ori = err.params[k]
const value = Array.isArray(ori) ? dedupe(ori).join(", ") : ori
preparedMessage += `${k}: ${value}\n`
}
}
}
const errorPathArray = jsonPointerStringToArray(err.dataPath)
return {
level: "error",
line: boundGetLineNumber(errorPathArray || []),
path: errorPathArray,
message: preparedMessage.trim(),
source,
original: err
}
})
}
catch (err) {
return {
level: "error",
line: err.problem_mark && err.problem_mark.line + 1 || 0,
message: err.problem,
source: "parser",
original: err
}
}
}
}
function dedupe(arr) {
return arr.filter((val, i) => {
return arr.indexOf(val) === i
})
}
function pathToJSONPointer(arr) {
return arr.map(a => (a + "").replace("~", "~0").replace("/", "~1")).join("/")
}
function jsonPointerStringToArray(str) {
return str.split("/")
.map(part => (part + "").replace(/~0/g, "~").replace(/~1/g, "/"))
.filter(str => str.length > 0)
}
// Convert arrays into a string. Safely, by using the JSONPath spec
function normalizeKey(key) {
if (!Array.isArray(key)) key = [key]
return pathToJSONPointer(key)
}
@@ -0,0 +1,147 @@
export default {
id: "http://json-schema.org/draft-04/schema#",
$schema: "http://json-schema.org/draft-04/schema#",
description: "Core schema meta-schema",
definitions: {
schemaArray: {
type: "array",
minItems: 1,
items: { $ref: "#" }
},
positiveInteger: {
type: "integer",
minimum: 0
},
positiveIntegerDefault0: {
allOf: [{ $ref: "#/definitions/positiveInteger" }, { default: 0 }]
},
simpleTypes: {
enum: [
"array",
"boolean",
"integer",
/* "null", */ // removed per https://github.com/swagger-api/swagger-editor/issues/1832#issuecomment-483717197
"number",
"object",
"string"
]
},
stringArray: {
type: "array",
items: { type: "string" },
minItems: 1,
uniqueItems: true
}
},
type: "object",
properties: {
id: {
type: "string",
format: "uri"
},
$schema: {
type: "string",
format: "uri"
},
title: {
type: "string"
},
description: {
type: "string"
},
default: {},
multipleOf: {
type: "number",
minimum: 0,
exclusiveMinimum: true
},
maximum: {
type: "number"
},
exclusiveMaximum: {
type: "boolean",
default: false
},
minimum: {
type: "number"
},
exclusiveMinimum: {
type: "boolean",
default: false
},
maxLength: { $ref: "#/definitions/positiveInteger" },
minLength: { $ref: "#/definitions/positiveIntegerDefault0" },
pattern: {
type: "string",
format: "regex"
},
additionalItems: {
anyOf: [{ type: "boolean" }, { $ref: "#" }],
default: {}
},
items: {
anyOf: [{ $ref: "#" }, { $ref: "#/definitions/schemaArray" }],
default: {}
},
maxItems: { $ref: "#/definitions/positiveInteger" },
minItems: { $ref: "#/definitions/positiveIntegerDefault0" },
uniqueItems: {
type: "boolean",
default: false
},
maxProperties: { $ref: "#/definitions/positiveInteger" },
minProperties: { $ref: "#/definitions/positiveIntegerDefault0" },
required: { $ref: "#/definitions/stringArray" },
additionalProperties: {
anyOf: [{ type: "boolean" }, { $ref: "#" }],
default: {}
},
definitions: {
type: "object",
additionalProperties: { $ref: "#" },
default: {}
},
properties: {
type: "object",
additionalProperties: { $ref: "#" },
default: {}
},
patternProperties: {
type: "object",
additionalProperties: { $ref: "#" },
default: {}
},
dependencies: {
type: "object",
additionalProperties: {
anyOf: [{ $ref: "#" }, { $ref: "#/definitions/stringArray" }]
}
},
enum: {
type: "array",
minItems: 1,
uniqueItems: true
},
type: {
$ref: "#/definitions/simpleTypes"
// anyOf: [
// { $ref: "#/definitions/simpleTypes" },
// {
// type: "array",
// items: { $ref: "#/definitions/simpleTypes" },
// minItems: 1,
// uniqueItems: true
// }
// ]
},
allOf: { $ref: "#/definitions/schemaArray" },
anyOf: { $ref: "#/definitions/schemaArray" },
oneOf: { $ref: "#/definitions/schemaArray" },
not: { $ref: "#" }
},
dependencies: {
exclusiveMaximum: ["maximum"],
exclusiveMinimum: ["minimum"]
},
default: {}
}
@@ -0,0 +1,77 @@
import get from "lodash/get"
export function transformPathToArray(property, jsSpec) {
if (property.slice(0, 9) === "instance.") {
var str = property.slice(9)
} else {
// eslint-disable-next-line no-redeclare
var str = property
}
var pathArr = []
// replace '.', '["', '"]' separators with pipes
str = str.replace(/\.(?![^["]*"\])|(\[\")|(\"\]\.?)/g, "|")
// handle single quotes as well
str = str.replace(/\[\'/g, "|")
str = str.replace(/\'\]/g, "|")
// split on our new delimiter, pipe
str = str.split("|")
str
.map(item => {
// "key[0]" becomes ["key", "0"]
if (item.indexOf("[") > -1) {
let index = parseInt(item.match(/\[(.*)\]/)[1])
let keyName = item.slice(0, item.indexOf("["))
return [keyName, index.toString()]
} else {
return item
}
})
.reduce(function(a, b) {
// flatten!
return a.concat(b)
}, [])
.concat([""]) // add an empty item into the array, so we don't get stuck with something in our buffer below
.reduce((buffer, curr) => {
let obj = pathArr.length ? get(jsSpec, pathArr) : jsSpec
if (get(obj, makeAccessArray(buffer, curr))) {
if (buffer.length) {
pathArr.push(buffer)
}
if (curr.length) {
pathArr.push(curr)
}
return ""
} else {
// attach key to buffer
return `${buffer}${buffer.length ? "." : ""}${curr}`
}
}, "")
if (typeof get(jsSpec, pathArr) !== "undefined") {
return pathArr
} else {
// if our path is not correct (there is no value at the path),
// return null
return null
}
}
function makeAccessArray(buffer, curr) {
let arr = []
if (buffer.length) {
arr.push(buffer)
}
if (curr.length) {
arr.push(curr)
}
return arr
}
@@ -0,0 +1,4 @@
// export * from './ast.js'
// These import/exports are shared code between worker and main bundle.
// Putting them here keeps the distiction clear
export { getLineNumberForPath } from "../../ast/ast.js"
@@ -0,0 +1,64 @@
import React from "react"
import PropTypes from "prop-types"
import JumpIcon from "./jump-icon.svg"
export class JumpToPath extends React.Component {
static propTypes = {
editorActions: PropTypes.object.isRequired,
specSelectors: PropTypes.object.isRequired,
fn: PropTypes.object.isRequired,
path: PropTypes.oneOfType([
PropTypes.array,
PropTypes.string
]),
content: PropTypes.element,
showButton: PropTypes.bool,
specPath: PropTypes.array, // The location within the spec. Used as a fallback if `path` doesn't exist
}
static defaultProps = {
path: "",
}
shouldComponentUpdate(nextProps) {
let { shallowEqualKeys } = nextProps.fn
return shallowEqualKeys(this.props, nextProps, [
"content", "showButton", "path", "specPath"
])
}
jumpToPath = (e) => {
e.stopPropagation()
const {
specPath=[],
path,
specSelectors,
editorActions
} = this.props
const jumpPath = specSelectors.bestJumpPath({path, specPath})
editorActions.jumpToLine(specSelectors.getSpecLineFromPath(jumpPath))
}
defaultJumpContent = <img src={JumpIcon} onClick={this.jumpToPath} className="view-line-link" title={"Jump to definition"} />
render() {
let { content, showButton } = this.props
if (content) {
// if we were given content to render, wrap it
return (
<span onClick={ this.jumpToPath }>
{ showButton ? this.defaultJumpContent : null }
{content}
</span>
)
} else {
// just render a link
return this.defaultJumpContent
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import spec from "./spec"
import * as components from "./components"
export default function JumpToPathPlugin() {
return [
spec,
{
components,
}
]
}
@@ -0,0 +1,3 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

+77
View File
@@ -0,0 +1,77 @@
import { unescapeJsonPointerToken } from "../refs-util"
export default function spec() {
return {
statePlugins: {
spec: {
selectors: {
getSpecLineFromPath: (state, path) => ({fn: { AST }, specSelectors: { specStr }}) => {
return AST.getLineNumberForPath(specStr(), path.toJS ? path.toJS() : path)
},
// This will search return `path if it exists, else it'll look for the best $ref jump point
// There is one caveat, it'll not search _down_ for deeply nested $refs. In those cases, it'll bring you to the shallower $ref.
bestJumpPath: (state, {path, specPath}) => (system) => {
const {
specSelectors: { specJson },
fn: { transformPathToArray }
} = system
// We"ve been given an explicit path? Use that...
if(path) {
return typeof path === "string" ? transformPathToArray(path, specJson().toJS()) : path
}
// Try each path in the resolved spec, starting from the deepest
for(let i = specPath.length; i >= 0; i--) {
const tryPath = specPath.slice(0,i)
// A $ref exists in the source? ( ie: pre-resolver)
const $ref = specJson().getIn([...tryPath, "$ref"])
// We have a $ref in the source?
if($ref) {
if(!/^#\//.test($ref)) {
return [...tryPath, "$ref"]
} else { // Is local $ref
// Get rid of the trailing '#'
const pointer = $ref.charAt(0) === "#" ? $ref.substr(1) : $ref
return jsonPointerToArray(pointer)
}
}
// This path exists in the source spec?
if(specJson().hasIn(tryPath)) {
return tryPath
}
}
// ...else just specPath, which is hopefully close enough
return specPath
}
}
}
}
}
}
// Copied out of swagger-client, not sure if it should be exposed as a lib or as part of the public swagger-client api.
/**
* Converts a JSON pointer to array.
* @api public
*/
function jsonPointerToArray(pointer) {
if (typeof pointer !== "string") {
throw new TypeError(`Expected a string, got a ${typeof pointer}`)
}
if (pointer[0] === "/") {
pointer = pointer.substr(1)
}
if (pointer === "") {
return []
}
return pointer.split("/").map(unescapeJsonPointerToken)
}
@@ -0,0 +1,45 @@
import PetstoreYaml from "./petstore"
const CONTENT_KEY = "swagger-editor-content"
let localStorage = window.localStorage
export const updateSpec = (ori) => (...args) => {
let [spec] = args
ori(...args)
saveContentToStorage(spec)
}
export default function(system) {
// setTimeout runs on the next tick
setTimeout(() => {
if(localStorage.getItem(CONTENT_KEY)) {
system.specActions.updateSpec(localStorage.getItem(CONTENT_KEY), "local-storage")
} else if(localStorage.getItem("ngStorage-SwaggerEditorCache")) {
// Legacy migration for swagger-editor 2.x
try {
let obj = JSON.parse(localStorage.getItem("ngStorage-SwaggerEditorCache"))
let yaml = obj.yaml
system.specActions.updateSpec(yaml)
saveContentToStorage(yaml)
localStorage.setItem("ngStorage-SwaggerEditorCache", null)
} catch(e) {
system.specActions.updateSpec(PetstoreYaml)
}
} else {
system.specActions.updateSpec(PetstoreYaml)
}
}, 0)
return {
statePlugins: {
spec: {
wrapActions: {
updateSpec
}
}
}
}
}
function saveContentToStorage(str) {
return localStorage.setItem(CONTENT_KEY, str)
}
@@ -0,0 +1,708 @@
// Exports Petstore.yaml
export default `swagger: "2.0"
info:
description: "This is a sample server Petstore server. You can find out more about\
\ Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/).\
\ For this sample, you can use the api key \`special-key\` to test the authorization\
\ filters."
version: "1.0.0"
title: "Swagger Petstore"
termsOfService: "http://swagger.io/terms/"
contact:
email: "apiteam@swagger.io"
license:
name: "Apache 2.0"
url: "http://www.apache.org/licenses/LICENSE-2.0.html"
host: "petstore.swagger.io"
basePath: "/v2"
tags:
- name: "pet"
description: "Everything about your Pets"
externalDocs:
description: "Find out more"
url: "http://swagger.io"
- name: "store"
description: "Access to Petstore orders"
- name: "user"
description: "Operations about user"
externalDocs:
description: "Find out more about our store"
url: "http://swagger.io"
schemes:
- "https"
- "http"
paths:
/pet:
post:
tags:
- "pet"
summary: "Add a new pet to the store"
description: ""
operationId: "addPet"
consumes:
- "application/json"
- "application/xml"
produces:
- "application/xml"
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Pet object that needs to be added to the store"
required: true
schema:
$ref: "#/definitions/Pet"
responses:
"405":
description: "Invalid input"
security:
- petstore_auth:
- "write:pets"
- "read:pets"
put:
tags:
- "pet"
summary: "Update an existing pet"
description: ""
operationId: "updatePet"
consumes:
- "application/json"
- "application/xml"
produces:
- "application/xml"
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Pet object that needs to be added to the store"
required: true
schema:
$ref: "#/definitions/Pet"
responses:
"400":
description: "Invalid ID supplied"
"404":
description: "Pet not found"
"405":
description: "Validation exception"
security:
- petstore_auth:
- "write:pets"
- "read:pets"
/pet/findByStatus:
get:
tags:
- "pet"
summary: "Finds Pets by status"
description: "Multiple status values can be provided with comma separated strings"
operationId: "findPetsByStatus"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "status"
in: "query"
description: "Status values that need to be considered for filter"
required: true
type: "array"
items:
type: "string"
enum:
- "available"
- "pending"
- "sold"
default: "available"
collectionFormat: "multi"
responses:
"200":
description: "successful operation"
schema:
type: "array"
items:
$ref: "#/definitions/Pet"
"400":
description: "Invalid status value"
security:
- petstore_auth:
- "write:pets"
- "read:pets"
/pet/findByTags:
get:
tags:
- "pet"
summary: "Finds Pets by tags"
description: "Muliple tags can be provided with comma separated strings. Use\
\ tag1, tag2, tag3 for testing."
operationId: "findPetsByTags"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "tags"
in: "query"
description: "Tags to filter by"
required: true
type: "array"
items:
type: "string"
collectionFormat: "multi"
responses:
"200":
description: "successful operation"
schema:
type: "array"
items:
$ref: "#/definitions/Pet"
"400":
description: "Invalid tag value"
security:
- petstore_auth:
- "write:pets"
- "read:pets"
deprecated: true
/pet/{petId}:
get:
tags:
- "pet"
summary: "Find pet by ID"
description: "Returns a single pet"
operationId: "getPetById"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "petId"
in: "path"
description: "ID of pet to return"
required: true
type: "integer"
format: "int64"
responses:
"200":
description: "successful operation"
schema:
$ref: "#/definitions/Pet"
"400":
description: "Invalid ID supplied"
"404":
description: "Pet not found"
security:
- api_key: []
post:
tags:
- "pet"
summary: "Updates a pet in the store with form data"
description: ""
operationId: "updatePetWithForm"
consumes:
- "application/x-www-form-urlencoded"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "petId"
in: "path"
description: "ID of pet that needs to be updated"
required: true
type: "integer"
format: "int64"
- name: "name"
in: "formData"
description: "Updated name of the pet"
required: false
type: "string"
- name: "status"
in: "formData"
description: "Updated status of the pet"
required: false
type: "string"
responses:
"405":
description: "Invalid input"
security:
- petstore_auth:
- "write:pets"
- "read:pets"
delete:
tags:
- "pet"
summary: "Deletes a pet"
description: ""
operationId: "deletePet"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "api_key"
in: "header"
required: false
type: "string"
- name: "petId"
in: "path"
description: "Pet id to delete"
required: true
type: "integer"
format: "int64"
responses:
"400":
description: "Invalid ID supplied"
"404":
description: "Pet not found"
security:
- petstore_auth:
- "write:pets"
- "read:pets"
/pet/{petId}/uploadImage:
post:
tags:
- "pet"
summary: "uploads an image"
description: ""
operationId: "uploadFile"
consumes:
- "multipart/form-data"
produces:
- "application/json"
parameters:
- name: "petId"
in: "path"
description: "ID of pet to update"
required: true
type: "integer"
format: "int64"
- name: "additionalMetadata"
in: "formData"
description: "Additional data to pass to server"
required: false
type: "string"
- name: "file"
in: "formData"
description: "file to upload"
required: false
type: "file"
responses:
"200":
description: "successful operation"
schema:
$ref: "#/definitions/ApiResponse"
security:
- petstore_auth:
- "write:pets"
- "read:pets"
/store/inventory:
get:
tags:
- "store"
summary: "Returns pet inventories by status"
description: "Returns a map of status codes to quantities"
operationId: "getInventory"
produces:
- "application/json"
parameters: []
responses:
"200":
description: "successful operation"
schema:
type: "object"
additionalProperties:
type: "integer"
format: "int32"
security:
- api_key: []
/store/order:
post:
tags:
- "store"
summary: "Place an order for a pet"
description: ""
operationId: "placeOrder"
produces:
- "application/xml"
- "application/json"
parameters:
- in: "body"
name: "body"
description: "order placed for purchasing the pet"
required: true
schema:
$ref: "#/definitions/Order"
responses:
"200":
description: "successful operation"
schema:
$ref: "#/definitions/Order"
"400":
description: "Invalid Order"
/store/order/{orderId}:
get:
tags:
- "store"
summary: "Find purchase order by ID"
description: "For valid response try integer IDs with value >= 1 and <= 10.\
\ Other values will generated exceptions"
operationId: "getOrderById"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "orderId"
in: "path"
description: "ID of pet that needs to be fetched"
required: true
type: "integer"
maximum: 10.0
minimum: 1.0
format: "int64"
responses:
"200":
description: "successful operation"
schema:
$ref: "#/definitions/Order"
"400":
description: "Invalid ID supplied"
"404":
description: "Order not found"
delete:
tags:
- "store"
summary: "Delete purchase order by ID"
description: "For valid response try integer IDs with positive integer value.\
\ Negative or non-integer values will generate API errors"
operationId: "deleteOrder"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "orderId"
in: "path"
description: "ID of the order that needs to be deleted"
required: true
type: "integer"
minimum: 1.0
format: "int64"
responses:
"400":
description: "Invalid ID supplied"
"404":
description: "Order not found"
/user:
post:
tags:
- "user"
summary: "Create user"
description: "This can only be done by the logged in user."
operationId: "createUser"
produces:
- "application/xml"
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Created user object"
required: true
schema:
$ref: "#/definitions/User"
responses:
default:
description: "successful operation"
/user/createWithArray:
post:
tags:
- "user"
summary: "Creates list of users with given input array"
description: ""
operationId: "createUsersWithArrayInput"
produces:
- "application/xml"
- "application/json"
parameters:
- in: "body"
name: "body"
description: "List of user object"
required: true
schema:
type: "array"
items:
$ref: "#/definitions/User"
responses:
default:
description: "successful operation"
/user/createWithList:
post:
tags:
- "user"
summary: "Creates list of users with given input array"
description: ""
operationId: "createUsersWithListInput"
produces:
- "application/xml"
- "application/json"
parameters:
- in: "body"
name: "body"
description: "List of user object"
required: true
schema:
type: "array"
items:
$ref: "#/definitions/User"
responses:
default:
description: "successful operation"
/user/login:
get:
tags:
- "user"
summary: "Logs user into the system"
description: ""
operationId: "loginUser"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "username"
in: "query"
description: "The user name for login"
required: true
type: "string"
- name: "password"
in: "query"
description: "The password for login in clear text"
required: true
type: "string"
responses:
"200":
description: "successful operation"
schema:
type: "string"
headers:
X-Rate-Limit:
type: "integer"
format: "int32"
description: "calls per hour allowed by the user"
X-Expires-After:
type: "string"
format: "date-time"
description: "date in UTC when token expires"
"400":
description: "Invalid username/password supplied"
/user/logout:
get:
tags:
- "user"
summary: "Logs out current logged in user session"
description: ""
operationId: "logoutUser"
produces:
- "application/xml"
- "application/json"
parameters: []
responses:
default:
description: "successful operation"
/user/{username}:
get:
tags:
- "user"
summary: "Get user by user name"
description: ""
operationId: "getUserByName"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "username"
in: "path"
description: "The name that needs to be fetched. Use user1 for testing. "
required: true
type: "string"
responses:
"200":
description: "successful operation"
schema:
$ref: "#/definitions/User"
"400":
description: "Invalid username supplied"
"404":
description: "User not found"
put:
tags:
- "user"
summary: "Updated user"
description: "This can only be done by the logged in user."
operationId: "updateUser"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "username"
in: "path"
description: "name that need to be updated"
required: true
type: "string"
- in: "body"
name: "body"
description: "Updated user object"
required: true
schema:
$ref: "#/definitions/User"
responses:
"400":
description: "Invalid user supplied"
"404":
description: "User not found"
delete:
tags:
- "user"
summary: "Delete user"
description: "This can only be done by the logged in user."
operationId: "deleteUser"
produces:
- "application/xml"
- "application/json"
parameters:
- name: "username"
in: "path"
description: "The name that needs to be deleted"
required: true
type: "string"
responses:
"400":
description: "Invalid username supplied"
"404":
description: "User not found"
securityDefinitions:
petstore_auth:
type: "oauth2"
authorizationUrl: "http://petstore.swagger.io/oauth/dialog"
flow: "implicit"
scopes:
write:pets: "modify pets in your account"
read:pets: "read your pets"
api_key:
type: "apiKey"
name: "api_key"
in: "header"
definitions:
Order:
type: "object"
properties:
id:
type: "integer"
format: "int64"
petId:
type: "integer"
format: "int64"
quantity:
type: "integer"
format: "int32"
shipDate:
type: "string"
format: "date-time"
status:
type: "string"
description: "Order Status"
enum:
- "placed"
- "approved"
- "delivered"
complete:
type: "boolean"
default: false
xml:
name: "Order"
Category:
type: "object"
properties:
id:
type: "integer"
format: "int64"
name:
type: "string"
xml:
name: "Category"
User:
type: "object"
properties:
id:
type: "integer"
format: "int64"
username:
type: "string"
firstName:
type: "string"
lastName:
type: "string"
email:
type: "string"
password:
type: "string"
phone:
type: "string"
userStatus:
type: "integer"
format: "int32"
description: "User Status"
xml:
name: "User"
Tag:
type: "object"
properties:
id:
type: "integer"
format: "int64"
name:
type: "string"
xml:
name: "Tag"
Pet:
type: "object"
required:
- "name"
- "photoUrls"
properties:
id:
type: "integer"
format: "int64"
category:
$ref: "#/definitions/Category"
name:
type: "string"
example: "doggie"
photoUrls:
type: "array"
xml:
name: "photoUrl"
wrapped: true
items:
type: "string"
tags:
type: "array"
xml:
name: "tag"
wrapped: true
items:
$ref: "#/definitions/Tag"
status:
type: "string"
description: "pet status in the store"
enum:
- "available"
- "pending"
- "sold"
xml:
name: "Pet"
ApiResponse:
type: "object"
properties:
code:
type: "integer"
format: "int32"
type:
type: "string"
message:
type: "string"
externalDocs:
description: "Find out more about Swagger"
url: "http://swagger.io"`
+69
View File
@@ -0,0 +1,69 @@
const getTimestamp = ((that) => {
if(that.performance && that.performance.now) {
return that.performance.now.bind(that.performance)
}
return Date.now.bind(Date)
})(self || window)
export default function PerformancePlugin() {
if(!(window || {}).LOG_PERF) {
return {
fn: {
getTimestamp,
Timer: TimerStub,
timeCall: (name,fn) => fn(),
}
}
}
return {
fn: {
getTimestamp,
Timer,
timeCall,
}
}
}
function timeCall(name,fn) {
fn = fn || name
name = typeof name === "function" ? "that" : name
const a = getTimestamp()
const r = fn()
const b = getTimestamp()
console.log(name,"took", b - a, "ms") // eslint-disable-line no-console
return r
}
function TimerStub() {
this.start = this.mark = this.print = Function.prototype
}
function Timer(name, _getTimestamp=getTimestamp) {
this._name = name
this.getTimestamp = _getTimestamp
this._markers = []
this.start()
}
Timer.prototype.start = function() {
this._start = this.getTimestamp()
}
Timer.prototype.mark = function(name) {
this._markers = this._markers || []
this._markers.push({
time: this.getTimestamp(),
name
})
}
Timer.prototype.print = function(name) {
this.mark(name)
this._markers.forEach(m => {
// eslint-disable-next-line no-console
console.log(this._name, m.name, m.time - this._start, "ms")
})
this._markers = []
this.start()
}
+20
View File
@@ -0,0 +1,20 @@
import qs from "querystring-browser"
/**
* Unescapes a JSON pointer.
* @api public
*/
export function unescapeJsonPointerToken(token) {
if (typeof token !== "string") {
return token
}
return qs.unescape(token.replace(/~1/g, "/").replace(/~0/g, "~"))
}
/**
* Escapes a JSON pointer.
* @api public
*/
export function escapeJsonPointerToken(token) {
return qs.escape(token.replace(/~/g, "~0").replace(/\//g, "~1"))
}
@@ -0,0 +1,85 @@
import React from "react"
import PropTypes from "prop-types"
import SplitPane from "react-split-pane"
const MODE_KEY = ["split-pane-mode"]
const MODE_LEFT = "left"
const MODE_RIGHT = "right"
const MODE_BOTH = "both" // or anything other than left/right
export default class SplitPaneMode extends React.Component {
static propTypes = {
threshold: PropTypes.number,
children: PropTypes.array,
layoutSelectors: PropTypes.object.isRequired,
layoutActions: PropTypes.object.isRequired,
};
static defaultProps = {
threshold: 100, // in pixels
children: [],
};
initializeComponent = (c) => {
this.splitPane = c
}
onDragFinished = () => {
let { threshold, layoutActions } = this.props
let { position, draggedSize } = this.splitPane.state
this.draggedSize = draggedSize
let nearLeftEdge = position <= threshold
let nearRightEdge = draggedSize <= threshold
layoutActions
.changeMode(MODE_KEY, (
nearLeftEdge
? MODE_RIGHT : nearRightEdge
? MODE_LEFT : MODE_BOTH
))
}
sizeFromMode = (mode, defaultSize) => {
if(mode === MODE_LEFT) {
this.draggedSize = null
return "0px"
} else if (mode === MODE_RIGHT) {
this.draggedSize = null
return "100%"
}
// mode === "both"
return this.draggedSize || defaultSize
}
render() {
let { children, layoutSelectors } = this.props
const mode = layoutSelectors.whatMode(MODE_KEY)
const left = mode === MODE_RIGHT ? <noscript/> : children[0]
const right = mode === MODE_LEFT ? <noscript/> : children[1]
const size = this.sizeFromMode(mode, "50%")
return (
<SplitPane
disabledClass={""}
ref={this.initializeComponent}
split='vertical'
defaultSize={"50%"}
primary="second"
minSize={0}
size={size}
onDragFinished={this.onDragFinished}
allowResize={mode !== MODE_LEFT && mode !== MODE_RIGHT }
resizerStyle={{"flex": "0 0 auto", "position": "relative", "background": "#000", "opacity": ".2", "width": "11px", "cursor": "col-resize"}}
>
{ left }
{ right }
</SplitPane>
)
}
}
@@ -0,0 +1,15 @@
import SplitPaneMode from "./components/split-pane-mode"
export default function SplitPaneModePlugin() {
return {
// statePlugins: {
// layout: {
// actions,
// selectors,
// }
// },
components: {
SplitPaneMode
}
}
}
@@ -0,0 +1,37 @@
// Base validate plugin that provides a placeholder `validateSpec` that fires
// after `updateJsonSpec` is dispatched.
export const updateJsonSpec = (ori, {specActions}) => (...args) => {
ori(...args)
/*
To allow us to remove this, we should prefer the practice of
only _composing_ inside of wrappedActions. It keeps us free to
remove pieces added by plugins. In this case, I want to toggle validation.
But I can't look inside the updateJsonSpec action,
I can only remove it ( which isn't desirable ). However I _can_ remove `validateSpec` action,
making it a noop. That way, the only overhead I end up with is a bunch of noops inside wrappedActions.
Which isn't bad.
*/
const [ spec ] = args
specActions.validateSpec(spec)
}
//eslint-disable-next-line no-unused-vars
export const validateSpec = (jsSpec) => ({ specSelectors, errActions }) => {
}
export default function() {
return {
statePlugins: {
spec: {
actions: {
validateSpec,
},
wrapActions: {
updateJsonSpec
}
}
}
}
}
@@ -0,0 +1,59 @@
## Semantic Validators
Ie: anything not covered by the json-schema validation
All the following belongs to the `validate` namespace
Eg: a plugin with an action ( to validate things ) and a selector, to get nodes and stuff.
```js
export function SomeAwesomePlugin() {
return {
statePlugins: {
validate: { // "validate" Namespace
actions: {
validateSomeNewValidateFunction() {}
},
selectors: {
someNewSelector() {}
}
}
}
}
}
```
### Make a new one
```js
// Under the validate namespace
export const validateOnlyFoos = () => (system) => {
system.validateSelectors.allSchemas().then(schemas => {
const errors = []
schemas.forEach( schema => {
if(schema.node.type === "array") { // `node` is the value at that point
errors.push({
level: "error",
message: "We can do something with this, array.",
path: schema.path // it'll figure out the line # from this
})
}
})
}))
}
```
### Make a selector, to later validate
We use a single traverser although its performance leaves a little to be desired.
The idea is that you provide a name and a filter function and
in turn your validators can then iterate over those "nodes" to validate them.
```js
export const allParameters = () => (system) => {
return system.fn.traverseOnce({ // Returns a promise
name: "allParameterSchemas",
fn: (node) => { // called for each node, you need to return the node if you want it in the collecction
if(system.validateSelectors.isParameter(node)) {
return true
}
},
})
}
```
@@ -0,0 +1,63 @@
import debounce from "lodash/debounce"
export const SOURCE = "semantic"
// the test system does not tolerate slowness!
const NODE_ENV = process.env.NODE_ENV
const CI = process.env.CI
const DEBOUNCE_MS = (NODE_ENV === "test" || CI === "true") ? 0 : 30
// System for buffering/batching errors
var errorCollector = []
const debNewSpecErrBatch = debounce(() => {
const system = errorCollector.system // Just a reference to the "latest" system
try {
errorCollector.forEach(obj => {
obj.line = obj.line || system.fn.AST.getLineNumberForPath(system.specSelectors.specStr(), obj.path)
obj.source = SOURCE
})
system.errActions.newSpecErrBatch(errorCollector)
delete errorCollector.system
errorCollector = [] // Clear stack
} catch(e) {
// eslint-disable-next-line no-console
console.error(e)
}
}, DEBOUNCE_MS)
const bufferedNewSpecErrBatch = (system, obj) => {
errorCollector.push(obj)
errorCollector.system = system
debNewSpecErrBatch()
}
export const all = () => system => {
if (!system.validateSelectors.shouldValidate()) {
return
}
system.validateActions.beforeValidate()
const errCb = (obj) => bufferedNewSpecErrBatch(system, obj)
system.validateSelectors.validators().forEach(name => {
const fn = system.validateActions[name]
// nothing about oas3 or swagger2
if(name.indexOf("validateAsync") === 0) {
fn(errCb) // Function send messages on its own, it won't be cached ( due to the nature of async operations )
} else {
Promise.resolve(fn())
.then(validationObjs => {
if(validationObjs) {
validationObjs.forEach(errCb)
}
})
}
})
}
export const beforeValidate = () => (system) => {
system.errActions.clear({
source: SOURCE
})
}
@@ -0,0 +1,10 @@
const HARD_CYCLE_LIMIT = 200
export function getRootNode(node) {
var i = 0
while(node.notRoot && i < HARD_CYCLE_LIMIT) {
node = node.parent
i++
}
return node || {}
}
@@ -0,0 +1,141 @@
import * as selectors from "./selectors"
import * as actions from "./actions"
import traverse from "traverse"
import {createSelector} from "reselect"
import debounce from "lodash/debounce"
import memoize from "lodash/memoize"
import * as formDataValidateActions from "./validators/form-data"
import * as schemaValidateActions from "./validators/schema"
import * as pathsValidateActions from "./validators/paths"
import * as securityValidateActions from "./validators/security"
import * as parametersValidateActions from "./validators/parameters"
import * as operationsOAS3ValidateActions from "./validators/oas3/operations"
import * as parametersOAS3ValidateActions from "./validators/oas3/parameters"
import * as componentsOAS3ValidateActions from "./validators/oas3/components"
import * as refsOAS3ValidateActions from "./validators/oas3/refs"
import * as refs2and3ValidateActions from "./validators/2and3/refs"
import * as parameters2and3ValidateActions from "./validators/2and3/parameters"
import * as paths2and3ValidateActions from "./validators/2and3/paths"
import * as schemas2and3ValidateActions from "./validators/2and3/schemas"
import * as operations2and3ValidateActions from "./validators/2and3/operations"
import * as security2and3ValidateActions from "./validators/2and3/security"
import * as tags2and3ValidateActions from "./validators/2and3/tags"
export default function SemanticValidatorsPlugin({getSystem}) {
const debAll = debounce((system) => system.validateActions.all(), 300)
const traverseOnce = makeTraverseOnce(getSystem)
return {
fn: {
traverse,
traverseOnce,
memoizedResolveSubtree: makeMemoizedResolveSubtree(getSystem())
},
statePlugins: {
spec: {
selectors: {
jsonAsJS: createSelector(
state => state.get("json"),
(spec) => spec ? spec.toJS() : null
)
},
wrapActions: {
validateSpec: (ori, system) => (...args) => {
// verify editor plugin already loaded and function is available (for tests)
if (system.specSelectors.specOrigin) {
const specOrigin = system.specSelectors.specOrigin()
if (specOrigin === "editor") {
ori(...args)
debAll(system)
}
}
}
}
},
validate: {
selectors,
actions: {
...actions,
...formDataValidateActions,
...schemaValidateActions,
...pathsValidateActions,
...securityValidateActions,
...parametersValidateActions,
...operations2and3ValidateActions,
...refs2and3ValidateActions,
...operationsOAS3ValidateActions,
...parametersOAS3ValidateActions,
...componentsOAS3ValidateActions,
...refsOAS3ValidateActions,
...parameters2and3ValidateActions,
...paths2and3ValidateActions,
...schemas2and3ValidateActions,
...security2and3ValidateActions,
...tags2and3ValidateActions
}
},
}
}
}
function makeTraverseOnce(getSystem) {
let traversers = {}
let results = {}
let deferred = null
const debTraverse = debounce(() => {
// Setup collections
for(let name in traversers) {
results[name] = []
}
const system = getSystem()
const json = system.specSelectors.jsonAsJS()
getSystem().fn.traverse(json)
.forEach(function() { // Remember: this cannot be a fat-arrow function, because we need to read "this"
for(let name in traversers) {
const fn = traversers[name]
const fnRes = fn(this)
if(fnRes) {
results[name].push(fnRes)
}
}
})
deferred.resolve(results)
deferred = null
traversers = {}
results = {}
}, 20) // 20ms might be more than enough, since most of these are called immediately (within a tick)
const defer = () => {
let d = {}
d.promise = new Promise((resolve, reject) => {
d.resolve = resolve
d.reject = reject
})
return d
}
return ({fn, name}) => {
traversers[name] = fn
deferred = deferred || defer()
debTraverse()
return deferred.promise.then( a => a[name] )
}
}
function makeMemoizedResolveSubtree(system) {
const cacheKeymaker = (obj, path) => {
return `${obj.toString()} ${path.join("<>")}`
}
return memoize(async (obj, path, opts) => {
const res = await system.fn.resolveSubtree(obj.toJS(), path, opts)
return res
}, cacheKeymaker)
}
@@ -0,0 +1,452 @@
import flatten from "lodash/flatten"
export const isVendorExt = (state,node) => node.path.some(a => a.indexOf("x-") === 0)
export const isDefinition = (state,node) => node.path[0] == "definitions" && node.path.length == 2
export const isTag = (state, node) => node.path[0] === "tags" && node.path.length === 2
export const isRootParameter = (state, node) => node.path[0] === "parameters" && node.path.length === 2
export const isPathItemParameter = (state, node) => node.path[2] === "parameters" && node.path.length === 4
export const isRootParameters = (state, node) => node.path[0] === "parameters" && node.path.length === 1
export const isPathItemParameters = (state, node) => node.path[2] === "parameters" && node.path.length === 3
export const isOperationParameters = (state, node) => node.path[3] === "parameters" && node.path.length === 4
export const isRootResponse = (state, node) => node.path[0] === "responses" && node.path.length === 2
export const isRootHeader = (state, node) => node.path[0] === "headers" && node.path.length === 2
export const isRef = (state, node) => node.key === "$ref" && typeof node.node === "string" // This selector can be fooled.
export const isRefArtifact = (state, node) => node.key === "$$ref" && typeof node.node === "string"
export const isOAS3RootRequestBody = (state, node) => node.path.length === 3 && node.path[1] === "requestBodies"
export const isOAS3OperationRequestBody = (state, node) => node.path.length === 4 && node.path[3] === "requestBody"
export const isOAS3OperationCallbackRequestBody = (state, node) => node.path.length === 8 && node.path[7] === "requestBody"
export const isOAS3RootParameter = (state, node) => node.path[0] === "components" && node.path[1] === "parameters" && node.path.length === 3
export const isOAS3RootResponse = (state, node) => node.path[0] === "components" && node.path[1] === "responses" && node.path.length === 3
export const isOAS3RootSchema = (state, node) => node.path[0] === "components" && node.path[1] === "schemas" && node.path.length === 3
export const isSubSchema = (state, node) => (sys) => {
const path = node.path
if(path.length < 3) {
return false
}
if(node.parent.key == "properties") {
if(node.parent.parent && node.parent.parent.node && node.parent.parent.node.type === "object") {
return !sys.validateSelectors.isVendorExt(node)
}
} else if(node.key === "additionalProperties") {
if(node.parent && node.parent.node && node.parent.node.type === "object") {
return !sys.validateSelectors.isVendorExt(node)
}
} else if(node.key == "items") {
if(node.parent.node && node.parent.node.type === "array") {
return !sys.validateSelectors.isVendorExt(node)
}
}
}
export const isParameter = (state, node) => (sys) => {
if(sys.validateSelectors.isVendorExt(node)) {
return false
}
return (
sys.validateSelectors.isRootParameter(node)
|| sys.validateSelectors.isOAS3RootParameter(node)
|| sys.validateSelectors.isPathItemParameter(node)
|| (node.path[0] === "paths"
&& node.path[3] === "parameters"
&& node.path.length === 5)
)
}
export const isOAS3RequestBody = (state, node) => (sys) => {
if(sys.validateSelectors.isVendorExt(node)) {
return false
}
return (
sys.validateSelectors.isOAS3RootRequestBody(node)
|| sys.validateSelectors.isOAS3OperationRequestBody(node)
|| sys.validateSelectors.isOAS3OperationCallbackRequestBody(node)
)
}
export const isParameterSchema = (state, node) => (sys) => {
if(sys.specSelectors.isOAS3 && sys.specSelectors.isOAS3()) {
// OAS3
return node.key === "schema" && sys.validateSelectors.isParameter(node.parent)
}
// parameter.x.in != body
if(sys.validateSelectors.isParameter(node) && node.node.in !== "body") {
return true
}
// parameter.x.in == body
if(node.key === "schema" && node.parent && sys.validateSelectors.isParameter(node.parent) && node.parent.node.in === "body") {
return true
}
}
export const isOAS3RequestBodySchema = (state, node) => () => {
const [key,, gpKey, ggpKey] = node.path.slice().reverse()
return key === "schema"
&& gpKey === "content"
&& ggpKey === "requestBody"
}
export const isOAS3ResponseSchema = (state, node) => () => {
const [key,, gpKey,, gggpKey] = node.path.slice().reverse()
return key === "schema"
&& gpKey === "content"
&& gggpKey === "responses"
}
export const isResponse = (state, node) => (sys) => {
const isOperationResponse = (
node.path[0] === "paths"
&& node.path[3] === "responses"
&& node.path.length === 5
&& !sys.validateSelectors.isVendorExt(node)
)
return (
isOperationResponse
|| sys.validateSelectors.isRootResponse(node)
|| sys.validateSelectors.isOAS3RootResponse(node)
)
}
export const allResponses = () => (system) => {
return system.fn.traverseOnce({
name: "allResponses",
fn: (node) => {
if(system.validateSelectors.isResponse(node)) {
return node
}
},
})
}
export const isHeader = (state, node) => (sys) => {
if(sys.validateSelectors.isVendorExt(node)) {
return false
}
return (
sys.validateSelectors.isRootHeader(node)
|| ( node.path[0] === "paths"
&& node.path[3] === "responses"
&& node.path[5] === "headers"
&& node.path.length === 7)
)
}
export const isResponseSchema = (state, node) => (sys) => {
// paths.<operation>.<method>.responses.XXX.schema
// respones.<response>.schema
if(node.key === "schema" && node.parent && sys.validateSelectors.isResponse(node.parent)) {
return true
}
}
export const allSchemas = () => (system) => {
const { validateSelectors } = system
const selectors = [
validateSelectors.allParameterSchemas(),
validateSelectors.allResponseSchemas(),
validateSelectors.allDefinitions(),
validateSelectors.allHeaders(),
validateSelectors.allSubSchemas(),
validateSelectors.allOAS3OperationSchemas()
]
return Promise.all(selectors)
.then((schemasAr) => {
return flatten(schemasAr)
})
}
export const allParameters = () => (system) => {
return system.fn.traverseOnce({
name: "allParameters",
fn: (node) => {
if(system.validateSelectors.isParameter(node)) {
return node
}
},
})
}
export const allOAS3RequestBodies = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3RequestBodies",
fn: (node) => {
if(system.validateSelectors.isOAS3RequestBody(node)) {
return node
}
},
})
}
export const allParameterArrays = () => (system) => {
return system.validateSelectors.allParameters()
.then(parameters => {
return parameters.map(node => node.parent)
.filter((node, i, arr) => {
return Array.isArray(node.node) && arr.indexOf(node) === i
})
})
}
export const allTags = () => (system) => {
return system.fn.traverseOnce({
name: "allTags",
fn: (node) => {
if(system.validateSelectors.isTag(node)) {
return node
}
},
})
}
export const allSubSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allSubSchemas",
fn: (node) => {
if(system.validateSelectors.isSubSchema(node)) {
return node
}
},
})
}
export const all$refs = () => (system) => {
return system.fn.traverseOnce({
name: "all$refs",
fn: (node) => {
if(system.validateSelectors.isRef(node)) {
return node
}
},
})
}
export const all$refArtifacts = () => (system) => {
return system.fn.traverseOnce({
name: "all$refArtifacts",
fn: (node) => {
if(system.validateSelectors.isRefArtifact(node)) {
return node
}
},
})
}
export const allDefinitions = () => (system) => {
return system.fn.traverseOnce({
name: "allDefinitions",
fn: (node) => {
if(
system.validateSelectors.isDefinition(node)
|| system.validateSelectors.isOAS3RootSchema(node)
) {
return node
}
},
})
}
export const allParameterSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allParameterSchemas",
fn: (node) => {
if(system.validateSelectors.isParameterSchema(node)) {
return node
}
},
})
}
export const allOAS3OperationSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3OperationSchemas",
fn: (node) => {
if(
system.validateSelectors.isOAS3RequestBodySchema(node)
|| system.validateSelectors.isOAS3ResponseSchema(node)
) {
return node
}
},
})
}
export const allOAS3RequestBodySchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3RequestBodySchemas",
fn: (node) => {
if(
system.validateSelectors.isOAS3RequestBodySchema(node)
) {
return node
}
},
})
}
export const allHeaders = () => (system) => {
return system.fn.traverseOnce({
name: "allHeader",
fn: (node) => {
if(system.validateSelectors.isHeader(node)) {
return node
}
},
})
}
export const allResponseSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allResponseSchemas",
fn: (node) => {
if(system.validateSelectors.isResponseSchema(node)) {
return node
}
},
})
}
export const allOperations = () => (system) => {
return system.fn.traverseOnce({
name: "allOperations",
fn: (node) => {
const isOperation = (
node.path[0] == "paths"
&& node.path.length === 3
&& !system.validateSelectors.isVendorExt(node)
)
if(isOperation) {
return node
}
}
})
}
export const allPathItems = () => (system) => {
return system.fn.traverseOnce({
name: "allPathItems",
fn: (node) => {
const isPathItem = (
node.path[0] == "paths"
&& node.path.length === 2
&& !system.validateSelectors.isVendorExt(node)
)
if(isPathItem) {
return node
}
}
})
}
export const allSecurityDefinitions = () => (system) => {
return system.fn.traverseOnce({
name: "allSecurityDefinitions",
fn: (node) => {
const isSecurityDefinition = (
node.path[0] == "securityDefinitions"
&& node.path.length === 2
)
const isOAS3SecurityScheme = (
node.path[0] == "components"
&& node.path[1] == "securitySchemes"
&& node.path.length === 3
)
if(isSecurityDefinition || isOAS3SecurityScheme) {
return node
}
}
})
}
export const allSecurityRequirements = () => (system) => {
return system.fn.traverseOnce({
name: "allSecurityRequirements",
fn: (node) => {
const isGlobalSecurityRequirement = (
node.path[0] == "security"
&& node.path.length === 2
)
const isOperationSecurityRequirement = (
node.path[0] == "paths"
&& node.path[3] == "security"
&& node.path.length === 5
&& !system.validateSelectors.isVendorExt(node.parent) // ignore extension keys in path items
&& !system.validateSelectors.isVendorExt(node.parent.parent.parent) // ignore extension keys in "paths"
)
if(isGlobalSecurityRequirement || isOperationSecurityRequirement) {
return node
}
}
})
}
export const allOAS3Components = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3Components",
fn: (node) => {
const isComponent = (
node.path[0] === "components"
&& node.path.length === 3
&& !system.validateSelectors.isVendorExt(node.parent)
)
if(isComponent) {
return node
}
}
})
}
// List of validators to run...
export const validators = () => (system) => {
return Object.keys(system.validateActions)
.filter(name => {
// The action needs to start with the prefix "validate..."
if(name.indexOf("validate") !== 0)
return false
// This is for both types...
if(name.startsWith("validate2And3"))
return true
// Now for the exclusive validations...
if(system.specSelectors.isOAS3())
return name.startsWith("validateOAS3")
// Swagger2 only...
return !name.startsWith("validateOAS3")
//TODO: This doesn't account for validateAsync with oas3 or swagger2...
})
}
// Should we validate at all?
export const shouldValidate = () => (system) => {
// don't run validation if spec is empty
if(system.specSelectors.specStr().trim().length === 0) {
return
}
// Don't validate if ambiguous version...
const { specSelectors: { isSwagger2=Function.prototype, isOAS3=Function.prototype } } = system
// Can't handle TWO versions!
if(isSwagger2() && isOAS3())
return false
// Can't handle no version!
if(!isSwagger2() && !isOAS3())
return false
return true
}
@@ -0,0 +1,24 @@
export const validate2And3OperationHasUniqueId = () => sys => {
return sys.validateSelectors
.allOperations()
.then(nodes => {
const seen = []
return nodes.reduce((acc, node) => {
const value = node.node
const id = value.operationId
if (id) {
if (seen.indexOf(id) > -1) {
acc.push({
level: "error",
message: "Operations must have unique operationIds.",
path: [...node.path, "operationId"]
})
}
seen.push(id)
}
return acc
}, [])
})
}
@@ -0,0 +1,75 @@
export const validate2And3ParametersHaveUniqueNameAndInCombinations = () => (system) => {
return system.validateSelectors
.allParameterArrays()
.then(nodes => {
return nodes.reduce((acc, node) => {
const parameters = node.node || []
const seen = []
parameters.forEach((param, i) => {
const { name: paramName, in: paramIn } = param
if(!paramName || !paramIn) {
// name or in is missing, so we can't match the param to anything else
return
}
const key = `${paramName}::${paramIn}`
if(seen.indexOf(key) > -1) {
acc.push({
level: "error",
message: "Sibling parameters must have unique name + in values",
path: [
...node.path,
(param.__i || i).toString()
]
})
}
seen.push(key)
})
return acc
}, [])
})
}
export const validate2And3PathParameterIsDefinedInPath = () => (system) => {
const refArray = []
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const parameter = node.node || {}
const path = node.path
const isFromPath = path[0] === "paths" ? true : false
const pathString = path[1]
const paramName = parameter.name
const paramInPath = `{${paramName}}`
const ref = parameter.$ref
const pathStringIncludesParamInPath = pathString && !pathString.toUpperCase().includes("" + paramInPath.toUpperCase())
if (parameter.in === "path") {
if (isFromPath && pathStringIncludesParamInPath) {
acc.push({
message: `Path parameter "${paramName}" must have the corresponding ${paramInPath} segment in the "${pathString}" path`,
path: [...node.path, "name"],
level: "error"
})
} else {
const paramReference = refArray.find(({ referenceParamName }) => referenceParamName === node.key)
if (paramReference && paramReference.pathString && !paramReference.pathString.toUpperCase().includes("" + paramInPath.toUpperCase())) {
acc.push({
message: `Path parameter "${paramName}" must have the corresponding ${paramInPath} segment in the "${paramReference.pathString}" path`,
path: [...paramReference.node.path, "name"],
level: "error"
})
}
}
} else if (ref !== undefined) {
const refStrings = ref.split("/")
refArray.push({referenceParamName:refStrings[refStrings.length-1], pathString:pathString, node: node})
}
return acc
}, [])
})
}
@@ -0,0 +1,65 @@
import {
checkForDefinition,
PATH_TEMPLATES_REGEX
} from "../helpers"
export const validate2And3PathParameterKeysDontContainQuestionMarks = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(node.key.indexOf("?") > -1) {
acc.push({
message: `Query strings in paths are not allowed.`,
path: [...node.path],
level: "error",
})
}
return acc
}, [])
})
}
export const validate2And3PathParameterDeclarationHasMatchingDefiniton = () => async system => {
const nodes = await system.validateSelectors.allPathItems()
return nodes.reduce(async (prev, node) => {
const acc = await prev
const pathTemplates = (node.key.match(PATH_TEMPLATES_REGEX) || [])
.map(str => str.replace("{", "").replace("}", ""))
if(pathTemplates.length) {
for (let paramName of pathTemplates) {
if(paramName.length === 0) {
// don't validate empty param names... they're invalid anyway
continue
}
const resolverResult = await system.fn.memoizedResolveSubtree(system.specSelectors.specJson(), node.path)
const res = checkForDefinition(paramName, resolverResult.spec)
if(res.inOperation && res.missingFromOperations.length) {
const missingStr = res.missingFromOperations
.map(str => `"${str}"`)
.join(", ")
acc.push({
message: `Declared path parameter "${paramName}" needs to be defined within every operation in the path (missing in ${missingStr}), or moved to the path-level parameters object`,
path: [...node.path],
level: "error",
})
} else if(res.caseMatch) {
acc.push({
message: `Parameter names are case-sensitive. The parameter named "${res.paramCase}" does not match the case used in the path "${node.key}".`,
path: [...node.path],
level: "error",
})
} else if(!res.found) {
acc.push({
message: `Declared path parameter "${paramName}" needs to be defined as a path parameter at either the path or operation level`,
path: [...node.path],
level: "error",
})
}
}
}
return acc
}, Promise.resolve([]))
}
@@ -0,0 +1,139 @@
import get from "lodash/get"
import { escapeJsonPointerToken } from "../../../refs-util"
import qs from "querystring-browser"
import { pathFromPtr } from "json-refs"
export const validate2And3RefHasNoSiblings = () => system => {
return system.validateSelectors.all$refs()
.then((nodes) => {
const immSpecJson = system.specSelectors.specJson()
const specJson = immSpecJson.toJS ? immSpecJson.toJS() : {}
return nodes.reduce((acc, node) => {
const unresolvedValue = get(specJson, node.parent.path) || {}
const unresolvedKeys = Object.keys(unresolvedValue) || []
const isPathItem = node.parent.key === "paths" && node.path.length === 2
unresolvedKeys.forEach(k => {
if(!isPathItem && k !== "$ref" && unresolvedKeys.indexOf("$ref") > -1) {
acc.push({
message: `Sibling values alongside $refs are ignored.\nTo add properties to a $ref, wrap the $ref into allOf, or move the extra properties into the referenced definition (if applicable).`,
path: [...node.path.slice(0, -1), k],
level: "warning"
})
}
})
return acc
}, [])
})
}
// Add warnings for unused definitions
export const validate2And3UnusedDefinitions = () => (system) => {
return system.validateSelectors.all$refs()
.then((nodes) => {
const references = nodes.map(node => node.node)
const errors = []
const basePath = system.specSelectors.isOAS3() ?
["components", "schemas"] :
["definitions"]
system.specSelectors.definitions()
.forEach((val, key) => {
const escapedKey = escapeJsonPointerToken(key)
if(references.indexOf(`#/${basePath.join("/")}/${escapedKey}`) < 0) {
const path = [...basePath, key]
errors.push({
level: "warning",
path,
message: "Definition was declared but never used in document"
})
}
})
return errors
})
}
export const validate2And3RefPathFormatting = () => (system) => {
return system.validateSelectors.all$refs()
.then((refArtifacts) => {
const errors = []
refArtifacts.forEach((node) => {
const value = node.node
if(typeof value === "string") {
// eslint-disable-next-line no-unused-vars
const [refUrl, refPath] = value.split("#")
if(refPath && refPath[0] !== "/") {
errors.push({
path: [...node.path.slice(0, -1), "$ref"],
message: "$ref paths must begin with `#/`",
level: "error"
})
}
}
})
return errors
})
}
export const validate2And3RefPointersExist = () => (system) => {
const json = system.specSelectors.specJson()
return system.validateSelectors.all$refs()
.then((refs) => {
const errors = []
refs.forEach((node) => {
const value = node.node
if(typeof value === "string" && value[0] === "#") {
// if pointer starts with "#", it is a local ref
const path = pathFromPtr(qs.unescape(value))
if(json.getIn(path) === undefined) {
errors.push({
path: [...node.path.slice(0, -1), "$ref"],
message: "$refs must reference a valid location in the document",
level: "error"
})
}
}
})
return errors
})
}
// from RFC3986: https://tools.ietf.org/html/rfc3986#section-2.2
// plus "%", since it is needed for encoding.
const RFC3986_UNRESERVED_CHARACTERS = /[A-Za-z0-9\-_\.~%]/g
export const validate2And3RefPointersAreProperlyEscaped = () => (system) => {
return system.validateSelectors.all$refs()
.then((refs) => {
const errors = []
refs.forEach((node) => {
const value = node.node
const hashIndex = value.indexOf("#")
const fragment = hashIndex > -1 ? value.slice(hashIndex + 1) : null
if(typeof fragment === "string") {
const rawPath = fragment.split("/")
const hasReservedChars = rawPath
.some(p => p.replace(RFC3986_UNRESERVED_CHARACTERS, "").length > 0)
if(hasReservedChars) {
errors.push({
path: [...node.path.slice(0, -1), "$ref"],
message: "$ref values must be RFC3986-compliant percent-encoded URIs",
level: "error"
})
}
}
})
return errors
})
}
@@ -0,0 +1,137 @@
export const validate2And3TypeArrayRequiresItems = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const { type, items } = schemaObj || {}
if(type === "array" && typeof items === "undefined") {
acc.push({
message: "Schemas with 'type: array', require a sibling 'items: ' field",
path: node.path,
level: "error",
})
} else if(type === "array" && (typeof items !== "object" || Array.isArray(items))) {
acc.push({
message: "`items` must be an object",
path: [...node.path, "items"],
level: "error",
})
}
return acc
}, [])
})
}
export const validate2And3TypesInDefaultValuesMatchesWithEnum = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const { type } = schemaObj || {}
const isNullable = !!schemaObj.nullable
const enumeration = schemaObj.enum
if (enumeration !== null && typeof enumeration !== "undefined") {
var enumIndex = 0
enumeration.forEach((element, index) => {
var isValidFormat = true
if (element === null && isNullable) {
return
}
if (type === "array" && (!Array.isArray(element) || element === null)) {
isValidFormat = false
enumIndex = index
} else if ((type === "number" || type === "string" || type === "boolean") && !(typeof element === type)) {
isValidFormat = false
enumIndex = index
} else if (type === "integer" && !Number.isInteger(element)) {
isValidFormat = false
enumIndex = index
} else if (type === "object" && ((element === null) || !(typeof element === type) || Array.isArray(element))) {
isValidFormat = false
enumIndex = index
}
if (!isValidFormat) {
acc.push({
message: "enum value should conform to its schema's `type`",
path: [...node.path, "enum", enumIndex],
level: "warning",
})
}
})
}
return acc
}, [])
})
}
export const validate2And3SchemasDefaultsMatchAnEnum = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const element = node.node || {}
if(!element || element.enum === undefined || element.default === undefined) {
// nothing to do
return acc
}
if(element.enum.indexOf(element.default) === -1) {
acc.push({
message: "Default values must be present in `enum`",
path: [...node.path, "default"]
})
}
return acc
}, [])
})
}
export const validate2And3MinAndMax = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const {minimum, maximum, minLength, maxLength, minProperties, maxProperties, minItems, maxItems} = schemaObj
if(typeof minimum === "number" && typeof maximum === "number" && (minimum > maximum)) {
acc.push({
message: "'minimum' must be lower value than 'maximum'",
path: [...node.path, "minimum"],
level: "error",
})
}
if(typeof minLength === "number" && typeof maxLength === "number" && (minLength > maxLength)) {
acc.push({
message: "'minLength' must be lower value than 'maxLength'",
path: [...node.path, "minLength"],
level: "error",
})
}
if(typeof minProperties === "number" && typeof maxProperties === "number" && (minProperties > maxProperties)) {
acc.push({
message: "'minProperties' must be lower value than 'maxProperties'",
path: [...node.path, "minProperties"],
level: "error",
})
}
if(typeof minItems === "number" && typeof maxItems === "number" && (minItems > maxItems)) {
acc.push({
message: "'minItems' must be lower value than 'maxItems'",
path: [...node.path, "minItems"],
level: "error",
})
}
return acc
}, [])
})
}
@@ -0,0 +1,51 @@
export const validate2And3SecurityRequirementsHaveDefinitions = () => (system) => {
const { allSecurityRequirements, allSecurityDefinitions } = system.validateSelectors
return Promise.all([allSecurityRequirements(), allSecurityDefinitions()])
.then(([requirementNodes, definitionNodes]) => {
const definedSecuritySchemes = definitionNodes
.map(node => node.key)
return requirementNodes.reduce((acc, node) => {
const value = node.node
const requiredSecurityDefinitions = Object.keys(value) || []
requiredSecurityDefinitions.forEach(name => {
if(definedSecuritySchemes.indexOf(name) < 0) {
acc.push({
message: "Security requirements must match a security definition",
path: [...node.path],
level: "error",
})
}
})
return acc
}, [])
})
}
export const validate2And3UnusedSecuritySchemes = () => (system) => {
const { allSecurityRequirements, allSecurityDefinitions } = system.validateSelectors
return Promise.all([allSecurityRequirements(), allSecurityDefinitions()])
.then(([securityRequirements, securitySchemes]) => {
// Get just the names of security schemes used in `security`
const usedSecurities = securityRequirements
.map(node => Object.keys(node.node) || [])
.reduce(function(a, b) {
// flatten!
return a.concat(b)
}, [])
return securitySchemes.reduce((acc, node) => {
if(usedSecurities.indexOf(node.key) < 0) {
acc.push({
message: "Security scheme was defined but never used. To apply security, use the `security` section in operations or on the root level of your API definition.",
path: node.path,
level: "warning",
})
}
return acc
}, [])
})
}
@@ -0,0 +1,21 @@
export const validate2And3TagObjectsHaveUniqueNames = () => (system) => {
return system.validateSelectors
.allTags()
.then(nodes => {
const seenNames = []
return nodes.reduce((acc, node) => {
const tagObj = node.node
const { name } = tagObj || {}
if(!name || seenNames.indexOf(name) > -1) {
acc.push({
message: "Tag Objects must have unique `name` field values.",
path: node.path,
level: "error",
})
} else {
seenNames.push(name)
}
return acc
}, [])
})
}
@@ -0,0 +1,117 @@
import { SOURCE } from "../actions"
import { getRootNode } from "src/plugins/validate-semantic/helpers"
const operationKeys = ["get", "post", "put", "delete", "options", "head", "patch", "trace"]
export const validateParameterFormDataCaseTypo = () => system => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
if(
value.in &&
typeof value.in === "string" &&
value.in.toLowerCase() === "formdata" &&
value.in !== "formData"
) {
acc.push({
message: `Parameter "in: ${value.in}" is invalid, did you mean "in: formData"?`,
path: [...node.path],
level: "error",
source: SOURCE
})
}
return acc
}, [])
})
}
export const validateParameterFormDataForFileTypes = () => system => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
if(value.type === "file" && value.in !== "formData") {
acc.push({
message: `Parameters with "type: file" must have "in: formData"`,
path: [...node.path],
level: "error",
source: SOURCE
})
}
return acc
}, [])
})
}
export const validateParameterFormDataConsumesType = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
return nodes.reduce((acc, node) => {
const pathItemValue = node.node
const globalConsumes = getRootNode(node).node.consumes
const pathItemParameters = pathItemValue.parameters
const hasPathItemFormDataParameter = pathItemParameters != null && pathItemParameters.find(parameter => parameter.in === "formData")
const hasPathItemFileParameter = pathItemParameters != null && pathItemParameters.find(parameter => parameter.type === "file")
for (const method of operationKeys) {
const operationValue = pathItemValue[method]
if (operationValue) {
const effectiveConsumes = operationValue.consumes || globalConsumes || []
const operationParameters = operationValue.parameters || []
const hasOperationFormDataParameter = operationParameters.find(parameter => parameter.in === "formData")
const hasOperationFileParameter = operationParameters.find(parameter => parameter.type === "file")
if(hasPathItemFileParameter || hasOperationFileParameter){
if (!effectiveConsumes.includes("multipart/form-data")) {
acc.push({
message: `Operations with parameters of "type: file" must include "multipart/form-data" in their "consumes" property`,
path: [...node.path, method],
level: "error",
source: SOURCE
})
}
} else if (hasPathItemFormDataParameter || hasOperationFormDataParameter) {
if (!effectiveConsumes.includes("application/x-www-form-urlencoded") && !effectiveConsumes.includes("multipart/form-data")) {
acc.push({
message: `Operations with parameters of "in: formData" must include "application/x-www-form-urlencoded" or "multipart/form-data" in their "consumes" property`,
path: [...node.path, method],
level: "error",
source: SOURCE
})
}
}
}
}
return acc
}, [])
})
}
export const validateParameterArraysDontContainBodyAndFormData = () => system => {
return system.validateSelectors
.allParameterArrays()
.then(paramArrays => {
return paramArrays.reduce((acc, node) => {
const bodyParams = node.node.filter(param => param.in === "body")
const formDataParams = node.node.filter(param => param.in === "formData")
if(bodyParams.length && formDataParams.length) {
acc.push({
message: `Parameters cannot have both a "in: body" and "in: formData", as "formData" _will_ be the body`,
path: [...node.path],
level: "error",
source: SOURCE
})
}
return acc
}, [])
})
}
@@ -0,0 +1,61 @@
const operationKeys = ["get", "post", "put", "delete", "options", "head", "patch", "trace"]
export const PATH_TEMPLATES_REGEX = /\{(.*?)\}/g
export function checkForDefinition(paramName, pathItem) {
const pathItemParameters = pathItem.parameters
const operationsInPathItem = (Object.keys(pathItem) || [])
.filter(key => operationKeys.indexOf(key) > -1)
.map(key => {
const obj = pathItem[key]
obj.method = key
return obj
})
const res = {
found: false,
inPath: false,
inOperation: false,
caseMatch: false,
paramCase: "",
missingFromOperations: []
}
// Look at the path parameters
if(Array.isArray(pathItemParameters)) {
pathItemParameters.forEach(param => {
if(param.name === paramName && param.in === "path") {
res.found = true
res.inPath = true
}
})
}
// Next, look at the operations...
if(!res.found && operationsInPathItem.length) {
operationsInPathItem
.forEach(op => {
const inThisOperation = (op.parameters || [])
.some(param => param.name === paramName && param.in === "path")
const caseMatch = (op.parameters || [])
.find(param => param.name && !(param.name === paramName) && (param.name.toLowerCase() === paramName.toLowerCase()) && param.in === "path")
if(inThisOperation) {
res.found = true
res.inOperation = true
}
if(caseMatch) {
res.caseMatch = true
res.paramCase = caseMatch.name
}
if(!inThisOperation) {
res.missingFromOperations.push(op.method)
}
})
}
return res
}
@@ -0,0 +1,18 @@
export const COMPONENT_NAME_REGEX = /^[A-Za-z0-9\-\._]+$/
export const validateOAS3ComponentNames = () => (system) => {
return system.validateSelectors
.allOAS3Components()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(!COMPONENT_NAME_REGEX.test(node.key)) {
acc.push({
level: "error",
message: "Component names can only contain the characters A-Z a-z 0-9 - . _",
path: node.path
})
}
return acc
}, [])
})
}
@@ -0,0 +1,19 @@
export const validateOAS3GetAndDeleteOpsHaveNoRequestBody = () => sys => {
return sys.validateSelectors
.allOperations()
.then(nodes => {
return nodes.reduce((acc, node) => {
const key = (node.key || "").toLowerCase()
const value = node.node
if((key === "get" || key === "delete") && value.requestBody !== undefined) {
acc.push({
level: "error",
message: `${key.toUpperCase()} operations cannot have a requestBody.`,
path: [...node.path, "requestBody"]
})
}
return acc
}, [])
})
}
@@ -0,0 +1,31 @@
export const validateOAS3HeaderParameterNames = () => (system) => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(node.node.in === "header") {
const name = (node.node.name || "").toLowerCase()
if(name === "authorization") {
acc.push({
level: "warning",
message: "Header parameters named \"Authorization\" are ignored. Use the `securitySchemes` and `security` sections instead to define authorization.",
path: [...node.path, "name"]
})
} else if(name === "content-type") {
acc.push({
level: "warning",
message: "Header parameters named \"Content-Type\" are ignored. The values for the \"Content-Type\" header are defined by `requestBody.content.<media-type>`.",
path: [...node.path, "name"]
})
} else if(name === "accept") {
acc.push({
level: "warning",
message: "Header parameters named \"Accept\" are ignored. The values for the \"Accept\" header are defined by `responses.<code>.content.<media-type>`.",
path: [...node.path, "name"]
})
}
}
return acc
}, [])
})
}
@@ -0,0 +1,128 @@
export const validateOAS3RefsForRequestBodiesReferenceRequestBodyPositions = () => sys => {
return sys.validateSelectors
.allOAS3RequestBodies()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if (!ref) {
return acc
}
const [refPath = ""] = ref.split("#")
const pathArr = refPath.split("/") || []
// Ignore external references
if (ref.startsWith("#/")) {
// Local cases
if (refPath.endsWith("requestBody") && (refPath.startsWith("/paths") || refPath.startsWith("/components"))){
return acc
}
// Starting with #/compontents/schemas is not allowed
if (ref.startsWith("#/components/schemas")) {
acc.push({
level: "error",
message: `requestBody $refs cannot point to '#/components/schemas/…', they must point to '#/components/requestBodies/…'`,
path: [...node.path, "$ref"]
})
} else
if (ref.startsWith("#/components") && !ref.startsWith("#/components/requestBodies/")) {
acc.push({
level: "error",
message: `requestBody $refs must point to a position where a requestBody can be legally placed`,
path: [...node.path, "$ref"]
})
}
// Extensions are valid
if (ref.startsWith("#/") && pathArr.some(element => element.startsWith("x-"))){
return acc
}
}
return acc
}, [])
})
}
export const validateOAS3RequestBodyRefsReferenceAllowableSchemaPositions = () => sys => {
return sys.validateSelectors
.allOAS3RequestBodySchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if(!ref) {
return acc
}
const [, refPath = ""] = ref.split("#")
const pathArr = refPath.split("/") || []
const parentRefKey = pathArr.slice(-2)[0]
const targetRefKey = pathArr.slice(-1)[0]
if(
targetRefKey !== "schema"
&& parentRefKey !== "schemas"
&& ref.startsWith("#/")
) {
acc.push({
level: "error",
message: `requestBody schema $refs must point to a position where a Schema Object can be legally placed`,
path: [...node.path, "$ref"]
})
}
return acc
}, [])
})
}
export const validateOAS3ParameterRefsReferenceParameterPositions = () => sys => {
return sys.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if(!ref) {
return acc
}
if (ref.startsWith("#/components/headers")) {
acc.push({
level: "error",
message: `OAS3 parameter $refs should point to #/components/parameters/... and not #/components/headers/...`,
path: [...node.path, "$ref"]
})
}
return acc
}, [])
})
}
export const validateOAS3RefsForHeadersReferenceHeadersPositions = () => sys => {
return sys.validateSelectors
.allHeaders()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if(!ref) {
return acc
}
if (ref.startsWith("#/components/parameters")) {
acc.push({
level: "error",
message: `OAS3 header $refs should point to #/components/headers/... and not #/components/parameters/...`,
path: [...node.path, "$ref"]
})
}
return acc
}, [])
})
}
@@ -0,0 +1,41 @@
export const validateParameterBadKeys = () => (system) => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(node.node.required !== true && node.node.in === "path") {
acc.push({
level: "error",
message: "Path parameters must have 'required: true'. You can always create another path/operation without this parameter to get the same behaviour.",
path: node.path
})
}
return acc
}, [])
})
}
export const validateParametersHasOnlyOneBody = () => (system) => {
return system.validateSelectors
.allParameterArrays()
.then(nodes => {
return nodes.reduce((acc, node) => {
const parameters = node.node || []
let bodyParamSeen = false
parameters.forEach((param) => {
if(param.in === "body" && bodyParamSeen) {
acc.push({
level: "error",
message: "Multiple body parameters are not allowed.",
path: node.path
})
}
if(param.in === "body") {
bodyParamSeen = true
}
})
return acc
}, [])
})
}
@@ -0,0 +1,45 @@
import {
PATH_TEMPLATES_REGEX
} from "./helpers"
export const validatePathParameterDeclarationIsNotEmpty = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
return nodes.reduce((acc, node) => {
const pathTemplates = (node.key.match(PATH_TEMPLATES_REGEX) || [])
.map(str => str.replace("{", "").replace("}", ""))
const emptyPathTemplates = pathTemplates.filter(v => !v.length)
if(emptyPathTemplates.length) {
acc.push({
message: `Empty path parameter declarations are not valid`,
path: [...node.path],
level: "error",
})
}
return acc
}, [])
})
}
export const validatePathParameterKeysAreDifferent = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
const seen = []
return nodes.reduce((acc, node) => {
const realPath = node.key.replace(PATH_TEMPLATES_REGEX, "~~")
if(seen.indexOf(realPath) > -1) {
acc.push({
message: `Equivalent paths are not allowed.`,
path: [...node.path],
level: "error",
})
}
seen.push(realPath)
return acc
}, [])
})
}
@@ -0,0 +1,60 @@
export const validateTypeKeyShouldBeString = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
if(schemaObj.type !== undefined && typeof schemaObj.type !== "string") {
acc.push({
message: `Schema "type" key must be a string`,
path: [...node.path, "type"],
level: "error",
})
}
return acc
}, [])
})
}
export const validateReadOnlyPropertiesNotRequired = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
if(Array.isArray(schemaObj.required) && typeof schemaObj.properties === "object") {
schemaObj.required.forEach((prop, i) => {
if(schemaObj.properties[prop] && schemaObj.properties[prop].readOnly) {
acc.push({
message: `Read only properties cannot be marked as required by a schema.`,
path: [...node.path, "required", i.toString()],
level: "error",
})
}
})
}
return acc
}, [])
})
}
// See https://github.com/swagger-api/swagger-editor/issues/1601
export const validateSchemaPatternHasNoZAnchors = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const { pattern } = schemaObj || {}
if(typeof pattern === "string" && pattern.indexOf("\\Z") > -1) {
acc.push({
message: `"\\Z" anchors are not allowed in regular expression patterns`,
path: [...node.path, "pattern"],
level: "error",
})
}
return acc
}, [])
})
}
@@ -0,0 +1,32 @@
export const validateSecurityRequirementReferenceExistingScopes = () => (system) => {
const { allSecurityRequirements, allSecurityDefinitions } = system.validateSelectors
return Promise.all([allSecurityRequirements(), allSecurityDefinitions()])
.then(([requirementNodes, definitionNodes]) => {
const definedSecuritySchemes = definitionNodes
.reduce((p, node) => Object.assign(p, { [node.key]: node.node }), {})
return requirementNodes.reduce((acc, node) => {
const value = node.node
const requiredSecurityDefinitions = Object.keys(value) || []
requiredSecurityDefinitions.forEach(name => {
const scopes = value[name]
const definition = definedSecuritySchemes[name]
if(Array.isArray(scopes) && scopes.length && definition) {
scopes.forEach((scope, i) => {
if(!definition.scopes || definition.scopes[scope] === undefined) {
acc.push({
message: `Security scope definition ${scope} could not be resolved`,
path: [...node.path, i.toString()],
level: "error",
})
}
})
}
})
return acc
}, [])
})
}
+4
View File
@@ -0,0 +1,4 @@
require("core-js/fn/object/values")
require("core-js/fn/object/assign")
require("core-js/es6/string")
require("core-js/es6/array")
+6
View File
@@ -0,0 +1,6 @@
This is the Editor's Standalone preset - it adds some features used by [editor.swagger.io](http://editor.swagger.io):
- Topbar with Swagger branding
- URL/File import
- YAML conversion trigger
- JSON/YAML export
+23
View File
@@ -0,0 +1,23 @@
import TopbarPlugin from "./topbar"
import TopbarInsertPlugin from "./topbar-insert"
import TopbarMenuFileImportFile from "./topbar-menu-file-import_file"
import TopbarMenuEditConvert from "./topbar-menu-edit-convert"
import StandaloneLayout from "./standalone-layout"
let StandaloneLayoutPlugin = function() {
return {
components: {
StandaloneLayout
}
}
}
export default function () {
return [
TopbarPlugin,
TopbarInsertPlugin,
TopbarMenuFileImportFile,
TopbarMenuEditConvert,
StandaloneLayoutPlugin
]
}
@@ -0,0 +1,24 @@
import React from "react"
import PropTypes from "prop-types"
export default class StandaloneLayout extends React.Component {
static propTypes = {
specActions: PropTypes.object.isRequired,
getComponent: PropTypes.func.isRequired,
}
render() {
const { getComponent } = this.props
const EditorLayout = getComponent("EditorLayout", true)
const Topbar = getComponent("Topbar", true)
return (
<div>
<Topbar />
<EditorLayout/>
</div>
)
}
}
+3
View File
@@ -0,0 +1,3 @@
@import "topbar-insert-forms.less";
@import "topbar-modal.less";
@import "topbar.less";
@@ -0,0 +1,85 @@
.map-form-left {
float: left;
width: 40%;
}
.map-form-right {
float: right;
width: 55%;
}
.d-inline-block {
display: inline-block;
}
.float-right {
float: right;
}
button.remove-item {
position: absolute;
right: 10px;
line-height: 0;
padding: 1rem;
margin: -1rem -1rem -1rem auto;
}
.form-container {
width: 750px;
a {
color: green;
font-size: 1.3em;
text-decoration: none;
&:hover {
cursor: pointer;
}
}
.close.remove-item {
font-size: 2em;
margin-top: -10px;
float: right;
&:hover {
cursor: pointer;
}
}
}
.card-body {
padding: 20px;
border: 1px solid lightgrey;
border-radius: 5px;
margin: 10px 0;
overflow: hidden;
}
.invalid-feedback {
color: red;
}
.form-group {
padding-bottom: 10px;
.input-label {
font-weight: 400;
.input-label-title {
font-weight: 600;
}
}
input[type=text] {
width: 100%;
}
input[type=text].border-danger{
border: 1px solid red;
}
select {
width: 100%;
}
textarea {
border: 1px solid lightgrey;
height: 200px;
min-height: 100px;
}
}
@@ -0,0 +1,60 @@
.modal.topbar-modal {
position: absolute;
left: 0;
right: 0;
z-index: 1000;
max-height: 90vh;
.modal-dialog-sm {
width: 400px;
}
.modal-dialog {
background-color: white;
border: 1px solid lightgrey;
border-radius: 5px;
max-width: 800px;
margin: auto;
.modal-content {
.modal-body {
max-height: 80vh;
overflow-y: auto;
padding: 20px;
.label {
font-size: 18px;
}
}
.modal-footer {
padding: 20px;
width: 100%;
border-top: 1px solid lightgrey;
text-align: right;
}
.modal-header-border {
border-bottom: 1px solid lightgrey;
}
.modal-header {
width: 100%;
padding: 10px 20px;
.modal-title {
font-size: 1.8em;
padding-bottom: 15px;
}
.close {
float: right;
font-size: 1.8em;
&:hover {
cursor: pointer;
}
}
}
}
}
}
+114
View File
@@ -0,0 +1,114 @@
.topbar {
background-color: #1b1b1b;
width: 100%;
}
.topbar-wrapper {
padding: 0.7em;
display: flex;
& > * {
margin-left: 1em;
margin-right: 1em;
align-self: center;
color: white;
font-size: 1.0em;
font-weight: 500;
}
& .menu-item {
cursor: pointer;
font-size:14px;
&::after {
content: '▼';
margin-left: 6px;
font-size: 8px;
}
}
}
.topbar-logo__img {
float: left;
}
.topbar-logo__title {
display: inline-block;
color: #fff;
font-size: 1.5em;
font-weight: bold;
margin: 0.1em 1.2em 0 0.5em;
}
.dd-menu {
&.long {
display: flex;
flex-wrap: wrap;
max-width: 800px;
.dd-menu-items {
width: 700px;
.dd-items-left {
display: flex;
flex-direction: column;
max-height: 500px;
flex-wrap: wrap;
margin: 1.7em 0 0!important;
li {
flex:22%;
}
}
}
.long-menu-message {
padding: 1.5em;
color: #ccc;
cursor: pointer;
}
}
.dd-menu-items {
margin: 1.1em 0 0 0 !important;
ol,ul {
border-radius:0 0 4px 4px;
li {
&:last-of-type {
&:hover {
border-radius:0 0 4px 4px;
}
}
}
}
}
}
.modal {
font-family: sans-serif;
color: #3b4151;
padding: 1em;
position: relative;
min-height: 12em;
div.container {
height: 100%;
}
.right {
margin: 1em;
text-align: right;
}
button {
margin-left: 1em;
}
}
.modal-message {
margin: 1.75em 2em;
font-size: 1.1em;
p {
line-height: 1.3;
}
}
@@ -0,0 +1,68 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
class Dropdown extends Component {
constructor(props) {
super(props)
this.state = {
isExpanded: false
}
this.onToggleClick = this.onToggleClick.bind(this)
this.handleClickOutside = this.handleClickOutside.bind(this)
this.setWrapperRef = this.setWrapperRef.bind(this)
}
componentDidMount = () => {
document.addEventListener("mousedown", this.handleClickOutside)
}
componentWillUnmount = () => {
document.removeEventListener("mousedown", this.handleClickOutside)
}
onToggleClick = () => {
this.setState(prevState => ({
isExpanded: !prevState.isExpanded
}))
}
setWrapperRef = (node) => {
this.wrapperRef = node
}
handleClickOutside = (event) => {
if (this.wrapperRef && !this.wrapperRef.contains(event.target)) {
this.setState({
isExpanded: false
})
}
}
render() {
return (
<div className="dd-menu dd-menu-left" ref={this.setWrapperRef}>
<span className="menu-item" role="button" aria-haspopup="true" aria-expanded={this.state.isExpanded} onClick={this.onToggleClick}>
{this.props.displayName}
</span>
{this.state.isExpanded &&
<div className="dd-menu-items" aria-labelledby="Dropdown" onClick={this.onToggleClick} role="menu" tabIndex={0}>
<ul className="dd-items-left">
{this.props.children}
</ul>
</div>
}
</div>
)
}
}
Dropdown.propTypes = {
displayName: PropTypes.string.isRequired,
children: PropTypes.oneOfType([
PropTypes.array,
PropTypes.element
])
}
export default Dropdown
@@ -0,0 +1,19 @@
import React from "react"
import PropTypes from "prop-types"
const DropdownItem = (props) => (
<div>
<li className="dropdown-item">
<button onClick={props.onClick}> {props.name} </button>
</li>
{props.endsSection && <div className="dropdown-divider" />}
</div>
)
DropdownItem.propTypes = {
onClick: PropTypes.func,
name: PropTypes.string,
endsSection: PropTypes.bool
}
export default DropdownItem
@@ -0,0 +1,94 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import YAML from "js-yaml"
import { checkForErrors } from "../helpers/validation-helpers"
class AddForm extends Component {
constructor(props) {
super(props)
this.state = {
formErrors: false,
formData: this.props.existingData ?
this.props.getFormData( (newForm, path) => this.updateForm(newForm, path), [], this.props.existingData) :
this.props.getFormData( (newForm, path) => this.updateForm(newForm, path), [] )
}
this.updateForm = this.updateForm.bind(this)
this.submit = this.submit.bind(this)
}
submit = () => {
const formData = this.state.formData
// Prevent form submission if the data was invalid.
if (checkForErrors(formData)[1]) {
this.setState(prevState => ({
formErrors: true,
formData: checkForErrors(prevState.formData)[0]
}))
return
}
this.setState({
formErrors: false
})
// Update the Swagger UI state.
this.props.updateSpecJson(formData)
// Update the spec string in the Swagger UI state with the new json.
const currentJson = this.props.specSelectors.specJson()
this.props.specActions.updateSpec(YAML.safeDump(currentJson.toJS()), "insert")
// Perform any parent component actions for the form.
this.props.submit()
}
updateForm = (newFormData, path) => {
this.setState(prevState => ({
formData: prevState.formData.setIn(path, newFormData)
}))
}
render() {
const { getComponent } = this.props
const InsertForm = getComponent("InsertForm")
return (
<div>
<div className="modal-body">
<div className="form-container">
<InsertForm formData={this.state.formData} getComponent={getComponent} />
</div>
</div>
<div className="modal-footer">
{ this.state.formErrors && <div className="invalid-feedback">Please fix errors before submitting.</div>}
<button className="btn btn-default" onClick={this.submit}>{this.props.submitButtonText}</button>
</div>
</div>
)
}
}
AddForm.propTypes = {
specActions: PropTypes.shape({
updateSpec: PropTypes.func.isRequired
}),
specSelectors: PropTypes.shape({
specStr: PropTypes.func.isRequired,
specJson: PropTypes.func.isRequired
}),
submit: PropTypes.func.isRequired,
submitButtonText: PropTypes.string.isRequired,
getFormData: PropTypes.func.isRequired,
updateSpecJson: PropTypes.func.isRequired,
existingData: PropTypes.oneOfType([
PropTypes.object,
PropTypes.array
]),
getComponent: PropTypes.func.isRequired
}
export default AddForm
@@ -0,0 +1,29 @@
import React from "react"
import PropTypes from "prop-types"
const FormChild = (props) => {
const { getComponent } = props
const FormInputWrapper = getComponent("FormInputWrapper")
return (
<div key={props.name} className="card-body">
<FormInputWrapper name={props.name} description={props.description} isRequired={props.isRequired}>
{ !props.isRequired && <a onClick={props.flipRequired}> Add {props.name} </a> }
{ props.isRequired && props.optional && <a onClick={props.flipRequired}> Remove {props.name} </a> }
{ props.isRequired && props.childForm }
</FormInputWrapper>
</div>
)
}
FormChild.propTypes = {
name: PropTypes.string,
description: PropTypes.string,
isRequired: PropTypes.bool,
childForm: PropTypes.any.isRequired,
flipRequired: PropTypes.func.isRequired,
optional: PropTypes.bool,
getComponent: PropTypes.func.isRequired
}
export default FormChild
@@ -0,0 +1,146 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import classNames from "classnames"
class FormDropdown extends Component {
constructor(props) {
super(props)
this.state = {
addedOptions: [],
toBeAdded: "",
showAddOption: false,
isValidAddition: true
}
this.updateToBeAdded = this.updateToBeAdded.bind(this)
this.showAddField = this.showAddField.bind(this)
this.onEnterKeyPress = this.onEnterKeyPress.bind(this)
this.submitAdded = this.submitAdded.bind(this)
this.onChangeWrapper = this.onChangeWrapper.bind(this)
}
onEnterKeyPress = (event) => {
if (event.key === "Enter") {
this.submitAdded()
}
}
submitAdded = () => {
if (this.props.isValidAddition(this.state.toBeAdded)) {
this.setState((prevState) => {
prevState.addedOptions.push(prevState.toBeAdded)
return {
addedOptions: prevState.addedOptions,
toBeAdded: "",
showAddOption: false
}
})
} else {
this.setState({
isValidAddition: false
})
}
}
updateToBeAdded = (event) => {
this.setState({
toBeAdded: event.target.value,
isValidAddition: this.props.isValidAddition(event.target.value)
})
this.props.onChange(event)
}
showAddField = () => {
this.setState({
showAddOption: true
})
if (this.state.toBeAdded) {
this.submitAdded()
}
}
onChangeWrapper = (event) => {
if (event.target.value === "Please Select" || event.target.value === this.props.placeholderText) {
const updated = event
updated.target.value = null
this.props.onChange(updated)
}
this.props.onChange(event)
}
render() {
let addedOption = <span />
const addButton = <a role="button" className="d-inline-block float-right" onClick={this.showAddField} onKeyDown={this.onEnterKeyPress} tabIndex={0}>Add</a>
if (this.props.isValidAddition) {
if (this.state.showAddOption) {
addedOption = (
<div>
<input
className="form-control"
type="text"
onChange={this.updateToBeAdded}
value={this.state.toBeAdded}
placeholder="Add Option"
onKeyDown={this.addField}
/>
{addButton}
{!this.state.isValidAddition &&
this.props.isValidAdditionMessage &&
<div className="invalid-feedback">
{this.props.isValidAdditionMessage}
</div>
}
</div>)
} else {
addedOption = addButton
}
}
return (
<div>
{!this.state.showAddOption &&
<select
value={this.props.selected || this.props.placeholderText || "Please Select"}
onChange={this.onChangeWrapper}
className={classNames("custom-select", {"border-danger": !this.props.isValid})}
>
<option value={this.props.placeholderText || "Please Select"}>
{this.props.placeholderText || "Please Select"}
</option>
{(this.props.options || []).map((option, i) =>
<option key={option + i} value={option}>{option}</option>)}
{this.state.addedOptions.length &&
this.state.addedOptions.map((option, i) =>
<option key={option + i} value={option}>{option}</option>)
}
</select>
}
{addedOption}
{!this.props.isValid &&
<div className="invalid-feedback d-block">
{this.props.validationMessage}
</div>
}
</div>
)
}
}
FormDropdown.propTypes = {
isValid: PropTypes.bool.isRequired,
placeholderText: PropTypes.string,
validationMessage: PropTypes.string,
options: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
]),
onChange: PropTypes.func.isRequired,
selected: PropTypes.string,
isValidAddition: PropTypes.func,
isValidAdditionMessage: PropTypes.string
}
export default FormDropdown

Some files were not shown because too many files have changed in this diff Show More