Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ coverage
# AMF models
/demo/*.json
!demo/apis.json
!demo/grpc-test.json

.idea

Expand Down
26,871 changes: 22,668 additions & 4,203 deletions demo/grpc-test.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion demo/lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class NavDemoPage extends DemoPage {

_apiListTemplate() {
return [
['grpc-test', 'gRPC API'],
['grpc-test', 'gRPC Test'],
['demo-api', 'Demo API'],
['agents-api', 'Agents API'],
['exchange-experience-api', 'Exchange Experience API'],
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@api-components/api-navigation",
"description": "An element to display the response body",
"version": "4.3.21",
"version": "4.3.22",
"license": "Apache-2.0",
"main": "index.js",
"module": "index.js",
Expand Down
74 changes: 50 additions & 24 deletions src/ApiNavigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,16 +564,21 @@ export class ApiNavigation extends AmfHelperMixin(LitElement) {
if (this._hasType(model, this.ns.aml.vocabularies.document.Document)) {
isFragment = false;
model = this._ensureAmfModel(model);
// Decide collection strategy based on whether this is a gRPC API
const isGrpcApi = typeof this._isGrpcApi === 'function' ? !!this._isGrpcApi(model) : false;
this._isGrpc = isGrpcApi;
data = isGrpcApi ? this._collectGrpcNavigationData(model) : this._collectData(model);
const webApi = this._computeApi(model);
if (webApi && typeof this._isGrpcApi === 'function' && this._isGrpcApi(model)) {
this._isGrpc = true;
data = this._collectGrpcNavigationData(model);
} else {
this._isGrpc = false;
data = this._collectData(model);
}
} else if (
this._hasType(
model,
this.ns.aml.vocabularies.security.SecuritySchemeFragment
)
) {
this._isGrpc = false;
data = this._collectSecurityData(model);
this.securityOpened = true;
} else if (
Expand All @@ -582,15 +587,26 @@ export class ApiNavigation extends AmfHelperMixin(LitElement) {
this.ns.aml.vocabularies.apiContract.UserDocumentationFragment
)
) {
this._isGrpc = false;
data = this._collectDocumentationData(model);
this.docsOpened = true;
} else if (
this._hasType(model, this.ns.aml.vocabularies.shapes.DataTypeFragment)
) {
this._isGrpc = false;
data = this._collectTypeData(model);
this.typesOpened = true;
} else if (model['@type'] && moduleKey === model['@type'][0]) {
data = this._collectData(model);
const webApi = this._computeApi(model);
if (webApi && typeof this._isGrpcApi === 'function' && this._isGrpcApi(model)) {
this._isGrpc = true;
data = this._collectGrpcNavigationData(model);
} else {
this._isGrpc = false;
data = this._collectData(model);
}
} else {
this._isGrpc = false;
}
if (this._isFragment !== isFragment) {
this._isFragment = isFragment;
Expand Down Expand Up @@ -659,6 +675,7 @@ export class ApiNavigation extends AmfHelperMixin(LitElement) {
if (!webApi) {
return result;
}
this.__operationById = this.__operationById || {};
// Build services -> methods as endpoint-like items
const services = typeof this._computeGrpcServices === 'function' ? this._computeGrpcServices(webApi) : undefined;
const servicesArray = services || [];
Expand All @@ -675,7 +692,11 @@ export class ApiNavigation extends AmfHelperMixin(LitElement) {
this.__operationById[op['@id']] = op;
}
const methodModel = this._createOperationModel(op);
// Replace method chip label with simplified type for gRPC
// Populate gRPC stream type from mixin so we can set label and color
if (methodModel && typeof this._getGrpcStreamType === 'function') {
methodModel.grpcStreamType = this._getGrpcStreamType(op) || 'unary';
}
// Add gRPC stream type label and color mapping
if (methodModel && methodModel.grpcStreamType) {
// Map to HTTP method colors: unary→patch(violet), client→publish(green), server→subscribe(blue), bidi→options(gray)
const colorMethodMap = {
Expand All @@ -690,7 +711,8 @@ export class ApiNavigation extends AmfHelperMixin(LitElement) {
'server_streaming': 'SERVER',
'bidi_streaming': 'BIDIRECTIONAL'
};
methodModel.method = labelMap[methodModel.grpcStreamType] || 'UNARY';
// Store stream type label separately (don't overwrite method name)
methodModel.grpcStreamTypeLabel = labelMap[methodModel.grpcStreamType] || 'UNARY';
methodModel.methodForColor = colorMethodMap[methodModel.grpcStreamType] || 'patch';
}
return methodModel;
Expand Down Expand Up @@ -1012,17 +1034,13 @@ export class ApiNavigation extends AmfHelperMixin(LitElement) {
_appendEndpointItem(item, target) {
const result = {};

const voc = this.ns.aml.vocabularies;
let name = this._getValue(item, voc.core.displayName);
if (!name) {
name = this._getValue(item, voc.core.name);
}
let name = this._getValue(item, this.ns.aml.vocabularies.core.name);
let path = /** @type string */ (this._getValue(
item,
this.ns.raml.vocabularies.apiContract.path
));
if (!path && name) {
path = `/${name}`;
if (path == null || path === undefined) {
path = '';
}
result.path = path;

Expand Down Expand Up @@ -2141,17 +2159,25 @@ export class ApiNavigation extends AmfHelperMixin(LitElement) {
@click="${this._itemClickHandler}"
style="${style}"
>
<span
class="method-label ${methodItem.hasAgent
? 'method-label-with-icon'
: ''}"
data-method="${methodItem.methodForColor || methodItem.method}"
${this._isGrpc
? html`
<span
class="method-label stream-type-badge"
data-method="${methodItem.methodForColor || methodItem.method}"
>${methodItem.grpcStreamTypeLabel}</span>
<span class="grpc-method-name">${methodItem.label}</span>
${methodItem.hasAgent ? html`<span class="method-icon">${codegenie}</span>` : ''}
`
: html`
<span
class="method-label ${methodItem.hasAgent ? 'method-label-with-icon' : ''}"
data-method="${methodItem.methodForColor || methodItem.method}"
>${methodItem.method}
${methodItem.hasAgent
? html`<span class="method-icon">${codegenie}</span>`
: ''}</span
>
${!this._isGrpc ? methodItem.label : ''}
${methodItem.hasAgent ? html`<span class="method-icon">${codegenie}</span>` : ''}
</span>
${methodItem.label}
`
}
</div>`;
}

Expand Down
25 changes: 25 additions & 0 deletions src/Styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,29 @@ export default css`
);
color: var(--http-method-label-subscribe-color, #3490dc);
}

.stream-type-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-right: 8px;
vertical-align: middle;
/* Colores ya heredados de .method-label[data-method] */
}

.grpc-method-name {
font-size: 13px;
font-weight: 400;
vertical-align: middle;
}

/* Asegurar que el badge NO tenga brackets */
.stream-type-badge::before,
.stream-type-badge::after {
content: none;
}
`;
93 changes: 93 additions & 0 deletions test/api-navigation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1579,4 +1579,97 @@ describe('<api-navigation>', () => {
});
});
});

describe('gRPC Method Display', () => {
let element;
let amf;
let grpcModelAvailable = false;

before(async () => {
try {
amf = await AmfLoader.load(false, 'grpc-test');
grpcModelAvailable = true;
} catch (_) {
grpcModelAvailable = false;
}
});

beforeEach(async function () {
if (!grpcModelAvailable) {
this.skip();
return;
}
element = await basicFixture();
element.amf = amf;
await aTimeout(0);
});

it('displays gRPC method name in navigation', async () => {
await nextFrame();
const badge = element.shadowRoot.querySelector('.operation .stream-type-badge');
const methodName = element.shadowRoot.querySelector('.operation .grpc-method-name');
if (badge && methodName) {
assert.match(badge.textContent.trim(), /^(?:UNARY|CLIENT|SERVER|BIDIRECTIONAL)$/, 'Badge should show stream type');
assert.isAbove(methodName.textContent.trim().length, 0, 'Should show method name next to badge');
} else {
const endpoints = element._endpoints;
if (endpoints && endpoints.length > 0) {
const firstEndpoint = endpoints[0];
if (firstEndpoint.methods && firstEndpoint.methods.length > 0) {
assert.property(firstEndpoint.methods[0], 'label', 'Method should have label property');
}
}
}
});

it('shows stream type badge for gRPC methods', async () => {
await nextFrame();
const badge = element.shadowRoot.querySelector('.stream-type-badge');
if (badge) {
assert.exists(badge, 'Stream type badge should exist');
const badgeText = badge.textContent.trim();
assert.match(badgeText, /^(?:UNARY|CLIENT|SERVER|BIDIRECTIONAL)$/, 'Badge should show stream type (no brackets)');
}
});

it('applies correct color mapping for stream types', async () => {
await nextFrame();
const methodLabel = element.shadowRoot.querySelector('.operation .method-label');
if (methodLabel) {
const dataMethod = methodLabel.getAttribute('data-method');
assert.isString(dataMethod, 'Should have data-method attribute for color');
// Verify it's one of the mapped values (patch, publish, subscribe, options)
assert.include(['patch', 'publish', 'subscribe', 'options'], dataMethod, 'Should map to HTTP method for color');
}
});

it('stores stream type label separately from method name', () => {
// Check internal data structure
const endpoints = element._endpoints;
if (endpoints && endpoints.length > 0) {
const firstEndpoint = endpoints[0];
if (firstEndpoint.methods && firstEndpoint.methods.length > 0) {
const firstMethod = firstEndpoint.methods[0];
assert.property(firstMethod, 'grpcStreamTypeLabel', 'Should have grpcStreamTypeLabel property');
assert.property(firstMethod, 'method', 'Should have method property');
// They should be different (method is actual name, grpcStreamTypeLabel is type)
if (firstMethod.grpcStreamTypeLabel && firstMethod.method) {
assert.notEqual(firstMethod.method, firstMethod.grpcStreamTypeLabel, 'Method name and stream type label should be different');
}
}
}
});

it('displays both method name and stream type for each gRPC method', async () => {
await nextFrame();
const operations = element.shadowRoot.querySelectorAll('.operation');
operations.forEach((operation) => {
const text = operation.textContent.trim();
if (text) {
// Each operation should have more than just the stream type
assert.isAbove(text.length, 6, 'Should have method name and stream type, not just stream type');
}
});
});
});
});