diff --git a/demo/apis.json b/demo/apis.json index 088f3ba..84be5bf 100644 --- a/demo/apis.json +++ b/demo/apis.json @@ -18,5 +18,6 @@ }, "APIC-641/APIC-641.yaml": { "type": "OAS 3.0", "mime": "application/yaml" }, "APIC-711/APIC-711.raml": "RAML 1.0", - "agents-api/agents-api.yaml": { "type": "OAS 3.0", "mime": "application/yaml" } + "agents-api/agents-api.yaml": { "type": "OAS 3.0", "mime": "application/yaml" }, + "tags-flights/tags-flights.yml": { "type": "OAS 3.0", "mime": "application/yaml" } } diff --git a/demo/index.js b/demo/index.js index 4709806..1b9aaf0 100644 --- a/demo/index.js +++ b/demo/index.js @@ -13,6 +13,7 @@ class ApiDemo extends ApiDemoPage { _apiListTemplate() { return [ + ["tags-flights", "Tags Flights"], ["agents-api", "Agents API"], ["google-drive-api", "Google Drive"], ["exchange-experience-api", "Exchange xAPI"], diff --git a/demo/tags-flights/tags-flights.yml b/demo/tags-flights/tags-flights.yml new file mode 100644 index 0000000..65867f2 --- /dev/null +++ b/demo/tags-flights/tags-flights.yml @@ -0,0 +1,42 @@ +openapi: 3.0.3 +info: + title: Flights API + version: v1 +tags: + - name: AllFlights + description: Operations related to all flights + - name: OneFlight + description: Operations related to a single flight +paths: + /flights: + get: + tags: + - AllFlights + summary: List all flights + operationId: getAllFlights + responses: + '200': + description: Successful response + post: + tags: + - AllFlights + summary: Create a new flight + operationId: createFlight + responses: + '201': + description: Flight created + /flights/{ID}: + get: + tags: + - OneFlight + summary: Get a single flight by ID + operationId: getOneFlight + parameters: + - name: ID + in: path + required: true + schema: + type: string + responses: + '200': + description: Successful response \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a3cb744..69a4283 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@api-components/api-summary", - "version": "4.6.12", + "version": "4.6.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@api-components/api-summary", - "version": "4.6.12", + "version": "4.6.13", "license": "Apache-2.0", "dependencies": { "@advanced-rest-client/arc-marked": "^1.1.2", diff --git a/package.json b/package.json index 0b980dc..76343b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@api-components/api-summary", "description": "A summary view for an API base on AMF data model", - "version": "4.6.12", + "version": "4.6.13", "license": "Apache-2.0", "main": "index.js", "module": "index.js", diff --git a/src/ApiSummary.js b/src/ApiSummary.js index d1e8b6e..082b4a3 100644 --- a/src/ApiSummary.js +++ b/src/ApiSummary.js @@ -9,7 +9,7 @@ import labelStyles from "@api-components/http-method-label/http-method-label-com import sanitizer from "dompurify"; import "@advanced-rest-client/arc-marked/arc-marked.js"; import "@api-components/api-method-documentation/api-url.js"; -import { codegenie } from '@advanced-rest-client/icons/ArcIcons.js'; +import { codegenie } from "@advanced-rest-client/icons/ArcIcons.js"; import styles from "./Styles.js"; @@ -290,16 +290,176 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { return undefined; } return endpoints.map((item) => { + const path = this._getValue( + item, + this.ns.aml.vocabularies.apiContract.path + ); + const pathStr = typeof path === "string" ? path : String(path || ""); + const supportedOperations = this._endpointOperations(item); + const endpointDisplayInfo = this._computeEndpointName( + item, + webApi, + supportedOperations + ); const result = { - name: this._getValue(item, this.ns.aml.vocabularies.core.name), - path: this._getValue(item, this.ns.aml.vocabularies.apiContract.path), - id: item["@id"], - ops: this._endpointOperations(item), + name: endpointDisplayInfo?.name, + description: endpointDisplayInfo?.description, + path: pathStr, + id: item["@id"], + ops: supportedOperations, }; return result; }); } + /** + * Computes a descriptive name for an endpoint based on its operations and tags. + * @param {any} endpoint Endpoint model + * @param {any} webApi Web API model + * @param {any[]} ops Operations for this endpoint + * @return {Object|undefined} Object with name and description, or undefined + */ + _computeEndpointName(endpoint, webApi, ops) { + // First try to get explicit name from endpoint + const explicitName = this._getValue( + endpoint, + this.ns.aml.vocabularies.core.name + ); + if (explicitName && typeof explicitName === "string") { + return { name: explicitName, description: undefined }; + } + + // Try to get name from the most common tag + if (ops && ops.length > 0) { + const tagInfo = this._getEndpointTagInfo(endpoint, webApi); + if (tagInfo) { + return tagInfo; + } + + // Try to get name from operation summaries + const operationName = this._getEndpointOperationName(endpoint); + if (operationName) { + return { name: operationName, description: undefined }; + } + } + + // Fallback: return undefined so path is used + return undefined; + } + + /** + * Gets the tag info (name and description) for an endpoint based on its operations. + * @param {any} endpoint Endpoint model + * @param {any} webApi Web API model + * @return {Object|undefined} Object with name and description + */ + _getEndpointTagInfo(endpoint, webApi) { + const operationsKey = this._getAmfKey( + this.ns.aml.vocabularies.apiContract.supportedOperation + ); + const operations = this._ensureArray(endpoint[operationsKey]); + + if (!operations || !operations.length) { + return undefined; + } + + // Collect all tags from all operations + const tagKey = this._getAmfKey(this.ns.aml.vocabularies.apiContract.tag); + const allTags = []; + + operations.forEach((operation) => { + const operationTags = this._ensureArray(operation[tagKey]); + if (operationTags && operationTags.length) { + operationTags.forEach((tag) => { + const tagName = this._getValue( + tag, + this.ns.aml.vocabularies.core.name + ); + if (tagName && typeof tagName === "string") { + allTags.push(tagName); + } + }); + } + }); + + if (!allTags.length) { + return undefined; + } + + // Find the most common tag (or just use the first one if all are unique) + const tagCounts = {}; + allTags.forEach((tagName) => { + tagCounts[tagName] = (tagCounts[tagName] || 0) + 1; + }); + + // Get the tag with the highest count (most common) + const tagNames = Object.keys(tagCounts); + const mostCommonTag = tagNames.reduce( + (a, b) => (tagCounts[a] > tagCounts[b] ? a : b), + tagNames[0] + ); + + // Find the tag definition in the webApi to get its description + const webApiTagsKey = this._getAmfKey( + this.ns.aml.vocabularies.apiContract.tag + ); + const webApiTags = this._ensureArray(webApi[webApiTagsKey]); + + if (webApiTags) { + const matchingTag = webApiTags.find((tag) => { + const name = this._getValue(tag, this.ns.aml.vocabularies.core.name); + return name === mostCommonTag; + }); + + if (matchingTag) { + const description = this._getValue( + matchingTag, + this.ns.aml.vocabularies.core.description + ); + return { + name: mostCommonTag, + description: + description && typeof description === "string" + ? description + : undefined, + }; + } + } + + return { name: mostCommonTag, description: undefined }; + } + + /** + * Gets a descriptive name from operation summaries. + * @param {any} endpoint Endpoint model + * @return {string|undefined} + */ + _getEndpointOperationName(endpoint) { + const operationsKey = this._getAmfKey( + this.ns.aml.vocabularies.apiContract.supportedOperation + ); + const operations = this._ensureArray(endpoint[operationsKey]); + + if (!operations || !operations.length) { + return undefined; + } + + // Only use the summary if there's exactly one operation + if (operations.length === 1) { + const firstOp = operations[0]; + const summary = this._getValue( + firstOp, + this.ns.aml.vocabularies.apiContract.guiSummary + ); + + if (summary && typeof summary === "string") { + return summary; + } + } + + return undefined; + } + /** * Computes a view model for supported operations for an endpoint. * @param {any} endpoint Endpoint model. @@ -571,6 +731,7 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { if (!_endpoints || !_endpoints.length) { return ""; } + debugger; const result = _endpoints.map((item) => this._endpointTemplate(item)); const pathLabel = this._isAsyncAPI(this.amf) ? "channels" : "endpoints"; return html` @@ -613,15 +774,20 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { return ""; } return html` +

+ ${item.name}${item.description + ? html` - + ${item.description}` + : ""} +

${item.name}${item.path} -

${item.path}

`; } @@ -635,11 +801,9 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { data-shape-type="method" title="Open method documentation" >${item.method} - ${ - item.hasAgent - ? html`${codegenie}` - : "" - } + ${item.hasAgent + ? html`${codegenie}` + : ""} `; } diff --git a/src/Styles.js b/src/Styles.js index b552551..ddbddd7 100644 --- a/src/Styles.js +++ b/src/Styles.js @@ -164,6 +164,21 @@ export default css` font-size: var(--arc-font-title-font-size); } + .endpoint-name-description { + margin: 4px 0; + font-size: var(--api-summary-endpoint-description-font-size, 0.9em); + } + + .endpoint-name { + font-weight: var(--api-summary-endpoint-name-font-weight, 600); + color: var(--arc-font-title-color, var(--api-summary-endpoint-name-color, #333)); + } + + .endpoint-description { + font-style: italic; + color: var(--api-summary-endpoint-description-color, #666); + } + .endpoint-path-name { word-break: break-all; margin: 8px 0; diff --git a/test/api-summary.test.js b/test/api-summary.test.js index 14867db..5d52256 100644 --- a/test/api-summary.test.js +++ b/test/api-summary.test.js @@ -264,40 +264,33 @@ describe('ApiSummary', () => { }); it('renders endpoint name', () => { - const node = element.shadowRoot.querySelectorAll('.endpoint-item')[2].querySelector('.endpoint-path'); + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[2].querySelector('.endpoint-name'); assert.dom.equal( node, - ` - People - `, + `People`, { - ignoreAttributes: ['data-id'] + ignoreAttributes: [] } ); }); - it('sets data-id on name', () => { + it('sets data-id on path', () => { const node = element.shadowRoot.querySelectorAll('.endpoint-item')[2].querySelector('.endpoint-path'); assert.ok(node.getAttribute('data-id')); }); it('renders endpoint path with name', () => { - const node = element.shadowRoot.querySelectorAll('.endpoint-item')[2].querySelector('.endpoint-path-name'); - assert.dom.equal(node, `

/people

`, { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[2].querySelector('.endpoint-path'); + assert.dom.equal(node, `/people`, { ignoreAttributes: ['data-id'] }); }); - it('sets data-id on path', () => { - const node = element.shadowRoot.querySelectorAll('.endpoint-item')[2].querySelector('.endpoint-path'); - assert.ok(node.getAttribute('data-id')); - }); - it('renders list of operations', () => { const nodes = element.shadowRoot.querySelectorAll('.endpoint-item')[2].querySelectorAll('.method-label'); assert.lengthOf(nodes, 3); @@ -579,6 +572,91 @@ describe('ApiSummary', () => { }); }); + describe('Tags-based endpoint grouping', () => { + [ + ['Full AMF model', false], + ['Compact AMF model', true] + ].forEach(([label, compact]) => { + describe(String(label), () => { + let element = /** @type ApiSummary */ (null); + let amf; + + before(async () => { + amf = await AmfLoader.load(compact, 'tags-flights'); + }); + + beforeEach(async () => { + element = await basicFixture(); + element.amf = amf; + await aTimeout(0); + }); + + it('renders all endpoints grouped by tags', () => { + const nodes = element.shadowRoot.querySelectorAll('.endpoint-item'); + assert.lengthOf(nodes, 2, 'Should have 2 endpoint groups'); + }); + + it('renders first endpoint with tag name "AllFlights"', () => { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[0].querySelector('.endpoint-name'); + assert.ok(node, 'Should have endpoint name element'); + assert.equal(node.textContent.trim(), 'AllFlights'); + }); + + it('renders first endpoint description', () => { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[0].querySelector('.endpoint-description'); + assert.ok(node, 'Should have endpoint description element'); + assert.equal(node.textContent.trim(), 'Operations related to all flights'); + }); + + it('renders first endpoint path', () => { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[0].querySelector('.endpoint-path'); + assert.ok(node, 'Should have endpoint path element'); + assert.equal(node.textContent.trim(), '/flights'); + }); + + it('renders two operations for first endpoint', () => { + const nodes = element.shadowRoot.querySelectorAll('.endpoint-item')[0].querySelectorAll('.method-label'); + assert.lengthOf(nodes, 2, 'Should have 2 operations (GET, POST)'); + }); + + it('renders GET and POST methods for first endpoint', () => { + const nodes = element.shadowRoot.querySelectorAll('.endpoint-item')[0].querySelectorAll('.method-label'); + const methods = Array.from(nodes).map(n => n.textContent.trim()); + assert.include(methods, 'get'); + assert.include(methods, 'post'); + }); + + it('renders second endpoint with tag name "OneFlight"', () => { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[1].querySelector('.endpoint-name'); + assert.ok(node, 'Should have endpoint name element'); + assert.equal(node.textContent.trim(), 'OneFlight'); + }); + + it('renders second endpoint description', () => { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[1].querySelector('.endpoint-description'); + assert.ok(node, 'Should have endpoint description element'); + assert.equal(node.textContent.trim(), 'Operations related to a single flight'); + }); + + it('renders second endpoint path', () => { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[1].querySelector('.endpoint-path'); + assert.ok(node, 'Should have endpoint path element'); + assert.equal(node.textContent.trim(), '/flights/{ID}'); + }); + + it('renders one operation for second endpoint', () => { + const nodes = element.shadowRoot.querySelectorAll('.endpoint-item')[1].querySelectorAll('.method-label'); + assert.lengthOf(nodes, 1, 'Should have 1 operation (GET)'); + }); + + it('renders GET method for second endpoint', () => { + const node = element.shadowRoot.querySelectorAll('.endpoint-item')[1].querySelector('.method-label'); + assert.equal(node.textContent.trim(), 'get'); + }); + }); + }); + }); + describe('a11y', () => { let amf; let element = /** @type ApiSummary */ (null);