diff --git a/.changeset/real-lights-study.md b/.changeset/real-lights-study.md
new file mode 100644
index 00000000..ffdfa765
--- /dev/null
+++ b/.changeset/real-lights-study.md
@@ -0,0 +1,5 @@
+---
+"redoc": patch
+---
+
+Release 3.0.0-rc.1.
diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml
index ccb09dc0..a701f976 100644
--- a/.github/actions/setup-node/action.yml
+++ b/.github/actions/setup-node/action.yml
@@ -11,7 +11,7 @@ runs:
- name: 📦 Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 22.17.1
+ node-version: 24.11.1
cache: npm
- name: ✅ Finished Node setup
shell: bash
diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml
index 33078401..2deec74e 100644
--- a/.github/actions/setup-playwright/action.yml
+++ b/.github/actions/setup-playwright/action.yml
@@ -13,7 +13,7 @@ runs:
id: playwright-version
shell: bash
run: |
- echo "PLAYWRIGHT_VERSION=$(jq -r '.devDependencies["@playwright/test"]' package.json)" >> $GITHUB_OUTPUT
+ echo "PLAYWRIGHT_VERSION=$(jq -r '.devDependencies[\"@playwright/test\"]' package.json)" >> $GITHUB_OUTPUT
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
name: Check if Playwright browser is cached
id: playwright-cache
@@ -25,8 +25,7 @@ runs:
shell: bash
run: |
echo "Installing Playwright in root"
- echo "Installing @playwright/test@${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} (no-save)"
- npm install -D @playwright/test@${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} --no-save
+ npm install @playwright/test
npx playwright install --with-deps
- name: ✅ Finished Playwright setup
shell: bash
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 178fffb2..49272674 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -1,8 +1,5 @@
name: Tests e2e
-permissions:
- contents: read
-
on:
pull_request:
branches:
@@ -17,7 +14,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 22.17
+ node-version: 24.11.1
cache: 'npm'
- name: Install Dependencies
run: npm ci --ignore-scripts --no-fund
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 20945176..3ad390a5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,14 +1,9 @@
name: Release
-# permissions:
-# id-token: write
-# contents: read
-
on:
push:
branches:
- main
-
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -17,6 +12,10 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
+ permissions:
+ id-token: write # Required for OIDC
+ contents: write
+ pull-requests: write
outputs:
published: ${{ steps.changesets.outputs.published }}
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
@@ -26,7 +25,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 22.17
+ node-version: 24.11.1
cache: 'npm'
- name: Install Dependencies
run: npm ci
@@ -40,11 +39,13 @@ jobs:
title: 'chore: 🔖 release new versions'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
dockerhub:
needs: [release]
if: needs.release.outputs.published == 'true'
+ permissions:
+ contents: read
+ packages: write
uses: ./.github/workflows/docker.yml
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -55,6 +56,8 @@ jobs:
needs: [release]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
@@ -74,6 +77,8 @@ jobs:
runs-on: ubuntu-latest
needs: [release, publish-cdn]
if: needs.release.outputs.published == 'true'
+ permissions:
+ contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 4ac6d0f6..3524b9d9 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -4,8 +4,6 @@ on:
pull_request:
branches:
- main
-permissions:
- contents: read
jobs:
build-and-unit:
@@ -16,7 +14,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 22.17
+ node-version: 24.11.1
cache: 'npm'
- name: Install Dependencies
run: npm ci --ignore-scripts --no-fund
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 00000000..ebe9b5d4
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,3 @@
+# When this package is built standalone (e.g., in CI),
+# ensure dependencies are installed locally, not hoisted to parent workspace
+shamefully-hoist=true
diff --git a/config/docker/Dockerfile b/config/docker/Dockerfile
index a2ce59ff..03679a51 100644
--- a/config/docker/Dockerfile
+++ b/config/docker/Dockerfile
@@ -5,7 +5,7 @@
# npm i -g http-server
# http-server -p 8000 --cors
-FROM node:22.20.0-alpine@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af AS build
+FROM node:24.11.1-alpine3.22@sha256:2867d550cf9d8bb50059a0fff528741f11a84d985c732e60e19e8e75c7239c43 AS build
WORKDIR /build
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 2f93e47d..23c0b69d 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -85,7 +85,8 @@ export default tseslint.config(
devDependencies: [
'**/playwright/**',
'**/vite.config.ts',
- '**/setupTests.ts',
+ '**/vitest.config.ts',
+ '**/vitest.setup.ts',
'**/__tests__/**/*.{ts,tsx}',
'**/benchmarks/*.ts',
'**/__mocks__/**/*.{ts,tsx}',
diff --git a/examples/index.standalone.html b/examples/index.standalone.html
index e56f745c..007b5d33 100644
--- a/examples/index.standalone.html
+++ b/examples/index.standalone.html
@@ -9,7 +9,7 @@
href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600|Source+Code+Pro:400:700"
rel="stylesheet"
/>
-
+
\\s*$';
-
-// prettier-ignore
-export const MDX_COMPONENT_REGEXP = '(?:^ {0,3}<({component})([\\s\\S]*?)>([\\s\\S]*?)\\2>' // with children
- + '|^ {0,3}<({component})([\\s\\S]*?)(?:/>|\\n{2,}))'; // self-closing
-
-export const COMPONENT_REGEXP = '(?:' + LEGACY_REGEXP + '|' + MDX_COMPONENT_REGEXP + ')';
-
export const DEFAULT_TAG_SLUG = 'other';
export const DEFAULT_WEBHOOKS_TAG_NAME = 'webhooks';
@@ -28,8 +19,6 @@ export const LOADING_STATE = {
LOADED: 'LOADED',
};
-export const REDOCLY_CONFIG_FILE = 'redocly.yaml';
-
export const DISABLE_DEEP_LINK_IF_FIELDS_EXIST = ['title'];
export const SEARCH_LIMIT = 100;
export const HIGHLIGHTED_TEXT_MAX_LENGTH = 150;
diff --git a/src/hooks/useRouter.ts b/src/hooks/useRouter.ts
index f387eb91..e269517d 100644
--- a/src/hooks/useRouter.ts
+++ b/src/hooks/useRouter.ts
@@ -12,6 +12,10 @@ const RouterComponent: Dictionary, RouterType>
interface RouterProps {
basename?: string;
location?: string;
+ future?: {
+ v7_startTransition: boolean;
+ v7_relativeSplatPath: boolean;
+ };
}
export const useRouter = (router: RouterType, basePath: string) => {
diff --git a/src/hooks/useTelemetry.ts b/src/hooks/useTelemetry.ts
index b36b62b7..de6cde94 100644
--- a/src/hooks/useTelemetry.ts
+++ b/src/hooks/useTelemetry.ts
@@ -12,6 +12,8 @@ import type {
SearchInputResetButtonClickedMessage,
SearchOpenedMessage,
SearchResultClickedMessage,
+ ViewSecurityDetailsClickedMessage,
+ ViewSecurityDetailsClosedMessage,
} from '@redocly/redoc-opentelemetry';
import type { Options, OpenAPIParser } from '../services/index.js';
import type { OpenAPISecurityScheme } from '../types/open-api.js';
@@ -81,6 +83,8 @@ export function useTelemetry() {
sendSearchResultClickedMessage: () => {},
sendSearchInputResetButtonClickedMessage: () => {},
sendSearchOpenedMessage: () => {},
+ sendViewSecurityDetailsClickedMessage: () => {},
+ sendViewSecurityDetailsClosedMessage: () => {},
};
}
@@ -108,5 +112,11 @@ export function useTelemetry() {
redocTelemetry.sendEvent('redoc_ce.search.opened', data),
sendSearchResultClickedMessage: (data: EventPayload) =>
redocTelemetry.sendEvent('redoc_ce.search.result.clicked', data),
+ sendViewSecurityDetailsClickedMessage: (
+ data: EventPayload,
+ ) => redocTelemetry.sendEvent('redoc_ce.view_security_details.clicked', data),
+ sendViewSecurityDetailsClosedMessage: (
+ data: EventPayload,
+ ) => redocTelemetry.sendEvent('redoc_ce.view_security_details.closed', data),
};
}
diff --git a/src/icons/DefaultMappingIcon/DefaultMappingIcon.tsx b/src/icons/DefaultMappingIcon/DefaultMappingIcon.tsx
new file mode 100644
index 00000000..744d6be3
--- /dev/null
+++ b/src/icons/DefaultMappingIcon/DefaultMappingIcon.tsx
@@ -0,0 +1,34 @@
+type IconProps = React.SVGProps;
+
+import { styled } from '../../styled-components.js';
+
+export const Icon = (props: IconProps) => (
+
+);
+
+export const DefaultMappingIcon = styled(Icon).attrs(
+ () =>
+ ({
+ 'data-component-name': 'icons/DefaultMappingIcon',
+ }) as IconProps,
+)`
+ path {
+ fill: var(--color-warm-grey-11);
+ html.dark & {
+ fill: var(--text-color-secondary);
+ }
+ }
+`;
diff --git a/src/icons/DefaultMappingIcon/index.ts b/src/icons/DefaultMappingIcon/index.ts
new file mode 100644
index 00000000..fadaac00
--- /dev/null
+++ b/src/icons/DefaultMappingIcon/index.ts
@@ -0,0 +1 @@
+export { DefaultMappingIcon } from './DefaultMappingIcon.js';
diff --git a/src/models/__tests__/Callback.test.ts b/src/models/__tests__/Callback.test.ts
index 46980121..9bd8e7fe 100644
--- a/src/models/__tests__/Callback.test.ts
+++ b/src/models/__tests__/Callback.test.ts
@@ -1,12 +1,16 @@
-import { getCallback } from '../callback';
-import { normalizeOptions, OpenAPIParser } from '../../services';
+import { getCallback } from '../callback.js';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
+import spec from './fixtures/callback.json';
const opts = normalizeOptions({});
describe('Models', () => {
describe('CallbackModel', () => {
- const spec = require('./fixtures/callback.json');
- const parser = new OpenAPIParser(spec, undefined, opts);
+ const parser = new OpenAPIParser(
+ spec as unknown as ConstructorParameters[0],
+ undefined,
+ opts,
+ );
test('basic callback details', () => {
const callback = getCallback(
diff --git a/src/models/__tests__/FieldModel.test.ts b/src/models/__tests__/FieldModel.test.ts
index 514e73e1..700ff295 100644
--- a/src/models/__tests__/FieldModel.test.ts
+++ b/src/models/__tests__/FieldModel.test.ts
@@ -1,14 +1,18 @@
-import type { OperationModel } from '..';
+import type { OperationModel } from '../index.js';
-import { getField } from '../field';
-import { normalizeOptions, OpenAPIParser } from '../../services';
+import { getField } from '../field.js';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
+import spec from './fixtures/fields.json';
const opts = normalizeOptions({});
const deps = { operation: { pointer: 'testFieldModel' } as OperationModel };
describe('Models', () => {
describe('FieldModel', () => {
- const spec = require('./fixtures/fields.json');
- const parser = new OpenAPIParser(spec, undefined, opts);
+ const parser = new OpenAPIParser(
+ spec as unknown as ConstructorParameters[0],
+ undefined,
+ opts,
+ );
test('basic field details', () => {
const field = getField(
diff --git a/src/models/__tests__/MediaContent.test.ts b/src/models/__tests__/MediaContent.test.ts
index 297d4263..1113c374 100644
--- a/src/models/__tests__/MediaContent.test.ts
+++ b/src/models/__tests__/MediaContent.test.ts
@@ -1,9 +1,9 @@
-import type { OpenAPIDefinition } from '../../types';
-import type { OperationModel } from '..';
+import type { OpenAPIDefinition } from '../../types/index.js';
+import type { OperationModel } from '../index.js';
-import { getMediaContent } from '../mediaContent';
-import { normalizeOptions, OpenAPIParser } from '../../services';
-import { MediaTypes } from '../../constants';
+import { getMediaContent } from '../mediaContent.js';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
+import { MediaTypes } from '../../constants.js';
const options = normalizeOptions({});
const data = { operation: { pointer: 'testMediaContent' } as OperationModel };
@@ -25,7 +25,7 @@ describe('Models', () => {
});
test('should work with default arguments', () => {
- const consoleError = jest.spyOn(global.console, 'error');
+ const consoleError = vi.spyOn(global.console, 'error');
const mediaContentModel = getMediaContent({
parser,
info: {},
diff --git a/src/models/__tests__/Operation.test.ts b/src/models/__tests__/Operation.test.ts
index 341ebd49..a0e015d4 100644
--- a/src/models/__tests__/Operation.test.ts
+++ b/src/models/__tests__/Operation.test.ts
@@ -1,20 +1,26 @@
-import type { MediaContentModel, OperationModel, RequestBodyModel } from '../types';
-import type { XPayloadSample } from '../operation';
+import type { MediaContentModel, OperationModel, RequestBodyModel } from '../types.js';
+import type { XPayloadSample } from '../operation.js';
+import type { ExtendedOpenAPIOperation } from '../../services/index.js';
+import type { OpenAPIDefinition, OpenAPIRequestBody, Referenced } from '../../types/index.js';
-import { normalizeOptions, OpenAPIParser } from '../../services';
-import { getOperation } from '../operation';
-import { getRequestBody } from '../request';
-import spec from './fixtures/operation/petstore.json';
-import definition from './fixtures/operation/operationDefinition.json';
-import noRequestDefinition from './fixtures/operation/noRequestOperationDefinition.json';
-import { replaceCircularJson } from './helpers';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
+import { getOperation } from '../operation.js';
+import { getRequestBody } from '../request.js';
+import specFixture from './fixtures/operation/petstore.json';
+import definitionFixture from './fixtures/operation/operationDefinition.json';
+import noRequestDefinitionFixture from './fixtures/operation/noRequestOperationDefinition.json';
+import { replaceCircularJson } from './helpers.js';
const options = normalizeOptions({});
// Add mock at the top with other imports
-jest.mock('@redocly/theme', () => ({
- ...jest.requireActual('@redocly/theme'),
+vi.mock('@redocly/theme', async () => ({
+ ...(await vi.importActual('@redocly/theme')),
}));
+const spec = specFixture as unknown as OpenAPIDefinition;
+const definition = definitionFixture as unknown as ExtendedOpenAPIOperation;
+const noRequestDefinition = noRequestDefinitionFixture as unknown as ExtendedOpenAPIOperation;
+
describe('Models', () => {
describe('Operation', () => {
const parser = new OpenAPIParser(spec, undefined, options);
@@ -27,7 +33,7 @@ describe('Models', () => {
operation = getOperation(parser, definition, undefined, options, 'test');
requestBody = getRequestBody({
parser,
- infoOrRef: definition.requestBody,
+ infoOrRef: definition.requestBody as unknown as Referenced,
options,
operation,
isEvent: false,
diff --git a/src/models/__tests__/Response.test.ts b/src/models/__tests__/Response.test.ts
index a01b6117..7486ea81 100644
--- a/src/models/__tests__/Response.test.ts
+++ b/src/models/__tests__/Response.test.ts
@@ -1,11 +1,11 @@
import { parseYaml } from '@redocly/openapi-core';
import { outdent } from 'outdent';
-import type { OpenAPIDefinition } from '../../types';
-import type { OperationModel } from '../types';
+import type { OpenAPIDefinition } from '../../types/index.js';
+import type { OperationModel } from '../types.js';
-import { getResponse } from '../response';
-import { normalizeOptions, OpenAPIParser } from '../../services';
+import { getResponse } from '../response.js';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
const opts = normalizeOptions({});
describe('Models', () => {
diff --git a/src/models/__tests__/Schema.circular.test.ts b/src/models/__tests__/Schema.circular.test.ts
index a8f5bdf3..60713edf 100644
--- a/src/models/__tests__/Schema.circular.test.ts
+++ b/src/models/__tests__/Schema.circular.test.ts
@@ -1,11 +1,11 @@
-import * as outdent from 'outdent';
+import outdent from 'outdent';
import { parseYaml } from '@redocly/openapi-core';
-import type { OperationModel } from '../types';
+import type { OperationModel } from '../types.js';
-import { getSchema } from '../schema';
-import { normalizeOptions, OpenAPIParser } from '../../services';
-import { circularDetailsPrinter, printSchema } from './helpers';
+import { getSchema } from '../schema.js';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
+import { circularDetailsPrinter, printSchema } from './helpers.js';
const options = normalizeOptions({});
const deps = { operation: { pointer: 'testSchemaCircular' } as OperationModel };
@@ -700,5 +700,47 @@ describe('Models', () => {
type:
`);
});
+ test('should use titles in oneOf schemas and should not be recursive', () => {
+ const spec = parseYaml(outdent`
+ openapi: 3.1.0
+ components:
+ schemas:
+ OneOfTitle:
+ oneOf:
+ - $ref: "#/components/schemas/Foo"
+ - $ref: "#/components/schemas/Bar"
+ Foo:
+ title: Foo Title
+ type: object
+ description: test Foo description
+ properties:
+ type:
+ type: string
+ Bar:
+ title: Bar Title
+ type: object
+ description: test Bar description
+ properties:
+ type:
+ type: string
+ `) as any;
+
+ parser = new OpenAPIParser(spec, undefined, options);
+ const schema = getSchema({
+ parser,
+ schemaOrRef: spec.components.schemas.OneOfTitle,
+ pointer: '#/components/schemas/OneOfTitle',
+ options,
+ deps,
+ });
+
+ expect(printSchema(schema, circularDetailsPrinter)).toMatchInlineSnapshot(`
+ oneOf
+ Foo Title ->
+ type:
+ Bar Title ->
+ type:
+ `);
+ });
});
});
diff --git a/src/models/__tests__/Schema.test.ts b/src/models/__tests__/Schema.test.ts
index c90b8be6..0e72b1d1 100644
--- a/src/models/__tests__/Schema.test.ts
+++ b/src/models/__tests__/Schema.test.ts
@@ -1,14 +1,34 @@
-/* eslint @typescript-eslint/no-var-requires: 0 */
import { parseYaml } from '@redocly/openapi-core';
import { outdent } from 'outdent';
-import type { OperationModel, SchemaModel } from '../types';
+import type { OperationModel, SchemaModel } from '../types.js';
-import { getMediaType } from '../mediaType';
-import { getField } from '../field';
-import { normalizeOptions, OpenAPIParser } from '../../services';
-import { enumDetailsPrinter, printSchema } from './helpers';
-import { getSchema } from '../schema';
+import { getMediaType } from '../mediaType.js';
+import { getField } from '../field.js';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
+import { enumDetailsPrinter, printSchema } from './helpers.js';
+import { getSchema } from '../schema.js';
+
+// Fixture imports
+import nestedEnumDescriptionSpec from './fixtures/nestedEnumDescroptionSample.json';
+import discriminatorSpec from './fixtures/discriminator.json';
+import discriminatorWithDefaultMappingSpec from './fixtures/discriminator-with-default-mapping.json';
+import discriminatorDefaultOnlySpec from './fixtures/discriminator-default-only.json';
+import discriminatorOrderedWithDefaultSpec from './fixtures/discriminator-ordered-with-default.json';
+import oneOfTitlesSpec from './fixtures/oneOfTitles.json';
+import conditionalSchemaSpec from './fixtures/3.1/conditionalSchema.json';
+import conditionalFieldSpec from './fixtures/3.1/conditionalField.json';
+import patternPropertiesSpec from './fixtures/3.1/patternProperties.json';
+import prefixItemsSpec from './fixtures/3.1/prefixItems.json';
+import arrayItemsSpec from './fixtures/arrayItems.json';
+import fieldsSpec from './fixtures/fields.json';
+import type { OpenAPIDefinition, OpenAPISchema } from '../../types/index.js';
+
+// Map for test.each fixtures
+const fixtureMap: Record = {
+ './fixtures/3.1/prefixItems.json': prefixItemsSpec,
+ './fixtures/arrayItems.json': arrayItemsSpec,
+};
const options = normalizeOptions({});
const deps = { operation: { pointer: 'testSchema' } as OperationModel };
@@ -17,9 +37,9 @@ describe('Models', () => {
let parser;
test('parsing nested x-enumDescription', () => {
- const spec = require('./fixtures/nestedEnumDescroptionSample.json');
+ const spec = nestedEnumDescriptionSpec as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, options);
- const testSchema = spec.components.schemas.Test;
+ const testSchema = spec.components?.schemas?.Test as OpenAPISchema;
const schemaModel = getSchema({
parser,
schemaOrRef: testSchema,
@@ -29,12 +49,12 @@ describe('Models', () => {
});
expect(schemaModel['x-enumDescriptions']).toStrictEqual(
- testSchema.items['x-enumDescriptions'],
+ testSchema?.items?.['x-enumDescriptions'],
);
});
test('discriminator with one field', () => {
- const spec = require('./fixtures/discriminator.json');
+ const spec = discriminatorSpec;
parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
@@ -47,12 +67,64 @@ describe('Models', () => {
expect(schema.discriminatorProp).toEqual('type');
});
- test('oneOf/allOf titles', () => {
- const spec = require('./fixtures/oneOfTitles.json');
+ test('discriminator with defaultMapping adds it as last item', () => {
+ const spec = discriminatorWithDefaultMappingSpec;
parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Test,
+ schemaOrRef: spec.components.schemas.Pet,
+ pointer: '#/components/schemas/Pet',
+ options,
+ deps,
+ });
+ expect(schema.oneOf).toHaveLength(3);
+ expect(schema.oneOf?.[0].isDefaultMapping).toBe(false);
+ expect(schema.oneOf?.[0].title).toBe('cat');
+ expect(schema.oneOf?.[1].isDefaultMapping).toBe(false);
+ expect(schema.oneOf?.[1].title).toBe('dog');
+ expect(schema.oneOf?.[2].isDefaultMapping).toBe(true);
+ expect(schema.oneOf?.[2].title).toBe('Default mapping');
+ });
+
+ test('discriminator with only defaultMapping (no mapping)', () => {
+ const spec = discriminatorDefaultOnlySpec;
+ parser = new OpenAPIParser(spec, undefined, options);
+ const schema = getSchema({
+ parser,
+ schemaOrRef: spec.components.schemas.Animal,
+ pointer: '#/components/schemas/Animal',
+ options,
+ deps,
+ });
+ expect(schema.oneOf).toHaveLength(1);
+ expect(schema.oneOf?.[0].isDefaultMapping).toBe(true);
+ expect(schema.oneOf?.[0].title).toBe('Default mapping');
+ });
+
+ test('discriminator defaultMapping order preserved after sorting', () => {
+ const spec = discriminatorOrderedWithDefaultSpec;
+ parser = new OpenAPIParser(spec, undefined, options);
+ const schema = getSchema({
+ parser,
+ schemaOrRef: spec.components.schemas.Vehicle,
+ pointer: '#/components/schemas/Vehicle',
+ options,
+ deps,
+ });
+ expect(schema.oneOf).toHaveLength(4);
+ expect(schema.oneOf?.[0].title).toBe('truck');
+ expect(schema.oneOf?.[1].title).toBe('bike');
+ expect(schema.oneOf?.[2].title).toBe('car');
+ expect(schema.oneOf?.[3].title).toBe('Default mapping');
+ expect(schema.oneOf?.[3].isDefaultMapping).toBe(true);
+ });
+
+ test('oneOf/allOf titles', () => {
+ const spec = oneOfTitlesSpec as unknown as OpenAPIDefinition;
+ parser = new OpenAPIParser(spec, undefined, options);
+ const schema = getSchema({
+ parser,
+ schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema,
pointer: '',
options,
deps,
@@ -81,14 +153,14 @@ describe('Models', () => {
});
test('oneOf/allOf titles with hideSchemaTitles', () => {
- const spec = require('./fixtures/oneOfTitles.json');
+ const spec = oneOfTitlesSpec as unknown as OpenAPIDefinition;
const options = normalizeOptions({
hideSchemaTitles: true,
});
parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Test,
+ schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema,
pointer: '',
options,
deps,
@@ -113,11 +185,11 @@ describe('Models', () => {
});
test('oneOf/allOf schema complex displayType', () => {
- const spec = require('./fixtures/oneOfTitles.json');
+ const spec = oneOfTitlesSpec as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.WithArray,
+ schemaOrRef: spec.components?.schemas?.WithArray as OpenAPISchema,
pointer: '',
options,
deps,
@@ -126,50 +198,115 @@ describe('Models', () => {
expect(schema.displayType).toBe('(Array of strings or numbers) or string');
});
- test('oneOf titles should not be overridden by parent schema title', () => {
- const spec = {
- openapi: '3.1.0',
- components: {
- schemas: {
- Parent: {
- title: 'Parent',
- type: 'object',
- oneOf: [
- { $ref: '#/components/schemas/Child1' },
- { $ref: '#/components/schemas/Child2' },
- ],
- },
- Child1: {
- title: 'Child1',
- type: 'object',
- },
- Child2: {
- type: 'object',
- },
- },
- },
- };
+ test('oneOf/anyOf with pure $ref should NOT be marked as circular', () => {
+ const spec = parseYaml(outdent`
+ openapi: 3.1.0
+ components:
+ schemas:
+ ContactObject:
+ type: object
+ properties:
+ firstName:
+ type: string
+ lastName:
+ type: string
+ Customer:
+ type: object
+ properties:
+ primaryAddressOneOf:
+ description: Customer's primary address
+ oneOf:
+ - $ref: '#/components/schemas/ContactObject'
+ - type: 'null'
+ primaryAddressAnyOf:
+ description: Customer's primary address
+ anyOf:
+ - $ref: '#/components/schemas/ContactObject'
+ - type: 'null'
+ `) as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Parent,
- pointer: '',
+ schemaOrRef: spec.components?.schemas?.Customer as OpenAPISchema,
+ pointer: '#/components/schemas/Customer',
options,
deps,
});
- expect(schema.oneOf?.[0].rawSchema.allOf[0].title).toBe('Child1');
- expect(schema.oneOf?.[1].rawSchema.allOf[0].title).toBe('Child2');
+ expect(schema.isCircular).toBe(false);
+
+ const oneOfField = schema.fields?.find((f) => f.name === 'primaryAddressOneOf');
+ expect(oneOfField).toBeDefined();
+ expect(oneOfField?.schema.oneOf).toBeDefined();
+ expect(oneOfField?.schema.oneOf?.length).toBe(2);
+
+ oneOfField?.schema.oneOf?.forEach((variant) => {
+ expect(variant.isCircular).toBe(false);
+ });
+
+ const anyOfField = schema.fields?.find((f) => f.name === 'primaryAddressAnyOf');
+ expect(anyOfField).toBeDefined();
+ expect(anyOfField?.schema.oneOf).toBeDefined();
+ expect(anyOfField?.schema.oneOf?.length).toBe(2);
+
+ anyOfField?.schema.oneOf?.forEach((variant) => {
+ expect(variant.isCircular).toBe(false);
+ });
+ });
+
+ test('oneOf/anyOf with circular reference should be detected', () => {
+ const spec = parseYaml(outdent`
+ openapi: 3.1.0
+ components:
+ schemas:
+ TreeNode:
+ type: object
+ properties:
+ value:
+ type: string
+ nextOneOf:
+ oneOf:
+ - $ref: '#/components/schemas/TreeNode'
+ - type: 'null'
+ nextAnyOf:
+ anyOf:
+ - $ref: '#/components/schemas/TreeNode'
+ - type: 'null'
+ `) as unknown as OpenAPIDefinition;
+
+ parser = new OpenAPIParser(spec, undefined, options);
+
+ const schema = getSchema({
+ parser,
+ schemaOrRef: spec.components?.schemas?.TreeNode as OpenAPISchema,
+ pointer: '#/components/schemas/TreeNode',
+ options,
+ deps,
+ });
+
+ const oneOfField = schema.fields?.find((f) => f.name === 'nextOneOf');
+ expect(oneOfField).toBeDefined();
+ expect(oneOfField?.schema.oneOf).toBeDefined();
+
+ const oneOfCircularVariant = oneOfField?.schema.oneOf?.find((v) => v.isCircular);
+ expect(oneOfCircularVariant).toBeDefined();
+
+ const anyOfField = schema.fields?.find((f) => f.name === 'nextAnyOf');
+ expect(anyOfField).toBeDefined();
+ expect(anyOfField?.schema.oneOf).toBeDefined();
+
+ const anyOfCircularVariant = anyOfField?.schema.oneOf?.find((v) => v.isCircular);
+ expect(anyOfCircularVariant).toBeDefined();
});
test('schemaDefinition should resolve schema with conditional operators', () => {
- const spec = require('./fixtures/3.1/conditionalSchema.json');
+ const spec = conditionalSchemaSpec as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Test,
+ schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema,
pointer: '',
options,
deps,
@@ -184,11 +321,11 @@ describe('Models', () => {
});
test('schemaDefinition should resolve field with conditional operators', () => {
- const spec = require('./fixtures/3.1/conditionalField.json');
+ const spec = conditionalFieldSpec as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Test,
+ schemaOrRef: spec.components?.schemas?.Test as OpenAPISchema,
pointer: '',
options,
deps,
@@ -203,16 +340,16 @@ describe('Models', () => {
});
test('schemaDefinition should resolve patternProperties', () => {
- jest.spyOn(console, 'warn').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
- const spec = require('./fixtures/3.1/patternProperties.json');
+ const spec = patternPropertiesSpec as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, {
...options,
sortRequiredPropsFirst: true,
});
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Patterns,
+ schemaOrRef: spec.components?.schemas?.Patterns as OpenAPISchema,
pointer: '',
options: {
...options,
@@ -250,11 +387,11 @@ describe('Models', () => {
test.each(eachArray)(
'schemaDefinition should resolve prefixItems without additional items',
(specFixture) => {
- const spec = require(specFixture);
+ const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition;
const parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Case1,
+ schemaOrRef: spec.components?.schemas?.Case1 as OpenAPISchema,
pointer: '',
options,
deps,
@@ -272,11 +409,11 @@ describe('Models', () => {
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional items',
(specFixture) => {
- const spec = require(specFixture);
+ const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition;
const parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Case2,
+ schemaOrRef: spec.components?.schemas?.Case2 as OpenAPISchema,
pointer: '',
options,
deps,
@@ -295,11 +432,11 @@ describe('Models', () => {
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional items with $ref',
(specFixture) => {
- const spec = require(specFixture);
+ const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition;
const parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Case3,
+ schemaOrRef: spec.components?.schemas?.Case3 as OpenAPISchema,
pointer: '',
options,
deps,
@@ -319,11 +456,11 @@ describe('Models', () => {
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional schema items',
(specFixture) => {
- const spec = require(specFixture);
+ const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition;
const parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Case4,
+ schemaOrRef: spec.components?.schemas?.Case4 as OpenAPISchema,
pointer: '',
options,
deps,
@@ -342,11 +479,11 @@ describe('Models', () => {
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional array items',
(specFixture) => {
- const spec = require(specFixture);
+ const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition;
const parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Case5,
+ schemaOrRef: spec.components?.schemas?.Case5 as OpenAPISchema,
pointer: '',
options,
deps,
@@ -370,11 +507,11 @@ describe('Models', () => {
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional array items',
(specFixture) => {
- const spec = require(specFixture);
+ const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition;
const parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Case6,
+ schemaOrRef: spec.components?.schemas?.Case6 as OpenAPISchema,
pointer: '',
options,
deps,
@@ -392,11 +529,11 @@ describe('Models', () => {
test.each(eachArray)(
'schemaDefinition should resolve items with boolean type',
(specFixture) => {
- const spec = require(specFixture);
+ const spec = fixtureMap[specFixture] as unknown as OpenAPIDefinition;
const parser = new OpenAPIParser(spec, undefined, options);
const schema = getSchema({
parser,
- schemaOrRef: spec.components.schemas.Case7,
+ schemaOrRef: spec.components?.schemas?.Case7 as OpenAPISchema,
pointer: '',
options,
deps,
@@ -412,7 +549,7 @@ describe('Models', () => {
});
test('content example should be override the example provided by the schema', () => {
- const spec = require('./fixtures/fields.json');
+ const spec = fieldsSpec as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, options);
const field = getField(
parser,
@@ -829,5 +966,72 @@ describe('Models', () => {
maxItems: "
`);
});
+
+ test('oneOf child titles should not be overridden by parent schema title via $ref', () => {
+ const spec = {
+ openapi: '3.1.0',
+ components: {
+ schemas: {
+ DocumentMetadata: {
+ type: 'object',
+ properties: {
+ entityMetadata: {
+ $ref: '#/components/schemas/EntityMetadata',
+ },
+ },
+ },
+ EntityMetadata: {
+ title: 'EntityMetadata',
+ description: 'Metadata for associated entity of document.',
+ type: 'object',
+ oneOf: [
+ { $ref: '#/components/schemas/PnrMetadataWrapper' },
+ { $ref: '#/components/schemas/EventMetadataWrapper' },
+ ],
+ },
+ PnrMetadataWrapper: {
+ type: 'object',
+ title: 'PnrMetadataWrapper',
+ properties: {
+ pnrMetadata: {
+ type: 'string',
+ },
+ },
+ },
+ EventMetadataWrapper: {
+ type: 'object',
+ title: 'EventMetadataWrapper',
+ properties: {
+ eventMetadata: {
+ type: 'string',
+ },
+ },
+ },
+ },
+ },
+ } as unknown as OpenAPIDefinition;
+
+ parser = new OpenAPIParser(spec, undefined, options);
+
+ const documentSchema = getSchema({
+ parser,
+ schemaOrRef: spec.components?.schemas?.DocumentMetadata as OpenAPISchema,
+ pointer: '#/components/schemas/DocumentMetadata',
+ options,
+ deps,
+ });
+
+ const entityMetadataField = documentSchema.fields?.find((f) => f.name === 'entityMetadata');
+
+ expect(entityMetadataField).toBeDefined();
+ expect(entityMetadataField?.schema.oneOf).toBeDefined();
+ expect(entityMetadataField?.schema.oneOf).toHaveLength(2);
+
+ expect(entityMetadataField?.schema.oneOf?.[0].title).toBe('PnrMetadataWrapper');
+ expect(entityMetadataField?.schema.oneOf?.[1].title).toBe('EventMetadataWrapper');
+
+ expect(entityMetadataField?.schema.oneOf?.[0].title).not.toBe('EntityMetadata');
+ expect(entityMetadataField?.schema.oneOf?.[1].title).not.toBe('EntityMetadata');
+ });
});
});
diff --git a/src/models/__tests__/__snapshots__/Schema.test.ts.snap b/src/models/__tests__/__snapshots__/Schema.test.ts.snap
index d0911ff1..0b8b8f3c 100644
--- a/src/models/__tests__/__snapshots__/Schema.test.ts.snap
+++ b/src/models/__tests__/__snapshots__/Schema.test.ts.snap
@@ -1,6 +1,6 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`Models Schema schemaDefinition should resolve field with conditional operators 1`] = `
+exports[`Models > Schema > schemaDefinition should resolve field with conditional operators 1`] = `
{
"absolutePointer": "/properties/test",
"allOf": undefined,
@@ -36,7 +36,7 @@ exports[`Models Schema schemaDefinition should resolve field with conditional op
}
`;
-exports[`Models Schema schemaDefinition should resolve field with conditional operators 2`] = `
+exports[`Models > Schema > schemaDefinition should resolve field with conditional operators 2`] = `
{
"absolutePointer": "/1",
"allOf": undefined,
@@ -74,7 +74,7 @@ exports[`Models Schema schemaDefinition should resolve field with conditional op
}
`;
-exports[`Models Schema schemaDefinition should resolve schema with conditional operators 1`] = `
+exports[`Models > Schema > schemaDefinition should resolve schema with conditional operators 1`] = `
{
"absolutePointer": "/2",
"allOf": undefined,
@@ -129,7 +129,7 @@ exports[`Models Schema schemaDefinition should resolve schema with conditional o
}
`;
-exports[`Models Schema schemaDefinition should resolve schema with conditional operators 2`] = `
+exports[`Models > Schema > schemaDefinition should resolve schema with conditional operators 2`] = `
{
"absolutePointer": "/1",
"allOf": undefined,
diff --git a/src/models/__tests__/fixtures/callback.json b/src/models/__tests__/fixtures/callback.json
index 4e556669..0bf40bc7 100644
--- a/src/models/__tests__/fixtures/callback.json
+++ b/src/models/__tests__/fixtures/callback.json
@@ -4,6 +4,7 @@
"version": "1.0",
"title": "Foo"
},
+ "paths": {},
"components": {
"callbacks": {
"Test": {
diff --git a/src/models/__tests__/fixtures/discriminator-default-only.json b/src/models/__tests__/fixtures/discriminator-default-only.json
new file mode 100644
index 00000000..b65ba8e9
--- /dev/null
+++ b/src/models/__tests__/fixtures/discriminator-default-only.json
@@ -0,0 +1,38 @@
+{
+ "openapi": "3.2.0",
+ "info": {
+ "title": "Discriminator with Only Default Mapping",
+ "version": "1.0.0"
+ },
+ "paths": {},
+ "components": {
+ "schemas": {
+ "Animal": {
+ "type": "object",
+ "required": ["type"],
+ "discriminator": {
+ "propertyName": "type",
+ "defaultMapping": "#/components/schemas/GenericAnimal"
+ },
+ "properties": {
+ "type": {
+ "type": "string"
+ }
+ }
+ },
+ "GenericAnimal": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Animal"
+ }
+ ],
+ "properties": {
+ "species": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/models/__tests__/fixtures/discriminator-ordered-with-default.json b/src/models/__tests__/fixtures/discriminator-ordered-with-default.json
new file mode 100644
index 00000000..43f3940c
--- /dev/null
+++ b/src/models/__tests__/fixtures/discriminator-ordered-with-default.json
@@ -0,0 +1,62 @@
+{
+ "openapi": "3.2.0",
+ "info": {
+ "title": "Discriminator with Ordered Mapping and Default",
+ "version": "1.0.0"
+ },
+ "paths": {},
+ "components": {
+ "schemas": {
+ "Vehicle": {
+ "type": "object",
+ "required": ["type"],
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "truck": "#/components/schemas/Truck",
+ "bike": "#/components/schemas/Bike",
+ "car": "#/components/schemas/Car"
+ },
+ "defaultMapping": "#/components/schemas/GenericVehicle"
+ },
+ "properties": {
+ "type": {
+ "type": "string"
+ }
+ }
+ },
+ "Truck": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Vehicle"
+ }
+ ]
+ },
+ "Bike": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Vehicle"
+ }
+ ]
+ },
+ "Car": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Vehicle"
+ }
+ ]
+ },
+ "GenericVehicle": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Vehicle"
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/src/models/__tests__/fixtures/discriminator-with-default-mapping.json b/src/models/__tests__/fixtures/discriminator-with-default-mapping.json
new file mode 100644
index 00000000..4577ae6e
--- /dev/null
+++ b/src/models/__tests__/fixtures/discriminator-with-default-mapping.json
@@ -0,0 +1,68 @@
+{
+ "openapi": "3.2.0",
+ "info": {
+ "title": "Discriminator with Default Mapping",
+ "version": "1.0.0"
+ },
+ "paths": {},
+ "components": {
+ "schemas": {
+ "Pet": {
+ "type": "object",
+ "required": ["type"],
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "cat": "#/components/schemas/Cat",
+ "dog": "#/components/schemas/Dog"
+ },
+ "defaultMapping": "#/components/schemas/GenericPet"
+ },
+ "properties": {
+ "type": {
+ "type": "string"
+ }
+ }
+ },
+ "Cat": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Pet"
+ }
+ ],
+ "properties": {
+ "meow": {
+ "type": "boolean"
+ }
+ }
+ },
+ "Dog": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Pet"
+ }
+ ],
+ "properties": {
+ "bark": {
+ "type": "boolean"
+ }
+ }
+ },
+ "GenericPet": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Pet"
+ }
+ ],
+ "properties": {
+ "species": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/models/__tests__/fixtures/fields.json b/src/models/__tests__/fixtures/fields.json
index 2f6d1f47..e31d35c8 100644
--- a/src/models/__tests__/fixtures/fields.json
+++ b/src/models/__tests__/fixtures/fields.json
@@ -4,6 +4,7 @@
"version": "1.0",
"title": "Foo"
},
+ "paths": {},
"components": {
"parameters": {
"testParam": {
diff --git a/src/models/__tests__/request.test.ts b/src/models/__tests__/request.test.ts
index 5ab62c3c..989f23ac 100644
--- a/src/models/__tests__/request.test.ts
+++ b/src/models/__tests__/request.test.ts
@@ -1,11 +1,11 @@
import { parseYaml } from '@redocly/openapi-core';
import { outdent } from 'outdent';
-import type { OpenAPIDefinition } from '../../types';
-import type { OperationModel } from '../types';
+import type { OpenAPIDefinition } from '../../types/index.js';
+import type { OperationModel } from '../types.js';
-import { getRequestBody } from '../request';
-import { normalizeOptions, OpenAPIParser } from '../../services';
+import { getRequestBody } from '../request.js';
+import { normalizeOptions, OpenAPIParser } from '../../services/index.js';
const opts = normalizeOptions({});
describe('request', () => {
@@ -23,14 +23,14 @@ describe('request', () => {
});
test('should work with default props', () => {
- const consoleError = jest.spyOn(global.console, 'error');
+ const consoleError = vi.spyOn(global.console, 'error');
const req = getRequestBody(props);
expect(consoleError).not.toHaveBeenCalled();
expect(req).toEqual({ description: '', required: undefined });
});
test('should work with set required', () => {
- const consoleError = jest.spyOn(global.console, 'error');
+ const consoleError = vi.spyOn(global.console, 'error');
props.infoOrRef.required = false;
const req = getRequestBody(props);
expect(consoleError).not.toHaveBeenCalled();
diff --git a/src/models/group.ts b/src/models/group.ts
index 7b1b3cc9..40630c0f 100644
--- a/src/models/group.ts
+++ b/src/models/group.ts
@@ -14,7 +14,10 @@ export function getTagOrGroup(
parent?: GroupModel,
): GroupModel {
let id: string;
- if (parent?.id && (type === 'schema' || type === 'mcp')) {
+ if (
+ parent?.id &&
+ (type === 'schema' || type === 'tool' || type === 'rsrc' || type === 'prompt')
+ ) {
// handle schemas ids similar to how it is done for operations
id = joinWithSeparator(parent.id, safeSlugify(tagOrGroup.name));
} else {
diff --git a/src/models/mediaType.ts b/src/models/mediaType.ts
index e3fa96dc..48e2a835 100644
--- a/src/models/mediaType.ts
+++ b/src/models/mediaType.ts
@@ -69,7 +69,8 @@ function generateExamples({
schema.discriminatorProp &&
typeof sample === 'object' &&
sample &&
- sample[schema.discriminatorProp] // handle case with readOnly with discriminator
+ sample[schema.discriminatorProp] && // handle case with readOnly with discriminator
+ !subSchema.isDefaultMapping
) {
sample[schema.discriminatorProp] = subSchema.title;
}
@@ -148,6 +149,7 @@ export function getMediaType(
): MediaTypeModel {
let examples;
let formExamples;
+ let examplesPointer;
const schema =
info.schema &&
getSchema({
@@ -192,7 +194,9 @@ export function getMediaType(
format: isXmlLike(name) ? 'xml' : 'json',
},
});
+ examplesPointer = schema?.pointer;
} else if (isFormUrlEncoded(name) || isMultipartFormData(name)) {
+ examplesPointer = schema?.pointer;
formExamples = generateExamples({
parser,
info,
@@ -209,6 +213,7 @@ export function getMediaType(
return {
examples,
schema,
+ examplesPointer,
name,
isRequestType,
formExamples,
diff --git a/src/models/operation.ts b/src/models/operation.ts
index f4d4e353..be5bd1c2 100644
--- a/src/models/operation.ts
+++ b/src/models/operation.ts
@@ -70,6 +70,7 @@ export function getOperation(
operationId: operationDefinition.operationId,
path: operationDefinition.pathName,
isWebhook,
+ isAdditionalOperation: operationDefinition.isAdditionalOperation,
isCallback: Boolean(callback?.isCallback),
isEvent: callback?.isCallback || isWebhook,
name: getOperationName(operationDefinition),
diff --git a/src/models/schema.ts b/src/models/schema.ts
index e4adac8d..529110dc 100644
--- a/src/models/schema.ts
+++ b/src/models/schema.ts
@@ -26,6 +26,7 @@ export function getSchema({
pointer,
options,
isChild = false,
+ isDefaultMapping = false,
baseRefsStack = [],
deps,
absolutePointer,
@@ -35,6 +36,7 @@ export function getSchema({
pointer: string;
options: Options;
isChild?: boolean;
+ isDefaultMapping?: boolean;
baseRefsStack?: string[];
deps: Deps;
absolutePointer?: string;
@@ -54,6 +56,7 @@ export function getSchema({
operationPointer: deps.operation?.pointer || absolutePointer || '',
schemaOrRef,
isChild,
+ isDefaultMapping,
typePrefix: '',
pointer: schemaPointer,
absolutePointer,
@@ -289,13 +292,9 @@ function initDiscriminator({
const mapping = discriminator?.mapping || {};
- // Defines if the mapping is exhaustive. This avoids having references
- // that overlap with the mapping entries
- let isLimitedToMapping = discriminator?.['x-explicitMappingOnly'] || false;
- // if there are no mappings, assume non-exhaustive
- if (Object.keys(mapping).length === 0) {
- isLimitedToMapping = false;
- }
+ // When explicit mappings are defined, use only those mappings.
+ const isLimitedToMapping =
+ discriminator?.['x-explicitMappingOnly'] ?? Object.keys(mapping).length > 0;
const explicitInvertedMapping = {};
for (const name in mapping) {
@@ -313,7 +312,7 @@ function initDiscriminator({
? { ...explicitInvertedMapping }
: { ...implicitInvertedMapping, ...explicitInvertedMapping };
- let refs: Array<{ $ref; name }> = [];
+ let refs: Array<{ $ref; name; isDefaultMapping?: boolean }> = [];
for (const $ref of Object.keys(invertedMapping)) {
const names = invertedMapping[$ref];
@@ -326,6 +325,28 @@ function initDiscriminator({
}
}
+ if (discriminator?.defaultMapping) {
+ const defaultRefIdx = refs.findIndex(
+ (ref) => ref.name === JsonPointer.baseName(discriminator.defaultMapping),
+ );
+ const defaultMappingKey = 'Default mapping';
+
+ // if the default mapping added to refs from implicitInvertedMapping, update it to the default mapping key
+ if (~defaultRefIdx) {
+ refs[defaultRefIdx] = {
+ $ref: discriminator.defaultMapping,
+ name: defaultMappingKey,
+ isDefaultMapping: true,
+ };
+ } else {
+ refs.push({
+ $ref: discriminator.defaultMapping,
+ name: defaultMappingKey,
+ isDefaultMapping: true,
+ });
+ }
+ }
+
// Make the listing respects the mapping
// in case a mapping is defined, the user usually wants to have the order shown
// as it was defined in the yaml. This will sort the names given the provided
@@ -334,9 +355,17 @@ function initDiscriminator({
// - If a name is among the mapping, promote it to first
// - Names among the mapping are sorted by their order in the mapping
// - Names outside the mapping are sorted alphabetically
+ // - Default mapping is always last
const names = Object.keys(mapping);
if (names.length !== 0) {
refs = refs.sort((left, right) => {
+ if (left.isDefaultMapping && !right.isDefaultMapping) {
+ return 1;
+ }
+ if (!left.isDefaultMapping && right.isDefaultMapping) {
+ return -1;
+ }
+
const indexLeft = names.indexOf(left.name);
const indexRight = names.indexOf(right.name);
@@ -355,13 +384,14 @@ function initDiscriminator({
});
}
- const oneOf = refs.map(({ $ref, name }, index) => {
+ const oneOf = refs.map(({ $ref, name, isDefaultMapping }, index) => {
const innerSchema = getSchema({
parser,
schemaOrRef: { $ref },
pointer: $ref,
options,
isChild: true,
+ isDefaultMapping,
baseRefsStack: refsStack.slice(0, -1),
deps: {
...deps,
@@ -374,6 +404,7 @@ function initDiscriminator({
innerSchema.title = name;
return innerSchema;
});
+
return {
oneOf,
discriminatorProp,
diff --git a/src/models/types.ts b/src/models/types.ts
index d3d706bd..a30d1266 100644
--- a/src/models/types.ts
+++ b/src/models/types.ts
@@ -101,6 +101,7 @@ export type MediaTypeModel = {
isRequestType: boolean;
onlyRequiredInSamples: boolean;
operation: OperationModel;
+ examplesPointer?: string;
};
export type MediaContentModel = {
@@ -186,6 +187,7 @@ export type OperationModel = {
extensions: GenericObject;
isCallback: boolean;
isWebhook: boolean;
+ isAdditionalOperation: boolean;
isEvent: boolean;
callbackId: string;
requestBody?: RequestBodyModel;
@@ -217,6 +219,7 @@ export type GroupModel = {
isSchema?: boolean;
type: MenuItemGroupType;
deprecated?: boolean;
+ isAdditionalOperation?: boolean;
badges?: OpenAPIXBadges[];
items: ContentItemModel[];
@@ -242,6 +245,7 @@ export type OperationMenuItem = {
httpVerb: string;
path: string;
isWebhook: boolean;
+ isAdditionalOperation: boolean;
operationId?: string;
badges?: OpenAPIXBadges[];
};
@@ -283,6 +287,7 @@ export type SchemaModel = {
oneOf?: SchemaModel[];
oneOfType: string;
discriminatorProp?: string;
+ isDefaultMapping?: boolean;
rawSchema: OpenAPISchema;
schema: MergedOpenAPISchema;
diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts
index ae115a55..47f560f6 100644
--- a/src/services/OpenAPIParser.ts
+++ b/src/services/OpenAPIParser.ts
@@ -135,13 +135,11 @@ export class OpenAPIParser {
}
mergeRefs(ref: OpenAPIRef, resolved: T, mergeAsAllOf: boolean): T {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $ref, ...rest } = ref;
const keys = Object.keys(rest);
if (keys.length === 0) {
- return {
- ...resolved,
- $ref,
- };
+ return resolved;
}
if (
mergeAsAllOf &&
@@ -250,6 +248,7 @@ export class OpenAPIParser {
};
})
.filter(Boolean) as Array<{
+ $ref?: string;
schema: MergedOpenAPISchema;
refsStack: string[];
absolutePointer: string;
@@ -257,7 +256,7 @@ export class OpenAPIParser {
for (const [
index,
- { schema: subSchema, refsStack: subRefsStack, absolutePointer },
+ { $ref: itemRef, schema: subSchema, refsStack: subRefsStack, absolutePointer },
] of allOfSchemas.entries()) {
const {
type,
@@ -359,7 +358,7 @@ export class OpenAPIParser {
// TODO: do more intelligent merge
receiver = {
...receiver,
- title: receiver.title || title,
+ title: itemRef && title ? title : receiver.title || title,
description: receiver.description || description,
readOnly: !isUndefined(receiver.readOnly) ? receiver.readOnly : readOnly,
writeOnly: !isUndefined(receiver.writeOnly) ? receiver.writeOnly : writeOnly,
diff --git a/src/services/__tests__/OpenAPIParser.test.ts b/src/services/__tests__/OpenAPIParser.test.ts
index eb53743f..02bd6f8e 100644
--- a/src/services/__tests__/OpenAPIParser.test.ts
+++ b/src/services/__tests__/OpenAPIParser.test.ts
@@ -1,5 +1,7 @@
-import { OpenAPIParser } from '../OpenAPIParser';
-import { normalizeOptions } from '../config-options';
+import type { OpenAPIDefinition, OpenAPISchema } from '../../types/index.js';
+
+import { OpenAPIParser } from '../OpenAPIParser.js';
+import { normalizeOptions } from '../config-options/index.js';
const opts = normalizeOptions({});
@@ -8,39 +10,50 @@ describe('Models', () => {
let parser;
test('should hoist oneOfs when mergin allOf', async () => {
- const spec = (await import('./fixtures/oneOfHoist.json')).default;
+ const spec = (await import('./fixtures/oneOfHoist.json'))
+ .default as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, opts);
- expect(parser.mergeAllOf(spec.components.schemas.test)).toMatchSnapshot();
+ expect(parser.mergeAllOf(spec.components?.schemas?.test)).toMatchSnapshot();
});
test('should not crash on self-referencing array in allOf', async () => {
- const spec = (await import('./fixtures/allOfSelfReferencingArray.json')).default;
+ const spec = (await import('./fixtures/allOfSelfReferencingArray.json'))
+ .default as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, opts);
- expect(parser.mergeAllOf(spec.components.schemas.Test)).toMatchSnapshot();
+ expect(parser.mergeAllOf(spec.components?.schemas?.Test)).toMatchSnapshot();
});
test('should get schema name from named schema', async () => {
- const spec = (await import('./fixtures/mergeAllOf.json')).default;
+ const spec = (await import('./fixtures/mergeAllOf.json'))
+ .default as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, opts);
- const schema = parser.mergeAllOf(spec.components.schemas.Case1, '#/components/schemas/Case1');
+ const schema = parser.mergeAllOf(
+ spec.components?.schemas?.Case1,
+ '#/components/schemas/Case1',
+ );
expect(schema.title).toEqual('Case1');
});
test('should get schema name from first allOf', async () => {
- const spec = (await import('./fixtures/mergeAllOf.json')).default;
+ const spec = (await import('./fixtures/mergeAllOf.json'))
+ .default as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, opts);
+ const Case2 = spec.components?.schemas?.Case2 as OpenAPISchema & {
+ properties: { a: OpenAPISchema };
+ };
const schema = parser.mergeAllOf(
- spec.components.schemas.Case2.properties.a,
+ Case2.properties.a,
'#components/schemas/Case2/properties/a',
);
expect(schema.title).toEqual('Bar');
});
test('should get schema name from named schema in allOf', async () => {
- const spec = (await import('./fixtures/mergeAllOf.json')).default;
+ const spec = (await import('./fixtures/mergeAllOf.json'))
+ .default as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, opts);
const schema = parser.mergeAllOf(
- spec.components.schemas.Case3.schemas.Foo,
+ (spec.components?.schemas?.Case3 as { schemas: { Foo: unknown } })?.schemas?.Foo,
'#components/schemas/Case3/schemas/Foo',
);
expect(schema.title).toEqual('Foo');
@@ -49,9 +62,10 @@ describe('Models', () => {
test('should merge oneOf to inside allOff', async () => {
// TODO: should hoist
- const spec = (await import('./fixtures/mergeAllOf.json')).default;
+ const spec = (await import('./fixtures/mergeAllOf.json'))
+ .default as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, opts);
- const schema = parser.mergeAllOf(spec.components.schemas.Case4);
+ const schema = parser.mergeAllOf(spec.components?.schemas?.Case4);
expect(schema.title).toEqual('Foo');
expect(schema['x-parentRefs']).toHaveLength(1);
expect(schema['x-parentRefs'][0]).toEqual('#/components/schemas/Ref');
diff --git a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap
index b086cdf4..cac41c74 100644
--- a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap
+++ b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap
@@ -1,6 +1,6 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`Models Schema should hoist oneOfs when mergin allOf 1`] = `
+exports[`Models > Schema > should hoist oneOfs when mergin allOf 1`] = `
{
"absolutePointer": "",
"oneOf": [
@@ -150,7 +150,7 @@ exports[`Models Schema should hoist oneOfs when mergin allOf 1`] = `
}
`;
-exports[`Models Schema should not crash on self-referencing array in allOf 1`] = `
+exports[`Models > Schema > should not crash on self-referencing array in allOf 1`] = `
{
"absolutePointer": "#/components/schemas/Array",
"allOf": undefined,
diff --git a/src/services/__tests__/check.test.ts b/src/services/__tests__/check.test.ts
index f2ae651d..7edc1751 100644
--- a/src/services/__tests__/check.test.ts
+++ b/src/services/__tests__/check.test.ts
@@ -1,4 +1,4 @@
-import { isHostAllowed } from '../check';
+import { isHostAllowed } from '../check.js';
describe('license check', () => {
test('isHostAllowed should work properly', () => {
diff --git a/src/services/__tests__/telemetry.test.ts b/src/services/__tests__/telemetry.test.ts
index 42bd5149..ade39d29 100644
--- a/src/services/__tests__/telemetry.test.ts
+++ b/src/services/__tests__/telemetry.test.ts
@@ -1,9 +1,9 @@
import type { EventType } from '@redocly/redoc-opentelemetry';
describe('RedocTelemetry', () => {
- const mockFetch = jest.fn();
- const mockRandomUUID = jest.fn();
- const mockGetRandomValues = jest.fn();
+ const mockFetch = vi.fn();
+ const mockRandomUUID = vi.fn();
+ const mockGetRandomValues = vi.fn();
beforeAll(() => {
global.fetch = mockFetch;
@@ -18,9 +18,9 @@ describe('RedocTelemetry', () => {
});
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
// A new instance is needed to clear session ID for each test
- jest.resetModules();
+ vi.resetModules();
});
it('should send an event with the correct structure', async () => {
@@ -47,7 +47,7 @@ describe('RedocTelemetry', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
const fetchCall = mockFetch.mock.calls[0];
- expect(fetchCall[0]).toBe('https://otel.cloud.redocly.com/v1/traces');
+ expect(fetchCall[0]).toBe('https://otel.blueharvest.cloud/v1/traces');
expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
@@ -67,15 +67,24 @@ describe('RedocTelemetry', () => {
});
expect(spanAttributes).toContainEqual({
key: 'cloudevents.event_data.booleanValue',
- value: { booleanValue: 'true' },
+ value: { boolValue: true },
});
expect(spanAttributes).toContainEqual({
key: 'cloudevents.event_data.objectValue',
- value: { objValue: '{"key":"value"}' },
+ value: {
+ kvlistValue: {
+ values: [
+ {
+ key: 'key',
+ value: { stringValue: 'value' },
+ },
+ ],
+ },
+ },
});
expect(spanAttributes).toContainEqual({
key: 'cloudevents.event_data.nullValue',
- value: { objValue: 'null' },
+ value: { stringValue: 'null' },
});
const resourceAttributes = body.resourceSpans[0].resource.attributes;
@@ -85,6 +94,93 @@ describe('RedocTelemetry', () => {
});
});
+ it('should convert complex payloads to OTLP values', async () => {
+ mockRandomUUID.mockReturnValue('complex-session-uuid');
+ mockGetRandomValues.mockImplementation((arr) => {
+ for (let i = 0; i < arr.length; i++) {
+ arr[i] = i;
+ }
+ return arr;
+ });
+
+ const { redocTelemetry: redocTelemetryInstance } = await import('../telemetry');
+
+ await redocTelemetryInstance.sendEvent(
+ 'complex_event' as EventType,
+ {
+ arrayValue: ['alpha', 5, true, null, { foo: 'bar' }],
+ nestedObject: { innerNumber: 1.5, innerArray: [1, 2], innerObject: { baz: false } },
+ shouldIgnore: undefined,
+ } as any,
+ );
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ const spanAttributes = body.resourceSpans[0].scopeSpans[0].spans[0].attributes;
+
+ expect(spanAttributes).toContainEqual({
+ key: 'cloudevents.event_data.arrayValue',
+ value: {
+ arrayValue: {
+ values: [
+ { stringValue: 'alpha' },
+ { intValue: 5 },
+ { boolValue: true },
+ { stringValue: 'null' },
+ {
+ kvlistValue: {
+ values: [
+ {
+ key: 'foo',
+ value: { stringValue: 'bar' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ expect(spanAttributes).toContainEqual({
+ key: 'cloudevents.event_data.nestedObject',
+ value: {
+ kvlistValue: {
+ values: [
+ {
+ key: 'innerNumber',
+ value: { doubleValue: 1.5 },
+ },
+ {
+ key: 'innerArray',
+ value: {
+ arrayValue: {
+ values: [{ intValue: 1 }, { intValue: 2 }],
+ },
+ },
+ },
+ {
+ key: 'innerObject',
+ value: {
+ kvlistValue: {
+ values: [
+ {
+ key: 'baz',
+ value: { boolValue: false },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ const attributeKeys = spanAttributes.map((attribute: { key: string }) => attribute.key);
+ expect(attributeKeys).not.toContain('cloudevents.event_data.shouldIgnore');
+ });
+
it('should generate and reuse session ID', async () => {
mockRandomUUID.mockReturnValueOnce('first-uuid').mockReturnValueOnce('second-uuid');
diff --git a/src/services/__tests__/utils.test.ts b/src/services/__tests__/utils.test.ts
index 899f2b86..2f3136cb 100644
--- a/src/services/__tests__/utils.test.ts
+++ b/src/services/__tests__/utils.test.ts
@@ -3,7 +3,7 @@ import {
normalizeMimeType,
unescapeServerVariables,
arrayMergeStrategy,
-} from '../utils';
+} from '../utils.js';
describe('services utils', () => {
test('isSameMime should equal mime', () => {
diff --git a/src/services/config-options/__tests__/helpers.test.ts b/src/services/config-options/__tests__/helpers.test.ts
index 87c18842..c8415375 100644
--- a/src/services/config-options/__tests__/helpers.test.ts
+++ b/src/services/config-options/__tests__/helpers.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach, afterAll, jest } from '@jest/globals';
+import { vi, type MockedFunction } from 'vitest';
import {
normalizeShowExtensions,
normalizeScrollYOffset,
@@ -6,18 +6,18 @@ import {
import { querySelector } from '../../../utils/dom.js';
import { isNumeric } from '../../../utils/helpers.js';
-jest.mock('../../../utils/dom.js');
-jest.mock('../../../utils/helpers.js');
+vi.mock('../../../utils/dom.js');
+vi.mock('../../../utils/helpers.js');
-const mockQuerySelector = querySelector as jest.MockedFunction;
-const mockIsNumeric = isNumeric as jest.MockedFunction;
+const mockQuerySelector = querySelector as MockedFunction;
+const mockIsNumeric = isNumeric as unknown as MockedFunction;
describe('helpers', () => {
const originalConsoleWarn = console.warn;
beforeEach(() => {
- jest.clearAllMocks();
- console.warn = jest.fn();
+ vi.clearAllMocks();
+ console.warn = vi.fn();
});
afterAll(() => {
@@ -85,8 +85,8 @@ describe('helpers', () => {
it('should return function that returns element bottom for valid selector', () => {
const mockElement = {
- getBoundingClientRect: jest.fn().mockReturnValue({ bottom: 100 }),
- } as any;
+ getBoundingClientRect: vi.fn().mockReturnValue({ bottom: 100 }),
+ } as unknown as Element;
mockIsNumeric.mockReturnValue(false);
mockQuerySelector.mockReturnValue(mockElement);
@@ -129,7 +129,7 @@ describe('helpers', () => {
});
it('should handle function that returns number', () => {
- const mockFn = jest.fn<() => number>().mockReturnValue(120);
+ const mockFn = vi.fn<() => number>().mockReturnValue(120);
const offsetFn = normalizeScrollYOffset(mockFn);
const result = offsetFn();
@@ -139,9 +139,9 @@ describe('helpers', () => {
});
it('should warn when function returns non-number', () => {
- const mockFn = jest.fn<() => unknown>().mockReturnValue('invalid');
+ const mockFn = vi.fn<() => unknown>().mockReturnValue('invalid');
- const offsetFn = normalizeScrollYOffset(mockFn as any);
+ const offsetFn = normalizeScrollYOffset(mockFn as () => number);
const result = offsetFn();
expect(console.warn).toHaveBeenCalledWith(
@@ -151,7 +151,7 @@ describe('helpers', () => {
});
it('should return function that returns 0 for invalid value types', () => {
- const offsetFn = normalizeScrollYOffset({} as any);
+ const offsetFn = normalizeScrollYOffset({} as unknown as number);
const result = offsetFn();
expect(console.warn).toHaveBeenCalledWith(
diff --git a/src/services/history/__tests__/helpers.test.ts b/src/services/history/__tests__/helpers.test.ts
index 17ad4a02..6b204bf2 100644
--- a/src/services/history/__tests__/helpers.test.ts
+++ b/src/services/history/__tests__/helpers.test.ts
@@ -1,4 +1,4 @@
-import { joinWithSeparator } from '../helpers';
+import { joinWithSeparator } from '../helpers.js';
describe('History helpers', () => {
describe('joinWithSeparator', () => {
diff --git a/src/services/history/helpers.ts b/src/services/history/helpers.ts
index 12bf20d3..29548d79 100644
--- a/src/services/history/helpers.ts
+++ b/src/services/history/helpers.ts
@@ -3,11 +3,6 @@ import type { FieldModel } from '../../models/index.js';
import { encodeBackSlashes, queryString } from '../../utils/index.js';
import { getActiveMediaType } from '../../models/mediaContent.js';
-export function isSameHash(a: string, b: string): boolean {
- return a === b || '#' + a === b || a === '#' + b;
-}
-
-// TODO: Add tests
function getContentTypeName(field: FieldModel, activeMimeName?: string) {
// param in "query" | "header" | "path" | "cookie" - no need for content type in link
if (field.in) {
diff --git a/src/services/menu/__tests__/builder.test.ts b/src/services/menu/__tests__/builder.test.ts
index bb809a3a..a49b22b0 100644
--- a/src/services/menu/__tests__/builder.test.ts
+++ b/src/services/menu/__tests__/builder.test.ts
@@ -1,7 +1,10 @@
-import { normalizeOptions } from '../../config-options';
-import { OpenAPIParser } from '../../OpenAPIParser';
-import { buildMenuStructure } from '../builder';
-import { addMarkdownItems } from '../markdown';
+import type { OperationMenuItem } from '../../../models/index.js';
+import type { OpenAPIDefinition } from '../../../types/index.js';
+
+import { normalizeOptions } from '../../config-options/index.js';
+import { OpenAPIParser } from '../../OpenAPIParser.js';
+import { buildMenuStructure } from '../builder.js';
+import { addMarkdownItems } from '../markdown.js';
const opts = normalizeOptions({});
@@ -10,7 +13,8 @@ describe('Menu', () => {
let parser;
test('should resolve pathItems', async () => {
- const spec = (await import('./fixtures/pathItems.json')).default;
+ const spec = (await import('./fixtures/pathItems.json'))
+ .default as unknown as OpenAPIDefinition;
parser = new OpenAPIParser(spec, undefined, opts);
const contentItems = buildMenuStructure(parser, opts);
expect(contentItems).toHaveLength(3);
@@ -23,6 +27,116 @@ describe('Menu', () => {
expect(contentItems[2].name).toEqual('pet');
expect(contentItems[2].type).toEqual('tag');
});
+
+ test('should include operations from additionalOperations', () => {
+ const spec = {
+ openapi: '3.2.0',
+ info: { title: 'Test API', version: '1.0.0' },
+ tags: [{ name: 'operations' }],
+ paths: {
+ '/test': {
+ get: {
+ summary: 'Get test',
+ tags: ['operations'],
+ responses: { '200': { description: 'Success' } },
+ },
+ additionalOperations: {
+ test: {
+ summary: 'Custom TEST method',
+ tags: ['operations'],
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ },
+ };
+
+ parser = new OpenAPIParser(spec, undefined, opts);
+ const contentItems = buildMenuStructure(parser, opts);
+
+ const operationsTag = contentItems.find((item) => item.id === 'operations');
+ expect(operationsTag).toBeDefined();
+ expect(operationsTag?.items).toHaveLength(2);
+
+ const getOperation = operationsTag?.items.find(
+ (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'get',
+ ) as OperationMenuItem;
+ expect(getOperation).toBeDefined();
+ expect(getOperation.type).toBe('operation');
+
+ const testOperation = operationsTag?.items.find(
+ (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'test',
+ ) as OperationMenuItem;
+ expect(testOperation).toBeDefined();
+ expect(testOperation.type).toBe('operation');
+ expect(testOperation.name).toContain('TEST');
+ });
+
+ test('should handle paths with only additionalOperations', () => {
+ const spec = {
+ openapi: '3.2.0',
+ info: { title: 'Test API', version: '1.0.0' },
+ tags: [{ name: 'custom' }],
+ paths: {
+ '/custom-only': {
+ additionalOperations: {
+ greet: {
+ summary: 'Custom GREET method',
+ tags: ['custom'],
+ responses: { '200': { description: 'Success' } },
+ },
+ wave: {
+ summary: 'Custom WAVE method',
+ tags: ['custom'],
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ },
+ };
+
+ parser = new OpenAPIParser(spec, undefined, opts);
+ const contentItems = buildMenuStructure(parser, opts);
+
+ const customTag = contentItems.find((item) => item.id === 'custom');
+ expect(customTag).toBeDefined();
+ expect(customTag?.items).toHaveLength(2);
+
+ const greetOperation = customTag?.items.find(
+ (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'greet',
+ );
+ expect(greetOperation).toBeDefined();
+
+ const waveOperation = customTag?.items.find(
+ (item) => item.type === 'operation' && (item as OperationMenuItem).httpVerb === 'wave',
+ );
+ expect(waveOperation).toBeDefined();
+ });
+
+ test('should not fail when additionalOperations is empty', () => {
+ const spec = {
+ openapi: '3.2.0',
+ info: { title: 'Test API', version: '1.0.0' },
+ tags: [{ name: 'operations' }],
+ paths: {
+ '/test': {
+ get: {
+ summary: 'Get test',
+ tags: ['operations'],
+ responses: { '200': { description: 'Success' } },
+ },
+ additionalOperations: {},
+ },
+ },
+ };
+
+ parser = new OpenAPIParser(spec, undefined, opts);
+ const contentItems = buildMenuStructure(parser, opts);
+
+ const operationsTag = contentItems.find((item) => item.id === 'operations');
+ expect(operationsTag).toBeDefined();
+ expect(operationsTag?.items).toHaveLength(1);
+ });
});
describe('addMarkdownItems', () => {
diff --git a/src/services/menu/__tests__/fixtures/pathItems.json b/src/services/menu/__tests__/fixtures/pathItems.json
index ffcdbe5b..09873fa1 100644
--- a/src/services/menu/__tests__/fixtures/pathItems.json
+++ b/src/services/menu/__tests__/fixtures/pathItems.json
@@ -5,6 +5,7 @@
"title": "Swagger Petstore"
},
"tags": [{ "name": "pet2" }, { "name": "pet" }],
+ "paths": {},
"webhooks": {
"myWebhook": {
"$ref": "#/components/pathItems/catsWebhook",
diff --git a/src/services/menu/__tests__/operation.test.ts b/src/services/menu/__tests__/operation.test.ts
new file mode 100644
index 00000000..0f06c489
--- /dev/null
+++ b/src/services/menu/__tests__/operation.test.ts
@@ -0,0 +1,112 @@
+import { getOperationsItems } from '../operation.js';
+
+vi.mock('../../../utils/index.js', () => ({
+ encodeBackSlashes: vi.fn((str) => str.toLowerCase()),
+ getOperationId: vi.fn(
+ (operation, parent) => `${parent?.id || 'root'}-${operation.httpVerb}-${operation.pathName}`,
+ ),
+ getOperationName: vi.fn(
+ (operation) => operation.summary || `${operation.httpVerb.toUpperCase()} ${operation.pathName}`,
+ ),
+}));
+
+describe('getOperationsItems sorting logic', () => {
+ const mockParent = {
+ id: 'test-tag',
+ name: 'Test Tag',
+ type: 'tag' as const,
+ items: [],
+ depth: 1,
+ level: 1,
+ href: '/test-tag',
+ };
+
+ const createMockOperation = (overrides = {}) => ({
+ httpVerb: 'get',
+ pathName: '/test',
+ summary: 'Test operation',
+ deprecated: false,
+ isAdditionalOperation: false,
+ isWebhook: false,
+ operationId: 'test-operation',
+ 'x-badges': [],
+ ...overrides,
+ });
+
+ const createMockTag = (operations) => ({
+ name: 'test-tag',
+ operations,
+ });
+
+ test('should sort non-deprecated operations before deprecated ones', () => {
+ const operations = [
+ createMockOperation({ httpVerb: 'post', summary: 'Deprecated POST', deprecated: true }),
+ createMockOperation({ httpVerb: 'get', summary: 'Active GET', deprecated: false }),
+ createMockOperation({ httpVerb: 'put', summary: 'Deprecated PUT', deprecated: true }),
+ createMockOperation({ httpVerb: 'delete', summary: 'Active DELETE', deprecated: false }),
+ ];
+
+ const tag = createMockTag(operations);
+ const result = getOperationsItems(mockParent, tag, 1);
+
+ expect(result).toHaveLength(4);
+ expect(result[0].httpVerb).toBe('get');
+ expect(result[0].deprecated).toBe(false);
+ expect(result[1].httpVerb).toBe('delete');
+ expect(result[1].deprecated).toBe(false);
+ expect(result[2].httpVerb).toBe('post');
+ expect(result[2].deprecated).toBe(true);
+ expect(result[3].httpVerb).toBe('put');
+ expect(result[3].deprecated).toBe(true);
+ });
+
+ test('should apply combined sorting: non-deprecated regular, non-deprecated additional, deprecated regular, deprecated additional', () => {
+ const operations = [
+ createMockOperation({
+ httpVerb: 'deprecated-additional',
+ summary: 'Deprecated additional operation',
+ isAdditionalOperation: true,
+ deprecated: true,
+ }),
+ createMockOperation({
+ httpVerb: 'active-regular',
+ summary: 'Active regular operation',
+ isAdditionalOperation: false,
+ deprecated: false,
+ }),
+ createMockOperation({
+ httpVerb: 'deprecated-regular',
+ summary: 'Deprecated regular operation',
+ isAdditionalOperation: false,
+ deprecated: true,
+ }),
+ createMockOperation({
+ httpVerb: 'active-additional',
+ summary: 'Active additional operation',
+ isAdditionalOperation: true,
+ deprecated: false,
+ }),
+ ];
+
+ const tag = createMockTag(operations);
+ const result = getOperationsItems(mockParent, tag, 1);
+
+ expect(result).toHaveLength(4);
+
+ expect(result[0].httpVerb).toBe('active-regular');
+ expect(result[0].deprecated).toBe(false);
+ expect(result[0].isAdditionalOperation).toBe(false);
+
+ expect(result[1].httpVerb).toBe('active-additional');
+ expect(result[1].deprecated).toBe(false);
+ expect(result[1].isAdditionalOperation).toBe(true);
+
+ expect(result[2].httpVerb).toBe('deprecated-regular');
+ expect(result[2].deprecated).toBe(true);
+ expect(result[2].isAdditionalOperation).toBe(false);
+
+ expect(result[3].httpVerb).toBe('deprecated-additional');
+ expect(result[3].deprecated).toBe(true);
+ expect(result[3].isAdditionalOperation).toBe(true);
+ });
+});
diff --git a/src/services/menu/__tests__/tags.test.ts b/src/services/menu/__tests__/tags.test.ts
new file mode 100644
index 00000000..e53bcbb8
--- /dev/null
+++ b/src/services/menu/__tests__/tags.test.ts
@@ -0,0 +1,85 @@
+import { processOperation } from '../tags.js';
+import type { OpenAPIParser } from '../../OpenAPIParser.js';
+
+describe('processOperation', () => {
+ let parser: OpenAPIParser;
+ let tagsMap: any;
+
+ beforeEach(() => {
+ parser = {
+ definition: {},
+ deref: vi.fn().mockReturnValue({ resolved: {} }),
+ } as unknown as OpenAPIParser;
+
+ tagsMap = {};
+ });
+
+ it('adds operation to existing tag', () => {
+ const operationInfo = { summary: 'Test op', tags: ['tag1'] };
+ tagsMap['tag1'] = { name: 'tag1', operations: [] };
+
+ processOperation(parser, 'get', operationInfo, '/path', {}, tagsMap);
+
+ expect(tagsMap['tag1'].operations).toHaveLength(1);
+ expect(tagsMap['tag1'].operations[0].summary).toBe('Test op');
+ });
+
+ it('creates tag if it does not exist', () => {
+ const operationInfo = { summary: 'New op', tags: ['newTag'] };
+
+ processOperation(parser, 'post', operationInfo, '/path', {}, tagsMap);
+
+ expect(tagsMap['newTag']).toBeDefined();
+ expect(tagsMap['newTag'].operations[0].summary).toBe('New op');
+ });
+
+ it('assigns empty tag if operation has no tags', () => {
+ const operationInfo = { summary: 'No tag op' };
+
+ processOperation(parser, 'put', operationInfo, '/path', {}, tagsMap);
+
+ expect(tagsMap['']).toBeDefined();
+ expect(tagsMap[''].operations[0].httpVerb).toBe('put');
+ });
+
+ it('assigns default webhook tag if isWebhook is true and no tags', () => {
+ const operationInfo = { summary: 'Webhook op' };
+
+ processOperation(parser, 'post', operationInfo, '/webhook', {}, tagsMap, true);
+
+ expect(tagsMap['webhooks']).toBeDefined();
+ expect(tagsMap['webhooks'].operations[0].isWebhook).toBe(true);
+ });
+
+ it('skips operations with x-traitTag', () => {
+ const operationInfo = { summary: 'Trait op', tags: ['traitTag'] };
+ tagsMap['traitTag'] = { name: 'traitTag', operations: [], 'x-traitTag': true };
+
+ processOperation(parser, 'get', operationInfo, '/path', {}, tagsMap);
+
+ expect(tagsMap['traitTag'].operations).toHaveLength(0);
+ });
+
+ it('processes additionalOperations correctly', () => {
+ const path = {
+ additionalOperations: {
+ test: { summary: 'Additional op', tags: ['tag2'] },
+ },
+ };
+ tagsMap['tag2'] = { name: 'tag2', operations: [] };
+
+ processOperation(
+ parser,
+ 'test',
+ path.additionalOperations.test,
+ '/path',
+ path,
+ tagsMap,
+ false,
+ true,
+ );
+
+ expect(tagsMap['tag2'].operations).toHaveLength(1);
+ expect(tagsMap['tag2'].operations[0].isAdditionalOperation).toBe(true);
+ });
+});
diff --git a/src/services/menu/operation.ts b/src/services/menu/operation.ts
index 86bfca72..4a4fac96 100644
--- a/src/services/menu/operation.ts
+++ b/src/services/menu/operation.ts
@@ -34,6 +34,7 @@ export function getOperationsItems(
httpVerb: operationDefinition.httpVerb,
path: operationDefinition.pathName,
items: [],
+ isAdditionalOperation: operationDefinition.isAdditionalOperation,
isWebhook: operationDefinition.isWebhook,
operationId: operationDefinition.operationId,
badges: operationDefinition['x-badges'] || [],
@@ -49,7 +50,11 @@ export function getOperationsItems(
}
return [
- ...operations.sort((a, b) => Number(a.deprecated) - Number(b.deprecated)),
+ ...operations.sort(
+ (a, b) =>
+ Number(a.deprecated) - Number(b.deprecated) ||
+ Number(a.isAdditionalOperation) - Number(b.isAdditionalOperation),
+ ),
...webhooks.sort((a, b) => Number(a.deprecated) - Number(b.deprecated)), // webhooks must be in the end for adding separator
];
}
diff --git a/src/services/menu/tags.ts b/src/services/menu/tags.ts
index 393a9994..479d3c36 100644
--- a/src/services/menu/tags.ts
+++ b/src/services/menu/tags.ts
@@ -22,7 +22,7 @@ import { getOperationsItems } from './operation.js';
import { addMarkdownItems } from './markdown.js';
import { joinWithSeparator } from '../history/helpers.js';
-const SUPPORTED_MCP_TYPES = ['tools']; // TODO: implement resources and prompts
+const SUPPORTED_MCP_TYPES = ['tools', 'resources', 'prompts']; // TODO: implement resources and prompts
/**
* Returns array of OperationsGroup items for the tags of the group or for all tags
@@ -188,12 +188,14 @@ function getTagRelatedMcp(
const toolTags = tool.tags?.length ? tool.tags : defaultTags;
if (toolTags.includes(tag.name)) {
const id = joinWithSeparator(parent.id, safeSlugify(tool.name));
+ const prefix = type === 'tools' ? `Tool name: \`${tool.name}\`\n\n` : '';
+ const customType = type === 'tools' ? 'tool' : type === 'resources' ? 'rsrc' : 'prompt';
const item = getTagOrGroup(
- 'mcp',
+ customType,
{
name: tool.name,
'x-displayName': tool.title || tool.name,
- description: `${tool.description || ''}\n{% mcp${typeName.slice(0, -1)} toolName="${tool.name}" id="${id}" /%}`,
+ description: `${prefix}${tool.description || ''}\n{% mcp${typeName.slice(0, -1)} name="${tool.name}" id="${id}" /%}`,
isSchema: true,
level: 2,
} as OpenAPITag,
@@ -256,6 +258,54 @@ function getMcpTags(mcp: OpenAPIMcp, tags: TagsInfoMap) {
}
}
+export function processOperation(
+ parser: OpenAPIParser,
+ operationName: string,
+ operationInfo: any,
+ pathName: string,
+ path: any,
+ tags: TagsInfoMap,
+ isWebhook?: boolean,
+ isAdditionalOperation?: boolean,
+) {
+ if (path.$ref) {
+ const { resolved: resolvedPaths } = parser.deref(path as OpenAPIPaths);
+ getTags(parser, { [pathName]: resolvedPaths }, tags, isWebhook);
+ return;
+ }
+
+ let operationTags = operationInfo?.tags;
+
+ if (!operationTags || !operationTags.length) {
+ // empty tag for operations and default tag for webhooks
+ operationTags = isWebhook ? [DEFAULT_WEBHOOKS_TAG_NAME] : [''];
+ }
+
+ for (const tagName of operationTags) {
+ let tag = tags[tagName];
+ if (tag === undefined) {
+ tag = {
+ name: tagName,
+ operations: [],
+ };
+ tags[tagName] = tag;
+ }
+ if (tag['x-traitTag']) {
+ continue;
+ }
+ tag.operations.push({
+ ...operationInfo,
+ pathName,
+ pointer: JsonPointer.compile(['paths', pathName, operationName]),
+ httpVerb: operationName,
+ pathParameters: path.parameters || [],
+ pathServers: path.servers,
+ isWebhook: !!isWebhook,
+ isAdditionalOperation: !!isAdditionalOperation,
+ });
+ }
+}
+
function getTags(
parser: OpenAPIParser,
paths: OpenAPIPaths,
@@ -265,41 +315,24 @@ function getTags(
for (const pathName of Object.keys(paths || {})) {
const path = paths[pathName];
const operations = Object.keys(path).filter(isOperationName);
+
for (const operationName of operations) {
const operationInfo = path[operationName];
- if (path.$ref) {
- const { resolved: resolvedPaths } = parser.deref(path as OpenAPIPaths);
- getTags(parser, { [pathName]: resolvedPaths }, tags, isWebhook);
- continue;
- }
- let operationTags = operationInfo?.tags;
+ processOperation(parser, operationName, operationInfo, pathName, path, tags, isWebhook);
+ }
- if (!operationTags || !operationTags.length) {
- // empty tag for operations and default tag for webhooks
- operationTags = isWebhook ? [DEFAULT_WEBHOOKS_TAG_NAME] : [''];
- }
-
- for (const tagName of operationTags) {
- let tag = tags[tagName];
- if (tag === undefined) {
- tag = {
- name: tagName,
- operations: [],
- };
- tags[tagName] = tag;
- }
- if (tag['x-traitTag']) {
- continue;
- }
- tag.operations.push({
- ...operationInfo,
+ if (path.additionalOperations) {
+ for (const [operationName, operationInfo] of Object.entries(path.additionalOperations)) {
+ processOperation(
+ parser,
+ operationName,
+ operationInfo,
pathName,
- pointer: JsonPointer.compile(['paths', pathName, operationName]),
- httpVerb: operationName,
- pathParameters: path.parameters || [],
- pathServers: path.servers,
- isWebhook: !!isWebhook,
- });
+ path,
+ tags,
+ isWebhook,
+ true,
+ );
}
}
}
diff --git a/src/services/search/init.ts b/src/services/search/init.ts
index e182bc1b..9cc5bc63 100644
--- a/src/services/search/init.ts
+++ b/src/services/search/init.ts
@@ -153,6 +153,7 @@ const addOperation = (operation: OperationModel): SearchDocument => {
text: stripFormatting(removeMarkdownLinks(operation.description || '')),
httpMethod: operation.httpVerb,
httpPath: operation.path,
+ isAdditionalOperation: operation.isAdditionalOperation,
deprecated: operation.deprecated,
security: operation.security
.map((s) => s.schemes.map((scheme) => scheme.id))
diff --git a/src/services/search/types.ts b/src/services/search/types.ts
index d169938a..f8c9f0f0 100644
--- a/src/services/search/types.ts
+++ b/src/services/search/types.ts
@@ -9,6 +9,7 @@ export type SearchDocument = {
path?: string[];
httpMethod?: string;
httpPath?: string | string[];
+ isAdditionalOperation?: boolean;
deprecated?: boolean;
security?: string[];
parameters?: OperationParameter[];
diff --git a/src/services/telemetry.ts b/src/services/telemetry.ts
index e99f980e..dda46049 100644
--- a/src/services/telemetry.ts
+++ b/src/services/telemetry.ts
@@ -1,7 +1,18 @@
-const OTEL_TRACES_URL = 'https://otel.cloud.redocly.com/v1/traces'; // Prod: 'https://otel.cloud.redocly.com/v1/traces';
+const OTEL_TRACES_URL = 'https://otel.blueharvest.cloud/v1/traces'; // Prod: 'https://otel.cloud.redocly.com/v1/traces';
import type { EventType, EventPayload } from '@redocly/redoc-opentelemetry';
+type OtlpPrimitive =
+ | { stringValue: string }
+ | { boolValue: boolean }
+ | { intValue: number }
+ | { doubleValue: number };
+
+type OtlpValue =
+ | OtlpPrimitive
+ | { arrayValue: { values: OtlpValue[] } }
+ | { kvlistValue: { values: { key: string; value: OtlpValue }[] } };
+
class RedocTelemetry {
private sessionId: string = '';
@@ -62,37 +73,83 @@ class RedocTelemetry {
}
private dataToCloudEventData(data: EventPayload) {
- const cloudEventData = Object.entries(data || {}).map(([key, value]) => {
- switch (typeof value) {
- case 'number':
- return {
- key: `cloudevents.event_data.${key}`,
- value: { intValue: value },
- };
- case 'object':
- return {
- key: `cloudevents.event_data.${key}`,
- value: { objValue: JSON.stringify(value) },
- };
- case 'string':
- return {
- key: `cloudevents.event_data.${key}`,
- value: { stringValue: value.toString() },
- };
- case 'boolean':
- return {
- key: `cloudevents.event_data.${key}`,
- value: { booleanValue: value.toString() },
- };
- default:
- return {
- key: `cloudevents.event_data.${key}`,
- value: { stringValue: value ? value.toString() : 'unknown_value' },
- };
- }
- });
+ if (!data) {
+ return [];
+ }
- return cloudEventData;
+ return Object.entries(data)
+ .map(([key, value]) => {
+ const otlpValue = this.toOtlpValue(value);
+
+ if (!otlpValue) {
+ return null;
+ }
+
+ return {
+ key: `cloudevents.event_data.${key}`,
+ value: otlpValue,
+ };
+ })
+ .filter((attribute): attribute is { key: string; value: OtlpValue } => attribute !== null);
+ }
+
+ private toOtlpValue(value: unknown): OtlpValue | null {
+ if (value === undefined) {
+ return null;
+ }
+
+ if (value === null) {
+ return { stringValue: 'null' };
+ }
+
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) {
+ return { stringValue: value.toString() };
+ }
+
+ return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
+ }
+
+ if (typeof value === 'boolean') {
+ return { boolValue: value };
+ }
+
+ if (typeof value === 'string') {
+ return { stringValue: value };
+ }
+
+ if (Array.isArray(value)) {
+ const values = value
+ .map((item) => this.toOtlpValue(item))
+ .filter((item): item is OtlpValue => item !== null);
+
+ return { arrayValue: { values } };
+ }
+
+ if (typeof value === 'object') {
+ if (Object.prototype.toString.call(value) !== '[object Object]') {
+ return { stringValue: String(value) };
+ }
+
+ const entries = Object.entries(value as Record)
+ .map(([key, nestedValue]) => {
+ const otlpValue = this.toOtlpValue(nestedValue);
+
+ if (!otlpValue) {
+ return null;
+ }
+
+ return {
+ key,
+ value: otlpValue,
+ };
+ })
+ .filter((entry): entry is { key: string; value: OtlpValue } => entry !== null);
+
+ return { kvlistValue: { values: entries } };
+ }
+
+ return { stringValue: String(value) };
}
}
diff --git a/src/services/types.ts b/src/services/types.ts
index 4852d6c8..b55d5a63 100644
--- a/src/services/types.ts
+++ b/src/services/types.ts
@@ -27,7 +27,7 @@ export type ExternalLinkSeparator = {
separatorLine?: boolean;
};
-export type MenuItemGroupType = 'group' | 'tag' | 'section' | 'schema' | 'mcp';
+export type MenuItemGroupType = 'group' | 'tag' | 'section' | 'schema' | 'tool' | 'rsrc' | 'prompt';
export type MenuItemType = MenuItemGroupType | 'operation';
/** Generic interface for MenuItems */
@@ -40,6 +40,7 @@ export interface IMenuItem {
items: IMenuItem[];
parent?: IMenuItem;
deprecated?: boolean;
+ isAdditionalOperation?: boolean;
type: MenuItemType;
isSchema?: boolean;
httpVerb?: string;
@@ -62,6 +63,7 @@ export type ExtendedOpenAPIOperation = {
pathParameters: Array>;
pathServers: Array | undefined;
isWebhook: boolean;
+ isAdditionalOperation: boolean;
defaultSampleName?: string | false;
} & OpenAPIOperation;
diff --git a/src/setupTests.ts b/src/setupTests.ts
deleted file mode 100644
index ddaec007..00000000
--- a/src/setupTests.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import '@testing-library/jest-dom';
-import 'jest-styled-components';
-import { randomUUID } from 'node:crypto';
-
-window.crypto.randomUUID = randomUUID;
-global.structuredClone = (val) => JSON.parse(JSON.stringify(val));
-// Mock fetch globally for all tests
-global.fetch = jest.fn(() =>
- Promise.resolve({
- ok: true,
- status: 200,
- json: () => Promise.resolve({}),
- text: () => Promise.resolve(''),
- headers: new Headers(),
- } as Response),
-);
diff --git a/src/testProviders.tsx b/src/testProviders.tsx
index b098a592..52057718 100644
--- a/src/testProviders.tsx
+++ b/src/testProviders.tsx
@@ -1,11 +1,34 @@
import { Children } from 'react';
+import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import type { ReactNode, ReactElement } from 'react';
import type { OpenAPIDefinition } from './types';
import type { StoreProviderProps } from './components';
+import type { BrowserRouterProps, MemoryRouterProps } from 'react-router-dom';
import { StoreProvider } from './components';
+const routerFutureFlags = {
+ v7_startTransition: true,
+ v7_relativeSplatPath: true,
+};
+
+export function TestBrowserRouter({ children, ...props }: BrowserRouterProps): ReactElement {
+ return (
+
+ {children}
+
+ );
+}
+
+export function TestMemoryRouter({ children, ...props }: MemoryRouterProps): ReactElement {
+ return (
+
+ {children}
+
+ );
+}
+
export function withTestProviders(children: ReactNode, store?: StoreProviderProps): ReactElement {
return (
diff --git a/src/types/app.ts b/src/types/app.ts
index 7a57ac1f..f820d903 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -1,6 +1 @@
export type RouterType = 'history' | 'memory' | 'hash';
-export type MenuLink = {
- id: string;
- href: string;
- name: string;
-};
diff --git a/src/types/open-api.ts b/src/types/open-api.ts
index b2f377d4..475f9019 100644
--- a/src/types/open-api.ts
+++ b/src/types/open-api.ts
@@ -89,6 +89,7 @@ export interface OpenAPIPath extends Partial, ParsedDescriptionWithS
head?: OpenAPIOperation;
patch?: OpenAPIOperation;
trace?: OpenAPIOperation;
+ additionalOperations?: Record;
servers?: OpenAPIServer[];
parameters?: Array>;
}
@@ -208,6 +209,7 @@ export interface OpenAPIDiscriminator {
propertyName: string;
mapping?: { [name: string]: string };
'x-explicitMappingOnly'?: boolean;
+ defaultMapping?: string;
}
export interface OpenAPIMediaType {
@@ -403,6 +405,8 @@ export interface McpResource {
description?: string;
uri: string;
mimeType: string;
+ blob?: string;
+ text?: string;
security?: OpenAPISecurityRequirement[];
tags?: string[];
@@ -414,10 +418,14 @@ export interface McpPrompt {
title?: string;
description: string;
arguments: McpPromptArgument[];
+ security?: OpenAPISecurityRequirement[];
+ tags?: string[];
+ 'x-badges'?: OpenAPIXBadges[];
}
export interface McpPromptArgument {
name: string;
description: string;
required: boolean;
+ example?: string;
}
diff --git a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap
index b6d2a67a..29ea36af 100644
--- a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap
+++ b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap
@@ -1,6 +1,6 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`#loadAndBundleDefinition should load And Bundle Spec openapi-3-1.yaml 1`] = `
+exports[`#loadAndBundleDefinition > should load And Bundle Spec openapi-3-1.yaml 1`] = `
{
"components": {
"examples": {
@@ -1959,7 +1959,7 @@ culpa qui officia deserunt mollit anim id est laborum.
}
`;
-exports[`#loadAndBundleDefinition should load and bundle spec petstore.yaml 1`] = `
+exports[`#loadAndBundleDefinition > should load and bundle spec petstore.yaml 1`] = `
{
"components": {
"examples": {
@@ -4013,7 +4013,7 @@ Some Item
}
`;
-exports[`#loadAndBundleDefinition should load and bundle spec rebilly.yaml 1`] = `
+exports[`#loadAndBundleDefinition > should load and bundle spec rebilly.yaml 1`] = `
{
"components": {
"examples": {
diff --git a/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap b/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap
index 47e58c60..e41fa425 100644
--- a/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap
+++ b/src/utils/__tests__/__snapshots__/simplifyAstStructure.test.ts.snap
@@ -1,6 +1,6 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`simplifyAstStructure should handle empty AST 1`] = `
+exports[`simplifyAstStructure > should handle empty AST 1`] = `
Node {
"$$mdtype": "Node",
"annotations": [],
@@ -14,7 +14,7 @@ Node {
}
`;
-exports[`simplifyAstStructure should remove location from AST nodes 1`] = `
+exports[`simplifyAstStructure > should remove location from AST nodes 1`] = `
[
Node {
"$$mdtype": "Node",
@@ -44,7 +44,7 @@ exports[`simplifyAstStructure should remove location from AST nodes 1`] = `
]
`;
-exports[`simplifyAstStructure should return full AST when not a document node type 1`] = `
+exports[`simplifyAstStructure > should return full AST when not a document node type 1`] = `
Node {
"$$mdtype": "Node",
"annotations": [],
diff --git a/src/utils/__tests__/areArraysEqual.test.ts b/src/utils/__tests__/areArraysEqual.test.ts
index dbe20ea8..1b8e8dec 100644
--- a/src/utils/__tests__/areArraysEqual.test.ts
+++ b/src/utils/__tests__/areArraysEqual.test.ts
@@ -1,4 +1,4 @@
-import { areArraysEqual } from '../areArraysEqual';
+import { areArraysEqual } from '../areArraysEqual.js';
describe('areArraysEqual', () => {
it('should return true for two empty arrays', () => {
diff --git a/src/utils/__tests__/convertSwagger2OpenAPI.test.ts b/src/utils/__tests__/convertSwagger2OpenAPI.test.ts
index cf19150a..38a95e83 100644
--- a/src/utils/__tests__/convertSwagger2OpenAPI.test.ts
+++ b/src/utils/__tests__/convertSwagger2OpenAPI.test.ts
@@ -1,18 +1,18 @@
-import { jest } from '@jest/globals';
+import { vi, type Mocked } from 'vitest';
import swagger2openapi from 'swagger2openapi';
import type { OpenAPIDefinition } from '../../types/index.js';
-import { convertSwagger2OpenAPI } from '../convertSwagger2OpenAPI';
+import { convertSwagger2OpenAPI } from '../convertSwagger2OpenAPI.js';
// Mock the swagger2openapi module
-jest.mock('swagger2openapi');
+vi.mock('swagger2openapi');
-const mockSwagger2OpenAPI = swagger2openapi as jest.Mocked;
+const mockSwagger2OpenAPI = swagger2openapi as Mocked;
describe('convertSwagger2OpenAPI', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
it('should convert a valid Swagger 2.0 spec to OpenAPI 3.0', async () => {
@@ -54,9 +54,9 @@ describe('convertSwagger2OpenAPI', () => {
},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, { openapi: expectedOpenAPISpec });
- return undefined as any;
+ return undefined as never;
});
const result = await convertSwagger2OpenAPI(swaggerSpec);
@@ -87,9 +87,9 @@ describe('convertSwagger2OpenAPI', () => {
paths: {},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, { openapi: expectedOpenAPISpec });
- return undefined as any;
+ return undefined as never;
});
await convertSwagger2OpenAPI(swaggerSpec);
@@ -140,9 +140,9 @@ describe('convertSwagger2OpenAPI', () => {
},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, { openapi: expectedOpenAPISpec });
- return undefined as any;
+ return undefined as never;
});
await convertSwagger2OpenAPI(swaggerSpec);
@@ -166,9 +166,9 @@ describe('convertSwagger2OpenAPI', () => {
const conversionError = new Error('Invalid Swagger specification');
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(conversionError, null);
- return undefined as any;
+ return undefined as never;
});
await expect(convertSwagger2OpenAPI(swaggerSpec)).rejects.toThrow(
@@ -192,9 +192,9 @@ describe('convertSwagger2OpenAPI', () => {
paths: {},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, null);
- return undefined as any;
+ return undefined as never;
});
const result = await convertSwagger2OpenAPI(swaggerSpec);
@@ -212,9 +212,9 @@ describe('convertSwagger2OpenAPI', () => {
paths: {},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, { someOtherProperty: 'value' });
- return undefined as any;
+ return undefined as never;
});
const result = await convertSwagger2OpenAPI(swaggerSpec);
@@ -391,9 +391,9 @@ describe('convertSwagger2OpenAPI', () => {
},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, { openapi: expectedOpenAPISpec });
- return undefined as any;
+ return undefined as never;
});
const result = await convertSwagger2OpenAPI(swaggerSpec);
@@ -418,9 +418,9 @@ describe('convertSwagger2OpenAPI', () => {
paths: {},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, { openapi: expectedOpenAPISpec });
- return undefined as any;
+ return undefined as never;
});
const result = await convertSwagger2OpenAPI(swaggerSpec);
@@ -452,9 +452,9 @@ describe('convertSwagger2OpenAPI', () => {
paths: {},
};
- mockSwagger2OpenAPI.convertObj.mockImplementation((spec, options, callback) => {
+ mockSwagger2OpenAPI.convertObj.mockImplementation((_spec, _options, callback) => {
callback(null, { openapi: expectedOpenAPISpec });
- return undefined as any;
+ return undefined as never;
});
const result = await convertSwagger2OpenAPI(swaggerSpec);
diff --git a/src/utils/__tests__/debounce.test.ts b/src/utils/__tests__/debounce.test.ts
index 8d31a838..83d1fc9e 100644
--- a/src/utils/__tests__/debounce.test.ts
+++ b/src/utils/__tests__/debounce.test.ts
@@ -1,10 +1,10 @@
-import { debounce } from '../debounce';
+import { debounce } from '../debounce.js';
-jest.useFakeTimers();
+vi.useFakeTimers();
describe('debounce', () => {
it('should debounce function calls', () => {
- const fn = jest.fn();
+ const fn = vi.fn();
const debouncedFn = debounce(fn, 100);
// Call the debounced function multiple times within the threshold
@@ -13,7 +13,7 @@ describe('debounce', () => {
debouncedFn('c');
// Advance time by 99ms
- jest.advanceTimersByTime(99);
+ vi.advanceTimersByTime(99);
// The function should not have been called yet
expect(fn).not.toHaveBeenCalled();
@@ -22,13 +22,13 @@ describe('debounce', () => {
debouncedFn('d');
// Advance time by another 99ms
- jest.advanceTimersByTime(99);
+ vi.advanceTimersByTime(99);
// The function should still not have been called yet
expect(fn).not.toHaveBeenCalled();
// Advance time by the remaining 1ms
- jest.advanceTimersByTime(1);
+ vi.advanceTimersByTime(1);
// The function should have been called once with the last value passed to it
expect(fn).toHaveBeenCalledTimes(1);
@@ -36,7 +36,7 @@ describe('debounce', () => {
});
it('should call the function immediately when the `leading` option is `true`', () => {
- const fn = jest.fn();
+ const fn = vi.fn();
const debouncedFn = debounce(fn, 100, true);
// Call the debounced function multiple times within the threshold
@@ -49,7 +49,7 @@ describe('debounce', () => {
expect(fn).toHaveBeenCalledWith('a');
// Advance time by 99ms
- jest.advanceTimersByTime(99);
+ vi.advanceTimersByTime(99);
// The function should not have been called again yet
expect(fn).toHaveBeenCalledTimes(1);
@@ -58,13 +58,13 @@ describe('debounce', () => {
debouncedFn('d');
// Advance time by another 99ms
- jest.advanceTimersByTime(99);
+ vi.advanceTimersByTime(99);
// The function should still not have been called yet
expect(fn).toHaveBeenCalledTimes(1);
// Advance time by the remaining 1ms
- jest.advanceTimersByTime(1);
+ vi.advanceTimersByTime(1);
// The function should have been called once more with the last value passed to it
expect(fn).toHaveBeenCalledTimes(2);
diff --git a/src/utils/__tests__/debug.test.ts b/src/utils/__tests__/debug.test.ts
index 59a40c95..9afd957f 100644
--- a/src/utils/__tests__/debug.test.ts
+++ b/src/utils/__tests__/debug.test.ts
@@ -1,10 +1,11 @@
-import { debugTime } from '../debug';
+import type { MockInstance } from 'vitest';
+import { debugTime } from '../debug.js';
describe('debug', () => {
- let spy: jest.SpyInstance;
+ let spy: MockInstance;
beforeEach(() => {
- spy = jest.spyOn(console, 'time');
+ spy = vi.spyOn(console, 'time');
});
afterEach(() => {
diff --git a/src/utils/__tests__/dom.test.ts b/src/utils/__tests__/dom.test.ts
index 3a4ee336..dcd7ce80 100644
--- a/src/utils/__tests__/dom.test.ts
+++ b/src/utils/__tests__/dom.test.ts
@@ -1,4 +1,4 @@
-import { querySelector, html2Str, IS_BROWSER } from '../dom';
+import { querySelector, html2Str, IS_BROWSER } from '../dom.js';
describe('DOM utils', () => {
describe('querySelector', () => {
@@ -54,13 +54,13 @@ describe('DOM utils', () => {
let parent: HTMLElement;
let child: HTMLElement;
- beforeEach(() => {
+ beforeEach(async () => {
parent = document.createElement('div');
child = document.createElement('div');
parent.appendChild(child);
document.body.appendChild(parent);
- jest.spyOn(window, 'getComputedStyle').mockReturnValue({
+ vi.spyOn(window, 'getComputedStyle').mockReturnValue({
getPropertyValue: (prop: string) => {
if (prop === 'border-top-width') return '10';
if (prop === 'border-left-width') return '10';
@@ -82,11 +82,11 @@ describe('DOM utils', () => {
offsetLeft: { value: 150, configurable: true },
clientHeight: { value: 50, configurable: true },
clientWidth: { value: 50, configurable: true },
- scrollIntoView: { value: jest.fn(), configurable: true },
+ scrollIntoView: { value: vi.fn(), configurable: true },
});
if (typeof Element.prototype.scrollIntoViewIfNeeded !== 'function') {
- require('../dom');
+ await import('../dom');
}
});
@@ -94,7 +94,7 @@ describe('DOM utils', () => {
if (parent.parentNode) {
parent.parentNode.removeChild(parent);
}
- jest.restoreAllMocks();
+ vi.restoreAllMocks();
});
it('should center the element when over top and centerIfNeeded is true', () => {
diff --git a/src/utils/__tests__/hasNestedFields.test.ts b/src/utils/__tests__/hasNestedFields.test.ts
index b04eec79..e570c959 100644
--- a/src/utils/__tests__/hasNestedFields.test.ts
+++ b/src/utils/__tests__/hasNestedFields.test.ts
@@ -1,5 +1,5 @@
-import { hasNestedFields } from '../hasNestedFields';
-import type { SchemaModel, FieldModel } from '../../models/types';
+import { hasNestedFields } from '../hasNestedFields.js';
+import type { SchemaModel, FieldModel } from '../../models/types.js';
const createMockField = (isPrimitive: boolean, isCircular: boolean = false): FieldModel => ({
schema: {
diff --git a/src/utils/__tests__/helpers.test.ts b/src/utils/__tests__/helpers.test.ts
index e3ee1225..72fb876b 100644
--- a/src/utils/__tests__/helpers.test.ts
+++ b/src/utils/__tests__/helpers.test.ts
@@ -8,7 +8,7 @@ import {
isLastInArray,
isLastProperty,
getValueFromMdParsedExtension,
-} from '../helpers';
+} from '../helpers.js';
describe('Utils', () => {
describe('helpers', () => {
diff --git a/src/utils/__tests__/isMobile.test.ts b/src/utils/__tests__/isMobile.test.ts
index 2279c9db..b2313519 100644
--- a/src/utils/__tests__/isMobile.test.ts
+++ b/src/utils/__tests__/isMobile.test.ts
@@ -1,4 +1,4 @@
-import { isMobile } from '../isMobile';
+import { isMobile } from '../isMobile.js';
describe('isMobile', () => {
it('should return true for mobile user agent', () => {
diff --git a/src/utils/__tests__/loadAndBundleSpec.test.ts b/src/utils/__tests__/loadAndBundleSpec.test.ts
index 3cf4da5e..9d7a570c 100644
--- a/src/utils/__tests__/loadAndBundleSpec.test.ts
+++ b/src/utils/__tests__/loadAndBundleSpec.test.ts
@@ -2,7 +2,7 @@ import * as yaml from 'js-yaml';
import { readFileSync } from 'fs';
import { resolve } from 'path';
-import { loadAndBundleDefinition } from '../loadAndBundleSpec';
+import { loadAndBundleDefinition } from '../loadAndBundleSpec.js';
describe('#loadAndBundleDefinition', () => {
it('should load and bundle spec petstore.yaml', async () => {
diff --git a/src/utils/__tests__/local-storage.test.ts b/src/utils/__tests__/local-storage.test.ts
index 2a8d58f3..8624b17c 100644
--- a/src/utils/__tests__/local-storage.test.ts
+++ b/src/utils/__tests__/local-storage.test.ts
@@ -1,6 +1,6 @@
-import { toLocalStorage, fromLocalStorage } from '../local-storage';
+import { toLocalStorage, fromLocalStorage } from '../local-storage.js';
-jest.mock('@redocly/theme/core/openapi', () => ({
+vi.mock('@redocly/theme/core/openapi', () => ({
IS_BROWSER: true,
}));
@@ -12,10 +12,10 @@ describe('local-storage', () => {
Object.defineProperty(window, 'localStorage', {
value: {
- setItem: jest.fn((key: string, value: string) => {
+ setItem: vi.fn((key: string, value: string) => {
mockLocalStorage[key] = value;
}),
- getItem: jest.fn((key: string) => {
+ getItem: vi.fn((key: string) => {
return mockLocalStorage[key] || null;
}),
},
@@ -24,7 +24,7 @@ describe('local-storage', () => {
});
afterEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
describe('toLocalStorage', () => {
diff --git a/src/utils/__tests__/object.test.ts b/src/utils/__tests__/object.test.ts
index a9f545fe..5361795f 100644
--- a/src/utils/__tests__/object.test.ts
+++ b/src/utils/__tests__/object.test.ts
@@ -1,4 +1,4 @@
-import { objectHas, objectSet } from '../object';
+import { objectHas, objectSet } from '../object.js';
describe('object utils', () => {
let obj;
diff --git a/src/utils/__tests__/openapi.test.ts b/src/utils/__tests__/openapi.test.ts
index 10fa9333..9e288abd 100644
--- a/src/utils/__tests__/openapi.test.ts
+++ b/src/utils/__tests__/openapi.test.ts
@@ -1,12 +1,13 @@
+import type { MockInstance } from 'vitest';
import type {
OpenAPIParameter,
OpenAPIParameterLocation,
OpenAPIParameterStyle,
OpenAPIMediaType,
OpenAPIDefinition,
-} from '../../types';
-import type { ExtendedOpenAPIOperation } from '../../services';
-import type { FieldModel, OperationModel } from '../../models/types';
+} from '../../types/index.js';
+import type { ExtendedOpenAPIOperation } from '../../services/index.js';
+import type { FieldModel, OperationModel } from '../../models/types.js';
import {
detectType,
@@ -23,11 +24,11 @@ import {
humanizeNumberRange,
getDefinitionName,
serializeQueryParameter,
-} from '../';
-import { getField } from '../../models';
-import { OpenAPIParser } from '../../services/OpenAPIParser';
-import { getContentWithLegacyExamples } from '../openapi';
-import { normalizeOptions } from '../../services';
+} from '../index.js';
+import { getField } from '../../models/index.js';
+import { OpenAPIParser } from '../../services/OpenAPIParser.js';
+import { getContentWithLegacyExamples } from '../openapi.js';
+import { normalizeOptions } from '../../services/index.js';
describe('Utils', () => {
describe('openapi getStatusCode', () => {
@@ -434,8 +435,8 @@ describe('Utils', () => {
it('should remove query string and hash from url', () => {
const originalWindow = { ...window };
- const windowSpy = jest.spyOn(global, 'window', 'get') as jest.SpyInstance<
- Window & typeof globalThis
+ const windowSpy = vi.spyOn(global, 'window', 'get') as unknown as MockInstance<
+ () => Window & typeof globalThis
>;
windowSpy.mockImplementation(
() =>
diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts
index 9bd93454..507a453d 100644
--- a/src/utils/__tests__/path.test.ts
+++ b/src/utils/__tests__/path.test.ts
@@ -1,4 +1,4 @@
-import { normalizePath } from '../path';
+import { normalizePath } from '../path.js';
describe('path utils', () => {
test('normalizePath should return correct path', () => {
diff --git a/src/utils/__tests__/queryString.test.ts b/src/utils/__tests__/queryString.test.ts
index 3972f029..c405cf0d 100644
--- a/src/utils/__tests__/queryString.test.ts
+++ b/src/utils/__tests__/queryString.test.ts
@@ -1,4 +1,4 @@
-import { queryString } from '../queryString';
+import { queryString } from '../queryString.js';
describe('QueryString', () => {
describe('parse', () => {
diff --git a/src/utils/__tests__/replaceVariables.test.ts b/src/utils/__tests__/replaceVariables.test.ts
index d4b54e4b..dd6fc69b 100644
--- a/src/utils/__tests__/replaceVariables.test.ts
+++ b/src/utils/__tests__/replaceVariables.test.ts
@@ -1,4 +1,4 @@
-import { replaceVariables } from '../replaceVariables';
+import { replaceVariables } from '../replaceVariables.js';
describe('replaceVariables', () => {
test('should return the same URL when there are no variables', () => {
diff --git a/src/utils/__tests__/simplifyAstStructure.test.ts b/src/utils/__tests__/simplifyAstStructure.test.ts
index 490416d2..8aa7bd67 100644
--- a/src/utils/__tests__/simplifyAstStructure.test.ts
+++ b/src/utils/__tests__/simplifyAstStructure.test.ts
@@ -1,6 +1,6 @@
import { Node } from '@markdoc/markdoc';
-import { simplifyAstStructure } from '../simplifyAstStructure';
+import { simplifyAstStructure } from '../simplifyAstStructure.js';
describe('simplifyAstStructure', () => {
it('should remove location from AST nodes', () => {
@@ -28,9 +28,8 @@ describe('simplifyAstStructure', () => {
it('should return full AST when not a document node type', () => {
const mockAst = new Node('text', { content: 'Some text' });
- const openapiNode = { title: 'API Documentation' };
- const result = simplifyAstStructure(mockAst as any, openapiNode);
+ const result = simplifyAstStructure(mockAst as Node);
expect(result).toMatchSnapshot();
});
diff --git a/src/utils/__tests__/sort.test.ts b/src/utils/__tests__/sort.test.ts
index 33897e8e..3a181fb5 100644
--- a/src/utils/__tests__/sort.test.ts
+++ b/src/utils/__tests__/sort.test.ts
@@ -1,4 +1,4 @@
-import { alphabeticallyByProp } from '../sort';
+import { alphabeticallyByProp } from '../sort.js';
describe('alphabeticallyByProp', () => {
test('should sort objects by property in ascending order', () => {
diff --git a/src/utils/__tests__/string.test.ts b/src/utils/__tests__/string.test.ts
index 1757aaae..710778a7 100644
--- a/src/utils/__tests__/string.test.ts
+++ b/src/utils/__tests__/string.test.ts
@@ -1,6 +1,6 @@
import slugify from 'slugify';
-import { strikethroughText, safeSlugify } from '../string';
+import { strikethroughText, safeSlugify } from '../string.js';
describe('string utils', () => {
it('strikethroughText', () => {
diff --git a/src/utils/__tests__/theme-helpers.test.ts b/src/utils/__tests__/theme-helpers.test.ts
index 2239f769..30512547 100644
--- a/src/utils/__tests__/theme-helpers.test.ts
+++ b/src/utils/__tests__/theme-helpers.test.ts
@@ -1,4 +1,4 @@
-import { getTypographyCssRulesByComponentName } from '../theme-helpers';
+import { getTypographyCssRulesByComponentName } from '../theme-helpers.js';
describe('getTypographyCssRulesByComponentName', () => {
it('should return object with defined css variable for component', () => {
diff --git a/src/utils/__tests__/url.test.ts b/src/utils/__tests__/url.test.ts
index b5223a4b..0974429c 100644
--- a/src/utils/__tests__/url.test.ts
+++ b/src/utils/__tests__/url.test.ts
@@ -1,6 +1,14 @@
-import { urlParse, getUrlDirname } from '../url';
+import { urlParse, getUrlDirname } from '../url.js';
describe('url utils', () => {
+ const originalConsoleError = console.error;
+ beforeEach(() => {
+ console.error = vi.fn();
+ });
+ afterEach(() => {
+ console.error = originalConsoleError;
+ });
+
describe('urlParse', () => {
it('should parse valid URLs', () => {
const result = urlParse('https://example.com/path');
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 0d89ee14..1fd8f6ef 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -13,7 +13,6 @@ export * from './session-storage.js';
export * from './local-storage.js';
export * from './parameters.js';
export * from './string.js';
-export * from './test-utils.js';
export * from './compose.js';
export * from './saveTextBeforeHeading.js';
export * from './path.js';
diff --git a/src/utils/menu.ts b/src/utils/menu.ts
index f509fc95..5e8d84d2 100644
--- a/src/utils/menu.ts
+++ b/src/utils/menu.ts
@@ -1,5 +1,11 @@
import type { IMenuItem } from '../services/types';
export function isRenderableMenuItem(item: IMenuItem) {
- return item.type === 'operation' || item.type === 'schema' || item.type === 'mcp';
+ return (
+ item.type === 'operation' ||
+ item.type === 'schema' ||
+ item.type === 'tool' ||
+ item.type === 'rsrc' ||
+ item.type === 'prompt'
+ );
}
diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts
index 44447365..86440dd6 100644
--- a/src/utils/openapi.ts
+++ b/src/utils/openapi.ts
@@ -10,11 +10,11 @@ import type {
OpenAPISchema,
OpenAPIServer,
Referenced,
+ OpenAPIParameter,
} from '../types/index.js';
import type { ExtendedOpenAPIOperation, OpenAPIParser } from '../services/index.js';
import type { FieldModel, GroupModel } from '../models/index.js';
-import { OpenAPIParameter } from '../types/index.js';
import {
deleteEmptyArrayItem,
isArrayOfObjects,
diff --git a/src/utils/test-utils.ts b/src/utils/test-utils.ts
deleted file mode 100644
index a3f5b822..00000000
--- a/src/utils/test-utils.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/* tslint:disable:no-implicit-dependencies */
-
-import { objectHas, objectSet } from './object.js';
-
-function traverseComponent(root, fn) {
- if (!root) {
- return;
- }
-
- fn(root);
-
- if (root.children) {
- for (const child of root.children) {
- traverseComponent(child, fn);
- }
- }
-}
-
-export function filterPropsDeep(component: T, paths: string[]): T {
- traverseComponent(component, (comp) => {
- if (comp.props) {
- for (const path of paths) {
- if (objectHas(comp.props, path)) {
- objectSet(comp.props, path, '<<>>');
- }
- }
- }
- });
-
- return component;
-}
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 00000000..36755059
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,61 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./",
+ "strict": false,
+ "noEmit": false,
+ "jsx": "react-jsx",
+ "experimentalDecorators": true,
+ "lib": [
+ "es2015",
+ "es2016",
+ "es2017",
+ "dom",
+ "WebWorker.ImportScripts",
+ "esnext",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "target": "ESNext",
+ "verbatimModuleSyntax": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "noImplicitAny": false,
+ "useDefineForClassFields": true,
+ "types": ["node"],
+ "strictPropertyInitialization": false,
+ "strictNullChecks": true,
+ "resolveJsonModule": false,
+ "rootDir": "src",
+ "declaration": true,
+ "outDir": "lib",
+ "composite": true,
+ "tsBuildInfoFile": "lib/.tsbuildinfo",
+ "isolatedModules": true,
+ "noImplicitReturns": false,
+ "noImplicitThis": true,
+ "forceConsistentCasingInFileNames": true,
+ "allowJs": true,
+ "useUnknownInCatchVariables": false,
+ "noFallthroughCasesInSwitch": true,
+ "allowUnreachableCode": false,
+ "noUnusedParameters": true,
+ "noUnusedLocals": true,
+ "downlevelIteration": true,
+ "sourceMap": true,
+ "skipLibCheck": true
+ },
+ "compileOnSave": false,
+ "exclude": [
+ "node_modules",
+ "bundle",
+ ".tmp",
+ "lib",
+ "redoc",
+ "bin",
+ "**/__tests__/**/*",
+ "src/testProviders.tsx",
+ "vitest.setup.ts"
+ ],
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 6960b7da..d8d73503 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,60 +1,9 @@
{
+ "extends": "./tsconfig.build.json",
"compilerOptions": {
- "baseUrl": "./",
- "strict": false,
- "noEmit": false,
- "jsx": "react-jsx",
- "experimentalDecorators": true,
- "lib": [
- "es2015",
- "es2016",
- "es2017",
- "dom",
- "WebWorker.ImportScripts",
- "esnext",
- "DOM.Iterable"
- ],
- "moduleResolution": "bundler",
- "noImplicitAny": false,
- "useDefineForClassFields": true,
- "target": "ESNext",
- "types": ["jest", "node", "@testing-library/jest-dom", "@jest/globals"],
- "strictPropertyInitialization": false,
- "strictNullChecks": true,
- "resolveJsonModule": false,
- "rootDir": "src",
- "declaration": true,
- "module": "ESNext",
- "outDir": "lib",
- "composite": true,
- "tsBuildInfoFile": "lib/.tsbuildinfo",
- "isolatedModules": true,
- "noImplicitReturns": false,
- "noImplicitThis": true,
- "forceConsistentCasingInFileNames": true,
- "allowJs": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "useUnknownInCatchVariables": false,
- "noFallthroughCasesInSwitch": true,
- "allowUnreachableCode": false,
- "noUnusedParameters": true,
- "noUnusedLocals": true,
- "downlevelIteration": true,
- "sourceMap": true,
- "skipLibCheck": true
+ "types": ["vitest/globals", "node", "@testing-library/jest-dom"],
+ "resolveJsonModule": true
},
- "compileOnSave": false,
- "exclude": [
- "node_modules",
- "bundle",
- ".tmp",
- "lib",
- "redoc",
- "bin",
- "**/__tests__/**/*",
- "src/testProviders.tsx",
- "src/setupTests.ts"
- ],
- "include": ["src"]
+ "exclude": ["node_modules", "bundle", ".tmp", "lib", "redoc", "bin"],
+ "include": ["src", "src/**/*.json"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..49c3e2f8
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,57 @@
+import { defineConfig } from 'vitest/config';
+import { resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+export default defineConfig({
+ optimizeDeps: {
+ exclude: ['htmlparser2'],
+ },
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ root: __dirname,
+ setupFiles: [resolve(__dirname, 'vitest.setup.ts')],
+ include: ['**/__tests__/**/*.test.[jt]s?(x)', '**/?(*.)+(test).[jt]s?(x)'],
+ exclude: [
+ '**/node_modules/**',
+ '**/.git/**',
+ '**/bundle/**',
+ '**/lib/**',
+ '**/playground/**',
+ '**/playwright/**',
+ '**/scripts/**',
+ '**/__mocks__/**',
+ '**/src/icons/**',
+ ],
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ '**/index.ts',
+ '**/types.ts',
+ '**/src/types/**',
+ '**/src/icons/**',
+ '**/events/**',
+ '**/__snapshots__/**',
+ '**/__fixtures__/**',
+ '**/__tests__/**',
+ '**/__mocks__/**',
+ ],
+ thresholds: {
+ statements: 78,
+ branches: 68,
+ functions: 75,
+ lines: 78,
+ },
+ },
+ },
+ resolve: {
+ alias: {
+ path: 'path-browserify',
+ },
+ preserveSymlinks: false,
+ },
+});
diff --git a/vitest.setup.ts b/vitest.setup.ts
new file mode 100644
index 00000000..c72d7d7b
--- /dev/null
+++ b/vitest.setup.ts
@@ -0,0 +1,58 @@
+import '@testing-library/jest-dom';
+import { styleSheetSerializer } from 'jest-styled-components/serializer';
+import { vi, expect } from 'vitest';
+import { randomUUID } from 'node:crypto';
+
+const sortedStyleSheetSerializer: Parameters[0] = {
+ test: styleSheetSerializer.test,
+ serialize(val, config, indentation, depth, refs, printer): string {
+ const result = styleSheetSerializer.serialize(val, config, indentation, depth, refs, printer);
+ if (typeof result !== 'string') return result;
+
+ const cssEndIndex = result.lastIndexOf('\n\n<');
+ if (cssEndIndex === -1) return result;
+
+ const cssBlock = result.slice(0, cssEndIndex);
+ const htmlBlock = result.slice(cssEndIndex);
+ const sortedCss = cssBlock
+ .split(/(?=\.c\d+)/g)
+ .filter(Boolean)
+ .sort((a, b) => {
+ const aMatch = a.match(/^\.c(\d+)/);
+ const bMatch = b.match(/^\.c(\d+)/);
+ if (aMatch && bMatch) {
+ return parseInt(aMatch[1], 10) - parseInt(bMatch[1], 10);
+ }
+ return a.localeCompare(b);
+ })
+ .join('');
+
+ return sortedCss + htmlBlock;
+ },
+};
+
+expect.addSnapshotSerializer(sortedStyleSheetSerializer);
+
+// Simple fetch mock using vi.fn()
+global.fetch = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({}),
+ text: () => Promise.resolve(''),
+ headers: new Headers(),
+ } as Response),
+);
+
+window.scrollTo = vi.fn();
+
+window.crypto.randomUUID = randomUUID as () => `${string}-${string}-${string}-${string}-${string}`;
+global.structuredClone = (val) => JSON.parse(JSON.stringify(val));
+
+// Ensure window is defined for React 19 state updates
+if (typeof window !== 'undefined' && !window.document) {
+ Object.defineProperty(window, 'document', {
+ value: global.document,
+ writable: true,
+ });
+}