-
Responses
+
{isCallback ? 'Callback responses' : 'Responses'}
{responses.map(response => {
return
;
})}
diff --git a/src/components/__tests__/Callbacks.test.tsx b/src/components/__tests__/Callbacks.test.tsx
new file mode 100644
index 0000000000..8cb2b39ad3
--- /dev/null
+++ b/src/components/__tests__/Callbacks.test.tsx
@@ -0,0 +1,59 @@
+/* tslint:disable:no-implicit-dependencies */
+
+import { shallow } from 'enzyme';
+import * as React from 'react';
+
+import { OpenAPIParser } from '../../services';
+import { CallbackModel } from '../../services/models/Callback';
+import { RedocNormalizedOptions } from '../../services/RedocNormalizedOptions';
+import { CallbacksList, CallbackTitle, CallbackOperation } from '../Callbacks';
+import * as simpleCallbackFixture from './fixtures/simple-callback.json';
+
+const options = new RedocNormalizedOptions({});
+describe('Components', () => {
+ describe('Callbacks', () => {
+ it('should correctly render CallbackView', () => {
+ const parser = new OpenAPIParser(simpleCallbackFixture, undefined, options);
+ const callback = new CallbackModel(
+ parser,
+ 'Test.Callback',
+ { $ref: '#/components/callbacks/Test' },
+ '',
+ options,
+ );
+ // There should be 1 operation defined in simple-callback.json, just get it manually for readability.
+ const callbackViewElement = shallow(
+
,
+ ).getElement();
+ expect(callbackViewElement.props).toBeDefined();
+ expect(callbackViewElement.props.children).toBeDefined();
+ expect(callbackViewElement.props.children.length).toBeGreaterThan(0);
+ });
+
+ it('should correctly render CallbackTitle', () => {
+ const callbackTitleViewElement = shallow(
+
,
+ ).getElement();
+ expect(callbackTitleViewElement.props).toBeDefined();
+ expect(callbackTitleViewElement.props.className).toEqual('.test');
+ expect(callbackTitleViewElement.props.onClick).toBeUndefined();
+ });
+
+ it('should correctly render CallbacksList', () => {
+ const parser = new OpenAPIParser(simpleCallbackFixture, undefined, options);
+ const callback = new CallbackModel(
+ parser,
+ 'Test.Callback',
+ { $ref: '#/components/callbacks/Test' },
+ '',
+ options,
+ );
+ const callbacksListViewElement = shallow(
+
,
+ ).getElement();
+ expect(callbacksListViewElement.props).toBeDefined();
+ expect(callbacksListViewElement.props.children).toBeDefined();
+ expect(callbacksListViewElement.props.children.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/src/components/__tests__/fixtures/simple-callback.json b/src/components/__tests__/fixtures/simple-callback.json
new file mode 100644
index 0000000000..6ee56361f7
--- /dev/null
+++ b/src/components/__tests__/fixtures/simple-callback.json
@@ -0,0 +1,66 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "version": "1.0",
+ "title": "Foo"
+ },
+ "components": {
+ "callbacks": {
+ "Test": {
+ "/test": {
+ "post": {
+ "operationId": "testCallback",
+ "description": "Test callback.",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "title": "TestTitle",
+ "type": "object",
+ "description": "Test description",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of response.",
+ "enum": [
+ "TestResponse.Complete"
+ ]
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "FAILURE",
+ "SUCCESS"
+ ]
+ }
+ },
+ "required": [
+ "status"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "X-Test-Header",
+ "in": "header",
+ "required": true,
+ "example": "1",
+ "description": "This is a test header parameter",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Test response."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts
index f61f61890a..e9daeedb19 100644
--- a/src/services/MenuBuilder.ts
+++ b/src/services/MenuBuilder.ts
@@ -1,8 +1,16 @@
-import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types';
+import {
+ OpenAPIOperation,
+ OpenAPIParameter,
+ OpenAPISpec,
+ OpenAPITag,
+ Referenced,
+ OpenAPIServer,
+} from '../types';
import {
isOperationName,
SECURITY_DEFINITIONS_COMPONENT_NAME,
setSecuritySchemePrefix,
+ JsonPointer,
} from '../utils';
import { MarkdownRenderer } from './MarkdownRenderer';
import { GroupModel, OperationModel } from './models';
@@ -15,9 +23,11 @@ export type TagInfo = OpenAPITag & {
};
export type ExtendedOpenAPIOperation = {
+ pointer: string;
pathName: string;
httpVerb: string;
pathParameters: Array
>;
+ pathServers: Array | undefined;
} & OpenAPIOperation;
export type TagsInfoMap = Dict;
@@ -237,8 +247,10 @@ export class MenuBuilder {
tag.operations.push({
...operationInfo,
pathName,
+ pointer: JsonPointer.compile(['paths', pathName, operationName]),
httpVerb: operationName,
pathParameters: path.parameters || [],
+ pathServers: path.servers,
});
}
}
diff --git a/src/services/__tests__/fixtures/callback.json b/src/services/__tests__/fixtures/callback.json
new file mode 100644
index 0000000000..5ca4af74af
--- /dev/null
+++ b/src/services/__tests__/fixtures/callback.json
@@ -0,0 +1,64 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "version": "1.0",
+ "title": "Foo"
+ },
+ "components": {
+ "callbacks": {
+ "Test": {
+ "post": {
+ "operationId": "testCallback",
+ "description": "Test callback.",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "title": "TestTitle",
+ "type": "object",
+ "description": "Test description",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of response.",
+ "enum": [
+ "TestResponse.Complete"
+ ]
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "FAILURE",
+ "SUCCESS"
+ ]
+ }
+ },
+ "required": [
+ "status"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "X-Test-Header",
+ "in": "header",
+ "required": true,
+ "example": "1",
+ "description": "This is a test header parameter",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Test response."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
\ No newline at end of file
diff --git a/src/services/__tests__/models/Callback.test.ts b/src/services/__tests__/models/Callback.test.ts
new file mode 100644
index 0000000000..9fb67799dc
--- /dev/null
+++ b/src/services/__tests__/models/Callback.test.ts
@@ -0,0 +1,26 @@
+import { CallbackModel } from '../../models/Callback';
+import { OpenAPIParser } from '../../OpenAPIParser';
+import { RedocNormalizedOptions } from '../../RedocNormalizedOptions';
+
+const opts = new RedocNormalizedOptions({});
+
+describe('Models', () => {
+ describe('CallbackModel', () => {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const spec = require('../fixtures/callback.json');
+ const parser = new OpenAPIParser(spec, undefined, opts);
+
+ test('basic callback details', () => {
+ const callback = new CallbackModel(
+ parser,
+ 'Test.Callback',
+ { $ref: '#/components/callbacks/Test' },
+ '',
+ opts,
+ );
+ expect(callback.name).toEqual('Test.Callback');
+ expect(callback.operations.length).toEqual(0);
+ expect(callback.expanded).toBeUndefined();
+ });
+ });
+});
diff --git a/src/services/models/Callback.ts b/src/services/models/Callback.ts
new file mode 100644
index 0000000000..7adfc27f10
--- /dev/null
+++ b/src/services/models/Callback.ts
@@ -0,0 +1,56 @@
+import { action, observable } from 'mobx';
+
+import { OpenAPICallback, Referenced } from '../../types';
+import { isOperationName, JsonPointer } from '../../utils';
+import { OpenAPIParser } from '../OpenAPIParser';
+import { OperationModel } from './Operation';
+import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
+
+export class CallbackModel {
+ @observable
+ expanded: boolean;
+ name: string;
+ operations: OperationModel[] = [];
+
+ constructor(
+ parser: OpenAPIParser,
+ name: string,
+ infoOrRef: Referenced,
+ pointer: string,
+ options: RedocNormalizedOptions,
+ ) {
+ this.name = name;
+ const paths = parser.deref(infoOrRef);
+ parser.exitRef(infoOrRef);
+
+ 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];
+
+ const operation = new OperationModel(
+ parser,
+ {
+ ...operationInfo,
+ pathName,
+ pointer: JsonPointer.compile([pointer, name, pathName, operationName]),
+ httpVerb: operationName,
+ pathParameters: path.parameters || [],
+ pathServers: path.servers,
+ },
+ undefined,
+ options,
+ true,
+ );
+
+ this.operations.push(operation);
+ }
+ }
+ }
+
+ @action
+ toggle() {
+ this.expanded = !this.expanded;
+ }
+}
diff --git a/src/services/models/Operation.ts b/src/services/models/Operation.ts
index 1aa3a65d79..fc2d41ceb2 100644
--- a/src/services/models/Operation.ts
+++ b/src/services/models/Operation.ts
@@ -4,19 +4,13 @@ import { IMenuItem } from '../MenuStore';
import { GroupModel } from './Group.model';
import { SecurityRequirementModel } from './SecurityRequirement';
-import {
- OpenAPIExternalDocumentation,
- OpenAPIPath,
- OpenAPIServer,
- OpenAPIXCodeSample,
-} from '../../types';
+import { OpenAPIExternalDocumentation, OpenAPIServer, OpenAPIXCodeSample } from '../../types';
import {
extractExtensions,
getOperationSummary,
getStatusCodeType,
isStatusCode,
- JsonPointer,
memoize,
mergeParams,
normalizeServers,
@@ -26,12 +20,13 @@ import {
import { ContentItemModel, ExtendedOpenAPIOperation } from '../MenuBuilder';
import { OpenAPIParser } from '../OpenAPIParser';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
+import { CallbackModel } from './Callback';
import { FieldModel } from './Field';
import { MediaContentModel } from './MediaContent';
import { RequestBodyModel } from './RequestBody';
import { ResponseModel } from './Response';
-interface XPayloadSample {
+export interface XPayloadSample {
lang: 'payload';
label: string;
requestBodyContent: MediaContentModel;
@@ -77,23 +72,17 @@ export class OperationModel implements IMenuItem {
servers: OpenAPIServer[];
security: SecurityRequirementModel[];
extensions: Dict;
+ isCallback: boolean;
constructor(
private parser: OpenAPIParser,
private operationSpec: ExtendedOpenAPIOperation,
parent: GroupModel | undefined,
private options: RedocNormalizedOptions,
+ isCallback: boolean = false,
) {
- this.pointer = JsonPointer.compile(['paths', operationSpec.pathName, operationSpec.httpVerb]);
-
- this.id =
- operationSpec.operationId !== undefined
- ? 'operation/' + operationSpec.operationId
- : parent !== undefined
- ? parent.id + this.pointer
- : this.pointer;
+ this.pointer = operationSpec.pointer;
- this.name = getOperationSummary(operationSpec);
this.description = operationSpec.description;
this.parent = parent;
this.externalDocs = operationSpec.externalDocs;
@@ -103,19 +92,36 @@ export class OperationModel implements IMenuItem {
this.deprecated = !!operationSpec.deprecated;
this.operationId = operationSpec.operationId;
this.path = operationSpec.pathName;
+ this.isCallback = isCallback;
- const pathInfo = parser.byRef(
- JsonPointer.compile(['paths', operationSpec.pathName]),
- );
-
- this.servers = normalizeServers(
- parser.specUrl,
- operationSpec.servers || (pathInfo && pathInfo.servers) || parser.spec.servers || [],
- );
+ this.name = getOperationSummary(operationSpec);
- this.security = (operationSpec.security || parser.spec.security || []).map(
- security => new SecurityRequirementModel(security, parser),
- );
+ if (this.isCallback) {
+ // NOTE: Callbacks by default should not inherit the specification's global `security` definition.
+ // Can be defined individually per-callback in the specification. Defaults to none.
+ this.security = (operationSpec.security || []).map(
+ security => new SecurityRequirementModel(security, parser),
+ );
+
+ // TODO: update getting pathInfo for overriding servers on path level
+ this.servers = normalizeServers('', operationSpec.servers || operationSpec.pathServers || []);
+ } else {
+ this.id =
+ operationSpec.operationId !== undefined
+ ? 'operation/' + operationSpec.operationId
+ : parent !== undefined
+ ? parent.id + this.pointer
+ : this.pointer;
+
+ this.security = (operationSpec.security || parser.spec.security || []).map(
+ security => new SecurityRequirementModel(security, parser),
+ );
+
+ this.servers = normalizeServers(
+ parser.specUrl,
+ operationSpec.servers || operationSpec.pathServers || parser.spec.servers || [],
+ );
+ }
if (options.showExtensions) {
this.extensions = extractExtensions(operationSpec, options.showExtensions);
@@ -138,6 +144,14 @@ export class OperationModel implements IMenuItem {
this.active = false;
}
+ /**
+ * Toggle expansion in middle panel (for callbacks, which are operations)
+ */
+ @action
+ toggle() {
+ this.expanded = !this.expanded;
+ }
+
expand() {
if (this.parent) {
this.parent.expand();
@@ -224,4 +238,17 @@ export class OperationModel implements IMenuItem {
);
});
}
+
+ @memoize
+ get callbacks() {
+ return Object.keys(this.operationSpec.callbacks || []).map(callbackEventName => {
+ return new CallbackModel(
+ this.parser,
+ callbackEventName,
+ this.operationSpec.callbacks![callbackEventName],
+ this.pointer,
+ this.options,
+ );
+ });
+ }
}
diff --git a/src/services/models/index.ts b/src/services/models/index.ts
index 65006e79be..a3569c5a94 100644
--- a/src/services/models/index.ts
+++ b/src/services/models/index.ts
@@ -10,3 +10,4 @@ export * from './Schema';
export * from './Field';
export * from './ApiInfo';
export * from './SecuritySchemes';
+export * from './Callback';
diff --git a/src/styled-components.ts b/src/styled-components.ts
index 868211a44d..9db27997c3 100644
--- a/src/styled-components.ts
+++ b/src/styled-components.ts
@@ -10,9 +10,7 @@ const {
createGlobalStyle,
keyframes,
ThemeProvider,
-} = (styledComponents as any) as styledComponents.ThemedStyledComponentsModule<
- ResolvedThemeInterface
->;
+} = styledComponents as styledComponents.ThemedStyledComponentsModule;
export const media = {
lessThan(breakpoint, print?: boolean) {
diff --git a/src/theme.ts b/src/theme.ts
index 3181808a6e..eca8855e35 100644
--- a/src/theme.ts
+++ b/src/theme.ts
@@ -37,6 +37,10 @@ const defaultTheme: ThemeInterface = {
dark: ({ colors }) => darken(colors.tonalOffset, colors.error.main),
contrastText: ({ colors }) => readableColor(colors.error.main),
},
+ gray: {
+ 50: '#FAFAFA',
+ 100: '#F5F5F5',
+ },
text: {
primary: '#333333',
secondary: ({ colors }) => lighten(colors.tonalOffset, colors.text.primary),
@@ -229,6 +233,10 @@ export interface ResolvedThemeInterface {
success: ColorSetting;
warning: ColorSetting;
error: ColorSetting;
+ gray: {
+ 50: string;
+ 100: string;
+ };
border: {
light: string;
dark: string;
diff --git a/src/types/open-api.d.ts b/src/types/open-api.d.ts
index 891d7a0fa8..12344ec553 100644
--- a/src/types/open-api.d.ts
+++ b/src/types/open-api.d.ts
@@ -196,7 +196,7 @@ export interface OpenAPILink {
export type OpenAPIHeader = Omit;
export interface OpenAPICallback {
- $ref?: string;
+ [name: string]: OpenAPIPath;
}
export interface OpenAPIComponents {
diff --git a/tests/e2e/redoc.e2e.js b/tests/e2e/redoc.e2e.js
index f453e9c20a..b9ef55de0e 100644
--- a/tests/e2e/redoc.e2e.js
+++ b/tests/e2e/redoc.e2e.js
@@ -8,9 +8,9 @@ const getInnerHtml = require('./helpers').getInnerHtml;
const URL = 'index.html';
function waitForInit() {
- var EC = protractor.ExpectedConditions;
- var $apiInfo = $('api-info');
- var $errorMessage = $('.redoc-error')
+ const EC = protractor.ExpectedConditions;
+ const $apiInfo = $('api-info');
+ const $errorMessage = $('.redoc-error');
browser.wait(EC.or(EC.visibilityOf($apiInfo), EC.visibilityOf($errorMessage)), 60000);
}
@@ -21,7 +21,7 @@ function basicTests(swaggerUrl, title) {
specUrl += `?url=${encodeURIComponent(swaggerUrl)}`;
}
- beforeEach((done) => {
+ beforeEach(done => {
browser.get(specUrl);
waitForInit();
fixFFTest(done);
@@ -31,11 +31,11 @@ function basicTests(swaggerUrl, title) {
verifyNoBrowserErrors();
});
- it('should init redoc without errors', (done) => {
- let $redoc = $('redoc');
+ it('should init redoc without errors', done => {
+ const $redoc = $('redoc');
expect($redoc.isPresent()).toBe(true);
setTimeout(() => {
- let $operations = $$('operation');
+ const $operations = $$('operation');
expect($operations.count()).toBeGreaterThan(0);
done();
});
@@ -45,11 +45,10 @@ function basicTests(swaggerUrl, title) {
basicTests(null, 'Extended Petstore');
-
describe('Scroll sync', () => {
- let specUrl = URL;
+ const specUrl = URL;
- beforeEach((done) => {
+ beforeEach(done => {
browser.get(specUrl);
waitForInit();
fixFFTest(done);
@@ -57,25 +56,31 @@ describe('Scroll sync', () => {
it('should update active menu entries on page scroll forwards', () => {
scrollToEl('[section="tag/store"]').then(() => {
- expect(getInnerHtml('.menu-item.menu-item-depth-1.active > .menu-item-header')).toContain('store');
+ expect(getInnerHtml('.menu-item.menu-item-depth-1.active > .menu-item-header')).toContain(
+ 'store',
+ );
expect(getInnerHtml('.selected-tag')).toContain('store');
});
});
it('should update active menu entries on page scroll backwards', () => {
scrollToEl('[operation-id="getPetById"]').then(() => {
- expect(getInnerHtml('.menu-item.menu-item-depth-1.active .menu-item-header')).toContain('pet');
+ expect(getInnerHtml('.menu-item.menu-item-depth-1.active .menu-item-header')).toContain(
+ 'pet',
+ );
expect(getInnerHtml('.selected-tag')).toContain('pet');
- expect(getInnerHtml('.menu-item.menu-item-depth-2.active .menu-item-header')).toContain('Find pet by ID');
+ expect(getInnerHtml('.menu-item.menu-item-depth-2.active .menu-item-header')).toContain(
+ 'Find pet by ID',
+ );
expect(getInnerHtml('.selected-endpoint')).toContain('Find pet by ID');
});
});
});
describe('Language tabs sync', () => {
- let specUrl = URL;
+ const specUrl = URL;
- beforeEach((done) => {
+ beforeEach(done => {
browser.get(specUrl);
waitForInit();
fixFFTest(done);
@@ -84,10 +89,10 @@ describe('Language tabs sync', () => {
// skip as it fails for no reason on IE on sauce-labs
// TODO: fixme
xit('should sync language tabs', () => {
- var $item = $$('[operation-id="addPet"] tabs > ul > li').last();
+ const $item = $$('[operation-id="addPet"] tabs > ul > li').last();
// check if correct item
expect($item.getText()).toContain('PHP');
- var EC = protractor.ExpectedConditions;
+ const EC = protractor.ExpectedConditions;
browser.wait(EC.elementToBeClickable($item), 5000);
$item.click().then(() => {
expect($('[operation-id="updatePet"] li.active').getText()).toContain('PHP');
@@ -96,8 +101,7 @@ describe('Language tabs sync', () => {
});
if (process.env.JOB === 'e2e-guru') {
- describe('APIs.guru specs test', ()=> {
-
+ describe('APIs.guru specs test', () => {
// global.apisGuruList was loaded in onPrepare method of protractor config
let apisGuruList = global.apisGuruList;
@@ -118,11 +122,11 @@ if (process.env.JOB === 'e2e-guru') {
console.log('Running on a short APIs guru list');
apisGuruList = eachNth(apisGuruList, 20);
} else {
- console.log('Running on full APIs guru list')
+ console.log('Running on full APIs guru list');
}
- for (let apiName of Object.keys(apisGuruList)) {
- let apiInfo = apisGuruList[apiName].versions[apisGuruList[apiName].preferred];
+ for (const apiName of Object.keys(apisGuruList)) {
+ const apiInfo = apisGuruList[apiName].versions[apisGuruList[apiName].preferred];
let url = apiInfo.swaggerUrl;
// temporary hack due to this issue: https://github.com/substack/https-browserify/issues/6