From 57e93ec4355de2659fcb5449b14b7ed738c6c276 Mon Sep 17 00:00:00 2001 From: Oleksiy Kachynskyy Date: Wed, 8 Apr 2020 14:04:58 +0300 Subject: [PATCH] feat: add callbacks support (#1224) Co-authored-by: Jonathan Bailey Co-authored-by: Roman Hotsiy --- .github/CONTRIBUTING.md | 4 +- demo/openapi.yaml | 234 ++++++++++++++++- e2e/integration/menu.e2e.ts | 2 +- package-lock.json | 243 ++++++++++++++---- .../CallbackSamples/CallbackReqSamples.tsx | 35 +++ .../CallbackSamples/CallbackSamples.tsx | 79 ++++++ src/components/Callbacks/CallbackDetails.tsx | 46 ++++ .../Callbacks/CallbackOperation.tsx | 30 +++ src/components/Callbacks/CallbackTitle.tsx | 54 ++++ src/components/Callbacks/CallbacksList.tsx | 40 +++ src/components/Callbacks/index.ts | 3 + src/components/Callbacks/styled.elements.ts | 18 ++ src/components/ContentItems/ContentItems.tsx | 5 +- src/components/Endpoint/Endpoint.tsx | 5 +- src/components/Endpoint/styled.elements.ts | 13 +- .../GenericChildrenSwitcher.tsx | 74 ++++++ src/components/Operation/Operation.tsx | 23 +- .../PayloadSamples/styled.elements.ts | 16 +- src/components/Responses/ResponsesList.tsx | 9 +- src/components/__tests__/Callbacks.test.tsx | 59 +++++ .../__tests__/fixtures/simple-callback.json | 66 +++++ src/services/MenuBuilder.ts | 14 +- src/services/__tests__/fixtures/callback.json | 64 +++++ .../__tests__/models/Callback.test.ts | 26 ++ src/services/models/Callback.ts | 56 ++++ src/services/models/Operation.ts | 81 ++++-- src/services/models/index.ts | 1 + src/styled-components.ts | 4 +- src/theme.ts | 8 + src/types/open-api.d.ts | 2 +- tests/e2e/redoc.e2e.js | 48 ++-- 31 files changed, 1229 insertions(+), 133 deletions(-) create mode 100644 src/components/CallbackSamples/CallbackReqSamples.tsx create mode 100644 src/components/CallbackSamples/CallbackSamples.tsx create mode 100644 src/components/Callbacks/CallbackDetails.tsx create mode 100644 src/components/Callbacks/CallbackOperation.tsx create mode 100644 src/components/Callbacks/CallbackTitle.tsx create mode 100644 src/components/Callbacks/CallbacksList.tsx create mode 100644 src/components/Callbacks/index.ts create mode 100644 src/components/Callbacks/styled.elements.ts create mode 100644 src/components/GenericChildrenSwitcher/GenericChildrenSwitcher.tsx create mode 100644 src/components/__tests__/Callbacks.test.tsx create mode 100644 src/components/__tests__/fixtures/simple-callback.json create mode 100644 src/services/__tests__/fixtures/callback.json create mode 100644 src/services/__tests__/models/Callback.test.ts create mode 100644 src/services/models/Callback.ts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8cd5107f..9daca5cb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -50,11 +50,13 @@ $ npm run unit # run e2e tests $ npm run e2e +# Make sure you have created bundle before running e2e test +# E.g. run `npm run bundle` and wait for the finishing process. # open cypress UI to debug e2e test $ npm run cy:open -# run the full test suite, include linting / unit / e2e +# run the unit tests (includes linting and license checks) $ npm test # prepare bundles diff --git a/demo/openapi.yaml b/demo/openapi.yaml index 715ce2cb..5bf4c419 100644 --- a/demo/openapi.yaml +++ b/demo/openapi.yaml @@ -489,6 +489,234 @@ paths: description: Invalid ID supplied '404': description: Order not found + /store/subscribe: + post: + tags: + - store + summary: Subscribe to the Store events + description: Add subscription for a store events + requestBody: + content: + application/json: + schema: + type: object + properties: + callbackUrl: + type: string + format: uri + description: This URL will be called by the server when the desired event will occur + example: https://myserver.com/send/callback/here + eventName: + type: string + description: Event name for the subscription + enum: + - orderInProgress + - orderShipped + - orderDelivered + example: orderInProgress + required: + - callbackUrl + - eventName + responses: + '201': + description: Subscription added + content: + application/json: + schema: + type: object + properties: + subscriptionId: + type: string + example: AAA-123-BBB-456 + callbacks: + orderInProgress: + '{$request.body#/callbackUrl}?event={$request.body#/eventName}': + servers: + - url: //callback-url.path-level/v1 + description: Path level server 1 + - url: //callback-url.path-level/v2 + description: Path level server 2 + post: + summary: Order in Progress (Summary) + description: A callback triggered every time an Order is updated status to "inProgress" (Description) + externalDocs: + description: Find out more + url: 'https://more-details.com/demo' + requestBody: + content: + application/json: + schema: + type: object + properties: + orderId: + type: string + example: '123' + timestamp: + type: string + format: date-time + example: '2018-10-19T16:46:45Z' + status: + type: string + example: 'inProgress' + application/xml: + schema: + type: object + properties: + orderId: + type: string + example: '123' + example: | + + + 123 + inProgress + 2018-10-19T16:46:45Z + + responses: + '200': + description: Callback successfully processed and no retries will be performed + content: + application/json: + schema: + type: object + properties: + someProp: + type: string + example: '123' + '299': + description: Response for cancelling subscription + '500': + description: Callback processing failed and retries will be performed + x-code-samples: + - lang: 'C#' + source: | + PetStore.v1.Pet pet = new PetStore.v1.Pet(); + pet.setApiKey("your api key"); + pet.petType = PetStore.v1.Pet.TYPE_DOG; + pet.name = "Rex"; + // set other fields + PetStoreResponse response = pet.create(); + if (response.statusCode == HttpStatusCode.Created) + { + // Successfully created + } + else + { + // Something wrong -- check response for errors + Console.WriteLine(response.getRawResponse()); + } + - lang: PHP + source: | + $form = new \PetStore\Entities\Pet(); + $form->setPetType("Dog"); + $form->setName("Rex"); + // set other fields + try { + $pet = $client->pets()->create($form); + } catch (UnprocessableEntityException $e) { + var_dump($e->getErrors()); + } + put: + description: Order in Progress (Only Description) + servers: + - url: //callback-url.operation-level/v1 + description: Operation level server 1 (Operation override) + - url: //callback-url.operation-level/v2 + description: Operation level server 2 (Operation override) + requestBody: + content: + application/json: + schema: + type: object + properties: + orderId: + type: string + example: '123' + timestamp: + type: string + format: date-time + example: '2018-10-19T16:46:45Z' + status: + type: string + example: 'inProgress' + application/xml: + schema: + type: object + properties: + orderId: + type: string + example: '123' + example: | + + + 123 + inProgress + 2018-10-19T16:46:45Z + + responses: + '200': + description: Callback successfully processed and no retries will be performed + content: + application/json: + schema: + type: object + properties: + someProp: + type: string + example: '123' + orderShipped: + '{$request.body#/callbackUrl}?event={$request.body#/eventName}': + post: + description: | + Very long description + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis + nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in + culpa qui officia deserunt mollit anim id est laborum. + requestBody: + content: + application/json: + schema: + type: object + properties: + orderId: + type: string + example: '123' + timestamp: + type: string + format: date-time + example: '2018-10-19T16:46:45Z' + estimatedDeliveryDate: + type: string + format: date-time + example: '2018-11-11T16:00:00Z' + responses: + '200': + description: Callback successfully processed and no retries will be performed + orderDelivered: + 'http://notificationServer.com?url={$request.body#/callbackUrl}&event={$request.body#/eventName}': + post: + deprecated: true + summary: Order delivered + description: A callback triggered every time an Order is delivered to the recipient + requestBody: + content: + application/json: + schema: + type: object + properties: + orderId: + type: string + example: '123' + timestamp: + type: string + format: date-time + example: '2018-10-19T16:46:45Z' + responses: + '200': + description: Callback successfully processed and no retries will be performed /user: post: tags: @@ -955,7 +1183,7 @@ components: examples: Order: value: - quantity: 1, - shipDate: 2018-10-19T16:46:45Z, - status: placed, + quantity: 1 + shipDate: '2018-10-19T16:46:45Z' + status: placed complete: false diff --git a/e2e/integration/menu.e2e.ts b/e2e/integration/menu.e2e.ts index 511baca7..f21aebc6 100644 --- a/e2e/integration/menu.e2e.ts +++ b/e2e/integration/menu.e2e.ts @@ -6,7 +6,7 @@ describe('Menu', () => { it('should have valid items count', () => { cy.get('.menu-content') .find('li') - .should('have.length', 6 + (2 + 8 + 1 + 4 + 2) + (1 + 8)); + .should('have.length', 6 + (2 + 8 + 1 + 4 + 2) + (1 + 8) + 1); }); it('should sync active menu items while scroll', () => { diff --git a/package-lock.json b/package-lock.json index ee1ae4ea..fd20b276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "redoc", - "version": "2.0.0-rc.24", + "version": "2.0.0-rc.26", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2867,14 +2867,26 @@ } }, "bl": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.1.tgz", - "integrity": "sha512-FL/TdvchukRCuWVxT0YMO/7+L5TNeNrVFvRU2IY63aUyv9mpt8splf2NEr6qXtPo5fya5a66YohQKvGNmLrWNA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.2.tgz", + "integrity": "sha512-j4OH8f6Qg2bGuWfRiltT2HYGx0e1QcBTrK9KAHNMwMZdQnDZFk0ZSYIpADjYCB3U12nicC5tVJwSIhwOWjb4RQ==", "dev": true, "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", "readable-stream": "^3.4.0" }, "dependencies": { + "buffer": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.5.0.tgz", + "integrity": "sha512-9FTEDjLjwoAkEwyMGDjYJQN2gfRgOKBKRfiglhvibGbpeeU/pQn1bJxQqm32OD/AIeEuHxU9roxXxg34Byp/Ww==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -7270,22 +7282,182 @@ "dev": true }, "handlebars": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.3.tgz", - "integrity": "sha512-SRGwSYuNfx8DwHD/6InAPzD6RgeruWLT+B8e8a7gGs8FWgHzlExpTFMEq2IA6QpAfOClpKHy6+8IqTjeBCu6Kg==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.4.tgz", + "integrity": "sha512-Is8+SzHv8K9STNadlBVpVhxXrSXxVgTyIvhdg2Qjak1SfSZ7iEozLHdwiX1jJ9lLFkcFJxqGK5s/cI7ZX+qGkQ==", "dev": true, "requires": { "neo-async": "^2.6.0", - "optimist": "^0.6.1", "source-map": "^0.6.1", - "uglify-js": "^3.1.4" + "uglify-js": "^3.1.4", + "yargs": "^15.3.1" }, "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "yargs": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz", + "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.1" + } + }, + "yargs-parser": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.2.tgz", + "integrity": "sha512-hlIPNR3IzC1YuL1c2UwwDKpXlNFBqD1Fswwh1khz5+d8Cq/8yc/Mn0i+rQXduu8hcrFKvO7Eryk+09NecTQAAQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } } } }, @@ -9444,8 +9616,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "resolved": "", "dev": true, "optional": true } @@ -11485,6 +11656,12 @@ } } }, + "mkdirp-classic": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.2.tgz", + "integrity": "sha512-ejdnDQcR75gwknmMw/tx02AuRs8jCtqFoFqDZMjiNxsu85sRIJVXDKHuLYvUUPRBUtV2FpSZa9bL1BUa3BdR2g==", + "dev": true + }, "mobx": { "version": "4.15.4", "resolved": "https://registry.npmjs.org/mobx/-/mobx-4.15.4.tgz", @@ -12098,24 +12275,6 @@ "is-wsl": "^1.1.0" } }, - "optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", - "dev": true, - "requires": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - }, - "dependencies": { - "minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=", - "dev": true - } - } - }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -14939,13 +15098,13 @@ "dev": true }, "tar-fs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.0.tgz", - "integrity": "sha512-vaY0obB6Om/fso8a8vakQBzwholQ7v5+uy+tF3Ozvxv1KNezmVQAiWtcNmMHFSFPqL3dJA8ha6gdtFbfX9mcxA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==", "dev": true, "requires": { "chownr": "^1.1.1", - "mkdirp": "^0.5.1", + "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.0.0" } @@ -15412,9 +15571,9 @@ "dev": true }, "uglify-js": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.8.0.tgz", - "integrity": "sha512-ugNSTT8ierCsDHso2jkBHXYrU8Y5/fY2ZUprfrJUiD7YpuFvV4jODLFmb3h4btQjqr5Nh4TX4XtgDfCU1WdioQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.8.1.tgz", + "integrity": "sha512-W7KxyzeaQmZvUFbGj4+YFshhVrMBGSg2IbcYAjGWGvx8DHvJMclbTDMpffdxFUGPBHjIytk7KJUR/KUXstUGDw==", "dev": true, "optional": true, "requires": { @@ -16308,8 +16467,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "resolved": "", "dev": true, "optional": true } @@ -17407,8 +17565,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "resolved": "", "dev": true, "optional": true } @@ -17776,12 +17933,6 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, - "wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", - "dev": true - }, "worker-farm": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz", diff --git a/src/components/CallbackSamples/CallbackReqSamples.tsx b/src/components/CallbackSamples/CallbackReqSamples.tsx new file mode 100644 index 00000000..e7a51723 --- /dev/null +++ b/src/components/CallbackSamples/CallbackReqSamples.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import styled from '../../styled-components'; +import { DropdownProps } from '../../common-elements'; +import { PayloadSamples } from '../PayloadSamples/PayloadSamples'; +import { OperationModel } from '../../services/models'; +import { XPayloadSample } from '../../services/models/Operation'; +import { isPayloadSample } from '../../services'; + +export interface PayloadSampleProps { + callback: OperationModel; + renderDropdown: (props: DropdownProps) => JSX.Element; +} + +export class CallbackPayloadSample extends React.Component { + render() { + const payloadSample = this.props.callback.codeSamples.find(sample => + isPayloadSample(sample), + ) as XPayloadSample | undefined; + + if (!payloadSample) { + return null; + } + + return ( + + + + ); + } +} + +export const PayloadSampleWrapper = styled.div` + margin-top: 15px; +`; diff --git a/src/components/CallbackSamples/CallbackSamples.tsx b/src/components/CallbackSamples/CallbackSamples.tsx new file mode 100644 index 00000000..b5064d5c --- /dev/null +++ b/src/components/CallbackSamples/CallbackSamples.tsx @@ -0,0 +1,79 @@ +import { observer } from 'mobx-react'; +import * as React from 'react'; + +import styled from '../../styled-components'; +import { RightPanelHeader } from '../../common-elements'; +import { RedocNormalizedOptions } from '../../services'; +import { CallbackModel } from '../../services/models'; +import { OptionsContext } from '../OptionsProvider'; +import { GenericChildrenSwitcher } from '../GenericChildrenSwitcher/GenericChildrenSwitcher'; +import { DropdownOrLabel } from '../DropdownOrLabel/DropdownOrLabel'; +import { InvertedSimpleDropdown, MimeLabel } from '../PayloadSamples/styled.elements'; +import { CallbackPayloadSample } from './CallbackReqSamples'; + +export interface CallbackSamplesProps { + callbacks: CallbackModel[]; +} + +@observer +export class CallbackSamples extends React.Component { + static contextType = OptionsContext; + context: RedocNormalizedOptions; + + private renderDropdown = props => { + return ; + }; + + render() { + const { callbacks } = this.props; + + if (!callbacks || callbacks.length === 0) { + return null; + } + + const operations = callbacks + .map(callback => callback.operations.map(operation => operation)) + .reduce((a, b) => a.concat(b), []); + + const hasSamples = operations.some(operation => operation.codeSamples.length > 0); + + if (!hasSamples) { + return null; + } + + const dropdownOptions = operations.map((callback, idx) => { + return { + label: `${callback.httpVerb.toUpperCase()}: ${callback.name}`, + value: idx.toString(), + }; + }); + + return ( +
+ Callback payload samples + + + + {callback => ( + + )} + + +
+ ); + } +} + +export const SamplesWrapper = styled.div` + background: ${({ theme }) => theme.codeBlock.backgroundColor}; + padding: ${props => props.theme.spacing.unit * 4}px; +`; diff --git a/src/components/Callbacks/CallbackDetails.tsx b/src/components/Callbacks/CallbackDetails.tsx new file mode 100644 index 00000000..ce003c56 --- /dev/null +++ b/src/components/Callbacks/CallbackDetails.tsx @@ -0,0 +1,46 @@ +import { observer } from 'mobx-react'; +import * as React from 'react'; + +import { OperationModel } from '../../services/models'; +import styled from '../../styled-components'; +import { Endpoint } from '../Endpoint/Endpoint'; +import { ExternalDocumentation } from '../ExternalDocumentation/ExternalDocumentation'; +import { Extensions } from '../Fields/Extensions'; +import { Markdown } from '../Markdown/Markdown'; +import { Parameters } from '../Parameters/Parameters'; +import { ResponsesList } from '../Responses/ResponsesList'; +import { SecurityRequirements } from '../SecurityRequirement/SecurityRequirement'; +import { CallbackDetailsWrap } from './styled.elements'; + +export interface CallbackDetailsProps { + operation: OperationModel; +} + +@observer +export class CallbackDetails extends React.Component { + render() { + const { operation } = this.props; + const { description, externalDocs } = operation; + const hasDescription = !!(description || externalDocs); + + return ( + + {hasDescription && ( + + {description !== undefined && } + {externalDocs && } + + )} + + + + + + + ); + } +} + +const Description = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.unit * 3}px; +`; diff --git a/src/components/Callbacks/CallbackOperation.tsx b/src/components/Callbacks/CallbackOperation.tsx new file mode 100644 index 00000000..f811cdb5 --- /dev/null +++ b/src/components/Callbacks/CallbackOperation.tsx @@ -0,0 +1,30 @@ +import { observer } from 'mobx-react'; +import * as React from 'react'; + +import { OperationModel } from '../../services/models'; +import { StyledCallbackTitle } from './styled.elements'; +import { CallbackDetails } from './CallbackDetails'; + +@observer +export class CallbackOperation extends React.Component<{ callbackOperation: OperationModel }> { + toggle = () => { + this.props.callbackOperation.toggle(); + }; + + render() { + const { name, expanded, httpVerb, deprecated } = this.props.callbackOperation; + + return ( + <> + + {expanded && } + + ); + } +} diff --git a/src/components/Callbacks/CallbackTitle.tsx b/src/components/Callbacks/CallbackTitle.tsx new file mode 100644 index 00000000..d91dab7c --- /dev/null +++ b/src/components/Callbacks/CallbackTitle.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; + +import { darken } from 'polished'; +import { ShelfIcon } from '../../common-elements'; +import { OperationBadge } from '../SideMenu/styled.elements'; +import { shortenHTTPVerb } from '../../utils/openapi'; +import styled from '../../styled-components'; +import { Badge } from '../../common-elements/'; +import { l } from '../../services/Labels'; + +export interface CallbackTitleProps { + name: string; + opened?: boolean; + httpVerb: string; + deprecated?: boolean; + className?: string; + onClick?: () => void; +} + +export class CallbackTitle extends React.PureComponent { + render() { + const { name, opened, className, onClick, httpVerb, deprecated } = this.props; + + return ( + + {shortenHTTPVerb(httpVerb)} + + {name} + {deprecated ? {l('deprecated')} : null} + + ); + } +} + +const CallbackTitleWrapper = styled.div` + & > * { + vertical-align: middle; + } + + ${ShelfIcon} { + polygon { + fill: ${({ theme }) => darken(theme.colors.tonalOffset, theme.colors.gray[100])}; + } + } +`; + +const CallbackName = styled.span<{ deprecated?: boolean }>` + text-decoration: ${props => (props.deprecated ? 'line-through' : 'none')}; + margin-right: 8px; +`; + +const OperationBadgeStyled = styled(OperationBadge)` + margin: 0px 5px 0px 0px; +`; diff --git a/src/components/Callbacks/CallbacksList.tsx b/src/components/Callbacks/CallbacksList.tsx new file mode 100644 index 00000000..8ef40654 --- /dev/null +++ b/src/components/Callbacks/CallbacksList.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { CallbackModel } from '../../services/models'; +import styled from '../../styled-components'; +import { CallbackOperation } from './CallbackOperation'; + +export interface CallbacksListProps { + callbacks: CallbackModel[]; +} + +export class CallbacksList extends React.PureComponent { + render() { + const { callbacks } = this.props; + + if (!callbacks || callbacks.length === 0) { + return null; + } + + return ( +
+ Callbacks + {callbacks.map(callback => { + return callback.operations.map((operation, index) => { + return ( + + ); + }); + })} +
+ ); + } +} + +const CallbacksHeader = styled.h3` + font-size: 1.3em; + padding: 0.2em 0; + margin: 3em 0 1.1em; + color: ${({ theme }) => theme.colors.text.primary}; + font-weight: normal; +`; diff --git a/src/components/Callbacks/index.ts b/src/components/Callbacks/index.ts new file mode 100644 index 00000000..740ec844 --- /dev/null +++ b/src/components/Callbacks/index.ts @@ -0,0 +1,3 @@ +export * from './CallbackOperation'; +export * from './CallbackTitle'; +export * from './CallbacksList'; diff --git a/src/components/Callbacks/styled.elements.ts b/src/components/Callbacks/styled.elements.ts new file mode 100644 index 00000000..522423bb --- /dev/null +++ b/src/components/Callbacks/styled.elements.ts @@ -0,0 +1,18 @@ +import styled from '../../styled-components'; +import { CallbackTitle } from './CallbackTitle'; + +export const StyledCallbackTitle = styled(CallbackTitle)` + padding: 10px; + border-radius: 2px; + margin-bottom: 4px; + line-height: 1.5em; + background-color: ${({ theme }) => theme.colors.gray[100]}; + cursor: pointer; +`; + +export const CallbackDetailsWrap = styled.div` + padding: 10px 25px; + background-color: ${({ theme }) => theme.colors.gray[50]}; + margin-bottom: 5px; + margin-top: 5px; +`; diff --git a/src/components/ContentItems/ContentItems.tsx b/src/components/ContentItems/ContentItems.tsx index 7545243f..2766887a 100644 --- a/src/components/ContentItems/ContentItems.tsx +++ b/src/components/ContentItems/ContentItems.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { ExternalDocumentation } from '../ExternalDocumentation/ExternalDocumentation'; import { AdvancedMarkdown } from '../Markdown/AdvancedMarkdown'; - import { H1, H2, MiddlePanel, Row, Section, ShareLink } from '../../common-elements'; import { ContentItemModel } from '../../services/MenuBuilder'; import { GroupModel, OperationModel } from '../../services/models'; @@ -18,7 +17,9 @@ export class ContentItems extends React.Component<{ if (items.length === 0) { return null; } - return items.map(item => ); + return items.map(item => { + return ; + }); } } diff --git a/src/components/Endpoint/Endpoint.tsx b/src/components/Endpoint/Endpoint.tsx index 280a8582..e59ceff5 100644 --- a/src/components/Endpoint/Endpoint.tsx +++ b/src/components/Endpoint/Endpoint.tsx @@ -21,6 +21,7 @@ export interface EndpointProps { hideHostname?: boolean; inverted?: boolean; + compact?: boolean; } export interface EndpointState { @@ -49,7 +50,9 @@ export class Endpoint extends React.Component { {options => ( - {operation.httpVerb}{' '} + + {operation.httpVerb} + {operation.path} ({ +export const HttpVerb = styled.span.attrs((props: { type: string; compact?: boolean }) => ({ className: `http-verb ${props.type}`, -}))<{ type: string }>` - font-size: 0.929em; - line-height: 20px; - background-color: ${(props: any) => props.theme.colors.http[props.type] || '#999999'}; +}))<{ type: string; compact?: boolean }>` + font-size: ${props => (props.compact ? '0.8em' : '0.929em')}; + line-height: ${props => (props.compact ? '18px' : '20px')}; + background-color: ${props => props.theme.colors.http[props.type] || '#999999'}; color: #ffffff; - padding: 3px 10px; + padding: ${props => (props.compact ? '2px 8px' : '3px 10px')}; text-transform: uppercase; font-family: ${props => props.theme.typography.headings.fontFamily}; margin: 0; @@ -59,7 +59,6 @@ export const ServersOverlay = styled.div<{ expanded: boolean }>` border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; transition: all 0.25s ease; - ${props => (props.expanded ? '' : 'transform: translateY(-50%) scaleY(0);')} `; diff --git a/src/components/GenericChildrenSwitcher/GenericChildrenSwitcher.tsx b/src/components/GenericChildrenSwitcher/GenericChildrenSwitcher.tsx new file mode 100644 index 00000000..b850fd99 --- /dev/null +++ b/src/components/GenericChildrenSwitcher/GenericChildrenSwitcher.tsx @@ -0,0 +1,74 @@ +import { observer } from 'mobx-react'; +import * as React from 'react'; + +import { DropdownProps, DropdownOption } from '../../common-elements/dropdown'; +import { DropdownLabel, DropdownWrapper } from '../PayloadSamples/styled.elements'; + +export interface GenericChildrenSwitcherProps { + items?: T[]; + options: DropdownOption[]; + label?: string; + renderDropdown: (props: DropdownProps) => JSX.Element; + children: (activeItem: T) => JSX.Element; +} + +export interface GenericChildrenSwitcherState { + activeItemIdx: number; +} +/** + * TODO: Refactor this component: + * Implement rendering dropdown/label directly in this component + * Accept as a parameter mapper-function for building dropdown option labels + */ +@observer +export class GenericChildrenSwitcher extends React.Component< + GenericChildrenSwitcherProps, + GenericChildrenSwitcherState +> { + constructor(props) { + super(props); + this.state = { + activeItemIdx: 0, + }; + } + + switchItem = ({ value }) => { + if (this.props.items) { + this.setState({ + activeItemIdx: parseInt(value, 10), + }); + } + }; + + render() { + const { items } = this.props; + + if (!items || !items.length) { + return null; + } + + const Wrapper = ({ children }) => + this.props.label ? ( + + {this.props.label} + {children} + + ) : ( + children + ); + + return ( + <> + + {this.props.renderDropdown({ + value: this.props.options[this.state.activeItemIdx], + options: this.props.options, + onChange: this.switchItem, + })} + + + {this.props.children(items[this.state.activeItemIdx])} + + ); + } +} diff --git a/src/components/Operation/Operation.tsx b/src/components/Operation/Operation.tsx index 1283a6ad..6da94e1c 100644 --- a/src/components/Operation/Operation.tsx +++ b/src/components/Operation/Operation.tsx @@ -1,29 +1,26 @@ -import * as React from 'react'; -import { SecurityRequirements } from '../SecurityRequirement/SecurityRequirement'; - import { observer } from 'mobx-react'; +import * as React from 'react'; import { Badge, DarkRightPanel, H2, MiddlePanel, Row } from '../../common-elements'; - -import { OptionsContext } from '../OptionsProvider'; - import { ShareLink } from '../../common-elements/linkify'; +import { OperationModel } from '../../services/models'; +import styled from '../../styled-components'; +import { CallbacksList } from '../Callbacks'; +import { CallbackSamples } from '../CallbackSamples/CallbackSamples'; import { Endpoint } from '../Endpoint/Endpoint'; import { ExternalDocumentation } from '../ExternalDocumentation/ExternalDocumentation'; +import { Extensions } from '../Fields/Extensions'; import { Markdown } from '../Markdown/Markdown'; +import { OptionsContext } from '../OptionsProvider'; import { Parameters } from '../Parameters/Parameters'; import { RequestSamples } from '../RequestSamples/RequestSamples'; import { ResponsesList } from '../Responses/ResponsesList'; import { ResponseSamples } from '../ResponseSamples/ResponseSamples'; - -import { OperationModel as OperationType } from '../../services/models'; -import styled from '../../styled-components'; -import { Extensions } from '../Fields/Extensions'; +import { SecurityRequirements } from '../SecurityRequirement/SecurityRequirement'; const OperationRow = styled(Row)` backface-visibility: hidden; contain: content; - overflow: hidden; `; @@ -32,7 +29,7 @@ const Description = styled.div` `; export interface OperationProps { - operation: OperationType; + operation: OperationModel; } @observer @@ -63,11 +60,13 @@ export class Operation extends React.Component { + {!options.pathInMiddlePanel && } + )} diff --git a/src/components/PayloadSamples/styled.elements.ts b/src/components/PayloadSamples/styled.elements.ts index 5dd3f41a..a2d75d6b 100644 --- a/src/components/PayloadSamples/styled.elements.ts +++ b/src/components/PayloadSamples/styled.elements.ts @@ -4,14 +4,16 @@ import ReactDropdown from 'react-dropdown'; import { transparentize } from 'polished'; import styled from '../../styled-components'; - import { StyledDropdown } from '../../common-elements'; export const MimeLabel = styled.div` - padding: 12px; + padding: 0.9em; background-color: ${({ theme }) => transparentize(0.6, theme.rightPanel.backgroundColor)}; margin: 0 0 10px 0; display: block; + font-family: ${({ theme }) => theme.typography.headings.fontFamily}; + font-size: 0.929em; + line-height: 1.5em; `; export const DropdownLabel = styled.span` @@ -36,6 +38,11 @@ export const InvertedSimpleDropdown = styled(StyledDropdown)` margin: 0 0 10px 0; display: block; background-color: ${({ theme }) => transparentize(0.6, theme.rightPanel.backgroundColor)}; + .Dropdown-placeholder { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } .Dropdown-control { margin-top: 0; } @@ -55,6 +62,11 @@ export const InvertedSimpleDropdown = styled(StyledDropdown)` .Dropdown-menu { margin: 0; margin-top: 2px; + .Dropdown-option { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } } `; diff --git a/src/components/Responses/ResponsesList.tsx b/src/components/Responses/ResponsesList.tsx index da465489..157aca58 100644 --- a/src/components/Responses/ResponsesList.tsx +++ b/src/components/Responses/ResponsesList.tsx @@ -4,20 +4,21 @@ import styled from '../../styled-components'; import { ResponseView } from './Response'; const ResponsesHeader = styled.h3` - font-size: 18px; + font-size: 1.3em; padding: 0.2em 0; margin: 3em 0 1.1em; - color: #253137; + color: ${({ theme }) => theme.colors.text.primary}; font-weight: normal; `; export interface ResponseListProps { responses: ResponseModel[]; + isCallback?: boolean; } export class ResponsesList extends React.PureComponent { render() { - const { responses } = this.props; + const { responses, isCallback } = this.props; if (!responses || responses.length === 0) { return null; @@ -25,7 +26,7 @@ export class ResponsesList extends React.PureComponent { return (
- 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 00000000..8cb2b39a --- /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 00000000..6ee56361 --- /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 f61f6189..e9daeedb 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 00000000..5ca4af74 --- /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 00000000..9fb67799 --- /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 00000000..7adfc27f --- /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 1aa3a65d..fc2d41ce 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.pointer = operationSpec.pointer; - this.id = - operationSpec.operationId !== undefined - ? 'operation/' + operationSpec.operationId - : parent !== undefined - ? parent.id + this.pointer - : this.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.name = getOperationSummary(operationSpec); - this.servers = normalizeServers( - parser.specUrl, - operationSpec.servers || (pathInfo && pathInfo.servers) || parser.spec.servers || [], - ); + 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), + ); - this.security = (operationSpec.security || parser.spec.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 65006e79..a3569c5a 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 868211a4..9db27997 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 3181808a..eca8855e 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 891d7a0f..12344ec5 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 f453e9c2..b9ef55de 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