diff --git a/.claude/skills/add-oas-support.md b/.claude/skills/add-oas-support.md index 0709b242d90..36062c30ab4 100644 --- a/.claude/skills/add-oas-support.md +++ b/.claude/skills/add-oas-support.md @@ -152,6 +152,13 @@ See [full mapping table with examples](#step-1d-map-specification-changes-to-swa 5. ❌ **Single quotes** → ✅ Use double quotes 6. ❌ **Skipping spec examples** → ✅ Use as test fixtures 7. ❌ **Hardcoded assumptions** → ✅ Verify everything in spec +8. ❌ **`props.Ori` in ComponentWrapper** → ✅ Use `props.originalComponent` (see Pattern 3) +9. ❌ **Copying entire Info component for minor version** → ✅ Use OpenAPIVersion wrapper + `getComponent("OAS{PREV}Info")` +10. ❌ **OAS{VERSION} logic in OAS{PREV} plugin** → ✅ Each version's logic lives in its own plugin +11. ❌ **`isOAS{PREV}` wrapper on minor version** → ✅ Only add if regex overlaps; minor bumps don't need it +12. ❌ **New HTTP method in core `validOperationMethods`** → ✅ Add to `OPERATION_METHODS` + wrap with `createOnlyOAS{VERSION}SelectorWrapper` +13. ❌ **Assuming new OAS meta-schema = new JSON Schema dialect** → ✅ Verify — OAS 3.2 still uses JSON Schema 2020-12 +14. ❌ **Inline version guard in wrap-components** → ✅ Always use `createOnlyOAS{VERSION}ComponentWrapper`; never write `(Original, system) => (props) => { if (...isOAS{VERSION}...) }` by hand — even expanding an existing guard (`isOAS31 || isOAS32`) is wrong; add dedicated wrap-components in the new plugin instead ### ✅ Pre-Submit Checklist @@ -268,11 +275,26 @@ export const createOnlyOAS{VERSION}SelectorWrapper = ``` ### Pattern 3: Component Wrappers + +**⚠️ IMPORTANT: `originalComponent` prop name, not `Ori`** + +`createOnlyOAS{VERSION}ComponentWrapper` passes the original component as `originalComponent` in props (NOT `Ori` — that's the OAS3/OAS30ComponentWrapFactory convention). Use `const { originalComponent: Ori } = props` to access it. + +**Pattern A — Reuse previous version's component via getComponent:** ```javascript const ComponentWrapper = createOnlyOAS{VERSION}ComponentWrapper(({ getSystem }) => { const system = getSystem() - const OAS{VERSION}Component = system.getComponent("OAS{VERSION}Component", true) - return + const OAS{PREV_VERSION}Component = system.getComponent("OAS{PREV_VERSION}Component", true) + return +}) +``` + +**Pattern B — Render original component with extra props (e.g. version badge):** +```javascript +// Use `originalComponent` (NOT `Ori`) — that's the prop name createOnlyOAS{VERSION}ComponentWrapper passes +const OpenAPIVersionWrapper = createOnlyOAS{VERSION}ComponentWrapper((props) => { + const { originalComponent: Ori } = props + return }) ``` @@ -839,15 +861,25 @@ export const createSystemSelector = } /** - * Creates a component wrapper that only renders for OAS {VERSION} + * Creates a component wrapper that only renders for OAS {VERSION}. + * When active, passes `originalComponent` (the unwrapped original) and + * `getSystem` as extra props. Access the original via: + * const { originalComponent: Ori } = props + * NOT via `props.Ori` — that's the OAS3/OAS30ComponentWrapFactory convention. */ export const createOnlyOAS{VERSION}ComponentWrapper = - (Component) => - ({ ...props }) => { - const { specSelectors } = props - const isOAS{VERSION} = specSelectors.isOAS{VERSION}() + (Component) => (Original, system) => (props) => { + if (system.specSelectors.isOAS{VERSION}()) { + return ( + + ) + } - return isOAS{VERSION} ? : props.Ori() + return } /** @@ -1008,6 +1040,8 @@ export const selectIsOAS{VERSION} = (state, system) => () => { **File:** `src/core/plugins/oas{VERSION_NUMBER}/spec-extensions/wrap-selectors.js` +**⚠️ Minor version: DON'T wrap `isOAS{PREV}` unless the regex actually matches both versions.** For example, OAS 3.1's `isOAS31` regex (`/^3\.1\./`) will never match `3.2.x`, so wrapping it to return `false` is dead code. Only add the `isOAS{PREV}` override if the previous version's detection regex would incorrectly match the new version. + **Template:** ```javascript /** @@ -1016,11 +1050,17 @@ export const selectIsOAS{VERSION} = (state, system) => () => { import { createOnlyOAS{VERSION}SelectorWrapper } from "../fn.js" -// Wrap previous version selectors if behavior changes -// Example: -// export const isOAS{PREVIOUS_VERSION} = createOnlyOAS{VERSION}SelectorWrapper((state) => () => false) +// Ensure OAS {VERSION} specs are recognized as OAS 3.x (needed when major version number didn't change) +export const isOAS3 = + (oriSelector, system) => + (state, ...args) => { + const isOAS{VERSION} = system.specSelectors.isOAS{VERSION}() + return isOAS{VERSION} || oriSelector(...args) + } -// This makes isOAS{PREVIOUS_VERSION} return false when spec is OAS {VERSION} +// ONLY add isOAS{PREV} wrapper if the previous version's regex could match this new version. +// For a minor version bump (e.g. 3.1 → 3.2), the previous regex won't match, so DON'T add this. +// export const isOAS{PREV} = createOnlyOAS{VERSION}SelectorWrapper((state) => () => false) ``` ### Step 6: Create Version Pragma Filter Component @@ -1135,23 +1175,51 @@ export default NewFeature **File:** `src/core/plugins/oas{VERSION_NUMBER}/wrap-components/info.jsx` -**Template:** +**⚠️ Minor version (e.g. 3.2): don't create a new Info component.** If the Info object is identical to the previous version, reuse `OAS{PREV_VERSION}Info` via `getComponent` instead of copying the whole component. Only create a new Info component for major versions with significant structural changes. + +**Template (minor version — reuse previous Info):** ```javascript /** * @prettier */ +import React from "react" import { createOnlyOAS{VERSION}ComponentWrapper } from "../fn.js" const InfoWrapper = createOnlyOAS{VERSION}ComponentWrapper(({ getSystem }) => { const system = getSystem() - const OAS{VERSION}Info = system.getComponent("OAS{VERSION}Info", true) - return + const OAS{PREV_VERSION}Info = system.getComponent("OAS{PREV_VERSION}Info", true) + return }) export default InfoWrapper ``` +**Also — OpenAPIVersion wrapper (minor version — change version badge only):** + +For minor versions where the only Info difference is the version badge, use the OpenAPIVersion wrapper pattern instead of wrapping InfoContainer at all: + +```javascript +// wrap-components/openapi-version.jsx +/** + * @prettier + */ +import React from "react" +import { createOnlyOAS{VERSION}ComponentWrapper } from "../fn.js" + +export default createOnlyOAS{VERSION}ComponentWrapper((props) => { + const { originalComponent: Ori } = props // NOT `Ori` from props directly — use `originalComponent` + return +}) +``` + +Then register it in index.js: +```javascript +wrapComponents: { + OpenAPIVersion: OpenAPIVersionWrapper, // changes the version badge shown in the Info header +} +``` + ### Step 9: Implement afterLoad Hook **File:** `src/core/plugins/oas{VERSION_NUMBER}/after-load.js` @@ -1531,11 +1599,23 @@ Closes #{ISSUE_NUMBER} 6. **Test with real specs** - Use actual OAS {VERSION} examples 7. **Don't modify core unnecessarily** - Use plugin architecture 8. **Version regex must be exact** - Follow pattern from OAS 3.1 -9. **Component wrappers return Ori()** - When not active version +9. **Component wrappers render ``** - When not active version (handled by `createOnlyOAS{VERSION}ComponentWrapper` automatically) 10. **afterLoad runs after all plugins** - Safe to modify system +11. **`originalComponent` not `Ori`** - `createOnlyOAS{VERSION}ComponentWrapper` passes the original as `originalComponent` prop. Use `const { originalComponent: Ori } = props`. The `Ori` name comes from `OAS30ComponentWrapFactory` which uses a different signature. +12. **Don't copy the entire Info component for minor versions** - Use the OpenAPIVersion wrapper to change the version badge, and reuse the previous version's Info via `getComponent("OAS{PREV}Info", true)` instead of copying. +13. **Don't add version-specific logic to previous version's plugin** - OAS32 logic belongs in the OAS32 plugin, not in OAS31 plugin files (e.g. don't add `isOAS32` checks to `oas31/wrap-components/license.jsx`). +20. **Always use `createOnlyOAS{VERSION}ComponentWrapper` in wrap-components — never inline the version guard** - Each wrap-component in a plugin should use the factory (`createOnlyOAS{VERSION}ComponentWrapper`), not a hand-written `(Original, system) => (props) => { if (system.specSelectors.isOAS{VERSION}()) ... }`. When a later version (OAS32) also needs to reuse the same OAS31 component, it gets its own dedicated wrap-component in the OAS32 plugin — don't expand the guard to `isOAS31 || isOAS32` in the OAS31 file. The OAS31 wrappers for contact/license should use `createOnlyOAS31ComponentWrapper` and nothing else; OAS32 contact/license wrappers live in `oas32/wrap-components/` and handle the OAS32 case. +14. **Don't add `isOAS{PREV}` wrap-selector unless the regex actually overlaps** - For minor versions, the previous version's regex already won't match (e.g. OAS31's `/^3\.1\./` won't match `3.2.x`). Adding the wrapper is dead code. +15. **New HTTP methods: use `OPERATION_METHODS` in `spec/selectors.js`, not `operationsWithRootInherited` wrapper** - Adding the method to `OPERATION_METHODS` makes the core `operations` selector collect those ops. The `validOperationMethods` wrapper (guarded by `createOnlyOAS{VERSION}SelectorWrapper`) then controls whether the UI renders them. No need for an `operationsWithRootInherited` wrapper. +16. **Don't add new HTTP methods to the core `validOperationMethods` constant** - Adding `"query"` to the base constant in `spec/selectors.js` affects ALL OAS versions. Instead, add it only via `createOnlyOAS{VERSION}SelectorWrapper` in your plugin's `spec-extensions/wrap-selectors.js`. +17. **Don't create selectors only used in tests** - Selectors like `selectHasQueryOperations` that are never called from production code should not exist. Remove them. +18. **OAS meta-schema URL ≠ new JSON Schema dialect** - The URL `https://spec.openapis.org/oas/3.2/schema/...` is the OAS 3.2 document structure schema, NOT a new JSON Schema version. OAS 3.2 uses JSON Schema 2020-12, the same as OAS 3.1. Don't list "new JSON Schema version" as a feature unless it actually changes. +19. **Verify "not yet implemented" feature list against previous version** - Features like `pathItems in Components` were already in OAS 3.1, not new in 3.2. Check what's actually new before listing it. ## JSON Schema Version Changes +**⚠️ First, verify whether the JSON Schema version actually changed.** The OAS meta-schema URL (e.g. `https://spec.openapis.org/oas/3.2/schema/...`) describes the OAS document structure — it is NOT a new JSON Schema dialect. OAS 3.2 uses JSON Schema 2020-12, the same as OAS 3.1. Only create a new `json-schema-{VERSION}` plugin if the actual JSON Schema dialect changed (as it did from OAS 3.0 Draft-07 → OAS 3.1 JSON Schema 2020-12). + If the new OAS version uses a different JSON Schema version: 1. **Create json-schema-{VERSION} plugin** (separate from OAS plugin) @@ -1574,16 +1654,24 @@ Example from OAS 3.1 using JSON Schema 2020-12: - Backward-compatible additions - New optional fields - Enhanced existing features -- Same JSON Schema version +- Same JSON Schema version (verify before assuming it changed) - Incremental improvements **Implementation approach:** - Lighter plugin with focused additions - Fewer component wrappers needed - Selective selector additions -- Reuse most of previous version logic +- Reuse most of previous version logic via `getComponent("OAS{PREV}...", true)` - Simpler afterLoad modifications +**Key decisions for minor versions (lessons from OAS 3.2):** + +1. **Version badge only changed?** Use `OpenAPIVersion` wrapper + reuse `OAS{PREV}Info` — don't create a new Info component. +2. **New HTTP method (e.g. QUERY)?** Add it to `OPERATION_METHODS` in `src/core/plugins/spec/selectors.js` AND add it to `validOperationMethods` via `createOnlyOAS{VERSION}SelectorWrapper`. Don't add it to the core `validOperationMethods` constant (affects all versions). +3. **`isOAS{PREV}` wrapper needed?** Only if the previous regex also matches the new version string. Minor version bumps (3.1 → 3.2) don't need it. +4. **JSON Schema version comment?** Verify it actually changed. OAS 3.2 uses JSON Schema 2020-12, the same as OAS 3.1. The new OAS meta-schema URL (`https://spec.openapis.org/oas/3.2/schema/...`) describes OAS document structure, not a new JSON Schema dialect. +5. **"Not yet implemented" list?** Double-check each item against the *previous* version's changelog — some may already be implemented. + ## Example: Adding OAS 4.0 (Major) **Version detection:** @@ -1632,16 +1720,56 @@ export const isOAS32 = (jsSpec) => { **Directory:** `src/core/plugins/oas32/` -**Extend OAS 3.1:** +**Lighter component wrapping — OAS 3.2 specific patterns:** + +```javascript +// wrap-components/openapi-version.jsx — change the version badge only +// Use `originalComponent` (NOT `Ori`) — that's what createOnlyOAS32ComponentWrapper passes +export default createOnlyOAS32ComponentWrapper((props) => { + const { originalComponent: Ori } = props + return +}) + +// wrap-components/info.jsx — reuse OAS31Info, don't create a new one +export default createOnlyOAS32ComponentWrapper(({ getSystem }) => { + const system = getSystem() + const OAS31Info = system.getComponent("OAS31Info", true) + return +}) + +// wrap-components/contact.jsx and license.jsx — same pattern (OAS32 logic belongs HERE, not in OAS31) +export default createOnlyOAS32ComponentWrapper((props) => { + const { getSystem } = props + const system = getSystem() + const OAS31Contact = system.getComponent("OAS31Contact", true) + return +}) +``` + +**New HTTP method (QUERY) — two-part approach:** + +Part 1: Add to `OPERATION_METHODS` in `src/core/plugins/spec/selectors.js` so the `operations` selector collects QUERY ops: +```javascript +// spec/selectors.js +export const OPERATION_METHODS = [ + "get", "put", "post", "delete", "options", "head", "patch", "trace", "query", +] +``` + +Part 2: Add to `validOperationMethods` via `createOnlyOAS32SelectorWrapper` so the UI only renders them for OAS 3.2: ```javascript -// oas31-extensions/fn.js -// Import functions from oas31 and extend as needed +// oas32/spec-extensions/wrap-selectors.js +export const validOperationMethods = createOnlyOAS32SelectorWrapper( + () => (oriSelector, system) => system.oas32Selectors.validOperationMethods() +) + +// oas32/selectors.js +export const validOperationMethods = () => [ + "get", "put", "post", "delete", "options", "head", "patch", "trace", "query", +] ``` -**Lighter component wrapping:** -- Only wrap components that change -- Reuse most OAS 3.1 logic -- Minimal selector additions +Do NOT add `"query"` to the core `validOperationMethods` constant — that would affect all versions. ## Final Checklist diff --git a/CLAUDE.md b/CLAUDE.md index f58b0b4673b..e7bc2e818e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md - Swagger UI Codebase Guide -> **Last Updated:** 2026-01-21 -> **Version:** 5.31.0 +> **Last Updated:** 2026-02-24 +> **Version:** 5.32.0 (in development) > **Purpose:** Comprehensive guide for AI assistants working with the Swagger UI codebase --- @@ -124,6 +124,7 @@ Swagger UI uses a **sophisticated plugin system** powered by Redux. The core sys - `logs` - Logging - `oas3` - OpenAPI 3.0.x support - `oas31` - OpenAPI 3.1.x support +- `oas32` - OpenAPI 3.2.x support - `on-complete` - Completion callbacks - `request-snippets` - Code snippet generation - `safe-render` - Safe component rendering @@ -571,6 +572,42 @@ Each plugin has: See documentation: `docs/customization/plugin-api.md` +### Cross-Plugin Import Guidelines + +**IMPORTANT:** Avoid cross-plugin imports to maintain plugin independence and modularity. + +**Pattern to Follow:** +- Each plugin should be self-contained with its own components, utilities, and functions +- When OAS version plugins (oas3, oas31, oas32) need similar functionality, create self-contained copies within each plugin +- Wrap components should import from their own plugin's components, not from other plugins + +**Example Structure:** +``` +src/core/plugins/oas32/ +├── json-schema-2020-12-extensions/ +│ ├── components/ # Self-contained components +│ │ └── keywords/ +│ │ ├── Description.jsx +│ │ └── Properties.jsx +│ ├── wrap-components/ # Wrappers for components +│ │ └── keywords/ +│ │ ├── Description.jsx # Imports from ../../components/ +│ │ └── Properties.jsx # Not from ../../../../oas31/ +│ └── fn.js # Self-contained utilities +``` + +**Why This Matters:** +- Prevents tight coupling between plugins +- Makes plugins easier to test in isolation +- Allows independent versioning and updates +- Reduces risk of breaking changes across plugins +- Improves code maintainability + +**Exceptions:** +- Shared core utilities in `src/core/utils/` are acceptable +- System-level functions in `src/core/system.js` are acceptable +- Base components in `src/core/components/` are acceptable + ### Preset System **Base Preset:** `src/core/presets/base.js` @@ -813,6 +850,7 @@ dist/ # Build output (generated) - OAS 2.0: Use `src/core/plugins/swagger-client/` - OAS 3.0.x: Use `src/core/plugins/oas3/` - OAS 3.1.x: Use `src/core/plugins/oas31/` +- OAS 3.2.x: Use `src/core/plugins/oas32/` **Adding Test Specs:** - Add to `test/e2e-cypress/static/documents/` @@ -839,6 +877,7 @@ dist/ # Build output (generated) 13. **Use the plugin architecture** - don't modify core unnecessarily 14. **Preserve backward compatibility** unless explicitly breaking 15. **Run full test suite before submitting PR** +16. **Keep plugins self-contained** - avoid cross-plugin imports (see [Cross-Plugin Import Guidelines](#cross-plugin-import-guidelines)) ### DON'Ts ❌ @@ -857,6 +896,7 @@ dist/ # Build output (generated) 13. **Don't ignore Cypress test failures** 14. **Don't add dependencies without justification** 15. **Don't break the build** - verify with `npm run build` +16. **Don't import from other plugins** - create self-contained copies instead (e.g., don't import from `oas31` in `oas32`) ### When Working with AI Assistants diff --git a/README.md b/README.md index c7f74defcfa..d538a7fc4d7 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The OpenAPI Specification has undergone 5 revisions since initial creation in 20 | Swagger UI Version | Release Date | OpenAPI Spec compatibility | Notes | |--------------------|--------------|-------------------------------------------------------------|-----------------------------------------------------------------------| +| 5.32.0 | TBD | 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, 3.1.0, 3.1.1, 3.1.2, 3.2.0 | [next release](https://github.com/swagger-api/swagger-ui/tree/master) | | 5.19.0 | 2025-02-17 | 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, 3.1.0, 3.1.1, 3.1.2 | [tag v5.19.0](https://github.com/swagger-api/swagger-ui/tree/v5.19.0) | | 5.0.0 | 2023-06-12 | 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.1.0 | [tag v5.0.0](https://github.com/swagger-api/swagger-ui/tree/v5.0.0) | | 4.0.0 | 2021-11-03 | 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3 | [tag v4.0.0](https://github.com/swagger-api/swagger-ui/tree/v4.0.0) | diff --git a/src/core/components/layouts/base.jsx b/src/core/components/layouts/base.jsx index efd61551c36..04807aa2ee7 100644 --- a/src/core/components/layouts/base.jsx +++ b/src/core/components/layouts/base.jsx @@ -34,6 +34,7 @@ export default class BaseLayout extends React.Component { const isSwagger2 = specSelectors.isSwagger2() const isOAS3 = specSelectors.isOAS3() const isOAS31 = specSelectors.isOAS31() + const isOAS32 = specSelectors.isOAS32() const isSpecEmpty = !specSelectors.specStr() @@ -100,6 +101,8 @@ export default class BaseLayout extends React.Component { } > diff --git a/src/core/plugins/oas31/components/version-pragma-filter.jsx b/src/core/plugins/oas31/components/version-pragma-filter.jsx index 58b7616aa41..eaf3594c2b9 100644 --- a/src/core/plugins/oas31/components/version-pragma-filter.jsx +++ b/src/core/plugins/oas31/components/version-pragma-filter.jsx @@ -26,9 +26,9 @@ const VersionPragmaFilter = ({ one of the fields.

- Supported version fields are swagger: "2.0" and - those that match openapi: 3.x.y (for example,{" "} - openapi: 3.1.0). + Supported version fields are swagger: "2.0",{" "} + openapi: 3.0.x, or openapi: 3.1.x (for + example, openapi: 3.1.0).

@@ -48,9 +48,9 @@ const VersionPragmaFilter = ({

Please indicate a valid Swagger or OpenAPI version field. - Supported version fields are swagger: "2.0" and - those that match openapi: 3.x.y (for example,{" "} - openapi: 3.1.0). + Supported version fields are swagger: "2.0",{" "} + openapi: 3.0.x, or openapi: 3.1.x (for + example, openapi: 3.1.0).

diff --git a/src/core/plugins/oas32/after-load.js b/src/core/plugins/oas32/after-load.js new file mode 100644 index 00000000000..e192f818c30 --- /dev/null +++ b/src/core/plugins/oas32/after-load.js @@ -0,0 +1,58 @@ +/** + * @prettier + */ +import { makeIsFileUploadIntended } from "./oas3-extensions/fn" +import { wrapOAS32Fn } from "./fn" +import { immutableToJS } from "core/utils" + +function afterLoad({ fn, getSystem }) { + // Wire up JSON Schema 2020-12 sample generation functions for OAS 3.2. + // OAS 3.2 uses the same JSON Schema 2020-12 dialect as OAS 3.1, so we point + // the system-level sample functions at the jsonSchema202012 implementations + // when the loaded spec is OAS 3.2. + if (typeof fn.sampleFromSchema === "function" && fn.jsonSchema202012) { + const wrappedFns = wrapOAS32Fn( + { + sampleFromSchema: fn.jsonSchema202012.sampleFromSchema, + sampleFromSchemaGeneric: fn.jsonSchema202012.sampleFromSchemaGeneric, + createXMLExample: fn.jsonSchema202012.createXMLExample, + memoizedSampleFromSchema: fn.jsonSchema202012.memoizedSampleFromSchema, + memoizedCreateXMLExample: fn.jsonSchema202012.memoizedCreateXMLExample, + getJsonSampleSchema: fn.jsonSchema202012.getJsonSampleSchema, + getYamlSampleSchema: fn.jsonSchema202012.getYamlSampleSchema, + getXmlSampleSchema: fn.jsonSchema202012.getXmlSampleSchema, + getSampleSchema: fn.jsonSchema202012.getSampleSchema, + mergeJsonSchema: fn.jsonSchema202012.mergeJsonSchema, + getSchemaObjectTypeLabel: (schema) => + fn.jsonSchema202012.getType(immutableToJS(schema)), + getSchemaObjectType: (schema) => + fn.jsonSchema202012.foldType(immutableToJS(schema)?.type), + }, + getSystem() + ) + + Object.assign(this.fn, wrappedFns) + } + + // Wrap file-upload detection for OAS 3.2 (supports contentMediaType / contentEncoding). + const isFileUploadIntended = makeIsFileUploadIntended(getSystem) + const { isFileUploadIntended: isFileUploadIntendedWrap } = wrapOAS32Fn( + { isFileUploadIntended }, + getSystem() + ) + + this.fn.isFileUploadIntended = isFileUploadIntendedWrap + this.fn.isFileUploadIntendedOAS32 = isFileUploadIntended + + // Wrap hasSchemaType for OAS 3.2. + if (fn.jsonSchema202012) { + const { hasSchemaType } = wrapOAS32Fn( + { hasSchemaType: fn.jsonSchema202012.hasSchemaType }, + getSystem() + ) + + this.fn.hasSchemaType = hasSchemaType + } +} + +export default afterLoad diff --git a/src/core/plugins/oas32/components/version-pragma-filter.jsx b/src/core/plugins/oas32/components/version-pragma-filter.jsx new file mode 100644 index 00000000000..245ea0b9e3f --- /dev/null +++ b/src/core/plugins/oas32/components/version-pragma-filter.jsx @@ -0,0 +1,80 @@ +/** + * @prettier + */ +import React from "react" +import PropTypes from "prop-types" + +const VersionPragmaFilter = ({ + bypass, + isSwagger2, + isOAS3, + isOAS31, + isOAS32, + alsoShow, + children, +}) => { + if (bypass) { + return
{children}
+ } + + if (isSwagger2 && (isOAS3 || isOAS31 || isOAS32)) { + return ( +
+ {alsoShow} +
+
+

Unable to render this definition

+

+ swagger and openapi fields cannot be + present in the same Swagger or OpenAPI definition. Please remove + one of the fields. +

+

+ Supported version fields are swagger: "2.0"{" "} + and openapi: 3.0.x, openapi: 3.1.x, or{" "} + openapi: 3.2.x (for example,{" "} + openapi: 3.2.0). +

+
+
+
+ ) + } + + if (!isSwagger2 && !isOAS3 && !isOAS31 && !isOAS32) { + return ( +
+ {alsoShow} +
+
+

Unable to render this definition

+

+ The provided definition does not specify a valid version field. +

+

+ Please indicate a valid Swagger or OpenAPI version field. + Supported version fields are swagger: "2.0"{" "} + and openapi: 3.0.x, openapi: 3.1.x, or{" "} + openapi: 3.2.x (for example,{" "} + openapi: 3.2.0). +

+
+
+
+ ) + } + + return
{children}
+} + +VersionPragmaFilter.propTypes = { + isSwagger2: PropTypes.bool.isRequired, + isOAS3: PropTypes.bool.isRequired, + isOAS31: PropTypes.bool.isRequired, + isOAS32: PropTypes.bool.isRequired, + bypass: PropTypes.bool, + alsoShow: PropTypes.element, + children: PropTypes.any, +} + +export default VersionPragmaFilter diff --git a/src/core/plugins/oas32/fn.js b/src/core/plugins/oas32/fn.js new file mode 100644 index 00000000000..200efbe5b7e --- /dev/null +++ b/src/core/plugins/oas32/fn.js @@ -0,0 +1,124 @@ +/** + * @prettier + */ +import React from "react" + +export const isOAS32 = (jsSpec) => { + const oasVersion = jsSpec.get("openapi") + + return ( + typeof oasVersion === "string" && /^3\.2\.(?:[1-9]\d*|0)$/.test(oasVersion) + ) +} + +/** + * Creates selector that returns value of the passed + * selector when spec is OpenAPI 3.2.x, null otherwise. + * + * @param selector + * @returns {function(*, ...[*]): function(*): (*|null)} + */ +export const createOnlyOAS32Selector = + (selector) => + (state, ...args) => + (system) => { + if (system.getSystem().specSelectors.isOAS32()) { + const selectedValue = selector(state, ...args) + return typeof selectedValue === "function" + ? selectedValue(system) + : selectedValue + } else { + return null + } + } + +/** + * Creates selector wrapper that returns value of the passed + * selector when spec is OpenAPI 3.2.x, calls original selector otherwise. + * + * + * @param selector + * @returns {function(*, *): function(*, ...[*]): (*)} + */ +export const createOnlyOAS32SelectorWrapper = + (selector) => + (oriSelector, system) => + (state, ...args) => { + if (system.getSystem().specSelectors.isOAS32()) { + const selectedValue = selector(state, ...args) + return typeof selectedValue === "function" + ? selectedValue(oriSelector, system) + : selectedValue + } else { + return oriSelector(...args) + } + } + +/** + * Creates selector that provides system as the + * second argument. This allows to create memoized + * composed selectors from different plugins. + * + * @param selector + * @returns {function(*, ...[*]): function(*): *} + */ +export const createSystemSelector = + (selector) => + (state, ...args) => + (system) => { + const selectedValue = selector(state, system, ...args) + return typeof selectedValue === "function" + ? selectedValue(system) + : selectedValue + } + +/* eslint-disable react/jsx-filename-extension */ +/** + * Creates component wrapper that only wraps the component + * when spec is OpenAPI 3.2.x. Otherwise, returns original + * component with passed props. + * + * @param Component + * @returns {function(*, *): function(*): *} + */ +export const createOnlyOAS32ComponentWrapper = + (Component) => (Original, system) => (props) => { + if (system.specSelectors.isOAS32()) { + return ( + + ) + } + + return + } +/* eslint-enable react/jsx-filename-extension */ + +/** + * Runs the fn replacement implementation when spec is OpenAPI 3.2.x. + * Runs the fn original implementation otherwise. + * + * @param fn + * @param system + * @returns {{[p: string]: function(...[*]): *}} + */ +export const wrapOAS32Fn = (fn, system) => { + const { fn: systemFn, specSelectors } = system + + return Object.fromEntries( + Object.entries(fn).map(([name, newImpl]) => { + const oriImpl = systemFn[name] + const impl = (...args) => + specSelectors.isOAS32() + ? newImpl(...args) + : typeof oriImpl === "function" + ? oriImpl(...args) + : undefined + + return [name, impl] + }) + ) +} diff --git a/src/core/plugins/oas32/index.js b/src/core/plugins/oas32/index.js new file mode 100644 index 00000000000..73fe1a6c36d --- /dev/null +++ b/src/core/plugins/oas32/index.js @@ -0,0 +1,130 @@ +/** + * @prettier + */ +import VersionPragmaFilter from "./components/version-pragma-filter" +import ContactWrapper from "./wrap-components/contact" +import InfoWrapper from "./wrap-components/info" +import LicenseWrapper from "./wrap-components/license" +import ModelWrapper from "./wrap-components/model" +import ModelsWrapper from "./wrap-components/models" +import OpenAPIVersionWrapper from "./wrap-components/openapi-version" +import VersionPragmaFilterWrapper from "./wrap-components/version-pragma-filter" +import JSONSchema202012KeywordDescriptionWrapper from "./json-schema-2020-12-extensions/wrap-components/keywords/Description" +import JSONSchema202012KeywordExamplesWrapper from "./json-schema-2020-12-extensions/wrap-components/keywords/Examples" +import JSONSchema202012KeywordPropertiesWrapper from "./json-schema-2020-12-extensions/wrap-components/keywords/Properties" +import { + isOAS32 as isOAS32Fn, + createOnlyOAS32Selector as createOnlyOAS32SelectorFn, + createSystemSelector as createSystemSelectorFn, +} from "./fn" +import { validOperationMethods } from "./selectors" +import { + isOAS3 as isOAS3SelectorWrapper, + validOperationMethods as validOperationMethodsWrapper, +} from "./spec-extensions/wrap-selectors" +import { + license as selectLicense, + contact as selectContact, + selectIsOAS32, + selectLicenseNameField, + selectLicenseUrlField, + selectLicenseIdentifierField, + selectContactNameField, + selectContactEmailField, + selectContactUrlField, + selectContactUrl, + selectLicenseUrl, + selectInfoSummaryField, +} from "./spec-extensions/selectors" +import afterLoad from "./after-load" + +/** + * OpenAPI 3.2 Plugin + * + * Adds support for OpenAPI Specification 3.2.x + * + * This plugin should be loaded AFTER: + * - oas31 plugin + * - json-schema-2020-12 plugin (uses same as OAS 3.1) + * + * It wraps and overrides components/selectors from previous versions. + * + * New features in OAS 3.2 (basic implementation): + * - query operation: QUERY HTTP method 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 + * - itemSchema for streaming responses + */ +const OAS32Plugin = ({ fn }) => { + const createSystemSelector = fn.createSystemSelector || createSystemSelectorFn + + const plugin = { + afterLoad, + fn: { + isOAS32: isOAS32Fn, + createSystemSelector: createSystemSelectorFn, + createOnlyOAS32Selector: createOnlyOAS32SelectorFn, + }, + components: { + OAS32VersionPragmaFilter: VersionPragmaFilter, + }, + wrapComponents: { + Contact: ContactWrapper, + InfoContainer: InfoWrapper, + License: LicenseWrapper, + Model: ModelWrapper, + Models: ModelsWrapper, + OpenAPIVersion: OpenAPIVersionWrapper, + VersionPragmaFilter: VersionPragmaFilterWrapper, + JSONSchema202012KeywordDescription: + JSONSchema202012KeywordDescriptionWrapper, + JSONSchema202012KeywordExamples: JSONSchema202012KeywordExamplesWrapper, + JSONSchema202012KeywordProperties: + JSONSchema202012KeywordPropertiesWrapper, + }, + statePlugins: { + spec: { + selectors: { + isOAS32: createSystemSelector(selectIsOAS32), + + // Info selectors (inherited from OAS31) + selectInfoSummaryField, + + // License and contact selectors (inherited from OAS31) + license: selectLicense, + selectLicenseNameField, + selectLicenseUrlField, + selectLicenseIdentifierField, + selectLicenseUrl: createSystemSelector(selectLicenseUrl), + + contact: selectContact, + selectContactNameField, + selectContactEmailField, + selectContactUrlField, + selectContactUrl: createSystemSelector(selectContactUrl), + }, + 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 + validOperationMethods: validOperationMethodsWrapper, + }, + }, + oas32: { + selectors: { + validOperationMethods, + }, + }, + }, + } + + return plugin +} + +export default OAS32Plugin diff --git a/src/core/plugins/oas32/json-schema-2020-12-extensions/components/keywords/Description.jsx b/src/core/plugins/oas32/json-schema-2020-12-extensions/components/keywords/Description.jsx new file mode 100644 index 00000000000..36c171a4eac --- /dev/null +++ b/src/core/plugins/oas32/json-schema-2020-12-extensions/components/keywords/Description.jsx @@ -0,0 +1,27 @@ +/** + * @prettier + */ +import React from "react" +import PropTypes from "prop-types" + +const Description = ({ schema, getSystem }) => { + if (!schema?.description) return null + + const { getComponent } = getSystem() + const MarkDown = getComponent("Markdown") + + return ( +
+
+ +
+
+ ) +} + +Description.propTypes = { + schema: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired, + getSystem: PropTypes.func.isRequired, +} + +export default Description diff --git a/src/core/plugins/oas32/json-schema-2020-12-extensions/components/keywords/Properties.jsx b/src/core/plugins/oas32/json-schema-2020-12-extensions/components/keywords/Properties.jsx new file mode 100644 index 00000000000..733b8dd8c3d --- /dev/null +++ b/src/core/plugins/oas32/json-schema-2020-12-extensions/components/keywords/Properties.jsx @@ -0,0 +1,60 @@ +/** + * @prettier + */ +import React from "react" +import PropTypes from "prop-types" +import classNames from "classnames" + +const Properties = ({ schema, getSystem }) => { + const { fn, getComponent } = getSystem() + const { useComponent, usePath } = fn.jsonSchema202012 + const { getDependentRequired, getProperties } = fn.jsonSchema202012.useFn() + const config = fn.jsonSchema202012.useConfig() + const required = Array.isArray(schema?.required) ? schema.required : [] + const { path } = usePath("properties") + const JSONSchema = useComponent("JSONSchema") + const JSONSchemaPathContext = getComponent("JSONSchema202012PathContext")() + const properties = getProperties(schema, config) + + /** + * Rendering. + */ + if (Object.keys(properties).length === 0) { + return null + } + + return ( + +
+
    + {Object.entries(properties).map(([propertyName, propertySchema]) => { + const isRequired = required.includes(propertyName) + const dependentRequired = getDependentRequired(propertyName, schema) + + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +} + +Properties.propTypes = { + schema: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]).isRequired, + getSystem: PropTypes.func.isRequired, +} + +export default Properties diff --git a/src/core/plugins/oas32/json-schema-2020-12-extensions/fn.js b/src/core/plugins/oas32/json-schema-2020-12-extensions/fn.js new file mode 100644 index 00000000000..48e2e927a73 --- /dev/null +++ b/src/core/plugins/oas32/json-schema-2020-12-extensions/fn.js @@ -0,0 +1,20 @@ +/** + * @prettier + */ +export const makeGetSchemaKeywords = (original) => { + if (typeof original !== "function") { + return null + } + + const jsonSchema202012Keywords = original() + + return () => [ + ...jsonSchema202012Keywords, + "discriminator", + "xml", + "externalDocs", + "example", + // $$ref is an internal keyword used for dereferencing + "$$ref", + ] +} diff --git a/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Description.jsx b/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Description.jsx new file mode 100644 index 00000000000..7b864a9597d --- /dev/null +++ b/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Description.jsx @@ -0,0 +1,9 @@ +/** + * @prettier + */ +import DescriptionKeyword from "../../components/keywords/Description" +import { createOnlyOAS32ComponentWrapper } from "../../../fn" + +const DescriptionWrapper = createOnlyOAS32ComponentWrapper(DescriptionKeyword) + +export default DescriptionWrapper diff --git a/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Examples.jsx b/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Examples.jsx new file mode 100644 index 00000000000..90a9e94d0af --- /dev/null +++ b/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Examples.jsx @@ -0,0 +1,31 @@ +/** + * @prettier + */ +import React from "react" +import { createOnlyOAS32ComponentWrapper } from "../../../fn" + +const ExamplesWrapper = createOnlyOAS32ComponentWrapper( + ({ schema, getSystem, originalComponent: KeywordExamples }) => { + const { getComponent } = getSystem() + const KeywordDiscriminator = getComponent( + "JSONSchema202012KeywordDiscriminator" + ) + const KeywordXml = getComponent("JSONSchema202012KeywordXml") + const KeywordExample = getComponent("JSONSchema202012KeywordExample") + const KeywordExternalDocs = getComponent( + "JSONSchema202012KeywordExternalDocs" + ) + + return ( + <> + + + + + + + ) + } +) + +export default ExamplesWrapper diff --git a/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Properties.jsx b/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Properties.jsx new file mode 100644 index 00000000000..f48b2c859b8 --- /dev/null +++ b/src/core/plugins/oas32/json-schema-2020-12-extensions/wrap-components/keywords/Properties.jsx @@ -0,0 +1,9 @@ +/** + * @prettier + */ +import PropertiesKeyword from "../../components/keywords/Properties" +import { createOnlyOAS32ComponentWrapper } from "../../../fn" + +const PropertiesWrapper = createOnlyOAS32ComponentWrapper(PropertiesKeyword) + +export default PropertiesWrapper diff --git a/src/core/plugins/oas32/oas3-extensions/fn.js b/src/core/plugins/oas32/oas3-extensions/fn.js new file mode 100644 index 00000000000..db7d801704a --- /dev/null +++ b/src/core/plugins/oas32/oas3-extensions/fn.js @@ -0,0 +1,46 @@ +/** + * @prettier + */ +import { Map } from "immutable" +import isPlainObject from "lodash/isPlainObject" + +export const makeIsFileUploadIntended = (getSystem) => { + const isFileUploadIntended = (schema, mediaType = null) => { + const { fn } = getSystem() + + /** + * Return `true` early if the media type indicates a file upload + * or if a combination of type: `string` and format: `binary/byte` is detected. + * This ensures support for empty Media Type Objects, + * as the schema check is performed later. + */ + const isFileUploadIntendedOAS30 = fn.isFileUploadIntendedOAS30( + schema, + mediaType + ) + + if (isFileUploadIntendedOAS30) { + return true + } + + const isSchemaImmutable = Map.isMap(schema) + + if (!isSchemaImmutable && !isPlainObject(schema)) { + return false + } + + const contentMediaType = isSchemaImmutable + ? schema.get("contentMediaType") + : schema.contentMediaType + const contentEncoding = isSchemaImmutable + ? schema.get("contentEncoding") + : schema.contentEncoding + + return ( + (typeof contentMediaType === "string" && contentMediaType !== "") || + (typeof contentEncoding === "string" && contentEncoding !== "") + ) + } + + return isFileUploadIntended +} diff --git a/src/core/plugins/oas32/selectors.js b/src/core/plugins/oas32/selectors.js new file mode 100644 index 00000000000..59fbd4d7068 --- /dev/null +++ b/src/core/plugins/oas32/selectors.js @@ -0,0 +1,24 @@ +/** + * @prettier + */ +import constant from "lodash/constant" + +/** + * Valid HTTP operation methods for OAS 3.2.x + * + * OAS 3.2.0 adds support for the QUERY HTTP method per + * draft-ietf-httpbis-safe-method-w-body + * + * Reference: https://spec.openapis.org/oas/v3.2.0.html#path-item-object + */ +export const validOperationMethods = constant([ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query", // NEW in OAS 3.2 +]) diff --git a/src/core/plugins/oas32/spec-extensions/selectors.js b/src/core/plugins/oas32/spec-extensions/selectors.js new file mode 100644 index 00000000000..2eee9207143 --- /dev/null +++ b/src/core/plugins/oas32/spec-extensions/selectors.js @@ -0,0 +1,86 @@ +/** + * @prettier + */ +import { Map } from "immutable" +import { createSelector } from "reselect" + +import { safeBuildUrl } from "core/utils/url" +import { isOAS32 } from "../fn" + +const map = Map() + +/** + * Detects if the current spec is OAS 3.2.x + */ +export const selectIsOAS32 = (state, system) => () => { + const spec = system.specSelectors.specJson() + return isOAS32(spec) +} + +export const license = () => (system) => { + const license = system.specSelectors.info().get("license") + return Map.isMap(license) ? license : map +} + +export const selectLicenseNameField = () => (system) => { + return system.specSelectors.license().get("name", "License") +} + +export const selectLicenseUrlField = () => (system) => { + return system.specSelectors.license().get("url") +} + +export const selectLicenseUrl = createSelector( + [ + (state, system) => system.specSelectors.url(), + (state, system) => system.oas3Selectors.selectedServer(), + (state, system) => system.specSelectors.selectLicenseUrlField(), + ], + (specUrl, selectedServer, url) => { + if (url) { + return safeBuildUrl(url, specUrl, { selectedServer }) + } + + return undefined + } +) + +export const selectLicenseIdentifierField = () => (system) => { + return system.specSelectors.license().get("identifier") +} + +export const contact = () => (system) => { + const contact = system.specSelectors.info().get("contact") + return Map.isMap(contact) ? contact : map +} + +export const selectContactNameField = () => (system) => { + return system.specSelectors.contact().get("name", "the developer") +} + +export const selectContactEmailField = () => (system) => { + return system.specSelectors.contact().get("email") +} + +export const selectContactUrlField = () => (system) => { + return system.specSelectors.contact().get("url") +} + +export const selectContactUrl = createSelector( + [ + (state, system) => system.specSelectors.url(), + (state, system) => system.oas3Selectors.selectedServer(), + (state, system) => system.specSelectors.selectContactUrlField(), + ], + (specUrl, selectedServer, url) => { + if (url) { + return safeBuildUrl(url, specUrl, { selectedServer }) + } + + return undefined + } +) + +export const selectInfoSummaryField = () => (system) => { + return system.specSelectors.info().get("summary") +} diff --git a/src/core/plugins/oas32/spec-extensions/wrap-selectors.js b/src/core/plugins/oas32/spec-extensions/wrap-selectors.js new file mode 100644 index 00000000000..da9e4a82438 --- /dev/null +++ b/src/core/plugins/oas32/spec-extensions/wrap-selectors.js @@ -0,0 +1,27 @@ +/** + * @prettier + */ +import { createOnlyOAS32SelectorWrapper } from "../fn" + +/** + * Wraps isOAS3 selector to return true when spec is OAS 3.2.x + * This ensures OAS 3.2 specs are recognized as OAS 3.x for + * OAS3-specific features like servers, security, etc. + */ +export const isOAS3 = + (oriSelector, system) => + (state, ...args) => { + const isOAS32 = system.specSelectors.isOAS32() + return isOAS32 || oriSelector(...args) + } + +/** + * 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 + * + * Reference: https://spec.openapis.org/oas/v3.2.0.html#path-item-object + */ +export const validOperationMethods = createOnlyOAS32SelectorWrapper( + () => (oriSelector, system) => system.oas32Selectors.validOperationMethods() +) diff --git a/src/core/plugins/oas32/wrap-components/contact.jsx b/src/core/plugins/oas32/wrap-components/contact.jsx new file mode 100644 index 00000000000..2a16e213eda --- /dev/null +++ b/src/core/plugins/oas32/wrap-components/contact.jsx @@ -0,0 +1,13 @@ +/** + * @prettier + */ +import React from "react" + +import { createOnlyOAS32ComponentWrapper } from "../fn" + +export default createOnlyOAS32ComponentWrapper((props) => { + const { getSystem } = props + const system = getSystem() + const OAS31Contact = system.getComponent("OAS31Contact", true) + return +}) diff --git a/src/core/plugins/oas32/wrap-components/info.jsx b/src/core/plugins/oas32/wrap-components/info.jsx new file mode 100644 index 00000000000..e6d54ce3df1 --- /dev/null +++ b/src/core/plugins/oas32/wrap-components/info.jsx @@ -0,0 +1,15 @@ +/** + * @prettier + */ +import React from "react" + +import { createOnlyOAS32ComponentWrapper } from "../fn" + +const InfoWrapper = createOnlyOAS32ComponentWrapper(({ getSystem }) => { + const system = getSystem() + const OAS31Info = system.getComponent("OAS31Info", true) + + return +}) + +export default InfoWrapper diff --git a/src/core/plugins/oas32/wrap-components/license.jsx b/src/core/plugins/oas32/wrap-components/license.jsx new file mode 100644 index 00000000000..246e004d73a --- /dev/null +++ b/src/core/plugins/oas32/wrap-components/license.jsx @@ -0,0 +1,13 @@ +/** + * @prettier + */ +import React from "react" + +import { createOnlyOAS32ComponentWrapper } from "../fn" + +export default createOnlyOAS32ComponentWrapper((props) => { + const { getSystem } = props + const system = getSystem() + const OAS31License = system.getComponent("OAS31License", true) + return +}) diff --git a/src/core/plugins/oas32/wrap-components/model.jsx b/src/core/plugins/oas32/wrap-components/model.jsx new file mode 100644 index 00000000000..c4ca0416ec6 --- /dev/null +++ b/src/core/plugins/oas32/wrap-components/model.jsx @@ -0,0 +1,44 @@ +/** + * @prettier + */ +import React from "react" + +import { createOnlyOAS32ComponentWrapper } from "../fn" +import { makeGetSchemaKeywords } from "../json-schema-2020-12-extensions/fn" + +const ModelWrapper = createOnlyOAS32ComponentWrapper( + ({ getSystem, ...props }) => { + const system = getSystem() + const { getComponent, fn, getConfigs } = system + const configs = getConfigs() + + const Model = getComponent("OAS31Model") + const withJSONSchemaSystemContext = getComponent( + "withJSONSchema202012SystemContext" + ) + + // we cache the HOC as recreating it with every re-render is quite expensive + ModelWrapper.ModelWithJSONSchemaContext ??= withJSONSchemaSystemContext( + Model, + { + config: { + default$schema: "https://spec.openapis.org/oas/3.2/schema/2025-09-17", + defaultExpandedLevels: configs.defaultModelExpandDepth, + includeReadOnly: props.includeReadOnly, + includeWriteOnly: props.includeWriteOnly, + }, + fn: { + getProperties: fn.jsonSchema202012.getProperties, + isExpandable: fn.jsonSchema202012.isExpandable, + getSchemaKeywords: makeGetSchemaKeywords( + fn.jsonSchema202012.getSchemaKeywords + ), + }, + } + ) + + return + } +) + +export default ModelWrapper diff --git a/src/core/plugins/oas32/wrap-components/models.jsx b/src/core/plugins/oas32/wrap-components/models.jsx new file mode 100644 index 00000000000..1a364c76eec --- /dev/null +++ b/src/core/plugins/oas32/wrap-components/models.jsx @@ -0,0 +1,47 @@ +/** + * @prettier + */ +import React from "react" + +import { createOnlyOAS32ComponentWrapper } from "../fn" +import { makeGetSchemaKeywords } from "../json-schema-2020-12-extensions/fn" + +const ModelsWrapper = createOnlyOAS32ComponentWrapper(({ getSystem }) => { + const { getComponent, fn, getConfigs } = getSystem() + const configs = getConfigs() + + if (ModelsWrapper.ModelsWithJSONSchemaContext) { + return + } + + const Models = getComponent("OAS31Models", true) + const withJSONSchemaSystemContext = getComponent( + "withJSONSchema202012SystemContext" + ) + + // we cache the HOC as recreating it with every re-render is quite expensive + ModelsWrapper.ModelsWithJSONSchemaContext ??= withJSONSchemaSystemContext( + Models, + { + config: { + default$schema: "https://spec.openapis.org/oas/3.2/schema/2025-09-17", + defaultExpandedLevels: configs.defaultModelsExpandDepth - 1, + includeReadOnly: true, + includeWriteOnly: true, + }, + fn: { + getProperties: fn.jsonSchema202012.getProperties, + isExpandable: fn.jsonSchema202012.isExpandable, + getSchemaKeywords: makeGetSchemaKeywords( + fn.jsonSchema202012.getSchemaKeywords + ), + }, + } + ) + + return +}) + +ModelsWrapper.ModelsWithJSONSchemaContext = null + +export default ModelsWrapper diff --git a/src/core/plugins/oas32/wrap-components/openapi-version.jsx b/src/core/plugins/oas32/wrap-components/openapi-version.jsx new file mode 100644 index 00000000000..43cd423aadb --- /dev/null +++ b/src/core/plugins/oas32/wrap-components/openapi-version.jsx @@ -0,0 +1,11 @@ +/** + * @prettier + */ +import React from "react" + +import { createOnlyOAS32ComponentWrapper } from "../fn" + +export default createOnlyOAS32ComponentWrapper((props) => { + const { originalComponent: Ori } = props + return +}) diff --git a/src/core/plugins/oas32/wrap-components/version-pragma-filter.jsx b/src/core/plugins/oas32/wrap-components/version-pragma-filter.jsx new file mode 100644 index 00000000000..1b1c83501b8 --- /dev/null +++ b/src/core/plugins/oas32/wrap-components/version-pragma-filter.jsx @@ -0,0 +1,16 @@ +/** + * @prettier + */ +import React from "react" + +const VersionPragmaFilterWrapper = (Original, system) => (props) => { + const isOAS32 = system.specSelectors.isOAS32() + + const OAS32VersionPragmaFilter = system.getComponent( + "OAS32VersionPragmaFilter" + ) + + return +} + +export default VersionPragmaFilterWrapper diff --git a/src/core/plugins/spec/selectors.js b/src/core/plugins/spec/selectors.js index 3a0d7f4857a..ada3b9ba8b8 100644 --- a/src/core/plugins/spec/selectors.js +++ b/src/core/plugins/spec/selectors.js @@ -6,7 +6,7 @@ import { fromJS, Set, Map, OrderedMap, List } from "immutable" const DEFAULT_TAG = "default" const OPERATION_METHODS = [ - "get", "put", "post", "delete", "options", "head", "patch", "trace" + "get", "put", "post", "delete", "options", "head", "patch", "trace", "query" ] const state = state => { diff --git a/src/core/presets/apis/index.js b/src/core/presets/apis/index.js index 74d0e8022d4..239526f29b7 100644 --- a/src/core/presets/apis/index.js +++ b/src/core/presets/apis/index.js @@ -4,6 +4,7 @@ import BasePreset from "core/presets/base" import OpenAPI30Plugin from "core/plugins/oas3" import OpenAPI31Plugin from "core/plugins/oas31" +import OpenAPI32Plugin from "core/plugins/oas32" import JSONSchema202012Plugin from "core/plugins/json-schema-2020-12" import JSONSchema202012SamplesPlugin from "core/plugins/json-schema-2020-12-samples" @@ -14,5 +15,6 @@ export default function PresetApis() { JSONSchema202012Plugin, JSONSchema202012SamplesPlugin, OpenAPI31Plugin, + OpenAPI32Plugin, // Load LAST to override previous versions ] } diff --git a/src/style/_dark-mode.scss b/src/style/_dark-mode.scss index ffa453fdbd3..397cecada66 100644 --- a/src/style/_dark-mode.scss +++ b/src/style/_dark-mode.scss @@ -1,41 +1,87 @@ @use "sass:list"; // Variables for consistent theme -$neutral-10: #F0F1F1; -$neutral-20: #E4E6E6; -$neutral-30: #D2D6D7; -$neutral-40: #B7BCBF; -$neutral-50: #8C969A; -$neutral-60: #6B757A; -$neutral-80: #545D61; -$neutral-85: #434B4F; -$neutral-95: #2A2E30; -$neutral-98: #1C2022; -$neutral-100: #080A0B; - -$success-30: #4AC966; -$error-30: #FF5F5F; - -$authorize-button: #3ECE90; -$link: #51A8FF; -$markdown-code: #B68AE1; -$textarea: #0D1014; +$neutral-10: #f0f1f1; +$neutral-20: #e4e6e6; +$neutral-30: #d2d6d7; +$neutral-40: #b7bcbf; +$neutral-50: #8c969a; +$neutral-60: #6b757a; +$neutral-80: #545d61; +$neutral-85: #434b4f; +$neutral-95: #2a2e30; +$neutral-98: #1c2022; +$neutral-100: #080a0b; + +$success-30: #4ac966; +$error-30: #ff5f5f; + +$authorize-button: #3ece90; +$link: #51a8ff; +$markdown-code: #b68ae1; +$textarea: #0d1014; $textarea-disabled: #202225; -$info-version-stamp: #1D632E; -$curl-command-button: #3B424D; -$curl-command-button-text: #EBEBEB; -$json-schema-2020-12__attribute--primary: #9898FF; -$json-schema-2020-12__constraint--string: #D4AA53; +$info-version-stamp: #1d632e; +$curl-command-button: #3b424d; +$curl-command-button-text: #ebebeb; +$json-schema-2020-12__attribute--primary: #9898ff; +$json-schema-2020-12__constraint--string: #d4aa53; $opblock_colors: ( - post: (#112929, #104834, #14392C, #00B572), - deprecated: (#272C34, #495361, #262E36, #6A6A6A), - put: (#27201E, #523524, #9a5b3e, #FF7D35), - get: (#182536, #294262, #1C3043, #55A1FF), - delete: (#241A20, #4B2420, #2F2020, #EB6156), - patch: (#11282F, #16494B, #113239, #03B7BF), - head: (#282231, #44336A, #352C45, #B889FF), - options: (#202C3C, #33465E, #314558, #6895C8), + post: ( + #112929, + #104834, + #14392c, + #00b572, + ), + deprecated: ( + #272c34, + #495361, + #262e36, + #6a6a6a, + ), + put: ( + #27201e, + #523524, + #9a5b3e, + #ff7d35, + ), + get: ( + #182536, + #294262, + #1c3043, + #55a1ff, + ), + delete: ( + #241a20, + #4b2420, + #2f2020, + #eb6156, + ), + patch: ( + #11282f, + #16494b, + #113239, + #03b7bf, + ), + head: ( + #282231, + #44336a, + #352c45, + #b889ff, + ), + options: ( + #202c3c, + #33465e, + #314558, + #6895c8, + ), + query: ( + #2a1a28, + #4a2848, + #3a2238, + #d977c6, + ), ); html.dark-mode { @@ -57,13 +103,16 @@ html.dark-mode { } table thead tr { - td, th { + td, + th { color: $neutral-20; } } - .markdown, .renderedMarkdown { - p, pre { + .markdown, + .renderedMarkdown { + p, + pre { color: $neutral-20; } @@ -109,7 +158,7 @@ html.dark-mode { right 10px center no-repeat; color: $neutral-10; border-color: $neutral-40; - box-shadow: none; + box-shadow: none; outline: none; &[multiple] { @@ -121,12 +170,15 @@ html.dark-mode { } } - input::placeholder, textarea::placeholder { + input::placeholder, + textarea::placeholder { color: $neutral-10; opacity: 0.5; } - input.invalid, select.invalid, textarea.invalid { + input.invalid, + select.invalid, + textarea.invalid { background: $neutral-98; border-color: $error-30; } @@ -158,17 +210,22 @@ html.dark-mode { .modal-ux { background-color: $neutral-95; color: $neutral-20; - border: none; + border: none; &-header { border-color: $neutral-80; - .close-modal svg { + .close-modal svg { fill: $neutral-20; } } - h2, h3, h4, h5, p, label { + h2, + h3, + h4, + h5, + p, + label { color: $neutral-20; } @@ -207,8 +264,8 @@ html.dark-mode { } } } - - // ------ LOADING SPINNER ------ + + // ------ LOADING SPINNER ------ .loading-container .loading { &::before { @@ -221,7 +278,7 @@ html.dark-mode { } } - // ------ SCHEMES / SERVERS ------ + // ------ SCHEMES / SERVERS ------ .scheme-container { background: $neutral-98; @@ -241,14 +298,22 @@ html.dark-mode { } } - // ------ INFO ------ + // ------ INFO ------ .info { - h1, h2, h3, h4, h5, .title { + h1, + h2, + h3, + h4, + h5, + .title { color: $neutral-30; } - li, p, table, .base-url { + li, + p, + table, + .base-url { color: $neutral-20; } @@ -268,7 +333,8 @@ html.dark-mode { background: $neutral-85; border-color: $error-30; - h4, span { + h4, + span { color: $neutral-20; } @@ -281,7 +347,8 @@ html.dark-mode { // ------ COPY / DOWNLOAD BUTTONS ------ - .copy-to-clipboard, .download-contents { + .copy-to-clipboard, + .download-contents { background: $neutral-80; color: $neutral-20; @@ -476,7 +543,8 @@ html.dark-mode { } .responses-inner { - h4, h5 { + h4, + h5 { color: $neutral-20; } } @@ -507,7 +575,12 @@ html.dark-mode { color: $markdown-code; } - .property-row, .brace-open, .brace-close, .prop-format, .property, .description { + .property-row, + .brace-open, + .brace-close, + .prop-format, + .property, + .description { color: $neutral-20; } @@ -519,7 +592,8 @@ html.dark-mode { .model-box { background: $neutral-95; - .model-title, .model { + .model-title, + .model { color: $neutral-20; } @@ -527,7 +601,7 @@ html.dark-mode { &:focus { outline: none; } - + &:not(.prop) { color: $neutral-20; } @@ -555,7 +629,10 @@ html.dark-mode { color: $neutral-20; } - &-property--required > .json-schema-2020-12:first-of-type > .json-schema-2020-12-head .json-schema-2020-12__title::after { + &-property--required + > .json-schema-2020-12:first-of-type + > .json-schema-2020-12-head + .json-schema-2020-12__title::after { color: $error-30; } @@ -596,10 +673,10 @@ html.dark-mode { } &--patternProperties { - .json-schema-2020-12__title::before, + .json-schema-2020-12__title::before, .json-schema-2020-12__title::after { color: $json-schema-2020-12__attribute--primary; - } + } } } @@ -618,7 +695,8 @@ html.dark-mode { } &-json-viewer { - &__name--secondary, &__value--secondary { + &__name--secondary, + &__value--secondary { color: $neutral-40; } } diff --git a/src/style/_layout.scss b/src/style/_layout.scss index b6f3ba7727f..294e9a8f92f 100644 --- a/src/style/_layout.scss +++ b/src/style/_layout.scss @@ -388,6 +388,10 @@ @include mixins.method($color-options); } + &.opblock-query { + @include mixins.method($color-query); + } + &.opblock-deprecated { opacity: 0.6; diff --git a/src/style/_topbar.scss b/src/style/_topbar.scss index edbae35d96b..c5fbf8bf4ec 100644 --- a/src/style/_topbar.scss +++ b/src/style/_topbar.scss @@ -106,7 +106,7 @@ .dark-mode-toggle { margin-left: 10px; opacity: 0.8; - transition: all .2s; + transition: all 0.2s; cursor: pointer; button { @@ -115,7 +115,7 @@ padding: 0; svg { - fill: #E4E6E6; + fill: #e4e6e6; } } diff --git a/src/style/_variables.scss b/src/style/_variables.scss index 771e564d3fb..d297d4a6e0d 100644 --- a/src/style/_variables.scss +++ b/src/style/_variables.scss @@ -53,6 +53,7 @@ $color-head: #9012fe !default; $color-patch: #50e3c2 !default; $color-disabled: #ebebeb !default; $color-options: #0d5aa7 !default; +$color-query: #9d408a !default; // OAS 3.2 QUERY method // Authorize diff --git a/test/e2e-cypress/e2e/features/oas-badge.cy.js b/test/e2e-cypress/e2e/features/oas-badge.cy.js index 1b70c906fc0..8656d915a5a 100644 --- a/test/e2e-cypress/e2e/features/oas-badge.cy.js +++ b/test/e2e-cypress/e2e/features/oas-badge.cy.js @@ -22,4 +22,12 @@ describe("OpenAPI Badge", () => { .get("pre.version") .contains("OAS 3.1") }) + + it("should display light green badge with version indicator for OpenAPI 3.2.0", () => { + cy.visit("/?url=/documents/oas32/oas32-features.yaml") + .get("#swagger-ui") + .get('*[class^="version-stamp"]') + .get("pre.version") + .contains("OAS 3.2") + }) }) diff --git a/test/e2e-cypress/e2e/features/oas32-contact-and-license.cy.js b/test/e2e-cypress/e2e/features/oas32-contact-and-license.cy.js new file mode 100644 index 00000000000..0c7aca1c21e --- /dev/null +++ b/test/e2e-cypress/e2e/features/oas32-contact-and-license.cy.js @@ -0,0 +1,34 @@ +/** + * @prettier + */ +describe("Render Contact and License in OAS 3.2.0", () => { + const baseUrl = "/?url=/documents/oas32/contact-and-license.yaml" + + it("should render contact with all fields", () => { + cy.visit(baseUrl) + .get(".info__contact") + .should("exist") + .find("a") + .first() + .should("contain.text", "API Support Team") + .should("have.attr", "href", "https://www.example.com/support") + .should("have.attr", "rel") + .and("include", "noopener") + }) + + it("should render license with all fields", () => { + cy.visit(baseUrl) + .get(".info__license") + .should("exist") + .get(".info__license__url") + .should("contain.text", "Apache 2.0") + .find("a") + .should( + "have.attr", + "href", + "https://www.apache.org/licenses/LICENSE-2.0.html" + ) + .should("have.attr", "rel") + .and("include", "noopener") + }) +}) diff --git a/test/e2e-cypress/e2e/features/oas32-extension.cy.js b/test/e2e-cypress/e2e/features/oas32-extension.cy.js new file mode 100644 index 00000000000..373bb2ff08b --- /dev/null +++ b/test/e2e-cypress/e2e/features/oas32-extension.cy.js @@ -0,0 +1,78 @@ +/** + * @prettier + */ + +const showsExtensions = (keyword) => { + it("extensions are visible on keyword click", () => { + cy.get(".json-schema-2020-12-json-viewer__name") + .contains("x-primitiveExtension") + .should("not.be.visible") + cy.get(".json-schema-2020-12-json-viewer__name") + .contains("x-arrayExtension") + .should("not.be.visible") + cy.get(".json-schema-2020-12-json-viewer__name") + .contains("x-objectExtension") + .should("not.be.visible") + + cy.get(".json-schema-2020-12-keyword__name").contains(keyword).click() + + cy.get(".json-schema-2020-12-json-viewer__name") + .contains("x-primitiveExtension") + .should("be.visible") + cy.get(".json-schema-2020-12-json-viewer__name") + .contains("x-arrayExtension") + .should("be.visible") + cy.get(".json-schema-2020-12-json-viewer__name") + .contains("x-objectExtension") + .should("be.visible") + }) +} + +describe("OpenAPI 3.2 extension keyword", () => { + describe("displays extensions", () => { + beforeEach(() => { + cy.visit( + "/?url=/documents/features/oas32-extension.yaml&showExtensions=true" + ) + }) + + describe("Discriminator extension", () => { + beforeEach(() => { + cy.get(".json-schema-2020-12").contains("My Pet").click() + }) + showsExtensions("Discriminator") + }) + + describe("External documentation extension", () => { + beforeEach(() => { + cy.get(".json-schema-2020-12").contains("Object").click() + }) + showsExtensions("External documentation") + }) + + describe("XML extension", () => { + beforeEach(() => { + cy.get(".json-schema-2020-12").contains("Book").click() + }) + showsExtensions("XML") + }) + }) + + it("should hide extensions if showExtensions option is set to false", () => { + cy.visit( + "/?url=/documents/features/oas32-extension.yaml&showExtensions=false" + ) + cy.get(".json-schema-2020-12").contains("Object").click() + cy.get(".json-schema-2020-12-keyword__name") + .contains("External documentation") + .click() + + cy.get(".json-schema-2020-12-keyword__name--secondary") + .contains("url") + .should("be.visible") + + cy.contains("x-primitiveExtension").should("not.exist") + cy.contains("x-arrayExtension").should("not.exist") + cy.contains("x-objectExtension").should("not.exist") + }) +}) diff --git a/test/e2e-cypress/e2e/features/oas32-query-operation.cy.js b/test/e2e-cypress/e2e/features/oas32-query-operation.cy.js new file mode 100644 index 00000000000..b79247b783a --- /dev/null +++ b/test/e2e-cypress/e2e/features/oas32-query-operation.cy.js @@ -0,0 +1,30 @@ +/** + * @prettier + */ + +describe("OpenAPI 3.2 QUERY operation rendering", () => { + const baseUrl = "/?url=/documents/features/oas32-query-operation.yaml" + + it("should render QUERY operation with all fields", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .should("exist") + .find(".opblock-summary-method") + .should("contain", "QUERY") + .get("#operations-Search-searchWithQuery") + .click() + .should("have.class", "is-open") + .find(".opblock-body") + .should("exist") + }) + + it("should render multiple operations including QUERY", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchProducts") + .should("exist") + .get("#operations-Search-advancedSearchProducts") + .should("exist") + .get("#operations-Search-searchWithQuery") + .should("exist") + }) +}) diff --git a/test/e2e-cypress/e2e/features/oas32/oas32-component-only.cy.js b/test/e2e-cypress/e2e/features/oas32/oas32-component-only.cy.js new file mode 100644 index 00000000000..2c8fbb712c8 --- /dev/null +++ b/test/e2e-cypress/e2e/features/oas32/oas32-component-only.cy.js @@ -0,0 +1,21 @@ +/** + * @prettier + */ + +describe("OpenAPI 3.2.0 - Component-Only Specification", () => { + const baseUrl = "/?url=/documents/oas32/component-only.yaml" + + it("should render component-only spec with all fields", () => { + cy.visit(baseUrl) + .get(".version-pragma__message--missing") + .should("not.exist") + .get(".information-container") + .should("exist") + .find(".title") + .should("contain", "Component-Only Specification") + .get(".information-container .description") + .should("contain", "valid OAS 3.2.0 specification") + .get(".opblock-tag-section") + .should("not.exist") + }) +}) diff --git a/test/e2e-cypress/e2e/features/oas32/oas32-query-operation.cy.js b/test/e2e-cypress/e2e/features/oas32/oas32-query-operation.cy.js new file mode 100644 index 00000000000..6df62d3140c --- /dev/null +++ b/test/e2e-cypress/e2e/features/oas32/oas32-query-operation.cy.js @@ -0,0 +1,180 @@ +/** + * @prettier + */ +describe("OAS 3.2 QUERY Operation Support", () => { + const baseUrl = "/?url=/documents/features/oas32-query-operation.yaml" + + describe("QUERY Operation Rendering", () => { + it("should render QUERY operation in the operations list", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .should("exist") + }) + + it("should display QUERY method with correct styling", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .should("have.class", "opblock-query") + }) + + it("should render QUERY operation summary", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .within(() => { + cy.get(".opblock-summary-description").should( + "contain.text", + "Search with complex query payload" + ) + }) + }) + + it("should display QUERY badge/label", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .within(() => { + cy.get(".opblock-summary-method").should("contain.text", "QUERY") + }) + }) + }) + + describe("QUERY Operation Expansion", () => { + it("should expand QUERY operation when clicked", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .should("have.class", "is-open") + }) + + it("should display operation description when expanded", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .within(() => { + cy.get(".opblock-description-wrapper").should("exist") + cy.get(".renderedMarkdown").should( + "contain.text", + "QUERY HTTP method" + ) + }) + }) + }) + + describe("QUERY Operation Request Body", () => { + it("should render request body section", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .get(".opblock-section-request-body") + .should("exist") + }) + + it("should show request body is required", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .get(".opblock-section-request-body") + .should("exist") + .within(() => { + cy.get(".opblock-description-wrapper").should("exist") + }) + }) + + it("should render request body schema with properties", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .get(".opblock-section-request-body") + .within(() => { + cy.contains("query").should("exist") + cy.contains("filters").should("exist") + cy.contains("pagination").should("exist") + }) + }) + }) + + describe("QUERY Operation Responses", () => { + it("should render response section", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .get(".responses-wrapper") + .should("exist") + }) + + it("should display 200 response", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .get(".responses-wrapper") + .within(() => { + cy.contains("200").should("exist") + cy.contains("Search results").should("exist") + }) + }) + + it("should display error responses", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchWithQuery") + .click() + .get(".responses-wrapper") + .within(() => { + cy.contains("400").should("exist") + cy.contains("413").should("exist") + }) + }) + }) + + describe("Mixed Operations on Same Path", () => { + it("should render both GET and QUERY operations for /products/search", () => { + cy.visit(baseUrl) + cy.get("#operations-Search-searchProducts").should("exist") + cy.get("#operations-Search-advancedSearchProducts").should("exist") + }) + + it("should distinguish GET and QUERY operations visually", () => { + cy.visit(baseUrl) + cy.get("#operations-Search-searchProducts") + .should("have.class", "opblock-get") + .within(() => { + cy.get(".opblock-summary-method").should("contain.text", "GET") + }) + + cy.get("#operations-Search-advancedSearchProducts") + .should("have.class", "opblock-query") + .within(() => { + cy.get(".opblock-summary-method").should("contain.text", "QUERY") + }) + }) + + it("should show GET operation with query parameters", () => { + cy.visit(baseUrl) + .get("#operations-Search-searchProducts") + .click() + .within(() => { + cy.contains("Parameters").should("exist") + cy.contains("q").should("exist") + }) + }) + + it("should show QUERY operation with request body", () => { + cy.visit(baseUrl) + .get("#operations-Search-advancedSearchProducts") + .click() + .get(".opblock-section-request-body") + .should("exist") + .within(() => { + cy.contains("priceRange").should("exist") + cy.contains("specifications").should("exist") + }) + }) + }) + + describe("OAS Version Detection", () => { + it("should display OAS 3.2 badge", () => { + cy.visit(baseUrl) + .get(".info .version-stamp") + .contains("OAS 3.2") + .should("exist") + }) + }) +}) diff --git a/test/e2e-cypress/e2e/features/oas32/oas32-version-detection.cy.js b/test/e2e-cypress/e2e/features/oas32/oas32-version-detection.cy.js new file mode 100644 index 00000000000..e75f0aeaefa --- /dev/null +++ b/test/e2e-cypress/e2e/features/oas32/oas32-version-detection.cy.js @@ -0,0 +1,21 @@ +/** + * @prettier + */ + +describe("OpenAPI 3.2.0 - Version Detection", () => { + const baseUrl = "/?url=/documents/oas32/oas32-features.yaml" + + it("should detect and render OAS 3.2.0 spec with all info fields", () => { + cy.visit(baseUrl) + .get(".information-container") + .should("exist") + .find(".title") + .should("contain", "OAS 3.2.0 Basic Features") + .get(".information-container .description") + .should("contain", "basic features implemented for OpenAPI 3.2.0") + .get(".information-container .info__summary") + .should("contain", "Demonstrates basic OpenAPI 3.2.0 implementation") + .get(".version-pragma__message--missing") + .should("not.exist") + }) +}) diff --git a/test/e2e-cypress/e2e/features/plugins/oas32/oas32-json-schema-rendering.cy.js b/test/e2e-cypress/e2e/features/plugins/oas32/oas32-json-schema-rendering.cy.js new file mode 100644 index 00000000000..015a9732bf3 --- /dev/null +++ b/test/e2e-cypress/e2e/features/plugins/oas32/oas32-json-schema-rendering.cy.js @@ -0,0 +1,76 @@ +/** + * @prettier + */ + +describe("OpenAPI 3.2 JSON Schema 2020-12 rendering", () => { + const baseUrl = "/?url=/documents/features/oas32-json-schema-rendering.yaml" + + describe("Schemas section", () => { + beforeEach(() => { + cy.visit(baseUrl) + }) + + it("should render the schemas section using JSON Schema 2020-12", () => { + cy.get(".json-schema-2020-12").should("exist") + }) + + it("should render schema properties with JSON Schema 2020-12 property classes", () => { + cy.get(".json-schema-2020-12").contains("My Pet").click() + cy.get(".json-schema-2020-12-property").contains("id").should("exist") + cy.get(".json-schema-2020-12-property").contains("name").should("exist") + }) + + it("should render the schema description keyword", () => { + cy.get(".json-schema-2020-12").contains("My Pet").click() + cy.get(".json-schema-2020-12-keyword--description") + .should("exist") + .and("contain.text", "A pet in the system") + }) + + it("should render the Discriminator keyword", () => { + cy.get(".json-schema-2020-12").contains("My Pet").click() + cy.get(".json-schema-2020-12-keyword__name") + .contains("Discriminator") + .should("exist") + }) + + it("should render the External documentation keyword", () => { + cy.get(".json-schema-2020-12").contains("My Pet").click() + cy.get(".json-schema-2020-12-keyword__name") + .contains("External documentation") + .should("exist") + }) + + it("should render the XML keyword", () => { + cy.get(".json-schema-2020-12").contains("My Pet").click() + cy.get(".json-schema-2020-12-keyword--xml").should("exist") + }) + + it("should render the Examples keyword", () => { + cy.get(".json-schema-2020-12").contains("My Pet").click() + cy.get(".json-schema-2020-12-keyword--examples").should("exist") + }) + }) + + describe("Request body schema", () => { + beforeEach(() => { + cy.visit(baseUrl) + cy.get(".opblock-summary-path span").contains("/pets").click() + cy.get("button").contains("Try it out").click() + }) + + it("should render request body schema using JSON Schema 2020-12", () => { + cy.get(".model-example button").contains("Schema").click() + cy.get(".model-example .json-schema-2020-12").should("exist") + }) + + it("should render example for properties with union type including object", () => { + cy.get(".model-example textarea") + .should("exist") + .and( + "have.value", + '{\n "objectTypeUnion": {\n "id": "string",\n "name": "string"\n }\n}' + ) + }) + }) +}) diff --git a/test/e2e-cypress/e2e/features/plugins/oas32/oas32-request-body-complex-schema-properties.cy.js b/test/e2e-cypress/e2e/features/plugins/oas32/oas32-request-body-complex-schema-properties.cy.js new file mode 100644 index 00000000000..7e15904ae4c --- /dev/null +++ b/test/e2e-cypress/e2e/features/plugins/oas32/oas32-request-body-complex-schema-properties.cy.js @@ -0,0 +1,89 @@ +/** + * @prettier + */ + +describe("OpenAPI 3.2 request body properties with schema and union type", () => { + beforeEach(() => { + cy.visit( + "/?url=/documents/features/oas32-request-body-complex-schema-properties.yaml" + ) + }) + + it("should render example for properties with union type including object", () => { + cy.get(".opblock-summary-path span").contains("/objectTypeUnion").click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example textarea") + .should("exist") + .and("have.value", '{\n "id": "string",\n "name": "string"\n}') + }) + + it("should render schema for properties with union type including object", () => { + cy.get(".opblock-summary-path span").contains("/objectTypeUnion").click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example button").contains("Schema").click() + cy.get(".model-example .json-schema-2020-12").should("exist") + }) + + it("should render example for properties with union type including array of objects", () => { + cy.get(".opblock-summary-path span").contains("/arrayTypeUnion").click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example textarea") + .should("exist") + .and( + "have.value", + '[\n {\n "id": "string",\n "name": "string"\n }\n]' + ) + }) + + it("should render schema for properties with union type including array of objects", () => { + cy.get(".opblock-summary-path span").contains("/arrayTypeUnion").click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example button").contains("Schema").click() + cy.get(".model-example .json-schema-2020-12").should("exist") + }) + + it("should render example for properties of type array with union type of items including object", () => { + cy.get(".opblock-summary-path span").contains("/arrayItemTypeUnion").click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example textarea") + .should("exist") + .and("have.value", '{\n "id": "string",\n "name": "string"\n}') + }) + + it("should render schema for properties of type array with union type of items including object", () => { + cy.get(".opblock-summary-path span").contains("/arrayItemTypeUnion").click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example button").contains("Schema").click() + cy.get(".model-example .json-schema-2020-12").should("exist") + }) + + it("should render example for properties with union type including array and union type of items including object", () => { + cy.get(".opblock-summary-path span") + .contains("/arrayTypeAndItemTypeUnion") + .click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example textarea") + .should("exist") + .and( + "have.value", + '[\n {\n "id": "string",\n "name": "string"\n }\n]' + ) + }) + + it("should render schema for properties with union type including array and union type of items including object", () => { + cy.get(".opblock-summary-path span") + .contains("/arrayTypeAndItemTypeUnion") + .click() + cy.get("button").contains("Try it out").click() + + cy.get(".model-example button").contains("Schema").click() + cy.get(".model-example .json-schema-2020-12").should("exist") + }) +}) diff --git a/test/e2e-cypress/e2e/features/plugins/oas32/oas32-schema-expansion.cy.js b/test/e2e-cypress/e2e/features/plugins/oas32/oas32-schema-expansion.cy.js new file mode 100644 index 00000000000..06f9845b307 --- /dev/null +++ b/test/e2e-cypress/e2e/features/plugins/oas32/oas32-schema-expansion.cy.js @@ -0,0 +1,49 @@ +/** + * @prettier + */ + +describe("OpenAPI 3.2.0 schema expansion", () => { + it("should expand to the default expansion level", () => { + cy.visit( + "/?url=/documents/features/oas32-schema-expansion.yaml&defaultModelsExpandDepth=3&showExtensions=true" + ) + + cy.get(".json-schema-2020-12-property").contains("prop2").should("exist") + cy.get(".json-schema-2020-12-property") + .contains("prop3") + .should("not.exist") + + cy.get(".json-schema-2020-12-keyword--xml") + .contains("x-extension") + .should("exist") + cy.get(".json-schema-2020-12-keyword--xml") + .contains("prop1") + .should("not.exist") + }) + + it("should deeply expand nested collapsed keywords", () => { + cy.visit( + "/?url=/documents/features/oas32-schema-expansion.yaml&showExtensions=true" + ) + + cy.get(".json-schema-2020-12-expand-deep-button").click() + cy.get(".json-schema-2020-12-keyword--xml") + .contains("prop4") + .should("exist") + + cy.get(".json-schema-2020-12-keyword--xml").contains("prop1").click() + cy.get(".json-schema-2020-12-keyword--xml") + .contains("prop4") + .should("not.exist") + + cy.get(".json-schema-2020-12-keyword--xml").contains("XML").click() + cy.get( + ".json-schema-2020-12-keyword--xml .json-schema-2020-12-expand-deep-button" + ) + .first() + .click() + cy.get(".json-schema-2020-12-keyword--xml") + .contains("prop4") + .should("exist") + }) +}) diff --git a/test/e2e-cypress/static/documents/features/oas32-extension.yaml b/test/e2e-cypress/static/documents/features/oas32-extension.yaml new file mode 100644 index 00000000000..c865ce3bf58 --- /dev/null +++ b/test/e2e-cypress/static/documents/features/oas32-extension.yaml @@ -0,0 +1,66 @@ +openapi: 3.2.0 +info: + title: Test + description: 'Test' + license: + name: 'Apache 2.0' + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' + version: '1.0' +servers: + - url: http://petstore.swagger.io/v1 +components: + schemas: + Pet: + title: My Pet + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + discriminator: + propertyName: id + mapping: + x-primitiveExtension: 1 + x-arrayExtension: + - 2 + x-objectExtension: + prop: 3 + default: default value + Object: + type: object + externalDocs: + description: Object Docs + url: http://swagger.io + x-primitiveExtension: 1 + x-arrayExtension: + - 2 + x-objectExtension: + prop: 3 + default: default value + properties: + name: + type: string + Book: + type: object + properties: + id: + type: integer + title: + type: string + author: + type: string + xml: + prefix: "smp" + namespace: "http://example.com/schema" + x-primitiveExtension: 1 + x-arrayExtension: + - 2 + x-objectExtension: + prop: 3 diff --git a/test/e2e-cypress/static/documents/features/oas32-json-schema-rendering.yaml b/test/e2e-cypress/static/documents/features/oas32-json-schema-rendering.yaml new file mode 100644 index 00000000000..623a57a5790 --- /dev/null +++ b/test/e2e-cypress/static/documents/features/oas32-json-schema-rendering.yaml @@ -0,0 +1,61 @@ +openapi: 3.2.0 +info: + title: Test + version: 1.0.0 +servers: + - url: http://example.com/v1 +paths: + /pets: + post: + operationId: createPet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + title: My Pet + description: A pet in the system + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + tag: + type: string + readOnly: true + discriminator: + propertyName: id + externalDocs: + description: Find more info + url: http://example.com + xml: + name: Pet + prefix: pet + examples: + - id: 1 + name: Fluffy + NewPet: + type: object + properties: + objectTypeUnion: + type: [object, integer] + properties: + id: + type: string + name: + type: string diff --git a/test/e2e-cypress/static/documents/features/oas32-query-operation.yaml b/test/e2e-cypress/static/documents/features/oas32-query-operation.yaml new file mode 100644 index 00000000000..3270e0f364e --- /dev/null +++ b/test/e2e-cypress/static/documents/features/oas32-query-operation.yaml @@ -0,0 +1,221 @@ +openapi: 3.2.0 +info: + title: OAS 3.2 QUERY Operation Test + version: 1.0.0 + description: Test API demonstrating the QUERY HTTP method introduced in OAS 3.2 + +paths: + /search: + query: + operationId: searchWithQuery + summary: Search with complex query payload + description: > + This endpoint demonstrates the QUERY HTTP method introduced in OAS 3.2. + The QUERY method is a new HTTP method in OAS 3.2 that allows sending + a request body with search parameters, providing more flexibility than GET. + tags: + - Search + requestBody: + description: Search payload with query, filters, and pagination + required: true + content: + application/json: + schema: + type: object + properties: + query: + type: string + description: Search query string + filters: + type: object + description: Additional filters to apply + properties: + category: + type: string + status: + type: string + pagination: + type: object + description: Pagination settings + properties: + page: + type: integer + minimum: 1 + default: 1 + pageSize: + type: integer + minimum: 1 + maximum: 100 + default: 20 + example: + query: "example search" + filters: + category: "electronics" + status: "active" + pagination: + page: 1 + pageSize: 20 + responses: + '200': + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + total: + type: integer + page: + type: integer + '400': + description: Invalid search criteria + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: Payload too large + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /products/search: + get: + operationId: searchProducts + summary: Simple product search + description: Search products using query parameters (traditional GET) + tags: + - Search + parameters: + - name: q + in: query + description: Search query + schema: + type: string + - name: category + in: query + description: Product category filter + schema: + type: string + responses: + '200': + description: Search results + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Product' + + query: + operationId: advancedSearchProducts + summary: Advanced product search + description: > + Advanced product search using complex criteria in request body. + Demonstrates QUERY method alongside GET on the same path. + tags: + - Search + requestBody: + description: Advanced search criteria + required: true + content: + application/json: + schema: + type: object + properties: + priceRange: + type: object + properties: + min: + type: number + format: float + max: + type: number + format: float + specifications: + type: object + description: Product specifications to match + additionalProperties: + type: string + categories: + type: array + items: + type: string + sortBy: + type: string + enum: [price, rating, name, date] + example: + priceRange: + min: 10.0 + max: 100.0 + specifications: + brand: "Example Brand" + color: "blue" + categories: ["electronics", "computers"] + sortBy: "price" + responses: + '200': + description: Advanced search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/Product' + total: + type: integer + facets: + type: object + '400': + description: Invalid search criteria + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + schemas: + Product: + type: object + required: + - id + - name + - price + properties: + id: + type: string + name: + type: string + price: + type: number + format: float + category: + type: string + specifications: + type: object + additionalProperties: + type: string + rating: + type: number + format: float + minimum: 0 + maximum: 5 + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + message: + type: string diff --git a/test/e2e-cypress/static/documents/features/oas32-request-body-complex-schema-properties.yaml b/test/e2e-cypress/static/documents/features/oas32-request-body-complex-schema-properties.yaml new file mode 100644 index 00000000000..6923d8193cb --- /dev/null +++ b/test/e2e-cypress/static/documents/features/oas32-request-body-complex-schema-properties.yaml @@ -0,0 +1,83 @@ +openapi: 3.2.0 +info: + title: Request Body Example + version: 1.0.0 +paths: + /objectTypeUnion: + post: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + objectTypeUnion: + type: [object, integer] + properties: + id: + type: string + name: + type: string + responses: + '200': + description: OK + /arrayTypeUnion: + post: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + arrayTypeUnion: + type: [array, integer] + items: + type: object + properties: + id: + type: string + name: + type: string + responses: + '200': + description: OK + /arrayItemTypeUnion: + post: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + arrayItemTypeUnion: + type: array + items: + type: [object, integer] + properties: + id: + type: string + name: + type: string + responses: + '200': + description: OK + /arrayTypeAndItemTypeUnion: + post: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + arrayTypeAndItemTypeUnion: + type: [array, integer] + items: + type: [object, string] + properties: + id: + type: string + name: + type: string + responses: + '200': + description: OK diff --git a/test/e2e-cypress/static/documents/features/oas32-schema-expansion.yaml b/test/e2e-cypress/static/documents/features/oas32-schema-expansion.yaml new file mode 100644 index 00000000000..ec43ec6d77d --- /dev/null +++ b/test/e2e-cypress/static/documents/features/oas32-schema-expansion.yaml @@ -0,0 +1,19 @@ +openapi: 3.2.0 +components: + schemas: + Expansion: + properties: + prop1: + properties: + prop2: + properties: + prop3: + properties: + prop4: + type: string + xml: + x-extension: + prop1: + prop2: + prop3: + prop4: test diff --git a/test/e2e-cypress/static/documents/oas32/component-only.yaml b/test/e2e-cypress/static/documents/oas32/component-only.yaml new file mode 100644 index 00000000000..60ced11f8b9 --- /dev/null +++ b/test/e2e-cypress/static/documents/oas32/component-only.yaml @@ -0,0 +1,66 @@ +openapi: 3.2.0 +info: + title: Component-Only Specification + version: 1.0.0 + description: | + This is a valid OAS 3.2.0 specification that contains only components, + without any paths or webhooks. This demonstrates the relaxed requirement + where at least one of components, paths, or webhooks must be present. + +components: + schemas: + User: + type: object + required: + - id + - name + properties: + id: + type: string + format: uuid + name: + type: string + email: + type: string + format: email + created: + type: string + format: date-time + + Error: + type: object + properties: + code: + type: integer + message: + type: string + + responses: + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + parameters: + UserId: + name: userId + in: path + required: true + schema: + type: string + format: uuid + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/test/e2e-cypress/static/documents/oas32/contact-and-license.yaml b/test/e2e-cypress/static/documents/oas32/contact-and-license.yaml new file mode 100644 index 00000000000..76a2c5223db --- /dev/null +++ b/test/e2e-cypress/static/documents/oas32/contact-and-license.yaml @@ -0,0 +1,31 @@ +openapi: 3.2.0 +info: + title: OAS 3.2.0 Contact and License Test + version: 1.0.0 + description: This spec tests contact and license rendering in OAS 3.2.0 + contact: + name: API Support Team + url: https://www.example.com/support + email: support@example.com + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: https://api.example.com/v1 + +paths: + /test: + get: + summary: Test endpoint + operationId: test + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string diff --git a/test/e2e-cypress/static/documents/oas32/oas32-features.yaml b/test/e2e-cypress/static/documents/oas32/oas32-features.yaml new file mode 100644 index 00000000000..f5130803e91 --- /dev/null +++ b/test/e2e-cypress/static/documents/oas32/oas32-features.yaml @@ -0,0 +1,61 @@ +openapi: 3.2.0 +info: + title: OAS 3.2.0 Basic Features + summary: Demonstrates basic OpenAPI 3.2.0 implementation + version: 1.0.0 + description: | + This specification demonstrates the basic features implemented for OpenAPI 3.2.0: + - QUERY HTTP method + - Info summary field + +servers: + - url: https://api.example.com/v1 + +paths: + /search: + query: + summary: Search with query payload + description: | + The QUERY method is a new HTTP method in OAS 3.2 that allows + safe, idempotent queries with a request body. + operationId: searchQuery + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + query: + type: string + example: "user:john" + filters: + type: object + properties: + status: + type: string + enum: [active, inactive] + responses: + '200': + description: Search results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/SearchResult' + +components: + schemas: + SearchResult: + type: object + properties: + id: + type: string + title: + type: string + score: + type: number diff --git a/test/unit/core/plugins/oas32/components/version-pragma-filter.jsx b/test/unit/core/plugins/oas32/components/version-pragma-filter.jsx new file mode 100644 index 00000000000..0acc4227d5b --- /dev/null +++ b/test/unit/core/plugins/oas32/components/version-pragma-filter.jsx @@ -0,0 +1,115 @@ +/** + * @prettier + */ +import React from "react" +import { shallow } from "enzyme" +import VersionPragmaFilter from "core/plugins/oas32/components/version-pragma-filter" + +describe("OAS32 VersionPragmaFilter", () => { + it("should render children when version is valid (OAS 3.2)", () => { + const wrapper = shallow( + +
Test Content
+
+ ) + + expect(wrapper.find(".test-child")).toHaveLength(1) + expect(wrapper.text()).toContain("Test Content") + }) + + it("should render children when bypass is true", () => { + const wrapper = shallow( + +
Test Content
+
+ ) + + expect(wrapper.find(".test-child")).toHaveLength(1) + }) + + it("should render error when version is ambiguous (Swagger2 and OAS32)", () => { + const wrapper = shallow( + +
Test Content
+
+ ) + + expect(wrapper.find(".version-pragma__message--ambiguous")).toHaveLength(1) + expect(wrapper.text()).toContain("Unable to render this definition") + expect(wrapper.find(".test-child")).toHaveLength(0) + }) + + it("should render error when version is missing", () => { + const wrapper = shallow( + +
Test Content
+
+ ) + + expect(wrapper.find(".version-pragma__message--missing")).toHaveLength(1) + expect(wrapper.text()).toContain("Unable to render this definition") + expect(wrapper.find(".test-child")).toHaveLength(0) + }) + + it("should render children when only OAS3 is present", () => { + const wrapper = shallow( + +
Test Content
+
+ ) + + // Should render children (valid single version) + expect(wrapper.find(".test-child")).toHaveLength(1) + expect(wrapper.text()).toContain("Test Content") + }) + + it("should render alsoShow when provided and version is invalid", () => { + const alsoShowElement =
Additional Info
+ + const wrapper = shallow( + +
Test Content
+
+ ) + + expect(wrapper.find(".also-show")).toHaveLength(1) + expect(wrapper.text()).toContain("Additional Info") + }) +}) diff --git a/test/unit/core/plugins/oas32/fn.js b/test/unit/core/plugins/oas32/fn.js new file mode 100644 index 00000000000..660d2c7ee21 --- /dev/null +++ b/test/unit/core/plugins/oas32/fn.js @@ -0,0 +1,62 @@ +/** + * @prettier + */ +import { fromJS } from "immutable" +import { isOAS32 } from "core/plugins/oas32/fn" + +describe("oas32 plugin - fn - isOAS32", () => { + it("should match OpenAPI 3.2.0", () => { + const spec = fromJS({ openapi: "3.2.0" }) + expect(isOAS32(spec)).toBe(true) + }) + + it("should match OpenAPI 3.2.1", () => { + const spec = fromJS({ openapi: "3.2.1" }) + expect(isOAS32(spec)).toBe(true) + }) + + it("should match OpenAPI 3.2.25", () => { + const spec = fromJS({ openapi: "3.2.25" }) + expect(isOAS32(spec)).toBe(true) + }) + + it("should NOT match OpenAPI 3.2", () => { + const spec = fromJS({ openapi: "3.2" }) + expect(isOAS32(spec)).toBe(false) + }) + + it("should NOT match OpenAPI 3.2.01 (leading zero)", () => { + const spec = fromJS({ openapi: "3.2.01" }) + expect(isOAS32(spec)).toBe(false) + }) + + it("should NOT match OpenAPI 3.1.0", () => { + const spec = fromJS({ openapi: "3.1.0" }) + expect(isOAS32(spec)).toBe(false) + }) + + it("should NOT match OpenAPI 3.0.3", () => { + const spec = fromJS({ openapi: "3.0.3" }) + expect(isOAS32(spec)).toBe(false) + }) + + it("should NOT match swagger: 2.0", () => { + const spec = fromJS({ swagger: "2.0" }) + expect(isOAS32(spec)).toBe(false) + }) + + it("should handle null spec", () => { + const spec = fromJS({}) + expect(isOAS32(spec)).toBe(false) + }) + + it("should NOT match OpenAPI 3.3.0", () => { + const spec = fromJS({ openapi: "3.3.0" }) + expect(isOAS32(spec)).toBe(false) + }) + + it("should NOT match OpenAPI 4.0.0", () => { + const spec = fromJS({ openapi: "4.0.0" }) + expect(isOAS32(spec)).toBe(false) + }) +}) diff --git a/test/unit/core/plugins/oas32/query-operation-rendering.test.js b/test/unit/core/plugins/oas32/query-operation-rendering.test.js new file mode 100644 index 00000000000..e11c6539acc --- /dev/null +++ b/test/unit/core/plugins/oas32/query-operation-rendering.test.js @@ -0,0 +1,125 @@ +/** + * @prettier + */ +import { Map, List } from "immutable" +import { validOperationMethods as validOperationMethodsWrapper } from "core/plugins/oas32/spec-extensions/wrap-selectors" +import { validOperationMethods as oas32ValidOperationMethods } from "core/plugins/oas32/selectors" + +describe("OAS 3.2 QUERY operation rendering", () => { + describe("validOperationMethods wrapper", () => { + it("should include 'query' method 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), + }, + })), + oas32Selectors: { + validOperationMethods: oas32ValidOperationMethods, + }, + } + + const wrappedSelector = validOperationMethodsWrapper( + originalSelector, + system + ) + const state = Map() + const result = wrappedSelector(state) + + expect(result).toContain("query") + expect(result).toContain("get") + expect(result).toContain("post") + expect(result.length).toBe(9) // 8 standard + query + }) + + it("should not include 'query' for non-OAS32 specs", () => { + const originalSelector = jest.fn(() => [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ]) + + const system = { + getSystem: jest.fn(() => ({ + specSelectors: { + isOAS32: jest.fn(() => false), + }, + })), + oas32Selectors: { + validOperationMethods: oas32ValidOperationMethods, + }, + } + + const wrappedSelector = validOperationMethodsWrapper( + originalSelector, + system + ) + const state = Map() + const result = wrappedSelector(state) + + expect(result).not.toContain("query") + expect(result.length).toBe(8) + }) + }) + + describe("integration test", () => { + it("should allow Operations component to render QUERY operations", () => { + // Simulate what the Operations component does + const validOperationMethods = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query", + ] + + const operations = List([ + Map({ + path: "/pets", + method: "get", + operation: Map({ summary: "Get pets" }), + }), + Map({ + path: "/pets", + method: "query", + operation: Map({ summary: "Search pets" }), + }), + Map({ + path: "/pets", + method: "post", + operation: Map({ summary: "Create pet" }), + }), + ]) + + // Filter operations like Operations component does + const renderedOperations = operations.filter( + (op) => validOperationMethods.indexOf(op.get("method")) !== -1 + ) + + expect(renderedOperations.size).toBe(3) + expect( + renderedOperations.find((op) => op.get("method") === "query") + ).toBeDefined() + }) + }) +}) diff --git a/test/unit/core/plugins/oas32/selectors.test.js b/test/unit/core/plugins/oas32/selectors.test.js new file mode 100644 index 00000000000..a5c1eeb5410 --- /dev/null +++ b/test/unit/core/plugins/oas32/selectors.test.js @@ -0,0 +1,37 @@ +/** + * @prettier + */ +import { validOperationMethods } from "core/plugins/oas32/selectors" + +describe("oas32 plugin - selectors", () => { + describe("validOperationMethods", () => { + it("should return an array of valid operation methods", () => { + const result = validOperationMethods() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(9) + }) + + it("should include standard HTTP methods", () => { + const result = validOperationMethods() + expect(result).toContain("get") + expect(result).toContain("put") + expect(result).toContain("post") + expect(result).toContain("delete") + expect(result).toContain("options") + expect(result).toContain("head") + expect(result).toContain("patch") + expect(result).toContain("trace") + }) + + it("should include QUERY method for OAS 3.2", () => { + const result = validOperationMethods() + expect(result).toContain("query") + }) + + it("should return the same array reference on multiple calls", () => { + const result1 = validOperationMethods() + const result2 = validOperationMethods() + expect(result1).toBe(result2) + }) + }) +}) 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 new file mode 100644 index 00000000000..6f336cabcdb --- /dev/null +++ b/test/unit/core/plugins/oas32/spec-extensions/wrap-selectors.test.js @@ -0,0 +1,85 @@ +/** + * @prettier + */ +import { Map } from "immutable" +import { validOperationMethods as validOperationMethodsWrapper } from "core/plugins/oas32/spec-extensions/wrap-selectors" + +describe("OAS32 wrap-selectors", () => { + describe("validOperationMethods", () => { + it("should include query for OAS 3.2 specs", () => { + const oriSelector = jest.fn(() => [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ]) + + const oas32Methods = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query", + ] + + const system = { + getSystem: jest.fn(() => ({ + specSelectors: { + isOAS32: jest.fn(() => true), + }, + })), + oas32Selectors: { + validOperationMethods: jest.fn(() => oas32Methods), + }, + } + + const wrappedSelector = validOperationMethodsWrapper(oriSelector, system) + const state = Map() + const result = wrappedSelector(state) + + expect(result).toContain("query") + expect(system.oas32Selectors.validOperationMethods).toHaveBeenCalled() + }) + + it("should not include query for non-OAS32 specs", () => { + const oas3Methods = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ] + const oriSelector = jest.fn(() => oas3Methods) + + const system = { + getSystem: jest.fn(() => ({ + specSelectors: { + isOAS32: jest.fn(() => false), + }, + })), + oas32Selectors: { + validOperationMethods: jest.fn(), + }, + } + + const wrappedSelector = validOperationMethodsWrapper(oriSelector, system) + const state = Map() + const result = wrappedSelector(state) + + expect(result).not.toContain("query") + expect(oriSelector).toHaveBeenCalled() + expect(system.oas32Selectors.validOperationMethods).not.toHaveBeenCalled() + }) + }) +})