diff --git a/src/core/components/overview.jsx b/src/core/components/overview.jsx
index 4556542e952..7d900683f29 100644
--- a/src/core/components/overview.jsx
+++ b/src/core/components/overview.jsx
@@ -1,6 +1,7 @@
import React from "react"
import PropTypes from "prop-types"
import { Link } from "core/components/layout-utils"
+import { OPERATION_METHODS } from "core/utils/operation-methods"
export default class Overview extends React.Component {
@@ -99,7 +100,7 @@ export class OperationLink extends React.Component {
return (
- {method.toUpperCase()}
+ {OPERATION_METHODS.includes(method) ? method.toUpperCase() : method}
{id}
diff --git a/src/core/plugins/oas3/selectors.js b/src/core/plugins/oas3/selectors.js
index ae24ef6b457..f0f8d3fd6a1 100644
--- a/src/core/plugins/oas3/selectors.js
+++ b/src/core/plugins/oas3/selectors.js
@@ -24,16 +24,22 @@ const onlyOAS3 =
}
}
+const pathMethodSpecPath = (specSelectors, path, method) => {
+ return specSelectors.operationSpecPath
+ ? specSelectors.operationSpecPath(path, method)
+ : ["paths", path, method]
+}
+
function validateRequestBodyIsRequired(selector) {
return (...args) =>
(system) => {
- const specJson = system.getSystem().specSelectors.specJson()
+ const specSelectors = system.getSystem().specSelectors
+ const specJson = specSelectors.specJsonWithResolvedSubtrees()
const argsList = [...args]
// expect argsList[0] = state
let pathMethod = argsList[1] || []
let isOas3RequestBodyRequired = specJson.getIn([
- "paths",
- ...pathMethod,
+ ...pathMethodSpecPath(specSelectors, ...pathMethod),
"requestBody",
"required",
])
@@ -84,9 +90,7 @@ export const selectDefaultRequestBodyValue =
if (currentMediaType) {
return getDefaultRequestBodyValue(
specSelectors.specResolvedSubtree([
- "paths",
- path,
- method,
+ ...pathMethodSpecPath(specSelectors, path, method),
"requestBody",
]),
currentMediaType,
@@ -110,9 +114,7 @@ export const hasUserEditedBody = onlyOAS3((state, path, method) => (system) => {
const currentMediaType = oas3Selectors.requestContentType(path, method)
let userEditedRequestBody = oas3Selectors.requestBodyValue(path, method)
const requestBody = specSelectors.specResolvedSubtree([
- "paths",
- path,
- method,
+ ...pathMethodSpecPath(specSelectors, path, method),
"requestBody",
])
diff --git a/src/core/plugins/oas32/index.js b/src/core/plugins/oas32/index.js
index 73fe1a6c36d..85e4ce5d72d 100644
--- a/src/core/plugins/oas32/index.js
+++ b/src/core/plugins/oas32/index.js
@@ -21,6 +21,9 @@ import { validOperationMethods } from "./selectors"
import {
isOAS3 as isOAS3SelectorWrapper,
validOperationMethods as validOperationMethodsWrapper,
+ callbacksOperations,
+ selectWebhooksOperations,
+ webhooks,
} from "./spec-extensions/wrap-selectors"
import {
license as selectLicense,
@@ -51,11 +54,11 @@ import afterLoad from "./after-load"
*
* New features in OAS 3.2 (basic implementation):
* - query operation: QUERY HTTP method support
+ * - additionalOperations: Custom HTTP methods support
* - info.summary: Short summary field in Info Object
*
* Additional features (not yet implemented):
* - $self: Self-referencing URI for base URI resolution
- * - additionalOperations: Custom HTTP methods support
* - mediaTypes in Components: Reusable Media Type Objects
* - Tag enhancements (summary, kind, parent)
* - querystring parameter location
@@ -112,8 +115,12 @@ const OAS32Plugin = ({ fn }) => {
wrapSelectors: {
// Ensure OAS 3.2 specs are recognized as OAS 3.x (for servers, etc.)
isOAS3: isOAS3SelectorWrapper,
- // Override validOperationMethods to include QUERY for OAS 3.2
+ // Extend validOperationMethods with QUERY and custom additionalOperations methods (OAS 3.2)
validOperationMethods: validOperationMethodsWrapper,
+ // Include additionalOperations from callback and webhook Path Items (OAS 3.2)
+ callbacksOperations,
+ selectWebhooksOperations,
+ webhooks,
},
},
oas32: {
diff --git a/src/core/plugins/oas32/spec-extensions/wrap-selectors.js b/src/core/plugins/oas32/spec-extensions/wrap-selectors.js
index da9e4a82438..8a964331d0b 100644
--- a/src/core/plugins/oas32/spec-extensions/wrap-selectors.js
+++ b/src/core/plugins/oas32/spec-extensions/wrap-selectors.js
@@ -1,6 +1,12 @@
/**
* @prettier
*/
+import { createSelector } from "reselect"
+import { List, Map } from "immutable"
+import {
+ isOperationMethodField,
+ isFixedOperationMethod,
+} from "core/utils/operation-methods"
import { createOnlyOAS32SelectorWrapper } from "../fn"
/**
@@ -16,12 +22,152 @@ export const isOAS3 =
}
/**
- * Wraps validOperationMethods to include QUERY method for OAS 3.2.x
- * OAS 3.2.0 adds support for the QUERY HTTP method per
- * draft-ietf-httpbis-safe-method-w-body
+ * Memoized: recomputes only when the base list or the resolved `paths` change.
+ */
+const selectValidOperationMethods = createSelector(
+ [(baseMethods) => baseMethods, (baseMethods, paths) => paths],
+ (baseMethods, paths) => {
+ const customMethods = []
+
+ if (Map.isMap(paths)) {
+ paths.forEach((pathItem) => {
+ const additionalOperations = Map.isMap(pathItem)
+ ? pathItem.get("additionalOperations")
+ : null
+
+ if (Map.isMap(additionalOperations)) {
+ additionalOperations.forEach((operation, method) => {
+ if (
+ !isFixedOperationMethod(method) &&
+ !customMethods.includes(method)
+ ) {
+ customMethods.push(method)
+ }
+ })
+ }
+ })
+ }
+
+ return baseMethods.concat(customMethods)
+ }
+)
+
+/**
+ * Extends validOperationMethods for OAS 3.2.x with:
+ * - QUERY (new fixed field)
+ * - exact custom method tokens declared under Path Item `additionalOperations`
*
* Reference: https://spec.openapis.org/oas/v3.2.0.html#path-item-object
*/
export const validOperationMethods = createOnlyOAS32SelectorWrapper(
- () => (oriSelector, system) => system.oas32Selectors.validOperationMethods()
+ () => (oriSelector, system) =>
+ selectValidOperationMethods(
+ system.oas32Selectors.validOperationMethods(),
+ system.specSelectors.specJsonWithResolvedSubtrees().get("paths")
+ )
+)
+
+/**
+ * Collects operation DTOs from a single Path Item:
+ * - fixed-field operations (get, post, ..., query)
+ * - custom operations under `additionalOperations`, keeping exact method tokens
+ */
+const pathItemOperations = (pathItem, specPath) => {
+ if (!Map.isMap(pathItem)) return List()
+
+ const operations = pathItem
+ .entrySeq()
+ .filter(([key]) => isOperationMethodField(key))
+ .map(([method, operation]) => ({
+ operation: Map({ operation }),
+ method,
+ specPath: specPath.concat([method]),
+ }))
+ .toList()
+
+ const additionalOperations = pathItem.get("additionalOperations")
+ if (!Map.isMap(additionalOperations)) return operations
+
+ return operations.concat(
+ additionalOperations
+ .entrySeq()
+ .filter(([method]) => !isFixedOperationMethod(method))
+ .map(([method, operation]) => ({
+ operation: Map({ operation }),
+ method,
+ specPath: specPath.concat(["additionalOperations", method]),
+ }))
+ )
+}
+
+export const callbacksOperations = createOnlyOAS32SelectorWrapper(
+ (state, { callbacks, specPath }) =>
+ () => {
+ if (!Map.isMap(callbacks)) return {}
+
+ return callbacks
+ .reduce((allOperations, callback, callbackName) => {
+ if (!Map.isMap(callback)) return allOperations
+
+ return allOperations.concat(
+ callback.reduce(
+ (callbackOps, pathItem, expression) =>
+ callbackOps.concat(
+ pathItemOperations(
+ pathItem,
+ specPath.concat([callbackName, expression])
+ ).map((operationDTO) => ({
+ ...operationDTO,
+ path: expression,
+ callbackName,
+ }))
+ ),
+ List()
+ )
+ )
+ }, List())
+ .groupBy((operationDTO) => operationDTO.callbackName)
+ .map((operations) => operations.toArray())
+ .toObject()
+ }
+)
+
+export const webhooks = createOnlyOAS32SelectorWrapper(
+ () => (oriSelector, system) => {
+ const value = system.specSelectors
+ .specJsonWithResolvedSubtrees()
+ .get("webhooks")
+ return Map.isMap(value) ? value : Map()
+ }
+)
+
+/**
+ * Memoized on the resolved `webhooks` map, matching the OAS 3.1 selector.
+ */
+const selectWebhooksOperationsFromMap = createSelector(
+ [(webhooksMap) => webhooksMap],
+ (webhooksMap) => {
+ if (!Map.isMap(webhooksMap)) return {}
+
+ return webhooksMap
+ .reduce(
+ (allOperations, pathItem, pathItemName) =>
+ allOperations.concat(
+ pathItemOperations(pathItem, ["webhooks", pathItemName]).map(
+ (operationDTO) => ({ ...operationDTO, path: pathItemName })
+ )
+ ),
+ List()
+ )
+ .groupBy((operationDTO) => operationDTO.path)
+ .map((operations) => operations.toArray())
+ .toObject()
+ }
+)
+
+export const selectWebhooksOperations = createOnlyOAS32SelectorWrapper(
+ () => (oriSelector, system) =>
+ selectWebhooksOperationsFromMap(
+ system.specSelectors.specJsonWithResolvedSubtrees().get("webhooks")
+ )
)
diff --git a/src/core/plugins/spec/reducers.js b/src/core/plugins/spec/reducers.js
index 8a5644ded40..90399b359fe 100644
--- a/src/core/plugins/spec/reducers.js
+++ b/src/core/plugins/spec/reducers.js
@@ -5,6 +5,7 @@ import win from "core/window"
// selector-in-reducer is suboptimal, but `operationWithMeta` is more of a helper
import {
specJsonWithResolvedSubtrees,
+ operationSpecPath,
parameterValues,
parameterInclusionSettingFor,
} from "./selectors"
@@ -83,7 +84,7 @@ export default {
},
[VALIDATE_PARAMS]: ( state, { payload: { pathMethod, isOAS3 } } ) => {
- const op = specJsonWithResolvedSubtrees(state).getIn(["paths", ...pathMethod])
+ const op = specJsonWithResolvedSubtrees(state).getIn(operationSpecPath(state, ...pathMethod))
const paramValues = parameterValues(state, pathMethod).toJS()
return state.updateIn(["meta", "paths", ...pathMethod, "parameters"], fromJS({}), paramMeta => {
diff --git a/src/core/plugins/spec/selectors.js b/src/core/plugins/spec/selectors.js
index 35842823ba1..5adaadd7fe8 100644
--- a/src/core/plugins/spec/selectors.js
+++ b/src/core/plugins/spec/selectors.js
@@ -2,13 +2,10 @@ import { createSelector } from "reselect"
import constant from "lodash/constant"
import { sorters, paramToIdentifier } from "core/utils"
import { fromJS, Set, Map, OrderedMap, List } from "immutable"
+import { OPERATION_METHODS, isFixedOperationMethod } from "core/utils/operation-methods"
const DEFAULT_TAG = "default"
-const OPERATION_METHODS = [
- "get", "put", "post", "delete", "options", "head", "patch", "trace", "query"
-]
-
const state = state => {
return state || Map()
}
@@ -79,6 +76,24 @@ export const specJsonWithResolvedSubtrees = createSelector(
)
)
+export const operationSpecPath = (state, path, method) => {
+ const directPath = ["paths", path, method]
+ if (isFixedOperationMethod(method)) {
+ return directPath
+ }
+
+ const additionalOperationPath = [
+ "paths",
+ path,
+ "additionalOperations",
+ method,
+ ]
+
+ return specJsonWithResolvedSubtrees(state).hasIn(additionalOperationPath)
+ ? additionalOperationPath
+ : directPath
+}
+
// Default Spec ( as an object )
export const spec = state => {
let res = specJson(state)
@@ -146,8 +161,21 @@ export const operations = createSelector(
specPath: ["paths", pathName, method],
}))
})
+ const additionalOperations = path.get("additionalOperations", Map())
+ if (Map.isMap(additionalOperations)) {
+ additionalOperations.forEach((operation, method) => {
+ if(!isFixedOperationMethod(method)) {
+ list = list.push(fromJS({
+ path: pathName,
+ method,
+ operation,
+ id: `${method}-${pathName}`,
+ specPath: ["paths", pathName, "additionalOperations", method],
+ }))
+ }
+ })
+ }
})
-
return list
}
)
@@ -308,7 +336,7 @@ export const allowTryItOutFor = () => {
}
export const parameterWithMetaByIdentity = (state, pathMethod, param) => {
- const opParams = specJsonWithResolvedSubtrees(state).getIn(["paths", ...pathMethod, "parameters"], OrderedMap())
+ const opParams = specJsonWithResolvedSubtrees(state).getIn([...operationSpecPath(state, ...pathMethod), "parameters"], OrderedMap())
const metaParams = state.getIn(["meta", "paths", ...pathMethod, "parameters"], OrderedMap())
const mergedParams = opParams.map((currentParam) => {
@@ -330,13 +358,13 @@ export const parameterInclusionSettingFor = (state, pathMethod, paramName, param
export const parameterWithMeta = (state, pathMethod, paramName, paramIn) => {
- const opParams = specJsonWithResolvedSubtrees(state).getIn(["paths", ...pathMethod, "parameters"], OrderedMap())
+ const opParams = specJsonWithResolvedSubtrees(state).getIn([...operationSpecPath(state, ...pathMethod), "parameters"], OrderedMap())
const currentParam = opParams.find(param => param.get("in") === paramIn && param.get("name") === paramName, OrderedMap())
return parameterWithMetaByIdentity(state, pathMethod, currentParam)
}
export const operationWithMeta = (state, path, method) => {
- const op = specJsonWithResolvedSubtrees(state).getIn(["paths", path, method], OrderedMap())
+ const op = specJsonWithResolvedSubtrees(state).getIn(operationSpecPath(state, path, method), OrderedMap())
const meta = state.getIn(["meta", "paths", path, method], OrderedMap())
const mergedParams = op.get("parameters", List()).map((param) => {
@@ -395,7 +423,7 @@ export function parametersIncludeType(parameters, typeValue="") {
// Get the consumes/produces value that the user selected
export function contentTypeValues(state, pathMethod) {
pathMethod = pathMethod || []
- let op = specJsonWithResolvedSubtrees(state).getIn(["paths", ...pathMethod], fromJS({}))
+ let op = specJsonWithResolvedSubtrees(state).getIn(operationSpecPath(state, ...pathMethod), fromJS({}))
let meta = state.getIn(["meta", "paths", ...pathMethod], fromJS({}))
let producesValue = currentProducesFor(state, pathMethod)
@@ -418,7 +446,7 @@ export function contentTypeValues(state, pathMethod) {
export function currentProducesFor(state, pathMethod) {
pathMethod = pathMethod || []
- const operation = specJsonWithResolvedSubtrees(state).getIn([ "paths", ...pathMethod], null)
+ const operation = specJsonWithResolvedSubtrees(state).getIn(operationSpecPath(state, ...pathMethod), null)
if(operation === null) {
// return nothing if the operation does not exist
@@ -437,7 +465,7 @@ export function producesOptionsFor(state, pathMethod) {
pathMethod = pathMethod || []
const spec = specJsonWithResolvedSubtrees(state)
- const operation = spec.getIn([ "paths", ...pathMethod], null)
+ const operation = spec.getIn(operationSpecPath(state, ...pathMethod), null)
if(operation === null) {
// return nothing if the operation does not exist
@@ -458,7 +486,7 @@ export function consumesOptionsFor(state, pathMethod) {
pathMethod = pathMethod || []
const spec = specJsonWithResolvedSubtrees(state)
- const operation = spec.getIn(["paths", ...pathMethod], null)
+ const operation = spec.getIn(operationSpecPath(state, ...pathMethod), null)
if (operation === null) {
// return nothing if the operation does not exist
@@ -539,7 +567,7 @@ export const getOAS3RequiredRequestBodyContentType = (state, pathMethod) => {
requestBody: false,
requestContentType: {}
}
- let requestBody = state.getIn(["resolvedSubtrees", "paths", ...pathMethod, "requestBody"], fromJS([]))
+ let requestBody = specJsonWithResolvedSubtrees(state).getIn([...operationSpecPath(state, ...pathMethod), "requestBody"], fromJS([]))
if (requestBody.size < 1) {
return requiredObj
}
@@ -564,7 +592,7 @@ export const isMediaTypeSchemaPropertiesEqual = ( state, pathMethod, currentMedi
if((currentMediaType || targetMediaType) && currentMediaType === targetMediaType ) {
return true
}
- let requestBodyContent = state.getIn(["resolvedSubtrees", "paths", ...pathMethod, "requestBody", "content"], fromJS([]))
+ let requestBodyContent = specJsonWithResolvedSubtrees(state).getIn([...operationSpecPath(state, ...pathMethod), "requestBody", "content"], fromJS([]))
if (requestBodyContent.size < 2 || !currentMediaType || !targetMediaType) {
// nothing to compare
return false
diff --git a/src/core/utils/operation-methods.ts b/src/core/utils/operation-methods.ts
new file mode 100644
index 00000000000..316aa82a550
--- /dev/null
+++ b/src/core/utils/operation-methods.ts
@@ -0,0 +1,33 @@
+/**
+ * Fixed Path Item operation fields (OAS 3.2 adds `query`).
+ */
+export const OPERATION_METHODS = [
+ "get",
+ "put",
+ "post",
+ "delete",
+ "options",
+ "head",
+ "patch",
+ "trace",
+ "query",
+] as const
+
+export type OperationMethod = (typeof OPERATION_METHODS)[number]
+
+const fixedMethods: readonly string[] = OPERATION_METHODS
+
+/**
+ * Exact match: is this Path Item key a fixed operation field?
+ * Path Item keys are lowercase field names, so `additionalOperations`,
+ * `summary`, `parameters`, etc. are rejected.
+ */
+export const isOperationMethodField = (key: unknown): key is OperationMethod =>
+ typeof key === "string" && fixedMethods.includes(key)
+
+/**
+ * Case-insensitive: does an `additionalOperations` key duplicate a fixed field?
+ * Such entries (e.g. `POST`, `Head`) are invalid per OAS 3.2 and must be ignored.
+ */
+export const isFixedOperationMethod = (method: unknown): boolean =>
+ fixedMethods.includes(String(method).toLowerCase())
\ No newline at end of file
diff --git a/src/style/_dark-mode.scss b/src/style/_dark-mode.scss
index 40cfa6d7d32..c786a0ab4d0 100644
--- a/src/style/_dark-mode.scss
+++ b/src/style/_dark-mode.scss
@@ -82,6 +82,12 @@ $opblock_colors: (
#3a2238,
#d977c6,
),
+ custom: (
+ #1b1a0a,
+ #3d3910,
+ #201f0d,
+ #dabe63,
+ ),
);
html.dark-mode {
diff --git a/src/style/_layout.scss b/src/style/_layout.scss
index b2edcfc33ac..ad1c537f6bc 100644
--- a/src/style/_layout.scss
+++ b/src/style/_layout.scss
@@ -432,6 +432,10 @@
@include mixins.method($color-query);
}
+ &.opblock-custom {
+ @include mixins.method($color-custom-method);
+ }
+
&.opblock-deprecated {
opacity: 0.6;
diff --git a/src/style/_variables.scss b/src/style/_variables.scss
index d297d4a6e0d..453733e9c5a 100644
--- a/src/style/_variables.scss
+++ b/src/style/_variables.scss
@@ -54,6 +54,7 @@ $color-patch: #50e3c2 !default;
$color-disabled: #ebebeb !default;
$color-options: #0d5aa7 !default;
$color-query: #9d408a !default; // OAS 3.2 QUERY method
+$color-custom-method: #86751f !default; // OAS 3.2 additionalOperations custom methods
// Authorize
diff --git a/test/e2e-cypress/e2e/features/oas32/oas32-additional-operations.cy.js b/test/e2e-cypress/e2e/features/oas32/oas32-additional-operations.cy.js
new file mode 100644
index 00000000000..331639e8ad4
--- /dev/null
+++ b/test/e2e-cypress/e2e/features/oas32/oas32-additional-operations.cy.js
@@ -0,0 +1,405 @@
+/**
+ * @prettier
+ */
+describe("OAS 3.2 Additional Operations", () => {
+ const fixtureUrl = "/documents/features/oas32-additional-operations.yaml"
+ const baseUrl = `/?url=${fixtureUrl}`
+ const getOperation = "#operations-Items-getItems"
+ const listOperation = "#operations-Items-listItems"
+ const searchItemsOperation = "#operations-Items-searchItems"
+
+ // Pets
+ const copyOperation = "#operations-pets-copyPet"
+ const searchOperation = "#operations-pets-searchPets"
+ const pingOperation = () =>
+ cy.contains(".opblock", "Check health without an operationId")
+
+ const corsHeaders = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-methods": "GET, COPY, PING, Search-Pets",
+ "access-control-allow-headers": "*",
+ }
+
+ // Try it out is opt-in for custom methods: start Swagger UI again with the exact tokens.
+ const loadWithConfig = (config, visitOptions = {}) => {
+ cy.visit(baseUrl, visitOptions)
+ cy.get(copyOperation).should("exist")
+ cy.window().then((win) => {
+ win.ui = win.SwaggerUIBundle({
+ url: fixtureUrl,
+ dom_id: "#swagger-ui",
+ presets: [win.SwaggerUIBundle.presets.apis],
+ ...config,
+ })
+ })
+ }
+
+ const itemsOperations = () =>
+ cy
+ .get(".opblock-tag-section")
+ .filter(":has(#operations-tag-Items)")
+ .find(".opblock")
+
+ describe("rendering", () => {
+ it("renders standard and custom operations on the same path", () => {
+ cy.visit(baseUrl)
+ cy.get(getOperation)
+ .should("have.class", "opblock-get")
+ .within(() => {
+ cy.get(".opblock-summary-method").should("have.text", "GET")
+ cy.get(".opblock-summary-description").should(
+ "have.text",
+ "Get items with query parameters"
+ )
+ })
+
+ for (const [operation, method, summary] of [
+ [listOperation, "LIST", "List items with a request body"],
+ [
+ searchItemsOperation,
+ "X-Search",
+ "Search with a mixed-case custom method",
+ ],
+ ]) {
+ cy.get(operation)
+ .should("have.class", "opblock-custom")
+ .within(() => {
+ cy.get(".opblock-summary-method").should("have.text", method)
+ cy.get(".opblock-summary-description").should("have.text", summary)
+ })
+ }
+
+ itemsOperations().should("have.length", 3)
+
+ cy.get("#operations-pets-getPet").should("have.class", "opblock-get")
+ cy.get(copyOperation).should("have.class", "opblock-custom")
+ cy.get(searchOperation).should("have.class", "opblock-custom")
+ pingOperation()
+ .should("have.class", "opblock-custom")
+ .find(".opblock-summary-method")
+ .should("have.text", "PING")
+ })
+
+ it("preserves exact, case-sensitive custom method labels", () => {
+ cy.visit(baseUrl)
+ cy.get(copyOperation)
+ .find(".opblock-summary-method")
+ .should("have.text", "COPY")
+ cy.get(searchOperation)
+ .find(".opblock-summary-method")
+ .should("have.text", "Search-Pets")
+ cy.get("#operations-pets-searchPetsLowercase")
+ .find(".opblock-summary-method")
+ .should("have.text", "search-pets")
+ cy.get("#operations-pets-getPet")
+ .find(".opblock-summary-method")
+ .should("have.text", "GET")
+ })
+
+ it("expands custom operation parameters, referenced request body, and responses", () => {
+ cy.visit(baseUrl)
+ cy.get(listOperation).find(".opblock-summary-control").click()
+ cy.get(listOperation)
+ .should("have.class", "is-open")
+ .within(() => {
+ cy.get(".opblock-description-wrapper").should(
+ "contain.text",
+ "Filter items using the custom LIST method."
+ )
+ cy.get(".parameters-container").should("contain.text", "status")
+ cy.get(".opblock-section-request-body")
+ .should("contain.text", "Required item filter")
+ .and("contain.text", "category")
+ cy.get(".responses-wrapper")
+ .should("contain.text", "200")
+ .and("contain.text", "Filtered items")
+ .and("contain.text", "400")
+ .and("contain.text", "Invalid item filter")
+ })
+
+ cy.get(getOperation).find(".opblock-summary-control").click()
+ cy.get(getOperation).within(() => {
+ cy.get(".parameters-container").should("contain.text", "limit")
+ cy.get(".opblock-section-request-body").should("not.exist")
+ })
+ })
+
+ it("renders referenced parameters and responses", () => {
+ cy.visit(baseUrl)
+ cy.get(copyOperation).find(".opblock-summary-control").click()
+ cy.get(copyOperation)
+ .should("have.class", "is-open")
+ .within(() => {
+ cy.get(".opblock-description").should("contain.text", "Copy this pet")
+ cy.get(".parameters")
+ .should("contain.text", "id")
+ .and("contain.text", "destination")
+ cy.get(".responses-wrapper")
+ .should("contain.text", "201")
+ .and("contain.text", "Pet copied")
+ cy.get(".responses-wrapper .highlight-code").should(
+ "contain.text",
+ "name"
+ )
+ })
+ })
+
+ it("renders referenced request bodies and operation-specific metadata", () => {
+ loadWithConfig({
+ displayOperationId: true,
+ supportedSubmitMethods: ["Search-Pets"],
+ })
+ cy.get(searchOperation).find(".opblock-summary-control").click()
+ cy.get(searchOperation).within(() => {
+ cy.get(".opblock-summary-operation-id").should(
+ "have.text",
+ "searchPets"
+ )
+ cy.get(".opblock-section-request-body").should("contain.text", "Milo")
+ cy.get(".responses-wrapper")
+ .should("contain.text", "Matching pets")
+ .and("contain.text", "400")
+ cy.get(".opblock-external-docs__link").should(
+ "have.attr",
+ "href",
+ "https://example.com/search-docs"
+ )
+ cy.get(".authorization__btn").should("exist")
+ cy.get(".try-out__btn").click()
+ cy.get(".operation-servers select").should(
+ "have.value",
+ "https://example.com/search"
+ )
+ })
+ })
+
+ it("ignores additionalOperations entries that duplicate fixed fields", () => {
+ cy.visit(baseUrl)
+ cy.get("#operations-invalid-createInvalid").should(
+ "have.class",
+ "opblock-post"
+ )
+ cy.get("#operations-invalid-duplicatePostUpper").should("not.exist")
+ cy.get("#operations-invalid-duplicatePostLower").should("not.exist")
+ cy.get("#operations-invalid-createInvalid")
+ .find(".opblock-summary-control")
+ .click()
+ cy.get("#operations-invalid-createInvalid .responses-wrapper")
+ .should("contain.text", "Created")
+ .and("not.contain.text", "Duplicate response")
+ })
+ })
+
+ describe("deep linking", () => {
+ it("opens a custom operation from its deep link after reload", () => {
+ cy.visit(`${baseUrl}&deepLinking=true#/Items/listItems`)
+ cy.get(listOperation).should("have.class", "is-open")
+ cy.get(getOperation).should("not.have.class", "is-open")
+ cy.reload()
+ cy.get(listOperation)
+ .should("have.class", "is-open")
+ .find(".opblock-section-request-body")
+ .should("contain.text", "category")
+ })
+
+ it("toggles a custom operation and keeps its hash", () => {
+ cy.visit(`${baseUrl}&deepLinking=true#/pets/searchPets`)
+ cy.get(searchOperation).should("have.class", "is-open")
+ cy.get(copyOperation).should("not.have.class", "is-open")
+ cy.get(searchOperation).find(".opblock-summary-control").click()
+ cy.get(searchOperation).should("not.have.class", "is-open")
+ cy.get(searchOperation).find(".opblock-summary-control").click()
+ cy.location("hash").should("eq", "#/pets/searchPets")
+ })
+ })
+
+ describe("state and validation", () => {
+ it("validates required parameters through Try it out and keeps values scoped", () => {
+ loadWithConfig({ supportedSubmitMethods: ["get", "COPY"] })
+ cy.get(copyOperation).find(".opblock-summary-control").click()
+ cy.get(copyOperation).within(() => {
+ cy.get(".try-out__btn").click()
+ cy.get(".execute").click()
+ cy.get('tr[data-param-name="id"] input').should("have.class", "invalid")
+ cy.get('tr[data-param-name="destination"] input').should(
+ "have.class",
+ "invalid"
+ )
+ cy.get(".curl-command").should("not.exist")
+ cy.get('tr[data-param-name="id"] input').type("123")
+ })
+
+ // The value typed for COPY must not leak into GET on the same path
+ cy.get("#operations-pets-getPet").find(".opblock-summary-control").click()
+ cy.get("#operations-pets-getPet").within(() => {
+ cy.get(".try-out__btn").click()
+ cy.get('tr[data-param-name="id"] input').should("have.value", "")
+ })
+ })
+
+ it("validates the required custom-operation body before execution", () => {
+ loadWithConfig({ supportedSubmitMethods: ["get", "LIST", "X-Search"] })
+ cy.get(listOperation).find(".opblock-summary-control").click()
+ cy.get(listOperation).within(() => {
+ cy.get(".try-out__btn").click()
+ cy.get(".body-param textarea").clear()
+ cy.get(".execute").click()
+ cy.get(".body-param textarea").should("have.class", "invalid")
+ cy.get(".curl-command").should("not.exist")
+ })
+ })
+
+ it("respects the default submission allowlist for custom methods", () => {
+ cy.visit(baseUrl)
+ cy.get(copyOperation).find(".opblock-summary-control").click()
+ cy.get(copyOperation).find(".try-out__btn").should("not.exist")
+ })
+ })
+
+ describe("callbacks and webhooks", () => {
+ it("renders additional operations in callback and webhook Path Items", () => {
+ cy.visit(baseUrl)
+ cy.get(copyOperation).find(".opblock-summary-control").click()
+ cy.get(copyOperation).contains(".tab-item", "Callbacks").click()
+ cy.get("#operations-callbacks-notifyCopy")
+ .should("have.class", "opblock-custom")
+ .find(".opblock-summary-method")
+ .should("have.text", "Notify-Pet")
+
+ cy.get(".webhooks .opblock").should("have.length", 2)
+ cy.get("#operations-webhooks-notifyPetChanged")
+ .should("have.class", "opblock-custom")
+ .find(".opblock-summary-method")
+ .should("have.text", "Notify-Pet")
+ cy.get("#operations-webhooks-additionalOperationspetChanged").should(
+ "not.exist"
+ )
+ })
+
+ it("displays standard OAS 3.2 webhooks", () => {
+ cy.visit(baseUrl)
+ cy.get("#operations-webhooks-petCreated")
+ .should("have.class", "opblock-post")
+ .find(".opblock-summary-method")
+ .should("have.text", "POST")
+ cy.get("#operations-webhooks-petCreated")
+ .find(".opblock-summary-control")
+ .click()
+ cy.get("#operations-webhooks-petCreated")
+ .should("contain.text", "A pet was created")
+ .and("contain.text", "Received")
+ })
+ })
+
+ describe("execution", () => {
+ for (const [operation, method, url, body] of [
+ [listOperation, "LIST", "/api/items", { category: "books" }],
+ [
+ searchItemsOperation,
+ "X-Search",
+ "/search-api/items",
+ { term: "notebook" },
+ ],
+ ]) {
+ it(`executes ${method} with the released Swagger Client`, () => {
+ const customRequest = cy.stub().as("customRequest")
+ // Cypress's Node HTTP proxy rejects extension methods before cy.intercept.
+ // Stub only the browser transport; the real released client still resolves
+ // the operation and builds its request. Document fetches are untouched.
+ loadWithConfig(
+ { supportedSubmitMethods: ["get", "LIST", "X-Search"] },
+ {
+ onBeforeLoad(win) {
+ const originalFetch = win.fetch.bind(win)
+ cy.stub(win, "fetch").callsFake((input, options) => {
+ if (input === new URL(url, win.location.origin).href) {
+ customRequest(input, options)
+ return Promise.resolve(
+ new win.Response(JSON.stringify(["matched"]), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ )
+ }
+ return originalFetch(input, options)
+ })
+ },
+ }
+ )
+
+ cy.get(operation).find(".opblock-summary-control").click()
+ cy.get(operation).within(() => {
+ cy.get(".try-out__btn").click()
+ cy.get(".body-param textarea").should(($textarea) => {
+ expect(JSON.parse($textarea.val())).to.deep.equal(body)
+ })
+ cy.get(".execute").click()
+ })
+
+ cy.get("@customRequest")
+ .should("have.been.calledOnce")
+ .then((stub) => {
+ const [requestUrl, request] = stub.firstCall.args
+ expect(new URL(requestUrl).pathname).to.equal(url)
+ expect(request.method).to.equal(method)
+ expect(JSON.parse(request.body)).to.deep.equal(body)
+ expect(request.headers["Content-Type"]).to.equal("application/json")
+ })
+ cy.get(operation)
+ .find(".live-responses-table")
+ .should("contain.text", "200")
+ .and("contain.text", "matched")
+ })
+ }
+
+ it("executes COPY with its exact token and headers", () => {
+ cy.intercept(
+ { method: "OPTIONS", url: "**/pets/123" },
+ { statusCode: 204, headers: corsHeaders }
+ )
+ cy.intercept(
+ { method: "COPY", url: "**/pets/123" },
+ { statusCode: 201, headers: corsHeaders, body: { name: "Milo" } }
+ ).as("copy")
+
+ loadWithConfig({ supportedSubmitMethods: ["COPY"] })
+ cy.get(copyOperation).find(".opblock-summary-control").click()
+ cy.get(copyOperation).within(() => {
+ cy.get(".try-out__btn").click()
+ cy.get('tr[data-param-name="id"] input').type("123")
+ cy.get('tr[data-param-name="destination"] input').type("/pets/456")
+ cy.get(".execute").click()
+ })
+
+ cy.wait("@copy").then(({ request }) => {
+ expect(request.method).to.eq("COPY")
+ expect(request.headers.destination).to.eq("/pets/456")
+ })
+ cy.get(copyOperation)
+ .find(".live-responses-table")
+ .should("contain.text", "201")
+ })
+
+ // Pending upstream: swagger-client 3.38.2 cannot resolve additionalOperations
+ // without an operationId by pathName + method ("Operation undefined not found").
+ // Re-enable once supported in swagger-js.
+ it.skip("executes a custom operation that has no operationId", () => {
+ cy.intercept(
+ { method: "OPTIONS", url: "**/health" },
+ { statusCode: 204, headers: corsHeaders }
+ )
+ cy.intercept(
+ { method: "PING", url: "**/health" },
+ { statusCode: 200, headers: corsHeaders }
+ ).as("ping")
+
+ loadWithConfig({ supportedSubmitMethods: ["PING"] })
+ pingOperation().find(".opblock-summary-control").click()
+ pingOperation().within(() => {
+ cy.get(".try-out__btn").click()
+ cy.get(".execute").click()
+ })
+ cy.wait("@ping").its("request.method").should("eq", "PING")
+ })
+ })
+})
diff --git a/test/e2e-cypress/static/documents/features/oas32-additional-operations.yaml b/test/e2e-cypress/static/documents/features/oas32-additional-operations.yaml
new file mode 100644
index 00000000000..b172dd50443
--- /dev/null
+++ b/test/e2e-cypress/static/documents/features/oas32-additional-operations.yaml
@@ -0,0 +1,235 @@
+openapi: 3.2.0
+info:
+ title: Additional Operations
+ version: 1.0.0
+servers:
+ - url: https://example.com/api
+paths:
+ /items:
+ servers:
+ - url: /api
+ get:
+ tags: [Items]
+ operationId: getItems
+ summary: Get items with query parameters
+ parameters:
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: Items retrieved
+ additionalOperations:
+ LIST:
+ tags: [Items]
+ operationId: listItems
+ summary: List items with a request body
+ description: Filter items using the custom LIST method.
+ parameters:
+ - name: status
+ in: query
+ schema:
+ type: string
+ requestBody:
+ required: true
+ description: Required item filter
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ItemFilter'
+ example:
+ category: books
+ responses:
+ '200':
+ description: Filtered items
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: string
+ '400':
+ description: Invalid item filter
+ X-Search:
+ tags: [Items]
+ operationId: searchItems
+ summary: Search with a mixed-case custom method
+ servers:
+ - url: /search-api
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ term:
+ type: string
+ example:
+ term: notebook
+ responses:
+ '200':
+ description: Search results
+ /pets/{id}:
+ get:
+ tags: [pets]
+ operationId: getPet
+ summary: Get a pet
+ parameters:
+ - $ref: '#/components/parameters/PetId'
+ responses:
+ '200':
+ description: A pet
+ additionalOperations:
+ COPY:
+ tags: [pets]
+ operationId: copyPet
+ summary: Copy a pet
+ description: Copy this pet to a new destination.
+ callbacks:
+ copied:
+ '{$request.query.callbackUrl}':
+ additionalOperations:
+ Notify-Pet:
+ operationId: notifyCopy
+ summary: Notify that copying is complete
+ responses:
+ '204':
+ description: Notification accepted
+ parameters:
+ - $ref: '#/components/parameters/PetId'
+ - name: destination
+ in: header
+ required: true
+ schema:
+ type: string
+ responses:
+ '201':
+ $ref: '#/components/responses/Copied'
+ Search-Pets:
+ tags: [pets]
+ operationId: searchPets
+ summary: Search with a custom method
+ description: Search pets using a custom request body.
+ externalDocs:
+ description: Search documentation
+ url: https://example.com/search-docs
+ servers:
+ - url: https://example.com/search
+ security:
+ - apiKey: []
+ parameters:
+ - $ref: '#/components/parameters/PetId'
+ requestBody:
+ $ref: '#/components/requestBodies/Search'
+ responses:
+ '200':
+ description: Matching pets
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ '400':
+ description: Invalid search
+ search-pets:
+ tags: [pets]
+ operationId: searchPetsLowercase
+ summary: Distinct lowercase method
+ responses:
+ '204':
+ description: No matches
+ /health:
+ additionalOperations:
+ PING:
+ summary: Check health without an operationId
+ responses:
+ '200':
+ description: Healthy
+ /invalid-duplicates:
+ post:
+ tags: [invalid]
+ operationId: createInvalid
+ summary: Standard post
+ responses:
+ '201':
+ description: Created
+ additionalOperations:
+ POST:
+ tags: [invalid]
+ operationId: duplicatePostUpper
+ summary: Duplicate POST must be ignored
+ responses:
+ '200':
+ description: Duplicate response
+ post:
+ tags: [invalid]
+ operationId: duplicatePostLower
+ summary: Duplicate post must be ignored
+ responses:
+ '200':
+ description: Duplicate response
+webhooks:
+ petChanged:
+ additionalOperations:
+ Notify-Pet:
+ operationId: notifyPetChanged
+ summary: Notify a pet change
+ responses:
+ '204':
+ description: Change accepted
+ petCreated:
+ post:
+ operationId: petCreated
+ summary: A pet was created
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ responses:
+ '200':
+ description: Received
+components:
+ securitySchemes:
+ apiKey:
+ type: apiKey
+ in: header
+ name: X-Api-Key
+ parameters:
+ PetId:
+ name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBodies:
+ Search:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ example:
+ name: Milo
+ responses:
+ Copied:
+ description: Pet copied
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ schemas:
+ Pet:
+ type: object
+ properties:
+ name:
+ type: string
+ ItemFilter:
+ type: object
+ required: [category]
+ properties:
+ category:
+ type: string
diff --git a/test/unit/components/additional-operations.tsx b/test/unit/components/additional-operations.tsx
new file mode 100644
index 00000000000..be1481ee29e
--- /dev/null
+++ b/test/unit/components/additional-operations.tsx
@@ -0,0 +1,63 @@
+/**
+ * @prettier
+ */
+import React from "react"
+import { shallow } from "enzyme"
+import { fromJS, List } from "immutable"
+import Operation from "core/components/operation"
+import OperationSummaryMethod from "core/components/operation-summary-method"
+import { OperationLink } from "core/components/overview"
+
+describe("Additional operation presentation", () => {
+ it.each(["Search-Pets", "SEARCH", "search", "M-SEARCH"])(
+ "preserves the %s token in method labels",
+ (method) => {
+ expect(shallow(
).text()).toBe(
+ method
+ )
+ expect(
+ shallow(
)
+ .find("small")
+ .text()
+ ).toBe(method)
+ }
+ )
+
+ it("keeps the standard GET badge uppercase", () => {
+ expect(shallow(
).text()).toBe("GET")
+ })
+
+ it.each([false, true])("uses neutral styling when expanded=%s", (isShown) => {
+ const getComponent = jest.fn().mockReturnValue("div")
+ const getConfigs = jest.fn().mockReturnValue({})
+ const wrapper = shallow(
+
null,
+ validationErrors: () => List(),
+ }}
+ oas3Selectors={{ selectedServer: () => null }}
+ />
+ )
+
+ expect(wrapper.hasClass("opblock-custom")).toBe(true)
+ expect(wrapper.hasClass("is-open")).toBe(isShown)
+ expect(wrapper.hasClass("opblock-search-pets")).toBe(false)
+ })
+})
diff --git a/test/unit/core/plugins/oas3/state-integration.js b/test/unit/core/plugins/oas3/state-integration.js
index 6a262d193d6..0600b696b47 100644
--- a/test/unit/core/plugins/oas3/state-integration.js
+++ b/test/unit/core/plugins/oas3/state-integration.js
@@ -4,7 +4,8 @@ import {
selectedServer,
serverVariableValue,
serverVariables,
- serverEffectiveValue
+ serverEffectiveValue,
+ validateBeforeExecute
} from "core/plugins/oas3/selectors"
import reducers from "core/plugins/oas3/reducers"
@@ -301,7 +302,51 @@ describe("OAS3 plugin - state", function() {
expect(globalRes).toEqual("google.com/123")
})
})
+ describe("validateBeforeExecute", function() {
+ const pathMethod = ["/pets", "Search-Pets"]
+ const specPath = ["paths", "/pets", "additionalOperations", "Search-Pets"]
+ const withRequestBody = (requestBody) =>
+ fromJS({
+ paths: {
+ "/pets": {
+ additionalOperations: { "Search-Pets": { requestBody } },
+ },
+ },
+ })
+
+ const makeSystem = (resolvedRequestBody) => ({
+ getSystem() {
+ return {
+ specSelectors: {
+ operationSpecPath: () => specPath,
+ // raw document: requestBody is only a Reference Object
+ specJson: () =>
+ withRequestBody({ $ref: "#/components/requestBodies/Search" }),
+ specJsonWithResolvedSubtrees: () =>
+ withRequestBody(resolvedRequestBody),
+ },
+ }
+ },
+ })
+
+ const emptyBodyState = fromJS({
+ requestData: { "/pets": { "Search-Pets": { bodyValue: "" } } },
+ })
+
+ it("should fail when a $ref'd required request body is empty", function() {
+ const res = validateBeforeExecute(emptyBodyState, pathMethod)(
+ makeSystem({ required: true, content: {} })
+ )
+ expect(res).toEqual(false)
+ })
+ it("should pass when the resolved request body is optional", function() {
+ const res = validateBeforeExecute(emptyBodyState, pathMethod)(
+ makeSystem({ required: false, content: {} })
+ )
+ expect(res).toEqual(true)
+ })
+ })
})
describe("selectors", function() {
describe("serverEffectiveValue", function() {
diff --git a/test/unit/core/plugins/oas32/additional-path-items.test.ts b/test/unit/core/plugins/oas32/additional-path-items.test.ts
new file mode 100644
index 00000000000..6bcaff0c92d
--- /dev/null
+++ b/test/unit/core/plugins/oas32/additional-path-items.test.ts
@@ -0,0 +1,85 @@
+/**
+ * @prettier
+ */
+import { fromJS, List, Map } from "immutable"
+import {
+ callbacksOperations,
+ selectWebhooksOperations,
+ webhooks,
+} from "core/plugins/oas32/spec-extensions/wrap-selectors"
+import { validOperationMethods } from "core/plugins/oas32/selectors"
+
+describe("OAS 3.2 additional operations in nested Path Items", () => {
+ const pathItem = fromJS({
+ post: { summary: "Standard" },
+ additionalOperations: {
+ "Notify-Pet": { summary: "Custom" },
+ PUT: { summary: "Invalid duplicate of a fixed field" },
+ },
+ })
+ const system = {
+ getSystem: () => ({ specSelectors: { isOAS32: () => true } }),
+ oas32Selectors: { validOperationMethods },
+ specSelectors: {
+ specJsonWithResolvedSubtrees: () =>
+ Map({ webhooks: Map({ petChanged: pathItem }) }),
+ },
+ }
+
+ it("includes custom callback methods with the complete document path", () => {
+ const specPath = List(["paths", "/pets", "get", "callbacks"])
+ const callbacks = Map({
+ changed: Map({ "{$request.query.url}": pathItem }),
+ })
+ const result = callbacksOperations(jest.fn(), system)(Map(), {
+ callbacks,
+ specPath,
+ })
+
+ expect(result.changed.map((operation) => operation.method)).toEqual([
+ "post",
+ "Notify-Pet",
+ ])
+ expect(result.changed[1].specPath.toJS()).toEqual([
+ ...specPath,
+ "changed",
+ "{$request.query.url}",
+ "additionalOperations",
+ "Notify-Pet",
+ ])
+ })
+
+ it("includes custom webhook methods with the complete document path", () => {
+ expect(webhooks(jest.fn(), system)(Map()).has("petChanged")).toBe(true)
+ const result = selectWebhooksOperations(jest.fn(), system)(Map())
+
+ expect(result.petChanged.map((operation) => operation.method)).toEqual([
+ "post",
+ "Notify-Pet",
+ ])
+ expect(result.petChanged[1].specPath).toEqual([
+ "webhooks",
+ "petChanged",
+ "additionalOperations",
+ "Notify-Pet",
+ ])
+ })
+
+ it.each([callbacksOperations, selectWebhooksOperations])(
+ "delegates older specs to the original selector",
+ (wrapper) => {
+ const original = jest.fn(() => ({ original: true }))
+ const olderSystem = {
+ getSystem: () => ({ specSelectors: { isOAS32: () => false } }),
+ }
+
+ expect(
+ wrapper(original, olderSystem)(Map(), {
+ callbacks: Map(),
+ specPath: List(),
+ })
+ ).toEqual({ original: true })
+ expect(original).toHaveBeenCalled()
+ }
+ )
+})
diff --git a/test/unit/core/plugins/oas32/query-operation-rendering.test.js b/test/unit/core/plugins/oas32/query-operation-rendering.test.js
index e11c6539acc..4ec28aa254a 100644
--- a/test/unit/core/plugins/oas32/query-operation-rendering.test.js
+++ b/test/unit/core/plugins/oas32/query-operation-rendering.test.js
@@ -1,7 +1,7 @@
/**
* @prettier
*/
-import { Map, List } from "immutable"
+import { Map, List, fromJS } from "immutable"
import { validOperationMethods as validOperationMethodsWrapper } from "core/plugins/oas32/spec-extensions/wrap-selectors"
import { validOperationMethods as oas32ValidOperationMethods } from "core/plugins/oas32/selectors"
@@ -25,6 +25,9 @@ describe("OAS 3.2 QUERY operation rendering", () => {
isOAS32: jest.fn(() => true),
},
})),
+ specSelectors: {
+ specJsonWithResolvedSubtrees: jest.fn(() => Map()),
+ },
oas32Selectors: {
validOperationMethods: oas32ValidOperationMethods,
},
@@ -43,6 +46,83 @@ describe("OAS 3.2 QUERY operation rendering", () => {
expect(result.length).toBe(9) // 8 standard + query
})
+ it("should include custom additionalOperations methods for OAS 3.2 specs", () => {
+ const originalSelector = jest.fn(() => [
+ "get",
+ "put",
+ "post",
+ "delete",
+ "options",
+ "head",
+ "patch",
+ "trace",
+ ])
+
+ const system = {
+ getSystem: jest.fn(() => ({
+ specSelectors: {
+ isOAS32: jest.fn(() => true),
+ },
+ })),
+ specSelectors: {
+ specJsonWithResolvedSubtrees: jest.fn(() =>
+ fromJS({
+ paths: {
+ "/pets": {
+ additionalOperations: {
+ LIST: { summary: "List pets" },
+ SEARCH: { summary: "Search pets" },
+ },
+ },
+ },
+ })
+ ),
+ },
+ oas32Selectors: {
+ validOperationMethods: oas32ValidOperationMethods,
+ },
+ }
+
+ const wrappedSelector = validOperationMethodsWrapper(
+ originalSelector,
+ system
+ )
+ const state = Map()
+ const result = wrappedSelector(state)
+
+ expect(result).toContain("query")
+ expect(result).toContain("LIST")
+ expect(result).toContain("SEARCH")
+ })
+
+ it("should discover exact custom tokens from resolved path items", () => {
+ const system = {
+ getSystem: () => ({ specSelectors: { isOAS32: () => true } }),
+ specSelectors: {
+ specJson: () => Map(),
+ specJsonWithResolvedSubtrees: jest.fn(() =>
+ fromJS({
+ paths: {
+ "/pets": {
+ additionalOperations: { "Search-Pets": {} },
+ },
+ "/other": {
+ additionalOperations: { "Search-Pets": {} },
+ },
+ },
+ })
+ ),
+ },
+ oas32Selectors: { validOperationMethods: oas32ValidOperationMethods },
+ }
+ const result = validOperationMethodsWrapper(jest.fn(), system)(Map())
+
+ expect(result.filter((method) => method === "Search-Pets")).toEqual([
+ "Search-Pets",
+ ])
+ expect(result).not.toContain("search-pets")
+ })
+
it("should not include 'query' for non-OAS32 specs", () => {
const originalSelector = jest.fn(() => [
"get",
@@ -76,6 +156,19 @@ describe("OAS 3.2 QUERY operation rendering", () => {
expect(result).not.toContain("query")
expect(result.length).toBe(8)
})
+
+ it("returns the same array for unchanged paths (memoized)", () => {
+ const paths = fromJS({ "/pets": { additionalOperations: { LIST: {} } } })
+ const system = {
+ getSystem: () => ({ specSelectors: { isOAS32: () => true } }),
+ specSelectors: {
+ specJsonWithResolvedSubtrees: () => fromJS({}).set("paths", paths),
+ },
+ oas32Selectors: { validOperationMethods: oas32ValidOperationMethods },
+ }
+ const wrapped = validOperationMethodsWrapper(jest.fn(), system)
+ expect(wrapped(Map())).toBe(wrapped(Map()))
+ })
})
describe("integration test", () => {
@@ -94,20 +187,20 @@ describe("OAS 3.2 QUERY operation rendering", () => {
]
const operations = List([
- Map({
+ fromJS({
path: "/pets",
method: "get",
- operation: Map({ summary: "Get pets" }),
+ operation: fromJS({ summary: "Get pets" }),
}),
- Map({
+ fromJS({
path: "/pets",
method: "query",
- operation: Map({ summary: "Search pets" }),
+ operation: fromJS({ summary: "Search pets" }),
}),
- Map({
+ fromJS({
path: "/pets",
method: "post",
- operation: Map({ summary: "Create pet" }),
+ operation: fromJS({ summary: "Create pet" }),
}),
])
diff --git a/test/unit/core/plugins/oas32/spec-extensions/wrap-selectors.test.js b/test/unit/core/plugins/oas32/spec-extensions/wrap-selectors.test.js
index 6f336cabcdb..1f68c62df2a 100644
--- a/test/unit/core/plugins/oas32/spec-extensions/wrap-selectors.test.js
+++ b/test/unit/core/plugins/oas32/spec-extensions/wrap-selectors.test.js
@@ -31,6 +31,9 @@ describe("OAS32 wrap-selectors", () => {
]
const system = {
+ specSelectors: {
+ specJsonWithResolvedSubtrees: jest.fn(() => Map()),
+ },
getSystem: jest.fn(() => ({
specSelectors: {
isOAS32: jest.fn(() => true),
diff --git a/test/unit/core/plugins/spec/assets/petstore.json b/test/unit/core/plugins/spec/assets/petstore.json
index 5a8081a1b49..e3cbfa01196 100644
--- a/test/unit/core/plugins/spec/assets/petstore.json
+++ b/test/unit/core/plugins/spec/assets/petstore.json
@@ -1,1035 +1,889 @@
{
- "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": [
- "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,
- "minimum": 1,
- "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,
- "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"
- }
- },
- "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"
- }
- },
- "Category": {
- "type": "object",
- "properties": {
- "id": {
- "type": "integer",
- "format": "int64"
- },
- "name": {
- "type": "string"
- }
- },
- "xml": {
- "name": "Category"
- }
- },
- "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"
- }
+ "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": ["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,
+ "minimum": 1,
+ "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,
+ "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"
+ }
+ },
+ "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"
+ }
+ },
+ "Category": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "xml": {
+ "name": "Category"
+ }
+ },
+ "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"
+ }
}
diff --git a/test/unit/core/plugins/spec/selectors.js b/test/unit/core/plugins/spec/selectors.js
index 672c87baca0..d7b51d771c6 100644
--- a/test/unit/core/plugins/spec/selectors.js
+++ b/test/unit/core/plugins/spec/selectors.js
@@ -16,7 +16,8 @@ import {
taggedOperations,
isMediaTypeSchemaPropertiesEqual,
getOAS3RequiredRequestBodyContentType,
- validationErrors
+ validationErrors,
+ operationSpecPath,
} from "core/plugins/spec/selectors"
import Petstore from "./assets/petstore.json"
@@ -1054,6 +1055,60 @@ describe("consumesOptionsFor", function() {
})
})
describe("taggedOperations", function () {
+ it("should include OpenAPI 3.2 additionalOperations as first-class operations", function () {
+ const system = {
+ getConfigs: () => ({})
+ }
+ const state = fromJS({
+ json: {
+ openapi: "3.2.0",
+ paths: {
+ "/pets": {
+ get: {
+ tags: ["pets"],
+ summary: "List pets with query parameters"
+ },
+ additionalOperations: {
+ LIST: {
+ tags: ["pets"],
+ summary: "List pets with a request body",
+ requestBody: {
+ content: {
+ "application/json": {
+ schema: {
+ type: "object"
+ }
+ }
+ }
+ }
+ },
+ SEARCH: {
+ tags: ["pets"],
+ summary: "Search pets with a custom method"
+ }
+ }
+ }
+ }
+ }
+ })
+
+ const result = taggedOperations(state)(system)
+ const renderedOperations = result.getIn(["pets", "operations"])
+
+ expect(renderedOperations.map(op => op.get("method")).toJS()).toEqual([
+ "get",
+ "LIST",
+ "SEARCH",
+ ])
+ expect(renderedOperations.getIn([1, "specPath"]).toJS()).toEqual([
+ "paths",
+ "/pets",
+ "additionalOperations",
+ "LIST",
+ ])
+ expect(renderedOperations.getIn([1, "operation", "requestBody", "content", "application/json", "schema", "type"])).toBe("object")
+ })
+
it("should return a List of ad-hoc tagged operations", function () {
const system = {
getConfigs: () => ({})
@@ -1243,6 +1298,88 @@ describe("taggedOperations", function () {
describe("getOAS3RequiredRequestBodyContentType", () => {
const pathMethod = ["/test", "post"]
+ it("should use resolved subtree data for standard operations", () => {
+ const state = fromJS({
+ json: {
+ paths: {
+ "/test": {
+ post: {
+ requestBody: {
+ required: false,
+ content: {
+ "application/json": {
+ schema: {
+ required: ["fromJson"]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ resolvedSubtrees: {
+ paths: {
+ "/test": {
+ post: {
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ required: ["fromResolvedSubtree"]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+
+ expect(getOAS3RequiredRequestBodyContentType(state, pathMethod)).toEqual({
+ requestBody: true,
+ requestContentType: {
+ "application/json": ["fromResolvedSubtree"]
+ }
+ })
+ })
+
+ it("should read request body content from additionalOperations", () => {
+ const state = fromJS({
+ json: {
+ paths: {
+ "/test": {
+ additionalOperations: {
+ LIST: {
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": {
+ schema: {
+ required: ["filter"]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ })
+
+ const result = getOAS3RequiredRequestBodyContentType(state, ["/test", "LIST"])
+
+ expect(result).toEqual({
+ requestBody: true,
+ requestContentType: {
+ "application/json": ["filter"]
+ }
+ })
+ })
+
it("should return default requiredObj when requestBody is missing", () => {
const state = fromJS({
resolvedSubtrees: {
@@ -1593,3 +1730,35 @@ describe("validationErrors", function() {
])
})
})
+
+describe("additionalOperations fixed-field handling", () => {
+ const state = fromJS({
+ json: {
+ paths: {
+ "/p": {
+ post: { summary: "real" },
+ additionalOperations: {
+ post: { summary: "dup-lower" },
+ POST: { summary: "dup-upper" },
+ COPY: { summary: "custom" },
+ },
+ },
+ },
+ },
+ resolvedSubtrees: {},
+ })
+
+ it("resolves fixed-field methods to the direct path", () => {
+ expect(operationSpecPath(state, "/p", "post")).toEqual(["paths", "/p", "post"])
+ })
+
+ it("resolves custom methods to additionalOperations", () => {
+ expect(operationSpecPath(state, "/p", "COPY"))
+ .toEqual(["paths", "/p", "additionalOperations", "COPY"])
+ })
+
+ it("skips additionalOperations keys duplicating fixed fields in any case", () => {
+ const methods = operations(state).map((op) => op.get("method")).toJS()
+ expect(methods).toEqual(["post", "COPY"])
+ })
+})
diff --git a/test/unit/tsconfig.json b/test/unit/tsconfig.json
index 9474b15b7e2..73dcf2fb21a 100644
--- a/test/unit/tsconfig.json
+++ b/test/unit/tsconfig.json
@@ -18,11 +18,6 @@
"core/*": ["../../src/core/*"]
}
},
- "include": [
- "**/*.ts",
- "**/*.tsx"
- ],
- "exclude": [
- "node_modules"
- ]
+ "include": ["**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
}