[UI] Display Camelized Operation ID in API Explorer (#29785)

* updates swagger-ui to display camelized operation ids in development

* attempt to fix test timing issue

* fixes issue stubbing environment in swagger-ui test

* adds test for operation ids in production for swagger-ui component
This commit is contained in:
Jordan Reimer
2025-02-28 15:57:43 -07:00
committed by GitHub
parent 8cf97568c0
commit a66bd4ec20
4 changed files with 106 additions and 46 deletions

View File

@@ -8,4 +8,9 @@
/* align list items with container */
.swagger-ember .swagger-ui .wrapper {
padding: 0;
.opblock-summary-path-description-wrapper {
width: min-content;
flex-grow: 1;
}
}

View File

@@ -8,11 +8,13 @@ import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import parseURL from 'core/utils/parse-url';
import config from 'open-api-explorer/config/environment';
import config from 'vault/config/environment';
import openApiExplorerConfig from 'open-api-explorer/config/environment';
import { guidFor } from '@ember/object/internals';
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle.js';
import { camelize } from '@ember/string';
const { APP } = config;
const { APP } = openApiExplorerConfig;
export default class SwaggerUiComponent extends Component {
@service auth;
@@ -49,16 +51,41 @@ export default class SwaggerUiComponent extends Component {
};
}
// the operationId values in the spec are dasherized
// camelize the values so they match the function names in the generated API client SDK
CamelizeOperationIdPlugin() {
return {
wrapComponents: {
operation:
(Original, { React }) =>
(props) => {
const { operation } = props;
const operationId = operation.get('operationId');
if (operationId) {
return React.createElement(Original, {
...props,
operation: operation.set('operationId', camelize(operationId)),
});
}
return React.createElement(Original, props);
},
},
};
}
CONFIG = (SwaggerUIBundle, componentInstance) => {
return {
dom_id: `#${componentInstance.inputId}`,
url: '/v1/sys/internal/specs/openapi',
deepLinking: false,
presets: [SwaggerUIBundle.presets.apis],
plugins: [SwaggerUIBundle.plugins.DownloadUrl, this.SearchFilterPlugin],
plugins: [SwaggerUIBundle.plugins.DownloadUrl, this.SearchFilterPlugin, this.CamelizeOperationIdPlugin],
// 'list' expands tags, but not operations
docExpansion: 'list',
operationsSorter: 'alpha',
displayOperationId: config.environment === 'development',
filter: true,
// this makes sure we show the x-vault- options
showExtensions: true,

View File

@@ -4,42 +4,51 @@
*/
import { Factory } from 'miragejs';
/* eslint-disable ember/avoid-leaking-state-in-ember-objects */
export default Factory.extend({
openapi: '3.0.2',
info: {
title: 'HashiCorp Vault API',
description: 'HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.',
version: '1.0.0',
license: {
name: 'Mozilla Public License 2.0',
url: 'https://www.mozilla.org/en-US/MPL/2.0',
},
},
paths: {
'/auth/token/create': {
description: 'The token create path is used to create new tokens.',
post: {
summary: 'The token create path is used to create new tokens.',
tags: ['auth'],
responses: {
200: {
description: 'OK',
// set in afterCreate to avoid leaking state lint error
info: null,
paths: null,
afterCreate(spec) {
spec.info = {
title: 'HashiCorp Vault API',
description: 'HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.',
version: '1.0.0',
license: {
name: 'Mozilla Public License 2.0',
url: 'https://www.mozilla.org/en-US/MPL/2.0',
},
};
spec.paths = {
'/auth/token/create': {
description: 'The token create path is used to create new tokens.',
post: {
summary: 'The token create path is used to create new tokens.',
tags: ['auth'],
operationId: 'token-create',
responses: {
200: {
description: 'OK',
},
},
},
},
},
'/secret/data/{path}': {
description: 'Location of a secret.',
post: {
summary: 'Location of a secret.',
tags: ['secret'],
responses: {
200: {
description: 'OK',
'/secret/data/{path}': {
description: 'Location of a secret.',
post: {
summary: 'Location of a secret.',
tags: ['secret'],
operationId: 'kv-v2-write',
responses: {
200: {
description: 'OK',
},
},
},
},
},
};
},
});

View File

@@ -5,50 +5,48 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { fillIn, render, typeIn, waitFor } from '@ember/test-helpers';
import { fillIn, render, typeIn } from '@ember/test-helpers';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
import config from 'vault/config/environment';
import { camelize } from '@ember/string';
const SELECTORS = {
container: '[data-test-swagger-ui]',
searchInput: 'input.operation-filter-input',
apiPathBlock: '.opblock-post',
operationId: '.opblock-summary-operation-id',
};
module('Integration | Component | open-api-explorer | swagger-ui', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'open-api-explorer');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
const openApiResponse = this.server.create('open-api-explorer');
this.openApiResponse = this.server.create('open-api-explorer');
this.server.get('sys/internal/specs/openapi', () => {
return openApiResponse;
return this.openApiResponse;
});
this.totalApiPaths = Object.keys(openApiResponse.paths).length;
this.totalApiPaths = Object.keys(this.openApiResponse.paths).length;
this.renderComponent = async () => {
await render(hbs`<SwaggerUi/>`, {
owner: this.engine,
});
};
this.renderComponent = () => render(hbs`<SwaggerUi/>`, { owner: this.engine });
});
test(`it renders`, async function (assert) {
test('it renders', async function (assert) {
await this.renderComponent();
await waitFor(SELECTORS.container);
assert.dom(SELECTORS.container).exists('renders component');
assert.dom(SELECTORS.apiPathBlock).exists({ count: this.totalApiPaths }, 'renders all api paths');
});
test(`it can search`, async function (assert) {
test('it can search', async function (assert) {
await this.renderComponent();
// in testing only the input is not filling correctly except after the second time
await fillIn(SELECTORS.searchInput, 'moot');
await typeIn(SELECTORS.searchInput, 'token');
@@ -57,4 +55,25 @@ module('Integration | Component | open-api-explorer | swagger-ui', function (hoo
// if the search fn breaks, this test will fail
assert.dom(SELECTORS.searchInput).hasValue('token', 'search input has value');
});
test('it should render camelized operation ids', async function (assert) {
const envStub = sinon.stub(config, 'environment').value('development');
await this.renderComponent();
const id = this.openApiResponse.paths['/auth/token/create'].post.operationId;
assert.dom(SELECTORS.operationId).hasText(camelize(id), 'renders camelized operation id');
envStub.restore();
});
test('it should not render operation ids in production', async function (assert) {
const envStub = sinon.stub(config, 'environment').value('production');
await this.renderComponent();
assert.dom(SELECTORS.operationId).doesNotExist('operation ids are hidden in production environment');
envStub.restore();
});
});